Skip to content

Commit 8ece9e1

Browse files
authored
feat(examples,docs): migrate reference hubs to initHub; Nitro & Hono examples, Bun smoke, framework guides (#172)
* feat(examples,docs): migrate the reference hubs to initHub; add Nitro & Hono examples, Bun smoke, framework guides Both reference hosts now assemble through one initHub() call while keeping their hand-built viewer UIs as protocol demos: the Vite example shares Vite's own http server for the WS upgrade at /__devframes/__ws (zero extra ports) and the Next example collapses its encoded catch-all routes into a single app/%5F_devframes/[[...path]]/route.ts delegating to hub.handler. New minimal examples prove the middleware story end to end: - examples/nitro-devframe-hub — Nitro v3, one catch-all route (+ an index route for the namespace root), devframe packages kept external so import.meta.url asset resolution survives bundling - examples/hono-devframe-hub — one runtime-agnostic app file served by @hono/node-server on Node and Bun.serve on Bun (fetch-upgrade tier); scripts/smoke-bun.ts exercises fetch + WS RPC + embedded.js on Bun initHub grows what the migrations needed: devframes entries with dock overrides, rpcDeclarations passthrough, a route-safe id guard (DF8004), a bind-retry for the auto side-car, and a buffered embedded.js body that survives dev-worker proxies. Docs: adapters/initiate (mount snippets for Vite/Nitro/Hono/Next/Nuxt/ SvelteKit, WS binding precedence, auth posture) and guide/hub-initiate (the namespace, the ui slot, single hub Auth, singular-vs-hub table). * fix(hub-ui): read import.meta.url through a variable so Vite doesn't inline the module Vite's lib build recognizes the literal `new URL('...', import.meta.url)` asset pattern and was inlining the embedded/standalone entry's own source as a `data:video/mp2t` URL — so at runtime the dock resolved its hub base from that data URL instead of the served script URL, and connection-meta discovery failed. Assigning import.meta.url to a variable first sidesteps the pattern matcher; the base stays the real runtime URL. * feat(initiate)!: require `base` and expose it on the instance Both initDevframe and initHub now take a required `base` option (the mount path is explicit at the call site — pass DEVFRAMES_HUB_BASE for the hub's conventional /__devframes/) and echo the normalized value back as `instance.base`, so route guards and middleware reference it instead of repeating the magic string. BREAKING CHANGE: `base` is no longer optional on initDevframe/initHub. * refactor(examples)!: rename hub examples to the hub-* prefix and add the hub-*-minimal family The two reference hosts become hub-vite and hub-next; the middleware demos become hub-nitro-minimal and hub-hono-minimal, joined by new hub-vite-minimal, hub-next-minimal, and hub-rsbuild-minimal — each a single initHub({ ui: createUi() }) handler mounted on its framework (the whole minimal integration is the config/route file). The Nitro one now uses a catch-all server route (+ index route) per @atinux's review; the Rsbuild one lazy-inits the hub inside server.setup so importing the config is side-effect free. Also: .gitignore now covers .next/.nitro/.output so knip (which respects gitignore) doesn't scan Next/Nitro build output; playwright/vitest/turbo/ knip/verify-typecheck-coverage/scripts/AGENTS/docs and the sidebar are repointed at the new names, and docs gain a page per minimal example. BREAKING CHANGE: example package names changed (vite-devframe-hub -> hub-vite, next-devframe-hub -> hub-next, nitro/hono-devframe-hub -> hub-nitro-minimal/hub-hono-minimal).
1 parent 8c18a2f commit 8ece9e1

145 files changed

Lines changed: 3045 additions & 1184 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
*.tsbuildinfo
88
coverage
99
dist
10+
.next
11+
.nitro
12+
.output
1013
lib-cov
1114
logs
1215
node_modules

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
**`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.
66

7-
**`@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.
7+
**`@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.
88

99
## Stack & Structure
1010

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

7171
### Hub example parity
7272

73-
`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).
73+
`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).
7474

7575
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.
7676

docs/.vitepress/config.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function guideItems(prefix: string) {
3232
{ text: 'Security', link: `${prefix}/guide/security` },
3333
{ text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` },
3434
{ text: 'Hub', link: `${prefix}/guide/hub` },
35+
{ text: 'Serve a Hub Anywhere', link: `${prefix}/guide/hub-initiate` },
3536
{ text: 'Deep Linking', link: `${prefix}/guide/deep-linking` },
3637
{ text: 'Client Scripts & Context', link: `${prefix}/guide/client-context` },
3738
{ text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` },
@@ -43,6 +44,7 @@ function adaptersItems(prefix: string) {
4344
{ text: 'Overview', link: `${prefix}/adapters/` },
4445
{ text: 'CLI (cac)', link: `${prefix}/adapters/cac` },
4546
{ text: 'Dev', link: `${prefix}/adapters/dev` },
47+
{ text: 'Initiate (middleware)', link: `${prefix}/adapters/initiate` },
4648
{ text: 'Build', link: `${prefix}/adapters/build` },
4749
{ text: 'Vite', link: `${prefix}/adapters/vite` },
4850
{ text: 'Embedded', link: `${prefix}/adapters/embedded` },
@@ -84,8 +86,13 @@ function examplesItems(prefix: string) {
8486
{ text: 'json-render', link: `${prefix}/examples/json-render` },
8587
{ text: 'streaming-chat', link: `${prefix}/examples/streaming-chat` },
8688
{ text: 'next-runtime-snapshot', link: `${prefix}/examples/next-runtime-snapshot` },
87-
{ text: 'vite-devframe-hub', link: `${prefix}/examples/vite-devframe-hub` },
88-
{ text: 'next-devframe-hub', link: `${prefix}/examples/next-devframe-hub` },
89+
{ text: 'hub-vite', link: `${prefix}/examples/hub-vite` },
90+
{ text: 'hub-next', link: `${prefix}/examples/hub-next` },
91+
{ text: 'hub-vite-minimal', link: `${prefix}/examples/hub-vite-minimal` },
92+
{ text: 'hub-next-minimal', link: `${prefix}/examples/hub-next-minimal` },
93+
{ text: 'hub-nitro-minimal', link: `${prefix}/examples/hub-nitro-minimal` },
94+
{ text: 'hub-hono-minimal', link: `${prefix}/examples/hub-hono-minimal` },
95+
{ text: 'hub-rsbuild-minimal', link: `${prefix}/examples/hub-rsbuild-minimal` },
8996
] satisfies DefaultTheme.NavItemWithLink[]
9097
}
9198

docs/adapters/initiate.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Initiate (standard middleware)
2+
3+
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.
4+
5+
```ts
6+
import { initDevframe } from 'devframe/initiate'
7+
import myDevframe from './devframe'
8+
9+
const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
10+
// devtools.base, devtools.handler, devtools.nodeMiddleware, devtools.websocket,
11+
// devtools.ready, devtools.context, devtools.connectionMeta(), devtools.close()
12+
```
13+
14+
`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.
15+
16+
## Mount the handler
17+
18+
::: code-group
19+
20+
```ts [Vite]
21+
import { initDevframe } from 'devframe/initiate'
22+
// vite.config.ts — connect-style middleware + Vite's own server for the socket
23+
import { defineConfig } from 'vite'
24+
import myDevframe from './devframe'
25+
26+
export default defineConfig({
27+
plugins: [{
28+
name: 'my-tool',
29+
apply: 'serve',
30+
configureServer(server) {
31+
const devtools = initDevframe(myDevframe, {
32+
base: '/__my-tool/',
33+
key: 'my-tool',
34+
server: server.httpServer ?? undefined,
35+
})
36+
server.middlewares.use(devtools.nodeMiddleware)
37+
},
38+
}],
39+
})
40+
```
41+
42+
```ts [Nitro]
43+
// routes/__my-tool/[...path].ts — plus routes/__my-tool/index.ts (same body)
44+
// for the namespace root, since a catch-all doesn't match its own empty path.
45+
import { defineHandler } from 'nitro'
46+
import { devtools } from '../../devtools'
47+
48+
export default defineHandler(event => devtools.handler(event.req))
49+
```
50+
51+
```ts [Hono]
52+
// server.ts — the same file runs on Node and Bun
53+
import { Hono } from 'hono'
54+
import { devtools } from './devtools'
55+
56+
const app = new Hono()
57+
app.all('/__my-tool/*', c => devtools.handler(c.req.raw, c.env))
58+
```
59+
60+
```ts [Next.js]
61+
import { initDevframe } from 'devframe/initiate'
62+
// app/%5F_my-tool/[[...path]]/route.ts — Next reserves `_`-prefixed
63+
// folders, so the segment is URL-encoded (`%5F_` decodes to `__`).
64+
import myDevframe from '@/devframe'
65+
66+
export const runtime = 'nodejs'
67+
export const dynamic = 'force-dynamic'
68+
69+
const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
70+
export const GET = devtools.handler
71+
```
72+
73+
```ts [Nuxt]
74+
// server/middleware/devtools.ts
75+
import { devtools } from '../devtools'
76+
77+
export default defineEventHandler((event) => {
78+
const { pathname } = new URL(toWebRequest(event).url)
79+
// `devtools.base` is the normalized mount base — no repeated string.
80+
if (pathname.startsWith(devtools.base) || pathname === devtools.base.slice(0, -1))
81+
return devtools.handler(toWebRequest(event))
82+
})
83+
```
84+
85+
```ts [SvelteKit]
86+
// src/routes/%5F_my-tool/[...path]/+server.ts
87+
import myDevframe from '$lib/devframe'
88+
import { initDevframe } from 'devframe/initiate'
89+
90+
const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
91+
export const GET = ({ request }) => devtools.handler(request)
92+
```
93+
94+
:::
95+
96+
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).
97+
98+
## The WebSocket binding
99+
100+
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:
101+
102+
1. **`ws.port`** — an explicit side-car port.
103+
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.
104+
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.
105+
4. **Bun** — same-origin fetch upgrades: pass the `Bun.serve` server as `handler`'s second argument and wire `Bun.serve({ websocket: devtools.websocket })`.
106+
5. **Default** — an eager side-car on a free port, started at init so the meta is stable from the first request.
107+
108+
## Auth
109+
110+
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.
111+
112+
## Relation to the other adapters
113+
114+
`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).

docs/errors/DF0053.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ outline: deep
1818
import { initDevframe } from 'devframe/initiate'
1919

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

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

2727
## Fix

docs/errors/DF0054.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ outline: deep
1717
```ts
1818
import { initDevframe } from 'devframe/initiate'
1919

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

2323
await devtools.ready

docs/errors/DF8000.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ outline: deep
1818
import { initHub } from '@devframes/hub/initiate'
1919

2020
initHub({
21+
base: '/__devframes/',
2122
devframes: [defineDevframe({ id: '__mcp', /**/ })], // ✗ throws DF8000
2223
})
2324
```

docs/errors/DF8001.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ outline: deep
1818
import { initHub } from '@devframes/hub/initiate'
1919

2020
// First evaluation:
21-
initHub({ key: 'devtools', devframes: [git] })
21+
initHub({ base: '/__devframes/', key: 'devtools', devframes: [git] })
2222

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

2727
## Fix

docs/errors/DF8002.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ outline: deep
1616

1717
```ts
1818
// ✗ Bad
19-
initHub({ devframes: [git], context: myCtx })
19+
initHub({ base: '/__devframes/', devframes: [git], context: myCtx })
2020

2121
// ✓ Good — declarative:
22-
initHub({ devframes: [git] })
22+
initHub({ base: '/__devframes/', devframes: [git] })
2323

2424
// ✓ Good — bring your own context:
2525
const ctx = await createHubContext({ host: myHost, cwd })
2626
await mountDevframe(ctx, git)
27-
initHub({ context: ctx })
27+
initHub({ base: '/__devframes/', context: ctx })
2828
```
2929

3030
## Fix

docs/errors/DF8003.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ outline: deep
1717
```ts
1818
import { initHub } from '@devframes/hub/initiate'
1919

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

2323
await hub.ready

0 commit comments

Comments
 (0)