refactor(net): isolate transport and wire lifecycle

This commit is contained in:
2026-09-05 03:57:32 +08:00
parent 72227efd05
commit a8873b9a84
3 changed files with 517 additions and 3 deletions
@@ -0,0 +1,202 @@
import {
RECONNECT_INTERVAL_MS,
RECV_TIMEOUT_MS,
SERVER_ROTATE_EVERY,
} from '../core/constants.ts';
import type { InboundMessage, OutboundEnvelope } from '../core/types/envelope.ts';
import { decodeFrame, encodeOutbound } from './envelope-codec.ts';
import { HeartbeatWatchdog, realClock, type Clock } from './heartbeat.ts';
import { ReconnectPolicy } from './reconnect.ts';
import type { Transport } from './transport.ts';
export type WireEvent =
| { readonly type: 'open'; readonly server: string }
| { readonly type: 'message'; readonly message: InboundMessage }
| { readonly type: 'slow' }
| { readonly type: 'reconnecting'; readonly server: string }
| { readonly type: 'close' };
export interface WireClientOptions {
readonly servers: readonly string[];
readonly transportFactory: () => Transport;
readonly clock?: Clock;
}
type WireListener = (event: WireEvent) => void;
function assertSwitchTarget(value: string): void {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error('switchServer requires a valid WebSocket URL');
}
if ((parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') || parsed.host.length === 0) {
throw new Error('switchServer requires a valid WebSocket URL');
}
}
/**
* 只拥有 WebSocket 生命周期、帧编解码、收包看门狗和重连策略。
* 登录、踢人、切服 RPC 等业务意图均作为 message 原样交给唯一上层编排。
*/
export class WireClient {
private readonly transportFactory: () => Transport;
private readonly clock: Clock;
private readonly policy: ReconnectPolicy;
private readonly watchdog: HeartbeatWatchdog;
private readonly listeners = new Set<WireListener>();
private transport: Transport | null = null;
private reconnectTimer: unknown = null;
private generation = 0;
private started = false;
private stopped = false;
private open = false;
constructor(options: WireClientOptions) {
if (!Array.isArray(options.servers) || options.servers.length === 0) {
throw new Error('servers must be a non-empty validated list');
}
this.transportFactory = options.transportFactory;
this.clock = options.clock ?? realClock;
this.policy = new ReconnectPolicy([...options.servers], SERVER_ROTATE_EVERY);
this.watchdog = new HeartbeatWatchdog(
RECV_TIMEOUT_MS,
() => this.onReceiveTimeout(),
this.clock,
);
}
subscribe(listener: WireListener): () => void {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
}
start(): void {
if (this.started) throw new Error('WireClient already started');
if (this.stopped) throw new Error('WireClient already stopped');
this.started = true;
this.connect(this.policy.current());
}
send(envelope: OutboundEnvelope): void {
if (!this.open || this.stopped || this.transport === null) {
throw new Error('connection is not open');
}
this.transport.send(encodeOutbound(envelope.route, envelope.rpc, envelope.data));
}
switchServer(url: string): void {
assertSwitchTarget(url);
this.assertRunning();
this.policy.setCurrent(url);
this.disconnectAndSchedule(this.policy.current());
}
reconnectCurrent(): void {
this.assertRunning();
this.disconnectAndSchedule(this.policy.current());
}
stop(): void {
if (this.stopped) return;
this.stopped = true;
this.watchdog.stop();
this.clearReconnectTimer();
const transport = this.transport;
const hadTransport = transport !== null;
this.transport = null;
this.open = false;
this.generation++;
transport?.close();
if (hadTransport) this.emit({ type: 'close' });
}
private assertRunning(): void {
if (!this.started || this.stopped) throw new Error('WireClient is not running');
}
private emit(event: WireEvent): void {
for (const listener of [...this.listeners]) listener(event);
}
private connect(server: string): void {
if (this.stopped) return;
const generation = ++this.generation;
const transport = this.transportFactory();
this.transport = transport;
this.open = false;
transport.onOpen(() => {
if (!this.isCurrent(generation, transport)) return;
this.open = true;
this.emit({ type: 'open', server });
});
transport.onMessage((frame) => {
if (!this.isCurrent(generation, transport) || !this.open) return;
this.onMessage(frame);
});
transport.onClose(() => {
if (!this.isCurrent(generation, transport)) return;
this.onUnexpectedClose();
});
transport.connect(server);
}
private isCurrent(generation: number, transport: Transport): boolean {
return !this.stopped && generation === this.generation && transport === this.transport;
}
private onMessage(frame: string): void {
const decoded = decodeFrame(frame);
if (decoded.kind === 'ignore') return;
this.watchdog.feed();
if (decoded.kind !== 'message') return;
this.emit({ type: 'message', message: decoded.message });
}
private onReceiveTimeout(): void {
if (!this.open || this.stopped) return;
this.emit({ type: 'slow' });
this.disconnectAndSchedule(this.policy.onFailure());
}
private onUnexpectedClose(): void {
this.transport = null;
this.open = false;
this.generation++;
this.watchdog.stop();
this.emit({ type: 'close' });
this.scheduleReconnect(this.policy.onFailure());
}
private disconnectAndSchedule(server: string): void {
const transport = this.transport;
const hadTransport = transport !== null;
this.transport = null;
this.open = false;
this.generation++;
this.watchdog.stop();
this.clearReconnectTimer();
transport?.close();
if (hadTransport) this.emit({ type: 'close' });
this.scheduleReconnect(server);
}
private scheduleReconnect(server: string): void {
this.clearReconnectTimer();
this.emit({ type: 'reconnecting', server });
this.reconnectTimer = this.clock.setTimeout(() => {
this.reconnectTimer = null;
this.connect(server);
}, RECONNECT_INTERVAL_MS);
}
private clearReconnectTimer(): void {
if (this.reconnectTimer === null) return;
this.clock.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}