-
Notifications
You must be signed in to change notification settings - Fork 10
feat(examples,docs): migrate reference hubs to initHub; Nitro & Hono examples, Bun smoke, framework guides #172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+1,439
−412
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| # Initiate (standard middleware) | ||
|
|
||
| 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. | ||
|
|
||
| ```ts | ||
| import { initDevframe } from 'devframe/initiate' | ||
| import myDevframe from './devframe' | ||
|
|
||
| const devtools = initDevframe(myDevframe, { key: 'my-tool' }) | ||
| // devtools.handler, devtools.nodeMiddleware, devtools.websocket, | ||
| // devtools.ready, devtools.context, devtools.connectionMeta(), devtools.close() | ||
| ``` | ||
|
|
||
| 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>/`. | ||
|
|
||
| ## 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, { | ||
| key: 'my-tool', | ||
| server: server.httpServer ?? undefined, | ||
| }) | ||
| server.middlewares.use(devtools.nodeMiddleware) | ||
| }, | ||
| }], | ||
| }) | ||
| ``` | ||
|
|
||
| ```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 [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, { 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) | ||
| if (pathname === '/__my-tool' || pathname.startsWith('/__my-tool/')) | ||
| 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, { 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). | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| --- | ||
| 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({ | ||
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| # Serve a Hub Anywhere | ||
|
|
||
| `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/`). | ||
|
|
||
| ```ts | ||
| import { createUi } from '@devframes/hub-ui' | ||
| import { initHub } from '@devframes/hub/initiate' | ||
| import { createInspectDevframe } from '@devframes/plugin-inspect' | ||
| import { createTerminalsDevframe } from '@devframes/plugin-terminals' | ||
|
|
||
| export const hub = initHub({ | ||
| key: 'devtools', | ||
| devframes: [createInspectDevframe(), createTerminalsDevframe()], | ||
| ui: createUi(), | ||
| configure(ctx) { | ||
| ctx.commands.register({ id: 'app:hello', title: 'Hello', handler: () => 'hi' }) | ||
| }, | ||
| }) | ||
| ``` | ||
|
|
||
| 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). | ||
|
|
||
| ## The namespace | ||
|
|
||
| | Path | Serves | | ||
| | --- | --- | | ||
| | `/` | the `ui.viewer` SPA — or the index document when the hub runs headless | | ||
| | `<id>/` | each mounted devframe's SPA, with its own `__connection.json` pointing at the shared socket | | ||
| | `embedded.js` | the `ui.embedded` bootstrap (`404` without one) | | ||
| | `__connection.json` | connection meta for the shared RPC socket | | ||
| | `__ws` | the WebSocket upgrade route (shared-`server` and Bun tiers) | | ||
| | `__index.json` | the machine-readable index: frames, endpoints | | ||
| | `__client-imports.js` | the dock client-script import map for external viewers | | ||
| | `__mcp` | the aggregate MCP endpoint over the whole tool registry (opt-in via `mcp`) | | ||
|
|
||
| Frame ids become URL segments, so they are validated: reserved names throw `DF8000`, and ids must be route-safe (`DF8004`). | ||
|
|
||
| ## The `ui` slot | ||
|
|
||
| The hub is headless — `DevframeHubUi` is pure data, and whoever fills it decides what a viewer looks like: | ||
|
|
||
| ```ts | ||
| interface DevframeHubUi { | ||
| viewer?: { distDir: string } // a standalone SPA served at the namespace root | ||
| embedded?: { entry: string } // a prebuilt bootstrap served at <base>embedded.js | ||
| } | ||
| ``` | ||
|
|
||
| `@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. | ||
|
|
||
| ## One Auth for the hub | ||
|
|
||
| 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. | ||
|
|
||
| ## Singular vs hub mounting | ||
|
|
||
| A devframe's SPA and RPC client code are byte-identical in both cases — that is devframe's portability promise. The differences are environmental: | ||
|
|
||
| | What the SPA / RPC client sees | Singular (`/__git/`) | Hub (`/__devframes/git/`) | | ||
| | --- | --- | --- | | ||
| | Runtime base | `/__git/` | `/__devframes/git/` (transparent to the SPA) | | ||
| | `__connection.json` | own meta, own socket | per-frame meta pointing at the shared hub socket | | ||
| | RPC registry | this frame's functions | merged: all frames + hub built-ins, callable cross-frame | | ||
| | Shared state | own context's slots | all frames' slots + hub slots | | ||
| | Auth | own gate, own token | the single hub Auth | | ||
| | Hub subsystems | — | docks, terminals, messages, commands; the frame is also an iframe dock | | ||
| | MCP | `<base>__mcp`, this frame's tools | the aggregate at hub level | | ||
| | Isolation | hard (own context, own transport) | cooperative (shared context — tools compose) | | ||
|
|
||
| ## Bring your own context | ||
|
|
||
| Hosts that assemble `createHubContext` + `mountDevframe` themselves (with their own `DevframeHost` serving the frames) pass the finished context instead of a `devframes` list: | ||
|
|
||
| ```ts | ||
| const hub = initHub({ context: ctx }) | ||
| ``` | ||
|
|
||
| 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). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| # hono-devframe-hub | ||
|
|
||
| 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. | ||
|
|
||
| ```sh | ||
| pnpm --filter hono-devframe-hub dev # Node (tsx) | ||
| pnpm --filter hono-devframe-hub dev:bun # Bun | ||
| ``` | ||
|
|
||
| Open <http://localhost:5179> — the host page carries the floating dock via one script tag — or <http://localhost:5179/__devframes/> for the standalone viewer. | ||
|
|
||
| ## How it works | ||
|
|
||
| - [`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. | ||
| - [`src/node.ts`](./src/node.ts) — `@hono/node-server`; the RPC WebSocket runs on an eager side-car port, advertised through `__connection.json`. | ||
| - [`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. | ||
|
|
||
| The Bun path is exercised end to end by the repo's smoke script: | ||
|
|
||
| ```sh | ||
| bun scripts/smoke-bun.ts | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| { | ||
| "name": "hono-devframe-hub", | ||
| "type": "module", | ||
| "version": "0.8.1", | ||
| "private": true, | ||
| "description": "Minimal Hono host for @devframes/hub — the same app file serves the devtools namespace on Node and Bun.", | ||
| "scripts": { | ||
| "dev": "tsx src/node.ts", | ||
| "dev:bun": "bun src/bun.ts", | ||
| "typecheck": "tsc --noEmit" | ||
| }, | ||
| "dependencies": { | ||
| "@devframes/hub": "workspace:*", | ||
| "@devframes/hub-ui": "workspace:*", | ||
| "@devframes/plugin-inspect": "workspace:*", | ||
| "@devframes/plugin-messages": "workspace:*", | ||
| "@hono/node-server": "catalog:deps", | ||
| "devframe": "workspace:*", | ||
| "hono": "catalog:deps" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "catalog:types", | ||
| "tsx": "catalog:build" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { createUi } from '@devframes/hub-ui' | ||
| import { initHub } from '@devframes/hub/initiate' | ||
| import { createInspectDevframe } from '@devframes/plugin-inspect' | ||
| import { createMessagesDevframe } from '@devframes/plugin-messages' | ||
| import { Hono } from 'hono' | ||
|
|
||
| // One runtime-agnostic app file: the hub instance and the Hono routes are | ||
| // identical on Node (`src/node.ts`) and Bun (`src/bun.ts`) — only the | ||
| // WebSocket transport differs, and the instance resolves that itself | ||
| // (eager side-car port on Node, fetch-upgrade on Bun). | ||
| // | ||
| // `key` memoizes the instance on globalThis so dev-time module reloads | ||
| // return the live hub instead of leaking transports. | ||
| export const hub = initHub({ | ||
| key: 'hono-devframe-hub', | ||
| devframes: [ | ||
| createInspectDevframe(), | ||
| createMessagesDevframe(), | ||
| ], | ||
| ui: createUi(), | ||
| // Single-user localhost demo: reachable only on loopback, so it opts out | ||
| // of the gate for a no-friction dev experience. A hub reachable beyond | ||
| // localhost should gate (see docs/guide/security.md). | ||
| auth: false, | ||
| configure(ctx) { | ||
| ctx.commands.register({ | ||
| id: 'example:hono-devframe-hub:ping', | ||
| title: 'Hono Hub · Ping', | ||
| icon: 'ph:bell-duotone', | ||
| category: 'kit', | ||
| handler: () => 'pong', | ||
| }) | ||
| ctx.rpc.register({ | ||
| name: 'example:hono-devframe-hub:probe', | ||
| type: 'query', | ||
| jsonSerializable: true, | ||
| handler: () => 'pong', | ||
| }) | ||
| }, | ||
| }) | ||
|
|
||
| export const app = new Hono() | ||
|
|
||
| // The whole hub namespace behind one catch-all. On Bun, `c.env` is the | ||
| // `Bun.serve` server — the instance uses it to complete same-origin | ||
| // WebSocket upgrades; on Node it's simply unused. | ||
| app.all('/__devframes', c => hub.handler(c.req.raw, c.env)) | ||
| app.all('/__devframes/*', c => hub.handler(c.req.raw, c.env)) | ||
|
|
||
| // The host app: any page becomes devtools-equipped with one script tag. | ||
| app.get('/', c => c.html( | ||
| `<!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>Hono Devframe Hub</title> | ||
| </head> | ||
| <body style="font-family: system-ui; padding: 2rem"> | ||
| <h1>Hono Devframe Hub</h1> | ||
| <p>This page is the host app. The devtools ride along:</p> | ||
| <ul> | ||
| <li>the floating dock (bottom of this page) is <code>/__devframes/embedded.js</code></li> | ||
| <li>the standalone viewer lives at <a href="/__devframes/">/__devframes/</a></li> | ||
| <li>discovery: <a href="/__devframes/__index.json">__index.json</a> · <a href="/__devframes/__connection.json">__connection.json</a></li> | ||
| </ul> | ||
| <script type="module" src="/__devframes/embedded.js"></script> | ||
| </body> | ||
| </html>`, | ||
| )) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import process from 'node:process' | ||
| import { app, hub } from './app' | ||
|
|
||
| // Bun tier: WebSocket upgrades complete through `hub.handler(request, | ||
| // server)` on the app's own origin — no side-car port. `Bun.serve` needs | ||
| // the instance's `websocket` handlers wired alongside the fetch handler. | ||
| const port = Number(process.env.PORT ?? 5179) | ||
|
|
||
| export default { | ||
| port, | ||
| fetch: app.fetch, | ||
| websocket: hub.websocket, | ||
| } | ||
|
|
||
| void hub.ready.then(() => { | ||
| // eslint-disable-next-line no-console | ||
| console.log(`hono-devframe-hub (bun) on http://localhost:${port} — devtools at /__devframes/`) | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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