0.2.6-dev: WebSocket permessage-deflate for frames from 1 KB (no server context takeover, 2 KB server window, memLevel 7; a client offering a narrower window connects uncompressed) — host bandwidth layer 0.2; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 15:57:33 +08:00
1 parent 69011d2b54
commit 8edda589f3
6 files changed
+135 -8

No files matched your search

+35 -3
View File
@@ -2,8 +2,8 @@
//
// * 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, no per-message deflate → Network.handleConnection. Refused at the
// upgrade: any other path 404; per-network socket limit for internet clients (maxConnectionsPerAddr, see net.js
// * 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';
@@ -15,6 +15,37 @@ 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, 216 server frames): 169 KB of payload → 63 KB on the wire; a
* larger window gains 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
@@ -37,7 +68,7 @@ export function createSessionStack(opts, { data, log }) {
* @returns {WebSocketServer}
*/
export function attachWebSocket(server, { network, log }) {
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: false, clientTracking: false });
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: { ...WS_DEFLATE }, clientTracking: false });
wss.on('connection', (ws, req) => network.handleConnection(ws, req));
wss.on('error', (e) => log.error('[ws] server error', e));
@@ -51,6 +82,7 @@ export function attachWebSocket(server, { network, log }) {
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) {