diablo2-web/scripts/net-relay.ts

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() }) })
},
}
}