Files
Shade/packages/shade-transport-webrtc/src/transport.ts

211 lines
7.5 KiB
TypeScript
Raw Normal View History

/**
* `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<void> {
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<ChunkAck> {
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<TransferResumeState | null> {
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<void> {
// 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<void>((resolve) => setTimeout(resolve, 25));
}
}
}