Skip to content
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
*.tsbuildinfo
coverage
dist
.next
.nitro
.output
lib-cov
logs
node_modules
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**`devframe`** is the framework-neutral container for one devtool integration, portable across viewers. Build a single tool (its RPC, its SPA, its diagnostics, its CLI/build/spa/embedded outputs) without caring how it'll be displayed. A devframe app runs standalone (CLI, static deploy, embedded SPA) just as well as it mounts inside a hub.

**`@devframes/hub`** is the framework-neutral hub layer that sits on top of devframe and provides the multi-integration orchestration (docks, terminals, messages, commands). It does not ship UI — implementers (e.g. `@vitejs/devtools-kit`) provide their own UI on top of the hub's RPC + shared-state protocol. It does ship a **headless client runtime** (`createDevframeClientHost()` from `@devframes/hub/client`): booted in the host page, it assembles the shared `DevframeClientContext` (panel, docks, commands, when) and imports each dock entry's client script (`action` / `custom-render` / iframe `clientScript`) into that page — how a plugin like the a11y inspector runs code inside the page being inspected. See `examples/vite-devframe-hub/` for a working ~120-line Vite host demonstrating the protocol end to end.
**`@devframes/hub`** is the framework-neutral hub layer that sits on top of devframe and provides the multi-integration orchestration (docks, terminals, messages, commands). It does not ship UI — implementers (e.g. `@vitejs/devtools-kit`) provide their own UI on top of the hub's RPC + shared-state protocol. It does ship a **headless client runtime** (`createDevframeClientHost()` from `@devframes/hub/client`): booted in the host page, it assembles the shared `DevframeClientContext` (panel, docks, commands, when) and imports each dock entry's client script (`action` / `custom-render` / iframe `clientScript`) into that page — how a plugin like the a11y inspector runs code inside the page being inspected. See `examples/hub-vite/` for a working ~120-line Vite host demonstrating the protocol end to end.

## Stack & Structure

Expand Down Expand Up @@ -70,7 +70,7 @@ These reinforce devframe's positioning as "the container for one devtool integra

### Hub example parity

`examples/vite-devframe-hub/` (Vite plugin + vanilla client) and `examples/next-devframe-hub/` (Next.js App Router + React client) are the two reference hosts, and they stay at **feature parity**. They mount the same set of plugins and demo devframes, expose the same dock rail / iframe stage / subsystem drawer, and speak the same hub protocol — the only differences should be the host framework's own plumbing (how static assets are mounted, how the side-car server starts, how the client is rendered).
`examples/hub-vite/` (Vite plugin + vanilla client) and `examples/hub-next/` (Next.js App Router + React client) are the two reference hosts, and they stay at **feature parity**. They mount the same set of plugins and demo devframes, expose the same dock rail / iframe stage / subsystem drawer, and speak the same hub protocol — the only differences should be the host framework's own plumbing (how static assets are mounted, how the side-car server starts, how the client is rendered).

Any change to one lands in the other in the same PR: adding a dock, wiring a new hub subsystem, changing the drawer layout, adopting a new client-runtime API. Their READMEs mirror each other too. If a capability genuinely can't exist on one host, say so explicitly in both READMEs rather than letting the examples silently drift.

Expand Down
11 changes: 9 additions & 2 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function guideItems(prefix: string) {
{ text: 'Security', link: `${prefix}/guide/security` },
{ text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` },
{ text: 'Hub', link: `${prefix}/guide/hub` },
{ text: 'Serve a Hub Anywhere', link: `${prefix}/guide/hub-initiate` },
{ text: 'Deep Linking', link: `${prefix}/guide/deep-linking` },
{ text: 'Client Scripts & Context', link: `${prefix}/guide/client-context` },
{ text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` },
Expand All @@ -43,6 +44,7 @@ function adaptersItems(prefix: string) {
{ text: 'Overview', link: `${prefix}/adapters/` },
{ text: 'CLI (cac)', link: `${prefix}/adapters/cac` },
{ text: 'Dev', link: `${prefix}/adapters/dev` },
{ text: 'Initiate (middleware)', link: `${prefix}/adapters/initiate` },
{ text: 'Build', link: `${prefix}/adapters/build` },
{ text: 'Vite', link: `${prefix}/adapters/vite` },
{ text: 'Embedded', link: `${prefix}/adapters/embedded` },
Expand Down Expand Up @@ -84,8 +86,13 @@ function examplesItems(prefix: string) {
{ text: 'json-render', link: `${prefix}/examples/json-render` },
{ text: 'streaming-chat', link: `${prefix}/examples/streaming-chat` },
{ text: 'next-runtime-snapshot', link: `${prefix}/examples/next-runtime-snapshot` },
{ text: 'vite-devframe-hub', link: `${prefix}/examples/vite-devframe-hub` },
{ text: 'next-devframe-hub', link: `${prefix}/examples/next-devframe-hub` },
{ text: 'hub-vite', link: `${prefix}/examples/hub-vite` },
{ text: 'hub-next', link: `${prefix}/examples/hub-next` },
{ text: 'hub-vite-minimal', link: `${prefix}/examples/hub-vite-minimal` },
{ text: 'hub-next-minimal', link: `${prefix}/examples/hub-next-minimal` },
{ text: 'hub-nitro-minimal', link: `${prefix}/examples/hub-nitro-minimal` },
{ text: 'hub-hono-minimal', link: `${prefix}/examples/hub-hono-minimal` },
{ text: 'hub-rsbuild-minimal', link: `${prefix}/examples/hub-rsbuild-minimal` },
] satisfies DefaultTheme.NavItemWithLink[]
}

Expand Down
114 changes: 114 additions & 0 deletions docs/adapters/initiate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Initiate (standard middleware)

Serve a devframe from inside any app that can mount a catch-all route: `initDevframe(def, { base })` returns a live instance whose `.handler` — a web-standard `(request: Request) => Promise<Response>` — carries the whole surface (the SPA, `__connection.json` discovery, the WebSocket RPC endpoint, the auth gate, and the optional MCP route) under one mount base.

```ts
import { initDevframe } from 'devframe/initiate'
import myDevframe from './devframe'

const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
// devtools.base, devtools.handler, devtools.nodeMiddleware, devtools.websocket,
// devtools.ready, devtools.context, devtools.connectionMeta(), devtools.close()
```

`base` is required, so the mount path is explicit at the call site — pass the conventional `resolveBasePath(def, 'hosted')` (i.e. `def.basePath ?? /__<id>/`) if you don't want to pick one. The instance echoes the normalized value back as `devtools.base`, so route guards and middleware reference it instead of repeating the string. The factory is synchronous and initializes eagerly; `handler`/`nodeMiddleware` await readiness internally, so hosts never race the boot.

## Mount the handler

::: code-group

```ts [Vite]
import { initDevframe } from 'devframe/initiate'
// vite.config.ts — connect-style middleware + Vite's own server for the socket
import { defineConfig } from 'vite'
import myDevframe from './devframe'

export default defineConfig({
plugins: [{
name: 'my-tool',
apply: 'serve',
configureServer(server) {
const devtools = initDevframe(myDevframe, {
base: '/__my-tool/',
key: 'my-tool',
server: server.httpServer ?? undefined,
})
server.middlewares.use(devtools.nodeMiddleware)
},
}],
})
```

```ts [Nitro]
// routes/__my-tool/[...path].ts — plus routes/__my-tool/index.ts (same body)
// 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))
```
Comment on lines +42 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another solution is to create a server route, see: https://content.comark.dev/integrations/nitro#mount-the-handler

Suggested change
```ts [Nitro]
// middleware/devtools.ts
import { defineHandler } from 'h3'
import { devtools } from '../devtools'
export default defineHandler((event) => {
const { pathname } = new URL(event.req.url)
if (pathname === '/__my-tool' || pathname.startsWith('/__my-tool/'))
return devtools.handler(event.req)
})
```
```ts [Nitro]
// routes/__my-tool/[...path].ts
import { defineHandler } from 'nitro'
import { devtools } from '../../devtools'
export default defineHandler((event) => devtools.handler(event.req))

(not tested)

```ts [Hono]
// server.ts — the same file runs on Node and Bun
import { Hono } from 'hono'
import { devtools } from './devtools'

const app = new Hono()
app.all('/__my-tool/*', c => devtools.handler(c.req.raw, c.env))
```

```ts [Next.js]
import { initDevframe } from 'devframe/initiate'
// app/%5F_my-tool/[[...path]]/route.ts — Next reserves `_`-prefixed
// folders, so the segment is URL-encoded (`%5F_` decodes to `__`).
import myDevframe from '@/devframe'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
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/devframe'
import { initDevframe } from 'devframe/initiate'

const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
export const GET = ({ request }) => devtools.handler(request)
```

:::

For frameworks with dev-time module reloading (Next, Nitro, SvelteKit), always set `key` — a re-evaluation returns the live instance instead of leaking WebSocket servers (`DF0053` reports an intentional replacement when the options changed).

## The WebSocket binding

Fetch handlers hand over `Request`s, so the RPC socket needs its own binding. The instance resolves it in precedence order and advertises the result in `__connection.json` — the browser client follows whatever is advertised:

1. **`ws.port`** — an explicit side-car port.
2. **`server`** — share the host's `node:http` server; the upgrade binds at `<base>__ws`. Zero extra ports, and the socket follows the app through proxies and HTTPS.
3. **`ws.url` alone** — advertise an external endpoint verbatim; the server behind that URL owns the transport (wire the instance's `context` into your own server with `startHttpAndWs`). Combined with `server`/`ws.port`, `ws.url` overrides only the advertisement — the tunnel pattern.
4. **Bun** — same-origin fetch upgrades: pass the `Bun.serve` server as `handler`'s second argument and wire `Bun.serve({ websocket: devtools.websocket })`.
5. **Default** — an eager side-car on a free port, started at init so the meta is stable from the first request.

## Auth

The instance **gates by default** — a handler mounted inside an app server is reachable by anything that can open its socket. Devframe's interactive OTP handler is wired automatically and prints its code/magic-link banner once the public origin is known (derived from the first request, or the `origin` option). Pass `auth: false` for a single-user localhost setup, or a `DevframeAuthHandler` for a custom scheme.

## Relation to the other adapters

`createDevServer`, `viteDevBridge`, and `@devframes/next` are assembled from this instance internally — the handler is the one wiring underneath every serving path. To host **many** devframes behind one namespace with shared transport and docks, use the hub's counterpart: [`initHub`](../guide/hub-initiate).
4 changes: 2 additions & 2 deletions docs/errors/DF0053.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ outline: deep
import { initDevframe } from 'devframe/initiate'

// First evaluation:
initDevframe(def, { key: 'devtools', ws: { port: 7811 } })
initDevframe(def, { base: '/__my-tool/', key: 'devtools', ws: { port: 7811 } })

// A later reload with a different port replaces the live instance:
initDevframe(def, { key: 'devtools', ws: { port: 7812 } }) // ⚠ DF0053
initDevframe(def, { base: '/__my-tool/', key: 'devtools', ws: { port: 7812 } }) // ⚠ DF0053
```

## Fix
Expand Down
2 changes: 1 addition & 1 deletion docs/errors/DF0054.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ outline: deep
```ts
import { initDevframe } from 'devframe/initiate'

const devtools = initDevframe(def)
const devtools = initDevframe(def, { base: '/__my-tool/' })
devtools.connectionMeta() // ✗ throws DF0054 — init is still in flight

await devtools.ready
Expand Down
1 change: 1 addition & 0 deletions docs/errors/DF8000.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ outline: deep
import { initHub } from '@devframes/hub/initiate'

initHub({
base: '/__devframes/',
devframes: [defineDevframe({ id: '__mcp', /* … */ })], // ✗ throws DF8000
})
```
Expand Down
4 changes: 2 additions & 2 deletions docs/errors/DF8001.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ outline: deep
import { initHub } from '@devframes/hub/initiate'

// First evaluation:
initHub({ key: 'devtools', devframes: [git] })
initHub({ base: '/__devframes/', key: 'devtools', devframes: [git] })

// A later reload with a different frame list replaces the live instance:
initHub({ key: 'devtools', devframes: [git, terminals] }) // ⚠ DF8001
initHub({ base: '/__devframes/', key: 'devtools', devframes: [git, terminals] }) // ⚠ DF8001
```

## Fix
Expand Down
6 changes: 3 additions & 3 deletions docs/errors/DF8002.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ outline: deep

```ts
// ✗ Bad
initHub({ devframes: [git], context: myCtx })
initHub({ base: '/__devframes/', devframes: [git], context: myCtx })

// ✓ Good — declarative:
initHub({ devframes: [git] })
initHub({ base: '/__devframes/', devframes: [git] })

// ✓ Good — bring your own context:
const ctx = await createHubContext({ host: myHost, cwd })
await mountDevframe(ctx, git)
initHub({ context: ctx })
initHub({ base: '/__devframes/', context: ctx })
```

## Fix
Expand Down
2 changes: 1 addition & 1 deletion docs/errors/DF8003.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ outline: deep
```ts
import { initHub } from '@devframes/hub/initiate'

const hub = initHub({ devframes: [git] })
const hub = initHub({ base: '/__devframes/', devframes: [git] })
hub.connectionMeta() // ✗ throws DF8003 — init is still in flight

await hub.ready
Expand Down
35 changes: 35 additions & 0 deletions docs/errors/DF8004.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
outline: deep
---

# DF8004: Devframe Id Is Not a Mountable URL Segment

## Message

> Devframe id "`{id}`" is not a mountable URL segment — the hub mounts each frame at `<base><id>/`.

## Cause

`initHub` derives each frame's mount base from its id (`/__devframes/<id>/`), and that segment is routed by h3 — where `:` and `*` are route-pattern markers and `/` ends the segment. An id carrying those characters either crashes route registration or matches the wrong paths.

## Example

```ts
import { initHub } from '@devframes/hub/initiate'

initHub({
base: '/__devframes/',
devframes: [defineDevframe({ id: 'devframes:plugin:my-tool', /* … */ })], // ✗ throws DF8004
})

// ✓ Good — route-safe id (letters, digits, `_`, `-`, `.`):
defineDevframe({ id: 'devframes_plugin_my-tool', /* … */ })
```

## Fix

Set a route-safe `id` on the definition — letters, digits, `_`, `-`, and `.` only. Plugins that accept an `id` option can be re-instantiated with a safe one; RPC function ids (the colon-namespaced `devframes:plugin:<slug>:<fn>` convention) are unaffected — this constraint applies to the devframe id alone.

## Source

- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `initHub` throws this while mounting the `devframes` list.
27 changes: 27 additions & 0 deletions docs/examples/hub-hono-minimal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
outline: deep
---

# hub-hono-minimal

The minimal [Hono](https://hono.dev) host for [`@devframes/hub`](/guide/hub): one `initHub()` call behind a catch-all route, running on **Node and Bun** from the same app file, the UI supplied by `@devframes/hub-ui`.

Package: `hub-hono-minimal` · framework: **Hono**

## What it shows

- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` in `src/app.ts` plus `app.all(\`${hub.base}*\`, c => hub.handler(c.req.raw, c.env))`.
- On Node (`@hono/node-server`), the RPC WebSocket runs on an eager side-car port.
- On Bun (`Bun.serve({ fetch, websocket: hub.websocket })`), WebSocket upgrades complete through `hub.handler(request, server)` on the app's own origin — no side-car. The repo's `scripts/smoke-bun.ts` exercises this path end to end.

## Run it

```sh
pnpm install
pnpm --filter hub-hono-minimal dev # Node
pnpm --filter hub-hono-minimal dev:bun # Bun
```

## Source

[`examples/hub-hono-minimal`](https://github.com/devframes/devframe/tree/main/examples/hub-hono-minimal)
28 changes: 28 additions & 0 deletions docs/examples/hub-next-minimal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
outline: deep
---

# hub-next-minimal

The minimal [Next.js](https://nextjs.org) host for [`@devframes/hub`](/guide/hub): one `initHub()` call on an App Router catch-all route, the UI supplied by `@devframes/hub-ui`.

Package: `hub-next-minimal` · framework: **React (Next.js)**

## What it shows

- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` behind one route (`app/%5F_devframes/[[...path]]/route.ts`) delegating to `hub.handler(request)`.
- The plugins and `@devframes/hub-ui` load via a bundler-ignored dynamic `import()`, so Next resolves their published `dist` at runtime (their `import.meta.url` asset lookups don't survive static bundling).
- Next route handlers can't accept WebSocket upgrades, so the instance runs its eager side-car WS server, advertised through `<base>__connection.json`.

## Run it

```sh
pnpm install
pnpm --filter hub-next-minimal dev
```

Open the printed URL for the host page with the floating dock, or `/__devframes/` for the standalone viewer.

## Source

[`examples/hub-next-minimal`](https://github.com/devframes/devframe/tree/main/examples/hub-next-minimal)
31 changes: 31 additions & 0 deletions docs/examples/hub-next.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
outline: deep
---

# hub-next

The same hub protocol as the [Vite host](./hub-vite), hosted from a **Next.js** App Router app with a hand-built React viewer — proof that the hub is host-runtime-agnostic.

Package: `hub-next` · framework: **React (Next.js)**

## What it proves

- `initHub({ base, devframes, configure })` boots the whole hub from one call; a single App Router catch-all route (`app/%5F_devframes/[[...path]]/route.ts`) delegates to `hub.handler(request)`.
- Next route handlers can't accept WebSocket upgrades, so the instance starts its eager side-car WS server, advertised through `<base>__connection.json`.
- The [JSON-render](/guide/json-render) hub integration with **registry replacement**: the React client renders the server-authored view with a small in-example React registry (rather than the Vue `@devframes/json-render-ui`) — the path a non-Vue host uses.
- [Client-only docks](/guide/client-context#client-only-docks) the page registers itself with `context.docks.register()`.

For the minimal counterpart — the hub UI supplied by `@devframes/hub-ui` instead of a hand-built viewer — see [hub-next-minimal](./hub-next-minimal).

## Run it

```sh
pnpm install
pnpm --filter hub-next dev
```

Open the printed URL to see the docks, commands, messages, and terminals the hub exposes.

## Source

[`examples/hub-next`](https://github.com/devframes/devframe/tree/main/examples/hub-next)
28 changes: 28 additions & 0 deletions docs/examples/hub-nitro-minimal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
outline: deep
---

# hub-nitro-minimal

The minimal [Nitro](https://nitro.build) host for [`@devframes/hub`](/guide/hub): one `initHub()` call behind a catch-all route, the UI supplied by `@devframes/hub-ui`.

Package: `hub-nitro-minimal` · framework: **Nitro**

## What it shows

- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` in `hub.ts`, delegated to by a catch-all route (`routes/__devframes/[...path].ts`, plus its `index.ts` sibling for the namespace root) via `hub.handler(event.req)`.
- `nitro.config.ts` keeps the devframe packages external so their prebuilt client assets resolve from the packages themselves rather than Nitro's build output.
- The RPC WebSocket runs on an eager side-car port, advertised through `<base>__connection.json`.

## Run it

```sh
pnpm install
pnpm --filter hub-nitro-minimal dev
```

Open the printed URL for the host page with the floating dock, or `/__devframes/` for the standalone viewer.

## Source

[`examples/hub-nitro-minimal`](https://github.com/devframes/devframe/tree/main/examples/hub-nitro-minimal)
Loading