Skip to content

Commit f60ca01

Browse files
committed
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 middleware delegation, 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).
1 parent 0d131c6 commit f60ca01

46 files changed

Lines changed: 1439 additions & 412 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.

docs/.vitepress/config.ts

Lines changed: 2 additions & 0 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` },

docs/adapters/initiate.md

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Initiate (standard middleware)
2+
3+
Serve a devframe from inside any app that can mount a catch-all route: `initDevframe(def)` 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, { key: 'my-tool' })
10+
// devtools.handler, devtools.nodeMiddleware, devtools.websocket,
11+
// devtools.ready, devtools.context, devtools.connectionMeta(), devtools.close()
12+
```
13+
14+
The factory is synchronous and initializes eagerly; `handler`/`nodeMiddleware` await readiness internally, so hosts never race the boot. The default base is the hosted rule — `def.basePath` or `/__<id>/`.
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+
key: 'my-tool',
33+
server: server.httpServer ?? undefined,
34+
})
35+
server.middlewares.use(devtools.nodeMiddleware)
36+
},
37+
}],
38+
})
39+
```
40+
41+
```ts [Nitro]
42+
// middleware/devtools.ts
43+
import { defineHandler } from 'h3'
44+
import { devtools } from '../devtools'
45+
46+
export default defineHandler((event) => {
47+
const { pathname } = new URL(event.req.url)
48+
if (pathname === '/__my-tool' || pathname.startsWith('/__my-tool/'))
49+
return devtools.handler(event.req)
50+
})
51+
```
52+
53+
```ts [Hono]
54+
// server.ts — the same file runs on Node and Bun
55+
import { Hono } from 'hono'
56+
import { devtools } from './devtools'
57+
58+
const app = new Hono()
59+
app.all('/__my-tool/*', c => devtools.handler(c.req.raw, c.env))
60+
```
61+
62+
```ts [Next.js]
63+
import { initDevframe } from 'devframe/initiate'
64+
// app/%5F_my-tool/[[...path]]/route.ts — Next reserves `_`-prefixed
65+
// folders, so the segment is URL-encoded (`%5F_` decodes to `__`).
66+
import myDevframe from '@/devframe'
67+
68+
export const runtime = 'nodejs'
69+
export const dynamic = 'force-dynamic'
70+
71+
const devtools = initDevframe(myDevframe, { key: 'my-tool' })
72+
export const GET = devtools.handler
73+
```
74+
75+
```ts [Nuxt]
76+
// server/middleware/devtools.ts
77+
import { devtools } from '../devtools'
78+
79+
export default defineEventHandler((event) => {
80+
const { pathname } = new URL(toWebRequest(event).url)
81+
if (pathname === '/__my-tool' || pathname.startsWith('/__my-tool/'))
82+
return devtools.handler(toWebRequest(event))
83+
})
84+
```
85+
86+
```ts [SvelteKit]
87+
// src/routes/%5F_my-tool/[...path]/+server.ts
88+
import myDevframe from '$lib/devframe'
89+
import { initDevframe } from 'devframe/initiate'
90+
91+
const devtools = initDevframe(myDevframe, { key: 'my-tool' })
92+
export const GET = ({ request }) => devtools.handler(request)
93+
```
94+
95+
:::
96+
97+
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).
98+
99+
## The WebSocket binding
100+
101+
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:
102+
103+
1. **`ws.port`** — an explicit side-car port.
104+
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.
105+
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.
106+
4. **Bun** — same-origin fetch upgrades: pass the `Bun.serve` server as `handler`'s second argument and wire `Bun.serve({ websocket: devtools.websocket })`.
107+
5. **Default** — an eager side-car on a free port, started at init so the meta is stable from the first request.
108+
109+
## Auth
110+
111+
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.
112+
113+
## Relation to the other adapters
114+
115+
`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/DF8004.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8004: Devframe Id Is Not a Mountable URL Segment
6+
7+
## Message
8+
9+
> Devframe id "`{id}`" is not a mountable URL segment — the hub mounts each frame at `<base><id>/`.
10+
11+
## Cause
12+
13+
`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.
14+
15+
## Example
16+
17+
```ts
18+
import { initHub } from '@devframes/hub/initiate'
19+
20+
initHub({
21+
devframes: [defineDevframe({ id: 'devframes:plugin:my-tool', /**/ })], // ✗ throws DF8004
22+
})
23+
24+
// ✓ Good — route-safe id (letters, digits, `_`, `-`, `.`):
25+
defineDevframe({ id: 'devframes_plugin_my-tool', /**/ })
26+
```
27+
28+
## Fix
29+
30+
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.
31+
32+
## Source
33+
34+
- [`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.

docs/guide/hub-initiate.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Serve a Hub Anywhere
2+
3+
`initHub()` from `@devframes/hub/initiate` puts a whole multi-devframe devtools installation behind one web-standard handler: mount it on a single catch-all route and every frame, the shared RPC socket, the single auth gate, discovery, and the optional UI are live under one namespace (default `/__devframes/`).
4+
5+
```ts
6+
import { createUi } from '@devframes/hub-ui'
7+
import { initHub } from '@devframes/hub/initiate'
8+
import { createInspectDevframe } from '@devframes/plugin-inspect'
9+
import { createTerminalsDevframe } from '@devframes/plugin-terminals'
10+
11+
export const hub = initHub({
12+
key: 'devtools',
13+
devframes: [createInspectDevframe(), createTerminalsDevframe()],
14+
ui: createUi(),
15+
configure(ctx) {
16+
ctx.commands.register({ id: 'app:hello', title: 'Hello', handler: () => 'hi' })
17+
},
18+
})
19+
```
20+
21+
Every mounted devframe runs its `setup()` against the **shared hub context**: one merged RPC registry (frames can call each other's functions), one shared-state store, one WebSocket transport, one Auth. The instance mirrors `initDevframe`'s surface — `handler`, `nodeMiddleware`, `websocket` (Bun), `ready`, `context`, `connectionMeta()`, `close()` — and the same mount snippets apply with the base swapped to `/__devframes/`; see [the initiate adapter](../adapters/initiate#mount-the-handler).
22+
23+
## The namespace
24+
25+
| Path | Serves |
26+
| --- | --- |
27+
| `/` | the `ui.viewer` SPA — or the index document when the hub runs headless |
28+
| `<id>/` | each mounted devframe's SPA, with its own `__connection.json` pointing at the shared socket |
29+
| `embedded.js` | the `ui.embedded` bootstrap (`404` without one) |
30+
| `__connection.json` | connection meta for the shared RPC socket |
31+
| `__ws` | the WebSocket upgrade route (shared-`server` and Bun tiers) |
32+
| `__index.json` | the machine-readable index: frames, endpoints |
33+
| `__client-imports.js` | the dock client-script import map for external viewers |
34+
| `__mcp` | the aggregate MCP endpoint over the whole tool registry (opt-in via `mcp`) |
35+
36+
Frame ids become URL segments, so they are validated: reserved names throw `DF8000`, and ids must be route-safe (`DF8004`).
37+
38+
## The `ui` slot
39+
40+
The hub is headless — `DevframeHubUi` is pure data, and whoever fills it decides what a viewer looks like:
41+
42+
```ts
43+
interface DevframeHubUi {
44+
viewer?: { distDir: string } // a standalone SPA served at the namespace root
45+
embedded?: { entry: string } // a prebuilt bootstrap served at <base>embedded.js
46+
}
47+
```
48+
49+
`@devframes/hub-ui`'s `createUi()` is the reference implementation: a standalone viewer plus the floating dock — one `<script type="module" src="/__devframes/embedded.js">` tag in the host page and the dock mounts itself, always visible. A viewer product supplies a different object to the same slot and reuses all the infrastructure; visibility policy (keyboard summon, passive modes) belongs entirely to the entry's author.
50+
51+
## One Auth for the hub
52+
53+
The hub has a **single Auth**: one gate at the one shared transport covers every frame, the hub built-ins, and the MCP route. Mounted frames have no gates of their own — trust established once (OTP exchange, magic link, or a pre-shared token) unlocks the namespace. The gate is on by default; `auth: false` opts a single-user localhost setup out.
54+
55+
## Singular vs hub mounting
56+
57+
A devframe's SPA and RPC client code are byte-identical in both cases — that is devframe's portability promise. The differences are environmental:
58+
59+
| What the SPA / RPC client sees | Singular (`/__git/`) | Hub (`/__devframes/git/`) |
60+
| --- | --- | --- |
61+
| Runtime base | `/__git/` | `/__devframes/git/` (transparent to the SPA) |
62+
| `__connection.json` | own meta, own socket | per-frame meta pointing at the shared hub socket |
63+
| RPC registry | this frame's functions | merged: all frames + hub built-ins, callable cross-frame |
64+
| Shared state | own context's slots | all frames' slots + hub slots |
65+
| Auth | own gate, own token | the single hub Auth |
66+
| Hub subsystems || docks, terminals, messages, commands; the frame is also an iframe dock |
67+
| MCP | `<base>__mcp`, this frame's tools | the aggregate at hub level |
68+
| Isolation | hard (own context, own transport) | cooperative (shared context — tools compose) |
69+
70+
## Bring your own context
71+
72+
Hosts that assemble `createHubContext` + `mountDevframe` themselves (with their own `DevframeHost` serving the frames) pass the finished context instead of a `devframes` list:
73+
74+
```ts
75+
const hub = initHub({ context: ctx })
76+
```
77+
78+
The instance then serves the hub-level endpoints and transport only; serve each frame's meta from `hub.connectionMeta()` yourself. The two reference examples — `examples/vite-devframe-hub` and `examples/next-devframe-hub` — use the declarative mode with their own hand-built viewer UIs, and `examples/nitro-devframe-hub` / `examples/hono-devframe-hub` show the minimal `createUi()` mounts (the Hono one on Node and Bun).

eslint.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export default antfu({
99
'**/dist',
1010
'**/storybook-static',
1111
'**/.next',
12+
'**/.nitro',
13+
'**/.output',
1214
'**/out',
1315
'**/next-env.d.ts',
1416
'**/.vitepress/cache',
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# hono-devframe-hub
2+
3+
The minimal [Hono](https://hono.dev) host for `@devframes/hub` — one `initHub()` call, one catch-all route, and the same app file runs on Node and Bun.
4+
5+
```sh
6+
pnpm --filter hono-devframe-hub dev # Node (tsx)
7+
pnpm --filter hono-devframe-hub dev:bun # Bun
8+
```
9+
10+
Open <http://localhost:5179> — the host page carries the floating dock via one script tag — or <http://localhost:5179/__devframes/> for the standalone viewer.
11+
12+
## How it works
13+
14+
- [`src/app.ts`](./src/app.ts) — runtime-agnostic: `initHub({ devframes, ui: createUi(), key })` plus `app.all('/__devframes/*', c => hub.handler(c.req.raw, c.env))`. Everything — frame SPAs, `__connection.json`, `__index.json`, `embedded.js`, `__client-imports.js` — flows through that one route.
15+
- [`src/node.ts`](./src/node.ts)`@hono/node-server`; the RPC WebSocket runs on an eager side-car port, advertised through `__connection.json`.
16+
- [`src/bun.ts`](./src/bun.ts)`Bun.serve({ fetch: app.fetch, websocket: hub.websocket })`; WebSocket upgrades complete through `hub.handler(request, server)` on the app's own origin — no side-car.
17+
18+
The Bun path is exercised end to end by the repo's smoke script:
19+
20+
```sh
21+
bun scripts/smoke-bun.ts
22+
```
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "hono-devframe-hub",
3+
"type": "module",
4+
"version": "0.8.1",
5+
"private": true,
6+
"description": "Minimal Hono host for @devframes/hub — the same app file serves the devtools namespace on Node and Bun.",
7+
"scripts": {
8+
"dev": "tsx src/node.ts",
9+
"dev:bun": "bun src/bun.ts",
10+
"typecheck": "tsc --noEmit"
11+
},
12+
"dependencies": {
13+
"@devframes/hub": "workspace:*",
14+
"@devframes/hub-ui": "workspace:*",
15+
"@devframes/plugin-inspect": "workspace:*",
16+
"@devframes/plugin-messages": "workspace:*",
17+
"@hono/node-server": "catalog:deps",
18+
"devframe": "workspace:*",
19+
"hono": "catalog:deps"
20+
},
21+
"devDependencies": {
22+
"@types/node": "catalog:types",
23+
"tsx": "catalog:build"
24+
}
25+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { createUi } from '@devframes/hub-ui'
2+
import { initHub } from '@devframes/hub/initiate'
3+
import { createInspectDevframe } from '@devframes/plugin-inspect'
4+
import { createMessagesDevframe } from '@devframes/plugin-messages'
5+
import { Hono } from 'hono'
6+
7+
// One runtime-agnostic app file: the hub instance and the Hono routes are
8+
// identical on Node (`src/node.ts`) and Bun (`src/bun.ts`) — only the
9+
// WebSocket transport differs, and the instance resolves that itself
10+
// (eager side-car port on Node, fetch-upgrade on Bun).
11+
//
12+
// `key` memoizes the instance on globalThis so dev-time module reloads
13+
// return the live hub instead of leaking transports.
14+
export const hub = initHub({
15+
key: 'hono-devframe-hub',
16+
devframes: [
17+
createInspectDevframe(),
18+
createMessagesDevframe(),
19+
],
20+
ui: createUi(),
21+
// Single-user localhost demo: reachable only on loopback, so it opts out
22+
// of the gate for a no-friction dev experience. A hub reachable beyond
23+
// localhost should gate (see docs/guide/security.md).
24+
auth: false,
25+
configure(ctx) {
26+
ctx.commands.register({
27+
id: 'example:hono-devframe-hub:ping',
28+
title: 'Hono Hub · Ping',
29+
icon: 'ph:bell-duotone',
30+
category: 'kit',
31+
handler: () => 'pong',
32+
})
33+
ctx.rpc.register({
34+
name: 'example:hono-devframe-hub:probe',
35+
type: 'query',
36+
jsonSerializable: true,
37+
handler: () => 'pong',
38+
})
39+
},
40+
})
41+
42+
export const app = new Hono()
43+
44+
// The whole hub namespace behind one catch-all. On Bun, `c.env` is the
45+
// `Bun.serve` server — the instance uses it to complete same-origin
46+
// WebSocket upgrades; on Node it's simply unused.
47+
app.all('/__devframes', c => hub.handler(c.req.raw, c.env))
48+
app.all('/__devframes/*', c => hub.handler(c.req.raw, c.env))
49+
50+
// The host app: any page becomes devtools-equipped with one script tag.
51+
app.get('/', c => c.html(
52+
`<!doctype html>
53+
<html lang="en">
54+
<head>
55+
<meta charset="UTF-8" />
56+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
57+
<title>Hono Devframe Hub</title>
58+
</head>
59+
<body style="font-family: system-ui; padding: 2rem">
60+
<h1>Hono Devframe Hub</h1>
61+
<p>This page is the host app. The devtools ride along:</p>
62+
<ul>
63+
<li>the floating dock (bottom of this page) is <code>/__devframes/embedded.js</code></li>
64+
<li>the standalone viewer lives at <a href="/__devframes/">/__devframes/</a></li>
65+
<li>discovery: <a href="/__devframes/__index.json">__index.json</a> · <a href="/__devframes/__connection.json">__connection.json</a></li>
66+
</ul>
67+
<script type="module" src="/__devframes/embedded.js"></script>
68+
</body>
69+
</html>`,
70+
))
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import process from 'node:process'
2+
import { app, hub } from './app'
3+
4+
// Bun tier: WebSocket upgrades complete through `hub.handler(request,
5+
// server)` on the app's own origin — no side-car port. `Bun.serve` needs
6+
// the instance's `websocket` handlers wired alongside the fetch handler.
7+
const port = Number(process.env.PORT ?? 5179)
8+
9+
export default {
10+
port,
11+
fetch: app.fetch,
12+
websocket: hub.websocket,
13+
}
14+
15+
void hub.ready.then(() => {
16+
// eslint-disable-next-line no-console
17+
console.log(`hono-devframe-hub (bun) on http://localhost:${port} — devtools at /__devframes/`)
18+
})

0 commit comments

Comments
 (0)