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:
1 parent
05b0dba8b5
commit
be8d382c05
14 files changed
+516
-23
No files matched your search
@@ -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) |
|
||||
|
||||
@@ -289,6 +289,7 @@ services:
|
||||
| 表情显示成默认图标、「玩法说明」只有文字要点 | 素材没下载完整:重新运行 `node tools/setup.mjs`(表情和教程图随其他素材一起从公开镜像下载,不需要客户端);缺失明细在 `.cache/assets-report.json` |
|
||||
| 本地提取失败 | 游戏照常运行,只是第 6 节表格里的几样换成替代样式。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` |
|
||||
| 3D 棋盘没出现 | 需要本地提取的棋盘贴图(`node tools/doctor.mjs` 会显示「3D 棋盘可用」),以及支持 WebGL2 的浏览器。没有客户端的服务器可以从同一版本的整合包复制本地素材(第 6 节) |
|
||||
| 想知道开服电脑上传了多少流量 | 浏览器打开 `http://localhost:3000/healthz?traffic=1`(换成你的端口):`traffic.http` 是服务器启动以来各类内容(`assets` 图片和模型、`audio` 音频、`code` 页面和脚本、`data` 游戏数据和素材清单)发出的字节数和请求数(压缩后的实际字节,`notModified` 是只回了「没变」的 304),`traffic.ws` 是 WebSocket 的线上字节(`wireOut` / `wireIn`,压缩后)和压缩前的消息字节(`payloadOut` / `payloadIn`)。数字只在内存里,重启后从 0 开始。玩家那边:设置 →「问题反馈」复制的诊断里有一行 `traffic:`,是这个页面从打开起下载的字节数(`cached` 是浏览器缓存直接给的,不占流量;`external` 是其他网站,例如 Google 字体) |
|
||||
| 断线 | 同盟模拟 10 分钟内、独立模拟 24 小时内(`config.constants.singleReconnectTime`)用同一浏览器重新打开页面,自动回到原座位。同盟掉线期间按原阵容自动作战、到时自动准备(不会代为购买;想让 AI 代打请用「离开模拟 → 暂离(AI 托管)」);独立模拟不计时,等你回来 |
|
||||
|
||||
## 6. 本地客户端素材(可选)
|
||||
|
||||
+1
-1
@@ -184,7 +184,7 @@
|
||||
|
||||
标题画面页脚的「项目仓库」在新标签页打开本项目的 [GitHub 仓库](https://github.com/sganggs/Stronghold-Protocol)。
|
||||
|
||||
遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合,以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 `<me>`、`<player2>`、`<room>` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。
|
||||
遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合、打开页面以来按类别统计的下载量(图片和模型、音频、代码、数据),以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 `<me>`、`<player2>`、`<room>` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。
|
||||
|
||||
## 13. 统计(本机)
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ server/
|
||||
media.js /media/… extension-less audio → public/assets/audio
|
||||
files.js one file → response: MIME, gzip (.skel/.atlas/.json/.js/.css …), ETag / 304, cache policy, ranges
|
||||
buildTag.js build tag of the served browser runtime (/healthz build, public/js/ui/buildGuard.js)
|
||||
routes.js request listener: 414 / 400 / 405 guards, GET /healthz, then static files
|
||||
routes.js request listener: 414 / 400 / 405 guards, GET /healthz (?traffic=1: the counters), then static files
|
||||
traffic.js traffic counters: HTTP body bytes by category (assets / audio / code / data), WebSocket wire / payload
|
||||
common.js security headers, URL split, error page, JSON replies, bare 400 for unparseable requests
|
||||
boot.js boot banner (Local / LAN / tunnel URLs), port-in-use hint, graceful SIGINT / SIGTERM
|
||||
net.js session registry, send helpers, per-connection rate limit, message validation (uses shared/protocol.js)
|
||||
|
||||
+82
-4
@@ -5,19 +5,23 @@
|
||||
// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in
|
||||
// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle
|
||||
// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev
|
||||
// tools replay.
|
||||
// tools replay. The report's `traffic:` line sums what this page downloaded (Resource Timing `transferSize`: bytes
|
||||
// on the wire, 0 for a response from the browser cache) by the categories of shared/assetPaths.js trafficCategory,
|
||||
// other sites apart as `external` (host bandwidth, layer 0; the server's own counters: /healthz?traffic=1).
|
||||
//
|
||||
// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
|
||||
// error the battle isolated) | 'runner' (the local battle runner) | 'request'
|
||||
// (a failed request or error push the player was shown)
|
||||
// errorLog() / errorCount() / clearErrorLog()
|
||||
// setBattleSource(fn) the battle runner registers how to read the battle on screen
|
||||
// installTrafficMeter() / recordResourceEntry(entry) / trafficSummary() / clearTraffic() the page's download totals
|
||||
// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in)
|
||||
// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen)
|
||||
//
|
||||
// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language.
|
||||
|
||||
import { APP_VERSION } from '../../shared/constants.js';
|
||||
import { TRAFFIC_CATEGORIES, trafficCategory } from '../../shared/assetPaths.js';
|
||||
|
||||
export const LOG_MAX = 40;
|
||||
export const STACK_LINES = 6;
|
||||
@@ -87,6 +91,78 @@ export const errorLog = () => log.map((e) => ({ ...e }));
|
||||
export const errorCount = () => total;
|
||||
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; }
|
||||
|
||||
// ---- traffic: what this page downloaded ---------------------------------------------------------------------
|
||||
|
||||
/** @type {Map<string, { requests: number, bytes: number, cached: number }>} category → totals */
|
||||
const traffic = new Map();
|
||||
let trafficMeter = null;
|
||||
|
||||
/**
|
||||
* Add one Resource Timing entry (a PerformanceResourceTiming, or the page's own navigation entry) to the totals: bytes
|
||||
* on the wire (`transferSize`; headers included, 0 when the browser cache answered — counted as `cached`). A same-origin
|
||||
* URL counts under trafficCategory(path), another site's under `external`; data: / blob: URLs are not downloads.
|
||||
* @param {{ name?: string, transferSize?: number, decodedBodySize?: number }} entry
|
||||
* @param {string} [origin] the page's origin
|
||||
*/
|
||||
export function recordResourceEntry(entry, origin = globalThis.location?.origin) {
|
||||
if (!entry || typeof entry.name !== 'string') return;
|
||||
let u;
|
||||
try { u = new URL(entry.name, origin || 'http://localhost'); } catch { return; }
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
|
||||
const cat = origin && u.origin !== origin ? 'external' : trafficCategory(u.pathname);
|
||||
let t = traffic.get(cat);
|
||||
if (!t) traffic.set(cat, (t = { requests: 0, bytes: 0, cached: 0 }));
|
||||
const bytes = Number(entry.transferSize) || 0;
|
||||
t.requests++;
|
||||
t.bytes += bytes;
|
||||
if (bytes === 0 && (Number(entry.decodedBodySize) || 0) > 0) t.cached++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start summing this page's downloads (main.js at boot): the entries already in the Resource Timing buffer, then
|
||||
* every new one (a PerformanceObserver also gets the entries a full buffer drops). False where unsupported.
|
||||
* @param {any} [win]
|
||||
*/
|
||||
export function installTrafficMeter(win = globalThis) {
|
||||
if (trafficMeter) return true;
|
||||
const PO = win?.PerformanceObserver;
|
||||
if (typeof PO !== 'function') return false;
|
||||
try {
|
||||
const origin = win.location?.origin;
|
||||
for (const e of win.performance?.getEntriesByType?.('navigation') || []) recordResourceEntry(e, origin);
|
||||
trafficMeter = new PO((list) => { for (const e of list.getEntries()) recordResourceEntry(e, origin); });
|
||||
trafficMeter.observe({ type: 'resource', buffered: true });
|
||||
return true;
|
||||
} catch {
|
||||
trafficMeter = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The totals so far: `{ [category]: { requests, bytes, cached } }` in report order, plus `total`. */
|
||||
export function trafficSummary() {
|
||||
const out = {};
|
||||
const total = { requests: 0, bytes: 0, cached: 0 };
|
||||
for (const cat of [...TRAFFIC_CATEGORIES, 'external']) {
|
||||
const t = traffic.get(cat);
|
||||
if (!t) continue;
|
||||
out[cat] = { ...t };
|
||||
if (cat !== 'external') { total.requests += t.requests; total.bytes += t.bytes; total.cached += t.cached; }
|
||||
}
|
||||
out.total = total;
|
||||
return out;
|
||||
}
|
||||
export function clearTraffic() { traffic.clear(); }
|
||||
|
||||
const sizeOf = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : n >= 1e3 ? `${Math.round(n / 1e3)} KB` : `${n} B`);
|
||||
/** `assets 9.7 MB (254, 41 cached) | … | host total 21.4 MB (869)` — the report's traffic line; null when nothing was recorded. */
|
||||
export function trafficLine(summary) {
|
||||
if (!summary || !summary.total || !(summary.total.requests > 0)) return null;
|
||||
const parts = Object.entries(summary).filter(([k]) => k !== 'total')
|
||||
.map(([k, t]) => `${k} ${sizeOf(t.bytes)} (${t.requests}${t.cached ? `, ${t.cached} cached` : ''})`);
|
||||
return `${parts.join(' | ')} | host total ${sizeOf(summary.total.bytes)} (${summary.total.requests})`;
|
||||
}
|
||||
|
||||
let battleSource = null;
|
||||
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
|
||||
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; }
|
||||
@@ -177,10 +253,10 @@ const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== fa
|
||||
* error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
|
||||
* prose and exact string values in the JSON.
|
||||
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
|
||||
* now?: number, pageStart?: number, version?: string }} input
|
||||
* now?: number, pageStart?: number, version?: string, traffic?: ReturnType<typeof trafficSummary> | null }} input
|
||||
*/
|
||||
export function buildDiagnostics(input) {
|
||||
const { state = {}, env = {}, settings = null, battle = null } = input;
|
||||
const { state = {}, env = {}, settings = null, battle = null, traffic: trafficTotals = null } = input;
|
||||
const entries = input.entries ?? errorLog();
|
||||
const now = input.now ?? Date.now();
|
||||
const lines = [];
|
||||
@@ -208,6 +284,8 @@ export function buildDiagnostics(input) {
|
||||
if (settings) {
|
||||
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`);
|
||||
}
|
||||
const tl = trafficLine(trafficTotals);
|
||||
if (tl) lines.push(`traffic: ${tl}`);
|
||||
const shown = entries.slice().reverse();
|
||||
const tot = input.total ?? total;
|
||||
const drop = input.dropped ?? dropped;
|
||||
@@ -265,5 +343,5 @@ export function browserEnv() {
|
||||
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
|
||||
*/
|
||||
export function diagnosticsText({ state, settings = null, attachBattle = false }) {
|
||||
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
|
||||
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null, traffic: trafficSummary() });
|
||||
}
|
||||
+3
-1
@@ -55,7 +55,7 @@ import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/l
|
||||
import { startBuildGuard } from './ui/buildGuard.js';
|
||||
import { initLang, useLang, tickerText } from './ui/lang.js';
|
||||
import { t, N_, translateWire } from '../../shared/i18n.js';
|
||||
import { recordError } from './diag.js';
|
||||
import { recordError, installTrafficMeter } from './diag.js';
|
||||
|
||||
const RESTORE_GRACE_MS = 1500;
|
||||
const JOIN_DELAY_MS = 350;
|
||||
@@ -346,6 +346,8 @@ function installGlobalErrorHandlers() {
|
||||
|
||||
async function boot() {
|
||||
installGlobalErrorHandlers();
|
||||
// what this page downloads, by category, for the 问题反馈 report's traffic line (diag.js; nothing is sent anywhere)
|
||||
installTrafficMeter();
|
||||
// touch / hover / fullscreen classes, zoom-gesture blocking, rotation re-layout (ui/device.js, css/devices.css)
|
||||
installDeviceSupport();
|
||||
// A page restored from the back/forward cache has a dead socket and a stale token choice: start over.
|
||||
|
||||
+16
-7
@@ -4,9 +4,11 @@
|
||||
// * a URL longer than 4096 characters → 414; one that does not parse → 400;
|
||||
// * any method but GET / HEAD → 405 with `Allow: GET, HEAD`;
|
||||
// * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions,
|
||||
// rooms, matches), never cached;
|
||||
// rooms, matches), never cached; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the
|
||||
// start, WebSocket wire and payload bytes);
|
||||
// * everything else → the static files (static.js).
|
||||
// A route that throws is logged and answers 500.
|
||||
// Every response's body bytes count in the traffic counters (traffic.js meter). A route that throws is logged and
|
||||
// answers 500.
|
||||
|
||||
import { PROTOCOL_VERSION, APP_VERSION } from '../../shared/constants.js';
|
||||
import { buildTag } from './buildTag.js';
|
||||
@@ -15,20 +17,26 @@ import { setSecurityHeaders, sendError, sendJson, splitUrl } from './common.js';
|
||||
const MAX_URL_LENGTH = 4096;
|
||||
|
||||
/**
|
||||
* The GET /healthz body.
|
||||
* The GET /healthz body (`withTraffic`: plus the traffic counters, `?traffic=1`).
|
||||
* @param {{ startedAt: number, network: import('../net.js').Network, registry: import('../net.js').SessionRegistry,
|
||||
* lobby: import('../lobby.js').Lobby }} health
|
||||
* lobby: import('../lobby.js').Lobby, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter> }} health
|
||||
* @param {boolean} [withTraffic]
|
||||
*/
|
||||
export function healthReport({ startedAt, network, registry, lobby }) {
|
||||
return {
|
||||
export function healthReport({ startedAt, network, registry, lobby, traffic }, withTraffic = false) {
|
||||
const report = {
|
||||
ok: true, version: PROTOCOL_VERSION, app: APP_VERSION, uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
||||
// the runtime the server is serving right now (public/js/ui/buildGuard.js): a page whose own build is
|
||||
// older than this reloads itself, so a deploy reaches clients that never reload
|
||||
build: buildTag(),
|
||||
sockets: network.connectionCount, sessions: registry.size, ...lobby.stats(),
|
||||
};
|
||||
if (withTraffic && traffic) report.traffic = traffic.snapshot();
|
||||
return report;
|
||||
}
|
||||
|
||||
/** Does the query ask for the traffic counters (`traffic`, `traffic=1`, `traffic=true`)? */
|
||||
const wantsTraffic = (query) => /(?:^|&)traffic(?:=(?:1|true|yes))?(?:&|$)/i.test(query || '');
|
||||
|
||||
/**
|
||||
* The request listener for `http.createServer`.
|
||||
* @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
|
||||
@@ -48,7 +56,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
|
||||
return;
|
||||
}
|
||||
if (parts.rawPath === '/healthz') {
|
||||
sendJson(req, res, 200, healthReport(health));
|
||||
sendJson(req, res, 200, healthReport(health, wantsTraffic(parts.query)));
|
||||
return;
|
||||
}
|
||||
await serveStatic(req, res, parts.rawPath, parts.query);
|
||||
@@ -56,6 +64,7 @@ export function createRequestHandler({ serveStatic, health, log }) {
|
||||
|
||||
return (req, res) => {
|
||||
setSecurityHeaders(res);
|
||||
if (health.traffic) health.traffic.meter(res, splitUrl(req.url || '/')?.rawPath || '/');
|
||||
handleRequest(req, res).catch((e) => {
|
||||
log.error('[http] request failed', e);
|
||||
sendError(req, res, 500, '服务器内部错误 · Internal error');
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -61,15 +61,37 @@ export function createSessionStack(opts, { data, log }) {
|
||||
return { registry, lobby, network };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count a socket in the traffic counters (traffic.js): its wire bytes from now until it closes, the payload of every
|
||||
* message it sends (ws.send wrapped) and receives.
|
||||
* @param {import('ws').WebSocket} ws
|
||||
* @param {import('node:http').IncomingMessage} req
|
||||
* @param {ReturnType<typeof import('./traffic.js').createTrafficCounter>} traffic
|
||||
*/
|
||||
function meterSocket(ws, req, traffic) {
|
||||
const socket = req.socket;
|
||||
traffic.wsOpen(socket);
|
||||
const send = ws.send;
|
||||
ws.send = function meteredSend(data, ...rest) {
|
||||
traffic.wsSent(data);
|
||||
return send.call(this, data, ...rest);
|
||||
};
|
||||
ws.on('message', (data) => traffic.wsReceived(data));
|
||||
ws.once('close', () => traffic.wsClose(socket));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the WebSocket endpoint /ws on `server` (its 'upgrade' event).
|
||||
* @param {import('node:http').Server} server
|
||||
* @param {{ network: Network, log: object }} deps
|
||||
* @param {{ network: Network, log: object, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter> }} deps
|
||||
* @returns {WebSocketServer}
|
||||
*/
|
||||
export function attachWebSocket(server, { network, log }) {
|
||||
export function attachWebSocket(server, { network, log, traffic = null }) {
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD, perMessageDeflate: { ...WS_DEFLATE }, clientTracking: false });
|
||||
wss.on('connection', (ws, req) => network.handleConnection(ws, req));
|
||||
wss.on('connection', (ws, req) => {
|
||||
if (traffic) meterSocket(ws, req, traffic);
|
||||
network.handleConnection(ws, req);
|
||||
});
|
||||
wss.on('error', (e) => log.error('[ws] server error', e));
|
||||
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
|
||||
+8
-4
@@ -11,6 +11,8 @@
|
||||
// http/files.js one file → response: MIME, gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges
|
||||
// http/buildTag.js the build tag of the served browser runtime (/healthz `build`, public/js/ui/buildGuard.js)
|
||||
// http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static
|
||||
// http/traffic.js the traffic counters: bytes sent per category (assets / audio / code / data) and on the
|
||||
// WebSocket, since the start (GET /healthz?traffic=1)
|
||||
// http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400
|
||||
// http/boot.js a pending update package first (update.js: old files deleted, the install verified against
|
||||
// MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT /
|
||||
@@ -31,6 +33,7 @@ import { createPackRegistry } from './packs.js';
|
||||
import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js';
|
||||
import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js';
|
||||
import { createRequestHandler } from './http/routes.js';
|
||||
import { createTrafficCounter } from './http/traffic.js';
|
||||
import { answerClientError } from './http/common.js';
|
||||
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
|
||||
|
||||
@@ -54,7 +57,7 @@ export {
|
||||
* @returns {Promise<{ port: number, host: string, url: string, server: http.Server, wss: import('ws').WebSocketServer,
|
||||
* lobby: import('./lobby.js').Lobby, network: import('./net.js').Network,
|
||||
* registry: import('./net.js').SessionRegistry, packs: ReturnType<typeof createPackRegistry>,
|
||||
* close: () => Promise<void> }>}
|
||||
* traffic: ReturnType<typeof createTrafficCounter>, close: () => Promise<void> }>}
|
||||
*/
|
||||
export async function startServer(opts = {}) {
|
||||
const { port, host } = listenAddress(opts);
|
||||
@@ -73,9 +76,10 @@ export async function startServer(opts = {}) {
|
||||
resetBuildTag();
|
||||
buildTag();
|
||||
|
||||
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby }, log }));
|
||||
const traffic = createTrafficCounter();
|
||||
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic }, log }));
|
||||
server.on('clientError', answerClientError);
|
||||
const wss = attachWebSocket(server, { network, log });
|
||||
const wss = attachWebSocket(server, { network, log, traffic });
|
||||
|
||||
// The address actually bound. The default may fall back to IPv4; the returned host and url follow that.
|
||||
let boundHost;
|
||||
@@ -132,7 +136,7 @@ export async function startServer(opts = {}) {
|
||||
return closing;
|
||||
}
|
||||
|
||||
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, close };
|
||||
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, close };
|
||||
}
|
||||
|
||||
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user