import { createHash } from "node:crypto"; import { createServer } from "node:http"; import { createServer as createNetServer } from "node:net"; export async function freePort() { const server = createNetServer(); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); const port = server.address().port; await new Promise(resolve => server.close(resolve)); return port; } // Accepts the WebSocket upgrade handshake so the frontend's real decoder-connect path // (src/decoder_runtime.js) reaches `connected: true` without a physical AMMC decoder. // sendPassing() pushes a PASSING frame for scenarios that need the app to see a transponder. export async function startFakeDecoder() { const port = await freePort(); const server = createServer(); // The upgraded sockets are intentionally left open (no decoder frames needed), so a // plain server.close() would wait forever for them to end; track and destroy them // explicitly instead. closeAllConnections() does not reach upgraded sockets. const sockets = new Set(); let totalConnections = 0; server.on("upgrade", (req, socket) => { totalConnections += 1; sockets.add(socket); socket.on("close", () => sockets.delete(socket)); const key = req.headers["sec-websocket-key"]; if (!key) { socket.destroy(); return; } const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64"); socket.write(["HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Accept: ${accept}`, "", ""].join("\r\n")); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", resolve); }); return { port, activeConnections: () => sockets.size, totalConnections: () => totalConnections, // Drops every currently-open socket with a raw TCP reset (not a clean WS close // handshake) to simulate a decoder/network interruption, while the server keeps // listening so the frontend's own reconnect logic has somewhere to reconnect to. dropConnections: () => { for (const socket of sockets) socket.destroy(); }, // Sends an AMMC-style PASSING message as an unmasked server->client WebSocket text frame. // `at` (ms) sets the decoder's own passing time, for exact lap times in a test. sendPassing: (transponder, at = Date.now()) => { const payload = Buffer.from(JSON.stringify({ msg: "PASSING", transponder: String(transponder), rtc_time: new Date(at).toISOString() })); const header = payload.length < 126 ? Buffer.from([0x81, payload.length]) : Buffer.from([0x81, 126, payload.length >> 8, payload.length & 0xff]); for (const socket of sockets) socket.write(Buffer.concat([header, payload])); }, close: () => new Promise(resolve => { for (const socket of sockets) socket.destroy(); server.close(() => resolve()); }), }; }