/* A WebSocket client small enough to live here. Node 19 has no global WebSocket (it arrived in 22), and the recorder must not depend on an npm install — a mockup that needs one is a mockup nobody records. Text frames only, which is all the DevTools protocol uses; handles the 16- and 64-bit lengths a 200 KB screencast frame needs, continuation frames, and pings. Same surface as the browser's: onopen / onerror / onmessage({data}) / send. */ import http from 'node:http'; import crypto from 'node:crypto'; export class MiniWS { constructor(url) { this.onopen = null; this.onerror = null; this.onmessage = null; this.onclose = null; this.buf = Buffer.alloc(0); this.frag = []; this.sock = null; const u = new URL(url); const req = http.request({ host: u.hostname, port: u.port, path: u.pathname + u.search, method: 'GET', headers: { Connection: 'Upgrade', Upgrade: 'websocket', 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': crypto.randomBytes(16).toString('base64'), }, }); req.on('upgrade', (res, sock) => { this.sock = sock; sock.on('data', d => this.feed(d)); sock.on('error', e => this.onerror && this.onerror(e)); sock.on('close', () => this.onclose && this.onclose()); if (this.onopen) this.onopen(); }); req.on('response', res => { if (this.onerror) this.onerror(new Error('upgrade refused: HTTP ' + res.statusCode)); }); req.on('error', e => { if (this.onerror) this.onerror(e); }); req.end(); } feed(d) { this.buf = this.buf.length ? Buffer.concat([this.buf, d]) : d; for (;;) { const b = this.buf; if (b.length < 2) return; const fin = (b[0] & 0x80) !== 0, op = b[0] & 0x0f, masked = (b[1] & 0x80) !== 0; let len = b[1] & 0x7f, off = 2; if (len === 126) { if (b.length < 4) return; len = b.readUInt16BE(2); off = 4; } else if (len === 127) { if (b.length < 10) return; len = Number(b.readBigUInt64BE(2)); off = 10; } if (masked) off += 4; /* a server must not mask; tolerate it */ if (b.length < off + len) return; /* wait for the rest of the frame */ let payload = b.subarray(off, off + len); if (masked) { const m = b.subarray(off - 4, off); payload = Buffer.from(payload.map((v, i) => v ^ m[i & 3])); } this.buf = b.subarray(off + len); if (op === 0x8) { this.close(); return; } if (op === 0x9) { this.frame(0xA, payload); continue; } if (op === 0xA) continue; if (op === 0x1 || op === 0x2 || op === 0x0) { this.frag.push(payload); if (fin) { const msg = Buffer.concat(this.frag).toString('utf8'); this.frag = []; if (this.onmessage) this.onmessage({ data: msg }); } } } } frame(op, payload) { const mask = crypto.randomBytes(4); const n = payload.length; let head; if (n < 126) head = Buffer.from([0x80 | op, 0x80 | n]); else if (n < 65536) { head = Buffer.alloc(4); head[0] = 0x80 | op; head[1] = 0x80 | 126; head.writeUInt16BE(n, 2); } else { head = Buffer.alloc(10); head[0] = 0x80 | op; head[1] = 0x80 | 127; head.writeBigUInt64BE(BigInt(n), 2); } const body = Buffer.alloc(n); for (let i = 0; i < n; i++) body[i] = payload[i] ^ mask[i & 3]; this.sock.write(Buffer.concat([head, mask, body])); } send(s) { this.frame(0x1, Buffer.from(String(s), 'utf8')); } close() { if (this.sock) { this.sock.end(); this.sock = null; } } }