Skip to content

Commit 236ae62

Browse files
committed
fix(handler): support the tunnel pattern — ws.url overrides advertisement, not binding
ws.url now controls only the advertised endpoint (a relay the browser dials verbatim); the local binding still follows server/ws.port when given, matching createDevServer's remote-origin scenario. Only when no explicit binding accompanies ws.url does the handler start no transport. Drops the now-meaningless conflict diagnostic (codes renumbered: DF0052 = memoized handler replaced, DF0053 = connectionMeta before ready).
1 parent fdf12a5 commit 236ae62

6 files changed

Lines changed: 84 additions & 94 deletions

File tree

docs/errors/DF0052.md

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,32 @@
22
outline: deep
33
---
44

5-
# DF0052: Conflicting WebSocket Bindings on createHandler
5+
# DF0052: Memoized Handler Replaced
66

77
## Message
88

9-
> createHandler("`{id}`") received \`ws.url\` alongside \`server\`/\`ws.port\` — the external URL wins and no local WebSocket transport is started.
9+
> createHandler("`{id}`") replaced the live handler memoized under key "`{key}`": its options changed since the previous call.
1010
1111
## Cause
1212

13-
`createHandler` resolves its WebSocket tier in precedence order — `ws.url` (advertise an external endpoint verbatim) > `ws.port` (explicit side-car port) > `server` (shared upgrade on the host's HTTP server) > the eager auto side-car. Passing `ws.url` together with `server` or `ws.port` is contradictory: the external URL is advertised, and the other bindings are ignored — the handler starts no transport of its own in that tier.
13+
`createHandler` was called with a `key` that already maps to a live handler, but the option fingerprint differs from the memoized instance's. Dev servers that re-evaluate modules on the fly (Next.js, Nitro, SvelteKit HMR) re-run `createHandler` 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.
1414

1515
## Example
1616

1717
```ts
1818
import { createHandler } from 'devframe/handler'
1919

20-
// ✗ Bad — the server is never used for devframe's socket:
21-
const handler = createHandler(def, {
22-
server: httpServer,
23-
ws: { url: 'wss://relay.example.com/__ws' },
24-
})
20+
// First evaluation:
21+
createHandler(def, { key: 'devtools', ws: { port: 7811 } })
2522

26-
// ✓ Good — pick exactly one binding:
27-
const shared = createHandler(def, { server: httpServer })
28-
const external = createHandler(def, { ws: { url: 'wss://relay.example.com/__ws' } })
23+
// A later reload with a different port replaces the live instance:
24+
createHandler(def, { key: 'devtools', ws: { port: 7812 } }) // ⚠ DF0052
2925
```
3026

3127
## Fix
3228

33-
Pass exactly one WebSocket binding: `ws.url` when a server you run yourself owns the RPC endpoint (wire the handler's `context` into it via `startHttpAndWs`), `ws.port` for an explicit side-car port, or `server` to share the host HTTP server's port. Drop the extras.
29+
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 handlers distinct keys.
3430

3531
## Source
3632

37-
- [`packages/devframe/src/adapters/handler.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/handler.ts)`createHandler`'s WebSocket tier resolution warns this when `ws.url` shadows another binding.
33+
- [`packages/devframe/src/adapters/handler.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/handler.ts)`createHandler` warns this before closing and replacing a memoized instance whose options fingerprint changed.

docs/errors/DF0053.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,32 @@
22
outline: deep
33
---
44

5-
# DF0053: Memoized Handler Replaced
5+
# DF0053: connectionMeta() Before Handler Ready
66

77
## Message
88

9-
> createHandler("`{id}`") replaced the live handler memoized under key "`{key}`": its options changed since the previous call.
9+
> connectionMeta() was called before createHandler("`{id}`") finished initializing.
1010
1111
## Cause
1212

13-
`createHandler` was called with a `key` that already maps to a live handler, but the option fingerprint differs from the memoized instance's. Dev servers that re-evaluate modules on the fly (Next.js, Nitro, SvelteKit HMR) re-run `createHandler` 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.
13+
`createHandler` is a synchronous factory that kicks off asynchronous initialization eagerly — running `def.setup`, binding the WebSocket tier, and mounting the routes. `connectionMeta()` describes the WebSocket binding, which only exists once that initialization completes; calling it earlier has nothing correct to return.
1414

1515
## Example
1616

1717
```ts
1818
import { createHandler } from 'devframe/handler'
1919

20-
// First evaluation:
21-
createHandler(def, { key: 'devtools', ws: { port: 7811 } })
20+
const handler = createHandler(def)
21+
handler.connectionMeta() // ✗ throws DF0053 — init is still in flight
2222

23-
// A later reload with a different port replaces the live instance:
24-
createHandler(def, { key: 'devtools', ws: { port: 7812 } }) // ⚠ DF0053
23+
await handler.ready
24+
handler.connectionMeta() // ✓ { backend: 'websocket', websocket: { } }
2525
```
2626

2727
## Fix
2828

29-
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 handlers distinct keys.
29+
Await `handler.ready` (or any `handler.fetch` call — it awaits readiness internally) before reading `connectionMeta()`.
3030

3131
## Source
3232

33-
- [`packages/devframe/src/adapters/handler.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/handler.ts)`createHandler` warns this before closing and replacing a memoized instance whose options fingerprint changed.
33+
- [`packages/devframe/src/adapters/handler.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/adapters/handler.ts)`createHandler`'s `connectionMeta()` throws this while initialization is still pending.

docs/errors/DF0054.md

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

packages/devframe/src/adapters/__tests__/handler.test.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ function defineTestDef(id: string) {
4242
}
4343

4444
describe('adapters/handler', () => {
45-
it('connectionMeta() before ready throws DF0054', () => {
45+
it('connectionMeta() before ready throws DF0053', () => {
4646
const handler = createHandler(defineTestDef('handler-early'), { auth: false })
47-
expect(() => handler.connectionMeta()).toThrow(/DF0054|finished initializing/)
47+
expect(() => handler.connectionMeta()).toThrow(/DF0053|finished initializing/)
4848
return handler.close()
4949
})
5050

@@ -228,23 +228,33 @@ describe('adapters/handler', () => {
228228
}
229229
})
230230

231-
it('ws.url alongside server warns DF0052 and wins', async () => {
232-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
233-
const server = createServer(() => {})
234-
const handler = createHandler(defineTestDef('handler-conflict'), {
231+
it('tunnel pattern: ws.url with a server binds locally, advertises the relay', async () => {
232+
const host = '127.0.0.1'
233+
const port = await getPort({ port: 18170, host })
234+
let handlerRef!: ReturnType<typeof createHandler>
235+
const server = createServer((req, res) => {
236+
handlerRef.nodeMiddleware(req, res)
237+
})
238+
handlerRef = createHandler(defineTestDef('handler-tunnel'), {
239+
auth: false,
235240
server,
236241
ws: { url: 'wss://devtools.example.com/relay/__ws' },
237242
})
243+
await new Promise<void>(resolve => server.listen(port, host, resolve))
238244

239245
try {
240-
await handler.ready
241-
expect(handler.connectionMeta().websocket).toBe('wss://devtools.example.com/relay/__ws')
242-
expect(String(warn.mock.calls)).toContain('DF0052')
246+
await handlerRef.ready
247+
// The browser is told to dial the relay…
248+
expect(handlerRef.connectionMeta().websocket).toBe('wss://devtools.example.com/relay/__ws')
249+
// …while the local socket keeps serving (the relay's forward target).
250+
const client = connectWsClient(`ws://${host}:${port}/__handler-tunnel/__ws`)
251+
await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
252+
client.$close()
243253
}
244254
finally {
245-
warn.mockRestore()
246-
await handler.close()
255+
await handlerRef.close()
247256
server.close()
257+
server.closeAllConnections()
248258
}
249259
})
250260

@@ -285,7 +295,7 @@ describe('adapters/handler', () => {
285295
const c = createHandler(def, { auth: false, key: 'memo-test', host: '127.0.0.1', ws: { port: wsPort2 } })
286296
try {
287297
expect(c).not.toBe(a)
288-
expect(String(warn.mock.calls)).toContain('DF0053')
298+
expect(String(warn.mock.calls)).toContain('DF0052')
289299
await c.ready
290300
expect(c.connectionMeta()).toEqual({
291301
backend: 'websocket',

packages/devframe/src/adapters/handler.ts

Lines changed: 43 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,12 @@ export interface CreateHandlerOptions {
4646
/**
4747
* Explicit control over how the browser reaches the RPC WebSocket —
4848
* advertised in `__connection.json`. Precedence `url` > `port` > `route`
49-
* (see {@link DevframeWsOptions}); `url` advertises an external endpoint
50-
* verbatim and the handler starts **no transport of its own** (run
51-
* `startHttpAndWs({ context, server, path })` against
49+
* (see {@link DevframeWsOptions}). `url` controls the *advertisement*
50+
* only: the browser dials it verbatim (a tunnel/relay). The local
51+
* binding still follows `server`/`ws.port` when given — the tunnel
52+
* pattern, where the relay forwards to the locally-bound socket — and
53+
* when neither is given, the handler starts **no transport of its own**
54+
* (run `startHttpAndWs({ context, server, path })` against
5255
* {@link DevframeHandler.context} to serve RPC from your own server).
5356
*/
5457
ws?: DevframeWsOptions
@@ -80,7 +83,7 @@ export interface CreateHandlerOptions {
8083
* `createHandler` on every reload — without a key each run would leak an
8184
* eager side-car WebSocket server. With a key, a re-run returns the live
8285
* instance; if the options changed, the old instance is closed and
83-
* replaced (reported as `DF0053`).
86+
* replaced (reported as `DF0052`).
8487
*/
8588
key?: string
8689
/**
@@ -144,7 +147,7 @@ export interface DevframeHandler {
144147
context: Promise<DevframeNodeContext>
145148
/**
146149
* The `ConnectionMeta` this handler serves at `<base>__connection.json`.
147-
* Only readable after initialization (`DF0054` otherwise).
150+
* Only readable after initialization (`DF0053` otherwise).
148151
*/
149152
connectionMeta: () => ConnectionMeta
150153
/** Tear down: WS transport/side-car, MCP sessions, memo-registry entry. */
@@ -217,7 +220,7 @@ export function createHandler(
217220
if (existing) {
218221
if (existing.hash === hash)
219222
return existing.handler
220-
diagnostics.DF0053({ key: options.key, id: def.id })
223+
diagnostics.DF0052({ key: options.key, id: def.id })
221224
void existing.handler.close().catch(() => {})
222225
}
223226
const handler = instantiateHandler(def, options)
@@ -302,9 +305,12 @@ function instantiateHandler(
302305
mcpDispose = mounted.dispose
303306
}
304307

305-
// WebSocket tier resolution — `url` > `port` > shared `server` > Bun
306-
// fetch-upgrade > eager auto side-car. The advertised meta always
307-
// matches the tier that actually serves RPC.
308+
// WebSocket binding resolution — explicit `ws.port` side-car > shared
309+
// `server` > Bun fetch-upgrade > eager auto side-car; `ws.url`, when
310+
// set, overrides only the *advertised* endpoint (the tunnel pattern:
311+
// the relay forwards to whatever local binding the rest configured),
312+
// and suppresses the default binding entirely when no explicit
313+
// `server`/`ws.port` is given (an external server owns the transport).
308314
const ws = options.ws ?? def.cli?.ws ?? {}
309315
const route = withoutLeadingSlash(ws.route ?? DEVFRAME_WS_ROUTE)
310316

@@ -325,22 +331,13 @@ function instantiateHandler(
325331
}
326332

327333
let websocketMeta: ConnectionMeta['websocket']
328-
if (ws.url) {
329-
// External endpoint — the server behind it owns transport and auth.
330-
if (options.server || ws.port != null)
331-
diagnostics.DF0052({ id: def.id })
332-
authHandler = undefined
333-
websocketMeta = ws.url
334-
}
335-
else if (ws.port != null || (!options.server && typeof (globalThis as any).Bun === 'undefined')) {
336-
// Side-car: an explicit port, or the eager default when no host
337-
// server is shared (and we're not under Bun).
334+
if (ws.port != null) {
335+
// Explicit side-car port.
338336
const sidecarHost = options.host ?? def.cli?.host ?? 'localhost'
339-
const port = ws.port ?? await resolveDevServerPort(def, { host: sidecarHost })
340337
started = await startHttpAndWs({
341338
context: ctx,
342339
host: sidecarHost,
343-
port,
340+
port: ws.port,
344341
path: withLeadingSlash(route),
345342
auth: resolvedAuth,
346343
allowedOrigins: options.allowedOrigins,
@@ -360,6 +357,26 @@ function instantiateHandler(
360357
})
361358
websocketMeta = { path: route }
362359
}
360+
else if (ws.url) {
361+
// Advertise-only: an external server owns transport and auth.
362+
authHandler = undefined
363+
websocketMeta = ws.url
364+
}
365+
else if (typeof (globalThis as any).Bun === 'undefined') {
366+
// Eager auto side-car on a free port — the default when no host
367+
// server is shared (and we're not under Bun).
368+
const sidecarHost = options.host ?? def.cli?.host ?? 'localhost'
369+
const port = await resolveDevServerPort(def, { host: sidecarHost })
370+
started = await startHttpAndWs({
371+
context: ctx,
372+
host: sidecarHost,
373+
port,
374+
path: withLeadingSlash(route),
375+
auth: resolvedAuth,
376+
allowedOrigins: options.allowedOrigins,
377+
})
378+
websocketMeta = { port: started.port, path: route }
379+
}
363380
else {
364381
// Bun fetch-upgrade — same-origin upgrades completed through
365382
// `fetch(request, server)`, handlers exposed via `websocket`.
@@ -370,6 +387,10 @@ function instantiateHandler(
370387
bunUpgradePath = joinURL(base, route)
371388
websocketMeta = { path: route }
372389
}
390+
// The tunnel pattern: `ws.url` overrides the advertisement while the
391+
// local binding above keeps serving (the relay forwards to it).
392+
if (ws.url)
393+
websocketMeta = ws.url
373394

374395
// Discovery meta before the SPA mount so its SPA-fallback can't swallow
375396
// the route; both sit at the SPA root for relative `./__connection.json`
@@ -471,7 +492,7 @@ function instantiateHandler(
471492
context: contextPromise,
472493
connectionMeta: () => {
473494
if (!meta)
474-
throw diagnostics.DF0054({ id: def.id })
495+
throw diagnostics.DF0053({ id: def.id })
475496
return meta
476497
},
477498
close: async () => {

packages/devframe/src/node/diagnostics.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,10 @@ export const diagnostics = defineDiagnostics({
113113
fix: 'Restart the instance with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again.',
114114
},
115115
DF0052: {
116-
why: (p: { id: string }) => `createHandler("${p.id}") received \`ws.url\` alongside \`server\`/\`ws.port\` — the external URL wins and no local WebSocket transport is started.`,
117-
fix: 'Pass exactly one WebSocket binding: `ws.url` (advertise an external endpoint you run yourself), `ws.port` (explicit side-car port), or `server` (share the host HTTP server). Drop the extras.',
118-
},
119-
DF0053: {
120116
why: (p: { key: string, id: string }) => `createHandler("${p.id}") replaced the live handler memoized under key "${p.key}": its options changed since the previous call.`,
121117
fix: 'A dev-time module reload re-ran createHandler with different options, so the old instance (and its side-car WebSocket server) was closed and a new one started. If this is unexpected, keep the options stable across reloads — or use distinct keys for genuinely different handlers.',
122118
},
123-
DF0054: {
119+
DF0053: {
124120
why: (p: { id: string }) => `connectionMeta() was called before createHandler("${p.id}") finished initializing.`,
125121
fix: 'Await `handler.ready` (or any `handler.fetch` call) before reading `connectionMeta()` — the WebSocket binding it describes is only known once initialization completes.',
126122
},

0 commit comments

Comments
 (0)