4.2 KiB
ICE Handlers
(no submodules)
Purpose
The ice module owns the two thin pieces of ICE handling that WebRTCClient
needs but that don't belong in the orchestrator: listener attachment with
auto-restart policy, and a typed wrapper around addIceCandidate. Keeping
this logic in its own module means the restart algorithm is testable in
isolation (against a structural FakeRTCPeerConnection) and reusable if
another transport wants to adopt the same bounded-restart semantics.
The restart policy is intentionally bounded — three attempts by default —
because mobile networks routinely cycle iceConnectionState === "failed"
during handover. We want to ride those out without flapping forever; once
the budget is exhausted, onStateChange("failed") fires and the caller
(typically WebRTCClient) decides whether to close the peer or escalate to
the reconnect controller.
Features
attachIceHandlers(pc, peerId, options)installs bothicecandidateandiceconnectionstatechangelisteners on anRTCPeerConnectionand returns an idempotentdispose()handle.- Bounded auto-restart: on
iceConnectionState === "failed"the helper callspc.restartIce()and emitsonRestart({ peerId, attempt, timestamp })untilmaxRestartAttempts(default 3) is reached. restartIcethrows are swallowed (closed connections re-fire the terminal state throughonStateChange).applyIceCandidate(pc, candidate)wrapspc.addIceCandidateand translates failures intoDriveWebRtcError(ICE_FAILED)with the original error preserved viacause.- Injectable clock keeps
timestampdeterministic in tests.
Usage: Basic Example
import { applyIceCandidate, attachIceHandlers } from "@suaveplan/drive-webrtc";
const handle = attachIceHandlers(pc, "peer-A", {
onCandidate: (c) => signaling.send({ type: "ice-candidate", payload: c }),
onStateChange: (s) => console.log("ice state", s),
onRestart: (info) => console.log("restart", info),
});
// Later, when a remote candidate arrives:
await applyIceCandidate(pc, remoteCandidate);
// On peer teardown:
handle.dispose();
Usage: Advanced Example
import { attachIceHandlers } from "@suaveplan/drive-webrtc";
let nowMs = 0;
const clock = { now: () => nowMs };
const restarts: { attempt: number; timestamp: number }[] = [];
const states: RTCIceConnectionState[] = [];
const handle = attachIceHandlers(fakePc, "peer-X", {
onCandidate: () => {},
onStateChange: (s) => states.push(s),
onRestart: (info) => restarts.push(info),
maxRestartAttempts: 2,
clock,
});
// Simulate three failures: the first two trigger restartIce, the third
// surfaces as a terminal "failed" without restart.
nowMs = 100; fakePc.simulateIceState("failed");
nowMs = 200; fakePc.simulateIceState("failed");
nowMs = 300; fakePc.simulateIceState("failed");
// restarts === [{ attempt: 1, timestamp: 100 }, { attempt: 2, timestamp: 200 }]
// states === ["failed", "failed", "failed"]
// handle.restartAttempts === 2
handle.dispose();
API Reference
attachIceHandlers(pc, peerId, options): IceHandlersHandle
pc: RTCPeerConnectionpeerId: stringoptions.onCandidate(candidate: RTCIceCandidate): voidoptions.onStateChange(state: RTCIceConnectionState): voidoptions.onRestart(info: IceRestartInfo): voidoptions.maxRestartAttempts?: number(default 3)options.clock?: { now: () => number }
Returns { dispose(), restartAttempts }. dispose() is idempotent.
applyIceCandidate(pc, candidate): Promise<void>
Wraps pc.addIceCandidate(candidate). Throws
DriveWebRtcError(ICE_FAILED) with cause populated on any failure.
Implementation Notes
EventTarget-styleaddEventListener/removeEventListeneris used rather thanoniceconnectionstatechange = ...assignment so that the module never clobbers a listener the caller installed for another purpose.pc.restartIce()is guarded bytypeof pc.restartIce === "function"to remain robust against olderRTCPeerConnectionshims and the structural test double.dispose()flips a local flag before removing listeners so an in-flight event-dispatch loop that re-enters during teardown is a no-op rather than a double-emit.