From 485fbae5882fb214e97727baf2ab4c4ef4124074 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 19 Aug 2026 06:57:38 +0000 Subject: [PATCH] docs(skills): rework devframe skill for standard handler + hub Reframe the devframe agent skill around the current architecture: the Web-Standard handler (initDevframe) as the portability primitive, the @devframes/hub composition layer (initHub, docks/commands/messages/ terminals), and the single/hub split across @devframes/vite, /next, and /nuxt. Fold in the high-level positioning from 'Pluggable, Extensible, and Playful DevTools'. - Replace the stale Vite-DevTools-only mounting story with the deployment map (serve one devframe vs. serve many via a hub) - Add sections on the standard handler, the hub subsystems/protocol, and the framework packages' two scopes - Fix drift against current APIs: importMetaUrl, storage scopes, validator-neutral RPC schemas (devframe/utils/simple-schema), duplicationStrategy/services/staticConfig/rpc.snapshot - Remove the dead ../../skills/vite-devtools-kit reference - Update templates/counter-devframe.ts and add templates/hub.ts --- skills/devframe/SKILL.md | 605 +++++++++--------- skills/devframe/templates/counter-devframe.ts | 11 +- skills/devframe/templates/hub.ts | 50 ++ 3 files changed, 371 insertions(+), 295 deletions(-) create mode 100644 skills/devframe/templates/hub.ts diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index f9f5a368..d9e0881e 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -1,63 +1,85 @@ --- name: devframe description: > - Use when building a devtool with devframe - the - framework- and build-tool-agnostic foundation for defining a - devtool once and serving it in many places. Covers - DevframeDefinition, picking the right deployment adapter - (cli / build / vite / embedded / mcp), designing RPC - contracts, exposing an agent-native surface over MCP, and - wiring the author's SPA client. For host-level features (docks, - terminals, palette, etc.), the devframe can be mounted into a - host that provides them - Vite DevTools is one supported target, - reached via the `vite` adapter. Triggers on `devframe` imports, - `defineDevframe`, `createCac`, `createMcpServer`, - `connectDevframe`, and on migrations of existing inspectors + Use when building a devtool with devframe - the framework- and + build-tool-agnostic foundation for defining a devtool once and + serving it anywhere. Covers the DevframeDefinition, the standard + web handler (`initDevframe` → `handler` / `nodeMiddleware`) that + mounts a tool into any host, the packaging adapters (cli / build / + dev / mcp / embedded), the framework packages (`@devframes/vite`, + `@devframes/next`, `@devframes/nuxt`, each split into `single` and + `hub`), composing many tools into one devtools host with + `@devframes/hub` (`initHub`, docks / commands / messages / + terminals), designing RPC contracts, exposing an agent-native + surface over MCP, and wiring the author's SPA client. Triggers on + `devframe` imports, `defineDevframe`, `initDevframe`, `initHub`, + `createCac`, `createMcpServer`, `connectDevframe`, `@devframes/*` + imports, and on migrations of existing inspectors (eslint-config-inspector, unocss-inspector, - node-modules-inspector-style tools) to devframe. + node-modules-inspector-style tools) onto devframe. --- # devframe skill -**Devframe is an asset: define your devtool once, serve it anywhere.** A devtool built on devframe is a single `DevframeDefinition` plus an author-provided SPA - the same definition deploys through a set of pluggable adapters (standalone CLI, static report, embedded SPA, MCP server, mounted into a host, etc.). Devframe is framework- and build-tool-agnostic; it has no Vite dependency and makes no UI-framework assumption. +**Devframe is the `unplugin` for devtools: define a tool once, mount it anywhere.** A devtool built on devframe is a single `DevframeDefinition` plus an author-provided SPA. That definition describes one tool - its RPC surface, shared state, diagnostics, web interface, and agent-facing surface - independent of how it is presented. The same definition then deploys through a standard web handler, a set of packaging adapters, thin framework integrations, or composed with other tools inside a hub. -Devframe describes one tool. If you need host-level features (cross-tool palette, integrated terminals, dock aggregation), mount the devframe into a host that provides them - [Vite DevTools](https://devtools.vite.dev/) is the canonical example, reached via the `vite` adapter - or build your own host adapter. `devframe` itself must not depend on Vite or any `@vitejs/*` package. +Two layers, one boundary: -Full reference: [devfra.me/](https://devfra.me/). +- **A devframe** is one portable tool. `initDevframe(def, { base })` turns it into a live instance whose `.handler` is a Web-Standard `(request: Request) => Promise` carrying the whole surface (SPA, discovery, WebSocket RPC, auth gate, optional MCP route) under one mount base. Anything that can mount a catch-all route or Connect-style middleware can host it. +- **A hub** (`@devframes/hub`) composes *many* devframes behind one namespace with a shared RPC registry, one transport, one auth gate, and the orchestration features that only make sense when tools share a UI (docks, commands, messages, terminals). `initHub()` puts the whole collection behind the same kind of standard handler. -## When to use devframe +Devframe is framework- and build-tool-agnostic - it has zero dependency on Vite or any `@vitejs/*` package and makes no UI-framework assumption. [Vite DevTools](https://devtools.vite.dev/) is the first flagship *host* built on it; the built-in plugins deliberately span Vue, Svelte, Solid, React, and Next to prove the point. -All adapter factories share the shape `createXxx(devframeDef, options?)`. +High-level concept: [Pluggable, Extensible, and Playful DevTools](https://antfu.me/posts/pluggable-extensible-playful-devtools). Full reference: [devfra.me](https://devfra.me/). -| Author goal | Factory | Entry | -|-------------|---------|-------| -| Standalone CLI for local use | `createCac(def, options?)` | `devframe/adapters/cac` | -| Run the dev server programmatically (any CLI framework) | `createDevServer(def, options?)` | `devframe/adapters/dev` | -| Self-contained static deploy with baked data | `createBuild(def, options?)` | `devframe/adapters/build` | -| Mount into a host (Vite DevTools or any compatible host) | `createPluginFromDevframe(def, options?)` | `@vitejs/devtools-kit/node` | -| Register dynamically at runtime | `createEmbedded(def, { ctx })` | `devframe/adapters/embedded` | -| Expose to coding agents (MCP) | `createMcpServer(def, options?)` | `devframe/adapters/mcp` | +## Deployment map — pick by how it's served, not by what it does -The same `DevframeDefinition` runs under every adapter - pick based on deployment, not on what the tool does. +The same `DevframeDefinition` runs under every one of these. Choose based on where the tool needs to live. -For Vite-based hosts that don't use the kit (Nuxt, Astro, SolidStart, plain Vite apps), `@devframes/vite` exports `devframeVitePlugin(def, options?)` (static SPA mount) and `devframeViteBridge(def, options?)` (RPC + WS bridge alongside the host's dev server) - plus `devframeVite(def, { bridge, ...options })` as a convenience wrapper over both. Not an adapter; just a Vite integration helper. +**Serve one devframe:** + +| Goal | Entry | Import | +|------|-------|--------| +| Mount into any host (the portability primitive) | `initDevframe(def, { base })` → `.handler` / `.nodeMiddleware` | `devframe/initiate` | +| Standalone CLI (dev / build / mcp subcommands) | `createCac(def, opts?).parse()` | `devframe/adapters/cac` | +| Programmatic dev server | `createDevServer(def, opts?)` | `devframe/adapters/dev` | +| Self-contained static deploy with baked data | `createBuild(def, opts?)` | `devframe/adapters/build` | +| MCP server for coding agents | `createMcpServer(def, opts?)` | `devframe/adapters/mcp` | +| Runtime registration into an existing host | `createEmbedded(def, { ctx })` | `devframe/adapters/embedded` | +| Ride along a Vite dev server (no dock) | `devframeVitePlugin` / `devframeViteBridge` / `devframeVite` | `@devframes/vite/single` | +| Author one devframe's SPA with Next | `withDevframe` + `createDevframeNextHandler` | `@devframes/next/single` | +| Author one devframe's SPA with Nuxt | Nuxt module | `@devframes/nuxt/single` | +| Mount into the Vite DevTools dock | `createPluginFromDevframe(def, opts?)` | `@vitejs/devtools-kit/node` | + +**Serve many devframes as a devtools host:** + +| Goal | Entry | Import | +|------|-------|--------| +| Compose a hub behind one handler | `initHub({ base, devframes, ui })` | `@devframes/hub/initiate` | +| Imperatively mount into a hub context | `createHubContext(...)` → `ctx.install(def)` | `@devframes/hub/node` | +| Reference viewer UI for a hub | `createUi(opts?)` | `@devframes/hub-ui` | +| Mount a hub inside Vite / Next / Nuxt | `viteDevframeHub` / `nextDevframeHub` / hub module | `@devframes/{vite,next,nuxt}/hub` | + +`createCac`, `createDevServer`, the `@devframes/vite` bridge, and `@devframes/next` are all assembled from `initDevframe` internally - the standard handler is the one wiring underneath every serving path. ## Minimum viable devframe ```ts import { defineDevframe, defineRpcFunction } from 'devframe' +import pkg from '../package.json' with { type: 'json' } export default defineDevframe({ id: 'my-inspector', - name: 'My Inspector', - version: '1.0.0', - packageName: 'my-inspector', - homepage: 'https://github.com/me/my-inspector', - description: 'Inspects things and reports stats.', + name: 'My Inspector', // display label — distinct from packageName + version: pkg.version, + packageName: pkg.name, + importMetaUrl: import.meta.url, // resolution base for the tool's own deps (assets, services) + homepage: pkg.homepage, + description: pkg.description, icon: 'ph:magnifying-glass-duotone', cli: { distDir: './client/dist' }, setup(ctx) { - const my = ctx.scope('my-inspector') // preferred - auto-namespaces ids + const my = ctx.scope('my-inspector') // preferred — auto-namespaces ids my.rpc.register(defineRpcFunction({ name: 'get-stats', // stored as `my-inspector:get-stats` type: 'static', @@ -67,65 +89,89 @@ export default defineDevframe({ }) ``` -**Recommended:** keep `version` / `packageName` / `homepage` / `description` in sync with your published package by sourcing them from `package.json` rather than hardcoding. The package's `name` maps to `packageName`; the devframe `name` is a separate display label. Use the JSON import-attribute form so it resolves under both bundlers and Node's native TypeScript execution: +Source `version` / `packageName` / `homepage` / `description` from your published `package.json` (the JSON import-attribute form resolves under both bundlers and Node's native TypeScript execution). Always pass `importMetaUrl: import.meta.url` - it is the base the host resolves the tool's own companion packages against (a `--assets` package holding the built SPA, a wire-service package), so a plugin ships them as its own dependencies and users install nothing extra. + +`setup(ctx, info?)` runs in **every** runtime and does all devframe-level wiring: RPC functions, shared state, streaming channels, diagnostics, agent surface. Its optional second argument carries runtime metadata (most notably parsed CLI `flags` under `createCac`). Gate per-runtime work on `ctx.mode` (`'dev'` | `'build'`). + +**A plugin's default export is its `createDevframe` factory, never a pre-built instance** - `export default createMyInspectorDevframe`, so importing the module costs nothing and each consumer calls the factory (with or without options) to get its own instance. + +See `templates/counter-devframe.ts` for a runnable example, `templates/hub.ts` for composing a hub, and `templates/vite-client.ts` for the author's client entry. + +## The standard handler (`initDevframe`) + +This is the portability trick and the thing to reach for whenever a host can mount a route. `base` is required, so the mount path is explicit at the call site. ```ts -import pkg from '../package.json' with { type: 'json' } +import { initDevframe } from 'devframe/initiate' +import myDevframe from './devframe' -export default defineDevframe({ - id: 'my-inspector', - name: 'My Inspector', - version: pkg.version, - packageName: pkg.name, - homepage: pkg.homepage, - description: pkg.description, - // … -}) +const devtools = initDevframe(myDevframe, { base: '/__my-tool/' }) +// devtools.base, .handler, .nodeMiddleware, .attach, .handleUpgrade, +// .ready, .context, .connectionMeta(), .close() +``` + +Mount `.handler` (Web-Standard) or `.nodeMiddleware` (Connect-style) on a catch-all route: + +```ts +// Hono — `serve()` returns the node server the socket rides on +app.all('/__my-tool/*', c => devtools.handler(c.req.raw)) +devtools.attach(serve({ fetch: app.fetch, port: 3000 })) + +// Vite — connect middleware + Vite's own server for the socket +server.middlewares.use(initDevframe(myDevframe, { + base: '/__my-tool/', + server: server.httpServer ?? undefined, +}).nodeMiddleware) ``` -`setup(ctx)` registers RPC functions, shared state, diagnostics, and any other devframe-level wiring. Host adapters can augment `ctx` with extra surfaces - for example, mounting into Vite DevTools via `createPluginFromDevframe(d)` exposes `docks`, `terminals`, `messages`, and `commands` on the augmented context, and the kit auto-derives an iframe dock entry from `id` / `name` / `icon` / `basePath`. For richer host-side behaviour (custom-render, terminals, palette commands) pass `options.setup` to `createPluginFromDevframe`. +`devtools.base` is the normalized mount base - reference it in route guards instead of repeating the string. + +**The WebSocket binding** is the host's explicit call. Fetch handlers only hand over `Request`s, so the RPC socket needs its own binding, resolved in precedence order: + +1. `ws.port` — a side-car on that exact port. +2. `server` — share the host's `node:http` server; the upgrade binds at `__ws`. Zero extra ports, follows the app through proxies/HTTPS. +3. `ws: { sidecar: true }` — a side-car on a free port, for hosts whose handlers never see upgrades (Next.js route handlers, Nitro, SvelteKit, Rsbuild). +4. **The host's own upgrades** — with none of the above, `devtools.attach(server)` routes a server's `upgrade` events (returns a detach fn) and `devtools.handleUpgrade(req, socket, head)` completes a single one. Built lazily — an instance nobody attaches costs nothing. + +`ws.url` instead controls the *advertisement* (the tunnel/external-transport pattern). Whichever is active, `__connection.json` describes it and the browser client follows. -See `templates/counter-devframe.ts` for a runnable counter example and `templates/vite-client.ts` for the author's client entry. +Frameworks that re-evaluate modules in dev (Next, Nitro, SvelteKit) must memoize the instance on `globalThis`, or every reload leaks the previous WebSocket server. The framework packages do this for you. + +**Auth gates by default** - a handler mounted inside an app server is reachable by anything that can open its socket. The interactive OTP handler is wired automatically and prints its code / magic-link once the public origin is known. Pass `auth: false` only for a single-user localhost setup, or a `DevframeAuthHandler` for a custom scheme. ## Scoped context (preferred) -`ctx.scope(id)` (server) and `client.scope(id)` (browser) return a namespace-scoped view that auto-prefixes every RPC id, shared-state key, and streaming channel with `id:`, and adds a top-level persisted `settings` store. Prefer it over the raw `ctx.rpc` / client - you name the namespace once and register / call by bare name. +`ctx.scope(id)` (server) and `client.scope(id)` (browser) return a namespace-scoped view that auto-prefixes every RPC id, shared-state key, and streaming channel with `id:`, and adds a top-level persisted `settings` store. Prefer it over raw `ctx.rpc` / client - name the namespace once, register and call by bare name. ```ts -// server - setup(ctx) +// server — setup(ctx) const my = ctx.scope('my-inspector') -my.rpc.register(getStats) // -> my-inspector:get-stats -await my.rpc.call('get-stats') // invokeLocal, namespaced +my.rpc.register(getStats) // -> my-inspector:get-stats +await my.rpc.call('get-stats') // invokeLocal, namespaced const state = await my.rpc.sharedState('view') // -> my-inspector:view await my.settings.project.set('theme', 'dark') -// browser - connectDevframe() +// browser — connectDevframe() const my = (await connectDevframe()).scope('my-inspector') const stats = await my.rpc.call('get-stats') ``` -- **Auto-namespacing.** Bare names get `id:` prepended; a name already containing `:` is treated as fully-qualified and passed through (so `my.rpc.call('other-tool:fn')` works). `register` only accepts bare names - passing a namespaced one throws `DF0034`. -- **Typed bare calls.** Define functions with bare names and augment the registry with `RpcDefinitionsToFunctionsWithNamespace<'my-inspector', typeof serverFunctions>` so the registry keys match the namespaced runtime ids; scoped `call('get-stats')` then stays typed. -- **`base`.** The scoped context keeps the raw context as `my.base` (and re-exposes `views` / `diagnostics` / `agent` / `host` / `cwd` / `mode` on the server). +- **Auto-namespacing.** Bare names get `id:` prepended; a name already containing `:` is treated as fully-qualified and passed through (so `my.rpc.call('other-tool:fn')` works). `register` only accepts bare names - a namespaced one throws `DF0034`. +- **Typed bare calls.** Define functions with bare names and augment the registry with `RpcDefinitionsToFunctionsWithNamespace<'my-inspector', typeof serverFunctions>` so registry keys match the namespaced runtime ids; scoped `call('get-stats')` then stays typed. +- **`base`.** The scoped context keeps the raw context as `my.base` (and re-exposes `views` / `diagnostics` / `agent` / `services` / `host` / `cwd` / `mode` on the server). ### Settings -`my.settings` is a persisted key-value store at the **top level** of the scoped context (a sibling of `my.rpc`, not under it). Two scopes: - -- `project` - per-workspace, persisted under the host's `workspace` storage dir. -- `global` - per-user, persisted under the host's `global` storage dir. - -Both are file-backed on the server and synced to clients over the shared-state protocol, so a `set` on either side propagates everywhere and survives restarts. All methods are async. +`my.settings` is a persisted key-value store at the **top level** of the scoped context (a sibling of `my.rpc`). Two scopes: `project` (per-workspace) and `global` (per-user). Both are file-backed on the server and synced to clients over the shared-state protocol, so a `set` on either side propagates everywhere and survives restarts. All methods are async. ```ts await my.settings.project.set('theme', 'dark') await my.settings.project.get('theme') // 'dark' await my.settings.global.all() -await my.settings.project.delete('theme') const off = await my.settings.global.onChange(value => apply(value)) ``` -Type a namespace's settings shape by augmenting `DevframeSettingsRegistry`: +Type a namespace's settings by augmenting `DevframeSettingsRegistry`: ```ts declare module 'devframe' { @@ -135,9 +181,37 @@ declare module 'devframe' { } ``` +## DevframeNodeContext at a glance + +`setup(ctx)` receives the framework-neutral server-side surface: + +| Host | Purpose | +|------|---------| +| `ctx.scope(id)` | **Preferred** namespace-scoped view — auto-prefixed `rpc` + top-level `settings` store | +| `ctx.rpc` | Register RPC functions, broadcast, shared state, streaming channels | +| `ctx.views` | Serve static files via `hostStatic(base, distDir)` | +| `ctx.diagnostics` | Structured diagnostics host (nostics) — register custom error codes | +| `ctx.agent` | Expose tools + resources to coding agents | +| `ctx.services` | Typed cross-plugin service registry (`provide` / `whenAvailable`) | +| `ctx.staticConfig` | This context's own `ConnectionMeta.configs` — boot-time, read-only-from-browser data | +| `ctx.host` | Runtime abstraction — `mountStatic`, `resolveOrigin`, `getStorageDir` | +| `ctx.mode` | `'dev'` or `'build'` — gate setup work per runtime | + +> Hub adapters augment `ctx` with extra surfaces (`docks`, `terminals`, `messages`, `commands`) — see [The Hub](#the-hub). The Vite DevTools kit exposes the same subsystems via an optional `setup` hook. + +**Storage scopes** — `ctx.host.getStorageDir(scope)` places persisted state in one of three classes: + +| Scope | Placement | For | +|-------|-----------|-----| +| `workspace` | committable, `/.devframe/` | team-shared presets, shared config | +| `project` | per-checkout, `/node_modules/./devframe/` | caches, personal settings | +| `global` | per-user, `~/./devframe/` | auth tokens, machine-wide prefs | + +Scoped settings persist their `project` scope through `project` storage and their `global` scope through `global`. + ## Project layout -Once a devframe grows past a handful of RPC functions, split them out - one file per function under `src/rpc/functions/`, with `src/rpc/index.ts` as the barrel. The `functions/` subdirectory leaves room for sibling files like `src/rpc/utils.ts` (helpers, type aliases) as the surface grows. Each function file exports a named const with a **bare** name; the barrel collects them into a `const serverFunctions = [...] as const` and feeds the type-safe client registry recipe with the namespace-aware helper `RpcDefinitionsToFunctionsWithNamespace<'my-tool', typeof serverFunctions>` (it prefixes each bare name to match the namespaced runtime id the scope registers). +Once a devframe grows past a couple of RPC functions, split them out - one file per function under `src/rpc/functions/`, with `src/rpc/index.ts` as the barrel that collects them into `const serverFunctions = [...] as const` and feeds the type-safe client registry via `RpcDefinitionsToFunctionsWithNamespace<'my-tool', typeof serverFunctions>`. ```ts // src/rpc/functions/list-files.ts @@ -145,7 +219,7 @@ import { defineRpcFunction } from 'devframe' import { getMyToolContext } from '../../context' export const listFiles = defineRpcFunction({ - name: 'list-files', // bare - the scope namespaces it to `my-tool:list-files` + name: 'list-files', // bare — the scope namespaces it to `my-tool:list-files` type: 'query', jsonSerializable: true, setup: (ctx) => { @@ -180,6 +254,7 @@ export default defineDevframe({ name: 'My Tool', version: pkg.version, packageName: pkg.name, + importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, setup(ctx) { @@ -190,21 +265,14 @@ export default defineDevframe({ }) ``` -Note `setMyToolContext(ctx, …)` keys off the raw `ctx` (the same object the function `setup(ctx)` receives) - store the per-tool context on `ctx`, register through `my.rpc`. - ### Sharing setup-time state via `src/context.ts` -When per-file RPCs need access to runtime values that `setup(ctx)` constructs once - streaming channels, shared state handles, watchers, loaders, caches - expose them through a `WeakMap` in a sibling `src/context.ts`. This mirrors the framework's own `internalContextMap` in `packages/devframe/src/node/hub-internals/context.ts`. The WeakMap keys off the existing `DevframeNodeContext` so contexts are garbage-collected automatically when the host tears down. +When per-file RPCs need runtime values `setup(ctx)` constructs once - channels, shared-state handles, watchers, loaders, caches - expose them through a `WeakMap` in a sibling `src/context.ts`. The WeakMap keys off the existing `DevframeNodeContext` so contexts are GC'd automatically when the host tears down. ```ts // src/context.ts import type { DevframeNodeContext } from 'devframe' -export interface MyToolContext { - loaders: { list: () => Promise } - // …channels, shared state handles, watchers, etc. -} - const map = new WeakMap() export function setMyToolContext(ctx: DevframeNodeContext, value: MyToolContext): void { @@ -214,49 +282,34 @@ export function setMyToolContext(ctx: DevframeNodeContext, value: MyToolContext) export function getMyToolContext(ctx: DevframeNodeContext): MyToolContext { const value = map.get(ctx) if (!value) - throw new Error('my-tool context not initialised - call setMyToolContext in devframe.setup') + throw new Error('my-tool context not initialised — call setMyToolContext in devframe.setup') return value } ``` -Stateless RPCs and tiny demos can keep the inline shorthand inside `setup(ctx)` - reach for `src/rpc/functions/` and `src/context.ts` once you have more than one or two functions, or any shared setup state. +Note `setMyToolContext(ctx, …)` keys off the raw `ctx` (the same object `setup(ctx)` receives), while registration goes through `my.rpc`. Stateless RPCs and tiny demos can keep the inline shorthand inside `setup(ctx)`. ## Namespacing -**Always prefix** RPC names, dock IDs, command IDs, shared-state keys, and agent tool IDs with the devframe `id`: +**Always prefix** RPC names, dock IDs, command IDs, shared-state keys, and agent tool IDs with the devframe `id` - a hub may mount many tools side by side. ```ts 'my-inspector:get-modules' // ✓ -'my-inspector:state' // ✓ -'get-modules' // ✗ - may collide with other devframes sharing the host +'get-modules' // ✗ — may collide with other devframes sharing the host ``` -A [scoped context](#scoped-context-preferred) applies this prefix for you - `ctx.scope('my-inspector').rpc.register({ name: 'get-modules' })` registers `my-inspector:get-modules`. Define and call by bare name through the scope; reach for full ids only via `ctx.base` or when targeting another tool. Dock / command IDs are host-level (not part of the scoped `rpc` surface) - prefix those by hand. - -## DevframeNodeContext at a glance - -`setup(ctx)` receives the framework-neutral server-side surface. Each host corresponds to a [docs](https://devfra.me/) page: - -| Host | Purpose | -|------|---------| -| `ctx.scope(id)` | **Preferred** namespace-scoped view - auto-prefixed `rpc` + top-level `settings` store | -| `ctx.rpc` | Register RPC functions, broadcast, shared state, streaming channels | -| `ctx.views` | Serve static files via `hostStatic(base, distDir)` | -| `ctx.diagnostics` | Structured diagnostics host (nostics) - register custom error codes | -| `ctx.agent` | Expose tools + resources to coding agents | -| `ctx.host` | Runtime abstraction - `mountStatic`, `resolveOrigin`, `getStorageDir` | -| `ctx.mode` | `'dev'` or `'build'` - gate setup work per runtime | - -> Hosts can augment `ctx` with additional surfaces (e.g. Vite DevTools' `docks`, `terminals`, `messages`, `commands`). Consult the host's docs - for Vite DevTools, see the [`vite-devtools-kit` skill](../../skills/vite-devtools-kit). +A [scoped context](#scoped-context-preferred) applies this prefix for RPC / shared-state / streaming. Dock and command IDs are host-level (not part of the scoped `rpc` surface) - prefix those by hand. ## RPC contracts +Built on [birpc](https://github.com/antfu/birpc), validated at runtime against any [Standard Schema](https://standardschema.dev/) validator (valibot, zod, arktype, …). Devframe forces no validator - install whichever you prefer. First-party `@devframes/*` code stays validator-neutral and uses the built-in zero-dep `devframe/utils/simple-schema` builder; for your own tool, valibot is the lightest default, or reuse zod if you already ship it. + ```ts import { defineRpcFunction } from 'devframe' import * as v from 'valibot' const getModules = defineRpcFunction({ - name: 'get-modules', // bare - registered via `ctx.scope('my-inspector').rpc.register` + name: 'get-modules', // bare — registered via `ctx.scope('my-inspector').rpc.register` type: 'query', jsonSerializable: true, args: [v.object({ limit: v.number() })], @@ -269,27 +322,23 @@ const getModules = defineRpcFunction({ | Type | Use when | Cached | Static dump | |------|----------|--------|-------------| -| `'static'` | Data constant for a given input - dump at build time | Indefinitely | Automatic | +| `'static'` | Data constant for a given input — dump at build time | Indefinitely | Automatic | | `'query'` | Read that may change; optional `dump` for build adapters | Opt-in via `cacheable` | Manual | | `'action'` | Server-state mutation | Never | Never | | `'event'` | Fire-and-forget; no response | Never | Never | -Add valibot schemas when the RPC is user-facing, when you want static dumps, or when you expose it to agents. Prefer a **single object arg** (`args: [v.object({ ... })]`) over positional args - property names self-document and agents rely on them. +Declared `args` / `returns` schemas are **enforced at runtime** - a failing call is rejected with `DF0043` / `DF0044`. Prefer a **single object arg** (`args: [v.object({ ... })]`) over positional args - property names self-document and agents rely on them. ### `jsonSerializable` (wire + dump format) -`jsonSerializable` declares the on-wire / on-disk shape contract: - | Value | Encoder | Wire prefix | Round-trips | |-------|---------|-------------|-------------| | `false` (default) | `structured-clone-es` | `s:` | `Map`, `Set`, `Date`, `BigInt`, cycles, class instances | | `true` (opt-in) | strict `JSON.stringify` | _(unprefixed)_ | JSON-only | -Set `jsonSerializable: true` when your handler returns plain JSON shapes - the strict serializer **throws `DF0020`** synchronously on the offending call when it sees a value JSON cannot round-trip (Map/Set/Date/BigInt/class instance/`undefined`-in-array). Errors surface in dev next to the call that introduced them, not silently at build time. +Set `jsonSerializable: true` when your handler returns plain JSON - the strict serializer **throws `DF0020`** synchronously on the offending call when a value can't round-trip through JSON, surfacing next to the call in dev. `agent: {...}` requires `jsonSerializable: true` (registration throws `DF0019` otherwise) - MCP tools speak JSON. -`agent: {...}` requires `jsonSerializable: true` (registration throws `DF0019` otherwise). MCP tools speak JSON - opting into the agent surface is also opting into JSON-only data. - -Through the scope, `my.rpc.broadcast({ method, args, optional?, event?, filter? })` pushes to every connected client (method name namespaced), and `my.rpc.call(name, ...args)` invokes a server function locally without going through transport (the scoped form of `ctx.rpc.invokeLocal`, useful for cross-function composition). +Through the scope, `my.rpc.broadcast({ method, args, optional?, event?, filter? })` pushes to every connected client (method name namespaced), and `my.rpc.call(name, ...args)` invokes a server function locally without transport (the scoped form of `ctx.rpc.invokeLocal`, for cross-function composition). ## Shared state @@ -305,13 +354,13 @@ state.mutate((draft) => { }) ``` -- Values must be serializable - no functions, no circular refs. +- Values must be serializable — no functions, no circular refs. - Mutations round-trip to all clients; the host tracks `syncIds` to avoid replay loops. - Prefer shared state over ad-hoc RPC events for UI that must reappear after reconnect. ## Streaming channels -For chunk-style data flowing in either direction - LLM deltas, log tails, build progress, file uploads, mic / screen-share frames - use a streaming channel instead of inventing `action + delta/end` events. The same `channel` object handles both directions: +For chunk-style data in either direction - LLM deltas, log tails, build progress, uploads - use a streaming channel instead of inventing `action + delta/end` events. ```ts const my = ctx.scope('my-inspector') @@ -319,194 +368,183 @@ const channel = my.rpc.streaming.create('tokens', { // -> my-inspector:t replayWindow: 256, // server keeps last N chunks per stream id closedStreamRetention: 30_000, // ms to hold finished streams for late subscribers }) -``` -### Server-to-client (the common case) - -```ts -// Server - typically inside an action handler that returns the stream id +// Server — typically inside an action handler that returns the stream id const stream = channel.start({ id: 'optional-stream-id' }) stream.write(token) // imperative -stream.error(err) // terminal failure -stream.close() // terminal success -stream.signal // AbortSignal - flips when consumers cancel or all subscribers drop -stream.writable // WritableStream for `pipeTo`-style consumption +stream.close() // terminal success; stream.error(err) for terminal failure +stream.signal // AbortSignal — flips when consumers cancel or all subscribers drop +await channel.pipeFrom(sourceReadable) // start + pipe in one call -// Convenience - start + pipe in one call: -await channel.pipeFrom(sourceReadable, { id: 'optional' }) - -// Client - my = (await connectDevframe()).scope('my-inspector') -const reader = my.rpc.streaming.subscribe('tokens', streamId) // -> my-inspector:tokens +// Client — my = (await connectDevframe()).scope('my-inspector') +const reader = my.rpc.streaming.subscribe('tokens', streamId) for await (const token of reader) renderToken(token) -// Or: reader.readable.pipeTo(domWritable) reader.cancel() // server `stream.signal` aborts ``` -### Client-to-server uploads +The same channel exposes `openInbound()` for the server side of a client→server upload; pair it with an action that returns the id, and the client drives `my.rpc.streaming.upload('files', uploadId)`. Web Streams are the canonical surface (Node 17+ ships `Readable.fromWeb` / `Writable.fromWeb` converters). Producers should poll `stream.signal.aborted` and exit cooperatively. + +**Streaming vs events vs shared state:** streaming for token/chunk feeds, uploads, per-call lifecycles with cancellation, and replay-on-reconnect; `event`-typed RPC for payload-free notifications and fire-and-forget signals; shared state for long-lived UI that survives reconnect. For chat UIs, keep the conversation log in shared state and stream active responses - working example: [`examples/streaming-chat`](https://github.com/devframes/devframe/tree/main/examples/streaming-chat). -The same channel exposes `openInbound()` for the server side of a client→server upload. Pair it with a normal action that returns the id: +## Agent-native surface + +Once a tool has a structured boundary, its visual panel is no longer the only interface: the same internal state and capabilities are consumable programmatically by coding agents, sharing one source of truth. RPC functions stay **private by default** and explicitly opt into agent exposure with an `agent` field. Agent-exposed functions must declare `jsonSerializable: true`. ```ts -// Server - my = ctx.scope('my-inspector') -my.rpc.register(defineRpcFunction({ - name: 'upload-file', // -> my-inspector:upload-file - type: 'action', - args: [v.object({ name: v.string() })], - returns: v.object({ uploadId: v.string() }), - handler: async ({ name }) => { - const reader = channel.openInbound() - ;(async () => { - for await (const chunk of reader) saveChunk(chunk) - })() - return { uploadId: reader.id } +defineRpcFunction({ + name: 'get-stats', + type: 'query', + jsonSerializable: true, + args: [v.object({ limit: v.number() })], + returns: v.object({ count: v.number() }), + agent: { + description: 'Return the top-N module stats. Safe to call freely.', + // safety inferred from type: 'query' → 'read' }, -})) - -// Client - my = (await connectDevframe()).scope('my-inspector') -const { uploadId } = await my.rpc.call('upload-file', { name: 'foo' }) -const upload = my.rpc.streaming.upload('files', uploadId) // -> my-inspector:files -fileReadable.pipeTo(upload.writable, { signal: upload.signal }) + setup: () => ({ handler: async ({ limit }) => ({ count: limit }) }), +}) ``` -Client disconnect surfaces as `UploadDisconnected` to the server's `for await`. Server-side `reader.cancel()` broadcasts `upload-cancel` to the uploading session, flipping `upload.signal`. +Or register tools / resources directly on `ctx.agent.registerTool({ id, description, safety, handler })` and `ctx.agent.registerResource({ id, name, mimeType, read })`. Expose the surface over MCP: -### Lifecycle +```ts +import { createMcpServer } from 'devframe/adapters/mcp' -| Event | Server | Client | -|-------|--------|--------| -| `stream.close()` / `stream.error(err)` | broadcasts `end` to subscribers | `for await` resolves / throws | -| `reader.cancel()` (last subscriber) | `stream.signal` aborts | reader marked cancelled | -| WS disconnects (last subscriber drops) | `stream.signal` aborts | reader auto-resubscribes after re-trust | +await createMcpServer(devframe, { transport: 'stdio' }) +``` -Producers should always poll `stream.signal.aborted` and exit cooperatively. +`@modelcontextprotocol/server` is a peer dependency. The CLI adapter also exposes `my-devframe mcp` (route host logs to stderr - stdout is the transport). Safety classifications (`'read' | 'action' | 'destructive'`) drive MCP hint annotations that agent clients use to prompt for confirmation. In a hub, `ctx.commands` entries opt into the same agent surface with an `agent` field and reach MCP through the aggregate endpoint. -### Web / Node Streams interop +## Author SPA -Web Streams are the canonical surface. Node 17+ ships free converters: +Authors bring their own SPA (any framework or plain HTML). The client code is **byte-identical** whether the tool runs standalone, embedded, or inside a hub - that is the portability promise. ```ts -import { Readable, Writable } from 'node:stream' +import { connectDevframe } from 'devframe/client' -sourceNodeReadable.pipe(Writable.fromWeb(stream.writable)) -Readable.fromWeb(reader.readable).pipe(targetNodeWritable) +const client = await connectDevframe() +const my = client.scope('my-inspector') // preferred — namespaced calls +const data = await my.rpc.call('get-stats', { limit: 10 }) ``` -### When to use streaming vs events vs shared state +`connectDevframe` auto-detects the backend via `./__connection.json`, resolved relative to the executing script's runtime base (so the SPA never hardcodes its mount path - build with `vite.base: './'`): + +- **websocket** (dev mode) — full read/write, requires the auth handshake. `await client.ensureTrusted()` blocks until the server accepts; listen for token updates on the `devframe-auth` BroadcastChannel. +- **static** (build output) — read-only, resolves calls from the baked RPC dump. -| Use streaming for | Use `event`-typed RPC for | Use shared state for | -|-------------------|---------------------------|----------------------| -| Token / chunk feeds, uploads | Notifications without payload (`refresh`) | Long-lived UI state that survives reconnect | -| Per-call lifecycles with cancellation | Cross-cutting fire-and-forget signals | Diff-based sync between clients | -| Replay on reconnect | | | +Use `my.rpc.sharedState(key)` for observable state, `my.rpc.register(...)` to receive server broadcasts, `my.rpc.callOptional(...)` when a missing handler should resolve to `undefined`, and `my.settings.{project,global}` for persisted settings synced from the server. -For chat-style UIs that combine both: keep the **conversation log** in shared state (survives reconnects), and use a streaming channel for **active responses**. The action that starts a response appends a placeholder to shared state; on producer close, commit the joined content back to shared state. Working example: [`examples/streaming-chat`](https://github.com/devframes/devframe/tree/main/examples/streaming-chat). +## The Hub -## Mounting into a host (Vite DevTools example) +A single devframe is one portable tool; a hub is where many tools **meet and collaborate**. `@devframes/hub` is the framework-neutral composition layer. It adds the orchestration subsystems that only make sense when tools share a UI, and it ships no UI of its own - a viewer product fills the `ui` slot. -A portable devframe can be mounted into any host that ships an adapter for it. Vite DevTools is one supported target - `createPluginFromDevframe` is the Vite-DevTools-specific bridge; other hosts can implement equivalent factories. Example: +`initHub()` puts the whole collection behind one standard handler with the same surface and mount snippets as `initDevframe`: ```ts -// vite.config.ts -import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' -import myInspector from './my-inspector' - -export default { - plugins: [ - createPluginFromDevframe(myInspector, { - // Optional kit-only setup - runs after the auto-derived dock entry. - setup(kitCtx) { - kitCtx.commands.register({ - id: 'my-inspector:clear-cache', - title: 'Clear Cache', - handler: () => { /* ... */ }, - }) - }, - }), - ], -} +import { createUi } from '@devframes/hub-ui' +import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' +import createInspectDevframe from '@devframes/plugin-inspect' +import createTerminalsDevframe from '@devframes/plugin-terminals' + +export const hub = initHub({ + base: DEVFRAMES_HUB_BASE, // required — the conventional `/__devframes/` + devframes: [createInspectDevframe(), createTerminalsDevframe()], + ui: createUi(), // reference viewer + floating dock; `ui: false` for headless + configure(ctx) { + ctx.commands.register({ id: 'app:hello', title: 'Hello', handler: () => 'hi' }) + }, +}) + +hub.handler // the whole devtools ecosystem as Request -> Response ``` -The kit auto-derives an iframe dock entry from `id` / `name` / `icon` / `basePath`. For dock variations (custom-render, launcher, action, json-render), terminals, palette commands, and toasts, use the `options.setup` hook - those APIs live on the host-augmented context, not on the devframe-level `setup`. See the [`vite-devtools-kit` skill](../../skills/vite-devtools-kit) for the Vite-DevTools-specific reference. +Every mounted devframe runs its `setup()` against **one shared hub context**: a merged RPC registry (frames can call each other's functions), one shared-state store, one WebSocket transport, and one auth gate. Frame ids become URL segments (`/`) and are validated (reserved → `DF8000`, non-route-safe → `DF8004`). -## When clauses +### Hub subsystems -Gate kit-side dock / command visibility with VS Code-style expressions. The runtime + types ship bundled from `devframe/utils/when` - no separate install. The consumers (`when` field on docks and commands) live in the kit: +A hub-aware `DevframeHubContext` extends `DevframeNodeContext` with four subsystems: -```ts -when: 'clientType == embedded' -when: 'dockOpen && !paletteOpen' -when: 'my-inspector.ready && count >= 10' -``` +| Subsystem | Surface | Purpose | +|-----------|---------|---------| +| `ctx.docks` | `register / update / values / activate` | Dock entries (iframe, launcher, custom-render, group, and opt-in types) and cross-iframe activation. | +| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. | +| `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped 1000). | +| `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. | -Built-in context: `clientType` (`'embedded' | 'standalone'`), `dockOpen`, `paletteOpen`, `dockSelectedId`. Plugins can add namespaced keys (`.` or `:` separators). Both the types (`WhenExpression`) and runtime (`evaluateWhen`, `resolveContextValue`) come from `devframe/utils/when`. +The dock union is **open** - opt-in integrations contribute their own entry types (e.g. JSON-Render adds a `json-render` dock type with no JSON-render dependency in the hub). `ctx.docks.activate(dockId, params?)` steers which dock the viewer shows; from a mounted iframe, `rpc.call('hub:docks:activate', { dockId, params })` does the same cross-iframe. A `type: 'launcher'` dock binds a command, streams a `digest` line, and jumps to its terminal session - the pattern that lets an analyzer spawn `vite build` and navigate the user to its output. -## Agent-native surface +### Mounting into a hub -Opt an RPC function into the agent surface with an `agent` field - default-deny otherwise. Agent-exposed functions **must declare `jsonSerializable: true`** (registration throws `DF0019` otherwise): +`ctx.install(def)` is the framework-neutral primitive - it registers any `DevframeDefinition` as a dock and runs its `setup(ctx)` - and the imperative counterpart to `initHub`'s declarative `devframes` list: ```ts -defineRpcFunction({ - name: 'my-inspector:get-stats', - type: 'query', - jsonSerializable: true, - args: [v.object({ limit: v.number() })], - returns: v.object({ count: v.number() }), - agent: { - description: 'Return the top-N module stats. Safe to call freely.', - // safety inferred from type: 'query' → 'read' - }, - setup: () => ({ handler: async ({ limit }) => ({ count: limit }) }), -}) +import { createHubContext } from '@devframes/hub/node' + +const ctx = await createHubContext({ cwd, host, mode: 'dev' }) +await ctx.install(myDevframe) ``` -Or register tools / resources directly: +When a devframe sharing an already-mounted `id` is installed, its `duplicationStrategy` (`'warn'` default / `'silent'` / `'throw'` / `'duplicate'`) decides the outcome. -```ts -ctx.agent.registerTool({ - id: 'my-inspector:summarize', - description: 'Plain-text summary of the current scan.', - safety: 'read', - handler: async () => ({ markdown: buildSummary() }), -}) +### The protocol — what a viewer sees -ctx.agent.registerResource({ - id: 'current-scan', - name: 'Current scan', - mimeType: 'text/markdown', - read: () => ({ text: renderMarkdown(currentScan) }), -}) -``` +A hub-aware UI imports no hub classes; it reads shared-state keys and one RPC method: -Expose via MCP: +| Channel | Type | Carries | +|---------|------|---------| +| `devframe:docks` (shared state) | `DevframeDockEntry[]` | Every registered dock entry. | +| `devframe:commands` (shared state) | `DevframeServerCommandEntry[]` | Serializable command list (handlers stripped). | +| `devframe:docks:active` (shared state) | `DevframeDocksActiveState` | Most recent dock-activation request. | +| `hub:commands:execute` (RPC) | `(id, ...args) => unknown` | Server-side command dispatch. | +| `hub:docks:activate` (RPC) | `({ dockId, params? }) => void` | Switch the active dock from any client. | -```ts -import { createMcpServer } from 'devframe/adapters/mcp' +Plus broadcasts (`devframe:docks:activate`, `devframe:terminals:updated`, `devframe:messages:updated`). The hub also ships a headless browser runtime, `createDevframeClientHost()` from `@devframes/hub/client`: booted in the host page, it assembles the shared client context from this protocol and imports each dock entry's client script into that page - how a plugin like the a11y inspector runs code inside the page being inspected. -await createMcpServer(devframe, { transport: 'stdio' }) -``` +### The `ui` slot -`@modelcontextprotocol/server` is a peer dependency. The CLI adapter also exposes `my-devframe mcp` - route host logs to stderr (stdout is the MCP transport). Safety classifications (`'read' | 'action' | 'destructive'`) drive MCP hint annotations that agent clients use to prompt for confirmation. +The hub is headless; `DevframeHubUi` is pure data (`viewer` / `embedded` / `assets` / `setup`). `@devframes/hub-ui`'s `createUi()` is the reference implementation - a standalone viewer plus a floating dock injected via one `