The Web SDK ships as two packages:
@levelchat/web— framework-agnostic, ESM + CJS builds, ~80 kB gz, zero runtime deps.@levelchat/web-react— React hooks + signature components on top of@levelchat/web.
Install
npm install @levelchat/webnpm install @levelchat/web @levelchat/web-reactpnpm add @levelchat/web
yarn add @levelchat/webWe follow semver; pin major. Latest version: 0.2.x.
Connect
import { LevelChat } from '@levelchat/web';
const lc = new LevelChat({ logLevel: 'info' });
// `joinLive` returns a `LiveStream` that wraps the underlying `Room` and
// exposes the lifecycle helpers most apps need.
const live = await lc.joinLive({
token, // minted on your server
role: 'broadcaster', // 'broadcaster' | 'viewer' — required
roomType: 'meeting', // 'meeting' | '1to1' | 'live' | 'webinar'
});
// The SDK reads the room id straight out of the JWT and connects to the
// managed cloud automatically. Self-hosting? Pass `signalingUrl` here or
// in the `LevelChat` config.Publish + subscribe
// Publish a camera with simulcast — three quality layers the SFU can pick
// from based on each subscriber's bandwidth.
const camera = await live.publishCamera({
resolution: '720p',
encodings: [
{ rid: 'h', maxBitrate: 1_500_000 },
{ rid: 'm', maxBitrate: 500_000 },
{ rid: 'l', maxBitrate: 150_000 },
],
scalabilityMode: 'L1T3',
});
await live.publishMic();
// Subscribe to remote tracks as they arrive.
live.room.on('track-subscribed', (track) => {
if (!track.mediaStreamTrack) return;
const el = document.createElement('video');
el.srcObject = new MediaStream([track.mediaStreamTrack]);
el.autoplay = true;
el.playsInline = true;
document.body.appendChild(el);
});
live.room.on('participant-joined', (p) => console.log('hello', p.identity));
live.room.on('participant-left', (p) => console.log('goodbye', p.identity));Screen share
const screen = await live.room.publishScreen({ frameRate: 15 });
// returns an array of TrackView (one for the picked surface, optionally one for system audio)
// Stop sharing:
await live.room.stopPublishing(screen[0].id);Recording
const recording = await live.room.record({ compose: 'tracks' });
// `recording.id` is the recording id; surface it in your UI.
await live.room.stopRecording();compose is one of 'tracks' (one file per participant per kind), 'grid'
(evenly-tiled composed video), or 'speaker' (active-speaker switcher).
room.record() with no arguments records everyone, tracks layout, mp4_av1
output. room.startRecording() is the pre-1.0 alias and still works.
Room API reference
joinRoom() / joinLive() hand you a Room (for joinLive, a LiveStream
that wraps one — reach the room via live.room). The full public surface:
Properties
| Member | Type | Notes |
|---|---|---|
room.id | RoomId | The room id, decoded from the JWT. |
room.participants | ReadonlyMap<ParticipantId, ParticipantView> | Live, reactive view of known participants. Await room.ready first. |
room.localId | ParticipantId | null | The local participant id — set once the server acknowledges the join. |
room.roomType | string | The topology hint passed at join ('1to1', 'meeting', 'live', 'webinar'). |
room.devices | DeviceManager | Camera/mic/speaker enumeration + device-change events. |
room.ready | Promise<void> | Resolves once the initial participant roster is populated. |
Methods
| Method | Returns | What it does |
|---|---|---|
leave() | Promise<void> | Tears down every peer connection, stops local tracks, closes signaling. |
publishCamera(opts?) | Promise<TrackView> | getUserMedia + publish a camera track (simulcast by default). |
publishMic(opts?) | Promise<TrackView> | getUserMedia + publish a microphone track. |
publishScreen(opts?) | Promise<TrackView[]> | getDisplayMedia + publish the screen surface (plus system audio if asked). |
stopPublishing(trackId) | Promise<void> | Unpublish one local track without leaving the room. |
subscribe(participantId, kind?) | Promise<void> | Manually subscribe to a remote participant's track(s). |
unsubscribe(participantId, kind?) | Promise<void> | Stop receiving a remote participant's track(s). |
setPreferredQuality('auto' | 'low' | 'high') | void | Bias which simulcast layer you receive for all remote tracks. |
sendChat({ text, to? }) | void | Send a chat message — broadcast, or direct with to. |
sendReaction({ kind }) | void | Send a reaction (e.g. { kind: 'thumbsup' }). |
setEncryptionKey(material, keyId?) | Promise<void> | Install an E2EE key across the mesh. See E2EE. |
record(opts?) | Promise<RecordingView> | Start a server-side recording. Canonical entry point. |
stopRecording() | Promise<RecordingView | null> | Stop the in-flight recording; resolves null when none is running. |
on(event, handler) / off(event, handler) | Room | Typed event subscription — see Room events. |
room.startRecording() is the pre-1.0 alias of room.record() and still
works. joinRoom() is subscribe-by-default — every remote track is subscribed
automatically — so subscribe() / unsubscribe() are only needed when you
want manual control over bandwidth.
Room events
Room is a typed event emitter (RoomEvents). Drive your UI from these
rather than polling. Every platform SDK publishes the same event names
(adapted to platform idiom — see the parity matrix).
| Event | Handler signature | Fires when |
|---|---|---|
participant-joined | (p: ParticipantView) => void | A remote participant joins. |
participant-left | (p: ParticipantView, reason?: string) => void | A remote participant leaves. |
track-published | (t: TrackView) => void | A remote participant publishes a track (before you subscribe). |
track-unpublished | (t: TrackView) => void | A remote participant unpublishes a track. |
track-subscribed | (t: TrackView) => void | A remote track is ready to render — attach t.mediaStreamTrack. |
track-unsubscribed | (t: TrackView) => void | A remote track went away — detach it. |
connection-quality | (p: ParticipantId | 'local', q: QualityScore) => void | A quality score crossed a band threshold. |
connection-state | (state: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed') => void | The room-level connection state changed. |
active-speaker | (p: ParticipantId | null, level: number) => void | The dominant speaker changed. |
recording-started | (r: RecordingView) => void | A recording started. |
recording-stopped | (r: RecordingView) => void | A recording stopped. |
chat-message | (m: ChatMessageView) => void | A chat message arrived. |
reaction | (r: ReactionView) => void | A reaction arrived. |
error | (e: unknown) => void | An error reached the top of the SDK stack. |
peer-state | (state: RTCPeerConnectionState) => void | Raw RTCPeerConnection state — for diagnostic UIs only. |
ice-state | (state: RTCIceConnectionState) => void | Raw ICE connection state — for diagnostic UIs only. |
For application logic prefer connection-state over peer-state / ice-state
— the latter two are raw WebRTC states exposed for diagnostic HUDs.
Config + options types
The objects you pass into the SDK, with the fields you will actually set:
LevelChatConfig (new LevelChat(config)) — region (slug or full
signaling URL), signalingUrl (explicit override), logLevel ('trace' …
'silent'), appName (telemetry prefix), telemetry (onEvent /
onQualityTransition / onError hooks), headless (omit DOM-coupled helpers
for bots/SSR), apiKey (dev-only, for issueToken).
JoinRoomOptions (client.joinRoom(opts)) — token (required),
device (cameraId / microphoneId / speakerId), preferredCodec,
simulcast (default true for video), svc (SVC opt-in), e2ee,
recording ({ enabled, layout }), signalingUrl, iceServers (add TURN
for symmetric-NAT clients).
JoinLiveOptions (client.joinLive(opts)) — token (required), role
('viewer' | 'broadcaster', required), roomType ('1to1' | 'meeting' |
'live' | 'webinar'), preferredCodec, signalingUrl, iceServers.
PublishCameraOptions — deviceId, resolution ('180p' … '1080p'),
frameRate (default 30), encodings (override simulcast layers),
scalabilityMode (SVC), preferredCodec, track (pre-acquired
MediaStreamTrack, useful for bots/tests).
PublishMicOptions — deviceId, sampleRate, echoCancellation,
noiseSuppression, autoGainControl, track.
PublishScreenOptions — withAudio, resolution ('720p' | '1080p' |
'1440p'), frameRate (default 15).
IssueTokenOptions (dev-only client.issueToken(opts)) — userId,
roomId, capabilities, ttlSeconds (default 600, server max 3600), meta
(displayName / picture), limits.
React bindings
The @levelchat/web-react package wraps the vanilla SDK with a context provider, hooks,
and pre-styled components. Same WebRTC underneath; the React layer is purely ergonomic.
import { LevelChatProvider, useLocalParticipant, ParticipantGrid } from '@levelchat/web-react';
// `autoJoin` takes the same options as `client.joinRoom` — pass the token
// your server minted. The provider joins on mount and tears the room down
// on unmount. Pass `config` too if you need a region or custom signaling URL.
export function Call({ token }: { token: string }) {
return (
<LevelChatProvider autoJoin={{ token }}>
<ParticipantGrid />
<Controls />
</LevelChatProvider>
);
}
function Controls() {
// `useLocalParticipant` returns the room's publish helpers, scoped to the
// local participant. They no-op until the provider has joined.
const { publishCamera, publishMic, publishScreen } = useLocalParticipant();
return (
<>
<button onClick={() => publishCamera()}>Share camera</button>
<button onClick={() => publishMic()}>Share mic</button>
<button onClick={() => publishScreen()}>Share screen</button>
</>
);
}Hooks available: useLevelChat, useRoom, useParticipants,
useLocalParticipant, useTrack, useConnectionQuality, useChat,
useReactions. Components: VideoTile, ParticipantGrid, LiveBadge,
NetworkIndicator, MicIndicator, ReactionOverlay, Chat.
End-to-end encryption
import { LevelChat } from '@levelchat/web';
const lc = new LevelChat();
const live = await lc.joinLive({ token, role: 'broadcaster', roomType: 'meeting' });
// Provide a 32-byte key from your own keying system (KMS / MLS group key /
// per-room derived secret). The SFU never sees this key.
await live.room.setEncryptionKey(crypto.getRandomValues(new Uint8Array(32)));The SDK uses Insertable Streams (Chrome/Edge/Safari 16.4+) to encrypt frames before they leave the device. The SFU sees routing metadata — never plaintext.
Network resilience
The SDK auto-reconnects on transient network loss with exponential backoff (250 ms → 8 s, ±25 % jitter) and restarts ICE on resume. You can listen in:
live.room.on('connection-state', (s) => {
// s ∈ 'connected' | 'reconnecting' | 'disconnected' | 'failed'
});
live.room.on('connection-quality', (id, q) => {
// q.label ∈ 'excellent' | 'good' | 'fair' | 'poor' | 'disconnected'
});Error handling
import { LevelChatError } from '@levelchat/web';
try {
await live.publishCamera();
} catch (err) {
if (err instanceof LevelChatError) {
if (err.code === 'media/permission-denied') promptForCamera();
if (err.code === 'transport/ice-failed') retryWithTurn();
if (err.code === 'room/full') showFullRoomMessage();
}
}Every error is a LevelChatError with .code, .message, and .retryable. The code
namespace matches what the signaling server emits in error frames, so end-to-end log
correlation is trivial. The full, stable LevelChatErrorCode enum:
| Code | Subclass | retryable | When it fires |
|---|---|---|---|
internal | LevelChatError | no | An unexpected SDK-internal failure. |
config/invalid | ConfigError | no | Bad config passed to new LevelChat(...) or joinRoom(...). |
config/missing-api-key | ConfigError | no | issueToken() called without an apiKey (it must run server-side). |
token/invalid | TokenError | no | The room JWT is malformed or fails signature verification. |
token/expired | TokenError | yes | The room JWT is past its expiry — mint a fresh one and retry. |
token/unauthorized | TokenError | no | The token's caps don't permit the attempted action. |
signaling/connect-failed | SignalingError | yes | Could not open the signaling WebSocket. |
signaling/closed | SignalingError | yes | The signaling socket closed unexpectedly. |
signaling/timeout | SignalingError | yes | A signaling operation exceeded its deadline. |
signaling/protocol | SignalingError | yes | The server sent a frame the SDK could not parse. |
signaling/send-queue-overflow | SignalingError | yes | Outbound signaling backed up beyond the queue cap. |
signaling/rpc-error | SignalingError | yes | An SFU control-plane RPC returned an error frame. |
signaling/rpc-timeout | SignalingError | yes | An SFU control-plane RPC got no reply before its deadline. |
transport/ice-failed | TransportError | yes | ICE never reached a connected state — usually needs TURN. |
transport/dtls-failed | TransportError | yes | The DTLS handshake failed. |
transport/unsupported | TransportError | yes | The browser lacks a required WebRTC transport capability. |
media/permission-denied | MediaError | no | The user denied camera/mic permission. |
media/device-not-found | MediaError | no | The requested camera/mic/speaker id does not exist. |
media/unsupported | MediaError | no | The browser cannot satisfy the requested media capability. |
media/constraint-not-satisfied | MediaError | no | getUserMedia constraints could not be met. |
room/full | RoomError | no | The room is at its participant cap. |
room/ended | RoomError | no | The meeting has ended — joinLive / joinRoom / issueToken refuse a finished room. Terminal: start a new room. |
room/kicked | RoomError | no | The local participant was removed by a moderator. |
room/cap-denied | RoomError | no | The action exceeds the plan's quota. |
codec/unsupported | LevelChatError | no | No mutually supported codec for a track. |
encryption/key-missing | EncryptionError | no | A frame arrived but no decryption key is set. |
encryption/unsupported | EncryptionError | no | The browser lacks Insertable Streams support. |
recording/not-permitted | RecordingError | yes | The token's caps don't permit recording. |
recording/backend-failed | RecordingError | yes | The recording service rejected or failed the request. |
network/unreachable | LevelChatError | yes | The LevelChat API/edge is unreachable. |
network/rate-limited | LevelChatError | yes | The client is being rate-limited — back off and retry. |
Use the isLevelChatError(x) helper when an error may have crossed a worker boundary (it
checks the structural shape, not just instanceof).
Bundle size
The vanilla SDK is tree-shakeable — importing only LevelChat ships ~52 kB gz.
@levelchat/web/headless (no DOM dependencies, used by bots and SSR) is 30 kB gz.
@levelchat/web/noise-suppression is a separate entry point that lazy-loads ~250 kB of
RNNoise WASM only when the operator flips on AI noise gating.
Browser support
| Browser | Minimum version | Notes |
|---|---|---|
| Chrome / Chromium | 100+ | Full feature set including Insertable Streams |
| Edge | 100+ | Same as Chrome |
| Firefox | 115+ | All except SVC encoding (browser limitation) |
| Safari (macOS / iOS) | 16.4+ | Insertable Streams + AV1 decode (where hardware allows) |
| WebView (mobile in-app) | Chrome 100+ engine | Test the host app's WebView version |