pairing-dial.ts

4.3 KB · raw

1// The WebSocket seam every relay-facing Peach flow shares.2//3// Production wires the real `WebSocket`; tests inject a fake. One binary4// message per frame. Nothing here touches chrome or the vault, because vault5// adoption runs before a vault exists at all.67/**8 * Minimal binary WebSocket seam. One binary message per frame.9 */10export interface PairingSocket {11  send(bytes: Uint8Array): void;12  /** Resolves with the next inbound binary frame, or rejects on close. */13  receive(): Promise<Uint8Array>;14  close(): void;15  /** True if the socket closed before the next frame arrived. */16  closedBeforeFrame(): boolean;17}1819// PB-26-205: transport backstop enforced before allocating/copying frame20// data. The 1 MiB frame ceiling clears the 768 KiB adoption frame ceiling21// with headroom; the 12 MiB / 256-message inbox clears the 10 MiB aggregate22// adoption budget plus handshake/control overhead.23export const MAX_PAIRING_FRAME_BYTES = 1024 * 1024;24export const MAX_PAIRING_INBOX_BYTES = 12 * 1024 * 1024;25export const MAX_PAIRING_INBOX_MESSAGES = 256;26export const PAIRING_RESOURCE_LIMIT = "PAIRING_RESOURCE_LIMIT";2728/** Relay pairing uses this binary socket seam over `wss://`. */29export function openRelayPairingSocket(url: string): Promise<PairingSocket> {30  return new Promise((resolve, reject) => {31    let ws: WebSocket;32    try {33      ws = new WebSocket(url);34    } catch (err) {35      reject(err instanceof Error ? err : new Error("websocket open failed"));36      return;37    }38    ws.binaryType = "arraybuffer";3940    const inbox: Uint8Array[] = [];41    const readers: Array<{ resolve: (b: Uint8Array) => void; reject: (e: Error) => void }> = [];42    let opened = false;43    let closed = false;44    // True iff the socket closed with no frame delivered for the pending read —45    // the wrong/expired-code signal (the listener sends no error frame).46    let sawCloseBeforeFrame = false;47    // PB-26-205: bytes/messages currently held in `inbox`, and the terminal48    // limit state. Once tripped, later socket events are ignored.49    let queuedBytes = 0;50    let limitError: Error | null = null;5152    function failLimit(): void {53      if (limitError) return;54      limitError = new Error(PAIRING_RESOURCE_LIMIT);55      inbox.length = 0;56      queuedBytes = 0;57      while (readers.length) readers.shift()!.reject(limitError);58      try { ws.close(1009); } catch { /* ignore */ }59    }6061    ws.onopen = () => {62      opened = true;63      resolve(sock);64    };65    ws.onmessage = (ev: MessageEvent) => {66      if (limitError) return;67      // Check the length before constructing the Uint8Array copy.68      if (!(ev.data instanceof ArrayBuffer) || ev.data.byteLength > MAX_PAIRING_FRAME_BYTES) {69        failLimit();70        return;71      }72      if (73        inbox.length + 1 > MAX_PAIRING_INBOX_MESSAGES ||74        queuedBytes + ev.data.byteLength > MAX_PAIRING_INBOX_BYTES75      ) {76        failLimit();77        return;78      }79      const bytes = new Uint8Array(ev.data);80      const r = readers.shift();81      if (r) { sawCloseBeforeFrame = false; r.resolve(bytes); }82      else { inbox.push(bytes); queuedBytes += bytes.byteLength; }83    };84    ws.onerror = () => {85      if (!opened) reject(new Error("websocket connection failed"));86    };87    ws.onclose = () => {88      if (limitError) return;89      closed = true;90      if (readers.length) {91        sawCloseBeforeFrame = true;92        while (readers.length) readers.shift()!.reject(new Error("socket closed"));93      }94      if (!opened) reject(new Error("socket closed before open"));95    };9697    const sock: PairingSocket = {98      send(bytes: Uint8Array) {99        if (bytes.byteLength > MAX_PAIRING_FRAME_BYTES) throw new Error(PAIRING_RESOURCE_LIMIT);100        ws.send(bytes as Uint8Array<ArrayBuffer>);101      },102      receive(): Promise<Uint8Array> {103        if (limitError) return Promise.reject(limitError);104        const queued = inbox.shift();105        if (queued) { queuedBytes -= queued.byteLength; sawCloseBeforeFrame = false; return Promise.resolve(queued); }106        if (closed) { sawCloseBeforeFrame = true; return Promise.reject(new Error("socket closed")); }107        return new Promise<Uint8Array>((res, rej) => readers.push({ resolve: res, reject: rej }));108      },109      close() { try { ws.close(); } catch { /* ignore */ } },110      closedBeforeFrame() { return sawCloseBeforeFrame; },111    };112  });113}