/** * `ITransferTransport` adapter that ships chunks over a WebRTC * `DataChannel`. * * `probe` → opens (or reuses) the peer connection and asserts the * data channel is `open`. Throws on failure so the * caller-side `FallbackTransferTransport` can demote us * to HTTP. * `sendChunk` → encodes a `0x01` frame, sends, awaits the matching * `0x81 chunk-ack` (or `0xFE error`). * `fetchResumeState` → encodes a `0x02 resume-query`, awaits `0x82 * resume-state`. Returns `null` when the peer answers * with `'not found'`. * * Identical Ack contract to `ShadeTransferHttpTransport` so the upstream * `TransferEngine` pipeline (lane queues, retries, resume) doesn't care * which transport is in use. */ import { TransferAbortError, TransferTransportError, type ChunkAck, type ChunkSendOptions, type ITransferTransport, type TransferResumeState, } from '@shade/transfer'; import { WebRtcDataChannelError } from './errors.js'; import { encodeChunkFrame, encodeResumeQueryFrame, randomRequestId, streamIdStringToBytes, WIRE_CHUNK, WIRE_CHUNK_ACK, WIRE_ERROR, WIRE_RESUME_QUERY, WIRE_RESUME_STATE, } from './wire.js'; import type { WebRtcConnectionManager } from './manager.js'; export interface WebRtcTransferTransportOptions { manager: WebRtcConnectionManager; /** Per-request timeout in ms. Default 30s. */ requestTimeoutMs?: number; /** * Backpressure threshold — if `bufferedAmount` on the data channel * exceeds this value, sends pause until it drains under the threshold. * Default 4 MiB; the spec recommends ≤ 16 MiB to avoid SCTP stalls. */ backpressureThresholdBytes?: number; } /** * SCTP DataChannel chunks are limited per-message. The default cap matches * Chrome's safe upper bound (256 KiB) — adapters can fragment/reassemble * beyond that, but Shade's chunkSize default is 1 MiB so we'd need * fragmenting to ship full chunks. For now we surface a clear error if a * single envelope exceeds the cap. */ export const DEFAULT_MAX_DATACHANNEL_MESSAGE = 256 * 1024; export class WebRtcTransferTransport implements ITransferTransport { private readonly requestTimeoutMs: number; private readonly backpressureBytes: number; constructor(private readonly options: WebRtcTransferTransportOptions) { this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000; this.backpressureBytes = options.backpressureThresholdBytes ?? 4 * 1024 * 1024; } async probe(peerAddress: string): Promise { try { await this.options.manager.getOrCreate(peerAddress); } catch (err) { throw new TransferTransportError( `webrtc probe failed: ${(err as Error).message}`, ); } } async sendChunk( peerAddress: string, streamId: string, laneId: number, seq: number | bigint, bytes: Uint8Array, options?: ChunkSendOptions, ): Promise { if (options?.signal?.aborted) throw new TransferAbortError('aborted before send'); const conn = await this.options.manager.getOrCreate(peerAddress); // Backpressure: block if the SCTP buffer is full. await this.awaitDrain(conn, options?.signal); const seqBig = typeof seq === 'bigint' ? seq : BigInt(seq); const requestId = randomRequestId(); const streamIdBytes = streamIdStringToBytes(streamId); if (streamIdBytes.length !== 16) { throw new TransferTransportError(`streamId must decode to 16 bytes`); } const frame = encodeChunkFrame({ type: WIRE_CHUNK, requestId, streamId: streamIdBytes, laneId, seq: seqBig, envelope: bytes, }); if (frame.length > DEFAULT_MAX_DATACHANNEL_MESSAGE) { throw new TransferTransportError( `frame too large for data channel (${frame.length} > ${DEFAULT_MAX_DATACHANNEL_MESSAGE}); reduce chunkSize`, ); } const onAbort = (): void => { // The pending request inside `connection.request` will reject when // the data channel closes. We don't have a direct cancel handle, so // surface the abort as a transport error — the engine retries. }; options?.signal?.addEventListener('abort', onAbort, { once: true }); let frameRes; try { frameRes = await conn.request(frame, requestId, this.requestTimeoutMs); } finally { options?.signal?.removeEventListener('abort', onAbort); } if (frameRes.type === WIRE_ERROR) { throw new TransferTransportError(`webrtc sendChunk error: ${frameRes.json}`); } if (frameRes.type !== WIRE_CHUNK_ACK) { throw new TransferTransportError( `unexpected webrtc response type 0x${frameRes.type.toString(16)}`, ); } return { lastSeq: frameRes.lastSeq, bytesReceived: frameRes.bytesReceived, }; } async fetchResumeState( peerAddress: string, streamId: string, ): Promise { const conn = await this.options.manager.getOrCreate(peerAddress); const requestId = randomRequestId(); const streamIdBytes = streamIdStringToBytes(streamId); const frame = encodeResumeQueryFrame({ type: WIRE_RESUME_QUERY, requestId, streamId: streamIdBytes, }); const response = await conn.request(frame, requestId, this.requestTimeoutMs); if (response.type === WIRE_ERROR) { // Convention: 'not found' → null; anything else throws. try { const parsed = JSON.parse(response.json) as { error?: string }; if (typeof parsed.error === 'string' && parsed.error.includes('not found')) { return null; } } catch { /* fall through to throw */ } throw new TransferTransportError(`fetchResumeState failed: ${response.json}`); } if (response.type !== WIRE_RESUME_STATE) { throw new TransferTransportError( `unexpected webrtc response type 0x${response.type.toString(16)}`, ); } try { return JSON.parse(response.json) as TransferResumeState; } catch (err) { throw new TransferTransportError( `fetchResumeState bad JSON: ${(err as Error).message}`, ); } } /** Wait until the SCTP send buffer drains below the configured threshold. */ private async awaitDrain( conn: { sendRaw: (b: Uint8Array) => void }, signal?: AbortSignal, ): Promise { // The `conn` parameter intentionally has a structurally-narrow shape // — the data channel is internal to WebRtcConnection. Backpressure is // a soft optimisation; we expose the bufferedAmount via a getter. const dc = (conn as unknown as { dc: { bufferedAmount: number } | null }).dc; if (dc === null || dc === undefined) return; if (dc.bufferedAmount <= this.backpressureBytes) return; // Poll every 25 ms until the buffer drains. A more sophisticated impl // would use `bufferedamountlow` events but those require setting // `bufferedAmountLowThreshold`, which the IDataChannel shim doesn't // standardise yet. The polling overhead is negligible at MiB-scale // chunk sizes. const start = Date.now(); while (dc.bufferedAmount > this.backpressureBytes) { if (signal?.aborted) throw new TransferAbortError('aborted while waiting for drain'); if (Date.now() - start > 30_000) { throw new WebRtcDataChannelError( `bufferedAmount stayed above threshold for 30s (${dc.bufferedAmount} bytes)`, ); } await new Promise((resolve) => setTimeout(resolve, 25)); } } }