@bitskiff/host
The host SDK for a browser page or an Electron renderer: BitskiffHost opens sessions, hands back a
handle, and moves strings and bytes between the host and the phones seated in it. The phone side is
@bitskiff/client, a separate install. A headless Node process installs @bitskiff/host-node.
This page is the reference. The guides walk the whole thing: quickstart · channels · lifecycle and errors · node hosts
Install
npm install @bitskiff/host
Hello world
import { BitskiffHost } from '@bitskiff/host';
const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
console.log(session.joinCode.code, session.joinCode.url); // show these, or draw the QR
const press = session.channel('press'); // a channel is a name you wrote
const readout = session.channel('readout'); // reliable by default
press.on('message', (m) => { // only `press` reaches this
readout.send(m.from.id, `pressed ${m.data}`); // a message is a string or bytes
});
new BitskiffHost(options?)
Synchronous, does no network. Every option is optional, so new BitskiffHost() is a whole call—a host built without a key can resume but not open.
| Option | Type | What it is |
|---|---|---|
key | string | The one credential slot; the prefix says which credential (table below) |
platform | Partial<Platform> | Merged member by member over the browser default: storage, persistence, presence, crypto, random, timers, logger, now |
transport | TransportFactory | The engine adapter; the default is this package's own |
reconnect | Partial<ReconnectPolicy> | baseMs, capMs, minSpacingMs, stableMs, openBudgetMs |
api | HostApi | The control-plane client; the default is this package's fetch client |
e2ee | 'required' | This host mints its own identity key and encrypts every payload |
joinTrust | JoinTrust | The public key your backend signs join assertions with |
There is no endpoint option: the published package talks to the api it was built for.
The three host credentials
| Prefix | What it is | Where it belongs |
|---|---|---|
mrc_pk_ | publishable key | a page you serve, on an allowed origin |
mrc_sk_ | secret key | a server or a device you control; never a browser bundle |
mrc_at_ | token | a page or app behind your login, minted per user by your backend |
The SDK never parses the value—expiry, permissions and limits are the service's answers. A page that opened with a token holds an ordinary host token afterwards, and that is what a reload resumes on, so nothing fetches a second token for the reload.
BitskiffHost
| Member | Type | What it does |
|---|---|---|
open(options?) | Promise<Session> | Opens a session. Resolves when it is open; rejects with the BitskiffError that refused it |
resume(hostToken, options?) | Promise<Session> | Reattaches to a session this process already holds a host token for. Same rule |
sessions | ReadonlyMap<string, Session> | Live sessions by id |
close() | Promise<void> | Closes every session |
dispose() | void | Local teardown of every session; calling it twice is calling it once |
open() rejects when the api refuses. A key it will not take, a blocked origin, a spent quota
or a retry budget that ran out all come back as a thrown BitskiffError, and a refused session never
becomes a handle you hold:
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';
// Your own functions: draw the code, report what refused.
function draw(url: string): void { /* ... */ }
function report(code: string, requestId?: string): void { /* ... */ }
try {
const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
draw(session.joinCode.url); // your own function
} catch (error) {
if (!isBitskiffError(error)) throw error;
// `error.code` is the registry code -- `invalid_key`, `origin_not_allowed`, `quota_exceeded`,
// `invalid_token`. `error.resetAtUnixMs` is when a quota frees up, when the refusal named one,
// and `error.details.reason` says `expired`, `revoked` or `unknown` on an `invalid_token`.
report(error.code, error.requestId);
}
A link that drops *after* the session opened is a different thing and is not an error: the handle
goes reconnecting, then suspended if the budget runs out, and session.retry() is the way
back. The lifecycle guide has the
block that turns those phases into words for whoever is standing at the host.
SessionConfig—what open takes
Every field is optional and each is a plain session value; channels are not config.
| Field | Type | What it is |
|---|---|---|
maxControllers, maxViewers | number | Seat ceilings for the two classes |
idleTimeoutMs | number | Nobody joined for this long ends the session; 0 is unlimited |
maxSessionTimeMs | number | The session's own time limit; 0 is unlimited |
hostResumeWindowMs | number | The host grace: how long the service holds the session open for a host that dropped |
reconnectBudgetMs, backgroundBudgetMs | number | What a phone gets to reconnect, and to sit in the background |
joinCodeRotationS | number | The join code's rotation step, in seconds |
warningLeadMs | number | How long before a terminal deadline on('warning') fires |
inactivityMs | number | Per-seat inactivity window, kept by this SDK and restamped by markActive |
persist | { in?: PersistMedium; as?: string } | Where the host token is kept and which saved host this is; { in: 'none' } keeps nothing |
joinGrants | Grants | What a new seat is seated with; must not be empty |
label, metadata | string, Record<string, string> | Your own labels, echoed back on the session |
The service clamps these to the project's own limits and the handle reads back what it clamped.
Session—the handle open hands back
| Member | Type | What it is |
|---|---|---|
id | string | The session id |
state | SessionState | The snapshot: phase, changed, ended, suspended, grants, config, held |
lifecycle | SessionLifecycle | The last transition and the reason for it |
joinCode | JoinCode | { code, url, expiresAt }—always there on a handle you hold |
participants | ReadonlyMap<string, Participant> | The seats, by participant id |
channels | ReadonlyMap<string, HostChannel> | Every channel this session declared |
hostToken | string | undefined | The rotating host token a reload resumes on |
deadlines | readonly Deadline[] | Every armed deadline, session-scoped and seat-scoped |
now() | number | The monotonic clock this SDK stamps receivedAt and sentAt on |
apiNowMs() | number | undefined | The service's clock, absolute Unix ms, for counting a deadline down |
channel(name, spec?) | HostChannel | Declare a channel, or read back the one under that name |
markActive(id) | void | Restamp a seat's inactivity window |
setGrants(id, grants) | Promise<void> | Change what one seat may do |
boot(id, reason?) | Promise<void> | Remove one seat |
retry() | Promise<void> | Start the attach loop again from suspended |
close() | Promise<void> | End the session and tell the phones |
dispose() | void | Local teardown: the session runs out its host grace waiting for a restart |
Events, all through session.on(event, fn), each returning an unsubscribe function: state,
lifecycle, joinCode, participant, deadline, warning, error, diagnostic. The first
join code and the first state are already on the handle—subscribing does not replay them—so
read the snapshot, then subscribe.
JoinCode—the string a phone joins on
| Field | Type | What it is |
|---|---|---|
code | string | The whole string a phone passes to client.start(code). Under e2ee: 'required' it carries the host key fingerprint after a . |
url | string | code in the project's own join URL, ready for a QR |
expiresAt | number | Absolute Unix ms the service stops accepting this code |
Building your own link is buildJoinUrl(session.joinCode, { base }), or your own string with
code in it; the page it lands on hands that string straight back to start. Never split it—under encryption the half you dropped is what verifies the host.
qrMatrix(text, opts?) encodes to a boolean grid and qrSvg(text | matrix, opts?) to an SVG
string. Where the SVG goes is your page's: qr.innerHTML = qrSvg(session.joinCode.url).
Channels
session.channel(name, spec?) hands back the object every message goes through: its own send,
its own on('message'), its own counters and pressure. spec is
{ delivery?: 'reliable' | 'lossy'; maxHz?: number; maxBytes?: number; latestOnly?: boolean };
omitted, a channel is reliable. maxHz and latestOnly are lossy-only.
send(to, data, opts?) takes one participant id, an array of them, or everyone, and data is a
string or a Uint8Array. It answers 'sent' | 'queued' | 'dropped' and never throws for anything
the network did: a message over the channel's maxBytes is 'dropped' with a tooLarge
diagnostic, and so is one the lossy pace held back. There is no ack and no request-reply—a reply
is a message on a channel of your own.
The channels guide has the two delivery
classes, the limits and what a latestOnly channel is for.
Subpaths
| Subpath | What it is |
|---|---|
@bitskiff/host/node | createNodeTransport() for an Electron main-process or dual-bundled host that wants the Node transport with the browser platform. Needs @livekit/rtc-node, an optional peer |
@bitskiff/host/unattended | openForever(host, sessionConfig?, policy?): the reopen-on-end loop below |
Unattended hosts
A host with nobody standing at it—a signage host, a bot, a dedicated game host—opens another
session when one ends. openForever is that loop. A host like that is usually headless, so the
block below is the Node package's: same loop, same events, and the secret key comes from the
environment, never from a browser bundle and never from source.
import { BitskiffHost, type HostMessage } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';
// Your own functions: draw the code, forward a message, tell an operator, page someone.
function showQr(url: string): void { /* ... */ }
function play(m: HostMessage): void { /* ... */ }
function warn(error: unknown, strikes: number, retryInMs: number): void { /* ... */ }
function page(error: unknown): void { /* ... */ }
const host = openForever(new BitskiffHost()); // the key comes from BITSKIFF_SECRET_KEY
host.on('open', (session) => { // every session, the first one included
showQr(session.joinCode.url);
session.channel('press').on('message', (m) => play(m));
});
host.on('reopen', (_session, { attempt, reason }) =>
console.log(`reopened after ${reason} (attempt ${attempt})`),
);
host.on('openFailed', (error, { strikes, retryInMs }) => warn(error, strikes, retryInMs));
host.on('gaveUp', ({ error }) => page(error)); // an event, never a throw
openForever(host, sessionConfig?, policy?) takes three arguments and the policy is the third one,
not four more fields on the config: sessionConfig is what open takes, and policy is
initialBackoffMs (2000), maxBackoffMs (30000), maxStrikes (20) and reopenOn, a predicate
over the end reason that defaults to everything but disposed. A { maxStrikes: 5 } passed as the
second argument is read as a session config and the loop keeps its own defaults. The handle carries current(),
stop() and the four events above. It acts on ends only and never reconnects—a dropped
transport is the SDK's own loop, and this one would fight it.