# @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 `