Files
2026-05-27 23:47:53 +01:00

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-threaded connect — pre-aborted signals reject synchronously and mid-flight aborts close the socket cleanly.
  • Outbound send throws SIGNALING_UNREACHABLE when 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), connect rejects and the client transitions to closed. Callers wire their own outer retry / circuit-breaker if they need re-attempts before any successful open.
  • Inbound JSON.parse failure, non-string payloads, and Zod validation failures all surface as SIGNALING_MESSAGE_INVALID via the error event. The connection is NOT closed on malformed messages — this is defensive behaviour against noisy or partially-upgraded peers.
  • reconnect:attempt fires before the backoff delay; reconnect:success fires from onopen after 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 TypedEventEmitter from the same package so listener registration mirrors every other event surface in @suaveplan/drive-webrtc.