Skip to content

Commit 3dc6b3e

Browse files
antfuclaude
andauthored
feat!: share one instance shell; make the WebSocket binding explicit (#191)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2a04efe commit 3dc6b3e

48 files changed

Lines changed: 1502 additions & 1121 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/adapters/initiate.md‎

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ Serve a devframe from inside any app that can mount a catch-all route: `initDevf
66
import { initDevframe } from 'devframe/initiate'
77
import myDevframe from './devframe'
88

9-
const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
10-
// devtools.base, devtools.handler, devtools.nodeMiddleware, devtools.websocket,
11-
// devtools.ready, devtools.context, devtools.connectionMeta(), devtools.close()
9+
const devtools = initDevframe(myDevframe, { base: '/__my-tool/' })
10+
// devtools.base, devtools.handler, devtools.nodeMiddleware, devtools.attach,
11+
// devtools.handleUpgrade, devtools.ready, devtools.context,
12+
// devtools.connectionMeta(), devtools.close()
1213
```
1314

14-
`base` is required, so the mount path is explicit at the call site — pass the conventional `resolveBasePath(def, 'hosted')` (i.e. `def.basePath ?? /__<id>/`) if you don't want to pick one. The instance echoes the normalized value back as `devtools.base`, so route guards and middleware reference it instead of repeating the string. The factory is synchronous and initializes eagerly; `handler`/`nodeMiddleware` await readiness internally, so hosts never race the boot.
15+
`base` is required, so the mount path is explicit at the call site — pass the conventional `resolveBasePath(def, 'hosted')` (i.e. `def.basePath ?? /__<id>/`) if you don't want to pick one. The instance echoes the normalized value back as `devtools.base`, so route guards and middleware reference it instead of repeating the string. The factory is synchronous and initializes eagerly; `handler`/`nodeMiddleware` await readiness internally, so hosts never race the boot. Creating an instance binds no port on its own — [the WebSocket binding](#the-websocket-binding) is the host's call.
1516

1617
## Mount the handler
1718

@@ -30,7 +31,6 @@ export default defineConfig({
3031
configureServer(server) {
3132
const devtools = initDevframe(myDevframe, {
3233
base: '/__my-tool/',
33-
key: 'my-tool',
3434
server: server.httpServer ?? undefined,
3535
})
3636
server.middlewares.use(devtools.nodeMiddleware)
@@ -49,12 +49,14 @@ export default defineHandler(event => devtools.handler(event.req))
4949
```
5050

5151
```ts [Hono]
52-
// server.ts — the same file runs on Node and Bun
52+
// server.ts — `serve()` hands back the node server the socket rides on
53+
import { serve } from '@hono/node-server'
5354
import { Hono } from 'hono'
5455
import { devtools } from './devtools'
5556

5657
const app = new Hono()
57-
app.all('/__my-tool/*', c => devtools.handler(c.req.raw, c.env))
58+
app.all('/__my-tool/*', c => devtools.handler(c.req.raw))
59+
devtools.attach(serve({ fetch: app.fetch, port: 3000 }))
5860
```
5961

6062
```ts [Next.js]
@@ -66,7 +68,13 @@ import myDevframe from '@/devframe'
6668
export const runtime = 'nodejs'
6769
export const dynamic = 'force-dynamic'
6870

69-
const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
71+
// Route handlers never see upgrades, so the socket asks for a side-car; the
72+
// globalThis memo keeps a dev-time reload from starting a second one.
73+
const g = globalThis as { devtools?: ReturnType<typeof initDevframe> }
74+
const devtools = g.devtools ??= initDevframe(myDevframe, {
75+
base: '/__my-tool/',
76+
ws: { sidecar: true },
77+
})
7078
export const GET = devtools.handler
7179
```
7280

@@ -87,23 +95,30 @@ export default defineEventHandler((event) => {
8795
import myDevframe from '$lib/devframe'
8896
import { initDevframe } from 'devframe/initiate'
8997

90-
const devtools = initDevframe(myDevframe, { base: '/__my-tool/', key: 'my-tool' })
98+
const g = globalThis as { devtools?: ReturnType<typeof initDevframe> }
99+
const devtools = g.devtools ??= initDevframe(myDevframe, {
100+
base: '/__my-tool/',
101+
ws: { sidecar: true },
102+
})
91103
export const GET = ({ request }) => devtools.handler(request)
92104
```
93105

94106
:::
95107

96-
For frameworks with dev-time module reloading (Next, Nitro, SvelteKit), always set `key` — a re-evaluation returns the live instance instead of leaking WebSocket servers (`DF0053` reports an intentional replacement when the options changed).
108+
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.
97109

98110
## The WebSocket binding
99111

100-
Fetch handlers hand over `Request`s, so the RPC socket needs its own binding. The instance resolves it in precedence order and advertises the result in `__connection.json` — the browser client follows whatever is advertised:
112+
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:
101113

102-
1. **`ws.port`**an explicit side-car port.
114+
1. **`ws.port`**a side-car server on that exact port.
103115
2. **`server`** — share the host's `node:http` server; the upgrade binds at `<base>__ws`. Zero extra ports, and the socket follows the app through proxies and HTTPS.
104-
3. **`ws.url` alone** — advertise an external endpoint verbatim; the server behind that URL owns the transport (wire the instance's `context` into your own server with `startHttpAndWs`). Combined with `server`/`ws.port`, `ws.url` overrides only the advertisement — the tunnel pattern.
105-
4. **Bun** — same-origin fetch upgrades: pass the `Bun.serve` server as `handler`'s second argument and wire `Bun.serve({ websocket: devtools.websocket })`.
106-
5. **Default** — an eager side-car on a free port, started at init so the meta is stable from the first request.
116+
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).
117+
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.
118+
119+
`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.
120+
121+
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).
107122

108123
## Auth
109124

‎docs/errors/DF0053.md‎

Lines changed: 0 additions & 33 deletions
This file was deleted.

‎docs/errors/DF0055.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0055: Instance Already Owns Its WebSocket Transport
6+
7+
## Message
8+
9+
> This instance already owns its WebSocket transport (`{tier}`), so it cannot take over the host's upgrade events.
10+
11+
## Cause
12+
13+
`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.
14+
15+
## Example
16+
17+
```ts
18+
import { initHub } from '@devframes/hub/initiate'
19+
20+
const hub = initHub({ base: '/__devframes/', ws: { sidecar: true } })
21+
hub.attach(myServer) // ✗ throws DF0055 — the side-car already serves `__ws`
22+
23+
// ✓ Pick one: the side-car…
24+
const sidecar = initHub({ base: '/__devframes/', ws: { sidecar: true } })
25+
26+
// …or the host's own server.
27+
const attached = initHub({ base: '/__devframes/' })
28+
attached.attach(myServer)
29+
```
30+
31+
## Fix
32+
33+
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.
34+
35+
## Source
36+
37+
- [`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`.

‎docs/errors/DF0056.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0056: Instance Advertises an External WebSocket Endpoint
6+
7+
## Message
8+
9+
> This instance advertises an external WebSocket endpoint (`{url}`), so it serves no socket of its own.
10+
11+
## Cause
12+
13+
`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.
14+
15+
## Example
16+
17+
```ts
18+
import { initDevframe } from 'devframe/initiate'
19+
20+
const relayed = initDevframe(def, {
21+
base: '/__my-tool/',
22+
ws: { url: 'wss://devtools.example.com/relay/__ws' },
23+
})
24+
relayed.attach(myServer) // ✗ throws DF0056 — an external server owns the socket
25+
26+
// ✓ Serve the socket here, advertised through the relay (the tunnel pattern).
27+
const tunnelled = initDevframe(def, {
28+
base: '/__my-tool/',
29+
ws: { url: 'wss://devtools.example.com/relay/__ws', sidecar: true },
30+
})
31+
```
32+
33+
## Fix
34+
35+
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.
36+
37+
## Source
38+
39+
- [`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`.

‎docs/errors/DF8001.md‎

Lines changed: 0 additions & 33 deletions
This file was deleted.

‎docs/examples/hub-hono-minimal.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ Package: `hub-hono-minimal` · framework: **Hono**
1010

1111
## What it shows
1212

13-
- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` in `src/app.ts` plus `app.all(\`${hub.base}*\`, c => hub.handler(c.req.raw, c.env))`.
14-
- On Node (`@hono/node-server`), the RPC WebSocket runs on an eager side-car port.
15-
- 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.
13+
- `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.
14+
- On Node (`src/server.ts`), `@hono/node-server`'s `serve()` returns the `node:http` server and `hub.attach(server)` takes its upgrade events.
15+
- 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.
1616

1717
## Run it
1818

‎docs/examples/hub-next-minimal.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Package: `hub-next-minimal` · framework: **React (Next.js)**
1212

1313
- `initHub({ base, devframes: [inspect, messages], ui: createUi() })` behind one route (`app/%5F_devframes/[[...path]]/route.ts`) delegating to `hub.handler(request)`.
1414
- 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).
15-
- Next route handlers can't accept WebSocket upgrades, so the instance runs its eager side-car WS server, advertised through `<base>__connection.json`.
15+
- Next route handlers can't accept WebSocket upgrades, so `ws: { sidecar: true }` gives the socket its own port, advertised through `<base>__connection.json`; the instance is memoized on `globalThis` so a dev-time reload reuses it.
1616

1717
## Run it
1818

‎docs/examples/hub-next.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Package: `hub-next` · framework: **React (Next.js)**
1111
## What it proves
1212

1313
- `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)`.
14-
- Next route handlers can't accept WebSocket upgrades, so the instance starts its eager side-car WS server, advertised through `<base>__connection.json`.
14+
- Next route handlers can't accept WebSocket upgrades, so `ws: { sidecar: true }` gives the socket its own port, advertised through `<base>__connection.json`; the instance is memoized on `globalThis` so a dev-time reload reuses it.
1515
- 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.
1616
- [Client-only docks](/guide/client-context#client-only-docks) the page registers itself with `context.docks.register()`.
1717

‎docs/examples/hub-nitro-minimal.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Package: `hub-nitro-minimal` · framework: **Nitro**
1212

1313
- `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)`.
1414
- `nitro.config.ts` keeps the devframe packages external so their prebuilt client assets resolve from the packages themselves rather than Nitro's build output.
15-
- The RPC WebSocket runs on an eager side-car port, advertised through `<base>__connection.json`.
15+
- Nitro handlers hand over `Request`s, so `ws: { sidecar: true }` puts the RPC WebSocket on its own port, advertised through `<base>__connection.json`.
1616

1717
## Run it
1818

‎docs/examples/hub-rsbuild-minimal.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ Package: `hub-rsbuild-minimal` · framework: **Rsbuild**
1010

1111
## What it shows
1212

13-
- `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.
13+
- `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.
1414
- `server.setup` registers `hub.nodeMiddleware`, which owns the `/__devframes/` namespace and hands everything else back to Rsbuild.
15-
- The RPC WebSocket runs on an eager side-car port, advertised through `<base>__connection.json`; `html.tags` injects the `${hub.base}embedded.js` bootstrap.
15+
- Rsbuild's middleware stack never hands over upgrades, so `ws: { sidecar: true }` puts the RPC WebSocket on its own port, advertised through `<base>__connection.json`; `html.tags` injects the `${hub.base}embedded.js` bootstrap.
1616

1717
## Run it
1818

0 commit comments

Comments
 (0)