实现账号绑定、持久登录与跨设备对局恢复
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:
herryyun committed 2026-10-14 23:22:50 +08:00
1 parent 488db49f11
commit 7c25c89992
62 files changed
+6855 -1005

No files matched your search

+22
View File
@@ -184,6 +184,16 @@ export class SessionRegistry {
if (this.byTokenMap.get(s.token) === s) this.byTokenMap.delete(s.token);
}
/** Rotate an authenticated session's bearer after attaching a new socket. @param {Session} s */
rotateToken(s) {
const previous = s.token;
if (this.byTokenMap.get(previous) === s) this.byTokenMap.delete(previous);
let token;
do token = newToken(); while (token === previous || this.byTokenMap.has(token));
s.token = token;
this.byTokenMap.set(token, s);
}
/**
* The reconnect window of one session: the registry's, or the session's own `resumeWindowMs` when that is longer
* (the lobby may only extend it, never shorten it).
@@ -526,6 +536,8 @@ export class Network {
/** @type {Map<string, number>} open sockets per client network key */
this.connsPerKey = new Map();
this.closed = false;
// Optional fork account policy; transport and the upstream game protocol remain usable when it is absent.
this.accountPolicy = null;
this.heartbeatTimer = setInterval(() => this.heartbeat(), this.opts.heartbeatMs);
this.heartbeatTimer.unref?.();
const sweepMs = Math.max(20, Math.min(15_000, Math.floor(this.opts.reconnectWindowMs / 4)));
@@ -559,6 +571,7 @@ export class Network {
if (this.closed) { try { ws.close(CLOSE.SHUTDOWN, 'server shutdown'); } catch { /* ignore */ } return; }
const conn = new Connection(ws, clientAddress(req, this.opts.trustProxy), this.now(), this.opts);
this.conns.set(ws, conn);
if (this.accountPolicy) conn.account = this.accountPolicy.connection(req);
if (conn.key) this.connsPerKey.set(conn.key, (this.connsPerKey.get(conn.key) || 0) + 1);
ws.on('message', (data, isBinary) => {
try { this.onFrame(conn, data, isBinary); } catch (e) { this.log.error('[net] frame handler crashed', e); }
@@ -612,6 +625,9 @@ export class Network {
}
if (msg.t === 'hello') { this.onHelloMsg(conn, msg, now); return; }
if (!conn.session) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'hello required')); return; }
if (this.accountPolicy && !this.accountPolicy.authorized(conn)) {
this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'account binding required')); return;
}
if (HEAVY_TYPES.has(msg.t) && !conn.heavy.take(now)) { this.reply(conn, errorMsg(ERR.RATE, rid, `${msg.t} too often`)); return; }
let res;
@@ -640,6 +656,11 @@ export class Network {
this.reply(conn, errorMsg(ERR.BAD_MSG, rid, `version mismatch: server ${PROTOCOL_VERSION}`));
return;
}
if (this.accountPolicy) {
const account = this.accountPolicy.hello(conn, msg);
if (!account) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'account authentication required')); return; }
msg = { ...msg, ...account };
}
const name = sanitizeName(msg.name);
if (!name) { this.reply(conn, errorMsg(ERR.BAD_MSG, rid, 'bad field name')); return; }
@@ -670,6 +691,7 @@ export class Network {
session.lastSeen = now;
session.addr = conn.ip;
session.limitKey = conn.key;
this.accountPolicy?.attached?.(conn, { repeat });
let extra = null;
try { extra = this.handler.welcomeInfo?.() ?? null; } catch (e) { this.log.error('[net] welcomeInfo crashed', e); }