diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f7e21814..26e3dea4 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -19,31 +19,44 @@ function listErrorCodes(prefix: string): string[] { function guideGroups(prefix: string) { return [ { - text: 'Fundamentals', + text: 'Introduction', items: [ { text: 'Introduction', link: `${prefix}/guide/` }, + ], + }, + { + text: 'Define your tool', + items: [ { 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` }, { text: 'Streaming', link: `${prefix}/guide/streaming` }, + { text: 'Client Assets', link: `${prefix}/guide/client-assets` }, { text: 'Scoped Context', link: `${prefix}/guide/scoped-context` }, - { text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` }, + { text: 'JSON-Render', link: `${prefix}/guide/json-render` }, + { text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` }, + { text: 'When Clauses', link: `${prefix}/guide/when-clauses` }, ], }, { - text: 'Client & Security', + text: 'Mount anywhere', items: [ + { text: 'The Standard Handler', link: `${prefix}/adapters/initiate` }, + { text: 'Adapters', link: `${prefix}/adapters/` }, + { text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` }, { text: 'Client', link: `${prefix}/guide/client` }, { text: 'Transports', link: `${prefix}/guide/transports` }, { text: 'Security', link: `${prefix}/guide/security` }, ], }, { - text: 'Hub', + text: 'Agentic', + items: [ + { text: 'Agent-Native', link: `${prefix}/guide/agent-native` }, + ], + }, + { + text: 'Compose a hub', items: [ { text: 'Hub', link: `${prefix}/guide/hub` }, { text: 'Client Scripts & Context', link: `${prefix}/guide/client-context` }, @@ -54,17 +67,16 @@ function guideGroups(prefix: string) { ], }, { - text: 'Customization', + text: 'Customize the UI', items: [ { text: 'Build Your Own JSON-Render Frontend', link: `${prefix}/guide/build-your-own-json-render-frontend` }, { text: 'Build Your Own Hub UI', link: `${prefix}/guide/build-your-own-hub-ui` }, ], }, { - text: 'References', + text: 'Ecosystem', items: [ - { text: 'When Clauses', link: `${prefix}/guide/when-clauses` }, - { text: 'Examples', link: `${prefix}/examples/` }, + { text: 'Built with Devframe', link: `${prefix}/guide/built-with` }, ], }, ] satisfies { text: string, items: DefaultTheme.NavItemWithLink[] }[] @@ -73,9 +85,9 @@ function guideGroups(prefix: string) { function adaptersItems(prefix: string) { return [ { text: 'Overview', link: `${prefix}/adapters/` }, - { text: 'Initiate (middleware)', link: `${prefix}/adapters/initiate` }, - { text: 'Dev', link: `${prefix}/adapters/dev` }, + { text: 'The Standard Handler', link: `${prefix}/adapters/initiate` }, { text: 'CLI', link: `${prefix}/adapters/cac` }, + { text: 'Dev', link: `${prefix}/adapters/dev` }, { text: 'Build', link: `${prefix}/adapters/build` }, { text: 'Vite DevTools', link: `${prefix}/adapters/vite` }, { text: 'Embedded', link: `${prefix}/adapters/embedded` }, @@ -115,27 +127,6 @@ function pluginsItems(prefix: string) { ] satisfies DefaultTheme.NavItemWithLink[] } -function examplesItems(prefix: string) { - return [ - { text: 'Overview', link: `${prefix}/examples/` }, - { text: 'Built with Devframe', link: `${prefix}/examples/built-with` }, - { text: 'files-inspector', link: `${prefix}/examples/files-inspector` }, - { text: 'json-render', link: `${prefix}/examples/json-render` }, - { text: 'streaming-chat', link: `${prefix}/examples/streaming-chat` }, - { text: 'next-runtime-snapshot', link: `${prefix}/examples/next-runtime-snapshot` }, - { text: 'hub-vite', link: `${prefix}/examples/hub-vite` }, - { text: 'hub-next', link: `${prefix}/examples/hub-next` }, - { text: 'hub-vite-minimal', link: `${prefix}/examples/hub-vite-minimal` }, - { text: 'hub-next-minimal', link: `${prefix}/examples/hub-next-minimal` }, - { text: 'hub-nitro-minimal', link: `${prefix}/examples/hub-nitro-minimal` }, - { text: 'hub-hono-minimal', link: `${prefix}/examples/hub-hono-minimal` }, - { text: 'hub-fastify-minimal', link: `${prefix}/examples/hub-fastify-minimal` }, - { text: 'hub-sveltekit-minimal', link: `${prefix}/examples/hub-sveltekit-minimal` }, - { text: 'hub-deno-minimal', link: `${prefix}/examples/hub-deno-minimal` }, - { text: 'hub-rsbuild-minimal', link: `${prefix}/examples/hub-rsbuild-minimal` }, - ] satisfies DefaultTheme.NavItemWithLink[] -} - export function devframeSidebar(prefix = ''): DefaultTheme.SidebarItem[] { return [ { @@ -159,10 +150,6 @@ export function devframeSidebar(prefix = ''): DefaultTheme.SidebarItem[] { text: 'Plugins', items: pluginsItems(prefix), }, - { - text: 'Examples', - items: examplesItems(prefix), - }, ] } diff --git a/docs/adapters/build.md b/docs/adapters/build.md index 7f34dbb1..8e8290b8 100644 --- a/docs/adapters/build.md +++ b/docs/adapters/build.md @@ -4,12 +4,12 @@ outline: deep # Build -Produces a self-contained static deploy of a devframe: +Produces a static deploy: -1. Copies the author's SPA dist (`clientAssets` or `options.distDir`) into ``. +1. Copies the SPA dist into ``. 2. Runs `setup(ctx)` with `mode: 'build'`. -3. Collects RPC dumps for every `'static'` function and any `'query'` function with `dump.inputs` / `snapshot: true`. -4. Writes `/__connection.json` (`{ backend: 'static' }`) and sharded dump files under `/__rpc-dump/` — both at the SPA root so the deployed client discovers them via relative paths from `document.baseURI`. +3. Collects RPC dumps for every `'static'` and `'query'` with `dump.inputs` / `snapshot: true`. +4. Writes `__connection.json` (`{ backend: 'static' }`) and sharded dumps under `__rpc-dump/`. ```ts import { createBuild } from 'devframe/adapters/build' @@ -22,10 +22,8 @@ await createBuild(devframe, { | Option | Default | Description | |--------|---------|-------------| -| `outDir` | `dist-static` | Output directory. Cleared on each build. | -| `distDir` | `def.clientAssets` (falls back to deprecated `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). | +| `outDir` | `dist-static` | Output directory (cleared). | +| `distDir` | `def.clientAssets` | SPA dist override (or [remote assets](/guide/client-assets)). | +| `pretty` | `false` | Pretty-print dump JSON. | -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. - -`createBuild` copies the SPA verbatim, so deploying under a custom URL base just means building the SPA with relative asset paths (`vite.base: './'`) — the client discovers the effective base at runtime. +The client runs read-only. For a custom URL base, build with relative asset paths (`vite.base: './'`). diff --git a/docs/adapters/cac.md b/docs/adapters/cac.md index 6fbbc19a..bdb918a6 100644 --- a/docs/adapters/cac.md +++ b/docs/adapters/cac.md @@ -4,15 +4,13 @@ outline: deep # CLI (cac) -The cac adapter wraps a `DevframeDefinition` in a [`cac`](https://github.com/cacjs/cac)-powered command-line interface. From one entry it spins up an `h3` dev server with WebSocket RPC, builds static snapshots, or starts an MCP server. - -`cac` is an optional peer dependency, pulled in only through this adapter — install it alongside `devframe` to opt into `createCac`: +A [`cac`](https://github.com/cacjs/cac) CLI around a `DevframeDefinition` with `dev`, `build`, and `mcp` commands. `cac` is an optional peer: ```sh npm install devframe cac ``` -Tools that assemble their own command-line shell from the [lower-level factories](#use-your-own-cli-framework) never import this adapter, so they run without `cac`. +The [lower-level factories](#use-your-own-cli-framework) need no `cac`. ```ts import { defineDevframe } from 'devframe' @@ -28,8 +26,6 @@ const devframe = defineDevframe({ await createCac(devframe).parse() ``` -Running the resulting binary: - ```sh my-devframe # dev server at http://localhost:9999/ my-devframe --port 8080 @@ -38,19 +34,19 @@ my-devframe build --out-dir dist-static --base /devframe/ my-devframe mcp # stdio MCP server ``` -Standalone CLI serves the SPA at `/` by default. The `/__devframe/` prefix is for *hosted* adapters where devframe mounts alongside an existing app — see [Mount paths](./#mount-paths). +The SPA serves at `/` standalone, `/__devframe/` hosted ([Mount paths](./#mount-paths)). ## Options -`createCac(def, options?)` accepts: +`createCac(def, options?)`: | Option | Default | Description | |--------|---------|-------------| -| `defaultPort` | `9999` (or `def.cli?.port`) | Port used by the dev command when `--port` isn't provided. | -| `configureCli` | — | `(cli: CAC) => void` — final hook to add commands/flags at the assembly stage, after the definition's `cli.configure` runs. | -| `onReady` | — | `(info: { origin, port, app }) => void \| Promise` — called once the dev server is listening. Use this to print your own startup banner. | +| `defaultPort` | `9999` (or `def.cli?.port`) | Dev port if `--port` unset. | +| `configureCli` | — | `(cli: CAC) => void` — add commands/flags. | +| `onReady` | — | `(info: { origin, port, app }) => void \| Promise` — once listening. | -`createCac` returns a `CacHandle`: +Returns a `CacHandle`: ```ts interface CacHandle { @@ -59,8 +55,6 @@ interface CacHandle { } ``` -The `cli` property lets the caller add ad-hoc commands and flags right before `parse()` when a `configureCli` callback is inconvenient. - ## Definition-level `cli` fields ```ts @@ -87,11 +81,11 @@ defineDevframe({ }) ``` -The top-level [`clientAssets`](/guide/client-assets) supplies the SPA the dev/build commands serve; everything under `cli` has sensible defaults. The `configure` hook runs *before* the `configureCli` option passed to `createCac`, so the final tool author always has the last word on flags. +`configure` runs *before* `createCac`'s `configureCli`. ## Headless logging -Devframe leaves startup output to the application. Wire `onReady` to print your own banner: +Print a banner via `onReady`: ```ts await createCac(devframe, { @@ -101,17 +95,15 @@ await createCac(devframe, { }).parse() ``` -Structured diagnostics (via `nostics`) continue to surface through their normal reporters. - ## Use your own CLI framework -To integrate devframe into an existing commander / yargs program — or to expose a different command structure than `createCac`'s `dev` / `build` / `mcp` triplet — drop down to the peer factories. Same `DevframeDefinition`, different shell: +Peer factories for a commander/yargs program: | Building block | Entry | Purpose | |----------------|-------|---------| -| [`createDevServer(def, opts?)`](./dev) | `devframe/adapters/dev` | h3 + WebSocket RPC + SPA mount | -| [`createBuild(def, opts?)`](./build) | `devframe/adapters/build` | Static deploy | -| [`createMcpServer(def, opts?)`](./mcp) | `devframe/adapters/mcp` | stdio MCP server | -| `parseCliFlags(schema, raw)` | `devframe/adapters/cac` | Validate a flag bag against a `CliFlagsSchema` | +| [`createDevServer()`](./dev) | `devframe/adapters/dev` | h3 + WebSocket RPC + SPA mount | +| [`createBuild()`](./build) | `devframe/adapters/build` | Static deploy | +| [`createMcpServer()`](./mcp) | `devframe/adapters/mcp` | stdio MCP server | +| `parseCliFlags(schema, raw)` | `devframe/adapters/cac` | Validate flags (`CliFlagsSchema`) | -See the [Standalone CLI guide](/guide/standalone-cli#use-your-own-cli-framework) for a worked commander example. +See the [Standalone CLI guide](/guide/standalone-cli#use-your-own-cli-framework). diff --git a/docs/adapters/dev.md b/docs/adapters/dev.md index 4fcd70b3..ca3201d9 100644 --- a/docs/adapters/dev.md +++ b/docs/adapters/dev.md @@ -4,7 +4,7 @@ outline: deep # Dev -The `dev` adapter is the building block `createCac` uses internally — h3 + WebSocket RPC + the author's SPA mounted at the resolved base path. Reach for it directly to mount the dev server inside an existing CLI program (commander, yargs, hand-rolled CAC) or to attach custom middleware to the underlying h3 app. +`createCac`'s building block: h3 + WebSocket RPC + the SPA at the resolved base path. ```ts import { createDevServer } from 'devframe/adapters/dev' @@ -19,48 +19,35 @@ const handle = await createDevServer(devframe, { process.on('SIGINT', () => handle.close().then(() => process.exit(0))) ``` -`createDevServer` returns the underlying `StartedServer` (origin, port, h3 app, WS server, RPC group, `close()`) so callers can integrate it into their own process lifecycle. +Returns a `StartedServer`: origin, port, h3 app, WS server, RPC group, `close()`. | Option | Default | Description | |--------|---------|-------------| | `host` | `def.cli?.host ?? 'localhost'` | Bind host. | -| `port` | resolved via `resolveDevServerPort` | Port to listen on. | -| `flags` | `{}` | Parsed flag bag forwarded to `setup(ctx, { flags })`. | -| `distDir` | `def.clientAssets` (falls back to deprecated `def.cli?.distDir`) | SPA dist override. When unset the server runs in bridge mode (meta + WS only). | -| `basePath` | `resolveBasePath(def, 'standalone')` | Mount path override. | -| `app` | fresh h3 app | Pre-configured h3 app to mount onto (custom middleware, auth, extra static assets). | -| `openBrowser` | resolves from `flags.open` / `def.cli?.open` | Explicit on/off override. `false` disables; a string opens that relative path. | -| `ws` | `def.cli?.ws` | How the browser reaches the RPC WebSocket — see below. | -| `onReady` | — | Callback when the WS server is bound. | +| `port` | resolved via `resolveDevServerPort` | Listen port. | +| `flags` | `{}` | To `setup(ctx, { flags })`. | +| `distDir` | `def.clientAssets` (falls back to deprecated `def.cli?.distDir`) | SPA dist; unset = bridge mode. | +| `basePath` | `resolveBasePath(def, 'standalone')` | Mount override. | +| `app` | fresh h3 app | Mount onto. | +| `openBrowser` | resolves from `flags.open` / `def.cli?.open` | `false` off; string opens a path. | +| `ws` | `def.cli?.ws` | RPC WebSocket — see below. | +| `onReady` | — | WS-bind callback. | ## WebSocket endpoint -By default the RPC socket shares the HTTP server's port and binds to the `__ws` route next to `__connection.json`. The descriptor advertises a *relative* path, so the client connects to its own origin — the link follows the page through a reverse proxy that rewrites the domain, port, or subpath. Configure the three connection scenarios via `def.cli.ws` (or the `ws` call-site option): - -```ts -defineDevframe({ - // 1. Same server, a custom route (default route is `__ws`): - cli: { ws: { route: '__sockets' } }, - - // 2. A dedicated port on the same host: - cli: { ws: { port: 9788 } }, - - // 3. A remote, fully-qualified endpoint (e.g. a tunnel/relay): - cli: { ws: { url: 'wss://devtools.example.com/relay/__ws' } }, -}) -``` +The RPC socket shares the HTTP port at `__ws`, advertised *relative*. Configure `def.cli.ws`: | Field | Scenario | Advertised `websocket` | |-------|----------|------------------------| -| `route` | same server, different route | `{ path: }` (same origin) | -| `port` | different port | `{ port, path: }` (page host) | -| `url` | remote, different origin | the URL string, used verbatim | +| `route` | same server, other route | `{ path: }` | +| `port` | different port | `{ port, path: }` | +| `url` | remote origin | URL verbatim | -Precedence is `url` > `port` > `route`. In the remote case the dev server still hosts the socket locally on `route`; point your tunnel at it. +Precedence `url` > `port` > `route`; `url` keeps the socket local on `route`. ## Port resolution -`resolveDevServerPort(def, opts?)` resolves a port up-front (to print or log it) before the server starts: +`resolveDevServerPort(def, opts?)` resolves a port before start. ```ts import { resolveDevServerPort } from 'devframe/adapters/dev' @@ -71,5 +58,5 @@ const port = await resolveDevServerPort(devframe, { host: '127.0.0.1' }) | Option | Default | Description | |--------|---------|-------------| -| `host` | `def.cli?.host ?? 'localhost'` | Bind host (passed to `get-port-please` for in-use detection). | -| `defaultPort` | `def.cli?.port ?? 9999` | Override the preferred port. | +| `host` | `def.cli?.host ?? 'localhost'` | Bind host (`get-port-please` detection). | +| `defaultPort` | `def.cli?.port ?? 9999` | Preferred-port override. | diff --git a/docs/adapters/embedded.md b/docs/adapters/embedded.md index ef9bf648..d297d99c 100644 --- a/docs/adapters/embedded.md +++ b/docs/adapters/embedded.md @@ -4,7 +4,7 @@ outline: deep # Embedded -Register a devframe into an already-running context at runtime. Mirrors the [`vite`](./vite) adapter's plugin-scan, but for callers that need dynamic, post-startup registration. The host decides the mount path; `embedded` is a hosted adapter and inherits the `/__/` default when one is needed. +Register a devframe into an already-running context at runtime — dynamic, post-startup registration (unlike [`vite`](./vite)'s plugin-scan). Inherits the hosted `/__/` default. ```ts import { createEmbedded } from 'devframe/adapters/embedded' @@ -15,6 +15,4 @@ await createEmbedded(devframe, { ctx: existingCtx }) | Option | Required | Description | |--------|----------|-------------| -| `ctx` | ✓ | Target `DevframeNodeContext` the devframe is registered into. | - -Useful when a host loads devframes based on runtime conditions (feature flags, user opt-in, dynamic discovery) rather than static config. +| `ctx` | ✓ | Target `DevframeNodeContext` to register into. | diff --git a/docs/adapters/index.md b/docs/adapters/index.md index d8e217d6..aaee9944 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -4,31 +4,32 @@ outline: deep # Adapters -An adapter takes a `DevframeDefinition` and deploys it into a specific runtime — a standalone CLI, a Vite plugin, a static snapshot, an embedded host, or an MCP server. Each adapter ships at its own entry point (`devframe/adapters/`); the bundler pulls in only the ones you use. +The lowest-level path is [the standard handler](./initiate), `initDevframe(def, { base })` — a Web Standard `(request: Request) => Promise` for any catch-all route. Every path below builds on it. -Every adapter factory has the shape `createXxx(devframeDef, options?)`. Some adapters draw on an optional peer dependency, installed only when you opt into that adapter: `cac` pulls in [`cac`](https://github.com/cacjs/cac), and `mcp` pulls in [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk). +Adapters wrap it as `createXxx(def, options?)` at `devframe/adapters/`. `cac` and `mcp` need an optional peer ([`cac`](https://github.com/cacjs/cac), [`@modelcontextprotocol/server`](https://github.com/modelcontextprotocol/typescript-sdk)). ## Comparison -| Adapter | Entry | Factory | Best for | +| Entry point | Module | Factory | Best for | |---------|-------|---------|----------| -| [`cac`](./cac) | `devframe/adapters/cac` | `createCac(def, options?)` | Standalone tools run via `node ./my-tool.js` | -| [`dev`](./dev) | `devframe/adapters/dev` | `createDevServer(def, options?)` | Run the dev server programmatically — drive it from any CLI framework | -| [`build`](./build) | `devframe/adapters/build` | `createBuild(def, options?)` | Offline reports, CI artifacts, deployable SPA snapshots | -| [`vite`](./vite) | `@vitejs/devtools-kit/node` | `createPluginFromDevframe(def, options?)` | Mount the definition into Vite DevTools (or any compatible host) | -| [`embedded`](./embedded) | `devframe/adapters/embedded` | `createEmbedded(def, { ctx })` | Runtime registration into an already-running host | -| [`mcp`](./mcp) | `devframe/adapters/mcp` | `createMcpServer(def, options?)` | Exposing a devframe to coding agents | +| [Standard Handler](./initiate) | `devframe/initiate` | `initDevframe(def, { base })` | Raw handler | +| [`cac`](./cac) | `devframe/adapters/cac` | `createCac()` | Standalone tools | +| [`dev`](./dev) | `devframe/adapters/dev` | `createDevServer()` | Dev server | +| [`build`](./build) | `devframe/adapters/build` | `createBuild()` | Static snapshots | +| [`vite`](./vite) | `@vitejs/devtools-kit/node` | `createPluginFromDevframe()` | Vite DevTools | +| [`embedded`](./embedded) | `devframe/adapters/embedded` | `createEmbedded(def, { ctx })` | Runtime | +| [`mcp`](./mcp) | `devframe/adapters/mcp` | `createMcpServer()` | Coding agents | ## Mount paths -A devframe's SPA basePath depends on which adapter is running it: +SPA basePath depends on the adapter: | Adapter kind | Default basePath | Reason | |--------------|------------------|--------| -| `cli`, `build` (standalone) | `/` | The devframe owns the origin. | -| `vite`, `embedded` (hosted) | `/__/` | The devframe shares the origin with a host app and namespaces itself. | +| `cli`, `build` (standalone) | `/` | Owns the origin. | +| `vite`, `embedded` (hosted) | `/__/` | Shares a host's origin. | -Override either side explicitly with `DevframeDefinition.basePath`: +Override with `DevframeDefinition.basePath`: ```ts defineDevframe({ @@ -38,4 +39,4 @@ defineDevframe({ }) ``` -SPA authors should build with relative asset paths (`vite.base: './'`); the client resolves its connection descriptor relative to the page at runtime. See [Client](/guide/client#runtime-basepath-discovery) for the discovery rules. +The client discovers its SPA base at runtime — see [Client](/guide/client#runtime-basepath-discovery). diff --git a/docs/adapters/initiate.md b/docs/adapters/initiate.md index 5849963d..be5fe74d 100644 --- a/docs/adapters/initiate.md +++ b/docs/adapters/initiate.md @@ -1,6 +1,6 @@ -# Initiate (standard middleware) +# The Standard Handler -Serve a devframe from inside any app that can mount a catch-all route: `initDevframe(def, { base })` returns a live instance whose `.handler` — a web-standard `(request: Request) => Promise` — carries the whole surface (the SPA, `__connection.json` discovery, the WebSocket RPC endpoint, the auth gate, and the optional MCP route) under one mount base. +`initDevframe()` turns a `DevframeDefinition` into a live instance whose `.handler` — a Web Standard `(request: Request) => Promise` — carries the entire surface (SPA, `__connection.json` discovery, RPC socket, auth gate, MCP route) under one mount base. Every other serving path — [adapters](./), [framework packages](/frameworks/), [hub](../guide/hub-initiate) — is assembled from it. Mount it with a catch-all route. ```ts import { initDevframe } from 'devframe/initiate' @@ -12,7 +12,7 @@ const devtools = initDevframe(myDevframe, { base: '/__my-tool/' }) // devtools.connectionMeta(), devtools.close() ``` -`base` is required, so the mount path is explicit at the call site — pass the conventional `resolveBasePath(def, 'hosted')` (i.e. `def.basePath ?? /__/`) if you don't want to pick one. The instance echoes the normalized value back as `devtools.base`, so route guards and middleware reference it instead of repeating the string. The factory is synchronous and initializes eagerly; `handler`/`nodeMiddleware` await readiness internally, so hosts never race the boot. Creating an instance binds no port on its own — [the WebSocket binding](#the-websocket-binding) is the host's call. +`base` is required — pass `resolveBasePath(def, 'hosted')` (`def.basePath ?? /__/`) to default it; the instance echoes it back as `devtools.base`. `handler`/`nodeMiddleware` await readiness internally. The instance binds no port — [the WebSocket binding](#the-websocket-binding) is the host's call. ## Mount the handler @@ -108,25 +108,25 @@ export const GET = ({ request }) => devtools.handler(request) ::: -Frameworks with dev-time module reloading (Next, Nitro, SvelteKit) re-evaluate the module that calls `initDevframe`, so memoize the instance on `globalThis` as above — otherwise every reload builds a second instance and leaks the first one's WebSocket server. `@devframes/next`'s `createDevframeNextHandler` does this for you. +Frameworks with dev-time module reloading (Next, Nitro, SvelteKit) re-evaluate the calling module, so memoize the instance on `globalThis` to avoid leaking a socket per reload. `@devframes/next`'s `createDevframeNextHandler` handles this. ## The WebSocket binding -Fetch handlers hand over `Request`s, so the RPC socket needs a binding of its own, and the host picks it explicitly. The **local binding** resolves in precedence order: +Fetch handlers only hand over `Request`s, so the host binds the RPC socket. The **local binding** resolves in this order: 1. **`ws.port`** — a side-car server on that exact port. -2. **`server`** — share the host's `node:http` server; the upgrade binds at `__ws`. Zero extra ports, and the socket follows the app through proxies and HTTPS. +2. **`server`** — share the host's `node:http` server; the upgrade binds at `__ws`. No extra ports. 3. **`ws: { sidecar: true }`** — a side-car server on a free port, for hosts whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild). -4. **The host's own upgrades** — with none of the above, the socket waits for the host to hand upgrade events over: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach function), and `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you already own. This is the tier for hosts whose server exists only after the instance does, and it builds the transport lazily — an instance nobody attaches costs nothing. +4. **The host's own upgrades** — with none set, the socket waits: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach fn); `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you own. -`ws.url` controls the *advertisement* instead: the browser dials it verbatim. On its own it means an external server owns the transport and its auth (wire the instance's `context` into that server by composing `createContextRpcServer` with a WS transport); alongside a local binding it overrides only what is advertised — the tunnel pattern, where a relay forwards to the socket bound here. +`ws.url` controls the *advertisement* instead — the browser dials it verbatim. Alone, an external server owns the transport and its auth (wire the instance's `context` via `createContextRpcServer` + a WS transport); alongside a local binding it overrides only the advertisement (the tunnel pattern). -Whichever combination is active, `__connection.json` describes it and the browser client follows. Asking a configured instance to also take over host upgrades reports `DF0055` (a local binding already owns the socket) or `DF0056` (`ws.url` handed it to someone else). +`__connection.json` describes the active combination. Asking a configured instance to take over host upgrades reports `DF0055` (a local binding owns the socket) or `DF0056` (`ws.url` handed it off). ## Auth -The instance **gates by default** — a handler mounted inside an app server is reachable by anything that can open its socket. Devframe's interactive OTP handler is wired automatically and prints its code/magic-link banner once the public origin is known (derived from the first request, or the `origin` option). Pass `auth: false` for a single-user localhost setup, or a `DevframeAuthHandler` for a custom scheme. +The instance **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner once the public origin is known (the first request, or the `origin` option). Pass `auth: false` for single-user localhost, or a `DevframeAuthHandler` for a custom scheme. ## Relation to the other adapters -`createDevServer`, `devframeViteBridge` (`@devframes/vite`), and `@devframes/next` are assembled from this instance internally — the handler is the one wiring underneath every serving path. To host **many** devframes behind one namespace with shared transport and docks, use the hub's counterpart: [`initHub`](../guide/hub-initiate). +`createDevServer`, `devframeViteBridge` (`@devframes/vite`), and `@devframes/next` are assembled from it internally. To host **many** devframes, use [`initHub`](../guide/hub-initiate). diff --git a/docs/adapters/mcp.md b/docs/adapters/mcp.md index 858ce878..45d3f9b9 100644 --- a/docs/adapters/mcp.md +++ b/docs/adapters/mcp.md @@ -4,7 +4,7 @@ outline: deep # MCP -Translates a devframe's agent host into a [Model Context Protocol](https://modelcontextprotocol.io) server so coding agents (Claude Desktop, Cursor, Zed, Claude Code) can call flagged RPCs and read exposed resources. +Exposes a devframe's agent host as a [Model Context Protocol](https://modelcontextprotocol.io) server: agents call flagged RPCs and read resources. ```ts import { createMcpServer } from 'devframe/adapters/mcp' @@ -13,11 +13,11 @@ import devframe from './devframe' await createMcpServer(devframe, { transport: 'stdio' }) ``` -`@modelcontextprotocol/server` is a peer dependency — install it when shipping MCP support. `createMcpServer` speaks the `stdio` transport, spawned per session by the client. +`@modelcontextprotocol/server` is a peer dependency; `createMcpServer` speaks `stdio`, spawned per session. ## Route-based server -The dev server can expose the same agent surface over HTTP, so an MCP client connects to the **running** server and sees live tool and resource changes. Enable it with `cli.mcp`: +The dev server exposes the same surface over HTTP, live. Enable with `cli.mcp`: ```ts import { defineDevframe } from 'devframe' @@ -30,22 +30,13 @@ export default defineDevframe({ }) ``` -The endpoint speaks the MCP Streamable-HTTP transport at `/__mcp` (relative to the base path — `/__/__mcp` under a host), sharing the dev server's origin and port. The `--mcp` and `--no-mcp` flags override the definition per run. `__connection.json` advertises the route so in-browser tooling can discover it. +The endpoint speaks Streamable-HTTP at `/__mcp` (`/__/__mcp` under a host), sharing its origin/port. `--mcp` / `--no-mcp` override; `__connection.json` advertises it. -Each client session gets its own MCP server built from the live context, correlated by the `Mcp-Session-Id` header, so `tools/list_changed` and `resources/list_changed` notifications reach connected clients as the tool evolves. The endpoint binds to the same loopback host as the dev server and applies an origin gate: a request must carry an `Origin` that is loopback (or on the configured allow-list). Unlike the WS transport it rejects `Origin`-less requests, so a route-based endpoint isn't reachable by an arbitrary local process — native clients (like `devframe connect`) send their loopback origin explicitly. Widen the gate for a tunnel or LAN origin: - -```ts -defineDevframe({ - // … - cli: { - mcp: { allowedOrigins: ['https://tunnel.example.com'] }, - }, -}) -``` +Each session gets its own MCP server, keyed by `Mcp-Session-Id`. An origin gate requires `Origin` be loopback (or allow-listed) and rejects `Origin`-less requests. Widen for a tunnel/LAN origin with `cli: { mcp: { allowedOrigins: ['https://tunnel.example.com'] } }`. ### Hosted bridges -Both hosted bridges forward the same option to their side-car dev server and advertise the endpoint (with its port) in the `__connection.json` they serve: +Both bridges forward it to their side-car dev server, advertising the endpoint in `__connection.json`: ```ts // Vite (@devframes/vite) @@ -57,7 +48,7 @@ createDevframeNextHandler(devframe, { mcp: true }) ## Custom hosts -`createMcpFetchHandler(ctx, options)` returns the endpoint as a web-standard `Request → Response` handler plus a `dispose()` for session teardown — mount it on any fetch-shaped server (a Next.js App Router route, a custom Node server). The h3 `mountMcpHttp` used by the dev server is a thin wrapper over it. +`createMcpFetchHandler(ctx, options)` returns the endpoint as a `Request → Response` handler plus a `dispose()` — mount on any fetch server. ```ts import { createMcpFetchHandler } from 'devframe/adapters/mcp' @@ -72,7 +63,7 @@ const mcp = createMcpFetchHandler(ctx, { ## Discovery: `devframe connect` -The `devframe` bin ships an MCP **connector** — a thin discovery + proxy server in the shape [next-devtools-mcp](https://github.com/vercel/next-devtools-mcp) validated. Configure it once in an agent client and it finds every running devframe: +The `devframe` bin ships an MCP **connector** ([next-devtools-mcp](https://github.com/vercel/next-devtools-mcp)-style) that finds every running devframe. Configure once: ```json { @@ -82,11 +73,11 @@ The `devframe` bin ships an MCP **connector** — a thin discovery + proxy serve } ``` -It exposes two gateway tools (the wire names of the `devframe:connect:*` ids — see [tool ids and wire names](/guide/agent-native#tool-ids-and-wire-names)): +Two gateway tools (`devframe:connect:*` ids — see [tool ids and wire names](/guide/agent-native#tool-ids-and-wire-names)): -- **`devframe_connect_list-instances`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`. -- **`devframe_connect_call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint. +- **`devframe_connect_list-instances`** — list running dev servers and their MCP tools. +- **`devframe_connect_call-tool`** — invoke one tool on an instance (`{ port, tool, args }`) over Streamable-HTTP. -Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `devframeViteBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/-.json` on boot and removes it on close; readers prune records whose liveness probe fails. The connector dials each instance's endpoint with the instance's own loopback origin, so it clears the route's origin gate without any configuration. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port ` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out. +Discovery reads the **instance registry**: every `createDevServer` writes `~/.devframe/instances/-.json`, dialed with a loopback origin. In-process hosts register via `registerDevframeInstance` (`devframe/node`). `--port ` probes a port; `DEVFRAME_INSTANCES_DIR` relocates the registry, `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts out. -See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example. +See [Agent-Native](/guide/agent-native) for the API and safety model. diff --git a/docs/adapters/vite.md b/docs/adapters/vite.md index 504ec049..6f9186c5 100644 --- a/docs/adapters/vite.md +++ b/docs/adapters/vite.md @@ -4,7 +4,7 @@ outline: deep # Vite -The Vite-DevTools adapter — wraps a `DevframeDefinition` so Vite DevTools' kit plugin-scan picks it up. The factory lives in `@vitejs/devtools-kit/node` so devframe itself stays free of any Vite or `@vitejs/*` dependency. The pattern (`definition → host plugin → mount`) is general; other hosts can implement equivalent bridges. +Wraps a `DevframeDefinition` so Vite DevTools' kit plugin-scan picks it up. The factory lives in `@vitejs/devtools-kit/node`, keeping devframe Vite-free. ```ts import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' @@ -15,11 +15,11 @@ export default function myVitePlugin() { } ``` -The returned object has the shape `{ name, devtools: { setup, capabilities } }`. Use this adapter when your devframe should live inside the Vite DevTools dock alongside other integrations. The kit synthesises an iframe dock entry from the definition's `id` / `name` / `icon` / `basePath`; for richer host-side behaviour (extra terminals, commands, dock overrides) pass `options.setup`. See the [DevTools Kit → DevTools Plugin](https://devtools.vite.dev/kit/devtools-plugin) page for the Vite-specific guide. +The returned object has the shape `{ name, devtools: { setup, capabilities } }`. See [DevTools Plugin](https://devtools.vite.dev/kit/devtools-plugin). | Option | Default | Description | |--------|---------|-------------| -| `name` | `devframe:` | Override the Vite plugin name. | +| `name` | `devframe:` | Plugin name. | | `base` | `def.basePath ?? /.${id}/` | Mount path override. | -| `dock` | `{}` | Overrides for the synthesized iframe dock entry (category, icon, when). | -| `setup` | — | Additional host-only setup hook; receives the kit-augmented context (Vite DevTools' `docks`, `terminals`, `messages`, `commands`). | +| `dock` | `{}` | Overrides for the iframe dock entry (category, icon, when). | +| `setup` | — | Host-only setup hook; receives the kit-augmented context. | diff --git a/docs/examples/built-with.md b/docs/examples/built-with.md deleted file mode 100644 index c1ef055a..00000000 --- a/docs/examples/built-with.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -outline: deep ---- - -# Built with Devframe - -Real-world devframes: - -- [**Vite DevTools**](https://devtools.vite.dev/) — the host that bundles multiple devframes into one UI (docks, command palette, terminals). Mount your own definition into it via the [`vite` adapter](/adapters/vite). -- [**ESLint Config Inspector**](https://github.com/eslint/config-inspector) — official ESLint tool for inspecting flat configs. -- [**node-modules-inspector**](https://github.com/antfu/node-modules-inspector) — interactive visualizer for your `node_modules` dependency graph. - -End-to-end examples in this repo, exercising the full adapter surface: - -- [**files-inspector**](https://github.com/devframes/devframe/tree/main/examples/files-inspector) — lists files in cwd via RPC; exercises CLI dev/build surfaces. -- [**streaming-chat**](https://github.com/devframes/devframe/tree/main/examples/streaming-chat) — streams synthetic chat tokens from server to client via `ctx.rpc.streaming`. -- [**next-runtime-snapshot**](https://github.com/devframes/devframe/tree/main/examples/next-runtime-snapshot) — Next.js App Router SPA over RPC, surfacing the host Node runtime (system info, memory, env). diff --git a/docs/examples/files-inspector.md b/docs/examples/files-inspector.md deleted file mode 100644 index 03729c19..00000000 --- a/docs/examples/files-inspector.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -outline: deep ---- - -# files-inspector - -Lists the files in the current working directory and renders them through a **Preact** SPA. A node-modules-inspector-style demo that exercises every devframe surface end to end. - -Package: `files-inspector-example` · framework: **Preact + Vite** - -## What it shows - -- **CLI dev server** — `node bin.mjs` boots an HTTP + WebSocket server backing live RPC. -- **Static build** — `node bin.mjs build` produces a self-contained directory (SPA + baked RPC dump) deployable to any static host. -- **Runtime base discovery** — the client is built with `vite.base: './'` and reads `document.baseURI` at runtime, so the same `dist/client` works under any base path without rebuilding. -- **Two RPC types** — `:list-files` is a `query` baked into the dump; `:get-cwd` is a `static` RPC. - -## Run it - -```sh -pnpm -C examples/files-inspector run build # build the Preact client -pnpm -C examples/files-inspector run dev # CLI dev server (live RPC) -pnpm -C examples/files-inspector run cli:build # static deploy → dist/static -``` - -The dev server prints its URL. Serve `dist/static` from any static host — relative asset paths make it portable. - -## Source - -[`examples/files-inspector`](https://github.com/devframes/devframe/tree/main/examples/files-inspector) diff --git a/docs/examples/hub-deno-minimal.md b/docs/examples/hub-deno-minimal.md deleted file mode 100644 index e56dd629..00000000 --- a/docs/examples/hub-deno-minimal.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -outline: deep ---- - -# hub-deno-minimal - -The minimal [Deno](https://deno.com) host for [`@devframes/hub`](/guide/hub): one `initHub()` call served through `Deno.serve`, the UI supplied by `@devframes/hub-ui`. - -Package: `hub-deno-minimal` · framework: **Deno** - -## What it shows - -- `initHub({ base, devframes, ui: createUi() })` in `src/hub.ts`, memoized on `globalThis`. No transport option, so the entry wires the socket itself. -- `Deno.serve(options, handler)` serves HTTP (web `Request` → `Response`), and the whole namespace flows through `hub.handler(request)`. -- WebSockets arrive as fetch upgrades, so `src/server.ts` binds Deno's transport with `createContextRpcServer` + `attachDenoWsTransport` (crossws' Deno adapter) and answers `${hub.base}__ws` on the app's own origin. crossws attaches the socket to the `Response` its `handleUpgrade` returns, so there is no separate `websocket` handler object. - -## Run it - -```sh -pnpm install -pnpm --filter hub-deno-minimal dev -``` - -## Source - -[`examples/hub-deno-minimal`](https://github.com/devframes/devframe/tree/main/examples/hub-deno-minimal) diff --git a/docs/examples/hub-fastify-minimal.md b/docs/examples/hub-fastify-minimal.md deleted file mode 100644 index d762f8db..00000000 --- a/docs/examples/hub-fastify-minimal.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -outline: deep ---- - -# hub-fastify-minimal - -The minimal [Fastify](https://fastify.dev) host for [`@devframes/hub`](/guide/hub): one `initHub()` call mounted through Fastify's connect-middleware layer, the UI supplied by `@devframes/hub-ui`. - -Package: `hub-fastify-minimal` · framework: **Fastify** - -## What it shows - -- `initHub({ base, devframes, ui: createUi() })` in `src/hub.ts`, memoized on `globalThis`. No transport option, so the socket rides Fastify's own server. -- Fastify is the `nodeMiddleware` host: `src/server.ts` registers `hub.nodeMiddleware` — the same `(req, res, next)` shape Vite's dev server consumes — through [`@fastify/middie`](https://github.com/fastify/middie). Requests under `${hub.base}` are served by the hub; the rest fall through `next()` to Fastify's routes. -- `hub.attach(fastify.server)` routes the HTTP server's upgrade events to the RPC socket at `${hub.base}__ws`, on the app's own origin — no side-car port. - -## Run it - -```sh -pnpm install -pnpm --filter hub-fastify-minimal dev -``` - -## Source - -[`examples/hub-fastify-minimal`](https://github.com/devframes/devframe/tree/main/examples/hub-fastify-minimal) diff --git a/docs/examples/hub-hono-minimal.md b/docs/examples/hub-hono-minimal.md deleted file mode 100644 index c166f6ad..00000000 --- a/docs/examples/hub-hono-minimal.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -outline: deep ---- - -# hub-hono-minimal - -The minimal [Hono](https://hono.dev) host for [`@devframes/hub`](/guide/hub): one `initHub()` call behind a catch-all route, running on **Node and Bun** from the same app file, the UI supplied by `@devframes/hub-ui`. - -Package: `hub-hono-minimal` · framework: **Hono** - -## What it shows - -- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` in `src/app.ts` plus `app.all(\`${hub.base}*\`, c => hub.handler(c.req.raw))`. No transport option, so each runtime's entry wires the socket its own way — both landing on `${hub.base}__ws`, the app's own origin. -- On Node (`src/server.ts`), `@hono/node-server`'s `serve()` returns the `node:http` server and `hub.attach(server)` takes its upgrade events. -- On Bun (`src/bun.ts`), upgrades arrive as fetch requests, so the entry binds Bun's transport with `createContextRpcServer` + `attachBunWsTransport` inside `Bun.serve({ fetch, websocket })`. The repo's `scripts/smoke-bun.ts` exercises this path end to end. - -## Run it - -```sh -pnpm install -pnpm --filter hub-hono-minimal dev # Node -pnpm --filter hub-hono-minimal dev:bun # Bun -``` - -## Source - -[`examples/hub-hono-minimal`](https://github.com/devframes/devframe/tree/main/examples/hub-hono-minimal) diff --git a/docs/examples/hub-next-minimal.md b/docs/examples/hub-next-minimal.md deleted file mode 100644 index 44192dd8..00000000 --- a/docs/examples/hub-next-minimal.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -outline: deep ---- - -# hub-next-minimal - -The minimal [Next.js](https://nextjs.org) host for [`@devframes/hub`](/guide/hub): one `initHub()` call on an App Router catch-all route, the UI supplied by `@devframes/hub-ui`. - -Package: `hub-next-minimal` · framework: **React (Next.js)** - -## What it shows - -- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` behind one route (`app/%5F_devframes/[[...path]]/route.ts`) delegating to `hub.handler(request)`. -- The plugins and `@devframes/hub-ui` load via a bundler-ignored dynamic `import()`, so Next resolves their published `dist` at runtime (their `import.meta.url` asset lookups don't survive static bundling). -- Next route handlers can't accept WebSocket upgrades, so `ws: { sidecar: true }` gives the socket its own port, advertised through `__connection.json`; the instance is memoized on `globalThis` so a dev-time reload reuses it. - -## Run it - -```sh -pnpm install -pnpm --filter hub-next-minimal dev -``` - -Open the printed URL for the host page with the floating dock, or `/__devframes/` for the standalone viewer. - -## Source - -[`examples/hub-next-minimal`](https://github.com/devframes/devframe/tree/main/examples/hub-next-minimal) diff --git a/docs/examples/hub-next.md b/docs/examples/hub-next.md deleted file mode 100644 index 6c6c5bd4..00000000 --- a/docs/examples/hub-next.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -outline: deep ---- - -# hub-next - -The same hub protocol as the [Vite host](./hub-vite), hosted from a **Next.js** App Router app with a hand-built React viewer — proof that the hub is host-runtime-agnostic. - -Package: `hub-next` · framework: **React (Next.js)** - -## What it proves - -- `initHub({ base, devframes, configure })` boots the whole hub from one call; a single App Router catch-all route (`app/%5F_devframes/[[...path]]/route.ts`) delegates to `hub.handler(request)`. -- Next route handlers can't accept WebSocket upgrades, so `ws: { sidecar: true }` gives the socket its own port, advertised through `__connection.json`; the instance is memoized on `globalThis` so a dev-time reload reuses it. -- The [JSON-render](/guide/json-render) hub integration with **registry replacement**: the React client renders the server-authored view with a small in-example React registry (rather than the Vue `@devframes/json-render-ui`) — the path a non-Vue host uses. -- [Client-only docks](/guide/client-context#client-only-docks) the page registers itself with `context.docks.register()`. - -For the minimal counterpart — the hub UI supplied by `@devframes/hub-ui` instead of a hand-built viewer — see [hub-next-minimal](./hub-next-minimal). - -## Run it - -```sh -pnpm install -pnpm --filter hub-next dev -``` - -Open the printed URL to see the docks, commands, messages, and terminals the hub exposes. - -## Source - -[`examples/hub-next`](https://github.com/devframes/devframe/tree/main/examples/hub-next) diff --git a/docs/examples/hub-nitro-minimal.md b/docs/examples/hub-nitro-minimal.md deleted file mode 100644 index 98949b92..00000000 --- a/docs/examples/hub-nitro-minimal.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -outline: deep ---- - -# hub-nitro-minimal - -The minimal [Nitro](https://nitro.build) host for [`@devframes/hub`](/guide/hub): one `initHub()` call behind a catch-all route, the UI supplied by `@devframes/hub-ui`. - -Package: `hub-nitro-minimal` · framework: **Nitro** - -## What it shows - -- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` in `hub.ts`, delegated to by a catch-all route (`routes/__devframes/[...path].ts`, plus its `index.ts` sibling for the namespace root) via `hub.handler(event.req)`. -- `nitro.config.ts` keeps the devframe packages external so their prebuilt client assets resolve from the packages themselves rather than Nitro's build output. -- Nitro handlers hand over `Request`s, so `ws: { sidecar: true }` puts the RPC WebSocket on its own port, advertised through `__connection.json`. - -## Run it - -```sh -pnpm install -pnpm --filter hub-nitro-minimal dev -``` - -Open the printed URL for the host page with the floating dock, or `/__devframes/` for the standalone viewer. - -## Source - -[`examples/hub-nitro-minimal`](https://github.com/devframes/devframe/tree/main/examples/hub-nitro-minimal) diff --git a/docs/examples/hub-rsbuild-minimal.md b/docs/examples/hub-rsbuild-minimal.md deleted file mode 100644 index a5039c1a..00000000 --- a/docs/examples/hub-rsbuild-minimal.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -outline: deep ---- - -# hub-rsbuild-minimal - -The minimal [Rsbuild](https://rsbuild.dev) host for [`@devframes/hub`](/guide/hub): one `initHub()` call mounted into the dev server's middleware stack, the UI supplied by `@devframes/hub-ui`. - -Package: `hub-rsbuild-minimal` · framework: **Rsbuild** - -## What it shows - -- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` created inside `server.setup` in `rsbuild.config.ts` — lazily, so importing the config never spawns the hub's side-car, and reused across re-runs. -- `server.setup` registers `hub.nodeMiddleware`, which owns the `/__devframes/` namespace and hands everything else back to Rsbuild. -- Rsbuild's middleware stack never hands over upgrades, so `ws: { sidecar: true }` puts the RPC WebSocket on its own port, advertised through `__connection.json`; `html.tags` injects the `${hub.base}embedded.js` bootstrap. - -## Run it - -```sh -pnpm install -pnpm --filter hub-rsbuild-minimal dev -``` - -Open the printed URL for the host page with the floating dock, or `/__devframes/` for the standalone viewer. - -## Source - -[`examples/hub-rsbuild-minimal`](https://github.com/devframes/devframe/tree/main/examples/hub-rsbuild-minimal) diff --git a/docs/examples/hub-sveltekit-minimal.md b/docs/examples/hub-sveltekit-minimal.md deleted file mode 100644 index 3c099d33..00000000 --- a/docs/examples/hub-sveltekit-minimal.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -outline: deep ---- - -# hub-sveltekit-minimal - -The minimal [SvelteKit](https://svelte.dev/docs/kit) host for [`@devframes/hub`](/guide/hub): one `initHub()` call behind a single catch-all endpoint, the UI supplied by `@devframes/hub-ui`. - -Package: `hub-sveltekit-minimal` · framework: **SvelteKit** - -## What it shows - -- `initHub({ base, devframes, ui: createUi() })` in `src/hub.ts`, memoized on `globalThis`. The RPC socket runs on a side-car port (`ws: { sidecar: true }`) advertised via `__connection.json` — SvelteKit's `+server.ts` handlers hand over `Request`s and never see WebSocket upgrades, so the hub takes a socket of its own. -- `src/routes/__devframes/[...path]/+server.ts` mounts the whole namespace: `fallback` answers every method with `hub.handler(event.request)`, and the `[...path]` rest param matches the namespace root as well as everything beneath it. -- The endpoint exports `trailingSlash = 'ignore'` so SvelteKit serves the hub's trailing-slash URLs (the standalone viewer and each frame SPA) verbatim instead of 308-redirecting them, and `src/app.html` injects `${hub.base}embedded.js` to mount the floating dock. - -## Run it - -```sh -pnpm install -pnpm --filter hub-sveltekit-minimal dev -``` - -## Source - -[`examples/hub-sveltekit-minimal`](https://github.com/devframes/devframe/tree/main/examples/hub-sveltekit-minimal) diff --git a/docs/examples/hub-vite-minimal.md b/docs/examples/hub-vite-minimal.md deleted file mode 100644 index ac177fa2..00000000 --- a/docs/examples/hub-vite-minimal.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -outline: deep ---- - -# hub-vite-minimal - -The minimal [Vite](https://vite.dev) host for [`@devframes/hub`](/guide/hub): one `initHub()` call mounted as dev middleware, the UI supplied by `@devframes/hub-ui`. No hand-built viewer — the whole integration is the config file. - -Package: `hub-vite-minimal` · framework: **Vite** - -## What it shows - -- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` in `vite.config.ts` — runs in Vite's Node config process, never bundled into the browser. -- `server.middlewares.use(hub.nodeMiddleware)` mounts the whole `/__devframes/` namespace; the WebSocket upgrade shares Vite's own server at `__ws`. -- `transformIndexHtml` injects ` ``` -Or from a composable: +Or a composable: ```ts [composables/usePayload.ts] export function usePayload() { @@ -55,12 +46,12 @@ export default defineNuxtConfig({ }) ``` -- **`baseURL`** defaults to `'./'`, which resolves against `document.baseURI` at runtime. The connection meta and dump shards sit next to `index.html`, so the same build works at any deployment path. -- **`skipAppDefaults: true`** disables the `app.baseURL: './'` / `vite.base: './'` defaults. Use this when you're shipping with absolute asset paths and have your own base-URL story. +- **`baseURL`** resolves against `document.baseURI` at runtime. +- **`skipAppDefaults: true`** ships absolute asset paths. ## Dev-time RPC bridge -Pass your devframe definition to wire `nuxt dev` up to the RPC backend: +Pass a devframe definition: ```ts [nuxt.config.ts] import devframe from './src/devframe' // defineDevframe(...) export @@ -70,14 +61,14 @@ export default defineNuxtConfig({ }) ``` -That's the full setup. Behind the scenes, `nuxt dev` now: +`nuxt dev` now: -- Starts a separate WebSocket RPC server on a port resolved via [`get-port-please`](https://github.com/unjs/get-port-please) (respects `devframe.cli.port` / `portRange` / `random`). -- Registers Vite middleware at `${baseURL}__connection.json` so the SPA reads it on load. -- Runs `devframe.setup(ctx, { flags })` once the bridge is up, registering your RPC functions. -- Cleans up the bridge on Vite restart, `nuxt dev` shutdown, and bundle close. +- Starts a WebSocket RPC server on a [`get-port-please`](https://github.com/unjs/get-port-please) port (respects `devframe.cli.port` / `portRange` / `random`). +- Registers Vite middleware at `${baseURL}__connection.json`. +- Runs `devframe.setup(ctx, { flags })`. +- Cleans up on Vite restart, `nuxt dev` shutdown, and bundle close. -The bridge is **on by default** whenever `devframe` is set. Skip it (back to client-only) with `devMiddleware: false`. +On by default when `devframe` is set; disable it (client-only) with `devMiddleware: false`. ### Customizing the bridge @@ -94,13 +85,13 @@ export default defineNuxtConfig({ }) ``` -- **`port`** pins the bridge port. Skip it to let `get-port-please` pick a free port. -- **`host`** controls the bridge bind host. Defaults to `nuxt.options.devServer.host ?? devframe.cli?.host ?? 'localhost'`, so `nuxt dev --host` propagates automatically. Set this manually when your Nuxt server config doesn't surface `host` (e.g. custom listen options). -- **`flags`** is forwarded to `devframe.setup(ctx, { flags })`. Use it to pass env-derived configuration into the RPC layer. +- **`port`** pins the bridge port (else `get-port-please` picks one). +- **`host`** defaults to `nuxt.options.devServer.host ?? devframe.cli?.host ?? 'localhost'`. +- **`flags`** → `devframe.setup(ctx, { flags })`. ### Relationship to `createCac` -The bridge handles the **dev workflow**. Production deploys still go through `createCac` (or `createBuild`), which produces a static `__connection.json` + `__rpc-dump/` snapshot from `clientAssets`: +Production uses `createCac` (or `createBuild`), producing a static `__connection.json` + `__rpc-dump/` snapshot from `clientAssets`: ``` my-tool/ @@ -113,26 +104,20 @@ my-tool/ └── public/ # Nuxt build output, pointed at by clientAssets ``` -In dev (`nuxt dev`) the bridge is live. In production (` build`) the SPA loads the static dump. - ## How it works -At build time the module: +At build time it sets the `app.baseURL` / `vite.base` defaults, merges `{ devframe: { baseURL } }` into `runtimeConfig.public`, and injects a client-only plugin (`helpers/nuxt/runtime/plugin.client`): -- Sets `nuxt.options.app.baseURL` to `'./'` (unless already set) -- Sets `nuxt.options.vite.base` to `'./'` (unless already set) -- Merges `{ devframe: { baseURL } }` into `runtimeConfig.public` -- Injects a client-only plugin (`helpers/nuxt/runtime/plugin.client`) that: - ```ts - const rpc = await connectDevframe({ baseURL: config.public.devframe.baseURL }) - return { provide: { rpc } } - ``` +```ts +const rpc = await connectDevframe({ baseURL: config.public.devframe.baseURL }) +return { provide: { rpc } } +``` -At runtime the built SPA fetches `./__connection.json` (resolved against `document.baseURI`) and branches on the `backend` field — `websocket` in dev, `static` from a `createBuild` snapshot. +At runtime the SPA fetches `./__connection.json` and branches on `backend` — `websocket` in dev, `static` from a `createBuild` snapshot. ## Mounting a hub -`@devframes/nuxt/hub` mounts a whole [devframes-hub](/guide/hub) — many integrations under one namespace — alongside `nuxt dev`, wiring `@devframes/vite`'s hub plugin into Nuxt's Vite dev server and injecting `@devframes/hub-ui`'s floating dock. The UI defaults to `@devframes/hub-ui`; pass `ui` to swap it or `ui: false` for a headless hub you drive with `@devframes/nuxt/hub/client`. +`@devframes/nuxt/hub` mounts a whole [devframes-hub](/guide/hub) alongside `nuxt dev`, injecting `@devframes/hub-ui`'s dock. `ui` swaps the default; `ui: false` gives a headless hub via `@devframes/nuxt/hub/client`. ```ts [nuxt.config.ts] export default defineNuxtConfig({ @@ -140,10 +125,10 @@ export default defineNuxtConfig({ }) ``` -Nuxt DevTools (`@nuxt/devtools`) integrates the same hub protocol natively and is the recommended path for a Nuxt app, so this module prints a one-time recommendation to that effect (silence it with `{ quiet: true }`). +Nuxt DevTools (`@nuxt/devtools`) integrates the same protocol natively; this module recommends it once (silence with `{ quiet: true }`). ## See also -- [Standalone CLI recipe](/guide/standalone-cli) — end-to-end walk-through +- [Standalone CLI recipe](/guide/standalone-cli) - [Client](/guide/client) — `connectDevframe` reference -- [Adapters](/adapters/) — CLI / Vite / Build / Embedded / MCP +- [Adapters](/adapters/) diff --git a/docs/frameworks/vite.md b/docs/frameworks/vite.md index c49e7b7e..cfd99d48 100644 --- a/docs/frameworks/vite.md +++ b/docs/frameworks/vite.md @@ -4,11 +4,9 @@ outline: deep # Vite -`@devframes/vite` splits into two scopes: **`@devframes/vite/single`** (this page — dev-serve one devframe's SPA with Vite) and [**`@devframes/vite/hub`**](#mounting-a-hub) (mount a whole devframes-hub inside a Vite app). The bare `@devframes/vite` import throws with a pointer to both. +`@devframes/vite` splits into **`@devframes/vite/single`** (dev-serve one devframe's SPA) and [**`@devframes/vite/hub`**](#mounting-a-hub) (mount a devframes-hub); the bare import throws. -The `single` scope exports two Vite plugins for mounting a single devframe inside an existing Vite dev server — `devframeVitePlugin` (static mount) and `devframeViteBridge` (RPC bridge) — plus `devframeVite`, a convenience wrapper that picks between them. Used by [`@devframes/nuxt`](./nuxt) and available for any Vite-based host (Astro, SolidStart, plain Vite apps). - -This sits below the [`vite` adapter](/adapters/vite) on the abstraction ladder: the adapter targets the full Vite DevTools dock; these are the lower-level Vite plugins you reach for when you want a devframe to ride along with an existing app's dev server without the DevTools dock. +`single` exports `devframeVitePlugin`, `devframeViteBridge`, and `devframeVite`; also used by [`@devframes/nuxt`](./nuxt). ```ts import { devframeViteBridge, devframeVitePlugin } from '@devframes/vite/single' @@ -26,36 +24,33 @@ export default defineConfig({ ## `devframeVitePlugin` — static mount -Mounts `def.clientAssets` 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. `clientAssets` may be a local directory or a [remote assets](/guide/client-assets) package. +Mounts `def.clientAssets` at `options.base` (`/__/` default) with SPA fallback. `clientAssets` accepts a local directory or [remote assets](/guide/client-assets). | Option | Default | Description | |--------|---------|-------------| -| `base` | `def.basePath ?? '/__/'` | Mount path inside the Vite dev server. | +| `base` | `def.basePath ?? '/__/'` | Mount path. | ## `devframeViteBridge` — RPC bridge -Skips the static mount — the host app owns the SPA. Devframe spawns a separate RPC + WS server and registers Vite middleware at `__connection.json` so the host-served SPA can discover the WS endpoint. The side-car listens on its own port unless it can share Vite's own HTTP server, so the descriptor carries that port alongside the `/__ws` route. - -To mount the RPC socket onto the Vite server's own port instead of a side-car — so it shares the origin with the app and rides through a proxy — pass Vite's HTTP server to [`initDevframe`](/adapters/initiate) / `initHub` via the `server` option. Devframe binds only its own `__ws` upgrade route and leaves the rest (Vite's HMR socket included) untouched. +Devframe spawns a separate RPC + WS server and registers Vite middleware at `__connection.json`. To share Vite's port, pass its HTTP server to [`initDevframe`](/adapters/initiate) / `initHub` via `server`. | Option | Default | Description | |--------|---------|-------------| -| `base` | `def.basePath ?? '/__/'` | Mount path inside the Vite dev server. | -| `port` | share Vite's HTTP server | Pin a side-car port for the RPC socket instead. | +| `base` | `def.basePath ?? '/__/'` | Mount path. | +| `port` | share Vite's HTTP server | Pin a side-car RPC port instead. | | `host` | `def.cli?.host ?? 'localhost'` | Bind host for a pinned side-car. | -| `flags` | — | Forwarded to `def.setup(ctx, { flags })`. | -| `auth` | gated (interactive OTP) | `false` to opt out for a single-user localhost host, or a `DevframeAuthHandler` for a custom scheme. | -| `mcp` | `def.cli?.mcp` | `true` or `McpRouteOptions` to expose the route-based MCP server at `__mcp`. | - -`port` / `host` / `flags` mirror [`createDevServer`](/adapters/dev)'s options of the same name. +| `flags` | — | To `def.setup(ctx, { flags })`. | +| `auth` | gated (interactive OTP) | `false` to opt out, or a `DevframeAuthHandler` for a custom scheme. | +| `mcp` | `def.cli?.mcp` | `true` or `McpRouteOptions` to expose the MCP route at `__mcp`. | ## `devframeVite` — convenience wrapper -`devframeVite(def, { bridge, ...bridgeOptions })` forwards to `devframeViteBridge` when `bridge: true`, or `devframeVitePlugin` otherwise — handy when a single call site needs to switch between the two modes. Reach for the two plugins directly when a devframe needs both mounted at once (e.g. a bridge for RPC alongside a static mount serving its own bundled UI, as the built-in `terminals`/`code-server` plugins do). +`devframeVite(def, { bridge, ...opts })` forwards to `devframeViteBridge` when `bridge: true`, else `devframeVitePlugin`; use them directly when a devframe needs both. ## Mounting a hub -`@devframes/vite/hub` mounts a whole [devframes-hub](/guide/hub) — many integrations under one namespace, one merged RPC registry — inside a Vite dev server with one `viteDevframeHub()` plugin. It wraps `initHub`, shares Vite's HTTP server for the WebSocket, defaults the dock UI to `@devframes/hub-ui` (injecting its `embedded.js` bootstrap into the host page), and mounts everything as connect middleware. +`@devframes/vite/hub` mounts a [devframes-hub](/guide/hub) with `viteDevframeHub()`: wraps `initHub`, shares Vite's HTTP server, defaults dock UI to `@devframes/hub-ui`. + ```ts import { viteDevframeHub } from '@devframes/vite/hub' @@ -66,4 +61,4 @@ export default defineConfig({ }) ``` -Pass `ui` to swap the viewer or `ui: false` for a headless hub you drive with the client helper at `@devframes/vite/hub/client` (`mountDevframeHubClient()`). Vite DevTools (`@vitejs/devtools-kit`) integrates the same hub protocol natively and is the recommended path for a Vite app, so this plugin prints a one-time recommendation to that effect (silence it with `{ quiet: true }`). +Pass `ui` to swap the viewer, `ui: false` for headless (via `@devframes/vite/hub/client`'s `mountDevframeHubClient()`). Vite DevTools (`@vitejs/devtools-kit`) supports this natively; recommended once (`{ quiet: true }` to silence). diff --git a/docs/guide/agent-native.md b/docs/guide/agent-native.md index aaeb637c..6ba4f9c8 100644 --- a/docs/guide/agent-native.md +++ b/docs/guide/agent-native.md @@ -4,15 +4,11 @@ outline: deep # Agent-Native Devframe -Devframe can expose the same surface a browser UI consumes — RPC functions, resources, and shared state — to coding agents (Claude Desktop / Cursor / Zed / Claude Code, or any MCP-speaking client). Agent exposure is opt-in per function; functions stay private by default. +Devframe exposes its browser-UI surface — RPC functions, resources, shared state — to agents over MCP, opt-in per function. ## How it works -Three building blocks: - -1. **An `agent` field on `defineRpcFunction`.** Add `agent: { description, ... }` to opt a function in. Functions without the field stay private. -2. **`ctx.agent`** — a host exposed on `DevframeNodeContext`. Plugins register tools that aren't backed by an RPC, and expose readable resources (e.g. a Markdown build summary). -3. **The MCP adapter** (`devframe/adapters/mcp`) — translates the agent host into a [Model Context Protocol](https://modelcontextprotocol.io) server, over `stdio` (`devframe mcp`) or as a Streamable-HTTP route on the dev server (`--mcp`, advertised in `__connection.json`). +Three pieces: the **`agent` field** on `defineRpcFunction`, **`ctx.agent`** (non-RPC tools + resources), and the **MCP adapter** (`devframe/adapters/mcp`) serving an [MCP](https://modelcontextprotocol.io) server. ## Exposing an RPC function @@ -37,14 +33,10 @@ export const getSessionSummary = defineRpcFunction({ }) ``` -Agent tools take a single object input. The MCP adapter synthesises `arg0`, `arg1`, … from positional args (`args: [A, B]`); a single object schema (`args: [v.object({ ... })]`) reads better at the agent boundary because property names are self-describing. - ## Tool ids and wire names -Every agent tool has two names: - -- **The id** — how the tool is registered and invoked inside devframe. Ids are colon-namespaced by convention: `devframes:plugin::` for plugin RPCs, `devframe::` for built-ins, and command ids for hub-command-derived tools. -- **The wire name** — what MCP clients see and call. Clients constrain tool names to `^[a-zA-Z0-9_-]{1,128}$`, so the MCP adapter derives the wire name automatically: every run of characters outside `[a-zA-Z0-9_-]` becomes a single `_`, truncated to 128 characters. +- **The id** — registers/invokes in devframe, colon-namespaced: `devframes:plugin::` (plugin RPCs), `devframe::` (built-ins), command ids. +- **The wire name** — what MCP clients call, constrained to `^[a-zA-Z0-9_-]{1,128}$`; runs outside that set collapse to `_`, truncated to 128. ``` devframe:state:read → devframe_state_read @@ -52,11 +44,11 @@ devframes:plugin:git:status → devframes_plugin_git_status my-plugin:summarize → my-plugin_summarize ``` -The convention applies uniformly to `agent`-flagged RPCs, tools registered via `registerTool` / `registerToolProvider`, and the hub's command-derived tools — keep registering with namespaced ids and let the boundary derive the name. `toAgentToolName` (from `devframe/utils/agent-tool-name` — a plain string transform, safe to import client-side too, e.g. from a UI that displays a tool's id) computes the mapping when you need to predict a wire name (e.g. in a client config, a test, or an inspector view). Calls resolve back to the id at the boundary; two ids that sanitize to the same wire name keep the first registration and hide the later one with a `DF0047` warning. +`toAgentToolName` (`devframe/utils/agent-tool-name`, client-safe) predicts a wire name; two ids sanitizing alike keep the first, the later hidden with `DF0047`. ## Registering a plugin tool -For tools without a matching RPC — say, an on-demand narrative summary — register them directly: +Tools without a matching RPC register directly. ```ts export default defineDevframe({ @@ -76,7 +68,7 @@ export default defineDevframe({ ## Deriving tools from other state -When tools derive from state you already maintain — a command registry, a plugin catalog — register a **provider** instead of mirroring registrations. The host queries it at list/invoke time (the same lazy projection it applies to `agent`-flagged RPCs), so your source of truth stays the only copy: +Register a **provider** for tools derived from state, queried at list/invoke time: ```ts const handle = ctx.agent.registerToolProvider(() => @@ -89,11 +81,9 @@ const handle = ctx.agent.registerToolProvider(() => handle.notifyChanged() // fires tools/list_changed ``` -The hub's commands host uses exactly this to project agent-flagged palette commands. - ## Registering a resource -Resources surface readable snapshots of state, identified by URI: +Readable snapshots by URI: ```ts ctx.agent.registerResource({ @@ -105,20 +95,18 @@ ctx.agent.registerResource({ }) ``` -Every `ctx.rpc.sharedState` key is also automatically exposed to MCP as `devframe://state/`. Pass `exposeSharedState: false` (or a filter function) to `createMcpServer` to opt out. - -Shared state is additionally reachable through the built-in **`devframe:state:read` tool** (wire name `devframe_state_read`) — call it without arguments for the key list, with a `key` for that value — since many MCP clients only consume tools. It honors the same `exposeSharedState` filter as the resource projection. +Every `ctx.rpc.sharedState` key is exposed as a `devframe://state/` resource and via the **`devframe:state:read` tool** (wire `devframe_state_read`): no args → key list, `key` → its value. `exposeSharedState: false` (or a filter) on `createMcpServer` opts out. ## Starting the MCP server -The simplest path is the CLI: +CLI: ```sh # Run your devtool with an MCP stdio server attached. devframe mcp ``` -Programmatic equivalent: +Programmatically: ```ts import { defineDevframe } from 'devframe' @@ -129,11 +117,11 @@ const devframe = defineDevframe({ /* … */ }) await createMcpServer(devframe, { transport: 'stdio' }) ``` -`@modelcontextprotocol/server` is a peer dependency — add it to your package when you want to ship an MCP-enabled devframe. +`@modelcontextprotocol/server` is a peer dependency. ## Connecting Claude Desktop -Add an entry to `claude_desktop_config.json`: +In `claude_desktop_config.json`: ```json { @@ -146,11 +134,11 @@ Add an entry to `claude_desktop_config.json`: } ``` -Restart Claude Desktop. The tools you flagged with `agent: { ... }` (plus any `registerTool` calls) show up in the MCP tool drawer. Resources are reachable as `devframe://resource/` and `devframe://state/` URIs. +Restart; tools appear in the drawer, resources as `devframe://resource/` / `devframe://state/` URIs. ## Writing descriptions agents act on -A tool description is a prompt, not documentation. The agent decides *when* to call your tool from the description alone, so tell it — state when to reach for the tool, not just what it returns: +Describe *when* to use a tool, not just its return: @@ -161,14 +149,9 @@ agent: { description: 'Returns the session summary object.' } agent: { description: 'Summarize the current build session — durations, chunk counts, warnings. Call this before proposing any build-config change.' } ``` -Two conventions: - -- **Lead with the action and the trigger.** "Call this before/after/when …" steers proactive use; a bare noun phrase gets ignored. -- **State freshness and cost.** "Safe to call freely" / "expensive, call once per session" lets the agent budget calls. - ## Gateway tools -A gateway tool returns *instructions and locations* instead of doing the work — the pattern for anything the agent can do better directly (reading bundled docs, running a CLI it has shell access to): +A gateway tool returns *instructions and locations*, not work agents do better: ```ts ctx.agent.registerTool({ @@ -182,28 +165,25 @@ ctx.agent.registerTool({ }) ``` -The agent gets a path and a next step; the actual reading happens with its own tools, which are faster and keep large content out of the MCP payload. - ## Structured errors -A coded devframe diagnostic thrown from a tool handler crosses the MCP boundary as structured JSON rather than a flattened message: +A coded diagnostic thrown from a handler crosses the MCP boundary as JSON: ```json { "error": { "code": "DF0017", "message": "…", "fix": "…", "docs": "https://devfra.me/errors/df0017" } } ``` -Agents can act on `fix` directly and follow `docs` for detail — prefer throwing coded diagnostics from anything agent-reachable. +Prefer coded diagnostics anywhere agent-reachable: agents act on `fix` and follow `docs`. ## Safety model -- **Opt-in exposure.** Functions opt in via the `agent` field; everything else stays private. -- **`safety`** — one of `'read'`, `'action'`, `'destructive'`. Inferred from the RPC `type` (`static`/`query` → `read`, `action`/`event` → `action`), with explicit override available. -- The MCP adapter maps `safety` to tool annotations (`readOnlyHint`, `destructiveHint`). MCP clients use these to decide whether to prompt for confirmation before calling. +- **`safety`** — `'read'`, `'action'`, or `'destructive'`. Inferred from the RPC `type` (`static`/`query` → `read`, `action`/`event` → `action`), overridable. +- The adapter maps `safety` to tool annotations (`readOnlyHint`, `destructiveHint`). ## CLI | Command | Description | |---------|-------------| -| ` mcp` | Start your app's MCP server on `stdio` (from the `createCac` shell). | -| ` dev --mcp` | Serve the agent surface on the dev server's `/__mcp` route. | -| `devframe connect` | Run the app-independent MCP connector: discover running devframes and proxy their tools — see [MCP adapter](/adapters/mcp#discovery-devframe-connect). | +| ` mcp` | Start the MCP server on `stdio`. | +| ` dev --mcp` | Serve the agent surface on `/__mcp`. | +| `devframe connect` | Discover running devframes and proxy their tools — see [MCP adapter](/adapters/mcp#discovery-devframe-connect). | diff --git a/docs/guide/build-your-own-hub-ui.md b/docs/guide/build-your-own-hub-ui.md index fba59c26..e9cc029b 100644 --- a/docs/guide/build-your-own-hub-ui.md +++ b/docs/guide/build-your-own-hub-ui.md @@ -1,10 +1,6 @@ # Build Your Own Hub UI -A hub viewer is a replaceable implementation of two contracts — the node-side -`ui` slot and the client-side context — so you can ship a completely custom -devtools surface (your framework, your design system) on top of the hub's -infrastructure. `@devframes/hub-ui` is the reference implementation of both; -this page is the map for writing another. +A hub viewer implements two contracts — the node-side `ui` slot and the client-side context. `@devframes/hub-ui` is the reference. ## The node seam: `DevframeHubUi` @@ -20,62 +16,47 @@ interface DevframeHubUi { } ``` -Ship a function returning this object (the reference is `createUi()`), with -prebuilt assets: the viewer SPA is built with relative asset paths, and the -embedded entry is one self-contained ES module that mounts your dock into any -host page. +Ship a function returning this object (`createUi()`) with prebuilt assets using +relative paths. -`setup(ctx)` runs once during hub init — write your static, boot-time config -to `ctx.staticConfig`, which is serialized into `ConnectionMeta.configs` and -read by the client from the one connection handshake it already performs. The -reference UI's `createUi({ branding })` uses it to set -`ctx.staticConfig.ui = { branding, … }`; the hub never interprets what you -write. It's the structured, read-only counterpart to `assets` (arbitrary -served files). +`setup(ctx)` runs once at hub init: config in `ctx.staticConfig` is serialized +into `ConnectionMeta.configs` and read by the client at handshake. ## The client contracts -A viewer renders from the hub's shared state and drives it through -`@devframes/hub/client`. The simplest boot is -[`createDevframeClientHost()`](./client-context) — it assembles the whole +A viewer renders from the hub's shared state via +[`createDevframeClientHost()`](./client-context), which assembles the `DevframeClientContext` (docks, commands, renderers, when-clauses, connection) -and loads dock client scripts for you; the reference UI assembles the same -context shape with its own reactive machinery instead. Either way, honor these -contracts: +and loads dock client scripts. Honor: ### Dock entry types Render the built-in variants of the open dock union -(`DevframeDockEntryRegistry` from `@devframes/hub/types`): +(`DevframeDockEntryRegistry`, `@devframes/hub/types`): | Type | The viewer renders | |---|---| -| `iframe` | the entry's `url` in a kept-alive iframe (per `frameId` for shared frames); honor `subTabs` soft navigation | -| `action` | a bar button only — activating it runs the entry's client script | -| `custom-render` | a container the entry's client script mounts into | +| `iframe` | the entry's `url` in a kept-alive iframe (per `frameId` when shared); honor `subTabs` soft nav | +| `action` | a bar button; activating runs its client script | +| `custom-render` | a container its client script mounts into | | `launcher` | a launch call-to-action reflecting `launcher.status` | | `group` | one bar button collapsing its member entries | -| `~builtin` | your own native views (settings, feeds) for reserved ids | +| `~builtin` | your native views (settings, feeds) for reserved ids | -Honor `when` / `visibility` clauses, `category` grouping (order from -`DEFAULT_CATEGORIES_ORDER` in `@devframes/hub/constants`), and the +Honor `when` / `visibility`, `category` grouping (order from +`DEFAULT_CATEGORIES_ORDER`, `@devframes/hub/constants`), and the `hub:docks:activate` broadcast. -An `iframe` entry whose devframe serves its UI from a [remote assets -package](./client-assets) can also report that those assets are unreachable: its -fallback page posts a `RemoteAssetsErrorMessage` -(`DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE`, both re-exported from -`@devframes/hub/constants`) to `window.parent`. Match the message against the -frame's own `contentWindow` and you can offer the install command and a retry in -your own UI; leaving it alone keeps the fallback page visible inside the frame. +An `iframe` entry serving a [remote assets package](./client-assets) can +report it unreachable: its fallback page posts a `RemoteAssetsErrorMessage` +(`DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE`, `@devframes/hub/constants`) to +`window.parent`. Match it against the frame's `contentWindow` to offer install + retry. ### The renderer registry and its fallback **Every other dock type routes through the dock-renderer registry** — build it -with `createDockRenderersContext()` from `@devframes/hub/client` so local -registrations, the hub's [renderer -manifest](./hub-initiate#renderer-modules), and the typed mount result behave -like every other viewer: +with `createDockRenderersContext()` (`@devframes/hub/client`), wiring local +registrations and the hub's [renderer manifest](./hub-initiate#renderer-modules): ```ts import { createDockRenderersContext } from '@devframes/hub/client' @@ -88,30 +69,24 @@ const renderers = createDockRenderersContext({ const result = await renderers.mount(entry, container) ``` -The mount result is the fallback contract. A viewer shows a visible state for -each variant instead of a dead panel: +Show a state per mount-result variant: - `{ status: 'mounted', dispose }` — the renderer owns the container; call - `dispose` when the view unmounts. -- `{ status: 'missing-renderer' }` — render a fallback view: *No renderer for - "``" in the current environment*. `renderers.has(type)` answers up - front, so you can render this declaratively without a mount attempt. -- `{ status: 'load-error', error }` — the module failed to import or the - renderer threw; render the error with a retry affordance (a failed import is - not cached, so retrying re-imports). + `dispose` on unmount. +- `{ status: 'missing-renderer' }` — render a fallback (`renderers.has(type)` + answers up front). +- `{ status: 'load-error', error }` — import failed or the renderer threw; + render the error with retry. ### The theme contract for renderers -Renderer modules style themselves (they may attach a shadow root inside your -container). Your part: keep a live `dark` class on the mount container -reflecting your color mode, and let CSS custom properties inherit — a -`--devframe-primary` set on an ancestor rebrands rendered content too. +Renderer modules self-style (sometimes via a shadow root). Keep a live +`dark` class on the mount container and let CSS custom properties inherit — a +`--devframe-primary` ancestor rebrands rendered content. ## Reference points - `packages/hub-ui` — the full reference viewer (Vue, `@antfu/design`). -- [`examples/hub-vite`](/examples/hub-vite) and - [`examples/hub-next`](/examples/hub-next) — protocol witnesses: complete - hand-rolled viewers in ~500 lines of vanilla DOM and React respectively, - covering docks, the drawer subsystems, the renderer registry, and the - missing-renderer fallback. +- [`examples/hub-vite`](https://github.com/devframes/devframe/tree/main/examples/hub-vite) and + [`examples/hub-next`](https://github.com/devframes/devframe/tree/main/examples/hub-next) — hand-rolled + viewers in vanilla DOM and React. diff --git a/docs/guide/build-your-own-json-render-frontend.md b/docs/guide/build-your-own-json-render-frontend.md index aa56e7b0..61d170b7 100644 --- a/docs/guide/build-your-own-json-render-frontend.md +++ b/docs/guide/build-your-own-json-render-frontend.md @@ -1,9 +1,8 @@ # Build Your Own JSON-Render Frontend -`@devframes/json-render-ui` is the reference frontend, not the protocol — any -implementation of the renderer contract replaces it, in any framework. The -[Next hub witness](/examples/hub-next) ships a complete React one in two files -(`src/client/json-render/`); this page is the contract it implements. +`@devframes/json-render-ui` is the reference frontend, not the protocol; any +implementation of the renderer contract replaces it. The [Next hub witness](https://github.com/devframes/devframe/tree/main/examples/hub-next) ships a React one at +`src/client/json-render/`. ## The contract @@ -19,45 +18,36 @@ const renderer: JsonRenderDockRenderer = async ({ entry, container, context }) = } ``` -Resolve the entry's serializable `view` reference: +Resolve the entry's `view`: -- `{ stateKey }` — subscribe to that shared state via - `context.rpc.sharedState.get(stateKey)`, render its value as the live spec, - and re-render on `'updated'`. **Unsubscribe in `dispose`.** -- `{ spec }` — render the embedded spec directly; no shared state involved. +- `{ stateKey }` — subscribe via `context.rpc.sharedState.get(stateKey)`, render + it as the live spec, re-render on `'updated'`. **Unsubscribe in `dispose`.** +- `{ spec }` — render the embedded spec directly. -Detect static output via `context.rpc.connectionMeta.backend === 'static'` and -disable action dispatch there. +Detect static output via `context.rpc.connectionMeta.backend === 'static'`, +disabling action dispatch there. ## Behavior expectations -Match the reference frontend's semantics so specs behave identically across -frontends: - -- **Actions** — a spec action name dispatches an RPC call of the same name. - Never bridge the reserved built-ins (`setState`, `pushState`, `removeState`, - `validateForm` — handled by the upstream renderer) or promise probes - (`then`/`catch`/`finally`). Surface failures to the view rather than - swallowing them. +- **Actions** — a spec action name dispatches the same-named RPC call. Never + bridge the reserved built-ins (`setState`, `pushState`, `removeState`, + `validateForm`) or promise probes (`then`/`catch`/`finally`); surface failures + to the view. - **Validation** — validate element props against `basePropSchemas` from - `@devframes/json-render`; swap an invalid element for an error placeholder so - one bad element doesn't break the view. + `@devframes/json-render`; swap an invalid element for an error placeholder. - **Unknown components** — a component your registry lacks renders as a - placeholder (type + prop-key gist) with a `console.warn`; the rest of the - view renders. + placeholder (type + prop-key gist) with a `console.warn`; the rest renders. - **State reset** — reseed spec state only when the view identity changes, not - on every spec update. + on every update. ## Plugging it in -Two seams, one contract: - -- **Local registration** — a host page that bundles its own client passes - `createDevframeClientHost({ renderers: { 'json-render': myRenderer } })`. - Local registrations win over the manifest. +- **Local registration** — a host bundling its client passes + `createDevframeClientHost({ renderers: { 'json-render': myRenderer } })`; + local registrations win over the manifest. - **A prebuilt renderer module** — bundle your renderer as one self-contained - browser ES module (framework and styles included) whose default export is the - renderer, and ship a node helper returning the hub registration: + browser ES module (framework and styles included), default-exporting the + renderer, plus a node helper returning the registration: ```ts import type { DockRendererRegistration } from '@devframes/hub/initiate' @@ -67,13 +57,11 @@ Two seams, one contract: } ``` - Hosts compose it with `initHub({ renderers: [myRenderer()] })` — the hub - serves the module and every viewer imports it lazily (see [renderer + Compose it with `initHub({ renderers: [myRenderer()] })`; the hub serves + the module and viewers import it lazily (see [renderer modules](./hub-initiate#renderer-modules)). -A prebuilt module must be **self-styling and shadow-root-safe**: the viewer's -container may live inside a shadow root, so deliver your stylesheet into the -mount subtree (the reference module attaches its own shadow root inside the -container and injects its compiled CSS there). Read the theme from the live -`dark` class the viewer keeps on the container, and derive brand color from the -inherited `--devframe-primary` custom property when present. +A prebuilt module must be **self-styling and shadow-root-safe**: deliver your +stylesheet into the mount subtree, which may be a shadow root. Read the theme +from the container's live `dark` class, brand color from the inherited +`--devframe-primary`. diff --git a/docs/guide/built-with.md b/docs/guide/built-with.md new file mode 100644 index 00000000..3b4abb96 --- /dev/null +++ b/docs/guide/built-with.md @@ -0,0 +1,57 @@ +--- +outline: deep +--- + +# Built with Devframe + +## Real-world DevTools + +- [**Vite DevTools**](https://devtools.vite.dev/) — bundles many devframes into one UI. Mount your own via the [`vite` adapter](/adapters/vite). +- [**ESLint Config Inspector**](https://github.com/eslint/config-inspector) — inspects flat configs. +- [**node-modules-inspector**](https://github.com/antfu/node-modules-inspector) — visualizes your `node_modules` dependency graph. + +## Builtin Plugins + +The [built-in plugins](/plugins/) are real tools built on Devframe, each in a different UI framework: + +| Plugin | UI framework | What it does | +|--------|--------------|--------------| +| [Data Inspector](/plugins/data-inspector) | Vue | Query live server-side objects with jora. | +| [Devframe Inspector](/plugins/inspect) | Vue | Browse RPC, shared state, and agent surface. | +| [Open Graph Viewer](/plugins/og) | Vue | Inspect Open Graph / Twitter metadata and card previews. | +| [Accessibility Inspector](/plugins/a11y) | Solid | Run axe-core; list WCAG violations. | +| [Git](/plugins/git) | React (Next.js) | Repository dashboard: status, graph, branches, diffs. | +| [Terminals](/plugins/terminals) | Svelte | Stream output and run interactive PTY shells. | +| [Code Server](/plugins/code-server) | Vue | Run VS Code in the browser. | +| [Assets](/plugins/assets) | Vue | Browse, preview, upload, rename, and delete files. | + +## Playable Examples + +Runnable apps in the [repository](https://github.com/devframes/devframe/tree/main/examples), each a single node-side definition behind a different UI. Run one with: + +```sh +pnpm install +pnpm --filter dev +``` + +| Example | UI | Shows | +|---------|----|-------| +| [files-inspector](https://github.com/devframes/devframe/tree/main/examples/files-inspector) | Preact | Lists cwd files over RPC. | +| [json-render](https://github.com/devframes/devframe/tree/main/examples/json-render) | Vue | Server-authored view via `@devframes/json-render-ui`; live state + action bridge. | +| [streaming-chat](https://github.com/devframes/devframe/tree/main/examples/streaming-chat) | Preact | Streams tokens; history in shared state. | +| [next-runtime-snapshot](https://github.com/devframes/devframe/tree/main/examples/next-runtime-snapshot) | React (Next.js) | App Router SPA surfacing the Node runtime. | +| [hub-vite](https://github.com/devframes/devframe/tree/main/examples/hub-vite) | Vanilla TS (Vite) | ~120-line Vite host wiring `@devframes/hub`; hand-built viewer. | +| [hub-next](https://github.com/devframes/devframe/tree/main/examples/hub-next) | React (Next.js) | Same protocol, Next.js route. | + +The **minimal** family mounts `initHub({ ui: createUi() })` with `@devframes/hub-ui`: + +| Example | Host | Shows | +|---------|------|-------| +| [hub-vite-minimal](https://github.com/devframes/devframe/tree/main/examples/hub-vite-minimal) | Vite | Dev middleware. | +| [hub-next-minimal](https://github.com/devframes/devframe/tree/main/examples/hub-next-minimal) | Next.js | App Router route. | +| [hub-nitro-minimal](https://github.com/devframes/devframe/tree/main/examples/hub-nitro-minimal) | Nitro | Catch-all route. | +| [hub-hono-minimal](https://github.com/devframes/devframe/tree/main/examples/hub-hono-minimal) | Hono | Node and Bun. | +| [hub-fastify-minimal](https://github.com/devframes/devframe/tree/main/examples/hub-fastify-minimal) | Fastify | `nodeMiddleware`. | +| [hub-sveltekit-minimal](https://github.com/devframes/devframe/tree/main/examples/hub-sveltekit-minimal) | SvelteKit | Catch-all endpoint. | +| [hub-deno-minimal](https://github.com/devframes/devframe/tree/main/examples/hub-deno-minimal) | Deno | `Deno.serve` + upgrade socket. | +| [hub-rsbuild-minimal](https://github.com/devframes/devframe/tree/main/examples/hub-rsbuild-minimal) | Rsbuild | Dev middleware. | diff --git a/docs/guide/client-assets.md b/docs/guide/client-assets.md index dec72657..cb681678 100644 --- a/docs/guide/client-assets.md +++ b/docs/guide/client-assets.md @@ -4,11 +4,11 @@ outline: deep # Client Assets -A devframe's UI is a built single-page app served as its client. The top-level `clientAssets` field tells devframe where those assets live — either a **local directory** bundled with your tool, or a **published npm package** fetched on demand. +A devframe's UI is a built SPA; `clientAssets` says where it lives — a **local directory** or **published npm package**. ## Mounting a local build -The basic form points `clientAssets` at the directory your SPA build produces. Resolve it from the module so it works from both source and the published package: +Point `clientAssets` at your SPA build directory, resolved from the module: ```ts import { fileURLToPath } from 'node:url' @@ -26,15 +26,13 @@ export default defineDevframe({ }) ``` -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. +devframe serves it with SPA fallback (unknown paths → `index.html`) and no-store dev caching. Build the SPA with a relative base (`vite: { base: './' }`); it reads its runtime base from `document.baseURI`. -The [`dev`](/adapters/dev), [`build`](/adapters/build), and [Vite](/frameworks/vite) adapters all consume this same `clientAssets`. - -The earlier home for this value, `cli.distDir`, is deprecated but still read as a fallback when `clientAssets` is unset, so existing definitions keep working — move it up to the top level at your convenience. +The [`dev`](/adapters/dev), [`build`](/adapters/build), and [Vite](/frameworks/vite) adapters share `clientAssets`; the deprecated `cli.distDir` is a fallback when it's unset. ## Programmatic hosting from `setup` -`clientAssets` is the declarative way to serve the tool's *primary* UI — the adapters resolve it and mount it at the base path for you. When you need to host assets yourself — mount a second static bundle at another path, decide the source at runtime, or serve extra directories alongside the main SPA — reach for `ctx.views.hostStatic` inside `setup`: +To host assets beyond the *primary* UI, use `ctx.views.hostStatic`: ```ts export default defineDevframe({ @@ -59,15 +57,11 @@ export default defineDevframe({ }) ``` -`hostStatic(baseUrl, source, defaultResolveFrom?)` accepts the same `StaticAssetsSource` (a local directory or a remote declaration) as `clientAssets`. In `dev` mode it registers the middleware live; in `build` mode it copies the files into the static output, so a programmatically hosted bundle survives `createBuild` too. The optional `defaultResolveFrom` overrides the context's own `importMetaUrl` as the resolution base for a remote source — a hub mounting assets on behalf of a plugin passes that plugin's `importMetaUrl` so they resolve against its dependency graph. - -Under the hood the adapters resolve `clientAssets` (falling back to the deprecated `cli.distDir`) with the exported `resolveClientAssets(def)` helper and hand it to the host's static mount — `hostStatic` is that same mechanism, exposed for your own bases. +`hostStatic(baseUrl, source, defaultResolveFrom?)` takes `clientAssets`'s `StaticAssetsSource`; `dev` registers middleware live, `build` copies into the static output. ## Remote assets -Instead of a directory, `clientAssets` 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 `clientAssets` a `RemoteAssets` object naming the package and exact version: +Give `clientAssets` a `RemoteAssets` object naming a **published npm package** and exact version: ```ts import type { RemoteAssets } from 'devframe' @@ -91,52 +85,46 @@ export default defineDevframe({ }) ``` -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. - -The definition's [`importMetaUrl`](./devframe-definition#resolving-against-the-plugins-own-dependencies) supplies the resolution base, so a remote source needs only its `package` and `version`. A per-source `resolveFrom` overrides that base for one source, and an explicit `resolveFrom: null` opts a source out of the installed-copy lookup entirely. +The definition's [`importMetaUrl`](./devframe-definition#resolving-against-the-plugins-own-dependencies) is the resolution base. ### How assets resolve -For each request the source resolves in order: +Per request, resolution tries in order: -1. **Locally installed package** — resolved from `resolveFrom`, which defaults to the definition's `importMetaUrl`. If `@acme/my-tool-assets` is installed next to your tool, it's served directly with no network. This is the offline path. +1. **Locally installed package** — resolved from `resolveFrom` (default `importMetaUrl`); served with no network. 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. +3. **CDN back-proxy** — [jsDelivr](https://www.jsdelivr.com/) by default; exact-version URLs are immutable, so caches never stale. ### Options | Field | Purpose | |-------|---------| -| `package` | npm package holding the built assets. | -| `version` | Exact version to serve — usually your tool's own `pkg.version`. | -| `resolveFrom` | Resolution base for the zero-network path from a locally installed copy. Defaults to the definition's `importMetaUrl`; set it to override that for one source, or to `null` 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` | npm package with the built assets. | +| `version` | Exact version, usually your `pkg.version`. | +| `resolveFrom` | Local-path resolution base. Defaults to `importMetaUrl`; `null` skips to cache + CDN. | +| `path` | Subpath the assets live under (default `dist`). | +| `provider` | `'jsdelivr'` (default), `'unpkg'`, or a custom provider (internal mirror). | +| `offline` | `true` serves only from local install or cache, never 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). +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: +Install the assets package explicitly — step 1 serves it locally. Set `offline: true` to never contact the CDN, or point `provider` at a mirror: ```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. - ### When the assets can't be reached -A file that is in neither a local install nor the cache, with the provider unreachable, raises [`DF0060`](../errors/DF0060). An HTML navigation gets a self-contained page naming the assets package, the install command that makes it work offline, and the provider's own error, with a retry button. +A file absent from local install and cache, with the provider unreachable, raises [`DF0060`](../errors/DF0060); an HTML navigation gets a self-contained error page. -That page also posts its failure to `window.parent` (`DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE` from `devframe/constants`, payload `RemoteAssetsErrorMessage`), so a viewer embedding the tool in an iframe can render the same thing in its own design — `@devframes/hub-ui` shows it as a panel over the dock's frame ([building your own](./build-your-own-hub-ui)). +It also posts the failure to `window.parent` (`DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE` from `devframe/constants`, payload `RemoteAssetsErrorMessage`) for an embedding viewer like [`@devframes/hub-ui`](./build-your-own-hub-ui). ### Custom provider -A custom provider supplies the file URL, and optionally a file listing (used for correct 404s, SPA fallback, and static builds): +A custom provider supplies the file URL, optionally a listing: ```ts const clientAssets: RemoteAssets = { @@ -151,7 +139,7 @@ const clientAssets: RemoteAssets = { ### 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 the resolver can locate it: +An ordinary npm package ships the built UI under `path` (default `dist`) and exposes its `package.json` for the resolver: ```json { @@ -162,4 +150,4 @@ The assets package is an ordinary npm package that ships the built UI under `pat } ``` -Keep its version in lockstep with the tool that declares it, so `version: pkg.version` always points at matching UI. +Keep its version in lockstep with the tool. diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index 3e995ec0..d1c231d5 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -4,21 +4,14 @@ outline: deep # Client Scripts & Client Context -In a hub, a plugin can run code inside the **host page** — the page being inspected — through a dock **client script**. The **client context** is the object every client-side surface (dock client scripts, viewer UIs, your own app code) uses to talk to the hub: RPC, dock state, the command palette, and the when-clause context. +A dock **client script** runs a plugin's code inside the **host page**; the **client context** is how client surfaces reach the hub. > [!WARNING] Experimental > The hub API surface is still being refined. Names may change before 1.0. ## The client host runtime -`createDevframeClientHost()` from `@devframes/hub/client` is the headless browser runtime a host page boots. When it runs it: - -1. Connects an RPC client — or adopts one you already made. -2. Assembles the `DevframeClientContext` (panel, docks, commands, when) from the hub's shared state. -3. Publishes the context to a global slot, so `getDevframeClientContext()` can read it from anywhere in the page. -4. Imports each dock entry's client script into the page and calls it with the context. - -The host page owns the boot — one import from its own browser entry starts the runtime, and your HTML stays untouched: +`createDevframeClientHost()` (`@devframes/hub/client`) boots the host page: it connects (or adopts) an RPC client, publishes the `DevframeClientContext`, and imports each dock's client script: ```ts // main.ts — the host app / hub page's browser entry @@ -28,87 +21,44 @@ const rpc = await connectDevframe({ baseURL: '/__hub/' }) const { context, dispose } = await createDevframeClientHost({ rpc }) ``` -Viewers with an HTML pipeline layer injection on top: `@vitejs/devtools` wraps this boot in the client entry its Vite plugin injects through `transformIndexHtml`, while the devframe examples import it from the app entry directly. Either way the same runtime executes in the page. - ### Options | Option | Description | |--------|-------------| -| `rpc` | An already-connected `DevframeRpcClient`. When omitted, one is created via `connectDevframe(connect)`. | -| `connect` | Options forwarded to `connectDevframe` when `rpc` is not supplied — pass `baseURL` to point at the hub's connection-meta mount (e.g. `/__hub/`). | -| `clientType` | `'standalone'` (default) — the runtime owns the whole page (a hub UI). `'embedded'` — the runtime lives inside a user app alongside a panel. | -| `loadClientScripts` | Import and run dock entries' client scripts. Default `true`. | -| `renderers` | Dock renderers to register at boot, keyed by dock `type` (e.g. `{ 'json-render': myRenderer }` — any implementation of the dock-renderer contract the host bundles). Local registrations take precedence over the hub's [renderer manifest](./hub-initiate#renderer-modules). | +| `rpc` | An already-connected `DevframeRpcClient`; when omitted, created via `connectDevframe(connect)`. | +| `connect` | Forwarded to `connectDevframe` when `rpc` is omitted (e.g. `baseURL`). | +| `clientType` | `'standalone'` (default) — owns the page; `'embedded'` — inside a user app alongside a panel. | +| `loadClientScripts` | Import and run dock client scripts (default `true`). | +| `renderers` | Dock renderers registered at boot, keyed by dock `type`; local wins over the hub's [renderer manifest](./hub-initiate#renderer-modules). | -Boot the host once per page: a second boot replaces the published context and logs a warning. `dispose()` tears down its listeners and unpublishes the context it owns. +A second boot replaces the context and warns; `dispose()` tears down listeners and unpublishes it. ## The client context -`DevframeClientContext` is the client-side counterpart of the hub's node context: one object carrying everything a client surface needs. - | Property | Description | |----------|-------------| -| `rpc` | The [RPC client](./client) — call server functions, register client-side functions, access shared state. | -| `clientType` | `'embedded'` (runtime inside your app) or `'standalone'` (independent hub page). | -| `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, plus `register()` / `update()` for [client-only docks](#client-only-docks). | -| `panel` | Dock panel state: position, size, drag/resize flags. | -| `commands` | The command palette: `register()`, `execute()`, `getKeybindings()`. | -| `renderers` | Dock-renderer registry — `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a renderer: one registered locally at boot, or a prebuilt module lazy-imported from the hub's [renderer manifest](./hub-initiate#renderer-modules) (local wins). `mount()` resolves a typed result — `{ status: 'mounted', dispose }`, `{ status: 'missing-renderer' }`, or `{ status: 'load-error', error }` — so a viewer renders a visible fallback for a type nothing covers instead of a dead panel; `has()` answers for both sources so the fallback can render without a mount attempt. | -| `when` | The [when-clause](./when-clauses) evaluation context. | -| `connection` | The client's live [connection status](./client#handling-connection-and-auth-errors) — `status`, `error`, and `events` — so a viewer can render one central connection indicator for every docked plugin. | +| `rpc` | The [RPC client](./client) — server/client functions, shared state. | +| `clientType` | `'embedded'` (inside your app) or `'standalone'` (independent hub page). | +| `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, `register()` / `update()` for [client-only docks](#client-only-docks). | +| `panel` | Dock panel state: position, size, drag/resize. | +| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`. | +| `renderers` | Dock-renderer registry — `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a renderer (local boot or the hub's [manifest](./hub-initiate#renderer-modules); local wins). `mount()` resolves a `status`: `mounted` (with `dispose`), `missing-renderer`, or `load-error` (with `error`). | +| `when` | The [when-clause](./when-clauses) context. | +| `connection` | Live [connection status](./client#handling-connection-and-auth-errors) — `status`, `error`, `events`. | ### Accessing the context -From anywhere in the host page, use `getDevframeClientContext()`. It returns `undefined` until the client host finishes booting: - -```ts -import { getDevframeClientContext } from '@devframes/hub/client' - -const ctx = getDevframeClientContext() -if (ctx) { - const modules = await ctx.rpc.call('my-plugin:get-modules') - ctx.docks.switchEntry('my-plugin') -} -``` +`getDevframeClientContext()` returns the context anywhere; `undefined` before boot. ### Client-only docks -The [node hub context](./hub) registers docks that flow into the `devframe:docks` shared state and reach every connected viewer. A client host can also register a dock that lives only in this page, for a view a host page synthesizes itself: - -```ts -const handle = ctx.docks.register({ - id: 'my-local-view', - title: 'Local', - icon: 'ph:cube-duotone', - type: 'custom-render', - renderer: { importFrom: '/my-view.mjs' }, -}) - -handle.update({ badge: '3' }) // patch it in place (the id is immutable) -handle.dispose() // remove it -``` +A client host can register a dock local to this page (unlike [node hub context](./hub) docks synced via `devframe:docks`). `ctx.docks.register(entry)` — e.g. `type: 'custom-render'` with `renderer: { importFrom }` — returns a handle whose `update({ badge })` patches in place (id immutable) and `dispose()` removes it. One sharing a server dock's id overrides it locally; re-registering an owned id throws unless you pass `register(entry, true)`. -Client-only docks merge into the same `docks.entries` list, group, select, and load their client scripts exactly like server docks — they just never sync to the hub or other viewers. A client dock sharing an id with a server dock overrides it locally. `ctx.docks.update(entry)` replaces a previously registered client dock wholesale. Registering an id that a client dock already owns throws unless you pass `register(entry, true)`. - -A client-only dock can render a [JSON-render](./json-render) view the page authors itself. Carry the spec **inline** in the dock's `view` — no shared state, no server round-trip — and register a `json-render` dock. With a `json-render` renderer registered at boot, it renders through the same path as a server-authored view: - -```ts -const spec = { /* a DevframeJsonRenderSpec built in the browser */ } - -ctx.docks.register({ - id: 'client-playground', - title: 'Client Playground', - icon: 'ph:sliders-horizontal-duotone', - type: 'json-render', - view: { spec }, -}) -``` - -The `view` field accepts either `{ spec }` (the spec rendered inline) or `{ stateKey }` (subscribed to a live shared state, the shape `createJsonRenderView` produces server-side). An inline view still runs its own state: `{ $bindState }` inputs and `{ $state }` reads work against the spec's `state`, and the built-in `setState` / `pushState` / `removeState` actions mutate it — so a client-authored view is interactive with no server and no shared state. What `{ spec }` lacks versus `{ stateKey }` is a server-driven update stream. +A client-only dock can also carry `type: 'json-render'` with an inline [JSON-render](./json-render) `view: { spec }` (a `DevframeJsonRenderSpec` built in-browser), rendered when a `json-render` renderer is registered at boot. `view` also accepts `{ stateKey }` for live shared state (from `createJsonRenderView`). ## Dock client scripts -A dock entry declares its client script as a `ClientScriptEntry` — `{ importFrom, importName? }`, where `importName` defaults to `'default'`. The field depends on the entry kind: +A client script is a `ClientScriptEntry` — `{ importFrom, importName? }` (`importName` defaults `'default'`); the field varies by entry kind: | Entry kind | Field | Runs | |---|---|---| @@ -116,108 +66,48 @@ A dock entry declares its client script as a `ClientScriptEntry` — `{ importFr | `custom-render` | `renderer` | to render the entry's panel | | `iframe` | `clientScript` (optional) | alongside the iframe panel, inside the host page | -The client host imports `importFrom` with a native dynamic import at runtime — the specifier is a URL served by the host, not a build-time module — and calls the exported function with the client context, extended with two dock-scoped extras: +The exported function (`DockClientScriptContext`) receives the client context and two dock-scoped extras: -- **`current`** — this entry's state: `entryMeta`, `isActive`, `domElements`, and `events` (`entry:activated`, `entry:deactivated`, `entry:updated`, `dom:panel:mounted`, `dom:iframe:mounted`). -- **`messages`** — a messages client scoped to the entry: messages it adds default their `category` to the entry id, and the per-level shortcuts (`info` / `warn` / `error` / `success` / `debug`) delegate to `add()`. +- **`current`** — this entry's state: `entryMeta`, `isActive`, `domElements`, `events` (`entry:activated`, `entry:deactivated`, `entry:updated`, `dom:panel:mounted`, `dom:iframe:mounted`). +- **`messages`** — an entry-scoped messages client (`category` defaults to the entry id; `info`/`warn`/`error`/`success`/`debug` shortcuts for `add()`). -```ts -import type { DockClientScriptContext } from '@devframes/hub/client' - -export default async function setup(ctx: DockClientScriptContext) { - ctx.current.events.on('entry:activated', async () => { - const data = await ctx.rpc.call('my-plugin:get-modules') - ctx.messages.info(`Loaded ${data.length} modules`) - }) -} -``` - -A script that fails to import is logged and retried on the next dock update. +A failed import retries on the next dock update. ### Shipping a client script `importFrom` accepts two shapes: -- **A URL the host serves** — a single self-contained ES module, loading outside any chunk graph. Works on every host. -- **A bare npm specifier** (`'vite-plugin-vue-tracer/client/vite-devtools'`) — resolved through the host runtime, where supported. +- **A host-served URL** — a self-contained ES module; works on every host. +- **A bare npm specifier** (`'vite-plugin-vue-tracer/client/vite-devtools'`) — resolved through the host runtime. -For the URL shape, attach the built bundle when mounting the devframe: - -```ts -await ctx.install(myDevframe, { - dock: { clientScript: { importFrom: `/@fs/${myAgentBundlePath}` } }, -}) -``` - -Under Vite, `/@fs/` serves the built bundle directly; other hosts mount the bundle's directory statically and pass that URL instead. +For a URL, attach it via `ctx.install(myDevframe, { dock: { clientScript: { importFrom } } })`. Under Vite `/@fs/` serves it; other hosts mount the directory statically. ### Bare npm specifiers -Bare specifiers are a **host-runtime capability**. A host that can serve npm modules to the browser advertises a resolution template as `ConnectionMeta.configs.dock.clientModuleResolution` — the `{specifier}` token is replaced with the specifier, and every client-script loader (the client host, the hub-ui viewers, `__client-imports.js`) applies it before importing: - -```ts -// A Vite host resolves bare specifiers through its own module graph. -// `@devframes/vite/hub` declares this by default. -initHub({ clientModuleResolution: '/@id/{specifier}' }) -``` - -On a Vite host, `/@id/` routes the import through Vite's own resolution and import-analysis, so the script's transitive bare imports work too and resolve in the same module graph as the inspected app — a plugin whose injected app-side code and dock client script import the same modules shares their instances. A plugin can then declare its dock with just the specifier: - -```ts -ctx.docks.register({ - type: 'action', - id: 'vue-tracer', - title: 'Vue Tracer', - icon: 'ph:crosshair-simple-duotone', - action: { importFrom: 'vite-plugin-vue-tracer/client/vite-devtools' }, -}) -``` - -A host that declares no template (Next.js today) supports the URL shape only — registering a bare specifier there warns [`DF8111`](/errors/DF8111). A viewer can also resolve bare specifiers itself with `createDevframeClientHost({ resolveClientModule })`, which wins over the host template. +Bare specifiers are a **host-runtime capability**: a host advertises a resolution template at `ConnectionMeta.configs.dock.clientModuleResolution` (loaders replace `{specifier}` before import). A Vite host declares this by default (`@devframes/vite/hub`) as `initHub({ clientModuleResolution: '/@id/{specifier}' })`; then use the specifier alone as `importFrom`. A host with no template (Next.js) supports the URL shape only, warning [`DF8111`](/errors/DF8111) on a specifier. A viewer can override with `createDevframeClientHost({ resolveClientModule })`. -Two guarantees to design against: - -- **Client scripts always execute in the inspected page's realm** — the same `window` as the app being inspected. -- **Module identity is best-effort, realm identity is the contract.** On Vite hosts a bare specifier shares the app's module graph; elsewhere a script ships as its own bundle. A plugin keeping shared state between its injected app code and its dock script should anchor that state on `globalThis` (vue-tracer's `__vue_tracer__` store is the reference pattern) rather than rely on both sides importing one module instance. +Client scripts execute in the inspected page's realm (`window`); anchor shared state on `globalThis`. ### Dual boots -The [a11y inspector](/plugins/a11y)'s in-page agent is the canonical client script, and it boots both ways from one bundle: the default export accepts the client-script context (mirroring each scan into the hub's messages feed), while a deferred, globally-guarded self-boot lets a plain `