CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
92 lines
5.5 KiB
JavaScript
92 lines
5.5 KiB
JavaScript
// server/http/routes.js — the node:http request listener. Every response gets the security headers (common.js), then:
|
|
// (i18n-ignore-file: the error pages are bilingual by design, 中文 · English — docs/I18N.md)
|
|
//
|
|
// * a URL longer than 4096 characters → 414; one that does not parse → 400;
|
|
// * /skland/* → the Skland module's own listener when it is enabled (server/skland/http.js; SP_SKLAND=1). It owns
|
|
// its methods (POST), so it runs before the GET/HEAD gate below;
|
|
// * 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; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the
|
|
// start, WebSocket wire and payload bytes) for a loopback or private-network client only — the maintainer's
|
|
// decision of 2026-10-12: the figures are the host's, so any other client, also one a tunnel or proxy on this
|
|
// machine forwards, gets the plain report (net.js clientAddress, honouring TRUST_PROXY like the WebSocket limits);
|
|
// * everything else → the static files (static.js).
|
|
// 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';
|
|
import { setSecurityHeaders, sendError, sendJson, splitUrl } from './common.js';
|
|
import { clientAddress, isLocalIp } from '../net.js';
|
|
|
|
const MAX_URL_LENGTH = 4096;
|
|
|
|
/**
|
|
* The GET /healthz body (`withTraffic`: plus the traffic counters and the asset index's state, `?traffic=1`).
|
|
* @param {{ startedAt: number, network: import('../net.js').Network, registry: import('../net.js').SessionRegistry,
|
|
* lobby: import('../lobby.js').Lobby, traffic?: ReturnType<typeof import('./traffic.js').createTrafficCounter>,
|
|
* assetIndex?: ReturnType<typeof import('./assetIndex.js').createAssetIndex> | null,
|
|
* views?: ReturnType<typeof import('./assetViews.js').createManifestViews> | null }} health
|
|
* @param {boolean} [withTraffic]
|
|
*/
|
|
export function healthReport({ startedAt, network, registry, lobby, traffic, assetIndex, views }, 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();
|
|
// content-hashed asset URLs (assetIndex.js / assetViews.js): off, building, or ready with how many files and URLs
|
|
if (withTraffic) report.assetHashes = assetIndex ? { ...assetIndex.stats(), views: views ? views.stats() : null } : null;
|
|
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,
|
|
* rawPath: string, query: string) => Promise<void>,
|
|
* health: Parameters<typeof healthReport>[0], log: object, trustProxy?: 'auto' | boolean,
|
|
* skland?: ((req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse,
|
|
* parts: { rawPath: string, query: string }) => Promise<boolean>) | null }} deps
|
|
* trustProxy: whose forwarding headers name the client (net.js clientAddress; the server's TRUST_PROXY)
|
|
* skland: the /skland/* handler when the module is enabled (server/skland/http.js; SP_SKLAND=1). It owns its paths
|
|
* and its methods (POST), so it runs before the GET/HEAD method gate.
|
|
* @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void}
|
|
*/
|
|
export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto', skland = null }) {
|
|
async function handleRequest(req, res) {
|
|
const url = req.url || '/';
|
|
if (url.length > MAX_URL_LENGTH) { sendError(req, res, 414, '请求地址过长 · URI too long'); return; }
|
|
const parts = splitUrl(url);
|
|
if (!parts) { sendError(req, res, 400, '请求地址无效 · Bad request'); return; }
|
|
if (skland && (parts.rawPath === '/skland' || parts.rawPath.startsWith('/skland/'))) {
|
|
if (await skland(req, res, parts)) return;
|
|
}
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
res.setHeader('Allow', 'GET, HEAD');
|
|
sendError(req, res, 405, '不支持的请求方法 · Method not allowed');
|
|
return;
|
|
}
|
|
if (parts.rawPath === '/healthz') {
|
|
const withTraffic = wantsTraffic(parts.query) && isLocalIp(clientAddress(req, trustProxy).ip);
|
|
sendJson(req, res, 200, healthReport(health, withTraffic));
|
|
return;
|
|
}
|
|
await serveStatic(req, res, parts.rawPath, parts.query);
|
|
}
|
|
|
|
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');
|
|
});
|
|
};
|
|
}
|