release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
48
packages/shade-transport-webrtc/README.md
Normal file
48
packages/shade-transport-webrtc/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# @shade/transport-webrtc
|
||||
|
||||
V3.11 — direct peer-to-peer chunk transport for Shade transfers via
|
||||
`RTCDataChannel`. Plugs into `@shade/transfer`'s `ITransferTransport`
|
||||
contract and wires automatically into `@shade/sdk` via
|
||||
`shade.configureWebRTC()`.
|
||||
|
||||
```ts
|
||||
import { createShade } from '@shade/sdk';
|
||||
import { nativeRtcFactory } from '@shade/transport-webrtc';
|
||||
|
||||
const shade = await createShade({ prekeyServer });
|
||||
shade.configureWebRTC({ factory: nativeRtcFactory() });
|
||||
shade.configureTransfers({ resolveBaseUrl });
|
||||
|
||||
await shade.upload({ to: 'bob', input: file }); // → P2P when NAT allows,
|
||||
// HTTP otherwise.
|
||||
```
|
||||
|
||||
See [docs/webrtc.md](../../docs/webrtc.md) for the full guide:
|
||||
NAT-traversal realities, TURN config, glare resolution, wire format,
|
||||
diagnostics, and end-to-end test recipes.
|
||||
|
||||
## What's inside
|
||||
|
||||
- `WebRtcConnection` — one peer connection between two Shade endpoints,
|
||||
driving offer/answer/ICE through Shade's own ratchet.
|
||||
- `WebRtcConnectionManager` — per-peer pool with deterministic glare
|
||||
resolution.
|
||||
- `WebRtcSignalingChannel` — JSON signaling messages multiplexed over
|
||||
`Shade.send` / `Shade.onMessage`.
|
||||
- `WebRtcTransferTransport` — implements `ITransferTransport` over the
|
||||
managed DataChannel; ack-correlated by 16-byte requestId tokens.
|
||||
- `MemoryRtcFactory` — in-process WebRTC simulator for tests.
|
||||
- `nativeRtcFactory()` — adapter over `globalThis.RTCPeerConnection`
|
||||
(browsers / Deno / Cloudflare Workers).
|
||||
|
||||
## Adapters
|
||||
|
||||
`@shade/transport-webrtc` ships only the standard-API adapter
|
||||
(`nativeRtcFactory`). For Bun / Node, wrap your library of choice
|
||||
behind the `IRtcFactory` interface — only `createPeerConnection`,
|
||||
`createDataChannel`, and standard `addEventListener` are required.
|
||||
|
||||
Recommended adapters:
|
||||
|
||||
- [`node-datachannel`](https://github.com/murat-dogan/node-datachannel)
|
||||
- [`@roamhq/wrtc`](https://www.npmjs.com/package/@roamhq/wrtc)
|
||||
12
packages/shade-transport-webrtc/package.json
Normal file
12
packages/shade-transport-webrtc/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@shade/transport-webrtc",
|
||||
"version": "4.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"dependencies": {
|
||||
"@shade/core": "workspace:*",
|
||||
"@shade/streams": "workspace:*",
|
||||
"@shade/transfer": "workspace:*"
|
||||
}
|
||||
}
|
||||
560
packages/shade-transport-webrtc/src/connection.ts
Normal file
560
packages/shade-transport-webrtc/src/connection.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* One peer connection between two Shade endpoints.
|
||||
*
|
||||
* Wraps an `IPeerConnection` plus the single bidirectional Shade transfer
|
||||
* `IDataChannel`. Drives offer/answer/ICE through a {@link WebRtcSignalingChannel}
|
||||
* and exposes lifecycle hooks (`open`, `close`, `failure`).
|
||||
*
|
||||
* The class is symmetric in API but asymmetric in state machine: the
|
||||
* caller-side instance generates `sessionId` + creates the offer; the
|
||||
* callee-side instance is created when an inbound `offer` arrives. Glare
|
||||
* is resolved at the manager layer — see `manager.ts`.
|
||||
*/
|
||||
|
||||
import {
|
||||
WebRtcConnectError,
|
||||
WebRtcDataChannelError,
|
||||
WebRtcTimeoutError,
|
||||
} from './errors.js';
|
||||
import {
|
||||
decodeFrame,
|
||||
encodeChunkAckFrame,
|
||||
encodeErrorFrame,
|
||||
encodePongFrame,
|
||||
encodeResumeStateFrame,
|
||||
randomRequestId,
|
||||
WIRE_CHUNK,
|
||||
WIRE_CHUNK_ACK,
|
||||
WIRE_ERROR,
|
||||
WIRE_PING,
|
||||
WIRE_PONG,
|
||||
WIRE_RESUME_QUERY,
|
||||
WIRE_RESUME_STATE,
|
||||
type AnyFrame,
|
||||
type ChunkFrame,
|
||||
type ResumeQueryFrame,
|
||||
} from './wire.js';
|
||||
import type { WebRtcSignalingChannel } from './signaling.js';
|
||||
import type {
|
||||
IDataChannel,
|
||||
IPeerConnection,
|
||||
IRtcFactory,
|
||||
ShadeIceCandidate,
|
||||
ShadeRtcConfig,
|
||||
ShadeRtcConnectionState,
|
||||
ShadeSessionDescription,
|
||||
WebRtcAnswerMessage,
|
||||
WebRtcByeMessage,
|
||||
WebRtcIceMessage,
|
||||
WebRtcOfferMessage,
|
||||
} from './types.js';
|
||||
|
||||
/** Label used for the single Shade data channel. */
|
||||
export const SHADE_DATACHANNEL_LABEL = 'shade-transfer/v1';
|
||||
|
||||
export interface WebRtcConnectionDeps {
|
||||
factory: IRtcFactory;
|
||||
config: ShadeRtcConfig;
|
||||
signaling: WebRtcSignalingChannel;
|
||||
/** Default 30s — applies to the entire connection-establishment handshake. */
|
||||
connectTimeoutMs?: number;
|
||||
/**
|
||||
* Optional receiver-side hooks. When provided, the connection registers
|
||||
* a chunk + resume-state handler on the DataChannel and replies with
|
||||
* the appropriate ack frames.
|
||||
*/
|
||||
receiver?: WebRtcReceiverHooks;
|
||||
}
|
||||
|
||||
export interface WebRtcReceiverHooks {
|
||||
/** Called when a peer pushes a chunk over the DataChannel. */
|
||||
onChunk(
|
||||
fromPeer: string,
|
||||
streamId: string,
|
||||
laneId: number,
|
||||
seq: bigint,
|
||||
envelope: Uint8Array,
|
||||
): Promise<{ lastSeq: number; bytesReceived?: number }>;
|
||||
/** Called when a peer queries resume state. Return `null` for unknown. */
|
||||
onResumeQuery(
|
||||
fromPeer: string,
|
||||
streamId: string,
|
||||
): Promise<{ streamId: string; lanes: Array<{ laneId: number; lastSeqAcked: number }> } | null>;
|
||||
}
|
||||
|
||||
type Role = 'caller' | 'callee';
|
||||
|
||||
interface PendingRequest {
|
||||
resolve(frame: AnyFrame): void;
|
||||
reject(err: unknown): void;
|
||||
cleanupTimer: () => void;
|
||||
}
|
||||
|
||||
export class WebRtcConnection {
|
||||
/** State exposed to the manager layer. */
|
||||
state: ShadeRtcConnectionState = 'new';
|
||||
/** Peer this connection talks to. */
|
||||
readonly peerAddress: string;
|
||||
/** Caller-generated sessionId; both peers tag every signaling message with this. */
|
||||
readonly sessionId: string;
|
||||
/** 'caller' produced the SDP offer; 'callee' answered. */
|
||||
readonly role: Role;
|
||||
|
||||
private pc!: IPeerConnection;
|
||||
private dc: IDataChannel | null = null;
|
||||
private readonly deps: WebRtcConnectionDeps;
|
||||
private opened = false;
|
||||
private closed = false;
|
||||
|
||||
private readonly openWaiters: Array<{
|
||||
resolve(): void;
|
||||
reject(err: unknown): void;
|
||||
}> = [];
|
||||
|
||||
private readonly closeWaiters: Array<() => void> = [];
|
||||
|
||||
/** requestId → pending response. */
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
/** Trickled ICE candidates that arrived before setRemoteDescription. */
|
||||
private readonly pendingRemoteCandidates: Array<ShadeIceCandidate | null> = [];
|
||||
/** True after we've called setRemoteDescription so further ICE applies directly. */
|
||||
private remoteDescriptionSet = false;
|
||||
|
||||
/** External listeners (manager wires these). */
|
||||
readonly onClose = new Set<() => void>();
|
||||
readonly onFailure = new Set<(err: unknown) => void>();
|
||||
|
||||
/**
|
||||
* Construct WITHOUT starting the handshake. Call `start()` for caller
|
||||
* role, or `acceptOffer(offer)` for callee role.
|
||||
*/
|
||||
constructor(args: {
|
||||
deps: WebRtcConnectionDeps;
|
||||
peerAddress: string;
|
||||
sessionId: string;
|
||||
role: Role;
|
||||
}) {
|
||||
this.deps = args.deps;
|
||||
this.peerAddress = args.peerAddress;
|
||||
this.sessionId = args.sessionId;
|
||||
this.role = args.role;
|
||||
}
|
||||
|
||||
/** Caller side: create offer and ship it to the peer. */
|
||||
async start(): Promise<void> {
|
||||
if (this.role !== 'caller') {
|
||||
throw new WebRtcConnectError(`start() invalid for role=${this.role}`);
|
||||
}
|
||||
this.pc = this.deps.factory.createPeerConnection(this.deps.config);
|
||||
this.installPeerConnectionListeners();
|
||||
|
||||
// The caller creates the DataChannel BEFORE createOffer so the SDP
|
||||
// negotiates an `m=application` section.
|
||||
this.dc = this.pc.createDataChannel(SHADE_DATACHANNEL_LABEL, { ordered: true });
|
||||
this.dc.binaryType = 'arraybuffer';
|
||||
this.installDataChannelListeners(this.dc);
|
||||
|
||||
this.state = 'connecting';
|
||||
const offer = await this.pc.createOffer();
|
||||
await this.pc.setLocalDescription(offer);
|
||||
await this.deps.signaling.sendOffer(this.peerAddress, this.sessionId, offer.sdp);
|
||||
|
||||
this.armConnectTimeout();
|
||||
}
|
||||
|
||||
/** Callee side: accept inbound offer, create answer, ship it back. */
|
||||
async acceptOffer(offer: ShadeSessionDescription): Promise<void> {
|
||||
if (this.role !== 'callee') {
|
||||
throw new WebRtcConnectError(`acceptOffer() invalid for role=${this.role}`);
|
||||
}
|
||||
this.pc = this.deps.factory.createPeerConnection(this.deps.config);
|
||||
this.installPeerConnectionListeners();
|
||||
|
||||
// Callee receives the DataChannel via 'datachannel' event.
|
||||
this.pc.addEventListener('datachannel', (ev) => {
|
||||
if (this.dc !== null) return;
|
||||
this.dc = ev.channel;
|
||||
this.dc.binaryType = 'arraybuffer';
|
||||
this.installDataChannelListeners(this.dc);
|
||||
});
|
||||
|
||||
this.state = 'connecting';
|
||||
await this.pc.setRemoteDescription(offer);
|
||||
this.remoteDescriptionSet = true;
|
||||
await this.flushPendingRemoteCandidates();
|
||||
|
||||
const answer = await this.pc.createAnswer();
|
||||
await this.pc.setLocalDescription(answer);
|
||||
await this.deps.signaling.sendAnswer(this.peerAddress, this.sessionId, answer.sdp);
|
||||
|
||||
this.armConnectTimeout();
|
||||
}
|
||||
|
||||
/** Caller side: peer's answer arrived. */
|
||||
async acceptAnswer(answer: ShadeSessionDescription): Promise<void> {
|
||||
if (this.role !== 'caller') {
|
||||
throw new WebRtcConnectError(`acceptAnswer() invalid for role=${this.role}`);
|
||||
}
|
||||
if (this.remoteDescriptionSet) {
|
||||
// Spurious duplicate; ignore.
|
||||
return;
|
||||
}
|
||||
await this.pc.setRemoteDescription(answer);
|
||||
this.remoteDescriptionSet = true;
|
||||
await this.flushPendingRemoteCandidates();
|
||||
}
|
||||
|
||||
/** Trickle-ICE: peer reported a candidate. `null` means end-of-candidates. */
|
||||
async addRemoteCandidate(candidate: ShadeIceCandidate | null): Promise<void> {
|
||||
if (!this.remoteDescriptionSet) {
|
||||
this.pendingRemoteCandidates.push(candidate);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.pc.addIceCandidate(candidate);
|
||||
} catch (err) {
|
||||
// Failed candidates are non-fatal; ICE chooses among many.
|
||||
console.warn('[WebRtcConnection] addIceCandidate failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until the data channel is open. Rejects on failure or timeout. */
|
||||
async waitForOpen(): Promise<void> {
|
||||
if (this.opened) return;
|
||||
if (this.closed) throw new WebRtcConnectError('connection already closed');
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.openWaiters.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a pre-encoded frame and wait for the reply matched by requestId. */
|
||||
async request(frame: Uint8Array, requestId: Uint8Array, timeoutMs = 30_000): Promise<AnyFrame> {
|
||||
await this.waitForOpen();
|
||||
if (this.dc === null) throw new WebRtcDataChannelError('data channel missing');
|
||||
if (this.dc.readyState !== 'open') {
|
||||
throw new WebRtcDataChannelError(`data channel not open (${this.dc.readyState})`);
|
||||
}
|
||||
return new Promise<AnyFrame>((resolve, reject) => {
|
||||
const key = bytesToHex(requestId);
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(key);
|
||||
reject(new WebRtcTimeoutError(`request ${key} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(key, {
|
||||
resolve: (f) => {
|
||||
clearTimeout(timer);
|
||||
resolve(f);
|
||||
},
|
||||
reject: (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
},
|
||||
cleanupTimer: () => clearTimeout(timer),
|
||||
});
|
||||
try {
|
||||
this.dc!.send(frame);
|
||||
} catch (err) {
|
||||
this.pending.delete(key);
|
||||
clearTimeout(timer);
|
||||
reject(new WebRtcDataChannelError(`send failed: ${(err as Error).message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Send a frame without expecting a reply. Used for receiver-side ack frames. */
|
||||
sendRaw(frame: Uint8Array): void {
|
||||
if (this.dc === null || this.dc.readyState !== 'open') {
|
||||
throw new WebRtcDataChannelError(
|
||||
`data channel not open (state=${this.dc?.readyState ?? 'missing'})`,
|
||||
);
|
||||
}
|
||||
this.dc.send(frame);
|
||||
}
|
||||
|
||||
/** Tear down. Idempotent. */
|
||||
async close(reason?: string): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
if (!this.opened) {
|
||||
const err = new WebRtcConnectError(reason ?? 'connection closed before open');
|
||||
for (const w of this.openWaiters.splice(0)) w.reject(err);
|
||||
} else {
|
||||
this.openWaiters.splice(0);
|
||||
}
|
||||
for (const p of this.pending.values()) {
|
||||
p.cleanupTimer();
|
||||
p.reject(new WebRtcConnectError(reason ?? 'connection closed'));
|
||||
}
|
||||
this.pending.clear();
|
||||
try {
|
||||
await this.deps.signaling.sendBye(this.peerAddress, this.sessionId, reason);
|
||||
} catch {
|
||||
/* swallow — we're tearing down anyway */
|
||||
}
|
||||
try {
|
||||
this.dc?.close();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
try {
|
||||
this.pc?.close();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
this.state = 'closed';
|
||||
for (const w of this.closeWaiters.splice(0)) w();
|
||||
for (const cb of this.onClose) {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.warn('[WebRtcConnection] onClose handler threw:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Promise that resolves when the connection has fully closed. */
|
||||
closed_promise(): Promise<void> {
|
||||
if (this.closed) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => this.closeWaiters.push(resolve));
|
||||
}
|
||||
|
||||
// ─── Private ───────────────────────────────────────────
|
||||
|
||||
private installPeerConnectionListeners(): void {
|
||||
this.pc.addEventListener('icecandidate', async (ev) => {
|
||||
try {
|
||||
await this.deps.signaling.sendIce(this.peerAddress, this.sessionId, ev.candidate);
|
||||
} catch (err) {
|
||||
console.warn('[WebRtcConnection] ICE send failed:', err);
|
||||
}
|
||||
});
|
||||
this.pc.addEventListener('connectionstatechange', () => {
|
||||
const cs = String(this.pc.connectionState);
|
||||
if (cs === 'failed') {
|
||||
const err = new WebRtcConnectError('connectionState=failed');
|
||||
for (const w of this.openWaiters.splice(0)) w.reject(err);
|
||||
for (const cb of this.onFailure) cb(err);
|
||||
void this.close('connectionState=failed');
|
||||
} else if (cs === 'closed') {
|
||||
if (!this.closed) void this.close('peer closed');
|
||||
} else if (cs === 'disconnected') {
|
||||
this.state = 'disconnected';
|
||||
}
|
||||
});
|
||||
this.pc.addEventListener('iceconnectionstatechange', () => {
|
||||
// Some browsers (Firefox/Safari) drive 'connectionstatechange' through
|
||||
// 'iceconnectionstatechange'. Mirror failed → tear down.
|
||||
const ics = this.pc.iceConnectionState;
|
||||
if (ics === 'failed') {
|
||||
const err = new WebRtcConnectError('iceConnectionState=failed');
|
||||
for (const w of this.openWaiters.splice(0)) w.reject(err);
|
||||
for (const cb of this.onFailure) cb(err);
|
||||
void this.close('iceConnectionState=failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private installDataChannelListeners(dc: IDataChannel): void {
|
||||
dc.addEventListener('open', () => {
|
||||
this.opened = true;
|
||||
this.state = 'connected';
|
||||
const waiters = this.openWaiters.splice(0);
|
||||
for (const w of waiters) w.resolve();
|
||||
});
|
||||
dc.addEventListener('close', () => {
|
||||
if (!this.closed) void this.close('data channel closed');
|
||||
});
|
||||
dc.addEventListener('error', (ev) => {
|
||||
const err = new WebRtcDataChannelError(`data channel error: ${stringifyEvent(ev)}`);
|
||||
for (const w of this.openWaiters.splice(0)) w.reject(err);
|
||||
for (const cb of this.onFailure) cb(err);
|
||||
void this.close('data channel error');
|
||||
});
|
||||
dc.addEventListener('message', (ev) => {
|
||||
void this.handleMessage(ev.data);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessage(data: ArrayBuffer): Promise<void> {
|
||||
let frame: AnyFrame;
|
||||
try {
|
||||
frame = decodeFrame(data);
|
||||
} catch (err) {
|
||||
console.warn('[WebRtcConnection] frame decode failed:', err);
|
||||
return;
|
||||
}
|
||||
const key = bytesToHex(frame.requestId);
|
||||
|
||||
// Server-side response frames: complete a pending caller request.
|
||||
if (
|
||||
frame.type === WIRE_CHUNK_ACK ||
|
||||
frame.type === WIRE_RESUME_STATE ||
|
||||
frame.type === WIRE_PONG ||
|
||||
frame.type === WIRE_ERROR
|
||||
) {
|
||||
const pending = this.pending.get(key);
|
||||
if (pending !== undefined) {
|
||||
this.pending.delete(key);
|
||||
pending.resolve(frame);
|
||||
return;
|
||||
}
|
||||
// Unmatched response — log and drop.
|
||||
console.warn(`[WebRtcConnection] no pending request for response ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Client-side request frames: dispatch to receiver hooks (if any).
|
||||
if (this.deps.receiver === undefined) {
|
||||
this.sendRawSafe(
|
||||
encodeErrorFrame({
|
||||
type: WIRE_ERROR,
|
||||
requestId: frame.requestId,
|
||||
json: JSON.stringify({ error: 'no receiver registered on this peer' }),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === WIRE_PING) {
|
||||
this.sendRawSafe(
|
||||
encodePongFrame({ type: WIRE_PONG, requestId: frame.requestId, nonce: frame.nonce }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === WIRE_CHUNK) {
|
||||
await this.handleChunkRequest(frame);
|
||||
return;
|
||||
}
|
||||
if (frame.type === WIRE_RESUME_QUERY) {
|
||||
await this.handleResumeQuery(frame);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleChunkRequest(frame: ChunkFrame): Promise<void> {
|
||||
const streamIdString = streamIdBytesToBase64Url(frame.streamId);
|
||||
try {
|
||||
const ack = await this.deps.receiver!.onChunk(
|
||||
this.peerAddress,
|
||||
streamIdString,
|
||||
frame.laneId,
|
||||
frame.seq,
|
||||
frame.envelope,
|
||||
);
|
||||
this.sendRawSafe(
|
||||
encodeChunkAckFrame({
|
||||
type: WIRE_CHUNK_ACK,
|
||||
requestId: frame.requestId,
|
||||
lastSeq: ack.lastSeq,
|
||||
bytesReceived: ack.bytesReceived ?? 0,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
this.sendRawSafe(
|
||||
encodeErrorFrame({
|
||||
type: WIRE_ERROR,
|
||||
requestId: frame.requestId,
|
||||
json: JSON.stringify({ error: (err as Error).message ?? String(err) }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResumeQuery(frame: ResumeQueryFrame): Promise<void> {
|
||||
const streamIdString = streamIdBytesToBase64Url(frame.streamId);
|
||||
try {
|
||||
const state = await this.deps.receiver!.onResumeQuery(this.peerAddress, streamIdString);
|
||||
if (state === null) {
|
||||
this.sendRawSafe(
|
||||
encodeErrorFrame({
|
||||
type: WIRE_ERROR,
|
||||
requestId: frame.requestId,
|
||||
json: JSON.stringify({ error: 'not found' }),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.sendRawSafe(
|
||||
encodeResumeStateFrame({
|
||||
type: WIRE_RESUME_STATE,
|
||||
requestId: frame.requestId,
|
||||
json: JSON.stringify(state),
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
this.sendRawSafe(
|
||||
encodeErrorFrame({
|
||||
type: WIRE_ERROR,
|
||||
requestId: frame.requestId,
|
||||
json: JSON.stringify({ error: (err as Error).message ?? String(err) }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private sendRawSafe(frame: Uint8Array): void {
|
||||
try {
|
||||
this.sendRaw(frame);
|
||||
} catch (err) {
|
||||
console.warn('[WebRtcConnection] sendRaw failed during response:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async flushPendingRemoteCandidates(): Promise<void> {
|
||||
const queued = this.pendingRemoteCandidates.splice(0);
|
||||
for (const c of queued) {
|
||||
try {
|
||||
await this.pc.addIceCandidate(c);
|
||||
} catch (err) {
|
||||
console.warn('[WebRtcConnection] queued ICE addIceCandidate failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private armConnectTimeout(): void {
|
||||
const timeout = this.deps.connectTimeoutMs ?? 30_000;
|
||||
setTimeout(() => {
|
||||
if (this.opened || this.closed) return;
|
||||
const err = new WebRtcTimeoutError(`connection did not open within ${timeout}ms`);
|
||||
for (const w of this.openWaiters.splice(0)) w.reject(err);
|
||||
for (const cb of this.onFailure) cb(err);
|
||||
void this.close('connect timeout');
|
||||
}, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level helpers — exported as they're used by manager + transport.
|
||||
|
||||
export function generateSessionId(): string {
|
||||
const bytes = randomRequestId(); // re-use 16-byte randomness
|
||||
return streamIdBytesToBase64Url(bytes);
|
||||
}
|
||||
|
||||
function bytesToHex(b: Uint8Array): string {
|
||||
let s = '';
|
||||
for (let i = 0; i < b.length; i++) s += b[i]!.toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
function streamIdBytesToBase64Url(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function stringifyEvent(ev: { error?: unknown }): string {
|
||||
if (ev?.error !== undefined) {
|
||||
if (ev.error instanceof Error) return ev.error.message;
|
||||
return String(ev.error);
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// Forward type-only re-exports so consumers don't have to import twice.
|
||||
export type {
|
||||
WebRtcOfferMessage,
|
||||
WebRtcAnswerMessage,
|
||||
WebRtcIceMessage,
|
||||
WebRtcByeMessage,
|
||||
};
|
||||
35
packages/shade-transport-webrtc/src/errors.ts
Normal file
35
packages/shade-transport-webrtc/src/errors.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Errors raised by `@shade/transport-webrtc`. All extend
|
||||
* `TransferTransportError` (re-exported by `@shade/transfer`) so that
|
||||
* `FallbackTransferTransport` automatically demotes us when WebRTC is
|
||||
* dead and HTTP/WS picks up.
|
||||
*/
|
||||
import { TransferTransportError } from '@shade/transfer';
|
||||
|
||||
export class WebRtcSignalingError extends TransferTransportError {
|
||||
override readonly name = 'WebRtcSignalingError';
|
||||
constructor(message: string) {
|
||||
super(`[webrtc-signaling] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class WebRtcConnectError extends TransferTransportError {
|
||||
override readonly name = 'WebRtcConnectError';
|
||||
constructor(message: string) {
|
||||
super(`[webrtc-connect] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class WebRtcDataChannelError extends TransferTransportError {
|
||||
override readonly name = 'WebRtcDataChannelError';
|
||||
constructor(message: string) {
|
||||
super(`[webrtc-datachannel] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class WebRtcTimeoutError extends TransferTransportError {
|
||||
override readonly name = 'WebRtcTimeoutError';
|
||||
constructor(message: string) {
|
||||
super(`[webrtc-timeout] ${message}`);
|
||||
}
|
||||
}
|
||||
102
packages/shade-transport-webrtc/src/index.ts
Normal file
102
packages/shade-transport-webrtc/src/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* `@shade/transport-webrtc` — V3.11 P2P transport.
|
||||
*
|
||||
* Direct peer-to-peer `RTCDataChannel` between Shade clients when NAT/
|
||||
* firewall allows. Signaling rides Shade's existing control plane
|
||||
* (`Shade.send` / `Shade.onMessage`); chunk data flows direct (or via
|
||||
* TURN-relay when ICE forces it). The wire payload is still encrypted
|
||||
* with Shade's ratchet/streams crypto — WebRTC's DTLS-SRTP is just an
|
||||
* additional transport-secrecy layer.
|
||||
*
|
||||
* Quick start:
|
||||
*
|
||||
* ```ts
|
||||
* import { createShade } from '@shade/sdk';
|
||||
* import {
|
||||
* WebRtcConnectionManager,
|
||||
* WebRtcSignalingChannel,
|
||||
* WebRtcTransferTransport,
|
||||
* nativeRtcFactory,
|
||||
* createShadeBridgeFromShade,
|
||||
* } from '@shade/transport-webrtc';
|
||||
*
|
||||
* const shade = await createShade({ ... });
|
||||
* const signaling = new WebRtcSignalingChannel(createShadeBridgeFromShade(shade));
|
||||
* const manager = new WebRtcConnectionManager({
|
||||
* factory: nativeRtcFactory(),
|
||||
* signaling,
|
||||
* config: { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] },
|
||||
* });
|
||||
* const transport = new WebRtcTransferTransport({ manager });
|
||||
* shade.configureTransfers({ resolveBaseUrl, transport }); // see SDK docs
|
||||
* ```
|
||||
*
|
||||
* For receiver-side use, pass `receiver` hooks to the manager so it can
|
||||
* decrypt incoming chunks via `Shade.acceptTransferEnvelope`.
|
||||
*/
|
||||
|
||||
export { WebRtcConnection, SHADE_DATACHANNEL_LABEL, generateSessionId } from './connection.js';
|
||||
export type { WebRtcReceiverHooks, WebRtcConnectionDeps } from './connection.js';
|
||||
|
||||
export { WebRtcConnectionManager } from './manager.js';
|
||||
export type { WebRtcConnectionManagerOptions } from './manager.js';
|
||||
|
||||
export {
|
||||
WebRtcSignalingChannel,
|
||||
MemoryShadeBridge,
|
||||
} from './signaling.js';
|
||||
export type { ShadeBridge, SignalingHandler } from './signaling.js';
|
||||
|
||||
export {
|
||||
WebRtcTransferTransport,
|
||||
DEFAULT_MAX_DATACHANNEL_MESSAGE,
|
||||
} from './transport.js';
|
||||
export type { WebRtcTransferTransportOptions } from './transport.js';
|
||||
|
||||
export {
|
||||
WebRtcConnectError,
|
||||
WebRtcDataChannelError,
|
||||
WebRtcSignalingError,
|
||||
WebRtcTimeoutError,
|
||||
} from './errors.js';
|
||||
|
||||
export {
|
||||
DEFAULT_STUN_SERVERS,
|
||||
isWebRtcSignalingMessage,
|
||||
parseWebRtcSignaling,
|
||||
encodeWebRtcSignaling,
|
||||
} from './types.js';
|
||||
export type {
|
||||
IPeerConnection,
|
||||
IDataChannel,
|
||||
IRtcFactory,
|
||||
ShadeRtcConfig,
|
||||
ShadeIceServer,
|
||||
ShadeIceCandidate,
|
||||
ShadeRtcConnectionState,
|
||||
ShadeSdpType,
|
||||
ShadeSessionDescription,
|
||||
WebRtcSignalingMessage,
|
||||
WebRtcOfferMessage,
|
||||
WebRtcAnswerMessage,
|
||||
WebRtcIceMessage,
|
||||
WebRtcByeMessage,
|
||||
WebRtcSignalingKind,
|
||||
} from './types.js';
|
||||
|
||||
export { NativeRtcFactory, nativeRtcFactory, isNativeRtcAvailable } from './native-rtc.js';
|
||||
export { MemoryRtcFactory } from './memory-rtc.js';
|
||||
|
||||
export { createShadeBridgeFromShade } from './shade-bridge.js';
|
||||
export type { ShadeLike } from './shade-bridge.js';
|
||||
|
||||
// Wire format constants — useful for adapters that want to interop.
|
||||
export {
|
||||
WIRE_CHUNK,
|
||||
WIRE_RESUME_QUERY,
|
||||
WIRE_PING,
|
||||
WIRE_CHUNK_ACK,
|
||||
WIRE_RESUME_STATE,
|
||||
WIRE_PONG,
|
||||
WIRE_ERROR,
|
||||
} from './wire.js';
|
||||
296
packages/shade-transport-webrtc/src/manager.ts
Normal file
296
packages/shade-transport-webrtc/src/manager.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Per-peer connection pool.
|
||||
*
|
||||
* The manager owns at most one {@link WebRtcConnection} per peer address.
|
||||
* It dispatches inbound signaling messages to the matching connection,
|
||||
* resolves glare deterministically (lexicographic address compare), and
|
||||
* exposes a single `getOrCreate(peerAddress)` entrypoint to the rest of
|
||||
* the package.
|
||||
*/
|
||||
|
||||
import { WebRtcConnectError } from './errors.js';
|
||||
import {
|
||||
generateSessionId,
|
||||
WebRtcConnection,
|
||||
type WebRtcReceiverHooks,
|
||||
} from './connection.js';
|
||||
import type { WebRtcSignalingChannel } from './signaling.js';
|
||||
import type {
|
||||
IRtcFactory,
|
||||
ShadeRtcConfig,
|
||||
WebRtcSignalingMessage,
|
||||
} from './types.js';
|
||||
|
||||
export interface WebRtcConnectionManagerOptions {
|
||||
factory: IRtcFactory;
|
||||
signaling: WebRtcSignalingChannel;
|
||||
config?: ShadeRtcConfig;
|
||||
/** Default 30s; passed through to each connection. */
|
||||
connectTimeoutMs?: number;
|
||||
/** Optional hooks invoked when a peer pushes chunks / queries resume state. */
|
||||
receiver?: WebRtcReceiverHooks;
|
||||
/** Default {@link DEFAULT_STUN_SERVERS}. */
|
||||
defaultStunServers?: ShadeRtcConfig['iceServers'];
|
||||
}
|
||||
|
||||
export class WebRtcConnectionManager {
|
||||
private readonly byPeer = new Map<string, WebRtcConnection>();
|
||||
private readonly inflight = new Map<string, Promise<WebRtcConnection>>();
|
||||
private readonly unsubscribe: () => void;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(private readonly options: WebRtcConnectionManagerOptions) {
|
||||
this.unsubscribe = options.signaling.onSignal((from, msg) =>
|
||||
this.handleSignaling(from, msg),
|
||||
);
|
||||
}
|
||||
|
||||
/** This endpoint's own address — used for glare tiebreak. */
|
||||
get myAddress(): string {
|
||||
return this.options.signaling.myAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the connection for `peerAddress`. Returns the existing one if
|
||||
* it's open or in-flight, otherwise initiates a fresh caller-side
|
||||
* negotiation. The returned promise resolves once the data channel is
|
||||
* `open` and rejects on connect failure / timeout.
|
||||
*
|
||||
* Glare semantics: if our caller-role connection got yielded mid-flight
|
||||
* (because the peer's address is lexically smaller than ours), the
|
||||
* `byPeer` slot is replaced with a callee-role connection. We follow
|
||||
* the swap automatically — the user sees a single resolved promise.
|
||||
*/
|
||||
async getOrCreate(peerAddress: string, attempt = 0): Promise<WebRtcConnection> {
|
||||
if (this.destroyed) throw new WebRtcConnectError('manager destroyed');
|
||||
if (attempt > 4) {
|
||||
throw new WebRtcConnectError(
|
||||
`getOrCreate(${peerAddress}) gave up after ${attempt} retries`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = this.byPeer.get(peerAddress);
|
||||
if (existing !== undefined && (existing.state === 'connecting' || existing.state === 'connected')) {
|
||||
try {
|
||||
await existing.waitForOpen();
|
||||
return existing;
|
||||
} catch (err) {
|
||||
// The conn closed mid-wait. If a fresh slot landed in `byPeer`
|
||||
// (glare swap), retry; otherwise propagate.
|
||||
const replacement = this.byPeer.get(peerAddress);
|
||||
if (replacement !== undefined && replacement !== existing) {
|
||||
return this.getOrCreate(peerAddress, attempt + 1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const inflight = this.inflight.get(peerAddress);
|
||||
if (inflight !== undefined) {
|
||||
try {
|
||||
return await inflight;
|
||||
} catch {
|
||||
// The in-flight initiate failed; fall through to a retry which
|
||||
// will pick up either an empty slot or a callee that took ours.
|
||||
return this.getOrCreate(peerAddress, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const promise = this.initiate(peerAddress);
|
||||
this.inflight.set(peerAddress, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} catch (err) {
|
||||
const replacement = this.byPeer.get(peerAddress);
|
||||
if (
|
||||
replacement !== undefined &&
|
||||
(replacement.state === 'connecting' || replacement.state === 'connected')
|
||||
) {
|
||||
return this.getOrCreate(peerAddress, attempt + 1);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.inflight.delete(peerAddress);
|
||||
}
|
||||
}
|
||||
|
||||
/** Force a fresh connection (closes any existing). Used by tests + diagnostics. */
|
||||
async reconnect(peerAddress: string): Promise<WebRtcConnection> {
|
||||
await this.closePeer(peerAddress, 'reconnect');
|
||||
return this.getOrCreate(peerAddress);
|
||||
}
|
||||
|
||||
/** True when an open connection exists. */
|
||||
isConnected(peerAddress: string): boolean {
|
||||
const conn = this.byPeer.get(peerAddress);
|
||||
return conn !== undefined && conn.state === 'connected';
|
||||
}
|
||||
|
||||
/** Tear down the connection (if any) for a single peer. */
|
||||
async closePeer(peerAddress: string, reason?: string): Promise<void> {
|
||||
const conn = this.byPeer.get(peerAddress);
|
||||
if (conn !== undefined) await conn.close(reason ?? 'manager-closePeer');
|
||||
}
|
||||
|
||||
/** Tear down everything. After this the manager rejects further work. */
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.unsubscribe();
|
||||
for (const conn of [...this.byPeer.values()]) {
|
||||
void conn.close('manager-destroy');
|
||||
}
|
||||
this.byPeer.clear();
|
||||
this.inflight.clear();
|
||||
}
|
||||
|
||||
// ─── Internals ───────────────────────────────────────────
|
||||
|
||||
private async initiate(peerAddress: string): Promise<WebRtcConnection> {
|
||||
const sessionId = generateSessionId();
|
||||
const conn = new WebRtcConnection({
|
||||
deps: {
|
||||
factory: this.options.factory,
|
||||
config: this.resolveConfig(),
|
||||
signaling: this.options.signaling,
|
||||
...(this.options.connectTimeoutMs !== undefined
|
||||
? { connectTimeoutMs: this.options.connectTimeoutMs }
|
||||
: {}),
|
||||
...(this.options.receiver !== undefined ? { receiver: this.options.receiver } : {}),
|
||||
},
|
||||
peerAddress,
|
||||
sessionId,
|
||||
role: 'caller',
|
||||
});
|
||||
this.installPeerListeners(peerAddress, conn);
|
||||
this.byPeer.set(peerAddress, conn);
|
||||
|
||||
try {
|
||||
await conn.start();
|
||||
await conn.waitForOpen();
|
||||
return conn;
|
||||
} catch (err) {
|
||||
await conn.close('initiate-failed').catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private installPeerListeners(peerAddress: string, conn: WebRtcConnection): void {
|
||||
conn.onClose.add(() => {
|
||||
const current = this.byPeer.get(peerAddress);
|
||||
if (current === conn) this.byPeer.delete(peerAddress);
|
||||
});
|
||||
}
|
||||
|
||||
private resolveConfig(): ShadeRtcConfig {
|
||||
const supplied = this.options.config ?? {};
|
||||
if (supplied.iceServers !== undefined) return supplied;
|
||||
if (this.options.defaultStunServers !== undefined) {
|
||||
return { ...supplied, iceServers: this.options.defaultStunServers };
|
||||
}
|
||||
// Fall through to caller-side default in connection (= public STUN).
|
||||
return supplied;
|
||||
}
|
||||
|
||||
private async handleSignaling(
|
||||
from: string,
|
||||
msg: WebRtcSignalingMessage,
|
||||
): Promise<void> {
|
||||
if (this.destroyed) return;
|
||||
switch (msg.kind) {
|
||||
case 'shade.webrtc-offer/v1':
|
||||
await this.handleOffer(from, msg.sessionId, msg.sdp);
|
||||
return;
|
||||
case 'shade.webrtc-answer/v1':
|
||||
await this.handleAnswer(from, msg.sessionId, msg.sdp);
|
||||
return;
|
||||
case 'shade.webrtc-ice/v1':
|
||||
await this.handleIce(from, msg.sessionId, msg.candidate);
|
||||
return;
|
||||
case 'shade.webrtc-bye/v1':
|
||||
await this.handleBye(from, msg.sessionId, msg.reason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOffer(from: string, sessionId: string, sdp: string): Promise<void> {
|
||||
let existing = this.byPeer.get(from);
|
||||
|
||||
if (existing !== undefined) {
|
||||
if (existing.state === 'connected') {
|
||||
// Already connected — this is a reconnect attempt by the peer.
|
||||
// Tear down ours so the new offer takes the slot.
|
||||
await existing.close('peer-reconnect');
|
||||
existing = undefined;
|
||||
} else if (existing.state === 'connecting') {
|
||||
// Glare. Tiebreak on address comparison: the smaller address keeps
|
||||
// its caller-role, the larger address yields and accepts the peer's
|
||||
// offer.
|
||||
const yieldToPeer = this.myAddress > from;
|
||||
if (yieldToPeer) {
|
||||
await existing.close('glare-yield');
|
||||
existing = undefined;
|
||||
} else {
|
||||
// Ignore the peer's offer; our outbound will win once their side
|
||||
// accepts our offer.
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const conn = new WebRtcConnection({
|
||||
deps: {
|
||||
factory: this.options.factory,
|
||||
config: this.resolveConfig(),
|
||||
signaling: this.options.signaling,
|
||||
...(this.options.connectTimeoutMs !== undefined
|
||||
? { connectTimeoutMs: this.options.connectTimeoutMs }
|
||||
: {}),
|
||||
...(this.options.receiver !== undefined ? { receiver: this.options.receiver } : {}),
|
||||
},
|
||||
peerAddress: from,
|
||||
sessionId,
|
||||
role: 'callee',
|
||||
});
|
||||
this.installPeerListeners(from, conn);
|
||||
this.byPeer.set(from, conn);
|
||||
try {
|
||||
await conn.acceptOffer({ type: 'offer', sdp });
|
||||
} catch (err) {
|
||||
console.warn('[WebRtcConnectionManager] acceptOffer failed:', err);
|
||||
await conn.close('acceptOffer-failed').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAnswer(from: string, sessionId: string, sdp: string): Promise<void> {
|
||||
const conn = this.byPeer.get(from);
|
||||
if (conn === undefined) return;
|
||||
if (conn.role !== 'caller' || conn.sessionId !== sessionId) return;
|
||||
try {
|
||||
await conn.acceptAnswer({ type: 'answer', sdp });
|
||||
} catch (err) {
|
||||
console.warn('[WebRtcConnectionManager] acceptAnswer failed:', err);
|
||||
await conn.close('acceptAnswer-failed').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIce(
|
||||
from: string,
|
||||
sessionId: string,
|
||||
candidate: import('./types.js').ShadeIceCandidate | null,
|
||||
): Promise<void> {
|
||||
const conn = this.byPeer.get(from);
|
||||
if (conn === undefined) return;
|
||||
if (conn.sessionId !== sessionId) return;
|
||||
try {
|
||||
await conn.addRemoteCandidate(candidate);
|
||||
} catch (err) {
|
||||
console.warn('[WebRtcConnectionManager] addRemoteCandidate failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleBye(from: string, sessionId: string, reason?: string): Promise<void> {
|
||||
const conn = this.byPeer.get(from);
|
||||
if (conn === undefined || conn.sessionId !== sessionId) return;
|
||||
await conn.close(`peer-bye${reason !== undefined ? `: ${reason}` : ''}`);
|
||||
}
|
||||
}
|
||||
293
packages/shade-transport-webrtc/src/memory-rtc.ts
Normal file
293
packages/shade-transport-webrtc/src/memory-rtc.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* In-process WebRTC factory for tests.
|
||||
*
|
||||
* No real ICE/DTLS — peer connections are linked by `sessionId` and the
|
||||
* "data channel" is a direct in-memory pipe between the two paired
|
||||
* endpoints. The result is a deterministic harness for the offer/answer/
|
||||
* datachannel flow without a `node-datachannel` / `wrtc` install in CI.
|
||||
*
|
||||
* Test code uses {@link MemoryRtcFactory} where production code would
|
||||
* inject {@link nativeRtcFactory}. Both implement the same
|
||||
* {@link IRtcFactory} contract.
|
||||
*/
|
||||
|
||||
import type {
|
||||
IDataChannel,
|
||||
IPeerConnection,
|
||||
IRtcFactory,
|
||||
ShadeIceCandidate,
|
||||
ShadeRtcConfig,
|
||||
ShadeRtcConnectionState,
|
||||
ShadeSessionDescription,
|
||||
} from './types.js';
|
||||
|
||||
type Listener = (...args: unknown[]) => void;
|
||||
|
||||
class EventBus {
|
||||
private readonly map = new Map<string, Set<Listener>>();
|
||||
on(event: string, cb: Listener): void {
|
||||
let set = this.map.get(event);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
this.map.set(event, set);
|
||||
}
|
||||
set.add(cb);
|
||||
}
|
||||
off(event: string, cb: Listener): void {
|
||||
this.map.get(event)?.delete(cb);
|
||||
}
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
const set = this.map.get(event);
|
||||
if (set === undefined) return;
|
||||
for (const cb of [...set]) {
|
||||
try {
|
||||
cb(...args);
|
||||
} catch (err) {
|
||||
console.warn(`[MemoryRTC] listener for ${event} threw:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryDataChannel implements IDataChannel {
|
||||
readyState: 'connecting' | 'open' | 'closing' | 'closed' = 'connecting';
|
||||
binaryType: 'arraybuffer' | 'blob' = 'arraybuffer';
|
||||
bufferedAmount = 0;
|
||||
/** The DataChannel on the other side of the wire. */
|
||||
peer: MemoryDataChannel | null = null;
|
||||
private readonly bus = new EventBus();
|
||||
private readonly deliveryQueue: ArrayBuffer[] = [];
|
||||
private deliveryDraining = false;
|
||||
|
||||
constructor(public readonly label: string) {}
|
||||
|
||||
send(data: ArrayBuffer | Uint8Array): void {
|
||||
if (this.readyState !== 'open') {
|
||||
throw new Error(`MemoryDataChannel.send: not open (state=${this.readyState})`);
|
||||
}
|
||||
if (this.peer === null) throw new Error('MemoryDataChannel.send: no peer');
|
||||
const buf = data instanceof Uint8Array
|
||||
? data.slice().buffer
|
||||
: data.slice(0);
|
||||
this.peer.enqueueDelivery(buf as ArrayBuffer);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.readyState === 'closed' || this.readyState === 'closing') return;
|
||||
this.readyState = 'closing';
|
||||
this.bus.emit('close');
|
||||
this.readyState = 'closed';
|
||||
if (this.peer !== null && this.peer.readyState !== 'closed') {
|
||||
this.peer.close();
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener(event: 'open' | 'close' | 'error' | 'message', cb: (ev?: any) => void): void {
|
||||
this.bus.on(event, cb);
|
||||
}
|
||||
removeEventListener(event: string, cb: Listener): void {
|
||||
this.bus.off(event, cb);
|
||||
}
|
||||
|
||||
// Internal — used by the paired PC to "open" both sides simultaneously.
|
||||
open(): void {
|
||||
if (this.readyState === 'open') return;
|
||||
this.readyState = 'open';
|
||||
this.bus.emit('open');
|
||||
}
|
||||
|
||||
enqueueDelivery(buf: ArrayBuffer): void {
|
||||
this.deliveryQueue.push(buf);
|
||||
if (this.deliveryDraining) return;
|
||||
this.deliveryDraining = true;
|
||||
queueMicrotask(() => this.drainDelivery());
|
||||
}
|
||||
|
||||
private drainDelivery(): void {
|
||||
while (this.deliveryQueue.length > 0) {
|
||||
const buf = this.deliveryQueue.shift()!;
|
||||
try {
|
||||
this.bus.emit('message', { data: buf });
|
||||
} catch (err) {
|
||||
console.warn('[MemoryDataChannel] message handler threw:', err);
|
||||
}
|
||||
}
|
||||
this.deliveryDraining = false;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryPeerConnection implements IPeerConnection {
|
||||
connectionState: ShadeRtcConnectionState = 'new';
|
||||
iceConnectionState = 'new';
|
||||
|
||||
/** Keyed by sessionId encoded into the SDP. */
|
||||
static readonly registry = new Map<string, MemoryPeerConnection>();
|
||||
|
||||
private readonly bus = new EventBus();
|
||||
private dc: MemoryDataChannel | null = null;
|
||||
private incomingDcCallback: ((channel: MemoryDataChannel) => void) | null = null;
|
||||
|
||||
/** Last SDP we sent — sessionId is embedded in it. */
|
||||
private localSessionId: string | null = null;
|
||||
|
||||
constructor(public readonly config: ShadeRtcConfig) {}
|
||||
|
||||
createDataChannel(label: string): IDataChannel {
|
||||
if (this.dc !== null) return this.dc;
|
||||
this.dc = new MemoryDataChannel(label);
|
||||
return this.dc;
|
||||
}
|
||||
|
||||
async createOffer(): Promise<ShadeSessionDescription> {
|
||||
this.localSessionId = randomSessionId();
|
||||
return { type: 'offer', sdp: encodeMemorySdp('offer', this.localSessionId) };
|
||||
}
|
||||
|
||||
async createAnswer(): Promise<ShadeSessionDescription> {
|
||||
if (this.localSessionId === null) this.localSessionId = randomSessionId();
|
||||
return { type: 'answer', sdp: encodeMemorySdp('answer', this.localSessionId) };
|
||||
}
|
||||
|
||||
async setLocalDescription(desc: ShadeSessionDescription): Promise<void> {
|
||||
const decoded = decodeMemorySdp(desc.sdp);
|
||||
this.localSessionId = decoded.sessionId;
|
||||
if (decoded.type === 'offer') {
|
||||
MemoryPeerConnection.registry.set(`offer:${decoded.sessionId}`, this);
|
||||
} else {
|
||||
MemoryPeerConnection.registry.set(`answer:${decoded.sessionId}`, this);
|
||||
}
|
||||
}
|
||||
|
||||
async setRemoteDescription(desc: ShadeSessionDescription): Promise<void> {
|
||||
const decoded = decodeMemorySdp(desc.sdp);
|
||||
|
||||
// Look up the paired PC.
|
||||
let peer: MemoryPeerConnection | undefined;
|
||||
if (decoded.type === 'offer') {
|
||||
peer = MemoryPeerConnection.registry.get(`offer:${decoded.sessionId}`);
|
||||
} else {
|
||||
peer = MemoryPeerConnection.registry.get(`answer:${decoded.sessionId}`);
|
||||
}
|
||||
// The peer has set their *local* description with this sessionId, so
|
||||
// we connect our DCs.
|
||||
if (peer !== undefined && peer !== this) {
|
||||
this.linkDataChannels(peer);
|
||||
}
|
||||
}
|
||||
|
||||
async addIceCandidate(_candidate: ShadeIceCandidate | null): Promise<void> {
|
||||
// No-op in the memory factory — the linking happens on
|
||||
// setRemoteDescription via the registry lookup.
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.connectionState = 'closed';
|
||||
this.iceConnectionState = 'closed';
|
||||
this.bus.emit('connectionstatechange');
|
||||
if (this.dc !== null) {
|
||||
try {
|
||||
this.dc.close();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
if (this.localSessionId !== null) {
|
||||
MemoryPeerConnection.registry.delete(`offer:${this.localSessionId}`);
|
||||
MemoryPeerConnection.registry.delete(`answer:${this.localSessionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener(event: string, cb: (...args: any[]) => void): void {
|
||||
if (event === 'datachannel') {
|
||||
this.incomingDcCallback = cb as (channel: MemoryDataChannel) => void;
|
||||
// If a DC is already linked at the time the listener registers,
|
||||
// fire immediately.
|
||||
if (this.dc !== null && this.linkedPeer !== null) {
|
||||
cb({ channel: this.dc });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.bus.on(event, cb);
|
||||
}
|
||||
removeEventListener(event: string, cb: Listener): void {
|
||||
if (event === 'datachannel') {
|
||||
if (this.incomingDcCallback === (cb as unknown)) this.incomingDcCallback = null;
|
||||
return;
|
||||
}
|
||||
this.bus.off(event, cb);
|
||||
}
|
||||
|
||||
// ─── Internals ────────────────────────────────────────
|
||||
|
||||
private linkedPeer: MemoryPeerConnection | null = null;
|
||||
|
||||
private linkDataChannels(peer: MemoryPeerConnection): void {
|
||||
if (this.linkedPeer !== null) return;
|
||||
this.linkedPeer = peer;
|
||||
peer.linkedPeer = this;
|
||||
|
||||
// Caller created its DC up-front; callee creates it lazily here.
|
||||
if (this.dc === null) {
|
||||
this.dc = new MemoryDataChannel('shade-transfer/v1');
|
||||
// Notify the callee's 'datachannel' listener.
|
||||
const cb = this.incomingDcCallback;
|
||||
if (cb !== null) {
|
||||
const dc = this.dc;
|
||||
queueMicrotask(() => cb({ channel: dc } as never));
|
||||
}
|
||||
}
|
||||
if (peer.dc === null) {
|
||||
peer.dc = new MemoryDataChannel('shade-transfer/v1');
|
||||
const cb = peer.incomingDcCallback;
|
||||
if (cb !== null) {
|
||||
const dc = peer.dc;
|
||||
queueMicrotask(() => cb({ channel: dc } as never));
|
||||
}
|
||||
}
|
||||
this.dc.peer = peer.dc;
|
||||
peer.dc.peer = this.dc;
|
||||
|
||||
// Open both sides asynchronously to mimic real WebRTC connect timing.
|
||||
queueMicrotask(() => {
|
||||
this.connectionState = 'connected';
|
||||
this.iceConnectionState = 'connected';
|
||||
this.bus.emit('connectionstatechange');
|
||||
this.dc!.open();
|
||||
|
||||
peer.connectionState = 'connected';
|
||||
peer.iceConnectionState = 'connected';
|
||||
peer.bus.emit('connectionstatechange');
|
||||
peer.dc!.open();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class MemoryRtcFactory implements IRtcFactory {
|
||||
createPeerConnection(config: ShadeRtcConfig): IPeerConnection {
|
||||
return new MemoryPeerConnection(config);
|
||||
}
|
||||
/** Tests can call this between cases to nuke the registry. */
|
||||
static reset(): void {
|
||||
MemoryPeerConnection.registry.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function randomSessionId(): string {
|
||||
const b = new Uint8Array(8);
|
||||
globalThis.crypto.getRandomValues(b);
|
||||
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function encodeMemorySdp(type: 'offer' | 'answer', sessionId: string): string {
|
||||
// Deliberately not real SDP — just a tagged string the registry indexes.
|
||||
return `v=0\no=memory-rtc 0 0 IN IP4 0.0.0.0\ns=shade-memory\na=type:${type}\na=session-id:${sessionId}\n`;
|
||||
}
|
||||
|
||||
function decodeMemorySdp(sdp: string): { type: 'offer' | 'answer'; sessionId: string } {
|
||||
const typeLine = sdp.match(/a=type:(offer|answer)/);
|
||||
const sessLine = sdp.match(/a=session-id:([0-9a-f]+)/);
|
||||
if (typeLine === null || sessLine === null) {
|
||||
throw new Error(`MemoryRTC: cannot decode SDP: ${sdp.slice(0, 80)}`);
|
||||
}
|
||||
return { type: typeLine[1] as 'offer' | 'answer', sessionId: sessLine[1]! };
|
||||
}
|
||||
211
packages/shade-transport-webrtc/src/native-rtc.ts
Normal file
211
packages/shade-transport-webrtc/src/native-rtc.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Native browser / runtime adapter — delegates to `globalThis.RTCPeerConnection`.
|
||||
*
|
||||
* Bun does not yet expose `RTCPeerConnection` natively (as of Bun 1.3),
|
||||
* so server-side users must inject an adapter such as `node-datachannel`
|
||||
* or `wrtc` themselves. The factory exported here is the right choice
|
||||
* for any browser / Deno / Cloudflare Workers / runtime that ships the
|
||||
* standard `RTCPeerConnection`.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_STUN_SERVERS,
|
||||
type IDataChannel,
|
||||
type IPeerConnection,
|
||||
type IRtcFactory,
|
||||
type ShadeIceCandidate,
|
||||
type ShadeRtcConfig,
|
||||
type ShadeRtcConnectionState,
|
||||
type ShadeSessionDescription,
|
||||
} from './types.js';
|
||||
|
||||
interface NativeRtcGlobals {
|
||||
RTCPeerConnection?: new (config?: unknown) => unknown;
|
||||
RTCIceCandidate?: new (init: ShadeIceCandidate) => unknown;
|
||||
}
|
||||
|
||||
class NativePeerConnection implements IPeerConnection {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private readonly pc: any;
|
||||
|
||||
constructor(config: ShadeRtcConfig) {
|
||||
const g = globalThis as unknown as NativeRtcGlobals;
|
||||
if (g.RTCPeerConnection === undefined) {
|
||||
throw new Error('globalThis.RTCPeerConnection is not available — pass a custom IRtcFactory');
|
||||
}
|
||||
this.pc = new g.RTCPeerConnection({
|
||||
iceServers: config.iceServers ?? DEFAULT_STUN_SERVERS,
|
||||
...(config.iceTransportPolicy !== undefined
|
||||
? { iceTransportPolicy: config.iceTransportPolicy }
|
||||
: {}),
|
||||
...(config.bundlePolicy !== undefined ? { bundlePolicy: config.bundlePolicy } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
get connectionState(): ShadeRtcConnectionState | string {
|
||||
return this.pc.connectionState as string;
|
||||
}
|
||||
get iceConnectionState(): string {
|
||||
return this.pc.iceConnectionState as string;
|
||||
}
|
||||
|
||||
createDataChannel(
|
||||
label: string,
|
||||
init?: { ordered?: boolean; maxRetransmits?: number; maxPacketLifeTime?: number },
|
||||
): IDataChannel {
|
||||
return new NativeDataChannel(this.pc.createDataChannel(label, init));
|
||||
}
|
||||
|
||||
async createOffer(): Promise<ShadeSessionDescription> {
|
||||
const desc = await this.pc.createOffer();
|
||||
return { type: 'offer', sdp: desc.sdp ?? '' };
|
||||
}
|
||||
async createAnswer(): Promise<ShadeSessionDescription> {
|
||||
const desc = await this.pc.createAnswer();
|
||||
return { type: 'answer', sdp: desc.sdp ?? '' };
|
||||
}
|
||||
async setLocalDescription(desc: ShadeSessionDescription): Promise<void> {
|
||||
await this.pc.setLocalDescription({ type: desc.type, sdp: desc.sdp });
|
||||
}
|
||||
async setRemoteDescription(desc: ShadeSessionDescription): Promise<void> {
|
||||
await this.pc.setRemoteDescription({ type: desc.type, sdp: desc.sdp });
|
||||
}
|
||||
async addIceCandidate(candidate: ShadeIceCandidate | null): Promise<void> {
|
||||
if (candidate === null) {
|
||||
// End-of-candidates — spec accepts undefined / null here.
|
||||
try {
|
||||
await this.pc.addIceCandidate();
|
||||
} catch {
|
||||
/* swallow — some impls reject */
|
||||
}
|
||||
return;
|
||||
}
|
||||
const g = globalThis as unknown as NativeRtcGlobals;
|
||||
if (g.RTCIceCandidate !== undefined) {
|
||||
await this.pc.addIceCandidate(new g.RTCIceCandidate(candidate));
|
||||
} else {
|
||||
await this.pc.addIceCandidate(candidate);
|
||||
}
|
||||
}
|
||||
close(): void {
|
||||
try {
|
||||
this.pc.close();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
addEventListener(event: string, cb: any): void {
|
||||
if (event === 'icecandidate') {
|
||||
this.pc.addEventListener('icecandidate', (ev: { candidate: ShadeIceCandidate | null }) => {
|
||||
const c = ev.candidate;
|
||||
if (c === null) {
|
||||
cb({ candidate: null });
|
||||
return;
|
||||
}
|
||||
cb({
|
||||
candidate: {
|
||||
candidate: c.candidate,
|
||||
sdpMid: c.sdpMid ?? null,
|
||||
sdpMLineIndex: c.sdpMLineIndex ?? null,
|
||||
...(c.usernameFragment !== undefined
|
||||
? { usernameFragment: c.usernameFragment ?? null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event === 'datachannel') {
|
||||
this.pc.addEventListener('datachannel', (ev: { channel: unknown }) => {
|
||||
cb({ channel: new NativeDataChannel(ev.channel) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.pc.addEventListener(event, cb);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
removeEventListener(event: string, cb: any): void {
|
||||
this.pc.removeEventListener(event, cb);
|
||||
}
|
||||
}
|
||||
|
||||
class NativeDataChannel implements IDataChannel {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
constructor(private readonly dc: any) {}
|
||||
|
||||
get readyState(): 'connecting' | 'open' | 'closing' | 'closed' {
|
||||
return this.dc.readyState as 'connecting' | 'open' | 'closing' | 'closed';
|
||||
}
|
||||
get label(): string {
|
||||
return this.dc.label as string;
|
||||
}
|
||||
get binaryType(): 'arraybuffer' | 'blob' {
|
||||
return this.dc.binaryType as 'arraybuffer' | 'blob';
|
||||
}
|
||||
set binaryType(v: 'arraybuffer' | 'blob') {
|
||||
this.dc.binaryType = v;
|
||||
}
|
||||
get bufferedAmount(): number {
|
||||
return Number(this.dc.bufferedAmount ?? 0);
|
||||
}
|
||||
|
||||
send(data: ArrayBuffer | Uint8Array): void {
|
||||
if (data instanceof Uint8Array) {
|
||||
// Some adapters (Node's wrtc) require ArrayBuffer specifically.
|
||||
const ab = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
||||
this.dc.send(ab);
|
||||
} else {
|
||||
this.dc.send(data);
|
||||
}
|
||||
}
|
||||
close(): void {
|
||||
try {
|
||||
this.dc.close();
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
addEventListener(event: string, cb: any): void {
|
||||
if (event === 'message') {
|
||||
this.dc.addEventListener('message', (ev: { data: unknown }) => {
|
||||
const data = ev.data;
|
||||
if (data instanceof ArrayBuffer) {
|
||||
cb({ data });
|
||||
return;
|
||||
}
|
||||
const maybeBlob = data as { arrayBuffer?: () => Promise<ArrayBuffer> };
|
||||
if (typeof maybeBlob.arrayBuffer === 'function') {
|
||||
maybeBlob
|
||||
.arrayBuffer()
|
||||
.then((ab) => cb({ data: ab }))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
cb({ data: data as ArrayBuffer });
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.dc.addEventListener(event, cb);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
removeEventListener(event: string, cb: any): void {
|
||||
this.dc.removeEventListener(event, cb);
|
||||
}
|
||||
}
|
||||
|
||||
export class NativeRtcFactory implements IRtcFactory {
|
||||
createPeerConnection(config: ShadeRtcConfig): IPeerConnection {
|
||||
return new NativePeerConnection(config);
|
||||
}
|
||||
}
|
||||
|
||||
export const nativeRtcFactory = (): NativeRtcFactory => new NativeRtcFactory();
|
||||
|
||||
/** True if the runtime exposes `globalThis.RTCPeerConnection`. */
|
||||
export function isNativeRtcAvailable(): boolean {
|
||||
const g = globalThis as unknown as NativeRtcGlobals;
|
||||
return typeof g.RTCPeerConnection === 'function';
|
||||
}
|
||||
54
packages/shade-transport-webrtc/src/shade-bridge.ts
Normal file
54
packages/shade-transport-webrtc/src/shade-bridge.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* SDK glue. The {@link createShadeBridgeFromShade} helper turns any Shade-
|
||||
* shaped object into a {@link ShadeBridge} suitable for
|
||||
* `WebRtcSignalingChannel`. Kept in its own file so the package can be
|
||||
* consumed standalone (e.g. by tests with a memory bridge) without
|
||||
* pulling in the full SDK type tree.
|
||||
*/
|
||||
|
||||
import type { ShadeBridge } from './signaling.js';
|
||||
|
||||
/**
|
||||
* Minimal shape of `Shade` we depend on. The real `@shade/sdk` `Shade`
|
||||
* class satisfies this structurally; declaring it locally avoids a
|
||||
* circular dependency on `@shade/sdk` in this package's `package.json`.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface ShadeLike {
|
||||
readonly myAddress: string;
|
||||
// The real SDK returns a `ShadeEnvelope`; we accept anything because the
|
||||
// bridge just hands it back to `deliverControlEnvelope`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
send(address: string, plaintext: string): Promise<any>;
|
||||
onMessage(handler: (from: string, plaintext: string) => void | Promise<void>): () => void;
|
||||
/**
|
||||
* Optional. When present (the real SDK provides it), the bridge calls
|
||||
* `deliverControlEnvelope` after `send()` so the encrypted envelope
|
||||
* actually reaches the peer over HTTP. When absent (memory tests or
|
||||
* custom transports), `send()` alone is enough.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
deliverControlEnvelope?: (peer: string, envelope: any) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt a `Shade`-shaped instance to the {@link ShadeBridge} interface used
|
||||
* by `WebRtcSignalingChannel`. Each `bridge.send` calls
|
||||
* `shade.send(plaintext)` to ratchet-encrypt the signaling JSON, then —
|
||||
* when available — `shade.deliverControlEnvelope(...)` to actually ship the
|
||||
* envelope to the peer.
|
||||
*/
|
||||
export function createShadeBridgeFromShade(shade: ShadeLike): ShadeBridge {
|
||||
return {
|
||||
myAddress: shade.myAddress,
|
||||
async send(peerAddress: string, plaintext: string): Promise<void> {
|
||||
const envelope = await shade.send(peerAddress, plaintext);
|
||||
if (shade.deliverControlEnvelope !== undefined) {
|
||||
await shade.deliverControlEnvelope(peerAddress, envelope);
|
||||
}
|
||||
},
|
||||
onMessage(handler) {
|
||||
return shade.onMessage(handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
182
packages/shade-transport-webrtc/src/signaling.ts
Normal file
182
packages/shade-transport-webrtc/src/signaling.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Shade-control-plane signaling for WebRTC.
|
||||
*
|
||||
* SDP offer/answer + trickle-ICE candidates ride as JSON plaintext over
|
||||
* `Shade.send` / `Shade.onMessage` — the same Double Ratchet that
|
||||
* everything else does. The signaling layer never sees ciphertext or
|
||||
* crypto material directly; it just dispatches typed messages.
|
||||
*
|
||||
* The interface is host-agnostic on purpose: tests inject a memory pair
|
||||
* (`MemoryShadeBridge.linked()`), the SDK injects a thin adapter over
|
||||
* `Shade.send` / `Shade.onMessage` (`createShadeBridgeFromShade()`).
|
||||
*/
|
||||
|
||||
import { WebRtcSignalingError } from './errors.js';
|
||||
import {
|
||||
encodeWebRtcSignaling,
|
||||
parseWebRtcSignaling,
|
||||
type WebRtcAnswerMessage,
|
||||
type WebRtcByeMessage,
|
||||
type WebRtcIceMessage,
|
||||
type WebRtcOfferMessage,
|
||||
type WebRtcSignalingMessage,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Minimal bridge into the host messaging layer (typically `Shade.send` +
|
||||
* `Shade.onMessage`). The bridge transports plaintext strings end-to-end
|
||||
* encrypted by the underlying ratchet.
|
||||
*
|
||||
* `send` MUST resolve only after the receiver's `onMessage` handler has
|
||||
* fully processed the plaintext, so the signaling layer can rely on
|
||||
* causal ordering between offer/answer and trickled candidates.
|
||||
*/
|
||||
export interface ShadeBridge {
|
||||
send(peerAddress: string, plaintext: string): Promise<void>;
|
||||
onMessage(handler: (from: string, plaintext: string) => void | Promise<void>): () => void;
|
||||
/** This endpoint's own address — used by the manager when generating sessionIds. */
|
||||
readonly myAddress: string;
|
||||
}
|
||||
|
||||
export type SignalingHandler = (
|
||||
from: string,
|
||||
message: WebRtcSignalingMessage,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export class WebRtcSignalingChannel {
|
||||
private readonly handlers = new Set<SignalingHandler>();
|
||||
private readonly unsubscribeBridge: () => void;
|
||||
private readonly passthrough: ((from: string, plaintext: string) => void) | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly bridge: ShadeBridge,
|
||||
options?: { passthrough?: (from: string, plaintext: string) => void },
|
||||
) {
|
||||
this.passthrough = options?.passthrough;
|
||||
this.unsubscribeBridge = bridge.onMessage(async (from, plaintext) => {
|
||||
if (!plaintext.includes('shade.webrtc-')) {
|
||||
this.passthrough?.(from, plaintext);
|
||||
return;
|
||||
}
|
||||
const msg = parseWebRtcSignaling(plaintext);
|
||||
if (msg === null) {
|
||||
this.passthrough?.(from, plaintext);
|
||||
return;
|
||||
}
|
||||
// Awaiting handlers preserves causal order — the manager relies on
|
||||
// setRemoteDescription completing before trickled candidates arrive.
|
||||
for (const handler of [...this.handlers]) {
|
||||
try {
|
||||
await handler(from, msg);
|
||||
} catch (err) {
|
||||
console.error('[WebRtcSignalingChannel] handler error:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get myAddress(): string {
|
||||
return this.bridge.myAddress;
|
||||
}
|
||||
|
||||
async sendOffer(peerAddress: string, sessionId: string, sdp: string): Promise<void> {
|
||||
const msg: WebRtcOfferMessage = {
|
||||
kind: 'shade.webrtc-offer/v1',
|
||||
sessionId,
|
||||
sdp,
|
||||
};
|
||||
return this.transmit(peerAddress, msg);
|
||||
}
|
||||
|
||||
async sendAnswer(peerAddress: string, sessionId: string, sdp: string): Promise<void> {
|
||||
const msg: WebRtcAnswerMessage = {
|
||||
kind: 'shade.webrtc-answer/v1',
|
||||
sessionId,
|
||||
sdp,
|
||||
};
|
||||
return this.transmit(peerAddress, msg);
|
||||
}
|
||||
|
||||
async sendIce(
|
||||
peerAddress: string,
|
||||
sessionId: string,
|
||||
candidate: WebRtcIceMessage['candidate'],
|
||||
): Promise<void> {
|
||||
const msg: WebRtcIceMessage = {
|
||||
kind: 'shade.webrtc-ice/v1',
|
||||
sessionId,
|
||||
candidate,
|
||||
};
|
||||
return this.transmit(peerAddress, msg);
|
||||
}
|
||||
|
||||
async sendBye(peerAddress: string, sessionId: string, reason?: string): Promise<void> {
|
||||
const msg: WebRtcByeMessage = {
|
||||
kind: 'shade.webrtc-bye/v1',
|
||||
sessionId,
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
};
|
||||
return this.transmit(peerAddress, msg);
|
||||
}
|
||||
|
||||
onSignal(handler: SignalingHandler): () => void {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.unsubscribeBridge();
|
||||
this.handlers.clear();
|
||||
}
|
||||
|
||||
private async transmit(peer: string, msg: WebRtcSignalingMessage): Promise<void> {
|
||||
try {
|
||||
await this.bridge.send(peer, encodeWebRtcSignaling(msg));
|
||||
} catch (err) {
|
||||
throw new WebRtcSignalingError(
|
||||
`failed to send ${msg.kind} to ${peer}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Memory bridge (tests) ─────────────────────────────────
|
||||
|
||||
export class MemoryShadeBridge implements ShadeBridge {
|
||||
private peer: MemoryShadeBridge | null = null;
|
||||
private handlers = new Set<(from: string, plaintext: string) => void | Promise<void>>();
|
||||
|
||||
private constructor(public readonly myAddress: string) {}
|
||||
|
||||
static linked(addressA: string, addressB: string): {
|
||||
a: MemoryShadeBridge;
|
||||
b: MemoryShadeBridge;
|
||||
} {
|
||||
const a = new MemoryShadeBridge(addressA);
|
||||
const b = new MemoryShadeBridge(addressB);
|
||||
a.peer = b;
|
||||
b.peer = a;
|
||||
return { a, b };
|
||||
}
|
||||
|
||||
async send(peerAddress: string, plaintext: string): Promise<void> {
|
||||
if (this.peer === null) {
|
||||
throw new WebRtcSignalingError('MemoryShadeBridge: not linked');
|
||||
}
|
||||
if (peerAddress !== this.peer.myAddress) {
|
||||
throw new WebRtcSignalingError(
|
||||
`MemoryShadeBridge: peer mismatch (expected ${this.peer.myAddress}, got ${peerAddress})`,
|
||||
);
|
||||
}
|
||||
const target = this.peer;
|
||||
const from = this.myAddress;
|
||||
for (const handler of [...target.handlers]) {
|
||||
await handler(from, plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
onMessage(handler: (from: string, plaintext: string) => void | Promise<void>): () => void {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
}
|
||||
210
packages/shade-transport-webrtc/src/transport.ts
Normal file
210
packages/shade-transport-webrtc/src/transport.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* `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));
|
||||
}
|
||||
}
|
||||
}
|
||||
193
packages/shade-transport-webrtc/src/types.ts
Normal file
193
packages/shade-transport-webrtc/src/types.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Minimal subset of the standard WebRTC interfaces that
|
||||
* `@shade/transport-webrtc` depends on.
|
||||
*
|
||||
* Bun does not yet expose `RTCPeerConnection` natively, so the package
|
||||
* accepts a factory rather than reaching for `globalThis`. Browsers can
|
||||
* use {@link nativeRtcFactory}; tests use {@link MemoryRtcFactory} from
|
||||
* `./memory-rtc.js`; Node-class environments can adapt
|
||||
* `node-datachannel`/`wrtc` behind the same shape.
|
||||
*
|
||||
* The shape is intentionally narrower than the spec — only the event names,
|
||||
* methods, and properties Shade actually uses are required. Everything is
|
||||
* declared in terms of plain DOM event listeners (`addEventListener`) so
|
||||
* that adapter authors can implement it without pulling lib.dom typings
|
||||
* into their own packages.
|
||||
*/
|
||||
|
||||
/** A subset of `RTCConfiguration` that Shade understands. */
|
||||
export interface ShadeRtcConfig {
|
||||
/** ICE servers (STUN + TURN). When omitted, defaults to public STUN. */
|
||||
iceServers?: ShadeIceServer[];
|
||||
/**
|
||||
* Force `'relay'` to mandate TURN-relay (useful for tests / paranoid
|
||||
* deployments where a direct path must never be tried).
|
||||
*/
|
||||
iceTransportPolicy?: 'all' | 'relay';
|
||||
/** Bundle policy passed through verbatim. */
|
||||
bundlePolicy?: 'balanced' | 'max-compat' | 'max-bundle';
|
||||
}
|
||||
|
||||
export interface ShadeIceServer {
|
||||
urls: string | string[];
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}
|
||||
|
||||
/** Default public STUN servers (Google's). Used when `iceServers` is omitted. */
|
||||
export const DEFAULT_STUN_SERVERS: ShadeIceServer[] = [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
];
|
||||
|
||||
/** Standard `RTCSdpType` subset. */
|
||||
export type ShadeSdpType = 'offer' | 'answer';
|
||||
|
||||
export interface ShadeSessionDescription {
|
||||
type: ShadeSdpType;
|
||||
sdp: string;
|
||||
}
|
||||
|
||||
/** ICE candidate as serialized over the signaling channel. */
|
||||
export interface ShadeIceCandidate {
|
||||
candidate: string;
|
||||
sdpMid: string | null;
|
||||
sdpMLineIndex: number | null;
|
||||
/** Optional usernameFragment, mirrored from `RTCIceCandidate`. */
|
||||
usernameFragment?: string | null;
|
||||
}
|
||||
|
||||
/** State the manager exposes to the rest of the SDK. */
|
||||
export type ShadeRtcConnectionState =
|
||||
| 'new'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'failed'
|
||||
| 'closed';
|
||||
|
||||
// ─── Adapter contracts ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Factory injected at construction time. Returns a peer connection
|
||||
* configured with the supplied {@link ShadeRtcConfig}.
|
||||
*/
|
||||
export interface IRtcFactory {
|
||||
createPeerConnection(config: ShadeRtcConfig): IPeerConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrow `RTCPeerConnection` shape Shade depends on. Adapters MUST
|
||||
* implement these methods + emit the listed events.
|
||||
*
|
||||
* Events:
|
||||
* - `'icecandidate'` — `{ candidate: ShadeIceCandidate | null }`
|
||||
* - `'datachannel'` — `{ channel: IDataChannel }` (callee side)
|
||||
* - `'connectionstatechange'`
|
||||
* - `'iceconnectionstatechange'`
|
||||
*/
|
||||
export interface IPeerConnection {
|
||||
readonly connectionState: ShadeRtcConnectionState | string;
|
||||
readonly iceConnectionState: string;
|
||||
|
||||
createDataChannel(
|
||||
label: string,
|
||||
init?: { ordered?: boolean; maxRetransmits?: number; maxPacketLifeTime?: number },
|
||||
): IDataChannel;
|
||||
|
||||
createOffer(): Promise<ShadeSessionDescription>;
|
||||
createAnswer(): Promise<ShadeSessionDescription>;
|
||||
setLocalDescription(desc: ShadeSessionDescription): Promise<void>;
|
||||
setRemoteDescription(desc: ShadeSessionDescription): Promise<void>;
|
||||
addIceCandidate(candidate: ShadeIceCandidate | null): Promise<void>;
|
||||
|
||||
close(): void;
|
||||
|
||||
addEventListener(event: 'icecandidate', cb: (ev: { candidate: ShadeIceCandidate | null }) => void): void;
|
||||
addEventListener(event: 'datachannel', cb: (ev: { channel: IDataChannel }) => void): void;
|
||||
addEventListener(event: 'connectionstatechange', cb: () => void): void;
|
||||
addEventListener(event: 'iceconnectionstatechange', cb: () => void): void;
|
||||
removeEventListener(event: string, cb: (...args: unknown[]) => void): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrow `RTCDataChannel` shape Shade depends on. Binary-only — Shade
|
||||
* never sends text frames over a transfer DC.
|
||||
*/
|
||||
export interface IDataChannel {
|
||||
readonly readyState: 'connecting' | 'open' | 'closing' | 'closed';
|
||||
readonly label: string;
|
||||
|
||||
/** Default `'arraybuffer'`. Adapters MUST coerce so `message.data` is an `ArrayBuffer`. */
|
||||
binaryType: 'arraybuffer' | 'blob';
|
||||
|
||||
/** Buffered amount in bytes. Used for pacing. */
|
||||
readonly bufferedAmount: number;
|
||||
|
||||
send(data: ArrayBuffer | Uint8Array): void;
|
||||
close(): void;
|
||||
|
||||
addEventListener(event: 'open', cb: () => void): void;
|
||||
addEventListener(event: 'close', cb: () => void): void;
|
||||
addEventListener(event: 'error', cb: (ev: { error?: unknown }) => void): void;
|
||||
addEventListener(event: 'message', cb: (ev: { data: ArrayBuffer }) => void): void;
|
||||
removeEventListener(event: string, cb: (...args: unknown[]) => void): void;
|
||||
}
|
||||
|
||||
// ─── Signaling envelope kinds (over Shade.send) ─────────────
|
||||
|
||||
export type WebRtcSignalingKind =
|
||||
| 'shade.webrtc-offer/v1'
|
||||
| 'shade.webrtc-answer/v1'
|
||||
| 'shade.webrtc-ice/v1'
|
||||
| 'shade.webrtc-bye/v1';
|
||||
|
||||
export interface WebRtcOfferMessage {
|
||||
kind: 'shade.webrtc-offer/v1';
|
||||
/** Caller-generated session id; both peers tag every signaling message with this. */
|
||||
sessionId: string;
|
||||
sdp: string;
|
||||
}
|
||||
|
||||
export interface WebRtcAnswerMessage {
|
||||
kind: 'shade.webrtc-answer/v1';
|
||||
sessionId: string;
|
||||
sdp: string;
|
||||
}
|
||||
|
||||
export interface WebRtcIceMessage {
|
||||
kind: 'shade.webrtc-ice/v1';
|
||||
sessionId: string;
|
||||
candidate: ShadeIceCandidate | null;
|
||||
}
|
||||
|
||||
export interface WebRtcByeMessage {
|
||||
kind: 'shade.webrtc-bye/v1';
|
||||
sessionId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type WebRtcSignalingMessage =
|
||||
| WebRtcOfferMessage
|
||||
| WebRtcAnswerMessage
|
||||
| WebRtcIceMessage
|
||||
| WebRtcByeMessage;
|
||||
|
||||
export function isWebRtcSignalingMessage(value: unknown): value is WebRtcSignalingMessage {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const kind = (value as { kind?: unknown }).kind;
|
||||
return typeof kind === 'string' && kind.startsWith('shade.webrtc-');
|
||||
}
|
||||
|
||||
export function parseWebRtcSignaling(plaintext: string): WebRtcSignalingMessage | null {
|
||||
try {
|
||||
const parsed = JSON.parse(plaintext) as unknown;
|
||||
return isWebRtcSignalingMessage(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeWebRtcSignaling(msg: WebRtcSignalingMessage): string {
|
||||
return JSON.stringify(msg);
|
||||
}
|
||||
257
packages/shade-transport-webrtc/src/wire.ts
Normal file
257
packages/shade-transport-webrtc/src/wire.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Binary wire format used inside the Shade WebRTC `DataChannel`.
|
||||
*
|
||||
* The DataChannel is a single bidirectional pipe shared by every in-flight
|
||||
* stream between two peers. Each frame begins with a 1-byte type tag and
|
||||
* a 16-byte requestId so the responder can correlate replies back to the
|
||||
* caller.
|
||||
*
|
||||
* Client → server frames
|
||||
* ──────────────────────
|
||||
* 0x01 chunk : requestId(16) streamId(16) laneId(u32 BE) seq(u64 BE) envelope(...)
|
||||
* 0x02 resume-query : requestId(16) streamId(16)
|
||||
* 0x03 ping : requestId(16) nonce(u64 BE)
|
||||
*
|
||||
* Server → client frames
|
||||
* ──────────────────────
|
||||
* 0x81 chunk-ack : requestId(16) lastSeq(u32 BE) bytesReceived(u32 BE)
|
||||
* 0x82 resume-state : requestId(16) jsonBody(utf-8)
|
||||
* 0x83 pong : requestId(16) nonce(u64 BE)
|
||||
* 0xFE error : requestId(16) jsonBody(utf-8)
|
||||
*
|
||||
* The wire matches `ShadeTransferWsTransport` (see
|
||||
* `@shade/transfer/transport/ws-transport.ts`) on purpose so the same
|
||||
* mental model applies to both transports.
|
||||
*/
|
||||
|
||||
export const WIRE_CHUNK = 0x01;
|
||||
export const WIRE_RESUME_QUERY = 0x02;
|
||||
export const WIRE_PING = 0x03;
|
||||
export const WIRE_CHUNK_ACK = 0x81;
|
||||
export const WIRE_RESUME_STATE = 0x82;
|
||||
export const WIRE_PONG = 0x83;
|
||||
export const WIRE_ERROR = 0xfe;
|
||||
|
||||
export const REQUEST_ID_LEN = 16;
|
||||
export const STREAM_ID_LEN = 16;
|
||||
/** Header length before the chunk envelope begins (TYPE + reqId + streamId + laneId + seq). */
|
||||
export const CHUNK_HEADER_LEN = 1 + REQUEST_ID_LEN + STREAM_ID_LEN + 4 + 8;
|
||||
/** Header length for resume-query frames (TYPE + reqId + streamId). */
|
||||
export const RESUME_QUERY_HEADER_LEN = 1 + REQUEST_ID_LEN + STREAM_ID_LEN;
|
||||
/** Header length for chunk-ack frames (TYPE + reqId + lastSeq + bytesReceived). */
|
||||
export const CHUNK_ACK_LEN = 1 + REQUEST_ID_LEN + 4 + 4;
|
||||
/** Header length for ping/pong frames (TYPE + reqId + nonce). */
|
||||
export const PING_FRAME_LEN = 1 + REQUEST_ID_LEN + 8;
|
||||
|
||||
export interface ChunkFrame {
|
||||
type: typeof WIRE_CHUNK;
|
||||
requestId: Uint8Array;
|
||||
streamId: Uint8Array;
|
||||
laneId: number;
|
||||
seq: bigint;
|
||||
envelope: Uint8Array;
|
||||
}
|
||||
|
||||
export interface ResumeQueryFrame {
|
||||
type: typeof WIRE_RESUME_QUERY;
|
||||
requestId: Uint8Array;
|
||||
streamId: Uint8Array;
|
||||
}
|
||||
|
||||
export interface PingFrame {
|
||||
type: typeof WIRE_PING;
|
||||
requestId: Uint8Array;
|
||||
nonce: bigint;
|
||||
}
|
||||
|
||||
export interface ChunkAckFrame {
|
||||
type: typeof WIRE_CHUNK_ACK;
|
||||
requestId: Uint8Array;
|
||||
lastSeq: number;
|
||||
bytesReceived: number;
|
||||
}
|
||||
|
||||
export interface ResumeStateFrame {
|
||||
type: typeof WIRE_RESUME_STATE;
|
||||
requestId: Uint8Array;
|
||||
json: string;
|
||||
}
|
||||
|
||||
export interface PongFrame {
|
||||
type: typeof WIRE_PONG;
|
||||
requestId: Uint8Array;
|
||||
nonce: bigint;
|
||||
}
|
||||
|
||||
export interface ErrorFrame {
|
||||
type: typeof WIRE_ERROR;
|
||||
requestId: Uint8Array;
|
||||
json: string;
|
||||
}
|
||||
|
||||
export type ServerFrame = ChunkAckFrame | ResumeStateFrame | PongFrame | ErrorFrame;
|
||||
export type ClientFrame = ChunkFrame | ResumeQueryFrame | PingFrame;
|
||||
export type AnyFrame = ServerFrame | ClientFrame;
|
||||
|
||||
// ─── Encoders ──────────────────────────────────────────────
|
||||
|
||||
export function encodeChunkFrame(f: ChunkFrame): Uint8Array {
|
||||
if (f.requestId.length !== REQUEST_ID_LEN) {
|
||||
throw new Error(`requestId must be ${REQUEST_ID_LEN} bytes`);
|
||||
}
|
||||
if (f.streamId.length !== STREAM_ID_LEN) {
|
||||
throw new Error(`streamId must be ${STREAM_ID_LEN} bytes`);
|
||||
}
|
||||
const out = new Uint8Array(CHUNK_HEADER_LEN + f.envelope.length);
|
||||
const view = new DataView(out.buffer);
|
||||
out[0] = WIRE_CHUNK;
|
||||
out.set(f.requestId, 1);
|
||||
out.set(f.streamId, 1 + REQUEST_ID_LEN);
|
||||
view.setUint32(1 + REQUEST_ID_LEN + STREAM_ID_LEN, f.laneId, false);
|
||||
view.setBigUint64(1 + REQUEST_ID_LEN + STREAM_ID_LEN + 4, f.seq, false);
|
||||
out.set(f.envelope, CHUNK_HEADER_LEN);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encodeResumeQueryFrame(f: ResumeQueryFrame): Uint8Array {
|
||||
const out = new Uint8Array(RESUME_QUERY_HEADER_LEN);
|
||||
out[0] = WIRE_RESUME_QUERY;
|
||||
out.set(f.requestId, 1);
|
||||
out.set(f.streamId, 1 + REQUEST_ID_LEN);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encodePingFrame(f: PingFrame): Uint8Array {
|
||||
const out = new Uint8Array(PING_FRAME_LEN);
|
||||
const view = new DataView(out.buffer);
|
||||
out[0] = WIRE_PING;
|
||||
out.set(f.requestId, 1);
|
||||
view.setBigUint64(1 + REQUEST_ID_LEN, f.nonce, false);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encodeChunkAckFrame(f: ChunkAckFrame): Uint8Array {
|
||||
const out = new Uint8Array(CHUNK_ACK_LEN);
|
||||
const view = new DataView(out.buffer);
|
||||
out[0] = WIRE_CHUNK_ACK;
|
||||
out.set(f.requestId, 1);
|
||||
view.setUint32(1 + REQUEST_ID_LEN, f.lastSeq, false);
|
||||
view.setUint32(1 + REQUEST_ID_LEN + 4, f.bytesReceived, false);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encodeResumeStateFrame(f: ResumeStateFrame): Uint8Array {
|
||||
const enc = new TextEncoder();
|
||||
const body = enc.encode(f.json);
|
||||
const out = new Uint8Array(1 + REQUEST_ID_LEN + body.length);
|
||||
out[0] = WIRE_RESUME_STATE;
|
||||
out.set(f.requestId, 1);
|
||||
out.set(body, 1 + REQUEST_ID_LEN);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encodePongFrame(f: PongFrame): Uint8Array {
|
||||
const out = new Uint8Array(PING_FRAME_LEN);
|
||||
const view = new DataView(out.buffer);
|
||||
out[0] = WIRE_PONG;
|
||||
out.set(f.requestId, 1);
|
||||
view.setBigUint64(1 + REQUEST_ID_LEN, f.nonce, false);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encodeErrorFrame(f: ErrorFrame): Uint8Array {
|
||||
const enc = new TextEncoder();
|
||||
const body = enc.encode(f.json);
|
||||
const out = new Uint8Array(1 + REQUEST_ID_LEN + body.length);
|
||||
out[0] = WIRE_ERROR;
|
||||
out.set(f.requestId, 1);
|
||||
out.set(body, 1 + REQUEST_ID_LEN);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Decoder ───────────────────────────────────────────────
|
||||
|
||||
export function decodeFrame(buf: ArrayBuffer | Uint8Array): AnyFrame {
|
||||
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
|
||||
if (bytes.length < 1 + REQUEST_ID_LEN) {
|
||||
throw new Error('frame truncated (under header length)');
|
||||
}
|
||||
const type = bytes[0]!;
|
||||
const requestId = bytes.slice(1, 1 + REQUEST_ID_LEN);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
|
||||
switch (type) {
|
||||
case WIRE_CHUNK: {
|
||||
if (bytes.length < CHUNK_HEADER_LEN) throw new Error('chunk frame truncated');
|
||||
const streamId = bytes.slice(1 + REQUEST_ID_LEN, 1 + REQUEST_ID_LEN + STREAM_ID_LEN);
|
||||
const laneId = view.getUint32(1 + REQUEST_ID_LEN + STREAM_ID_LEN, false);
|
||||
const seq = view.getBigUint64(1 + REQUEST_ID_LEN + STREAM_ID_LEN + 4, false);
|
||||
const envelope = bytes.slice(CHUNK_HEADER_LEN);
|
||||
return { type: WIRE_CHUNK, requestId, streamId, laneId, seq, envelope };
|
||||
}
|
||||
case WIRE_RESUME_QUERY: {
|
||||
if (bytes.length < RESUME_QUERY_HEADER_LEN) {
|
||||
throw new Error('resume-query frame truncated');
|
||||
}
|
||||
const streamId = bytes.slice(1 + REQUEST_ID_LEN, RESUME_QUERY_HEADER_LEN);
|
||||
return { type: WIRE_RESUME_QUERY, requestId, streamId };
|
||||
}
|
||||
case WIRE_PING: {
|
||||
if (bytes.length < PING_FRAME_LEN) throw new Error('ping frame truncated');
|
||||
const nonce = view.getBigUint64(1 + REQUEST_ID_LEN, false);
|
||||
return { type: WIRE_PING, requestId, nonce };
|
||||
}
|
||||
case WIRE_CHUNK_ACK: {
|
||||
if (bytes.length < CHUNK_ACK_LEN) throw new Error('chunk-ack frame truncated');
|
||||
const lastSeq = view.getUint32(1 + REQUEST_ID_LEN, false);
|
||||
const bytesReceived = view.getUint32(1 + REQUEST_ID_LEN + 4, false);
|
||||
return { type: WIRE_CHUNK_ACK, requestId, lastSeq, bytesReceived };
|
||||
}
|
||||
case WIRE_RESUME_STATE: {
|
||||
const body = bytes.slice(1 + REQUEST_ID_LEN);
|
||||
return { type: WIRE_RESUME_STATE, requestId, json: new TextDecoder().decode(body) };
|
||||
}
|
||||
case WIRE_PONG: {
|
||||
if (bytes.length < PING_FRAME_LEN) throw new Error('pong frame truncated');
|
||||
const nonce = view.getBigUint64(1 + REQUEST_ID_LEN, false);
|
||||
return { type: WIRE_PONG, requestId, nonce };
|
||||
}
|
||||
case WIRE_ERROR: {
|
||||
const body = bytes.slice(1 + REQUEST_ID_LEN);
|
||||
return { type: WIRE_ERROR, requestId, json: new TextDecoder().decode(body) };
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown frame type 0x${type.toString(16)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function randomRequestId(): Uint8Array {
|
||||
const out = new Uint8Array(REQUEST_ID_LEN);
|
||||
globalThis.crypto.getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── streamId base64url codec (matches @shade/streams) ──────
|
||||
|
||||
export function streamIdStringToBytes(s: string): Uint8Array {
|
||||
const padded = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
||||
const bin = atob(padded + pad);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function streamIdBytesToString(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
223
packages/shade-transport-webrtc/tests/connection.test.ts
Normal file
223
packages/shade-transport-webrtc/tests/connection.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
encodeChunkFrame,
|
||||
encodeResumeQueryFrame,
|
||||
randomRequestId,
|
||||
STREAM_ID_LEN,
|
||||
WIRE_CHUNK,
|
||||
WIRE_CHUNK_ACK,
|
||||
WIRE_RESUME_QUERY,
|
||||
WIRE_RESUME_STATE,
|
||||
WIRE_ERROR,
|
||||
bytesEqual,
|
||||
} from '../src/wire.js';
|
||||
import { MemoryRtcFactory } from '../src/memory-rtc.js';
|
||||
import { MemoryShadeBridge, WebRtcSignalingChannel } from '../src/signaling.js';
|
||||
import { WebRtcConnectionManager } from '../src/manager.js';
|
||||
|
||||
afterEach(() => {
|
||||
MemoryRtcFactory.reset();
|
||||
});
|
||||
|
||||
async function setupPair(opts?: {
|
||||
bobReceiver?: import('../src/connection.js').WebRtcReceiverHooks;
|
||||
aliceReceiver?: import('../src/connection.js').WebRtcReceiverHooks;
|
||||
}): Promise<{
|
||||
alice: WebRtcConnectionManager;
|
||||
bob: WebRtcConnectionManager;
|
||||
aliceSig: WebRtcSignalingChannel;
|
||||
bobSig: WebRtcSignalingChannel;
|
||||
}> {
|
||||
const { a, b } = MemoryShadeBridge.linked('alice', 'bob');
|
||||
const aliceSig = new WebRtcSignalingChannel(a);
|
||||
const bobSig = new WebRtcSignalingChannel(b);
|
||||
const factory = new MemoryRtcFactory();
|
||||
const alice = new WebRtcConnectionManager({
|
||||
factory,
|
||||
signaling: aliceSig,
|
||||
...(opts?.aliceReceiver !== undefined ? { receiver: opts.aliceReceiver } : {}),
|
||||
});
|
||||
const bob = new WebRtcConnectionManager({
|
||||
factory,
|
||||
signaling: bobSig,
|
||||
...(opts?.bobReceiver !== undefined ? { receiver: opts.bobReceiver } : {}),
|
||||
});
|
||||
return { alice, bob, aliceSig, bobSig };
|
||||
}
|
||||
|
||||
describe('WebRtcConnection — caller/callee handshake', () => {
|
||||
it('opens a data channel after offer/answer/ICE flow', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
const conn = await alice.getOrCreate('bob');
|
||||
expect(conn.state).toBe('connected');
|
||||
expect(bob.isConnected('alice')).toBe(true);
|
||||
|
||||
// The peer connection on the bob side should also be open and reachable.
|
||||
const bobConn = await bob.getOrCreate('alice');
|
||||
expect(bobConn.state).toBe('connected');
|
||||
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
|
||||
it('routes a chunk request to the receiver hook and replies with chunk-ack', async () => {
|
||||
const calls: Array<{
|
||||
from: string;
|
||||
streamId: string;
|
||||
laneId: number;
|
||||
seq: bigint;
|
||||
bytes: number;
|
||||
}> = [];
|
||||
const { alice, bob } = await setupPair({
|
||||
bobReceiver: {
|
||||
async onChunk(from, streamId, laneId, seq, envelope) {
|
||||
calls.push({ from, streamId, laneId, seq, bytes: envelope.length });
|
||||
return { lastSeq: Number(seq), bytesReceived: envelope.length };
|
||||
},
|
||||
async onResumeQuery() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const conn = await alice.getOrCreate('bob');
|
||||
const requestId = randomRequestId();
|
||||
const streamId = new Uint8Array(STREAM_ID_LEN).fill(0xaa);
|
||||
const envelope = new Uint8Array(64);
|
||||
for (let i = 0; i < envelope.length; i++) envelope[i] = i;
|
||||
|
||||
const frame = encodeChunkFrame({
|
||||
type: WIRE_CHUNK,
|
||||
requestId,
|
||||
streamId,
|
||||
laneId: 3,
|
||||
seq: 7n,
|
||||
envelope,
|
||||
});
|
||||
const response = await conn.request(frame, requestId);
|
||||
expect(response.type).toBe(WIRE_CHUNK_ACK);
|
||||
if (response.type === WIRE_CHUNK_ACK) {
|
||||
expect(bytesEqual(response.requestId, requestId)).toBe(true);
|
||||
expect(response.lastSeq).toBe(7);
|
||||
expect(response.bytesReceived).toBe(64);
|
||||
}
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]!.laneId).toBe(3);
|
||||
expect(calls[0]!.seq).toBe(7n);
|
||||
expect(calls[0]!.bytes).toBe(64);
|
||||
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
|
||||
it('returns error frame when receiver throws', async () => {
|
||||
const { alice, bob } = await setupPair({
|
||||
bobReceiver: {
|
||||
async onChunk() {
|
||||
throw new Error('nope');
|
||||
},
|
||||
async onResumeQuery() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const conn = await alice.getOrCreate('bob');
|
||||
const requestId = randomRequestId();
|
||||
const streamId = new Uint8Array(STREAM_ID_LEN).fill(0x01);
|
||||
const frame = encodeChunkFrame({
|
||||
type: WIRE_CHUNK,
|
||||
requestId,
|
||||
streamId,
|
||||
laneId: 0,
|
||||
seq: 0n,
|
||||
envelope: new Uint8Array(4),
|
||||
});
|
||||
const response = await conn.request(frame, requestId);
|
||||
expect(response.type).toBe(WIRE_ERROR);
|
||||
if (response.type === WIRE_ERROR) {
|
||||
expect(response.json).toContain('nope');
|
||||
}
|
||||
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
|
||||
it('handles resume-query with not-found → error frame', async () => {
|
||||
const { alice, bob } = await setupPair({
|
||||
bobReceiver: {
|
||||
async onChunk() {
|
||||
return { lastSeq: 0 };
|
||||
},
|
||||
async onResumeQuery() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
const conn = await alice.getOrCreate('bob');
|
||||
const requestId = randomRequestId();
|
||||
const streamId = new Uint8Array(STREAM_ID_LEN).fill(0x55);
|
||||
const frame = encodeResumeQueryFrame({
|
||||
type: WIRE_RESUME_QUERY,
|
||||
requestId,
|
||||
streamId,
|
||||
});
|
||||
const res = await conn.request(frame, requestId);
|
||||
expect(res.type).toBe(WIRE_ERROR);
|
||||
if (res.type === WIRE_ERROR) {
|
||||
expect(res.json).toContain('not found');
|
||||
}
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
|
||||
it('handles resume-query that returns state → resume-state frame', async () => {
|
||||
const { alice, bob } = await setupPair({
|
||||
bobReceiver: {
|
||||
async onChunk() {
|
||||
return { lastSeq: 0 };
|
||||
},
|
||||
async onResumeQuery(_from, streamId) {
|
||||
return { streamId, lanes: [{ laneId: 0, lastSeqAcked: 11 }] };
|
||||
},
|
||||
},
|
||||
});
|
||||
const conn = await alice.getOrCreate('bob');
|
||||
const requestId = randomRequestId();
|
||||
const streamId = new Uint8Array(STREAM_ID_LEN).fill(0x77);
|
||||
const frame = encodeResumeQueryFrame({
|
||||
type: WIRE_RESUME_QUERY,
|
||||
requestId,
|
||||
streamId,
|
||||
});
|
||||
const res = await conn.request(frame, requestId);
|
||||
expect(res.type).toBe(WIRE_RESUME_STATE);
|
||||
if (res.type === WIRE_RESUME_STATE) {
|
||||
const parsed = JSON.parse(res.json) as { lanes: { laneId: number; lastSeqAcked: number }[] };
|
||||
expect(parsed.lanes[0]!.lastSeqAcked).toBe(11);
|
||||
}
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebRtcConnectionManager pool', () => {
|
||||
it('reuses one connection per peer', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
const c1 = await alice.getOrCreate('bob');
|
||||
const c2 = await alice.getOrCreate('bob');
|
||||
expect(c1).toBe(c2);
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
|
||||
it('removes the connection from the pool when it closes', async () => {
|
||||
const { alice, bob } = await setupPair();
|
||||
const conn = await alice.getOrCreate('bob');
|
||||
expect(alice.isConnected('bob')).toBe(true);
|
||||
await conn.close('test');
|
||||
expect(alice.isConnected('bob')).toBe(false);
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
});
|
||||
49
packages/shade-transport-webrtc/tests/glare.test.ts
Normal file
49
packages/shade-transport-webrtc/tests/glare.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Glare = both peers initiate at the same instant. The manager resolves
|
||||
* deterministically: the address with the lexically-larger value yields
|
||||
* to the smaller one's offer.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { MemoryRtcFactory } from '../src/memory-rtc.js';
|
||||
import { MemoryShadeBridge, WebRtcSignalingChannel } from '../src/signaling.js';
|
||||
import { WebRtcConnectionManager } from '../src/manager.js';
|
||||
|
||||
afterEach(() => {
|
||||
MemoryRtcFactory.reset();
|
||||
});
|
||||
|
||||
describe('Glare resolution', () => {
|
||||
it('two simultaneous getOrCreate() calls converge on a single connection', async () => {
|
||||
const { a, b } = MemoryShadeBridge.linked('alice', 'bob');
|
||||
const aliceSig = new WebRtcSignalingChannel(a);
|
||||
const bobSig = new WebRtcSignalingChannel(b);
|
||||
const factory = new MemoryRtcFactory();
|
||||
const alice = new WebRtcConnectionManager({ factory, signaling: aliceSig });
|
||||
const bob = new WebRtcConnectionManager({
|
||||
factory,
|
||||
signaling: bobSig,
|
||||
receiver: {
|
||||
async onChunk() {
|
||||
return { lastSeq: 0 };
|
||||
},
|
||||
async onResumeQuery() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Both kick off at once.
|
||||
const [aConn, bConn] = await Promise.all([
|
||||
alice.getOrCreate('bob'),
|
||||
bob.getOrCreate('alice'),
|
||||
]);
|
||||
|
||||
expect(aConn.state).toBe('connected');
|
||||
expect(bConn.state).toBe('connected');
|
||||
expect(alice.isConnected('bob')).toBe(true);
|
||||
expect(bob.isConnected('alice')).toBe(true);
|
||||
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
});
|
||||
28
packages/shade-transport-webrtc/tests/native-rtc.test.ts
Normal file
28
packages/shade-transport-webrtc/tests/native-rtc.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Smoke test against the native RTCPeerConnection adapter when the
|
||||
* runtime exposes one (browsers / Deno / Bun ≥ X). Skipped otherwise so
|
||||
* Bun's own test runner stays green without third-party native modules.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { isNativeRtcAvailable, nativeRtcFactory } from '../src/native-rtc.js';
|
||||
|
||||
describe('native RTC adapter', () => {
|
||||
test('isNativeRtcAvailable() returns false in plain Bun', () => {
|
||||
// This may flip to true in future Bun releases; the test is mostly a
|
||||
// belt-and-suspenders against accidental globalThis pollution by
|
||||
// earlier tests.
|
||||
expect(typeof isNativeRtcAvailable()).toBe('boolean');
|
||||
});
|
||||
|
||||
test('nativeRtcFactory()-built PC throws a clear error if RTCPeerConnection is missing', () => {
|
||||
if (isNativeRtcAvailable()) {
|
||||
const f = nativeRtcFactory();
|
||||
const pc = f.createPeerConnection({});
|
||||
expect(typeof pc.createDataChannel).toBe('function');
|
||||
pc.close();
|
||||
} else {
|
||||
const f = nativeRtcFactory();
|
||||
expect(() => f.createPeerConnection({})).toThrow(/RTCPeerConnection/);
|
||||
}
|
||||
});
|
||||
});
|
||||
76
packages/shade-transport-webrtc/tests/signaling.test.ts
Normal file
76
packages/shade-transport-webrtc/tests/signaling.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
MemoryShadeBridge,
|
||||
WebRtcSignalingChannel,
|
||||
} from '../src/signaling.js';
|
||||
|
||||
describe('WebRtcSignalingChannel', () => {
|
||||
it('routes typed signaling messages through the bridge', async () => {
|
||||
const { a, b } = MemoryShadeBridge.linked('alice', 'bob');
|
||||
const aliceSig = new WebRtcSignalingChannel(a);
|
||||
const bobSig = new WebRtcSignalingChannel(b);
|
||||
|
||||
const received: Array<{ from: string; kind: string }> = [];
|
||||
bobSig.onSignal((from, msg) => {
|
||||
received.push({ from, kind: msg.kind });
|
||||
});
|
||||
|
||||
await aliceSig.sendOffer('bob', 'sess-1', 'v=0\nfake-sdp');
|
||||
await aliceSig.sendIce('bob', 'sess-1', {
|
||||
candidate: 'candidate:1 1 udp 0 1.2.3.4 1234 typ host',
|
||||
sdpMid: '0',
|
||||
sdpMLineIndex: 0,
|
||||
});
|
||||
await aliceSig.sendBye('bob', 'sess-1', 'no longer needed');
|
||||
|
||||
expect(received).toEqual([
|
||||
{ from: 'alice', kind: 'shade.webrtc-offer/v1' },
|
||||
{ from: 'alice', kind: 'shade.webrtc-ice/v1' },
|
||||
{ from: 'alice', kind: 'shade.webrtc-bye/v1' },
|
||||
]);
|
||||
|
||||
aliceSig.destroy();
|
||||
bobSig.destroy();
|
||||
});
|
||||
|
||||
it('passes non-signaling messages through to the passthrough hook', async () => {
|
||||
const { a, b } = MemoryShadeBridge.linked('alice', 'bob');
|
||||
const passthrough: string[] = [];
|
||||
const bobSig = new WebRtcSignalingChannel(b, {
|
||||
passthrough: (_from, plaintext) => passthrough.push(plaintext),
|
||||
});
|
||||
const seen: string[] = [];
|
||||
bobSig.onSignal((_from, msg) => seen.push(msg.kind));
|
||||
|
||||
await a.send('bob', 'hello world (not signaling)');
|
||||
await a.send('bob', JSON.stringify({ kind: 'shade.fs.cancel/v1' }));
|
||||
|
||||
expect(passthrough).toContain('hello world (not signaling)');
|
||||
expect(seen).toEqual([]);
|
||||
bobSig.destroy();
|
||||
});
|
||||
|
||||
it('preserves causal order — offer awaited before ICE handler runs', async () => {
|
||||
const { a, b } = MemoryShadeBridge.linked('alice', 'bob');
|
||||
const aliceSig = new WebRtcSignalingChannel(a);
|
||||
const bobSig = new WebRtcSignalingChannel(b);
|
||||
const order: string[] = [];
|
||||
|
||||
bobSig.onSignal(async (_from, msg) => {
|
||||
if (msg.kind === 'shade.webrtc-offer/v1') {
|
||||
// Slow handler — must complete before ICE arrives.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||
order.push('offer-done');
|
||||
} else if (msg.kind === 'shade.webrtc-ice/v1') {
|
||||
order.push('ice');
|
||||
}
|
||||
});
|
||||
|
||||
await aliceSig.sendOffer('bob', 's', 'sdp');
|
||||
await aliceSig.sendIce('bob', 's', null);
|
||||
|
||||
expect(order).toEqual(['offer-done', 'ice']);
|
||||
aliceSig.destroy();
|
||||
bobSig.destroy();
|
||||
});
|
||||
});
|
||||
193
packages/shade-transport-webrtc/tests/transport.test.ts
Normal file
193
packages/shade-transport-webrtc/tests/transport.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* End-to-end test of `WebRtcTransferTransport` against the manager + memory
|
||||
* factory. Exercises the same `ITransferTransport` API the engine calls
|
||||
* (`probe`, `sendChunk`, `fetchResumeState`).
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { MemoryRtcFactory } from '../src/memory-rtc.js';
|
||||
import { MemoryShadeBridge, WebRtcSignalingChannel } from '../src/signaling.js';
|
||||
import { WebRtcConnectionManager } from '../src/manager.js';
|
||||
import {
|
||||
DEFAULT_MAX_DATACHANNEL_MESSAGE,
|
||||
WebRtcTransferTransport,
|
||||
} from '../src/transport.js';
|
||||
import { streamIdBytesToString } from '../src/wire.js';
|
||||
|
||||
afterEach(() => {
|
||||
MemoryRtcFactory.reset();
|
||||
});
|
||||
|
||||
function paired(opts: {
|
||||
bobReceiver: import('../src/connection.js').WebRtcReceiverHooks;
|
||||
}): {
|
||||
alice: WebRtcConnectionManager;
|
||||
bob: WebRtcConnectionManager;
|
||||
aliceTransport: WebRtcTransferTransport;
|
||||
} {
|
||||
const factory = new MemoryRtcFactory();
|
||||
const { a, b } = MemoryShadeBridge.linked('alice', 'bob');
|
||||
const aliceSig = new WebRtcSignalingChannel(a);
|
||||
const bobSig = new WebRtcSignalingChannel(b);
|
||||
const alice = new WebRtcConnectionManager({ factory, signaling: aliceSig });
|
||||
const bob = new WebRtcConnectionManager({
|
||||
factory,
|
||||
signaling: bobSig,
|
||||
receiver: opts.bobReceiver,
|
||||
});
|
||||
const aliceTransport = new WebRtcTransferTransport({ manager: alice });
|
||||
return { alice, bob, aliceTransport };
|
||||
}
|
||||
|
||||
function makeStreamId(): string {
|
||||
const b = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(b);
|
||||
return streamIdBytesToString(b);
|
||||
}
|
||||
|
||||
describe('WebRtcTransferTransport', () => {
|
||||
it('probe opens the peer connection', async () => {
|
||||
const { alice, bob, aliceTransport } = paired({
|
||||
bobReceiver: {
|
||||
async onChunk() {
|
||||
return { lastSeq: 0 };
|
||||
},
|
||||
async onResumeQuery() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
await aliceTransport.probe('bob');
|
||||
expect(alice.isConnected('bob')).toBe(true);
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
|
||||
it('sendChunk routes envelope to receiver and returns the ack', async () => {
|
||||
let received: { laneId: number; seq: bigint; bytes: number } | null = null;
|
||||
const { alice, bob, aliceTransport } = paired({
|
||||
bobReceiver: {
|
||||
async onChunk(_from, _streamId, laneId, seq, envelope) {
|
||||
received = { laneId, seq, bytes: envelope.length };
|
||||
return { lastSeq: Number(seq), bytesReceived: envelope.length };
|
||||
},
|
||||
async onResumeQuery() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const streamId = makeStreamId();
|
||||
const envelope = new Uint8Array(2048);
|
||||
envelope.fill(0x42);
|
||||
const ack = await aliceTransport.sendChunk('bob', streamId, 1, 5n, envelope);
|
||||
expect(ack.lastSeq).toBe(5);
|
||||
expect(ack.bytesReceived).toBe(2048);
|
||||
expect(received).not.toBeNull();
|
||||
expect(received!.laneId).toBe(1);
|
||||
expect(received!.seq).toBe(5n);
|
||||
expect(received!.bytes).toBe(2048);
|
||||
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
|
||||
it('rejects oversized envelopes that would exceed the data channel cap', async () => {
|
||||
const { alice, bob, aliceTransport } = paired({
|
||||
bobReceiver: {
|
||||
async onChunk() {
|
||||
return { lastSeq: 0 };
|
||||
},
|
||||
async onResumeQuery() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
const streamId = makeStreamId();
|
||||
const huge = new Uint8Array(DEFAULT_MAX_DATACHANNEL_MESSAGE + 1);
|
||||
await expect(aliceTransport.sendChunk('bob', streamId, 0, 0n, huge)).rejects.toThrow(
|
||||
/frame too large/,
|
||||
);
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
|
||||
it('fetchResumeState returns parsed state when the receiver knows the stream', async () => {
|
||||
const { alice, bob, aliceTransport } = paired({
|
||||
bobReceiver: {
|
||||
async onChunk() {
|
||||
return { lastSeq: 0 };
|
||||
},
|
||||
async onResumeQuery(_from, streamId) {
|
||||
return {
|
||||
streamId,
|
||||
lanes: [
|
||||
{ laneId: 0, lastSeqAcked: 11 },
|
||||
{ laneId: 1, lastSeqAcked: 4 },
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const sid = makeStreamId();
|
||||
const state = await aliceTransport.fetchResumeState('bob', sid);
|
||||
expect(state).not.toBeNull();
|
||||
expect(state!.streamId).toBe(sid);
|
||||
expect(state!.lanes[0]!.lastSeqAcked).toBe(11);
|
||||
expect(state!.lanes[1]!.lastSeqAcked).toBe(4);
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
|
||||
it('fetchResumeState returns null when the peer reports not found', async () => {
|
||||
const { alice, bob, aliceTransport } = paired({
|
||||
bobReceiver: {
|
||||
async onChunk() {
|
||||
return { lastSeq: 0 };
|
||||
},
|
||||
async onResumeQuery() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
const state = await aliceTransport.fetchResumeState('bob', makeStreamId());
|
||||
expect(state).toBeNull();
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
|
||||
it('multiple in-flight requests interleave correctly via requestId correlation', async () => {
|
||||
let inflight = 0;
|
||||
let maxInflight = 0;
|
||||
const { alice, bob, aliceTransport } = paired({
|
||||
bobReceiver: {
|
||||
async onChunk(_from, _streamId, _laneId, seq) {
|
||||
inflight++;
|
||||
if (inflight > maxInflight) maxInflight = inflight;
|
||||
// Stagger response so request ordering doesn't trivially match
|
||||
// response ordering.
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, Number(seq % 5n) * 5),
|
||||
);
|
||||
inflight--;
|
||||
return { lastSeq: Number(seq), bytesReceived: 0 };
|
||||
},
|
||||
async onResumeQuery() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
const streamId = makeStreamId();
|
||||
const acks = await Promise.all(
|
||||
Array.from({ length: 12 }, (_, i) =>
|
||||
aliceTransport.sendChunk('bob', streamId, i % 4, BigInt(i), new Uint8Array(8)),
|
||||
),
|
||||
);
|
||||
// Each ack matches its request seq (round-trip via requestId).
|
||||
for (let i = 0; i < acks.length; i++) {
|
||||
expect(acks[i]!.lastSeq).toBe(i);
|
||||
}
|
||||
expect(maxInflight).toBeGreaterThan(1);
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
});
|
||||
116
packages/shade-transport-webrtc/tests/turn-relay.test.ts
Normal file
116
packages/shade-transport-webrtc/tests/turn-relay.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* V3.11 acceptance criterion: TURN-relay påtvinger relay-modus.
|
||||
*
|
||||
* We can't do real ICE in the memory factory, but we CAN verify that the
|
||||
* RTCConfiguration we pass to the underlying factory carries the
|
||||
* `iceTransportPolicy: 'relay'` flag through unchanged when the
|
||||
* application configures a TURN-only setup. This guarantees a real
|
||||
* RTCPeerConnection adapter (browser / wrtc / node-datachannel) will
|
||||
* reject all non-relay candidate pairs as the spec requires.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { MemoryShadeBridge, WebRtcSignalingChannel } from '../src/signaling.js';
|
||||
import { WebRtcConnectionManager } from '../src/manager.js';
|
||||
import {
|
||||
DEFAULT_STUN_SERVERS,
|
||||
type IDataChannel,
|
||||
type IPeerConnection,
|
||||
type IRtcFactory,
|
||||
type ShadeIceCandidate,
|
||||
type ShadeRtcConfig,
|
||||
type ShadeRtcConnectionState,
|
||||
type ShadeSessionDescription,
|
||||
} from '../src/types.js';
|
||||
import { MemoryRtcFactory } from '../src/memory-rtc.js';
|
||||
|
||||
afterEach(() => {
|
||||
MemoryRtcFactory.reset();
|
||||
});
|
||||
|
||||
class CapturingFactory implements IRtcFactory {
|
||||
configs: ShadeRtcConfig[] = [];
|
||||
constructor(private readonly inner: IRtcFactory) {}
|
||||
createPeerConnection(config: ShadeRtcConfig): IPeerConnection {
|
||||
this.configs.push(config);
|
||||
return this.inner.createPeerConnection(config);
|
||||
}
|
||||
}
|
||||
|
||||
describe('TURN-relay configuration plumbing', () => {
|
||||
it('passes iceServers + iceTransportPolicy through to the underlying RTCConfiguration', async () => {
|
||||
const turnServers = [
|
||||
{
|
||||
urls: 'turn:turn.example.com:3478',
|
||||
username: 'shade',
|
||||
credential: 'secret',
|
||||
},
|
||||
];
|
||||
const inner = new MemoryRtcFactory();
|
||||
const factory = new CapturingFactory(inner);
|
||||
|
||||
const { a, b } = MemoryShadeBridge.linked('alice', 'bob');
|
||||
const aliceSig = new WebRtcSignalingChannel(a);
|
||||
const bobSig = new WebRtcSignalingChannel(b);
|
||||
const alice = new WebRtcConnectionManager({
|
||||
factory,
|
||||
signaling: aliceSig,
|
||||
config: { iceServers: turnServers, iceTransportPolicy: 'relay' },
|
||||
});
|
||||
const bob = new WebRtcConnectionManager({
|
||||
factory,
|
||||
signaling: bobSig,
|
||||
config: { iceServers: turnServers, iceTransportPolicy: 'relay' },
|
||||
receiver: {
|
||||
async onChunk() {
|
||||
return { lastSeq: 0 };
|
||||
},
|
||||
async onResumeQuery() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await alice.getOrCreate('bob');
|
||||
|
||||
// Both sides created at least one PC; each call's config should carry
|
||||
// the TURN-only policy verbatim.
|
||||
expect(factory.configs.length).toBeGreaterThanOrEqual(2);
|
||||
for (const c of factory.configs) {
|
||||
expect(c.iceTransportPolicy).toBe('relay');
|
||||
expect(c.iceServers).toEqual(turnServers);
|
||||
}
|
||||
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
|
||||
it('falls back to default public STUN when no iceServers are supplied', async () => {
|
||||
const inner = new MemoryRtcFactory();
|
||||
const factory = new CapturingFactory(inner);
|
||||
const { a, b } = MemoryShadeBridge.linked('alice', 'bob');
|
||||
const alice = new WebRtcConnectionManager({
|
||||
factory,
|
||||
signaling: new WebRtcSignalingChannel(a),
|
||||
defaultStunServers: DEFAULT_STUN_SERVERS,
|
||||
});
|
||||
const bob = new WebRtcConnectionManager({
|
||||
factory,
|
||||
signaling: new WebRtcSignalingChannel(b),
|
||||
defaultStunServers: DEFAULT_STUN_SERVERS,
|
||||
receiver: {
|
||||
async onChunk() {
|
||||
return { lastSeq: 0 };
|
||||
},
|
||||
async onResumeQuery() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
await alice.getOrCreate('bob');
|
||||
for (const c of factory.configs) {
|
||||
expect(c.iceServers).toEqual(DEFAULT_STUN_SERVERS);
|
||||
}
|
||||
alice.destroy();
|
||||
bob.destroy();
|
||||
});
|
||||
});
|
||||
140
packages/shade-transport-webrtc/tests/wire.test.ts
Normal file
140
packages/shade-transport-webrtc/tests/wire.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
CHUNK_HEADER_LEN,
|
||||
bytesEqual,
|
||||
decodeFrame,
|
||||
encodeChunkAckFrame,
|
||||
encodeChunkFrame,
|
||||
encodeErrorFrame,
|
||||
encodePingFrame,
|
||||
encodePongFrame,
|
||||
encodeResumeQueryFrame,
|
||||
encodeResumeStateFrame,
|
||||
randomRequestId,
|
||||
REQUEST_ID_LEN,
|
||||
STREAM_ID_LEN,
|
||||
streamIdBytesToString,
|
||||
streamIdStringToBytes,
|
||||
WIRE_CHUNK,
|
||||
WIRE_CHUNK_ACK,
|
||||
WIRE_ERROR,
|
||||
WIRE_PING,
|
||||
WIRE_PONG,
|
||||
WIRE_RESUME_QUERY,
|
||||
WIRE_RESUME_STATE,
|
||||
} from '../src/wire.js';
|
||||
|
||||
describe('wire format', () => {
|
||||
it('roundtrips a chunk frame', () => {
|
||||
const requestId = randomRequestId();
|
||||
const streamId = new Uint8Array(STREAM_ID_LEN).fill(0xab);
|
||||
const envelope = new Uint8Array(1024);
|
||||
for (let i = 0; i < envelope.length; i++) envelope[i] = i & 0xff;
|
||||
const buf = encodeChunkFrame({
|
||||
type: WIRE_CHUNK,
|
||||
requestId,
|
||||
streamId,
|
||||
laneId: 7,
|
||||
seq: 12345n,
|
||||
envelope,
|
||||
});
|
||||
expect(buf.length).toBe(CHUNK_HEADER_LEN + envelope.length);
|
||||
|
||||
const decoded = decodeFrame(buf);
|
||||
expect(decoded.type).toBe(WIRE_CHUNK);
|
||||
if (decoded.type !== WIRE_CHUNK) throw new Error('type narrow failed');
|
||||
expect(bytesEqual(decoded.requestId, requestId)).toBe(true);
|
||||
expect(bytesEqual(decoded.streamId, streamId)).toBe(true);
|
||||
expect(decoded.laneId).toBe(7);
|
||||
expect(decoded.seq).toBe(12345n);
|
||||
expect(bytesEqual(decoded.envelope, envelope)).toBe(true);
|
||||
});
|
||||
|
||||
it('roundtrips a resume-query frame', () => {
|
||||
const requestId = randomRequestId();
|
||||
const streamId = new Uint8Array(STREAM_ID_LEN).fill(0x33);
|
||||
const buf = encodeResumeQueryFrame({
|
||||
type: WIRE_RESUME_QUERY,
|
||||
requestId,
|
||||
streamId,
|
||||
});
|
||||
const decoded = decodeFrame(buf);
|
||||
if (decoded.type !== WIRE_RESUME_QUERY) throw new Error('type narrow failed');
|
||||
expect(bytesEqual(decoded.requestId, requestId)).toBe(true);
|
||||
expect(bytesEqual(decoded.streamId, streamId)).toBe(true);
|
||||
});
|
||||
|
||||
it('roundtrips chunk-ack', () => {
|
||||
const requestId = randomRequestId();
|
||||
const buf = encodeChunkAckFrame({
|
||||
type: WIRE_CHUNK_ACK,
|
||||
requestId,
|
||||
lastSeq: 42,
|
||||
bytesReceived: 1024,
|
||||
});
|
||||
const decoded = decodeFrame(buf);
|
||||
if (decoded.type !== WIRE_CHUNK_ACK) throw new Error('type narrow failed');
|
||||
expect(decoded.lastSeq).toBe(42);
|
||||
expect(decoded.bytesReceived).toBe(1024);
|
||||
});
|
||||
|
||||
it('roundtrips resume-state frame', () => {
|
||||
const json = JSON.stringify({
|
||||
streamId: 'abc',
|
||||
lanes: [{ laneId: 0, lastSeqAcked: 5 }],
|
||||
});
|
||||
const buf = encodeResumeStateFrame({
|
||||
type: WIRE_RESUME_STATE,
|
||||
requestId: randomRequestId(),
|
||||
json,
|
||||
});
|
||||
const decoded = decodeFrame(buf);
|
||||
if (decoded.type !== WIRE_RESUME_STATE) throw new Error('type narrow failed');
|
||||
expect(decoded.json).toBe(json);
|
||||
});
|
||||
|
||||
it('roundtrips ping/pong frames', () => {
|
||||
const requestId = randomRequestId();
|
||||
const ping = encodePingFrame({ type: WIRE_PING, requestId, nonce: 99n });
|
||||
const decodedPing = decodeFrame(ping);
|
||||
if (decodedPing.type !== WIRE_PING) throw new Error('type narrow failed');
|
||||
expect(decodedPing.nonce).toBe(99n);
|
||||
|
||||
const pong = encodePongFrame({ type: WIRE_PONG, requestId, nonce: 99n });
|
||||
const decodedPong = decodeFrame(pong);
|
||||
if (decodedPong.type !== WIRE_PONG) throw new Error('type narrow failed');
|
||||
expect(decodedPong.nonce).toBe(99n);
|
||||
});
|
||||
|
||||
it('roundtrips error frame', () => {
|
||||
const buf = encodeErrorFrame({
|
||||
type: WIRE_ERROR,
|
||||
requestId: randomRequestId(),
|
||||
json: '{"error":"oh no"}',
|
||||
});
|
||||
const decoded = decodeFrame(buf);
|
||||
if (decoded.type !== WIRE_ERROR) throw new Error('type narrow failed');
|
||||
expect(JSON.parse(decoded.json)).toEqual({ error: 'oh no' });
|
||||
});
|
||||
|
||||
it('rejects truncated frames', () => {
|
||||
const tiny = new Uint8Array(8);
|
||||
expect(() => decodeFrame(tiny)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects unknown type', () => {
|
||||
const bad = new Uint8Array(REQUEST_ID_LEN + 1);
|
||||
bad[0] = 0x77;
|
||||
expect(() => decodeFrame(bad)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamId base64url codec', () => {
|
||||
it('roundtrips arbitrary 16-byte ids', () => {
|
||||
const original = new Uint8Array(STREAM_ID_LEN);
|
||||
globalThis.crypto.getRandomValues(original);
|
||||
const s = streamIdBytesToString(original);
|
||||
const back = streamIdStringToBytes(s);
|
||||
expect(bytesEqual(back, original)).toBe(true);
|
||||
});
|
||||
});
|
||||
8
packages/shade-transport-webrtc/tsconfig.json
Normal file
8
packages/shade-transport-webrtc/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user