# @metered-ca/peer — JavaScript / TypeScript SDK reference
For developers using the `@metered-ca/peer` npm package — this file documents the SDK surface end-to-end. The SDK wraps the Realtime Messaging wire protocol with framing, ack correlation, auto-reconnect, perfect-negotiation WebRTC, multi-stream + per-track metadata, and TURN credential injection. **You should not need to read the wire-protocol layer to build with the SDK** — but if you do, see [the raw-WebSocket reference](https://www.metered.ca/docs/llms-realtime-messaging-raw-websocket.txt) (also useful when interoperating with non-JS clients).
- npm: https://www.npmjs.com/package/@metered-ca/peer
- Docs: https://www.metered.ca/docs/realtime-messaging/sdk-javascript/
- Dashboard: https://dashboard.metered.ca | Pricing: https://www.metered.ca/pricing
---
## Install
```
npm install @metered-ca/peer
```
ESM + CJS + UMD bundles. Full UMD ~13 KB gzipped (WebRTC included), within a 30 KB budget. Zero runtime dependencies. Works in browser + Node 18+.
For browser `
```
---
## Two classes
Pick by what you're building:
| You're building… | Use | Why |
|---|---|---|
| **WebRTC video / voice call**, screen share, peer-to-peer game | `MeteredPeer` | Joins a channel, discovers peers via presence, manages each `RTCPeerConnection`, fans `MediaStream`s out automatically, recovers ICE on network changes |
| **Live chat with presence**, classroom roster, multiplayer lobby | `MeteredPeer` | The `peer-joined` / `peer-left` events drive your UI; `peer.send(data)` broadcasts or directs |
| **IoT telemetry**, MQTT-style pub/sub, AI agent message bus, collaborative cursors | `SignallingClient` | Pub/sub + directed messages without the WebRTC overhead. Smaller, simpler, multiple channels per connection |
Not sure? Start with `MeteredPeer`. Drop to `SignallingClient` later if you find you don't need the channel-+-peer model.
---
## Quick start — `MeteredPeer` (WebRTC + presence + chat)
```ts
import { MeteredPeer } from "@metered-ca/peer";
const peer = new MeteredPeer({
// exactly one of:
apiKey: "pk_live_…", // pk_ keys must have `Send` ticked in the dashboard
// for WebRTC; off by default. See "pk_ gotcha" below.
tokenProvider: async () => fetchJwt(),
});
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("stream-added", ({ stream, metadata }) => {
// Preferred over `track` — fires once per remote MediaStream.
attachToVideoTile(stream, metadata?.role);
});
});
peer.on("peer-left", ({ peer: remote }) => removeVideoTile(remote.id));
peer.on("data", ({ senderPeerId, data, kind, senderMetadata }) => {
// `senderPeerId` is server-stamped — trust it.
// `kind` discriminates broadcast vs direct.
// `senderMetadata` requires JWT path + `includeSenderMetadata` on join for broadcasts.
});
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
peer.addStream(localStream, { role: "camera", label: "front cam" });
await peer.join("room-42");
await peer.send({ chat: "hi everyone" }); // broadcast to channel
await peer.sendTo(otherPeerId, { hi: "you" }); // directed to one peer
```
**Public methods on `MeteredPeer`:**
- `join(channel, opts?)` — `opts: { includeSenderMetadata?: boolean }`
- `close(reason?)` — terminal. To reconnect after closing, construct a new `MeteredPeer`.
- `send(data)` — broadcast to channel
- `sendTo(peerId, data)` — directed to one peer
- `addStream(stream, metadata?)` / `removeStream(stream)`
- `addTrack(track, stream?, metadata?)` / `removeTrack(track)`
- `replaceTrack(oldTrack, newTrack)` — swap without renegotiation
- `getStreamMetadata(stream)` / `getTrackMetadata(track)` — local lookups for what YOU attached
- `on(event, handler)` / `off(event, handler)` / `once(event, handler)`
**Read-only state:** `peer.state`, `peer.peerId`, `peer.channel`, `peer.remotePeers` (snapshot).
**pk_ key gotcha — `Send` permission is required for WebRTC.** When you create a publishable key in the dashboard, `Subscribe` / `Publish` / `Presence` are checked by default but `Send` is OFF (because the key lives in users' browsers and `Send` lets anyone holding it direct-message any peer). MeteredPeer's WebRTC layer uses the wire protocol's `send` op under the hood to exchange SDP and ICE between peers — without it, `peer.join()` and `peer.addStream()` complete, presence fires, but `peer-joined → stream-added` never fires because no `RTCPeerConnection` ever negotiates. **For pk_ + WebRTC, tick `Send` when creating the key.** For pk_ + pub/sub-only (`SignallingClient`, no WebRTC), `Send` is only required if you call `client.send(peerId, ...)` directly.
---
## Quick start — `SignallingClient` (pub/sub only)
```ts
import { SignallingClient } from "@metered-ca/peer";
const client = new SignallingClient({ apiKey: "pk_live_…" });
await client.connect();
await client.subscribe("alerts/critical");
client.on("message", ({ channel, from, data }) => console.log(from, data));
await client.publish("alerts/critical", { sensor: "T-12", value: 92 });
// Direct point-to-point send
await client.send(otherPeerId, { type: "challenge", nonce: "abc" });
```
**Public methods on `SignallingClient`:**
- `connect()` / `close(code?, reason?)`
- `subscribe(channel, opts?)` / `unsubscribe(channel)`
- `publish(channel, data, opts?)` / `send(toPeerId, data, opts?)`
- `on` / `off` / `once`
**Read-only state:** `client.state`.
**Naming asymmetry vs `MeteredPeer`:**
- SignallingClient keeps `client.send(toPeerId, data)` (no `sendTo` rename) — wire-faithful to the JSON `send` frame.
- SignallingClient's directed-message event is `direct { from, ... }` (not `senderPeerId`) — same wire-faithfulness rationale.
- MeteredPeer earns the more verbose names because it merges sources; SignallingClient stays lower-level.
---
## Auth modes
Two paths. Pick by where your code runs.
### Path 1 — `apiKey` (publishable key)
Direct browser use. Fixed scope from dashboard. No backend required.
```ts
const peer = new MeteredPeer({ apiKey: "pk_live_…" });
```
Set `allowedOrigins` on the key from the dashboard to lock it to your domain. Use for static sites, prototypes, anywhere per-user scoping isn't needed.
**For WebRTC: tick `Send` when creating the key.** It's off by default for publishable keys (the dashboard's amber warning explains why — anyone holding the key could direct-message any peer). MeteredPeer's WebRTC layer needs `Send` because the wire protocol's `send` operation carries SDP and ICE between peers. The dashboard's amber callout specifically allows enabling it for static-page browser-to-browser WebRTC, which is this exact use case.
**Limitations vs the JWT path:**
- Server assigns random UUID as `peerId` (no stable per-user identity)
- No `peerMetadata` (no JWT to carry it)
- `iceServers` come from auto-injection (when the key has "Auto-inject TURN" enabled, default) — you do not need to fetch TURN credentials separately. See [WebRTC No-Backend guide](https://www.metered.ca/docs/realtime-messaging/sdk-javascript/guides/webrtc-no-backend) for the full pattern.
### Path 2 — `tokenProvider` (sk_-minted JWT)
Backend mints an HS256 JWT signed with `sk_live_…`. SDK calls your provider on first connect AND every reconnect (auto-refresh).
```ts
const peer = new MeteredPeer({
tokenProvider: async () => {
const r = await fetch("/api/mint-realtime-token");
if (!r.ok) throw new Error("mint failed");
return (await r.json()).token;
},
});
```
### Minting JWTs server-side (Node.js)
```js
const jwt = require("jsonwebtoken");
app.get("/api/mint-realtime-token", requireAuth, async (req, res) => {
// Optional: fetch TURN creds for WebRTC use cases
const turnCreds = await fetchTurnForUser(req.user.id);
const token = jwt.sign(
{
sub: req.user.id, // becomes peerId
channels: [`app_${req.appId}/call-*`], // wildcard
permissions: ["publish", "subscribe", "presence", "send"],
metadata: { iceServers: turnCreds }, // welcome-only (TURN)
peerMetadata: { username: req.user.name, avatarUrl: req.user.avatar }, // visible to other peers
exp: Math.floor(Date.now() / 1000) + 3600,
},
process.env.SK_SECRET,
{ algorithm: "HS256", header: { alg: "HS256", kid: process.env.SK_ID } },
);
res.json({ token });
});
```
**Critical:** `tokenProvider` is called on every reconnect — your endpoint must return a FRESH JWT each call, or cache with a TTL well under the JWT's `exp`. Returning a stale JWT triggers 4002 → re-mint → stale JWT loop.
### JWT claims reference
| Claim | Required | What it does |
|---|---|---|
| `sub` | yes | Becomes the peer's `peerId`. Up to 128 chars. Use your user ID. |
| `exp` | yes | Unix seconds. ≤ 24h. |
| `channels` | yes | Array of wildcard patterns (`*` = one segment, `**` = multi-segment). |
| `permissions` | yes | Subset of `["publish", "subscribe", "presence", "send"]`. |
| `metadata` | no | Up to 8 KB. Server returns it on `welcome`. WebRTC: put `iceServers` here. |
| `peerMetadata` | no | Up to 4 KB. Server stamps onto presence + direct + opt-in channel messages. |
### Or skip the JWT signing — use the REST API
```js
const { token } = await fetch("https://rms.metered.ca/v1/tokens", {
method: "POST",
headers: { Authorization: `Bearer ${SK_SECRET}`, "Content-Type": "application/json" },
body: JSON.stringify({
peerId, channels, permissions, expiresInSec: 3600,
metadata: { iceServers }, peerMetadata: { username, avatarUrl },
}),
}).then(r => r.json());
```
---
## Multi-stream + per-track metadata
`addStream` and `addTrack` accept an optional `StreamMetadata` bag:
```ts
interface StreamMetadata {
role?: string; // convention: "camera" | "screen" | "canvas" | "file" | custom
label?: string; // human-readable, e.g. "front cam", "shared screen"
[key: string]: unknown;
}
```
The bag arrives at the receiver on `track` and `stream-added` events. Use it to label streams so receivers can lay out simultaneous camera + screen share correctly.
```ts
// Sender
peer.addStream(cam, { role: "camera", label: "front cam" });
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);
});
remote.on("stream-removed", ({ stream }) => removeTile(stream.id));
});
```
**Trust model:** `StreamMetadata` is sender-stamped and server-routed verbatim, NOT JWT-signed. Treat as untrusted. For server-verified identity, use the JWT's `peerMetadata` (surfaces as `senderMetadata` on data + `remote.metadata` on RemotePeer).
**Reconcile semantics:**
- `stream-added` fires fresh after reconcile with same `stream.id` but a NEW `MediaStream` object — customer must re-bind `