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 (
MeteredPeeris 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
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | — | pk_live_…. Mutually exclusive with tokenProvider. |
tokenProvider | () => Promise<string> | — | Async function returning an HS256 JWT. Called on first connect AND every reconnect. |
url | string | "wss://rms.metered.ca" | Validated at construction — must be wss://… (or ws://localhost for dev). |
logger | Logger | NoopLogger | Use ConsoleLogger while debugging. |
reconnect | ReconnectOptions \| false | enabled | See below + Reconnect Best Practices. |
inactivityTimeoutMs | number | 60_000 | If no frame arrives in the window, the SDK closes-and-reconnects. |
tokenProviderTimeoutMs | number | 10_000 | Cap on tokenProvider() resolution. Defends against a hanging mint endpoint. |
autoResubscribe | boolean | true | After reconnect, re-subscribe to every channel that was active before the drop. |
webSocketFactory | (url) => WebSocketLike | global WebSocket | Test/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 code | Behaviour |
|---|---|
| 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 });
| Option | Type | Default | Notes |
|---|---|---|---|
includeSenderMetadata | boolean | false | If 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:
errorevents withcode: "over_message_quota"(period quota exhausted; connection stays open)MeteredPeerOversizedErrorifdataexceedswelcome.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
| Event | Payload | When 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
Calling
publish/sendbeforeconnect()resolves. Rejects synchronously. Eitherawait connect()first or queue calls behind theconnectedevent.Assuming
state === "connected"means the latest subscribe is active. Asubscribe()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.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. LeaveautoResubscribe: trueunless you have a specific scoped-subscription pattern.Treating
server-errorevents as fatal. Mostserver-errorevents are per-request — they fail one promise (the request matchingrequestId) and the connection stays open. The SDK won't auto-disconnect on them.Confusing SignallingClient's
server-errorwith MeteredPeer'serror. SignallingClient emitsserver-errorfor server-rejected requests (carries{ code, requestId? }). MeteredPeer emitserrorfor unrecoverable internal SDK errors (carries{ err: Error }). Different shapes, different sources. The rename keeps the two layers loud.Trusting
fromenvelope vs nested fields. Same asMeteredPeer—from(top level) is server-stamped, anything insidedatais whatever the sender chose to put there.
See also
MeteredPeer— the higher-level class with WebRTC + channel-driven peer discovery- Errors & Codes — every error / close code + what to do
- Reconnect Best Practices
- Authentication —
apiKeyvstokenProvider, JWT claims, peerMetadata