Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions docs/adapters/initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? /__<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.
`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.

## Mount the handler

Expand All @@ -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)
Expand All @@ -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]
Expand All @@ -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<typeof initDevframe> }
const devtools = g.devtools ??= initDevframe(myDevframe, {
base: '/__my-tool/',
ws: { sidecar: true },
})
export const GET = devtools.handler
```

Expand All @@ -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<typeof initDevframe> }
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 `<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.
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

Expand Down
33 changes: 0 additions & 33 deletions docs/errors/DF0053.md

This file was deleted.

37 changes: 37 additions & 0 deletions docs/errors/DF0055.md
Original file line number Diff line number Diff line change
@@ -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`.
39 changes: 39 additions & 0 deletions docs/errors/DF0056.md
Original file line number Diff line number Diff line change
@@ -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`.
33 changes: 0 additions & 33 deletions docs/errors/DF8001.md

This file was deleted.

6 changes: 3 additions & 3 deletions docs/examples/hub-hono-minimal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/hub-next-minimal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>__connection.json`.
- 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.

## Run it

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/hub-next.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>__connection.json`.
- 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.
- 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()`.

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/hub-nitro-minimal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>__connection.json`.
- Nitro handlers hand over `Request`s, so `ws: { sidecar: true }` puts the RPC WebSocket on its own port, advertised through `<base>__connection.json`.

## Run it

Expand Down
4 changes: 2 additions & 2 deletions docs/examples/hub-rsbuild-minimal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>__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 `<base>__connection.json`; `html.tags` injects the `${hub.base}embedded.js` bootstrap.

## Run it

Expand Down
Loading
Loading