Skip to content

Commit 78a43b5

Browse files
authored
fix(devframe): listen on both loopback addresses for a localhost side-car (#462)
1 parent 0736264 commit 78a43b5

3 files changed

Lines changed: 105 additions & 11 deletions

File tree

‎docs/content/2.adapters/1.initiate.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so
123123
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).
124124
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 `<base>__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.
125125

126+
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.
127+
126128
`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).
127129

128130
`__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).

‎packages/devframe/src/adapters/__tests__/initiate.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from '../../types'
2+
import { lookup } from 'node:dns/promises'
23
import { mkdtempSync, writeFileSync } from 'node:fs'
34
import { createServer } from 'node:http'
45
import { createSecureServer } from 'node:http2'
6+
import { isIP } from 'node:net'
57
import { tmpdir } from 'node:os'
68
import { join } from 'node:path'
79
import { defineDevframe } from 'devframe'
@@ -12,6 +14,7 @@ import { describe, expect, it, vi } from 'vitest'
1214
import { WebSocket } from 'ws'
1315
import { getTempAuthCode } from '../../node/auth/state'
1416
import { getInternalContext } from '../../node/hub-internals/context'
17+
import { formatHostForUrl } from '../../node/utils'
1518
import { initDevframe } from '../initiate'
1619

1720
const HANDSHAKE = { authToken: '', ua: 'test', origin: 'http://localhost' }
@@ -537,6 +540,55 @@ describe('adapters/handler', () => {
537540
}
538541
})
539542

543+
// The client dials the side-car on its page's own hostname, so a page on
544+
// `127.0.0.1` and one on `[::1]` both have to reach a `localhost` side-car,
545+
// whichever of the two addresses `localhost` resolves to first.
546+
it('ws.sidecar on localhost: reachable on both loopback addresses', async () => {
547+
const devtools = initDevframe(defineTestDef('handler-sidecar-localhost'), {
548+
base: '/__handler-sidecar-localhost/',
549+
auth: false,
550+
ws: { sidecar: true },
551+
})
552+
553+
try {
554+
await devtools.ready
555+
const { port } = devtools.connectionMeta().websocket as { port: number }
556+
for (const host of ['127.0.0.1', '[::1]']) {
557+
const client = connectWsClient(`ws://${host}:${port}/__ws`)
558+
await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
559+
client.$close()
560+
}
561+
}
562+
finally {
563+
await devtools.close()
564+
}
565+
})
566+
567+
it('ws.sidecar on localhost: moves off a port another server holds on the other loopback', async () => {
568+
const { address: primary } = await lookup('localhost')
569+
const twin = isIP(primary) === 6 ? '127.0.0.1' : '::1'
570+
const port = await getPort({ port: 18170, host: twin })
571+
const squatter = createServer((_req, res) => res.end('squatter'))
572+
await new Promise<void>(resolve => squatter.listen(port, twin, resolve))
573+
const devtools = initDevframe(
574+
defineDevframe({ ...defineTestDef('handler-sidecar-twin'), cli: { port } }),
575+
{ base: '/__handler-sidecar-twin/', auth: false, ws: { sidecar: true } },
576+
)
577+
578+
try {
579+
await devtools.ready
580+
const advertised = (devtools.connectionMeta().websocket as { port: number }).port
581+
expect(advertised).not.toBe(port)
582+
const client = connectWsClient(`ws://${formatHostForUrl(twin)}:${advertised}/__ws`)
583+
await expect(client.$call('test:probe' as any)).resolves.toBe('ok')
584+
client.$close()
585+
}
586+
finally {
587+
await devtools.close()
588+
squatter.close()
589+
}
590+
})
591+
540592
it('a configured transport refuses to take over the host upgrades', async () => {
541593
const wsPort = await getPort({ port: 18155, host: '127.0.0.1' })
542594
const sidecar = initDevframe(defineTestDef('handler-owned'), { base: '/__handler-owned/', auth: false, host: '127.0.0.1', ws: { port: wsPort } })

‎packages/devframe/src/node/instance-shell.ts‎

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,54 @@ function websocketConnectionMeta(rpcHost: RpcFunctionsHostImpl, path: string | u
8282
return { backend: 'websocket', websocket: { path }, jsonSerializableMethods }
8383
}
8484

85+
function listen(server: NodeHttpServer, port: number, host: string): Promise<void> {
86+
return new Promise<void>((resolve, reject) => {
87+
const onError = (error: Error): void => reject(error)
88+
// Without this listener a failed bind emits `error` with nobody
89+
// attached (an uncaughtException) and the `listen` callback never
90+
// fires, so this promise never settles.
91+
server.once('error', onError)
92+
server.listen(port, host, () => {
93+
server.removeListener('error', onError)
94+
resolve()
95+
})
96+
})
97+
}
98+
99+
/** Errors meaning the machine has no such loopback address to bind. */
100+
const NO_LOOPBACK_ADDRESS = new Set(['EADDRNOTAVAIL', 'EAFNOSUPPORT'])
101+
102+
/**
103+
* Listen on `host`, and when that is `localhost`, on the other loopback
104+
* address too. `listen(port, 'localhost')` binds only the first address
105+
* `localhost` resolves to (`::1` on most current Linux and macOS setups),
106+
* while a client dials the side-car on its own page's hostname, so a page
107+
* opened on `127.0.0.1` would be refused. The twin server hands its traffic
108+
* to `server`. If another process holds the twin address, fail like a taken
109+
* port, so pages on that address never reach a different server. A machine
110+
* without that address family skips the twin. Resolves to a closer for the
111+
* twin.
112+
*/
113+
async function listenWithLoopbackTwin(server: NodeHttpServer, port: number, host: string): Promise<() => Promise<void>> {
114+
await listen(server, port, host)
115+
const bound = server.address()
116+
if (host !== 'localhost' || !bound || typeof bound === 'string')
117+
return async () => {}
118+
const twin = createServer()
119+
twin.on('request', (req, res) => server.emit('request', req, res))
120+
twin.on('upgrade', (req, socket, head) => server.emit('upgrade', req, socket, head))
121+
try {
122+
await listen(twin, bound.port, bound.family === 'IPv6' ? '127.0.0.1' : '::1')
123+
return () => new Promise<void>(r => twin.close(() => r()))
124+
}
125+
catch (error) {
126+
if (NO_LOOPBACK_ADDRESS.has((error as NodeJS.ErrnoException).code ?? ''))
127+
return async () => {}
128+
await new Promise<void>(r => server.close(() => r()))
129+
throw error
130+
}
131+
}
132+
85133
/**
86134
* Compose an h3 + WebSocket RPC server for a devframe context: the low-level
87135
* "listen on a port (or share one) + attach the WS transport" binding the
@@ -115,19 +163,10 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
115163
closeWs = transport.close
116164
}
117165

166+
let closeLoopbackTwin = async (): Promise<void> => {}
118167
if (ownsHttpServer) {
119168
try {
120-
await new Promise<void>((resolve, reject) => {
121-
const onError = (error: Error): void => reject(error)
122-
// Without this listener a failed bind emits `error` with nobody
123-
// attached (an uncaughtException) and the `listen` callback never
124-
// fires, so this promise never settles.
125-
httpServer.once('error', onError)
126-
httpServer.listen(port, bindHost, () => {
127-
httpServer.removeListener('error', onError)
128-
resolve()
129-
})
130-
})
169+
closeLoopbackTwin = await listenWithLoopbackTwin(httpServer, port, bindHost)
131170
}
132171
catch (error) {
133172
// The WS transport is already attached above, so tear it down before
@@ -172,6 +211,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
172211
async close() {
173212
httpServer.off('listening', publishWsEndpoint)
174213
await closeWs()
214+
await closeLoopbackTwin()
175215
if (ownsHttpServer)
176216
await new Promise<void>(r => httpServer.close(() => r()))
177217
if (wsUrl && getInternalContext(context).wsEndpoint?.url === wsUrl)

0 commit comments

Comments
 (0)