实现账号绑定、持久登录与跨设备对局恢复
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
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
This commit is contained in:
1 parent
488db49f11
commit
7c25c89992
62 files changed
+6855
-1005
No files matched your search
+55
-3
@@ -17,6 +17,9 @@
|
||||
// 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 /
|
||||
@@ -29,6 +32,7 @@
|
||||
// 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';
|
||||
@@ -42,6 +46,13 @@ 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 {
|
||||
@@ -60,8 +71,12 @@ export {
|
||||
* 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>,
|
||||
@@ -83,14 +98,20 @@ export async function startServer(opts = {}) {
|
||||
const indexOptions = assetIndexOptionsFrom(opts, publicDir);
|
||||
const assetIndex = indexOptions ? createAssetIndex({ publicDir, log, ...indexOptions }) : null;
|
||||
const views = assetIndex ? createManifestViews({ dataDir, index: assetIndex, log }) : null;
|
||||
const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, packsDir, packs, log, assetIndex, views });
|
||||
// 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 }));
|
||||
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 });
|
||||
|
||||
@@ -142,6 +163,7 @@ export async function startServer(opts = {}) {
|
||||
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?.();
|
||||
@@ -152,7 +174,37 @@ export async function startServer(opts = {}) {
|
||||
return closing;
|
||||
}
|
||||
|
||||
return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, traffic, assetIndex, views, close };
|
||||
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).
|
||||
|
||||
Reference in new issue
Block a user