Skip to main content

SignallingClient

The lower-level class. Pub/sub + directed messages over WebSocket, ack-correlated, auto-reconnecting. No WebRTC, no per-peer state, no channel-driven discovery.

Use this when:

  • You're building chat, presence, IoT telemetry, AI agent message bus, collaborative cursors, multiplayer lobbies, etc. — anything that's pub/sub at heart.
  • You want to subscribe to multiple channels from one connection (MeteredPeer is tied to one channel).
  • You want to use the SDK as the signalling layer for a custom WebRTC stack that doesn't fit MeteredPeer's 1:N channel model.

If you're building WebRTC and the channel + peer model fits (MeteredPeer), use that instead — it wraps SignallingClient and handles the per-peer plumbing.

Construct

import { SignallingClient } from "@metered-ca/peer";

const client = new SignallingClient({
apiKey: "pk_live_…",
// — OR —
tokenProvider: async () => fetchJwtFromYourBackend(),
});

Exactly one of apiKey or tokenProvider. Constructor doesn't connect — call connect().

Options

OptionTypeDefaultNotes
apiKeystringpk_live_…. Mutually exclusive with tokenProvider.
tokenProvider() => Promise<string>Async function returning an HS256 JWT. Called on first connect AND every reconnect.
urlstring"wss://rms.metered.ca"Validated at construction — must be wss://… (or ws://localhost for dev).
loggerLoggerNoopLoggerUse ConsoleLogger while debugging.
reconnectReconnectOptions \| falseenabledSee below + Reconnect Best Practices.
inactivityTimeoutMsnumber60_000If no frame arrives in the window, the SDK closes-and-reconnects.
tokenProviderTimeoutMsnumber10_000Cap on tokenProvider() resolution. Defends against a hanging mint endpoint.
autoResubscribebooleantrueAfter reconnect, re-subscribe to every channel that was active before the drop.
webSocketFactory(url) => WebSocketLikeglobal WebSocketTest/Node injection.

reconnect options

interface ReconnectOptions {
initialDelayMs?: number; // 500
multiplier?: number; // 2
maxDelayMs?: number; // 30_000
jitterRatio?: number; // 0.2 (±20%)
maxAttempts?: number; // 100 (Infinity for daemon-style)
}

Backoff formula: min(initialDelayMs * multiplier^(attempt-1), maxDelayMs) ± jitterRatio. Each successful welcome resets the attempt counter.

Close-code-aware behaviour — the SDK doesn't blindly retry on every close:

Close codeBehaviour
1001, 1006, 1008, 1009, 4002 (TokenExpired), 4011 (OverMessageRate)Normal backoff
4001 (InvalidToken), 4003 (ChannelNotAuthorized), 4012 (AccountSuspended), 4020 (AdminDisconnect)No retry — caller action required
4010 (OverConcurrentLimit)Forced ≥30 s backoff — retrying every 500 ms against a plan-cap rejection just hammers the server

See Errors & Codes for what each code means + what to do.

After maxAttempts consecutive failures, the client transitions to "closed" and emits a terminal disconnected event. Call connect() again manually if you want to keep trying.

Methods

connect()Promise<void>

Opens the WebSocket, sends the auth handshake, waits for the server welcome.

await client.connect();
console.log(client.state); // "connected"

Rejects with SignallingConnectError if the WS closes before welcome (invalid token, quota rejection). Branch on closeCode:

import { SignallingConnectError, WsCloseCode } from "@metered-ca/peer";

try {
await client.connect();
} catch (e) {
if (e instanceof SignallingConnectError) {
if (e.closeCode === WsCloseCode.InvalidToken) showLoginUI();
else if (e.closeCode === WsCloseCode.OverConcurrentLimit) showQuotaUI();
else throw e;
} else throw e;
}

close(code?, reason?)Promise<void>

Closes the WebSocket. Terminal — no auto-reconnect after this.

await client.close();

Resolves after the underlying socket fires its close event (or after a 1 s timeout). Awaiting it before process.exit(0) lets the TCP FIN flush so the server records a clean disconnect.

subscribe(channel, opts?)Promise<void>

Subscribes to channel. Resolves on server ack.

await client.subscribe("alerts/critical");
await client.subscribe("rooms/lobby", { includeSenderMetadata: true });
OptionTypeDefaultNotes
includeSenderMetadatabooleanfalseIf true, broadcast message events on this channel carry the sender's peerMetadata in fromMetadata. Off by default — metadata can be large and most subscribers don't need per-message sender identity.

Tracked for autoResubscribe. When the WS reconnects, the SDK re-subscribes to this channel automatically.

Rejects on error events with code: "channel_not_authorized" (the JWT's channels claim doesn't allow it), "channel_reserved" (the channel starts with _metered/, _internal/, or _system/), or "channel_limit_exceeded" (you're at 100 channels on this connection).

unsubscribe(channel)Promise<void>

Idempotent — calling for a channel you're not subscribed to is a no-op. Removes from the autoResubscribe set.

publish(channel, data, opts?)Promise<void>

Broadcasts data to every subscriber on channel.

await client.publish("alerts/critical", { sensor: "T-12", value: 92 });

Rejects on:

  • error events with code: "over_message_quota" (period quota exhausted; connection stays open)
  • MeteredPeerOversizedError if data exceeds welcome.maxMessageSize

send(toPeerId, data, opts?)Promise<void>

Direct point-to-point message. Routes via the server (no P2P).

await client.send("peer-xyz", { type: "challenge", nonce: "abc" });

Rejects on error event with code: "peer_not_found" if the target peer isn't online (no current connection on this app).

on(event, handler) · off(event, handler) · once(event, handler)this

Chainable.

Read-only state

client.state  // "idle" | "connecting" | "connected" | "reconnecting" | "closed"

State transitions

                connect()                                close()
idle ─────────────────► connecting ──► connected ──────────────► closed
│ ▲
transient drop │ │ reconnect succeeded
▼ │
reconnecting

│ terminal close OR maxAttempts

closed

Events

EventPayloadWhen it fires
connected{ peerId, serverTime, expiresAt, isReconnect, maxMessageSize, iceServers? }After every successful welcome — initial + reconnects. Use isReconnect to skip "connected!" toasts on reconnects.
disconnected{ code, reason, willReconnect }Every WS close. willReconnect tells you if the SDK is going to retry.
state-change{ from, to }Every state transition.
message{ channel, from, fromMetadata?, data }A peer published to a channel you're subscribed to.
direct{ from, fromMetadata?, data }Someone called send(yourPeerId, data).
presence{ channel, joined, left }Roster change on a subscribed channel. joined / left are arrays of { peerId, metadata? }.
server-error{ code, message?, requestId? }Server-emitted error. Mostly ack failures for a specific request — match requestId to the call you made. The name server-error (rather than error) disambiguates from MeteredPeer.error, which is exception-style ({ err: Error }).
going-away{ retryAfterMs }Server is shutting down for deploy. Informational — the SDK uses its normal reconnect backoff (it does not delay by retryAfterMs automatically). To honor the hint, listen for this event yourself and gate your own delay.
token-provider-error{ consecutiveFailures, err }Your tokenProvider() has failed 3 times in a row. Informational — the SDK keeps retrying. Surface a "please log in again" prompt if appropriate.

connected — what's in the payload

client.on("connected", ({ peerId, serverTime, expiresAt, isReconnect, maxMessageSize, iceServers }) => {
// peerId server-assigned UUID, or your JWT's `sub` claim
// serverTime Unix seconds when the server stamped the welcome (use for clock skew)
// expiresAt JWT's `exp` — Unix seconds, null for pk_ keys (no expiry)
// isReconnect false on first connect, true on every reconnect — useful for UI
// maxMessageSize server-advertised cap; the SDK checks payloads against this
// iceServers from the JWT's metadata.iceServers (TURN creds). undefined for pk_ keys
});

disconnected — what to do

client.on("disconnected", ({ code, reason, willReconnect }) => {
if (willReconnect) {
// SDK will retry. Show a "reconnecting…" banner.
} else {
// Terminal. Either caller-action close code (4001 / 4003 / 4012 / 4020) or maxAttempts exhausted.
// Show a "disconnected" error + retry button.
}
});

willReconnect: false means the SDK gave up retrying. Map close codes to UX in Errors & Codes.

presence — diffing the roster

client.on("presence", ({ channel, joined, left }) => {
for (const p of joined) addToRoster(p.peerId, p.metadata);
for (const p of left) removeFromRoster(p.peerId);
});

The first presence event after a subscribe lists everyone already in the channel — use it to populate your initial roster. Subsequent events are deltas.

If you're showing a "who's here" list with avatars / names, your JWT should mint peerMetadata: { username, avatarUrl } — that's what shows up in p.metadata. See Presence & Chat.

Common pitfalls

  1. Calling publish / send before connect() resolves. Rejects synchronously. Either await connect() first or queue calls behind the connected event.

  2. Assuming state === "connected" means the latest subscribe is active. A subscribe() call resolves on ack — wait for that promise, don't fire-and-forget then immediately publish to the same channel from another piece of code expecting your subscription to be live.

  3. autoResubscribe: false + forgetting to re-subscribe. A common bug is to subscribe once at app boot. After the first reconnect, the SDK has no record of your subscribes (because you turned off tracking) and you silently miss every message. Leave autoResubscribe: true unless you have a specific scoped-subscription pattern.

  4. Treating server-error events as fatal. Most server-error events are per-request — they fail one promise (the request matching requestId) and the connection stays open. The SDK won't auto-disconnect on them.

  5. Confusing SignallingClient's server-error with MeteredPeer's error. SignallingClient emits server-error for server-rejected requests (carries { code, requestId? }). MeteredPeer emits error for unrecoverable internal SDK errors (carries { err: Error }). Different shapes, different sources. The rename keeps the two layers loud.

  6. Trusting from envelope vs nested fields. Same as MeteredPeerfrom (top level) is server-stamped, anything inside data is whatever the sender chose to put there.

See also