light-my-websocket
v0.1.0
Published
Like light-my-request, but for WebSockets — synthetic in-process upgrade for testing servers without binding a port.
Maintainers
Readme
light-my-websocket
Like
light-my-request, but for WebSockets.
Inject synthetic WebSocket upgrades against a Node http.Server without binding to a port. Useful for testing WebSocket handlers in-process — fast, deterministic, no port collisions, and no listener-attachment race for server-sent handshake frames.
The pattern is taken from @fastify/websocket's internal injectWS helper, extracted into a standalone, server-framework-agnostic package.
Install
pnpm add -D light-my-websocketRequires Node 22.6+ (uses node:stream's duplexPair). ws is a peer dependency — you almost certainly already have it via your WebSocket server.
Usage
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import { injectWS } from 'light-my-websocket'
const server = createServer()
const wss = new WebSocketServer({ noServer: true })
server.on('upgrade', (req, socket, head) => {
wss.handleUpgrade(req, socket, head, (ws) => {
ws.on('message', (data) => ws.send(`echo:${data}`))
})
})
const client = await injectWS(server, '/chat').on('message', (data) => {
console.log(data.toString()) // "echo:hello"
})
client.send('hello')No server.listen() call needed.
Why chain .on() before await?
injectWS(...) returns a WebSocketChain — a thenable builder. Listener registrations chained on the builder are attached to the real WebSocket before the synthetic handshake runs, so frames the server sends during the upgrade reach the consumer reliably.
A naive await injectWS(...) followed by client.on('message', ...) would race: the duplex pair carrying the synthetic socket may have already delivered the first frames by the time the post-await code runs, and a freshly-attached listener would miss them. The chain closes that window — it's the library's headline correctness guarantee.
// ✅ Listeners attached before handshake — frames during upgrade are caught.
const client = await injectWS(server, '/').on('message', handle)
// ⚠ Listener attached after handshake. Fine for echo / request-response
// patterns where the server only sends in response to a client send.
// Risky if the server sends anything immediately on connect.
const client = await injectWS(server, '/')
client.on('message', handle)The chain's .on / .once / .addListener mirror ws.WebSocket's typed event overloads, so data is correctly typed as Buffer (or whatever the event's listener signature specifies) without an extra cast.
Interactive patterns
The connected WebSocket is a real ws.WebSocket, so post-await it behaves like any other WS client. Combined with Node's built-in events.once and events.on, you can write linear test flows:
import { once } from 'node:events'
const log: string[] = []
const client = await injectWS(server, '/chat').on('message', (data) => log.push(data.toString()))
client.send('hello')
const [first] = await once(client, 'message')
client.send('thanks')API
injectWS(server, url?, options?)
server: http.Server— the server whose'upgrade'listeners should run.url?: string— request URL path, including any query string. Default"/".options.headers?: Record<string, string>— extra request headers.hostdefaults to"localhost";connection,upgrade,sec-websocket-version, andsec-websocket-keyare set automatically.
Returns a WebSocketChain.
WebSocketChain
A thenable builder. Listener-registration methods queue on the chain and are replayed onto the real WebSocket immediately before the handshake completes — eliminating the race between handshake completion and consumer listener attachment.
Listener-attachment methods (each returns this for chaining):
.on(event, listener)— typed overloads mirrorws.WebSocket.on..once(event, listener)— typed overloads mirrorws.WebSocket.once..addListener(event, listener)— typed overloads mirrorws.WebSocket.addListener(nothis: WebSocketbinding)..prependListener(event, listener)— generic EventEmitter signature..prependOnceListener(event, listener)— generic EventEmitter signature.
Trigger / promise surface:
.connect() → Promise<WebSocket>— explicit trigger; idempotent. Most consumersawaitthe chain instead..then(onFulfilled, onRejected) → Promise<…>— awaiting the chain calls.connect()and resolves with the connectedWebSocket..catch(onRejected) → Promise<…>— sugar over.connect().catch(...)..finally(onFinally) → Promise<WebSocket>— sugar over.connect().finally(...).
Iteration surface:
.toIterable() → AsyncIterable<Buffer>— race-safe stream of incoming message buffers. Terminates on'close', throws on'error'. Iteration triggers.connect()(idempotent), and listeners are queued through the chain so handshake-time frames are caught..toIterable(transform) → AsyncIterable<T>— same, with a per-chunk decoder(chunk: Buffer) => T | Promise<T>. Useful for framing protocols (CBOR, JSON, protobuf) so consumers can writefor await (const frame of chain.toIterable(decodeFrame))instead of wrapping with an outer async generator.
const ticks: number[] = []
for await (const n of injectWS(server, '/feed').toIterable((c) => Number(c.toString()))) {
ticks.push(n)
if (ticks.length === 3) break
}DOM-style addEventListener is not exposed on the chain — call it on the connected WebSocket after await. .off() / .removeListener() are also not on the chain (it's append-only); detach on the connected WebSocket if needed.
A non-101 response rejects the underlying promise with Error("Unexpected server response: <code>").
How it works
injectWS builds a cross-wired Duplex pair via stream.duplexPair() that looks like the two ends of a TCP socket. When the chain is triggered (via await or .connect()), it constructs a detached ws.WebSocket, applies the queued listeners, then emits 'upgrade' on the server. The server's registered upgrade handlers run as they would for a real upgrade. When the server writes the HTTP 101 response, the chain attaches the duplex to the detached WebSocket via the library's internal setSocket, completing the client side of the handshake.
The timing subtlety the chain protects against: RFC 6455 permits the server to begin sending data frames immediately after the 101 response — there's no quiet period. In the synthetic transport, the server's handleUpgrade callback runs synchronously inside server.emit('upgrade', …), so any ws.send(...) it makes lands in the duplex before the client side has parsed the 101. Listeners pre-attached via the chain (before setSocket) catch the resulting 'message' emits; listeners attached after await (i.e. after 'open') may miss them.
Credits
@fastify/websocket— theinjectWSpattern.light-my-request— the broader "inject a synthetic request" approach this is modelled on, including the thenable-builder pattern.
License
MIT
