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/DF0059.md b/docs/errors/DF0059.md new file mode 100644 index 00000000..e851c003 --- /dev/null +++ b/docs/errors/DF0059.md @@ -0,0 +1,39 @@ +--- +outline: deep +--- + +# DF0059: 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 `DF0059` 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/DF0060.md b/docs/errors/DF0060.md new file mode 100644 index 00000000..5afe8d7f --- /dev/null +++ b/docs/errors/DF0060.md @@ -0,0 +1,37 @@ +--- +outline: deep +--- + +# DF0060: 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 `DF0060` 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/DF0061.md b/docs/errors/DF0061.md new file mode 100644 index 00000000..9d21cf65 --- /dev/null +++ b/docs/errors/DF0061.md @@ -0,0 +1,43 @@ +--- +outline: deep +--- + +# DF0061: 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 `DF0061`. + +## 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/DF0062.md b/docs/errors/DF0062.md new file mode 100644 index 00000000..b06be987 --- /dev/null +++ b/docs/errors/DF0062.md @@ -0,0 +1,31 @@ +--- +outline: deep +--- + +# DF0062: 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 `DF0062` 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 [DF0061](./DF0061.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/DF0063.md b/docs/errors/DF0063.md new file mode 100644 index 00000000..2b8db070 --- /dev/null +++ b/docs/errors/DF0063.md @@ -0,0 +1,23 @@ +--- +outline: deep +--- + +# DF0063: 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/DF0064.md b/docs/errors/DF0064.md new file mode 100644 index 00000000..875990e2 --- /dev/null +++ b/docs/errors/DF0064.md @@ -0,0 +1,30 @@ +--- +outline: deep +--- + +# DF0064: 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 `DF0064`. + +## 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/docs/errors/DF0065.md b/docs/errors/DF0065.md new file mode 100644 index 00000000..65bbb432 --- /dev/null +++ b/docs/errors/DF0065.md @@ -0,0 +1,45 @@ +--- +outline: deep +--- + +# DF0065: Invalid Remote Assets Package Or Version + +## Message + +> Invalid remote-assets `{field}` "`{value}`". + +## Cause + +A remote-assets source's `package` and `version` are interpolated into CDN URLs (`https://cdn.jsdelivr.net/npm/@/…`) and into the on-disk cache path (`.remote-assets/@/`). To keep those safe and well-formed, the `package` must be a valid npm package name and the `version` an exact semver version — a value carrying path separators, `@`, whitespace, or traversal segments (`..`) is rejected. + +## Example + +```ts +defineDevframe({ + cli: { + distDir: { + package: '@devframes/plugin-git-client', + version: '../etc', // ✗ not a semver version + }, + }, +}) +``` + +## Fix + +Use a valid npm package name and an exact version: + +```ts +defineDevframe({ + cli: { + distDir: { + package: '@devframes/plugin-git-client', + version: '1.2.3', + }, + }, +}) +``` + +## Source + +- [`packages/devframe/src/utils/remote-assets.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/utils/remote-assets.ts) — `resolveStaticAssetsSource()` validates a remote source before resolving it. 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 }) + + // A static deploy must be self-contained: a local dir (or a remote source + // backed by a locally installed package) is copied; an uninstalled remote + // source materializes every listed file from the provider. + const resolved = resolveStaticAssetsSource(distSource, host.getStorageDir('project')) + if (typeof resolved === 'string') { + console.log(c.cyan`[devframe] copying SPA from ${resolved} -> ${outDir}`) + await fs.cp(resolved, outDir, { recursive: true }) + } + else { + console.log(c.cyan`[devframe] materializing SPA from ${resolved.assets.package}@${resolved.assets.version} -> ${outDir}`) + await resolved.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 13b8fa7d..10857841 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -2,6 +2,7 @@ import type { DevframeRpcConnection, WsOriginRegistry } from 'devframe/rpc/trans 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..bbdf1e3a 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 { 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,16 @@ 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, 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 f72118f2..6b8b193a 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -136,5 +136,40 @@ export const diagnostics = defineDiagnostics({ why: (p: { id: string }) => `"${p.id}" declares \`capabilities.dev: false\` — it does not support a live dev server (its value is a static export only).`, fix: 'Pass `{ force: true }` to `createDevServer()` to run it anyway, or drop `capabilities.dev: false` on the definition.', }, + DF0059: { + 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.', + }, + DF0060: { + 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.', + }, + DF0061: { + 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.', + }, + DF0062: { + 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.', + }, + DF0063: { + 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.', + }, + DF0064: { + 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.', + }, + DF0065: { + why: (p: { field: 'package' | 'version', value: string }) => + `Invalid remote-assets ${p.field} "${p.value}".`, + fix: 'A remote-assets `package` must be a valid npm package name and `version` an exact semver version (e.g. `1.2.3`) — they are interpolated into CDN URLs and the cache path.', + }, }, }) diff --git a/packages/devframe/src/node/host-h3.ts b/packages/devframe/src/node/host-h3.ts index 935fb542..acd8cab9 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' @@ -12,11 +13,13 @@ export interface CreateH3DevframeHostOptions { */ origin: string | (() => string) /** - * Register a static-file handler at `base` serving files from `distDir`. - * `mountStatic` forwards to it; when omitted the host serves no SPA - * (bridge mode, where the SPA is hosted elsewhere). + * 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`). `mountStatic` forwards to + * it; when omitted the host serves no SPA (bridge mode, where the SPA is + * hosted elsewhere). */ - 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 @@ -39,8 +42,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..81b31c15 100644 --- a/packages/devframe/src/node/host-views.ts +++ b/packages/devframe/src/node/host-views.ts @@ -1,24 +1,29 @@ -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 { 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, 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 e05aaff8..fa1367ec 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' /** * Classification of how a devframe is being deployed. Hosted adapters @@ -163,8 +164,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 d08a4a4f..81687128 100644 --- a/packages/devframe/src/types/host.ts +++ b/packages/devframe/src/types/host.ts @@ -8,14 +8,19 @@ // (CLI dev server, static build, embedded); hosted runtimes provide their own // (e.g. `@devframes/vite`). +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 b42b6754..ce4024d1 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..991e18bf --- /dev/null +++ b/packages/devframe/src/types/remote-assets.ts @@ -0,0 +1,105 @@ +/** + * 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 — + * produced by `resolveStaticAssetsSource()` (`devframe/utils/remote-assets`) + * and consumed by the static-serving engine (`devframe/utils/serve-static`). + */ +export interface RemoteAssetsStore { + /** The declaration this store serves (with defaults applied). */ + readonly assets: RemoteAssets & { path: string } + /** + * Resolve a request path (relative to the mount base, SPA fallback to + * `index.html`) and return a `Response`: streamed from the cache when + * present, otherwise through the provider while being written into the + * cache. `null` on a miss (404); throws on provider/network failure. + */ + serve: (urlPath: string) => Promise + /** + * Download every listed file under `assets.path` into `targetDir` + * (paths relative to `assets.path`). Requires a provider file listing. + */ + materialize: (targetDir: string) => Promise +} 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..4477163c --- /dev/null +++ b/packages/devframe/src/utils/remote-assets.test.ts @@ -0,0 +1,285 @@ +import type { AddressInfo } from 'node:net' +import type { MockInstance } from 'vitest' +import type { RemoteAssets, RemoteAssetsStore } 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 { resolveStaticAssetsSource } from './remote-assets' +import { serveStaticHandler } from './serve-static' + +function makeTmp(): string { + return mkdtempSync(join(tmpdir(), 'devframe-remote-assets-')) +} + +/** A fake CDN over a flat `filePath -> contents` map, answering the jsDelivr listing API + per-file URLs. */ +function fakeCdn(files: Record): { fetch: typeof globalThis.fetch, calls: string[] } { + const calls: string[] = [] + interface Node { type: 'file' | 'directory', name: string, files?: Node[] } + const tree = (): Node[] => { + const root: Node[] = [] + for (const path of Object.keys(files)) { + let level = root + const segs = path.split('/') + segs.forEach((seg, i) => { + if (i === segs.length - 1) { + level.push({ type: 'file', name: seg }) + return + } + let dir = level.find(n => n.type === 'directory' && n.name === seg) + if (!dir) + level.push(dir = { type: 'directory', name: seg, files: [] }) + 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: tree() }) + const prefix = 'https://cdn.jsdelivr.net/npm/@scope/demo-client@1.2.3/' + const filePath = url.startsWith(prefix) ? url.slice(prefix.length) : undefined + if (filePath && filePath in files) + return new Response(files[filePath]) + 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 } +} + +/** Resolve `assets` into a store (fails if it resolved to a local dir instead). */ +function storeFor(cdn: { fetch: typeof globalThis.fetch }, storageDir: string, overrides?: Partial): RemoteAssetsStore { + const resolved = resolveStaticAssetsSource(makeAssets(cdn, overrides), storageDir) + if (typeof resolved === 'string') + throw new TypeError('expected a store') + return resolved +} + +function cachePath(storageDir: string, file: string): string { + return join(storageDir, '.remote-assets', '@scope+demo-client@1.2.3', file) +} + +let warnSpy: MockInstance + +beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('resolveStaticAssetsSource (remote store)', () => { + it('serves through the provider and caches; a second serve skips the network', async () => { + const cdn = fakeCdn(CDN_FILES) + const storageDir = makeTmp() + const store = storeFor(cdn, storageDir) + + const res = await store.serve('/assets/app.js') + expect(res!.headers.get('content-type')).toBe('text/javascript') + await expect(res!.text()).resolves.toBe('console.log("app")') + + await vi.waitFor(() => expect(existsSync(cachePath(storageDir, 'dist/assets/app.js'))).toBe(true)) + + const before = cdn.calls.filter(u => u.includes('app.js')).length + await expect((await store.serve('/assets/app.js'))!.text()).resolves.toBe('console.log("app")') + expect(cdn.calls.filter(u => u.includes('app.js')).length).toBe(before) + }) + + it('resolves the manifest: index fallback, SPA fallback, and extension-ed 404', async () => { + const cdn = fakeCdn(CDN_FILES) + const store = storeFor(cdn, makeTmp()) + + await expect((await store.serve('/'))!.text()).resolves.toBe('remote index') + await expect((await store.serve('/some/client/route'))!.text()).resolves.toBe('remote index') + + const before = cdn.calls.length + await expect(store.serve('/missing.js')).resolves.toBeNull() + expect(cdn.calls.length).toBe(before) + }) + + it('rejects traversal escapes', async () => { + const store = storeFor(fakeCdn(CDN_FILES), makeTmp()) + await expect(store.serve('/../package.json')).resolves.toBeNull() + }) + + it('degrades to probe mode when the file listing fails (DF0059)', async () => { + const cdn = fakeCdn(CDN_FILES) + const fetchImpl: typeof globalThis.fetch = async input => + String(input).startsWith('https://data.jsdelivr.com/') ? new Response('nope', { status: 500 }) : cdn.fetch(input) + const store = storeFor(cdn, makeTmp(), { fetch: fetchImpl }) + await expect((await store.serve('/assets/app.js'))!.text()).resolves.toBe('console.log("app")') + expect(warnSpy.mock.calls.some(a => String(a[0]).includes('DF0059'))).toBe(true) + }) + + it('offline: serves from the cache only and throws on a miss', async () => { + const cdn = fakeCdn(CDN_FILES) + const storageDir = makeTmp() + await storeFor(cdn, storageDir).serve('/assets/app.js').then(r => r!.text()) + await vi.waitFor(() => expect(existsSync(cachePath(storageDir, 'dist/assets/app.js'))).toBe(true)) + + const offline = storeFor(cdn, storageDir, { offline: true }) + await expect((await offline.serve('/assets/app.js'))!.text()).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 store = storeFor(fakeCdn(CDN_FILES), makeTmp()) + const target = makeTmp() + 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")') + 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 = storeFor({ fetch: fetchImpl }, makeTmp(), { provider: 'unpkg' }) + await expect((await store.serve('/'))!.text()).resolves.toBe('unpkg') + expect(calls[0]).toBe('https://unpkg.com/@scope/demo-client@1.2.3/?meta') + }) +}) + +describe('resolveStaticAssetsSource (installed package)', () => { + function install(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('passes local directories through', () => { + expect(resolveStaticAssetsSource('/some/dir', makeTmp())).toBe('/some/dir') + }) + + // `resolveStaticAssetsSource` returns `pathe` (forward-slash) paths; the + // test builds `distDir` with `node:path` (backslash on Windows). + const norm = (p: string): string => p.replace(/\\/g, '/') + + it('short-circuits to an exactly matching installed package', () => { + const { resolveFrom, distDir } = install('1.2.3') + expect(norm(resolveStaticAssetsSource({ package: '@scope/demo-client', version: '1.2.3', resolveFrom }, makeTmp()) as string)).toBe(norm(distDir)) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it('warns on minor/patch skew and serves the installed copy (DF0062)', () => { + const { resolveFrom, distDir } = install('1.3.0') + expect(norm(resolveStaticAssetsSource({ package: '@scope/demo-client', version: '1.2.3', resolveFrom }, makeTmp()) as string)).toBe(norm(distDir)) + expect(warnSpy.mock.calls.some(a => String(a[0]).includes('DF0062'))).toBe(true) + }) + + it('throws on a major version mismatch (DF0061)', () => { + const { resolveFrom } = install('2.0.0') + expect(() => resolveStaticAssetsSource({ package: '@scope/demo-client', version: '1.2.3', resolveFrom }, makeTmp())) + .toThrow(/different major version/) + }) + + it('falls back to a store when the package is absent', () => { + const { resolveFrom } = install('1.2.3') + expect(typeof resolveStaticAssetsSource({ package: '@scope/other', version: '1.2.3', resolveFrom }, makeTmp())).not.toBe('string') + }) +}) + +describe('resolveStaticAssetsSource (validation)', () => { + it.each([ + ['UPPERCASE/name', '1.2.3'], + ['has spaces', '1.2.3'], + ['../escape', '1.2.3'], + ['@scope/', '1.2.3'], + ])('rejects invalid package %j (DF0065)', (pkg, version) => { + expect(() => resolveStaticAssetsSource({ package: pkg, version }, makeTmp())).toThrow(/Invalid remote-assets package/) + }) + + it.each([ + ['@scope/ok', '../etc'], + ['@scope/ok', 'latest'], + ['@scope/ok', '1.2'], + ['@scope/ok', '1.2.3/x'], + ])('rejects invalid version for %j (DF0065)', (pkg, version) => { + expect(() => resolveStaticAssetsSource({ package: pkg, version }, makeTmp())).toThrow(/Invalid remote-assets version/) + }) + + it('accepts valid scoped names and semver (incl. prerelease/build)', () => { + const tmp = makeTmp() + for (const version of ['1.2.3', '0.9.0-beta.4', '1.0.0+build.5', '10.20.30-rc.1+meta']) { + expect(() => resolveStaticAssetsSource({ package: '@devframes/plugin-git-client', version, fetch: async () => new Response(null) }, tmp)).not.toThrow() + } + }) +}) + +describe('serveStaticHandler with a remote store', () => { + async function serve(store: RemoteAssetsStore): Promise<{ url: string, close: () => Promise }> { + 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)) + return { + url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + close: () => new Promise(r => server.close(() => r())), + } + } + + it('serves through h3 and 404s a miss', async () => { + const { url, close } = await serve(storeFor(fakeCdn(CDN_FILES), makeTmp())) + try { + const index = await fetch(`${url}/`) + expect(index.status).toBe(200) + expect(index.headers.get('content-type')).toContain('text/html') + await expect(index.text()).resolves.toBe('remote index') + expect((await fetch(`${url}/missing.js`)).status).toBe(404) + } + finally { + await close() + } + }) + + it('renders 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 = storeFor({ fetch: failing }, makeTmp()) + const { url, close } = await serve(store) + try { + const res = await fetch(`${url}/`, { 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(`${url}/app.js`, { headers: { accept: '*/*' } }) + expect(asset.status).toBe(502) + await expect(asset.text()).resolves.toBe('') + } + finally { + await close() + } + }) +}) diff --git a/packages/devframe/src/utils/remote-assets.ts b/packages/devframe/src/utils/remote-assets.ts new file mode 100644 index 00000000..194239a4 --- /dev/null +++ b/packages/devframe/src/utils/remote-assets.ts @@ -0,0 +1,348 @@ +import type { + RemoteAssets, + RemoteAssetsProviderCustom, + RemoteAssetsStore, + StaticAssetsSource, +} from '../types/remote-assets' +import { Buffer } from 'node:buffer' +import { createReadStream, existsSync } from 'node:fs' +import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { Readable } from 'node:stream' +import { lookup } from 'mrmime' +import { dirname, extname, join, normalize, sep } from 'pathe' +import { diagnostics } from '../node/diagnostics' + +const MANIFEST_FILENAME = '.manifest.json' + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + +interface TreeNode { type: 'file' | 'directory', name?: string, path?: string, files?: TreeNode[] } + +/** Flatten a jsDelivr (`name`/`files`) or unpkg (`path`/`files`) file tree. */ +function flattenTree(nodes: TreeNode[], style: 'name' | 'path'): string[] { + const out: string[] = [] + const walk = (list: TreeNode[], prefix: string): void => { + for (const node of list) { + if (style === 'path') { + if (node.type === 'file') + out.push((node.path ?? '').replace(/^\//, '')) + else + walk(node.files ?? [], '') + } + else if (node.type === 'file') { + out.push(prefix + (node.name ?? '')) + } + else if (node.files) { + walk(node.files, `${prefix}${node.name}/`) + } + } + } + walk(nodes, '') + return out +} + +const providers: Record<'jsdelivr' | 'unpkg', Required> = { + jsdelivr: { + fileUrl: (pkg, version, filePath) => `https://cdn.jsdelivr.net/npm/${pkg}@${version}/${filePath}`, + listFiles: async (pkg, version, fetchImpl) => { + const res = await fetchImpl(`https://data.jsdelivr.com/v1/packages/npm/${pkg}@${version}`) + if (!res.ok) + throw new Error(`HTTP ${res.status} from data.jsdelivr.com`) + return flattenTree((await res.json() as { files?: TreeNode[] }).files ?? [], 'name') + }, + }, + unpkg: { + fileUrl: (pkg, version, filePath) => `https://unpkg.com/${pkg}@${version}/${filePath}`, + listFiles: async (pkg, version, fetchImpl) => { + const res = await fetchImpl(`https://unpkg.com/${pkg}@${version}/?meta`) + if (!res.ok) + throw new Error(`HTTP ${res.status} from unpkg.com`) + return flattenTree([await res.json() as TreeNode], 'path') + }, + }, +} + +function resolveProvider(assets: RemoteAssets): { provider: RemoteAssetsProviderCustom, name: string } { + const p = assets.provider ?? 'jsdelivr' + return typeof p === 'string' ? { provider: providers[p], name: p } : { provider: p, name: 'custom' } +} + +// --------------------------------------------------------------------------- +// Locally installed package resolution +// --------------------------------------------------------------------------- + +/** + * Resolve a locally installed copy of `assets.package` from + * `assets.resolveFrom`'s dependency graph and return its assets directory, + * or `undefined` when the package (or directory) is absent. A different + * installed version warns (`DF0062`); a different major throws (`DF0061`). + */ +function resolveInstalled(assets: RemoteAssets): string | undefined { + if (!assets.resolveFrom) + return undefined + let pkgJsonPath: string + let installed: unknown + try { + const requireFrom = createRequire(assets.resolveFrom) + pkgJsonPath = requireFrom.resolve(`${assets.package}/package.json`) + installed = (requireFrom(`${assets.package}/package.json`) as { version?: unknown }).version + } + catch { + return undefined + } + if (typeof installed !== 'string') + return undefined + if (installed !== assets.version) { + const major = (v: string): string => v.trim().split('.')[0] ?? v + if (major(installed) !== major(assets.version)) + throw diagnostics.DF0061({ package: assets.package, required: assets.version, installed }) + diagnostics.DF0062({ package: assets.package, required: assets.version, installed }) + } + const dir = join(dirname(pkgJsonPath), assets.path ?? 'dist') + return existsSync(dir) ? dir : undefined +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +function contentTypeFor(filePath: string): string { + const type = lookup(filePath) + if (!type) + return 'application/octet-stream' + return type === 'text/html' ? 'text/html; charset=utf-8' : type +} + +/** Clean a request path into a safe package-relative POSIX path, or `null` if it escapes root. */ +function cleanRequestPath(urlPath: string): string | null { + let cleaned: string + try { + cleaned = decodeURIComponent(urlPath || '/') + } + catch { + return null + } + cleaned = cleaned.replace(/[?#].*$/, '').replace(/^\/+|\/+$/g, '') + const normalized = normalize(cleaned) + if (normalized === '..' || normalized.startsWith(`..${sep}`) || normalized.startsWith('/')) + return null + return normalized === '.' ? '' : normalized +} + +/** Candidate files for a request, in order: direct hit, index, `.html`, SPA fallback. */ +function candidatePaths(prefix: string, cleaned: string): string[] { + const candidates: string[] = [] + if (cleaned) + candidates.push(prefix + cleaned) + candidates.push(`${prefix}${cleaned ? `${cleaned}/` : ''}index.html`) + if (cleaned && !extname(cleaned)) + candidates.push(`${prefix + cleaned}.html`) + if (!/\.[a-z0-9]+$/i.test(cleaned) && !candidates.includes(`${prefix}index.html`)) + candidates.push(`${prefix}index.html`) + return candidates +} + +function createStore(assets: RemoteAssets, cacheDir: string): RemoteAssetsStore { + const normalized = { ...assets, path: assets.path ?? 'dist' } + const { provider, name: providerName } = resolveProvider(assets) + const fetchImpl = assets.fetch ?? globalThis.fetch + const prefix = `${normalized.path}/` + let manifestPromise: Promise | null> | undefined + let manifestReported = false + + async function loadManifest(): Promise | null> { + const manifestFile = join(cacheDir, MANIFEST_FILENAME) + try { + return new Set(JSON.parse(await readFile(manifestFile, 'utf8')) as string[]) + } + catch {} + if (assets.offline || !provider.listFiles) + return null + try { + const files = await provider.listFiles(normalized.package, normalized.version, fetchImpl) + await mkdir(cacheDir, { recursive: true }) + await writeFile(manifestFile, JSON.stringify(files), 'utf8').catch(() => {}) + return new Set(files) + } + catch (error) { + if (!manifestReported) { + manifestReported = true + diagnostics.DF0059({ package: normalized.package, version: normalized.version, provider: providerName, reason: errText(error), cause: error }) + } + return null + } + } + + async function serveCached(filePath: string): Promise { + const abs = join(cacheDir, filePath) + let size: number + try { + const s = await stat(abs) + if (!s.isFile()) + return null + size = s.size + } + catch { + return null + } + return new Response(Readable.toWeb(createReadStream(abs)) as ReadableStream, { + headers: { 'Content-Type': contentTypeFor(filePath), 'Content-Length': String(size), 'Cache-Control': 'no-store' }, + }) + } + + /** Persist `body` to the cache at `filePath` (tmp + rename); failures warn (`DF0063`). */ + async function persist(filePath: string, body: ReadableStream): Promise { + const target = join(cacheDir, filePath) + const tmp = `${target}.${Math.random().toString(36).slice(2)}.tmp` + try { + await mkdir(dirname(target), { recursive: true }) + await writeFile(tmp, Buffer.from(await new Response(body).arrayBuffer())) + await rename(tmp, target) + } + catch (error) { + await rm(tmp, { force: true }).catch(() => {}) + diagnostics.DF0063({ filepath: target, reason: errText(error), cause: error }) + } + } + + /** Fetch `filePath` through the provider: `null` on 404, a `Response` on 200, throws (`DF0060`) otherwise. */ + async function serveRemote(filePath: string): Promise { + const url = provider.fileUrl(normalized.package, normalized.version, filePath) + let res: Response + try { + res = await fetchImpl(url) + } + catch (error) { + throw diagnostics.DF0060({ url, package: normalized.package, reason: errText(error), cause: error }) + } + if (res.status === 404) { + await res.body?.cancel().catch(() => {}) + return null + } + if (!res.ok || !res.body) { + await res.body?.cancel().catch(() => {}) + throw diagnostics.DF0060({ url, package: normalized.package, reason: `HTTP ${res.status}` }) + } + const [toClient, toCache] = res.body.tee() + void persist(filePath, toCache) + const length = res.headers.get('content-length') + return new Response(toClient, { + headers: { 'Content-Type': contentTypeFor(filePath), 'Cache-Control': 'no-store', ...(length ? { 'Content-Length': length } : {}) }, + }) + } + + async function serve(urlPath: string): Promise { + const cleaned = cleanRequestPath(urlPath) + if (cleaned === null) + return null + const candidates = candidatePaths(prefix, cleaned) + manifestPromise ??= loadManifest() + const manifest = await manifestPromise + + if (manifest) { + const filePath = candidates.find(c => manifest.has(c)) + if (!filePath) + return null + return (await serveCached(filePath)) ?? (assets.offline + ? Promise.reject(diagnostics.DF0060({ url: filePath, package: normalized.package, reason: 'offline: true and the file is not in the cache' })) + : serveRemote(filePath)) + } + + // Probe mode (no listing): cache first, then the provider per candidate. + for (const candidate of candidates) { + const cached = await serveCached(candidate) + if (cached) + return cached + } + if (assets.offline) + return null + for (const candidate of candidates) { + const remote = await serveRemote(candidate) + if (remote) + return remote + } + return null + } + + async function materialize(targetDir: string): Promise { + const fail = (reason: string, cause?: unknown): never => { + throw diagnostics.DF0064({ package: normalized.package, version: normalized.version, reason, cause }) + } + if (!provider.listFiles) + fail('the configured provider has no file listing (`listFiles`)') + let files: string[] + try { + files = await provider.listFiles!(normalized.package, normalized.version, fetchImpl) + } + catch (error) { + return fail(errText(error), error) + } + for (const filePath of files.filter(f => f.startsWith(prefix))) { + const target = join(targetDir, filePath.slice(prefix.length)) + const url = provider.fileUrl(normalized.package, normalized.version, filePath) + let res: Response + try { + res = await fetchImpl(url) + if (!res.ok) + throw new Error(`HTTP ${res.status}`) + } + catch (error) { + return fail(`failed to download ${filePath}: ${errText(error)}`, error) + } + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, Buffer.from(await res.arrayBuffer())) + } + } + + return { assets: normalized, serve, materialize } +} + +function errText(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +// npm package name rules (github.com/npm/validate-npm-package-name), and an +// exact semver version — both interpolated into CDN URLs and the cache path, +// so they must not carry separators, `@`, or traversal segments. +const PACKAGE_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/ +const VERSION_RE = /^\d+\.\d+\.\d+(?:-[a-z0-9-]+(?:\.[a-z0-9-]+)*)?(?:\+[a-z0-9-]+(?:\.[a-z0-9-]+)*)?$/i + +/** Reject a {@link RemoteAssets} with an unsafe package name or version (`DF0065`). */ +function assertValidRemoteAssets(assets: RemoteAssets): void { + if (assets.package.length > 214 || !PACKAGE_NAME_RE.test(assets.package)) + throw diagnostics.DF0065({ field: 'package', value: assets.package }) + if (!VERSION_RE.test(assets.version)) + throw diagnostics.DF0065({ field: 'version', value: assets.version }) +} + +// --------------------------------------------------------------------------- +// Source resolution +// --------------------------------------------------------------------------- + +/** + * Normalize a {@link StaticAssetsSource} into something servable: a local + * directory (strings pass through; a remote source short-circuits to a + * locally installed copy of its package when present) or a caching + * {@link RemoteAssetsStore} back-proxy. Remote caches live under + * `/.remote-assets/@/`. + * + * A remote source's `package`/`version` are validated first (`DF0065`) — both + * are interpolated into CDN URLs and the cache path. + */ +export function resolveStaticAssetsSource( + source: StaticAssetsSource, + projectStorageDir: string, +): string | RemoteAssetsStore { + if (typeof source === 'string') + return source + assertValidRemoteAssets(source) + return resolveInstalled(source) + ?? createStore(source, join(projectStorageDir, '.remote-assets', `${source.package.replace(/\//g, '+')}@${source.version}`)) +} diff --git a/packages/devframe/src/utils/serve-static.ts b/packages/devframe/src/utils/serve-static.ts index 2b71a589..afd77811 100644 --- a/packages/devframe/src/utils/serve-static.ts +++ b/packages/devframe/src/utils/serve-static.ts @@ -1,5 +1,7 @@ import type { EventHandler } from 'h3' import type { IncomingMessage, ServerResponse } from 'node:http' +import type { ReadableStream as NodeWebReadableStream } from 'node:stream/web' +import type { RemoteAssetsStore } from '../types/remote-assets' import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { Readable } from 'node:stream' @@ -7,6 +9,12 @@ import { defineHandler, H3 } from 'h3' import { lookup } from 'mrmime' import { extname, join, normalize, resolve, sep } from 'pathe' +/** + * What the static-serving engine accepts: a local directory, or a resolved + * {@link RemoteAssetsStore} back-proxy (from `devframe/utils/remote-assets`). + */ +export type ServableAssets = string | RemoteAssetsStore + export interface ServeStaticOptions { /** Default: `['index.html']`. */ indexNames?: string[] @@ -133,7 +141,48 @@ function normalizeOptions(options: ServeStaticOptions | undefined): NormalizedOp } /** - * h3 event handler that serves files from `dir` with SPA fallback. + * Drive one request through a {@link RemoteAssetsStore}: the store's + * `Response` on a hit, a 404 on a miss, or a 502 (styled error page for HTML + * navigations) on provider failure — shared between the h3 and connect flavors. + */ +async function remoteResponse(store: RemoteAssetsStore, urlPath: string, accept: string | null | undefined): Promise { + try { + return (await store.serve(urlPath)) ?? new Response(null, { status: 404 }) + } + catch (error) { + if (typeof accept === 'string' && accept.includes('text/html')) { + return new Response( + remoteErrorPage(store.assets.package, store.assets.version, error instanceof Error ? error.message : String(error)), + { status: 502, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, + ) + } + return new Response(null, { status: 502 }) + } +} + +function serveRemoteAssetsHandler(store: RemoteAssetsStore): EventHandler { + return defineHandler(async (event) => { + const method = event.req.method + if (method !== 'GET' && method !== 'HEAD') { + event.res.status = 405 + event.res.headers.set('Allow', 'GET, HEAD') + return '' + } + const res = await remoteResponse(store, event.url.pathname, event.req.headers.get('accept')) + event.res.status = res.status + res.headers.forEach((v, k) => event.res.headers.set(k, v)) + if (method === 'HEAD') { + await res.body?.cancel().catch(() => {}) + return '' + } + return (res.body ?? '') as ReadableStream + }) +} + +/** + * h3 event handler that serves files from `source` with SPA fallback — a + * local directory, or a {@link RemoteAssetsStore} whose files stream through + * the CDN back-proxy into the local cache. * * Drop-in replacement for `fromNodeMiddleware(sirv(dir, { dev: true, single: true }))` * when the surrounding server is an h3 app — no `Cache-Control` beyond @@ -142,10 +191,12 @@ function normalizeOptions(options: ServeStaticOptions | undefined): NormalizedOp * works. */ export function serveStaticHandler( - dir: string, + source: ServableAssets, options?: ServeStaticOptions, ): EventHandler { - const absDir = resolve(dir) + if (typeof source !== 'string') + return serveRemoteAssetsHandler(source) + const absDir = resolve(source) const opts = normalizeOptions(options) return defineHandler(async (event) => { const method = event.req.method @@ -177,11 +228,11 @@ export function serveStaticHandler( export function mountStaticHandler( app: H3, base: string, - dir: string, + source: ServableAssets, options?: ServeStaticOptions, ): void { const staticApp = new H3() - staticApp.use(serveStaticHandler(dir, options)) + staticApp.use(serveStaticHandler(source, options)) app.mount(base.replace(/\/$/, ''), staticApp) } @@ -193,10 +244,10 @@ export function mountStaticHandler( * adapt an event handler back into Node middleware. */ export function serveStaticNodeMiddleware( - dir: string, + source: ServableAssets, options?: ServeStaticOptions, ): (req: IncomingMessage, res: ServerResponse, next?: (err?: Error) => void) => void { - const absDir = resolve(dir) + const absDir = typeof source === 'string' ? resolve(source) : undefined const opts = normalizeOptions(options) return (req, res, next) => { void (async () => { @@ -212,6 +263,24 @@ export function serveStaticNodeMiddleware( return } const url = req.url ?? '/' + + if (absDir === undefined) { + const response = await remoteResponse(source as RemoteAssetsStore, url, req.headers.accept) + if (response.status === 404 && next) { + next() + return + } + res.statusCode = response.status + response.headers.forEach((v, k) => res.setHeader(k, v)) + if (method === 'HEAD' || !response.body) { + await response.body?.cancel().catch(() => {}) + res.end() + return + } + Readable.fromWeb(response.body as NodeWebReadableStream).pipe(res) + return + } + const file = await resolveTarget(absDir, url, opts.indexNames, opts.single) if (!file) { if (next) { @@ -238,3 +307,45 @@ export function serveStaticNodeMiddleware( }) } } + +/** + * Minimal, dependency-free HTML shown when a remote-assets request cannot + * be satisfied (no installed package, no cache, provider unreachable). + */ +function remoteErrorPage(pkg: string, version: string, reason: string): string { + const esc = (s: string): string => s.replace(/&/g, '&').replace(//g, '>') + const name = esc(pkg) + const ver = esc(version) + return ` + + + + +Client assets unavailable + + + +
+

Client assets unavailable

+

The UI for this tool is served from ${name}@${ver}, which could not be reached.

+
${esc(reason)}
+

To use it without network access, install the assets package locally:

+
npm install ${name}@${ver}
+ +

devframe remote assets

+
+ + +` +} diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index d367adb0..5dd0ce62 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -104,6 +104,7 @@ const serverEntries = { 'internal/index': 'src/internal/index.ts', 'utils/launch-editor': 'src/utils/launch-editor.ts', 'utils/open': 'src/utils/open.ts', + 'utils/remote-assets': 'src/utils/remote-assets.ts', 'utils/serve-static': 'src/utils/serve-static.ts', 'adapters/cac': 'src/adapters/cac.ts', 'adapters/dev': 'src/adapters/dev.ts', diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts index 87f67fe5..9eac2c48 100644 --- a/packages/hub/src/node/install-devframe.ts +++ b/packages/hub/src/node/install-devframe.ts @@ -90,7 +90,8 @@ export async function installDevframe( await ctx.host.mountConnectionMeta(base) else diagnostics.DF8106({ id, name: d.name, base }) - ctx.views.hostStatic(base, resolve(d.cli.distDir)) + const distSource = d.cli.distDir + ctx.views.hostStatic(base, typeof distSource === 'string' ? resolve(distSource) : distSource) } ctx.docks.register({ diff --git a/packages/vite/src/dev-spa.ts b/packages/vite/src/dev-spa.ts index fbde1671..fb8c4cc6 100644 --- a/packages/vite/src/dev-spa.ts +++ b/packages/vite/src/dev-spa.ts @@ -3,10 +3,12 @@ import type { DevframeInstance } from 'devframe/initiate' import type { DevframeAuthHandler } from 'devframe/node/auth' import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http' import type { Plugin } from 'vite' +import process from 'node:process' import { initDevframe } from 'devframe/initiate' import { diagnostics, normalizeBasePath, resolveBasePath } from 'devframe/internal' +import { resolveStaticAssetsSource } from 'devframe/utils/remote-assets' import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' -import { resolve } from 'pathe' +import { join, resolve } from 'pathe' /** * The slice of a Vite dev server these plugins touch — deliberately @@ -67,7 +69,11 @@ export function devframeVitePlugin(d: DevframeDefinition, options: DevframeViteP configureServer(server: DevframeViteDevServerLike) { if (!distDir) return - server.middlewares.use(base, serveStaticNodeMiddleware(resolve(distDir))) + // Remote-assets sources resolve to the locally installed assets + // package when present, otherwise to a caching CDN back-proxy, under + // the h3 host's `project` storage convention. + const source = resolveStaticAssetsSource(distDir, join(process.cwd(), 'node_modules', `.${d.id}`, 'devframe')) + server.middlewares.use(base, serveStaticNodeMiddleware(typeof source === 'string' ? resolve(source) : source)) }, } } diff --git a/plugins/a11y/tests/_utils.ts b/plugins/a11y/tests/_utils.ts index 5c7d9a84..44b38ab1 100644 --- a/plugins/a11y/tests/_utils.ts +++ b/plugins/a11y/tests/_utils.ts @@ -37,6 +37,8 @@ export interface InspectorServer extends StartedServer { */ export async function startInspectorServer(): 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/plugins/git/test/_utils.ts b/plugins/git/test/_utils.ts index aedb9c03..f7ff8a34 100644 --- a/plugins/git/test/_utils.ts +++ b/plugins/git/test/_utils.ts @@ -47,6 +47,8 @@ export async function startDashboardServer( ): Promise { const devframe = createGitDevframe(options) const distDir = devframe.cli!.distDir! + if (typeof distDir !== 'string') + throw new TypeError('these tests serve the local dist directory — build the SPA first') // The factory leaves basePath adapter-resolved; standalone defaults to '/'. const basePath = devframe.basePath ?? '/' const host = '127.0.0.1' diff --git a/plugins/messages/test/_utils.ts b/plugins/messages/test/_utils.ts index 05031f73..fb714bb1 100644 --- a/plugins/messages/test/_utils.ts +++ b/plugins/messages/test/_utils.ts @@ -17,7 +17,13 @@ import { getPort } from 'get-port-please' import { H3 } from 'h3' import { serveTestContext } from '../../../tests/helpers/serve-test-context' -const SPA_DIST = messagesDevframe.cli!.distDir! +const SPA_DIST = localDistDir(messagesDevframe.cli!.distDir!) + +function localDistDir(source: string | object): string { + if (typeof source !== 'string') + throw new TypeError('these tests serve the local dist directory — build the SPA first') + return source +} /** * Assert the Vue SPA has been built. The dev-server and static-build @@ -53,7 +59,7 @@ interface BootOptions { * warn-and-noop path. */ async function boot(options: BootOptions): Promise { - const distDir = messagesDevframe.cli!.distDir! + const distDir = localDistDir(messagesDevframe.cli!.distDir!) const basePath = resolveBasePath(messagesDevframe, 'standalone') const host = '127.0.0.1' const port = await getPort({ host, random: true }) diff --git a/plugins/og/test/_utils.ts b/plugins/og/test/_utils.ts index fd71b58d..49679d49 100644 --- a/plugins/og/test/_utils.ts +++ b/plugins/og/test/_utils.ts @@ -28,7 +28,7 @@ const testDevframe = createOgDevframe({ fetch: testFetch }) export function assertSpaBuilt(): void { const distDir = testDevframe.cli!.distDir - if (!distDir || !existsSync(path.join(distDir, 'index.html'))) + if (!distDir || typeof distDir !== 'string' || !existsSync(path.join(distDir, 'index.html'))) throw new Error('Open Graph SPA missing. Run the plugin build first.') } @@ -38,6 +38,8 @@ export interface OgServer extends StartedServer { export async function startOgServer(): Promise { const distDir = testDevframe.cli!.distDir! + if (typeof distDir !== 'string') + throw new TypeError('these tests serve the local dist directory — build the SPA first') const basePath = resolveBasePath(testDevframe, 'standalone') const host = '127.0.0.1' const port = await getPort({ host, random: true }) diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts index 206a2d30..da300072 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/build.snapshot.d.ts @@ -4,7 +4,7 @@ // #region Interfaces export interface CreateBuildOptions { outDir?: string; - distDir?: string; + distDir?: StaticAssetsSource; pretty?: boolean; force?: boolean; } diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts index 3a628cc0..abe473a2 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/dev.snapshot.d.ts @@ -6,7 +6,7 @@ export interface CreateDevServerOptions { host?: string; port?: number; flags?: Record; - distDir?: string; + distDir?: StaticAssetsSource; basePath?: string; ws?: DevframeWsOptions | false; allowedOrigins?: readonly string[] | WsOriginRegistry | false; diff --git a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts index 449b7cde..2d89978c 100644 --- a/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/index.snapshot.d.ts @@ -120,7 +120,7 @@ export interface DevframeCliOptions { open?: boolean | string; auth?: boolean | DevframeAuthHandler; mcp?: boolean | McpRouteOptions; - distDir?: string; + distDir?: StaticAssetsSource; ws?: DevframeWsOptions | false; sse?: boolean | DevframeSseOptions; configure?: (_: CAC) => void; @@ -172,7 +172,7 @@ export interface DevframeDockDefaults { groupId?: string; } export interface DevframeHost { - mountStatic: (_: string, _: string) => void | Promise; + mountStatic: (_: string, _: string | RemoteAssetsStore) => void | Promise; mountConnectionMeta?: (_: string) => void | Promise; resolveOrigin: () => string; getStorageDir: (_: DevframeStorageScope) => string; @@ -329,9 +329,9 @@ export interface DevframeSseOptions { export interface DevframeViewHost { buildStaticDirs: { baseUrl: string; - distDir: string; + source: StaticAssetsSource; }[]; - hostStatic: (_: string, _: string) => void; + hostStatic: (_: string, _: StaticAssetsSource) => void; } export interface DevframeWsOptions { route?: string; @@ -356,6 +356,26 @@ export interface McpRouteOptions { path?: string; allowedOrigins?: readonly string[] | false; } +export interface RemoteAssets { + package: string; + version: string; + path?: string; + provider?: RemoteAssetsProvider; + resolveFrom?: string; + fetch?: typeof globalThis.fetch; + offline?: boolean; +} +export interface RemoteAssetsProviderCustom { + fileUrl: (_: string, _: string, _: string) => string; + listFiles?: (_: string, _: string, _: typeof globalThis.fetch) => Promise; +} +export interface RemoteAssetsStore { + readonly assets: RemoteAssets & { + path: string; + }; + serve: (_: string) => Promise; + materialize: (_: string) => Promise; +} export interface RpcBroadcastOptions { method: METHOD; args: Args; @@ -422,6 +442,7 @@ export type DevframeRpcTransportKind = 'websocket' | 'sse'; export type DevframeServiceId = keyof DevframeServicesRegistry | (string & {}); export type DevframeServiceOf = ID extends keyof DevframeServicesRegistry ? DevframeServicesRegistry[ID] : unknown; export type DevframeStorageScope = 'workspace' | 'project' | 'global'; +export type RemoteAssetsProvider = 'jsdelivr' | 'unpkg' | RemoteAssetsProviderCustom; export type RpcFunctionsHost = RpcFunctionsCollectorBase & { invokeLocal: >(_: T, ..._: Args) => Promise>>; broadcast: >(_: RpcBroadcastOptions) => Promise; @@ -434,6 +455,7 @@ export type ScopedRpcFn = `${NS}: export type ScopedServerFunctions = { [K in keyof DevframeRpcServerFunctions as K extends `${NS}:${infer R}` ? R : never]: DevframeRpcServerFunctions[K]; }; export type ScopedSharedStates = { [K in keyof DevframeRpcSharedStates as K extends `${NS}:${infer R}` ? R : never]: DevframeRpcSharedStates[K]; }; export type SettingsForNamespace = NS extends keyof DevframeSettingsRegistry ? DevframeSettingsRegistry[NS] extends Record ? DevframeSettingsRegistry[NS] : Record : Record; +export type StaticAssetsSource = string | RemoteAssets; // #endregion // #region Functions diff --git a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts index de745254..a3893f16 100644 --- a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts @@ -19,7 +19,7 @@ export interface DevframeInstanceInternals { } export interface InitDevframeOptions { base: string; - distDir?: string | false; + distDir?: StaticAssetsSource | false; server?: Server; ws?: DevframeWsOptions | false; sse?: boolean | DevframeSseOptions; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index b76e3ff9..31097203 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -4,7 +4,7 @@ // #region Interfaces export interface CreateH3DevframeHostOptions { origin: string | (() => string); - mount?: (_: string, _: string) => void | Promise; + mount?: (_: string, _: string | RemoteAssetsStore) => void | Promise; appName: string; workspaceRoot?: string; } @@ -250,6 +250,61 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "Pass `{ force: true }` to `createDevServer()` to run it anyway, or drop `capabilities.dev: false` on the definition."; }; + readonly DF0059: { + readonly why: (p: { + package: string; + version: string; + provider: string; + reason: string; + }) => string; + readonly 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."; + }; + readonly DF0060: { + readonly why: (p: { + url: string; + package: string; + reason: string; + }) => string; + readonly fix: "Install the assets package locally (`npm install `) to serve it with zero network, or check network access to the configured provider."; + }; + readonly DF0061: { + readonly why: (p: { + package: string; + required: string; + installed: string; + }) => string; + readonly fix: "Align the installed assets package with the version its node package declares — they are published in lockstep."; + }; + readonly DF0062: { + readonly why: (p: { + package: string; + required: string; + installed: string; + }) => string; + readonly fix: "Install the exact declared version to serve byte-identical assets."; + }; + readonly DF0063: { + readonly why: (p: { + filepath: string; + reason: string; + }) => string; + readonly fix: "The response was still served; only caching failed. Check that the cache directory is writable and has free space."; + }; + readonly DF0064: { + readonly why: (p: { + package: string; + version: string; + reason: string; + }) => string; + readonly 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."; + }; + readonly DF0065: { + readonly why: (p: { + field: "package" | "version"; + value: string; + }) => string; + readonly fix: "A remote-assets `package` must be a valid npm package name and `version` an exact semver version (e.g. `1.2.3`) — they are interpolated into CDN URLs and the cache path."; + }; }, readonly [typeof devframeReporter]>; // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts index 19d42a06..3621b5e4 100644 --- a/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/types.snapshot.d.ts @@ -55,6 +55,10 @@ export { EventEmitter } export { EventsMap } export { EventUnsubscribe } export { McpRouteOptions } +export { RemoteAssets } +export { RemoteAssetsProvider } +export { RemoteAssetsProviderCustom } +export { RemoteAssetsStore } export { RpcBroadcastOptions } export { RpcFunctionAgentOptions } export { RpcFunctionsHost } @@ -69,4 +73,5 @@ export { ScopedRpcFn } export { ScopedServerFunctions } export { ScopedSharedStates } export { SettingsForNamespace } +export { StaticAssetsSource } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.d.ts new file mode 100644 index 00000000..1bab4a1c --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.d.ts @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/remote-assets` + */ +// #region Functions +export declare function resolveStaticAssetsSource(_: StaticAssetsSource, _: string): string | RemoteAssetsStore; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.js new file mode 100644 index 00000000..b083d861 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/remote-assets.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/remote-assets` + */ +// #region Functions +export function resolveStaticAssetsSource(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.d.ts index 8924025b..6d5c6bdc 100644 --- a/tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/utils/serve-static.snapshot.d.ts @@ -8,8 +8,12 @@ export interface ServeStaticOptions { } // #endregion +// #region Types +export type ServableAssets = string | RemoteAssetsStore; +// #endregion + // #region Functions -export declare function mountStaticHandler(_: H3, _: string, _: string, _?: ServeStaticOptions): void; -export declare function serveStaticHandler(_: string, _?: ServeStaticOptions): EventHandler; -export declare function serveStaticNodeMiddleware(_: string, _?: ServeStaticOptions): (_: IncomingMessage, _: ServerResponse, _?: (_?: Error) => void) => void; +export declare function mountStaticHandler(_: H3, _: string, _: ServableAssets, _?: ServeStaticOptions): void; +export declare function serveStaticHandler(_: ServableAssets, _?: ServeStaticOptions): EventHandler; +export declare function serveStaticNodeMiddleware(_: ServableAssets, _?: ServeStaticOptions): (_: IncomingMessage, _: ServerResponse, _?: (_?: Error) => void) => void; // #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index 61b2b7af..dc3e173e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -76,6 +76,9 @@ "devframe/utils/open": [ "./packages/devframe/src/utils/open.ts" ], + "devframe/utils/remote-assets": [ + "./packages/devframe/src/utils/remote-assets.ts" + ], "devframe/utils/simple-schema": [ "./packages/devframe/src/utils/simple-schema.ts" ],