192 lines
7.1 KiB
TypeScript
192 lines
7.1 KiB
TypeScript
/**
|
|
* 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<void>
|
|
}
|
|
|
|
/**
|
|
* 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<ArrayBufferLike>): { frames: { opcode: number; payload: Uint8Array }[]; rest: Uint8Array<ArrayBufferLike> } {
|
|
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<Relay> {
|
|
const log = (message: string): void => { if (options.verbose === true) console.log(`[relay] ${message}`) }
|
|
const sockets = new Set<Socket>()
|
|
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<ArrayBufferLike> = 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<void>(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<void> => {
|
|
for (const socket of sockets) socket.destroy()
|
|
sockets.clear()
|
|
await new Promise<void>(resolve => { server.close(() => { resolve() }) })
|
|
},
|
|
}
|
|
}
|