import { EventEmitter } from 'node:events'; import type { Socket } from 'node:net'; import zlib from 'node:zlib'; import { afterEach, describe, expect, it } from 'vitest'; import { compressRaw } from '../src/extensions'; import native from '../src/native'; import { CLOSED, CLOSING, CONNECTING, DEFAULT_MAX_PAYLOAD_BYTES, OPEN, OP_BIN, OP_CLOSE, OP_CONT, OP_PING, OP_PONG, OP_TEXT, RSV1, WebSocket, encodeClosePayload, isValidCloseCode, randomMaskKey, } from '../src/websocket'; const MASK = Buffer.from([0x11, 0x22, 0x33, 0x44]); const ZLIB_TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]); const DEFLATE = { enabled: true as const, requestNoContextTakeover: true, responseNoContextTakeover: true, threshold: 1024, }; let nativeIo = true; try { native.buildFrame(OP_TEXT, true, Buffer.from([0x61]), null, 0); } catch (err) { nativeIo = !String((err as Error).message).includes('native addon is stubbed'); } class FakeSocket extends EventEmitter { readonly written: Buffer[] = []; ended = false; endedPayload: Buffer | undefined; destroyed = false; throwOnEnd = false; throwOnDestroy = false; autoCloseOnEnd = false; write(chunk: Buffer, cb?: (err?: Error | null) => void): boolean { this.written.push(Buffer.from(chunk)); cb?.(null); return true; } end(chunk?: Buffer): this { if (this.throwOnEnd) { throw new Error('end failed'); } if (chunk !== undefined) { this.endedPayload = Buffer.from(chunk); } this.ended = true; if (this.autoCloseOnEnd) { this.emit('end'); this.emit('close'); } return this; } destroy(): this { if (this.throwOnDestroy) { throw new Error('destroy failed'); } this.destroyed = true; return this; } } const sockets: FakeSocket[] = []; function createWs( options?: ConstructorParameters[1], socket = new FakeSocket(), ): { ws: WebSocket; socket: FakeSocket } { sockets.push(socket); return { ws: new WebSocket(socket as unknown as Socket, options), socket }; } function clientFrame(opcode: number, payload: Buffer, fin = true, rsv = 0): Buffer { return native.buildFrame(opcode, fin, payload, MASK, rsv); } function serverFrame(opcode: number, payload: Buffer, fin = true, rsv = 0): Buffer { return native.buildFrame(opcode, fin, payload, null, rsv); } /** A masked header declaring `payloadBytes` with no payload — the parser rejects * on the declared length before buffering any payload. */ function maskedOversizedHeader(opcode: number, payloadBytes: number): Buffer { const header = Buffer.alloc(14); header[0] = 0x80 | opcode; header[1] = 0xff; // masked + 64-bit length header.writeUInt32BE(Math.floor(payloadBytes / 2 ** 32), 2); header.writeUInt32BE(payloadBytes % 2 ** 32, 6); MASK.copy(header, 10); return header; } function parseFrames( buffers: Array, expectMasked: boolean, ): Array<{ fin: boolean; opcode: number; payload: Buffer; rsv: number; }> { const parser = new native.Parser({ isServer: expectMasked, maxPayload: 100 * 1024 * 1024, allowedRsv: 0x70, }); const frames: Array<{ fin: boolean; opcode: number; payload: Buffer; rsv: number }> = []; for (const buf of buffers) { if (!buf) { continue; } const res = parser.push(buf); if (res.error) { throw new Error(res.message ?? 'parse failed'); } for (const frame of res.frames) { frames.push({ fin: frame.fin, opcode: frame.opcode, payload: frame.payload, rsv: frame.rsv ?? 0, }); } } return frames; } function inflateRawError(data: Buffer): Error & { code?: string } { try { zlib.inflateRawSync(Buffer.concat([data, ZLIB_TRAILER]), { finishFlush: zlib.constants.Z_SYNC_FLUSH, }); } catch (err) { return err as Error & { code?: string }; } throw new Error('expected inflateRawSync to fail'); } afterEach(() => { for (const socket of sockets) { socket.emit('close'); } sockets.length = 0; }); describe('opcode and ready-state constants', () => { it('exports the RFC 6455 opcodes and ready-state numbers', () => { expect(OP_CONT).toBe(0x0); expect(OP_TEXT).toBe(0x1); expect(OP_BIN).toBe(0x2); expect(OP_CLOSE).toBe(0x8); expect(OP_PING).toBe(0x9); expect(OP_PONG).toBe(0xa); expect(RSV1).toBe(0x40); expect(CONNECTING).toBe(0); expect(OPEN).toBe(1); expect(CLOSING).toBe(2); expect(CLOSED).toBe(3); expect(WebSocket.CONNECTING).toBe(0); expect(WebSocket.OPEN).toBe(1); expect(WebSocket.CLOSING).toBe(2); expect(WebSocket.CLOSED).toBe(3); }); }); describe('isValidCloseCode', () => { it('accepts the RFC registered application codes and the 3000–4999 range', () => { for (const code of [ 1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011, 3000, 3001, 3999, 4000, 4999, ]) { expect(isValidCloseCode(code)).toBe(true); } }); it('rejects reserved, unused, and out-of-range codes', () => { for (const code of [ 0, 999, 1004, 1005, 1006, 1012, 1013, 1014, 1015, 1016, 2000, 2999, 5000, 65535, -1, ]) { expect(isValidCloseCode(code)).toBe(false); } }); }); describe('encodeClosePayload', () => { it('returns an empty buffer when the code is omitted', () => { expect(encodeClosePayload(undefined)).toEqual(Buffer.alloc(0)); expect(encodeClosePayload(null)).toEqual(Buffer.alloc(0)); expect(encodeClosePayload(1000, '')).toEqual(Buffer.from([0x03, 0xe8])); }); it('rejects a reason without a close code', () => { expect(() => encodeClosePayload(undefined, 'bye')).toThrow( 'Cannot send close reason without a code', ); expect(() => encodeClosePayload(null, 'bye')).toThrow( 'Cannot send close reason without a code', ); }); it('writes a big-endian code and the UTF-8 reason', () => { expect(encodeClosePayload(1000)).toEqual(Buffer.from([0x03, 0xe8])); expect(encodeClosePayload(1001, 'bye')).toEqual( Buffer.from([0x03, 0xe9, 0x62, 0x79, 0x65]), ); expect(encodeClosePayload(4999, 'é')).toEqual(Buffer.from([0x13, 0x87, 0xc3, 0xa9])); expect(encodeClosePayload(0x1_0000)).toEqual(Buffer.from([0x00, 0x00])); }); }); describe('randomMaskKey', () => { it('returns a 4-byte Buffer', () => { const key = randomMaskKey(); expect(Buffer.isBuffer(key)).toBe(true); expect(key).toHaveLength(4); expect(randomMaskKey()).not.toEqual(key); }); }); describe('WebSocket constructor', () => { it('defaults to an unmasked client socket in the OPEN state', () => { const { ws } = createWs(); expect(ws.readyState).toBe(1); expect(ws.isServer).toBe(false); expect(ws.protocol).toBe(''); expect(ws.extensions).toBe(''); }); it('applies server, protocol, payload, ready-state, and deflate options', () => { const { ws } = createWs({ isServer: true, maxPayload: 32, readyState: CONNECTING, protocol: 'chat', extensions: { perMessageDeflate: DEFLATE }, }); expect(ws.readyState).toBe(0); expect(ws.isServer).toBe(true); expect(ws.protocol).toBe('chat'); expect(ws.extensions).toBe('permessage-deflate'); }); it('treats a missing options object as empty', () => { const { ws } = createWs(undefined); expect(ws.readyState).toBe(OPEN); expect(ws.isServer).toBe(false); expect(ws.extensions).toBe(''); }); }); describe.skipIf(!nativeIo)('WebSocket send/ping/pong/close', () => { it('sends a UTF-8 text frame for a string', () => { const { ws, socket } = createWs({ isServer: true }); ws.send('hi'); expect(parseFrames(socket.written, false)).toEqual([ { fin: true, opcode: 0x1, payload: Buffer.from([0x68, 0x69]), rsv: 0 }, ]); }); it('sends a string as binary when binary: true', () => { const { ws, socket } = createWs({ isServer: true }); ws.send('hi', { binary: true }); expect(parseFrames(socket.written, false)[0]).toMatchObject({ opcode: 0x2, payload: Buffer.from([0x68, 0x69]), }); }); it('sends a Buffer as binary unless binary: false', () => { const { ws, socket } = createWs({ isServer: true }); ws.send(Buffer.from([0x01, 0x02])); ws.send(Buffer.from([0x61]), { binary: false }); const frames = parseFrames(socket.written, false); expect(frames[0]).toMatchObject({ opcode: 0x2, payload: Buffer.from([0x01, 0x02]) }); expect(frames[1]).toMatchObject({ opcode: 0x1, payload: Buffer.from([0x61]) }); }); it('sends a Uint8Array view using byteOffset/byteLength', () => { const { ws, socket } = createWs({ isServer: true }); const view = new Uint8Array([0x00, 0x09, 0x08, 0x07, 0x00]).subarray(1, 4); ws.send(view); expect(parseFrames(socket.written, false)[0]).toMatchObject({ opcode: 0x2, payload: Buffer.from([0x09, 0x08, 0x07]), }); }); it('sends an ArrayBuffer as binary unless binary: false', () => { const { ws, socket } = createWs({ isServer: true }); ws.send(new Uint8Array([0x04, 0x05, 0x06]).buffer); ws.send(new Uint8Array([0x7a]).buffer, { binary: false }); const frames = parseFrames(socket.written, false); expect(frames[0]).toMatchObject({ opcode: 0x2, payload: Buffer.from([0x04, 0x05, 0x06]) }); expect(frames[1]).toMatchObject({ opcode: 0x1, payload: Buffer.from([0x7a]) }); }); it('accepts the callback as the second argument', () => { const { ws } = createWs({ isServer: true }); const seen: Array = []; ws.send('ok', (err) => { seen.push(err); }); expect(seen).toEqual([null]); }); it('passes the callback through with send options', () => { const { ws, socket } = createWs({ isServer: true }); const seen: Array = []; ws.send('ok', { binary: false }, (err) => { seen.push(err); }); expect(seen).toEqual([null]); expect(parseFrames(socket.written, false)[0]!.opcode).toBe(0x1); }); it('throws when send() is used while not OPEN', () => { const { ws } = createWs({ isServer: true, readyState: CONNECTING }); expect(() => ws.send('x')).toThrow('WebSocket is not open'); }); it('delivers the not-open error to the send callback without throwing', () => { const { ws } = createWs({ isServer: true, readyState: CLOSING }); const seen: Array = []; ws.send('x', (err) => { seen.push(err); }); expect(seen).toHaveLength(1); expect(seen[0]).toBeInstanceOf(Error); expect(seen[0]!.message).toBe('WebSocket is not open'); }); it('rejects unsupported send() data types', () => { const { ws } = createWs({ isServer: true }); expect(() => ws.send(1 as unknown as string)).toThrow(TypeError); expect(() => ws.send(1 as unknown as string)).toThrow('Unsupported data type for send()'); const seen: Array = []; ws.send({} as unknown as Buffer, (err) => { seen.push(err); }); expect(seen).toHaveLength(1); expect(seen[0]).toBeInstanceOf(TypeError); expect(seen[0]!.message).toBe('Unsupported data type for send()'); }); it('masks client frames and leaves server frames unmasked', () => { const server = createWs({ isServer: true }); server.ws.send('a'); expect(server.socket.written[0]![1]! & 0x80).toBe(0); const client = createWs({ isServer: false }); client.ws.send('a'); expect(client.socket.written[0]![1]! & 0x80).toBe(0x80); expect(parseFrames(client.socket.written, true)[0]).toMatchObject({ opcode: 0x1, payload: Buffer.from([0x61]), }); }); it('compresses payloads at or above the deflate threshold', () => { const { ws, socket } = createWs({ isServer: true, extensions: { perMessageDeflate: DEFLATE }, }); const body = Buffer.alloc(1024, 0x61); ws.send(body); const frame = parseFrames(socket.written, false)[0]!; expect(frame.opcode).toBe(0x2); expect(frame.rsv).toBe(0x40); expect(frame.payload.equals(body)).toBe(false); expect( zlib.inflateRawSync(Buffer.concat([frame.payload, ZLIB_TRAILER]), { finishFlush: zlib.constants.Z_SYNC_FLUSH, }), ).toEqual(body); }); it('uses 1024 as the compress threshold when the negotiated value is missing', () => { const { ws, socket } = createWs({ isServer: true, extensions: { perMessageDeflate: { enabled: true, requestNoContextTakeover: true, responseNoContextTakeover: true, } as typeof DEFLATE, }, }); ws.send(Buffer.alloc(1023, 0x62)); ws.send(Buffer.alloc(1024, 0x62)); const frames = parseFrames(socket.written, false); expect(frames[0]!.rsv).toBe(0); expect(frames[0]!.payload).toEqual(Buffer.alloc(1023, 0x62)); expect(frames[1]!.rsv).toBe(0x40); }); it('skips compression when compress is false or the payload is under the threshold', () => { const { ws, socket } = createWs({ isServer: true, extensions: { perMessageDeflate: { ...DEFLATE, threshold: 8 } }, }); ws.send(Buffer.alloc(8, 0x63), { compress: false }); ws.send(Buffer.alloc(7, 0x63)); const frames = parseFrames(socket.written, false); expect(frames[0]).toMatchObject({ rsv: 0, payload: Buffer.alloc(8, 0x63) }); expect(frames[1]).toMatchObject({ rsv: 0, payload: Buffer.alloc(7, 0x63) }); }); it('does not compress when permessage-deflate was not negotiated', () => { const { ws, socket } = createWs({ isServer: true }); ws.send(Buffer.alloc(2048, 0x64), { compress: true }); expect(parseFrames(socket.written, false)[0]!.rsv).toBe(0); }); it('sends ping and pong control frames', () => { const { ws, socket } = createWs({ isServer: true }); ws.ping(); ws.ping('ab'); ws.ping(Buffer.from([0x01])); ws.pong(); ws.pong('cd'); ws.pong(Buffer.from([0x02])); const frames = parseFrames(socket.written, false); expect(frames).toEqual([ { fin: true, opcode: 0x9, payload: Buffer.alloc(0), rsv: 0 }, { fin: true, opcode: 0x9, payload: Buffer.from([0x61, 0x62]), rsv: 0 }, { fin: true, opcode: 0x9, payload: Buffer.from([0x01]), rsv: 0 }, { fin: true, opcode: 0xa, payload: Buffer.alloc(0), rsv: 0 }, { fin: true, opcode: 0xa, payload: Buffer.from([0x63, 0x64]), rsv: 0 }, { fin: true, opcode: 0xa, payload: Buffer.from([0x02]), rsv: 0 }, ]); }); it('accepts a callback as the first ping/pong argument', () => { const { ws } = createWs({ isServer: true }); const seen: Array = []; ws.ping((err) => { seen.push(err); }); ws.pong((err) => { seen.push(err); }); expect(seen).toEqual([null, null]); }); it('accepts ping/pong callbacks after the unused mask argument', () => { const { ws, socket } = createWs({ isServer: true }); const seen: Array = []; ws.ping('z', undefined, (err) => { seen.push(err); }); ws.pong('z', undefined, (err) => { seen.push(err); }); expect(seen).toEqual([null, null]); expect(parseFrames(socket.written, false).map((f) => f.opcode)).toEqual([0x9, 0xa]); }); it('rejects ping/pong payloads larger than 125 bytes before checking readyState', () => { const { ws } = createWs({ isServer: true, readyState: CLOSED }); expect(() => ws.ping(Buffer.alloc(126))).toThrow('Ping payload must be <=125 bytes'); expect(() => ws.pong('x'.repeat(126))).toThrow('Pong payload must be <=125 bytes'); expect(() => ws.ping(Buffer.alloc(125))).toThrow('WebSocket is not open'); expect(() => ws.pong(Buffer.alloc(125))).toThrow('WebSocket is not open'); }); it('delivers the not-open error to ping/pong callbacks', () => { const { ws } = createWs({ isServer: true, readyState: CONNECTING }); const seen: Error[] = []; ws.ping(Buffer.from('a'), undefined, (err) => { if (err) { seen.push(err); } }); ws.pong('a', undefined, (err) => { if (err) { seen.push(err); } }); expect(seen.map((err) => err.message)).toEqual([ 'WebSocket is not open', 'WebSocket is not open', ]); }); it('writes a close frame, then half-closes the socket', () => { const { ws, socket } = createWs({ isServer: true }); ws.close(1000, 'done'); expect(ws.readyState).toBe(2); expect(parseFrames(socket.written, false)).toEqual([ { fin: true, opcode: 0x8, payload: Buffer.from([0x03, 0xe8, 0x64, 0x6f, 0x6e, 0x65]), rsv: 0, }, ]); expect(socket.ended).toBe(true); expect(socket.endedPayload).toBeUndefined(); }); it('allows a 123-byte close reason and rejects 124 bytes', () => { const ok = createWs({ isServer: true }); ok.ws.close(1001, 'n'.repeat(123)); expect(parseFrames(ok.socket.written, false)[0]!.payload).toEqual( Buffer.concat([Buffer.from([0x03, 0xe9]), Buffer.from('n'.repeat(123))]), ); const tooLong = createWs({ isServer: true }); expect(() => tooLong.ws.close(1000, 'n'.repeat(124))).toThrow(RangeError); expect(() => tooLong.ws.close(1000, 'é'.repeat(62))).toThrow( 'Close reason must be <=123 bytes', ); expect(tooLong.socket.written).toEqual([]); }); it('rejects invalid close codes and ignores close() after CLOSING/CLOSED', () => { const open = createWs({ isServer: true }); expect(() => open.ws.close(1005)).toThrow(RangeError); expect(() => open.ws.close(1006)).toThrow('Invalid close code: 1006'); expect(() => open.ws.close(0)).toThrow('Invalid close code: 0'); expect(open.socket.written).toEqual([]); open.ws.close(); expect(open.ws.readyState).toBe(CLOSING); expect(parseFrames(open.socket.written, false)[0]!.payload).toEqual(Buffer.alloc(0)); open.ws.close(1000, 'again'); expect(open.socket.written).toHaveLength(1); const closed = createWs({ isServer: true, readyState: CLOSED }); closed.ws.close(1000); expect(closed.socket.written).toEqual([]); }); it('masks a client close frame', () => { const { ws, socket } = createWs({ isServer: false }); ws.close(3000); expect(socket.written[0]![1]! & 0x80).toBe(0x80); expect(parseFrames(socket.written, true)[0]!.payload).toEqual(Buffer.from([0x0b, 0xb8])); }); it('terminate() force-destroys the socket while leaving the state CLOSING', () => { const { ws, socket } = createWs({ isServer: true }); ws.terminate(); expect(ws.readyState).toBe(2); expect(socket.destroyed).toBe(true); expect(socket.written).toEqual([]); }); it('swallows destroy() failures from terminate()', () => { const { ws, socket } = createWs({ isServer: true }); socket.throwOnDestroy = true; expect(() => ws.terminate()).not.toThrow(); expect(ws.readyState).toBe(CLOSING); }); it('swallows end() failures from close()', () => { const { ws, socket } = createWs({ isServer: true }); socket.throwOnEnd = true; expect(() => ws.close(1000)).not.toThrow(); expect(ws.readyState).toBe(CLOSING); expect(socket.written).toHaveLength(1); }); }); describe.skipIf(!nativeIo)('WebSocket incoming frames', () => { it('emits a complete text message and a complete binary message', () => { const { ws, socket } = createWs({ isServer: true }); const messages: Array<{ data: Buffer; isBinary: boolean }> = []; ws.on('message', (data, isBinary) => { messages.push({ data, isBinary }); }); socket.emit('data', clientFrame(OP_TEXT, Buffer.from('hello'))); socket.emit('data', clientFrame(OP_BIN, Buffer.from([0xde, 0xad]))); expect(messages).toEqual([ { data: Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f]), isBinary: false }, { data: Buffer.from([0xde, 0xad]), isBinary: true }, ]); }); it('reassembles fragmented messages, including an empty continuation', () => { const { ws, socket } = createWs({ isServer: true }); const messages: Array<{ text: string; isBinary: boolean }> = []; ws.on('message', (data, isBinary) => { messages.push({ text: data.toString('utf8'), isBinary }); }); socket.emit('data', clientFrame(OP_TEXT, Buffer.from('he'), false)); socket.emit('data', clientFrame(OP_CONT, Buffer.alloc(0), false)); socket.emit('data', clientFrame(OP_CONT, Buffer.from('llo'), true)); socket.emit( 'data', Buffer.concat([ clientFrame(OP_BIN, Buffer.from([0x01]), false), clientFrame(OP_CONT, Buffer.from([0x02, 0x03]), true), ]), ); expect(messages).toEqual([ { text: 'hello', isBinary: false }, { text: '\u0001\u0002\u0003', isBinary: true }, ]); }); it('accepts valid 2/3/4-byte UTF-8 text', () => { const { ws, socket } = createWs({ isServer: true }); const seen: string[] = []; ws.on('message', (data) => { seen.push(data.toString('utf8')); }); socket.emit('data', clientFrame(OP_TEXT, Buffer.from([0xc2, 0xa2]))); socket.emit('data', clientFrame(OP_TEXT, Buffer.from([0xe2, 0x82, 0xac]))); socket.emit('data', clientFrame(OP_TEXT, Buffer.from([0xf0, 0x9d, 0x84, 0x9e]))); expect(seen).toEqual(['¢', '€', '𝄞']); }); it('fails invalid UTF-8 text with 1007', () => { const cases: Array<{ name: string; payload: Buffer }> = [ { name: 'lone 0xFF', payload: Buffer.from([0xff]) }, { name: 'incomplete 2-byte', payload: Buffer.from([0xc2]) }, { name: 'overlong 2-byte', payload: Buffer.from([0xc0, 0x80]) }, { name: 'overlong 3-byte', payload: Buffer.from([0xe0, 0x80, 0x80]) }, { name: 'UTF-16 surrogate', payload: Buffer.from([0xed, 0xa0, 0x80]) }, { name: 'out-of-range 4-byte lead', payload: Buffer.from([0xf5, 0x80, 0x80, 0x80]) }, { name: 'lone continuation', payload: Buffer.from([0x80]) }, ]; for (const testCase of cases) { const { ws, socket } = createWs({ isServer: true }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_TEXT, testCase.payload)); expect(errors, testCase.name).toHaveLength(1); expect(errors[0]!.message).toBe('WebSocket protocol error: Invalid UTF-8'); expect(errors[0]!.code).toBe(1007); expect(ws.readyState).toBe(CLOSING); } }); it('answers a ping with a pong while OPEN, and only emits ping while not OPEN', () => { const open = createWs({ isServer: true }); const pings: Buffer[] = []; open.ws.on('ping', (data) => { pings.push(data); }); open.socket.emit('data', clientFrame(OP_PING, Buffer.from('hi'))); expect(pings).toEqual([Buffer.from([0x68, 0x69])]); expect(parseFrames(open.socket.written, false)).toEqual([ { fin: true, opcode: 0xa, payload: Buffer.from([0x68, 0x69]), rsv: 0 }, ]); const closing = createWs({ isServer: true, readyState: CLOSING }); const ignored: Buffer[] = []; closing.ws.on('ping', (data) => { ignored.push(data); }); closing.socket.emit('data', clientFrame(OP_PING, Buffer.from('zz'))); expect(ignored).toEqual([Buffer.from([0x7a, 0x7a])]); expect(closing.socket.written).toEqual([]); }); it('emits pong payloads without writing a reply', () => { const { ws, socket } = createWs({ isServer: true }); const pongs: Buffer[] = []; ws.on('pong', (data) => { pongs.push(data); }); socket.emit('data', clientFrame(OP_PONG, Buffer.from([0xaa]))); expect(pongs).toEqual([Buffer.from([0xaa])]); expect(socket.written).toEqual([]); }); it('echoes a close code without the peer reason and then ends the socket', () => { const { ws, socket } = createWs({ isServer: true }); socket.emit('data', clientFrame(OP_CLOSE, Buffer.from([0x03, 0xe8, 0x62, 0x79, 0x65]))); expect(ws.readyState).toBe(CLOSING); expect(parseFrames(socket.written, false)).toEqual([ { fin: true, opcode: 0x8, payload: Buffer.from([0x03, 0xe8]), rsv: 0 }, ]); expect(socket.ended).toBe(true); const closes: Array<{ code: number; reason: string }> = []; ws.on('close', (code, reason) => { closes.push({ code, reason }); }); socket.emit('close'); expect(closes).toEqual([{ code: 1000, reason: 'bye' }]); }); it('treats an empty close payload as 1005 and echoes an empty close', () => { const { ws, socket } = createWs({ isServer: true }); socket.emit('data', clientFrame(OP_CLOSE, Buffer.alloc(0))); expect(parseFrames(socket.written, false)).toEqual([ { fin: true, opcode: 0x8, payload: Buffer.alloc(0), rsv: 0 }, ]); socket.emit('close'); expect(ws.readyState).toBe(CLOSED); }); it('accepts a UTF-8 close reason including non-ASCII', () => { const { ws, socket } = createWs({ isServer: true }); const closes: Array<{ code: number; reason: string }> = []; ws.on('close', (code, reason) => { closes.push({ code, reason }); }); socket.emit('data', clientFrame(OP_CLOSE, Buffer.from([0x03, 0xe8, 0xc3, 0xa9]))); socket.emit('close'); expect(closes).toEqual([{ code: 1000, reason: 'é' }]); }); it('fails a 1-byte close payload', () => { const { ws, socket } = createWs({ isServer: true }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_CLOSE, Buffer.from([0x00]))); expect(errors[0]!.message).toBe('WebSocket protocol error: Close payload length 1'); expect(errors[0]!.code).toBe(1002); expect(parseFrames([socket.endedPayload], false)[0]!.payload.subarray(0, 2)).toEqual( Buffer.from([0x03, 0xea]), ); }); it('fails reserved close codes', () => { const { ws, socket } = createWs({ isServer: true }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_CLOSE, Buffer.from([0x03, 0xed]))); expect(errors[0]!.message).toBe('WebSocket protocol error: Invalid close code'); expect(errors[0]!.code).toBe(1002); }); it('fails a close reason that is not valid UTF-8', () => { const { ws, socket } = createWs({ isServer: true }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_CLOSE, Buffer.from([0x03, 0xe8, 0xff]))); expect(errors[0]!.message).toBe('WebSocket protocol error: Invalid UTF-8 in close reason'); expect(errors[0]!.code).toBe(1007); }); it('does not echo a close received while CONNECTING', () => { const { ws, socket } = createWs({ isServer: true, readyState: CONNECTING }); socket.emit('data', clientFrame(OP_CLOSE, Buffer.from([0x03, 0xe8]))); expect(ws.readyState).toBe(CONNECTING); expect(socket.written).toEqual([]); expect(socket.ended).toBe(false); const closes: Array<{ code: number; reason: string }> = []; ws.on('close', (code, reason) => { closes.push({ code, reason }); }); socket.emit('close'); expect(closes).toEqual([{ code: 1000, reason: '' }]); }); it('ends the socket again when a close arrives already CLOSING', () => { const { ws, socket } = createWs({ isServer: true }); ws.close(1000); expect(socket.ended).toBe(true); socket.ended = false; socket.emit('data', clientFrame(OP_CLOSE, Buffer.from([0x03, 0xe8]))); expect(ws.readyState).toBe(CLOSING); expect(socket.ended).toBe(true); expect(socket.written).toHaveLength(1); }); it('stops the frame loop once a close handshake has already CLOSED the socket', () => { const { ws, socket } = createWs({ isServer: true }); socket.autoCloseOnEnd = true; const messages: Buffer[] = []; ws.on('message', (data) => { messages.push(data); }); const closes: number[] = []; ws.on('close', (code) => { closes.push(code); }); socket.emit( 'data', Buffer.concat([ clientFrame(OP_CLOSE, Buffer.from([0x03, 0xe8])), clientFrame(OP_TEXT, Buffer.from('late')), ]), ); expect(closes).toEqual([1000]); expect(messages).toEqual([]); expect(ws.readyState).toBe(CLOSED); }); it('fails a new data frame that starts before the previous fragment finishes', () => { const { ws, socket } = createWs({ isServer: true }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_TEXT, Buffer.from('ab'), false)); socket.emit('data', clientFrame(OP_BIN, Buffer.from('cd'), true)); expect(errors[0]!.message).toBe( 'WebSocket protocol error: New data frame started before previous fragmented message finished', ); expect(errors[0]!.code).toBe(1002); }); it('fails a continuation that has no active message', () => { const { ws, socket } = createWs({ isServer: true }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_CONT, Buffer.from('x'))); expect(errors[0]!.message).toBe( 'WebSocket protocol error: Continuation frame without active message', ); expect(errors[0]!.code).toBe(1002); }); it('fails RSV bits on a continuation after deflate was negotiated', () => { const { ws, socket } = createWs({ isServer: true, extensions: { perMessageDeflate: DEFLATE }, }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_TEXT, Buffer.from('ab'), false)); socket.emit('data', clientFrame(OP_CONT, Buffer.from('c'), true, RSV1)); expect(errors[0]!.message).toBe( 'WebSocket protocol error: RSV bits set on continuation frame', ); expect(errors[0]!.code).toBe(1002); }); it('fails when assembled fragments exceed maxPayload', () => { const { ws, socket } = createWs({ isServer: true, maxPayload: 10 }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_TEXT, Buffer.from('xxxxxx'), false)); socket.emit('data', clientFrame(OP_CONT, Buffer.from('xxxxx'), true)); expect(errors[0]!.message).toBe( 'WebSocket protocol error: Message too large: 11 B text message exceeds the 10 byte limit (maxPayloadBytes)', ); expect(errors[0]!.code).toBe(1009); }); it('fails a single frame that the parser rejects as larger than maxPayload', () => { const { ws, socket } = createWs({ isServer: true, maxPayload: 4 }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_TEXT, Buffer.from('hello'))); expect(errors[0]!.message).toBe( 'WebSocket protocol error: Payload exceeds configured max: 5 B text frame exceeds the 4 byte limit (maxPayloadBytes)', ); expect(errors[0]!.code).toBe(1009); }); it('reports the effective limit and frame size against the 100 MiB default', () => { const { ws, socket } = createWs({ isServer: true }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); const declared = 1024 * 1024 * 1024; socket.emit('data', maskedOversizedHeader(OP_TEXT, declared)); expect(DEFAULT_MAX_PAYLOAD_BYTES).toBe(100 * 1024 * 1024); expect(errors[0]!.message).toBe( `WebSocket protocol error: Payload exceeds configured max: ${declared} B text frame exceeds the ${DEFAULT_MAX_PAYLOAD_BYTES} byte limit (maxPayloadBytes)`, ); expect(errors[0]!.code).toBe(1009); }); it('names the oversized frame when a smaller frame shares the chunk', () => { const { ws, socket } = createWs({ isServer: true }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); const declared = 1024 * 1024 * 1024; // One TCP read: a complete 5-byte text frame followed by an oversized // header. The offending header is NOT at offset 0 of the chunk. socket.emit( 'data', Buffer.concat([ clientFrame(OP_TEXT, Buffer.from('hello')), maskedOversizedHeader(OP_TEXT, declared), ]), ); expect(errors[0]!.message).toBe( `WebSocket protocol error: Payload exceeds configured max: ${declared} B text frame exceeds the ${DEFAULT_MAX_PAYLOAD_BYTES} byte limit (maxPayloadBytes)`, ); expect(errors[0]!.code).toBe(1009); }); it('attributes an oversized frame whose header arrives in two chunks', () => { const { ws, socket } = createWs({ isServer: true }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); const declared = 1024 * 1024 * 1024; const header = maskedOversizedHeader(OP_TEXT, declared); socket.emit('data', header.subarray(0, 3)); expect(errors).toHaveLength(0); // incomplete header: parser waits socket.emit('data', header.subarray(3)); expect(errors[0]!.message).toBe( `WebSocket protocol error: Payload exceeds configured max: ${declared} B text frame exceeds the ${DEFAULT_MAX_PAYLOAD_BYTES} byte limit (maxPayloadBytes)`, ); expect(errors[0]!.code).toBe(1009); }); it.each([ { isServer: false, previousPayloadBytes: 1 }, { isServer: false, previousPayloadBytes: 126 }, { isServer: true, previousPayloadBytes: 1 }, { isServer: true, previousPayloadBytes: 126 }, ])('preserves a split header after delivering the preceding message: %j', (options) => { const { isServer, previousPayloadBytes } = options; const { ws, socket } = createWs({ isServer, maxPayload: 1024 }); const errors: Array = []; const messages: Buffer[] = []; ws.on('error', (err) => errors.push(err)); ws.on('message', (data) => messages.push(data)); const payload = Buffer.alloc(previousPayloadBytes, 0x61); const precedingFrame = isServer ? clientFrame(OP_TEXT, payload) : serverFrame(OP_TEXT, payload); const header = Buffer.from([0x82, isServer ? 0xfe : 0x7e, 0x82, 0x7e]); socket.emit('data', Buffer.concat([precedingFrame, header.subarray(0, 2)])); expect(messages).toEqual([payload]); expect(errors).toHaveLength(0); // The length bytes resemble a new frame header. Losing the buffered // prefix would invent a size from the mask or payload bytes that follow. socket.emit( 'data', Buffer.concat([ header.subarray(2), isServer ? MASK : Buffer.alloc(0), Buffer.from([0x20, 0x00]), ]), ); expect(errors[0]!.message).toBe( 'WebSocket protocol error: Payload exceeds configured max: 33406 B binary frame exceeds the 1024 byte limit (maxPayloadBytes)', ); expect(errors[0]!.code).toBe(1009); }); it('skips interleaved control frames while locating the oversized frame', () => { const { ws, socket } = createWs({ isServer: true }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); const declared = 1024 * 1024 * 1024; socket.emit( 'data', Buffer.concat([ clientFrame(OP_PING, Buffer.from('hb')), maskedOversizedHeader(OP_BIN, declared), ]), ); expect(errors[0]!.message).toBe( `WebSocket protocol error: Payload exceeds configured max: ${declared} B binary frame exceeds the ${DEFAULT_MAX_PAYLOAD_BYTES} byte limit (maxPayloadBytes)`, ); }); it('reports only the limit when the oversized header is outside the diagnostic budget', () => { const maxPayload = 1024 * 1024; const { ws, socket } = createWs({ isServer: true, maxPayload }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); // This single read exceeds the diagnostic budget before the parser // reports the error, so no reliable header is retained. socket.emit( 'data', Buffer.concat([ clientFrame(OP_TEXT, Buffer.alloc(96 * 1024, 0x61)), maskedOversizedHeader(OP_TEXT, 2 * 1024 * 1024), ]), ); expect(errors[0]!.message).toBe( 'WebSocket protocol error: Payload exceeds configured max: an inbound frame exceeds the 1048576 byte limit (maxPayloadBytes)', ); expect(errors[0]!.message).not.toMatch(/\d+ B /); expect(errors[0]!.code).toBe(1009); }); it('releases parsed fragments and control frames from the diagnostic budget', () => { const maxPayload = 1024 * 1024; const { ws, socket } = createWs({ isServer: true, maxPayload }); const errors: Error[] = []; ws.on('error', (err) => errors.push(err)); const fragment = Buffer.alloc(8 * 1024, 0x61); for (let i = 0; i < 12; i += 1) { socket.emit('data', clientFrame(i === 0 ? OP_TEXT : OP_CONT, fragment, i === 11)); socket.emit('data', clientFrame(OP_PING, Buffer.from('hb'))); } expect(errors).toHaveLength(0); socket.emit('data', maskedOversizedHeader(OP_BIN, 2 * 1024 * 1024)); expect(errors[0]!.message).toContain('2097152 B binary frame'); }); it('recovers diagnostics after a complete frame exceeds the diagnostic budget', () => { const { ws, socket } = createWs({ isServer: true, maxPayload: 1024 * 1024 }); const errors: Error[] = []; const messages: Buffer[] = []; ws.on('error', (err) => errors.push(err)); ws.on('message', (data) => messages.push(data)); const payload = Buffer.alloc(96 * 1024, 0x61); socket.emit('data', clientFrame(OP_TEXT, payload)); expect(messages).toEqual([payload]); expect(errors).toHaveLength(0); socket.emit('data', maskedOversizedHeader(OP_BIN, 2 * 1024 * 1024)); expect(errors[0]!.message).toContain('2097152 B binary frame'); }); it('keeps size unknown when a partial next header was discarded by the budget', () => { const { ws, socket } = createWs({ isServer: true, maxPayload: 1024 * 1024 }); const errors: Error[] = []; ws.on('error', (err) => errors.push(err)); const header = maskedOversizedHeader(OP_BIN, 2 * 1024 * 1024); socket.emit( 'data', Buffer.concat([ clientFrame(OP_TEXT, Buffer.alloc(96 * 1024, 0x61)), header.subarray(0, 2), ]), ); expect(errors).toHaveLength(0); socket.emit('data', header.subarray(2)); expect(errors[0]!.message).toBe( 'WebSocket protocol error: Payload exceeds configured max: an inbound frame exceeds the 1048576 byte limit (maxPayloadBytes)', ); }); it('names the message type when a binary frame exceeds the limit', () => { const { ws, socket } = createWs({ isServer: true, maxPayload: 4 }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_BIN, Buffer.from([1, 2, 3, 4, 5]))); expect(errors[0]!.message).toBe( 'WebSocket protocol error: Payload exceeds configured max: 5 B binary frame exceeds the 4 byte limit (maxPayloadBytes)', ); expect(errors[0]!.code).toBe(1009); }); it('accepts a message exactly at the configured limit', () => { const { ws, socket } = createWs({ isServer: true, maxPayload: 5 }); const messages: Array<{ data: Buffer; isBinary: boolean }> = []; ws.on('message', (data, isBinary) => { messages.push({ data, isBinary }); }); socket.emit('data', clientFrame(OP_TEXT, Buffer.from('hello'))); expect(messages).toEqual([{ data: Buffer.from('hello'), isBinary: false }]); }); it('truncates an over-long close reason on a code-point boundary', () => { const payload = encodeClosePayload(1009, 'é'.repeat(200)); expect(payload.length).toBeLessThanOrEqual(125); const reason = payload.subarray(2).toString('utf8'); expect(reason).toBe('é'.repeat(61)); expect(Buffer.from(reason, 'utf8').equals(payload.subarray(2))).toBe(true); }); it('truncates a 4-byte code point without splitting it', () => { const payload = encodeClosePayload(1009, '😀'.repeat(50)); expect(payload.length).toBe(122); // 2-byte code + 30 whole code points const reason = payload.subarray(2).toString('utf8'); expect(reason).toBe('😀'.repeat(30)); expect(Buffer.from(reason, 'utf8').equals(payload.subarray(2))).toBe(true); }); it('inflates a compressed text frame when deflate was negotiated', () => { const { ws, socket } = createWs({ isServer: true, extensions: { perMessageDeflate: DEFLATE }, }); const messages: Array<{ data: Buffer; isBinary: boolean }> = []; ws.on('message', (data, isBinary) => { messages.push({ data, isBinary }); }); const plain = Buffer.from('hello hello hello hello'); socket.emit('data', clientFrame(OP_TEXT, compressRaw(plain), true, RSV1)); expect(messages).toEqual([{ data: plain, isBinary: false }]); }); it('inflates a compressed binary frame', () => { const { ws, socket } = createWs({ isServer: true, extensions: { perMessageDeflate: { ...DEFLATE, threshold: 1 } }, }); const messages: Array<{ data: Buffer; isBinary: boolean }> = []; ws.on('message', (data, isBinary) => { messages.push({ data, isBinary }); }); const plain = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04]); socket.emit('data', clientFrame(OP_BIN, compressRaw(plain), true, RSV1)); expect(messages).toEqual([{ data: plain, isBinary: true }]); }); it('fails compressed bytes that zlib cannot inflate', () => { const { ws, socket } = createWs({ isServer: true, extensions: { perMessageDeflate: DEFLATE }, }); const garbage = Buffer.from([0x03, 0x01, 0x02, 0xff]); const cause = inflateRawError(garbage); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_TEXT, garbage, true, RSV1)); expect(errors[0]!.message).toBe(`WebSocket protocol error: ${cause.message}`); expect(errors[0]!.code).toBe(cause.code); expect(ws.readyState).toBe(CLOSING); }); it('maps an inflated payload that exceeds maxPayload to 1009', () => { const maxPayload = 32; const { ws, socket } = createWs({ isServer: true, maxPayload, extensions: { perMessageDeflate: DEFLATE }, }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_TEXT, compressRaw(Buffer.alloc(256, 0x61)), true, RSV1)); expect(errors[0]!.message).toBe( `WebSocket protocol error: Message too large after inflate (maxPayload=${maxPayload})`, ); expect(errors[0]!.code).toBe(1009); }); it('validates UTF-8 after inflate', () => { const { ws, socket } = createWs({ isServer: true, extensions: { perMessageDeflate: DEFLATE }, }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_TEXT, compressRaw(Buffer.from([0xff])), true, RSV1)); expect(errors[0]!.message).toBe('WebSocket protocol error: Invalid UTF-8'); expect(errors[0]!.code).toBe(1007); }); it('fails RSV1 when permessage-deflate was not negotiated', () => { const { ws, socket } = createWs({ isServer: true }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_TEXT, Buffer.from('x'), true, RSV1)); expect(errors[0]!.message).toBe('WebSocket protocol error: Unexpected RSV bits'); expect(errors[0]!.code).toBe(1002); }); it('fails unknown opcodes and unmasked client frames through the parser error path', () => { const unknown = createWs({ isServer: true }); const errors: Array = []; unknown.ws.on('error', (err) => { errors.push(err); }); unknown.socket.emit('data', clientFrame(0x3, Buffer.from('x'))); expect(errors[0]!.message).toBe('WebSocket protocol error: Unknown opcode'); expect(errors[0]!.code).toBe(1002); const unmasked = createWs({ isServer: true }); const more: Array = []; unmasked.ws.on('error', (err) => { more.push(err); }); unmasked.socket.emit('data', serverFrame(OP_TEXT, Buffer.from('x'))); expect(more[0]!.message).toBe( 'WebSocket protocol error: Expected masked frame from client', ); expect(more[0]!.code).toBe(1002); }); it('fails a masked frame delivered to a client socket', () => { const { ws, socket } = createWs({ isServer: false }); const errors: Array = []; ws.on('error', (err) => { errors.push(err); }); socket.emit('data', clientFrame(OP_TEXT, Buffer.from('x'))); expect(errors[0]!.message).toBe('WebSocket protocol error: Server must not mask frames'); expect(errors[0]!.code).toBe(1002); }); it('assembles a client-side message from unmasked server frames split across reads', () => { const { ws, socket } = createWs({ isServer: false }); const messages: Buffer[] = []; ws.on('message', (data) => { messages.push(data); }); const frame = serverFrame(OP_TEXT, Buffer.from('abc')); socket.emit('data', frame.subarray(0, 2)); expect(messages).toEqual([]); socket.emit('data', frame.subarray(2)); expect(messages).toEqual([Buffer.from([0x61, 0x62, 0x63])]); }); it('ignores data after the socket has already CLOSED', () => { const { ws, socket } = createWs({ isServer: true, readyState: CLOSED }); const errors: Error[] = []; const messages: Buffer[] = []; ws.on('error', (err) => { errors.push(err); }); ws.on('message', (data) => { messages.push(data); }); socket.emit('data', clientFrame(OP_TEXT, Buffer.from('x'))); expect(errors).toEqual([]); expect(messages).toEqual([]); }); }); describe.skipIf(!nativeIo)('WebSocket socket lifecycle', () => { it('maps a peer FIN without a close frame to 1006', () => { const { ws, socket } = createWs({ isServer: true }); const closes: Array<{ code: number; reason: string }> = []; ws.on('close', (code, reason) => { closes.push({ code, reason }); }); socket.emit('end'); expect(socket.destroyed).toBe(true); expect(ws.readyState).toBe(OPEN); socket.emit('close'); expect(ws.readyState).toBe(CLOSED); expect(closes).toEqual([{ code: 1006, reason: '' }]); }); it('keeps a previously received close code when the socket later ends', () => { const { ws, socket } = createWs({ isServer: true }); const closes: Array<{ code: number; reason: string }> = []; ws.on('close', (code, reason) => { closes.push({ code, reason }); }); socket.emit('data', clientFrame(OP_CLOSE, Buffer.from([0x03, 0xe9, 0x67, 0x6f]))); socket.emit('end'); socket.emit('close'); expect(closes).toEqual([{ code: 1001, reason: 'go' }]); }); it('does not emit a second close after the socket is already CLOSED', () => { const { ws, socket } = createWs({ isServer: true }); const closes: number[] = []; ws.on('close', (code) => { closes.push(code); }); socket.emit('close'); socket.emit('end'); socket.emit('close'); expect(closes).toEqual([1006]); expect(ws.readyState).toBe(CLOSED); }); it('forwards socket errors', () => { const { ws, socket } = createWs({ isServer: true }); const err = new Error('ECONNRESET'); const seen: Error[] = []; ws.on('error', (e) => { seen.push(e); }); socket.emit('error', err); expect(seen).toEqual([err]); }); it('swallows destroy() failures from a peer FIN', () => { const { ws, socket } = createWs({ isServer: true }); socket.throwOnDestroy = true; expect(() => socket.emit('end')).not.toThrow(); expect(ws.readyState).toBe(OPEN); }); it('swallows end() failures while failing the connection', () => { const { ws, socket } = createWs({ isServer: true }); socket.throwOnEnd = true; const errors: Error[] = []; ws.on('error', (err) => { errors.push(err); }); expect(() => socket.emit('data', clientFrame(0x3, Buffer.alloc(0)))).not.toThrow(); expect(errors[0]!.message).toBe('WebSocket protocol error: Unknown opcode'); expect(ws.readyState).toBe(CLOSING); }); });