5.8 KiB
SignalingClient
(no submodules)
Purpose
SignalingClient owns the WebSocket lifecycle to the WebRTC signaling
server. The signaling layer carries SDP offers and answers, ICE candidates,
peer-mesh roster updates, and the fallback chunk-relay envelopes used when
no direct data channel is available. The client is intentionally separate
from PeerManager so the wire codec, reconnect policy, and Zod validation
of inbound traffic can be reasoned about in isolation — peer state lives
elsewhere and consumes typed SignalingMessage events from this surface.
A dedicated client also keeps the package universal friendly: the only
runtime browser dependency is WebSocket, and the constructor accepts a
custom webSocketCtor so a server-tier transport (or a deterministic test
double implementing the WebSocket shape) can be plugged in without
touching production code paths.
Features
- JSON-over-WebSocket wire format with mandatory Zod validation on
every inbound frame via
SignalingMessageSchema. - Strongly-typed event surface:
open,close,message,error,reconnect:attempt,reconnect:success,reconnect:exhausted. - Bounded-backoff reconnect with caller-supplied schedule and hard cap on
total attempts. After exhaustion the client emits
DriveWebRtcError(SIGNALING_UNREACHABLE). AbortSignal-threadedconnect— pre-aborted signals reject synchronously and mid-flight aborts close the socket cleanly.- Outbound send throws
SIGNALING_UNREACHABLEwhen the socket is not in the OPEN state (no silent drops). - OpenTelemetry surface: reconnect-duration histogram and direction/outcome-tagged messages counter.
Usage: Basic Example
import {
SignalingClient,
type SignalingMessage,
} from "@suaveplan/drive-webrtc";
const client = new SignalingClient({
url: "wss://signaling.example.com/peer",
peerId: crypto.randomUUID(),
});
client.on("open", () => {
client.send({
type: "register",
payload: {
peerId: client.peerId,
peerName: "alice",
chunks: [],
},
});
});
client.on("message", (msg: SignalingMessage) => {
if (msg.type === "registered") {
console.log("registered with peers:", msg.payload.peers.length);
}
});
client.on("error", (err) => {
console.error(err.code, err.message);
});
await client.connect();
Usage: Advanced Example
The constructor accepts an injectable WebSocket factory and a custom
backoff schedule. The example below wires a deterministic clock,
hand-tunes the backoff curve, and threads an AbortSignal so the caller
can cancel a long-running reconnect chain on shutdown.
import { SignalingClient } from "@suaveplan/drive-webrtc";
const controller = new AbortController();
const client = new SignalingClient({
url: "wss://signaling.internal/peer",
peerId: "peer-A",
webSocketCtor: globalThis.WebSocket,
reconnectBackoffMs: [250, 500, 1_000, 2_000],
maxReconnectAttempts: 4,
clock: { now: () => performance.now() },
});
client.on("reconnect:attempt", ({ attempt, delayMs }) => {
console.warn(`reconnect attempt ${attempt} after ${delayMs} ms`);
});
client.on("reconnect:exhausted", ({ attempts }) => {
console.error(`signaling exhausted after ${attempts} attempts`);
});
try {
await client.connect(controller.signal);
} catch (err) {
// pre-aborted signals reject synchronously; mid-flight aborts close
// the socket cleanly and reject with SIGNALING_UNREACHABLE
}
// graceful shutdown — no further reconnect attempts
client.close("shutting down");
API Reference
new SignalingClient(options)
Construct a new client. options.url is the signaling endpoint; peerId
is the local peer identifier. webSocketCtor defaults to
globalThis.WebSocket. reconnectBackoffMs defaults to
DEFAULT_RECONNECT_BACKOFF_MS from the package constants;
maxReconnectAttempts defaults to the backoff length. clock is
optional and only affects the reconnect-duration histogram metric.
connect(signal?: AbortSignal): Promise<void>
Opens the WebSocket and resolves once onopen fires. Repeated calls
while connecting / reconnecting / open are no-ops and return a resolved
promise. Pre-aborted signals reject with SIGNALING_UNREACHABLE;
mid-flight aborts close the socket cleanly and reject.
send(message: SignalingMessage): void
Serialises message with JSON.stringify and writes to the socket.
Throws DriveWebRtcError(SIGNALING_UNREACHABLE) when the socket is not
open. No Zod re-validation on send — the type system enforces the shape
at the call site.
close(reason?: string): void
Closes the socket cleanly (code 1000) and disables future reconnect. Idempotent.
state
Read-only getter returning one of
"idle" | "connecting" | "open" | "reconnecting" | "closed".
Implementation Notes
- Reconnect is only scheduled after a successful
open. If the very first connect attempt fails (unclean close or constructor throw),connectrejects and the client transitions toclosed. Callers wire their own outer retry / circuit-breaker if they need re-attempts before any successful open. - Inbound
JSON.parsefailure, non-string payloads, and Zod validation failures all surface asSIGNALING_MESSAGE_INVALIDvia theerrorevent. The connection is NOT closed on malformed messages — this is defensive behaviour against noisy or partially-upgraded peers. reconnect:attemptfires before the backoff delay;reconnect:successfires fromonopenafter the socket transitions OPEN. The histogram records wall-clock elapsed time from the start of reconnect (first attempt scheduled) to the successful open.- The class extends
TypedEventEmitterfrom the same package so listener registration mirrors every other event surface in@suaveplan/drive-webrtc.