import crypto, { createHash } from 'node:crypto'; import net from 'node:net'; import type { AddressInfo, Socket } from 'node:net'; import tls from 'node:tls'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { WebSocketClient } from '../src/client'; const ACCEPT_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // Throwaway self-signed pair (openssl, CN=snowluma-test). Used only with // rejectUnauthorized: false so expiry/SAN are irrelevant. const TLS_CERT = `-----BEGIN CERTIFICATE----- MIIDETCCAfmgAwIBAgIUNiu66PmcO6Do6cUaB92UKD8j3qYwDQYJKoZIhvcNAQEL BQAwGDEWMBQGA1UEAwwNc25vd2x1bWEtdGVzdDAeFw0yNjA2MTgwNjIwMTZaFw0z NjA2MTUwNjIwMTZaMBgxFjAUBgNVBAMMDXNub3dsdW1hLXRlc3QwggEiMA0GCSqG SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCm0sVJhqlG75gOFJVsUJOfR+oqvb9eSq4t k5QItuwXr85E2mrzuKFnSzVVbi3VqqyN8t4otL11mGll2ANSjusmWN4hzpaOBBdX bP4UNX/YA7oHId+QKbVicxJgxDE18aPTwaWullyw23LsMsij8T4nLk4eHiOkeRfi uLinG1COe3gUGkCK4uyyMT4Vz+y+5Oj9dvoXYL/A/KmcNtq7JrX7F7qYnYsJKLZZ aZeFyIGSE8ihb0k53iyJ3agWY+rMUD+p5J7pgIZzBd5dcZuI+KwixVKZgapG5n5R ghrnN2ZAQFz13yTRXYmDYy42m4Ue73hMmmt1xVXyf+WVhvrD6pphAgMBAAGjUzBR MB0GA1UdDgQWBBRW/HP12nj9fYMYNxa3jyqmnUigvTAfBgNVHSMEGDAWgBRW/HP1 2nj9fYMYNxa3jyqmnUigvTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA A4IBAQCijzVQ/jHNoqu6stvkkigUv2lTKrd1EHcTZLfzwQkmNv/hfY2EMobO/Qxs FhmITreKFALJ/dUwTt0UTO00LV9whEgr2of4x8wwjZ9wRstY6uyRYBP85QC8+8mZ zWlcf611HugrmpOWjWfEVmmxdI1m26YWTn52nZFPnJqDWg2+RlLJWl55lVotbXEZ Fvas4Vcf2KOk0QwBQKvpt0BISeTIQhbT4GnducxSxyoXGeBQOjQNYb/vTpMn4F9U IAxSsfs9WVoHKXOabK8GV89BCxWoRk4UaSahTq/2Vnbgt86tWibt3lA4y49+XhA2 6z7n9HgJAUKqhsDrYvZmM7/VZ2d5 -----END CERTIFICATE----- `; const TLS_KEY = `-----BEGIN PRIVATE KEY----- MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCm0sVJhqlG75gO FJVsUJOfR+oqvb9eSq4tk5QItuwXr85E2mrzuKFnSzVVbi3VqqyN8t4otL11mGll 2ANSjusmWN4hzpaOBBdXbP4UNX/YA7oHId+QKbVicxJgxDE18aPTwaWullyw23Ls Msij8T4nLk4eHiOkeRfiuLinG1COe3gUGkCK4uyyMT4Vz+y+5Oj9dvoXYL/A/Kmc Ntq7JrX7F7qYnYsJKLZZaZeFyIGSE8ihb0k53iyJ3agWY+rMUD+p5J7pgIZzBd5d cZuI+KwixVKZgapG5n5RghrnN2ZAQFz13yTRXYmDYy42m4Ue73hMmmt1xVXyf+WV hvrD6pphAgMBAAECggEAJ13+u4qhIMHCnrQBzPU42PImEt8DLXOvJcc5PFM6ZJ6S rRHkAk60HAWV+OqOu2jS3o6NGYsJWJpWaPewVQev+zUmelDfm3TgsztfvBIh8K50 dGFsef81tB1WnWo++K1kzUBZ4ljOV9f5hz62tWVlFubo/Vd8bsA6wEB6Jskd2fls ZOO0hJCb/M6IIwe3cWw4sb/YFTTLMUnVHM0aJfnQ/QH+uraRwecJMv118d0YBYVG DsNmYqrv9TYNBh4P9mKv6/0x8zaKsJZVLAMwHztILHItOXBfxCbSDivGWVUGiDq2 NxEuBOgxzOelxEYzUzf7B6sjHoEeIL5wIKQbwUbmfwKBgQDZRLhucrMZPw7ous1t igkO32p1ku7FRxm0/JAskc66oX61KLIYbZFM9N7nTXAEs8ZGhzUkdSgxNcdHWIdK RI+6pd2FhhcKU1feEohlOHvZTXXEMhm61phWnN391GZXfwMKSpscqsu9kIix2jtw 4YrzKLx82EDDHcLfBh9AtArKywKBgQDEj+7UJp5wuqz9hj+gogXeq4Buv9be1u4u HCJZMZzLCwVjvoqYw0n1afXP3057v8hQPGsBaxzQpOg7i1lYGGfuZ9T6T8fFrtqw m/LJLHhMQxeyCEQi1/EoNewvVGSwBJkLOuOs8T2WmMXmOgdHbEYdzkKtT71jst6y TeJ15hVuAwKBgBMK4t9LTkc4L6ZWOQsQvhp/mmUTq7m+sZIbUMeXP/c7kE9wcauS btm/3ImJT/gZiZdE4nN/kTY+8GhgafsoZzCEuRWq2vocs+bS2QGGIdS55Uh826R0 ioWM2igVJaMljq6oO1AX6COFN3XfGraaDgOh3mNS0NpJEXtangKdxRRhAoGAP9SF t/r6hJz6RDHuQ5mZ0l9bC5vciOy+19ZnCRPlWMIxc9ySYV05jSplmqVndSQoRnX4 QbOo3dBPYda0orj6Nx8cuFRkCTvo5GUgCFgakJlQ/o1UowQA2g/4rL35HHfBwzXS bXzBhUADM+owJu9wLYmneWRlmhSh4MEOAz8+QkUCgYAw0fA1Im2/ftrA2Z2IENTi U4cLXqeUeGJfXjIBklUmXhc4qrd/9V9eEHjackI/JI7Qhp/QfPWMXLudlSrppnNJ +s3dVevEKosiZbf4qtTtlmR/IifuTKjK5O94KlBT+evxNsL4Hv0b0oTsVNnEfOOl OUsvSYNSnfvl87tyLIxYLA== -----END PRIVATE KEY----- `; const closers: Array<() => void | Promise> = []; afterEach(async () => { vi.restoreAllMocks(); const pending = closers.splice(0).reverse(); for (const close of pending) { await close(); } }); function acceptKey(key: string): string { return createHash('sha1') .update(key + ACCEPT_GUID) .digest('base64'); } function serverFrame(opcode: number, payload: Buffer): Buffer { if (payload.length > 125) { throw new Error('test frames must be <=125 bytes'); } return Buffer.concat([Buffer.from([0x80 | opcode, payload.length]), payload]); } function tryDecodeClientFrame( buf: Buffer, ): { opcode: number; payload: Buffer; rest: Buffer } | null { if (buf.length < 2) { return null; } const opcode = buf[0]! & 0x0f; const masked = (buf[1]! & 0x80) !== 0; let length = buf[1]! & 0x7f; let offset = 2; if (length === 126) { if (buf.length < 4) { return null; } length = buf.readUInt16BE(2); offset = 4; } const maskLen = masked ? 4 : 0; if (buf.length < offset + maskLen + length) { return null; } let mask: Buffer | undefined; if (masked) { mask = buf.subarray(offset, offset + 4); offset += 4; } const payload = Buffer.from(buf.subarray(offset, offset + length)); if (mask) { for (let i = 0; i < payload.length; i++) { payload[i]! ^= mask[i & 3]!; } } return { opcode, payload, rest: buf.subarray(offset + length) }; } function collectFrames(socket: Socket) { let buf = Buffer.alloc(0); socket.on('data', (chunk: Buffer) => { buf = Buffer.concat([buf, chunk]); }); return { async next(): Promise<{ opcode: number; payload: Buffer }> { for (;;) { const decoded = tryDecodeClientFrame(buf); if (decoded) { buf = decoded.rest; return { opcode: decoded.opcode, payload: decoded.payload }; } await new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error('timeout waiting for frame')), 3000, ); const onChunk = () => { clearTimeout(timer); socket.off('data', onChunk); socket.off('error', onErr); resolve(); }; const onErr = (err: Error) => { clearTimeout(timer); socket.off('data', onChunk); reject(err); }; socket.on('data', onChunk); socket.on('error', onErr); if (tryDecodeClientFrame(buf)) { onChunk(); } }); } }, }; } function handshakeResponse(key: string, extraHeaders: string[] = [], rest?: Buffer): Buffer { const lines = [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', `Sec-WebSocket-Accept: ${acceptKey(key)}`, ...extraHeaders, '', '', ]; const head = Buffer.from(lines.join('\r\n'), 'latin1'); return rest && rest.length > 0 ? Buffer.concat([head, rest]) : head; } type ParsedRequest = { requestLine: string; headers: Record; }; function parseRequest(raw: string): ParsedRequest { const lines = raw.split('\r\n'); const headers: Record = Object.create(null); for (const line of lines.slice(1)) { const colon = line.indexOf(':'); if (colon < 0) { continue; } headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim(); } return { requestLine: lines[0] ?? '', headers }; } function readRequest(socket: Socket): Promise { return new Promise((resolve, reject) => { let buf = Buffer.alloc(0); const timer = setTimeout( () => reject(new Error('timeout waiting for handshake request')), 3000, ); const onError = (err: Error) => { clearTimeout(timer); reject(err); }; const onData = (chunk: Buffer) => { buf = Buffer.concat([buf, chunk]); const idx = buf.indexOf('\r\n\r\n'); if (idx < 0) { return; } clearTimeout(timer); socket.off('data', onData); socket.off('error', onError); resolve(parseRequest(buf.subarray(0, idx).toString('latin1'))); }; socket.on('data', onData); socket.on('error', onError); }); } function track(client: WebSocketClient) { const errors: Error[] = []; const closes: Array<{ code: number; reason: string }> = []; const messages: Array<{ data: Buffer; isBinary: boolean }> = []; const pings: Buffer[] = []; const pongs: Buffer[] = []; let upgrade: { statusCode: number; statusMessage: string; headers: Record } | undefined; let opened = false; client.on('error', (err) => errors.push(err)); client.on('close', (code, reason) => closes.push({ code, reason })); client.on('message', (data, isBinary) => messages.push({ data, isBinary })); client.on('ping', (data) => pings.push(data)); client.on('pong', (data) => pongs.push(data)); client.on('upgrade', (parsed) => { upgrade = parsed as { statusCode: number; statusMessage: string; headers: Record; }; }); client.on('open', () => { opened = true; }); const wait = ( label: string, ready: () => T | undefined, subscribe: (finish: (value: T) => void) => void, ): Promise => { const existing = ready(); if (existing !== undefined) { return Promise.resolve(existing); } return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`timeout waiting for ${label}`)), 3000); subscribe((value) => { clearTimeout(timer); resolve(value); }); }); }; return { errors, closes, messages, pings, pongs, get upgrade() { return upgrade; }, waitOpen: () => new Promise((resolve, reject) => { if (opened) { return resolve(); } if (errors.length) { return reject(errors[0]); } const timer = setTimeout(() => reject(new Error('timeout waiting for open')), 3000); const onOpen = () => { cleanup(); resolve(); }; const onError = (err: Error) => { cleanup(); reject(err); }; const cleanup = () => { clearTimeout(timer); client.off('open', onOpen); client.off('error', onError); }; client.once('open', onOpen); client.once('error', onError); }), waitError: () => wait( 'error', () => errors[0], (finish) => { client.once('error', (err) => finish(err)); }, ), waitClose: () => wait( 'close', () => closes[0], (finish) => { client.once('close', (code, reason) => finish({ code, reason })); }, ), waitMessage: () => wait( 'message', () => messages[0], (finish) => { client.once('message', (data, isBinary) => finish({ data, isBinary })); }, ), waitPing: () => wait( 'ping', () => pings[0], (finish) => { client.once('ping', (data) => finish(data)); }, ), waitPong: () => wait( 'pong', () => pongs[0], (finish) => { client.once('pong', (data) => finish(data)); }, ), }; } async function listenPlain(): Promise<{ port: number; nextSocket: () => Promise }> { const sockets = new Set(); const queued: Socket[] = []; const waiters: Array<(socket: Socket) => void> = []; const server = net.createServer((socket) => { sockets.add(socket); const waiter = waiters.shift(); if (waiter) { waiter(socket); } else { queued.push(socket); } }); await new Promise((resolve, reject) => { const onError = (err: Error) => reject(err); server.once('error', onError); server.listen(0, '127.0.0.1', () => { server.off('error', onError); resolve(); }); }); const port = (server.address() as AddressInfo).port; closers.push(() => { for (const socket of sockets) { socket.destroy(); } return new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); }); }); return { port, nextSocket: () => { const socket = queued.shift(); if (socket) { return Promise.resolve(socket); } return new Promise((resolve) => waiters.push(resolve)); }, }; } async function listenTls(): Promise<{ port: number; nextSocket: () => Promise }> { const sockets = new Set(); const queued: tls.TLSSocket[] = []; const waiters: Array<(socket: tls.TLSSocket) => void> = []; const server = tls.createServer({ cert: TLS_CERT, key: TLS_KEY }, (socket) => { sockets.add(socket); const waiter = waiters.shift(); if (waiter) { waiter(socket); } else { queued.push(socket); } }); await new Promise((resolve, reject) => { const onError = (err: Error) => reject(err); server.once('error', onError); server.listen(0, '127.0.0.1', () => { server.off('error', onError); resolve(); }); }); const port = (server.address() as AddressInfo).port; closers.push(() => { for (const socket of sockets) { socket.destroy(); } return new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); }); }); return { port, nextSocket: () => { const socket = queued.shift(); if (socket) { return Promise.resolve(socket); } return new Promise((resolve) => waiters.push(resolve)); }, }; } function createClient( address: string, protocols?: string | string[] | ConstructorParameters[1], options?: ConstructorParameters[2], ): WebSocketClient { const client = new WebSocketClient(address, protocols, options); client.on('error', () => {}); closers.push(() => { try { client.terminate(); } catch { /* already closed */ } }); return client; } async function unusedPort(): Promise { const server = net.createServer(); await new Promise((resolve, reject) => { const onError = (err: Error) => reject(err); server.once('error', onError); server.listen(0, '127.0.0.1', () => { server.off('error', onError); resolve(); }); }); const port = (server.address() as AddressInfo).port; await new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); }); return port; } async function writeAndTick(socket: Socket, data: Buffer | string): Promise { await new Promise((resolve, reject) => { socket.write(data, (err) => (err ? reject(err) : resolve())); }); await new Promise((resolve) => setImmediate(resolve)); } describe('WebSocketClient', () => { it('exposes the standard ready-state constants', () => { expect(WebSocketClient.CONNECTING).toBe(0); expect(WebSocketClient.OPEN).toBe(1); expect(WebSocketClient.CLOSING).toBe(2); expect(WebSocketClient.CLOSED).toBe(3); }); it('rejects non-ws URLs before connecting', () => { expect(() => new WebSocketClient('http://127.0.0.1/')).toThrowError( new Error('Only ws: and wss: URLs are supported'), ); expect(() => new WebSocketClient('https://127.0.0.1/')).toThrowError( new Error('Only ws: and wss: URLs are supported'), ); expect(() => new WebSocketClient('foo')).toThrow(TypeError); }); it('starts in CONNECTING', async () => { const { port } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); expect(client.readyState).toBe(0); expect(client.protocol).toBe(''); expect(client.extensions).toBe(''); }); it('sends RFC 6455 handshake fields, path, query, protocols, deflate offer, and extra headers', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient( `ws://127.0.0.1:${port}/rooms/1?token=abc`, ['chat', 'superchat'], { perMessageDeflate: true, headers: { 'X-Token': 'secret', Authorization: 'Bearer t' }, }, ); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); expect(req.requestLine).toBe('GET /rooms/1?token=abc HTTP/1.1'); expect(req.headers.host).toBe(`127.0.0.1:${port}`); expect(req.headers.upgrade).toBe('websocket'); expect(req.headers.connection).toBe('Upgrade'); expect(req.headers['sec-websocket-version']).toBe('13'); expect(req.headers['sec-websocket-key']).toMatch(/^[A-Za-z0-9+/]{22}==$/); expect(req.headers['sec-websocket-protocol']).toBe('chat, superchat'); expect(req.headers['sec-websocket-extensions']).toBe( 'permessage-deflate; client_no_context_takeover; server_no_context_takeover', ); expect(req.headers['x-token']).toBe('secret'); expect(req.headers.authorization).toBe('Bearer t'); expect(client.readyState).toBe(0); expect(events.errors).toEqual([]); }); it('treats a string protocol as a one-element list', async () => { const { port, nextSocket } = await listenPlain(); createClient(`ws://127.0.0.1:${port}`, 'chat'); const req = await readRequest(await nextSocket()); expect(req.headers['sec-websocket-protocol']).toBe('chat'); }); it('omits Sec-WebSocket-Protocol when the list is empty', async () => { const { port, nextSocket } = await listenPlain(); createClient(`ws://127.0.0.1:${port}`, []); const req = await readRequest(await nextSocket()); expect(req.headers['sec-websocket-protocol']).toBeUndefined(); }); it('treats a non-array object second argument as options, not protocols', async () => { const { port, nextSocket } = await listenPlain(); createClient(`ws://127.0.0.1:${port}`, { headers: { 'X-From-Options': 'yes' }, perMessageDeflate: false, }); const req = await readRequest(await nextSocket()); expect(req.headers['sec-websocket-protocol']).toBeUndefined(); expect(req.headers['sec-websocket-extensions']).toBeUndefined(); expect(req.headers['x-from-options']).toBe('yes'); }); it('offers only the requested permessage-deflate flags', async () => { const { port, nextSocket } = await listenPlain(); createClient(`ws://127.0.0.1:${port}`, { perMessageDeflate: { clientNoContextTakeover: false }, }); const req = await readRequest(await nextSocket()); expect(req.headers['sec-websocket-extensions']).toBe( 'permessage-deflate; server_no_context_takeover', ); }); it('uses Host without a port when the URL port is the ws default', async () => { const { port, nextSocket } = await listenPlain(); createClient('ws://127.0.0.1:80/chat', { socketOptions: { port, host: '127.0.0.1' }, }); const req = await readRequest(await nextSocket()); expect(req.requestLine).toBe('GET /chat HTTP/1.1'); expect(req.headers.host).toBe('127.0.0.1'); }); it('emits upgrade+open, records protocol/extensions, and forwards leftover frames', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`, ['chat', 'superchat'], { perMessageDeflate: true, }); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); const rest = serverFrame(0x1, Buffer.from('hello')); socket.write( handshakeResponse( req.headers['sec-websocket-key']!, [ 'NoColonLine', 'Sec-WebSocket-Protocol: superchat', 'Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover; server_no_context_takeover', ], rest, ), ); await events.waitOpen(); const message = await events.waitMessage(); expect(client.readyState).toBe(1); expect(client.protocol).toBe('superchat'); expect(client.extensions).toBe('permessage-deflate'); expect(events.upgrade).toMatchObject({ statusCode: 101, statusMessage: 'Switching Protocols', headers: { upgrade: 'websocket', connection: 'Upgrade', 'sec-websocket-accept': acceptKey(req.headers['sec-websocket-key']!), 'sec-websocket-protocol': 'superchat', 'sec-websocket-extensions': 'permessage-deflate; client_no_context_takeover; server_no_context_takeover', }, }); expect(message.data).toEqual(Buffer.from('hello')); expect(message.isBinary).toBe(false); }); it('accepts a 101 that arrives in two TCP chunks and Upgrade/Connection variants', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); await writeAndTick(socket, 'HTTP/1.1 101\r\n'); expect(client.readyState).toBe(0); socket.write( `Upgrade: WebSocket\r\nConnection: keep-alive, Upgrade\r\nSec-WebSocket-Accept: ${acceptKey(req.headers['sec-websocket-key']!)}\r\n\r\n`, ); await events.waitOpen(); expect(client.readyState).toBe(1); expect(events.upgrade?.statusMessage).toBe(''); expect(events.upgrade?.headers.upgrade).toBe('WebSocket'); expect(events.upgrade?.headers.connection).toBe('keep-alive, Upgrade'); }); it('accepts the RFC 6455 example Sec-WebSocket-Accept', async () => { vi.spyOn(crypto, 'randomBytes').mockImplementation(((size: number) => { expect(size).toBe(16); return Buffer.from('the sample nonce'); }) as typeof crypto.randomBytes); const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); expect(req.headers['sec-websocket-key']).toBe('dGhlIHNhbXBsZSBub25jZQ=='); socket.write( [ 'HTTP/1.1 101 Switching Protocols', 'Upgrade: websocket', 'Connection: Upgrade', 'Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=', '', '', ].join('\r\n'), ); await events.waitOpen(); expect(client.readyState).toBe(1); }); it('keeps CONNECTING while the response is incomplete and aborts once it exceeds 16384 bytes', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); await writeAndTick(socket, Buffer.alloc(16384, 0x58)); expect(client.readyState).toBe(0); expect(events.errors).toEqual([]); socket.write('Y'); const err = await events.waitError(); expect(err.message).toBe('WebSocket handshake failed: Handshake response too large'); expect(await events.waitClose()).toEqual({ code: 1006, reason: '' }); expect(client.readyState).toBe(3); }); it.each([ { name: 'a non-HTTP status line', respond: () => 'NOT-A-STATUS\r\n\r\n', message: 'WebSocket handshake failed: Bad HTTP status line: NOT-A-STATUS', }, { name: 'HTTP/1.0', respond: () => 'HTTP/1.0 101 Switching Protocols\r\n\r\n', message: 'WebSocket handshake failed: Bad HTTP status line: HTTP/1.0 101 Switching Protocols', }, { name: 'an empty header block', respond: () => '\r\n\r\n', message: 'WebSocket handshake failed: Bad HTTP status line: ', }, { name: 'a non-101 status', respond: () => 'HTTP/1.1 400 Bad Request\r\n\r\n', message: 'WebSocket handshake failed: Unexpected response status 400', }, { name: 'a missing Upgrade header', respond: (key: string) => `HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`, message: 'WebSocket handshake failed: Missing/invalid Upgrade header', }, { name: 'an Upgrade header that is not websocket', respond: (key: string) => `HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`, message: 'WebSocket handshake failed: Missing/invalid Upgrade header', }, { name: 'a Connection header without upgrade', respond: (key: string) => `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: keep-alive\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`, message: 'WebSocket handshake failed: Missing Connection: Upgrade header', }, { name: 'a missing Connection header', respond: (key: string) => `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`, message: 'WebSocket handshake failed: Missing Connection: Upgrade header', }, { name: 'an invalid Sec-WebSocket-Accept', respond: () => 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: AAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n\r\n', message: 'WebSocket handshake failed: Invalid Sec-WebSocket-Accept', }, { name: 'an unexpected extension when none were offered', respond: (key: string) => `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\nSec-WebSocket-Extensions: permessage-deflate\r\n\r\n`, message: 'WebSocket handshake failed: Unexpected Sec-WebSocket-Extensions', }, { name: 'an unexpected subprotocol when none were requested', respond: (key: string) => `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\nSec-WebSocket-Protocol: chat\r\n\r\n`, message: 'WebSocket handshake failed: Unexpected Sec-WebSocket-Protocol', }, ])('aborts the handshake on $name', async ({ respond, message }) => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); socket.write(respond(req.headers['sec-websocket-key'] ?? '')); const err = await events.waitError(); expect(err.message).toBe(message); expect(await events.waitClose()).toEqual({ code: 1006, reason: '' }); expect(client.readyState).toBe(3); }); it('aborts when the selected subprotocol is not in the offered list', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`, ['chat']); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); socket.write( handshakeResponse(req.headers['sec-websocket-key']!, ['Sec-WebSocket-Protocol: other']), ); const err = await events.waitError(); expect(err.message).toBe('WebSocket handshake failed: Unexpected Sec-WebSocket-Protocol'); expect(client.readyState).toBe(3); }); it('aborts when the server returns an unsupported permessage-deflate parameter', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`, { perMessageDeflate: true }); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); socket.write( handshakeResponse(req.headers['sec-websocket-key']!, [ 'Sec-WebSocket-Extensions: permessage-deflate; server_max_window_bits=10', ]), ); const err = await events.waitError(); expect(err.message).toBe( 'WebSocket handshake failed: Unsupported permessage-deflate parameter: server_max_window_bits', ); expect(client.readyState).toBe(3); }); it('opens without extensions when deflate was offered but the server omitted them', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`, { perMessageDeflate: true }); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); socket.write(handshakeResponse(req.headers['sec-websocket-key']!)); await events.waitOpen(); expect(client.extensions).toBe(''); expect(client.protocol).toBe(''); }); it('forwards a binary leftover frame as isBinary=true', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); socket.write( handshakeResponse( req.headers['sec-websocket-key']!, [], serverFrame(0x2, Buffer.from([0xde, 0xad])), ), ); await events.waitOpen(); const message = await events.waitMessage(); expect(message.data).toEqual(Buffer.from([0xde, 0xad])); expect(message.isBinary).toBe(true); }); it('queues send() until the socket is open, including options-as-callback', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const queued = new Promise((resolve) => { client.send('queued', (err) => resolve(err)); }); client.send('bin', { binary: true }); client.ping('too-early'); client.pong('too-early'); const socket = await nextSocket(); const req = await readRequest(socket); const frames = collectFrames(socket); socket.write(handshakeResponse(req.headers['sec-websocket-key']!)); await events.waitOpen(); expect(await queued).toBeFalsy(); const first = await frames.next(); expect(first.opcode).toBe(0x1); expect(first.payload).toEqual(Buffer.from('queued')); const second = await frames.next(); expect(second.opcode).toBe(0x2); expect(second.payload).toEqual(Buffer.from('bin')); }); it('settles queued sends once on a rejected handshake and rejects further sends', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const reentrant = vi.fn(); const fromError = vi.fn(); const first = vi.fn(() => client.send('from failed callback', reentrant)); const second = vi.fn(); client.on('error', () => client.send('from error listener', fromError)); client.send('first', first); client.send('second', { binary: true }, second); client.send('without callback'); const socket = await nextSocket(); await readRequest(socket); socket.end('HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n'); await events.waitClose(); await new Promise((resolve) => setImmediate(resolve)); expect(first).toHaveBeenCalledExactlyOnceWith(events.errors[0]); expect(second).toHaveBeenCalledExactlyOnceWith(events.errors[0]); expect(reentrant).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); expect(fromError).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); expect(events.errors[0]?.message).toContain('Unexpected response status 401'); expect(events.closes).toEqual([{ code: 1006, reason: '' }]); const afterClose = vi.fn(); client.send('after close', afterClose); expect(afterClose).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); expect(() => client.send('after close without callback')).toThrow('WebSocket is not open'); client.close(); client.terminate(); await new Promise((resolve) => setImmediate(resolve)); expect(first).toHaveBeenCalledTimes(1); expect(second).toHaveBeenCalledTimes(1); expect(events.closes).toHaveLength(1); }); it.each(['close', 'terminate', 'peer close'] as const)( 'settles queued callbacks exactly once after %s before opening', async (outcome) => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const first = vi.fn(); const second = vi.fn(); client.send('first', first); client.send('second', { binary: true }, second); client.send('without callback'); const socket = await nextSocket(); await readRequest(socket); if (outcome === 'peer close') { socket.destroy(); } else { client[outcome](); const whileClosing = vi.fn(); client.send('while closing', whileClosing); expect(whileClosing).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); } await events.waitClose(); await new Promise((resolve) => setImmediate(resolve)); expect(first).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); expect(second).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); expect(client.readyState).toBe(WebSocketClient.CLOSED); expect(() => client.send('after close')).toThrow('WebSocket is not open'); client.close(); client.terminate(); await new Promise((resolve) => setImmediate(resolve)); expect(first).toHaveBeenCalledTimes(1); expect(second).toHaveBeenCalledTimes(1); expect(events.closes).toEqual([{ code: 1006, reason: '' }]); }, ); it('passes the original connection error to queued send callbacks', async () => { const port = await unusedPort(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const callback = vi.fn(); client.send('queued', callback); await events.waitClose(); expect(events.errors[0]).toHaveProperty('code', 'ECONNREFUSED'); expect(callback).toHaveBeenCalledExactlyOnceWith(events.errors[0]); expect(events.closes).toHaveLength(1); }); it('settles the pre-open queue if an open listener immediately terminates the connection', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const first = vi.fn(); const second = vi.fn(); client.send('without callback'); client.send('first', first); client.send('second', second); client.once('open', () => client.terminate()); const socket = await nextSocket(); const req = await readRequest(socket); socket.write(handshakeResponse(req.headers['sec-websocket-key']!)); await events.waitClose(); expect(first).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); expect(second).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); expect(events.closes).toHaveLength(1); }); it('delegates send/ping/pong/close after open and forwards control events', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); const frames = collectFrames(socket); socket.write(handshakeResponse(req.headers['sec-websocket-key']!)); await events.waitOpen(); const sent = new Promise((resolve) => { client.send('after-open', (err) => resolve(err)); }); expect(await frames.next()).toEqual({ opcode: 0x1, payload: Buffer.from('after-open') }); expect(await sent).toBeFalsy(); client.ping('hi'); expect(await frames.next()).toEqual({ opcode: 0x9, payload: Buffer.from('hi') }); client.pong(Buffer.from('xy')); expect(await frames.next()).toEqual({ opcode: 0xa, payload: Buffer.from('xy') }); socket.write(serverFrame(0x9, Buffer.from('srv-ping'))); expect(await events.waitPing()).toEqual(Buffer.from('srv-ping')); expect(await frames.next()).toEqual({ opcode: 0xa, payload: Buffer.from('srv-ping') }); socket.write(serverFrame(0xa, Buffer.from('srv-pong'))); expect(await events.waitPong()).toEqual(Buffer.from('srv-pong')); client.close(1000, 'done'); expect(client.readyState).toBe(2); const close = await frames.next(); expect(close.opcode).toBe(0x8); expect(close.payload.readUInt16BE(0)).toBe(1000); expect(close.payload.subarray(2).toString('utf8')).toBe('done'); socket.destroy(); expect(await events.waitClose()).toEqual({ code: 1006, reason: '' }); }); it('forwards a peer close code and reason', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); socket.write(handshakeResponse(req.headers['sec-websocket-key']!)); await events.waitOpen(); const reason = Buffer.from('bye'); const payload = Buffer.alloc(2 + reason.length); payload.writeUInt16BE(1001, 0); reason.copy(payload, 2); socket.write(serverFrame(0x8, payload)); socket.end(); expect(await events.waitClose()).toEqual({ code: 1001, reason: 'bye' }); }); it('rejects invalid close codes before touching the socket', async () => { const { port } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); expect(() => client.close(1005)).toThrowError(new RangeError('Invalid close code: 1005')); expect(() => client.close(1006)).toThrowError(new RangeError('Invalid close code: 1006')); expect(() => client.close(2500)).toThrowError(new RangeError('Invalid close code: 2500')); expect(client.readyState).toBe(0); }); it('destroys the TCP socket when close() is called before the handshake finishes', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); // The handshake request may already be sitting unread. A paused socket // with buffered data does not emit 'close' after the peer destroy()s. socket.resume(); const socketClosed = new Promise((resolve) => socket.once('close', () => resolve())); client.close(1000, 'ignored-before-open'); await socketClosed; expect(await events.waitClose()).toEqual({ code: 1006, reason: '' }); expect(client.readyState).toBe(3); }); it('destroys the TCP socket when terminate() is called before the handshake finishes', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); socket.resume(); const socketClosed = new Promise((resolve) => socket.once('close', () => resolve())); client.terminate(); await socketClosed; expect(await events.waitClose()).toEqual({ code: 1006, reason: '' }); expect(client.readyState).toBe(3); }); it('terminates an open connection without a close frame', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); socket.write(handshakeResponse(req.headers['sec-websocket-key']!)); await events.waitOpen(); client.terminate(); expect(() => client.send('nope')).toThrowError(new Error('WebSocket is not open')); expect(await events.waitClose()).toEqual({ code: 1006, reason: '' }); }); it('forwards a socket error after the handshake', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); socket.write(handshakeResponse(req.headers['sec-websocket-key']!)); await events.waitOpen(); // destroy(err) emits only on this socket. RST the peer so the client // sees a real TCP error (ECONNRESET) after the handshake listeners move. socket.resetAndDestroy(); const err = await events.waitError(); expect(err).toBeInstanceOf(Error); expect((err as NodeJS.ErrnoException).code).toBe('ECONNRESET'); expect(await events.waitClose()).toEqual({ code: 1006, reason: '' }); }); it('emits the TCP connect error and then a 1006 close', async () => { const port = await unusedPort(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const err = await events.waitError(); expect((err as NodeJS.ErrnoException).code).toBe('ECONNREFUSED'); expect(await events.waitClose()).toEqual({ code: 1006, reason: '' }); expect(client.readyState).toBe(3); }); it('emits 1006 when the peer closes before the handshake', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`); const events = track(client); const socket = await nextSocket(); socket.destroy(); expect(await events.waitClose()).toEqual({ code: 1006, reason: '' }); expect(client.readyState).toBe(3); }); it('forwards maxPayload into the post-handshake socket', async () => { const { port, nextSocket } = await listenPlain(); const client = createClient(`ws://127.0.0.1:${port}`, { maxPayload: 4 }); const events = track(client); const socket = await nextSocket(); const req = await readRequest(socket); socket.write( handshakeResponse( req.headers['sec-websocket-key']!, [], serverFrame(0x1, Buffer.from('12345')), ), ); const err = await events.waitError(); expect(err.message).toBe( 'WebSocket protocol error: Payload exceeds configured max: 5 B text frame exceeds the 4 byte limit (maxPayloadBytes)', ); expect((err as Error & { code?: number }).code).toBe(1009); }); it('completes a wss handshake and auto-sets SNI from a non-IP hostname', async () => { const { port, nextSocket } = await listenTls(); const client = createClient('wss://sni.example.test:443/secure', { socketOptions: { host: '127.0.0.1', port, rejectUnauthorized: false, }, }); const events = track(client); const socket = await nextSocket(); expect(socket.servername).toBe('sni.example.test'); const req = await readRequest(socket); expect(req.requestLine).toBe('GET /secure HTTP/1.1'); expect(req.headers.host).toBe('sni.example.test'); socket.write(handshakeResponse(req.headers['sec-websocket-key']!)); await events.waitOpen(); expect(client.readyState).toBe(1); }); it('does not overwrite an explicit TLS servername', async () => { const { port, nextSocket } = await listenTls(); createClient('wss://sni.example.test:443/', { socketOptions: { host: '127.0.0.1', port, rejectUnauthorized: false, servername: 'explicit.example', }, }); const socket = await nextSocket(); expect(socket.servername).toBe('explicit.example'); }); it('does not invent SNI for an IPv4 wss URL and includes a non-default port in Host', async () => { const { port, nextSocket } = await listenTls(); const client = createClient(`wss://127.0.0.1:${port}/`, { socketOptions: { rejectUnauthorized: false }, }); const events = track(client); const socket = await nextSocket(); expect(socket.servername || '').toBe(''); const req = await readRequest(socket); expect(req.headers.host).toBe(`127.0.0.1:${port}`); socket.write(handshakeResponse(req.headers['sec-websocket-key']!)); await events.waitOpen(); expect(client.readyState).toBe(1); }); });