From 3e5f0d10874de7c03bce3b20d0b4555292335f74 Mon Sep 17 00:00:00 2001 From: onmax Date: Mon, 26 Oct 2026 21:01:47 +0100 Subject: [PATCH] fix(devframe): listen on both loopback addresses for a localhost side-car A page on 127.0.0.1 could not reach a side-car on the default localhost host. The client dials the page hostname, but listen(port, 'localhost') binds only the first address localhost resolves to (::1 on current Linux and macOS). --- docs/content/2.adapters/1.initiate.md | 2 + .../src/adapters/__tests__/initiate.test.ts | 52 ++++++++++++++++ packages/devframe/src/node/instance-shell.ts | 62 +++++++++++++++---- 3 files changed, 105 insertions(+), 11 deletions(-) diff --git a/docs/content/2.adapters/1.initiate.md b/docs/content/2.adapters/1.initiate.md index fd21331a..bef25700 100644 --- a/docs/content/2.adapters/1.initiate.md +++ b/docs/content/2.adapters/1.initiate.md @@ -123,6 +123,8 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so 3. **`ws: { sidecar: true }`**: a side-car server on a free port, for host frameworks whose handlers never see upgrades (Next.js route handlers, Nitro, Rsbuild). 4. **The host framework's own upgrades.** With none set, the socket waits: `devtools.attach(server)` routes a server's `upgrade` events (returning a detach fn); `devtools.handleUpgrade(req, socket, head)` completes a single one from a listener you own. To run your own check before the socket opens, own the listener, match the `__ws` path first (the listener sees every upgrade on the server, including the host framework's own), and call `handleUpgrade` once the check passes; the `server` option attaches its listener asynchronously, after any guard that wraps the listeners present at startup. +The browser dials a side-car on its page's own hostname, so a side-car on the default `localhost` host listens on both `127.0.0.1` and `::1`. Pages opened on either address reach it. + `ws.url` controls the *advertisement* instead, so the browser dials it verbatim. Alone, an external WebSocket server owns the transport and its auth (wire the running devframe's `context` via `createContextRpcServer` + a WS transport); alongside a local binding it overrides only the advertisement (the tunnel pattern). `__connection.json` describes the active combination. Asking a configured running devframe to take over the host framework's upgrades reports `DF0055` (a local binding owns the socket) or `DF0056` (`ws.url` handed it off). diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index 78277a53..23fe5103 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -1,7 +1,9 @@ import type { DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../../types' +import { lookup } from 'node:dns/promises' import { mkdtempSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { createSecureServer } from 'node:http2' +import { isIP } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineDevframe } from 'devframe' @@ -12,6 +14,7 @@ import { describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' import { getTempAuthCode } from '../../node/auth/state' import { getInternalContext } from '../../node/hub-internals/context' +import { formatHostForUrl } from '../../node/utils' import { initDevframe } from '../initiate' const HANDSHAKE = { authToken: '', ua: 'test', origin: 'http://localhost' } @@ -537,6 +540,55 @@ describe('adapters/handler', () => { } }) + // The client dials the side-car on its page's own hostname, so a page on + // `127.0.0.1` and one on `[::1]` both have to reach a `localhost` side-car, + // whichever of the two addresses `localhost` resolves to first. + it('ws.sidecar on localhost: reachable on both loopback addresses', async () => { + const devtools = initDevframe(defineTestDef('handler-sidecar-localhost'), { + base: '/__handler-sidecar-localhost/', + auth: false, + ws: { sidecar: true }, + }) + + try { + await devtools.ready + const { port } = devtools.connectionMeta().websocket as { port: number } + for (const host of ['127.0.0.1', '[::1]']) { + const client = connectWsClient(`ws://${host}:${port}/__ws`) + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + } + } + finally { + await devtools.close() + } + }) + + it('ws.sidecar on localhost: moves off a port another server holds on the other loopback', async () => { + const { address: primary } = await lookup('localhost') + const twin = isIP(primary) === 6 ? '127.0.0.1' : '::1' + const port = await getPort({ port: 18170, host: twin }) + const squatter = createServer((_req, res) => res.end('squatter')) + await new Promise(resolve => squatter.listen(port, twin, resolve)) + const devtools = initDevframe( + defineDevframe({ ...defineTestDef('handler-sidecar-twin'), cli: { port } }), + { base: '/__handler-sidecar-twin/', auth: false, ws: { sidecar: true } }, + ) + + try { + await devtools.ready + const advertised = (devtools.connectionMeta().websocket as { port: number }).port + expect(advertised).not.toBe(port) + const client = connectWsClient(`ws://${formatHostForUrl(twin)}:${advertised}/__ws`) + await expect(client.$call('test:probe' as any)).resolves.toBe('ok') + client.$close() + } + finally { + await devtools.close() + squatter.close() + } + }) + it('a configured transport refuses to take over the host upgrades', async () => { const wsPort = await getPort({ port: 18155, host: '127.0.0.1' }) const sidecar = initDevframe(defineTestDef('handler-owned'), { base: '/__handler-owned/', auth: false, host: '127.0.0.1', ws: { port: wsPort } }) diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 212a65da..bc445db2 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -82,6 +82,54 @@ function websocketConnectionMeta(rpcHost: RpcFunctionsHostImpl, path: string | u return { backend: 'websocket', websocket: { path }, jsonSerializableMethods } } +function listen(server: NodeHttpServer, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error): void => reject(error) + // Without this listener a failed bind emits `error` with nobody + // attached (an uncaughtException) and the `listen` callback never + // fires, so this promise never settles. + server.once('error', onError) + server.listen(port, host, () => { + server.removeListener('error', onError) + resolve() + }) + }) +} + +/** Errors meaning the machine has no such loopback address to bind. */ +const NO_LOOPBACK_ADDRESS = new Set(['EADDRNOTAVAIL', 'EAFNOSUPPORT']) + +/** + * Listen on `host`, and when that is `localhost`, on the other loopback + * address too. `listen(port, 'localhost')` binds only the first address + * `localhost` resolves to (`::1` on most current Linux and macOS setups), + * while a client dials the side-car on its own page's hostname, so a page + * opened on `127.0.0.1` would be refused. The twin server hands its traffic + * to `server`. If another process holds the twin address, fail like a taken + * port, so pages on that address never reach a different server. A machine + * without that address family skips the twin. Resolves to a closer for the + * twin. + */ +async function listenWithLoopbackTwin(server: NodeHttpServer, port: number, host: string): Promise<() => Promise> { + await listen(server, port, host) + const bound = server.address() + if (host !== 'localhost' || !bound || typeof bound === 'string') + return async () => {} + const twin = createServer() + twin.on('request', (req, res) => server.emit('request', req, res)) + twin.on('upgrade', (req, socket, head) => server.emit('upgrade', req, socket, head)) + try { + await listen(twin, bound.port, bound.family === 'IPv6' ? '127.0.0.1' : '::1') + return () => new Promise(r => twin.close(() => r())) + } + catch (error) { + if (NO_LOOPBACK_ADDRESS.has((error as NodeJS.ErrnoException).code ?? '')) + return async () => {} + await new Promise(r => server.close(() => r())) + throw error + } +} + /** * Compose an h3 + WebSocket RPC server for a devframe context: the low-level * "listen on a port (or share one) + attach the WS transport" binding the @@ -115,19 +163,10 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise => {} if (ownsHttpServer) { try { - await new Promise((resolve, reject) => { - const onError = (error: Error): void => reject(error) - // Without this listener a failed bind emits `error` with nobody - // attached (an uncaughtException) and the `listen` callback never - // fires, so this promise never settles. - httpServer.once('error', onError) - httpServer.listen(port, bindHost, () => { - httpServer.removeListener('error', onError) - resolve() - }) - }) + closeLoopbackTwin = await listenWithLoopbackTwin(httpServer, port, bindHost) } catch (error) { // The WS transport is already attached above, so tear it down before @@ -172,6 +211,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise(r => httpServer.close(() => r())) if (wsUrl && getInternalContext(context).wsEndpoint?.url === wsUrl)