Files
buzz/beehive/src/client.ts
2026-09-10 04:51:53 -04:00

57 lines
3.4 KiB
TypeScript

import { trace } from './latency-trace.ts';
import { WebSocket } from 'ws';
import { open, seal, type Message, type Envelope } from './protocol.ts';
/** Validate transport before acquiring installation resources. */
export function validateRelayURL(url: string) {
const endpoint = new URL(url);
if (endpoint.protocol !== 'ws:' || endpoint.hostname !== '127.0.0.1') throw Error('Development transport requires ws://127.0.0.1');
}
/** All UI/host communication uses the real relay, never a host API.
* Opt-in recovery is bounded to eight reconnect attempts per connection lifetime.
* Initial connection failure still rejects ready, allowing installation lock unwind.
* The caller owns durable intent/receipts; an open socket is not an operation ACK.
*/
export function connect(url: string, secret: string, receive: (m: Message) => void, recovered?: () => void, disconnected?: (code: number) => void) {
validateRelayURL(url);
let closed = false; let established = false; let attempts = 0;
let retry: ReturnType<typeof setTimeout> | undefined;
let resolveReady: () => void; let rejectReady: (error: Error) => void;
const ready = new Promise<void>((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
function dial(): WebSocket {
const current = new WebSocket(url, { maxPayload: 70000, handshakeTimeout: 2000 });
current.on('open', () => { trace('client.open');
if (closed || current !== socket) { current.close(); return; }
if (!established) { established = true; resolveReady(); }
else recovered?.();
});
current.on('message', data => {
if (closed || current !== socket) return;
trace('client.wire'); let message: Message;
try { message = open(JSON.parse(data.toString()),secret); } catch { return; }
trace('client.decoded', { id: message.id, type: message.type, op: message.body.operation }); receive(message); // Application persistence failures must not masquerade as malformed wire data.
});
current.on('error', error => { if (!established) rejectReady(error); });
current.on('close', code => { trace('client.close', { code });
if (!established) rejectReady(Error('Relay closed before ready'));
if (!closed && current === socket) disconnected?.(code);
if (code === 1008 || closed || current !== socket || !established || !recovered || attempts >= 8) return;
retry = setTimeout(() => { retry = undefined; if (!closed) socket = dial(); }, Math.min(2000, 100 * 2 ** attempts++));
});
return current;
}
let socket = dial();
return {
get socket() { return socket; },
ready,
sendEnvelope(e: Envelope) { if (closed || socket.readyState !== WebSocket.OPEN) throw Error('Relay disconnected; result unknown'); socket.send(JSON.stringify(e)); },
send(m: Message) { if (closed || socket.readyState !== WebSocket.OPEN) throw Error('Relay disconnected; result unknown'); trace('client.seal.begin', { id: m.id, type: m.type, op: m.body.operation }); const e = seal(m,secret); trace('client.seal.end', { id: m.id, signature: e.signature }); socket.send(JSON.stringify(e)); trace('client.sent', { id: m.id }); },
/** Explicit transport reconciliation; opening is never an operation result. */
reconnect() {
if (closed) throw Error('UI closed');
clearTimeout(retry); attempts = 0;
const previous = socket; socket = dial(); previous.close();
},
close() { closed = true; clearTimeout(retry); socket.close(); },
};
}