From be8d382c05aceee0539a96647c3237e8c2af737f Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 12 Oct 2026 16:05:49 +0800 Subject: [PATCH] =?UTF-8?q?0.2.6-dev:=20traffic=20counters=20=E2=80=94=20H?= =?UTF-8?q?TTP=20body=20bytes=20by=20category=20(assets=20/=20audio=20/=20?= =?UTF-8?q?code=20/=20data)=20and=20WebSocket=20wire=20/=20payload=20bytes?= =?UTF-8?q?=20since=20the=20start=20at=20GET=20/healthz=3Ftraffic=3D1,=20a?= =?UTF-8?q?nd=20the=20page's=20own=20downloads=20(Resource=20Timing=20tran?= =?UTF-8?q?sferSize)=20as=20a=20traffic=20line=20in=20the=20=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E5=8F=8D=E9=A6=88=20diagnostics=20(host=20bandwidth?= =?UTF-8?q?=20layer=200.4);=20golden:=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ARCHITECTURE.md | 2 +- docs/DEPLOY.md | 1 + docs/PLAYING.md | 2 +- docs/design/overview.md | 3 +- public/js/diag.js | 86 ++++++++++++++++++++++++++++++-- public/js/main.js | 4 +- server/http/routes.js | 23 ++++++--- server/http/traffic.js | 102 +++++++++++++++++++++++++++++++++++++ server/http/websocket.js | 28 +++++++++-- server/index.js | 12 +++-- shared/assetPaths.js | 78 +++++++++++++++++++++++++++++ test/asset-paths.test.js | 41 +++++++++++++++ test/diag.test.js | 52 ++++++++++++++++++- test/traffic.test.js | 105 +++++++++++++++++++++++++++++++++++++++ 14 files changed, 516 insertions(+), 23 deletions(-) create mode 100644 server/http/traffic.js create mode 100644 shared/assetPaths.js create mode 100644 test/asset-paths.test.js create mode 100644 test/traffic.test.js diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 50efc4c6..a0e958d1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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) | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 387d0da5..4ef91f4f 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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. 本地客户端素材(可选) diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 36cc34af..aa2e7cbd 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -184,7 +184,7 @@ 标题画面页脚的「项目仓库」在新标签页打开本项目的 [GitHub 仓库](https://github.com/sganggs/Stronghold-Protocol)。 -遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合,以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 ``、``、`` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。 +遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合、打开页面以来按类别统计的下载量(图片和模型、音频、代码、数据),以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 ``、``、`` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。 ## 13. 统计(本机) diff --git a/docs/design/overview.md b/docs/design/overview.md index 90df7f61..ed82f088 100644 --- a/docs/design/overview.md +++ b/docs/design/overview.md @@ -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) diff --git a/public/js/diag.js b/public/js/diag.js index 959ae1e2..4132fca7 100644 --- a/public/js/diag.js +++ b/public/js/diag.js @@ -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} 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 | 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() }); } diff --git a/public/js/main.js b/public/js/main.js index f40b6545..52782875 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -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. diff --git a/server/http/routes.js b/server/http/routes.js index ab4a12ff..3b785fd5 100644 --- a/server/http/routes.js +++ b/server/http/routes.js @@ -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 }} 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'); diff --git a/server/http/traffic.js b/server/http/traffic.js new file mode 100644 index 00000000..66818bbe --- /dev/null +++ b/server/http/traffic.js @@ -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} 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 }, + }; + }, + }; +} diff --git a/server/http/websocket.js b/server/http/websocket.js index f74e0acd..303b0834 100644 --- a/server/http/websocket.js +++ b/server/http/websocket.js @@ -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} 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 }} 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) => { diff --git a/server/index.js b/server/index.js index 91178128..80675f4c 100644 --- a/server/index.js +++ b/server/index.js @@ -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, - * close: () => Promise }>} + * traffic: ReturnType, close: () => Promise }>} */ 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). diff --git a/shared/assetPaths.js b/shared/assetPaths.js new file mode 100644 index 00000000..24bb68a8 --- /dev/null +++ b/shared/assetPaths.js @@ -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/~/…` (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'; +} diff --git a/test/asset-paths.test.js b/test/asset-paths.test.js new file mode 100644 index 00000000..3f58a94b --- /dev/null +++ b/test/asset-paths.test.js @@ -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); +}); diff --git a/test/diag.test.js b/test/diag.test.js index 0a16ebbf..3f951991 100644 --- a/test/diag.test.js +++ b/test/diag.test.js @@ -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'); + }); +}); diff --git a/test/traffic.test.js b/test/traffic.test.js new file mode 100644 index 00000000..183978d2 --- /dev/null +++ b/test/traffic.test.js @@ -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', '

hi

'); + 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); +});