diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f983aeb7..c6c62964 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -25,6 +25,7 @@ function guideGroups(prefix: string) { { text: 'Devframe Definition', link: `${prefix}/guide/devframe-definition` }, { text: 'RPC', link: `${prefix}/guide/rpc` }, { text: 'Shared State', link: `${prefix}/guide/shared-state` }, + { text: 'Client Assets', link: `${prefix}/guide/client-assets` }, { text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` }, { text: 'Agent-Native', link: `${prefix}/guide/agent-native` }, { text: 'JSON-Render', link: `${prefix}/guide/json-render` }, diff --git a/docs/adapters/build.md b/docs/adapters/build.md index d1f4e215..fc5d7806 100644 --- a/docs/adapters/build.md +++ b/docs/adapters/build.md @@ -23,7 +23,7 @@ await createBuild(devframe, { | Option | Default | Description | |--------|---------|-------------| | `outDir` | `dist-static` | Output directory. Cleared on each build. | -| `distDir` | `def.cli?.distDir` | Override the SPA dist directory. | +| `distDir` | `def.cli?.distDir` | Override the SPA dist directory (a local path or a [remote assets](/guide/client-assets) package, materialized in full at build time). | | `pretty` | `false` | Pretty-print dump JSON (larger on disk). | The resulting directory hosts on any static web server (`serve`, nginx, GitHub Pages, …). The client auto-detects `static` mode by resolving `./__connection.json` against `document.baseURI` and runs in read-only form. diff --git a/docs/frameworks/vite.md b/docs/frameworks/vite.md index a346beb3..5cbac86b 100644 --- a/docs/frameworks/vite.md +++ b/docs/frameworks/vite.md @@ -26,7 +26,7 @@ export default defineConfig({ ## `devframeVitePlugin` — static mount -Mounts `def.cli.distDir` at `options.base` (`/__/` by default) with SPA fallback. No RPC server is started — useful when you only need the SPA bundle served from a known path. +Mounts `def.cli.distDir` at `options.base` (`/__/` by default) with SPA fallback. No RPC server is started — useful when you only need the SPA bundle served from a known path. `distDir` may be a local directory or a [remote assets](/guide/client-assets) package. | Option | Default | Description | |--------|---------|-------------| diff --git a/docs/guide/client-assets.md b/docs/guide/client-assets.md new file mode 100644 index 00000000..3d2527c5 --- /dev/null +++ b/docs/guide/client-assets.md @@ -0,0 +1,127 @@ +--- +outline: deep +--- + +# Client Assets + +A devframe's UI is a built single-page app served as its client. `cli.distDir` tells devframe where those assets live — either a **local directory** bundled with your tool, or a **published npm package** fetched on demand. + +## Mounting a local build + +The basic form points `cli.distDir` at the directory your SPA build produces. Resolve it from the module so it works from both source and the published package: + +```ts +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe' +import pkg from '../package.json' with { type: 'json' } + +export default defineDevframe({ + id: 'my-tool', + version: pkg.version, + packageName: pkg.name, + cli: { + distDir: fileURLToPath(new URL('../dist/spa', import.meta.url)), + }, + setup(ctx) { + // … + }, +}) +``` + +devframe serves that directory with SPA fallback (an unknown path resolves to `index.html`, so client-side routing works) and no-store caching for dev. Build your SPA with a relative base (`vite: { base: './' }`) so the bundle is mount-path portable — it discovers its runtime base from `document.baseURI` and works at `/`, `/__my-tool/`, or any mount point without rewriting. + +The [`dev`](/adapters/dev), [`build`](/adapters/build), and [Vite](/frameworks/vite) adapters all consume this same `distDir`. + +## Remote assets + +Instead of a directory, `cli.distDir` can name a **published npm package** that holds the built UI. The assets are then fetched on demand and cached locally, so the node package doesn't bundle its SPA — keeping the installed footprint small, since a plugin's UI is usually the bulk of its tarball. + +Give `cli.distDir` a `RemoteAssets` object naming the package and exact version: + +```ts +import type { RemoteAssets } from 'devframe' +import { defineDevframe } from 'devframe' +import pkg from '../package.json' with { type: 'json' } + +const distDir: RemoteAssets = { + package: '@acme/my-tool-assets', + version: pkg.version, + resolveFrom: import.meta.url, +} + +export default defineDevframe({ + id: 'my-tool', + version: pkg.version, + packageName: pkg.name, + cli: { distDir }, + setup(ctx) { + // … + }, +}) +``` + +The UI mounts as usual — the first request for each file is streamed from a CDN and written to a local cache; subsequent requests are served from disk. + +### How assets resolve + +For each request the source resolves in order: + +1. **Locally installed package** — resolved from `resolveFrom` (`import.meta.url`). If `@acme/my-tool-assets` is installed next to your tool, it's served directly with no network. This is the offline path. +2. **On-disk cache** — files already fetched, under the project's storage directory. +3. **CDN back-proxy** — [jsDelivr](https://www.jsdelivr.com/) by default, mirroring npm. Each file streams to the browser and is cached on the way past. + +Exact-version URLs are immutable, so a cached file never goes stale. + +### Options + +| Field | Purpose | +|-------|---------| +| `package` | npm package holding the built assets. | +| `version` | Exact version to serve — usually your tool's own `pkg.version`. | +| `resolveFrom` | `import.meta.url` of the declaring module; enables the zero-network path from a locally installed copy. Omit to skip straight to cache + CDN. | +| `path` | Subpath inside the package the assets live under. Defaults to `dist`. | +| `provider` | `'jsdelivr'` (default), `'unpkg'`, or a custom provider for an internal mirror. | +| `offline` | `true` serves only from a local install or the cache — never the network. | + +`package` and `version` are interpolated into the CDN URL and cache path, so they're validated: an invalid npm name or non-exact version throws [`DF0065`](../errors/DF0065). + +### Offline and air-gapped use + +Remote assets are a convenience, not a hard network dependency. To run with no network, install the assets package explicitly — resolution step 1 then serves it locally: + +```sh +npm install @acme/my-tool-assets +``` + +Set `offline: true` to guarantee the CDN is never contacted, or point `provider` at an internal npm mirror. + +### Custom provider + +A custom provider supplies the file URL, and optionally a file listing (used for correct 404s, SPA fallback, and static builds): + +```ts +const distDir: RemoteAssets = { + package: '@acme/my-tool-assets', + version: pkg.version, + resolveFrom: import.meta.url, + provider: { + fileUrl: (name, version, file) => + `https://npm.internal.acme.com/${name}@${version}/${file}`, + }, +} +``` + +### Publishing the assets + +The assets package is an ordinary npm package that ships the built UI under `path` (default `dist`) and exposes its `package.json` so `resolveFrom` can locate it: + +```json +{ + "name": "@acme/my-tool-assets", + "version": "1.0.0", + "exports": { "./package.json": "./package.json" }, + "files": ["dist"] +} +``` + +Keep its version in lockstep with the tool that declares it, so `version: pkg.version` always points at matching UI. diff --git a/docs/guide/devframe-definition.md b/docs/guide/devframe-definition.md index efc65d6d..57038828 100644 --- a/docs/guide/devframe-definition.md +++ b/docs/guide/devframe-definition.md @@ -201,7 +201,7 @@ defineDevframe({ | Field | Type | Description | |-------|------|-------------| | `command` | `string` | Binary name surfaced in `--help`. Default: the definition's `id`. | -| `distDir` | `string` | SPA dist directory. **Required** for `dev` / `build`. | +| `distDir` | `string \| RemoteAssets` | SPA dist directory, or a [remote assets](./client-assets) package fetched on demand. **Required** for `dev` / `build`. | | `port` | `number` | Preferred port for the dev server. | | `portRange` | `[number, number]` | Port scan range, passed through to `get-port-please`. | | `random` | `boolean` | Prefer a random open port. |