Files
ark-stronghold-protocol/server/http/websocket.js
T

118 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/http/websocket.js — the real-time side of the server:
//
// * session wiring: SessionRegistry (reconnect tokens) → Lobby (rooms, server/lobby.js) → Network (the socket
// protocol, server/net.js), built from the startServer() options (config.js decides which go where);
// * WebSocket (ws) at /ws, maxPayload 64 KB, permessage-deflate (WS_DEFLATE) → Network.handleConnection. Refused at
// the upgrade: any other path 404; per-network socket limit for internet clients (maxConnectionsPerAddr, see net.js
// clientAddress; local/LAN peers are exempt) 429; server full (maxConnections) or shutting down 503.
import { WebSocketServer } from 'ws';
import { Network, SessionRegistry, NET_DEFAULTS } from '../net.js';
import { Lobby } from '../lobby.js';
import { splitUrl } from './common.js';
import { netOptionsFrom, lobbyOptionsFrom } from './config.js';
/** Inbound WebSocket frame limit (DESIGN §8). */
export const WS_MAX_PAYLOAD = 64 * 1024;
/**
* permessage-deflate (RFC 7692) for the JSON text frames. A message from 1 KB up (m.public, m.private, b.start, welcome
* …) is compressed on its own — no context is kept from one message to the next (`serverNoContextTakeover`) — with a
* 2 KB window and zlib memLevel 7, so a connection holds about 72 KB of zlib state; smaller frames (pong, ok) go out
* as they are. Measured on a solo session (rounds 3–5, the server's own counters, traffic.js): 173 KB of payload →
* 60 KB on the wire; replaying a session's frames with larger windows gained nothing on these messages. Browsers
* negotiate it by themselves; a client that offers none gets plain frames. The inflated size of a client frame still
* counts against WS_MAX_PAYLOAD.
*/
export const WS_DEFLATE = Object.freeze({
threshold: 1024,
serverNoContextTakeover: true,
serverMaxWindowBits: 11,
zlibDeflateOptions: Object.freeze({ memLevel: 7 }),
});
/**
* Does every permessage-deflate offer of this `Sec-WebSocket-Extensions` header cap the server window below
* WS_DEFLATE.serverMaxWindowBits? ws cannot accept such an offer and refuses the whole handshake (400), so the upgrade
* drops the header instead and the connection goes uncompressed. No browser sends `server_max_window_bits`.
* @param {string | string[] | undefined} header
*/
export function narrowDeflateOffer(header) {
if (typeof header !== 'string') return false;
const offers = header.split(',').map((s) => s.trim()).filter((s) => /^permessage-deflate\b/i.test(s));
if (!offers.length) return false;
return offers.every((offer) => {
const m = /(?:^|;)\s*server_max_window_bits\s*=\s*"?(\d+)"?/i.exec(offer);
return !!m && Number(m[1]) < WS_DEFLATE.serverMaxWindowBits;
});
}
/**
* The session stack of one server.
* @param {{ MatchClass?: Function, seedFn?: () => number, [option: string]: any }} opts startServer() options
* @param {{ data: object, log: object }} deps the game data the lobby's matches use, the logger
* @returns {{ registry: SessionRegistry, lobby: Lobby, network: Network }}
*/
export function createSessionStack(opts, { data, log }) {
const netOptions = netOptionsFrom(opts);
const registry = new SessionRegistry({ reconnectWindowMs: netOptions.reconnectWindowMs ?? NET_DEFAULTS.reconnectWindowMs });
const lobbyOptions = lobbyOptionsFrom(opts);
const lobby = new Lobby({ registry, log, MatchClass: opts.MatchClass, getData: () => data, seedFn: opts.seedFn, options: lobbyOptions });
const network = new Network({ registry, handler: lobby, log, options: netOptions });
return { registry, lobby, network };
}
/**
* Count a socket in the traffic counters (traffic.js): its wire bytes from now until it closes, the payload of every
* message it sends (ws.send wrapped) and receives.
* @param {import('ws').WebSocket} ws
* @param {import('node:http').IncomingMessage} req
* @param {ReturnType<typeof import('./traffic.js').createTrafficCounter>} traffic
*/
function meterSocket(ws, req, traffic) {
const socket = req.socket;
traffic.wsOpen(socket);
const send = ws.send;
ws.send = function meteredSend(data, ...rest) {
traffic.wsSent(data);
return send.call(this, data, ...rest);
};
ws.on('message', (data) => traffic.wsReceived(data));
ws.once('close', () => traffic.wsClose(socket));
}
/**
* Serve the WebSocket endpoint /ws on `server` (its 'upgrade' event).
* @param {import('node:http').Server} server
* @param {{ network: Network, log: object, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter> }} deps
* @returns {WebSocketServer}
*/
export function attachWebSocket(server, { network, log, traffic = null }) {
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: { ...WS_DEFLATE }, clientTracking: false });
wss.on('connection', (ws, req) => {
if (traffic) meterSocket(ws, req, traffic);
network.handleConnection(ws, req);
});
wss.on('error', (e) => log.error('[ws] server error', e));
server.on('upgrade', (req, socket, head) => {
socket.on('error', () => {});
const parts = splitUrl(req.url || '/');
const reject = (status, text) => {
try { socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); } catch { socket.destroy(); }
};
if (!parts || parts.rawPath !== '/ws') { reject(404, 'Not Found'); return; }
const refused = network.admission(req);
if (refused === 'per-address') { reject(429, 'Too Many Requests'); return; }
if (refused) { reject(503, 'Service Unavailable'); return; }
if (narrowDeflateOffer(req.headers['sec-websocket-extensions'])) delete req.headers['sec-websocket-extensions'];
try {
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
} catch (e) {
log.error('[ws] upgrade failed', e);
socket.destroy();
}
});
return wss;
}