sync-transport.ts

23.3 KB · raw

1// WebSocket reconnect transport — Noise KK with both paired static keys.2// First pairing remains Noise NK; reconnect never negotiates or falls back.3//4// The lower long-term public key is the WebSocket/Noise initiator; the higher5// key holds the relay room as responder. Device type does not affect the role,6// so two extensions can sync directly without a native hub. Each binary7// WebSocket message carries one Noise frame (large padded application frames8// are split across multiple Noise messages below).9//10// On connect:11//   1. Open wss://<relay>/<rendezvous-id>/12//   2. Send PEACH_RECONNECT with an opaque rotating rendezvous capability so13//      the listener can select paired local state without exposing identities.14//   3. Run standard Noise KK using both locally-known static keys.15//   4. Exchange PAIR_IDENTITY frames over the encrypted channel (same frame16//      Android's SyncReconnectListener expects before it will treat the17//      connection as sync traffic rather than an unknown caller). Our18//      identity is bound to the static key authenticated by KK.19//   5. Hand the caller a `SyncSession` for the SyncMessage protocol.20//21// Subnet probe falls back to scanning the /24 returned by `getLocalSubnet`22// when the cached last-known IP fails. We use a short connection timeout23// (~500ms per IP) and parallel batches of 16 to keep the worst-case scan24// under ~10 seconds.2526import {27  noiseKKInitiator,28  noiseKKResponder,29  type NoiseNKSession,30} from "./noise-js";31import { SYNC_LIMITS } from "./sync-limits";32import {33  padSyncFrame,34  unpadSyncFrame,35  MAX_NOISE_PLAINTEXT_BYTES,36  FRAME_CONTINUATION_MARKER,37  chunkPaddedSyncFrame,38} from "./sync-padding";39import { hexToPubkey } from "./sync-identity";40import { buildRendezvousUrl } from "./rendezvous";41import type { DeviceType } from "./sync-types";4243// E6 (session rekeying) is deliberately NOT wired, and this is a reality check44// against the spec rather than an omission. E6 targets "long-lived sync45// sessions"; the browser has none. Sessions opened here are one-shot — roughly46// ten frames each way, a 30s coordinator timeout (SESSION_TIMEOUT_MS), then47// sync-runner closes them — and every reconnect runs a fresh Noise KK handshake48// with fresh ephemerals. (Android's listener does park a connection open after49// a round so it can act as a relay hub for another native peer, but the browser50// drives the lifetime of every session it opens, and closes it.) That already51// gives stronger post-compromise security than intra-session Rekey() would,52// because no two sessions share key material at all (pinned by the E6 test in53// noise-kk.test.ts).54//55// Wiring Rekey() anyway would mean both ends rekeying at the identical frame56// index with no wire signal to resynchronise on. One frame of drift between57// browser and Android breaks all sync — the exact failure class this protocol58// has already been burned by twice. The primitives are exposed on NoiseSession59// (rekeyOutgoing/rekeyIncoming) if a genuinely long-lived session ever appears;60// until then the cost is real and the benefit is zero.61const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;62const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;63export const RELAY_WAITER_KEEPALIVE_MS = 20_000;64export const SYNC_REASSEMBLY_TIMEOUT_MS = 30_000;6566export interface SyncSession {67  send(plaintext: Uint8Array): void;68  receive(): Promise<Uint8Array>;69  close(): Promise<void>;70  remoteStaticPublicKey: Uint8Array;71}7273/** Our identity, repeated in the post-handshake PAIR_IDENTITY frame and bound74 *  to the local static key already authenticated by KK. */75export interface SyncPeerIdentity {76  deviceId: string;77  deviceName: string;78  deviceType: DeviceType;79  longTermPublicKeyHex: string;80  /** Local-only key material for the KK reconnect handshake; never encoded. */81  longTermPrivateKey: Uint8Array;82  signingPublicKeyHex?: string;83}8485export class SyncFrameTooLargeError extends Error {86  constructor(message = "sync frame too large (WebSocket close 1009)") {87    super(message);88    this.name = "SyncFrameTooLargeError";89  }90}9192export class SyncTransportResourceLimitError extends Error {93  constructor() {94    super("sync transport resource limit exceeded");95    this.name = "SyncTransportResourceLimitError";96  }97}9899export class SyncFrameReassemblyTimeoutError extends Error {100  constructor() {101    super("sync frame reassembly timeout");102    this.name = "SyncFrameReassemblyTimeoutError";103  }104}105106export interface SyncSessionOptions {107  connectTimeoutMs?: number;108  handshakeTimeoutMs?: number;109  signal?: AbortSignal;110}111112function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {113  return new TextEncoder().encode(JSON.stringify({114    type: "PAIR_IDENTITY",115    device_id: id.deviceId,116    device_name: id.deviceName,117    device_type: id.deviceType,118    long_term_public_key_hex: id.longTermPublicKeyHex,119    signing_public_key_hex: id.signingPublicKeyHex,120  }));121}122123/**124 * Relay reconnect preserves the existing Noise KK wire protocol. The URL125 * builder enforces credential-free `wss://`.126 */127export async function openRelaySyncSession(128  relayUrl: string,129  ourIdentity: SyncPeerIdentity,130  expectedRemotePubkeyHex: string,131  rendezvousId: string,132  options: SyncSessionOptions = {},133): Promise<SyncSession> {134  return openSyncSessionAtUrl(135    buildRendezvousUrl(relayUrl, rendezvousId),136    "relay",137    ourIdentity,138    expectedRemotePubkeyHex,139    rendezvousId,140    options,141  );142}143144/**145 * Hold one relay room as the deterministic responder, then run Noise KK and146 * the bound PAIR_IDENTITY exchange. The zero-length liveness frame keeps a147 * Chrome 116+ MV3 service worker alive while the room is empty; the Peach148 * relay consumes that frame locally and never forwards it to the peer.149 */150export async function openRelaySyncResponderSession(151  relayUrl: string,152  ourIdentity: SyncPeerIdentity,153  expectedRemotePubkeyHex: string,154  rendezvousId: string,155  options: SyncSessionOptions & { keepaliveMs?: number } = {},156): Promise<SyncSession> {157  if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {158    throw new Error("reconnect requires a valid rotating rendezvous id");159  }160  if (ourIdentity.longTermPrivateKey.length !== 32) {161    throw new Error("local reconnect private key must be exactly 32 bytes");162  }163  const ws = await connectWebSocketUrl(164    buildRendezvousUrl(relayUrl, rendezvousId),165    "relay",166    options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,167  );168  const inbox: Uint8Array[] = [];169  let inboxBytes = 0;170  const readers: Array<(b: Uint8Array) => void> = [];171  let closed = false;172  let closeError: Error = new Error("WebSocket closed");173  const keepaliveMs = options.keepaliveMs ?? RELAY_WAITER_KEEPALIVE_MS;174  const abortWaiter = () => {175    try { ws.close(1000, "relay waiter replaced"); } catch { /* ignore */ }176  };177  const keepalive = setInterval(() => {178    if (ws.readyState !== WebSocket.OPEN) return;179    try { ws.send(new Uint8Array(0)); } catch { /* onclose owns cleanup */ }180  }, keepaliveMs);181182  ws.binaryType = "arraybuffer";183  ws.onmessage = (event: MessageEvent) => {184    if (closed) return;185    if (!(event.data instanceof ArrayBuffer)) return;186    if (event.data.byteLength === 0) return;187    if (event.data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {188      closeError = new SyncFrameTooLargeError(189        `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,190      );191      try { ws.close(1009, "sync frame too large"); } catch { /* ignore */ }192      return;193    }194    const bytes = new Uint8Array(event.data);195    const reader = readers.shift();196    if (reader) reader(bytes);197    else if (198      inbox.length >= SYNC_LIMITS.MAX_INBOX_MESSAGES ||199      inboxBytes + bytes.byteLength > SYNC_LIMITS.MAX_INBOX_BYTES200    ) {201      closeError = new SyncTransportResourceLimitError();202      closed = true;203      inbox.length = 0;204      inboxBytes = 0;205      try { ws.close(1009, "sync inbox limit"); } catch { /* ignore */ }206    } else {207      inbox.push(bytes);208      inboxBytes += bytes.byteLength;209    }210  };211  ws.onerror = () => {212    try { ws.close(); } catch { /* ignore */ }213  };214  ws.onclose = (event: CloseEvent) => {215    clearInterval(keepalive);216    options.signal?.removeEventListener("abort", abortWaiter);217    closed = true;218    inbox.length = 0;219    inboxBytes = 0;220    if (event.code === 1009 && !(closeError instanceof SyncTransportResourceLimitError)) {221      closeError = new SyncFrameTooLargeError();222    }223    while (readers.length > 0) readers.shift()!(new Uint8Array(0));224  };225  // Install close handling before honoring an already-aborted signal so a226  // synchronous/mock close cannot strand the first receive() forever.227  options.signal?.addEventListener("abort", abortWaiter, { once: true });228  if (options.signal?.aborted) abortWaiter();229230  const send = async (bytes: Uint8Array): Promise<void> => {231    if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {232      throw new SyncFrameTooLargeError(233        `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,234      );235    }236    ws.send(bytes as Uint8Array<ArrayBuffer>);237  };238  const receive = (): Promise<Uint8Array> => {239    if (closed) return Promise.reject(closeError);240    const buffered = inbox.shift();241    if (buffered) {242      inboxBytes -= buffered.byteLength;243      return Promise.resolve(buffered);244    }245    return new Promise<Uint8Array>((resolve, reject) => {246      readers.push((bytes) => {247        if (bytes.length === 0 && closed) reject(closeError);248        else resolve(bytes);249      });250    });251  };252253  let noiseSession: NoiseNKSession | undefined;254  try {255    const selectorBytes = await receive();256    const selector = JSON.parse(new TextDecoder().decode(selectorBytes));257    if (258      selector?.type !== "PEACH_RECONNECT" ||259      typeof selector.rendezvous_id !== "string" ||260      !equalAscii(selector.rendezvous_id, rendezvousId)261    ) {262      throw new Error("reconnect capability does not match relay room");263    }264    const handshakeTimer = setTimeout(() => {265      closeError = new Error("sync_handshake_timeout");266      try { ws.close(1000, "sync handshake timeout"); } catch { /* ignore */ }267    }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);268    try {269      const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);270      noiseSession = await noiseKKResponder(271        ourIdentity.longTermPrivateKey,272        ourPublicKey,273        hexToPubkey(expectedRemotePubkeyHex),274        send,275        receive,276      );277      const theirFrame = JSON.parse(278        new TextDecoder().decode(279          unpadSyncFrame(noiseSession.decrypt(await receive())),280        ),281      );282      if (theirFrame?.type !== "PAIR_IDENTITY") {283        throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);284      }285      if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {286        throw new Error("peer identity does not match authenticated KK key");287      }288      await send(noiseSession.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));289    } finally {290      clearTimeout(handshakeTimer);291    }292  } catch (error) {293    clearInterval(keepalive);294    options.signal?.removeEventListener("abort", abortWaiter);295    noiseSession?.close();296    try { ws.close(); } catch { /* ignore */ }297    throw error;298  }299300  return buildEncryptedSyncSession(301    ws,302    noiseSession,303    expectedRemotePubkeyHex,304    receive,305    () => {306      clearInterval(keepalive);307      options.signal?.removeEventListener("abort", abortWaiter);308    },309  );310}311312export async function openSyncSessionAtUrl(313  url: string,314  targetLabel: string,315  ourIdentity: SyncPeerIdentity,316  expectedRemotePubkeyHex: string,317  rendezvousId: string,318  options: SyncSessionOptions = {},319): Promise<SyncSession> {320  if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {321    throw new Error("reconnect requires a valid rotating rendezvous id");322  }323  const ws = await connectWebSocketUrl(324    url,325    targetLabel,326    options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,327  );328  const inbox: Uint8Array[] = [];329  let inboxBytes = 0;330  const readers: Array<(b: Uint8Array) => void> = [];331  let closed = false;332  let closeError: Error = new Error("WebSocket closed");333334  ws.binaryType = "arraybuffer";335  ws.onmessage = (ev: MessageEvent) => {336    if (closed) return;337    const data = ev.data;338    if (data instanceof ArrayBuffer) {339      // MV-3: refuse oversized frames before they are buffered or decrypted.340      // A peer that sends one is misbehaving — tear the session down rather341      // than letting the inbox grow toward OOM.342      if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {343        // MV-3: oversized frame — tear the session down. close() routes through344        // onclose, which rejects any parked receive(), so no error array is345        // needed (PCH-01-026 #9).346        console.warn(347          `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,348        );349        closeError = new SyncFrameTooLargeError(350          `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,351        );352        try { ws.close(1009, "sync frame too large"); } catch { /* ignore */ }353        return;354      }355      const bytes = new Uint8Array(data);356      const r = readers.shift();357      if (r) r(bytes);358      else if (359        inbox.length >= SYNC_LIMITS.MAX_INBOX_MESSAGES ||360        inboxBytes + bytes.byteLength > SYNC_LIMITS.MAX_INBOX_BYTES361      ) {362        closeError = new SyncTransportResourceLimitError();363        closed = true;364        inbox.length = 0;365        inboxBytes = 0;366        try { ws.close(1009, "sync inbox limit"); } catch { /* ignore */ }367      } else {368        inbox.push(bytes);369        inboxBytes += bytes.byteLength;370      }371    }372  };373  ws.onerror = () => {374    // PCH-01-026 #9: surface the error and unblock any parked receive() by375    // closing. Browsers usually follow error with close, but not always; an376    // explicit close guarantees the handshake fails fast instead of hanging.377    console.warn("[Peach] sync WebSocket error; closing transport");378    try { ws.close(); } catch { /* ignore */ }379  };380  ws.onclose = (event: CloseEvent) => {381    closed = true;382    inbox.length = 0;383    inboxBytes = 0;384    if (event.code === 1009 && !(closeError instanceof SyncTransportResourceLimitError)) {385      closeError = new SyncFrameTooLargeError();386    }387    while (readers.length > 0) {388      const r = readers.shift()!;389      r(new Uint8Array(0));390    }391  };392393  function send(bytes: Uint8Array): Promise<void> {394    return new Promise((resolve, reject) => {395      if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {396        reject(397          new SyncFrameTooLargeError(398            `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,399          ),400        );401        return;402      }403      try {404        ws.send(bytes as Uint8Array<ArrayBuffer>);405        resolve();406      } catch (err) {407        reject(err as Error);408      }409    });410  }411  function receive(): Promise<Uint8Array> {412    if (closed) return Promise.reject(closeError);413    const v = inbox.shift();414    if (v) {415      inboxBytes -= v.byteLength;416      return Promise.resolve(v);417    }418    return new Promise<Uint8Array>((resolve, reject) => {419      readers.push((b) => {420        if (b.length === 0 && closed) reject(closeError);421        else resolve(b);422      });423    });424  }425426  const handshakeTimer = setTimeout(() => {427    closeError = new Error("sync_handshake_timeout");428    try { ws.close(1000, "sync handshake timeout"); } catch { /* ignore */ }429  }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);430  let session: NoiseNKSession;431  try {432    const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);433    if (ourIdentity.longTermPrivateKey.length !== 32) {434      throw new Error("local reconnect private key must be exactly 32 bytes");435    }436    // This opaque rotating capability lets Android select paired local state437    // without exposing either public key to a relay. It grants no identity438    // trust; KK still proves both private keys immediately afterwards.439    await send(new TextEncoder().encode(JSON.stringify({440      type: "PEACH_RECONNECT",441      rendezvous_id: rendezvousId,442    })));443    session = await noiseKKInitiator(444      ourIdentity.longTermPrivateKey,445      ourPublicKey,446      hexToPubkey(expectedRemotePubkeyHex),447      send,448      receive,449    );450  } catch (err) {451    clearTimeout(handshakeTimer);452    try { ws.close(); } catch { /* ignore */ }453    throw err;454  }455456  try {457    await send(session.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));458    const theirFrame = JSON.parse(459      new TextDecoder().decode(unpadSyncFrame(session.decrypt(await receive()))),460    );461    if (theirFrame?.type !== "PAIR_IDENTITY") {462      throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);463    }464    if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {465      throw new Error("peer identity does not match authenticated KK key");466    }467    clearTimeout(handshakeTimer);468  } catch (err) {469    clearTimeout(handshakeTimer);470    session.close();471    try { ws.close(); } catch { /* ignore */ }472    throw err;473  }474475  return buildEncryptedSyncSession(476    ws,477    session,478    expectedRemotePubkeyHex,479    receive,480  );481}482483// Exported for the PB-26-002 reassembly-bound regression test.484export function buildEncryptedSyncSession(485  ws: WebSocket,486  session: NoiseNKSession,487  expectedRemotePubkeyHex: string,488  receiveCiphertext: () => Promise<Uint8Array>,489  beforeClose: () => void = () => undefined,490  reassemblyTimeoutMs = SYNC_REASSEMBLY_TIMEOUT_MS,491): SyncSession {492  return {493    async send(plaintext: Uint8Array) {494      // E7: bucket the size before encrypting, so the relay sees frame shapes495      // that no longer distinguish one operation (or vault size) from another.496      // E7b: a padded frame can legitimately be up to 24 MiB, but Noise caps a497      // single transport message at 65535 bytes (see crypto-core/src/lib.rs —498      // 65519 plaintext, 16-byte tag). We split a too-large padded frame into499      // numbered Noise messages and tag every chunk after the first with a500      // 1-byte continuation header. The receiver reassembles by reading the501      // padded frame's 4-byte length header, computing the same bucket from502      // the same ladder, and keeping reading until the bucket is full.503      const padded = padSyncFrame(plaintext);504      if (padded.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {505        throw new SyncFrameTooLargeError(506          `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,507        );508      }509      // A single-chunk frame goes out as one Noise message, exactly as before510      // — no continuation byte, no per-chunk overhead, so the common small511      // case is unchanged on the wire.512      if (padded.byteLength <= MAX_NOISE_PLAINTEXT_BYTES) {513        const ct = session.encrypt(padded);514        ws.send(ct as Uint8Array<ArrayBuffer>);515        return;516      }517      for (const plaintext of chunkPaddedSyncFrame(padded)) {518        if (plaintext.byteLength > MAX_NOISE_PLAINTEXT_BYTES) {519          throw new Error(520            `chunk exceeds Noise limit after continuation byte: ${plaintext.byteLength}`,521          );522        }523        const ct = session.encrypt(plaintext);524        if (ct.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {525          throw new SyncFrameTooLargeError(526            `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,527          );528        }529        ws.send(ct as Uint8Array<ArrayBuffer>);530      }531    },532    async receive() {533      // The first Noise message of a frame is the first one we read. If it534      // begins with the continuation marker, the message is one of N chunks535      // — the padded-frame length header comes inside the first chunk's536      // payload, so we read it there to learn the total.537      let buffered: Uint8Array | null = null;538      let deadlineTimer: ReturnType<typeof setTimeout> | undefined;539      let deadline: Promise<never> | undefined;540      try {541        while (true) {542          const ct = await (deadline543            ? Promise.race([receiveCiphertext(), deadline])544            : receiveCiphertext());545          const pt = session.decrypt(ct);546          if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {547            // Strip the continuation byte and keep the payload.548            buffered = pt.subarray(1);549            deadline = new Promise<never>((_resolve, reject) => {550              deadlineTimer = setTimeout(() => {551                session.close();552                try { ws.close(1008, "sync frame timeout"); } catch { /* ignore */ }553                reject(new SyncFrameReassemblyTimeoutError());554              }, reassemblyTimeoutMs);555            });556            continue;557          }558          if (buffered !== null) {559            if (pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {560              const merged: Uint8Array = new Uint8Array(buffered.length + pt.byteLength - 1);561              merged.set(buffered, 0);562              merged.set(pt.subarray(1), buffered.length);563              buffered = merged;564              // PB-26-002: the frame bound must hold DURING reassembly. The565              // post-receive check in the coordinator runs only after the full566              // frame is in memory, so a peer that completes the Noise KK567              // handshake could otherwise stream continuation chunks without568              // bound inside the session window.569              if (buffered.length > SYNC_LIMITS.MAX_FRAME_BYTES) {570                throw new SyncFrameTooLargeError(571                  `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,572                );573              }574              continue;575            }576            // Final chunk. PB-26-602: the continuation loop only bounds what is577            // buffered, so the last chunk could push the frame up to 64 KiB past578            // the cap. Bound the concatenation, not just the buffer.579            if (buffered.length + pt.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {580              throw new SyncFrameTooLargeError(581                `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,582              );583            }584            const reassembled = new Uint8Array(buffered.length + pt.byteLength);585            reassembled.set(buffered, 0);586            reassembled.set(pt, buffered.length);587            return unpadSyncFrame(reassembled);588          }589          // Single-chunk frame, exactly as before.590          return unpadSyncFrame(pt);591        }592      } finally {593        if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);594      }595    },596    async close() {597      beforeClose();598      try { ws.close(); } catch { /* ignore */ }599      session.close();600    },601    remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),602  };603}604605function equalAscii(left: string, right: string): boolean {606  const a = new TextEncoder().encode(left);607  const b = new TextEncoder().encode(right);608  if (a.length !== b.length) return false;609  let diff = 0;610  for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];611  return diff === 0;612}613614function connectWebSocketUrl(615  url: string,616  targetLabel: string,617  timeoutMs: number,618): Promise<WebSocket> {619  return new Promise((resolve, reject) => {620    let ws: WebSocket;621    try {622      ws = new WebSocket(url);623    } catch (err) {624      reject(err as Error);625      return;626    }627    const t = setTimeout(() => {628      try { ws.close(); } catch { /* ignore */ }629      reject(new Error(`${targetLabel} WebSocket connect timed out`));630    }, timeoutMs);631    ws.onopen = () => {632      clearTimeout(t);633      resolve(ws);634    };635    ws.onerror = () => {636      clearTimeout(t);637      reject(new Error(`${targetLabel} WebSocket connect errored`));638    };639  });640}