Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/content/2.adapters/1.initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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.

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).
Expand Down
52 changes: 52 additions & 0 deletions packages/devframe/src/adapters/__tests__/initiate.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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' }
Expand Down Expand Up @@ -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<void>(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 } })
Expand Down
62 changes: 51 additions & 11 deletions packages/devframe/src/node/instance-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
return new Promise<void>((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<void>> {
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<void>(r => twin.close(() => r()))
}
catch (error) {
if (NO_LOOPBACK_ADDRESS.has((error as NodeJS.ErrnoException).code ?? ''))
return async () => {}
await new Promise<void>(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
Expand Down Expand Up @@ -115,19 +163,10 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
closeWs = transport.close
}

let closeLoopbackTwin = async (): Promise<void> => {}
if (ownsHttpServer) {
try {
await new Promise<void>((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
Expand Down Expand Up @@ -172,6 +211,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
async close() {
httpServer.off('listening', publishWsEndpoint)
await closeWs()
await closeLoopbackTwin()
if (ownsHttpServer)
await new Promise<void>(r => httpServer.close(() => r()))
if (wsUrl && getInternalContext(context).wsEndpoint?.url === wsUrl)
Expand Down
Loading