0.2.6-dev: traffic counters — HTTP body bytes by category (assets / audio / code / data) and WebSocket wire / payload bytes since the start at GET /healthz?traffic=1, and the page's own downloads (Resource Timing transferSize) as a traffic line in the 问题反馈 diagnostics (host bandwidth layer 0.4); golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 16:05:49 +08:00
1 parent 05b0dba8b5
commit be8d382c05
14 files changed
+516 -23

No files matched your search

+1 -1
View File
@@ -77,7 +77,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
| path | what |
|---|---|
| `server/index.js` | the process entry (`npm start`); `startServer()` wires `server/http/` |
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`), `static.js` (the mounts), `media.js`, `files.js` (MIME, gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `common.js`, `boot.js` (a pending update package first, banner, shutdown) |
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`, permessage-deflate), `static.js` (the mounts), `media.js`, `files.js` (MIME, gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `traffic.js` (bytes sent by category and on the WebSocket, `/healthz?traffic=1`), `common.js`, `boot.js` (a pending update package first, banner, shutdown) |
| `server/net.js` | sessions and reconnect tokens, rate limits, message validation |
| `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
| `server/data.js` | loads `data/*.json` once (frozen) |
+1
View File
@@ -289,6 +289,7 @@ services:
| 表情显示成默认图标、「玩法说明」只有文字要点 | 素材没下载完整:重新运行 `node tools/setup.mjs`(表情和教程图随其他素材一起从公开镜像下载,不需要客户端);缺失明细在 `.cache/assets-report.json` |
| 本地提取失败 | 游戏照常运行,只是第 6 节表格里的几样换成替代样式。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` |
| 3D 棋盘没出现 | 需要本地提取的棋盘贴图(`node tools/doctor.mjs` 会显示「3D 棋盘可用」),以及支持 WebGL2 的浏览器。没有客户端的服务器可以从同一版本的整合包复制本地素材(第 6 节) |
| 想知道开服电脑上传了多少流量 | 浏览器打开 `http://localhost:3000/healthz?traffic=1`(换成你的端口):`traffic.http` 是服务器启动以来各类内容(`assets` 图片和模型、`audio` 音频、`code` 页面和脚本、`data` 游戏数据和素材清单)发出的字节数和请求数(压缩后的实际字节,`notModified` 是只回了「没变」的 304),`traffic.ws` 是 WebSocket 的线上字节(`wireOut` / `wireIn`,压缩后)和压缩前的消息字节(`payloadOut` / `payloadIn`)。数字只在内存里,重启后从 0 开始。玩家那边:设置 →「问题反馈」复制的诊断里有一行 `traffic:`,是这个页面从打开起下载的字节数(`cached` 是浏览器缓存直接给的,不占流量;`external` 是其他网站,例如 Google 字体) |
| 断线 | 同盟模拟 10 分钟内、独立模拟 24 小时内(`config.constants.singleReconnectTime`)用同一浏览器重新打开页面,自动回到原座位。同盟掉线期间按原阵容自动作战、到时自动准备(不会代为购买;想让 AI 代打请用「离开模拟 → 暂离(AI 托管)」);独立模拟不计时,等你回来 |
## 6. 本地客户端素材(可选)
+1 -1
View File
@@ -184,7 +184,7 @@
标题画面页脚的「项目仓库」在新标签页打开本项目的 [GitHub 仓库](https://github.com/sganggs/Stronghold-Protocol)。
遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合,以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 `<me>`、`<player2>`、`<room>` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。
遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合、打开页面以来按类别统计的下载量(图片和模型、音频、代码、数据),以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 `<me>`、`<player2>`、`<room>` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。
## 13. 统计(本机)
+2 -1
View File
@@ -48,7 +48,8 @@ server/
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
buildTag.js build tag of the served browser runtime (/healthz build, public/js/ui/buildGuard.js)
routes.js request listener: 414 / 400 / 405 guards, GET /healthz, then static files
routes.js request listener: 414 / 400 / 405 guards, GET /healthz (?traffic=1: the counters), then static files
traffic.js traffic counters: HTTP body bytes by category (assets / audio / code / data), WebSocket wire / payload
common.js security headers, URL split, error page, JSON replies, bare 400 for unparseable requests
boot.js boot banner (Local / LAN / tunnel URLs), port-in-use hint, graceful SIGINT / SIGTERM
net.js session registry, send helpers, per-connection rate limit, message validation (uses shared/protocol.js)
+82 -4
View File
@@ -5,19 +5,23 @@
// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in
// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle
// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev
// tools replay.
// tools replay. The report's `traffic:` line sums what this page downloaded (Resource Timing `transferSize`: bytes
// on the wire, 0 for a response from the browser cache) by the categories of shared/assetPaths.js trafficCategory,
// other sites apart as `external` (host bandwidth, layer 0; the server's own counters: /healthz?traffic=1).
//
// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
// error the battle isolated) | 'runner' (the local battle runner) | 'request'
// (a failed request or error push the player was shown)
// errorLog() / errorCount() / clearErrorLog()
// setBattleSource(fn) the battle runner registers how to read the battle on screen
// installTrafficMeter() / recordResourceEntry(entry) / trafficSummary() / clearTraffic() the page's download totals
// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in)
// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen)
//
// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language.
import { APP_VERSION } from '../../shared/constants.js';
import { TRAFFIC_CATEGORIES, trafficCategory } from '../../shared/assetPaths.js';
export const LOG_MAX = 40;
export const STACK_LINES = 6;
@@ -87,6 +91,78 @@ export const errorLog = () => log.map((e) => ({ ...e }));
export const errorCount = () => total;
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; }
// ---- traffic: what this page downloaded ---------------------------------------------------------------------
/** @type {Map<string, { requests: number, bytes: number, cached: number }>} category → totals */
const traffic = new Map();
let trafficMeter = null;
/**
* Add one Resource Timing entry (a PerformanceResourceTiming, or the page's own navigation entry) to the totals: bytes
* on the wire (`transferSize`; headers included, 0 when the browser cache answered — counted as `cached`). A same-origin
* URL counts under trafficCategory(path), another site's under `external`; data: / blob: URLs are not downloads.
* @param {{ name?: string, transferSize?: number, decodedBodySize?: number }} entry
* @param {string} [origin] the page's origin
*/
export function recordResourceEntry(entry, origin = globalThis.location?.origin) {
if (!entry || typeof entry.name !== 'string') return;
let u;
try { u = new URL(entry.name, origin || 'http://localhost'); } catch { return; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
const cat = origin && u.origin !== origin ? 'external' : trafficCategory(u.pathname);
let t = traffic.get(cat);
if (!t) traffic.set(cat, (t = { requests: 0, bytes: 0, cached: 0 }));
const bytes = Number(entry.transferSize) || 0;
t.requests++;
t.bytes += bytes;
if (bytes === 0 && (Number(entry.decodedBodySize) || 0) > 0) t.cached++;
}
/**
* Start summing this page's downloads (main.js at boot): the entries already in the Resource Timing buffer, then
* every new one (a PerformanceObserver also gets the entries a full buffer drops). False where unsupported.
* @param {any} [win]
*/
export function installTrafficMeter(win = globalThis) {
if (trafficMeter) return true;
const PO = win?.PerformanceObserver;
if (typeof PO !== 'function') return false;
try {
const origin = win.location?.origin;
for (const e of win.performance?.getEntriesByType?.('navigation') || []) recordResourceEntry(e, origin);
trafficMeter = new PO((list) => { for (const e of list.getEntries()) recordResourceEntry(e, origin); });
trafficMeter.observe({ type: 'resource', buffered: true });
return true;
} catch {
trafficMeter = null;
return false;
}
}
/** The totals so far: `{ [category]: { requests, bytes, cached } }` in report order, plus `total`. */
export function trafficSummary() {
const out = {};
const total = { requests: 0, bytes: 0, cached: 0 };
for (const cat of [...TRAFFIC_CATEGORIES, 'external']) {
const t = traffic.get(cat);
if (!t) continue;
out[cat] = { ...t };
if (cat !== 'external') { total.requests += t.requests; total.bytes += t.bytes; total.cached += t.cached; }
}
out.total = total;
return out;
}
export function clearTraffic() { traffic.clear(); }
const sizeOf = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : n >= 1e3 ? `${Math.round(n / 1e3)} KB` : `${n} B`);
/** `assets 9.7 MB (254, 41 cached) | … | host total 21.4 MB (869)` — the report's traffic line; null when nothing was recorded. */
export function trafficLine(summary) {
if (!summary || !summary.total || !(summary.total.requests > 0)) return null;
const parts = Object.entries(summary).filter(([k]) => k !== 'total')
.map(([k, t]) => `${k} ${sizeOf(t.bytes)} (${t.requests}${t.cached ? `, ${t.cached} cached` : ''})`);
return `${parts.join(' | ')} | host total ${sizeOf(summary.total.bytes)} (${summary.total.requests})`;
}
let battleSource = null;
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; }
@@ -177,10 +253,10 @@ const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== fa
* error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
* prose and exact string values in the JSON.
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
* now?: number, pageStart?: number, version?: string }} input
* now?: number, pageStart?: number, version?: string, traffic?: ReturnType<typeof trafficSummary> | null }} input
*/
export function buildDiagnostics(input) {
const { state = {}, env = {}, settings = null, battle = null } = input;
const { state = {}, env = {}, settings = null, battle = null, traffic: trafficTotals = null } = input;
const entries = input.entries ?? errorLog();
const now = input.now ?? Date.now();
const lines = [];
@@ -208,6 +284,8 @@ export function buildDiagnostics(input) {
if (settings) {
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`);
}
const tl = trafficLine(trafficTotals);
if (tl) lines.push(`traffic: ${tl}`);
const shown = entries.slice().reverse();
const tot = input.total ?? total;
const drop = input.dropped ?? dropped;
@@ -265,5 +343,5 @@ export function browserEnv() {
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
*/
export function diagnosticsText({ state, settings = null, attachBattle = false }) {
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null, traffic: trafficSummary() });
}
+3 -1
View File
@@ -55,7 +55,7 @@ import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/l
import { startBuildGuard } from './ui/buildGuard.js';
import { initLang, useLang, tickerText } from './ui/lang.js';
import { t, N_, translateWire } from '../../shared/i18n.js';
import { recordError } from './diag.js';
import { recordError, installTrafficMeter } from './diag.js';
const RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350;
@@ -346,6 +346,8 @@ function installGlobalErrorHandlers() {
async function boot() {
installGlobalErrorHandlers();
// what this page downloads, by category, for the 问题反馈 report's traffic line (diag.js; nothing is sent anywhere)
installTrafficMeter();
// touch / hover / fullscreen classes, zoom-gesture blocking, rotation re-layout (ui/device.js, css/devices.css)
installDeviceSupport();
// A page restored from the back/forward cache has a dead socket and a stale token choice: start over.
+16 -7
View File
@@ -4,9 +4,11 @@
// * a URL longer than 4096 characters → 414; one that does not parse → 400;
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
// * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions,
// rooms, matches), never cached;
// rooms, matches), never cached; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the
// start, WebSocket wire and payload bytes);
// * everything else → the static files (static.js).
// A route that throws is logged and answers 500.
// Every response's body bytes count in the traffic counters (traffic.js meter). A route that throws is logged and
// answers 500.
import { PROTOCOL_VERSION, APP_VERSION } from '../../shared/constants.js';
import { buildTag } from './buildTag.js';
@@ -15,20 +17,26 @@ import { setSecurityHeaders, sendError, sendJson, splitUrl } from './common.js';
const MAX_URL_LENGTH = 4096;
/**
* The GET /healthz body.
* The GET /healthz body (`withTraffic`: plus the traffic counters, `?traffic=1`).
* @param {{ startedAt: number, network: import('../net.js').Network, registry: import('../net.js').SessionRegistry,
* lobby: import('../lobby.js').Lobby }} health
* lobby: import('../lobby.js').Lobby, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter> }} health
* @param {boolean} [withTraffic]
*/
export function healthReport({ startedAt, network, registry, lobby }) {
return {
export function healthReport({ startedAt, network, registry, lobby, traffic }, withTraffic = false) {
const report = {
ok: true, version: PROTOCOL_VERSION, app: APP_VERSION, uptimeSec: Math.round((Date.now() - startedAt) / 1000),
// the runtime the server is serving right now (public/js/ui/buildGuard.js): a page whose own build is
// older than this reloads itself, so a deploy reaches clients that never reload
build: buildTag(),
sockets: network.connectionCount, sessions: registry.size, ...lobby.stats(),
};
if (withTraffic && traffic) report.traffic = traffic.snapshot();
return report;
}
/** Does the query ask for the traffic counters (`traffic`, `traffic=1`, `traffic=true`)? */
const wantsTraffic = (query) => /(?:^|&)traffic(?:=(?:1|true|yes))?(?:&|$)/i.test(query || '');
/**
* The request listener for `http.createServer`.
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
@@ -48,7 +56,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
return;
}
if (parts.rawPath === '/healthz') {
sendJson(req, res, 200, healthReport(health));
sendJson(req, res, 200, healthReport(health, wantsTraffic(parts.query)));
return;
}
await serveStatic(req, res, parts.rawPath, parts.query);
@@ -56,6 +64,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
return (req, res) => {
setSecurityHeaders(res);
if (health.traffic) health.traffic.meter(res, splitUrl(req.url || '/')?.rawPath || '/');
handleRequest(req, res).catch((e) => {
log.error('[http] request failed', e);
sendError(req, res, 500, '服务器内部错误 · Internal error');
+102
View File
@@ -0,0 +1,102 @@
// server/http/traffic.js — what this server has sent and received since it started (host bandwidth, layer 0):
//
// * HTTP: per category of shared/assetPaths.js trafficCategory (assets / audio / code / data / other) the responses
// and the body bytes actually written (after gzip / brotli; headers not counted), and how many were 304s;
// * WebSocket: the connections, the bytes on the wire both ways (the sockets' own counters, after permessage-deflate,
// frame headers included, the HTTP upgrade not) and the message payloads before compression — their ratio is what
// the compression saves.
//
// routes.js meters every response (`meter`), websocket.js every socket (`wsOpen` / `wsClose` / `wsSent` / `wsReceived`);
// `snapshot()` is the `traffic` object of GET /healthz?traffic=1 (docs/DEPLOY.md §5). Nothing is stored or sent
// anywhere else; the counters start at zero with the process.
import { TRAFFIC_CATEGORIES, trafficCategory } from '../../shared/assetPaths.js';
/** Byte length of a chunk handed to res.write / res.end (a string in `encoding`, utf8 by default). */
function chunkBytes(chunk, encoding) {
if (chunk == null || typeof chunk === 'function') return 0;
if (typeof chunk === 'string') return Buffer.byteLength(chunk, typeof encoding === 'string' && Buffer.isEncoding(encoding) ? encoding : 'utf8');
return typeof chunk.byteLength === 'number' ? chunk.byteLength : 0;
}
/** Length of a ws 'message' payload (a Buffer, an ArrayBuffer or, fragmented, a Buffer list). */
function payloadBytes(data) {
if (Array.isArray(data)) return data.reduce((n, b) => n + payloadBytes(b), 0);
return data && typeof data.byteLength === 'number' ? data.byteLength : typeof data === 'string' ? Buffer.byteLength(data) : 0;
}
/**
* A traffic counter for one server.
* @param {{ now?: () => number }} [opts]
*/
export function createTrafficCounter({ now = Date.now } = {}) {
const startedAt = now();
const http = Object.fromEntries(TRAFFIC_CATEGORIES.map((c) => [c, { requests: 0, bytes: 0, notModified: 0 }]));
const ws = { connections: 0, wireOut: 0, wireIn: 0, payloadOut: 0, payloadIn: 0, messagesOut: 0, messagesIn: 0 };
/** @type {Map<import('node:net').Socket, { out: number, in: number }>} live sockets and their counters at the upgrade */
const live = new Map();
/** One finished HTTP response. */
function record(path, status, bytes) {
const c = http[trafficCategory(path)] || http.other;
c.requests++;
c.bytes += bytes;
if (status === 304) c.notModified++;
}
return {
/**
* Count the body bytes of `res` (every res.write / res.end) under the category of `path`, once the response is
* done or its connection gone.
* @param {import('node:http').ServerResponse} res
* @param {string} path the request's URL path
*/
meter(res, path) {
let bytes = 0;
const { write, end } = res;
res.write = function meteredWrite(chunk, encoding, cb) {
bytes += chunkBytes(chunk, encoding);
return write.call(this, chunk, encoding, cb);
};
res.end = function meteredEnd(chunk, encoding, cb) {
bytes += chunkBytes(chunk, encoding);
return end.call(this, chunk, encoding, cb);
};
res.once('close', () => record(path, res.statusCode, bytes));
},
/** A WebSocket connection opened on `socket` (after its upgrade answer was written). */
wsOpen(socket) {
ws.connections++;
if (socket && !live.has(socket)) live.set(socket, { out: socket.bytesWritten || 0, in: socket.bytesRead || 0 });
},
/** …and closed: its wire bytes move to the totals. */
wsClose(socket) {
const base = live.get(socket);
if (!base) return;
live.delete(socket);
ws.wireOut += Math.max(0, (socket.bytesWritten || 0) - base.out);
ws.wireIn += Math.max(0, (socket.bytesRead || 0) - base.in);
},
/** A message handed to ws.send (its payload before compression). */
wsSent(data) { ws.messagesOut++; ws.payloadOut += payloadBytes(data); },
/** A message received (its payload after inflating). */
wsReceived(data) { ws.messagesIn++; ws.payloadIn += payloadBytes(data); },
/** The counters now (plain JSON). */
snapshot() {
let wireOut = ws.wireOut;
let wireIn = ws.wireIn;
for (const [socket, base] of live) {
wireOut += Math.max(0, (socket.bytesWritten || 0) - base.out);
wireIn += Math.max(0, (socket.bytesRead || 0) - base.in);
}
const byCat = Object.fromEntries(Object.entries(http).map(([k, v]) => [k, { ...v }]));
const total = Object.values(http).reduce((t, v) => ({ requests: t.requests + v.requests, bytes: t.bytes + v.bytes }), { requests: 0, bytes: 0 });
return {
since: new Date(startedAt).toISOString(),
seconds: Math.max(0, Math.round((now() - startedAt) / 1000)),
http: { ...byCat, total },
ws: { ...ws, open: live.size, wireOut, wireIn },
};
},
};
}
+25 -3
View File
@@ -61,15 +61,37 @@ export function createSessionStack(opts, { data, log }) {
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 }} deps
* @param {{ network: Network, log: object, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter> }} deps
* @returns {WebSocketServer}
*/
export function attachWebSocket(server, { network, log }) {
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) => network.handleConnection(ws, req));
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) => {
+8 -4
View File
@@ -11,6 +11,8 @@
// http/files.js one file → response: MIME, gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges
// http/buildTag.js the build tag of the served browser runtime (/healthz `build`, public/js/ui/buildGuard.js)
// http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static
// http/traffic.js the traffic counters: bytes sent per category (assets / audio / code / data) and on the
// WebSocket, since the start (GET /healthz?traffic=1)
// http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400
// http/boot.js a pending update package first (update.js: old files deleted, the install verified against
// MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT /
@@ -31,6 +33,7 @@ import { createPackRegistry } from './packs.js';
import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
import { createRequestHandler } from './http/routes.js';
import { createTrafficCounter } from './http/traffic.js';
import { answerClientError } from './http/common.js';
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
@@ -54,7 +57,7 @@ export {
* @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer,
* lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
* registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
* close: () => Promise<void> }>}
* traffic: ReturnType<typeof createTrafficCounter>, close: () => Promise<void> }>}
*/
export async function startServer(opts = {}) {
const { port, host } = listenAddress(opts);
@@ -73,9 +76,10 @@ export async function startServer(opts = {}) {
resetBuildTag();
buildTag();
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby }, log }));
const traffic = createTrafficCounter();
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic }, log }));
server.on('clientError', answerClientError);
const wss = attachWebSocket(server, { network, log });
const wss = attachWebSocket(server, { network, log, traffic });
// The address actually bound. The default may fall back to IPv4; the returned host and url follow that.
let boundHost;
@@ -132,7 +136,7 @@ export async function startServer(opts = {}) {
return closing;
}
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, close };
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, close };
}
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
+78
View File
@@ -0,0 +1,78 @@
// Asset URL paths shared by the server and the browser (host bandwidth, docs/ASSETS.md "The served manifests"):
//
// * the content-hash segment. The server hands out data/assets.json and data/local-assets.json with every file URL
// carrying the first 16 hex digits of the file's SHA-256 (of the model's files for a Spine model) as its first
// segment — `/assets/char/avatar/x.png` → `/assets/~0123456789abcdef/char/avatar/x.png`, audio at
// `/media/~<hash>/…` (shared/media.js) — so a browser may keep it for a year. The server strips the segment and
// serves the file at the plain path (server/http/static.js, media.js). A path segment, never `?v=`: several
// loaders look at the URL's ending (`.skel`, `.mp3`) and pixi-spine finds the atlas and its pages next to the
// skeleton, which a query string would not survive.
// * trafficCategory: the category a request path counts under in the traffic counters (server/http/traffic.js,
// /healthz?traffic=1; public/js/diag.js for the browser's own totals).
/** Hex digits of a content hash in a URL. */
export const ASSET_HASH_HEX = 16;
/** The content-hash segment: `~` + ASSET_HASH_HEX lower-case hex digits. */
export const HASH_SEGMENT_RE = /^~([0-9a-f]{16})$/;
/** URL roots whose first segment may be a content hash. */
export const HASHED_ROOTS = Object.freeze(['/assets/', '/fonts/', '/media/']);
/**
* Split the content-hash segment off a URL path.
* '/assets/~0123456789abcdef/x.png' → { hash: '0123456789abcdef', path: '/assets/x.png' }; any other path (no segment,
* a malformed one, another root) → { hash: null, path } unchanged.
* @param {string} path a URL path (no query)
* @returns {{ hash: string | null, path: string }}
*/
export function splitHashedPath(path) {
const p = String(path ?? '');
for (const root of HASHED_ROOTS) {
if (!p.startsWith(root)) continue;
const slash = p.indexOf('/', root.length);
if (slash < 0) break;
const m = HASH_SEGMENT_RE.exec(p.slice(root.length, slash));
return m ? { hash: m[1], path: root + p.slice(slash + 1) } : { hash: null, path: p };
}
return { hash: null, path: p };
}
/**
* The URL path with the content-hash segment after its root ('/assets/x.png', 'abc…' → '/assets/~abc…/x.png'). A
* path outside HASHED_ROOTS, one that already has a segment, or no valid hash → the path unchanged.
* @param {string} path
* @param {string | null | undefined} hash
*/
export function hashedPath(path, hash) {
const p = String(path ?? '');
if (typeof hash !== 'string' || !HASH_SEGMENT_RE.test(`~${hash}`)) return p;
const root = HASHED_ROOTS.find((r) => p.startsWith(r));
if (!root || p.length === root.length || splitHashedPath(p).hash) return p;
return `${root}~${hash}/${p.slice(root.length)}`;
}
/** The URL with its content-hash segment removed (a URL derived from a hashed one names another file). */
export const unhashedUrl = (url) => (typeof url === 'string' ? splitHashedPath(url).path : url);
/** Traffic categories, in report order. */
export const TRAFFIC_CATEGORIES = Object.freeze(['assets', 'audio', 'code', 'data', 'other']);
/**
* The traffic category of a request path (the content-hash segment does not matter): `audio` (/media/,
* /assets/audio/), `assets` (/assets/, /fonts/, /icons/, the art of a content pack), `data` (/data/*.json, the UI
* language packs /i18n/, the pack index, other JSON), `code` (the page, scripts, styles: /, *.js, *.css, *.html,
* /vendor/, /sim/, /shared/), `other` (/healthz, error pages for anything else).
* @param {string} path a URL path (no query)
* @returns {'assets'|'audio'|'code'|'data'|'other'}
*/
export function trafficCategory(path) {
const p = splitHashedPath(path).path;
if (p.startsWith('/media/') || p.startsWith('/assets/audio/')) return 'audio';
if (p.startsWith('/assets/') || p.startsWith('/fonts/') || p.startsWith('/icons/')) return 'assets';
if (p.startsWith('/packs/')) return /\.json$/i.test(p) ? 'data' : 'assets';
if (p.startsWith('/data/') || p.startsWith('/i18n/') || /\.json$/i.test(p)) return 'data';
if (p === '/' || p.endsWith('/') || /\.(?:m?js|css|html?|map|webmanifest)$/i.test(p)
|| p.startsWith('/vendor/') || p.startsWith('/sim/') || p.startsWith('/shared/')) return 'code';
return 'other';
}
+41
View File
@@ -0,0 +1,41 @@
// shared/assetPaths.js: the content-hash segment of asset URLs (split / insert / strip) and the traffic categories the
// server's counters and the browser's diagnostics share (host bandwidth, layers 0 and 1).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { splitHashedPath, hashedPath, unhashedUrl, trafficCategory, TRAFFIC_CATEGORIES, ASSET_HASH_HEX, HASH_SEGMENT_RE } from '../shared/assetPaths.js';
const H = '0123456789abcdef';
test('the hash segment: split off, inserted after the root, removed again', () => {
assert.equal(ASSET_HASH_HEX, 16);
assert.ok(HASH_SEGMENT_RE.test(`~${H}`));
assert.deepEqual(splitHashedPath(`/assets/~${H}/char/avatar/a.png`), { hash: H, path: '/assets/char/avatar/a.png' });
assert.deepEqual(splitHashedPath(`/fonts/~${H}/f.woff2`), { hash: H, path: '/fonts/f.woff2' });
assert.deepEqual(splitHashedPath(`/media/~${H}/bgm/a`), { hash: H, path: '/media/bgm/a' });
for (const p of ['/assets/char/a.png', `/assets/~${H.toUpperCase()}/a.png`, `/assets/~${H}0/a.png`, `/assets/~${H}`, `/data/~${H}/x.json`,
`/assets/x/~${H}/a.png`, '/assets/~zz/a.png', '']) {
assert.deepEqual(splitHashedPath(p), { hash: null, path: p }, p);
}
assert.equal(hashedPath('/assets/char/a.png', H), `/assets/~${H}/char/a.png`);
assert.equal(hashedPath('/media/bgm/a', H), `/media/~${H}/bgm/a`);
assert.equal(hashedPath('/fonts/f.woff2', H), `/fonts/~${H}/f.woff2`);
assert.equal(hashedPath('/data/assets.json', H), '/data/assets.json', 'not a hashed root');
assert.equal(hashedPath('/assets/a.png', null), '/assets/a.png');
assert.equal(hashedPath('/assets/a.png', 'nothex'), '/assets/a.png');
assert.equal(hashedPath(`/assets/~${H}/a.png`, 'fedcba9876543210'), `/assets/~${H}/a.png`, 'never two segments');
assert.equal(hashedPath('/assets/', H), '/assets/');
assert.equal(unhashedUrl(`/assets/~${H}/audio/sfx/b_char_dead.mp3`), '/assets/audio/sfx/b_char_dead.mp3');
assert.equal(unhashedUrl(null), null);
});
test('traffic categories: audio, assets, code, data, other — the hash segment does not matter', () => {
assert.deepEqual([...TRAFFIC_CATEGORIES], ['assets', 'audio', 'code', 'data', 'other']);
const cases = {
'/media/bgm/act1': 'audio', [`/media/~${H}/bgm/act1`]: 'audio', '/assets/audio/bgm/a.mp3': 'audio', [`/assets/~${H}/audio/sfx/a.mp3`]: 'audio',
'/assets/char/avatar/a.png': 'assets', [`/assets/~${H}/spine/op/x/front/x.skel`]: 'assets', '/fonts/f.woff2': 'assets', '/icons/app.svg': 'assets',
'/packs/foo/art/a.png': 'assets', '/packs/index.json': 'data', '/data/assets.json': 'data', '/data/i18n/en.json': 'data', '/i18n/en.json': 'data',
'/manifest.json': 'data', '/': 'code', '/index.html': 'code', '/js/main.js': 'code', '/css/theme.css': 'code', '/vendor/pixi.min.js': 'code',
'/sim/Battle.js': 'code', '/shared/protocol.js': 'code', '/data.js': 'code', '/healthz': 'other', '/favicon.ico': 'other',
};
for (const [p, cat] of Object.entries(cases)) assert.equal(trafficCategory(p), cat, p);
});
+51 -1
View File
@@ -5,7 +5,7 @@ import { test, describe, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import {
recordError, errorLog, errorCount, clearErrorLog, messageOf, stackOf, stripHosts, redact, redactValues, secretsOf, buildDiagnostics,
setBattleSource, currentBattle, LOG_MAX, STACK_LINES,
setBattleSource, currentBattle, LOG_MAX, STACK_LINES, recordResourceEntry, trafficSummary, trafficLine, clearTraffic, installTrafficMeter,
} from '../public/js/diag.js';
import { APP_VERSION } from '../shared/constants.js';
@@ -184,3 +184,53 @@ describe('the report', () => {
setBattleSource(null);
});
});
describe('the page\'s traffic (host bandwidth layer 0)', () => {
beforeEach(() => clearTraffic());
const ORIGIN = 'http://192.168.1.5:3000';
test('Resource Timing entries sum by category: bytes on the wire, a cache hit counted as cached, other sites apart', () => {
recordResourceEntry({ name: `${ORIGIN}/assets/~0123456789abcdef/spine/op/x/front/x.skel`, transferSize: 120000, decodedBodySize: 400000 }, ORIGIN);
recordResourceEntry({ name: `${ORIGIN}/assets/char/avatar/a.png`, transferSize: 0, decodedBodySize: 50000 }, ORIGIN);
recordResourceEntry({ name: `${ORIGIN}/media/~0123456789abcdef/bgm/m_bat`, transferSize: 2000000, decodedBodySize: 2000000 }, ORIGIN);
recordResourceEntry({ name: `${ORIGIN}/js/main.js`, transferSize: 9000, decodedBodySize: 30000 }, ORIGIN);
recordResourceEntry({ name: `${ORIGIN}/data/assets.json`, transferSize: 300, decodedBodySize: 1300000 }, ORIGIN);
recordResourceEntry({ name: 'https://fonts.gstatic.com/s/x.woff2', transferSize: 50000 }, ORIGIN);
recordResourceEntry({ name: 'data:image/png;base64,AAAA', transferSize: 0 }, ORIGIN);
recordResourceEntry({ name: 'blob:http://192.168.1.5:3000/1234', transferSize: 0 }, ORIGIN);
recordResourceEntry(null, ORIGIN);
const t = trafficSummary();
assert.deepEqual(t.assets, { requests: 2, bytes: 120000, cached: 1 });
assert.deepEqual(t.audio, { requests: 1, bytes: 2000000, cached: 0 });
assert.deepEqual(t.code, { requests: 1, bytes: 9000, cached: 0 });
assert.deepEqual(t.data, { requests: 1, bytes: 300, cached: 0 }, 'a 304 costs its headers only');
assert.deepEqual(t.external, { requests: 1, bytes: 50000, cached: 0 });
assert.deepEqual(t.total, { requests: 5, bytes: 2129300, cached: 1 }, 'the host total leaves other sites out');
assert.deepEqual(Object.keys(t), ['assets', 'audio', 'code', 'data', 'external', 'total']);
assert.equal(trafficLine(t), 'assets 120 KB (2, 1 cached) | audio 2.0 MB (1) | code 9 KB (1) | data 300 B (1) | external 50 KB (1) | host total 2.1 MB (5)');
});
test('the report carries the traffic line; nothing recorded → no line', () => {
recordResourceEntry({ name: `${ORIGIN}/js/main.js`, transferSize: 1500 }, ORIGIN);
const text = buildDiagnostics({ state: {}, now: 0, pageStart: 0, traffic: trafficSummary() });
assert.match(text, /\ntraffic: code 2 KB \(1\) \| host total 2 KB \(1\)\n/);
clearTraffic();
assert.ok(!/traffic:/.test(buildDiagnostics({ state: {}, now: 0, pageStart: 0, traffic: trafficSummary() })));
assert.ok(!/traffic:/.test(buildDiagnostics({ state: {}, now: 0, pageStart: 0 })));
});
test('installTrafficMeter reads the navigation entry and observes the resources (buffered), once; false without the API', () => {
let observed = null;
class PO {
constructor(fn) { this.fn = fn; }
observe(o) { observed = o; this.fn({ getEntries: () => [{ name: `${ORIGIN}/vendor/pixi.min.js`, transferSize: 140000 }] }); }
}
const win = { PerformanceObserver: PO, location: { origin: ORIGIN }, performance: { getEntriesByType: (t) => (t === 'navigation' ? [{ name: `${ORIGIN}/`, transferSize: 7000 }] : []) } };
assert.equal(installTrafficMeter({}), false);
assert.equal(installTrafficMeter(win), true);
assert.deepEqual(observed, { type: 'resource', buffered: true });
assert.deepEqual(trafficSummary().code, { requests: 2, bytes: 147000, cached: 0 });
assert.equal(installTrafficMeter(win), true, 'already installed');
assert.equal(trafficSummary().code.requests, 2, 'not twice');
});
});
+105
View File
@@ -0,0 +1,105 @@
// The server's traffic counters (server/http/traffic.js, GET /healthz?traffic=1; host bandwidth layer 0): the body
// bytes every HTTP response wrote, by category, 304s apart, and the WebSocket's wire and payload bytes both ways.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { startServer } from '../server/index.js';
import { createTrafficCounter } from '../server/http/traffic.js';
import { TestClient } from './helpers/wsClient.js';
function get(port, p, headers = {}) {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port, path: p, headers, agent: false }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
});
req.on('error', reject);
req.end();
});
}
test('HTTP: body bytes by category as written (compressed), 304s counted, /healthz reports them only when asked', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-traffic-'));
const w = (rel, content) => { fs.mkdirSync(path.dirname(path.join(tmp, rel)), { recursive: true }); fs.writeFileSync(path.join(tmp, rel), content); };
const js = `// app\n${'export const x = 1;\n'.repeat(400)}`;
const png = randomBytes(3000);
const mp3 = randomBytes(5000);
w('public/index.html', '<!doctype html><p>hi</p>');
w('public/js/app.js', js);
w('public/assets/img/a.png', png);
w('public/assets/audio/bgm/a.mp3', mp3);
w('data/config.json', JSON.stringify({ a: 1 }));
w('shared/x.js', 'export const y = 2;');
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true, publicDir: path.join(tmp, 'public'), dataDir: path.join(tmp, 'data'), sharedDir: path.join(tmp, 'shared') });
try {
const plain = await get(srv.port, '/health');
assert.equal(plain.status, 404);
const gz = await get(srv.port, '/js/app.js', { 'accept-encoding': 'gzip' });
assert.ok(gz.body.length < js.length, 'compressed');
await get(srv.port, '/js/app.js', { 'if-none-match': (await get(srv.port, '/js/app.js')).headers.etag });
await get(srv.port, '/assets/img/a.png');
await get(srv.port, '/media/bgm/a');
await get(srv.port, '/data/config.json');
const before = await get(srv.port, '/healthz');
assert.equal(JSON.parse(before.body).traffic, undefined, 'plain /healthz stays as it was');
const t = JSON.parse((await get(srv.port, '/healthz?traffic=1')).body).traffic;
assert.equal(t.http.code.requests, 3);
assert.equal(t.http.code.bytes, gz.body.length + js.length, 'what was written: the gzip body, the plain body, nothing for the 304');
assert.equal(t.http.code.notModified, 1);
assert.deepEqual(t.http.assets, { requests: 1, bytes: png.length, notModified: 0 });
assert.deepEqual(t.http.audio, { requests: 1, bytes: mp3.length, notModified: 0 });
assert.equal(t.http.data.requests, 1);
assert.equal(t.http.other.requests, 2, 'the 404 page and the first /healthz');
assert.ok(t.http.other.bytes > 0);
assert.equal(t.http.total.requests, 8);
assert.equal(typeof t.since, 'string');
assert.equal(srv.traffic.snapshot().http.total.requests, 9, 'the traffic request itself once it has finished');
} finally {
await srv.close();
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('WebSocket: wire bytes both ways (after permessage-deflate) and the payloads, live and after the close', async () => {
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
try {
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
await c.hello('流量计数');
const live = srv.traffic.snapshot().ws;
assert.equal(live.connections, 1);
assert.equal(live.open, 1);
assert.ok(live.messagesOut >= 1 && live.payloadOut > 0, JSON.stringify(live));
assert.ok(live.messagesIn >= 1 && live.payloadIn > 0);
assert.ok(live.wireOut > 0 && live.wireIn > 0);
await c.close();
for (let i = 0; i < 50 && srv.traffic.snapshot().ws.open; i++) await new Promise((r) => setTimeout(r, 20));
const done = srv.traffic.snapshot().ws;
assert.equal(done.open, 0);
assert.ok(done.wireOut >= live.wireOut, 'the closed socket\'s bytes stay in the totals');
} finally {
await srv.close();
}
});
test('the counter on its own: a string body in another encoding, an aborted response, the payload of a fragmented message', () => {
const tc = createTrafficCounter({ now: () => 1000 });
const listeners = {};
const res = { statusCode: 200, write() { return true; }, end() {}, once(ev, fn) { listeners[ev] = fn; } };
tc.meter(res, '/data/x.json');
res.write('aGk=', 'base64');
res.end('é');
listeners.close();
const s = tc.snapshot();
assert.deepEqual(s.http.data, { requests: 1, bytes: 2 + 2, notModified: 0 });
tc.wsSent('{"t":"pong"}');
tc.wsReceived([Buffer.alloc(3), Buffer.alloc(4)]);
const ws = tc.snapshot().ws;
assert.equal(ws.payloadOut, 12);
assert.equal(ws.payloadIn, 7);
assert.equal(tc.snapshot().seconds, 0);
});