You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
`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.
exportconst GET = ({ request }) =>devtools.handler(request)
92
104
```
93
105
94
106
:::
95
107
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.
97
109
98
110
## The WebSocket binding
99
111
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:
101
113
102
-
1.**`ws.port`** — an explicit side-car port.
114
+
1.**`ws.port`** — a side-car server on that exact port.
103
115
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).
# 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.
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`.
# 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.
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`.
-`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.
-`initHub({ base, devframes: [inspect, messages], ui: createUi() })` behind one route (`app/%5F_devframes/[[...path]]/route.ts`) delegating to `hub.handler(request)`.
14
14
- 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.
-`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.
15
15
- 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.
16
16
-[Client-only docks](/guide/client-context#client-only-docks) the page registers itself with `context.docks.register()`.
-`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)`.
14
14
-`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`.
-`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.
14
14
-`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.
0 commit comments