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
+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).
|
||||
|
||||
Reference in new issue
Block a user