Skip to content

Commit ec4ffb8

Browse files
authored
fix: atomically allocate standalone ws ports (#171)
1 parent c3ef483 commit ec4ffb8

2 files changed

Lines changed: 68 additions & 7 deletions

File tree

packages/devframe/src/rpc/transports/ws-server.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { NodeAdapter } from 'crossws/adapters/node'
44
import type { Buffer } from 'node:buffer'
55
import type { Server as HttpServer, IncomingMessage } from 'node:http'
66
import type { Server as HttpsServer, ServerOptions as HttpsServerOptions } from 'node:https'
7+
import type { AddressInfo } from 'node:net'
78
import type { Duplex } from 'node:stream'
89
import type { RpcFunctionDefinitionAny } from '../types'
910
import { createServer as createHttpServer } from 'node:http'
@@ -44,9 +45,12 @@ export interface WsRpcTransportOptions {
4445
* this transport detaches the upgrade listener without closing the server.
4546
*/
4647
server?: HttpServer | HttpsServer
47-
/** Port for a newly-created standalone WS server. */
48+
/**
49+
* Port for the standalone WebSocket server. Defaults to `0`, which lets the
50+
* operating system assign an available port.
51+
*/
4852
port?: number
49-
/** Host for a newly-created standalone WS server. Defaults to `localhost`. */
53+
/** Host for the standalone WebSocket server. Defaults to `localhost`. */
5054
host?: string
5155
/**
5256
* Restrict the WS endpoint to a single upgrade route (e.g. `/__devframe_ws`). When
@@ -168,6 +172,10 @@ export interface WsRpcTransport {
168172
* `peers` and pub/sub. See https://crossws.h3.dev.
169173
*/
170174
ws: NodeAdapter
175+
/** Resolves when the transport-owned server is listening. */
176+
ready: Promise<void>
177+
/** Returns the bound address, or `null` when the server is not listening. */
178+
address: () => AddressInfo | string | null
171179
/** Remove the upgrade listener from a shared `server` (a no-op otherwise). */
172180
detach: () => void
173181
/**
@@ -184,6 +192,27 @@ const EMPTY_DEFS: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerial
184192

185193
function NOOP() {}
186194

195+
function listen(
196+
server: HttpServer | HttpsServer,
197+
port: number,
198+
host: string,
199+
): Promise<void> {
200+
return new Promise((resolve, reject) => {
201+
const onError = (error: Error) => reject(error)
202+
server.once('error', onError)
203+
try {
204+
server.listen(port, host, () => {
205+
server.off('error', onError)
206+
resolve()
207+
})
208+
}
209+
catch (error) {
210+
server.off('error', onError)
211+
reject(error)
212+
}
213+
})
214+
}
215+
187216
/** Compare two URL paths ignoring a trailing slash. */
188217
function pathMatches(a: string, b: string): boolean {
189218
const strip = (p: string) => (p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p)
@@ -276,9 +305,9 @@ function routeUpgrades(
276305
* `server` (sharing its port, optionally scoped to a `path`), or let this
277306
* helper create a standalone server from `port` / `host` / `https`.
278307
*
279-
* Returns the crossws node adapter plus `detach` (remove the upgrade
280-
* listener from a shared `server`) and `close` (full deterministic
281-
* teardown).
308+
* Returns the crossws node adapter, standalone-server readiness/address
309+
* accessors, `detach` (remove the upgrade listener from a shared `server`),
310+
* and `close` (full deterministic teardown).
282311
*/
283312
export function attachWsRpcTransport<
284313
ClientFunctions extends object,
@@ -388,6 +417,7 @@ export function attachWsRpcTransport<
388417
})
389418

390419
let detach = NOOP
420+
let ready = Promise.resolve()
391421
// A server created (and thus owned) by this transport. Nothing else
392422
// handles its upgrades, so off-route clients are rejected promptly.
393423
let ownedServer: HttpServer | HttpsServer | undefined
@@ -399,7 +429,7 @@ export function attachWsRpcTransport<
399429
else if (https) {
400430
ownedServer = createHttpsServer(https)
401431
detach = routeUpgrades(ownedServer, ws, path, true, allowedOrigins)
402-
ownedServer.listen(port, host)
432+
ready = listen(ownedServer, port ?? 0, host)
403433
}
404434
else {
405435
// Standalone server on its own port. Plain HTTP requests get the
@@ -409,11 +439,15 @@ export function attachWsRpcTransport<
409439
res.end('Upgrade Required')
410440
})
411441
detach = routeUpgrades(ownedServer, ws, path, true, allowedOrigins)
412-
ownedServer.listen(port, host)
442+
ready = listen(ownedServer, port ?? 0, host)
413443
}
414444

445+
const activeServer = server ?? ownedServer
446+
415447
return {
416448
ws,
449+
ready,
450+
address: () => activeServer?.address() ?? null,
417451
detach,
418452
async close() {
419453
// Detach our upgrade listener first so a shared host server stops
@@ -425,6 +459,9 @@ export function attachWsRpcTransport<
425459
ws.closeAll(undefined, undefined, true)
426460
if (ownedServer) {
427461
const srv = ownedServer
462+
await ready.catch(() => {})
463+
if (!srv.listening)
464+
return
428465
await new Promise<void>(r => srv.close(() => r()))
429466
}
430467
},

packages/devframe/src/rpc/transports/ws.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,30 @@ describe('ws client post on a non-open socket', () => {
183183
})
184184

185185
describe('devframe rpc', () => {
186+
it('atomically allocates unique ports for concurrent standalone transports', async () => {
187+
const HOST = '127.0.0.1'
188+
const transports = Array.from({ length: 3 }, () => {
189+
const server = createRpcServer<Record<string, never>, Record<string, never>>({})
190+
return attachWsRpcTransport(server, { host: HOST })
191+
})
192+
193+
try {
194+
await Promise.all(transports.map(transport => transport.ready))
195+
const ports = transports.map((transport) => {
196+
const address = transport.address()
197+
if (!address || typeof address === 'string')
198+
throw new TypeError('Expected an IP socket address')
199+
return address.port
200+
})
201+
202+
expect(ports.every(port => port > 0)).toBe(true)
203+
expect(new Set(ports).size).toBe(transports.length)
204+
}
205+
finally {
206+
await Promise.all(transports.map(transport => transport.close()))
207+
}
208+
})
209+
186210
it('should work w/ ws transport', async () => {
187211
// Use 127.0.0.1 on both client and server so they agree on the
188212
// address family — `localhost` resolution is ambiguous (IPv4 vs IPv6)

0 commit comments

Comments
 (0)