Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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