fix(platform): serialize runtime session transitions

This commit is contained in:
2026-09-05 06:17:04 +08:00
parent 1c124cf216
commit 7e5a950cc7
13 changed files with 1297 additions and 157 deletions
@@ -33,6 +33,7 @@ export {
parseOtherOfflinePayload,
parseOtherOnlinePayload,
parsePlayerPreparePayload,
parseSelfExitRoomPayload,
parseSelfJoinRoomResponse,
type JoinRoomEnvironment,
type JoinRoomPayload,
@@ -43,6 +44,7 @@ export {
type ParsedRoomResponse,
type ParsedRoomserverPayload,
type ParsedRoomSnapshot,
type ParsedSelfExitPayload,
type ParsedSeatPayload,
type ReconnectSnapshot,
type RoomActionPayload,
@@ -150,6 +150,7 @@ export interface ParsedRoomResponse {
readonly state: number;
readonly room: ParsedRoomSnapshot | null;
readonly reconnect: ReconnectSnapshot;
readonly stage: 0 | 1;
readonly raw: JsonRecord;
}
@@ -157,7 +158,13 @@ export function parseSelfJoinRoomResponse(value: unknown): ParsedRoomResponse {
const raw = requireRecord(value, '$.data');
const state = requireInteger(raw.state, '$.data.state');
const room = state === 0 ? parseRoomEntryFields(raw) : null;
return { state, room, reconnect: reconnectFrom(raw), raw };
const parsedDeskwar = optionalField(raw, 'deskwar', '$.data', requireInteger);
if (parsedDeskwar !== undefined && parsedDeskwar !== 0 && parsedDeskwar !== 1) {
throw new RangeError('$.data.deskwar: expected protocol flag 0 or 1');
}
const reconnect = reconnectFrom(raw);
const stage = reconnect.present ? 1 : parsedDeskwar ?? 0;
return { state, room, reconnect, stage, raw };
}
export interface ParsedSeatPayload {
@@ -177,6 +184,20 @@ export const parseOtherJoinRoomPayload = parseSeatPayload;
export const parseOtherExitRoomPayload = parseSeatPayload;
export const parseOtherOfflinePayload = parseSeatPayload;
export interface ParsedSelfExitPayload {
readonly seat?: number;
readonly raw: JsonRecord;
}
export function parseSelfExitRoomPayload(value: unknown): ParsedSelfExitPayload {
const raw = requireRecord(value, '$.data');
const seat = optionalField(raw, 'seat', '$.data', requireInteger);
if (seat !== undefined && seat < 0) {
throw new RangeError('$.data.seat: expected non-negative server seat, received number');
}
return { ...(seat === undefined ? {} : { seat }), raw };
}
export interface ParsedOnlinePayload extends ParsedSeatPayload {
readonly ip: string;
}
@@ -12,6 +12,7 @@ import {
parseOtherOfflinePayload,
parseOtherOnlinePayload,
parsePlayerPreparePayload,
parseSelfExitRoomPayload,
} from './contracts/index.ts';
import {
buildFirstSliceEnvelope,
@@ -67,7 +68,7 @@ export class PlatformHandlers {
break;
}
case 'self_exit_room': {
const parsed = parseOtherExitRoomPayload(message.data);
const parsed = parseSelfExitRoomPayload(message.data);
this.options.session.handleSelfExit(parsed);
break;
}
@@ -103,22 +104,26 @@ export class PlatformHandlers {
const parsed = parseConnectRoomserverPayload(message.data);
const target = parseSwitchTarget(parsed.roomserver, '$.data.roomserver');
const resend = buildFirstSliceEnvelope('connect_roomserver', parsed.raw);
this.options.session.handleServerSwitch();
this.options.connection.switchServer(target, resend);
this.options.session.handleServerSwitch(
() => this.options.connection.switchServer(target, resend),
);
break;
}
case 'connect_agentserver': {
const parsed = parseConnectAgentserverPayload(message.data);
const target = parseSwitchTarget(parsed.agentserver, '$.data.agentserver');
const resend = buildFirstSliceEnvelope('connect_agentserver', parsed.raw);
this.options.session.handleServerSwitch();
this.options.connection.switchServer(target, resend);
this.options.session.handleServerSwitch(
() => this.options.connection.switchServer(target, resend),
);
break;
}
case 'kick_server': {
const parsed = parseKickServerPayload(message.data);
this.options.session.handleKick(parsed.raw);
this.options.connection.stopForKick(parsed.raw);
this.options.session.handleKick(
parsed.raw,
() => this.options.connection.stopForKick(parsed.raw),
);
break;
}
}
@@ -141,14 +146,10 @@ function parseSwitchTarget(address: string, path: string): string {
throw new TypeError(`${path}: expected a non-empty WebSocket server address`);
}
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(address);
let target: string;
if (scheme) {
if (scheme[1] !== 'ws' && scheme[1] !== 'wss') {
throw new TypeError(`${path}: unsupported WebSocket scheme ${scheme[1]}`);
}
target = address;
} else {
target = `ws://${address}`;
const target = scheme ? address : `ws://${address}`;
const authority = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/.exec(target)?.[1];
if (authority === undefined || authority.length === 0) {
throw new TypeError(`${path}: expected a WebSocket address with a non-empty host`);
}
let parsed: URL;
@@ -157,7 +158,14 @@ function parseSwitchTarget(address: string, path: string): string {
} catch {
throw new TypeError(`${path}: expected a valid WebSocket server address`);
}
if ((parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') || parsed.hostname.length === 0) {
const protocol = parsed.protocol.toLowerCase();
if (
(protocol !== 'ws:' && protocol !== 'wss:')
|| parsed.hostname.length === 0
|| parsed.username.length > 0
|| parsed.password.length > 0
|| parsed.hash.length > 0
) {
throw new TypeError(`${path}: expected a valid WebSocket server address`);
}
return target;
@@ -14,11 +14,43 @@ export interface RouterOptions {
readonly gameSession: Pick<GameSessionHost, 'dispatchGameMessage'>;
}
interface QueuedDispatch {
readonly message: InboundMessage;
result?: RouterDispatchResult;
}
/** Classifies each inbound business envelope exactly once. */
export class Router {
private readonly queue: QueuedDispatch[] = [];
private draining = false;
constructor(private readonly options: RouterOptions) {}
dispatch(message: InboundMessage): RouterDispatchResult {
const queued: QueuedDispatch = { message };
this.queue.push(queued);
if (this.draining) return this.resultWithoutSideEffects(message);
this.draining = true;
try {
while (this.queue.length > 0) {
const next = this.queue.shift();
if (next === undefined) continue;
next.result = this.dispatchOne(next.message);
}
} catch (error) {
this.queue.length = 0;
throw error;
} finally {
this.draining = false;
}
if (queued.result === undefined) {
throw new Error('Router failed to produce a dispatch result');
}
return queued.result;
}
private dispatchOne(message: InboundMessage): RouterDispatchResult {
if (this.options.session.loginPending && !isLoginGateControl(message)) {
return 'ignored-login-gate';
}
@@ -31,6 +63,13 @@ export class Router {
});
return 'handled-game';
}
private resultWithoutSideEffects(message: InboundMessage): RouterDispatchResult {
if (this.options.session.loginPending && !isLoginGateControl(message)) {
return 'ignored-login-gate';
}
return isPlatformRoute(message.route) ? 'handled-platform' : 'handled-game';
}
}
function isPlatformRoute(route: string): route is 'platform' | 'agent' | 'room' {