/** * A tiny WebSocket relay, for testing the networked session over a real socket. * * The game needs a way for two peers to reach each other, and nothing more: the * relay forwards every binary message to the other connected clients and never * inspects it. That is not a design statement about production hosting — it is * the smallest server that lets the *real* transport code path (real TCP, real * WebSocket framing, real asynchrony) be tested without a browser and without a * dependency. * * Written against Node's `http` and `crypto` only, including the RFC 6455 * handshake and framing, because pulling in a WebSocket server library to test a * zero-dependency client would be the wrong trade. */ import { createHash } from 'node:crypto' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import type { Socket } from 'node:net' /** RFC 6455 handshake magic value. */ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' /** Opcodes used here. */ const OP_TEXT = 0x1 const OP_BINARY = 0x2 const OP_CLOSE = 0x8 const OP_PING = 0x9 const OP_PONG = 0xa /** A running relay. */ export interface Relay { /** `ws://` URL clients should connect to. */ readonly url: string /** Number of currently connected clients. */ readonly clients: number /** Total connections accepted since start. */ readonly accepted: number /** Total binary messages forwarded. */ readonly forwarded: number /** Stop listening and close every client. */ readonly close: () => Promise } /** * Encode one unmasked server frame. * * @param opcode - the frame opcode. * @param payload - the payload bytes. * @returns the frame. */ function encodeFrame(opcode: number, payload: Uint8Array): Uint8Array { const length = payload.byteLength const headerBytes = length < 126 ? 2 : length < 65536 ? 4 : 10 const out = new Uint8Array(headerBytes + length) out[0] = 0x80 | opcode if (length < 126) { out[1] = length } else if (length < 65536) { out[1] = 126 out[2] = (length >>> 8) & 0xff out[3] = length & 0xff } else { out[1] = 127 const view = new DataView(out.buffer) view.setUint32(2, 0) view.setUint32(6, length) } out.set(payload, headerBytes) return out } /** * Decode the client frames in a buffer. * * Returns the frames found plus the bytes that were not a complete frame yet: * TCP gives no message boundaries, so a partial frame is normal and must be kept * for the next chunk rather than parsed as if it were whole. * * @param buffer - accumulated bytes. * @returns the frames and the unconsumed remainder. */ function decodeFrames(buffer: Uint8Array): { frames: { opcode: number; payload: Uint8Array }[]; rest: Uint8Array } { const frames: { opcode: number; payload: Uint8Array }[] = [] let offset = 0 while (offset + 2 <= buffer.byteLength) { const first = buffer[offset]! const second = buffer[offset + 1]! const opcode = first & 0x0f const masked = (second & 0x80) !== 0 let length = second & 0x7f let cursor = offset + 2 if (length === 126) { if (cursor + 2 > buffer.byteLength) break length = (buffer[cursor]! << 8) | buffer[cursor + 1]! cursor += 2 } else if (length === 127) { if (cursor + 8 > buffer.byteLength) break const view = new DataView(buffer.buffer, buffer.byteOffset + cursor, 8) const big = view.getBigUint64(0) if (big > 1_000_000n) throw new Error('frame too large') length = Number(big) cursor += 8 } let mask: Uint8Array | null = null if (masked) { if (cursor + 4 > buffer.byteLength) break mask = buffer.subarray(cursor, cursor + 4) cursor += 4 } if (cursor + length > buffer.byteLength) break const payload = buffer.slice(cursor, cursor + length) if (mask !== null) for (let i = 0; i < payload.byteLength; i += 1) payload[i] = payload[i]! ^ mask[i % 4]! frames.push({ opcode, payload }) offset = cursor + length } return { frames, rest: buffer.slice(offset) } } /** * Start a relay on a port. * * @param port - TCP port; 0 picks a free one. * @returns the running relay. */ export async function startRelay(port = 0, options: { readonly verbose?: boolean } = {}): Promise { const log = (message: string): void => { if (options.verbose === true) console.log(`[relay] ${message}`) } const sockets = new Set() let accepted = 0 let forwarded = 0 const server: Server = createServer((_request: IncomingMessage, response: ServerResponse) => { response.writeHead(426, { 'content-type': 'text/plain' }) response.end('this endpoint speaks WebSocket only\n') }) server.on('upgrade', (request, socket: Socket, head) => { const key = request.headers['sec-websocket-key'] if (typeof key !== 'string') { socket.destroy(); return } const accept = createHash('sha1').update(key + WS_GUID).digest('base64') socket.write( 'HTTP/1.1 101 Switching Protocols\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, ) sockets.add(socket) accepted += 1 log(`upgrade from ${String(request.socket.remoteAddress ?? '?')} (${String(sockets.size)} connected)`) let buffer: Uint8Array = head.byteLength > 0 ? new Uint8Array(head) : new Uint8Array(0) socket.on('data', (chunk: Buffer) => { const merged = new Uint8Array(buffer.byteLength + chunk.byteLength) merged.set(buffer, 0) merged.set(chunk, buffer.byteLength) const { frames, rest } = decodeFrames(merged) buffer = rest log(`chunk ${String(chunk.byteLength)} bytes -> ${String(frames.length)} frames`) for (const frame of frames) { if (frame.opcode === OP_CLOSE) { socket.end(encodeFrame(OP_CLOSE, frame.payload)); continue } if (frame.opcode === OP_PING) { socket.write(encodeFrame(OP_PONG, frame.payload)); continue } if (frame.opcode !== OP_BINARY && frame.opcode !== OP_TEXT) continue const out = encodeFrame(OP_BINARY, frame.payload) let sent = 0 for (const peer of sockets) { if (peer === socket || peer.destroyed) continue peer.write(out) forwarded += 1 sent += 1 } log(`frame opcode ${String(frame.opcode)} ${String(frame.payload.byteLength)} bytes -> ${String(sent)} peer(s)`) } }) socket.on('close', () => { sockets.delete(socket); log('client closed') }) socket.on('error', () => { sockets.delete(socket) }) }) await new Promise(resolve => { server.listen(port, '127.0.0.1', resolve) }) const address = server.address() const boundPort = typeof address === 'object' && address !== null ? address.port : port return { url: `ws://127.0.0.1:${String(boundPort)}`, get clients(): number { return sockets.size }, get accepted(): number { return accepted }, get forwarded(): number { return forwarded }, close: async (): Promise => { for (const socket of sockets) socket.destroy() sockets.clear() await new Promise(resolve => { server.close(() => { resolve() }) }) }, } }