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
| Option | Type | Default | Notes |
|---|---|---|---|
apiKey | string | — | pk_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). |
url | string | "wss://rms.metered.ca" | Server URL. The SDK appends /v1. Throws TypeError on construction if invalid. |
logger | Logger | NoopLogger | Use ConsoleLogger during development, your own implementation in prod. |
reconnect | ReconnectOptions \| false | enabled | See Reconnect Best Practices. false disables — rarely what you want. |
inactivityTimeoutMs | number | 60_000 | If no frame arrives in this window, the SDK closes-and-reconnects. Aligned with the server's 30 s ping + 10 s grace. |
tokenProviderTimeoutMs | number | 10_000 | Cap on how long the SDK waits for tokenProvider() to resolve. |
autoResubscribe | boolean | true | After reconnect, re-issue subscribes for every channel that was active before the drop. Leave on unless you have a specific reason. |
rtcPeerConnectionFactory | (cfg) => RTCPeerConnectionLike | global RTCPeerConnection | Test injection. In Node 18–21, pass cfg => new (require("@roamhq/wrtc").RTCPeerConnection)(cfg). |
webSocketFactory | (url) => WebSocketLike | global WebSocket | Test 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.
| Option | Type | Default | Notes |
|---|---|---|---|
includeSenderMetadata | boolean | false | Opt 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" })ifchannelstarts with_metered/,_internal/, or_system/(server-reserved prefixes).- Synchronously if the state isn't
idle(already joining, already joined, or closed) — construct a newMeteredPeerif you've calledclose().
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 completes | Yes | No — wait for state === "connected" |
| Counts against signalling quota | Yes | No |
| Latency | Server hop (~10–50 ms) | P2P (~5–30 ms typical) |
| Survives WebRTC failure | Yes (signalling and media are separate) | No |
| Max payload | welcome.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 beforejoin()resolves.MeteredPeerSendError({ code: "self_send" })(fromsendToonly) ifpeerId === peer.peerId.MeteredPeerSendError({ code: "invalid_args" })ifdata === undefinedorpeerIdis malformed.MeteredPeerOversizedErrorif the payload exceedswelcome.maxMessageSize. Branch oninstanceofto 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"— throwsMeteredPeerStateError({ code: "invalid_state", method: "addTrack", currentState }). The instance is terminal; construct a freshMeteredPeer."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 newRTCPeerConnection). 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:
| State | Show your user | Send / receive? |
|---|---|---|
idle | — | No |
joining | "connecting…" | No |
joined | normal app UI | Yes |
reconnecting | "reconnecting…" banner (best-practices) | Buffered locally; resumes on success |
leaving | "disconnecting…" | No |
closed | "disconnected" + retry button | No (terminal; construct a fresh peer) |
Events
peer.on(eventName, handler);
| Event | Payload | Fires |
|---|---|---|
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:
| Source | When | err.name |
|---|---|---|
| Terminal WS close code 4001 | Invalid 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 4003 | JWT doesn't authorize the channel | "channel_not_authorized" |
| Terminal WS close code 4012 | Account suspended (billing) | "account_suspended" |
| Terminal WS close code 4020 | Admin kicked you via REST API | "admin_disconnect" |
Fatal server-error frame | Server-rejected request with account_suspended / channel_not_authorized / action_not_permitted / invalid_token / token_expired code | the server's code string |
tokenProvider failed past retry threshold | Customer'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 reference | Survives signalling-WS reconnect? | Notes |
|---|---|---|
peer instance | Yes | Same object across the drop. |
peer.remotePeers array contents | Yes for survivors | peer-joined / peer-left fires for newcomers / leavers respectively. |
RemotePeer reference you held | Yes | Same === object. The SDK silently swaps the underlying PC. |
Local MediaStream added via addStream | Yes | SDK reattaches automatically to each survivor's new PC. |
remote.pc reference you held in a variable | No | After reconcile, remote.pc returns a NEW PC; your old variable points at a closed one. |
RTCDataChannel from remote.pc.createDataChannel(...) | No | Tied 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
Calling
peer.sendbeforejoin()resolves. Rejects withMeteredPeerSendError("not_joined"). Eitherawait peer.join(...)first or queue your sends behind thejoinedevent.Assuming
peer.send/peer.sendTois 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.Holding
remote.pcacross a reconnect. The reference goes stale. Either re-readremote.pcevery time you need it, OR re-wire on eachstate-change→"connected".addStreamafterjoinwithout expecting renegotiation. Each post-joinaddStreamadds two SDP round-trips per peer. Visible as a ~200–600 ms delay before peers see the new track. Attach beforejoinwhen possible.Forgetting
peer.close()is terminal. A common pattern fromsimple-peer/PeerJSis "destroy and replay" on errors. Here, youclose()and construct a newMeteredPeer.Trusting
data.frominstead ofsenderPeerId. Sender-spoofed messages bypass your access checks. Always usesenderPeerId.
See also
SignallingClient— the lower-level classMeteredPeerwrapsRemotePeer— whatpeer-joinedandpeer.remotePeersgive youDataChannel— backpressure-aware wrapper for the P2P escape hatch- Errors & Codes — every error class, every close code, what to do about each
- Reconnect Best Practices — required production reading