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:
1 parent
69011d2b54
commit
8edda589f3
6 files changed
+135
-8
No files matched your search
+1
-1
@@ -9,7 +9,7 @@
|
||||
|---|---|
|
||||
| 服务器 CPU | 战斗在各玩家浏览器里模拟(DESIGN §14),服务器只负责回合、经济和校验:**每个房间每个作战回合约 1 ms CPU**。AI 队友 / 掉线玩家的战场由服务器模拟:作战开始时 3 个 AI 战场在开发机上约 0.2–0.5 s CPU,小主机上可能要几秒(分成 8 ms 小片执行,不会卡住其他房间)。`SP_VERIFY=all` 会复算每个真人战场,CPU 明显增加,小主机建议保持 `off` 或 `sample`。 |
|
||||
| 服务器内存 | 空闲约 100 MB,每个进行中的对局再增加几 MB。 |
|
||||
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。 |
|
||||
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测;0.2.6 起 1 KB 以上的 WebSocket 消息用 permessage-deflate 压缩,实测一局单人对局的服务器消息从 169 KB 降到 63 KB,约为原来的四成)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。 |
|
||||
| 磁盘 | 素材约 550 MB(`public/assets`,含中文约 65 MB、日文约 85 MB 两套干员语音)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 710 MB。 |
|
||||
| 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
||||
|
||||
## 8. Network protocol (shared/protocol.js is normative)
|
||||
|
||||
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
|
||||
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound (a compressed frame counts its inflated size); rate limit 40 msgs/s per connection (excess dropped with `error RATE`). Frames from 1 KB up are compressed with permessage-deflate (RFC 7692; `server/http/websocket.js WS_DEFLATE`: no server context takeover, a 2 KB server window, zlib memLevel 7 — about 72 KB of zlib state per connection); smaller frames go out plain, and a client that offers no compression, or caps the server window below 2 KB, gets plain frames. A measured solo session (rounds 3–5): 169 KB of server frames → 63 KB on the wire.
|
||||
|
||||
### 8.1 Session & lobby
|
||||
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.setAiPicksLast {on}` (host, co-op, before the match — `ROOM_STARTED` during it, `BAD_TARGET` in a solo room; un-readies the other humans like a difficulty change; the match takes it as `opts.aiPicksLast`: humans draft before AI seats, off by default, no extra random draw) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries, ops?}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`; `ops` — 0.2.2 — the per-operator 潜能 / 练度 `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` of the 干员调配 roster and the 自选 owned pool, ≤ 256, checked strictly with the entries, `m.private.ops`; absent = none set: every operator 潜能 6, 精英2 Lv.60) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
|
||||
|
||||
@@ -42,7 +42,8 @@ server/
|
||||
index.js process entry: startServer() wires server/http/ (plain node:http + ws); boots when run directly
|
||||
http/
|
||||
config.js ROOT, served dirs, env (PORT, HOST, TRUST_PROXY, DEBUG), options handed to net.js / lobby.js
|
||||
websocket.js session wiring (SessionRegistry → Lobby → Network), WebSocket upgrade at /ws (64 KB frames)
|
||||
websocket.js session wiring (SessionRegistry → Lobby → Network), WebSocket upgrade at /ws (64 KB frames,
|
||||
permessage-deflate from 1 KB)
|
||||
static.js mounts / → public, /data/, /shared/, /sim/ (.js only), the /data.js browser stand-in, path guards
|
||||
media.js /media/… extension-less audio → public/assets/audio
|
||||
files.js one file → response: MIME, gzip (.skel/.atlas/.json/.js/.css …), ETag / 304, cache policy, ranges
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST '::' dual-stack, TRUST_PROXY auto, DEBUG),
|
||||
// which startServer() options go to net.js / lobby.js, the console logger
|
||||
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB;
|
||||
// refused at upgrade with 404 / 429 per network / 503)
|
||||
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB,
|
||||
// permessage-deflate from 1 KB; refused at upgrade with 404 / 429 per network / 503)
|
||||
// http/static.js the static mounts (/ → public/, /data/, /shared/, /sim/ `.js` only), the /data.js browser stand-in,
|
||||
// the content packs (/packs/index.json, /packs/<id>/<file> — the registry is packs.js)
|
||||
// http/media.js /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 (audio addressed without its extension)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// WebSocket permessage-deflate (server/http/websocket.js WS_DEFLATE, host bandwidth layer 0): a browser-like client
|
||||
// negotiates it with no server context takeover and a 2 KB server window, a large JSON frame goes out compressed, a
|
||||
// frame under the 1 KB threshold and a client without the extension get plain frames, and an offer that caps the server
|
||||
// window below ours is answered without compression instead of a refused handshake.
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import WebSocket from 'ws';
|
||||
import { attachWebSocket, narrowDeflateOffer, WS_DEFLATE } from '../server/http/websocket.js';
|
||||
import { startServer } from '../server/index.js';
|
||||
|
||||
const BIG = JSON.stringify({ t: 'm.public', players: Array.from({ length: 200 }, (_, i) => ({ playerId: `p_${i % 4}`, name: '阿米娅', lp: 30, phase: 'PREP' })) });
|
||||
const SMALL = JSON.stringify({ t: 'pong', at: 12345 });
|
||||
|
||||
/** A bare http server whose /ws sends BIG then SMALL to every socket (a stand-in for net.js Network). */
|
||||
async function fakeServer() {
|
||||
const server = http.createServer((req, res) => { res.writeHead(404); res.end(); });
|
||||
const network = {
|
||||
admission: () => null,
|
||||
handleConnection: (ws) => { ws.send(BIG); ws.send(SMALL); },
|
||||
};
|
||||
const wss = attachWebSocket(server, { network, log: { error() {} } });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
return { url: `ws://127.0.0.1:${server.address().port}/ws`, close: () => new Promise((r) => { wss.close(); server.close(() => r()); }) };
|
||||
}
|
||||
|
||||
/** Connect, collect two messages, report the server's Sec-WebSocket-Extensions answer and the bytes the socket read. */
|
||||
function receive(url, opts) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, opts);
|
||||
const msgs = [];
|
||||
let negotiated = '';
|
||||
ws.on('upgrade', (res) => { negotiated = res.headers['sec-websocket-extensions'] || ''; });
|
||||
ws.on('error', reject);
|
||||
ws.on('message', (data) => {
|
||||
msgs.push(String(data));
|
||||
if (msgs.length === 2) {
|
||||
const read = ws._socket.bytesRead;
|
||||
ws.close();
|
||||
resolve({ msgs, read, extensions: negotiated });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('WebSocket permessage-deflate', () => {
|
||||
let srv;
|
||||
before(async () => { srv = await fakeServer(); });
|
||||
after(async () => { await srv.close(); });
|
||||
|
||||
test('negotiated with no server context takeover and a small server window; a large frame is compressed', async () => {
|
||||
const r = await receive(srv.url);
|
||||
assert.match(r.extensions, /permessage-deflate/);
|
||||
assert.match(r.extensions, /server_no_context_takeover/);
|
||||
assert.match(r.extensions, new RegExp(`server_max_window_bits=${WS_DEFLATE.serverMaxWindowBits}`));
|
||||
assert.deepEqual(r.msgs, [BIG, SMALL], 'the payloads arrive intact');
|
||||
assert.ok(BIG.length > 10000);
|
||||
assert.ok(r.read < BIG.length / 4, `compressed on the wire: ${r.read} bytes read for ${BIG.length} of payload`);
|
||||
assert.equal(WS_DEFLATE.threshold, 1024);
|
||||
});
|
||||
|
||||
test('a client without the extension gets the same messages uncompressed', async () => {
|
||||
const r = await receive(srv.url, { perMessageDeflate: false });
|
||||
assert.equal(r.extensions, '');
|
||||
assert.deepEqual(r.msgs, [BIG, SMALL]);
|
||||
assert.ok(r.read > BIG.length + SMALL.length, `plain frames: ${r.read}`);
|
||||
});
|
||||
|
||||
test('an offer that caps the server window below ours connects uncompressed instead of a refused handshake', async () => {
|
||||
const r = await receive(srv.url, { perMessageDeflate: { serverMaxWindowBits: 9 } });
|
||||
assert.equal(r.extensions, '');
|
||||
assert.deepEqual(r.msgs, [BIG, SMALL]);
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=9'), true);
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=12; client_max_window_bits'), false);
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; server_max_window_bits=9, permessage-deflate'), false, 'another offer is acceptable');
|
||||
assert.equal(narrowDeflateOffer('permessage-deflate; client_max_window_bits'), false);
|
||||
assert.equal(narrowDeflateOffer(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('the real server negotiates compression at /ws and keeps the 64 KB inflated frame limit', async () => {
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
try {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
|
||||
assert.match(ws.extensions, /permessage-deflate/);
|
||||
// a compressed client frame whose inflated size is over the limit closes the socket (1009), like a plain one
|
||||
const closed = new Promise((resolve) => ws.once('close', (code) => resolve(code)));
|
||||
ws.send(JSON.stringify({ t: 'hello', name: 'x'.repeat(70 * 1024) }));
|
||||
assert.equal(await closed, 1009);
|
||||
} finally {
|
||||
await srv.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user