Skip to content

Commit ccb0b0a

Browse files
committed
fix: remove assets resolution
1 parent 63a4ee9 commit ccb0b0a

10 files changed

Lines changed: 343 additions & 16 deletions

File tree

‎docs/guide/build-your-own-hub-ui.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ Honor `when` / `visibility` clauses, `category` grouping (order from
6161
`DEFAULT_CATEGORIES_ORDER` in `@devframes/hub/constants`), and the
6262
`hub:docks:activate` broadcast.
6363

64+
An `iframe` entry whose devframe serves its UI from a [remote assets
65+
package](./client-assets) can also report that those assets are unreachable: its
66+
fallback page posts a `RemoteAssetsErrorMessage`
67+
(`DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE`, both re-exported from
68+
`@devframes/hub/constants`) to `window.parent`. Match the message against the
69+
frame's own `contentWindow` and you can offer the install command and a retry in
70+
your own UI; leaving it alone keeps the fallback page visible inside the frame.
71+
6472
### The renderer registry and its fallback
6573

6674
**Every other dock type routes through the dock-renderer registry** — build it

‎docs/guide/client-assets.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ npm install @acme/my-tool-assets
9595

9696
Set `offline: true` to guarantee the CDN is never contacted, or point `provider` at an internal npm mirror.
9797

98+
### When the assets can't be reached
99+
100+
A file that is in neither a local install nor the cache, with the provider unreachable, raises [`DF0060`](../errors/DF0060). An HTML navigation gets a self-contained page naming the assets package, the install command that makes it work offline, and the provider's own error, with a retry button.
101+
102+
That page also posts its failure to `window.parent` (`DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE` from `devframe/constants`, payload `RemoteAssetsErrorMessage`), so a viewer embedding the tool in an iframe can render the same thing in its own design — `@devframes/hub-ui` shows it as a panel over the dock's frame ([building your own](./build-your-own-hub-ui)).
103+
98104
### Custom provider
99105

100106
A custom provider supplies the file URL, and optionally a file listing (used for correct 404s, SPA fallback, and static builds):

‎packages/devframe/src/constants.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ export const DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM = 'devframe_viewer_origin'
8080
/** Token that authorizes an external viewer origin registration. */
8181
export const DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM = 'devframe_viewer_origin_token'
8282

83+
/**
84+
* `postMessage` type the remote-assets fallback page posts to `window.parent`
85+
* on load. That page is what a devframe serves (with a 502) when its client
86+
* assets can be reached neither locally nor through their CDN provider — the
87+
* message lets an embedding viewer replace the bare page with its own UI
88+
* (`@devframes/hub-ui` does, in its iframe view). Payload shape:
89+
* `RemoteAssetsErrorMessage` (`devframe/types`).
90+
*/
91+
export const DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE = 'devframe:remote-assets-error'
92+
8393
/**
8494
* Prefix that marks an RPC method as callable before a connection is
8595
* trusted. This is the *only* rule the pre-trust gate applies — there is no

‎packages/devframe/src/types/remote-assets.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface RemoteAssets {
4141
* with zero network. Omitting it skips the installed-package step —
4242
* cache + CDN still work.
4343
*/
44-
resolveFrom?: string
44+
resolveFrom?: string | null
4545
/** Custom fetch implementation (proxies, tests). Defaults to the global `fetch`. */
4646
fetch?: typeof globalThis.fetch
4747
/**
@@ -82,6 +82,24 @@ export interface RemoteAssetsProviderCustom {
8282
*/
8383
export type StaticAssetsSource = string | RemoteAssets
8484

85+
/**
86+
* What the remote-assets fallback page posts to `window.parent` when a
87+
* devframe's client assets could be served from neither a local install nor
88+
* their provider. A viewer embedding the devframe in an iframe listens for
89+
* `DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE` (`devframe/constants`) and can
90+
* render the failure in its own design, with the two ways out the page also
91+
* spells out: install `package@version` locally, or restore network access.
92+
*/
93+
export interface RemoteAssetsErrorMessage {
94+
type: 'devframe:remote-assets-error'
95+
/** npm package the assets are published as. */
96+
package: string
97+
/** Exact version the devframe asked for. */
98+
version: string
99+
/** Why the fetch failed, as reported by the provider or the network stack. */
100+
reason: string
101+
}
102+
85103
/**
86104
* A resolved, servable handle over a {@link RemoteAssets} declaration —
87105
* produced by `resolveStaticAssetsSource()` (`devframe/utils/remote-assets`)

‎packages/devframe/src/utils/remote-assets.test.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { AddressInfo } from 'node:net'
22
import type { MockInstance } from 'vitest'
3-
import type { RemoteAssets, RemoteAssetsStore } from '../types/remote-assets'
3+
import type { RemoteAssets, RemoteAssetsErrorMessage, RemoteAssetsStore } from '../types/remote-assets'
44
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
55
import { createServer } from 'node:http'
66
import { tmpdir } from 'node:os'
77
import { join } from 'node:path'
88
import { pathToFileURL } from 'node:url'
99
import { H3, toNodeHandler } from 'h3'
1010
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11+
import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE } from '../constants'
1112
import { resolveStaticAssetsSource } from './remote-assets'
1213
import { serveStaticHandler } from './serve-static'
1314

@@ -101,6 +102,43 @@ describe('resolveStaticAssetsSource (remote store)', () => {
101102
expect(cdn.calls.filter(u => u.includes('app.js')).length).toBe(before)
102103
})
103104

105+
it('proxies the provider response: own content headers, no transfer-level ones', async () => {
106+
const upstream = (): Response => new Response('console.log("app")', {
107+
status: 200,
108+
headers: {
109+
'content-type': 'application/octet-stream',
110+
'content-encoding': 'gzip',
111+
'content-length': '11',
112+
'etag': 'W/"abc"',
113+
'set-cookie': 'session=1',
114+
'x-frame-options': 'DENY',
115+
'cache-control': 'public, max-age=31536000',
116+
},
117+
})
118+
const store = storeFor({ fetch: async () => upstream() }, makeTmp())
119+
120+
const res = (await store.serve('/assets/app.js'))!
121+
// `fetch` hands over a decoded body, so the encoded length would be a lie.
122+
expect(res.headers.get('content-encoding')).toBeNull()
123+
expect(res.headers.get('content-length')).toBeNull()
124+
// The provider's policy headers stay with the provider.
125+
expect(res.headers.get('set-cookie')).toBeNull()
126+
expect(res.headers.get('x-frame-options')).toBeNull()
127+
expect(res.status).toBe(200)
128+
expect(res.headers.get('content-type')).toBe('text/javascript')
129+
expect(res.headers.get('cache-control')).toBe('no-store')
130+
expect(res.headers.get('etag')).toBe('W/"abc"')
131+
await expect(res.text()).resolves.toBe('console.log("app")')
132+
})
133+
134+
it('keeps the upstream content-length when the body arrived unencoded', async () => {
135+
const store = storeFor(
136+
{ fetch: async () => new Response('hello', { headers: { 'content-length': '5' } }) },
137+
makeTmp(),
138+
)
139+
expect((await store.serve('/assets/app.js'))!.headers.get('content-length')).toBe('5')
140+
})
141+
104142
it('resolves the manifest: index fallback, SPA fallback, and extension-ed 404', async () => {
105143
const cdn = fakeCdn(CDN_FILES)
106144
const store = storeFor(cdn, makeTmp())
@@ -273,6 +311,13 @@ describe('serveStaticHandler with a remote store', () => {
273311
const body = await res.text()
274312
expect(body).toContain('Client assets unavailable')
275313
expect(body).toContain('@scope/demo-client')
314+
// The page reports itself to an embedding viewer, which renders the
315+
// same failure in its own UI (`@devframes/hub-ui`'s iframe view).
316+
expect(body).toContain('window.parent.postMessage(')
317+
expect(body).toContain(DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE)
318+
const payload = JSON.parse(/postMessage\((\{.*?\}), '\*'\)/.exec(body)![1]) as RemoteAssetsErrorMessage
319+
expect(payload).toMatchObject({ package: '@scope/demo-client', version: '1.2.3' })
320+
expect(payload.reason).toContain('network down')
276321

277322
const asset = await fetch(`${url}/app.js`, { headers: { accept: '*/*' } })
278323
expect(asset.status).toBe(502)

‎packages/devframe/src/utils/remote-assets.ts‎

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,27 @@ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
1010
import { createRequire } from 'node:module'
1111
import { Readable } from 'node:stream'
1212
import { lookup } from 'mrmime'
13+
import { createDebug } from 'obug'
1314
import { dirname, extname, join, normalize, sep } from 'pathe'
1415
import { diagnostics } from '../node/diagnostics'
1516

17+
const debugFetch = createDebug('devframe:remote-assets:fetch')
18+
const debugCache = createDebug('devframe:remote-assets:cache')
19+
1620
const MANIFEST_FILENAME = '.manifest.json'
1721

22+
/**
23+
* Upstream response headers replayed to the browser. Everything outside this
24+
* list is dropped, because it describes the *provider's* transfer rather than
25+
* the file: hop-by-hop and encoding headers no longer match the body `fetch`
26+
* already decoded, and a CDN's policy headers (`set-cookie`, `cache-control`,
27+
* framing/CSP) belong to its origin — replaying them under the dev server's
28+
* origin could just as well break the iframe these assets render in.
29+
*/
30+
const PROXIED_HEADERS = ['content-language', 'etag', 'last-modified'] as const
31+
32+
const CACHE_CONTROL_HEADER = 'no-store'
33+
1834
// ---------------------------------------------------------------------------
1935
// Providers
2036
// ---------------------------------------------------------------------------
@@ -48,18 +64,22 @@ const providers: Record<'jsdelivr' | 'unpkg', Required<RemoteAssetsProviderCusto
4864
jsdelivr: {
4965
fileUrl: (pkg, version, filePath) => `https://cdn.jsdelivr.net/npm/${pkg}@${version}/${filePath}`,
5066
listFiles: async (pkg, version, fetchImpl) => {
51-
const res = await fetchImpl(`https://data.jsdelivr.com/v1/packages/npm/${pkg}@${version}`)
67+
const url = `https://data.jsdelivr.com/v1/packages/npm/${pkg}@${version}`
68+
debugFetch('listing files for %s@%s from %s', pkg, version, url)
69+
const res = await fetchImpl(url)
5270
if (!res.ok)
53-
throw new Error(`HTTP ${res.status} from data.jsdelivr.com`)
71+
throw new Error(`HTTP ${res.status} from ${url}`)
5472
return flattenTree((await res.json() as { files?: TreeNode[] }).files ?? [], 'name')
5573
},
5674
},
5775
unpkg: {
5876
fileUrl: (pkg, version, filePath) => `https://unpkg.com/${pkg}@${version}/${filePath}`,
5977
listFiles: async (pkg, version, fetchImpl) => {
60-
const res = await fetchImpl(`https://unpkg.com/${pkg}@${version}/?meta`)
78+
const url = `https://unpkg.com/${pkg}@${version}/?meta`
79+
debugFetch('listing files for %s@%s from %s', pkg, version, url)
80+
const res = await fetchImpl(url)
6181
if (!res.ok)
62-
throw new Error(`HTTP ${res.status} from unpkg.com`)
82+
throw new Error(`HTTP ${res.status} from ${url}`)
6383
return flattenTree([await res.json() as TreeNode], 'path')
6484
},
6585
},
@@ -81,7 +101,7 @@ function resolveProvider(assets: RemoteAssets): { provider: RemoteAssetsProvider
81101
* installed version warns (`DF0062`); a different major throws (`DF0061`).
82102
*/
83103
function resolveInstalled(assets: RemoteAssets): string | undefined {
84-
if (!assets.resolveFrom)
104+
if (assets.resolveFrom == null)
85105
return undefined
86106
let pkgJsonPath: string
87107
let installed: unknown
@@ -116,6 +136,30 @@ function contentTypeFor(filePath: string): string {
116136
return type === 'text/html' ? 'text/html; charset=utf-8' : type
117137
}
118138

139+
/**
140+
* Headers for a file streamed through from the provider. `Content-Type` and
141+
* `Cache-Control` are ours, so a file looks identical whether it came from the
142+
* provider or from the cache ({@link createStore}'s `serveCached`).
143+
*/
144+
function proxyHeaders(filePath: string, upstream: Headers): Headers {
145+
const headers = new Headers({
146+
'Content-Type': contentTypeFor(filePath),
147+
'Cache-Control': CACHE_CONTROL_HEADER,
148+
})
149+
// `fetch` decodes the body, so an encoded response's `Content-Length`
150+
// counts bytes the browser will never see — it only survives verbatim.
151+
const encoding = upstream.get('content-encoding')
152+
const length = upstream.get('content-length')
153+
if (length && (!encoding || encoding === 'identity'))
154+
headers.set('Content-Length', length)
155+
for (const name of PROXIED_HEADERS) {
156+
const value = upstream.get(name)
157+
if (value != null)
158+
headers.set(name, value)
159+
}
160+
return headers
161+
}
162+
119163
/** Clean a request path into a safe package-relative POSIX path, or `null` if it escapes root. */
120164
function cleanRequestPath(urlPath: string): string | null {
121165
let cleaned: string
@@ -155,13 +199,19 @@ function createStore(assets: RemoteAssets, cacheDir: string): RemoteAssetsStore
155199

156200
async function loadManifest(): Promise<Set<string> | null> {
157201
const manifestFile = join(cacheDir, MANIFEST_FILENAME)
158-
try {
159-
return new Set(JSON.parse(await readFile(manifestFile, 'utf8')) as string[])
202+
if (existsSync(manifestFile)) {
203+
try {
204+
return new Set(JSON.parse(await readFile(manifestFile, 'utf8')) as string[])
205+
}
206+
catch {}
160207
}
161-
catch {}
162208
if (assets.offline || !provider.listFiles)
163209
return null
164210
try {
211+
// A listing failure is not fatal — requests degrade to probing the
212+
// provider per candidate. Only a file that can't be fetched at all
213+
// surfaces to the user (`DF0060`, and the fallback page that carries
214+
// it into the viewer — see `remoteErrorPage` in `serve-static`).
165215
const files = await provider.listFiles(normalized.package, normalized.version, fetchImpl)
166216
await mkdir(cacheDir, { recursive: true })
167217
await writeFile(manifestFile, JSON.stringify(files), 'utf8').catch(() => {})
@@ -188,8 +238,13 @@ function createStore(assets: RemoteAssets, cacheDir: string): RemoteAssetsStore
188238
catch {
189239
return null
190240
}
241+
debugCache('serving %s from cache (%d bytes)', filePath, size)
191242
return new Response(Readable.toWeb(createReadStream(abs)) as ReadableStream<Uint8Array>, {
192-
headers: { 'Content-Type': contentTypeFor(filePath), 'Content-Length': String(size), 'Cache-Control': 'no-store' },
243+
headers: {
244+
'Content-Type': contentTypeFor(filePath),
245+
'Content-Length': String(size),
246+
'Cache-Control': CACHE_CONTROL_HEADER,
247+
},
193248
})
194249
}
195250

@@ -213,6 +268,7 @@ function createStore(assets: RemoteAssets, cacheDir: string): RemoteAssetsStore
213268
const url = provider.fileUrl(normalized.package, normalized.version, filePath)
214269
let res: Response
215270
try {
271+
debugFetch('fetching %s from %s', filePath, url)
216272
res = await fetchImpl(url)
217273
}
218274
catch (error) {
@@ -226,11 +282,15 @@ function createStore(assets: RemoteAssets, cacheDir: string): RemoteAssetsStore
226282
await res.body?.cancel().catch(() => {})
227283
throw diagnostics.DF0060({ url, package: normalized.package, reason: `HTTP ${res.status}` })
228284
}
285+
// The body is consumed twice — once by the client, once by the cache
286+
// writer — so the client gets a fresh `Response` over its own branch of
287+
// the tee; `res` itself is unusable from here on (the tee locked its body).
229288
const [toClient, toCache] = res.body.tee()
230289
void persist(filePath, toCache)
231-
const length = res.headers.get('content-length')
232290
return new Response(toClient, {
233-
headers: { 'Content-Type': contentTypeFor(filePath), 'Cache-Control': 'no-store', ...(length ? { 'Content-Length': length } : {}) },
291+
status: res.status,
292+
statusText: res.statusText,
293+
headers: proxyHeaders(filePath, res.headers),
234294
})
235295
}
236296

‎packages/devframe/src/utils/serve-static.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { EventHandler } from 'h3'
22
import type { IncomingMessage, ServerResponse } from 'node:http'
33
import type { ReadableStream as NodeWebReadableStream } from 'node:stream/web'
4-
import type { RemoteAssetsStore } from '../types/remote-assets'
4+
import type { RemoteAssetsErrorMessage, RemoteAssetsStore } from '../types/remote-assets'
55
import { createReadStream } from 'node:fs'
66
import { stat } from 'node:fs/promises'
77
import { Readable } from 'node:stream'
88
import { defineHandler, H3 } from 'h3'
99
import { lookup } from 'mrmime'
1010
import { extname, join, normalize, resolve, sep } from 'pathe'
11+
import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE } from '../constants'
1112

1213
/**
1314
* What the static-serving engine accepts: a local directory, or a resolved
@@ -311,11 +312,20 @@ export function serveStaticNodeMiddleware(
311312
/**
312313
* Minimal, dependency-free HTML shown when a remote-assets request cannot
313314
* be satisfied (no installed package, no cache, provider unreachable).
315+
*
316+
* The page stands on its own when a devframe is opened directly, and
317+
* announces itself over `postMessage` when it loads inside a viewer's iframe
318+
* ({@link DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE}) so the viewer can show
319+
* the same failure in its own UI instead.
314320
*/
315321
function remoteErrorPage(pkg: string, version: string, reason: string): string {
316322
const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
317323
const name = esc(pkg)
318324
const ver = esc(version)
325+
const message: RemoteAssetsErrorMessage = { type: DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE, package: pkg, version, reason }
326+
// `</script>` inside the JSON would end the block early; `<` is the only
327+
// character that can do that, and escaping it keeps the literal valid JS.
328+
const payload = JSON.stringify(message).replace(/</g, '\\u003c')
319329
return `<!doctype html>
320330
<html lang="en">
321331
<head>
@@ -345,6 +355,10 @@ function remoteErrorPage(pkg: string, version: string, reason: string): string {
345355
<button onclick="location.reload()">Retry</button>
346356
<p class="muted">devframe remote assets</p>
347357
</main>
358+
<script>
359+
if (window.parent !== window)
360+
window.parent.postMessage(${payload}, '*')
361+
</script>
348362
</body>
349363
</html>
350364
`

0 commit comments

Comments
 (0)