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)