refactor(net): isolate transport and wire lifecycle
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user