From 3811512538bcf2dfc39a2fdbe44c4fe9debb1db2 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Wed, 12 Aug 2026 14:59:17 +0900 Subject: [PATCH] feat!: share one instance shell; make the WebSocket binding explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `initDevframe` and `initHub` duplicated ~250 lines of the same wiring and each carried machinery that belongs to their hosts. Both are now assembled from one internal shell, and neither binds a port unless asked to. - Add `createInstanceShell` (`devframe/internal`), owning the mount base and h3 app, the lazily-derived origin and the auth banner that waits for it, auth resolution, the WebSocket binding, connection meta, the fetch / connect-middleware pair, the upgrade API and teardown. Each factory now supplies only its own context, routes and diagnostics. - Resolve the WS binding as `ws.port` > `server` > `ws.sidecar` (new) > the host's own upgrades, via `instance.attach(server)` / `handleUpgrade()`. The default tier builds its transport lazily, so an instance nobody attaches binds nothing and loads nothing. `DF0055` / `DF0056` report an upgrade hand-off to an instance that already owns its socket. - Drop the implicit side-car: hosts whose handlers never see upgrades (Next.js, Nitro, Rsbuild) now ask for one with `ws: { sidecar: true }`. - Drop the `key` memoization (and `DF0053` / `DF8001`) — a host that re-evaluates its modules owns the `globalThis` memo, which keeps the lifecycle visible at the call site. `createDevframeNextHandler` keeps its `key` option and memoizes internally, so Next hosts need no change. - Drop the Bun tier: `instance.websocket` and `handler`'s second argument are gone, and `handler` is exactly `(request: Request) => Promise`. A Bun host binds the transport itself with `createContextRpcServer` + `attachBunWsTransport`, as `examples/hub-hono-minimal/src/bun.ts` now shows (`scripts/smoke-bun.ts` still covers it end to end). - Fix `resolveDevframesInput`: `Array.isArray` left the readonly-array member in the false branch (a type error), and an entry resolving to `[null]` reached `normalizeDevframeEntry(null)`. Empty slots are filtered, so conditional `devframes` lists work. Also: `attachWsRpcTransport` gains `unbound`, `handleUpgrade` and `attach`; `DevframeWsOptions` gains `sidecar`; the migration guide covers every step. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adapters/initiate.md | 45 +- docs/errors/DF0053.md | 33 - docs/errors/DF0055.md | 37 ++ docs/errors/DF0056.md | 39 ++ docs/errors/DF8001.md | 33 - docs/examples/hub-hono-minimal.md | 6 +- docs/examples/hub-next-minimal.md | 2 +- docs/examples/hub-next.md | 2 +- docs/examples/hub-nitro-minimal.md | 2 +- docs/examples/hub-rsbuild-minimal.md | 4 +- docs/guide/hub-initiate.md | 18 +- docs/guide/migration-0.9.md | 65 ++ docs/helpers/next.md | 5 +- examples/hub-hono-minimal/README.md | 6 +- examples/hub-hono-minimal/package.json | 2 +- examples/hub-hono-minimal/src/app.ts | 24 +- examples/hub-hono-minimal/src/bun.ts | 59 +- examples/hub-hono-minimal/src/node.ts | 10 - examples/hub-hono-minimal/src/server.ts | 24 + examples/hub-next-minimal/src/client/hub.ts | 21 +- examples/hub-next/README.md | 4 +- .../src/client/devframe/next-devframe-hub.ts | 29 +- examples/hub-nitro-minimal/README.md | 2 +- examples/hub-nitro-minimal/hub.ts | 17 +- examples/hub-rsbuild-minimal/README.md | 2 +- .../hub-rsbuild-minimal/rsbuild.config.ts | 14 +- examples/hub-vite-minimal/vite.config.ts | 16 +- examples/hub-vite/src/vite-devframe-hub.ts | 14 +- .../src/adapters/__tests__/initiate.test.ts | 115 +++- packages/devframe/src/adapters/initiate.ts | 544 ++++----------- packages/devframe/src/helpers/vite.ts | 6 +- packages/devframe/src/internal/index.ts | 12 + packages/devframe/src/node/diagnostics.ts | 12 +- packages/devframe/src/node/instance-shell.ts | 462 +++++++++++++ .../devframe/src/rpc/transports/ws-server.ts | 86 ++- packages/devframe/src/types/devframe.ts | 10 +- packages/hub-ui/src/client/.generated/css.ts | 2 +- .../hub/src/node/__tests__/initiate.test.ts | 53 +- packages/hub/src/node/diagnostics.ts | 4 - packages/hub/src/node/initiate.ts | 628 ++++++------------ packages/hub/src/node/mount-devframe.ts | 2 +- packages/next/src/handler.ts | 41 +- scripts/smoke-bun.ts | 29 +- .../@devframes/hub/initiate.snapshot.d.ts | 19 +- .../tsnapi/devframe/index.snapshot.d.ts | 1 + .../tsnapi/devframe/initiate.snapshot.d.ts | 12 +- .../tsnapi/devframe/internal.snapshot.d.ts | 48 ++ .../tsnapi/devframe/internal.snapshot.js | 2 + 48 files changed, 1502 insertions(+), 1121 deletions(-) delete mode 100644 docs/errors/DF0053.md create mode 100644 docs/errors/DF0055.md create mode 100644 docs/errors/DF0056.md delete mode 100644 docs/errors/DF8001.md delete mode 100644 examples/hub-hono-minimal/src/node.ts create mode 100644 examples/hub-hono-minimal/src/server.ts create mode 100644 packages/devframe/src/node/instance-shell.ts diff --git a/docs/adapters/initiate.md b/docs/adapters/initiate.md index e4607d10..f4031678 100644 --- a/docs/adapters/initiate.md +++ b/docs/adapters/initiate.md @@ -6,12 +6,13 @@ Serve a devframe from inside any app that can mount a catch-all route: `initDevf 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() +const devtools = initDevframe(myDevframe, { base: '/__my-tool/' }) +// devtools.base, devtools.handler, devtools.nodeMiddleware, devtools.attach, +// devtools.handleUpgrade, 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 ?? /__/`) 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. +`base` is required, so the mount path is explicit at the call site — pass the conventional `resolveBasePath(def, 'hosted')` (i.e. `def.basePath ?? /__/`) 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. Creating an instance binds no port on its own — [the WebSocket binding](#the-websocket-binding) is the host's call. ## Mount the handler @@ -30,7 +31,6 @@ export default defineConfig({ configureServer(server) { const devtools = initDevframe(myDevframe, { base: '/__my-tool/', - key: 'my-tool', server: server.httpServer ?? undefined, }) server.middlewares.use(devtools.nodeMiddleware) @@ -49,12 +49,14 @@ export default defineHandler(event => devtools.handler(event.req)) ``` ```ts [Hono] -// server.ts — the same file runs on Node and Bun +// server.ts — `serve()` hands back the node server the socket rides on +import { serve } from '@hono/node-server' import { Hono } from 'hono' import { devtools } from './devtools' const app = new Hono() -app.all('/__my-tool/*', c => devtools.handler(c.req.raw, c.env)) +app.all('/__my-tool/*', c => devtools.handler(c.req.raw)) +devtools.attach(serve({ fetch: app.fetch, port: 3000 })) ``` ```ts [Next.js] @@ -66,7 +68,13 @@ import myDevframe from '@/devframe' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' }) +// Route handlers never see upgrades, so the socket asks for a side-car; the +// globalThis memo keeps a dev-time reload from starting a second one. +const g = globalThis as { devtools?: ReturnType } +const devtools = g.devtools ??= initDevframe(myDevframe, { + base: '/__my-tool/', + ws: { sidecar: true }, +}) export const GET = devtools.handler ``` @@ -87,23 +95,30 @@ export default defineEventHandler((event) => { import myDevframe from '$lib/devframe' import { initDevframe } from 'devframe/initiate' -const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' }) +const g = globalThis as { devtools?: ReturnType } +const devtools = g.devtools ??= initDevframe(myDevframe, { + base: '/__my-tool/', + ws: { sidecar: true }, +}) 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). +Frameworks with dev-time module reloading (Next, Nitro, SvelteKit) re-evaluate the module that calls `initDevframe`, so memoize the instance on `globalThis` as above — otherwise every reload builds a second instance and leaks the first one's WebSocket server. `@devframes/next`'s `createDevframeNextHandler` does this for you. ## 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: +Fetch handlers hand over `Request`s, so the RPC socket needs a binding of its own, and the host picks it explicitly. The **local binding** resolves in precedence order: -1. **`ws.port`** — an explicit side-car port. +1. **`ws.port`** — a side-car server on that exact port. 2. **`server`** — share the host's `node:http` server; the upgrade binds at `__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. +3. **`ws: { sidecar: true }`** — a side-car server on a free port, for hosts whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild). +4. **The host's own upgrades** — with none of the above, the socket waits for the host to hand upgrade events over: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach function), and `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you already own. This is the tier for hosts whose server exists only after the instance does, and it builds the transport lazily — an instance nobody attaches costs nothing. + +`ws.url` controls the *advertisement* instead: the browser dials it verbatim. On its own it means an external server owns the transport and its auth (wire the instance's `context` into that server with `startHttpAndWs`); alongside a local binding it overrides only what is advertised — the tunnel pattern, where a relay forwards to the socket bound here. + +Whichever combination is active, `__connection.json` describes it and the browser client follows. Asking a configured instance to also take over host upgrades reports `DF0055` (a local binding already owns the socket) or `DF0056` (`ws.url` handed it to someone else). ## Auth diff --git a/docs/errors/DF0053.md b/docs/errors/DF0053.md deleted file mode 100644 index 9db8a5e3..00000000 --- a/docs/errors/DF0053.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -outline: deep ---- - -# DF0053: Memoized Instance Replaced - -## Message - -> initDevframe("`{id}`") replaced the live instance memoized under key "`{key}`": its options changed since the previous call. - -## Cause - -`initDevframe` was called with a `key` that already maps to a live instance, but the option fingerprint differs from the memoized one's. Dev servers that re-evaluate modules on the fly (Next.js, Nitro, SvelteKit HMR) re-run `initDevframe` on every reload; the `key` memoization normally returns the live instance, but when the options genuinely changed the old instance — including its side-car WebSocket server — is closed and a fresh one starts. - -## Example - -```ts -import { initDevframe } from 'devframe/initiate' - -// First evaluation: -initDevframe(def, { base: '/__my-tool/', key: 'devtools', ws: { port: 7811 } }) - -// A later reload with a different port replaces the live instance: -initDevframe(def, { base: '/__my-tool/', key: 'devtools', ws: { port: 7812 } }) // ⚠ DF0053 -``` - -## Fix - -This is informational when you edited the options on purpose — the replacement is the intended behavior. If it fires without an intentional change, make the options stable across reloads (module-level constants rather than values recomputed per evaluation), or give genuinely different instances distinct keys. - -## Source - -- [`packages/devframe/src/adapters/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/initiate.ts) — `initDevframe` warns this before closing and replacing a memoized instance whose options fingerprint changed. diff --git a/docs/errors/DF0055.md b/docs/errors/DF0055.md new file mode 100644 index 00000000..a78f905a --- /dev/null +++ b/docs/errors/DF0055.md @@ -0,0 +1,37 @@ +--- +outline: deep +--- + +# DF0055: Instance Already Owns Its WebSocket Transport + +## Message + +> This instance already owns its WebSocket transport (`{tier}`), so it cannot take over the host's upgrade events. + +## Cause + +`attach(server)` and `handleUpgrade(req, socket, head)` exist for the tier where the instance binds nothing itself and waits for the host to hand upgrade events over. When the options already name a local binding — `ws.port` or `ws.sidecar` (a side-car server, `tier: 'sidecar'`) or `server` (a shared upgrade route, `tier: 'server'`) — that transport is the one serving the socket, and routing a second server's upgrades into it would hand the same RPC group two conflicting bindings. + +## Example + +```ts +import { initHub } from '@devframes/hub/initiate' + +const hub = initHub({ base: '/__devframes/', ws: { sidecar: true } }) +hub.attach(myServer) // ✗ throws DF0055 — the side-car already serves `__ws` + +// ✓ Pick one: the side-car… +const sidecar = initHub({ base: '/__devframes/', ws: { sidecar: true } }) + +// …or the host's own server. +const attached = initHub({ base: '/__devframes/' }) +attached.attach(myServer) +``` + +## Fix + +Drop the `attach` / `handleUpgrade` call and let the configured transport serve the socket, or remove `server` / `ws.port` / `ws.sidecar` from the options so the instance leaves the binding to you. Both are advertised the same way in `__connection.json`, so the browser client is unaffected by the choice. + +## Source + +- [`packages/devframe/src/node/instance-shell.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/instance-shell.ts) — the shared instance shell throws this from `attach` / `handleUpgrade` when the resolved tier is `sidecar` or `server`, for both `initDevframe` and `initHub`. diff --git a/docs/errors/DF0056.md b/docs/errors/DF0056.md new file mode 100644 index 00000000..03c23ede --- /dev/null +++ b/docs/errors/DF0056.md @@ -0,0 +1,39 @@ +--- +outline: deep +--- + +# DF0056: Instance Advertises an External WebSocket Endpoint + +## Message + +> This instance advertises an external WebSocket endpoint (`{url}`), so it serves no socket of its own. + +## Cause + +`ws.url` on its own is the advertise-only tier: `__connection.json` names a fully-qualified endpoint the browser dials verbatim, and the server behind that URL owns the transport *and* its auth — this instance builds neither. There is therefore no socket for `attach(server)` / `handleUpgrade(req, socket, head)` to feed. + +## Example + +```ts +import { initDevframe } from 'devframe/initiate' + +const relayed = initDevframe(def, { + base: '/__my-tool/', + ws: { url: 'wss://devtools.example.com/relay/__ws' }, +}) +relayed.attach(myServer) // ✗ throws DF0056 — an external server owns the socket + +// ✓ Serve the socket here, advertised through the relay (the tunnel pattern). +const tunnelled = initDevframe(def, { + base: '/__my-tool/', + ws: { url: 'wss://devtools.example.com/relay/__ws', sidecar: true }, +}) +``` + +## Fix + +Drop `ws.url` to have the instance serve the socket, or pair it with `server` / `ws.port` / `ws.sidecar` for the tunnel pattern — a local binding that the advertised relay forwards to. To serve RPC from a server you wire yourself, run `startHttpAndWs({ context, server, path })` against the instance's `context` and keep `ws.url` pointed at it. + +## Source + +- [`packages/devframe/src/node/instance-shell.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/instance-shell.ts) — the shared instance shell throws this from `attach` / `handleUpgrade` when the resolved tier is `external`, for both `initDevframe` and `initHub`. diff --git a/docs/errors/DF8001.md b/docs/errors/DF8001.md deleted file mode 100644 index 94c3fef5..00000000 --- a/docs/errors/DF8001.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -outline: deep ---- - -# DF8001: Memoized Hub Instance Replaced - -## Message - -> initHub replaced the live hub instance memoized under key "`{key}`": its options changed since the previous call. - -## Cause - -`initHub` was called with a `key` that already maps to a live instance, but the option fingerprint differs from the memoized one's. Dev servers that re-evaluate modules on the fly (Next.js, Nitro, SvelteKit HMR) re-run `initHub` on every reload; the `key` memoization normally returns the live instance, but when the options genuinely changed the old instance — including its side-car WebSocket server — is closed and a fresh one starts. - -## Example - -```ts -import { initHub } from '@devframes/hub/initiate' - -// First evaluation: -initHub({ base: '/__devframes/', key: 'devtools', devframes: [git] }) - -// A later reload with a different frame list replaces the live instance: -initHub({ base: '/__devframes/', key: 'devtools', devframes: [git, terminals] }) // ⚠ DF8001 -``` - -## Fix - -This is informational when you edited the options on purpose — the replacement is the intended behavior. If it fires without an intentional change, keep the options stable across reloads (module-level constants rather than values recomputed per evaluation), or give genuinely different hubs distinct keys. - -## Source - -- [`packages/hub/src/node/initiate.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/initiate.ts) — `initHub` warns this before closing and replacing a memoized instance whose options fingerprint changed. diff --git a/docs/examples/hub-hono-minimal.md b/docs/examples/hub-hono-minimal.md index dcaf53f1..c166f6ad 100644 --- a/docs/examples/hub-hono-minimal.md +++ b/docs/examples/hub-hono-minimal.md @@ -10,9 +10,9 @@ 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. +- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` in `src/app.ts` plus `app.all(\`${hub.base}*\`, c => hub.handler(c.req.raw))`. No transport option, so each runtime's entry wires the socket its own way — both landing on `${hub.base}__ws`, the app's own origin. +- On Node (`src/server.ts`), `@hono/node-server`'s `serve()` returns the `node:http` server and `hub.attach(server)` takes its upgrade events. +- On Bun (`src/bun.ts`), upgrades arrive as fetch requests, so the entry binds Bun's transport with `createContextRpcServer` + `attachBunWsTransport` inside `Bun.serve({ fetch, websocket })`. The repo's `scripts/smoke-bun.ts` exercises this path end to end. ## Run it diff --git a/docs/examples/hub-next-minimal.md b/docs/examples/hub-next-minimal.md index c77d5cd6..44192dd8 100644 --- a/docs/examples/hub-next-minimal.md +++ b/docs/examples/hub-next-minimal.md @@ -12,7 +12,7 @@ Package: `hub-next-minimal` · framework: **React (Next.js)** - `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 `__connection.json`. +- Next route handlers can't accept WebSocket upgrades, so `ws: { sidecar: true }` gives the socket its own port, advertised through `__connection.json`; the instance is memoized on `globalThis` so a dev-time reload reuses it. ## Run it diff --git a/docs/examples/hub-next.md b/docs/examples/hub-next.md index 1232669e..6c6c5bd4 100644 --- a/docs/examples/hub-next.md +++ b/docs/examples/hub-next.md @@ -11,7 +11,7 @@ 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 `__connection.json`. +- Next route handlers can't accept WebSocket upgrades, so `ws: { sidecar: true }` gives the socket its own port, advertised through `__connection.json`; the instance is memoized on `globalThis` so a dev-time reload reuses it. - 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()`. diff --git a/docs/examples/hub-nitro-minimal.md b/docs/examples/hub-nitro-minimal.md index 69851df1..98949b92 100644 --- a/docs/examples/hub-nitro-minimal.md +++ b/docs/examples/hub-nitro-minimal.md @@ -12,7 +12,7 @@ Package: `hub-nitro-minimal` · framework: **Nitro** - `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 `__connection.json`. +- Nitro handlers hand over `Request`s, so `ws: { sidecar: true }` puts the RPC WebSocket on its own port, advertised through `__connection.json`. ## Run it diff --git a/docs/examples/hub-rsbuild-minimal.md b/docs/examples/hub-rsbuild-minimal.md index ea52f82e..a5039c1a 100644 --- a/docs/examples/hub-rsbuild-minimal.md +++ b/docs/examples/hub-rsbuild-minimal.md @@ -10,9 +10,9 @@ Package: `hub-rsbuild-minimal` · framework: **Rsbuild** ## What it shows -- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` created inside `server.setup` in `rsbuild.config.ts` — lazily, so importing the config never spawns the hub's side-car. +- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` created inside `server.setup` in `rsbuild.config.ts` — lazily, so importing the config never spawns the hub's side-car, and reused across re-runs. - `server.setup` registers `hub.nodeMiddleware`, which owns the `/__devframes/` namespace and hands everything else back to Rsbuild. -- The RPC WebSocket runs on an eager side-car port, advertised through `__connection.json`; `html.tags` injects the `${hub.base}embedded.js` bootstrap. +- Rsbuild's middleware stack never hands over upgrades, so `ws: { sidecar: true }` puts the RPC WebSocket on its own port, advertised through `__connection.json`; `html.tags` injects the `${hub.base}embedded.js` bootstrap. ## Run it diff --git a/docs/guide/hub-initiate.md b/docs/guide/hub-initiate.md index 1941467f..e3d61a8a 100644 --- a/docs/guide/hub-initiate.md +++ b/docs/guide/hub-initiate.md @@ -9,7 +9,6 @@ import { createInspectDevframe } from '@devframes/plugin-inspect' import { createTerminalsDevframe } from '@devframes/plugin-terminals' export const hub = initHub({ - key: 'devtools', base: DEVFRAMES_HUB_BASE, // required — the conventional `/__devframes/` devframes: [createInspectDevframe(), createTerminalsDevframe()], ui: createUi(), @@ -19,7 +18,20 @@ export const hub = initHub({ }) ``` -`base` is required so the mount path is explicit; pass the exported `DEVFRAMES_HUB_BASE` for the conventional `/__devframes/`. The instance echoes the normalized value back as `hub.base`, so route guards and middleware reference it instead of repeating the string. 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 — `base`, `handler`, `nodeMiddleware`, `websocket` (Bun), `ready`, `context`, `connectionMeta()`, `close()` — and the same mount snippets apply; see [the initiate adapter](../adapters/initiate#mount-the-handler). +`base` is required so the mount path is explicit; pass the exported `DEVFRAMES_HUB_BASE` for the conventional `/__devframes/`. The instance echoes the normalized value back as `hub.base`, so route guards and middleware reference it instead of repeating the string. 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 — `base`, `handler`, `nodeMiddleware`, `attach`, `handleUpgrade`, `ready`, `context`, `connectionMeta()`, `close()` — and the same mount snippets apply; see [the initiate adapter](../adapters/initiate#mount-the-handler). + +## The shared socket + +One transport serves the whole namespace, and the hub binds nothing on its own — the same four choices `initDevframe` offers, in the same precedence: `ws.port` pins a side-car, `server` shares the host's `node:http` upgrade at `__ws`, `ws: { sidecar: true }` takes a free port (for Next.js, Nitro and Rsbuild hosts, whose handlers never see upgrades), and passing none of them leaves the socket to the host: + +```ts +import { serve } from '@hono/node-server' + +// `serve()` returns the node server; the hub takes its upgrade events. +const detach = hub.attach(serve({ fetch: app.fetch, port: 3000 })) +``` + +The advertised path is hub-base-absolute (`/__devframes/__ws`), so the one meta document resolves to the same socket from the hub base and from every frame base. A host whose module gets re-evaluated in dev (Next, Nitro) memoizes the instance on `globalThis`, so a reload reuses the live hub instead of leaking its transport. ## The namespace @@ -29,7 +41,7 @@ export const hub = initHub({ | `/` | 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) | +| `__ws` | the WebSocket upgrade route, for a shared or host-attached server | | `__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`) | diff --git a/docs/guide/migration-0.9.md b/docs/guide/migration-0.9.md index 0cee1614..7fb2b57c 100644 --- a/docs/guide/migration-0.9.md +++ b/docs/guide/migration-0.9.md @@ -162,3 +162,68 @@ A host that stands up its own server composes from `devframe/internal` — `crea // 0.9 import { DEFAULT_CATEGORIES_ORDER } from '@devframes/hub/constants' ``` + +## `initDevframe` / `initHub` bind no WebSocket server on their own + +Both factories used to start a side-car WebSocket server when no transport option was given. In 0.9 a side-car is opt-in, so creating an instance never binds a port by itself. The binding resolves `ws.port` > `server` > `ws.sidecar` > the host's own upgrades: + +| 0.8.x | 0.9 | +|-------|-----| +| `initHub({ base })` (implicit side-car) | `initHub({ base, ws: { sidecar: true } })` | +| `initDevframe(def, { base })` (implicit side-car) | `initDevframe(def, { base, ws: { sidecar: true } })` | +| — | `hub.attach(server)` / `hub.handleUpgrade(req, socket, head)` — serve the socket from a server the host owns | + +Hosts already passing `server`, `ws.port` or `ws.url` are unaffected. Hosts whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild) add `ws: { sidecar: true }`; hosts that get their `node:http` server *after* the instance exists — a Hono app served by `@hono/node-server`, for instance — hand it over with `attach`, which returns a detach function: + +```ts +// 0.9 +import { serve } from '@hono/node-server' + +const hub = initHub({ base: DEVFRAMES_HUB_BASE }) +const detach = hub.attach(serve({ fetch: app.fetch, port: 3000 })) +``` + +Calling `attach` / `handleUpgrade` on an instance that already owns a transport reports [`DF0055`](/errors/DF0055), and on the advertise-only `ws.url` tier [`DF0056`](/errors/DF0056). + +## The `key` option is removed; memoize on `globalThis` + +`initDevframe` and `initHub` no longer memoize instances under a `key` (and the `DF0053` / `DF8001` replacement diagnostics are gone with it). A host that re-evaluates its modules in dev owns the memo, which makes the lifecycle visible at the call site: + +```ts +// 0.8.x +export const hub = initHub({ key: 'devtools', base: DEVFRAMES_HUB_BASE, devframes }) +``` + +```ts +// 0.9 +const g = globalThis as { hub?: HubInstance } +export const hub = g.hub ??= initHub({ base: DEVFRAMES_HUB_BASE, devframes }) +``` + +`@devframes/next`'s `createDevframeNextHandler` keeps its own `key` option and memoizes for you, so Next hosts using it need no change. + +## The Bun WebSocket tier moves out of the instances + +`initDevframe` / `initHub` no longer detect Bun and complete fetch upgrades themselves, so `instance.websocket` and `handler`'s second (`server`) argument are gone — `handler` is now exactly `(request: Request) => Promise`. A Bun host binds the transport itself, with the same public primitives the instances used underneath: + +```ts +// 0.9 +import { createContextRpcServer } from 'devframe/internal' +import { attachBunWsTransport } from 'devframe/rpc/transports/ws-bun' + +const core = createContextRpcServer({ context: await hub.context, auth: false }) +const tier = await attachBunWsTransport(core) + +Bun.serve({ + port: 3000, + fetch(request, server) { + const { pathname } = new URL(request.url) + if (pathname === `${hub.base}__ws` && request.headers.get('upgrade')?.toLowerCase() === 'websocket') + return tier.handleUpgrade(request, server) + return app.fetch(request) + }, + websocket: tier.websocket as never, +}) +``` + +`examples/hub-hono-minimal` ships this wiring in [`src/bun.ts`](https://github.com/devframes/devframe/blob/main/examples/hub-hono-minimal/src/bun.ts), next to the Node entry's `hub.attach(server)`. diff --git a/docs/helpers/next.md b/docs/helpers/next.md index 834a53ee..0f78d68f 100644 --- a/docs/helpers/next.md +++ b/docs/helpers/next.md @@ -44,7 +44,7 @@ const handler = createDevframeNextHandler(myDevframe) export const GET = handler.fetch ``` -The base defaults to `def.basePath ?? '/__/'`. `close()` shuts the side-car down; `ready` resolves once it's listening. +The base defaults to `def.basePath ?? '/__/'`. `close()` shuts the side-car down; `ready` resolves once it's listening. The handler is memoized on `globalThis` under its `key`, so Next's dev-time route-module re-evaluation reuses the live one instead of starting a second side-car. | Option | Default | Description | |--------|---------|-------------| @@ -53,6 +53,7 @@ The base defaults to `def.basePath ?? '/__/'`. `close()` shuts the side-car | `port` | resolved from `def.cli?.port` | Side-car port. | | `flags` | — | Forwarded to `def.setup(ctx, { flags })`. | | `auth` | `false` | `true` for devframe's OTP gate, or a handler. The Next app owns auth by default. | +| `key` | `@devframes/next::` | Memoization key for the handler on `globalThis`. | ## Hosting a hub @@ -61,7 +62,7 @@ For many devframes at once, use `createDevframeNextHost()` with [`@devframes/hub ```ts [devframe/host.ts] import { createHubContext, mountDevframe } from '@devframes/hub/node' import { createDevframeNextHost } from '@devframes/next' -import { startHttpAndWs } from 'devframe/node' +import { startHttpAndWs } from 'devframe/internal' const nextHost = createDevframeNextHost({ resolveOrigin: () => 'http://localhost:3000', diff --git a/examples/hub-hono-minimal/README.md b/examples/hub-hono-minimal/README.md index 9d2170c0..65dcaf16 100644 --- a/examples/hub-hono-minimal/README.md +++ b/examples/hub-hono-minimal/README.md @@ -11,9 +11,9 @@ Open — the host page carries the floating dock via one ## 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. +- [`src/app.ts`](./src/app.ts) — runtime-agnostic: `initHub({ devframes, ui: createUi() })` plus `app.all('/__devframes/*', c => hub.handler(c.req.raw))`. Everything — frame SPAs, `__connection.json`, `__index.json`, `embedded.js`, `__client-imports.js` — flows through that one route. The instance is memoized on `globalThis` so a dev-time reload reuses the live hub. It configures no WebSocket transport, so each entry below wires the socket its runtime's way; both end up serving `/__devframes/__ws` on the app's own origin, which is what the hub advertises either way. +- [`src/server.ts`](./src/server.ts) — Node: `@hono/node-server`'s `serve()` returns the `node:http` server, and `hub.attach(server)` routes its upgrade events to the shared RPC socket. +- [`src/bun.ts`](./src/bun.ts) — Bun: upgrades arrive as fetch requests, so this entry binds Bun's own transport to the hub context with `createContextRpcServer` + `attachBunWsTransport` and answers the upgrade route inside `Bun.serve({ fetch, websocket })`. The Bun path is exercised end to end by the repo's smoke script: diff --git a/examples/hub-hono-minimal/package.json b/examples/hub-hono-minimal/package.json index 2b58cd73..94efbb8b 100644 --- a/examples/hub-hono-minimal/package.json +++ b/examples/hub-hono-minimal/package.json @@ -5,7 +5,7 @@ "private": true, "description": "Minimal Hono host for @devframes/hub \u2014 the same app file serves the devtools namespace on Node and Bun.", "scripts": { - "dev": "tsx src/node.ts", + "dev": "tsx src/server.ts", "dev:bun": "bun src/bun.ts", "typecheck": "tsc --noEmit" }, diff --git a/examples/hub-hono-minimal/src/app.ts b/examples/hub-hono-minimal/src/app.ts index 74038c52..084a645e 100644 --- a/examples/hub-hono-minimal/src/app.ts +++ b/examples/hub-hono-minimal/src/app.ts @@ -1,3 +1,4 @@ +import type { HubInstance } from '@devframes/hub/initiate' import { createUi } from '@devframes/hub-ui' import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' import { createInspectDevframe } from '@devframes/plugin-inspect' @@ -5,14 +6,15 @@ 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). +// the same on Node and Bun. No transport option is passed, so the hub binds +// nothing on its own — `src/server.ts` hands it the HTTP server's upgrade +// events with `hub.attach(server)` once that server exists. // -// `key` memoizes the instance on globalThis so dev-time module reloads -// return the live hub instead of leaking transports. -export const hub = initHub({ - key: 'hub-hono-minimal', +// Memoized on globalThis so a dev-time module reload returns the live hub +// instead of leaking transports. +const globalRef = globalThis as { __hubHonoMinimal?: HubInstance } + +export const hub: HubInstance = globalRef.__hubHonoMinimal ??= initHub({ base: DEVFRAMES_HUB_BASE, devframes: [ createInspectDevframe(), @@ -43,11 +45,9 @@ export const hub = initHub({ export const app = new Hono() // The whole hub namespace behind one catch-all, keyed off `hub.base` rather -// than a repeated string. 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(hub.base.replace(/\/$/, ''), c => hub.handler(c.req.raw, c.env)) -app.all(`${hub.base}*`, c => hub.handler(c.req.raw, c.env)) +// than a repeated string. +app.all(hub.base.replace(/\/$/, ''), c => hub.handler(c.req.raw)) +app.all(`${hub.base}*`, c => hub.handler(c.req.raw)) // The host app: any page becomes devtools-equipped with one script tag. app.get('/', c => c.html( diff --git a/examples/hub-hono-minimal/src/bun.ts b/examples/hub-hono-minimal/src/bun.ts index 53d2a5d4..5ec64be3 100644 --- a/examples/hub-hono-minimal/src/bun.ts +++ b/examples/hub-hono-minimal/src/bun.ts @@ -1,18 +1,53 @@ import process from 'node:process' +import { createContextRpcServer } from 'devframe/internal' +import { attachBunWsTransport } from 'devframe/rpc/transports/ws-bun' 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) +/** + * The Bun entry. Bun serves HTTP through the same `app.fetch` as Node, but + * WebSockets arrive as fetch upgrades rather than `node:http` `upgrade` + * events — so instead of `hub.attach(server)` this host binds Bun's own + * transport to the hub's context with two public primitives: + * `createContextRpcServer` (the session/auth wiring every devframe transport + * shares) and `attachBunWsTransport` (crossws' Bun adapter). + * + * The hub advertises `/__devframes/__ws` on the app's own origin either way, + * so the browser client needs no per-runtime knowledge — this file only has + * to answer that route. + */ +export async function startBunServer(port: number): Promise<{ port: number, close: () => Promise }> { + await hub.ready + const context = await hub.context + // Matches `app.ts`'s `auth: false` — this single-user localhost demo owns + // its trust boundary. A gated host passes the same handler it gave `initHub`. + const core = createContextRpcServer({ context, auth: false }) + const tier = await attachBunWsTransport(core) + const upgradePath = `${hub.base}__ws` -export default { - port, - fetch: app.fetch, - websocket: hub.websocket, + const server = Bun.serve({ + port, + fetch(request, bunServer) { + const { pathname } = new URL(request.url) + if (pathname === upgradePath && request.headers.get('upgrade')?.toLowerCase() === 'websocket') + return tier.handleUpgrade(request, bunServer) as Promise + return app.fetch(request) + }, + websocket: tier.websocket as never, + }) + + return { + port: server.port, + close: async () => { + await tier.close() + await hub.close() + server.stop(true) + }, + } } -void hub.ready.then(() => { - // eslint-disable-next-line no-console - console.log(`hono-devframe-hub (bun) on http://localhost:${port} — devtools at /__devframes/`) -}) +if (import.meta.main) { + void startBunServer(Number(process.env.PORT ?? 5179)).then(({ port }) => { + // eslint-disable-next-line no-console + console.log(`hono-devframe-hub (bun) on http://localhost:${port} — devtools at /__devframes/`) + }) +} diff --git a/examples/hub-hono-minimal/src/node.ts b/examples/hub-hono-minimal/src/node.ts deleted file mode 100644 index d3a1cb61..00000000 --- a/examples/hub-hono-minimal/src/node.ts +++ /dev/null @@ -1,10 +0,0 @@ -import process from 'node:process' -import { serve } from '@hono/node-server' -import { app, hub } from './app' - -const port = Number(process.env.PORT ?? 5179) -serve({ fetch: app.fetch, port, hostname: '0.0.0.0' }) -void hub.ready.then(() => { - // eslint-disable-next-line no-console - console.log(`hono-devframe-hub on http://localhost:${port} — devtools at /__devframes/`) -}) diff --git a/examples/hub-hono-minimal/src/server.ts b/examples/hub-hono-minimal/src/server.ts new file mode 100644 index 00000000..34aa8a60 --- /dev/null +++ b/examples/hub-hono-minimal/src/server.ts @@ -0,0 +1,24 @@ +import process from 'node:process' +import { serve } from '@hono/node-server' +import { app, hub } from './app' + +// One entry for both runtimes — `tsx src/server.ts` on Node, +// `bun src/server.ts` on Bun. `serve()` hands back the `node:http` server it +// listens on, and `hub.attach()` routes that server's upgrade events to the +// shared RPC socket at `/__devframes/__ws`: same origin as the app, no +// side-car port to discover. A host that can't reach its server this way asks +// for one instead with `ws: { sidecar: true }`. +const port = Number(process.env.PORT ?? 5179) + +const server = serve({ fetch: app.fetch, port, hostname: '0.0.0.0' }) +const detach = hub.attach(server) + +process.on('SIGINT', () => { + detach() + void hub.close().finally(() => process.exit(0)) +}) + +void hub.ready.then(() => { + // eslint-disable-next-line no-console + console.log(`hono-devframe-hub on http://localhost:${port} — devtools at /__devframes/`) +}) diff --git a/examples/hub-next-minimal/src/client/hub.ts b/examples/hub-next-minimal/src/client/hub.ts index d7178d82..6944d7e4 100644 --- a/examples/hub-next-minimal/src/client/hub.ts +++ b/examples/hub-next-minimal/src/client/hub.ts @@ -19,13 +19,11 @@ async function loadHub(): Promise { (inspect.createInspectDevframe as () => DevframeDefinition)(), (messages.createMessagesDevframe as () => DevframeDefinition)(), ] - // `key` memoizes the instance on globalThis across Next's dev-time module - // re-evaluations. Next route handlers can't accept WebSocket upgrades, so - // the instance starts its default eager side-car WS server, advertised via - // `__connection.json`. + // Next route handlers can't accept WebSocket upgrades, so the socket asks + // for a side-car server of its own, advertised via `__connection.json`. return initHub({ - key: 'hub-next-minimal', base: DEVFRAMES_HUB_BASE, + ws: { sidecar: true }, devframes, ui: (hubUi.createUi as typeof CreateUi)(), // Single-user localhost demo: opts out of the gate. A hub reachable @@ -34,12 +32,13 @@ async function loadHub(): Promise { }) } -let hubPromise: Promise | undefined +// The route-facing singleton, memoized on globalThis: Next re-evaluates route +// modules across dev-time reloads, and without the memo each reload would +// start another side-car and leak the previous one. It also keeps the dynamic +// plugin loading from re-running per request. +const globalRef = globalThis as { __hubNextMinimal?: Promise } -// The route-facing singleton. `initHub`'s `key` memoizes the live instance on -// globalThis across dev reloads; this promise avoids re-running the dynamic -// plugin loading per request. export function ensureHub(): Promise { - hubPromise ??= loadHub() - return hubPromise + globalRef.__hubNextMinimal ??= loadHub() + return globalRef.__hubNextMinimal } diff --git a/examples/hub-next/README.md b/examples/hub-next/README.md index 88467c3f..f3d134e7 100644 --- a/examples/hub-next/README.md +++ b/examples/hub-next/README.md @@ -27,14 +27,14 @@ The **RPC & State Inspector** carries an **Instances** tab that lists every devf `initHub()` answers everything under **`/__devframes/`** through a single web-standard `(request: Request) => Promise` handler — exactly the shape a Next App Router route handler returns. One optional catch-all route delegates to it: - `/__devframes//` — each mounted devframe's SPA and its `__connection.json` -- `/__devframes/__connection.json` — hub discovery; advertises the side-car WebSocket (Next route handlers can't accept upgrades, so the instance starts its own eager WS side-car and the meta carries its port) +- `/__devframes/__connection.json` — hub discovery; advertises the side-car WebSocket (Next route handlers can't accept upgrades, so the hub asks for one with `ws: { sidecar: true }` and the meta carries its port) - `/__devframes/__index.json` — the frame index and endpoint map - `/__devframes/__client-imports.js` — the dock client-script import map - `/__devframes/__mcp` — the aggregate MCP endpoint (Streamable-HTTP) over the whole hub tool registry Next.js reserves `_`-prefixed segment folders, so the route directory URL-encodes the leading underscore: `app/%5F_devframes/[[...path]]/route.ts`. -`initHub`'s `key` memoizes the instance on `globalThis`, so Next's dev-time module re-evaluation returns the live instance instead of leaking side-car servers. +The instance is memoized on `globalThis`, so Next's dev-time module re-evaluation reuses the live hub instead of leaking side-car servers. ## What the example proves diff --git a/examples/hub-next/src/client/devframe/next-devframe-hub.ts b/examples/hub-next/src/client/devframe/next-devframe-hub.ts index b523945c..f593e5af 100644 --- a/examples/hub-next/src/client/devframe/next-devframe-hub.ts +++ b/examples/hub-next/src/client/devframe/next-devframe-hub.ts @@ -107,12 +107,6 @@ export interface NextDevframeHubOptions { host?: string /** Workspace root used by hub host capabilities. Default: `process.cwd()`. */ cwd?: string - /** - * Memoize the instance on `globalThis` under this key (`initHub`'s `key`), - * so dev-time module re-evaluation returns the live instance instead of - * leaking side-car servers. - */ - key?: string } const nextHubMessagesList = defineHubRpcFunction({ @@ -191,7 +185,6 @@ export async function nextDevframeHub( let registration: DevframeInstanceRegistration | undefined const hub = initHub({ - ...(options.key ? { key: options.key } : {}), base: DEVFRAMES_HUB_BASE, cwd, origin, @@ -204,10 +197,9 @@ export async function nextDevframeHub( // surface (agent-flagged commands, plugin tools, `devframe:state:read`) // over the same catch-all route as the SPAs. mcp: true, - // Omitted `server` (Next route handlers can't accept WS upgrades), so the - // instance starts its default eager side-car WebSocket server; a `port` - // option pins it. - ...(options.port != null ? { ws: { port: options.port } } : {}), + // Next route handlers can't accept WS upgrades, so the socket asks for a + // side-car of its own — on a free port near 9777, or the pinned `port`. + ws: options.port != null ? { port: options.port } : { sidecar: true }, getStorageDir(scope) { if (scope === 'workspace') return join(cwd, '.devframe') @@ -295,14 +287,15 @@ export async function nextDevframeHub( } } -let hubPromise: Promise | undefined - /** - * The route-facing singleton. `initHub`'s `key` memoizes the live instance on - * `globalThis` across dev-time module re-evaluations; the module-level promise - * just avoids re-running the plugin loading per request. + * The route-facing singleton, memoized on `globalThis`: Next re-evaluates + * route modules across dev-time reloads, and without the memo each reload + * would build another hub (and leak the previous side-car). The promise also + * keeps the plugin loading from re-running per request. */ +const globalRef = globalThis as { __nextDevframeHub?: Promise } + export function ensureNextDevframeHub(): Promise { - hubPromise ??= nextDevframeHub({ key: 'next-devframe-hub' }) - return hubPromise + globalRef.__nextDevframeHub ??= nextDevframeHub() + return globalRef.__nextDevframeHub } diff --git a/examples/hub-nitro-minimal/README.md b/examples/hub-nitro-minimal/README.md index a3eb589b..4361540f 100644 --- a/examples/hub-nitro-minimal/README.md +++ b/examples/hub-nitro-minimal/README.md @@ -13,6 +13,6 @@ Open — the host page carries the floating dock via one - [`hub.ts`](./hub.ts) — `initHub({ devframes, ui: createUi(), key })`: mounts the Inspect and Messages plugins against one shared hub context, fills the hub's `ui` slot with `@devframes/hub-ui`'s prebuilt viewer + floating-dock bootstrap, and memoizes the instance across Nitro's dev-time module reloads. - [`routes/__devframes/[...path].ts`](./routes/__devframes/%5B...path%5D.ts) (and its `index.ts` sibling for the namespace root) — the delegation: every request under `/__devframes/` becomes `hub.handler(event.req)`, web-standard Request in, Response out. Everything — frame SPAs, `__connection.json`, `__index.json`, `embedded.js`, `__client-imports.js` — flows through it. - [`nitro.config.ts`](./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 (Nitro handlers hand over `Request`s, so the socket lives on its own port), advertised through `__connection.json`; the browser client discovers it automatically. +- The RPC WebSocket runs on a side-car port — Nitro handlers hand over `Request`s, so `ws: { sidecar: true }` asks for one — advertised through `__connection.json`; the browser client discovers it automatically. The same `initHub` instance mounts identically on Vite, Hono, and Next.js — see the sibling examples. diff --git a/examples/hub-nitro-minimal/hub.ts b/examples/hub-nitro-minimal/hub.ts index ff519932..95879e3f 100644 --- a/examples/hub-nitro-minimal/hub.ts +++ b/examples/hub-nitro-minimal/hub.ts @@ -1,3 +1,4 @@ +import type { HubInstance } from '@devframes/hub/initiate' import { createUi } from '@devframes/hub-ui' import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate' import { createInspectDevframe } from '@devframes/plugin-inspect' @@ -6,14 +7,18 @@ import { createMessagesDevframe } from '@devframes/plugin-messages' // The whole devtools installation in one call: two plugins mounted under // /__devframes/, the reference UI filling the hub's ui slot (the standalone // viewer at the namespace root + the floating dock at embedded.js), and the -// RPC socket on an eager side-car port advertised via __connection.json — -// Nitro's route handlers never deal with WebSocket upgrades. +// RPC socket on a side-car port advertised via __connection.json — Nitro's +// route handlers never deal with WebSocket upgrades, so `ws.sidecar` asks +// for a socket of its own. // -// `key` memoizes the instance on globalThis, so Nitro's dev-time module -// reloads return the live hub instead of leaking side-car servers. -export const hub = initHub({ - key: 'hub-nitro-minimal', +// Memoized on globalThis: Nitro re-evaluates this module on a dev-time +// reload, and without the memo each reload would start another side-car and +// leak the previous one. Any host with module reloading wants this shape. +const globalRef = globalThis as { __hubNitroMinimal?: HubInstance } + +export const hub: HubInstance = globalRef.__hubNitroMinimal ??= initHub({ base: DEVFRAMES_HUB_BASE, + ws: { sidecar: true }, devframes: [ createInspectDevframe(), createMessagesDevframe(), diff --git a/examples/hub-rsbuild-minimal/README.md b/examples/hub-rsbuild-minimal/README.md index 46260b63..27db9bc8 100644 --- a/examples/hub-rsbuild-minimal/README.md +++ b/examples/hub-rsbuild-minimal/README.md @@ -14,7 +14,7 @@ Open the printed URL — the host page carries the floating dock via one injecte - `initHub({ devframes: [inspect, messages], ui: createUi() })` runs in Rsbuild's Node config process (never bundled into the browser), so `createUi()`'s prebuilt viewer/dock and the plugins' node code work unchanged. - `dev.setupMiddlewares` unshifts `hub.nodeMiddleware`, which owns the whole `/__devframes/` namespace and hands everything else back to Rsbuild. -- The RPC WebSocket runs on an eager side-car port, advertised through `__connection.json`; the browser client discovers it automatically. +- The RPC WebSocket runs on a side-car port (`ws: { sidecar: true }`, since Rsbuild's middleware stack never hands over upgrades), advertised through `__connection.json`; the browser client discovers it automatically. - `html.tags` injects `