// Tests the hand-rolled WebSocket codec directly, using a raw TCP client so // the low-level frames the browser API never exposes (ping/pong, fragmentation, // binary frames, close) can be exercised. import { connect } from "node:net"; import { randomBytes } from "node:crypto"; import { TestCase } from "./framework/test_case.js"; import { withServer } from "./framework/helpers.js"; const OP_CONTINUATION = 0x0; const OP_TEXT = 0x1; const OP_BINARY = 0x2; const OP_CLOSE = 0x8; const OP_PING = 0x9; function encodeFrame(opcode, payload, fin = true) { const mask = randomBytes(4); const length = payload.length; let header; if (length < 126) { header = Buffer.alloc(2); header[1] = 0x80 | length; } else if (length < 65536) { header = Buffer.alloc(4); header[1] = 0x80 | 126; header.writeUInt16BE(length, 2); } else { header = Buffer.alloc(10); header[1] = 0x80 | 127; header.writeBigUInt64BE(BigInt(length), 2); } header[0] = (fin ? 0x80 : 0) | opcode; const masked = Buffer.alloc(length); for (let i = 0; i < length; i++) masked[i] = payload[i] ^ mask[i & 3]; return Buffer.concat([header, mask, masked]); } function parseFrames(state) { while (true) { const buffer = state.buffer; if (buffer.length < 2) return; const b0 = buffer[0]; const b1 = buffer[1]; const fin = (b0 & 0x80) !== 0; const opcode = b0 & 0x0f; const masked = (b1 & 0x80) !== 0; let length = b1 & 0x7f; let offset = 2; if (length === 126) { if (buffer.length < 4) return; length = buffer.readUInt16BE(2); offset = 4; } else if (length === 127) { if (buffer.length < 10) return; length = Number(buffer.readBigUInt64BE(2)); offset = 10; } let maskKey = null; if (masked) { if (buffer.length < offset + 4) return; maskKey = buffer.subarray(offset, offset + 4); offset += 4; } if (buffer.length < offset + length) return; let payload = buffer.subarray(offset, offset + length); if (masked) { const out = Buffer.alloc(length); for (let i = 0; i < length; i++) out[i] = payload[i] ^ maskKey[i & 3]; payload = out; } state.buffer = buffer.subarray(offset + length); state.frames.push({ fin, opcode, payload }); } } function rawClient(port, path = "/ws") { const socket = connect(port, "127.0.0.1"); const state = { buffer: Buffer.alloc(0), frames: [] }; const client = { socket, frames: state.frames, handshakeComplete: false, responseHeaders: "", send: (buffer) => socket.write(buffer), nextFrame: (timeout = 2000) => new Promise((resolve, reject) => { const deadline = Date.now() + timeout; const tick = () => { parseFrames(state); if (state.frames.length > 0) { resolve(state.frames.shift()); return; } if (Date.now() > deadline) { reject(new Error("timed out waiting for a frame")); return; } setTimeout(tick, 5); }; tick(); }), closeRaw: () => socket.destroy(), }; client.ready = new Promise((resolve) => { const key = randomBytes(16).toString("base64"); socket.on("connect", () => { socket.write( `GET ${path} HTTP/1.1\r\n` + `Host: 127.0.0.1:${port}\r\n` + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + `Sec-WebSocket-Key: ${key}\r\n` + "Sec-WebSocket-Version: 13\r\n\r\n" ); }); socket.on("data", (chunk) => { state.buffer = Buffer.concat([state.buffer, chunk]); if (!client.handshakeComplete) { const text = state.buffer.toString("latin1"); const end = text.indexOf("\r\n\r\n"); if (end < 0) return; client.responseHeaders = text.slice(0, end); state.buffer = state.buffer.subarray(end + 4); client.handshakeComplete = true; resolve(); } parseFrames(state); }); }); client.closed = new Promise((resolve) => { socket.on("close", resolve); socket.on("end", resolve); }); socket.on("error", () => {}); return client; } export class WebSocketProtocolTest extends TestCase { async test_handshake_and_text_message() { await withServer(async (port) => { const client = rawClient(port); await client.ready; this.assertTrue(client.responseHeaders.startsWith("HTTP/1.1 101")); this.assertTrue(/sec-websocket-accept/i.test(client.responseHeaders)); client.send(encodeFrame(OP_TEXT, Buffer.from(JSON.stringify({ t: "request_games" })))); const frame = await client.nextFrame(); this.assertEqual(frame.opcode, OP_TEXT); this.assertEqual(JSON.parse(frame.payload.toString("utf8")).t, "game_list"); client.closeRaw(); }); } async test_wrong_path_is_rejected() { await withServer(async (port) => { const client = rawClient(port, "/nope"); await client.closed; this.assertFalse(client.handshakeComplete, "no upgrade on a foreign path"); client.closeRaw(); }); } async test_ping_gets_pong() { await withServer(async (port) => { const client = rawClient(port); await client.ready; client.send(encodeFrame(OP_PING, Buffer.from("hi"))); const frame = await client.nextFrame(); this.assertEqual(frame.opcode, 0xa); this.assertEqual(frame.payload.toString("utf8"), "hi"); client.closeRaw(); }); } async test_fragmented_text_is_reassembled() { await withServer(async (port) => { const client = rawClient(port); await client.ready; const payload = Buffer.from(JSON.stringify({ t: "request_games" })); const half = Math.floor(payload.length / 2); client.send(encodeFrame(OP_TEXT, payload.subarray(0, half), false)); client.send(encodeFrame(OP_CONTINUATION, payload.subarray(half), true)); const frame = await client.nextFrame(); this.assertEqual(JSON.parse(frame.payload.toString("utf8")).t, "game_list"); client.closeRaw(); }); } async test_binary_frame_is_delivered() { await withServer(async (port) => { const client = rawClient(port); await client.ready; client.send(encodeFrame(OP_BINARY, Buffer.from(JSON.stringify({ t: "request_games" })))); const frame = await client.nextFrame(); this.assertEqual(JSON.parse(frame.payload.toString("utf8")).t, "game_list"); client.closeRaw(); }); } async test_malformed_message_is_ignored() { await withServer(async (port) => { const client = rawClient(port); await client.ready; client.send(encodeFrame(OP_TEXT, Buffer.from("{not json"))); client.send(encodeFrame(OP_TEXT, Buffer.from(JSON.stringify({ t: "request_games" })))); const frame = await client.nextFrame(); this.assertEqual(JSON.parse(frame.payload.toString("utf8")).t, "game_list"); client.closeRaw(); }); } async test_medium_frame_uses_the_16_bit_length() { await withServer(async (port) => { const client = rawClient(port); await client.ready; // 300-ish bytes exercises the 16-bit length branch; trailing whitespace // keeps the padding valid JSON, so the server simply ignores the type. const payload = Buffer.from(JSON.stringify({ t: "noop" }) + " ".repeat(300)); client.send(encodeFrame(OP_TEXT, payload)); client.send(encodeFrame(OP_TEXT, Buffer.from(JSON.stringify({ t: "request_games" })))); const frame = await client.nextFrame(); this.assertEqual(JSON.parse(frame.payload.toString("utf8")).t, "game_list"); client.closeRaw(); }); } async test_large_frame_is_parsed() { await withServer(async (port) => { const client = rawClient(port); await client.ready; // Larger than 64 KiB, exercising the 64-bit length branch. The body is // not valid JSON, so the server ignores it but must stay usable. client.send(encodeFrame(OP_TEXT, Buffer.alloc(70000, 0x78))); client.send(encodeFrame(OP_TEXT, Buffer.from(JSON.stringify({ t: "request_games" })))); const frame = await client.nextFrame(); this.assertEqual(JSON.parse(frame.payload.toString("utf8")).t, "game_list"); client.closeRaw(); }); } async test_close_frame_is_answered() { await withServer(async (port) => { const client = rawClient(port); await client.ready; client.send(encodeFrame(OP_CLOSE, Buffer.alloc(0))); const frame = await client.nextFrame(); this.assertEqual(frame.opcode, OP_CLOSE); await client.closed; this.assertTrue(client.closed, "the server closed the socket"); client.closeRaw(); }); } }