// Relay tunnel client: Layer 1 wiring (relay WS, role=client), Layer 2 E2EE // initiator, and Layer 3 mux (HTTP/SSE/WS streams) on the client side. // One relay connection per client session carries all app traffic; a tunnel // reconnect fails every open stream and the app's existing retry machinery // (runtime-fetch retries, event-pipeline reconnect) recovers. // Spec: .opencode/plans/private-relay/01-protocol-spec.md import { createClientHandshake, type EstablishedChannelCrypto } from './handshake'; import { RELAY_PROTOCOL_VERSION, RelayCloseCode, TunnelFrameType, type TunnelHttpRequestPayload, type TunnelWsOpenPayload, } from './protocol'; import { chunkPayload, createFragmentAssembler, createOutboundFrameBatcher, createStreamIdAllocator, DEFAULT_BATCH_WINDOW_MS, decodeFrameBatch, decodeJsonPayload, decodeTunnelFrame, encodeFragmentedMessage, encodeJsonPayload, encodeTunnelFrame, encodeDeliveryAck, type OutboundFrameBatcher, type TunnelFrame, } from './tunnel-codec'; import { TUNNEL_FRAGMENT_FLAG } from './protocol'; import { isHttpResponsePayload, isStreamAbortPayload, isWsClosePayload, normalizeTunnelRequest, } from './tunnel-payloads'; import { markAmbiguousTransportFailure } from './transport-error'; const EMPTY_PAYLOAD = new Uint8Array(0); const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); const toError = (value: unknown): Error => (value instanceof Error ? value : new Error(String(value))); const abortError = (): DOMException => new DOMException('The operation was aborted.', 'AbortError'); // Minimal wire surface the client needs from the relay WebSocket. Injectable // so tests can substitute an in-memory transport pair. export interface TunnelWireSocket { readonly readyState: number; send(data: string | ArrayBuffer | Uint8Array): void; close(code?: number, reason?: string): void; onopen: (() => void) | null; onmessage: ((event: { data: unknown }) => void) | null; onclose: ((event: { code: number; reason: string }) => void) | null; onerror: (() => void) | null; } const wrapNativeWebSocket = (ws: WebSocket): TunnelWireSocket => { ws.binaryType = 'arraybuffer'; const wire: TunnelWireSocket = { get readyState() { return ws.readyState; }, send(data) { ws.send(data); }, close(code, reason) { ws.close(code, reason); }, onopen: null, onmessage: null, onclose: null, onerror: null, }; ws.onopen = () => wire.onopen?.(); ws.onmessage = (event) => wire.onmessage?.({ data: event.data }); ws.onclose = (event) => wire.onclose?.({ code: event.code, reason: event.reason }); ws.onerror = () => wire.onerror?.(); return wire; }; // Socket-like surface for tunneled WebSockets. Matches exactly what // packages/ui/src/sync/event-pipeline.ts uses: assignable on* handlers, // send(), close(), readyState. `wrapBrowserWebSocket` adapts a native // WebSocket to the same shape so consumers can hold one type for both paths. export interface RelayTunnelSocketMessageEvent { data: string | ArrayBuffer; } export interface RelayTunnelSocketCloseEvent { code: number; reason: string; } export interface RelayTunnelWebSocket { readonly readyState: number; // Native-only hint; the tunnel always delivers binary as ArrayBuffer, so it // accepts the setter as a no-op to keep the two socket shapes interchangeable. binaryType?: 'blob' | 'arraybuffer'; onopen: (() => void) | null; onmessage: ((event: RelayTunnelSocketMessageEvent) => void) | null; onerror: (() => void) | null; onclose: ((event: RelayTunnelSocketCloseEvent) => void) | null; send(data: string | ArrayBuffer | ArrayBufferView): void; close(code?: number, reason?: string): void; } export const wrapBrowserWebSocket = (ws: WebSocket): RelayTunnelWebSocket => { const socket: RelayTunnelWebSocket = { get readyState() { return ws.readyState; }, get binaryType() { return ws.binaryType; }, set binaryType(value) { if (value) ws.binaryType = value; }, onopen: null, onmessage: null, onerror: null, onclose: null, send(data) { ws.send(data); }, close(code, reason) { ws.close(code, reason); }, }; ws.onopen = () => socket.onopen?.(); ws.onmessage = (event) => { const data: unknown = event.data; if (typeof data === 'string' || data instanceof ArrayBuffer) { socket.onmessage?.({ data }); } }; ws.onerror = () => socket.onerror?.(); ws.onclose = (event) => socket.onclose?.({ code: event.code, reason: event.reason }); return socket; }; export type RelayTunnelState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error'; export interface RelayTunnelStatus { state: RelayTunnelState; lastError?: string; } export interface RelayTunnelClientOptions { relayUrl: string; serverId: string; hostEncPubJwk: JsonWebKey; grant?: string; /** Test hook: replaces native WebSocket construction with a fake wire. */ createWireSocket?: (url: string) => TunnelWireSocket; helloRetryMs?: number; helloTimeoutMs?: number; pingIntervalMs?: number; pingTimeoutMs?: number; /** Frame-batching flush window in ms (default 150). Only applies once negotiated. */ batchWindowMs?: number; /** Advertise frame batching in the handshake. Default true. */ batch?: boolean; /** Advertise downstream delivery acknowledgements. Default true. */ flowControl?: boolean; reconnectBaseDelayMs?: number; reconnectMaxDelayMs?: number; hiddenOrOfflineMaxDelayMs?: number; } export interface RelayTunnelClient { fetch(input: string | URL | Request, init?: RequestInit): Promise; openWebSocket(pathWithQuery: string, protocols?: string[]): RelayTunnelWebSocket; getStatus(): RelayTunnelStatus; subscribeStatus(listener: (status: RelayTunnelStatus) => void): () => void; close(): void; } const WS_CONNECTING = 0; const WS_OPEN = 1; const WS_CLOSING = 2; const WS_CLOSED = 3; // Relay close codes that a reconnect can never resolve — surface a terminal error // instead of looping forever (auth failed, duplicate client, limit exceeded). const TERMINAL_RELAY_CLOSE_CODES = new Set([ RelayCloseCode.AuthFailed, RelayCloseCode.DuplicateClient, RelayCloseCode.LimitExceeded, ]); const RELAY_CLOSE_MESSAGES: Record = { [RelayCloseCode.AuthFailed]: 'relay authentication failed', [RelayCloseCode.DuplicateClient]: 'relay connection replaced by another client', [RelayCloseCode.LimitExceeded]: 'relay connection limit reached', }; type StreamHandler = { handleFrame(frameType: number, payload: Uint8Array): void; fail(error: Error): void; }; type ActiveChannel = { streams: Map; assembler: ReturnType; nextStreamId(): number; send(frame: Uint8Array): void; dead: boolean; }; type ChannelWaiter = { resolve(channel: ActiveChannel): void; reject(error: Error): void; }; const isOfflineOrHidden = (): boolean => { const offline = typeof navigator !== 'undefined' && navigator.onLine === false; const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'; return offline || hidden; }; export const createRelayTunnelClient = (options: RelayTunnelClientOptions): RelayTunnelClient => { const helloRetryMs = options.helloRetryMs ?? 1_000; const helloTimeoutMs = options.helloTimeoutMs ?? 30_000; const pingIntervalMs = options.pingIntervalMs ?? 30_000; // Pong wait after an idle keepalive ping — must be well under the interval so a // dead socket is caught within one cycle rather than after two. const pingTimeoutMs = options.pingTimeoutMs ?? 15_000; const batchWindowMs = options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS; const advertiseBatch = options.batch !== false; const reconnectBaseDelayMs = options.reconnectBaseDelayMs ?? 1_000; const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 30_000; const hiddenOrOfflineMaxDelayMs = options.hiddenOrOfflineMaxDelayMs ?? 60_000; const createWire = options.createWireSocket ?? ((url: string) => wrapNativeWebSocket(new WebSocket(url))); let closed = false; let status: RelayTunnelStatus = { state: 'idle' }; // Plain listener set — status must not fan out through shared stores. const statusListeners = new Set<(next: RelayTunnelStatus) => void>(); let activeChannel: ActiveChannel | null = null; let currentWire: TunnelWireSocket | null = null; let currentAttemptCleanup: (() => void) | null = null; let attemptGeneration = 0; let consecutiveFailures = 0; let reconnectTimer: ReturnType | null = null; let channelWaiters: ChannelWaiter[] = []; let wakeListenersInstalled = false; const setStatus = (next: RelayTunnelStatus): void => { if (status.state === next.state && status.lastError === next.lastError) return; status = next; for (const listener of statusListeners) { try { listener(status); } catch { // A listener throwing must not break the tunnel. } } }; const rejectWaiters = (error: Error): void => { const waiters = channelWaiters; channelWaiters = []; for (const waiter of waiters) waiter.reject(error); }; const resolveWaiters = (channel: ActiveChannel): void => { const waiters = channelWaiters; channelWaiters = []; for (const waiter of waiters) waiter.resolve(channel); }; const failChannelStreams = (channel: ActiveChannel, error: Error): void => { channel.dead = true; const handlers = Array.from(channel.streams.values()); channel.streams.clear(); for (const handler of handlers) { try { handler.fail(error); } catch { // Stream teardown must not block the rest. } } }; const clearReconnectTimer = (): void => { if (reconnectTimer !== null) { clearTimeout(reconnectTimer); reconnectTimer = null; } }; const onWake = (): void => { if (closed || reconnectTimer === null || isOfflineOrHidden()) return; clearReconnectTimer(); removeWakeListeners(); void connect(); }; const onVisibilityWake = (): void => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') onWake(); }; const addWakeListeners = (): void => { if (wakeListenersInstalled || typeof window === 'undefined') return; wakeListenersInstalled = true; window.addEventListener('online', onWake); if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibilityWake); }; const removeWakeListeners = (): void => { if (!wakeListenersInstalled || typeof window === 'undefined') return; wakeListenersInstalled = false; window.removeEventListener('online', onWake); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibilityWake); }; const scheduleReconnect = (): void => { if (closed || reconnectTimer !== null) return; const attemptIndex = Math.max(0, consecutiveFailures - 1); const base = reconnectBaseDelayMs * 2 ** Math.min(attemptIndex, 10); // Per CLAUDE.md reconnect pacing: offline/hidden expect recovery from the // online/visibility events (interruptible wait below), so back off to the // long cap instead of probing a dead network. const cap = isOfflineOrHidden() ? hiddenOrOfflineMaxDelayMs : reconnectMaxDelayMs; const delay = Math.min(cap, base); addWakeListeners(); reconnectTimer = setTimeout(() => { reconnectTimer = null; removeWakeListeners(); void connect(); }, delay); }; const buildRelayWsUrl = (): string => { const url = new URL(options.relayUrl); url.searchParams.set('v', String(RELAY_PROTOCOL_VERSION)); url.searchParams.set('role', 'client'); url.searchParams.set('serverId', options.serverId); if (options.grant) url.searchParams.set('grant', options.grant); return url.toString(); }; const connect = async (): Promise => { if (closed) return; clearReconnectTimer(); attemptGeneration += 1; const generation = attemptGeneration; setStatus({ state: consecutiveFailures > 0 ? 'reconnecting' : 'connecting', lastError: status.lastError }); let handshake; try { handshake = await createClientHandshake(options.hostEncPubJwk, { batch: advertiseBatch, flowControl: options.flowControl }); } catch (error) { if (generation !== attemptGeneration || closed) return; failAttempt(generation, toError(error), true); return; } if (generation !== attemptGeneration || closed) return; let wire: TunnelWireSocket; try { wire = createWire(buildRelayWsUrl()); } catch (error) { failAttempt(generation, toError(error)); return; } currentWire = wire; let settled = false; let helloInterval: ReturnType | null = null; let helloDeadline: ReturnType | null = null; let pingTimer: ReturnType | null = null; // One-shot: armed when an idle-keepalive ping is sent, cleared by any received // frame (incl. Pong). If it fires, the tunnel is dead. Independent of the ping // cadence so a dead socket is detected ~pingTimeoutMs after an unanswered ping, // not on the next full interval. let pongDeadline: ReturnType | null = null; let recvChain: Promise = Promise.resolve(); let channel: ActiveChannel | null = null; let cryptoChannel: EstablishedChannelCrypto | null = null; let batchNegotiated = false; let flowControlNegotiated = false; let receivedBytes = 0; let acknowledgedBytes = 0; let ackTimer: ReturnType | null = null; let batcher: OutboundFrameBatcher | null = null; // Idle tracking: updated on any non-Ping/Pong frame in EITHER direction. // Ping/Pong are excluded so the keepalive can't sustain itself. let lastActivityAt = Date.now(); const cleanupTimers = (): void => { if (ackTimer !== null) { clearTimeout(ackTimer); ackTimer = null; } if (helloInterval !== null) { clearInterval(helloInterval); helloInterval = null; } if (helloDeadline !== null) { clearTimeout(helloDeadline); helloDeadline = null; } if (pingTimer !== null) { clearInterval(pingTimer); pingTimer = null; } if (pongDeadline !== null) { clearTimeout(pongDeadline); pongDeadline = null; } if (batcher !== null) { batcher.dispose(); batcher = null; } }; currentAttemptCleanup = cleanupTimers; function failAttemptLocal(error: Error, asErrorState = false, terminal = false): void { if (settled || generation !== attemptGeneration) return; settled = true; cleanupTimers(); if (channel) { activeChannel = null; failChannelStreams(channel, new Error(`relay tunnel reset: ${error.message}`)); } rejectWaiters(error); try { wire.close(); } catch { // Wire may already be closed. } if (currentWire === wire) currentWire = null; if (closed) return; consecutiveFailures += 1; // A permanent rejection (auth failed, duplicate, limit) won't resolve by // retrying — surface a terminal error instead of reconnecting forever. if (terminal) { setStatus({ state: 'error', lastError: error.message }); return; } setStatus({ state: asErrorState ? 'error' : 'reconnecting', lastError: error.message }); scheduleReconnect(); } const sendHello = (): void => { try { wire.send(handshake.helloText); } catch { // Socket not ready; the retry interval covers it. } }; const establish = (crypto: EstablishedChannelCrypto, batch: boolean, flowControl: boolean): void => { cryptoChannel = crypto; batchNegotiated = batch; flowControlNegotiated = flowControl; if (helloInterval !== null) { clearInterval(helloInterval); helloInterval = null; } if (helloDeadline !== null) { clearTimeout(helloDeadline); helloDeadline = null; } const streams = new Map(); const allocator = createStreamIdAllocator(); const assembler = createFragmentAssembler(); let sendChain: Promise = Promise.resolve(); // Serialize encrypt+send: the per-direction IV counter must hit the wire in // encryption order or the receiver fails closed. One call == one encrypted // WS message == one counter tick, whether it carries a batch or a lone frame. const sendEncryptedPlaintext = (plaintext: Uint8Array): void => { sendChain = sendChain .then(async () => { if (channelObj.dead) return; const encrypted = await crypto.encryptor.encrypt(plaintext); if (channelObj.dead) return; wire.send(encrypted); }) .catch(() => { failAttemptLocal(new Error('relay encrypt/send failed')); }); }; const localBatcher = batch ? createOutboundFrameBatcher({ windowMs: batchWindowMs, sendBatch: sendEncryptedPlaintext }) : null; batcher = localBatcher; const channelObj: ActiveChannel = { streams, assembler, nextStreamId: () => allocator.next(), dead: false, send(frame: Uint8Array): void { if (channelObj.dead) return; const frameType = frame[0] & ~TUNNEL_FRAGMENT_FLAG; if (frameType !== TunnelFrameType.Ping && frameType !== TunnelFrameType.Pong && frameType !== TunnelFrameType.DeliveryAck) { lastActivityAt = Date.now(); } if (localBatcher) localBatcher.enqueue(frame); else sendEncryptedPlaintext(frame); }, }; channel = channelObj; activeChannel = channelObj; consecutiveFailures = 0; lastActivityAt = Date.now(); setStatus({ state: 'connected' }); resolveWaiters(channelObj); pingTimer = setInterval(() => { const now = Date.now(); // Only ping when the tunnel has actually been idle; streaming traffic // keeps lastActivityAt fresh, so sustained bursts send zero pings. if (now - lastActivityAt < pingIntervalMs) return; channelObj.send(encodeTunnelFrame(TunnelFrameType.Ping, 0, EMPTY_PAYLOAD)); // Expect a Pong (or any frame) before the deadline; otherwise it's dead. if (pongDeadline === null) { pongDeadline = setTimeout(() => { pongDeadline = null; failAttemptLocal(new Error('relay keepalive timeout')); }, pingTimeoutMs); } }, pingIntervalMs); }; const acknowledgeDelivery = (): void => { if (ackTimer !== null) clearTimeout(ackTimer); ackTimer = null; if (!channel || channel.dead || receivedBytes === acknowledgedBytes) return; acknowledgedBytes = receivedBytes; channel.send(encodeTunnelFrame(TunnelFrameType.DeliveryAck, 0, encodeDeliveryAck(receivedBytes))); }; const handleTunnelFrame = (channelObj: ActiveChannel, plaintext: Uint8Array): void => { let frame: TunnelFrame; try { frame = decodeTunnelFrame(plaintext); } catch (error) { failAttemptLocal(toError(error)); return; } if (frame.frameType === TunnelFrameType.DeliveryAck) { failAttemptLocal(new Error('unexpected downstream delivery acknowledgement')); return; } if (flowControlNegotiated && frame.streamId !== 0) { // Count even late/cancelled streams: they still consumed sender credit. receivedBytes += plaintext.length; if (ackTimer === null) ackTimer = setTimeout(acknowledgeDelivery, 10); } // Any received frame proves the tunnel is alive — clear the pong deadline. if (pongDeadline !== null) { clearTimeout(pongDeadline); pongDeadline = null; } if (frame.frameType === TunnelFrameType.Ping) { channelObj.send(encodeTunnelFrame(TunnelFrameType.Pong, frame.streamId, EMPTY_PAYLOAD)); return; } if (frame.frameType === TunnelFrameType.Pong) return; // Non-keepalive inbound traffic counts as activity (suppresses our ping). lastActivityAt = Date.now(); let payload = frame.payload; if (frame.frameType === TunnelFrameType.WsText || frame.frameType === TunnelFrameType.WsBinary) { let complete: Uint8Array | null; try { complete = channelObj.assembler.push(frame); } catch (error) { failAttemptLocal(toError(error)); return; } if (complete === null) return; payload = complete; } else if (frame.hasMoreFragments) { failAttemptLocal(new Error('unexpected fragmented tunnel frame')); return; } const handler = channelObj.streams.get(frame.streamId); // Late frames for a stream we already dropped (abort race) are expected. if (!handler) return; handler.handleFrame(frame.frameType, payload); }; wire.onopen = () => { if (settled || generation !== attemptGeneration) return; sendHello(); helloInterval = setInterval(sendHello, helloRetryMs); }; wire.onmessage = (event) => { if (settled || generation !== attemptGeneration) return; const data = event.data; if (typeof data === 'string') { recvChain = recvChain .then(async () => { if (settled || generation !== attemptGeneration) return; // Post-establish text frames go through the handshake too: the host // re-answers retried hellos with duplicate `ready` frames, which the // handshake ignores; anything else fails closed there. const action = await handshake.handleText(data); if (action.type === 'established') { if (cryptoChannel) return; establish(action.channel, action.batch, action.flowControl); } else if (action.type === 'fail') { failAttemptLocal(new Error(`relay handshake failed: ${action.reason}`)); } }) .catch((error: unknown) => { failAttemptLocal(toError(error)); }); return; } const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null; if (!bytes) return; // Decrypt sequentially: the counter check requires wire order. recvChain = recvChain .then(async () => { if (settled || generation !== attemptGeneration) return; const currentChannel = channel; const currentCrypto = cryptoChannel; if (!currentChannel || !currentCrypto) { failAttemptLocal(new Error('encrypted frame before handshake completed')); return; } let plaintext: Uint8Array; try { plaintext = await currentCrypto.decryptor.decrypt(bytes); } catch (error) { failAttemptLocal(toError(error)); return; } let frames: Uint8Array[]; try { frames = batchNegotiated ? decodeFrameBatch(plaintext) : [plaintext]; } catch (error) { failAttemptLocal(toError(error)); return; } for (const frame of frames) { if (settled || generation !== attemptGeneration || currentChannel.dead) return; handleTunnelFrame(currentChannel, frame); } // One ACK per received batch, rather than one per fragment. Small // tails use the timer so a final frame cannot strand sender credit. if (flowControlNegotiated && receivedBytes - acknowledgedBytes >= 16 * 1024) acknowledgeDelivery(); }) .catch((error: unknown) => { failAttemptLocal(toError(error)); }); }; wire.onclose = (event) => { const terminal = TERMINAL_RELAY_CLOSE_CODES.has(event.code); failAttemptLocal( new Error(RELAY_CLOSE_MESSAGES[event.code] ?? `relay socket closed (code ${event.code})`), terminal, terminal, ); }; wire.onerror = () => { // onclose follows with the failure path. }; helloDeadline = setTimeout(() => { helloDeadline = null; failAttemptLocal(new Error('relay handshake timeout'), true); }, helloTimeoutMs); function failAttempt(gen: number, error: Error, asErrorState = false): void { if (gen !== attemptGeneration || closed) return; rejectWaiters(error); consecutiveFailures += 1; setStatus({ state: asErrorState ? 'error' : 'reconnecting', lastError: error.message }); scheduleReconnect(); } }; const waitForChannel = (signal?: AbortSignal): Promise => { if (closed) return Promise.reject(new Error('relay tunnel closed')); if (signal?.aborted) return Promise.reject(abortError()); if (activeChannel && !activeChannel.dead) return Promise.resolve(activeChannel); return new Promise((resolve, reject) => { let onAbort: (() => void) | null = null; const waiter: ChannelWaiter = { resolve(channel) { if (onAbort && signal) signal.removeEventListener('abort', onAbort); resolve(channel); }, reject(error) { if (onAbort && signal) signal.removeEventListener('abort', onAbort); reject(error); }, }; if (signal) { onAbort = () => { channelWaiters = channelWaiters.filter((entry) => entry !== waiter); reject(abortError()); }; signal.addEventListener('abort', onAbort, { once: true }); } channelWaiters.push(waiter); }); }; const tunnelFetch = async (input: string | URL | Request, init?: RequestInit): Promise => { const request = await normalizeTunnelRequest(input, init); const signal = request.signal; if (signal?.aborted) throw abortError(); const channel = await waitForChannel(signal); const streamId = channel.nextStreamId(); return await new Promise((resolve, reject) => { let responseDelivered = false; let finished = false; let bodyController: ReadableStreamDefaultController | null = null; let onAbort: (() => void) | null = null; const cleanupStream = (): void => { channel.streams.delete(streamId); channel.assembler.dropStream(streamId); if (onAbort && signal) signal.removeEventListener('abort', onAbort); }; const finishError = (error: Error): void => { if (finished) return; finished = true; cleanupStream(); if (!responseDelivered) { reject(error); return; } try { bodyController?.error(error); } catch { // Controller may already be closed. } }; const sendAbort = (reason: string): void => { if (!channel.dead) { channel.send(encodeTunnelFrame(TunnelFrameType.StreamAbort, streamId, encodeJsonPayload({ reason }))); } }; // The request head is written to the channel below before any of these // failures can fire, so losing the stream never proves the server did // not process the request — only that the response was lost. Callers // that would otherwise retry (prompt sends) must see that distinction. const dispatchedFailure = (message: string): Error => markAmbiguousTransportFailure(new Error(message)); onAbort = () => { sendAbort('aborted'); finishError(abortError()); }; channel.streams.set(streamId, { handleFrame(frameType, payload) { if (frameType === TunnelFrameType.HttpResponse) { if (responseDelivered || finished) return; let head; try { head = decodeJsonPayload(payload, isHttpResponsePayload); } catch (error) { sendAbort('malformed response head'); finishError(dispatchedFailure(toError(error).message)); return; } const nullBody = head.status === 204 || head.status === 205 || head.status === 304; let body: ReadableStream | null = null; if (!nullBody) { body = new ReadableStream({ start(controller) { bodyController = controller; }, cancel() { if (finished) return; finished = true; cleanupStream(); sendAbort('response body cancelled'); }, }); } responseDelivered = true; resolve(new Response(body, { status: head.status, headers: head.headers })); if (nullBody) { finished = true; cleanupStream(); } return; } if (frameType === TunnelFrameType.HttpBody) { if (!responseDelivered || finished) return; try { bodyController?.enqueue(payload); } catch { // Consumer already cancelled the stream. } return; } if (frameType === TunnelFrameType.StreamEnd) { if (finished) return; if (!responseDelivered) { finishError(dispatchedFailure('tunnel stream ended before response head')); return; } finished = true; cleanupStream(); try { bodyController?.close(); } catch { // Consumer already cancelled the stream. } return; } if (frameType === TunnelFrameType.StreamAbort) { let reason = 'stream aborted by host'; try { reason = decodeJsonPayload(payload, isStreamAbortPayload).reason; } catch { // Keep the generic reason. } finishError(dispatchedFailure(reason)); } }, fail(error) { // Channel death (reconnect, keepalive timeout) with this stream still // open — same rule as above: dispatched, outcome unknown. A fresh // error is tagged instead of the shared one so the tag cannot leak to // waiters whose request never reached the wire. finishError(dispatchedFailure(error.message)); }, }); if (signal) signal.addEventListener('abort', onAbort, { once: true }); const head: TunnelHttpRequestPayload = { method: request.method, path: request.path, query: request.query, headers: request.headers, hasBody: request.body !== null, }; channel.send(encodeTunnelFrame(TunnelFrameType.HttpRequest, streamId, encodeJsonPayload(head))); void (async () => { try { let sentBodyFrame = false; if (request.body) { for await (const chunk of request.body) { if (finished || channel.dead) return; for (const piece of chunkPayload(chunk)) { channel.send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, piece)); sentBodyFrame = true; } } } if (!finished && !channel.dead) { // A body source that yielded no chunks (e.g. an empty stream) still // declared hasBody in the head. Emit one empty body frame so the // host can tell this apart from body frames lost in transit, which // it aborts as an ambiguous transport failure. if (request.body && !sentBodyFrame) { channel.send(encodeTunnelFrame(TunnelFrameType.HttpBody, streamId, EMPTY_PAYLOAD)); } channel.send(encodeTunnelFrame(TunnelFrameType.StreamEnd, streamId, EMPTY_PAYLOAD)); } } catch (error) { sendAbort('request body failed'); finishError(dispatchedFailure(toError(error).message)); } })(); }); }; const splitPathQuery = (pathWithQuery: string): { path: string; query: string } => { const index = pathWithQuery.indexOf('?'); if (index === -1) return { path: pathWithQuery, query: '' }; return { path: pathWithQuery.slice(0, index), query: pathWithQuery.slice(index + 1) }; }; const openTunnelWebSocket = (pathWithQuery: string, protocols?: string[]): RelayTunnelWebSocket => { let readyState = WS_CONNECTING; let channelRef: ActiveChannel | null = null; let streamId = 0; let finished = false; const socket: RelayTunnelWebSocket = { get readyState() { return readyState; }, onopen: null, onmessage: null, onerror: null, onclose: null, send(data) { if (readyState !== WS_OPEN || !channelRef || channelRef.dead) { throw new Error('relay tunnel socket is not open'); } if (typeof data === 'string') { for (const frame of encodeFragmentedMessage(TunnelFrameType.WsText, streamId, textEncoder.encode(data))) { channelRef.send(frame); } return; } const bytes = data instanceof ArrayBuffer ? new Uint8Array(data.slice(0)) : (() => { const copy = new Uint8Array(data.byteLength); copy.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); return copy; })(); for (const frame of encodeFragmentedMessage(TunnelFrameType.WsBinary, streamId, bytes)) { channelRef.send(frame); } }, close(code = 1000, reason = '') { if (readyState === WS_CLOSED || readyState === WS_CLOSING) return; if (readyState === WS_OPEN && channelRef && !channelRef.dead) { readyState = WS_CLOSING; channelRef.send(encodeTunnelFrame(TunnelFrameType.WsClose, streamId, encodeJsonPayload({ code, reason }))); } settleClose(code, reason); }, }; const settleClose = (code: number, reason: string, errored = false): void => { if (finished) return; finished = true; if (channelRef) { channelRef.streams.delete(streamId); channelRef.assembler.dropStream(streamId); } readyState = WS_CLOSED; if (errored) { try { socket.onerror?.(); } catch { // Handler failures must not break teardown. } } try { socket.onclose?.({ code, reason }); } catch { // Handler failures must not break teardown. } }; void (async () => { let channel: ActiveChannel; try { channel = await waitForChannel(); } catch (error) { settleClose(1006, toError(error).message, true); return; } if (finished) return; channelRef = channel; streamId = channel.nextStreamId(); channel.streams.set(streamId, { handleFrame(frameType, payload) { if (frameType === TunnelFrameType.WsOpened) { if (readyState === WS_CONNECTING) { readyState = WS_OPEN; try { socket.onopen?.(); } catch { // Handler failures must not break the stream. } } return; } if (frameType === TunnelFrameType.WsText) { try { socket.onmessage?.({ data: textDecoder.decode(payload) }); } catch { // Handler failures must not break the stream. } return; } if (frameType === TunnelFrameType.WsBinary) { const buffer = new ArrayBuffer(payload.byteLength); new Uint8Array(buffer).set(payload); try { socket.onmessage?.({ data: buffer }); } catch { // Handler failures must not break the stream. } return; } if (frameType === TunnelFrameType.WsClose) { let code = 1000; let reason = ''; try { const parsed = decodeJsonPayload(payload, isWsClosePayload); code = parsed.code; reason = parsed.reason; } catch { // Keep defaults. } settleClose(code, reason); return; } if (frameType === TunnelFrameType.StreamAbort) { let reason = 'stream aborted'; try { reason = decodeJsonPayload(payload, isStreamAbortPayload).reason; } catch { // Keep the generic reason. } settleClose(1006, reason, true); } }, fail(error) { // Spec: streams killed by a tunnel reset close with 1012 so callers' // reconnect machinery treats it as "host went away, retry". settleClose(1012, error.message, true); }, }); const { path, query } = splitPathQuery(pathWithQuery); // The host sets the WS Origin itself (to the loopback origin it dials); the // client's window.location.origin is unreliable in WKWebView, so we don't send it. const openPayload: TunnelWsOpenPayload = protocols && protocols.length > 0 ? { path, query, protocols } : { path, query }; channel.send(encodeTunnelFrame(TunnelFrameType.WsOpen, streamId, encodeJsonPayload(openPayload))); })(); return socket; }; const close = (): void => { if (closed) return; closed = true; attemptGeneration += 1; clearReconnectTimer(); removeWakeListeners(); currentAttemptCleanup?.(); currentAttemptCleanup = null; const channel = activeChannel; activeChannel = null; const error = new Error('relay tunnel closed'); if (channel) failChannelStreams(channel, error); rejectWaiters(error); try { currentWire?.close(); } catch { // Wire may already be closed. } currentWire = null; setStatus({ state: 'idle' }); }; void connect(); return { fetch: tunnelFetch, openWebSocket: openTunnelWebSocket, getStatus: () => status, subscribeStatus(listener) { statusListeners.add(listener); return () => { statusListeners.delete(listener); }; }, close, }; };