Skip to main content

MeteredPeer

The high-level class. One MeteredPeer instance = one signalling connection + one channel + N RTCPeerConnections (one per remote peer in the channel).

Use this for WebRTC video calls, screen share, peer-to-peer games, multiplayer presence + chat. If you only need pub/sub (no media, no per-peer state), SignallingClient is smaller and simpler.

Construct

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

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

Exactly one of apiKey or tokenProvider. The constructor doesn't connect — call join() for that.

Options

OptionTypeDefaultNotes
apiKeystringpk_live_… for browser use. Mutually exclusive with tokenProvider.
tokenProvider() => Promise<string>Async function returning an HS256 JWT. Called on first connect AND every reconnect (auto-refresh).
urlstring"wss://rms.metered.ca"Server URL. The SDK appends /v1. Throws TypeError on construction if invalid.
loggerLoggerNoopLoggerUse ConsoleLogger during development, your own implementation in prod.
reconnectReconnectOptions \| falseenabledSee Reconnect Best Practices. false disables — rarely what you want.
inactivityTimeoutMsnumber60_000If no frame arrives in this window, the SDK closes-and-reconnects. Aligned with the server's 30 s ping + 10 s grace.
tokenProviderTimeoutMsnumber10_000Cap on how long the SDK waits for tokenProvider() to resolve.
autoResubscribebooleantrueAfter reconnect, re-issue subscribes for every channel that was active before the drop. Leave on unless you have a specific reason.
rtcPeerConnectionFactory(cfg) => RTCPeerConnectionLikeglobal RTCPeerConnectionTest injection. In Node 18–21, pass cfg => new (require("@roamhq/wrtc").RTCPeerConnection)(cfg).
webSocketFactory(url) => WebSocketLikeglobal WebSocketTest injection. In Node 18–21, pass url => new (require("ws"))(url).

MeteredPeer extends SignallingClient's options — anything documented there applies here too.

Methods

join(channel, opts?)Promise<void>

Connects (if not connected) and subscribes to channel. Resolves when the server acks the subscribe.

await peer.join("room-42");
console.log(peer.peerId); // server-assigned, or your JWT's sub claim

Important — peers don't arrive synchronously with join. The server's initial presence event for this channel arrives on the network after the subscribe ack, then the SDK translates it into peer-joined events for each existing peer. By the time await peer.join(...) returns, peer.remotePeers is typically still empty; populate your UI from the peer-joined event handler, not from a snapshot taken right after join.

peer.on("peer-joined", ({ peer: remote }) => addTile(remote));
await peer.join("room-42");
// peer.remotePeers is likely [] here; existing peers arrive as peer-joined events
// over the next ~50–200 ms.
OptionTypeDefaultNotes
includeSenderMetadatabooleanfalseOpt in to receiving each broadcast sender's peerMetadata on data events. Off by default since most use cases only need it via presence.

Rejects with:

  • MeteredPeerSendError({ code: "reserved_channel" }) if channel starts with _metered/, _internal/, or _system/ (server-reserved prefixes).
  • Synchronously if the state isn't idle (already joining, already joined, or closed) — construct a new MeteredPeer if you've called close().

Reconnect note: if the signalling WS drops and reconnects, the SDK re-subscribes to this channel for you (assuming autoResubscribe: true). You don't call join() again.

close(reason?)Promise<void>

Closes every RemotePeer, closes the WebSocket, transitions to "closed". Terminal — once closed, the same instance can't rejoin. Construct a fresh MeteredPeer.

await peer.close("user logged out");
peer = new MeteredPeer(opts);
await peer.join(channel);

Calling close() more than once is safe (idempotent). Pending operations reject with their underlying close error.

Why close and not disconnect? The method is one-way terminal — it closes the WS and tears down all per-peer state. The name matches WebSocket.close() / RTCPeerConnection.close() and leaves naming space for a future channel-scoped leave() (not implemented in 1.0).

send(data)Promise<void>

Broadcasts data to every peer in the joined channel. Server-routed via the wire-protocol publish frame.

await peer.send({ type: "chat", text: "hi everyone" });

Use this for any payload that should reach everyone in the room — chat, presence pings, "user typing" indicators, control signals.

sendTo(peerId, data)Promise<void>

Sends data directly to one peer in the channel. Server-routed via the wire-protocol send frame.

await peer.sendTo(otherPeer.id, { whisper: "private" });

Use this for one-to-one messages — private DMs, per-peer game state, direct queries.

send and sendTo are distinct methods (not an overload). Two distinct names make the routing intent loud — peer.send(userId, payload) vs peer.send(payload) would otherwise look identical at the call site even though they route differently.

Routing trade-offs (applies to both send and sendTo)

Both are server-routed, not P2P. Same trade-off as the protocol's publish / send frames:

Server-routed (send / sendTo)P2P DataChannel (remote.pc.createDataChannel)
Works before ICE completesYesNo — wait for state === "connected"
Counts against signalling quotaYesNo
LatencyServer hop (~10–50 ms)P2P (~5–30 ms typical)
Survives WebRTC failureYes (signalling and media are separate)No
Max payloadwelcome.maxMessageSize (server default 64 KB)Browser caps (~16 KB practical for ordered DCs)

For low-latency P2P data, see Data Channels & Low Latency.

Both methods reject with:

  • MeteredPeerSendError({ code: "not_joined" }) if you call before join() resolves.
  • MeteredPeerSendError({ code: "self_send" }) (from sendTo only) if peerId === peer.peerId.
  • MeteredPeerSendError({ code: "invalid_args" }) if data === undefined or peerId is malformed.
  • MeteredPeerOversizedError if the payload exceeds welcome.maxMessageSize. Branch on instanceof to show a "message too big" UI:
try {
await peer.send(huge);
} catch (e) {
if (e instanceof MeteredPeerOversizedError) {
toast(`Message too big (${e.size} bytes, max ${e.cap})`);
} else throw e;
}

addStream(stream, metadata?)void

Attaches every track in stream to every current peer and every peer that joins later. Sugar over addTrack(t, stream, metadata) per track in the stream.

Idempotency is per track, not per stream — calling addStream with a stream whose tracks are already tracked is a no-op. If you'd called addTrack(t, otherStream, otherMetadata) first and then addStream(streamThatAlsoHasT, meta), the original (otherStream, otherMetadata) wins for t and the rest of the new stream's tracks are added with meta.

const local = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
peer.addStream(local, { role: "camera", label: "front cam" });

The optional metadata rides over the signalling channel and arrives on the receiver's stream-added event and track event. Use it to label streams so peers know which is the camera vs the screen share vs an uploaded file (see StreamMetadata below).

Tip — attach before join() if you can. Streams added before join() ride along in the first SDP offer to each peer (one round trip). Streams added after join() trigger a renegotiation cycle per peer (two extra round trips). For the React example, attach after join() only because the camera permission prompt UX makes sense at that point.

Reconnect note: the SDK remembers what you added (tracks + their metadata). On reconcile, both your media AND its metadata reattach to each survivor's new RTCPeerConnection automatically — you don't re-addStream after a reconnect.

addTrack(track, stream?, metadata?)void

Lower-level primitive. addStream is sugar over this. Use directly when you want fine-grained control — e.g. adding tracks that aren't grouped into a MediaStream, or attaching the same audio track to multiple logical "streams".

const audioTrack = local.getAudioTracks()[0];
const videoTrack = local.getVideoTracks()[0];
peer.addTrack(audioTrack, local, { role: "voice" });
peer.addTrack(videoTrack, local, { role: "camera", label: "front" });

Omit stream for an unaffiliated track — the receiver gets it on the track event but doesn't fire stream-added (no stream to surface).

Idempotent on the same track reference — calling twice is a no-op (the first call's stream and metadata win). To re-tag an already-tracked track, call removeTrack(t) then addTrack(t, ...) with new metadata.

State gates:

  • "leaving" or "closed" — throws MeteredPeerStateError({ code: "invalid_state", method: "addTrack", currentState }). The instance is terminal; construct a fresh MeteredPeer.
  • "reconnecting" — the track is added to the SDK's tracked set but fan-out is deferred until the reconcile completes (the track lands on each survivor's new RTCPeerConnection). No error.
  • "idle" or "joining" — the track is tracked and will fan out to every peer that joins.

Track sharing across streams is rejected: adding the same track reference with a different stream argument throws MeteredPeerStateError({ code: "track_already_attached", method: "addTrack" }). WebRTC's pc.addTrack(t, s1, s2) multi-stream membership is on the v1.1 roadmap — until then, fail loudly so customers see the limit rather than having one stream-association silently dropped.

Metadata size: if the StreamMetadata bag would exceed welcome.maxMessageSize when serialized for the wire, addTrack throws MeteredPeerOversizedError before inserting into the tracking map (so over-cap metadata can't loop through reconcile re-sends and amplify the problem). Keep metadata small (a few KB at most).

removeStream(stream)void

Detaches the stream's tracks from every peer and stops tracking it for future joins. No-op if you never added it. Sugar over removeTrack(t) per track in the stream. Doesn't stop the underlying tracks — call track.stop() yourself if you want the camera light to turn off.

removeTrack(track)void

Lower-level counterpart to addTrack. Detaches one track from every peer and stops tracking it for future joins. No-op if not tracked. Doesn't stop the underlying track.

getStreamMetadata(stream)StreamMetadata \| undefined

Returns the metadata you passed for a local stream (the one you addStream'd). Returns undefined if the stream wasn't added, or if you passed it without metadata. Useful for round-tripping your own metadata in the UI (e.g. "the user just toggled the screen share off — what was its label?").

const meta = peer.getStreamMetadata(localScreen);
console.log(meta?.label); // "shared screen"

This is a local lookup — it tells you what you attached. To get the remote peer's metadata for a stream they sent you, read it off the stream-added event payload or via remote.metadata for peer-level metadata.

getTrackMetadata(track)StreamMetadata \| undefined

Same as getStreamMetadata but for an individual track. Returns the metadata you passed for that specific track. If you used addStream (sugar), every track in the stream shares the same metadata bag.

replaceTrack(oldTrack, newTrack)Promise<void>

Swap a track without renegotiating SDP (camera → screen share, mic → file). Fans the swap out to every peer.

const screen = await navigator.mediaDevices.getDisplayMedia({ video: true });
await peer.replaceTrack(cameraTrack, screen.getVideoTracks()[0]);

newTrack may be null to mute that sender across all peers. oldTrack must not be null.

Tracking-map sync: replaceTrack updates the SDK's internal tracking map so future newcomers and reconcile cycles use newTrack, not the stale oldTrack. Without this, the canonical camera ↔ screen swap would silently revert on any WS blip. Metadata is also re-keyed under newTrack.id and re-sent. replaceTrack(oldTrack, null) drops the entry entirely (the silenced sender is no longer tracked, and won't fan out to newcomers).

The map update only happens when at least one peer succeeded, OR when there were no peers to swap on (zero-peer case — you're alone in the channel). On total fanout failure (every peer's replaceTrack rejected, throwing MeteredPeerReplaceTrackError), the map stays under oldTrack to match the live sender state. This way, the tracking map never lies about what's actually being sent to peers.

Partial-failure handling. If the swap succeeds on some peers and fails on others (rare, but possible during reconnects or codec mismatches), the promise rejects with MeteredPeerReplaceTrackError carrying both lists:

try {
await peer.replaceTrack(oldCam, newCam);
} catch (e) {
if (e instanceof MeteredPeerReplaceTrackError) {
// e.succeeded: peerIds already on newCam — leave them alone
// e.failed: [{ peerId, err }] — retry just these
for (const { peerId, err } of e.failed) {
console.warn(`replaceTrack failed for ${peerId}`, err);
}
} else throw e;
}

Throws synchronously if called during "reconnecting", "leaving", or "closed". Wait for state === "joined" before retrying.

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

Chainable event subscriptions. Identical semantics to Node's EventEmitter (handlers run synchronously in registration order).

StreamMetadata

The bag you pass to addStream / addTrack and that arrives at the receiver on stream-added + track events.

interface StreamMetadata {
/** Conventional role hint: "camera" | "screen" | "canvas" | "file" | custom. */
role?: string;
/** Human-readable label, e.g. "front cam", "shared screen". */
label?: string;
/** Customer-defined fields. */
[key: string]: unknown;
}

Convention only — the SDK doesn't validate role / label. Use any keys your app needs. Remember that this is server-routed payload over the signalling channel — the metadata bag is sent as a direct message and so counts against welcome.maxMessageSize (server default 64 KB) like any other directed send. Keep it small for low call-setup latency.

Trust model for StreamMetadata

Per-track / per-stream metadata is sender-stamped and server-routed verbatim — NOT JWT-signed. A peer in your channel can put whatever it wants in the metadata bag it sends, and your track / stream-added handlers will see those exact values.

Use it for hints that help your UI lay out streams (camera vs screen, label text, custom flags). Do NOT use it for authoritative identity, authorization decisions, or anything you'd treat as security-relevant. For server-verified identity, use the JWT's peerMetadata claim (surfaces on presence + direct-from-sender data events as senderMetadata / remote.metadata).

Receiver-side flood defense: the SDK caps each remote peer's track-metadata cache at 512 entries; on overflow, the oldest entries are evicted (FIFO). This bounds the memory cost when an adversarial peer floods unique track IDs.

Wire transport (good to know, not required reading)

Per-track metadata travels over the same signalling WS as direct messages, under a reserved key (__meteredTrackMeta). The SDK intercepts these on the receive side and stashes them; they never surface on the data event. Metadata is sent BEFORE the matching SDP offer for the track so order is preserved — receivers have the bag stashed by the time their track event fires.

This means __meteredTrackMeta is a reserved key inside the data payload of peer.sendTo — if you happen to use it for your own purposes, the SDK warns via your logger and the receiver will interpret it as a metadata update. Pick a different key in your own payloads.

Simultaneous camera + screen share — the canonical pattern

const cam = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
peer.addStream(cam, { role: "camera", label: "front cam" });

const screen = await navigator.mediaDevices.getDisplayMedia({ video: true });
peer.addStream(screen, { role: "screen", label: "shared window" });

// Receiver:
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("stream-added", ({ stream, metadata }) => {
if (metadata?.role === "camera") attachToFaceTile(stream);
if (metadata?.role === "screen") attachToScreenTile(stream);
});
});

This replaces the simple-peer / PeerJS pattern of "replace the video track when switching to screen share" — instead of swapping, you send both. Peers see them as two separate streams and lay them out in their UI accordingly.

Read-only state

peer.state         // "idle" | "joining" | "joined" | "reconnecting" | "leaving" | "closed"
peer.peerId // string | null (null until welcome arrives)
peer.channel // string | null (null until joined)
peer.remotePeers // RemotePeer[] — snapshot, mutating the array does nothing

State transitions

            join()                                  close()
idle ──────────► joining ──► joined ──────────────────────► leaving ──► closed
│ ▲
│ │ reconnect succeeded
▼ │
reconnecting

│ terminal close code

closed

What each state means for your UI:

StateShow your userSend / receive?
idleNo
joining"connecting…"No
joinednormal app UIYes
reconnecting"reconnecting…" banner (best-practices)Buffered locally; resumes on success
leaving"disconnecting…"No
closed"disconnected" + retry buttonNo (terminal; construct a fresh peer)

Events

peer.on(eventName, handler);
EventPayloadFires
joined{ peerId, channel }Once, after join() resolves. Use this to read your final peerId if you didn't claim one in your JWT.
left{ peerId, channel, reason? }Once, when you reach "closed". peerId and channel are snapshots of what your peer was using before close (both null if close() fired before welcome or before subscribe-ack). Customers writing teardown logic don't have to stash them earlier.
state-change{ from, to }Every state transition. Drives your reconnect UI.
peer-joined{ peer: RemotePeer }Another peer joined the channel. Use this to wire up track / state-change listeners on the remote.
peer-left{ peer: RemotePeer }Peer unsubscribed or disconnected. Clean up their <video> element here.
data{ senderPeerId, data, kind: "broadcast" \| "direct", senderMetadata? }Customer payload arrived. kind discriminates: "broadcast" for channel-wide send(data), "direct" for one-to-one sendTo(myId, data). senderMetadata is the sender's peerMetadata from their JWT — only present when you opted in via join(channel, { includeSenderMetadata: true }). RTC signals are NEVER surfaced here. Only fires for senders currently in your channel (i.e., peers you've seen via presence — see "Channel scope" below).
error{ err: Error }Unified surface for fatal conditions the customer must act on — see "What fires error" below. The SDK forwards: terminal WS close codes (4001/4002/4003/4012/4020), fatal server-error frames, and tokenProvider failures past the retry threshold. err.name carries a symbolic code (e.g. "invalid_token", "account_suspended", "TokenProviderError") for branching.

The data event — channel scope

The data event only fires for senders the SDK has seen via presence on your current channel. If another tenant peer in a different channel sends you a direct message (the wire protocol allows it within a tenant), the SDK drops it — your data listener won't fire and you won't see the message.

This keeps data consistent with peer-joined / peer-left: every data event is from a peer you've also seen join. Customers who want unscoped direct messaging (e.g. cross-channel "system" peers that DM you) should use SignallingClient directly, where the wire-protocol's permissive semantics are intentional.

The data event — branching on kind

Use kind to handle broadcasts and direct sends differently:

peer.on("data", ({ senderPeerId, data, kind }) => {
if (kind === "broadcast") {
appendToRoomChat(senderPeerId, data);
} else {
appendToPrivateThread(senderPeerId, data);
}
});

Without kind, you'd have to maintain an external "is this peer DM'ing me right now" flag — both stateful and error-prone. The discriminator is server-stamped and trustworthy.

The data event — senderMetadata

If you joined with { includeSenderMetadata: true }, broadcasts arrive with the sender's peerMetadata (from their JWT) on senderMetadata. Direct sends always carry it when the sender's JWT had peerMetadata (you don't opt in for directs).

await peer.join("room-42", { includeSenderMetadata: true });

peer.on("data", ({ senderPeerId, senderMetadata, data }) => {
const name = senderMetadata?.username ?? "Anonymous";
appendMessage(name, data.text);
});

senderMetadata is untrusted peer-supplied input — your backend signed it for the sender, but a peer with a leaked JWT keeps using it. Use the JWT's server-enforced channel patterns for access decisions, not senderMetadata fields.

What fires error

The single error event consolidates fatal conditions that previously required customers to wire separate listeners on the underlying SignallingClient (disconnected, server-error, token-provider-error). Three things fire it:

SourceWhenerr.name
Terminal WS close code 4001Invalid JWT"invalid_token"
Terminal WS close code 4002 (no auto-retry left)Token expired and tokenProvider can't refresh"token_expired"
Terminal WS close code 4003JWT doesn't authorize the channel"channel_not_authorized"
Terminal WS close code 4012Account suspended (billing)"account_suspended"
Terminal WS close code 4020Admin kicked you via REST API"admin_disconnect"
Fatal server-error frameServer-rejected request with account_suspended / channel_not_authorized / action_not_permitted / invalid_token / token_expired codethe server's code string
tokenProvider failed past retry thresholdCustomer's mint endpoint kept rejecting (3+ consecutive)"TokenProviderError"

Non-fatal server errors (rate limits, transient failures correlated to a specific request) reject the related promise and do NOT fire error. The connection stays open.

Pattern:

peer.on("error", ({ err }) => {
switch (err.name) {
case "invalid_token":
case "token_expired":
showReloginPrompt();
break;
case "account_suspended":
showBillingUI();
break;
case "admin_disconnect":
showKickedUI();
break;
case "channel_not_authorized":
showAccessDeniedUI();
break;
case "TokenProviderError":
showAuthFlowBroken();
break;
default:
reportToSentry(err);
}
});

The data event — security note

senderPeerId is the server-stamped envelope sender. Trust it.

Anything inside the data payload is whatever the sender chose to put there. If a peer publishes { from: "alice", message: "..." }, the SDK doesn't validate the inner from. Use senderPeerId, not data.from, to identify who sent what.

// ❌ Don't trust data.from — peers can lie about it
peer.on("data", ({ data }) => addMessage(data.from, data.text));

// ✅ Trust senderPeerId — it's stamped by the server
peer.on("data", ({ senderPeerId, data }) => addMessage(senderPeerId, data.text));

The SDK also keeps JSON.parse output as-is. If you Object.assign(myConfig, data), a hostile peer can pollute your prototype with { "__proto__": { isAdmin: true } }. Copy specific known keys instead, or use Object.create(null) as a merge target.

What survives a reconnect

Customer-held referenceSurvives signalling-WS reconnect?Notes
peer instanceYesSame object across the drop.
peer.remotePeers array contentsYes for survivorspeer-joined / peer-left fires for newcomers / leavers respectively.
RemotePeer reference you heldYesSame === object. The SDK silently swaps the underlying PC.
Local MediaStream added via addStreamYesSDK reattaches automatically to each survivor's new PC.
remote.pc reference you held in a variableNoAfter reconcile, remote.pc returns a NEW PC; your old variable points at a closed one.
RTCDataChannel from remote.pc.createDataChannel(...)NoTied to the old PC. Re-open after state-change to "connected". See Data Channels & Low Latency.

Reconnect Best Practices has the full playbook — read it before going to production.

Common pitfalls

  1. Calling peer.send before join() resolves. Rejects with MeteredPeerSendError("not_joined"). Either await peer.join(...) first or queue your sends behind the joined event.

  2. Assuming peer.send / peer.sendTo is P2P. Both are server-routed. If you're moving thousands of messages per second between peers (game state, telemetry), open a DataChannel instead. Check the Routing trade-offs table above.

  3. Holding remote.pc across a reconnect. The reference goes stale. Either re-read remote.pc every time you need it, OR re-wire on each state-change"connected".

  4. addStream after join without expecting renegotiation. Each post-join addStream adds two SDP round-trips per peer. Visible as a ~200–600 ms delay before peers see the new track. Attach before join when possible.

  5. Forgetting peer.close() is terminal. A common pattern from simple-peer / PeerJS is "destroy and replay" on errors. Here, you close() and construct a new MeteredPeer.

  6. Trusting data.from instead of senderPeerId. Sender-spoofed messages bypass your access checks. Always use senderPeerId.

See also