feat: add strict local login websocket transport
This commit is contained in:
+117
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user