@oddin-gg/havik-webrtc-player
v0.2.1
Published
Portable, dependency-free browser WebRTC client for havik-webrtc — HavikPlayer (watch/WHEP signaling + getStats telemetry), HavikMatchList (self-refreshing live match list), and HavikGateway.
Maintainers
Readme
@oddin-gg/havik-webrtc-player
Dependency-free browser client for Havik WebRTC — Oddin's sub-second live
esports streaming API. It negotiates the WebRTC session with the Havik
gateway, drops the live H.264 + Opus stream into your <video> element, and
hands you normalized playback telemetry. No framework, no build step, and auth
stays entirely in your code.
import { HavikPlayer } from '@oddin-gg/havik-webrtc-player';
const player = new HavikPlayer({
gatewayUrl: 'https://webrtc-dev.oddin-video.gg',
matchId: 'od:match:<uuid>',
videoEl: document.querySelector('video'),
});
await player.connect();Three exports:
| Export | What it does |
| ------ | ------------ |
| HavikPlayer | One viewer session: signaling handshake, playback, stats, end-of-stream handling. |
| HavikMatchList | Self-refreshing "what's live right now" list for building a match picker. |
| HavikGateway | Single config point (gateway URL + authed fetch) that spawns both, plus token minting and a liveness probe. |
Install
npm install @oddin-gg/havik-webrtc-playerESM only, zero runtime dependencies, hand-written TypeScript declarations
bundled (src/index.d.ts). No-build setups can copy
src/index.js in directly — it is a single self-contained module.
How it works
The player drives the gateway's server-offer watch flow — the gateway sends
the SDP offer, the browser answers (the reverse of WHEP). One
POST /watch/{matchId} mints a session, the player applies the offer, posts its
answer, trickles ICE, and media flows; disconnect() tears the session down.
Capacity, task routing, TURN relay, and codec choices are all server-side
concerns — the browser never configures them.
The gateway HTTP API is the stable, binding contract; this library is a convenience layer over it. Integrating from scratch, or need the full endpoint semantics, error model, and rate limits? Read the Havik WebRTC public API reference.
Authentication
Two independent surfaces, both kept out of the library:
Watching (
HavikPlayer) — production gateways require a short-lived watch token per match per viewer, minted by your backend (it owns the entitlement decision) and passed viatokenorgetToken. On dev the gateway is open and no token is needed.new HavikPlayer({ // … getToken: async () => fetchTokenFromYourBackend(matchId), // fresh on every connect() });getTokentakes precedence over the statictoken; returnnullfor open deployments; a throw fails theconnect().Listing & minting (
HavikMatchList,HavikGateway.mintWatchToken) — these call operator/tenant-tier endpoints. You supply anauthedFetchthat attaches whatever credential your host app holds (e.g. a Cognito Bearer token); the library never sees or stores it.
HavikPlayer
const player = new HavikPlayer({
gatewayUrl: 'https://webrtc-dev.oddin-video.gg',
matchId: 'od:match:<uuid>',
videoEl: document.querySelector('video'),
onState: (state, detail) => updateUi(state, detail),
onStats: (s) => console.log(`${s.width}x${s.height} @${s.fps}fps ${s.videoKbps}kbps rtt=${s.rttMs}ms`),
onError: (err) => console.warn('signaling error', err.status, err.message),
});
await player.connect();
// …
await player.disconnect(); // idempotentInstances are reusable: connect() fully resets per-session state, so
connect → disconnect → connect and reconnect-after-failure all work on the same
instance. From an unload handler, use
player.disconnect({ keepalive: true }) so the teardown request survives the
page closing.
States
onState(state, detail?) walks this machine:
| State | Meaning | Detail |
| ----- | ------- | ------ |
| idle | Constructed, nothing in flight. | |
| connecting | Handshake or ICE in progress (also re-emitted on a transient ICE drop). | note on re-emits |
| connected | Media is flowing. | ttffMs once the first frame paints |
| ended | Terminal. The server announced the match is over. | reason: 'match_ended' |
| failed | The session failed; recovery is the host's call. | error message |
| closed | Torn down by disconnect(). | |
End of stream
The server announces teardowns on a per-session control DataChannel just
before closing the peer — the player handles the protocol for you:
- Match over →
onState('ended', { reason: 'match_ended' }). Terminal: show your end-of-stream UI and don't reconnect. NoonErrorfires (nothing failed) and the instance stays reusable. - Non-terminal server closes (
server_shutdownduring a rolling deploy,stream_unavailable,stopped, or a reason this build doesn't know) surface asfailedwithsession closed by server (…)— run your normal reconnect logic; the match may still be live. - The notice is best-effort (a crashed task sends nothing), so classify
unexplained failures with
HavikGateway.isMatchLive():false→ the stream is over,true→ reconnect,null→ unknown (never treat as ended).
Failure messages are phase-aware: ICE could not establish only when the
session never connected, transport failed mid-session after it had.
Telemetry
onStats delivers a normalized snapshot from RTCPeerConnection.getStats()
every second (tune with statsIntervalMs): resolution, fps, video/audio
bitrate, packets lost, jitter, RTT, codecs, time-to-first-frame, bytes
received, frames decoded. See PlayerStats in the typings for the exact shape.
Errors & retries
Retryable signaling failures (429, 503 on POST /watch / POST /answer)
are retried automatically, honoring Retry-After — maxRetries: 2 (default)
means up to 3 attempts. Signaling errors reach onError as HavikPlayerError,
carrying status, retryable, and the parsed retryAfter seconds.
Options
| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| gatewayUrl | string | — | Gateway base URL (trailing slash optional). |
| matchId | string | — | Match URN, e.g. od:match:<uuid>. |
| videoEl | HTMLVideoElement | — | Target element; the player attaches the remote stream. |
| token | string \| null | null | Static Bearer watch token. |
| getToken | () => string \| null \| Promise<…> | — | Token provider, called fresh on every connect(). Wins over token. |
| iceServers | RTCIceServer[] | Google STUN | STUN for the browser's reflexive candidate. No client TURN needed — the server side provides relay. |
| statsIntervalMs | number | 1000 | onStats cadence. |
| maxRetries | number | 2 | Extra attempts on retryable handshake calls. |
| onState / onStats / onLog / onError | callbacks | — | See above; onLog streams leveled wire-protocol logs for debugging. |
HavikMatchList
A match picker without the plumbing: it owns the poll / diff / backoff loop and
calls onUpdate only when the list actually changes.
import { HavikMatchList } from '@oddin-gg/havik-webrtc-player';
const list = new HavikMatchList({
gatewayUrl: 'https://webrtc-dev.oddin-video.gg',
authedFetch: (url, init) => fetch(url, withBearer(init)),
intervalMs: 5000,
onUpdate: (matches) => renderPicker(matches),
onError: (err, status) => status === 403 && promptSignIn(),
});
list.start(); // one immediate load, then every intervalMs
// list.stop(); list.refresh({ force: true });Each MatchInfo row carries match_id (the URN you hand to HavikPlayer),
viewers, and — when the platform knows them — display metadata:
match_name, tournament_name, sport. Render those when present and fall
back to the URN; they are display-only, so never key logic on them:
const label = m.match_name
? [m.match_name, m.tournament_name, m.sport].filter(Boolean).join(' · ')
: m.match_id;HavikGateway
Set the gateway URL and authed fetch once, then spawn players and lists bound to it:
import { HavikGateway } from '@oddin-gg/havik-webrtc-player';
const gw = new HavikGateway({
gatewayUrl: 'https://webrtc-dev.oddin-video.gg',
authedFetch: (url, init) => fetch(url, withBearer(init)),
});
const list = gw.matchList({ onUpdate: renderPicker });
const player = gw.player({ matchId, videoEl });
await gw.mintWatchToken(matchId); // token, or null when minting is disabled
await gw.isMatchLive(matchId); // true | false | null — see "End of stream"Browser support
Any modern browser with RTCPeerConnection and native H.264 + Opus decoding —
current Chrome, Edge, Firefox, Safari, and their Android/iOS equivalents. No
MSE, no EME, no plugins.
Reference app
runstone is a plain-JS app built on this exact client (CI keeps the two byte-identical): sign-in, match picker, player, stats grid, and end-of-stream handling — a working example of everything above.
License
ISC © Oddin.gg
