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

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:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View 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,
};

View 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}`);
}
}

View 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';

View 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}` : ''}`);
}
}

View 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]! };
}

View 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';
}

View 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);
},
};
}

View 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);
}
}

View 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));
}
}
}

View 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);
}

View 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(/=+$/, '');
}