/** * ws.mjs — 《围猎》模拟竞技场的 WebSocket 客户端(node:net 手写,零依赖)。 * 与 web/server/test/hunt-sim.test.mjs 的 B 组冒烟客户端同源,增加: * - 自动回 pong(服务器 15s ping,45s 无 pong 判死——对局长打必须应答) * - close 帧解析与自动重连占位(P09 断网重连走 arena 的 drop/resume) * - 发送队列(连接建立前的消息排队) * * 协议契约:docs/design/23-hunt.md §6.1。 */ import net from 'node:net'; import crypto from 'node:crypto'; const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; function frameFromClient(opcode, payload) { const mask = crypto.randomBytes(4); const len = payload.length; let header; if (len < 126) { header = Buffer.from([0x80 | opcode, 0x80 | len]); } else if (len < 65536) { header = Buffer.alloc(4); header[0] = 0x80 | opcode; header[1] = 0x80 | 126; header.writeUInt16BE(len, 2); } else { header = Buffer.alloc(10); header[0] = 0x80 | opcode; header[1] = 0x80 | 127; header.writeBigUInt64BE(BigInt(len), 2); } const masked = Buffer.from(payload); for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i & 3]; return Buffer.concat([header, mask, masked]); } function makeServerFrameParser(onFrame) { let buf = Buffer.alloc(0); return (chunk) => { buf = Buffer.concat([buf, chunk]); for (;;) { if (buf.length < 2) return; const b0 = buf[0]; const b1 = buf[1]; let len = b1 & 0x7f; let off = 2; if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4; } else if (len === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); off = 10; } if (buf.length < off + len) return; const payload = Buffer.from(buf.subarray(off, off + len)); buf = buf.subarray(off + len); onFrame({ opcode: b0 & 0x0f, payload }); } }; } /** * connect(port, { name }) → Promise * WsSession: { send(obj), close(), drop(), messages, on('message'|'close') } * drop():模拟断网——直接销毁 socket(不发 close 帧,等同 §6.6 断线路径)。 */ export function connect(port) { return new Promise((resolve, reject) => { const key = crypto.randomBytes(16).toString('base64'); const sock = net.connect(port, '127.0.0.1'); const session = new EventEmitterLite(); session.sock = sock; session.messages = []; session.send = (obj) => { if (sock.destroyed) return false; sock.write(frameFromClient(1, Buffer.from(JSON.stringify(obj)))); return true; }; session.close = () => { try { sock.write(frameFromClient(8, Buffer.alloc(0))); } catch (_) { /* 已死 */ } setTimeout(() => { try { sock.destroy(); } catch (_) { /* 已死 */ } }, 50); }; session.drop = () => { try { sock.destroy(); } catch (_) { /* 已死 */ } }; const parse = makeServerFrameParser((f) => { if (f.opcode === 9) { // ping → 自动 pong(保活,§6.1) try { sock.write(frameFromClient(10, f.payload)); } catch (_) { /* 已死 */ } return; } if (f.opcode === 8) { session.emit('close', f.payload.length >= 2 ? f.payload.readUInt16BE(0) : 1005); try { sock.destroy(); } catch (_) { /* 已死 */ } return; } if (f.opcode === 1) { let msg = null; try { msg = JSON.parse(f.payload.toString('utf8')); } catch (_) { /* 坏帧 */ } if (msg) { session.messages.push(msg); session.emit('message', msg); } } }); let handshake = Buffer.alloc(0); let upgraded = false; sock.on('data', (chunk) => { if (!upgraded) { handshake = Buffer.concat([handshake, chunk]); const idx = handshake.indexOf('\r\n\r\n'); if (idx === -1) return; const head = handshake.subarray(0, idx).toString('latin1'); if (!/^HTTP\/1\.1 101 /.test(head)) { reject(new Error('握手失败: ' + head.split('\r\n')[0])); sock.destroy(); return; } upgraded = true; const rest = handshake.subarray(idx + 4); if (rest.length) parse(rest); resolve(session); } else { parse(chunk); } }); sock.on('error', (e) => { if (!upgraded) reject(e); else session.emit('close', 1006); }); sock.on('close', () => { if (upgraded) session.emit('close', 1006); }); sock.write( 'GET /ws/hunt HTTP/1.1\r\n' + 'Host: 127.0.0.1\r\n' + 'Upgrade: websocket\r\n' + 'Connection: keep-alive, Upgrade\r\n' + `Sec-WebSocket-Key: ${key}\r\n` + 'Sec-WebSocket-Version: 13\r\n\r\n', ); }); } class EventEmitterLite { constructor() { this._l = new Map(); } on(ev, fn) { (this._l.get(ev) || this._l.set(ev, []).get(ev)).push(fn); return this; } emit(ev, ...args) { for (const fn of this._l.get(ev) || []) fn(...args); } }