From e0b627334af8e4371b3990e4fb2943293390e902 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Sat, 15 Aug 2026 07:21:19 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(devframe):=20remote=20client=20assets?= =?UTF-8?q?=20=E2=80=94=20serve=20SPA=20dists=20through=20a=20caching=20CD?= =?UTF-8?q?N=20back-proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every static-assets seam (cli.distDir, hostStatic, mountStatic, the serve-static engine) now accepts a version-locked RemoteAssets declaration ({ package, version }) alongside a local directory, so a plugin's browser bundle can live in its own npm package instead of shipping inside the node tarball. Resolution order per request: a locally installed copy of the assets package (resolved from the declaration's resolveFrom, warning on minor/patch skew and rejecting a major mismatch), the per-file cache under /.remote-assets/@/, then the CDN provider (jsdelivr by default, unpkg or a custom mirror via provider) — streaming through to the browser while teeing into the cache. Request paths resolve against the provider's file listing (correct 404s and SPA fallback), degrading to per-file probing when the listing is unreachable. HTML navigations that cannot be satisfied get a styled error page pointing at the local-install fix; static builds materialize the full file set so their output stays self-contained. New diagnostics DF0058–DF0063 cover listing/fetch/cache/materialization failures and version skew, each with a docs page. --- alias.ts | 1 + docs/errors/DF0058.md | 39 ++ docs/errors/DF0059.md | 37 ++ docs/errors/DF0060.md | 43 ++ docs/errors/DF0061.md | 31 + docs/errors/DF0062.md | 23 + docs/errors/DF0063.md | 30 + examples/files-inspector/tests/_utils.ts | 2 + .../next-runtime-snapshot/tests/_utils.ts | 2 + examples/streaming-chat/tests/_utils.ts | 2 + packages/devframe/package.json | 1 + packages/devframe/src/adapters/build.ts | 45 +- packages/devframe/src/adapters/dev.ts | 3 +- packages/devframe/src/adapters/initiate.ts | 14 +- packages/devframe/src/node/diagnostics.ts | 30 + packages/devframe/src/node/host-h3.ts | 15 +- packages/devframe/src/node/host-views.ts | 21 +- packages/devframe/src/types/devframe.ts | 10 +- packages/devframe/src/types/host.ts | 13 +- packages/devframe/src/types/index.ts | 1 + packages/devframe/src/types/remote-assets.ts | 129 +++++ packages/devframe/src/types/views.ts | 9 +- .../devframe/src/utils/remote-assets.test.ts | 337 +++++++++++ packages/devframe/src/utils/remote-assets.ts | 537 ++++++++++++++++++ packages/devframe/src/utils/serve-static.ts | 125 +++- packages/devframe/tsdown.config.ts | 1 + packages/hub/src/node/install-devframe.ts | 3 +- packages/vite/src/dev-spa.ts | 12 +- plugins/a11y/tests/_utils.ts | 2 + plugins/git/test/_utils.ts | 2 + plugins/messages/test/_utils.ts | 10 +- plugins/og/test/_utils.ts | 4 +- .../devframe/adapters/build.snapshot.d.ts | 2 +- .../devframe/adapters/dev.snapshot.d.ts | 2 +- .../tsnapi/devframe/index.snapshot.d.ts | 41 +- .../tsnapi/devframe/initiate.snapshot.d.ts | 2 +- .../tsnapi/devframe/internal.snapshot.d.ts | 50 +- .../tsnapi/devframe/types.snapshot.d.ts | 7 + .../utils/remote-assets.snapshot.d.ts | 24 + .../devframe/utils/remote-assets.snapshot.js | 11 + .../devframe/utils/serve-static.snapshot.d.ts | 10 +- tsconfig.base.json | 3 + 42 files changed, 1626 insertions(+), 60 deletions(-) create mode 100644 docs/errors/DF0058.md create mode 100644 docs/errors/DF0059.md create mode 100644 docs/errors/DF0060.md create mode 100644 docs/errors/DF0061.md create mode 100644 docs/errors/DF0062.md create mode 100644 docs/errors/DF0063.md create mode 100644 packages/devframe/src/types/remote-assets.ts create mode 100644 packages/devframe/src/utils/remote-assets.test.ts create mode 100644 packages/devframe/src/utils/remote-assets.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.js diff --git a/alias.ts b/alias.ts index 1443e4c9..5e81c3a5 100644 --- a/alias.ts +++ b/alias.ts @@ -30,6 +30,7 @@ export const alias = { 'devframe/utils/launch-editor': r('devframe/src/utils/launch-editor.ts'), 'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'), 'devframe/utils/open': r('devframe/src/utils/open.ts'), + 'devframe/utils/remote-assets': r('devframe/src/utils/remote-assets.ts'), 'devframe/utils/simple-schema': r('devframe/src/utils/simple-schema.ts'), 'devframe/utils/serve-static': r('devframe/src/utils/serve-static.ts'), 'devframe/utils/shared-state': r('devframe/src/utils/shared-state.ts'), diff --git a/docs/errors/DF0058.md b/docs/errors/DF0058.md new file mode 100644 index 00000000..be3161ab --- /dev/null +++ b/docs/errors/DF0058.md @@ -0,0 +1,39 @@ +--- +outline: deep +--- + +# DF0058: Remote Assets File Listing Failed + +## Message + +> Failed to fetch the file listing for "`{package}`@`{version}`" from `{provider}`: `{reason}` + +## Cause + +A remote-assets source (`{ package, version }` passed where a static mount accepts a dist directory) resolves request paths against the CDN provider's file-listing API — `data.jsdelivr.com` for jsDelivr, `?meta` for unpkg, or a custom provider's `listFiles`. That listing request failed, typically because the provider is unreachable (offline machine, blocked domain) or returned an error status. + +## Example + +```ts +defineDevframe({ + cli: { + distDir: { + package: '@devframes/plugin-git-client', + version: '1.2.3', + }, + }, +}) +``` + +Starting this devframe without network access to `data.jsdelivr.com` reports `DF0058` on the first request. + +## Fix + +Requests keep working in a degraded probe mode (each candidate path is tried against the provider directly). To resolve it: + +- Check network access to the configured provider, or switch providers (`provider: 'unpkg'` or a custom mirror). +- Install the assets package locally (`npm install `) — a locally installed copy is served with zero network and needs no listing. + +## Source + +- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts) — `createRemoteAssetsStore()` reports this (once per store) when the provider's file listing cannot be fetched or parsed. diff --git a/docs/errors/DF0059.md b/docs/errors/DF0059.md new file mode 100644 index 00000000..05e907d9 --- /dev/null +++ b/docs/errors/DF0059.md @@ -0,0 +1,37 @@ +--- +outline: deep +--- + +# DF0059: Remote Asset Fetch Failed + +## Message + +> Failed to fetch a remote asset of "`{package}`" (`{url}`): `{reason}` + +## Cause + +A file of a remote-assets source was requested that is neither in the locally installed assets package nor in the on-disk cache, and streaming it through the CDN provider failed — the network request errored, the provider returned a non-OK status, or the source is `offline: true` while the file is missing from the cache. + +## Example + +```ts +defineDevframe({ + cli: { + distDir: { + package: '@devframes/plugin-git-client', + version: '1.2.3', + }, + }, +}) +``` + +Opening the tool's UI with `cdn.jsdelivr.net` unreachable throws `DF0059` for each uncached file; HTML navigations respond with a styled error page carrying this code. + +## Fix + +- Install the assets package locally (`npm install `) to serve it with zero network — the recommended path for offline and air-gapped machines. +- Otherwise check network access to the configured provider, or point `provider` at a reachable mirror. + +## Source + +- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts) — `createRemoteAssetsStore()`'s `serve()` throws this when a provider fetch fails, returns a non-OK status, or an `offline` store misses its cache. diff --git a/docs/errors/DF0060.md b/docs/errors/DF0060.md new file mode 100644 index 00000000..3ef42abc --- /dev/null +++ b/docs/errors/DF0060.md @@ -0,0 +1,43 @@ +--- +outline: deep +--- + +# DF0060: Installed Assets Package Major Version Mismatch + +## Message + +> The locally installed "`{package}`@`{installed}`" is a different major version than the required "`{required}`". + +## Cause + +A remote-assets source found a locally installed copy of its assets package (resolved from the declaration's `resolveFrom` module), but the installed version differs from the declared one by a **major** version. Assets and node code are published in lockstep; across a major boundary the served UI can be incompatible with its node backend, so devframe refuses to serve it. + +## Example + +```ts +defineDevframe({ + cli: { + distDir: { + package: '@devframes/plugin-git-client', + version: '2.0.0', + resolveFrom: import.meta.url, + }, + }, +}) +``` + +With `@devframes/plugin-git-client@1.9.0` installed locally, mounting this devframe throws `DF0060`. + +## Fix + +Install the assets package at the version its node package declares (they are published in lockstep): + +```sh +npm install @devframes/plugin-git-client@2.0.0 +``` + +Or uninstall the stale local copy so the assets stream from the CDN back-proxy at the exact declared version. + +## Source + +- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts) — `resolveInstalledRemoteAssets()` throws this when the installed package's major version differs from the declared one. diff --git a/docs/errors/DF0061.md b/docs/errors/DF0061.md new file mode 100644 index 00000000..2ac382ce --- /dev/null +++ b/docs/errors/DF0061.md @@ -0,0 +1,31 @@ +--- +outline: deep +--- + +# DF0061: Installed Assets Package Version Skew + +## Message + +> The locally installed "`{package}`@`{installed}`" differs from the required "`{required}`" — serving the installed one. + +## Cause + +A remote-assets source found a locally installed copy of its assets package whose version differs from the declared one within the same major version. The local install wins — it keeps offline and air-gapped setups working — but the served assets are not byte-identical to the declared release, so the skew is surfaced. + +## Example + +With the node package declaring `version: '1.2.3'` and `@devframes/plugin-git-client@1.2.4` installed locally, the installed `1.2.4` assets are served and `DF0061` is reported. + +## Fix + +Install the exact declared version to serve byte-identical assets: + +```sh +npm install @devframes/plugin-git-client@1.2.3 +``` + +A major-version mismatch is rejected instead — see [DF0060](./DF0060.md). + +## Source + +- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts) — `resolveInstalledRemoteAssets()` reports this when the installed version differs from the declared one within the same major. diff --git a/docs/errors/DF0062.md b/docs/errors/DF0062.md new file mode 100644 index 00000000..e3dc8ca5 --- /dev/null +++ b/docs/errors/DF0062.md @@ -0,0 +1,23 @@ +--- +outline: deep +--- + +# DF0062: Remote Asset Cache Write Failed + +## Message + +> Failed to persist a remote asset into the cache at "`{filepath}`": `{reason}` + +## Cause + +A remote asset streamed through the CDN back-proxy to the browser, but writing the teed copy into the local cache directory (`/.remote-assets/@/…`) failed — usually a permissions problem, a full disk, or a removed `node_modules`. + +The response itself was served; only caching failed, so the same file will stream through the provider again on the next request. + +## Fix + +Check that the project storage directory (conventionally `node_modules/./devframe/`) is writable and has free space. + +## Source + +- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts) — `createRemoteAssetsStore()`'s background cache write reports this when persisting a fetched file fails. diff --git a/docs/errors/DF0063.md b/docs/errors/DF0063.md new file mode 100644 index 00000000..e5bdeae9 --- /dev/null +++ b/docs/errors/DF0063.md @@ -0,0 +1,30 @@ +--- +outline: deep +--- + +# DF0063: Remote Assets Materialization Failed + +## Message + +> Failed to materialize the remote assets of "`{package}`@`{version}`": `{reason}` + +## Cause + +A static build (`createBuild`) with a remote-assets `distDir` needs every asset file up front — the output must be self-contained. Materialization walks the provider's file listing and downloads each file, and one of those steps failed: the provider has no `listFiles` (custom providers may omit it), the listing request failed, or an individual file download errored. + +## Example + +```sh +my-tool build +``` + +Running a static build on a machine without network access to the CDN provider — and without the assets package installed locally — throws `DF0063`. + +## Fix + +- Install the assets package locally (`npm install @`) — builds copy from the local install and touch no network. +- Otherwise ensure the provider and its file-listing API are reachable during the build, or configure a custom provider that implements `listFiles`. + +## Source + +- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts) — `createRemoteAssetsStore()`'s `materialize()` throws this when the file listing is unavailable or a download fails. diff --git a/examples/files-inspector/tests/_utils.ts b/examples/files-inspector/tests/_utils.ts index f0ea41c9..738405c6 100644 --- a/examples/files-inspector/tests/_utils.ts +++ b/examples/files-inspector/tests/_utils.ts @@ -60,6 +60,8 @@ export async function startInspectorServer( { cwd }: { cwd: string }, ): Promise { const distDir = devframe.cli!.distDir! + if (typeof distDir !== 'string') + throw new TypeError('these tests serve the local dist directory — build the SPA first') const basePath = devframe.basePath! const host = '127.0.0.1' const port = await getPort({ host, random: true }) diff --git a/examples/next-runtime-snapshot/tests/_utils.ts b/examples/next-runtime-snapshot/tests/_utils.ts index 27625f58..32801992 100644 --- a/examples/next-runtime-snapshot/tests/_utils.ts +++ b/examples/next-runtime-snapshot/tests/_utils.ts @@ -24,6 +24,8 @@ export interface SnapshotServer extends StartedServer { */ export async function startSnapshotServer(): Promise { const distDir = devframe.cli!.distDir! + if (typeof distDir !== 'string') + throw new TypeError('these tests serve the local dist directory — build the SPA first') const basePath = devframe.basePath! const host = '127.0.0.1' const port = await getPort({ host, random: true }) diff --git a/examples/streaming-chat/tests/_utils.ts b/examples/streaming-chat/tests/_utils.ts index 4a3f25ea..2dd14ea5 100644 --- a/examples/streaming-chat/tests/_utils.ts +++ b/examples/streaming-chat/tests/_utils.ts @@ -30,6 +30,8 @@ export async function startStreamingChatServer(): Promise ${outDir}`) - await fs.cp(distDir, outDir, { recursive: true }) + const host = createH3DevframeHost({ origin: 'http://localhost', appName: d.id }) + + // Copy author's SPA into the output root. A remote-assets source copies + // from the locally installed assets package when present, otherwise every + // listed file is materialized from the provider — a static deploy must be + // self-contained. + if (typeof distSource === 'string') { + console.log(c.cyan`[devframe] copying SPA from ${distSource} -> ${outDir}`) + await fs.cp(distSource, outDir, { recursive: true }) + } + else { + const installed = resolveInstalledRemoteAssets(distSource) + if (installed) { + console.log(c.cyan`[devframe] copying SPA from ${installed} -> ${outDir}`) + await fs.cp(installed, outDir, { recursive: true }) + } + else { + console.log(c.cyan`[devframe] materializing SPA from ${distSource.package}@${distSource.version} -> ${outDir}`) + const cacheRoot = remoteAssetsCacheRoot(host.getStorageDir('project')) + const store = createRemoteAssetsStore(distSource, { + cacheDir: remoteAssetsCacheDir(cacheRoot, distSource), + }) + await store.materialize(outDir) + } + } const ctx = await createHostContext({ cwd: process.cwd(), mode: 'build', - host: createH3DevframeHost({ origin: 'http://localhost', appName: d.id }), + host, }) await d.setup(ctx) diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index aa549b9c..d0394ae8 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -2,6 +2,7 @@ import type { DevframeRpcConnection } from 'devframe/rpc/transports/ws-server' import type { DevframeAuthHandler } from '../node/auth/handler' import type { StartedServer } from '../node/instance-shell' import type { DevframeDefinition, DevframeSseOptions, DevframeWsOptions, McpRouteOptions } from '../types/devframe' +import type { StaticAssetsSource } from '../types/remote-assets' import type { DevframeNodeRpcSession, DevframeNodeRpcSessionMeta } from '../types/rpc' import { createServer } from 'node:http' import { open } from 'devframe/utils/open' @@ -37,7 +38,7 @@ export interface CreateDevServerOptions { * is expected to be hosted elsewhere (e.g. by a parent Vite/Nuxt * dev server via `devframeViteBridge` from `@devframes/vite`). */ - distDir?: string + distDir?: StaticAssetsSource /** * Override the SPA mount path. Defaults to * `resolveBasePath(def, 'standalone')` (i.e. `def.basePath` or `/`). diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 38f3f75d..b9988f4f 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -7,7 +7,9 @@ import type { DevframeAuthHandler } from '../node/auth/handler' import type { DevframeInstanceRecord } from '../node/instance-registry' import type { InstanceShellInternals, StartedServer } from '../node/instance-shell' import type { DevframeDefinition, DevframeSetupInfo, DevframeSseOptions, DevframeWsOptions, McpRouteOptions } from '../types/devframe' +import type { StaticAssetsSource } from '../types/remote-assets' import process from 'node:process' +import { remoteAssetsCacheRoot, resolveStaticAssetsSource } from 'devframe/utils/remote-assets' import { mountStaticHandler } from 'devframe/utils/serve-static' import { H3 } from 'h3' import { resolve } from 'pathe' @@ -36,7 +38,7 @@ export interface InitDevframeOptions { * **bridge mode**: only `__connection.json`, the WS endpoint, and the MCP * route (when enabled) are served; the SPA is hosted elsewhere. */ - distDir?: string | false + distDir?: StaticAssetsSource | false /** * Share the host's `node:http` server for the WebSocket RPC endpoint: the * upgrade listener binds to `__ws` on this server, so no extra port @@ -323,14 +325,18 @@ export function initDevframe( } }, - mount(_context, meta) { + mount(context, meta) { // Discovery meta before the SPA mount so its SPA-fallback can't swallow // the route; both sit at the SPA root for relative `./__connection.json` // fetches. app.use(joinURL(base, DEVFRAME_CONNECTION_META_FILENAME), () => meta) - if (distDir) - mountStaticHandler(app, base, resolve(distDir)) + if (distDir) { + const source = resolveStaticAssetsSource(distDir, { + cacheRoot: remoteAssetsCacheRoot(context.host.getStorageDir('project')), + }) + mountStaticHandler(app, base, typeof source === 'string' ? resolve(source) : source) + } }, }) diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index 657c6dfc..1765a6e0 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -132,5 +132,35 @@ export const diagnostics = defineDiagnostics({ why: () => 'This instance disables its WebSocket transport (`ws: false`), so there is no socket to drive upgrades into.', fix: 'Clients connect over the SSE endpoint instead — no upgrade wiring is needed. Remove `ws: false` if the instance should serve a WebSocket after all.', }, + DF0058: { + why: (p: { package: string, version: string, provider: string, reason: string }) => + `Failed to fetch the file listing for "${p.package}@${p.version}" from ${p.provider}: ${p.reason}`, + fix: 'Requests fall back to probing the provider per file. Check network access to the provider, or install the assets package locally so no listing is needed.', + }, + DF0059: { + why: (p: { url: string, package: string, reason: string }) => + `Failed to fetch a remote asset of "${p.package}" (${p.url}): ${p.reason}`, + fix: 'Install the assets package locally (`npm install `) to serve it with zero network, or check network access to the configured provider.', + }, + DF0060: { + why: (p: { package: string, required: string, installed: string }) => + `The locally installed "${p.package}@${p.installed}" is a different major version than the required "${p.required}".`, + fix: 'Align the installed assets package with the version its node package declares — they are published in lockstep.', + }, + DF0061: { + why: (p: { package: string, required: string, installed: string }) => + `The locally installed "${p.package}@${p.installed}" differs from the required "${p.required}" — serving the installed one.`, + fix: 'Install the exact declared version to serve byte-identical assets.', + }, + DF0062: { + why: (p: { filepath: string, reason: string }) => + `Failed to persist a remote asset into the cache at "${p.filepath}": ${p.reason}`, + fix: 'The response was still served; only caching failed. Check that the cache directory is writable and has free space.', + }, + DF0063: { + why: (p: { package: string, version: string, reason: string }) => + `Failed to materialize the remote assets of "${p.package}@${p.version}": ${p.reason}`, + fix: 'Static builds need every asset file up front. Install the assets package locally, or ensure the provider (and its file-listing API) is reachable during the build.', + }, }, }) diff --git a/packages/devframe/src/node/host-h3.ts b/packages/devframe/src/node/host-h3.ts index 90093c74..c5bff9e6 100644 --- a/packages/devframe/src/node/host-h3.ts +++ b/packages/devframe/src/node/host-h3.ts @@ -1,4 +1,5 @@ import type { DevframeHost } from '../types/host' +import type { RemoteAssetsStore } from '../types/remote-assets' import { homedir } from 'node:os' import process from 'node:process' import { join } from 'pathe' @@ -14,11 +15,13 @@ export interface CreateH3DevframeHostOptions { */ origin: string | (() => string) /** - * Register a static-file handler at `base` serving files from `distDir`. - * Wired into the h3 app once the CLI adapter lands (commit 5). For now - * the CLI isn't running, so the default is a no-op. + * Register a static-file handler at `base` serving files from `source` — + * a local directory or a resolved remote-assets back-proxy store (both + * accepted by `devframe/utils/serve-static`). Wired into the h3 app once + * the CLI adapter lands (commit 5). For now the CLI isn't running, so the + * default is a no-op. */ - mount?: (base: string, distDir: string) => void | Promise + mount?: (base: string, source: string | RemoteAssetsStore) => void | Promise /** * Namespace for storage paths returned by `getStorageDir`. Workspace * state (committable) lives under `${workspaceRoot}/.devframe/`, project @@ -41,8 +44,8 @@ export interface CreateH3DevframeHostOptions { export function createH3DevframeHost(options: CreateH3DevframeHostOptions): DevframeHost { const workspaceRoot = options.workspaceRoot ?? process.cwd() return { - mountStatic(base, distDir) { - return options.mount?.(base, distDir) + mountStatic(base, source) { + return options.mount?.(base, source) }, resolveOrigin() { return typeof options.origin === 'function' ? options.origin() : options.origin diff --git a/packages/devframe/src/node/host-views.ts b/packages/devframe/src/node/host-views.ts index dcf704e1..6e216a35 100644 --- a/packages/devframe/src/node/host-views.ts +++ b/packages/devframe/src/node/host-views.ts @@ -1,24 +1,31 @@ -import type { DevframeNodeContext, DevframeViewHost as DevframeViewHostType } from 'devframe/types' +import type { DevframeNodeContext, DevframeViewHost as DevframeViewHostType, StaticAssetsSource } from 'devframe/types' import { existsSync } from 'node:fs' +import { remoteAssetsCacheRoot, resolveStaticAssetsSource } from 'devframe/utils/remote-assets' import { diagnostics } from './diagnostics' export class DevframeViewHost implements DevframeViewHostType { /** * @internal */ - public buildStaticDirs: { baseUrl: string, distDir: string }[] = [] + public buildStaticDirs: { baseUrl: string, source: StaticAssetsSource }[] = [] constructor( public readonly context: DevframeNodeContext, ) { } - hostStatic(baseUrl: string, distDir: string) { - if (!existsSync(distDir)) { - throw diagnostics.DF0008({ distDir }) + hostStatic(baseUrl: string, source: StaticAssetsSource) { + // Local directories must exist up front; remote declarations resolve to + // a locally installed package when present, otherwise to a lazy CDN + // back-proxy store — nothing to check on disk yet. + const resolved = resolveStaticAssetsSource(source, { + cacheRoot: remoteAssetsCacheRoot(this.context.host.getStorageDir('project')), + }) + if (typeof resolved === 'string' && !existsSync(resolved)) { + throw diagnostics.DF0008({ distDir: resolved }) } - this.buildStaticDirs.push({ baseUrl, distDir }) - this.context.host.mountStatic(baseUrl, distDir) + this.buildStaticDirs.push({ baseUrl, source }) + this.context.host.mountStatic(baseUrl, resolved) } } diff --git a/packages/devframe/src/types/devframe.ts b/packages/devframe/src/types/devframe.ts index 383f80f5..40f2d79e 100644 --- a/packages/devframe/src/types/devframe.ts +++ b/packages/devframe/src/types/devframe.ts @@ -2,6 +2,7 @@ import type { CAC } from 'cac' import type { CliFlagsSchema } from '../adapters/flags' import type { DevframeAuthHandler } from '../node/auth/handler' import type { DevframeNodeContext } from './context' +import type { StaticAssetsSource } from './remote-assets' export type DevframeRuntime = 'cli' | 'build' | 'spa' | 'vite' | 'embedded' @@ -165,8 +166,13 @@ export interface DevframeCliOptions { * The `--mcp` / `--no-mcp` CLI flags override this per run. */ mcp?: boolean | McpRouteOptions - /** Author's SPA dist directory (served as the devframe's UI). */ - distDir?: string + /** + * Author's SPA dist — served as the devframe's UI. A local directory, or + * a {@link StaticAssetsSource} remote declaration (`{ package, version }`) + * served through devframe's caching CDN back-proxy so the assets need not + * ship inside the node package. + */ + distDir?: StaticAssetsSource /** * How the browser reaches the RPC WebSocket. Defaults to sharing the HTTP * port on the `__ws` route. See {@link DevframeWsOptions} for the diff --git a/packages/devframe/src/types/host.ts b/packages/devframe/src/types/host.ts index 0555f227..f3cf4f4b 100644 --- a/packages/devframe/src/types/host.ts +++ b/packages/devframe/src/types/host.ts @@ -8,14 +8,19 @@ // - packages/devframe/src/node/host-h3.ts — h3 CLI server // - (build/spa/embedded) — added as the respective adapters land +import type { RemoteAssetsStore } from './remote-assets' + export interface DevframeHost { /** - * Serve a static directory at the given URL base. Called by - * `DevframeViewHost.hostStatic`. Implementations map this to whatever + * Serve static assets at the given URL base — a local directory, or a + * resolved {@link RemoteAssetsStore} back-proxy. Called by + * `DevframeViewHost.hostStatic` (which normalizes `RemoteAssets` + * declarations into stores first). Implementations map this to whatever * the underlying runtime expects (Vite middleware, h3 handler, no-op - * for build snapshots). + * for build snapshots) — the shared engine in + * `devframe/utils/serve-static` accepts either shape. */ - mountStatic: (base: string, distDir: string) => void | Promise + mountStatic: (base: string, source: string | RemoteAssetsStore) => void | Promise /** * Serve the host's connection meta (`__connection.json`) at the given URL diff --git a/packages/devframe/src/types/index.ts b/packages/devframe/src/types/index.ts index 85028ab8..32254574 100644 --- a/packages/devframe/src/types/index.ts +++ b/packages/devframe/src/types/index.ts @@ -4,6 +4,7 @@ export * from './devframe' export * from './diagnostics' export * from './events' export * from './host' +export * from './remote-assets' export * from './rpc' export * from './rpc-augments' export * from './scope' diff --git a/packages/devframe/src/types/remote-assets.ts b/packages/devframe/src/types/remote-assets.ts new file mode 100644 index 00000000..d11d4e58 --- /dev/null +++ b/packages/devframe/src/types/remote-assets.ts @@ -0,0 +1,129 @@ +/** + * A version-locked pointer at browser assets published as their own npm + * package (e.g. `@devframes/plugin-git-client`), served through devframe's + * caching back-proxy instead of a directory shipped inside the node package. + * + * Resolution order at serve time: + * + * 1. The package installed locally (resolved from {@link resolveFrom}) + * — the zero-network / air-gap path. Version skew warns; a major + * version mismatch throws. + * 2. The per-file cache under + * `/.remote-assets/@/`. + * 3. The CDN {@link provider} — each requested file streams through to + * the browser while being written into the cache. + * + * Anywhere a static mount accepts a dist directory (`cli.distDir`, + * `hostStatic`, `mountStatic`) it also accepts this object — see + * {@link StaticAssetsSource}. + */ +export interface RemoteAssets { + /** npm package name that ships the assets, e.g. `@devframes/plugin-git-client`. */ + package: string + /** Exact version to serve, e.g. `1.2.3`. Typically the host package's own version. */ + version: string + /** + * Subpath inside the package the served assets live under. + * + * @default 'dist' + */ + path?: string + /** + * CDN that mirrors npm and serves individual package files. + * + * @default 'jsdelivr' + */ + provider?: RemoteAssetsProvider + /** + * `import.meta.url` of the declaring module. When set, a locally + * installed copy of {@link package} is resolved from this module's own + * dependency graph first (works under pnpm's strict layout) and served + * with zero network. Omitting it skips the installed-package step — + * cache + CDN still work. + */ + resolveFrom?: string + /** Custom fetch implementation (proxies, tests). Defaults to the global `fetch`. */ + fetch?: typeof globalThis.fetch + /** + * Never touch the network: serve only from the locally installed package + * or files already in the cache. + * + * @default false + */ + offline?: boolean +} + +/** + * Built-in CDN providers (`'jsdelivr'` — default, `'unpkg'`) or a custom + * provider for corp mirrors. + */ +export type RemoteAssetsProvider = 'jsdelivr' | 'unpkg' | RemoteAssetsProviderCustom + +/** A custom {@link RemoteAssets} CDN provider (e.g. an internal npm mirror). */ +export interface RemoteAssetsProviderCustom { + /** + * Absolute URL serving `filePath` (package-relative, POSIX, no leading + * slash) of `pkg@version`. + */ + fileUrl: (pkg: string, version: string, filePath: string) => string + /** + * List every file path in `pkg@version` (package-relative, no leading + * slash). Powers request-path resolution (correct 404s / SPA fallback) + * and build-time materialization. When omitted, requests are resolved by + * probing {@link fileUrl} directly and builds cannot materialize from + * this provider. + */ + listFiles?: (pkg: string, version: string, fetchImpl: typeof globalThis.fetch) => Promise +} + +/** + * What every static-assets seam accepts: a local dist directory, or a + * {@link RemoteAssets} pointer served through the caching back-proxy. + */ +export type StaticAssetsSource = string | RemoteAssets + +/** + * A resolved, servable handle over a {@link RemoteAssets} declaration — + * created by `createRemoteAssetsStore()` (`devframe/utils/remote-assets`) + * and consumed by the static-serving engine (`devframe/utils/serve-static`). + */ +export interface RemoteAssetsStore { + readonly kind: 'remote-assets-store' + /** The declaration this store serves (with defaults applied). */ + readonly assets: RemoteAssets & { path: string } + /** Version-locked cache directory files are persisted under. */ + readonly cacheDir: string + /** + * Resolve a request path (relative to the mount base) and open the file: + * from the cache when present, otherwise streamed through the provider + * while being written into the cache. Returns `null` for a miss (404) + * and throws on provider/network failure. + */ + serve: (urlPath: string, options?: RemoteAssetsServeOptions) => Promise + /** + * Download every listed file under `assets.path` into `targetDir` + * (paths relative to `assets.path`). Requires a provider file listing. + */ + materialize: (targetDir: string) => Promise +} + +/** Request-resolution options for {@link RemoteAssetsStore.serve}. */ +export interface RemoteAssetsServeOptions { + /** Default: `['index.html']`. */ + indexNames?: string[] + /** SPA fallback to `indexNames[0]` on miss. Default: `true`. */ + single?: boolean +} + +/** An opened file ready to respond with. */ +export interface RemoteAssetsServedFile { + /** Response headers (`Content-Type`, `Content-Length` when known, …). */ + headers: Record + /** Response body. Call at most once. */ + stream: () => ReadableStream + /** + * Release the file when the body will never be read (HEAD requests) — + * an in-flight provider download keeps filling the cache. + */ + cancel: () => void +} diff --git a/packages/devframe/src/types/views.ts b/packages/devframe/src/types/views.ts index 111a90f2..0a469503 100644 --- a/packages/devframe/src/types/views.ts +++ b/packages/devframe/src/types/views.ts @@ -1,12 +1,17 @@ +import type { StaticAssetsSource } from './remote-assets' + export interface DevframeViewHost { /** * @internal */ - buildStaticDirs: { baseUrl: string, distDir: string }[] + buildStaticDirs: { baseUrl: string, source: StaticAssetsSource }[] /** * Helper to host static files * - In `dev` mode, it will register middleware to `viteServer.middlewares` to host the static files * - In `build` mode, it will copy the static files to the dist directory + * + * Accepts a local dist directory, or a {@link StaticAssetsSource} remote + * declaration served through devframe's caching CDN back-proxy. */ - hostStatic: (baseUrl: string, distDir: string) => void + hostStatic: (baseUrl: string, source: StaticAssetsSource) => void } diff --git a/packages/devframe/src/utils/remote-assets.test.ts b/packages/devframe/src/utils/remote-assets.test.ts new file mode 100644 index 00000000..2e1aeca7 --- /dev/null +++ b/packages/devframe/src/utils/remote-assets.test.ts @@ -0,0 +1,337 @@ +import type { AddressInfo } from 'node:net' +import type { MockInstance } from 'vitest' +import type { RemoteAssets } from '../types/remote-assets' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { H3, toNodeHandler } from 'h3' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createRemoteAssetsStore, + remoteAssetsCacheDir, + remoteAssetsCacheRoot, + renderRemoteAssetsErrorPage, + resolveInstalledRemoteAssets, + resolveStaticAssetsSource, +} from './remote-assets' +import { serveStaticHandler } from './serve-static' + +function makeTmp(prefix = 'devframe-remote-assets-'): string { + return mkdtempSync(join(tmpdir(), prefix)) +} + +/** + * A fake CDN over a flat `filePath -> contents` map, answering both the + * jsDelivr file-listing API and per-file URLs. Counts fetches per URL. + */ +function fakeCdn(files: Record): { fetch: typeof globalThis.fetch, calls: string[] } { + const calls: string[] = [] + interface TreeNode { type: 'file' | 'directory', name: string, files?: TreeNode[] } + const buildTree = (): TreeNode[] => { + const root: TreeNode[] = [] + for (const path of Object.keys(files)) { + let level = root + const segments = path.split('/') + for (const [i, segment] of segments.entries()) { + if (i === segments.length - 1) { + level.push({ type: 'file', name: segment }) + break + } + let dir = level.find(node => node.type === 'directory' && node.name === segment) + if (!dir) { + dir = { type: 'directory', name: segment, files: [] } + level.push(dir) + } + level = dir.files! + } + } + return root + } + const fetchImpl: typeof globalThis.fetch = async (input) => { + const url = String(input) + calls.push(url) + if (url.startsWith('https://data.jsdelivr.com/')) + return Response.json({ files: buildTree() }) + const cdnPrefix = 'https://cdn.jsdelivr.net/npm/@scope/demo-client@1.2.3/' + const filePath = url.startsWith(cdnPrefix) ? url.slice(cdnPrefix.length) : undefined + if (filePath && filePath in files) + return new Response(files[filePath], { headers: { 'Content-Type': 'application/octet-stream' } }) + return new Response('not found', { status: 404 }) + } + return { fetch: fetchImpl, calls } +} + +const CDN_FILES = { + 'package.json': '{}', + 'dist/index.html': 'remote index', + 'dist/assets/app.js': 'console.log("app")', +} + +function makeAssets(cdn: { fetch: typeof globalThis.fetch }, overrides?: Partial): RemoteAssets { + return { + package: '@scope/demo-client', + version: '1.2.3', + fetch: cdn.fetch, + ...overrides, + } +} + +let warnSpy: MockInstance +let errorSpy: MockInstance + +beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + warnSpy.mockRestore() + errorSpy.mockRestore() +}) + +async function text(stream: ReadableStream): Promise { + return new Response(stream).text() +} + +describe('createRemoteAssetsStore', () => { + it('serves files through the provider and caches them', async () => { + const cdn = fakeCdn(CDN_FILES) + const cacheDir = join(makeTmp(), 'cache') + const store = createRemoteAssetsStore(makeAssets(cdn), { cacheDir }) + + const file = await store.serve('/assets/app.js') + expect(file).not.toBeNull() + expect(file!.headers['Content-Type']).toBe('text/javascript') + await expect(text(file!.stream())).resolves.toBe('console.log("app")') + + // The teed cache write settles asynchronously. + await vi.waitFor(() => { + expect(existsSync(join(cacheDir, 'dist/assets/app.js'))).toBe(true) + }) + expect(readFileSync(join(cacheDir, 'dist/assets/app.js'), 'utf8')).toBe('console.log("app")') + + // Second serve comes from the cache — no new file fetch. + const fetchesBefore = cdn.calls.filter(url => url.includes('app.js')).length + const again = await store.serve('/assets/app.js') + await expect(text(again!.stream())).resolves.toBe('console.log("app")') + expect(cdn.calls.filter(url => url.includes('app.js')).length).toBe(fetchesBefore) + }) + + it('resolves the manifest: index fallback, SPA fallback, and correct 404s', async () => { + const cdn = fakeCdn(CDN_FILES) + const store = createRemoteAssetsStore(makeAssets(cdn), { cacheDir: join(makeTmp(), 'cache') }) + + const index = await store.serve('/') + await expect(text(index!.stream())).resolves.toBe('remote index') + + // SPA fallback: extensionless miss resolves to index.html. + const spa = await store.serve('/some/client/route') + await expect(text(spa!.stream())).resolves.toBe('remote index') + + // A miss with a file extension is a real 404 — no provider probe. + const missCallsBefore = cdn.calls.length + await expect(store.serve('/missing.js')).resolves.toBeNull() + expect(cdn.calls.length).toBe(missCallsBefore) + }) + + it('rejects traversal escapes', async () => { + const cdn = fakeCdn(CDN_FILES) + const store = createRemoteAssetsStore(makeAssets(cdn), { cacheDir: join(makeTmp(), 'cache') }) + await expect(store.serve('/../package.json')).resolves.toBeNull() + }) + + it('falls back to probe mode when the file listing fails (DF0058)', async () => { + const cdn = fakeCdn(CDN_FILES) + const failingListing: typeof globalThis.fetch = async (input) => { + const url = String(input) + if (url.startsWith('https://data.jsdelivr.com/')) + return new Response('nope', { status: 500 }) + return cdn.fetch(input) + } + const store = createRemoteAssetsStore( + makeAssets(cdn, { fetch: failingListing }), + { cacheDir: join(makeTmp(), 'cache') }, + ) + const file = await store.serve('/assets/app.js') + await expect(text(file!.stream())).resolves.toBe('console.log("app")') + expect(warnSpy.mock.calls.some(args => String(args[0]).includes('DF0058'))).toBe(true) + }) + + it('offline: serves from the cache only and throws on a miss (DF0059)', async () => { + const cdn = fakeCdn(CDN_FILES) + const cacheDir = join(makeTmp(), 'cache') + + // Warm the cache (and the manifest) online first. + const online = createRemoteAssetsStore(makeAssets(cdn), { cacheDir }) + const warmed = await online.serve('/assets/app.js') + await text(warmed!.stream()) + await vi.waitFor(() => { + expect(existsSync(join(cacheDir, 'dist/assets/app.js'))).toBe(true) + }) + + const offline = createRemoteAssetsStore(makeAssets(cdn, { offline: true }), { cacheDir }) + const cached = await offline.serve('/assets/app.js') + await expect(text(cached!.stream())).resolves.toBe('console.log("app")') + await expect(offline.serve('/')).rejects.toThrow(/offline: true/) + }) + + it('materializes every file under `path` into a target directory', async () => { + const cdn = fakeCdn(CDN_FILES) + const store = createRemoteAssetsStore(makeAssets(cdn), { cacheDir: join(makeTmp(), 'cache') }) + const target = join(makeTmp(), 'out') + await store.materialize(target) + expect(readFileSync(join(target, 'index.html'), 'utf8')).toBe('remote index') + expect(readFileSync(join(target, 'assets/app.js'), 'utf8')).toBe('console.log("app")') + // Files outside `path` stay out. + expect(existsSync(join(target, 'package.json'))).toBe(false) + }) + + it('supports the unpkg provider URL scheme', async () => { + const calls: string[] = [] + const fetchImpl: typeof globalThis.fetch = async (input) => { + const url = String(input) + calls.push(url) + if (url === 'https://unpkg.com/@scope/demo-client@1.2.3/?meta') { + return Response.json({ + path: '/', + type: 'directory', + files: [{ path: '/dist/index.html', type: 'file' }], + }) + } + if (url === 'https://unpkg.com/@scope/demo-client@1.2.3/dist/index.html') + return new Response('unpkg') + return new Response('not found', { status: 404 }) + } + const store = createRemoteAssetsStore( + { package: '@scope/demo-client', version: '1.2.3', provider: 'unpkg', fetch: fetchImpl }, + { cacheDir: join(makeTmp(), 'cache') }, + ) + const file = await store.serve('/') + await expect(text(file!.stream())).resolves.toBe('unpkg') + expect(calls[0]).toBe('https://unpkg.com/@scope/demo-client@1.2.3/?meta') + }) +}) + +describe('resolveInstalledRemoteAssets', () => { + function makeInstalled(version: string): { resolveFrom: string, distDir: string } { + const root = makeTmp() + const pkgDir = join(root, 'node_modules', '@scope', 'demo-client') + mkdirSync(join(pkgDir, 'dist'), { recursive: true }) + writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name: '@scope/demo-client', version })) + writeFileSync(join(pkgDir, 'dist', 'index.html'), 'installed') + const entry = join(root, 'entry.mjs') + writeFileSync(entry, '') + return { resolveFrom: pathToFileURL(entry).href, distDir: join(pkgDir, 'dist') } + } + + it('resolves an exactly matching installed package', () => { + const { resolveFrom, distDir } = makeInstalled('1.2.3') + const dir = resolveInstalledRemoteAssets({ package: '@scope/demo-client', version: '1.2.3', resolveFrom }) + expect(dir).toBe(distDir) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('warns on minor/patch skew and serves the installed copy (DF0061)', () => { + const { resolveFrom, distDir } = makeInstalled('1.3.0') + const dir = resolveInstalledRemoteAssets({ package: '@scope/demo-client', version: '1.2.3', resolveFrom }) + expect(dir).toBe(distDir) + expect(warnSpy.mock.calls.some(args => String(args[0]).includes('DF0061'))).toBe(true) + }) + + it('throws on a major version mismatch (DF0060)', () => { + const { resolveFrom } = makeInstalled('2.0.0') + expect(() => resolveInstalledRemoteAssets({ package: '@scope/demo-client', version: '1.2.3', resolveFrom })) + .toThrow(/different major version/) + }) + + it('returns undefined when the package is absent or resolveFrom is unset', () => { + const { resolveFrom } = makeInstalled('1.2.3') + expect(resolveInstalledRemoteAssets({ package: '@scope/other', version: '1.2.3', resolveFrom })).toBeUndefined() + expect(resolveInstalledRemoteAssets({ package: '@scope/demo-client', version: '1.2.3' })).toBeUndefined() + }) +}) + +describe('resolveStaticAssetsSource', () => { + it('passes local directories through', () => { + expect(resolveStaticAssetsSource('/some/dir', { cacheRoot: '/tmp/x' })).toBe('/some/dir') + }) + + it('short-circuits to an installed package, otherwise builds a store with a version-locked cache dir', () => { + const cdn = fakeCdn(CDN_FILES) + const cacheRoot = remoteAssetsCacheRoot(join(makeTmp(), 'node_modules/.demo/devframe')) + const source = makeAssets(cdn) + const resolved = resolveStaticAssetsSource(source, { cacheRoot }) + expect(typeof resolved).not.toBe('string') + if (typeof resolved !== 'string') { + expect(resolved.kind).toBe('remote-assets-store') + expect(resolved.cacheDir).toBe(remoteAssetsCacheDir(cacheRoot, source)) + expect(resolved.cacheDir).toContain('@scope+demo-client@1.2.3') + } + }) +}) + +describe('serveStaticHandler with a remote store', () => { + it('serves through h3, streams remote files, and renders the error page on provider failure', async () => { + const cdn = fakeCdn(CDN_FILES) + const store = createRemoteAssetsStore(makeAssets(cdn), { cacheDir: join(makeTmp(), 'cache') }) + const app = new H3() + app.use(serveStaticHandler(store)) + const server = createServer(toNodeHandler(app)) + await new Promise(r => server.listen(0, '127.0.0.1', r)) + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + + try { + const index = await fetch(`${baseUrl}/`) + expect(index.status).toBe(200) + expect(index.headers.get('content-type')).toContain('text/html') + await expect(index.text()).resolves.toBe('remote index') + + const miss = await fetch(`${baseUrl}/missing.js`) + expect(miss.status).toBe(404) + } + finally { + await new Promise(r => server.close(() => r())) + } + }) + + it('responds with the styled error page for HTML navigations when the provider is down', async () => { + const failing: typeof globalThis.fetch = async () => { + throw new Error('network down') + } + const store = createRemoteAssetsStore( + { package: '@scope/demo-client', version: '1.2.3', fetch: failing }, + { cacheDir: join(makeTmp(), 'cache') }, + ) + const app = new H3() + app.use(serveStaticHandler(store)) + const server = createServer(toNodeHandler(app)) + await new Promise(r => server.listen(0, '127.0.0.1', r)) + const baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + + try { + const res = await fetch(`${baseUrl}/`, { headers: { accept: 'text/html' } }) + expect(res.status).toBe(502) + const body = await res.text() + expect(body).toContain('Client assets unavailable') + expect(body).toContain('@scope/demo-client') + + const asset = await fetch(`${baseUrl}/app.js`, { headers: { accept: '*/*' } }) + expect(asset.status).toBe(502) + await expect(asset.text()).resolves.toBe('') + } + finally { + await new Promise(r => server.close(() => r())) + } + }) +}) + +describe('renderRemoteAssetsErrorPage', () => { + it('escapes interpolated values', () => { + const html = renderRemoteAssetsErrorPage({ package: '