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 | | path | what |
|---|---| |---|---|
| `server/index.js` | the process entry (`npm start`); `startServer()` wires `server/http/` | | `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/net.js` | sessions and reconnect tokens, rate limits, message validation |
| `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` | | `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
| `server/data.js` | loads `data/*.json` once (frozen) | | `server/data.js` | loads `data/*.json` once (frozen) |
+1
View File
@@ -289,6 +289,7 @@ services:
| 表情显示成默认图标、「玩法说明」只有文字要点 | 素材没下载完整:重新运行 `node tools/setup.mjs`(表情和教程图随其他素材一起从公开镜像下载,不需要客户端);缺失明细在 `.cache/assets-report.json` | | 表情显示成默认图标、「玩法说明」只有文字要点 | 素材没下载完整:重新运行 `node tools/setup.mjs`(表情和教程图随其他素材一起从公开镜像下载,不需要客户端);缺失明细在 `.cache/assets-report.json` |
| 本地提取失败 | 游戏照常运行,只是第 6 节表格里的几样换成替代样式。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` | | 本地提取失败 | 游戏照常运行,只是第 6 节表格里的几样换成替代样式。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` |
| 3D 棋盘没出现 | 需要本地提取的棋盘贴图(`node tools/doctor.mjs` 会显示「3D 棋盘可用」),以及支持 WebGL2 的浏览器。没有客户端的服务器可以从同一版本的整合包复制本地素材(第 6 节) | | 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 托管)」);独立模拟不计时,等你回来 | | 断线 | 同盟模拟 10 分钟内、独立模拟 24 小时内(`config.constants.singleReconnectTime`)用同一浏览器重新打开页面,自动回到原座位。同盟掉线期间按原阵容自动作战、到时自动准备(不会代为购买;想让 AI 代打请用「离开模拟 → 暂离(AI 托管)」);独立模拟不计时,等你回来 |
## 6. 本地客户端素材(可选) ## 6. 本地客户端素材(可选)
+1 -1
View File
@@ -184,7 +184,7 @@
标题画面页脚的「项目仓库」在新标签页打开本项目的 [GitHub 仓库](https://github.com/sganggs/Stronghold-Protocol)。 标题画面页脚的「项目仓库」在新标签页打开本项目的 [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. 统计(本机) ## 13. 统计(本机)
+2 -1
View File
@@ -48,7 +48,8 @@ server/
media.js /media/… extension-less audio → public/assets/audio 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 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) 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 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 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) 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 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 // 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 // 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 // recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
// error the battle isolated) | 'runner' (the local battle runner) | 'request' // error the battle isolated) | 'runner' (the local battle runner) | 'request'
// (a failed request or error push the player was shown) // (a failed request or error push the player was shown)
// errorLog() / errorCount() / clearErrorLog() // errorLog() / errorCount() / clearErrorLog()
// setBattleSource(fn) the battle runner registers how to read the battle on screen // 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) // 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) // 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. // 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 { APP_VERSION } from '../../shared/constants.js';
import { TRAFFIC_CATEGORIES, trafficCategory } from '../../shared/assetPaths.js';
export const LOG_MAX = 40; export const LOG_MAX = 40;
export const STACK_LINES = 6; export const STACK_LINES = 6;
@@ -87,6 +91,78 @@ export const errorLog = () => log.map((e) => ({ ...e }));
export const errorCount = () => total; export const errorCount = () => total;
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; } 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; let battleSource = null;
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */ /** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; } 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 * 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. * prose and exact string values in the JSON.
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number, * @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) { 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 entries = input.entries ?? errorLog();
const now = input.now ?? Date.now(); const now = input.now ?? Date.now();
const lines = []; const lines = [];
@@ -208,6 +284,8 @@ export function buildDiagnostics(input) {
if (settings) { if (settings) {
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`); 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 shown = entries.slice().reverse();
const tot = input.total ?? total; const tot = input.total ?? total;
const drop = input.dropped ?? dropped; const drop = input.dropped ?? dropped;
@@ -265,5 +343,5 @@ export function browserEnv() {
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts * @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
*/ */
export function diagnosticsText({ state, settings = null, attachBattle = false }) { 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 { startBuildGuard } from './ui/buildGuard.js';
import { initLang, useLang, tickerText } from './ui/lang.js'; import { initLang, useLang, tickerText } from './ui/lang.js';
import { t, N_, translateWire } from '../../shared/i18n.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 RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350; const JOIN_DELAY_MS = 350;
@@ -346,6 +346,8 @@ function installGlobalErrorHandlers() {
async function boot() { async function boot() {
installGlobalErrorHandlers(); 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) // touch / hover / fullscreen classes, zoom-gesture blocking, rotation re-layout (ui/device.js, css/devices.css)
installDeviceSupport(); installDeviceSupport();
// A page restored from the back/forward cache has a dead socket and a stale token choice: start over. // 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; // * a URL longer than 4096 characters → 414; one that does not parse → 400;
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`; // * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
// * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions, // * 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). // * 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 { PROTOCOL_VERSION, APP_VERSION } from '../../shared/constants.js';
import { buildTag } from './buildTag.js'; import { buildTag } from './buildTag.js';
@@ -15,20 +17,26 @@ import { setSecurityHeaders, sendError, sendJson, splitUrl } from './common.js';
const MAX_URL_LENGTH = 4096; 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, * @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 }) { export function healthReport({ startedAt, network, registry, lobby, traffic }, withTraffic = false) {
return { const report = {
ok: true, version: PROTOCOL_VERSION, app: APP_VERSION, uptimeSec: Math.round((Date.now() - startedAt) / 1000), 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 // 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 // older than this reloads itself, so a deploy reaches clients that never reload
build: buildTag(), build: buildTag(),
sockets: network.connectionCount, sessions: registry.size, ...lobby.stats(), 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`. * The request listener for `http.createServer`.
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, * @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
@@ -48,7 +56,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
return; return;
} }
if (parts.rawPath === '/healthz') { if (parts.rawPath === '/healthz') {
sendJson(req, res, 200, healthReport(health)); sendJson(req, res, 200, healthReport(health, wantsTraffic(parts.query)));
return; return;
} }
await serveStatic(req, res, parts.rawPath, parts.query); await serveStatic(req, res, parts.rawPath, parts.query);
@@ -56,6 +64,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
return (req, res) => { return (req, res) => {
setSecurityHeaders(res); setSecurityHeaders(res);
if (health.traffic) health.traffic.meter(res, splitUrl(req.url || '/')?.rawPath || '/');
handleRequest(req, res).catch((e) => { handleRequest(req, res).catch((e) => {
log.error('[http] request failed', e); log.error('[http] request failed', e);
sendError(req, res, 500, '服务器内部错误 · Internal error'); 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 }; 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). * Serve the WebSocket endpoint /ws on `server` (its 'upgrade' event).
* @param {import('node:http').Server} server * @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} * @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 }); 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)); wss.on('error', (e) => log.error('[ws] server error', e));
server.on('upgrade', (req, socket, head) => { 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/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/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/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/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 // 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 / // 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 { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js'; import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
import { createRequestHandler } from './http/routes.js'; import { createRequestHandler } from './http/routes.js';
import { createTrafficCounter } from './http/traffic.js';
import { answerClientError } from './http/common.js'; import { answerClientError } from './http/common.js';
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.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, * @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, * lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
* registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>, * registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
* close: () => Promise<void> }>} * traffic: ReturnType<typeof createTrafficCounter>, close: () => Promise<void> }>}
*/ */
export async function startServer(opts = {}) { export async function startServer(opts = {}) {
const { port, host } = listenAddress(opts); const { port, host } = listenAddress(opts);
@@ -73,9 +76,10 @@ export async function startServer(opts = {}) {
resetBuildTag(); resetBuildTag();
buildTag(); 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); 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. // The address actually bound. The default may fall back to IPv4; the returned host and url follow that.
let boundHost; let boundHost;
@@ -132,7 +136,7 @@ export async function startServer(opts = {}) {
return closing; 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). // `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 assert from 'node:assert/strict';
import { import {
recordError, errorLog, errorCount, clearErrorLog, messageOf, stackOf, stripHosts, redact, redactValues, secretsOf, buildDiagnostics, 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'; } from '../public/js/diag.js';
import { APP_VERSION } from '../shared/constants.js'; import { APP_VERSION } from '../shared/constants.js';
@@ -184,3 +184,53 @@ describe('the report', () => {
setBattleSource(null); 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);
});