feat: add strict local login websocket transport

This commit is contained in:
2026-09-05 18:05:17 +08:00
parent 6976b55038
commit a3cd74d316
2 changed files with 347 additions and 0 deletions
@@ -0,0 +1,117 @@
import {
assertLocalConnectionTarget,
type LocalStartupContext,
} from '../../framework/config/local-startup.ts';
import type { Transport } from '../../framework/net/transport.ts';
export type LocalLoginEvidence = { kind: string; data: unknown };
export type EvidenceSink = (event: LocalLoginEvidence) => void;
interface LocalLoginTransportOptions {
context: LocalStartupContext;
createSocket: (url: string) => WebSocket;
onFault: (error: unknown) => void;
record: EvidenceSink;
}
/** Local-only socket boundary; raw evidence precedes protocol decoding. */
export class LocalLoginTransport implements Transport {
private socket: WebSocket | null = null;
private generation = 0;
private openCallback?: () => void;
private messageCallback?: (frame: string) => void;
private closeCallback?: () => void;
constructor(private readonly options: LocalLoginTransportOptions) {}
connect(url: string): void {
try {
assertLocalConnectionTarget(this.options.context, url);
} catch (error) {
this.options.onFault(error);
throw error;
}
this.close();
let socket: WebSocket;
try {
socket = this.options.createSocket(url);
} catch (error) {
this.options.onFault(error);
throw error;
}
this.socket = socket;
const generation = this.generation;
socket.onopen = () => {
if (this.socket === socket) this.openCallback?.();
};
socket.onmessage = (event) => {
if (this.socket !== socket) return;
if (typeof event.data !== 'string') {
this.stopWithFault(new TypeError('Local login WebSocket requires text frames'));
return;
}
try {
this.options.record({ kind: 'transport-receive', data: event.data });
} catch (error) {
this.stopWithFault(error);
throw error;
}
if (this.socket === socket) this.messageCallback?.(event.data);
};
socket.onclose = () => {
if (this.socket !== socket) return;
this.socket = null;
this.closeCallback?.();
};
socket.onerror = (event) => {
if (this.socket !== socket) return;
this.stopWithFault(event);
if (this.generation === generation) this.closeCallback?.();
};
}
send(frame: string): void {
const socket = this.socket;
if (!socket || socket.readyState !== 1) {
throw new Error('Local login WebSocket must be OPEN before send');
}
try {
socket.send(frame);
this.options.record({ kind: 'transport-send', data: frame });
} catch (error) {
this.stopWithFault(error);
throw error;
}
}
close(): void {
this.generation++;
const socket = this.socket;
this.socket = null;
if (!socket) return;
try {
socket.close();
} catch (error) {
this.options.onFault(error);
throw error;
}
}
onOpen(callback: () => void): void { this.openCallback = callback; }
onMessage(callback: (frame: string) => void): void { this.messageCallback = callback; }
onClose(callback: () => void): void { this.closeCallback = callback; }
private stopWithFault(error: unknown): void {
const socket = this.socket;
this.socket = null;
const cleanupErrors: unknown[] = [];
try {
socket?.close();
} catch (cleanupError) {
cleanupErrors.push(cleanupError);
}
this.options.onFault(error);
// Preserve both independent failures, including non-Error thrown values.
for (const cleanupError of cleanupErrors) this.options.onFault(cleanupError);
}
}