@ukrstream/beamcaster-js-sdk
v0.8.0
Published
TypeScript SDK for Beamcaster media server with WebRTC and HLS support
Readme
@ukrstream/beamcaster-js-sdk
TypeScript SDK for Beamcaster media server with WebRTC, WHEP, and HLS support.
- Examples: WebRTC | WHEP | HLS | Test harness | Conference (how to run)
Features
- WebRTC playback — ultra-low latency, real-time streaming (WebSocket signaling)
- WHEP playback — standards-based WebRTC over HTTP, no WebSocket required
- HLS playback — broad device compatibility, reliable on iOS Safari
- Live & VOD modes — live streams with DVR seek and on-demand playback
- Adaptive quality — switch between
fhd(1080p),hd(720p),sd(480p),ld(360p),uld(240p) - Vidstack player UI — controls, fullscreen, and seek bar out of the box
- Mobile-first —
playsinline, responsive 16:9 layout, touch controls - Stream status notifications — real-time
playing/stoppedupdates via WebSocket - Auto-reconnect — WebRTC/WHEP retry the initial connect and recover dropped sessions with jittered backoff, honoring the server's
Retry-After; observe via reconnect events - Data channel metadata — third-party payloads the server forwards to viewers, surfaced as an event on WebRTC and WHEP
- Conferences — a headless, DOM-free multi-party client (
@ukrstream/beamcaster-js-sdk/conference) with WHIP publishing, conference WHEP subscriptions, roster, chat, media state, spotlight, simulcast layers, screen share, moderation, invites and a grace-aware reconnect; works in React Native
Installation
npm install @ukrstream/beamcaster-js-sdkhls.js, phoenix, and vidstack are bundled dependencies and are installed automatically — no separate install is required.
Upgrading from 0.3.0
0.6.0 realigns the SDK with Beamcaster 0.6.0. The SDK's version now tracks the server version it
targets, which is why this release jumps from 0.3.0 — there were no 0.4.0 or 0.5.0 releases.
Five behaviours changed:
| Change | What to do |
|--------|------------|
| WHEP tokens moved to Authorization: Bearer | Nothing, unless a proxy in front of Beamcaster authorizes on ?token=. If it does, bake the token into whepSignalingUrl and drop the token option. |
| WHEP gathers ICE before POSTing | Nothing. Connects are slightly later but carry their candidates inline, and the trickle PATCH storm is gone. Tune with iceGatherTimeoutMs if you use TURN. |
| Socket heartbeat now 10s (was phoenix's 30s) | Nothing, unless you want the old cadence: set heartbeatIntervalMs: 30000. The tighter interval detects a dead socket ~3x sooner. |
| initialMaxAttempts now 3 (was 5) | Nothing, unless you want the old budget: set reconnect: { initialMaxAttempts: 5 }. Fewer attempts keeps a page inside the server's 10/min WHEP bucket. |
| The WHEP Location header resolves against the signaling URL | Nothing, unless your httpBaseUrl carries a path prefix — in which case the trickle-ICE resource URL is now built correctly where it previously was not. |
Additions that need no migration: the DataChannelMessage event,
Retry-After handling on 429, whepSignalingUrl, and the payload/export fixes noted in
CHANGELOG.md.
Quick start
WebRTC (low latency)
<div id="media-player"></div>
<script type="module">
import { BeamcasterClient, BeamcasterClientEvent } from '@ukrstream/beamcaster-js-sdk';
const sdk = new BeamcasterClient({
container: 'media-player',
});
sdk.on(BeamcasterClientEvent.Connected, (data) => console.log('Connected:', data));
sdk.on(BeamcasterClientEvent.Error, (error) => console.error('Error:', error));
sdk.connect({ streamId: 'your-stream-id' });
</script>WHEP (WebRTC over HTTP)
<div id="media-player"></div>
<script type="module">
import { BeamcasterClient, BeamcasterClientEvent } from '@ukrstream/beamcaster-js-sdk';
const sdk = new BeamcasterClient({
container: 'media-player',
});
sdk.on(BeamcasterClientEvent.Connected, (data) => console.log('Connected:', data));
sdk.on(BeamcasterClientEvent.Error, (error) => console.error('Error:', error));
sdk.connect({
streamId: 'your-stream-id',
streamType: 'whep',
// Optional STUN/TURN servers (needed when the media server is not directly reachable):
// iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
</script>HLS (broad compatibility)
<div id="media-player"></div>
<script type="module">
import { BeamcasterClient, BeamcasterClientEvent } from '@ukrstream/beamcaster-js-sdk';
const sdk = new BeamcasterClient({
container: 'media-player',
});
sdk.on(BeamcasterClientEvent.Connected, (data) => console.log('Connected:', data));
sdk.connect({
streamId: 'your-stream-id',
streamType: 'hls',
});
</script>Configuration (Config)
Pass a Config object when creating the client:
const sdk = new BeamcasterClient(config);| Option | Type | Default | Description |
|--------|------|---------|-------------|
| container | string \| HTMLElement | 'beamcasterPlayer' | Container element ID or DOM element where the player is rendered |
| websocketUrl | string | Auto-detected from window.location | WebSocket server URL (ws:// or wss://) |
| httpBaseUrl | string | Auto-detected from window.location | HTTP base URL for HLS manifest and WHEP signaling requests |
| debug | boolean | false | Enable console logging |
| enableNotifications | boolean | true | Subscribe to stream status notifications via WebSocket |
| connectTimeout | number | 10000 | Timeout (ms) for channel joins and the WHEP POST before connect() rejects |
| heartbeatIntervalMs | number | 10000 | Phoenix socket heartbeat. Matches Beamcaster's own WEBSOCKET_HEARTBEAT_INTERVAL_MS |
| whepAnswerTimeoutMs | number | 6000 | The server's WHEP answer timeout. The WHEP POST abort is floored at this + 2000 |
| iceGatherTimeoutMs | number | 2000 | How long to wait for local ICE gathering before POSTing a WHEP offer |
| reconnect | boolean \| ReconnectOptions | true | Auto-reconnect policy for WebRTC/WHEP. See Reconnection |
Auto-detection: When websocketUrl or httpBaseUrl are omitted, they are derived from the current page's window.location (protocol, hostname, and port). This works well when the SDK is served from the same origin as the media server.
Reconnection (ReconnectOptions)
For WebRTC and WHEP, the SDK reconnects automatically — on by default. It retries the initial connect() handshake with exponential backoff, and, once connected, re-establishes the session if the peer connection drops. (HLS is unaffected: it self-heals via hls.js.) Pass reconnect: false to disable, or an object to tune it:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| enabled | boolean | true | Master switch |
| maxAttempts | number | 3 | Max recovery attempts after an established session drops |
| baseDelayMs | number | 2000 | Base delay for exponential backoff between recovery attempts |
| disconnectedGraceMs | number | 5000 | Grace period to tolerate a transient disconnected state before recovering |
| initialMaxAttempts | number | 3 | Max attempts for the initial connect() handshake before it rejects |
| initialBaseDelayMs | number | 1000 | Base delay for backoff between initial-connect attempts |
| jitter | boolean | true | Apply equal jitter (d/2 + random·d/2) so concurrent tabs don't synchronise into one rate-limit bucket |
| maxRetryAfterMs | number | 60000 | Upper bound on a server Retry-After the SDK will honor |
Rate limits are respected. When the server replies 429 Too Many Requests, the SDK reads its
Retry-After header and waits that long instead of its own backoff (capped by maxRetryAfterMs).
The wait is visible via Reconnecting, which carries delayMs and retryAfterMs — without that an
integrator would just see a long silent pause. See Rate limits.
How exhaustion surfaces differs by phase, so listen to the right one:
| Phase | What you get |
|-------|--------------|
| Initial connect() | The promise rejects with the last error — an HttpError when the cause was a 429. Error is also emitted. |
| Recovery after a drop | The ReconnectFailed event, carrying reason: 'rate_limited' \| 'exhausted' and lastError. |
ReconnectFailed is a recovery-phase event only; a failed initial connect is reported by the
rejection, since you are already awaiting it.
connect()'s contract is preserved: it resolves only once connected and rejects after the initial attempts are exhausted. Observe progress via the Reconnecting, Reconnected, and ReconnectFailed events (see Events reference) to drive your own UI — the SDK owns the mechanism, you own the presentation.
Connecting to a stream (LiveStreamOptions)
await sdk.connect(options);| Option | Type | Default | Description |
|--------|------|---------|-------------|
| streamId | string | (required) | The stream's playback ID (see below) |
| streamType | 'webrtc' \| 'whep' \| 'hls' | 'webrtc' | Transport protocol |
| roomId | string | same as streamId | WebRTC room ID (if different from stream ID) |
| token | string | — | Playback token for signed / authenticated streams |
| mode | 'live' \| 'vod' | 'live' | Playback mode (HLS only) |
| seekBar | boolean | HLS true, WebRTC/WHEP false | Show or hide the player seek bar |
| iceServers | RTCIceServer[] | [] | STUN/TURN servers for WHEP playback. See ICE servers |
| whepSignalingUrl | string | derived | Full WHEP signaling URL, overriding {httpBaseUrl}/{streamId}/whep |
| hlsConfig | Record<string, any> | — | Custom hls.js configuration overrides |
| vodManifestId | string | — | VOD manifest identifier (HLS VOD only) |
streamIdmust be a playback ID. Beamcaster streams carry several identifiers: a publish ID (for ingest — RTMP/WHIP/SRT), the stream's database UUID, and one playback ID per playback config. All playback endpoints (/{id}/whep,/hls-video/{id}, the WebRTC channel) resolve playback IDs only — a publish ID or stream UUID yields404 Not found(whep/hls) or a failed channel join (webrtc). Find it in the admin UI under the stream's playback configs, or in the API response underplayback_configs[].playback_id/playback_urls.The stream's playback config must also list the format you request:
whep/webrtcneedwebrtcin the config's output formats,hlsneedshls.
WebRTC streams
await sdk.connect({
streamId: 'abc123',
});WebRTC is the default. The SDK opens a WebSocket, performs ICE negotiation, and renders the stream via a MediaStream on the Vidstack player.
WHEP streams
await sdk.connect({
streamId: 'abc123',
streamType: 'whep',
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], // optional
});WHEP (WebRTC-HTTP Egress Protocol) is a WebRTC variant that signals over plain HTTP instead of a WebSocket. Like WebRTC it is a live, receive-only stream: there is no seek bar and quality is controlled by the server (listQualities() returns []).
The SDK gathers ICE before it POSTs. Beamcaster waits for its own ICE gathering to finish before
returning the answer, and never trickles candidates to a WHEP viewer — so the SDK mirrors it. It
creates the offer, waits for local gathering to complete (or iceGatherTimeoutMs, default 2000ms),
then POSTs an offer that already carries its candidates. Trickle over HTTP PATCH remains only as
a fallback for stragglers that arrive after the cap, which is normally none. Gathering finishes in
milliseconds unless you supply TURN servers, so the cap rarely binds.
If a straggler PATCH comes back 404, the SDK stops trickling and says so in debug logs, but does
not tear the session down: trickle is best-effort, and a failed straggler says nothing about
whether media is flowing. Recovery stays driven by the peer connection's own state.
Custom signaling URL
whepSignalingUrl overrides the derived endpoint — it is the API's playback_urls.whep_signaling:
await sdk.connect({
streamId: 'abc123',
streamType: 'whep',
whepSignalingUrl: 'https://edge.example.com/abc123/whep',
});It doubles as the escape hatch for a proxy that authorizes on a query string. The SDK only ever
sends token as an Authorization: Bearer header and never appends it to a URL, so bake the token
into this URL and omit the token option:
await sdk.connect({
streamId: 'abc123',
streamType: 'whep',
whepSignalingUrl: 'https://edge.example.com/abc123/whep?token=' + encodeURIComponent(t),
// no `token` option — it would add a Bearer header as well
});Note the query token will not reach the trickle-ICE resource URL, since that is resolved from the
response's Location header. Harmless in practice now that stragglers are rare.
Note:
disconnect()/destroy()close the local peer connection but send no HTTPDELETE— the beamcaster WHEP endpoint exposes no resource-delete route, so the server reclaims the session via its own timeout.
HLS live streams
await sdk.connect({
streamId: 'abc123',
streamType: 'hls',
});The SDK loads the HLS manifest from {httpBaseUrl}/hls-video/{streamId} and uses hls.js (or native HLS on Safari) through the Vidstack player.
HLS VOD streams
await sdk.connect({
streamId: 'abc123',
streamType: 'hls',
mode: 'vod',
vodManifestId: 'recording-001',
});In VOD mode the player shows a seek bar and total duration instead of the live indicator.
Authenticated (signed) streams
await sdk.connect({
streamId: 'abc123',
streamType: 'hls',
token: 'eyJhbGciOi...',
});How the token travels depends on the transport:
| Transport | Where the token goes |
|-----------|----------------------|
| WHEP (POST + trickle PATCH) | Authorization: Bearer <token> header |
| HLS (manifest and every segment) | ?token= query parameter |
| WebRTC (Phoenix socket) | WebSocket connect parameter |
The server prefers the Authorization header over ?token=, so the SDK never sends both on one
request. WHEP uses the header because those requests are preflighted anyway (they carry
application/sdp), making it free. HLS deliberately keeps the query parameter: segments are simple
GETs, and adding a header would force a CORS preflight on every segment. Beamcaster's own HLS
player uses a Bearer header only because it is same-origin and pays no preflight.
Always pass a token if you have one, even where it is not strictly required. Beamcaster's rate
limiter keys its whip_whep bucket on the credential when one is present and on the client IP when
it is not — so anonymous viewers behind one NAT share a single bucket, while token-bearing viewers
each get their own. See Rate limits.
Playback controls
// Start / resume playback
await sdk.play();
// Pause playback
await sdk.pause();Seek (HLS only)
// Seek to a specific time in seconds
sdk.seek(30);- VOD: seeks to an absolute position clamped to
[0, duration]. - Live DVR: seeks within the seekable range reported by the browser.
- WebRTC / WHEP: seek is not supported (logs a message when
debugis enabled).
Time information
const currentTime = sdk.getCurrentTime(); // seconds
const duration = sdk.getDuration(); // seconds (Infinity for live)Accessing the video element
const video = sdk.getPlayer(); // HTMLVideoElement | nullReturns the underlying <video> element inside the Vidstack player. Useful for reading videoWidth/videoHeight, attaching canplay listeners, or advanced integrations.
May be
null. The<video>element only exists once the media provider has attached, which happens asynchronously after a source is set.connect()waits for it (bounded), so it is normally available after a resolved connect on a live stream — but do not gate playback on it. Prefersdk.setMuted(true)+await sdk.play(), which work regardless:play()waits internally for the media to become playable.
Quality management
List available qualities
const qualities = sdk.listQualities();
// e.g. ['fhd', 'hd', 'sd']Quality names are mapped from resolution height:
| Name | Resolution |
|------|-----------|
| fhd | 1080p |
| hd | 720p |
| sd | 480p |
| ld | 360p |
| uld | 240p |
Switch quality
sdk.switchQuality('hd');For HLS this sets the hls.js level directly. For WebRTC it sends a switch_track message to the server.
Listen for quality changes
sdk.on(BeamcasterClientEvent.QualitiesUpdated, (data) => {
console.log('Available qualities:', data.qualities);
// data.qualities: string[] — e.g. ['fhd', 'hd', 'sd']
});Stream status notifications
When enableNotifications is true (the default), the SDK subscribes to a WebSocket channel for real-time stream status updates.
Check current status
const status = sdk.getStreamStatus();
// 'playing' | 'stopped' | nullReturns null if the notification channel hasn't received a status yet.
Listen for status changes
sdk.on(BeamcasterClientEvent.StreamStatusChanged, (data) => {
console.log(data.streamId); // string
console.log(data.status); // 'playing' | 'stopped'
console.log(data.timestamp); // ISO 8601 string
});Data channel metadata
Beamcaster fans third-party metadata out to viewers over the WebRTC data channel. Anything POSTed to
the server's /api/forwarding-events for a stream reaches every connected webrtc and whep viewer
(HLS has no data channel):
sdk.on(BeamcasterClientEvent.DataChannelMessage, (msg) => {
if (msg.type !== 'forwarding_event') return;
console.log(msg.liveStreamId, msg.payload); // e.g. 'ls-abc', { temperature: 21 }
});The server's envelope is { type, live_stream_id, payload }; the SDK lifts those out as type,
liveStreamId and payload. Frames that are not JSON still arrive, with data left undefined and
the original in raw — decoding never throws and never drops the channel.
Which side creates the channel differs by transport, but this is invisible to you: WHEP offers it from the browser, while the Phoenix-signaled WebRTC path receives one the server creates.
Rate limits
Beamcaster ships per-IP and per-credential rate limiting, off by default
(RATE_LIMIT_ENABLED=false). When an operator enables it, the buckets that affect playback are:
| Bucket | Default | Applies to |
|--------|---------|------------|
| whip_whep | 10 / min | The WHEP POST (one per connect attempt) |
| whip_whep_ip | 50 / min | Per-IP backstop when a credential is present |
| whip_whep_res | 60 / min | Trickle-ICE PATCH |
| api | 120 / min | The JSON API |
A rejected request returns 429 with Retry-After in whole seconds. The SDK honors it rather than
retrying on its own schedule, and reports it through Reconnecting/ReconnectFailed — see
Reconnection.
Three things keep a normal page well inside the 10/min WHEP budget: initialMaxAttempts is 3, the
backoff is jittered so reloading tabs don't align, and gather-then-POST means a connect costs one
POST and usually zero PATCHes. The remaining lever is yours: pass a playback token, which
moves each viewer into their own bucket instead of sharing one per IP.
Cross-origin embedding
The media server gates WebSocket handshakes on an origin allow-list. If you embed the SDK on a page
served from a different origin than Beamcaster, that origin must appear in the server's
ALLOWED_ORIGINS, or the /socket handshake fails and channel joins never succeed. The SDK detects
this case — a join failing while the socket never opened — and says so in the error rather than
reporting a bare timeout.
CORS is already configured for the rest: authorization is an allowed request header (so the WHEP
Bearer token works cross-origin) and location is exposed (so the SDK can read the trickle-ICE
resource URL from the 201).
ICE servers
For WHEP, the SDK gathers candidates using only the iceServers you pass to connect(). It defaults
to [], which means host candidates only — fine when the server advertises publicly reachable
candidates, and the reason gathering usually completes in milliseconds.
The server does advertise its own ICE servers as Link headers on the WHEP 201, but that arrives
with the answer — too late to inform the offer's gathering. The WHEP spec's answer is an OPTIONS
discovery request, which Beamcaster does not currently route for WHEP. Until it does, supply
iceServers yourself when clients sit behind NAT. Expect the iceGatherTimeoutMs cap to matter only
in that case.
Events reference
| Event | Enum value | Payload | Description |
|-------|-----------|---------|-------------|
| Connected | 'connected' | { roomId, streamId, streamType } | Connection established |
| Disconnected | 'disconnected' | {} | Disconnected from server |
| TrackAdded | 'trackAdded' | { track: MediaStreamTrack, streamId } | WebRTC / WHEP media track received |
| Error | 'error' | Error | SDK or connection error |
| QualitiesUpdated | 'qualitiesUpdated' | { qualities: string[] } | Available quality list changed |
| StreamStatusChanged | 'streamStatusChanged' | { streamId, status, timestamp } | Stream started or stopped |
| HLSManifestParsed | 'hlsManifestParsed' | { levels: Array<{ height, width, bitrate }> } | HLS manifest loaded and parsed |
| HLSError | 'hlsError' | { type, details, fatal } | hls.js error (fatal errors trigger auto-recovery) |
| Seeked | 'seeked' | { currentTime } | Seek operation completed |
| Reconnecting | 'reconnecting' | { phase, attempt, maxAttempts, delayMs?, retryAfterMs? } | A WebRTC/WHEP (re)connection attempt is being made (retries only). phase is 'initial' or 'recovery'; retryAfterMs is set when the server asked for the wait |
| Reconnected | 'reconnected' | {} | A dropped WebRTC/WHEP session was re-established |
| ReconnectFailed | 'reconnectFailed' | { phase, attempts, reason?, lastError? } | Recovery attempts after a drop were exhausted; the session is torn down. reason is 'exhausted' or 'rate_limited'. An exhausted initial connect rejects connect() instead of firing this |
| DataChannelMessage | 'dataChannelMessage' | { data?, raw, type?, liveStreamId?, payload?, label } | A frame arrived on the WebRTC data channel. See Data channel metadata |
Listening for events
sdk.on(BeamcasterClientEvent.Connected, (data) => { /* ... */ });
// Remove a specific listener
sdk.off(BeamcasterClientEvent.Connected, myHandler);Lifecycle management
disconnect()
Tears down the active player, WebSocket channels, and notification subscriptions. The SDK instance remains usable — you can call connect() again to start a new session.
sdk.disconnect();
// Later: reconnect to a different stream
await sdk.connect({ streamId: 'new-stream', streamType: 'hls' });destroy()
Calls disconnect() and removes all DOM elements created by the SDK (the player wrapper). Use this when completely removing the player from the page.
sdk.destroy();Switching streams
To switch streams without destroying the player container:
sdk.disconnect();
await sdk.connect({
streamId: 'another-stream-id',
streamType: 'hls',
});Reconnection
WebRTC and WHEP sessions reconnect automatically (see Reconnection config). You don't need to write a reconnect loop — just observe the lifecycle events to drive UI such as a "reconnecting…" overlay:
sdk.on(BeamcasterClientEvent.Reconnecting, ({ phase, attempt, maxAttempts }) => {
showOverlay(`Reconnecting… (${attempt}/${maxAttempts})`);
});
sdk.on(BeamcasterClientEvent.Reconnected, () => hideOverlay());
sdk.on(BeamcasterClientEvent.ReconnectFailed, () => {
// Attempts exhausted and the session is torn down — offer a manual retry.
showOverlay('Connection lost.', { retryButton: true });
});Mobile guide
Protocol choice
| Platform | Recommendation | |----------|---------------| | iOS Safari | Use HLS — native HLS support, works in all iOS versions including PWAs | | Android Chrome | WebRTC or WHEP for lowest latency; HLS also works via hls.js | | Modern desktop browsers | Any protocol works; WebRTC/WHEP for real-time, HLS for reliability |
iOS Safari in PWA/WebView contexts has historically limited WebRTC support. Since WHEP relies on the same RTCPeerConnection APIs, it shares WebRTC's browser support; HLS remains the safest choice for any iOS deployment.
Autoplay policies
Browsers block autoplay of unmuted video without a user gesture. Two strategies:
- Call
play()from a tap/click handler (recommended):
document.getElementById('play-btn').addEventListener('click', () => {
sdk.play();
});- Mute for immediate autoplay:
sdk.setMuted(true);
await sdk.play();setMuted() works at any point after connect() starts — unlike mutating getPlayer().muted, which requires the <video> element to already exist. play() waits for the media to become playable (bounded by connectTimeout), so calling it right after connect() resolves is safe.
Embedding from WASM or another interop layer? Route sdk.on('error', ...) (and ideally debug: true) into your host's logging — connection failures, WHEP HTTP errors, and media errors are reported through the error event, and a swallowed promise rejection on the interop boundary otherwise hides them entirely. Note that when auto-reconnect exhausts its attempts, the SDK emits reconnectFailed and removes the player element.
Inline playback
The SDK sets the playsinline attribute on the Vidstack player automatically. On iOS this prevents the video from forcing full-screen when playback starts.
Responsive layout
The default CSS uses aspect-ratio: 16/9 and width: 100%, so the player scales to its container's width. Make the container responsive:
#media-player {
width: 100%;
max-width: 800px;
}Fullscreen and touch controls
The Vidstack player handles fullscreen toggle and touch interactions (tap to play/pause, seek gestures) natively. No additional setup is required.
Network resilience
The SDK's default HLS live configuration includes aggressive retry and recovery settings:
- Manifest retry: 6 attempts, 1000 ms delay
- Fragment retry: 10 attempts, 300 ms delay
- Fatal errors are retried up to 3 times, then reported as an unrecoverable
HLSError - Stall detection with automatic recovery
- Fatal network errors trigger
hls.startLoad()retry - Fatal media errors trigger
hls.recoverMediaError()
For flaky mobile networks you can override these via hlsConfig:
await sdk.connect({
streamId: 'abc123',
streamType: 'hls',
hlsConfig: {
fragLoadingMaxRetry: 20,
fragLoadingRetryDelay: 500,
manifestLoadingMaxRetry: 10,
levelLoadingMaxRetry: 10,
},
});Minimal mobile HTML page
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Live Stream</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; }
#media-player { width: 100%; max-width: 100vw; }
</style>
</head>
<body>
<div id="media-player"></div>
<button id="play-btn" style="width:100%;padding:16px;font-size:18px;">
Tap to Play
</button>
<script type="module">
import { BeamcasterClient, BeamcasterClientEvent } from '@ukrstream/beamcaster-js-sdk';
const sdk = new BeamcasterClient({ container: 'media-player' });
sdk.connect({
streamId: 'your-stream-id',
streamType: 'hls',
});
document.getElementById('play-btn').addEventListener('click', () => {
sdk.play();
document.getElementById('play-btn').style.display = 'none';
});
</script>
</body>
</html>Conferences
Beamcaster conferences are multi-party rooms. The SDK ships a headless conference client:
room signalling runs over a Phoenix channel, and media over WHIP (publish) and conference WHEP
(subscribe). It renders nothing. It emits typed events and MediaStreams, so the same code runs in
browsers, React Native (with react-native-webrtc) and Node 22+. It implements the server's
conference protocol: roster, chat, self media control (mic, camera, listener promotion), spotlight,
simulcast layer control, screen share, moderation and the reconnect grace
(guides/CONFERENCE_PROTOCOL.md in the Beamcaster repo).
import { ConferenceSession } from '@ukrstream/beamcaster-js-sdk/conference';The @ukrstream/beamcaster-js-sdk/conference and @ukrstream/beamcaster-js-sdk/api entry points are DOM-free. They
never load the Vidstack player and touch no window/document at import time. They resolve through
the package's exports map, and also for tools that ignore it: TypeScript with
moduleResolution: "node" (via typesVersions) and Metro without package-exports support (via
the conference/ and api/ stub directories).
Joining a room
An invite link looks like https://<host>/conf/<slug>?invite=<jwt>. Public rooms need only the
slug.
const session = new ConferenceSession();
session.on('roster', (participants) => renderRoster(participants));
session.on('participantJoined', (p) => console.log(`${p.display_name} joined`));
session.on('participantUpdated', (p) => updateBadges(p)); // audio / video / video_state / moderation
session.on('participantLeft', (p) => removeTiles(p.peer_id));
session.on('trackAdded', (participant, kind, stream, track, source) => {
// source is 'camera' or 'screen'; a participant's audio and video share one stream
tileFor(participant.peer_id, source).srcObject = stream;
});
session.on('trackRemoved', (participant, kind, stream, source) => removeTile(participant.peer_id, source));
session.on('chat', (line) => appendChat(line)); // your own lines come back too
session.on('kicked', (reason) => showLobby(reason)); // 'kicked' | 'invite_revoked' | 'invite_expired' | 'access_changed' | 'superseded'
session.on('ended', () => showLobby('The conference was deleted'));
session.on('error', (error, context) => console.error(context.operation, error));
// 1. Socket: /socket?token=<invite>&device_id=<persisted id>
await session.connect({ httpBaseUrl: 'https://media.example.com', slug: 'team-standup', invite });
// 2. Preview: who is inside, nothing is tracked yet (pre-join screen)
const { room, roster } = await session.preview();
// 3. Enter: become a participant (the pre-join mic / camera choices are the starting state)
const reply = await session.enter({ mode: 'publisher', displayName: 'Ada', subtitle: 'Engineering', video: false });
// 4. Publish: the session captures the mic (the camera stays off: not acquired) and publishes over
// WHIP with the enter reply's token, simulcast if the room has it on
await session.publishCamera();
// or publish a stream you captured yourself: await session.publishCamera(myStream);
// … later
await session.leave();Your mic and camera
Every entered participant controls its own mic and camera at any time, publishers and listeners alike. One WHIP session carries the whole call: nothing is renegotiated when you toggle.
session.on('localMediaChanged', (state) => renderControls(state));
// state: { mode, audio, video, audioLocked, videoLocked, screenLocked, audioDevice, videoDevice }
await session.setAudioEnabled(false); // mute: track.enabled = false (instant unmute), media_state {audio: false}
await session.setAudioEnabled(true); // unmute: the server accepts first, then the track is enabled
await session.setVideoEnabled(false); // camera off: track.stop() (the camera light goes off) + replaceTrack(null)
await session.setVideoEnabled(true); // camera on: capture again, replaceTrack(newTrack) on the SAME sender
await session.setMediaState({ videoState: 'degraded' }); // a weak uplink you detected ('live' | 'degraded' | 'paused')- Mic. Muting keeps the track and disables it, so unmuting is instant. The session captures
the mic even when you join muted (disabled), unless
localMedia: { captureMutedMic: false }; then it is captured on the first unmute. - Camera. Turning it off stops the track (the device is released) and detaches it from the
sender. Turning it on captures it again and attaches it to the same sender: no new offer, no new
WHIP POST, and the simulcast layers are kept. Viewers keep their subscription and the video
resumes without them doing anything. Joining with the camera off (
enter({video: false}), thenpublishCamera()) does not acquire the camera at all, but its m-line is negotiated (with the room's simulcast ladder, picked from the capture width you configured, 1280 by default), so the camera can be turned on later. When the camera you turn on is smaller than that, the layers it cannot feed are switched off on the sender. - Listeners. A listener that unmutes or turns its camera on is promoted to a publisher in
place (
mode:publisher): samepeer_id, new publish credentials, oneparticipant_updatedfor everyone (no leave/join, no chat line), then it publishes. The capture runs first: if it fails, you stay a listener.becomePublisher({audio, video})promotes without capturing (then callpublishCamera()yourself);promotedfires either way. A promotion is refused withconference_fullonly when the room is already overmax_peers(a promotion adds nobody). There is no way back to listener: a muted publisher with its camera off is fine. - Device failures.
publishCamera()captures the mic and camera together (one permission prompt), then each on its own when that fails. A missing or refused camera publishes audio-only, a missing mic video-only: the failed kind is reported off to the room,localMediaStateshowsaudioDevice/videoDeviceasdeniedorunavailable, anderrorfires with{operation: 'local_media', kind}. When nothing can be captured nothing is published and it rejects.setAudioEnabled(true)/setVideoEnabled(true)retry the capture each time and reject with aLocalMediaError(reason:denied,unavailableorno_provider) while it fails, so render the button as "unavailable, click to retry". A track that ends on its own (device unplugged, permission revoked) is reported off,videoDevice/audioDevicebecomesended, andlocalTrackEnded(kind)fires; turning it on again re-captures. When the WHIP start itself fails (a 5xx, a network error) the call rejects; the nextsetAudioEnabled(true)/setVideoEnabled(true)publishes again. - Switching devices.
setAudioEnabled(true, { track })/setVideoEnabled(true, { track })(or newconstraints) replace the live track of that kind on the same sender; the old one is stopped. - Moderator locks. While a moderator holds your audio (or video) gate off,
audioLocked(videoLocked) istrue: show the control locked ("Muted by moderator", "Camera off by moderator"). Turning that kind on rejects withConferenceErrormoderatedand captures or enables nothing; muting stays allowed, so keep the button usable while your own state is on (the user can mute before the gate opens). Your own flags and the gate are separate: when the gate opens your previous self state applies as it is (a mic you had muted stays muted).screenLockedis your screen share's gate. - Track ownership. Tracks the session captured are stopped when it is done with them
(
unpublish(),leave(),close(), a replaced publish). WithpublishCamera(yourStream)your self state wins (a mic you are muted on is disabled, a camera you are off on is stopped), and the session adds and removes tracks on your stream as the camera goes off and on, so a preview<video>showingsession.localStreamfollows along. Whatever track was sent as the camera is stopped when you turn the camera off, yours included. - A screen as your primary video. If you publish a
getDisplayMedia()stream as the camera and the user presses the browser's "Stop sharing", the video is reported off. Turning the video on captures the camera; to share the screen again as your primary video, callgetDisplayMedia()inside the click and pass it:setVideoEnabled(true, { track }). (For a screen share next to your camera, usestartScreenShare()below.) - Camera off and
video_state. The server never shows a participant withvideo: falseaspaused(camera off wins over its stall detection). Turning the camera off reportsliveif you had reported anything else, and a re-enter with the camera off does not re-send it.
Calls run one at a time, in order. setMediaState() only reports (it touches no track):
turning a kind off is kept for a re-enter at once, turning one on once the server accepted it.
Remote tiles. Drive the camera-off cover (the name) from participant.video, and the muted
badge from participant.audio and participant.moderation, not from the received track's
mute events: a remote camera off/on changes nothing in your subscription.
Spotlight and room state
await session.setSpotlight(peerId); // focus a participant for everyone (last writer wins)
await session.clearSpotlight();
session.on('spotlight', ({ peer_id, by }) => focus(peer_id)); // peer_id null: cleared
session.on('room', (room) => startTimer(room.session_started_at)); // the room timer started/stopped
session.on('recording', ({ composite, per_peer }) => showRecBadge(composite || per_peer));
session.on('participantReconnecting', (p) => showBadge(p.peer_id, 'reconnecting')); // keep its tiles
session.on('participantResumed', (p) => hideBadge(p.peer_id, 'reconnecting'));session.room, session.spotlight, session.screenSharer, session.sessionStartedAt and
session.recording hold the current values (from the join reply, the enter reply and the room
pushes). The server shows a publisher's video_state as paused while no video reaches its WHIP
session, whatever was reported, except while its video is false. media_state is for
publishers only (not_publisher); a listener is promoted by setAudioEnabled(true) /
setVideoEnabled(true).
Layers
Each subscription receives one simulcast layer. Pick it per tile without a new peer connection:
await session.setLayer(participant.publish_id, 'q'); // thumbnail; 'h' medium, 'f' full screen
await session.setLayer(participant.publish_id, 'off'); // pause its video, audio keeps flowing
await session.getSubscriber(peerId)?.setLayer('f'); // the same, from the subscriberlayer:set only affects you. When the requested layer is not flowing the server uses the nearest
one and moves you back when it returns. A room without simulcast has one layer: off and f
work, q/h reject with simulcast_disabled. A subscription still being set up is waited for;
one that does not exist rejects with not_subscribed. When the session re-establishes a
subscription (a failed PC, a reconnect) it starts on the layer you chose last, paused again if it
was off.
Screen share
const display = await navigator.mediaDevices.getDisplayMedia({ video: true });
const publishId = await session.startScreenShare(display); // or the video track
session.on('screenShareStopped', (reason) => resetShareButton(reason));
// … later
await session.stopScreenShare();A screen share is a second, video-only WHIP session on its own publish_id. The session sends
screen:start (one sharer per room, else screen_busy), POSTs the video track only (never audio,
which the server refuses), then screen:publishing, so viewers subscribe only once it is up.
Publishers and listeners may share. While somebody else shares, session.screenShareBlockedBy is
{peer_id, display_name}: show your share button disabled as "<display_name> is sharing"
(session.canStartScreenShare is the same check). screenShareStopped reports why it stopped: stopped
(stopScreenShare()), track_ended (the browser's own "Stop sharing"), ended (a moderator
stopped it, or a newer session of this device took over) or failed (out of retries). The session
closing stops it too, before closed: stopped on leave() / close(), ended on a kick, the
conference ending or a fatal error. The display stream is yours: stop its tracks on
screenShareStopped. A failed
screen PC is restarted with a fresh screen:start within mediaRecoveryAttempts.
Other participants' screen shares are subscribed like cameras: with autoSubscribe, the session
subscribes to a participant's screen_publish_id while its screen_published is true, and the
tracks arrive with source === 'screen' (session.getRemoteStream(peerId, 'screen'),
session.subscribe(peerId, { source: 'screen' })). defaultScreenRid sets their layer.
Moderation
Participants admitted with a moderator invite (session.isModerator) can act on others:
await session.moderate.mute(peerId); // server-enforced audio gate for everyone
await session.moderate.unmute(peerId);
await session.moderate.hideVideo(peerId); // and showVideo()
await session.moderate.set(peerId, { audio: 'off', video: 'off' }); // both in one change
await session.moderate.stopScreen(peerId);
await session.moderate.kick(peerId); // a disconnect, not a ban
session.on('moderation', ({ publish_id, audio, video }) => markGated(publish_id, audio, video));Anyone else gets a ConferenceError with reason: 'forbidden'. A gated publisher keeps sending;
the server just does not forward it. When the target is you, session.localMediaState shows
audioLocked / videoLocked and your own unmute is refused with moderated (see
Your mic and camera).
What the session handles for you
- Roster. It keeps a
peer_id → participantmap (session.participants). Therosterpush replaces the map, and theparticipant_*pushes update it. - Event stream (contract 2.0.0). After
enter, room events arrive asdeliverypushes with aseq. The session applies each one once, keeps its place (session.eventStream), and resumes from it when it re-enters after a reconnect, so missed events (chat lines included) are replayed in order.sendChat()sends aclient_msg_idand resends with the same id after a timeout, so a line is stored once; thechatevent fires once per lineid. - Broadcast status.
watchStreamStatus(playbackId, {liveStreamId, token})emitsbroadcastStatuswhen a broadcast tile's stream goes live or stops, on the session's own socket. - Subscriptions. With
autoSubscribe(the default), the session subscribes to every other participant whosemodeispublisherand whosepublishedistrue, and to every screen share whosescreen_publishedistrue. It closes a subscription when the participant leaves, when itspublished(orscreen_published) flips back tofalse, or when you callunsubscribe(), andDELETEs its WHEP resource so the server closes it at once. Each subscription is one recvonly peer connection, POSTed to/<publish_id>/conference_whepwith the subscribe token. A425(publisher not ready),503,408, a network failure or a POST timeout is retried afterRetry-After(orsubscriber.retryDelayMs). A401refreshes the token over the channel and retries once. A subscription that still fails to start, or whose peer connection fails, is started again up tomediaRecoveryAttemptstimes, 1 s apart; thenerrorfires, and auto-subscribe leaves that participant alone for 30 s unless its entry changes (session.subscribe(peerId)retries at once). PassautoSubscribe: falseand callsession.subscribe(peerId, { rid: 'q' | 'h' | 'f' })to choose which tiles to open. - No unpublish signal. The server never sets
publishedback tofalsewhen a publisher stops its WHIP session (unpublish()). Subscriptions to it fail and are retried within the budget above, then rest for 30 s at a time. - Publishing.
publishCamera(stream)POSTswhip_urlwith the publish token, then sendspublishing, so others only subscribe once your media is up. It uses simulcast (q/h/f, using the same resolution-aware ladder as the web client) when the room hassimulcaston. It prefers H264 when the room records per peer.startBitrateKbpssets the encoder's starting bandwidth estimate (?start_bitrate_kbps=; the server clamps it to 300–1500, default 1500), so pass a lower value on a known-weak uplink.unpublish()DELETEs the WHIP resource (other participants are not told; see above) and stops the tracks the session captured. A publish that fails to start again after a reconnect or afailedpeer connection is retried up tomediaRecoveryAttemptstimes; a republish always negotiates both m-lines, so a camera that was off when the connection was redone can still be turned on. - Tokens. The server pushes a fresh subscribe token at about 80 % of its lifetime; the session
applies it (
subscribeTokenevent) for later WHEP POSTs, PATCHes and DELETEs. As a fallback for a missed push (for example a suspended app) it callstoken:refreshat about 90 % (autoRefreshToken). You can also callrefreshSubscribeToken()yourself. - Device id. It generates a per-install
device_id(a UUIDv4) on first use and persists it, so the same install keeps the samepeer_id/publish_id. It is stored inlocalStoragewhen there is one and in memory otherwise. Treat it as a secret, because it owns the device's publish slot. - Reconnects and the grace. Only
leave()(and a kick) leaves at once. Anything else that ends the channel (a dropped socket,close(), the app being killed) keeps you in the room for the server's 30 s reconnect grace, as areconnecting: trueghost whose WHIP pipeline, screen share and WHEP subscriptions keep running; others see a badge, not a leave. When the socket drops, phoenix rejoins and the session enters again with the same profile, mode and media state (reconnecting/reconnectedevents). When the rejoin finds your own peer_id still in the room andenterreturns the same one, the participant was resumed: the subscriptions are kept, the camera publish is kept if your entry is stillpublishedand its peer connection has not failed (otherwise it is republished), and the screen share is kept if the room still holds it as yours (otherwise it is started again, for example after a half-open socket was superseded). When the grace ran out, every subscription is redone and the camera and screen republished. Channel requests you make while reconnecting (sendChat,setMediaState,setLayer,setSpotlight,stopScreenShare,moderate.*, …) are held and sent after the re-enter;publishCamera()andstartScreenShare()need theenteredstate and reject withnot_enteredmeanwhile. Akickedpush is final, includingsuperseded(a newer session of this device took over). The session never reconnects after one, and it never DELETEs your publish, because the newer session owns it. The session also gives up when a rejoin is refused for good (for example a revoked invite), or afterreconnect.maxAttemptsfailed socket reconnects. Apreview()that fails disconnects the socket, so a refused invite is not retried in the background; callingpreview()again reconnects.
Other requests: updateProfile({ displayName, subtitle }), sendChat(text),
loadChatHistory({ before, limit }), refreshSubscribeToken(). A refused request rejects with a
ConferenceError whose reason is the server's (for example conference_full, screen_busy,
forbidden, peer_not_found or invite_required).
React Native and Node
import { mediaDevices, RTCPeerConnection, MediaStream } from 'react-native-webrtc';
import AsyncStorage from '@react-native-async-storage/async-storage';
await session.connect({
httpBaseUrl,
slug,
invite,
storage: AsyncStorage, // persists the device id
rtc: {
webrtc: { RTCPeerConnection, MediaStream },
mediaDevices // where the session captures the mic and camera
}
});rtc.mediaDevices is anything with getUserMedia(constraints); it defaults to
navigator.mediaDevices when there is one.
The storage adapter is anything with getItem/setItem, sync or async. The socket uses the global
WebSocket; pass webSocket where there is none (for example the ws package on Node 20).
startScreenShare(track) wraps a bare track with rtc.webrtc.MediaStream (or the global one).
Session options
| Option | Default | Meaning |
|---|---|---|
| autoSubscribe | true | Subscribe to everyone publishing, and to the room's screen share. |
| defaultRid | server's choice | ?rid= for automatic camera subscriptions (q thumbnail, h medium, f full). |
| defaultScreenRid | server's choice | ?rid= for automatic screen-share subscriptions. |
| autoRefreshToken | true | Fall back to token:refresh at ~90 % of the token's lifetime when the server's push did not come. |
| reconnect | true ({ maxAttempts: 20 }) | Rejoin and re-enter after a drop. false ends the session on the first drop. |
| requestTimeoutMs / joinTimeoutMs | 10000 | Channel reply and join deadlines. |
| heartbeatIntervalMs | 10000 | Socket heartbeat. |
| subscriber | { maxAttempts: 5, retryDelayMs: 1000 } | WHEP POST retry budget. |
| mediaRecoveryAttempts | 3 | Restart a publish, screen share or subscription that failed to start or whose peer connection failed. |
| localMedia | { audio: true, video: 1280x720 ideal, captureMutedMic: true } | getUserMedia constraints for the mic and camera the session captures, and whether a muted mic is captured up front. |
connect() also takes websocketUrl (defaults to httpBaseUrl with ws(s)://), deviceId (to
supply your own), storage, webSocket and rtc (webrtc, mediaDevices, fallback
iceServers, rtcConfiguration, fetch, and timeouts).
Building blocks
WHIPPublisher and WHEPSubscriber are exported too, for apps that do their own signalling
(WHEPSubscriber.stop() DELETEs the subscription; close() only closes the PC).
simulcastEncodings(trackOrWidth) exposes the ladder, and WHIPPublisher.replaceTrack(kind,
track | null) / getSender(kind) swap what a running publish sends. The admin side (creating rooms and invites) is in
@ukrstream/beamcaster-js-sdk/api:
import { ConferencesApi } from '@ukrstream/beamcaster-js-sdk/api';
const api = new ConferencesApi({ httpBaseUrl, token: adminJwt });
const invite = await api.createInvite(conferenceId, { role: 'participant', max_uses: 10 });
share(invite.url); // https://<host>/conf/<slug>?invite=<jwt>A self-contained demo is at examples/conference.html
(how to run). The protocol contract suite
(tests/integration/conference-flow.test.ts) runs against a live Beamcaster with
BEAMCASTER_URL=… BEAMCASTER_ADMIN_EMAIL=… BEAMCASTER_ADMIN_PASSWORD=… npm run test:live
(see the file header for the invite-only and WebRTC options).
Styling
Import the default styles:
import '@ukrstream/beamcaster-js-sdk/dist/styles/defaultPlayer.css';The default stylesheet applies:
media-player {
width: 100%;
aspect-ratio: 16/9;
--media-brand: #ff0000;
}To customize, override in your own CSS:
media-player {
--media-brand: #3b82f6; /* change accent color */
aspect-ratio: 4/3; /* change aspect ratio */
border-radius: 8px;
overflow: hidden;
}Refer to the Vidstack CSS variables documentation for the full list of customizable properties.
API reference
| Method | Signature | Returns | Description |
|--------|-----------|---------|-------------|
| constructor | new BeamcasterClient(config?: Config) | BeamcasterClient | Create a new client instance |
| connect | connect(options: LiveStreamOptions) | Promise<this> | Connect to a stream |
| disconnect | disconnect() | void | Tear down player and connections |
| destroy | destroy() | void | Disconnect and remove DOM elements |
| play | play() | Promise<void> | Start playback (waits for media to become playable) |
| pause | pause() | Promise<void> | Pause playback |
| setMuted | setMuted(muted: boolean) | void | Set muted state (safe before the <video> exists) |
| seek | seek(time: number) | void | Seek to time in seconds (HLS only) |
| getCurrentTime | getCurrentTime() | number | Current playback position in seconds |
| getDuration | getDuration() | number | Stream duration in seconds |
| getPlayer | getPlayer() | HTMLVideoElement \| null | Underlying <video> element |
| listQualities | listQualities() | string[] | Available quality names |
| switchQuality | switchQuality(quality: string) | void | Switch to a named quality |
| getStreamStatus | getStreamStatus() | 'playing' \| 'stopped' \| null | Current stream status |
| on | on(event: BeamcasterClientEvent, callback) | this | Add event listener |
| off | off(event: BeamcasterClientEvent, callback) | void | Remove event listener |
TypeScript types
All types are exported from the package entry point:
import {
BeamcasterClient,
BeamcasterClientEvent,
} from '@ukrstream/beamcaster-js-sdk';
import type {
Config,
LiveStreamOptions,
ReconnectOptions,
ReconnectPhase,
ReconnectingPayload,
ReconnectFailedPayload,
StreamStatus,
StreamStatusPayload,
HLSManifestParsedPayload,
HLSErrorPayload,
DataChannelMessagePayload,
} from '@ukrstream/beamcaster-js-sdk';HttpError is exported as a value, not just a type, so you can branch on a failed signaling
request from an Error event:
import { HttpError } from '@ukrstream/beamcaster-js-sdk';
sdk.on(BeamcasterClientEvent.Error, (err) => {
if (err instanceof HttpError && err.isRateLimited) {
showBanner(`Server busy, retrying in ${Math.ceil((err.retryAfterMs ?? 0) / 1000)}s`);
}
});It also exposes isSessionGone (404/410) and isServerAnswerTimeout (408) alongside status,
url, method and bodyText. instanceof HttpError (or HttpError.isHttpError(err)) matches
errors from every entry point, including @ukrstream/beamcaster-js-sdk/conference, which bundles its own copy
of the class.
Browser compatibility
| Browser | WebRTC | WHEP | HLS | |---------|--------|------|-----| | Chrome 70+ | Yes | Yes | Yes (via hls.js) | | Firefox 60+ | Yes | Yes | Yes (via hls.js) | | Safari 14+ | Yes | Yes | Yes (native) | | Edge 79+ | Yes | Yes | Yes (via hls.js) | | iOS Safari 12+ | Limited* | Limited* | Yes (native) | | Android Chrome 70+ | Yes | Yes | Yes (via hls.js) |
* WebRTC on iOS Safari works in regular browser tabs but may have limitations in PWAs and WebViews on older iOS versions. Use HLS for maximum iOS compatibility.
License
MIT
