# metered_realtime — Flutter / Dart SDK reference For developers using the `metered_realtime` Flutter 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 (over `flutter_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). It is the Dart sibling of the [JavaScript SDK](https://www.metered.ca/docs/llms-realtime-messaging-sdk.txt) and speaks the same protocol — a Flutter peer, a browser peer, and a raw-WS client can share one channel. - Package: https://pub.dev/packages/metered_realtime - Docs: https://www.metered.ca/docs/realtime-messaging/sdk-flutter/ - Dashboard: https://dashboard.metered.ca | Pricing: https://www.metered.ca/pricing --- ## Install ```yaml # pubspec.yaml dependencies: flutter_webrtc: ^1.4.1 metered_realtime: ^0.1.0 ``` ```dart import 'package:metered_realtime/metered_realtime.dart'; ``` Requires Dart `^3.5.0` / Flutter `>=3.3.0`. WebRTC is provided by `flutter_webrtc`, so the SDK runs on **Android, iOS, web, macOS, Windows, Linux**. **Platform setup is required before WebRTC capture:** Android manifest (`CAMERA`, `RECORD_AUDIO`, `MODIFY_AUDIO_SETTINGS`, `INTERNET`) + `minSdk` 23; iOS `Info.plist` (`NSCameraUsageDescription`, `NSMicrophoneUsageDescription`, optional `UIBackgroundModes: [audio]`); web needs HTTPS/localhost. iOS Simulator has no camera — test video on a real device. See https://www.metered.ca/docs/realtime-messaging/sdk-flutter/guides/platform-setup --- ## Events are Dart Streams (not an EventEmitter) The key difference from the JS SDK: events are broadcast `Stream` getters you `.listen()` to, not `.on('event', cb)`. ```dart // JS: peer.on('peer-joined', ({ peer: remote }) => …) // Flutter: peer.onPeerJoined.listen((remote) { … }); // emits the RemotePeer directly ``` `.listen()` returns a `StreamSubscription` — cancel it in your widget's `dispose()`. --- ## Two classes | You're building… | Use | Why | |---|---|---| | WebRTC video / voice / screen share, P2P game | `MeteredPeer` | Joins a channel, discovers peers via presence, manages each `RTCPeerConnection`, fans media out, recovers ICE | | Live chat with presence, classroom roster, lobby | `MeteredPeer` | `onPeerJoined` / `onPeerLeft` drive your UI; `peer.send(data)` broadcasts or directs | | IoT telemetry, pub/sub, AI agent bus, collaborative cursors | `SignallingClient` | Pub/sub + directed messages, no WebRTC. Smaller; multiple channels per connection | Not sure? Start with `MeteredPeer`. --- ## Quick start — MeteredPeer (WebRTC + presence + chat) ```dart import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:metered_realtime/metered_realtime.dart'; final peer = MeteredPeer(MeteredPeerOptions( apiKey: 'pk_live_…', // pk_ keys need `Send` ticked for WebRTC (see below) // — OR — tokenProvider: () async => fetchJwt(), )); final renderers = {}; peer.onPeerJoined.listen((remote) async { final r = RTCVideoRenderer(); await r.initialize(); renderers[remote.id] = r; remote.onStreamAdded.listen((ev) { // Re-bind on EVERY event — reconnect surfaces a fresh stream (same id). r.srcObject = (ev.stream as FlutterWebrtcMediaStream).native; }); }); peer.onPeerLeft.listen((remote) async => renderers.remove(remote.id)?.dispose()); peer.onData.listen((m) { // m.senderPeerId (server-stamped — trust it), m.kind (broadcast|direct), m.senderMetadata }); await peer.join('room-42'); final local = await navigator.mediaDevices.getUserMedia({'video': true, 'audio': true}); await peer.addStream(wrapMediaStream(local), metadata: {'role': 'camera'}); await peer.send({'chat': 'hi everyone'}); // broadcast await peer.sendTo(otherPeerId, {'hi': 'you'}); // directed ``` Render each `RTCVideoRenderer` with `RTCVideoView(renderer)`. **Public methods on `MeteredPeer`** (all return `Future` unless noted): - `join(channel, [JoinOptions(includeSenderMetadata: false)])` - `close([reason])` — terminal; construct a new `MeteredPeer` to rejoin - `send(data)` / `sendTo(peerId, data)` - `addStream(MediaStreamLike, {StreamMetadata? metadata})` / `removeStream(stream)` - `addTrack(MediaStreamTrackLike, {MediaStreamLike? stream, StreamMetadata? metadata})` / `removeTrack(track)` - `replaceTrack(oldTrack, newTrack)` — swap without renegotiation; `newTrack` may be null to mute - `getStreamMetadata(stream)` / `getTrackMetadata(track)` → `StreamMetadata?` (local lookups) **Read-only:** `peer.state` (`MeteredPeerState`), `peer.peerId` (`String?`), `peer.channel` (`String?`), `peer.remotePeers` (`List`). **Media wrapping (Flutter-specific):** outbound — wrap your `flutter_webrtc` `MediaStream` with `wrapMediaStream(...)` (`wrapMediaStreamTrack(...)` for a track). Inbound — the SDK gives `MediaStreamLike`; unwrap with `(ev.stream as FlutterWebrtcMediaStream).native` to assign to a renderer. **pk_ gotcha — `Send` permission is required for WebRTC.** A new publishable key has `Subscribe`/`Publish`/`Presence` on but `Send` OFF. `MeteredPeer`'s WebRTC layer carries SDP+ICE over the wire `send` op — without `Send`, `join()` succeeds and presence fires but `onPeerJoined → onStreamAdded` never fires (no PC negotiates). **Tick `Send` when creating a pk_ key for WebRTC.** Pub/sub-only needs it only for `client.send(...)`. --- ## Quick start — SignallingClient (pub/sub only) ```dart final client = SignallingClient(const SignallingClientOptions(apiKey: 'pk_live_…')); await client.connect(); await client.subscribe('alerts/critical'); client.onMessage.listen((m) => print('${m.from}: ${m.data}')); await client.publish('alerts/critical', {'sensor': 'T-12', 'value': 92}); await client.send(otherPeerId, {'type': 'challenge'}); // direct ``` **Public methods:** `connect()`, `close([code, reason])`, `dispose()` (releases stream controllers — use in widget dispose), `subscribe(channel, [SubscribeOptions])`, `unsubscribe(channel)`, `publish(channel, data)`, `send(toPeerId, data)`. **Read-only:** `client.state` (`SignallingClientState`). **Naming vs MeteredPeer:** SignallingClient keeps `send(toPeerId, data)` (no `sendTo`) and surfaces inbound directs on `onDirect` (`DirectMessageEvent.from`) — both wire-faithful. MeteredPeer earns `sendTo` / `senderPeerId` because it merges sources. --- ## Auth modes ### Path 1 — apiKey (pk_ publishable key) ```dart MeteredPeer(MeteredPeerOptions(apiKey: 'pk_live_…')); ``` Direct client use, fixed dashboard scope, no backend. **For WebRTC, tick `Send`.** Limitations: random UUID peerId, no `peerMetadata`, TURN via auto-injection. ### Path 2 — tokenProvider (sk_-minted JWT) ```dart MeteredPeer(MeteredPeerOptions( tokenProvider: () async { final r = await http.get(Uri.parse('https://your.app/api/mint')); return (jsonDecode(r.body) as Map)['token'] as String; }, )); ``` `TokenProvider` = `Future Function()`. Called on first connect AND every reconnect — **return a FRESH JWT each call** or you get a 4002 → re-mint → stale loop. Capped by `tokenProviderTimeoutMs` (10s default). ### JWT claims | Claim | Req | Does | |---|---|---| | `sub` | yes | Becomes `peerId` (≤128 chars) | | `exp` | yes | Unix seconds, ≤24h | | `channels` | yes | Wildcards: `*` one segment, `**` any | | `permissions` | yes | Subset of publish/subscribe/presence/send — **include `send` for WebRTC** | | `metadata` | no | ≤8KB; returned on welcome (put `iceServers` here for TURN override) | | `peerMetadata` | no | ≤4KB; stamped on presence + directs + opt-in broadcasts; surfaces as `remote.metadata` / `senderMetadata` | REST alternative: `POST https://rms.metered.ca/v1/tokens` with `Authorization: Bearer sk_secret_…`. **TURN auto-injected** when the key has "Auto-inject TURN" on (default). Override via JWT `metadata.iceServers`. `ConnectedEvent.iceServers` (`List?`) is what was received (null for pk_). --- ## Multi-stream + per-track metadata ```dart typedef StreamMetadata = Map; // convention: {'role': 'camera'|'screen'|…, 'label': '…'} ``` `addStream`/`addTrack` take an optional `metadata:` bag, surfaced at the receiver on `onTrack` + `onStreamAdded` (`ev.metadata`). Send simultaneous camera + screen as two streams; receiver branches on `ev.metadata?['role']`. **Trust model:** sender-stamped, server-routed verbatim, NOT JWT-signed — untrusted. For verified identity use JWT `peerMetadata`. Receiver caps each peer's track-metadata cache at 512 entries (FIFO). Reserved key `__meteredTrackMeta` — don't use it in your own `sendTo` payloads. **Reconcile:** `onStreamAdded` re-fires after reconnect with a fresh `MediaStream` object (same `stream.id`) — re-bind `renderer.srcObject` every time. `onStreamRemoved` is suppressed during reconcile. The metadata bag survives. `getStreamMetadata`/`getTrackMetadata` are LOCAL lookups (what YOU attached). --- ## Reconnect — 3 layers (handled automatically) 1. **Signalling WebSocket** — exp backoff (500ms→30s), jitter, close-code-aware. Default 100 attempts. `ReconnectOptions` validated by `assert` (multiplier≥1, non-negative delays, jitter in [0,1]). `autoReconnect: false` disables. 2. **ICE-restart ladder** — per-peer, 9 attempts over ~121s, surfaces as `remote.state == PeerConnectionState.reconnecting`. **No typed "exhausted" error** (unlike JS) — if the budget is spent the connection just stops trying; recover via the next reconnect or close()+re-join. `onNegotiationError` carries only scrubbed, generally-ignorable errors. 3. **Channel reconcile** — on signalling WS reconnect, `RemotePeer` refs are PRESERVED (same `identical()` object); the underlying PC is swapped with fresh TURN creds; local streams auto-reattach. The SDK supersedes a stale reconcile internally (no hang); **there is no `ReconcileTimeoutError`** — use your own ~30s "stuck in reconnecting" timer as the safety net. **Survives:** `RemotePeer` refs, `remote.id`, `remote.metadata`, `remote.state`, stream metadata, local streams added via `addStream`. **Doesn't survive:** - `remote.pc` — different PC object after reconcile; re-read each time - `DataChannel` from `remote.pc.createDataChannel(...)` — re-open on `stateChanges → connected` - `MediaStream` object identity (stream.id stable, object fresh) — re-bind on each `onStreamAdded` - listeners added directly on `remote.pc` — listen on the `RemotePeer` streams instead **Default ReconnectOptions:** `ReconnectOptions(initialDelayMs: 500, multiplier: 2, maxDelayMs: 30000, jitterRatio: 0.2, maxAttempts: 100)`. For agents/IoT: raise `maxAttempts` + `maxDelayMs`. --- ## Routing — peer.send / peer.sendTo is SERVER-routed (not P2P) Both route through the signalling server: work before ICE completes, same path for broadcast+direct, count against signalling quota. For low-latency P2P (game ticks, file transfer), open `remote.pc.createDataChannel(...)` (async — returns `Future`) and wrap with `DataChannel`. Wire creation to `remote.stateChanges` → `PeerConnectionState.connected` (fires on initial connect AND each reconcile). `remote.send(data)` == `peer.sendTo(remote.id, data)`. --- ## MeteredPeer streams - `onJoined` → `JoinedEvent {peerId, channel}` - `onLeft` → `LeftEvent {peerId?, channel?, reason?}` (snapshots; null if closed before welcome/ack) - `stateChanges` → `StateChange` — `idle|joining|joined|reconnecting|leaving|closed` - `onPeerJoined` / `onPeerLeft` → `RemotePeer` (emitted directly) - `onData` → `MeteredData {senderPeerId, data, kind: MeteredDataKind.broadcast|direct, senderMetadata?}` — use `senderPeerId` not an inner `data['from']`. Channel-scoped (cross-channel directs dropped — use SignallingClient for unscoped). - `onError` → `Object` (a `StateError` whose MESSAGE carries the symbolic code — there is NO `err.name`). Fires for terminal close codes (4001/4002/4003/4012/4020), fatal server errors, tokenProvider exhaustion. Match on the symbolic strings (`invalid_token`, `channel_not_authorized`, `account_suspended`, `admin_disconnect`, `token provider failed`). --- ## RemotePeer (subscribe per onPeerJoined) **Read-only:** `remote.id`, `remote.metadata` (`Map?` — JWT peerMetadata; may refresh if remote rotates JWT), `remote.state` (`PeerConnectionState`: idle|connecting|connected|reconnecting|closed), `remote.polite` (bool), `remote.pc` (`RtcPeerConnectionLike` escape hatch). **Method:** `remote.send(data)` → `Future`. `remote.toJson()` → `{id, state}`. **Streams:** - `stateChanges` → `StateChange` - `onTrack` → `RemoteTrackEvent {track, streams, metadata?}` (per-track; use for audio→pipeline routing) - `onStreamAdded` → `RemoteStreamEvent {stream, metadata?}` (preferred for tile-per-stream UIs) - `onStreamRemoved` → `MediaStreamLike` (the stream; suppressed during reconcile) - `onDataChannel` → `RtcDataChannelLike` (remote opened a channel — wrap with `DataChannel`) - `onNegotiationError` / `onIceCandidateError` → `Object` (scrubbed, generally ignorable) `pc` warnings: (1) changes object on reconnect — re-read it; (2) don't call `pc.setConfiguration({iceServers})` — it bypasses the SDK's scheme allowlist + size caps. --- ## SignallingClient streams - `onConnected` → `ConnectedEvent {peerId, serverTime, expiresAt?, isReconnect, maxMessageSize, iceServers?}` - `onDisconnected` → `DisconnectedEvent {code, reason, willReconnect}` (`willReconnect == false` ⇒ gave up) - `stateChanges` → `StateChange` (idle|connecting|connected|reconnecting|closed) - `onMessage` → `ChannelMessage {channel, from, fromMetadata?, data}` - `onDirect` → `DirectMessageEvent {from, fromMetadata?, data}` - `onPresence` → `PresenceEvent {channel, joined, left}` — `joined`/`left` are `List`. First event after subscribe = authoritative roster snapshot (always sent, even if empty). - `onServerError` → `ServerErrorEvent {code: ErrorCode, message?, requestId?}` (uncorrelated; correlated ones reject the Future) - `onGoingAway` → `GoingAwayEvent {retryAfterMs}` (informational; SDK uses normal backoff) - `onTokenProviderError` → `TokenProviderError {consecutiveFailures, error}` (after N consecutive mint failures) --- ## Exception classes (typed; implement `Exception`) | Class | Thrown from | Fields | |---|---|---| | `SignallingConnectError` | `connect()` / `join()` | `closeCode` (int), `closeReason` (String) | | `SignallingServerError` | subscribe/publish/send (correlated error) | `code` (`ErrorCode`), `serverMessage?` | | `SignallingDisconnectedError` | in-flight request, conn closed before ack | `code` (int), `reason` (String) | | `MeteredPeerSendError` | send/sendTo/join/remote.send | `code` (`SendErrorCode`: reservedChannel\|notJoined\|invalidArgs\|selfSend), `message` | | `MeteredPeerStateError` | addStream/addTrack/removeStream/removeTrack/replaceTrack/join | `code` (`StateErrorCode`: invalidState\|trackAlreadyAttached), `method`, `currentState?` | | `MeteredPeerOversizedError` | send/sendTo/addStream/addTrack (oversized metadata) | `size`, `cap` (ints) | | `MeteredPeerReplaceTrackError` | replaceTrack (partial failure) | `succeeded` (`List`), `failed` (`List`) | | `DataChannelOverflowError` | `DataChannel.send` (queue full) | `queued`, `cap` (ints) | **Branch with `on catch (e)` + the enum `code`** — class names, enum values, field names are stable; `toString()` text is not. Note: there is NO `IceRestartExhaustedError`, `ReconcileTimeoutError`, `NotConnectedError` — those are JavaScript-SDK constructs and don't exist in the Dart port. --- ## Close codes (`WsCloseCode` int constants) `WsCloseCode.goingAway` 1001, `policyViolation` 1008, `messageTooBig` 1009, `clientInactivity` 4000, `invalidToken` 4001 (**terminal**), `tokenExpired` 4002 (auto-refresh), `channelNotAuthorized` 4003 (**terminal**), `overConcurrentLimit` 4010 (≥30s floor), `overMessageRate` 4011, `accountSuspended` 4012 (**terminal**), `adminDisconnect` 4020 (**terminal**). 1006 = abnormal (no constant). Terminal codes 4001/4003/4012/4020 → no retry; on `MeteredPeer` they fire `onError`. ## Server error codes (`ErrorCode` enum, `.wire` strings) `malformedMessage`, `unknownType`, `invalidChannel`, `invalidPeerId`, `channelNotAuthorized`, `channelReserved`, `channelLimitExceeded`, `peerNotFound`, `missingData`, `actionNotPermitted`, `overMessageQuota` (not a disconnect — connection stays open, sends reject until period rolls over). `ErrorCode.overMessageQuota.wire == 'over_message_quota'`; `ErrorCode.fromWire(...)` maps back. --- ## DataChannel (opt-in backpressure wrapper) ```dart final raw = await remote.pc.createDataChannel('file-transfer'); final dc = DataChannel(raw, const DataChannelOptions(maxBufferedAmount: 1048576, maxQueuedSends: 256)); await dc.send(RtcDataChannelMessage.binary(chunk)); // suspends on backpressure // dc.sendText('hi'); dc.close(); dc.readyState; dc.bufferedAmount; dc.label // streams: dc.onOpen, dc.onClose (transport-side only), dc.onMessage (RtcDataChannelMessage), dc.onError ``` `RtcDataChannelMessage.text(String)` / `.binary(Uint8List)`. Rejects with `DataChannelOverflowError` past `maxQueuedSends`. Non-positive options throw `ArgumentError`. **`onError` never emits on the flutter_webrtc binding** — rely on rejected `send()` futures + `onClose`. Nothing survives a reconnect — re-open on `stateChanges → connected`. --- ## Reserved prefixes + channel naming `_metered/`, `_internal/`, `_system/` are server-reserved — `join()` rejects with `MeteredPeerSendError(SendErrorCode.reservedChannel)`. Channel names ≤256 chars. Wildcards in JWT `channels`: `*` one segment, `**` any. --- ## Logging `Logger` interface; `NoopLogger` (default), `ConsoleLogger` (dev). Pass via `MeteredPeerOptions(logger: const ConsoleLogger())`. --- ## When to use which - WebRTC video/voice/screen, multi-peer presence with media → `MeteredPeer` - Chat/classroom/lobby (no media) → `MeteredPeer` (per-peer state) or `SignallingClient` (multi-channel) - IoT, AI agents, cursors → `SignallingClient` - One connection, many channels → `SignallingClient` (MeteredPeer is one channel per instance) - Custom WebRTC topology (SFU client, custom mesh) → `SignallingClient` as signalling-only + your own PCs --- ## Documentation pages - Overview: https://www.metered.ca/docs/realtime-messaging/sdk-flutter/ - Getting Started: /sdk-flutter/getting-started - Guides: /sdk-flutter/guides/platform-setup, /webrtc-video-call, /authentication, /reconnect-best-practices, /presence-and-chat, /data-channel-low-latency, /troubleshooting - API Reference: /sdk-flutter/api-reference/metered-peer, /signalling-client, /remote-peer, /data-channel, /errors-and-codes - Example: /sdk-flutter/examples/video-call - Migration: /sdk-flutter/migration/from-flutter-webrtc --- ## Links - [Full Flutter SDK documentation](https://www.metered.ca/docs/realtime-messaging/sdk-flutter/) - [JavaScript SDK llms reference](https://www.metered.ca/docs/llms-realtime-messaging-sdk.txt) — same protocol, browser/Node - [Raw-WebSocket llms reference](https://www.metered.ca/docs/llms-realtime-messaging-raw-websocket.txt) - [flutter_webrtc](https://pub.dev/packages/flutter_webrtc) — the WebRTC dependency - [Dashboard](https://dashboard.metered.ca) · [Pricing](https://www.metered.ca/pricing) - [Metered TURN Server](https://www.metered.ca/docs/llms-turn-server.txt) — the natural pairing for WebRTC signalling