--- title: Introduction navigation: icon: i-lucide-book-open description: Devframe is a framework-neutral foundation for building a devtool once and running it everywhere - inside any host framework, as a standalone tool, or through a coding agent. --- **Devframe is a framework-neutral foundation for building a devtool once and running it everywhere - inside any host framework, as a standalone tool, or through a coding agent.** A devtool here is anything that makes a program's implicit state visible and interactive: an inspector, a build or bundle analyzer, an asset viewer, a state or data explorer, a terminal. You describe such a tool one time, and the same definition mounts almost anywhere. Think of it as [`unplugin`](https://unplugin.unjs.io/) for devtools. ## Why it exists Most devtools rebuild the same plumbing (node–browser communication, state synchronization, serialization, static-asset hosting, a web interface) and wire it to one framework's dev server. The same idea then gets rebuilt, slightly differently, for the next framework, so effort fragments across the ecosystem instead of compounding. Devframe moves that boundary. A capability is defined once against a stable interface and runs on every supported host framework, so a good tool can be built once, travel further, and improve through the work of more communities. ## Who it's for - **Devtool authors** who want one tool to run standalone, embed in a host framework, ship as a CLI or static report, and answer to a coding agent, without maintaining a separate version per environment. - **Framework and build-tool teams** who want to offer devtools without rebuilding shared infrastructure, and to inherit capabilities other communities already built. - **Anyone** who wants a tool's state and actions available to both a human UI and a coding agent from one source of truth. With a coding agent to scaffold the boilerplate, Devframe is also a fast foundation for standing up a bespoke, specific-need, or even one-off devtool. New here? [Answer a few questions about your devtool](/guide/getting-started) and get a reading list tailored to it. For the story behind Devframe, read [Pluggable, Extensible, and Playful DevTools](/posts/pluggable-extensible-playful-devtools). ## One definition, one standard handler Every devframe starts with [`defineDevframe()`](/guide/devframe-definition), pairing a tool's identity with its capabilities. ```ts import { defineDevframe } from 'devframe' import { inspectProject } from './rpc' export default defineDevframe({ id: 'my-tool', name: 'My Tool', /** package metadata and client entry omitted… */ setup(ctx) { ctx.scope('my-tool').rpc.register(inspectProject) }, }) ``` [`initDevframe()`](/adapters/initiate) turns the definition into a live instance whose `handler` is a Web Standard `(request: Request) => Promise`: ```ts import { initDevframe } from 'devframe/initiate' import devframe from './devframe' const devtools = initDevframe(devframe, { base: '/__my-tool/' }) devtools.handler // (request: Request) => Promise devtools.nodeMiddleware // (req, res, next) => void, for Connect-style servers (Vite, Rsbuild) ``` The handler serves the web interface, connection metadata, live RPC, authentication, and optional MCP endpoint under one namespace. Hono and Nitro take Web Standard requests directly; Next.js and SvelteKit expose route handlers; Vite and Rsbuild accept its `nodeMiddleware`. The live RPC connection attaches via a shared HTTP server, upgrade events, or a side-car server, advertised through `__connection.json`. See [The Standard Handler](/adapters/initiate). ## Adapters as conveniences [Higher-level adapters](/adapters) package the foundation as a standalone CLI, dev server, Vite DevTools plugin, MCP server, or static report: ```ts import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' import { createBuild } from 'devframe/adapters/build' import { createCac } from 'devframe/adapters/cac' import { createDevServer } from 'devframe/adapters/dev' import { createMcpServer } from 'devframe/adapters/mcp' import devframe from './devframe' /** Pick the entry points your package ships: */ export const runCli = () => createCac(devframe).parse() export const startServer = () => createDevServer(devframe) export const vitePlugin = createPluginFromDevframe(devframe) export const startMcp = () => createMcpServer(devframe, { transport: 'stdio' }) export const buildReport = () => createBuild(devframe, { outDir: 'dist-static' }) ``` ## Visual and agentic One source of truth feeds a visual panel and programmatic consumers. RPC functions stay private by default and opt into agent exposure explicitly: the [MCP adapter](/adapters/mcp) translates functions, readable resources, and selected shared state into an agent-consumable interface. See [Agent-Native](/guide/agent-native). ## From one devframe to a hub [`@devframes/hub`](/guide/hub) is the composition layer, providing shared concepts (docks, commands, messages, terminals) against a shared context. [`initHub()`](/guide/hub-initiate) puts many devframes behind one Web Standard handler: ```ts import { createUi } from '@devframes/hub-ui' import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' import { createDataInspectorDevframe } from '@devframes/plugin-data-inspector' import { createTerminalsDevframe } from '@devframes/plugin-terminals' const hub = initHub({ base: DEVFRAMES_HUB_BASE, devframes: [createDataInspectorDevframe(), createTerminalsDevframe()], ui: createUi(), }) hub.handler // the whole devtools collection as Request → Response ``` The mounted devframes share one RPC registry, state store, connection, auth gate, and optional aggregate MCP endpoint. The hub is headless: [`@devframes/hub-ui`](/guide/build-your-own-hub-ui) is a reference hub UI provider a product can replace. ## Inheriting the ecosystem [Vite DevTools](https://devtools.vite.dev/) is the first flagship hub UI provider, using `initHub()` alongside its own Vite, Rolldown, Vitest, and Oxc tooling. The [framework kits](/frameworks) ([`@devframes/vite`](/frameworks/vite), [`@devframes/nuxt`](/frameworks/nuxt), [`@devframes/next`](/frameworks/next)) add conventions over the same handler. See the [Ecosystem](/ecosystem) page. ## Install ```sh pnpm add devframe ``` `devframe` ships ESM-only, no Vite dependency. The CLI adapter's optional peer (`cac`) surfaces its requirement at import time. ## Hello, Devframe A minimal devframe with a CLI entry point: ```ts import { defineDevframe, defineRpcFunction } from 'devframe' import { createCac } from 'devframe/adapters/cac' const devframe = defineDevframe({ id: 'my-devframe', name: 'My Devframe', version: '1.0.0', packageName: 'my-devframe', homepage: 'https://github.com/me/my-devframe', description: 'A one-line summary of what the tool does.', icon: 'ph:gauge-duotone', clientAssets: 'client/dist', setup(ctx) { ctx.rpc.register(defineRpcFunction({ name: 'my-devframe:hello', type: 'static', jsonSerializable: true, handler: () => ({ message: 'hello' }), })) }, }) await createCac(devframe).parse() ``` Run it: ```sh node ./my-devframe.js # dev server on http://localhost:9999/ node ./my-devframe.js build # self-contained static deploy in dist-static/ node ./my-devframe.js mcp # stdio MCP server ``` The CLI adapter serves the SPA at `/`; embedded in a host framework (`vite`, `embedded`) the default becomes `/__my-devframe/`. Override via `defineDevframe({ basePath })`. ## What Devframe provides | Subsystem | What it does | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | **[Devframe Definition](/guide/devframe-definition)** | One `defineDevframe` call describes your tool; adapters deploy it anywhere. | | **[RPC](/guide/rpc)** | Type-safe bidirectional calls on birpc, validated against any Standard Schema validator. `query`, `static`, `action`, `event` types. | | **[Shared State](/guide/shared-state)** | Observable, patch-synced state surviving reconnects, node side ↔ browser side. | | **[JSON-Render](/guide/json-render)** | Opt-in data-driven UI: a serializable view spec, rendered standalone or in a hub dock. | | **[Diagnostics](/guide/diagnostics)** | Coded warnings/errors via `nostics`, in the host framework's shared lookup. | | **[Streaming](/guide/streaming)** | One-way (RPC streaming) and two-way (uploads) channel primitives. | | **[When Clauses](/references/when-clauses)** | VS Code-style conditional expressions for docks, commands, and custom UI. | | **[The Standard Handler](/adapters/initiate)** | `initDevframe()`: the Web Standard `Request → Response` boundary. | | **[Client](/guide/client)** | Browser RPC client (`connectDevframe`), auto-auth, WebSocket / static modes. | | **[Agent-Native](/guide/agent-native)** | Opt-in exposure of your tool's capabilities to coding agents over MCP. | ## What's next - [Tutorial: Build a Server Data Inspector](/guide/tutorial-server-data-inspector): go from an empty folder to a shippable devtool, one capability at a time - [Devframe Definition](/guide/devframe-definition): `defineDevframe` and `DevframeNodeContext` - [The Standard Handler](/adapters/initiate): mount into any host framework - [Adapters](/adapters): convenience entry points - [Hub](/guide/hub): compose many devframes - [Pluggable, Extensible, and Playful DevTools](/posts/pluggable-extensible-playful-devtools): the vision and story behind Devframe --- title: Getting Started navigation: icon: i-lucide-rocket description: Answer a few questions about the devtool you want to build and get a reading list tailored to it. --- Devframe is a framework-neutral foundation for building a devtool once and running it everywhere: inside any host framework, as a standalone tool, or through a coding agent. See the [Introduction](/guide) for the full picture, or read [Pluggable, Extensible, and Playful DevTools](/posts/pluggable-extensible-playful-devtools) for the story behind it. Devframe provides a lot of features to build DevTools with various needs. It could be overwhelming to start with. Here we have the wizard to help you get started. Answer a few questions about the devtool you want to build and get a reading list tailored to it. ::getting-started-wizard :: None of this is a required reading order. [`defineDevframe()`](/guide/devframe-definition) and [the tutorial](/guide/tutorial-server-data-inspector) are worth reading regardlessly. --- title: 'Tutorial: Build a Server Data Inspector' navigation: icon: i-lucide-graduation-cap description: Build a devtool that displays and queries live server-side data, then ship it as a hub dock entry, a static build, a standalone dev server, and a CLI. --- Let's build a real devtool from scratch: a **Data Inspector** that shows the shape of live server-side state and lets you read any value out of it. We'll get it working first, then teach it new tricks one at a time: a dock entry in a hub, a static build, a standalone dev server, and a CLI. You'll need [Node 24+](https://nodejs.org/) and a terminal. Every code block is complete, so you can copy them as you go. ## The shape of a devframe A devframe is two halves talking over a typed connection: the **node side** exposes functions, and the **browser side** calls them and renders the results. Devframe is everything in between: the wire, the UI hosting, auth, builds, and a CLI. The two halves live in their own folders: the node side under `src/node/`, the web app under `app/`. A `playgrounds/` folder holds hosts that boot the built tool. We'll fill these in as we go. ## Step 1: Define the tool Everything starts with `defineDevframe`: your tool's name, plus a `setup` where you register what it can do. Create the project and the definition: ```sh mkdir data-inspector && cd data-inspector npm init -y && npm pkg set type=module npm install devframe && npm install -D typescript ``` ```ts [src/node/data-inspector.ts] import { defineDevframe } from 'devframe' // Some example server-side data, whatever you want to peek at while your // user app runs: config, a cache, a DB handle. const serverState = { config: { name: 'Acme', port: 3000, debug: false }, users: [ { id: 1, name: 'Ada', admin: true }, { id: 2, name: 'Lin', admin: false }, ], featureFlags: { newDashboard: true, betaSearch: false }, } // A tiny query helper that follows a dot-path like `users.0.name` into the state. function valueAtPath(root: unknown, path: string): unknown { if (!path) return root return path.split('.').reduce((value, key) => { if (value == null || typeof value !== 'object') return undefined return (value as Record)[key] }, root) } const dataInspectorFrame = defineDevframe({ id: 'data-inspector', name: 'Data Inspector', version: '0.0.0', packageName: 'data-inspector', description: 'Inspect live server state.', homepage: 'https://example.com', importMetaUrl: import.meta.url, setup(ctx) { // What does the state look like? ctx.rpc.register({ name: 'data-inspector:get-meta', type: 'query', jsonSerializable: true, handler: () => Object.entries(serverState).map(([key, value]) => ({ key, type: Array.isArray(value) ? 'array' : typeof value, length: Array.isArray(value) ? value.length : undefined, })), }) // What's at this path? ctx.rpc.register({ name: 'data-inspector:query', type: 'query', jsonSerializable: true, handler: (path: string) => valueAtPath(serverState, path), }) }, }) export default dataInspectorFrame ``` `ctx.rpc.register` publishes a function the browser side can call: a namespaced `name`, a `type` (`query` is read-only), and a `handler` that takes the call's arguments and returns JSON. That's the whole node side. ([RPC](/guide/rpc) has the other types; [Devframe Definition](/guide/devframe-definition) has every field.) ## Step 2: Add a UI Now the browser side. We'll use React here, but any framework works; the only devframe-specific line is `connectDevframe`, which opens the connection back to the node side. ```sh npm install react react-dom @devframes/vite npm install -D vite @vitejs/plugin-react @types/react @types/react-dom ``` ```html [app/index.html] Data Inspector
``` ```tsx [app/main.tsx] import { createRoot } from 'react-dom/client' import { App } from './App' createRoot(document.getElementById('app')!).render() ``` ```tsx [app/App.tsx] import type { DevframeRpcClient } from 'devframe/client' import { connectDevframe } from 'devframe/client' import { useEffect, useState } from 'react' interface MetaEntry { key: string, type: string, length?: number } export function App() { const [rpc, setRpc] = useState() const [meta, setMeta] = useState([]) const [path, setPath] = useState('config') const [result, setResult] = useState() useEffect(() => { // No argument: the RPC client finds the node side from the page's own // URL, so this line never changes no matter how the tool is hosted. connectDevframe().then(async (client) => { setRpc(client) const call = client.call as (name: string, ...args: unknown[]) => Promise setMeta(await call('data-inspector:get-meta')) }) }, []) async function runQuery() { if (!rpc) return const call = rpc.call as (name: string, ...args: unknown[]) => Promise setResult(await call('data-inspector:query', path)) } return (

Data Inspector

    {meta.map(m => (
  • {m.key} {' - '} {m.type} {m.length != null ? ` (${m.length})` : ''}
  • ))}
setPath(e.target.value)} placeholder="config.port" />
{JSON.stringify(result, null, 2)}
) } ``` `client.call(name, ...args)` reaches your handlers. (We cast `.call` and call by name here; wire up a typed registry and every call is checked end to end; see [RPC](/guide/rpc).) ## Step 3: Run it in development To try what we've built, let Vite serve the UI and hand RPC traffic to devframe: ```ts [app/vite.config.ts] import { devframeViteBridge } from '@devframes/vite/single' import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import dataInspectorFrame from '../src/node/data-inspector.ts' export default defineConfig({ base: './', // relative asset URLs, so the built UI works under any mount path build: { outDir: '../dist/client', emptyOutDir: true }, plugins: [ react(), // Vite serves the page; the bridge answers RPC on the same origin, so // `connectDevframe()` just finds it. `auth: false`, see the note below. devframeViteBridge(dataInspectorFrame, { base: '/', auth: false }), ], }) ``` ```sh npx vite --config app/vite.config.ts ``` Open the printed URL. The three keys and their types show up, and typing `config.port` or `users.0.name` and hitting **Query** prints the value. Button → `call` → your `handler` → back to the page: that's the whole devframe working. > [!WARNING] > `auth: false` trusts anything that can reach the port. It's off here to keep the tutorial simple; turn it on for anything you publish or expose beyond localhost. See [Security](/guide/security). From here on we reuse this same `src/node/data-inspector.ts` and `app/` unchanged; all that changes is where they run. ## Step 4: Dock it in a hub A [hub](/guide/hub) puts many devframes behind one interface, each a **dock entry** you switch between, the tool's own UI in an iframe. Since our SPA uses a bare `connectDevframe()`, it already works anywhere; the hub just needs the built UI, so point the definition at it: ```ts [src/node/data-inspector.ts] import { fileURLToPath } from 'node:url' // … const dataInspectorFrame = defineDevframe({ id: 'data-inspector', /** … */ clientAssets: fileURLToPath(new URL('../../dist/client', import.meta.url)), setup(ctx) { /* unchanged */ }, }) ``` Build the UI and stand up a one-devframe hub: ```sh npm install @devframes/hub @devframes/hub-ui npx vite build --config app/vite.config.ts ``` ```ts [playgrounds/hub.config.ts] import { createUi } from '@devframes/hub-ui' import { viteDevframeHub } from '@devframes/vite/hub' import { defineConfig } from 'vite' import dataInspectorFrame from '../src/node/data-inspector.ts' export default defineConfig({ plugins: [ viteDevframeHub({ devframes: [dataInspectorFrame], ui: createUi({ branding: { productName: 'My Devtools' } }), }), ], }) ``` ```sh npx vite --config playgrounds/hub.config.ts ``` Your inspector now sits in the hub's dock rail as a dock entry. Add more to `devframes: [...]` (your own or the [built-in devframes](/add-ons)) and each gets its own. (The hub prints a code to authorize on first connect.) ## Step 5: Build a static version Some tools should work with no node side at all: a report you can drop on any static hosting. `createBuild` renders the UI and **bakes in** the results of read-only calls. Opt one in with `snapshot: true`: ```ts ctx.rpc.register({ name: 'data-inspector:get-meta', type: 'query', jsonSerializable: true, snapshot: true, // bake this call's result into the build handler: () => { /* … unchanged … */ } }) ``` ```js [scripts/build.mjs] import { createBuild } from 'devframe/adapters/build' import dataInspectorFrame from '../src/node/data-inspector.ts' await createBuild(dataInspectorFrame, { outDir: 'dist-static' }) ``` ```sh npx vite build --config app/vite.config.ts # refresh dist/client node scripts/build.mjs # → dist-static/ ``` Serve `dist-static/` anywhere and the meta list renders from the baked snapshot, no Node in sight. `query` takes an argument, so it still needs the live node side (next), or you can bake specific inputs ([Client Assets](/guide/client-assets)). ## Step 6: Run it standalone The definition never depended on Vite. `createDevServer` runs the tool on its own, serving the UI from `clientAssets` and answering RPC live: ```js [playgrounds/serve.mjs] import { createDevServer } from 'devframe/adapters/dev' import dataInspectorFrame from '../src/node/data-inspector.ts' await createDevServer(dataInspectorFrame, { openBrowser: true }) ``` ```sh npx vite build --config app/vite.config.ts node playgrounds/serve.mjs ``` Same UI, same live calls, no bundler in the loop: this is what you'd drop into your own Node program. ## Step 7: Give it a CLI Finally, wrap that dev server in a CLI. `devframe/adapters/cac` turns a devframe into a CLI with `dev`, `build`, and `mcp` commands: ```js [bin.mjs] #!/usr/bin/env node import { createCac } from 'devframe/adapters/cac' import dataInspectorFrame from './src/node/data-inspector.ts' createCac(dataInspectorFrame).parse() ``` ```sh npm pkg set bin.data-inspector=bin.mjs node bin.mjs dev # the standalone dev server from Step 6 node bin.mjs build # the static build from Step 5 node bin.mjs mcp # expose the tool to a coding agent over MCP ``` You can also assemble your own CLI from the adapter functions used above. That's it for this tutorial. For a full-featured version, there's a ready-to-use [Data Inspector built-in devframe](/add-ons/devframes/data-inspector) to use or read for reference. ## What's next - [RPC](/guide/rpc): `action` and `event` calls, end-to-end types, schema validation - [Shared State](/guide/shared-state): push live changes to the UI without polling - [Hub](/guide/hub): docks, commands, terminals across many tools - [Agent-Native](/guide/agent-native): expose your tool to coding agents over MCP --- title: Devframe Definition navigation: icon: i-lucide-file-code description: One defineDevframe call returns a portable DevframeDefinition any adapter consumes. --- One `defineDevframe` call returns a portable `DevframeDefinition` any [adapter](/adapters) consumes. ## Minimal definition ```ts import { defineDevframe, defineRpcFunction } from 'devframe' import * as v from 'valibot' // npm i valibot export default defineDevframe({ id: 'my-tool', name: 'My Tool', version: '1.0.0', packageName: 'my-tool', importMetaUrl: import.meta.url, homepage: 'https://github.com/me/my-tool', description: 'A one-line summary of what the tool does.', icon: 'ph:gauge-duotone', setup(ctx) { // A scoped context auto-namespaces ids with your devframe `id`. const my = ctx.scope('my-tool') // Register your RPC functions, shared state, etc. here. my.rpc.register(defineRpcFunction({ name: 'hello', // stored as `my-tool:hello` type: 'static', jsonSerializable: true, handler: () => ({ message: 'hello' }), })) }, }) ``` ## Definition fields `id`, `name`, `version`, `packageName`, `homepage`, `description`, and `setup` are required; pass `importMetaUrl: import.meta.url` so [remote assets](/guide/client-assets) and declared [services](/guide/services#wire-services) resolve against the devframe's own dependencies. The remaining fields cover display (`icon`, [Icon values](/references/node-api#icon-values)), mounting (`basePath`, `duplicationStrategy`, `capabilities`), what the devframe consumes and serves (`services`, `clientAssets`, `rpc.snapshot`), and [CLI defaults](#cli-options) (`cli`). Every field is listed in the [Node-Side API reference](/references/node-api#definition-fields). ### Sourcing metadata from `package.json` Import metadata from `package.json` (`name` → `packageName`; devframe `name` is a separate display label): ```ts import pkg from '../package.json' with { type: 'json' } export default defineDevframe({ id: 'my-tool', name: 'My Tool', // display label version: pkg.version, packageName: pkg.name, importMetaUrl: import.meta.url, homepage: pkg.homepage, description: pkg.description, setup(ctx) { /* … */ }, }) ``` ### Resolving against the devframe's own dependencies `importMetaUrl` resolves companion packages against the devframe's **own** dependencies: ```ts export default defineDevframe({ /** …metadata as above */ importMetaUrl: import.meta.url, /** * Served from the locally installed `my-tool--assets`, resolved via * `importMetaUrl`; it works under pnpm's strict layout with zero network. */ clientAssets: { package: `${pkg.name}--assets`, version: pkg.version }, /** Imported from `my-tool`'s own dependency graph. */ services: [{ package: '@scope/my-service', version: pkg.version }], setup(ctx) { /* … */ }, }) ``` For remote assets, `importMetaUrl` is the default `resolveFrom`; a per-source value wins; `resolveFrom: null` opts out. ### Serving the UI with `clientAssets` ```ts import { fileURLToPath } from 'node:url' export default defineDevframe({ /** …metadata as above */ importMetaUrl: import.meta.url, /** * A local build resolved from the module; it works from source and the * published package. */ clientAssets: fileURLToPath(new URL('../dist/spa', import.meta.url)), setup(ctx) { /* … */ }, }) ``` For assets you host yourself, call `ctx.views.hostStatic` in `setup`; see [Client Assets](/guide/client-assets#programmatic-hosting-from-setup). ### Runtime flags `ctx.mode` (`'dev'`/`'build'`) gates runtime-specific work: ```ts defineDevframe({ id: 'my-tool', name: 'My Tool', setup(ctx) { if (ctx.mode === 'build') { // Static-only work, baked into the RPC dump. ctx.rpc.addFunctions(staticFunctions) } else { // Dev-mode wiring, file watchers, etc. watchProject(ctx) } }, }) ``` The CLI dev server sets `mode: 'dev'`; `createBuild`, `'build'`. ## The setup context `setup(ctx)` receives a `DevframeNodeContext`: ```ts interface DevframeNodeContext { readonly cwd: string readonly workspaceRoot: string readonly mode: 'dev' | 'build' host: DevframeHost // runtime abstraction (mountStatic / resolveOrigin / getStorageDir) rpc: RpcFunctionsHost // register + broadcast + sharedState views: DevframeViewHost // static file hosting (`hostStatic`) diagnostics: DevframeDiagnosticsHost agent: DevframeAgentHost // expose tools + resources to coding agents services: DevframeServicesHost // typed cross-devframe service registry staticConfig: Partial // this context's own ConnectionMeta.configs scope: (id) => DevframeScopedNodeContext // namespaced view (preferred) } ``` ### Cross-devframe services `ctx.services` is a typed, namespaced registry: one devframe exposes a capability, others consume it ([Cross-Devframe Services](/guide/services)). ```ts ctx.services.provide('my-plugin:sources', sources) ctx.services.whenAvailable('my-plugin:sources', (sources) => { sources.register(/* ... */) }) ``` ### Static connection configs `ctx.staticConfig` is this context's own `ConnectionMeta.configs`: read-only boot-time data from the connection handshake; write it during `setup(ctx)`. Contrast `ctx.scope(id).settings` (mutable, synced). ```ts declare module 'devframe/types' { interface DevframeConnectionConfigsRegistry { 'my-plugin': { featureFlag: boolean } } } ctx.staticConfig['my-plugin'] = { featureFlag: true } ``` ### Storage scopes `ctx.host.getStorageDir(scope)` places persisted state in three classes: `workspace` is committable and team-shared (saved presets, config), `project` is per-checkout (caches, personal settings), and `global` is per-user (auth tokens, machine-wide prefs). Placements are in the [Node-Side API reference](/references/node-api#storage-scopes). `ctx.scope(id)` returns a namespace-scoped view ([Scoped Context](/guide/scoped-context)) auto-prefixing every RPC id, shared-state key, and streaming channel, plus a persisted `settings` store (`project`/`global` scopes use the matching storage classes). Hosted adapters can augment `ctx`, e.g. the [`vite` adapter](/adapters/vite)'s dock, command, message, and terminal hosts. ## CLI options `cli` sets CLI-adapter defaults and plugs flags/commands into CAC: ```ts defineDevframe({ id: 'my-tool', name: 'My Tool', clientAssets: './client/dist', // built SPA served as the UI cli: { command: 'my-tool', // binary name; default: the `id` port: 9876, // preferred port; default: 9999 portRange: [9876, 10000], // forwarded to get-port-please random: false, // forwarded to get-port-please host: 'localhost', // default host; --host overrides open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated configure(cli) { // contribute capability flags/commands cli .option('--my-flag ', 'Tool-specific flag') }, }, setup(ctx, { flags }) { // `flags` carries the parsed cac bag: the built-in flags // (`--port`, `--host`, `--open`, `--no-open`) and anything you added // in `configure`. }, }) ``` Beyond the fields shown, `random` prefers a random open port and `auth: false` disables the WS trust flow for localhost-only, single-user tools. Every field is listed in the [Node-Side API reference](/references/node-api#cli-options). ## Multiple runtimes, one definition Wire the definition into multiple adapters from one file: ```ts import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' import { createBuild } from 'devframe/adapters/build' import { createCac } from 'devframe/adapters/cac' const myDevframe = defineDevframe({ id: 'my-tool', name: 'My Tool', setup() {} }) // 1. Standalone CLI: await createCac(myDevframe).parse() // 2. Offline snapshot: await createBuild(myDevframe, { outDir: 'dist-static' }) /** 3. Mount into a host framework (Vite DevTools shown; others can implement equivalents): */ export const myPlugin = () => createPluginFromDevframe(myDevframe) ``` ## What's next - [Adapters](/adapters): deployment targets - [RPC](/guide/rpc): register node-side functions - [`vite` adapter](/adapters/vite): mount into a host framework --- title: RPC navigation: icon: i-lucide-repeat description: Type-safe, bidirectional RPC built on birpc, validated against any Standard Schema validator. Dev runs over WebSocket; build/SPA serves a pre-computed static dump. --- Type-safe, bidirectional RPC built on [`birpc`](https://github.com/antfu/birpc), validated against any [Standard Schema](https://standardschema.dev/) validator. Dev runs over WebSocket; build/SPA serves a pre-computed static dump. ## Defining a function ```ts import { defineRpcFunction } from 'devframe' import * as v from 'valibot' // npm i valibot (or use zod / arktype) export const getModules = defineRpcFunction({ name: 'get-modules', // bare: the scope namespaces it to `my-tool:get-modules` type: 'query', args: [v.object({ limit: v.number() })], returns: v.array(v.object({ id: v.string(), size: v.number() })), setup: ctx => ({ handler: async ({ limit }) => { // `ctx` is the full DevframeNodeContext. return loadModules().slice(0, limit) }, }), }) ``` Register it via a [scoped context](/guide/scoped-context); `ctx.scope(id)` auto-namespaces ids: ```ts import { defineDevframe } from 'devframe' import { getModules } from './rpc/functions/get-modules' export default defineDevframe({ id: 'my-tool', name: 'My Tool', setup(ctx) { const my = ctx.scope('my-tool') my.rpc.register(getModules) }, }) ``` ### Naming convention Scope with your devframe id, then a kebab-case action: `my-tool:get-modules`. ### Function types A function's `type` sets its caching and [static-dump](#static-dumps) behavior: `query` for reads that change over time (caching opt-in via `cacheable`, dumping via an explicit `dump`), `static` for data fixed per input (cached indefinitely, dumped automatically), `action` for mutations, and `event` for fire-and-forget notifications. The full matrix is in the [Node-Side API reference](/references/node-api#rpc-function-types). ### Handler arguments Handlers accept any serializable arguments. Declared `args` and `returns` schemas are enforced at runtime; extra object fields still reach the handler. ### Setup vs handler Use `setup(ctx)` (returns `{ handler, dump? }`) when the handler needs `DevframeNodeContext`; otherwise the `handler(...)` shorthand. ## Broadcasting `rpc.broadcast` sends to every connected RPC client; a scoped context namespaces the method: ```ts defineDevframe({ id: 'my-tool', name: 'My Tool', setup(ctx) { const my = ctx.scope('my-tool') watcher.on('change', (file) => { void my.rpc.broadcast({ method: 'on-file-changed', // -> my-tool:on-file-changed args: [{ file }], }) }) }, }) ``` Beyond `method` and `args`, `optional` skips throwing when no RPC client is listening, `event` makes the broadcast fire-and-forget, and `filter` skips specific RPC clients; see the [Node-Side API reference](/references/node-api#broadcast-options). ## Streaming For node-side→browser-side chunk feeds, use [streaming channels](/guide/streaming): ```ts const channel = ctx.rpc.streaming.create('my-tool:chat', { replayWindow: 256, }) const stream = channel.start() sourceReadable.pipeTo(stream.writable) ``` ## Local invocation A scoped `rpc.call` invokes a node-side function directly, skipping the transport: ```ts const my = ctx.scope('my-tool') const modules = await my.rpc.call('get-modules', { limit: 10 }) ``` It wraps `ctx.rpc.invokeLocal(...)`; a fully-qualified name (with `:`) calls another tool's function. ## Browser-side calls From the browser, [`connectDevframe`](/guide/client) (or `getDevframeRpcClient`) returns an RPC client: ```ts import { connectDevframe } from 'devframe/client' const client = await connectDevframe() const my = client.scope('my-tool') const modules = await my.rpc.call('get-modules', { limit: 10 }) ``` Browser-side registration (node side → browser side) uses `my.rpc.register()`. ## Type-safe RPC-client registry Two augmentable interfaces, `DevframeRpcServerFunctions` (client→server) and `DevframeRpcClientFunctions` (server→client), type each registered name on the RPC client via `declare module 'devframe'`. Feed a const array through `RpcDefinitionsToFunctionsWithNamespace`, which prefixes each bare name with your id: ```ts import type { RpcDefinitionsToFunctionsWithNamespace } from 'devframe/rpc' import { getFile, getModules } from './rpc' const serverFunctions = [getModules, getFile] as const declare module 'devframe' { interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctionsWithNamespace<'my-tool', typeof serverFunctions> {} } ``` For fully namespaced names, use `RpcDefinitionsToFunctions` (no namespace argument) with the unscoped `ctx.rpc.register`. For a one-off, declare a single key with `RpcFunctionDefinitionToFunction`. Augment these interfaces where they live (`devframe` or `devframe/types`); a renamed re-export won't merge into the base. ## Static dumps For `static` functions, Devframe records the handler output during `createBuild`: ```ts defineRpcFunction({ name: 'build-meta', type: 'static', args: [], returns: v.object({ version: v.string(), builtAt: v.number() }), setup: () => ({ handler: async () => ({ version: '1.0.0', builtAt: Date.now() }), }), }) ``` For `query` functions, an explicit `dump` enumerates argument sets to pre-compute: ```ts defineRpcFunction({ name: 'get-session', type: 'query', setup: ctx => ({ handler: async (id: string) => loadSession(id), dump: { inputs: [['session-a'], ['session-b']], fallback: { id: 'unknown', data: null }, }, }), }) ``` Static RPC clients resolve from the baked dump; unmatched arguments hit `dump.fallback` (or throw). ## JSON-serializable declaration The WS transport picks one of two encoders per function: | `jsonSerializable` | 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 | When every function is JSON-flagged, the wire stays plain JSON. A `jsonSerializable: true` handler must return JSON-only values; `Map`, `Date`, and friends won't round-trip. ## Agent exposure Add an `agent` field to expose the function to coding agents over MCP: ```ts defineRpcFunction({ name: 'get-modules', type: 'query', args: [v.object({ limit: v.number() })], returns: v.array(v.object({ id: v.string(), size: v.number() })), agent: { description: 'List the N largest modules in the current build. Safe to call freely.', title: 'List modules', // safety inferred from type: 'query' → 'read' }, setup: () => ({ handler: async ({ limit }) => loadModules().slice(0, limit), }), }) ``` The `agent` field implicitly enables strict JSON serialization because MCP consumes JSON-shaped data. Set `jsonSerializable: true` directly when an RPC-only function also benefits from that contract. ## What's next - [Shared State](/guide/shared-state): state synced across RPC clients - [Client](/guide/client): connecting from the browser - [Agent-Native](/guide/agent-native): exposing RPCs to coding agents --- title: Shared State navigation: icon: i-lucide-database-zap description: 'Shared state is observable, immutable-by-default state synced between the node side and every RPC client, surviving reconnects: a new RPC client gets the snapshot.' --- Shared state is observable, immutable-by-default state synced between the node side and every RPC client, surviving reconnects: a new RPC client gets the snapshot. ## Overview ```mermaid {theme="zinc-light" theme-dark="zinc-dark"} flowchart LR subgraph ClientA["RPC client A"] A["state.value()"] end subgraph Server["Node side"] S["state.mutate(fn)"] end subgraph ClientB["RPC client B"] B["state.value()"] end S <-->|RPC sync| A S <-->|RPC sync| B ``` ## Creating state A [scoped context](/guide/scoped-context)'s `rpc.sharedState(key, options)` namespaces it: ```ts import { defineDevframe } from 'devframe' export default defineDevframe({ id: 'my-tool', name: 'My Tool', async setup(ctx) { const my = ctx.scope('my-tool') const state = await my.rpc.sharedState('state', { // -> my-tool:state initialValue: { count: 0, items: [] as { id: string, name: string }[], }, }) console.log(state.value().count) // 0 }, }) ``` The scope prefixes `:` (wrapping `ctx.rpc.sharedState.get(...)`), so pass a bare key. ## Reading `state.value()` returns an immutable snapshot. ```ts const current = state.value() console.log(current.count) // current.count = 1 // ✗ TypeScript error: snapshot is Immutable ``` ## Mutating Pass a recipe to `state.mutate()`: ```ts state.mutate((draft) => { draft.count += 1 draft.items.push({ id: 'a', name: 'Alpha' }) }) ``` Devframe applies the recipe to a draft. When Immer returns a new state reference, Devframe emits `updated` and broadcasts it to RPC clients. Explicit replacement objects also notify. With patches enabled, `updated` carries `SharedStatePatch[]`. Sync IDs remain recorded for unchanged recipes, so replays stay idempotent. ## Patches (advanced) Enable patches for minimal network diffs; `updated` then carries `Patch[]`: ```ts const state = await ctx.rpc.sharedState.get('my-tool:big-state', { initialValue: largeTree, /** sharedState-level enablePatches is opt-in: */ sharedState: createSharedState({ initialValue: largeTree, enablePatches: true }), }) ``` ## Subscribing ```ts state.on('updated', (fullState, patches, syncId) => { // `patches` is populated only when enablePatches is set. }) ``` ## Client-side access The same key is on the browser RPC client, scoped identically; browser-side mutations round-trip the node side, keeping `state.value()` authoritative. ```ts import { connectDevframe } from 'devframe/client' const my = (await connectDevframe()).scope('my-tool') const state = await my.rpc.sharedState('state') // -> my-tool:state console.log(state.value().count) state.mutate((draft) => { draft.count += 1 }) ``` ## Enumerating keys Both sides expose `keys()` and `onKeyAdded`: ```ts for (const key of ctx.rpc.sharedState.keys()) { console.log(key) } const unsubscribe = ctx.rpc.sharedState.onKeyAdded((key) => { console.log('new shared-state key:', key) }) ``` Protocol adapters (e.g. [MCP](/guide/agent-native)) surface shared state as dynamic resources. ## Type-safe keys Augment `DevframeRpcSharedStates` to type each key once; lookups stay typed: ```ts declare module 'devframe' { interface DevframeRpcSharedStates { 'my-tool:state': { count: number items: { id: string, name: string }[] } } } ``` ## When to use shared state vs RPC | Use shared state for | Use RPC for | | --------------------------------------------------------- | ------------------------------------------------ | | Long-lived UI state (selections, filters, expanded nodes) | One-shot queries (`get-modules`, `read-file`) | | Coordination across RPC clients | Commands / actions with side effects | | Data that should reappear after reconnect | Event streams (prefer `broadcast` / `callEvent`) | For actions and events, use `ctx.rpc.register` + `broadcast` ([RPC](/guide/rpc)). --- title: Streaming navigation: icon: i-lucide-radio description: Streaming channels push chunk-style data from the node side to the browser side over the RPC socket. --- Streaming channels push chunk-style data from the node side to the browser side over the RPC socket. ## Overview ```mermaid {theme="zinc-light" theme-dark="zinc-dark"} sequenceDiagram participant Producer as Producer (node side) participant Channel as ctx.rpc.streaming
channel participant Browser as Subscriber (browser) Producer->>Channel: start({ id }) Channel-->>Browser: chunk(seq=1, "...") Channel-->>Browser: chunk(seq=2, "...") Producer->>Channel: close() Channel-->>Browser: end() ``` A **channel** owns a wire namespace; each `channel.start()` makes one **stream** keyed by an id (auto unless passed), joined by `(channelName, id)`. ## Defining a channel ```ts import { defineDevframe, defineRpcFunction } from 'devframe' import * as v from 'valibot' // npm i valibot export default defineDevframe({ id: 'my-tool', name: 'My Tool', async setup(ctx) { const my = ctx.scope('my-tool') const channel = my.rpc.streaming.create('chat', { // -> my-tool:chat replayWindow: 256, }) my.rpc.register(defineRpcFunction({ name: 'start-chat', // -> my-tool:start-chat type: 'action', jsonSerializable: true, args: [v.object({ prompt: v.string() })], returns: v.object({ streamId: v.string() }), handler: async ({ prompt }) => { const stream = channel.start() ;(async () => { for await (const token of fakeLLM(prompt, { signal: stream.signal })) { stream.write(token) } stream.close() })() return { streamId: stream.id } }, })) }, }) ``` ## Producing: three APIs, one stream ```ts const stream = channel.start({ id: 'optional-explicit-id' }) // Imperative: minimal, hand-rolled producers stream.write(chunk) stream.error(err) // terminal failure stream.close() // terminal success stream.signal // AbortSignal: flips when consumers cancel stream.id // string: what RPC clients subscribe to // Web Streams: pipe any ReadableStream in: sourceReadable.pipeTo(stream.writable, { signal: stream.signal }) // Convenience: start + pipe in one call: const stream = await channel.pipeFrom(sourceReadable) ``` ### Node stream interop Node 17+ converters: ```ts import { Readable, Writable } from 'node:stream' // Pipe a Node Readable into the streaming channel sourceNodeReadable.pipe(Writable.fromWeb(stream.writable)) // Pipe the channel out to a Node Writable Readable.fromWeb(reader.readable).pipe(targetNodeWritable) ``` ## Consuming: `for await` or `pipeTo` The reader is an `AsyncIterable` also exposing `.readable` (`ReadableStream`), one per reader. ```ts import { connectDevframe } from 'devframe/client' const my = (await connectDevframe()).scope('my-tool') const { streamId } = await my.rpc.call('start-chat', { prompt: 'Hello', }) const reader = my.rpc.streaming.subscribe('chat', streamId) // -> my-tool:chat // Async iterable: the simplest consumer pattern for await (const token of reader) appendToken(token) // Or pipe to a DOM-side WritableStream await reader.readable.pipeTo(downloadWritable) reader.cancel() // sends cancel upstream; the node-side stream.signal flips ``` ## Lifecycle and cancellation `stream.close()` / `stream.error(err)` broadcast `end`, resolving (or throwing inside) the browser-side `for await`. Cancellation flows upstream: `reader.cancel()`, or the **last** subscriber's WS dropping, aborts `stream.signal`; a disconnected reader survives and resubscribes on re-trust. The event-by-event matrix is in the [Node-Side API reference](/references/node-api#streaming-lifecycle). ## Browser-to-node uploads In reverse: an RPC call allocates the id; events carry chunks. ```ts // Node side: typically inside an action handler ctx.rpc.register(defineRpcFunction({ name: 'my-tool:upload-file', type: 'action', args: [v.object({ name: v.string() })], returns: v.object({ uploadId: v.string() }), handler: async ({ name }) => { const reader = channel.openInbound() // Process chunks asynchronously; the action returns immediately // so the browser side can start uploading. ;(async () => { const file = createWriteStream(name) for await (const chunk of reader) file.write(chunk) file.close() })() return { uploadId: reader.id } }, })) ``` ```ts // Browser side const { uploadId } = await my.rpc.call('upload-file', { name: 'capture.bin', }) const upload = my.rpc.streaming.upload('files', uploadId) // -> my-tool:files // Imperative upload.write(chunk1) upload.write(chunk2) upload.close() // Or pipe a Web ReadableStream straight in: fileReadable.pipeTo(upload.writable, { signal: upload.signal }) ``` Lifecycle mirrors outbound: `upload.signal` aborts on `reader.cancel()` (broadcasting `upload-cancel`), `upload.error(err)` throws inside its `for await`, and an RPC-client disconnect exits with `UploadDisconnected`. Each `openInbound()` id is point-to-point: one producer, no fan-in or replay. ## Replay on reconnect With `replayWindow: N`, the node side keeps the last `N` chunks; a resubscribing RPC client sends its highest seen sequence and the node side replays newer ones. ```ts my.rpc.streaming.create('chat', { // -> my-tool:chat replayWindow: 256, // chunks to retain per stream id closedStreamRetention: 30_000, // ms to hold closed streams for late subscribers }) ``` `closedStreamRetention` defaults to 30 s when `replayWindow > 0`. ## Backpressure The RPC client keeps a bounded queue per subscription (`highWaterMark`, default 256); when the consumer falls behind, the oldest chunk drops, logging [`DF0029`](/errors/DF0029). ```ts const reader = my.rpc.streaming.subscribe('chat', id, { // -> my-tool:chat highWaterMark: 1024, // raise if you expect bursts the consumer can recover from }) ``` ## Streaming vs events vs shared state | Streaming | `event`-typed RPC | Shared state | | ------------------------------------ | ----------------------------------------------- | ----------------------------- | | Token/chunk feeds (LLM deltas, logs) | Payload-less notifications (`refresh`, `clear`) | Long-lived UI state | | Per-call lifecycles, cancellation | Cross-cutting signals | Snapshots surviving reconnect | | Replay on reconnect | Fire-and-forget | Diff-based sync | | Browser→node uploads (files, mic) | | | ## Reference - `RpcStreamingHost`, `RpcStreamingChannel`, `StreamSink`, `StreamReader` in `devframe/types`. - [`examples/streaming-chat`](https://github.com/devframes/devframe/tree/main/examples/streaming-chat). - Errors: [`DF0029`](/errors/DF0029) (overflow), [`DF0030`](/errors/DF0030) (unknown id), [`DF0031`](/errors/DF0031) (write to closed), [`DF0032`](/errors/DF0032) (name collision). --- title: Client Assets navigation: icon: i-lucide-folder description: 'A devframe''s UI is a built SPA; clientAssets says where it lives: a local directory or published npm package.' --- A devframe's UI is a built SPA; `clientAssets` says where it lives: a **local directory** or **published npm package**. ## Mounting a local build Point `clientAssets` at your SPA build directory, resolved from the module: ```ts import { fileURLToPath } from 'node:url' import { defineDevframe } from 'devframe' import pkg from '../package.json' with { type: 'json' } export default defineDevframe({ id: 'my-tool', version: pkg.version, packageName: pkg.name, clientAssets: fileURLToPath(new URL('../dist/spa', import.meta.url)), setup(ctx) { // … }, }) ``` 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 read `clientAssets`. ## Programmatic hosting from `setup` To host assets beyond the *primary* UI, use `ctx.views.hostStatic`: ```ts export default defineDevframe({ id: 'my-tool', version: pkg.version, packageName: pkg.name, importMetaUrl: import.meta.url, clientAssets: fileURLToPath(new URL('../dist/spa', import.meta.url)), setup(ctx) { // Serve an extra static bundle at a sibling base. ctx.views.hostStatic( '/docs/', fileURLToPath(new URL('../dist/docs', import.meta.url)), ) // A remote source works here too, same shape as `clientAssets`. ctx.views.hostStatic('/legacy/', { package: '@acme/my-tool-legacy-ui', version: pkg.version, }) }, }) ``` `hostStatic(baseUrl, source, defaultResolveFrom?)` takes `clientAssets`'s `StaticAssetsSource`; `dev` registers middleware live, `build` copies into the static output. ## Remote assets Give `clientAssets` a `RemoteAssets` object naming a **published npm package** and exact version: ```ts import type { RemoteAssets } from 'devframe' import { defineDevframe } from 'devframe' import pkg from '../package.json' with { type: 'json' } const clientAssets: RemoteAssets = { package: '@acme/my-tool-assets', version: pkg.version, } export default defineDevframe({ id: 'my-tool', version: pkg.version, packageName: pkg.name, importMetaUrl: import.meta.url, clientAssets, setup(ctx) { // … }, }) ``` The definition's [`importMetaUrl`](/guide/devframe-definition#resolving-against-the-devframes-own-dependencies) is the resolution base. ### How assets resolve Per request, resolution tries in order: 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; exact-version URLs are immutable, so caches never stale. ### Options `package` and `version` (exact) name the published assets; `resolveFrom`, `path`, `provider`, and `offline` tune resolution; every field is in the [Node-Side API reference](/references/node-api#remote-assets-options). An invalid npm name or non-exact version throws [`DF0065`](/errors/DF0065). ### Offline and air-gapped use 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 ``` ### When the assets can't be reached 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. It also posts the failure to `window.parent` (`DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE` from `devframe/constants`, payload `RemoteAssetsErrorMessage`) for an embedding hub UI provider like [`@devframes/hub-ui`](/guide/build-your-own-hub-ui). ### Custom provider A custom provider supplies the file URL, optionally a listing: ```ts const clientAssets: RemoteAssets = { package: '@acme/my-tool-assets', version: pkg.version, provider: { fileUrl: (name, version, file) => `https://npm.internal.acme.com/${name}@${version}/${file}`, }, } ``` ### Publishing the assets An ordinary npm package ships the built UI under `path` (default `dist`) and exposes its `package.json` for the resolver: ```json { "name": "@acme/my-tool-assets", "version": "1.0.0", "exports": { "./package.json": "./package.json" }, "files": ["dist"] } ``` Keep its version in lockstep with the tool. --- title: Scoped Context navigation: icon: i-lucide-box description: 'A scoped context is a namespaced view of the context: it auto-prefixes every RPC id, shared-state key, and streaming channel with your tool''s id, and adds a typed, persisted settings store, used from a single tool''s code.' --- A scoped context is a namespaced view of the context: it auto-prefixes every RPC id, shared-state key, and streaming channel with your tool's id, and adds a typed, persisted `settings` store, used from a single tool's code. ## Node side `setup(ctx)` receives the full `DevframeNodeContext`; scope it with `ctx.scope(id)`, conventionally your devframe `id`: ```ts import { defineDevframe, defineRpcFunction } from 'devframe' export default defineDevframe({ id: 'my-plugin', name: 'My Plugin', setup(ctx) { const my = ctx.scope('my-plugin') my.rpc.register(defineRpcFunction({ name: 'get-modules', // bare name: stored as `my-plugin:get-modules` type: 'query', handler: () => loadModules(), })) }, }) declare function loadModules(): { id: string }[] ``` `ctx.scope(id)` is stable per id, re-exposing unscoped APIs (`views`, `diagnostics`, `agent`, `host`, `cwd`, `mode`), swapping in the auto-namespaced `rpc`, and keeping the original as `my.base`. ## Browser side `(await connectDevframe()).scope(id)` gives the matching view: `my.rpc` carries `call` / `callEvent` / `callOptional`, `register`, `sharedState`, and `streaming`, plus `my.settings`. ## Auto-namespacing Bare names are prefixed `:` (`call('get-modules')` → `my-plugin:get-modules`); a name with `:` passes through unchanged to another tool (`call('other-plugin:status')`). `register` accepts only bare names; an already-namespaced one throws [`DF0034`](/errors/DF0034); use `ctx.base.rpc.register`. Bare names stay typed: `call('get-modules')` resolves to your [RPC registry](/guide/rpc#type-safe-rpc-client-registry) entry, `sharedState('selection')` to the matching [`DevframeRpcSharedStates`](/guide/shared-state#type-safe-keys) key. ## Settings `my.settings` is a persisted key-value store (alongside `my.rpc`), with two scopes: - **`project`**: per-checkout values, under the `workspace` storage dir. - **`global`**: per-user values, under the `global` storage dir. Both are file-backed and synced to the browser over the shared-state protocol; a `set` propagates to peers, surviving restarts. ```ts const { settings } = my await settings.project.set('theme', 'dark') await settings.project.get('theme') // => 'dark' await settings.project.all() // => theme is 'dark' await settings.project.delete('theme') const off = await settings.global.onChange((value) => { console.log('global settings changed', value) }) ``` Every method is async; the store resolves on first access. ### Typed settings Augment `DevframeSettingsRegistry` to type a namespace's settings once; the scope types `settings.global` and `settings.project`: ```ts declare module 'devframe' { interface DevframeSettingsRegistry { 'my-plugin': { theme: 'light' | 'dark' recentFiles: string[] } } } ``` ```ts const my = ctx.scope('my-plugin') await my.settings.project.set('theme', 'dark') // ✓ typed await my.settings.project.set('theme', 'blue') // ✗ not assignable ``` Unaugmented namespaces fall back to an open record. ## What's next - [RPC](/guide/rpc) - [Shared State](/guide/shared-state) - [Client](/guide/client) --- title: JSON-Render navigation: icon: i-lucide-braces description: 'JSON-render describes a UI as data: a serializable component spec any frontend renders. Opt-in: a plain devframe pulls zero JSON-render dependencies. Two packages:' --- JSON-render describes a UI as **data**: a serializable component spec any frontend renders. **Opt-in**: a plain devframe pulls zero JSON-render dependencies. Two packages: - **`@devframes/json-render`**: framework-neutral protocol layer (spec/catalog types, prop schemas, view refs, node runtime), built on [`@json-render/core`](https://www.npmjs.com/package/@json-render/core). - **`@devframes/json-render-ui`**: reference Vue frontend implementing the base catalog with [`@antfu/design`](https://github.com/antfu/design). ## Authoring a view `createJsonRenderView` registers the spec as shared state, returning a handle: ```ts import { createJsonRenderView } from '@devframes/json-render/node' export default defineDevframe({ /** … */ setup(ctx) { const view = createJsonRenderView(ctx, { id: 'metrics', // stable, unique within the scope spec: { root: 'root', elements: { root: { type: 'Card', props: { title: 'Live metrics' }, children: ['count'] }, count: { type: 'Text', props: { text: { $state: '/count' } }, children: [] }, }, state: { count: 0 }, }, }) // A structural change replaces the whole spec… view.update(nextSpec) // …while state travels as JSON-Pointer patches (only the changed path crosses the wire). view.patchState([{ op: 'replace', path: '/count', value: 3 }]) // Unregisters the shared state and its listeners. // view.dispose() }, }) ``` The view id is scoped `devframe:json-render::`, `scope` defaulting to the namespace or `global`. Diagnostics fire on invalid props ([DF0038](/errors/DF0038)), duplicate id ([DF0039](/errors/DF0039)), disposed-view use ([DF0040](/errors/DF0040)), non-serializable spec ([DF0041](/errors/DF0041)). ## The base catalog Catalog v1 ships fourteen components: `Stack`, `Card`, `Text`, `Badge`, `Button`, `Icon`, `Divider`, `TextInput`, `Switch`, `KeyValueTable`, `DataTable`, `CodeBlock`, `Progress`, `Tree`. A spec **is** an `@json-render/core` `Spec` plus a per-component Zod prop schema (`basePropSchemas`), validated at ingress (node side) and render time (browser side); `$state` / `$bindState` bindings work on scalar props. ## Actions and state - **State** is a JSON-serializable `Record` addressed by JSON Pointer. - **Actions** are unrestricted: an element event dispatches an RPC call of the same name, with no allowlist. - **Reserved built-ins** (`setState`, `pushState`, `removeState`, `validateForm`) are handled client-side, never bridged to RPC. ## Rendering standalone ### Out-of-box SPA (no client build) `createJsonRenderDevframe` points `clientAssets` at the prebuilt `@devframes/json-render-ui/spa` renderer: ```ts import { createJsonRenderDevframe } from '@devframes/json-render-ui/spa' import { createJsonRenderView } from '@devframes/json-render/node' export default createJsonRenderDevframe({ id: 'my-app', name: 'My App', version, packageName, homepage, description, cli: { command: 'my-app', port: 9800 }, setup(ctx) { createJsonRenderView(ctx, { id: 'main', title: 'Dashboard', spec }) }, }) ``` The SPA discovers views from the **view index** (`JSON_RENDER_INDEX_KEY`); one view renders full-bleed, multiple get a `title`-labeled switcher. ### Custom frontend A custom frontend renders from shared state: connect with `connectDevframe()`, read the view's state (keyed `devframe:json-render::`), subscribe to `updated` events, and render with your registry; the [Next hub example](https://github.com/devframes/devframe/tree/main/examples/custom-hub-next) has a React renderer. In a **static** build spec + state are read-only: actions unavailable, local state and bindings still work. ### The reference frontend `@devframes/json-render-ui` ships prebuilt `/spa` and `/hub` bundles. ## Rendering inside a hub `@devframes/json-render/hub` adds a `json-render` dock type: ```ts // node side: register a dock entry carrying the view's serializable reference, // and compose the frontend as a prebuilt renderer module import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' initHub({ ui: createUi(), renderers: [jsonRenderUiRenderer()], configure(ctx) { ctx.docks.register(toJsonRenderDockEntry(view, { id: 'metrics', title: 'Metrics', icon: 'ph:chart-bar-duotone', })) }, }) ``` The hub publishes the module in the [renderer manifest](/guide/hub-initiate#renderer-modules), lazily imported on first mount (else a missing-renderer fallback). A host page can register a `JsonRenderDockRenderer` **locally** instead, overriding the manifest: ```ts // host page: a locally-bundled frontend wins over the manifest module import { createDevframeClientRuntime } from '@devframes/hub/client' import { myJsonRenderDockRenderer } from './my-renderer' const host = await createDevframeClientRuntime({ renderers: { 'json-render': myJsonRenderDockRenderer }, }) // the hub UI provider mounts the active dock entry into a container it owns const result = await host.context.renderers.mount(entry, container) if (result.status === 'mounted') result.dispose // tear down when the hub UI provider decides; deactivation disposes too ``` The dock entry carries a serializable `JsonRenderViewRef` in two shapes: `{ stateKey }` points at live shared state (`createJsonRenderView`); `{ spec }` embeds it inline for a browser-synthesized [client-only dock](/guide/client-context#client-only-docks). ## Swapping the frontend `@devframes/json-render/hub` exports the contract, `JsonRenderDockRenderer` and `JsonRenderDockMountOptions`; `@devframes/json-render-ui` is the pluggable reference implementation. See [Build your own JSON-Render frontend](/guide/build-your-own-json-render-frontend) and the [`json-render` example](https://github.com/devframes/devframe/tree/main/examples/json-render). --- title: Structured Diagnostics navigation: icon: i-lucide-stethoscope description: ctx.diagnostics is a thin layer over nostics for author-defined coded diagnostics, each with a stable code, docs URL, and structured payload. --- `ctx.diagnostics` is a thin layer over [`nostics`](https://www.npmjs.com/package/nostics) for author-defined coded diagnostics, each with a stable code, docs URL, and structured payload. | Surface | Purpose | Example | | -------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------ | | `ctx.diagnostics` | Coded errors and warnings emitted from node-side code | `MYP0001: Plugin foo not configured` | | [`ctx.messages`](https://devtools.vite.dev/kit/messages) | Free-form, user-facing notifications shown in the Messages panel | `'Audit complete: 3 issues found'` | ## Shape ```ts interface DevframeDiagnosticsHost { /** Proxy-backed lookup over every registered code. */ readonly logger: Record /** Register additional diagnostic definitions. */ register: (definitions: Record) => void /** Build a typed diagnostics object with devframe's ANSI reporter pre-wired. */ defineDiagnostics: typeof defineDiagnostics } ``` The diagnostics host ships pre-seeded with devframe's `DF*` codes plus the host framework's own (`DTK*`, etc.); call `register()` to add your own. ## Register your own codes ```ts export function MyPlugin(): PluginWithDevTools { return { name: 'my-plugin', devtools: { setup(ctx) { const myDiagnostics = ctx.diagnostics.defineDiagnostics({ docsBase: 'https://example.com/errors', codes: { MYP0001: { why: (p: { name: string }) => `Plugin "${p.name}" is not configured`, fix: 'Add the plugin to your `vite.config.ts` and pass an options object.', }, MYP0002: { why: 'Cache directory missing; running cold.', }, }, }) ctx.diagnostics.register(myDiagnostics) // Emit through the host framework's shared reporter: myDiagnostics.MYP0002() }, }, } } ``` ## Code conventions Codes are a 4-letter prefix + 4-digit number (e.g. `MYP0001`); pick one distinctive enough to avoid collisions. `DF` belongs to `devframe` itself; the `@vitejs/devtools` packages own `DTK`, `RDDT`, and `VDT`. The prefixes in use are listed in the [Node-Side API reference](/references/node-api#diagnostic-code-prefixes). A definition takes a `why` (message) and optional `fix` (resolution), string or function; `docsBase` auto-attaches the URL to each diagnostic. ## Emit a diagnostic Each registered code becomes a callable `DiagnosticHandle`: call it to report, or `throw` to raise. ```ts // Throw: control flow stops here throw myDiagnostics.MYP0001({ name: 'foo' }) // Report without throwing (default console method: `warn`) myDiagnostics.MYP0002() // Override the console method per call myDiagnostics.MYP0002({}, { method: 'error' }) // Attach a `cause`: merged into the params object throw myDiagnostics.MYP0001({ name: 'foo', cause: error }) ``` The returned `Diagnostic` extends `Error`, so `throw` narrows following lines as unreachable. ## Typed handle reference `ctx.diagnostics.logger` is loosely typed over all registered codes; for autocompletion, keep and call the typed result of `defineDiagnostics()` directly, whose pre-wired ANSI reporter gives it and the lookup identical output. ## Document your codes Pair each code with a documentation page: ``` docs/errors/ index.md # Table of all codes MYP0001.md # One page per code MYP0002.md ``` Each page covers message, cause, example, and fix; see any [DF code page](https://devfra.me/errors/) for the template. ## When to use what **`ctx.diagnostics`** covers coded, docs-backed conditions, targeting tool authors and CI; **`ctx.messages`** covers user-facing UI activity, targeting the human at the panel. --- title: Standalone CLI with Devframe navigation: icon: i-lucide-terminal description: npx my-tool starts a dev server serving a Vue/Nuxt/React SPA over type-safe RPC, plus build/mcp. --- `npx my-tool` starts a dev server serving a Vue/Nuxt/React SPA over type-safe RPC, plus `build`/`mcp`. ## What you ship ``` my-tool/ ├── bin.mjs # shebang + import './dist/cli.mjs' ├── src/ │ ├── cli.ts # defineDevframe + createCac │ ├── rpc.ts # your RPC function definitions │ └── data.ts # your domain-specific logic ├── app/ # Nuxt / Vue / React SPA source ├── dist/ │ ├── public/ # built SPA output (served at /) │ └── cli.mjs # bundled node entry └── package.json ``` ## Minimal CLI ```ts [src/cli.ts] import process from 'node:process' import { defineDevframe, defineRpcFunction } from 'devframe' import { createCac } from 'devframe/adapters/cac' import { colors as c } from 'devframe/utils/colors' import { resolve } from 'pathe' const clientAssets = resolve(import.meta.dirname, '../dist/public') const myDevframe = defineDevframe({ id: 'my-tool', name: 'My Tool', clientAssets, cli: { command: 'my-tool', port: 7777, portRange: [7777, 9000], open: true, // auth defaults to on; `--open` embeds the current OTP so the tab lands authenticated configure(cli) { cli .option('--config ', 'Config file path') .option('--base-path ', 'Base directory for resolution') }, }, async setup(ctx, { flags }) { const my = ctx.scope('my-tool') my.rpc.register(defineRpcFunction({ name: 'get-payload', // -> my-tool:get-payload type: 'query', async handler() { return await loadPayload({ configPath: flags.config, basePath: flags.basePath, }) }, })) }, }) await createCac(myDevframe, { onReady({ origin }) { console.log(c.green`My Tool ready at ${origin}`) }, }).parse(process.argv) ``` Run: ```sh my-tool # dev server at http://localhost:7777/ my-tool --config ./my.config.mjs my-tool --port 8080 --no-open my-tool build --out-dir dist-static # self-contained static deploy my-tool build --out-dir dist-static --base /tool/ # …under a custom base my-tool mcp # agent exposure ``` ## Nuxt SPA setup The Nuxt helper sets `app.baseURL: './'` / `vite.base: './'` and wires `connectDevframe()` into `$rpc` ([Nuxt docs](/frameworks/nuxt)). ```ts [nuxt.config.ts] export default defineNuxtConfig({ ssr: false, modules: ['@devframes/nuxt/single'], nitro: { preset: 'static', output: { dir: './dist' }, // matches the definition's clientAssets of ./dist/public }, }) ``` ## Next.js SPA setup For a Next.js App Router SPA, static export needs: ```js [next.config.mjs] /** @type {import('next').NextConfig} */ export default { output: 'export', assetPrefix: '.', trailingSlash: true, images: { unoptimized: true }, } ``` `assetPrefix: '.'` keeps assets base-agnostic; `trailingSlash: true` emits `foo/index.html` for directory-with-index resolution. Copy `next build`'s `out/` to `clientAssets`: ```json [package.json] { "scripts": { "build": "next build src/client && rm -rf dist/client && mkdir -p dist && cp -r src/client/out dist/client" } } ``` ```ts [src/cli.ts] import { fileURLToPath } from 'node:url' defineDevframe({ id: 'my-tool', clientAssets: fileURLToPath(new URL('../dist/client', import.meta.url)), // … }) ``` Call `connectDevframe()` in a Client Component; see [Client](/guide/client) and [`examples/next-runtime-snapshot`](https://github.com/devframes/devframe/tree/main/examples/next-runtime-snapshot). ## Connecting from the browser side With the Nuxt helper, use `$rpc`: ```ts [app/composables/payload.ts] export async function fetchPayload() { const { $rpc } = useNuxtApp() return $rpc.call('my-tool:get-payload') } ``` Otherwise call `connectDevframe()`, which auto-resolves the connection descriptor relative to the page, whether dev (WebSocket) or static snapshot: ```ts import { connectDevframe } from 'devframe/client' const my = (await connectDevframe()).scope('my-tool') const payload = await my.rpc.call('get-payload') ``` ## Typed CLI flags Declare tool flags with any [Standard Schema](https://standardschema.dev/) validator (valibot/zod/arktype), validated at parse and typed at the call site: ```ts import type { InferCliFlags } from 'devframe/adapters/cac' import { defineDevframe } from 'devframe' import { defineCliFlags } from 'devframe/adapters/cac' import * as v from 'valibot' // npm i valibot const appFlags = defineCliFlags({ depth: v.pipe(v.number(), v.integer()), config: v.optional(v.string()), verbose: v.optional(v.boolean()), }) defineDevframe({ id: 'my-tool', name: 'My Tool', clientAssets, cli: { flags: appFlags, }, setup(ctx, info) { const flags = info.flags as InferCliFlags flags.depth // number flags.config // string | undefined }, }) ``` Booleans become `--verbose` / `--no-verbose`, else `--depth `; keys are camelCase in TS, kebab-case on the CLI (`configFile` → `--config-file`). Flags outside the schema pass through. ## Snapshot queries for static builds For an RPC function returning one payload per build, set `snapshot: true`; the build adapter runs the handler once, baking the result in: ```ts defineRpcFunction({ name: 'my-tool:get-payload', type: 'query', snapshot: true, handler() { return scanPackages(flags.root) }, }) ``` It's the no-args fallback for any deployed `rpc.call('my-tool:get-payload', …)`; a normal `query` in dev. ## On-disk caching Persistence is your tool's job ([`unstorage`](https://unstorage.unjs.io/) recommended); keep cache paths under `node_modules/.cache//` to rotate with `pnpm install`. ```ts import { resolve } from 'pathe' import { createStorage } from 'unstorage' import fsDriver from 'unstorage/drivers/fs' const cache = createStorage({ driver: fsDriver({ base: resolve(process.cwd(), 'node_modules/.cache/my-tool'), }), }) defineDevframe({ id: 'my-tool', name: 'My Tool', async setup(ctx) { ctx.scope('my-tool').rpc.register(defineRpcFunction({ name: 'get-npm-meta', // -> my-tool:get-npm-meta type: 'query', async handler(spec: string) { return (await cache.getItem(spec)) ?? await fetchAndCache(spec, cache) }, })) }, }) ``` ## Live-reload on config changes Filesystem watching is your tool's job: wire chokidar, signal the browser side via shared state. ```ts [src/cli.ts] defineDevframe({ id: 'my-tool', name: 'My Tool', async setup(ctx, { flags }) { const my = ctx.scope('my-tool') my.rpc.register(defineRpcFunction({ name: 'get-payload', // -> my-tool:get-payload type: 'query', cacheable: true, handler: () => loadPayload({ configPath: flags.config }), })) if (ctx.mode === 'dev') { const version = await my.rpc.sharedState('version', { initialValue: { ts: 0 } }) const { default: chokidar } = await import('chokidar') const watcher = chokidar.watch(flags.config ?? [], { ignoreInitial: true }) watcher.on('change', () => { version.mutate((draft) => { draft.ts = Date.now() }) }) } }, }) ``` On the browser side: ```ts const my = (await connectDevframe()).scope('my-tool') const version = await my.rpc.sharedState('version') version.on('updated', () => fetchPayload().then(setData)) ``` ## Use your own CLI framework Own a CLI framework (commander, yargs, oclif)? Use the three factories `createCac` wraps against one `DevframeDefinition`: `createDevServer` (`devframe/adapters/dev`), `createBuild` (`devframe/adapters/build`), and `createMcpServer` (`devframe/adapters/mcp`); see the [CLI adapter](/adapters/cac#use-your-own-cli-framework). ```ts [src/cli.ts] import process from 'node:process' import { Command } from 'commander' import { defineDevframe } from 'devframe' import { createBuild } from 'devframe/adapters/build' import { createDevServer } from 'devframe/adapters/dev' const myDevframe = defineDevframe({ id: 'my-tool', name: 'My Tool', clientAssets: './dist/public', cli: { port: 7777 }, setup(ctx, { flags }) { /* ... */ }, }) const program = new Command('my-tool') program .command('dev', { isDefault: true }) .option('-p, --port ', 'Port', '7777') .option('--config ', 'Config file path') .action(async (opts) => { const handle = await createDevServer(myDevframe, { port: Number(opts.port), flags: { config: opts.config }, onReady: ({ origin }) => console.log(`Ready at ${origin}`), }) process.on('SIGINT', () => handle.close().then(() => process.exit(0))) }) program .command('build') .option('--out-dir ', 'Output directory', 'dist-static') .action(opts => createBuild(myDevframe, { outDir: opts.outDir })) await program.parseAsync() ``` `createDevServer` returns a `StartedServer` handle (`origin`, `port`, `app`, `ws`, `rpcGroup`, `connectionMeta()`, `close()`). For typed flags, `parseCliFlags(schema, rawBag)` (`devframe/adapters/cac`) validates a commander/yargs bag against the `cli.flags` `CliFlagsSchema`. ## See also - [Devframe Definition](/guide/devframe-definition) - [Adapters → CLI (cac)](/adapters/cac): `configureCli`, mount-path rules - [Adapters → Dev](/adapters/dev) - [Client](/guide/client) - [Agent-Native](/guide/agent-native) --- title: Client navigation: icon: i-lucide-globe description: The RPC client connects any surface (dock iframe, remote page, standalone SPA) to a devframe's node side with type-safe RPC, shared state, and a trust handshake. --- The RPC client connects any surface (dock iframe, remote page, standalone SPA) to a devframe's node side with type-safe RPC, shared state, and a trust handshake. ## Connecting `devframe/client` exports `connectDevframe` (an alias of `getDevframeRpcClient`): ```ts import { connectDevframe } from 'devframe/client' const rpc = await connectDevframe() const modules = await rpc.call('my-tool:get-modules', { limit: 10 }) ``` At the default mount path, `connectDevframe` needs no arguments; it auto-detects the backend via `__devframe/__connection.json`. ### Runtime basePath discovery One SPA artifact serves at `/`, `/__/`, or any subpath, no rebuild. Build with relative asset paths: Vite `base: './'`, Nuxt `vite.base: './'` + `app.baseURL: './'`. ### Sharing a connection with an external viewer `setupDevframeConnection()` prepares a serializable connection for an external viewer: ```ts import { setupDevframeConnection } from 'devframe/client' const connection = await setupDevframeConnection({ baseURL: '/__devframe/', }) ``` In the external viewer: ```ts import { connectDevframe } from 'devframe/client' const rpc = await connectDevframe({ connection }) ``` The RPC client retains it as `rpc.connection`; cross-realm viewers read it via `getDevframeConnection()` or `DEVFRAME_CONNECTION_KEY` (`devframe/constants`). An external viewer registers its origin before the WebSocket opens (needs `viewerOriginToken` in the host framework's connection metadata; see [External viewer origins](/guide/security#external-viewer-origins)): ```ts import { registerDevframeViewerOrigin } from 'devframe/client' await registerDevframeViewerOrigin(connection) ``` ### Options `baseURL` points at the mount path to probe for `__connection.json` (default `'./'`, relative to `document.baseURI`); `connection` adopts one prepared by `setupDevframeConnection()`. The rest cover auth (`authToken`), [caching](#caching) (`cacheOptions`), timeouts (`callTimeout`), transport hooks (`wsOptions`), `birpc` passthrough (`rpcOptions`), and discovery override (`connectionMeta`); every option is in the [Browser-Side API reference](/references/browser-api#connectdevframe-options). ## Modes Per the `__devframe/__connection.json` backend: | Backend | When | Capabilities | | ----------- | --------------------------- | ------------------------------------------------------------------ | | `websocket` | Dev mode (`createCac`, Kit) | Full read/write, broadcasts, shared-state mutation. Requires auth. | | `static` | Build / SPA output | Read-only: all calls resolve against the baked RPC dump. | ## Trust & auth (WebSocket mode) `ensureTrusted()` resolves once the node side trusts the RPC client's stored token: ```ts const rpc = await connectDevframe() // Blocks until the node side trusts this RPC client (default timeout 60s) const trusted = await rpc.ensureTrusted() if (!trusted) { console.warn('Not authenticated yet') } ``` `connectDevframe()` starts the handshake without blocking; `rpc.call` / `rpc.callOptional` / `rpc.callEvent` hold anything issued before it settles. ### Authenticating with a one-time code The dev server prints a single-use 6-digit code (expires in five minutes, rotates after repeated wrong attempts) when an untrusted RPC client asks for one: call `requestAuthCode()` when your auth UI shows, passing `{ reissue: true }` from a "re-issue" button to rotate the code first. `requestTrustWithCode` then exchanges it for a persisted node-issued token shared across sibling tabs: ```ts await rpc.requestAuthCode() // … the developer reads the code from the terminal … const ok = await rpc.requestTrustWithCode('047204') ``` A host framework can embed the code in a link (`buildOtpAuthUrl(origin)`); `connectDevframe` reads the `devframe_otp` fragment, exchanges it, and strips the URL. Rename it with `otpParam`, or set `otpParam: false` to drive it yourself via `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()`. ### Re-using an existing token Authenticate with a token obtained elsewhere, without reloading: ```ts const ok = await rpc.requestTrustWithToken('a1b2c3…') ``` ### Broadcast-channel sync `connectDevframe` listens on a shared `BroadcastChannel` (`devframe-auth`) for `auth-update` messages; one tab authenticating trusts every open RPC client. ## Calling functions Derive a [scoped client](/guide/scoped-context) for namespaced ids: ```ts const my = (await connectDevframe()).scope('my-tool') // Standard call: awaits a response or throws. const modules = await my.rpc.call('get-modules', { limit: 10 }) // Optional: returns undefined when no handler responds (useful while HMR is restarting). const maybe = await my.rpc.callOptional('get-modules', { limit: 10 }) // Event: fire-and-forget, no response expected. my.rpc.callEvent('notify', { message: 'hello' }) ``` Types flow from the node side's `defineRpcFunction` definitions. ## Registering client functions Register functions the node side calls via `rpc.broadcast`: ```ts import { defineRpcFunction } from 'devframe' my.rpc.register(defineRpcFunction({ name: 'on-file-changed', // -> my-tool:on-file-changed type: 'event', setup: () => ({ handler: async ({ file }: { file: string }) => { console.log('server says:', file, 'changed') }, }), })) ``` ## Shared state ```ts const state = await my.rpc.sharedState('state') // -> my-tool:state console.log(state.value()) state.mutate((draft) => { draft.count += 1 }) state.on('updated', (next) => { console.log('new state', next) }) ``` See [Shared State](/guide/shared-state). ## Services `rpc.services` mirrors the node side's wire-service advertisements: ```ts if (rpc.services.has('@devframes/service-open')) await rpc.services.get('@devframes/service-open')!.rpc.call('open-in-editor', { path }) ``` See [Cross-Devframe Services](/guide/services#wire-services). ## Settings A scoped client exposes a persisted `settings` store, per-user (`global`) or per-checkout (`project`): ```ts await my.settings.project.set('theme', 'dark') const theme = await my.settings.project.get('theme') ``` See [Scoped Context](/guide/scoped-context#settings). ## Caching Set `cacheOptions: true` (or an object): ```ts const rpc = await connectDevframe({ cacheOptions: true }) ``` `query` / `static` responses are memoized per argument hash; the `rpc:cache:invalidate` broadcast clears entries after a mutation. ## Discovery (`__connection.json`) Devframe writes a JSON descriptor at `/__connection.json`. The socket shares the HTTP port, binding to `__ws` (advertised relative): ```json { "backend": "websocket", "websocket": { "path": "__ws" } } ``` The RPC client resolves it against its origin (`http`→`ws` / `https`→`wss`). The field also accepts a `number` (port on the page's host), a full `ws://`/`wss://` URL, or `{ port }` / `{ host }` for a cross-origin side-car server. For static mode: ```json { "backend": "static" } ``` Override discovery with `connectionMeta`: ```ts await connectDevframe({ connectionMeta: { backend: 'static' }, }) ``` ## Remote docks Supporting host frameworks (Vite DevTools; see [its remote-client docs](https://devtools.vite.dev/kit/remote-client)) inject a connection descriptor into the iframe URL that `connectDevframe` auto-detects: ```ts import { connectDevframe } from 'devframe/client' const rpc = await connectDevframe() // Already wired to the local dev server via the injected descriptor. ``` The descriptor's session-only, pre-approved token makes `ensureTrusted()` resolve immediately. An external hub builds an external-viewer URL from a trusted connection with `buildRemoteDevframeUrl()`, keeping the token in the URL fragment: ```ts import { buildRemoteDevframeUrl, stripRemoteConnectionFromUrl, } from '@devframes/hub/client' const viewerUrl = buildRemoteDevframeUrl('/viewer/', connection) const displayUrl = stripRemoteConnectionFromUrl(viewerUrl) ``` ## Events Four events arrive over `rpc.events`: `rpc:is-trusted:updated` when trust is granted, denied, or revoked; `connection:status` when the [connection status](#handling-connection-and-auth-errors) changes; `connection:error` on a connection-level failure; and `rpc:error` when an `rpc.call` rejects. Payloads are in the [Browser-Side API reference](/references/browser-api#rpc-client-events). ```ts rpc.events.on('rpc:is-trusted:updated', (isTrusted) => { if (isTrusted) console.log('server trusts this client') else console.log('trust revoked or denied') }) ``` `rpc.isTrusted` is the synchronous read. ## Handling connection and auth errors ### Connection status `rpc.status` collapses transport and trust into one value; `rpc.connectionError` holds the last connection-level `Error` (`null` when healthy). It moves through `connecting` (calls queue until open), `connected` (calls are served), `unauthorized` (socket open, trust refused; prompt for [authentication](#authenticating-with-a-one-time-code)), `disconnected`, and `error`; each value's meaning is in the [Browser-Side API reference](/references/browser-api#connection-statuses). A `static` backend has no live socket, so `rpc.status` stays `connected`. ### Calls fail fast When the socket closes or trust is refused, in-flight and new `rpc.call` promises reject with a `DevframeConnectionError`, its `kind`: - `'connection'`: the transport is down (`disconnected` / `error`). - `'auth'`: the RPC client is `unauthorized`. - `'timeout'`: the call outlived `callTimeout`. Set `callTimeout` to cap an unresponsive node side: ```ts const rpc = await connectDevframe({ callTimeout: 10_000 }) ``` ### Putting it together Gate the UI on `connection:status` and wrap calls to branch on failure: ```ts import { connectDevframe, DevframeConnectionError } from 'devframe/client' const rpc = await connectDevframe() // 1. Render from the live status. function render() { switch (rpc.status) { case 'connected': return renderApp() case 'connecting': return renderSpinner('Connecting…') case 'unauthorized': return renderMessage('Not authorized. Reopen the link from your dev server.') case 'disconnected': return renderMessage('Disconnected.', { onRetry: reconnect }) case 'error': return renderMessage(rpc.connectionError?.message ?? 'Connection failed.', { onRetry: reconnect }) } } rpc.events.on('connection:status', render) render() // 2. Handle a failing call. async function loadModules() { try { return await rpc.call('my-tool:get-modules', { limit: 10 }) } catch (error) { if (error instanceof DevframeConnectionError) { // 'connection' | 'auth' | 'timeout': the UI already reflects rpc.status. return null } throw error // a real server-side error; surface it. } } ``` ### Recovering The RPC client doesn't reconnect on its own; reload or re-run your connect routine: ```ts async function reconnect() { rpc = await connectDevframe() // a new RPC client; re-subscribe your listeners render() } ``` In a hub, a hub UI provider reads this status from [`context.connection`](/guide/client-context#the-client-context). --- title: In-Page Channel navigation: icon: i-lucide-message-square-code description: 'The in-page channel connects a devframe''s page script to its panels entirely in the browser: typed events, calls, and page-script-authoritative shared state, with no server involved.' --- The in-page channel (`devframe/in-page-channel`) connects a devframe's page script to its panels entirely in the browser: typed events, calls, and page-script-authoritative shared state, with no server involved. It is how a live inspect-the-page loop (like the [a11y inspector](/add-ons/devframes/a11y)'s scan/highlight cycle) works identically in dev and in a static build. ## Overview ```mermaid {theme="zinc-light" theme-dark="zinc-dark"} flowchart LR subgraph Host["User app's page"] PS["Page script
createPageScriptChannel()"] end subgraph Dock["Dock iframe"] PA["Panel
connectPanelChannel()"] end subgraph PiP["Popup / Document PiP"] PB["Panel
connectPanelChannel()"] end PA <-->|"MessageChannel port"| PS PB <-->|"MessageChannel port"| PS ``` The panel finds the page script with a same-origin `postMessage` handshake: it posts a versioned hello to its ancestor chain and `opener`, retrying with backoff until the page script answers by transferring a dedicated `MessageChannel` port. Boot order never matters, a reload of either side is just a re-handshake, and each connected panel gets its own port, so a dock iframe and a picture-in-picture window can watch the same page script at once. ## The protocol Declare the contract once, in a shared file both sides import: a pure type plus the channel-name constant: ```ts // shared/protocol.ts import type { InPageChannelProtocol } from 'devframe/in-page-channel' export const MY_CHANNEL = 'devframes:plugin:my-tool' export interface MyChannelProtocol extends InPageChannelProtocol { functions: { /** implemented by the page script, callable by panels */ pageScript: { measure: (selector: string) => { width: number, height: number } reset: () => Promise } /** implemented by panels, callable by the page script */ panel: { echo: (message: string) => Promise } } events: { /** listened to by the page script, emitted by panels */ pageScript: { highlight: (selector: string) => void } /** listened to by panels, emitted by the page script */ panel: { flash: (message: string) => void } } sharedStates: { state: { selections: string[] } } } ``` Channel names are namespaced with the devframe id, like RPC ids. Function names stay bare; the channel name already scopes them. ## The page script endpoint The required `functions` option and optional `events` option declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. A function with `agent` metadata is available to coding agents through MCP; the field implicitly enables strict JSON serialization (an explicit `jsonSerializable: false` conflicts). `defineChannelFunction` retains the named definition shape for lower-level authoring. `call()` accepts names from `functions`, including actions returning `void` or `Promise`: callers can await completion and catch errors or timeouts. `emit()` and `on()` use the names declared in `events`. Function and event names have separate namespaces. ```ts import type { MyChannelProtocol } from '../shared/protocol' // inject/index.ts: runs in the user app's page import { createPageScriptChannel } from 'devframe/in-page-channel' import { MY_CHANNEL } from '../shared/protocol' const pageChannel = createPageScriptChannel({ name: MY_CHANNEL, functions: { reset: { type: 'action', handler: async () => clearSelections() }, measure: { // request/response (the default `query` type) handler: (selector) => { const rect = document.querySelector(selector)!.getBoundingClientRect() return { width: rect.width, height: rect.height } }, }, }, events: { highlight: { jsonSerializable: true, handler: selector => drawRing(document.querySelector(selector)), }, }, }) pageChannel.emit('flash', 'scanning…') // received by each panel endpoint pageChannel.events.on('panel:connected', panel => console.log(panel.id)) pageChannel.events.on('panel:disconnected', () => pauseWorkIfNobodyWatches()) ``` `emit` on the page-script endpoint fans out to every connected panel endpoint. Functions declared under `functions.panel` are called through a specific `pageChannel.panels[0].call()` peer handle. ## The panel endpoint ```ts import type { MyChannelProtocol } from '../shared/protocol' // spa/main.ts: the devtools SPA (dock iframe, popup, or PiP) import { connectPanelChannel } from 'devframe/in-page-channel' import { MY_CHANNEL } from '../shared/protocol' const panelChannel = connectPanelChannel({ name: MY_CHANNEL, functions: {}, events: { flash: {}, }, }) const offFlash = panelChannel.on('flash', message => showFlash(message)) // defined and received by the page-script endpoint panelChannel.emit('highlight', '.hero') const size = await panelChannel.call('measure', '.hero') await panelChannel.call('reset') offFlash() // stop listening ``` The snippets form one channel pair: `pageChannel.emit('flash', …)` invokes `panelChannel.on('flash', …)`. In the other direction, `panelChannel.emit('highlight', …)` invokes the page-script endpoint's `highlight` handler and any matching `pageChannel.on()` listeners. An endpoint never receives its own emission. ## Shared state The channel's shared-state layer mirrors [`rpc.sharedState`](/guide/shared-state) (same `SharedState` handle, same accessor), with the page script playing the server's role as rendezvous and authority. Its first `get` of a key must provide the initial value; panels are seeded automatically on connect (including late joiners and re-connects) and converge through syncId-deduplicated patches. ```ts // Page script (the authority): const state = await channel.sharedState.get('state', { initialValue: { selections: [] } }) state.mutate((draft) => { draft.selections.push('.hero') }) // Panel (a live mirror): const state = await channel.sharedState.get('state') state.on('updated', fullState => render(fullState)) state.value() // Immutable snapshot ``` Without an `initialValue`, a panel's `get` resolves once the first replay arrives, so `render(state.value())` never sees a half-initialized value. Keep values serializable: they cross a structured-clone boundary on every sync. ## Errors and fallbacks Every failure mode is a coded `InPageChannelError` (`error.code`) with a message that explains itself: `timeout` (a call or `whenConnected(ms)` outlived its deadline), `closed` (endpoint torn down with calls pending), `not-serializable` / `not-cloneable` (a payload the port can't carry; the message names the offending path), `invalid-args` (Standard-Schema validation failed), and `state-uninitialized` (a shared state read before its `initialValue`). Causes and fixes per code are in the [Browser-Side API reference](/references/browser-api#in-page-channel-error-codes). The panel endpoint's connection lifecycle is explicit, so a panel renders a useful fallback instead of hanging: - `channel.status` is `connecting` → `connected` → (`connecting` on port loss) → `closed`, with `events.on('status:updated', …)` for reactivity. - While `connecting`, `call()` is queued (and still subject to its deadline) and `emit()` is buffered (up to `eventBufferLimit`, oldest dropped with a warning); both flush on connect. - A page script may legitimately never appear (the panel opened standalone, the user app not instrumented). Race `whenConnected(timeoutMs)` to show a "load the page script" empty state: ```ts try { await channel.whenConnected(3000) } catch { renderEmptyState('Add the page script to your app to see live data.') } ``` Recovery is automatic: a dead port (detected by the port's `close` event or the built-in heartbeat) returns the panel to `connecting` and resumes the handshake, so a host-page reload reconnects a popup panel by itself. ## Reactivity and serialization Payloads cross the port with structured clone. Framework reactivity wrappers don't survive it; unwrap them before sending, either in handlers or once per endpoint with the `serialize`/`deserialize` hooks: ```ts import { toRaw } from 'vue' const channel = connectPanelChannel({ name: MY_CHANNEL, serialize: value => toRawDeep(value), // applied to every outgoing argument and result functions: {}, events: { flash: {} }, }) ``` These hooks also apply to shared-state subscription snapshots, full-state updates, and patch arrays in both directions. Hooks that restore nested values should traverse objects and arrays, including each patch's `value`. Declaring a function `jsonSerializable: true` additionally enforces strict JSON on its payloads at the receiving endpoint, turning a would-be silent coercion or cryptic `DataCloneError` into a coded error naming the offending path. ## Multiple tabs The same app open in two tabs means two page scripts on one origin. Each page script carries a per-tab instance id (persisted in `sessionStorage`), and handshakes are targeted `postMessage`, so a dock panel always pairs with its own tab's page script. A panel can also pin explicitly: ```ts connectPanelChannel({ name: MY_CHANNEL, instanceId, functions: {}, events: { flash: {} } }) ``` ## Custom transports Both endpoints accept a pre-established `MessagePort` that bypasses the handshake, for custom topologies and tests: ```ts const { port1, port2 } = new MessageChannel() pageScript.addPanelPort(port1) const panel = connectPanelChannel({ name: MY_CHANNEL, transport: port2, functions: {}, events: { flash: {} } }) ``` ## When to use the in-page channel vs RPC | Use the in-page channel for | Use [RPC](/guide/rpc) for | | ---------------------------------------------------- | ------------------------------------------------------------ | | Page script ↔ panel loops (highlight, scan, measure) | Anything involving the node side (files, processes, storage) | | Working identically in dev and static builds | Data that must survive the tab (server owns it) | | Same-tab, same-origin surfaces | Cross-origin external viewers, remote panels | The a11y inspector uses both: the scan/highlight loop rides the in-page channel, while `get-config` is a `static` RPC resolved over WebSocket in dev and from the baked dump in a static build. --- title: Transports navigation: icon: i-lucide-cable description: Devframe serves live RPC over two interchangeable transports, WebSocket and SSE, so an RPC client connects even where the WebSocket upgrade is unavailable (serverless, buffering proxies). Both speak the identical birpc wire protocol, transparent to your RPC code. --- Devframe serves live RPC over two interchangeable transports, WebSocket and SSE, so an RPC client connects even where the WebSocket upgrade is unavailable (serverless, buffering proxies). Both speak the identical birpc wire protocol, transparent to your RPC code. ## What the node side binds A live instance binds both by default: - **WebSocket** at `__ws`: primary, one full-duplex socket. - **SSE** at `__sse`, one method-dispatched route: `GET` opens the server→client stream, `POST` carries client→server frames. It rides the same HTTP routes as `__connection.json`, so wherever discovery works SSE works, including middleware-only host frameworks (the Vite bridge, `initDevframe`'s `handler` / `nodeMiddleware`). `__connection.json` advertises what's bound; `backend` is the primary: ```json { "backend": "websocket", "websocket": { "path": "__ws" }, "sse": { "path": "__sse" } } ``` The SSE stream sends a keep-alive comment every 30 seconds. Both endpoints share one session space, identical for auth, shared-state, and streaming replay. ### Configuring ```ts // SSE-only: host frameworks/proxies where the upgrade can't happen. // RPC clients connect over SSE automatically (backend: 'sse'). initDevframe(def, { base: '/__my-tool/', ws: false }) // WebSocket-only: opt out of the SSE endpoint. initDevframe(def, { base: '/__my-tool/', server, sse: false }) // Rename the SSE route. initDevframe(def, { base: '/__my-tool/', server, sse: { route: '__events' } }) ``` `ws: false` + `sse: false` runs RPC-less (`backend: 'none'`); the SPA, discovery, and MCP routes still serve. The same options apply to `createDevServer`, `initHub`, and `cli.ws` / `cli.sse` defaults. ## What the RPC client picks `connectDevframe` connects over the declared primary, preferring WebSocket when both are present; a socket-less server advertises SSE as primary, so the RPC client lands there directly. Pin a transport when you know better, e.g. an intermediary that silently strips WS upgrades: ```ts const client = await connectDevframe({ transport: 'sse' }) client.transport // 'websocket' | 'sse' | 'static': what actually connected ``` Pinning an unadvertised transport rejects. SSE follows the same proxy-safe rules as WebSocket: relative paths against `__connection.json`'s URL, explicit `host`/`port` only for a cross-origin endpoint. A dropped SSE stream ends the RPC client like a closed socket: pending calls reject, status moves to `disconnected`, reconnect via `connectDevframe`. --- title: Security navigation: icon: i-lucide-shield-check description: 'Devframe tools are secure by default: connections bind to localhost, and dev-mode RPC requires a trust handshake before accepting a browser.' --- Devframe tools are secure by default: connections bind to `localhost`, and dev-mode RPC requires a trust handshake before accepting a browser. ## Trust model An RPC handler runs with the full privileges of its Node process (filesystem, child processes, network), and a trusted connection can call any registered function. The boundary that matters is *who may connect*: - **Authenticated (default).** `auth` defaults to `true`; the browser authenticates before calls are accepted, then reconnects with a node-issued bearer token. `createInteractiveAuth` (`devframe/recipes/interactive-auth`) packages the protocol into one `DevframeAuthHandler` the adapters wire for you (pass it to `initDevframe` / `initHub` via `auth`). - **Unauthenticated opt-out.** `auth: false` starts the server with an auto-trust handshake, for single-user tools on their own `localhost`. > [!WARNING] > `auth: false` trusts every connection that can reach the port. Only use it when the endpoint is reachable solely by the local developer. Never combine it with a non-loopback bind host, a tunnelled port, or a shared/CI environment. ## The pre-trust gate One rule decides what an untrusted connection may call: **a method is reachable before trust iff its name starts with `anonymous:`** (`isAnonymousRpcMethod`, from `devframe/constants`); only the handshake and code-request methods below qualify. The RPC server binding enforces this: pass `auth: authHandler` (its `.authorize` becomes the gate) or your own `authorize(methodName, session)`. Every other call from an untrusted session throws [`DF0036`](/errors/DF0036). `rpc.call` / `rpc.callOptional` / `rpc.callEvent` hold calls issued during the first handshake and release them once it settles. ## Authentication flow 1. A fresh RPC client calls `anonymous:devframe:auth` with its stored token (empty on first run); the server returns `{ isTrusted: false }` and the UI prompts for a code. 2. The auth UI requests a code (`rpc.requestAuthCode()`, sent automatically when the built-in notice view first shows, or by its "re-issue" button with `{ reissue: true }` to rotate the code first); the dev server prints the 6-digit code, its expiry, and the requesting browser in the terminal. An already-authorized page never triggers a print. 3. The developer enters it; the browser calls `requestTrustWithCode(code)`. 4. The server verifies the code, mints a high-entropy bearer token, trusts the session, and returns it. 5. The browser persists the token and presents it on reconnect (or via a `?devframe_auth_token=` query param the connect-time hook checks first); sibling tabs receive it over the `devframe-auth` channel and become trusted. The 6-digit code is single-use, expires after five minutes, is compared in constant time, and rotates after repeated wrong attempts. Show it only in a trusted channel (the terminal), never over the network. The bearer token is a secret. It travels to the server on the WebSocket URL (`?devframe_auth_token=…`), so serve over `wss://`/`https://` whenever the endpoint is reachable beyond loopback. An RPC client self-revokes (`devframe:auth:revoke`) or the node side revokes it (`revokeAuthToken`); affected RPC clients drop to untrusted via `devframe:auth:revoked`. ### The ready-made layer ```ts import { createInteractiveAuth } from 'devframe/recipes/interactive-auth' // The adapters gate with this layer by default. Construct it yourself only to // tune it (e.g. CI tokens) and hand it to `initDevframe` / `initHub` as `auth`. const auth = createInteractiveAuth(ctx, { clientAuthTokens: process.env.CI ? [process.env.DEVFRAME_CI_TOKEN!] : undefined, }) ``` Pass `clientAuthTokens` for CI/shared machines to skip the prompt, or a custom `banner`/`serverUrl`. ### Auth methods The `anonymous:`-prefixed methods re-authenticate a stored token (`anonymous:devframe:auth`), exchange a one-time code for a token (`anonymous:devframe:auth:exchange`), and ask the server to print its code banner (`anonymous:devframe:auth:request-code`); `devframe:auth:revoke` self-revokes, and the `devframe:auth:revoked` event drops affected RPC clients to untrusted. Wire shapes are in the [Node-Side API reference](/references/node-api#auth-methods). Node primitives in `devframe/node/auth` (`getTempAuthCode` / `refreshTempAuthCode`, `exchangeTempAuthCode`, `verifyAuthToken`, `buildOtpAuthUrl`, and `revokeAuthToken`) implement the same flow for a host framework wiring its own gate; signatures are in the [reference](/references/node-api#node-auth-primitives). RPC client methods (`devframe/client`): `requestAuthCode(options?)` (print the code banner; `{ reissue: true }` rotates the code first), `requestTrustWithCode(code)`, `requestTrustWithToken(token)`, and `ensureTrusted(timeout?)` / `isTrusted` (the trust gate). ### Magic-link authentication The standalone CLI (`createCac` / `createDevServer`) prints a link embedding the code for `--open`, so the launched tab lands authenticated with no prompt. Build it yourself with `buildOtpAuthUrl(origin)`: ``` Devtools ready. Authenticate this browser: http://localhost:3000/#devframe_otp=123456 ``` The code rides the URL **fragment** (`#devframe_otp=…`), which browsers never send to the server, keeping the single-use code out of access logs and `Referer` headers. `connectDevframe` reads it, exchanges it, and strips it from the URL. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal). The link points at the **public origin**. A standalone dev server derives it from its own bound address; an owned listener uses that address regardless of any inbound `Host` header. A handler or middleware without an explicit `origin` derives one from a request only when the request's own origin is loopback or exactly matches an `allowedOrigins` entry; a raw inbound authority and forwarded headers are never trusted. Set `origin` explicitly for non-loopback handler deployments (behind a proxy, on a LAN, or on a public host) so the magic link always resolves to the address you intend. For your own auth UI, disable built-in handling with `otpParam: false`, then call `authenticateWithUrlOtp(rpc)` or `consumeOtpFromUrl()` from `devframe/client`. ## Practices for tools built on devframe - **Stay on loopback.** Bind to a routable address only intentionally, and require authentication when you do. - **Keep `auth: false` local.** The hosted bridges (`devframeViteBridge`, `@devframes/next`'s handler) gate their side-car by default; opt out with an explicit `auth: false` only when the host framework owns the trust boundary another way. - **Gate the socket in a listener you own.** The `server` option binds its `upgrade` listener after async setup, so a guard that wraps the listeners present at startup misses it. To run your own check first, such as the peer address from `req.socket.remoteAddress` or a session cookie, leave `server` unset and own the listener. It sees every upgrade on the server, so act only on requests to `__ws` and leave the rest to the host framework. For those, attach an `error` handler to the socket, then call `devtools.handleUpgrade(req, socket, head)` when the check passes, or write a `403` response and destroy the socket when it fails. The built-in socket gate checks `Origin` and lets `Origin`-less clients through, so with `auth: false` on a non-loopback bind any client that reaches the port can open the socket. - **The MCP route trusts same-machine callers, harden it when that's not your boundary.** Two gates enforce that default: an origin gate (loopback-only, `Origin`-less rejected) is browser DNS-rebinding hardening, and a peer-address gate rejects a non-loopback caller even with a forged loopback `Origin` (the socket address can't be forged the way a header can). So the `'auto'` default - which mounts the route once agent tools exist - and `mcp: true` are enough for a local dev tool. Neither gate proves *which* caller it is, though, so to intentionally reach the route beyond loopback (a widened `allowedOrigins`, a hosted app) or to expose destructive tools, add an identity check with `mcp: { authorization }` (a bearer from an env var, or a callback), which also lifts the loopback-peer restriction - or turn the route off with `mcp: false`. See [MCP](/adapters/mcp). - **Treat tokens as secrets.** Never log the bearer token or the one-time code, or bake either into build output. - **Authorize every handler.** Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. - **Origin-lock remote docks.** When a hub embeds a remote-UI dock, keep `originLock` on (the default) so its session token is only honored on a connection whose `Origin` matches the dock's own. ## External viewer origins WebSocket handshakes from browser extensions and other external viewers carry the viewer's own `Origin` header, authorized through a live registry: ```ts import { attachWsRpcTransport, createWsOriginRegistry } from 'devframe/rpc/transports/ws-server' const viewerOrigins = createWsOriginRegistry({ validateOrigin: origin => origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://'), }) attachWsRpcTransport(rpc, { server, allowedOrigins: viewerOrigins, }) ``` Include `viewerOrigins.token` as `viewerOriginToken` in the connection metadata. In the metadata handler, call `viewerOrigins.registerFromUrl(request.url)`; when it returns an origin, set `Access-Control-Allow-Origin` to it. The external viewer then calls `registerDevframeViewerOrigin(connection)` before connecting. The registration token grants access through the transport's origin check; RPC authentication still authorizes the session and every non-anonymous method. Keep metadata carrying this token same-origin until the registration is verified. --- title: Agent-Native Devframe navigation: icon: i-lucide-bot description: Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and WebMCP on the browser side, opt-in per function. --- Devframe exposes its API (RPC functions, resources, shared state) to agents, over MCP on the node side and [WebMCP](#browser-side-tools-over-webmcp) on the browser side, opt-in per function. ## How it works 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. The same `agent` field on a *client* RPC function surfaces it [over WebMCP](#browser-side-tools-over-webmcp) instead. ## Exposing an RPC function ```ts import { defineRpcFunction } from 'devframe' export const getSessionSummary = defineRpcFunction({ name: 'rolldown-get-session-summary', type: 'query', args: [v.object({ sessionId: v.string() })], returns: v.object({ durationMs: v.number(), chunkCount: v.number() }), agent: { description: 'Summarize a Rolldown build session. Safe to call freely.', title: 'Build summary', // safety inferred from `type: 'query'` → 'read' }, setup: ctx => ({ handler: async ({ sessionId }) => { // ... }, }), }) ``` ## Tool ids and wire names - **The id** registers/invokes in devframe, colon-namespaced: `devframes:plugin::` (built-in devframe RPCs), `devframe::` (built-ins), command ids. - **The wire name** is 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 devframes:plugin:git:status → devframes_plugin_git_status my-plugin:summarize → my-plugin_summarize ``` `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 devframe tool Tools without a matching RPC register directly. ```ts export default defineDevframe({ id: 'my-plugin', setup(ctx) { ctx.agent.registerTool({ id: 'my-plugin:summarize', description: 'Plain-text summary of the current build state.', safety: 'read', handler: async () => ({ markdown: buildSummary(), }), }) }, }) ``` ## Deriving tools from other state Register a **provider** for tools derived from state, queried at list/invoke time: ```ts const handle = ctx.agent.registerToolProvider(() => currentCommands() .filter(command => command.agent) .map(command => toAgentTool(command)), ) // After the underlying state changes, nudge connected MCP clients: handle.notifyChanged() // fires tools/list_changed ``` ## Registering a resource Readable snapshots by URI: ```ts ctx.agent.registerResource({ id: 'current-session', name: 'Current Rolldown session', description: 'Markdown snapshot of the active build session.', mimeType: 'text/markdown', read: () => ({ text: renderMarkdown(currentSession) }), }) ``` 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 dev server serves the agent surface over HTTP on its own: the `mcp: 'auto'` default mounts the route at `/__mcp` once anything above exists (an `agent`-flagged RPC, a registered tool or resource) and the optional [`@devframes/agentic`](/adapters/mcp) peer is installed - one flagged function plus one install is the whole setup. See the [MCP adapter](/adapters/mcp#route-based-server) for forcing it on or off and hardening the route. For a stdio server instead, via the CLI: ```sh # Run your devtool with an MCP stdio server attached. devframe mcp ``` Programmatically: ```ts import { defineDevframe } from 'devframe' import { createMcpServer } from 'devframe/adapters/mcp' const myDevframe = defineDevframe({ /* … */ }) await createMcpServer(myDevframe, { transport: 'stdio' }) ``` ## Connecting Claude Desktop In `claude_desktop_config.json`: ```json { "mcpServers": { "my-tool": { "command": "pnpm", "args": ["--filter", "my-tool", "exec", "devframe", "mcp"] } } } ``` Restart; tools appear in the drawer, resources as `devframe://resource/` / `devframe://state/` URIs. ## Browser-side tools over WebMCP The same `agent` signature works on the browser side: a client RPC function (a function the node side calls on the browser, registered on `rpc.client` or through a scoped `client.scope('my-plugin').rpc.register(...)`) carrying an `agent` field is mirrored onto the page's [WebMCP](https://github.com/webmachinelearning/webmcp) model context (`document.modelContext` / `navigator.modelContext`) as a callable tool, so in-page and browser-integrated agents can drive browser-side functionality directly. Wire names, `arg0`/`arg1`/… input schemas, and safety annotations match the MCP projection above. ```ts const rpc = await connectDevframe() rpc.client.register({ name: 'my-plugin:highlight-node', type: 'action', jsonSerializable: true, agent: { description: 'Highlight a node in the open inspector view. Use it to point the user at a finding.', }, handler: (id: string) => highlightNode(id), }) ``` `connectDevframe()` wires this on its own when the browser provides a model context; `webmcp: false` keeps the browser side off the WebMCP surface. `registerWebMcpTools(collector)` (from `devframe/client`) applies the same projection to a hand-built collector and returns a dispose that unregisters every tool. > [!WARNING] > WebMCP is an experimental proposal; `registerWebMcpTools` tracks the current draft (`AbortSignal`-based unregistration) and earlier handle-returning drafts, but the browser API may still change. ## Writing descriptions agents act on Describe *when* to use a tool, not just its return: ```ts // ✗ Bad: describes the mechanism agent: { description: 'Returns the session summary object.' } // ✓ Good: tells the agent when and why agent: { description: 'Summarize the current build session: durations, chunk counts, warnings. Call this before proposing any build-config change.' } ``` ## Gateway tools A gateway tool returns *instructions and locations*, not work agents do better: ```ts ctx.agent.registerTool({ id: 'my-plugin:docs', description: 'Locate the version-accurate docs for this tool. Call before answering questions about its config format.', safety: 'read', handler: () => ({ docsPath: resolveInstalledDocsDir(), hint: 'Read the file matching your topic; do not rely on training-data knowledge of this config format.', }), }) ``` ## Structured errors 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" } } ``` Prefer coded diagnostics anywhere agent-reachable: agents act on `fix` and follow `docs`. ## Safety model - **`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 ` mcp` starts the MCP server on `stdio`; ` dev --mcp` serves the agent-consumable API on `/__mcp`; `devframe connect` discovers running devframes and proxies their tools ([MCP adapter](/adapters/mcp#discovery-devframe-connect)). The command table is in the [Node-Side API reference](/references/node-api#mcp-cli-commands). --- title: Hub navigation: icon: i-lucide-layout-dashboard description: '@devframes/hub orchestrates many devtools sharing a UI: a dock registry, terminal aggregation, message/toast queue, and command palette. It ships no UI; hub UI providers provide their own atop the hub''s RPC + shared-state protocol.' --- `@devframes/hub` orchestrates many devtools sharing a UI: a dock registry, terminal aggregation, message/toast queue, and command palette. It ships no UI; hub UI providers provide their own atop the hub's RPC + shared-state protocol. ![Hub screenshot](/screenshots/hub-1.png) *Orchestrating multiple devtools (from [A Playground](https://github.com/devframes/devframe/tree/main/examples/custom-hub-vite))* ## What the hub adds `DevframeHubContext` adds four subsystems to `DevframeNodeContext`: `ctx.docks` registers dock entries and groups and [activates docks](#cross-iframe-dock-activation); `ctx.terminals` aggregates terminal sessions with streaming output ([Terminals](/add-ons/devframes/terminals#hub-aggregation)); `ctx.messages` is the server-side toast/notification queue; `ctx.commands` is the hierarchical command palette with keybindings and `when` clauses. Each subsystem's API is in the [Hub API reference](/references/hub-api#hub-subsystems). Data-driven UI panels are an opt-in [JSON-Render](/guide/json-render) package (a `json-render` dock type). ## Built-in RPC Every hub context auto-registers these functions, callable from any RPC client: - `hub:commands:execute`: invoke a server command by id. - `hub:docks:activate`: switch the active dock ([Cross-iframe dock activation](#cross-iframe-dock-activation)). - `hub:messages:add` / `update` / `remove` / `clear`: write the messages feed. - `hub:terminals:write` / `resize`: drive a PTY session by id. Host-framework-specific capabilities (open in editor, reveal in finder) ship as kit-registered functions. ## Commands as agent tools A server command with an `agent` field ([agent-consumable API](/guide/agent-native)) becomes a `ctx.agent` MCP tool: ```ts ctx.commands.register({ id: 'app:build', title: 'Run build', agent: { description: 'Run the production build. Call after config or dependency changes to verify the app still builds.', args: [v.object({ configFile: v.optional(v.string()) })], }, handler: (opts?: { configFile?: string }) => runBuild(opts), }) ``` `args` takes positional [Standard Schema](https://standardschema.dev/) schemas (a single `v.object(...)` unwraps into the input); omit for zero-arg. `safety` defaults to `'action'`; `when` clauses are unenforced for agent calls. ## Localized titles A dock entry, a command, and a launcher each carry a plain `title` and an optional `titleLocales` map of translations by BCP 47 tag. The plain `title` stays the default, so a hub UI that ignores the map still shows it. The reference hub UI picks the translation closest to its own language setting (`zh-HK` falls back to `zh-TW`, `pt-PT` to `pt-BR`, `en-GB` to `en`): ```ts ctx.docks.register({ type: 'iframe', id: 'app:build', title: 'Build', titleLocales: { 'zh-CN': '构建', 'ja': 'ビルド' }, icon: 'ph:hammer-duotone', url: '/__build/', }) ``` A devframe localizes the dock a hub synthesizes for it through `dock: { titleLocales }` in its definition. A hub UI provider resolves a title with `resolveTitle(entry, locale)` from `@devframes/hub/client`. ## Nested commands A command's `children` nest arbitrarily deep. The palette drills into each level, and every command in the tree is bindable at any depth: a shortcut assigned to a leaf several levels down fires as directly as one on a top-level command, and each appears as its own row under **Settings → Shortcuts**, indented by nesting level. ```ts ctx.commands.register({ id: 'app:cache', title: 'Cache', children: [ { id: 'app:cache:clear', title: 'Clear', keybindings: [{ key: 'Mod+Shift+K' }], handler: clearCache }, ], }) ``` Set `showInPalette: 'without-children'` on a parent to keep its whole subtree out of root search while leaving it reachable by drilling down. ## Cross-iframe dock activation A mounted devframe's iframe uses `hub:docks:activate` to switch the active dock. ```ts // From inside a mounted devframe's iframe (its own RPC client): await rpc.call('hub:docks:activate', { dockId: 'devframes_plugin_terminals', params: { sessionId }, // opaque bag the target dock interprets }) ``` It mirrors into the `devframe:docks:active` shared-state slot; the [terminals dock](/add-ons/devframes/terminals#focusing-a-session) reads `params.sessionId`, unknown ids no-op ([DF8107](/errors/DF8107)). Server-side: `ctx.docks.activate(dockId, params?)`. ## Process-control launchers A `type: 'launcher'` dock entry is a one-click action tile. Three optional `launcher` fields make it a live process controller: `command` binds a command id dispatched via `hub:commands:execute`, `terminalSessionId` links a tracked session for a "view in terminal" action, and `digest` shows the latest progress line inline ([Hub API reference](/references/hub-api#launcher-fields)). `onLaunch` lets a same-process host framework invoke directly; provide `command`, `onLaunch`, or both. ```ts ctx.commands.register({ id: 'app:build', title: 'Run build', handler: runBuild }) const launcher = ctx.docks.register({ type: 'launcher', id: 'app:build', title: 'Build', icon: 'ph:hammer-duotone', launcher: { title: 'Run build', command: 'app:build', status: 'idle' }, }) async function runBuild() { const session = await ctx.terminals.startChildProcess( { command: 'vite', args: ['build'] }, { id: 'app:build-session', title: 'vite build' }, ) launcher.update({ launcher: { title: 'Run build', command: 'app:build', status: 'loading', terminalSessionId: session.id } }) // A child-process session keeps its `status` live: `running` → `stopped` on a // clean exit, `error` on a non-zero exit or spawn failure. Map it onto the // launcher and read the exit code from getResult(). const { exitCode } = await session.getResult() launcher.update({ launcher: { title: 'Run build', command: 'app:build', terminalSessionId: session.id, status: exitCode === 0 ? 'success' : 'error', error: exitCode === 0 ? undefined : `vite build exited ${exitCode}`, } }) } ``` ## Mounting a devframe into a hub `ctx.install(def)` registers a `DevframeDefinition` as a dock and runs its `setup(ctx)`, the imperative counterpart to `initHub`'s `devframes` list. ```ts import { createHubContext } from '@devframes/hub/node' const ctx = await createHubContext({ cwd, host, mode: 'dev' }) await ctx.install(myDevframe) ``` Framework kits and hub UI providers wrap this (e.g. `@vitejs/devtools-kit`'s `createPluginFromDevframe`). ### Connecting embedded SPAs A mounted SPA loads at `/__/` and calls `connectDevframe()`, which fetches `./__connection.json`, served by the host framework's `mountConnectionMeta(base)`: ```ts const host: DevframeHost = { mountStatic(base, distDir) { /* serve files */ }, mountConnectionMeta(base) { // serve `${base}__connection.json` → { backend: 'websocket', websocket: port } }, resolveOrigin() { /* … */ }, getStorageDir(scope) { // workspace = committable, team-shared; project = per-checkout; global = per-user if (scope === 'workspace') return join(cwd, '.devframe') if (scope === 'project') return join(cwd, 'node_modules/.my-hub') return join(homedir(), '.my-hub') }, } ``` Omitting `mountConnectionMeta` (with servable `clientAssets`) triggers [`DF8106`](https://devfra.me/errors/DF8106) and falls back to same-origin inheritance. ### Bundled host frameworks (Next.js) Load node-side built-in devframe packages via dynamic `import()` with `webpackIgnore`/`turbopackIgnore` comments: ```ts const pkgs = ['@devframes/plugin-git', '@devframes/plugin-terminals'] const defs = await Promise.all( pkgs.map(p => import(/* webpackIgnore: true */ /* turbopackIgnore: true */ p)), // Each package's default export is its `createDevframe` factory, not a // pre-built instance; call it to get one. ).then(mods => mods.map(m => m.default())) for (const def of defs) await ctx.install(def) ``` SPAs serve at `/__/` with relative assets; set `skipTrailingSlashRedirect`: ```js /** next.config.mjs */ export default { skipTrailingSlashRedirect: true } ``` ### Duplicate devframes When a devframe shares an already-mounted `id`, `duplicationStrategy` decides: `'warn'` (the default) keeps the first and drops the later with [`DF8105`](/errors/DF8105), `'silent'` drops it quietly, `'throw'` raises, and `'duplicate'` lets every instance coexist under disambiguated dock ids ([Hub API reference](/references/hub-api#duplication-strategies)). ```ts defineDevframe({ id: 'my-tool', /** … */ duplicationStrategy: 'duplicate', }) ``` ## Grouping dock entries Related dock entries collapse under one dock-rail button (a `type: 'group'` entry); an entry whose `groupId` matches the group's `id` joins it. ```ts ctx.docks.register({ type: 'group', id: 'nuxt', title: 'Nuxt', icon: 'logos:nuxt-icon', category: 'framework', defaultChildId: 'nuxt:overview', // optional; see "Activating a group" below }) ctx.docks.register({ type: 'iframe', id: 'nuxt:overview', title: 'Overview', icon: 'ph:gauge-duotone', url: '/__nuxt-overview/', groupId: 'nuxt', // joins the group above }) ``` Group and members stay independent top-level entries in `devframe:docks`. Grouping affects the dock rail, not iframes; to share **one** soft-navigated iframe, give docks a shared `frameId` and mark the anchor with `subTabs` ([Shared-iframe soft navigation](/guide/client-context#shared-iframe-soft-navigation)). ### Activating a group Activating a group resolves to one of its members. The dock rail button reopens the member last opened in the group (remembered per tab), then `defaultChildId`; with neither, it reveals the member popover. A group command, activated by its keyboard shortcut or a command-palette pick, opens that same remembered or default member, then the only visible member when there is exactly one. With several visible peers and no preferred member, it opens the command palette scoped to those members, so the choice stays with the user. Pressing the same shortcut again closes that palette. The first time any member of a group opens in a session, the hub also loads the group's `defaultChildId` member, then navigates to the requested member. A group that mixes server and client docks therefore always mounts its default member, even when the last opened member is a server dock. `hub:docks:activate` follows programmatic dock switching: it opens the remembered or default member, then the first registered member. Declare `defaultChildId` when one member is the natural landing spot; leave it off when the members are peers. ### The dual role of `category` `category` (ordered by `DEFAULT_CATEGORIES_ORDER`, default `'default'`) sets an **ungrouped** entry's outer dock-rail bucket. A **grouped** entry takes its outer bucket from the **group's** `category`, and its own `category` becomes an **in-group sub-category**; a member whose `groupId` never resolves renders top-level under its own `category`. #### Known categories `DEFAULT_CATEGORIES_ORDER` (from `@devframes/hub`, `/node`, `/client`, `/constants`) names the default buckets, running from `framework` (weight `-100`) through `default`, `app`, `ui`, `data`, `web`, `performance`, `advanced`, and `docs` to `~builtin` (always last). The weight table is in the [Hub API reference](/references/hub-api#dock-categories). Framework kits can interleave category ids or override weights; an unknown category sorts as `0`. ## The hub UI protocol A hub UI provider imports no hub classes; it renders from four shared-state slots and dispatches through two RPC methods. The slots are `devframe:docks` (every registered dock entry), `devframe:commands` (the serializable command list), `devframe:user-settings` (persisted hub settings), and `devframe:docks:active` (the most recent [dock activation](#cross-iframe-dock-activation) request); the methods are `hub:commands:execute` and `hub:docks:activate`. Types and payloads are in the [Hub API reference](/references/hub-api#hub-ui-protocol). Broadcast notifications (`devframe:docks:activate`, `devframe:terminals:updated`, `devframe:messages:updated`) arrive via `rpc.client.register(...)`; the client runtime registers `devframe:docks:activate` for you ([Events Reference](/references/events)). ## Running a devframe's code in the host page The hub ships a headless client runtime, `createDevframeClientRuntime()` (`@devframes/hub/client`): booted in the host page, it assembles the client context and imports each dock entry's client script ([Client Scripts & Client Context](/guide/client-context)). ## Example Two minimal hubs mount every built-in devframe behind an icon dock, plus a "Tabbed Tool" demonstrating [shared-iframe soft navigation](/guide/client-context#shared-iframe-soft-navigation): - [`examples/custom-hub-vite/`](https://github.com/devframes/devframe/tree/main/examples/custom-hub-vite): a \~120-line Vite host with a vanilla DOM UI. - [`examples/custom-hub-next/`](https://github.com/devframes/devframe/tree/main/examples/custom-hub-next): the same, from a Next.js App Router app. ## Diagnostics Hub-side diagnostic codes live in the `DF8xxx` range; see the [error reference](/errors). --- title: Client Scripts & Client Context navigation: icon: i-lucide-square-code description: A dock client script runs a devframe's code inside the host page; the client context is how client scripts reach the hub. --- A dock **client script** runs a devframe's code inside the **host page**; the **client context** is how client scripts reach the hub. > [!WARNING] > Experimental > The hub API is still being refined. Names may change before 1.0. ## The client runtime `createDevframeClientRuntime()` (`@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 page's browser entry import { connectDevframe, createDevframeClientRuntime } from '@devframes/hub/client' const rpc = await connectDevframe({ baseURL: '/__hub/' }) const { context, dispose } = await createDevframeClientRuntime({ rpc }) ``` ### Options Pass an already-connected `rpc` (or `connect` options for `connectDevframe`), the page's `clientType` (`'standalone'` by default, `'embedded'` inside a user app), `loadClientScripts: false` to skip dock client scripts, and boot-time `renderers` (which win over the hub's [renderer manifest](/guide/hub-initiate#renderer-modules)); see the [Hub API reference](/references/hub-api#client-runtime-options). A second boot replaces the context and warns; `dispose()` tears down listeners and unpublishes it. ## The client context The context carries the [RPC client](/guide/client) (`rpc`) and the page's `clientType`, plus six hub facets: `docks` (entries, selection, [client-only registration](#client-only-docks)), `panel` (dock panel state), `commands` (the command palette), `renderers` (the dock-renderer registry), `when` (the [when-clause](/references/when-clauses) context), and `connection` (live [connection status](/guide/client#handling-connection-and-auth-errors)). Every property is in the [Hub API reference](/references/hub-api#client-context-properties). ### Accessing the context `getDevframeClientContext()` returns the context anywhere; `undefined` before boot. ### Tracking panel state `ctx.panel.state` is the current dock panel snapshot. It contains `state: 'open' | 'closed' | 'hidden'` and includes `selectedDockId` while a dock is selected. Subscribe to [`ctx.panel.events`](/references/events#client-context-events) for later changes: ```ts import type { DockClientScriptContext } from '@devframes/hub/client' import { HUB_EVENTS } from '@devframes/hub/constants' export default function setup(context: DockClientScriptContext) { const reportPanelState = (panelState: typeof context.panel.state) => { void context.rpc.call('my-devframe:panel-state', panelState).catch(error => console.error(error)) } reportPanelState(context.panel.state) context.panel.events.on( HUB_EVENTS.client.docksPanelStateChanged, reportPanelState, ) } ``` The custom RPC keeps node-side reporting opt-in. ### Client-only docks A client runtime can register a dock local to the host page (unlike [node hub context](/guide/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)`. A client-only dock can also carry `type: 'json-render'` with an inline [JSON-render](/guide/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 client script is a `ClientScriptEntry`: `{ importFrom, importName?, eager? }`. `importName` defaults to `'default'` and `eager` defaults to `false`. An `iframe` entry's optional `clientScript` runs inside the host page when the dock entry is first activated. An `action` entry runs its `action` on each activation, while a `custom-render` entry initializes its `renderer` after selection so it can mount into the panel. Set `eager: true` on an `iframe` `clientScript` or an `action` to initialize it as soon as the RPC connection is trusted, before opening a dock panel. This suits background subscriptions and page commands, such as an `action` that registers page commands or subscribes to `entry:activated` before its first click. A `custom-render` `renderer` needs its mounted panel, so it always initializes on activation. Both the reference hub UI and `createDevframeClientRuntime()` honor these settings ([Hub API reference](/references/hub-api#dock-client-script-fields)). The exported function (`DockClientScriptContext`) receives the client context and two dock-scoped extras: - **`current`** holds 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()`). Failed setup retries on the next activation, or on a dock update for eager scripts. Setup is cached per RPC connection, dock and import descriptor. Action clicks always execute again. ### Shipping a client script `importFrom` accepts three shapes: - **A URL served by the host framework**: a self-contained ES module; works on every host framework. - **A bare npm specifier** (`'vite-plugin-vue-tracer/client/vite-devtools'`): resolved through the host framework. - **An absolute filesystem path**, declared on the definition's `dock.clientScript`. The hub serves its directory under `__page-script/` and rewrites `importFrom` to that URL, so mounting by package name needs no host wiring. Per-mount, attach a URL via `ctx.install(myDevframe, { dock: { clientScript: { importFrom } } })`; under Vite `/@fs/` serves it, and other host frameworks mount the directory statically. ### Bare npm specifiers Resolving a bare specifier is the **host framework's capability**: a host framework 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 framework with no template (Next.js) supports the URL shape only, warning [`DF8111`](/errors/DF8111) on a specifier. A hub UI provider can override with `createDevframeClientRuntime({ resolveClientModule })`. Client scripts execute in the user app's page realm (`window`); anchor shared state on `globalThis`. ### Dual boots One bundle can serve as both a client script (default export) and, via a globally-guarded self-boot, a standalone page script ([a11y inspector](/add-ons/devframes/a11y)). ## Iframe panels Dock iframes are their own documents: the panel calls `connectDevframe()`, discovering `./__connection.json` from its base. A client script and an iframe panel share the node side via RPC and shared state, or talk directly (server-free, static-build-friendly) over the [in-page channel](/guide/in-page-channel). ## Shared-iframe soft navigation A tool with many internal views (Nuxt DevTools' tabs) can surface each as a hub dock sharing **one** live iframe: the **anchor** owns a `frameId` and opts in via `ctx.install(…, { dock: { frameId, subTabs: { protocol: 'postmessage' } } })`. On mount, the client runtime attaches a **frame-nav adapter** speaking an origin-locked `postMessage` protocol on `devframe:frame-nav`: the iframe reports its tab list (`ready` / `manifest`), the host page requests a view (`navigate`), and the iframe reports internal navigation back (`navigated`). Message shapes are in the [Hub API reference](/references/hub-api#frame-nav-messages). It materializes a [client-only dock](#client-only-docks) per tab (id `:`) sharing the anchor's `frameId` and a `navTarget`, independent of [`groupId`](/guide/hub#grouping-dock-entries). ### The hub UI provider's part A hub UI provider keeps one iframe alive per `frameId` (shown/hidden); on mount, it sets the element on the anchor's `docks.getStateById(anchorId)` state (`domElements.iframe`) and emits `dom:iframe:mounted`. See the "Tabbed Tool" in [`examples/custom-hub-vite`](https://github.com/devframes/devframe/tree/main/examples/custom-hub-vite) / [`custom-hub-next`](https://github.com/devframes/devframe/tree/main/examples/custom-hub-next). --- title: Serve a Hub Anywhere navigation: icon: i-lucide-server description: initHub() from @devframes/hub/initiate serves a whole multi-devframe devtools install from one web-standard handler on a catch-all route. --- `initHub()` from `@devframes/hub/initiate` serves a whole multi-devframe devtools install from one web-standard handler on a catch-all route. ```ts 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(), configure(ctx) { ctx.commands.register({ id: 'app:hello', title: 'Hello', handler: () => 'hi' }) }, }) ``` `base` is required (echoed as `hub.base`); each mounted devframe runs `setup()` against the **shared hub context**. The instance mirrors `initDevframe`'s API; see [The Standard Handler](/adapters/initiate#mount-the-handler). ## The shared socket One transport serves the namespace, chosen in precedence: `ws.port` pins a side-car; `server` shares the host framework's `node:http` upgrade at `__ws`; `ws: { sidecar: true }` takes a free port; none leaves the socket to the host framework: Node uses `hub.attach(server)`, Bun/Deno `attachBunWsTransport` / `attachDenoWsTransport`. The advertised path is hub-base-absolute (`/__devframes/__ws`). Dev-reevaluated host frameworks (Next, Nitro) memoize it on `globalThis`. ## The namespace The namespace serves the hub UI at `/` (the `ui.viewer` SPA, or an index document when headless) and each devframe's SPA at `/` with its own `__connection.json` pointing at the shared socket. Hub-level endpoints sit alongside: `embedded.js` (the `ui.embedded` bootstrap), `__connection.json`, `__ws`, `__index.json`, `__client-imports.js`, and `__mcp` (mounted by the `'auto'` default once agent tools exist). The route table is in the [Hub API reference](/references/hub-api#hub-namespace-routes). Devframe ids become URL segments, validated: reserved names throw `DF8000`, non-route-safe `DF8004`. ## The `ui` slot The hub is headless; `DevframeHubUi` is pure data: ```ts interface DevframeHubUi { viewer?: { distDir: string } // a standalone SPA served at the namespace root embedded?: { entry: string } // a prebuilt bootstrap served at embedded.js assets?: Record string | Uint8Array> // extra UI-owned files setup?: (ctx) => void | Promise // publish static config via ctx.staticConfig } ``` `@devframes/hub-ui`'s `createUi()` is the reference (standalone `viewer` SPA + floating dock); its `setup(ctx)` publishes config to `ctx.staticConfig.ui` (`ConnectionMeta.configs.ui`): - **`viewer`**: set to `false` to disable the standalone viewer. - **`branding`**: rebrand the UI (logo, name, primary color). `background` accepts any CSS `background` value (color, gradient, image, or `transparent`) or `{ light, dark }` variants. These flat forms apply everywhere. Use `{ standalone, iframe? }` to specialize the framed viewer; an omitted `iframe` value falls back to `standalone`. Built-in devframe panels read `branding.primaryColor` from the existing connection metadata at startup and map it to CSS primary tokens. Standalone SPAs keep their default accent; status and preview palettes remain independent. Reload panels after changing startup branding. - **`dockPreferences`** tunes the dock rail: `categoryOrder`, floating-dock `maxVisibleItems`, first-run `defaultMode` (`'float'`/`'edge'`) and `defaultPosition`. - **`embeddedVisibility`** sets the floating dock's reveal policy: - `'normal'` (default): shows immediately. - `'passive'`: hidden until `Shift+Alt+D`, then persisted per-origin (later browser sessions start shown). - `'hidden'`: hidden until `Shift+Alt+D`, that browser session only. - **`locale`** sets the default UI language, one of the tags in `HUB_UI_LOCALES` (`en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `es`, `fr`, `de`, `pt-BR`, `ru`). Without it the UI follows the browser language and falls back to English. A visitor's own pick in Settings → Appearance wins and persists per browser. The hub UI writes the language it resolved back into `ConnectionMeta.configs.ui.locale` on its window, so a same-origin frame that inherits the connection (see [Client](/guide/client)) reads the effective language at boot. To add a language, add its tag and native name to `packages/hub-ui/src/locales.ts`, translate a copy of `packages/hub-ui/src/client/i18n/locales/en.json` under that tag, and register the file in the `messages` map in `packages/hub-ui/src/client/i18n/index.ts`. The i18n test fails on a missing key or a changed `{slot}`. ## Renderer modules A dock type's renderer (e.g. [JSON-Render](/guide/json-render)) composes via `initHub({ renderers })`. Each registration `{ type, file, importName? }` (`file` = a prebuilt ES module exporting a `DockRenderer`) is served at `__renderers/.mjs` and published into the `devframe:dock-renderers` manifest; client runtimes import it lazily on first mount: ```ts import { createUi } from '@devframes/hub-ui' import { jsonRenderUiRenderer } from '@devframes/json-render-ui/hub' initHub({ ui: createUi(), renderers: [jsonRenderUiRenderer()], }) ``` A renderer registered at boot (`createDevframeClientRuntime({ renderers })`) overrides the manifest; an uncovered type shows the hub UI provider's missing-renderer fallback. Registrations are validated fail-fast: one module per type (`DF8108`), an existing bundle (`DF8109`), a route-safe type name (`DF8110`). ## One Auth for the hub The hub's **single Auth** is one gate at the shared transport for every mounted devframe, built-ins, and the MCP route; one handshake (OTP, magic link, or pre-shared token) unlocks the namespace; `auth: false` disables it for localhost. The aggregate MCP route mounts through the `'auto'` default once any mounted devframe (or an agent-flagged hub command) exposes agent tools; `mcp: true` forces it on, `mcp: false` off. It has its own origin gate, independent of this RPC Auth: the mounted route trusts same-machine callers, and `mcp: { authorization }` adds an identity check when the hub is reachable beyond loopback. The hub exposes one aggregate route over every mounted devframe's tools. ## Singular vs hub mounting A devframe's SPA and RPC client are byte-identical in both cases; only the environment differs: | What the SPA / RPC client sees | Singular (`/__git/`) | Hub (`/__devframes/git/`) | | ------------------------------ | ------------------------------------ | ------------------------------------------------------------------------- | | Runtime base | `/__git/` | `/__devframes/git/` (transparent) | | `__connection.json` | own meta, own socket | per-devframe meta → shared hub socket | | RPC registry | this devframe's functions | merged: all mounted devframes + hub built-ins, cross-devframe | | Shared state | own context's slots | all mounted devframes' slots + hub slots | | Auth | own gate, own token | the single hub Auth | | Hub subsystems | none | docks, terminals, messages, commands; the devframe is also an iframe dock | | MCP | `__mcp`, this devframe's tools | the hub-level aggregate | | Isolation | hard (own context, own transport) | cooperative (shared context) | ## Static builds `buildHub()` from `@devframes/hub/build` is the hub counterpart of the [build adapter](/adapters/build): it bakes the whole hub into a directory any static file server can serve. Each devframe's SPA is copied to `//` (absolute-path page scripts alongside at `/__page-script/`), the UI slot's viewer and `embedded.js` next to them, and `__connection.json` (`backend: 'static'`) plus a shared [RPC dump](/adapters/build) at the hub base, with a snapshot of every shared-state key (docks, commands, renderer manifest) baked in - so `createDevframeClientRuntime()` and every panel boot from the dump with no live server. ```ts import { buildHub } from '@devframes/hub/build' await buildHub({ outDir: 'dist/__devframes', // corresponds to `base` at serve time devframes: [createA11yDevframe(), createMessagesDevframe()], ui: createUi(), }) ``` A mount served outside the hub base writes to `outDir`'s parent (the deploy root) by its absolute path, so a host can keep the hub at `/__devtools/` while its devframe SPAs and assets stay top-level siblings (`/__inspect/`, `/__devtools-assets/`) rather than children of the hub base. `outDir` holds the hub subtree, its parent holds the whole deploy root, and either directory serves as-is. Browser-side tools keep working in full: a page script still loads into the host page and talks to its panel over the [in-page channel](/guide/in-page-channel) (the a11y inspector scans a production app exactly as it does in dev). Reads resolve from the baked dump (`static`/`snapshot` RPCs, shared-state snapshots); live writes (messages, command execution) have no server, so the browser clients degrade to local no-ops, and a panel's dock-activation deep links ride a same-origin `BroadcastChannel` instead of the RPC relay. A devframe whose value is inherently live declares `capabilities.build: false` and silently stays out of the build entirely - no dock, no SPA copy, no RPCs in the dump. The built-in terminals, code-server, and assets devframes declare it, so a hub mounting every built-in bakes only the tools that mean something statically. See the [buildHub options](/references/hub-api#buildhub-options) reference, and [`examples/a11y-messages-playground`](https://github.com/devframes/devframe/tree/main/examples/a11y-messages-playground) for a Vite host whose `vite build` output ships the hub. ## Bring your own context Host frameworks that assemble `createHubContext` + `ctx.install` themselves pass the context instead of a `devframes` list: ```ts const hub = initHub({ base: DEVFRAMES_HUB_BASE, context: ctx }) ``` It then serves only hub-level endpoints and transport; serve each mounted devframe's meta from `hub.connectionMeta()` yourself. The same `context` option works for a static build: `buildHub({ context: ctx, outDir })` bakes an already-mounted context instead of a `devframes` list, reading `ctx.frames` and `ctx.views.buildStaticDirs` for what to emit, so a host that mounted its own context reuses `buildHub` rather than reimplementing it. Pass `clean: false` to bake beside an app's own build output. --- title: Cross-Devframe Services navigation: icon: i-lucide-share-2 description: 'ctx.services lets one devframe expose a typed, namespaced capability visible to every devframe. Two tiers: in-process services (provide/get) share live objects between devframes; wire services also register RPC and advertise to RPC clients.' --- `ctx.services` lets one devframe expose a typed, namespaced capability visible to every devframe. Two tiers: in-process services (`provide`/`get`) share live objects between devframes; [wire services](#wire-services) also register RPC and advertise to RPC clients. The [Node-Side API reference](/references/node-api#devframeserviceshost) collects the host methods and the definition/descriptor fields as lookup tables. ## Providing a service Augment `DevframeServicesRegistry` with your id and type, then provide in setup: ```ts export interface SourcesService { register: (entry: SourceEntry) => () => void } declare module 'devframe' { interface DevframeServicesRegistry { 'my-plugin:sources': SourcesService } } export function setup(ctx: DevframeNodeContext) { ctx.services.provide('my-plugin:sources', createSourcesService()) } ``` Service ids prefix the provider's id (`:`), unique per context; a second `provide()` under a taken id throws [`DF0037`](https://devfra.me/errors/DF0037). `provide()` returns a revoke; guard idempotent setup with `has(id)`. ## Consuming a service A consumer loads it with a types-only import (`import type {} from '@my-org/my-plugin'`), then reaches it in `setup`: ```ts ctx.services.whenAvailable('my-plugin:sources', (sources) => { sources.register({ id: 'other-plugin:state', data: () => state }) }) ``` Prefer `whenAvailable` over `get`: it fires immediately if provided, else on `provide`, and re-fires on revoke/re-provide. `get(id)` returns the implementation or `undefined` (ids without an augmentation as `unknown`). ## The `DevframeServicesHost` API `DevframeServicesHost` exposes `provide(id, service) => revoke`, `get(id)`, `has(id)`, `whenAvailable(id, cb) => unsubscribe`, and `keys()`; plus a wire tier `install(input, options?) => Promise` and `ready() => Promise`. ## Wire services A **wire service** is a shared node-side capability, an npm module. ### Shipping one A service package's default export is a factory returning `DevframeServiceDefinition`: ```ts export default function createOpenService(options?: OpenServiceOptions): DevframeServiceDefinition { return { package: '@devframes/service-open', // the registry key version: '1.0.0', // advertised; checked against declared ranges scope: 'devframes:service:open', // RPC namespace options, setup(ctx, { options }) { // `ctx` is pre-scoped: this registers `devframes:service:open:open-in-editor` ctx.rpc.register({ name: 'open-in-editor', handler: input => api.openInEditor(input) }) return api // the node API served from ctx.services.get(package) }, } } ``` Two declaration merges type it: RPC ids into `DevframeRpcServerFunctions`, package → scope into `DevframeServicesScopeRegistry`. ### Declaring Services are **declarative**: a devframe lists what it consumes; a hub, shared ones on `initHub`. The adapter resolves each package: for a devframe, **against its own dependencies** via [`importMetaUrl`](/guide/devframe-definition#resolving-against-the-devframes-own-dependencies). ```ts // devframe side: on the definition defineDevframe({ importMetaUrl: import.meta.url, // resolution base for the declared packages services: [ { package: '@devframes/service-open' }, { package: '@devframes/service-shiki', version: '^1', options: { langs: ['vue'] } }, ], }) ``` A hub lists shared, constructed services on `initHub({ services: [createShikiService(opts)] })`. Entries are optional; uninstalled packages are skipped (`has() === false`). Mark one `required: true` to fail hard: [`DF0067`](https://devfra.me/errors/DF0067) on a missing package, [`DF0068`](https://devfra.me/errors/DF0068) on an unsatisfied `version` range (otherwise a range mismatch only warns, [`DF0069`](https://devfra.me/errors/DF0069)). ### Lifecycle: ready before setup The hub constructs every declared service (all devframes plus `initHub`) **once**, deep-merging option sets (objects recurse, arrays union-dedupe, scalars later-win; override with `mergeOptions`), **before any `setup(ctx)` runs**, so setup consumes services synchronously via `ctx.services.get(pkg)`. For a runtime-only service, `ctx.services.install(input)` builds immediately; re-installing a constructed package returns the existing API, warning [`DF0066`](https://devfra.me/errors/DF0066) if options can't merge. ### Feature-detecting on the RPC client Installed services are advertised via `devframe:services` [shared state](/guide/shared-state), mirrored on the RPC client's `rpc.services`: ```ts const rpc = await connectDevframe() if (rpc.services.has('@devframes/service-open')) { const open = rpc.services.get('@devframes/service-open')! await open.rpc.call('open-in-editor', { path }) } ``` A reactive UI subscribes via `rpc.services.state()`. `has()`/`get()`/`keys()` are synchronous snapshots, empty before the first sync; each handle carries the `version` and `meta`. ### Built-in services Three first-party wire services ship ready to install, each with its own page under [Add-ons › Services](/add-ons/services): - **[`@devframes/service-open`](/add-ons/services/open)** (`devframes:service:open`): open files in an editor or OS explorer, refusing paths outside the workspace root. - **[`@devframes/service-git`](/add-ons/services/git)** (`devframes:service:git`): typed read/write git operations on one repo. - **[`@devframes/service-shiki`](/add-ons/services/shiki)** (`devframes:service:shiki`): node-side [Shiki](https://shiki.style) highlighting, LRU-cached and dual-theme. ## Services, RPC, or shared state? - **Services**: node-to-node in-process live references, never crossing a wire. - **[RPC](/guide/rpc)**: browser-to-node calls to a named function. - **[Shared state](/guide/shared-state)**: serializable data synced node side ↔ RPC clients. A service serves *other devframes*, RPC *surfaces or coding agents*, a [wire service](#wire-services) both. --- title: Deep Linking navigation: icon: i-lucide-link description: 'Send a user to a view inside a devframe (from another dock, a coding agent, or a copied URL) two ways: the hub relays a dock activation to focus a dock in place; a standalone SPA reads its URL hash to restore the view.' --- Send a user to a view inside a devframe (from another dock, a coding agent, or a copied URL) two ways: the hub relays a **dock activation** to focus a dock in place; a standalone SPA reads its **URL hash** to restore the view. ## Focusing a dock inside a hub The active dock is state local to the host page. A mounted devframe, in its own iframe and RPC client, reaches it via the hub: `hub:docks:activate` switches the active dock, carrying an opaque `params` bag the target reads: ```ts await rpc.call('hub:docks:activate', { dockId: 'devframes:plugin:data-inspector', params: { sourceId: 'my-plugin:store' }, }) ``` The hub broadcasts the request and mirrors it into the [`devframe:docks:active`](/guide/shared-state) slot, so a dock mounting *because* of the switch converges on it. The target subscribes, filters on its `dockId`, and reads `params`; see [Cross-iframe dock activation](/guide/hub#cross-iframe-dock-activation). Focus is one-shot: the [terminals dock](/add-ons/devframes/terminals#focusing-a-session) reads `params.sessionId`, the [Data Inspector](/add-ons/devframes/data-inspector#deep-linking) `params.sourceId`; a target naming something unregistered waits, then fires once. An id that never arrives is a no-op; an unknown `dockId` warns ([DF8107](/errors/DF8107)). ## Standalone URL deep links Standalone, a devframe SPA owns its own URL. Encode the shareable view in the **hash** (`#…`): it round-trips a copied link, survives reload, and stays clear of the handshake query string (`?devframe_auth_token=`). Parse with `URLSearchParams`: ```ts // read on load const params = new URLSearchParams(location.hash.replace(/^#/, '')) const sourceId = params.get('source') // write back, without stacking history entries history.replaceState(history.state, '', `#${params.toString()}`) // react to back/forward and manual edits window.addEventListener('hashchange', applyState) ``` The [terminals dock](/add-ons/devframes/terminals#deep-linking) keys a selection as `#id=`; the [Data Inspector](/add-ons/devframes/data-inspector#deep-linking) encodes its workbench (`#source=…&query=…` plus filter/auto-rerun flags). `replaceState` writes never fire `hashchange`, so boot read, live listener, and write-back don't loop. Keep credentials out of anything shareable: a handshake token belongs in the query string, scrubbed once read (as the Data Inspector does), never in a copyable hash. --- title: Build Your Own JSON-Render Frontend navigation: icon: i-lucide-paintbrush description: '@devframes/json-render-ui is the reference frontend, not the protocol; any implementation of the renderer contract replaces it. The Next hub witness ships a React one at src/client/json-render/.' --- `@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/custom-hub-next) ships a React one at `src/client/json-render/`. ## The contract `@devframes/json-render/hub` owns the types: ```ts import type { JsonRenderDockRenderer } from '@devframes/json-render/hub' // a hub DockRenderer narrowed to the json-render dock entry const renderer: JsonRenderDockRenderer = async ({ entry, container, context }) => { // mount your framework's root into `container`, render `entry.view` return { dispose() { /* unmount, unsubscribe */ } } } ``` Resolve the entry's `view`: - `{ 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'`, disabling action dispatch there. ## Behavior expectations - **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. - **Unknown components**: a component your registry lacks renders as a 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 update. ## Plugging it in - **Local registration**: a hub UI provider bundling its own client runtime passes `createDevframeClientRuntime({ 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), default-exporting the renderer, plus a node helper returning the registration: ```ts import type { DockRendererRegistration } from '@devframes/hub/initiate' export function myRenderer(): DockRendererRegistration { return { type: 'json-render', file: myPrebuiltModulePath } } ``` Compose it with `initHub({ renderers: [myRenderer()] })`; the hub serves the module and hub UI providers import it lazily (see [renderer modules](/guide/hub-initiate#renderer-modules)). 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`. --- title: Build Your Own Hub UI navigation: icon: i-lucide-palette description: A hub UI provider implements two contracts, the node-side ui slot and the browser-side context. @devframes/hub-ui is the reference. --- A hub UI provider implements two contracts, the node-side `ui` slot and the browser-side context. `@devframes/hub-ui` is the reference. ## The node seam: `DevframeHubUi` `initHub({ ui })` takes pure data (see [the `ui` slot](/guide/hub-initiate#the-ui-slot)): ```ts interface DevframeHubUi { viewer?: { distDir: string } // a standalone SPA served at the hub base embedded?: { entry: string } // a self-contained bootstrap at embedded.js assets?: Record string | Uint8Array> // extra UI-owned files setup?: (ctx) => void | Promise // publish static config via ctx.staticConfig } ``` Ship a function returning this object (`createUi()`) with prebuilt assets using relative paths. `setup(ctx)` runs once at hub init: config in `ctx.staticConfig` is serialized into `ConnectionMeta.configs` and read by the RPC client at handshake. ## The browser-side contracts A hub UI provider renders from the hub's shared state via [`createDevframeClientRuntime()`](/guide/client-context), which assembles the `DevframeClientContext` (docks, commands, renderers, when-clauses, connection) and loads dock client scripts. Honor: ### Dock entry types Render the built-in variants of the open dock union (`DevframeDockEntryRegistry`, `@devframes/hub/types`): `iframe` (the entry's `url` in a kept-alive iframe, honoring `subTabs` soft nav), `action` (a dock-rail button running its client script), `custom-render` (a container its client script mounts into), `launcher` (a launch call-to-action reflecting `launcher.status`), `group` (one dock-rail button collapsing its members), and `~builtin` (your native views for reserved ids). What to render per type is in the [Hub API reference](/references/hub-api#dock-entry-types). Honor `when` / `visibility`, `category` grouping (order from `DEFAULT_CATEGORIES_ORDER`, `@devframes/hub/constants`), and the `hub:docks:activate` broadcast. Show titles through `resolveTitle(entry, locale)` so a registrant's `titleLocales` translations reach the user. An `iframe` entry serving a [remote assets package](/guide/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 iframe'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()` (`@devframes/hub/client`), wiring local registrations and the hub's [renderer manifest](/guide/hub-initiate#renderer-modules): ```ts import { createDockRenderersContext } from '@devframes/hub/client' const renderers = createDockRenderersContext({ context: () => context, manifest: () => manifestState.value(), // the devframe:dock-renderers slot }) const result = await renderers.mount(entry, container) ``` Show a state per mount-result variant: - `{ status: 'mounted', dispose }`: the renderer owns the container; call `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 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 hub UI provider (Vue, `@antfu/design`). - [`examples/custom-hub-vite`](https://github.com/devframes/devframe/tree/main/examples/custom-hub-vite) and [`examples/custom-hub-next`](https://github.com/devframes/devframe/tree/main/examples/custom-hub-next): hand-rolled hub UI providers in vanilla DOM and React. --- title: Adapters navigation: icon: i-lucide-plug description: 'The lowest-level path is the standard handler, initDevframe(def, { base }): a Web Standard (request: Request) => Promise for any catch-all route. Every path below builds on it.' --- The lowest-level path is [the standard handler](/adapters/initiate), `initDevframe(def, { base })`: a Web Standard `(request: Request) => Promise` for any catch-all route. Every path below builds on it. Adapters wrap it as `createXxx(def, options?)` at `devframe/adapters/`. `cac` needs an optional peer ([`cac`](https://github.com/cacjs/cac)). ## Comparison | Entry point | Module | Factory | Best for | | -------------------------------------- | ---------------------------- | ------------------------------ | ---------------- | | [Standard Handler](/adapters/initiate) | `devframe/initiate` | `initDevframe(def, { base })` | Raw handler | | [`cac`](/adapters/cac) | `devframe/adapters/cac` | `createCac()` | Standalone tools | | [`dev`](/adapters/dev) | `devframe/adapters/dev` | `createDevServer()` | Dev server | | [`build`](/adapters/build) | `devframe/adapters/build` | `createBuild()` | Static snapshots | | [`vite`](/adapters/vite) | `@vitejs/devtools-kit/node` | `createPluginFromDevframe()` | Vite DevTools | | [`embedded`](/adapters/embedded) | `devframe/adapters/embedded` | `createEmbedded(def, { ctx })` | Runtime | | [`mcp`](/adapters/mcp) | `devframe/adapters/mcp` | `createMcpServer()` | Coding agents | ## Mount paths SPA basePath depends on the adapter: | Adapter kind | Default basePath | Reason | | --------------------------- | ---------------- | --------------------------------- | | `cli`, `build` (standalone) | `/` | Owns the origin. | | `vite`, `embedded` (hosted) | `/__/` | Shares a host framework's origin. | Override with `DevframeDefinition.basePath`: ```ts defineDevframe({ id: 'my-tool', basePath: '/devframes/', // force this base regardless of adapter setup(ctx) { /* … */ }, }) ``` The SPA discovers its base at runtime; see [Client](/guide/client#runtime-basepath-discovery). --- title: The Standard Handler navigation: icon: i-lucide-webhook description: 'initDevframe() turns a DevframeDefinition into a running devframe whose .handler (a Web Standard (request: Request) => Promise) carries everything a devframe serves (SPA, __connection.json discovery, RPC socket, auth gate, MCP route) under one mount base. Every other serving path (adapters,…' --- `initDevframe()` turns a `DevframeDefinition` into a running devframe whose `.handler` (a Web Standard `(request: Request) => Promise`) carries everything a devframe serves (SPA, `__connection.json` discovery, RPC socket, auth gate, MCP route) under one mount base. Every other serving path ([adapters](/adapters), [framework kits](/frameworks), [hub](/guide/hub-initiate)) is assembled from it. Mount it with a catch-all route. ```ts import { initDevframe } from 'devframe/initiate' import myDevframe from './my-tool' const devtools = initDevframe(myDevframe, { base: '/__my-tool/' }) // devtools.base, devtools.handler, devtools.nodeMiddleware, devtools.attach, // devtools.handleUpgrade, devtools.ready, devtools.context, // devtools.connectionMeta(), devtools.close() ``` `base` is required: pass `resolveBasePath(def, 'hosted')` (`def.basePath ?? /__/`) to default it, and the running devframe echoes it back as `devtools.base`. `handler`/`nodeMiddleware` await readiness internally. The running devframe binds no port, so [the WebSocket binding](#the-websocket-binding) is the host framework's call. ## Mount the handler ::code-group ```ts [Vite] // vite.config.ts // connect-style middleware + Vite's own server for the socket import { initDevframe } from 'devframe/initiate' import { defineConfig } from 'vite' import myDevframe from './my-tool' export default defineConfig({ plugins: [{ name: 'my-tool', apply: 'serve', configureServer(server) { const devtools = initDevframe(myDevframe, { base: '/__my-tool/', server: server.httpServer ?? undefined, }) server.middlewares.use(devtools.nodeMiddleware) }, }], }) ``` ```ts [Nitro] // routes/__my-tool/[...path].ts // routes/__my-tool/index.ts // for the namespace root, since a catch-all doesn't match its own empty path. import { defineHandler } from 'nitro' import { devtools } from '../../devtools' export default defineHandler(event => devtools.handler(event.req)) ``` ```ts [Hono] // server.ts // `serve()` hands back the node server the socket rides on import { serve } from '@hono/node-server' import { Hono } from 'hono' import { devtools } from './devtools' const app = new Hono() app.all('/__my-tool/*', c => devtools.handler(c.req.raw)) devtools.attach(serve({ fetch: app.fetch, port: 3000 })) ``` ```ts [Next.js] // app/%5F_my-tool/[[...path]]/route.ts // Next reserves `_`-prefixed folders, so the segment is URL-encoded (`%5F_` decodes to `__`). import { initDevframe } from 'devframe/initiate' import myDevframe from '@/my-tool' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' // Route handlers never see upgrades, so the socket asks for a side-car; the // globalThis memo keeps a dev-time reload from starting a second one. const g = globalThis as { devtools?: ReturnType } const devtools = g.devtools ??= initDevframe(myDevframe, { base: '/__my-tool/', ws: { sidecar: true }, }) export const GET = devtools.handler ``` ```ts [Nuxt] // server/middleware/devtools.ts import { devtools } from '../devtools' export default defineEventHandler((event) => { const { pathname } = new URL(toWebRequest(event).url) // `devtools.base` is the normalized mount base; no repeated string. if (pathname.startsWith(devtools.base) || pathname === devtools.base.slice(0, -1)) return devtools.handler(toWebRequest(event)) }) ``` ```ts [SvelteKit] // src/routes/%5F_my-tool/[...path]/+server.ts import myDevframe from '$lib/my-tool' import { initDevframe } from 'devframe/initiate' const g = globalThis as { devtools?: ReturnType } const devtools = g.devtools ??= initDevframe(myDevframe, { base: '/__my-tool/', ws: { sidecar: true }, }) export const GET = ({ request }) => devtools.handler(request) ``` :: Host frameworks with dev-time module reloading (Next, Nitro, SvelteKit) re-evaluate the calling module, so memoize the running devframe on `globalThis` to avoid leaking a socket per reload. `@devframes/next`'s `createDevframeNextHandler` handles this. ## The WebSocket binding Fetch handlers only hand over `Request`s, so the host framework 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 framework'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 host frameworks whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild). 4. **The host framework'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. To run your own check before the socket opens, own the listener, match the `__ws` path first (the listener sees every upgrade on the server, including the host framework's own), and call `handleUpgrade` once the check passes; the `server` option attaches its listener asynchronously, after any guard that wraps the listeners present at startup. `ws.url` controls the *advertisement* instead, so the browser dials it verbatim. Alone, an external WebSocket server owns the transport and its auth (wire the running devframe's `context` via `createContextRpcServer` + a WS transport); alongside a local binding it overrides only the advertisement (the tunnel pattern). `__connection.json` describes the active combination. Asking a configured running devframe to take over the host framework's upgrades reports `DF0055` (a local binding owns the socket) or `DF0056` (`ws.url` handed it off). ## Auth The running devframe **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner when an untrusted browser client asks for a code (`rpc.requestAuthCode()`); an already-authorized page triggers no print. The magic link's origin comes from the `origin` option, or is derived from a request whose own origin is loopback or exactly matches an `allowedOrigins` entry. A non-loopback deployment (behind a proxy, on a LAN, on a public host) sets `origin` explicitly so the magic link resolves to the intended address; a raw inbound `Host` header and forwarded headers are never trusted. 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 it internally. To host **many** devframes, use [`initHub`](/guide/hub-initiate). --- title: CLI (cac) navigation: icon: i-lucide-terminal description: 'A cac CLI around a DevframeDefinition with dev, build, and mcp commands. cac is an optional peer:' --- 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 ``` The [lower-level factories](#use-your-own-cli-framework) need no `cac`. ```ts import { defineDevframe } from 'devframe' import { createCac } from 'devframe/adapters/cac' const myDevframe = defineDevframe({ id: 'my-tool', name: 'My Tool', clientAssets: './client/dist', setup(ctx) { /* register docks, RPC, etc. */ }, }) await createCac(myDevframe).parse() ``` ```sh my-tool # dev server at http://localhost:9999/ my-tool --port 8080 my-tool build --out-dir dist-static my-tool build --out-dir dist-static --base /my-tool/ my-tool mcp # stdio MCP server ``` The SPA serves at `/` standalone, `/__my-tool/` hosted ([Mount paths](/adapters/#mount-paths)). ## Options `createCac(def, options?)`: | Option | Default | Description | | -------------- | --------------------------- | -------------------------------------------------------------------------------- | | `defaultPort` | `9999` (or `def.cli?.port`) | Dev port if `--port` unset. | | `configureCli` | none | `(cli: CAC) => void` to add commands/flags. | | `onReady` | none | `(info: { origin, port, app }) => void \| Promise`, called once listening. | Returns a `CacHandle`: ```ts interface CacHandle { cli: CAC // raw cac instance; mutate before calling parse() parse: (argv?: string[]) => Promise } ``` ## Definition-level `cli` fields ```ts defineDevframe({ id: 'my-tool', clientAssets: './client/dist', // built SPA served as the UI cli: { command: 'mytool', // binary name; default: the id port: 7777, // preferred port portRange: [7777, 9000], // passed through to get-port-please random: false, // passed through to get-port-please host: '127.0.0.1', // default bind host; --host overrides open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated configure(cli) { // contribute capability flags/commands cli.option('--config ', 'Custom config file') .option('--no-files', 'Skip file matching') }, }, setup(ctx, { flags }) { // `flags` is the parsed cac flag bag, holding both devframe's // built-ins (`--port`, `--host`, `--open`) and anything declared in // `cli.configure` or `configureCli`. }, }) ``` `configure` runs *before* `createCac`'s `configureCli`. ## Headless logging Print a banner via `onReady`: ```ts await createCac(myDevframe, { onReady({ origin }) { console.log(`ESLint Config Inspector ready at ${origin}`) }, }).parse() ``` ## Use your own CLI framework Peer factories for a commander/yargs program: | Building block | Entry | Purpose | | ------------------------------------ | ------------------------- | --------------------------------- | | [`createDevServer()`](/adapters/dev) | `devframe/adapters/dev` | h3 + WebSocket RPC + SPA mount | | [`createBuild()`](/adapters/build) | `devframe/adapters/build` | Static deploy | | [`createMcpServer()`](/adapters/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). --- title: Dev navigation: icon: i-lucide-server-cog description: 'createCac''s building block: h3 + WebSocket RPC + the SPA at the resolved base path.' --- `createCac`'s building block: h3 + WebSocket RPC + the SPA at the resolved base path. ```ts import { createDevServer } from 'devframe/adapters/dev' import myDevframe from './my-tool' const handle = await createDevServer(myDevframe, { port: 7777, onReady: ({ origin }) => console.log(`Ready at ${origin}`), }) // graceful shutdown: SIGINT, hot reload, test teardown process.on('SIGINT', () => handle.close().then(() => process.exit(0))) ``` 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` | Listen port. | | `flags` | `{}` | To `setup(ctx, { flags })`. | | `distDir` | `def.clientAssets` | 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` | none | WS-bind callback. | ## WebSocket endpoint The RPC socket shares the HTTP port at `__ws`, advertised *relative*. Configure `def.cli.ws`: | Field | Scenario | Advertised `websocket` | | ------- | ------------------------ | ------------------------- | | `route` | same server, other route | `{ path: }` | | `port` | different port | `{ port, path: }` | | `url` | remote origin | URL verbatim | Precedence `url` > `port` > `route`; `url` keeps the socket local on `route`. ## Port resolution `resolveDevServerPort(def, opts?)` resolves a port before start. ```ts import { resolveDevServerPort } from 'devframe/adapters/dev' const port = await resolveDevServerPort(myDevframe, { host: '127.0.0.1' }) // honors def.cli?.port / portRange / random ``` | Option | Default | Description | | ------------- | ------------------------------ | ---------------------------------------- | | `host` | `def.cli?.host ?? 'localhost'` | Bind host (`get-port-please` detection). | | `defaultPort` | `def.cli?.port ?? 9999` | Preferred-port override. | --- title: Build navigation: icon: i-lucide-package description: 'Produces a static deploy:' --- Produces a static deploy: 1. Copies the SPA dist into ``. 2. Runs `setup(ctx)` with `mode: 'build'`. 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' import myDevframe from './my-tool' await createBuild(myDevframe, { outDir: 'dist-static', }) ``` | Option | Default | Description | | --------- | ------------------ | ------------------------------------------------------------- | | `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 RPC client runs read-only. For a custom URL base, build with relative asset paths (`vite.base: './'`). `buildHub()` from `@devframes/hub/build` produces the same kind of deploy for a whole hub: [Static builds](/guide/hub-initiate#static-builds). --- title: Vite navigation: icon: i-simple-icons-vite description: Wraps a DevframeDefinition so Vite DevTools' kit plugin-scan picks it up. The factory lives in @vitejs/devtools-kit/node, keeping devframe Vite-free. --- 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' import myDevframe from './my-tool' export default function myVitePlugin() { return createPluginFromDevframe(myDevframe) } ``` 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:` | Plugin name. | | `base` | `def.basePath ?? /.${id}/` | Mount path override. | | `dock` | `{}` | Overrides for the iframe dock entry (category, icon, when). | | `setup` | none | Setup hook run only in the Vite host; receives the kit-augmented context. | --- title: Embedded navigation: icon: i-lucide-box description: 'Register a devframe into an already-running context at runtime: dynamic, post-startup registration (unlike vite''s plugin-scan). Inherits the hosted /__/ default.' --- Register a devframe into an already-running context at runtime: dynamic, post-startup registration (unlike [`vite`](/adapters/vite)'s plugin-scan). Inherits the hosted `/__/` default. ```ts import { createEmbedded } from 'devframe/adapters/embedded' import myDevframe from './my-tool' await createEmbedded(myDevframe, { ctx: existingCtx }) ``` | Option | Required | Description | | ------ | -------- | ---------------------------------------------- | | `ctx` | ✓ | Target `DevframeNodeContext` to register into. | --- title: MCP navigation: icon: i-lucide-bot description: 'Exposes a devframe''s agent-facing API as a Model Context Protocol server: coding agents call flagged RPCs and read resources.' --- Exposes a devframe's agent-facing API as a [Model Context Protocol](https://modelcontextprotocol.io) server: coding agents call flagged RPCs and read resources. The implementation (and the MCP SDK behind it) lives in **`@devframes/agentic`**, an optional peer of `devframe`: install it to enable the agent surface, and keep importing everything from `devframe/adapters/mcp` - the peer is loaded for you, never imported directly. A devframe without an agent surface ships with neither the peer nor the SDK installed: ```sh npm install @devframes/agentic ``` ```ts import { createMcpServer } from 'devframe/adapters/mcp' import myDevframe from './my-tool' await createMcpServer(myDevframe, { transport: 'stdio' }) ``` `createMcpServer` serves `stdio` through the MCP SDK's `serveStdio`, pinning one server instance per connection. ## Route-based server The dev server exposes the same MCP API over HTTP, live. The default setting is **`'auto'`**: the route mounts once the devframe exposes an agent surface (an `agent`-flagged RPC, a registered tool or resource) *and* `@devframes/agentic` is installed - flag your first function, install the peer, and the agent view is on. A devframe with nothing flagged mounts no route and loads no MCP code; an agent surface without the peer warns once ([DF0078](/errors/DF0078)) and mounts nothing, while an explicit `mcp` setting without the peer throws ([DF0079](/errors/DF0079)). `mcp: false` stays silent either way. Pin the behavior where you host the tool - it's a hosting decision, so pass `mcp` to `createCac` when you assemble the CLI (or to `createDevServer` / `initDevframe` / `initHub` when you host it programmatically): `true` always mounts, `false` never mounts, an object customises the route: ```ts import { createCac } from 'devframe/adapters/cac' import myDevframe from './my-tool' createCac(myDevframe, { mcp: true }).parse() // force on; `false` forces off; omit for 'auto' ``` The endpoint speaks Streamable-HTTP at `/__mcp` (`/__/__mcp` under a host framework), sharing its origin/port. `--mcp` / `--no-mcp` override per run; `__connection.json` advertises the mounted route. The endpoint is **stateless**: it serves the [2026-07-28 revision](https://modelcontextprotocol.io/specification/2026-07-28) per request through the SDK's `createMcpHandler`, building a fresh MCP server for each request, so every HTTP request stands alone, with no `Mcp-Session-Id` to correlate. 2025-era clients are still served through the SDK's stateless legacy path. ### Origin gate, and opt-in identity The **origin gate** guards every request: `Origin` must be loopback (or allow-listed), and `Origin`-less requests are rejected (a disallowed origin gets `403`). This is DNS-rebinding hardening that keeps a browser from reaching the route across origins. It is **not** a network-locality check: `Origin` is a request header, so a non-browser client (curl, a script) sends any value it likes. Same-machine locality is instead proven from the **connected peer**: on the origin-only default (the `'auto'` default and `mcp: true`, with no widened `allowedOrigins` and no identity check), a request whose peer address is not loopback gets `403` even with a loopback `Origin`. The peer address comes from the socket, not a header, so a remote client cannot forge it. This is what makes "trusts same-machine callers" hold, and it's all a local dev tool needs. (A host that can't resolve a peer address, such as a serverless route, keeps the origin-only behavior; harden it with `authorization`.) `Origin` still proves nothing about *who* is calling, and the peer check only proves *where from*. When same-machine isn't your trust boundary (a LAN/tunnel origin, a shared/CI host, a destructive tool surface), layer on an **identity check** with `authorization` - which also lifts the loopback-peer restriction, so authenticated callers may be remote: ```ts createCac(myDevframe, { mcp: { authorization: process.env.MY_TOKEN }, }).parse() ``` `authorization` takes a bearer token (backed by an env var, never a literal), a `(request) => boolean` callback that governs identity only and cannot relax the origin gate, or `false` for the explicit origin-only default. A request presents the bearer as `Authorization: Bearer `, matched in constant time; a missing or wrong bearer gets `401` with a `WWW-Authenticate: Bearer` challenge. The origin gate always runs first, so a disallowed origin is `403` regardless of the credential. Widen the origin allow-list for a tunnel/LAN reach with `mcp: { authorization: process.env.MY_TOKEN, allowedOrigins: ['https://tunnel.example.com'] }`. Never place the token in a URL, in `__connection.json`, in the instance registry, in logs, or on the command line; it belongs only in configuration and the `Authorization` header. ### Hosted bridges Both bridges forward the setting to their side-car dev server, advertising the mounted endpoint in `__connection.json`: ```ts // Vite (@devframes/vite) devframeViteBridge(myDevframe, { mcp: true }) // Next.js (@devframes/next) createDevframeNextHandler(myDevframe, { mcp: true }) ``` Both honor the same contract: omitted is `'auto'`, `true` forces the origin-only route on; add `mcp: { authorization }` to harden. ## Custom host frameworks `createMcpFetchHandler(ctx, options)` returns the endpoint as a `Request → Response` handler plus a `dispose()`; mount it on any fetch server. ```ts import { createMcpFetchHandler } from 'devframe/adapters/mcp' const mcp = createMcpFetchHandler(ctx, { serverName: 'my-tool (devframe)', serverVersion: '1.0.0', exposeSharedState: true, // Optional identity check on top of the origin gate; omit for origin-only. // authorization: process.env.MY_TOKEN, }) // route every method on /__mcp to mcp.fetch(request) ``` ## Discovery: `devframe connect` 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 { "mcpServers": { "devframe": { "command": "npx", "args": ["devframe", "connect"] } } } ``` Two gateway tools (`devframe:connect:*` ids; see [tool ids and wire names](/guide/agent-native#tool-ids-and-wire-names)): - **`devframe_connect_list-instances`**: list running dev servers and their MCP tools. - **`devframe_connect_call-tool`**: invoke one tool on a running devframe (`{ port, tool, args }`) over Streamable-HTTP. Discovery reads the **instance registry**: every `createDevServer` writes `~/.devframe/instances/-.json`, dialed with a loopback origin. In-process host frameworks register via `registerDevframeInstance` (`devframe/node`). `--port ` probes a port for `__connection.json` at its root; add `--base ` for a devframe mounted below the root (e.g. `devframe connect --port 5173 --base /__devtools/` for Vite DevTools). A probe only counts a JSON object as connection meta, so an SPA fallback page on the wrong path reads as "no instance". `DEVFRAME_INSTANCES_DIR` relocates the registry, `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts out. The connector needs the same optional `@devframes/agentic` peer as the adapter; `devframe connect` without it throws [DF0046](/errors/DF0046). Most instances trust same-machine callers, so the connector reaches them with no credential. For an instance you *hardened* with a bearer, the connector reads `DEVFRAME_MCP_AUTH_TOKEN` and presents it (never a CLI flag, since command-line arguments are visible to other processes). See [Agent-Native](/guide/agent-native) for the API and safety model. --- title: Frameworks navigation: icon: i-lucide-layers description: 'The framework kits (@devframes/vite, @devframes/nuxt, @devframes/next) integrate devframe with a meta-framework''s dev server. Two subpaths:' --- The framework kits ([`@devframes/vite`](/frameworks/vite), [`@devframes/nuxt`](/frameworks/nuxt), [`@devframes/next`](/frameworks/next)) integrate devframe with a meta-framework's dev server. Two **subpaths**: | Scope | Subpath | You are… | | ---------- | ------------ | ------------------------------------------------------------------------ | | **single** | `.../single` | building & dev-serving a **single devframe's SPA** with that tool | | **hub** | `.../hub` | mounting a whole **[hub](/guide/hub)** (many devframes) inside that tool | The bare package root throws, pointing to the two subpaths. | Package | single | hub | | ------------------------------------- | ----------------------------------------------------------------- | ------------------------------------- | | [`@devframes/vite`](/frameworks/vite) | `devframeVitePlugin` / `devframeViteBridge` / `devframeVite` | `viteDevframeHub` (+ `/hub/client`) | | [`@devframes/nuxt`](/frameworks/nuxt) | the Nuxt module (`modules: ['@devframes/nuxt/single']`) | the hub Nuxt module (+ `/hub/client`) | | [`@devframes/next`](/frameworks/next) | `withDevframe` + `createDevframeNextHandler` (+ `/single/client`) | `nextDevframeHub` (+ `/hub/client`) | ## single: author one devframe For framework-neutral CLI/build/embedded outputs, use the [adapters](/adapters) instead. ## hub: mount a hub Each `hub` scope wraps [`initHub`](/guide/hub-initiate) and defaults the UI to [`@devframes/hub-ui`](/guide/build-your-own-hub-ui)'s `createUi()` (`ui` to override, `ui: false` for headless). Per tool: **[Vite](/frameworks/vite#mounting-a-hub)**, **[Nuxt](/frameworks/nuxt#mounting-a-hub)**, **[Next](/frameworks/next#mounting-a-hub)**. `@devframes/vite/hub` and `@devframes/nuxt/hub` recommend the native hub UI providers ([Vite DevTools](https://devtools.vite.dev), [Nuxt DevTools](https://devtools.nuxt.com)) once (silence with `{ quiet: true }`). Next has none, so `@devframes/next/hub` stays quiet. --- title: Vite navigation: icon: i-simple-icons-vite description: '@devframes/vite splits into @devframes/vite/single (dev-serve one devframe''s SPA) and @devframes/vite/hub (mount a hub); the bare import throws.' --- `@devframes/vite` splits into **`@devframes/vite/single`** (dev-serve one devframe's SPA) and [**`@devframes/vite/hub`**](#mounting-a-hub) (mount a hub); the bare import throws. `single` exports `devframeVitePlugin`, `devframeViteBridge`, and `devframeVite`; also used by [`@devframes/nuxt`](/frameworks/nuxt). ```ts import { devframeViteBridge, devframeVitePlugin } from '@devframes/vite/single' import { defineConfig } from 'vite' import myDevframe from './my-tool' export default defineConfig({ /** Statically mounts the built SPA at `/__/`, with no RPC server: */ plugins: [devframeVitePlugin(myDevframe)], // Or bridge the RPC/WS backend into this dev server instead, so the // user app owns the SPA: // plugins: [devframeViteBridge(myDevframe)], }) ``` ## `devframeVitePlugin`: static mount 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. | ## `devframeViteBridge`: RPC bridge 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. | | `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` | none | To `def.setup(ctx, { flags })`. | | `auth` | gated (interactive OTP) | `false` to opt out, or a `DevframeAuthHandler` for a custom scheme. | | `mcp` | `'auto'` | Expose the MCP route at `__mcp`. `'auto'` mounts once agent tools exist; `true` forces the origin-only route on (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. | | `allowedOrigins` | loopback-only | Widen the WS origin check for a `vite --host` / container / tunnel origin: extra origins, a `WsOriginRegistry`, or `false` to disable (the auth gate stays the trust boundary). | ## `devframeVite`: convenience wrapper `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 [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' import { defineConfig } from 'vite' export default defineConfig({ plugins: [viteDevframeHub({ devframes: [] })], }) ``` Pass `ui` to swap the hub UI provider, `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). `build: true` also bakes the hub into `vite build` output: [`buildHub`](/guide/hub-initiate#static-builds) writes the static hub subtree into `` and the UI's `embedded.js` tag is injected into the built HTML, so the deployed app ships working devtools against a `static` backend (baked reads, no live server). --- title: Nuxt navigation: icon: i-simple-icons-nuxtdotjs description: '@devframes/nuxt splits into @devframes/nuxt/single (author one devframe) and @devframes/nuxt/hub (mount a hub); the bare import throws.' --- `@devframes/nuxt` splits into `@devframes/nuxt/single` (author one devframe) and [`@devframes/nuxt/hub`](#mounting-a-hub) (mount a hub); the bare import throws. The `single` module wires a Nuxt SPA as a devframe's browser side and types `useNuxtApp().$rpc` as `DevframeRpcClient`. ## Install ```ts [nuxt.config.ts] export default defineNuxtConfig({ modules: ['@devframes/nuxt/single'], }) ``` ## Using `$rpc` ```vue [app.vue] ``` Or a composable: ```ts [composables/usePayload.ts] export function usePayload() { const { $rpc } = useNuxtApp() return useAsyncData('payload', () => $rpc.call('my-tool:get-payload')) } ``` ## Options ```ts [nuxt.config.ts] export default defineNuxtConfig({ modules: ['@devframes/nuxt/single'], devframe: { baseURL: './', // where the devframe snapshot lives, relative to the page skipAppDefaults: false, // opt out of the app.baseURL / vite.base defaults }, }) ``` - **`baseURL`** resolves against `document.baseURI` at runtime. - **`skipAppDefaults: true`** ships absolute asset paths. ## Dev-time RPC bridge Pass a devframe definition: ```ts [nuxt.config.ts] import myDevframe from './src/my-tool' // defineDevframe(...) export export default defineNuxtConfig({ modules: [['@devframes/nuxt/single', { devframe: myDevframe }]], }) ``` `nuxt dev` now: - 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. On by default when `devframe` is set; disable it (browser side only) with `devMiddleware: false`. ### Customizing the bridge ```ts [nuxt.config.ts] export default defineNuxtConfig({ modules: [['@devframes/nuxt/single', { devframe: myDevframe, devMiddleware: { port: 7777, host: '0.0.0.0', flags: { config: process.env.MY_CONFIG }, }, }]], }) ``` - **`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` Production uses `createCac` (or `createBuild`), producing a static `__connection.json` + `__rpc-dump/` snapshot from `clientAssets`: ``` my-tool/ ├── bin.mjs # createCac(myDevframe).parse() ├── src/ │ ├── my-tool.ts # defineDevframe + setup(ctx) { ctx.rpc.register(...) } │ └── app/ # Nuxt SPA, uses `@devframes/nuxt` └── dist/ ├── cli.mjs # bundled Node entry └── public/ # Nuxt build output, pointed at by clientAssets ``` ## How it works 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`): ```ts const rpc = await connectDevframe({ baseURL: config.public.devframe.baseURL }) return { provide: { rpc } } ``` 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 [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({ modules: [['@devframes/nuxt/hub', { devframes: [] }]], }) ``` 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) - [Client](/guide/client): `connectDevframe` reference - [Adapters](/adapters) --- title: Next navigation: icon: i-simple-icons-nextdotjs description: '@devframes/next hosts devframes from a Next.js App Router app via a route handler: one fetch handler serves each SPA and its __connection.json via serveStaticHandler.' --- > [!WARNING] > Experimental. `@devframes/next`'s API is still settling; expect changes before a stable release. `@devframes/next` hosts devframes from a Next.js App Router app via a route handler: one `fetch` handler serves each SPA and its `__connection.json` via [`serveStaticHandler`](/adapters/dev). It splits into `@devframes/next/single` and [`@devframes/next/hub`](#mounting-a-hub); the bare import throws. `single` gives **`withDevframe()`**, **`createDevframeNextHandler()`**, and a React RPC-client helper (`@devframes/next/single/client`). ## Config ```ts [next.config.mjs] import { withDevframe } from '@devframes/next/single' export default withDevframe({ // ...your own Next config }) ``` `withDevframe` sets `skipTrailingSlashRedirect: true`, keeping the rest. ## Hosting a single devframe `createDevframeNextHandler(definition)` serves the SPA and starts a side-car RPC/WS server: ```ts [app/__my-tool/[[...path\\]\\]/route.ts] import { createDevframeNextHandler } from '@devframes/next/single' import myDevframe from '@/my-tool' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const handler = createDevframeNextHandler(myDevframe) export const GET = handler.fetch ``` `close()` stops the side-car; `ready` resolves when listening. | Option | Default | Description | | ---------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `base` | `def.basePath ?? '/__/'` | SPA mount path. | | `host` | `def.cli?.host ?? 'localhost'` | Side-car bind host. | | `port` | from `def.cli?.port` | Side-car port. | | `flags` | none | Passed to `def.setup(ctx, { flags })`. | | `auth` | `false` | `true` for the OTP gate, or a handler. | | `mcp` | `'auto'` | Expose the MCP route. `'auto'` mounts once agent tools exist; `true` forces the origin-only route on (trusts same-machine callers); `McpRouteOptions` can add an `authorization` identity check. | | `allowedOrigins` | loopback-only | Widen the side-car WS origin check for a remotely-accessed dev server (container / Codespace / tunnel): extra origins, a `WsOriginRegistry`, or `false` to disable (the auth gate stays the trust boundary). | | `key` | `@devframes/next::` | `globalThis` memoization key. | ## Hosting a hub [`@devframes/hub`](/guide/hub)'s `initHub` mounts every devframe under `/` behind one `handler` (memoize on `globalThis`; see `examples/custom-hub-next`): ```ts [devframe/host.ts] import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' // Next route handlers can't accept WS upgrades, so the socket asks for a // side-car of its own; the browser discovers it via `__connection.json`. const hub = initHub({ base: DEVFRAMES_HUB_BASE, devframes: [myDevframe], ws: { sidecar: true }, auth: false, }) export const { handler } = hub // mount on a `[[...path]]` route handler ``` ```ts [app/__devframes/[[...path\\]\\]/route.ts] export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export async function GET(request: Request): Promise { return handler(request) // serves every mounted SPA + connection meta } ``` ## React RPC client ```tsx [app/providers.tsx] 'use client' import { RpcProvider } from '@devframes/next/single/client' export function Providers({ children }: { children: React.ReactNode }) { return {children} } ``` `useRpc()` returns the `DevframeRpcClient` (`null` while connecting), `useRpcStatus()` the live `{ status, error }`; both throw outside ``. ```tsx [app/panel.tsx] 'use client' import { useRpc, useRpcStatus } from '@devframes/next/single/client' export function Panel() { const rpc = useRpc()?.scope('my-tool:') const { error } = useRpcStatus() if (!rpc) return

{error ? `connection failed: ${error.message}` : 'connecting…'}

// rpc.rpc.call('get-payload'), rpc.sharedState, … } ``` ## Runtime Handlers calling `fetch` pin `runtime = 'nodejs'` (Node side-car). ## Mounting a hub `@devframes/next/hub`'s `nextDevframeHub()` is a `globalThis`-memoized handle; `createNextDevframeHub()` is the builder. UI defaults to `@devframes/hub-ui`; `ui` swaps it, `ui: false` gives a headless hub via `@devframes/next/hub/client`'s `useDevframeHubClient()`. ```ts [app/__devframes/[[...path\\]\\]/route.ts] import { nextDevframeHub } from '@devframes/next/hub' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' const hub = nextDevframeHub({ devframes: [] }) export const GET = (req: Request) => hub.handler(req) export const POST = (req: Request) => hub.handler(req) export const DELETE = (req: Request) => hub.handler(req) ``` The aggregate MCP route mounts by default once any mounted devframe exposes agent tools (the `'auto'` setting). Force it on with `mcp: true` (origin-only, trusting same-machine callers), off with `mcp: false`, or add `mcp: { authorization }` for an identity check when the app is reachable beyond localhost. No native hub UI provider here, so this scope stays quiet; `createDevframeNextHost()` is the low-level `DevframeHost`. ## See also - [Vite](/frameworks/vite) - [Hub](/guide/hub) - [custom-hub-next](https://github.com/devframes/devframe/tree/main/examples/custom-hub-next) --- title: Ecosystem description: Devtools, hosts and services built on Devframe by the community and the core team. navigation: layout: page icon: i-lucide-blocks seo: title: 'Devframe ecosystem: devtools, hosts and services' description: Browse devtools built on Devframe, the hub UI providers that host them, wire services, and real products running Devframe in production. --- ::ecosystem-list ```yaml [props] packages: - name: ESLint Config Inspector description: Inspects ESLint flat configs author: eslint repo: eslint/config-inspector category: Devframes image: /images/ecosystem/logos/eslint-config-inspector.svg color: '#4b32c3' - name: node-modules-inspector description: Visualizes the node_modules dependency graph author: antfu repo: antfu/node-modules-inspector category: Devframes image: /images/ecosystem/logos/node-modules-inspector.svg color: '#3f8f4a' - name: vite-plugin-inspect description: Inspects the intermediate state of Vite plugins author: antfu-collective repo: antfu-collective/vite-plugin-inspect category: Devframes icon: i-carbon-search-advanced color: '#646cff' - name: medula description: Lets AI agents inspect and mutate live app state over MCP author: posva repo: posva/medula category: Devframes icon: i-octicon-mcp-24 color: '#d97706' - name: pulls.review description: Groups pull request changes and reviews them one scope at a time author: antfu repo: antfu/pulls.review category: Devframes image: /images/ecosystem/logos/pulls-review.svg color: '#3fb982' - name: Pinia Colada devtools description: Inspects Pinia Colada queries and mutations author: posva url: https://github.com/posva/pinia-colada/tree/main/devtools category: Devframes image: /images/ecosystem/logos/pinia-colada.svg color: '#e0b000' - name: UnoCSS Inspector description: Inspects generated utility classes and matched rules author: unocss url: https://github.com/unocss/unocss/tree/main/packages-integrations/inspector category: Devframes image: /images/ecosystem/logos/unocss-inspector.svg - name: Vite DevTools description: Hosts many devframes in one dock for any Vite app author: vitejs url: https://devtools.vite.dev category: Hosts image: /images/ecosystem/logos/vite-devtools.svg color: '#8b5cf6' - name: Nuxt DevTools description: Nuxt DevTools v4, built on Devframe and Vite DevTools author: nuxt url: https://devtools.nuxt.com category: Hosts image: /images/ecosystem/logos/nuxt-devtools.svg color: '#00dc82' - name: '@devframes/plugin-data-inspector' description: Queries live server objects with jora author: devframes url: /add-ons/devframes/data-inspector category: Devframes icon: i-ph:crosshair-duotone official: true - name: '@devframes/plugin-inspect' description: Browses RPC, shared state and the agent surface of a connection author: devframes url: /add-ons/devframes/inspect category: Devframes icon: i-ph:stethoscope-duotone official: true - name: '@devframes/plugin-og' description: Inspects Open Graph metadata and previews social cards author: devframes url: /add-ons/devframes/og category: Devframes icon: i-ph:image-square-duotone official: true - name: '@devframes/plugin-a11y' description: Runs axe-core and highlights accessibility issues in the page author: devframes url: /add-ons/devframes/a11y category: Devframes icon: i-ph:person-simple-circle-duotone official: true - name: '@devframes/plugin-git' description: Git dashboard for status, log, branches and diffs author: devframes url: /add-ons/devframes/git category: Devframes icon: i-ph:git-branch-duotone official: true - name: '@devframes/plugin-terminals' description: Terminal panels for streamed output and interactive PTY shells author: devframes url: /add-ons/devframes/terminals category: Devframes icon: i-ph:terminal-window-duotone official: true - name: '@devframes/plugin-code-server' description: Runs VS Code in the browser as an embedded editor panel author: devframes url: /add-ons/devframes/code-server category: Devframes icon: i-ph:code-duotone official: true - name: '@devframes/plugin-assets' description: Browses, previews, uploads and manages static assets author: devframes url: /add-ons/devframes/assets category: Devframes icon: i-ph:images-duotone official: true - name: '@devframes/plugin-messages' description: Browses, filters and dismisses the hub message feed author: devframes url: https://github.com/devframes/devframe/tree/main/plugins/messages category: Devframes icon: i-ph:chat-circle-dots-duotone official: true - name: '@devframes/service-open' description: Opens files in the editor or reveals them in the file explorer author: devframes url: /add-ons/services/open category: Services icon: i-ph:file-arrow-up-duotone official: true - name: '@devframes/service-git' description: Read and write git operations over RPC author: devframes url: /add-ons/services/git category: Services icon: i-ph:git-branch-duotone official: true - name: '@devframes/service-shiki' description: Server-side Shiki highlighting shared by every devframe author: devframes url: /add-ons/services/shiki category: Services icon: i-ph:paint-brush-duotone official: true ``` :: ::ecosystem-showcase ```yaml [props] addUrl: https://github.com/devframes/devframe/edit/main/docs/content/4.ecosystem.md sites: - name: Vite DevTools description: One dock for any Vite app, with Rolldown bundle analysis, Vitest UI, Oxc tooling and the devframes you mount. author: vitejs url: https://devtools.vite.dev screenshot: /images/ecosystem/vite-devtools.webp - name: Nuxt DevTools description: Nuxt DevTools v4 builds on Devframe and Vite DevTools and adds Nuxt-specific panels. author: nuxt url: https://devtools.nuxt.com screenshot: /images/ecosystem/nuxt-devtools.webp - name: ESLint Config Inspector description: Visualizes ESLint flat configs, the rules each file gets and where they come from. author: eslint url: https://github.com/eslint/config-inspector screenshot: /images/ecosystem/eslint-config-inspector.webp - name: node-modules-inspector description: Interactive dependency graph of node_modules with versions, duplicates and module types. author: antfu url: https://github.com/antfu/node-modules-inspector screenshot: /images/ecosystem/node-modules-inspector.webp - name: vite-plugin-inspect description: Shows how each Vite plugin transforms a module, step by step. author: antfu-collective url: https://github.com/antfu-collective/vite-plugin-inspect screenshot: /images/ecosystem/vite-plugin-inspect.webp - name: pulls.review description: Groups changed files, summarizes what matters and reviews pull requests one scope at a time. author: antfu url: https://github.com/antfu/pulls.review screenshot: /images/ecosystem/pulls-review.webp - name: Pinia Colada devtools description: Inspects Pinia Colada queries and mutations, with refetch, invalidate and simulated errors. author: posva url: https://github.com/posva/pinia-colada/tree/main/devtools screenshot: /images/ecosystem/pinia-colada-devtools.webp - name: weapp-vite DevTools description: Analyzes WeChat mini-program bundles, tracks build and HMR events, and compares source files with generated output. author: weapp-vite url: https://github.com/weapp-vite/weapp-vite/tree/main/packages/dashboard screenshot: /images/ecosystem/weapp-vite-devtools.webp ``` #title Built with Devframe #description Devtools that run Devframe in production :: ::ecosystem-cta #title Add your project #description Building on Devframe? Open a pull request that adds a card, or open an issue first if you are not sure it fits. #ctas :::u-button ```yaml [props] to: https://github.com/devframes/devframe/edit/main/docs/content/4.ecosystem.md icon: i-lucide-plus target: _blank color: neutral ``` Add your project ::: :::u-button ```yaml [props] to: https://github.com/devframes/devframe/issues/new target: _blank color: neutral variant: outline ``` Open an issue ::: :: --- title: Add-ons navigation: icon: i-lucide-blocks description: 'Ready-to-run packages built on Devframe: built-in devframes you can run, compose, or learn from, and wire services that share one node-side capability across every devframe on a host.' --- Ready-to-run packages built on Devframe, in two families: - **[Devframes](/add-ons/devframes)** are complete built-in devtools (`@devframes/plugin-*`). Run one standalone, compose several into your own DevTools, or read one as a reference for building your own. - **[Services](/add-ons/services)** are wire services (`@devframes/service-*`): one node-side capability installed once per host and consumed by every devframe and RPC client, without re-bundling it. ## Devframes Complete tools, each picking its own UI framework yet sharing one node-side API: [RPC](/guide/rpc), [shared state](/guide/shared-state), and `connectDevframe`. | Devframe | UI framework | What it does | | --------------------------------------------------- | --------------- | -------------------------------------------------------- | | [Data Inspector](/add-ons/devframes/data-inspector) | Vue | Query live server-side objects with jora. | | [Devframe Inspector](/add-ons/devframes/inspect) | Vue | Browse RPC, shared state, and agent exposure. | | [Open Graph Viewer](/add-ons/devframes/og) | Vue | Inspect Open Graph / Twitter metadata and card previews. | | [Accessibility Inspector](/add-ons/devframes/a11y) | Solid | Run axe-core; list WCAG violations. | | [Git](/add-ons/devframes/git) | React (Next.js) | Repository dashboard: status, graph, branches, diffs. | | [Terminals](/add-ons/devframes/terminals) | Svelte | Stream output and run interactive PTY shells. | | [Code Server](/add-ons/devframes/code-server) | Vue | Run VS Code in the browser. | | [Assets](/add-ons/devframes/assets) | Vue | Browse, preview, upload, rename, and delete files. | ## Services Shared node-side capabilities other devframes install and consume; see [Cross-Devframe Services](/guide/services) for the mechanism, and the [Node-Side API reference](/references/node-api#devframeserviceshost) for the host API. | Service | Scope | What it does | | ------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------- | | [Open](/add-ons/services/open) (`@devframes/service-open`) | `devframes:service:open` | Open files in an editor or the OS explorer, with workspace-root containment. | | [Git](/add-ons/services/git) (`@devframes/service-git`) | `devframes:service:git` | Typed read/write git operations over RPC on one repo. | | [Shiki](/add-ons/services/shiki) (`@devframes/service-shiki`) | `devframes:service:shiki` | Node-side [Shiki](https://shiki.style) syntax highlighting, cached and dual-theme. | ## Running a built-in devframe Most built-in devframes publish a `bin`: ```sh pnpx @devframes/plugin-inspect # the Devframe Inspector, standalone pnpx @devframes/plugin-og # inspect Open Graph metadata and social cards pnpx @devframes/plugin-git # the Git dashboard against the current repo pnpx @devframes/plugin-assets # manage the files under /public ``` Each also exports a `create…Devframe` factory; see each page for options. --- title: Devframes navigation: icon: i-lucide-palette description: Ready-to-run built-in example devframes. Compose your own DevTools solution from them, run one standalone, or read one as a reference for building your own. --- Ready-to-run built-in example devframes (`@devframes/plugin-*`). Compose your own DevTools solution from them, run one standalone, or read one as a reference for building your own. | Devframe | UI framework | What it does | | --------------------------------------------------- | --------------- | -------------------------------------------------------- | | [Data Inspector](/add-ons/devframes/data-inspector) | Vue | Query live server-side objects with jora. | | [Devframe Inspector](/add-ons/devframes/inspect) | Vue | Browse RPC, shared state, and agent exposure. | | [Open Graph Viewer](/add-ons/devframes/og) | Vue | Inspect Open Graph / Twitter metadata and card previews. | | [Accessibility Inspector](/add-ons/devframes/a11y) | Solid | Run axe-core; list WCAG violations. | | [Git](/add-ons/devframes/git) | React (Next.js) | Repository dashboard: status, graph, branches, diffs. | | [Terminals](/add-ons/devframes/terminals) | Svelte | Stream output and run interactive PTY shells. | | [Code Server](/add-ons/devframes/code-server) | Vue | Run VS Code in the browser. | | [Assets](/add-ons/devframes/assets) | Vue | Browse, preview, upload, rename, and delete files. | ## One RPC client, any framework Each devframe picks its own UI framework yet shares one node-side API: [RPC](/guide/rpc), [shared state](/guide/shared-state), and `connectDevframe`. ## Running a built-in devframe Most built-in devframes publish a `bin`: ```sh pnpx @devframes/plugin-inspect # the Devframe Inspector, standalone pnpx @devframes/plugin-og # inspect Open Graph metadata and social cards pnpx @devframes/plugin-git # the Git dashboard against the current repo pnpx @devframes/plugin-assets # manage the files under /public ``` Each also exports a `create…Devframe` factory; see each page for options. --- title: Data Inspector navigation: icon: i-ph:crosshair-duotone description: 'A Vue query workbench for live server-side objects: register data sources, then compose jora queries in the owning process.' --- A **Vue** query workbench for live server-side objects: register **data sources**, then compose [jora](https://discoveryjs.github.io/jora/) queries in the owning process. Package: `@devframes/plugin-data-inspector` · **Vue + Vite** ![Data Inspector screenshot](/screenshots/plugin-data-inspector-1.png) *Query workbench with results and data shape panel* ![Data Inspector screenshot](/screenshots/plugin-data-inspector-2.png) *Query data with advanced Jora syntax* ## What it does - **Query workbench**: a CodeMirror jora editor with server-computed autocomplete; queries auto-run as you type, [state in the URL hash](#deep-linking). - **Auto rerun**: optional poller (`auto rerun every N seconds`). - **Result viewer**: normalizes to strict JSON (circulars → `$ref`; Maps, Sets, class instances, functions, Dates get type badges) plus per-query stats. - **Expansion, shape & filters**: deep nodes fetch lazily via `load deeper`; a shape panel shows a one-level skeleton; filters drop functions and `_`/`$` properties. - **Saved queries**: recipes (`query` + title/description + filters) in the **workspace scope** (committable) and the **project scope** (per-checkout). A built-in **example source** registers by default; opt out with `exampleSource: false` (`--no-example`; `DEVFRAME_DATA_INSPECTOR_EXAMPLE=0` when injected). ## Providing data sources The registry is **process-global**; register anywhere, before or after mount. ```ts import { registerDataSource } from '@devframes/plugin-data-inspector/registry' registerDataSource({ id: 'my-plugin:store', // namespace with your devframe id title: 'My plugin store', description: 'The live state store', icon: 'i-ph:database-duotone', data: () => store, queries: [ { title: 'Active sessions', query: 'sessions.mapEntries().value.[active]' }, { title: 'Config (data only)', query: 'config', excludeFunctions: true }, ], }) ``` `data` is a plain value or sync/async factory; `static: true` resolves it once; `queries` show read-only beside saved ones; `registerDataSource` returns an unregister callback. Zero-dependency devframes `register` through the typed [context service](/guide/devframe-definition#cross-devframe-services) `ctx.services.whenAvailable('devframes:plugin:data-inspector:sources', …)`. > [!WARNING] > Queries are eval-grade: jora invokes any function reachable as an own property and fires own getters. Register live objects accordingly, and keep endpoints on loopback. ## Deep linking Workbench state lives in the URL hash (`#source=&query=`, filters); the handshake token rides the query string (`?devframe_auth_token=`), scrubbed on read. In a hub, another dock jumps to a source via [dock activation](/guide/deep-linking#focusing-a-dock-inside-a-hub): `rpc.call('hub:docks:activate', { dockId: 'devframes:plugin:data-inspector', params: { sourceId } })` (waits for registration). ## Standalone ```sh pnpx @devframes/plugin-data-inspector # the example source pnpx @devframes/plugin-data-inspector stats.json log.jsonl # one static source per data file pnpx @devframes/plugin-data-inspector build stats.json # self-contained static export pnpx @devframes/plugin-data-inspector attach # attach to a process running the inject endpoint ``` `.json` parses whole; `.jsonl` / `.ndjson` as a record array. `build` embeds the dataset in a static site. ## Mount into a Vite host For [Vite DevTools](https://devtools.vite.dev): ```ts // vite.config.ts import createDataInspectorDevframe from '@devframes/plugin-data-inspector' import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' import { defineConfig } from 'vite' export default defineConfig({ plugins: [ createPluginFromDevframe(createDataInspectorDevframe()), ], }) ``` Register sources with `registerDataSource`, or mount via `devframeVite(createDataInspectorDevframe())` from `@devframes/vite/single`. ## Programmatic ```ts import { createDataInspectorDevframe } from '@devframes/plugin-data-inspector' export default createDataInspectorDevframe({ exampleSource: false, }) ``` ## Attach to another Node process The target starts the inject endpoint: ```ts import { exposeDataInspector } from '@devframes/plugin-data-inspector/inject' await exposeDataInspector({ sources: [{ id: 'app:store', title: 'App store', data: () => store }], }) ``` `sources` pre-registers entries; call it empty to expose whatever's registered. Or code-free: ```sh DEVFRAME_DATA_INSPECTOR=1 node --import @devframes/plugin-data-inspector/inject server.js ``` This auto-registers a **`globalThis`** source: ```ts // somewhere in the running process globalThis.store = store globalThis.cache = cache ``` It reads `globalThis` at query time, so later assignments appear next run (opt out: `DEVFRAME_DATA_INSPECTOR_GLOBAL=0`). The inject endpoint binds `127.0.0.1`, requires the trust handshake with a per-run token, and advertises itself in `node_modules/.data-inspector/discovery.json`, which `attach` reads (or pass `ws://…` + `--token`). ## RPC All `devframes:plugin:data-inspector:*`: | Function | Type | Returns | | -------------------------------------------- | ------------------ | -------------------------------------------------- | | `sources` | `query` | Every registered source (meta, suggested queries). | | `query` | `query` | Runs a jora query; normalized result with stats. | | `queryPath` | `query` | Depth-limited subtree slice (lazy expansion). | | `skeleton` | `query` | A source's type skeleton, honoring filters. | | `suggest` | `query` | Autocomplete candidates at the cursor. | | `saved:list` / `saved:save` / `saved:delete` | `query` / `action` | Saved-query recipes (`workspace`, `project`). | ## Source [`plugins/data-inspector`](https://github.com/devframes/devframe/tree/main/plugins/data-inspector) --- title: Devframe Inspector navigation: icon: i-ph:stethoscope-duotone description: 'A self-inspector for any devframe connection, including the host framework''s: a Vue SPA.' --- A self-inspector for any devframe connection, including the host framework's: a **Vue** SPA. Package: `@devframes/plugin-inspect` · framework: **Vue + Vite** ![Inspector](/screenshots/plugin-inspect-1.png) *RPC functions list* ![Inspector](/screenshots/plugin-inspect-2.png) *Agent tools* ![Inspector](/screenshots/plugin-inspect-3.png) *History panels* ## What it does - **Functions**: type, flags, JSON Schema, agent exposure; read-only `query` / `static` invokable inline. - **Client**: the browser side of the connection: client RPC functions (invoked locally in the page) and the page's WebMCP tools, live from the model context's `getTools()` when the browser supports discovery, otherwise projected from `agent`-flagged client functions. - **State**: shared-state keys in a live JSON tree that flashes changes. - **Agent**: tools and resources for agents. - **History**: a timeline of RPC calls and shared-state updates. ## Standalone ```sh pnpx @devframes/plugin-inspect ``` ## Mount into a Vite host For a [Vite DevTools](https://devtools.vite.dev) app: ```ts // vite.config.ts import createInspectDevframe from '@devframes/plugin-inspect' import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' import { defineConfig } from 'vite' export default defineConfig({ plugins: [ createPluginFromDevframe(createInspectDevframe()), ], }) ``` Or mount directly with `devframeVite` from `@devframes/vite/single`: ```ts // vite.config.ts import createInspectDevframe from '@devframes/plugin-inspect' import { devframeVite } from '@devframes/vite/single' import { defineConfig } from 'vite' export default defineConfig({ plugins: [ devframeVite(createInspectDevframe()), ], }) ``` ## Programmatic `createInspectDevframe(options)` returns a definition for any adapter: ```ts import { createInspectDevframe } from '@devframes/plugin-inspect' import { createCac } from 'devframe/adapters/cac' await createCac(createInspectDevframe({ port: 9100 })).parse() ``` ## RPC All functions are namespaced `devframes:plugin:inspect:*`: | Function | Type | Returns | | ----------------- | ------------------ | ----------------------------------------------------------------- | | `list-functions` | `query` (snapshot) | RPC functions with metadata. | | `invoke` | `action` | Invokes a read-only `query` / `static`; refuses `action`/`event`. | | `list-state-keys` | `query` (snapshot) | Shared-state keys. | | `describe-agent` | `query` (snapshot) | Agent manifest: tools and resources. | ## Source [`plugins/inspect`](https://github.com/devframes/devframe/tree/main/plugins/inspect) --- title: Open Graph Viewer navigation: icon: i-ph:image-square-duotone description: Inspect a page's Open Graph / Twitter metadata and social cards, built as a Vue + Vite SPA. --- Inspect a page's Open Graph / Twitter metadata and social cards, built as a **Vue + Vite** SPA. Package: `@devframes/plugin-og` ## What it does Enter any HTTP/HTTPS URL. It resolves relative image/icon URLs, renders social cards for Twitter, Facebook, LinkedIn, and Telegram, and flags missing metadata with a Nuxt `useSeoMeta()` snippet. The standalone server requires devframe's trust handshake; set `auth: false` only for isolated local use. ## Standalone ```sh pnpx @devframes/plugin-og ``` ## Mount into a Vite host With [Vite DevTools](https://devtools.vite.dev): ```ts // vite.config.ts import createOgDevframe from '@devframes/plugin-og' import { createPluginFromDevframe } from '@vitejs/devtools-kit/node' import { defineConfig } from 'vite' export default defineConfig({ plugins: [ createPluginFromDevframe(createOgDevframe()), ], }) ``` Or use `devframeVite(createOgDevframe())` from `@devframes/vite/single`. ## Programmatic `createOgDevframe(options)` returns a definition for any adapter: ```ts import { createOgDevframe } from '@devframes/plugin-og' export default createOgDevframe({ defaultUrl: 'http://localhost:3000/about', }) ``` `defaultUrl` sets the initial target and bakes it into the static build. ## RPC Namespaced `devframes:plugin:og:*`: | Function | Type | Returns | | ------------------ | ------- | ---------------------------------------------------------------------------- | | `resolve-metadata` | `query` | Fetches a page; returns title, language, Open Graph, Twitter, link metadata. | ## Source [`plugins/og`](https://github.com/devframes/devframe/tree/main/plugins/og) --- title: Accessibility Inspector navigation: icon: i-ph:person-simple-circle-duotone description: Runs axe-core against the user app, lists WCAG A/AA violations in a Solid panel, and highlights the element on hover. --- Runs [axe-core](https://github.com/dequelabs/axe-core) against the user app, lists WCAG A/AA violations in a **Solid** panel, and highlights the element on hover. Package: `@devframes/plugin-a11y` · framework: **Solid + Vite** ![Accessibility Inspector screenshot](/screenshots/plugin-a11y-1.png) *Hover to highlight the offending element* ![Accessibility Inspector screenshot](/screenshots/plugin-a11y-copy-prompts.png) *Generate prompts to fix issues* ## What it does Three pieces, two browser-side: | Piece | Runs in | Role | | --------------- | ------------------- | -------------------------------------------------------------- | | **Page script** | the user app's page | runs axe-core, owns the report state, draws the highlight ring | | **Panel** | the devtools iframe | Solid SPA: lists violations, highlights on hover | | **Node side** | the Node process | the `get-config` RPC (impact taxonomy), baked in static builds | The page script and the panel talk over the [in-page channel](/guide/in-page-channel), so the loop works live or static. The page script is the author-provided bridge (the panel has no reach into the user app's DOM): one module script scans, reports, and highlights. ## In a hub The definition declares the page script as its dock [client script](/guide/client-context), so mounting by package name just works: ```ts initHub({ devframes: ['@devframes/plugin-a11y'] }) ``` The hub serves the bundle same-origin and a client runtime imports it into the host page. Each scan also mirrors into the hub's messages feed: a summary plus one per rule. A host can also mount the module itself, for example a Vite host via `/@fs/`: ```ts import createA11yDevframe, { a11yClientScriptBundlePath } from '@devframes/plugin-a11y' await ctx.install(createA11yDevframe(), { dock: { clientScript: { importFrom: `/@fs/${a11yClientScriptBundlePath}` } }, }) ``` Outside a hub, a `