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
212 lines
13 KiB
JavaScript
212 lines
13 KiB
JavaScript
// server/index.js — process entry & boot (DESIGN §1, §2). Plain node:http + ws, no framework: startServer() below
|
|
// wires the modules under server/http/, in this order —
|
|
//
|
|
// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST '::' dual-stack, TRUST_PROXY auto, DEBUG),
|
|
// which startServer() options go to net.js / lobby.js, the console logger
|
|
// http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB,
|
|
// permessage-deflate from 1 KB; refused at upgrade with 404 / 429 per network / 503)
|
|
// http/static.js the static mounts (/ → public/, /data/, /shared/, /sim/ `.js` only), the /data.js browser stand-in,
|
|
// the content packs (/packs/index.json, /packs/<id>/<file> — the registry is packs.js)
|
|
// http/media.js /media/bgm/act1 → public/assets/audio/bgm/act1.mp3 (audio addressed without its extension)
|
|
// http/files.js one file → response: MIME, brotli / gzip + memory cache, ETag / Last-Modified / 304, Cache-Control, ranges
|
|
// http/assetIndex.js the content hashes of public/assets and public/fonts (built in the background, cached in
|
|
// .cache/asset-hashes.json by path + size + mtime; Spine models share one hash)
|
|
// http/assetViews.js the served /data/assets.json and /data/local-assets.json: every art URL with its hash segment
|
|
// (/assets/~<hash>/…, immutable when the hash matches); the committed files stay unchanged
|
|
// 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)
|
|
// skland/ the opt-in Skland account binding (SP_SKLAND=1): /skland/* — QR / 验证码 / 密码 login, one
|
|
// SQLite row per player. server/skland/http.js is handed to routes.js as its own listener (it
|
|
// owns POST); docs/skland.md
|
|
// 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 /
|
|
// SIGTERM
|
|
//
|
|
// Per-network limits for internet clients (see net.js clientAddress; local/LAN peers are exempt): open sockets
|
|
// (maxConnectionsPerAddr, refused at upgrade with 429), rooms and running matches (lobby.js).
|
|
//
|
|
// Programmatic use (tests): `const srv = await startServer({ port: 0, quiet: true }); … await srv.close();`
|
|
// The server only auto-listens when this file is the process entry point.
|
|
|
|
import http from 'node:http';
|
|
import path from 'node:path';
|
|
import { getData, loadData } from './data.js';
|
|
import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy, assetIndexOptionsFrom } from './http/config.js';
|
|
import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js';
|
|
import { DATA_SHIM_JS, createStaticHandler } from './http/static.js';
|
|
import { createPackRegistry } from './packs.js';
|
|
import { MIME, COMPRESSIBLE, acceptsGzip, pickEncoding, 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 { createAssetIndex } from './http/assetIndex.js';
|
|
import { createManifestViews } from './http/assetViews.js';
|
|
import { answerClientError } from './http/common.js';
|
|
import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js';
|
|
import { createSklandStore } from './skland/store.js';
|
|
import { createSklandService } from './skland/service.js';
|
|
import { createSklandHttp, createSklandOffHandler } from './skland/http.js';
|
|
import { createSessionPlayerResolver } from './skland/session.js';
|
|
import { createAccountAuth } from './skland/auth.js';
|
|
import { createAccountHttp } from './skland/accountHttp.js';
|
|
import { createAccountRecovery } from './skland/recovery.js';
|
|
|
|
// The public API of this module (tests and tools import it from here); the code lives in ./http/.
|
|
export {
|
|
ROOT, WS_MAX_PAYLOAD, DATA_SHIM_JS, MIME, COMPRESSIBLE, BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag,
|
|
acceptsGzip, pickEncoding, parseRange, createStaticHandler, lanUrls, parseTrustProxy,
|
|
};
|
|
|
|
/**
|
|
* Build and start the HTTP + WebSocket server.
|
|
* @param {{
|
|
* port?: number, host?: string, quiet?: boolean, log?: object,
|
|
* publicDir?: string, dataDir?: string, sharedDir?: string, packsDir?: string,
|
|
* MatchClass?: Function, seedFn?: () => number,
|
|
* lobbyGraceMs?: number, reconnectWindowMs?: number, heartbeatMs?: number, helloTimeoutMs?: number,
|
|
* ratePerSec?: number, rateBurst?: number, maxConnections?: number, maxRooms?: number,
|
|
* maxConnectionsPerAddr?: number, maxRoomsPerAddr?: number, maxMatchesPerAddr?: number, resyncMinGapMs?: number,
|
|
* heavyPerSec?: number, heavyBurst?: number, trustProxy?: 'auto' | boolean, soloReconnectWindowMs?: number,
|
|
* assetIndex?: boolean | { cacheFile?: string | null, delayMs?: number },
|
|
* skland?: boolean | { storeFile?: string, fetchImpl?: typeof fetch, client?: object, scanTimeoutMs?: number,
|
|
* authSecret?: string, publicOrigin?: string, diagnosticKey?: string, authNow?: () => number },
|
|
* }} [opts]
|
|
* assetIndex: the content-hash index (default on; false or SP_ASSET_INDEX=0: off — config.js assetIndexOptionsFrom)
|
|
* skland: the Skland account binding (server/skland/), off by default (SP_SKLAND=1 turns it on). `storeFile` picks
|
|
* the SQLite file (SP_SKLAND_DB, default data/skland.db); `fetchImpl` replaces the upstream HTTP (tests).
|
|
* @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>,
|
|
* traffic: ReturnType<typeof createTrafficCounter>, assetIndex: ReturnType<typeof createAssetIndex> | null,
|
|
* views: ReturnType<typeof createManifestViews> | null, close: () => Promise<void> }>}
|
|
*/
|
|
export async function startServer(opts = {}) {
|
|
const { port, host } = listenAddress(opts);
|
|
const log = opts.log || makeLogger(!!opts.quiet);
|
|
const { publicDir, dataDir, sharedDir, packsDir } = serveDirs(opts);
|
|
|
|
// The process-wide singleton serves the default data dir; a custom dir (tests) gets its own copy.
|
|
const data = opts.dataDir ? loadData(dataDir, { log }) : getData({ dir: dataDir, log });
|
|
const { registry, lobby, network } = createSessionStack(opts, { data, log });
|
|
// content packs (docs/PACKS.md): scanned now — the start log names them — and again whenever their folders change
|
|
const packs = createPackRegistry({ publicDir, dataDir, packsDir }, { log });
|
|
packs.refresh(true);
|
|
// the content hashes of the art (built in the background once listening) and the manifests served with them
|
|
const indexOptions = assetIndexOptionsFrom(opts, publicDir);
|
|
const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : null;
|
|
const views = assetIndex ? createManifestViews({ dataDir, index: assetIndex, log }) : null;
|
|
// The Skland account binding (server/skland/): off unless asked for (SP_SKLAND=1, or opts.skland). Its own SQLite
|
|
// file (SP_SKLAND_DB, default <dataDir>/skland.db) holds one row per player; the routes are /skland/* (POST).
|
|
const skland = createSklandModule(opts, { log, registry, network, lobby });
|
|
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views,
|
|
privateFiles: skland?.privateFiles || [] });
|
|
// Off still owns /skland/*: one uniform JSON 404 beats a static 404 for GET and a 405 for POST (skland/http.js).
|
|
const sklandHttp = skland ? skland.http : createSklandOffHandler();
|
|
const startedAt = Date.now();
|
|
// The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz.
|
|
resetBuildTag();
|
|
buildTag();
|
|
|
|
const traffic = createTrafficCounter();
|
|
const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log, trustProxy: network.opts.trustProxy, skland: sklandHttp }));
|
|
server.on('clientError', answerClientError);
|
|
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;
|
|
try {
|
|
// A host with IPv6 switched off refuses '::'. Fall back to IPv4 rather than not booting. Only the default is
|
|
// retried: an explicit HOST is literal (server/http/config.js bindCandidates).
|
|
let bound = null;
|
|
let lastError = null;
|
|
const candidates = bindCandidates(host);
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
const candidate = candidates[i];
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
const onError = (e) => { server.off('listening', onListening); reject(e); };
|
|
const onListening = () => { server.off('error', onError); resolve(); };
|
|
server.once('error', onError);
|
|
server.once('listening', onListening);
|
|
server.listen(port, candidate);
|
|
});
|
|
bound = candidate;
|
|
break;
|
|
} catch (e) {
|
|
lastError = e;
|
|
const retry = ['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EINVAL'].includes(e.code) && i < candidates.length - 1;
|
|
if (!retry) break;
|
|
log.warn(`[boot] cannot bind ${candidate} (${e.code}) — falling back to IPv4 only`);
|
|
}
|
|
}
|
|
if (bound === null) throw lastError;
|
|
boundHost = bound;
|
|
} catch (e) {
|
|
network.close(); // stop heartbeat/sweep timers of the half-built server
|
|
assetIndex?.close();
|
|
throw e;
|
|
}
|
|
server.on('error', (e) => log.error('[http] server error', e));
|
|
assetIndex?.start();
|
|
|
|
const addr = server.address();
|
|
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
const url = `http://${displayHost(boundHost)}:${actualPort}`;
|
|
|
|
let closing = null;
|
|
async function close() {
|
|
if (closing) return closing;
|
|
closing = (async () => {
|
|
try { lobby.shutdown('shutdown'); } catch (e) { log.error('[shutdown] lobby', e); }
|
|
network.close();
|
|
assetIndex?.close();
|
|
try { skland?.close(); } catch (e) { log.error('[shutdown] skland', e); }
|
|
await new Promise((resolve) => {
|
|
server.close(() => resolve());
|
|
server.closeIdleConnections?.();
|
|
setTimeout(() => { server.closeAllConnections?.(); }, 500).unref();
|
|
});
|
|
try { wss.close(); } catch { /* ignore */ }
|
|
})();
|
|
return closing;
|
|
}
|
|
|
|
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, skland: skland ? skland.service : null, close };
|
|
}
|
|
|
|
/**
|
|
* The Skland module (server/skland/), or null when it is off. Off is the default: an unconfigured server keeps no
|
|
* account credentials at all. Turn it on with `SP_SKLAND=1` (or `opts.skland`), which every caller of startServer can
|
|
* pass — the HTTP tests inject their own store file and a stub `fetchImpl`, so nothing here reaches the network.
|
|
* @param {Record<string, any>} opts
|
|
* @param {{ log: object, registry: import('./net.js').SessionRegistry, network: import('./net.js').Network }} deps
|
|
* @returns {{ service: any, http: any, privateFiles: string[], close: () => void } | null}
|
|
*/
|
|
function createSklandModule(opts, { log, registry, network, lobby }) {
|
|
const cfg = opts.skland;
|
|
const enabled = !!cfg || process.env.SP_SKLAND === '1';
|
|
if (!enabled) return null;
|
|
const conf = typeof cfg === 'object' ? cfg : {};
|
|
const file = conf.storeFile || process.env.SP_SKLAND_DB || path.join(ROOT, 'data', 'skland.db');
|
|
const store = createSklandStore({ file, log });
|
|
const service = createSklandService({ store, fetchImpl: conf.fetchImpl, client: conf.client, scanTimeoutMs: conf.scanTimeoutMs });
|
|
const auth = createAccountAuth({ file: store.file, service, registry, secret: conf.authSecret || process.env.SP_AUTH_SECRET,
|
|
publicOrigin: conf.publicOrigin || process.env.SP_PUBLIC_ORIGIN, diagnosticKey: conf.diagnosticKey || process.env.SP_SKLAND_DIAGNOSTIC_KEY,
|
|
trustProxy: network.opts.trustProxy, now: conf.authNow, onExpire: (session) => lobby.onExpire(session) });
|
|
network.accountPolicy = auth.gamePolicy;
|
|
log.info?.(`[skland] account binding on — ${store.file}`);
|
|
return {
|
|
service,
|
|
http: createAccountHttp({ service, auth, registry, recovery: createAccountRecovery({ registry, lobby }),
|
|
diagnostic: createSklandHttp({ service, sessionPlayer: createSessionPlayerResolver(registry) }) }),
|
|
privateFiles: store.file === ':memory:' ? [] : [store.file, store.file + '-wal', store.file + '-shm'],
|
|
close: () => { auth.close(); store.close(); },
|
|
};
|
|
}
|
|
|
|
// `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js).
|
|
if (isProcessEntry(import.meta.url)) runMain(startServer);
|