Skip to content

Player kit ​

@vabu/streams-web is a small JavaScript package for the page that plays your stream. You keep your own player and your own buttons. The kit opens the viewer's session, keeps it alive, moves to the next source when one stops working, shows moments when the video reaches them, and sends quality data. It has no UI and no framework. It plays through the hls.js you already use.

If you do not have a player, use the Vabu player element. It is built on this kit.

Before you start ​

You need:

  • a publishable key, vpk_test_... while you build and vpk_live_... when you go live;
  • the id of a live stream (ls_...) or a video (ast_...), and an entitlement for your viewer, as in Access;
  • a route on your server that signs a short-lived customer token for the signed-in viewer.
sh
npm install @vabu/streams-web hls.js

hls.js 1.7 or later is a peer dependency. The kit attaches to your own instance, so you choose the version and the build.

Play a stream ​

ts
import Hls from 'hls.js'
import { attachHls, createStreamsSession } from '@vabu/streams-web'

const video = document.querySelector('video')!

const session = await createStreamsSession({
  apiBase: 'https://api.vabu.live',
  publishableKey: 'vpk_live_...',
  resource: { type: 'live_stream', id: 'ls_...' }, // or { type: 'asset', id: 'ast_...' }
  customerToken: async () => {
    const res = await fetch('/api/vabu-token')

    if (!res.ok) {
      throw new Error(`Token request failed: ${res.status}`) // never hand an error page to the kit as a token
    }

    return res.text()
  },
  collectorBase: 'https://collect.vabu.live', // optional: leave it out to send no quality data
})

const hls = new Hls()
const playback = attachHls({ session, hls, video })

await video.play()

createStreamsSession opens the viewer's session. It checks their entitlement and the limits on it, so a viewer who is already watching on another device can be refused here. session.grant has the playback sources in order. attachHls plays the first one.

From then on the kit:

  • sends a heartbeat every session.grant.heartbeat_interval seconds, with a fresh token each time;
  • asks your customerToken function for a new token a minute before the old one expires, and once more if the API refuses a token. After that it stops and tells you;
  • ends the session when you call session.end() and when the page is closed.

deviceId is 'auto' by default: a random id kept in localStorage, or a new one for each page load when storage is blocked. Pass your own string if you have one.

The token route on your server ​

The browser never sees your signing secret. It asks your own server, which already knows who is signed in.

ts
// Node, Express style
import jwt from 'jsonwebtoken'

app.get('/api/vabu-token', requireLogin, (req, res) => {
  const token = jwt.sign({ sub: req.user.id, aud: 'vabu' }, process.env.VABU_CUSTOMER_TOKEN_SECRET!, {
    algorithm: 'HS256',
    keyid: process.env.VABU_CUSTOMER_TOKEN_KID,
    expiresIn: '10m',
  })

  res.type('text/plain').send(token)
})

sub is your own reference for the viewer, the same one you used when you granted the entitlement.

When something goes wrong ​

Opening can fail, and a session can end later. Both give you a StreamsError with a code you can show or log.

ts
import { StreamsError, type StreamsSession } from '@vabu/streams-web'

let session: StreamsSession

try {
  session = await createStreamsSession(options)
} catch (error) {
  if (error instanceof StreamsError) showProblem(error.code, error.reason)
  throw error
}

session.on('ended', (error) => showEnded(error.reason))
session.on('error', (error) => console.warn(error.code, error.fatal))
codeWhat happenedWhat to say
concurrency_limit_reachedThe viewer is already watching and the entitlement allows one stream at a time.Playing on another device.
device_limit_reachedThe entitlement allows fewer devices than this viewer has used.They reached their device limit.
view_limit_reachedThey used all the views the entitlement allows.They have no views left.
access_deniedNo entitlement, or it expired, was revoked or paused. reason says which.Their access has ended.
invalid_customer_tokenYour token was refused, even after the kit asked for a new one. reason says why.Check your signing key and clock.
token_unavailableYour customerToken function failed. The reason is in error.cause.Try again.
network_errorThe API could not be reached.Check the connection.
no_playback_sourceThe stream or video has no playable source.Not available right now.

When a session ends after it opened, ended carries error.reason:

reasonMeaning
replaced_by_newer_sessionThe viewer started watching on another device and the entitlement allows one.
entitlement_inactiveThe entitlement was revoked, paused or expired.
missed_heartbeatsThe session lapsed, usually because the connection was down.
token_rejectedThe customer token was refused twice.
clientYour code, or the closing page, ended it.

When a session ends the kit stops loading and pauses the video, so access stops with the session. A failed heartbeat on a bad connection is reported through error with fatal: false and tried again soon, inside the time the server waits before it drops the session.

Sources and failover ​

A grant lists sources in order. The kit plays the first and watches for trouble. It moves to the next source:

  • on a fatal network error;
  • on a second fatal media error within a minute of a recovery attempt (the first one gets recoverMediaError());
  • after failover.max_errors source errors inside failover.window_seconds.

By default hls.js waits more than a minute before it gives up on a dead source. The kit sets manifestLoadPolicy, playlistLoadPolicy and fragLoadPolicy on your instance so that a dead source becomes fatal in about 10 seconds. A live stream starts again at the live edge on the next source. A video starts again where it was. If you already set those policies, the kit replaces them.

When there is no other source to move to, the kit loads the same one again, with the newest token, after 2, 4, 8 and 16 seconds, and then every 16 seconds, for about a minute. While the browser is offline it waits for the network and does not use up retries. Only after that does it report playback_failed. Soft errors on the only source never end playback. A source is healthy again after 5 seconds of playing.

With native HLS (Safari without hls.js) the kit cannot swap tokens in requests. About a minute before the token the player loaded expires, it loads the newest URL from the last heartbeat and carries on from the live edge. A native player that waits for 10 seconds without moving is treated as a source error.

ts
playback.on('source', ({ from, to, reason }) => {
  console.log(`${from.provider} to ${to.provider}: ${reason}`)
})

playback.on('error', (error) => {
  // Every source failed. Show a message, then try again from the first one:
  playback.retry()
})

Each heartbeat returns a fresh grant. When only the token in a URL changed, nothing reloads and the new token goes out with the requests that follow. When Vabu reorders the sources and sets switch to "now", the kit moves to the new first source. With "on_error" it remembers the order for the next failure. For this to work, keep hls.js's default loader: the kit swaps tokens in xhrSetup and fetchSetup, and calls yours after it.

To treat a frozen live playlist as an error as well, set liveMaxUnchangedPlaylistRefresh in your hls.js config. Those errors count towards max_errors.

Moments in step with the video ​

A moment is something your server puts on the stream, such as a paid shoutout. The kit shows it when the viewer's video reaches it, not when it arrives.

ts
playback.moments.followFeed({ intervalMs: 2000 }) // polls the viewer feed with your publishable key
playback.moments.add({ id, occurred_at, label, type, payload, display_ms }) // or hand one over from your own realtime channel

playback.on('moment', (moment) => overlay.show(moment.label, moment.payload))
playback.on('momentend', (moment) => overlay.hide(moment.id))

How it works:

  • The playhead's clock is hls.playingDate, from the EXT-X-PROGRAM-DATE-TIME tags in the stream. On a browser that plays HLS natively, it is video.getStartDate() plus video.currentTime.
  • Without those tags the kit estimates the clock as the current time minus hls.latency, the distance from the live edge.
  • A moment shows 150 ms early, so that drawing it is not late. It shows once, stays for display_ms, and is dropped if it is more than 2 minutes behind the playhead.
  • Every pipeline stamps the video with its own clock. If one source runs early or late, set playback.source.pdt_offset_ms, the milliseconds to add to that source's clock. It is 0 by default.

Draw label and payload as text, never as HTML. The feed is public to anyone who knows the stream id.

Data saver and audio only ​

ts
playback.setDataSaver(true) // renditions go no higher than 360p, or the closest lower height
playback.setAudioOnly(true) // returns false when the source has no audio-only rendition

hls.js hides audio-only variants from its own level list, so the kit reads them from the manifest. Turning audio only on or off loads the source again, and playback stops for a moment.

iPhone and native HLS ​

When hls.js cannot run but the browser plays HLS itself (iPhone Safari before 17.1), the kit sets video.src. Sessions, moments and quality data work the same way. Failover works on the video element's error event. Data saver and audio only need hls.js. Leave hls out to use native playback only:

ts
attachHls({ session, video })

Quality data ​

With a collectorBase, the kit sends beacons to /v1/playback on the collector: start with the startup time, heartbeat every 30 seconds while the video plays, rebuffer, error and end. Counters (watch_ms, rebuffer_count, rebuffer_ms, errors) add up over the session. Each beacon also has the current and average bitrate, the provider of the source in use, the session id, the playback id, a coarse device type (mobile, desktop or tv) and the connection type when the browser offers one. It never sends the viewer's name, contact details or the customer token.

Clean up ​

ts
playback.destroy() // detaches from the video and puts your hls.js config back
await session.end() // frees the device slot
hls.destroy() // yours to destroy

Vabu OS by Vabu Group