@bitskiff/host
The host SDK for a browser page or an Electron renderer. BitskiffHost opens sessions and moves
strings and bytes between the host and the clients in them. The client side is
@bitskiff/client, a separate install. A headless Node process installs @bitskiff/host-node.
This page is the reference. The guides cover the rest: quickstart · channels · lifecycle · node hosts
Install
Take the tarball from the downloads page: npm install takes a URL as well as
a name.
npm install https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-host.tgz
To check a download, compare sha256sum bitskiff-host.tgz with the release's line at https://api.dev.bitskiff.com/v1/releases/<version>/digests, which the api serves apart from the downloads.
Hello world
import { BitskiffHost, type Message } from '@bitskiff/host';
const host = new BitskiffHost({ key: 'bsk_pk_...' });
const press = host.channel('press'); // declare every channel before open
const readout = host.channel('readout'); // reliable by default
press.on('message', (m: Message) => { // only `press` reaches this
readout.send(m.client, `pressed ${m.data}`); // a message is a string or bytes
});
const session = await host.open(); // open last: it seals the channels
console.log(session.joinCode.code, session.joinCode.url); // show these, or draw the QR
session.on('clientJoined', () => { // a broadcast reaches whoever is
readout.broadcast('ready'); // listening when it is sent
});
import { BitskiffHost } from '@bitskiff/host';
const host = new BitskiffHost({ key: 'bsk_pk_...' });
const press = host.channel('press'); // declare every channel before open
const readout = host.channel('readout'); // reliable by default
press.on('message', (m) => { // only `press` reaches this
readout.send(m.client, `pressed ${m.data}`); // a message is a string or bytes
});
const session = await host.open(); // open last: it seals the channels
console.log(session.joinCode.code, session.joinCode.url); // show these, or draw the QR
session.on('clientJoined', () => { // a broadcast reaches whoever is
readout.broadcast('ready'); // listening when it is sent
});
new BitskiffHost(options?)
Synchronous, and it touches no network. Every option is optional, so new BitskiffHost() is a
complete call. A host built without a key can resume but not open.
| Option | Type | What it is |
|---|---|---|
key | HostKey | The one credential slot; the prefix says which credential (table below). A string, or a function returning one, which is how an unattended host reads a fresh key on every attempt |
reconnect | Partial<ReconnectPolicy> | base, cap, jitter, minSpacing, stable, joiningBudget, openBudget |
e2ee | 'required' | This host mints its own identity key and encrypts every payload |
e2ee is this host's own requirement. The project has one of its own, in the console's Settings
panel, and that is the switch the service holds every session to.
The two must agree. A required
host against a project that is off, or a plain host against a project that is required, is
refused: open() rejects with validation and details.option of e2ee. The
console guide is where the project's switch
lives.
There is no endpoint option: the published package talks to the service it was built for. Three
more options—platform, transport and api—reach under the SDK itself and are under
Advanced. Those three and the three above are the whole type.
Host credentials
The prefix on key says which of the three you passed:
| Prefix | What it is | Where it belongs |
|---|---|---|
bsk_pk_ | publishable key | a page you serve, on an allowed origin |
bsk_sk_ | secret key | a server or a device you control, never a browser bundle |
bsk_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 a reload resumes on that, so you do not have to fetch a second token for the reload.
BitskiffHost
The host's members:
| Member | Type | What it does |
|---|---|---|
channel(name, spec?) | HostChannel | Declare a channel before open() |
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 |
platform | Platform | This host's platform, shared by every session it opens |
reconnect | ResolvedReconnectPolicy | This host's resolved reconnect policy, shared by every session. Its members are the milliseconds the loops compute with, so they carry Ms: baseMs, capMs |
open() rejects when the service refuses. A key it will not take, a blocked origin, a spent
quota and an exhausted retry budget all arrive as a thrown BitskiffError. A refused open never
becomes a session, so catch the rejection.
A refusal waiting can clear—concurrency_limit,
rate_limited, unavailable, a network that never reached the service—is not the rejection:
open() waits and asks again until openBudget runs out, 2m by default, and
open({ openBudget: '0s' }) makes one attempt instead. Every code one can carry, which ones wait,
and what to do about each, is in
open() rejections:
import { BitskiffHost, isBitskiffError, type ErrorCode } from '@bitskiff/host';
// Your own functions: draw the code, report what refused.
function draw(url: string): void { /* ... */ }
function report(code: ErrorCode, requestId: string | undefined): void { /* ... */ }
try {
const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();
draw(session.joinCode.url); // your own function
} catch (error) {
if (!isBitskiffError(error)) throw error;
// The lifecycle guide's "open() rejections" table is every `error.code` this catch can see,
// with what to do about each. `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);
}
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';
// Your own functions: draw the code, report what refused.
function draw(url) { /* ... */ }
function report(code, requestId) { /* ... */ }
try {
const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();
draw(session.joinCode.url); // your own function
} catch (error) {
if (!isBitskiffError(error)) throw error;
// The lifecycle guide's "open() rejections" table is every `error.code` this catch can see,
// with what to do about each. `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 BitskiffError carries code, message, retryable, clears, source, retryAfterMs,
resetAtUnixMs,
status, limit, details, requestId, closeCode and cause.
clears is waiting when waiting alone can make the same call succeed, and never when
something else has to change first. source is api, sdk or edge. edge is a 429 or 403 the
edge in front of the service answered with no body at all. BitskiffErrorInit is what the
constructor takes: the same shape minus clears and resetAtUnixMs, which the constructor
derives from code.
A link that drops after the session opened is not an error. The session goes reconnecting, then
suspended if the budget runs out, and session.retry() starts it again. The
lifecycle guide 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.
A Duration is a string with its unit on it: 500ms, 90s, 5m, 1h30m. The units are ms,
s, m and h, largest first, with no spaces, no sign and no bare number:
Durations has the grammar.
| Field | Type | What it is |
|---|---|---|
maxClients | number | How many clients the session holds at once. There are no per-role ceilings |
idleTimeout | Duration | Nobody joined for this long ends the session; 0s is unlimited |
maxSessionTime | Duration | The session's own time limit; 0s is unlimited |
hostResumeWindow | Duration | The host grace: how long the service holds the session open for a host that dropped. A host that never arrives is a different clock: the session waits 60 s, the ticket's life, and then ends host_resume_expired |
reconnectBudget, backgroundBudget | Duration | What a client gets to reconnect, and to sit in the background |
joinCodeRotation | Duration | The join code's rotation step. The floor is 15s, and it rotates on a whole number of seconds, so a step that is not one is refused |
warningLead | Duration | How long before a terminal deadline on('warning') fires |
inactivity | Duration | Per-client inactivity window, kept by this SDK and restamped by client.markActive() |
terminationRules | TerminationRule[] | When the service ends a client, beyond the two budgets. Omit it and the session takes the project's default list; [] is no rules. At most 16. A rule outside its bounds is a validation error, never clamped: import @bitskiff/host/expressions to get it in the call rather than from the service |
persist | { in?: PersistMedium; as?: string } | Where the host token is kept and which saved host this is. { in: 'none' } keeps nothing |
defaultGrants | Grants | What a client joins with. Omit it and a client joins as CONTROLLER: it sends input and receives broadcasts |
label, metadata | string, Record<string, string> | Your own labels, echoed back on the session |
openBudget | Duration | What bounds the retry loop inside this one open() call and nothing else; 0s is one attempt |
idempotencyKey | string | The Idempotency-Key this one open() call sends, so a retried call can attach to the response of one that already went through |
A string the duration grammar refuses is not clamped: it is a validation error whose
details.reason is duration. An in-grammar value above the project's own limit is clamped to it.
Read session.state.config to see what you got.
Session
What open hands back.
| Member | Type | What it is |
|---|---|---|
id | string | The session id |
state | SessionState | The snapshot: phase, changed, sessionId, config, limits, resumed, grants, epoch, transport, suspended, ended |
lifecycle | SessionLifecycle | The last transition and the reason for it |
joinCode | JoinCode | { code, url, expiresAt }. Always there on a session you hold |
capacity | CapacityNotice | Your project's standing against its usage limits: { status, limits, at }, ok over an empty list until a limit is close or spent |
clients | ClientRoster | Every client in the session, in join order: get(id), has(id), size, keys(), values(), entries(), iterable |
channels | ReadonlyMap<string, HostChannel> | The host's channels, by name |
hostToken | string | undefined | The rotating host token a reload resumes on |
deadlines | readonly Deadline[] | Every armed deadline, session-scoped and client-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) | HostChannel | Look up a channel the host declared |
retry() | Promise<Session> | Start the attach loop again from suspended; resolves to this session, as the client's retry() resolves to its client |
close() | Promise<void> | End the session and tell every client |
dispose() | void | Local teardown: the session runs out its host grace waiting for a restart |
The per-client verbs are on the Client, not here: client.setGrants(grants),
client.boot(reason?) and client.markActive().
Events all go through session.on(event, handler, options?), which returns a () => void that
removes that one listener: state, lifecycle, joinCode, capacity, clientJoined,
clientChanged, clientLeft, deadline, warning, error, diagnostic.
Pass { signal } to close a group of listeners with one AbortController.
session.off(event, handler) stands beside it for a handler you already hold; the stop on handed back is what the samples use. There is no
session.on('message'): every message is heard on a channel.
Client
What session.clients holds and what every handler is handed. One object per client, the same
instance from clientJoined to clientLeft, across a reconnect.
| Member | Type | What it is |
|---|---|---|
id | string | Stable for the life of this client; never reused |
state | ClientState | A snapshot replaced whole: attested, grants, number, connection, presence, presenceSince and connectionSince (monotonic), deadlines, transport, joinedAt (service clock), deviceClass, networkClass, channels, subject, e2ee, left |
state.subject | string | undefined | The subject of the join token this client presented: your own user id, as your backend minted it. Absent for a client that joined with no token |
on(event, handler, options?) | () => void | change and left, each handed the same event object the session-wide handler gets (change is { client, change, previous, at, stopPropagation() } and left is { client, reason, at, stopPropagation() }); they run first and can stop it |
off(event, handler) | void | Drops a handler on added |
setGrants(grants) | Promise<void> | Change what this client may do |
boot(reason?) | Promise<void> | Remove this client. reason is a BootReason—'booted' (what an omitted reason reads as), 'idle' or 'policy'—and the client reads it on state.ended.bootReason. The REST boot route takes a fourth value, BOOT_REASON_KEY_UNVERIFIED, which reaches the client as state.ended.reason === 'keyUnverified' and not as a bootReason |
markActive() | void | Restamp this client's inactivity window |
Read the snapshot first, then subscribe. Subscribing does not replay, and the first join code and
the first state are already on the session when open resolves.
Grants
What a client may do. Five booleans, all of them required.
| Field | Type | What it allows |
|---|---|---|
sendInput | boolean | Send on an input channel |
receiveInput | boolean | Receive every input message in the session |
broadcast | boolean | Broadcast on a channel |
receiveBroadcast | boolean | Receive broadcasts |
manage | boolean | Call setGrants, boot and close |
An input channel is a lossy one. sendInput gates lossy sends and nothing else, so a client
without it still sends on its reliable channels and its lossy sends come back
{ dropped: 'noGrant' }.
The sets below are exported, so a call reads as a name and not as five flags:
| Export | Type | What it is |
|---|---|---|
CONTROLLER | Grants | Sends input, receives broadcasts. What a client joins with |
VIEWER | Grants | Receives broadcasts and nothing else |
HOST | Grants | What the host itself holds: receives input, broadcasts, manages. Not a set to hand a client |
NO_GRANTS | Grants | Every field false |
BUNDLE_GRANTS | { [name]: Grants } | The sets by name: controller, viewer, host |
import { BitskiffHost, VIEWER, type Session } from '@bitskiff/host';
const session: Session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();
for (const client of session.clients) {
await client.setGrants(VIEWER); // this one can watch and nothing else
}
import { BitskiffHost, VIEWER } from '@bitskiff/host';
const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();
for (const client of session.clients) {
await client.setGrants(VIEWER); // this one can watch and nothing else
}
Pass an object of your own for anything else: { ...CONTROLLER, manage: true }.
JoinCode
The string a client joins on.
| Field | Type | What it is |
|---|---|---|
code | string | The whole string a client passes to join(code). Under e2ee: 'required' it carries the host key fingerprint after a . |
url | string | The project's join URL with code in the fragment, <joinUrl>#<code>, ready for a QR |
expiresAt | number | Absolute Unix ms the service stops accepting this code |
A new code is rendered at each rotation step, and the steps are on the service's clock rather than
on your session's: a session opened part-way through a step rotates at the end of that step, so the
first code can be shorter-lived than joinCodeRotation.
The service accepts one step either side of the one that rendered a code, so a code a client sees
stays accepted for at least one full step and at most two—30 to 60 s at the 30s default.
expiresAt is the end of the step after the code's own, which is the instant that window closes, so
a client that scanned just before a rotation still joins. Read the code off the joinCode event
rather than holding the first one.
The code rides in the fragment so it never reaches a server, a proxy or an access log: a joinUrl
of https://play.example.com/join gives https://play.example.com/join#1CAbCdEfG1HPWtg.
Build your own link with buildJoinUrl(session.joinCode, { base }), which builds the same shape
over a base of your own, or put code in a string of your own. The page it lands on hands that
string straight back to join. Never split the code: under encryption, the half you drop is what
verifies the host.
qrMatrix(text, opts?) encodes to a boolean grid and qrSvg(text | matrix, opts?) to an SVG
string. Put the SVG on the page yourself: qr.innerHTML = qrSvg(session.joinCode.url).
Channels
host.channel(name, spec?), before open(), hands back the object every message goes through, with
its own send, its own broadcast, its own on('message'), its own counters on channel.counters
and its own congested reading. spec is
{ delivery?: 'reliable' | 'lossy'; ordered?: boolean; lifetime?: Duration; maxHz?: number;
maxBytes?: number; latestOnly?: boolean }. Omit it and the channel is reliable and ordered.
lifetime and latestOnly require delivery: 'lossy'. On a channel declared reliable—the default—either is a validation throw at channel(). ordered says whether a
receiver is handed this channel's messages in the order they were sent; omitted, it is true on a
reliable channel and false on a lossy one.
Every client is handed the row. A lossy channel with no maxHz is paced at 30 a second. On a
direct link a lossy channel may run at channel.linkMaxHz, never below maxHz. On a
reliable channel maxHz defaults to 20 and paces nothing: the service only reserves it. open()
seals the table, and a later host.channel() throws validation. After the open,
session.channel(name) looks a channel up.
broadcast(data, opts?) reaches every client that named the channel. send(client, data, opts?)
reaches the client you name, and takes an array of them; there is no target-less send. data is
a string, an ArrayBuffer, or any view over one (Sendable, exported from this root). Both verbs
return 'sent' or { dropped } with one of the reasons below, and never throw for anything the
network did.
| Result | What happened |
|---|---|
{ dropped: 'noClient' } | every client you named has left |
{ dropped: 'notListening' } | they are here but have not named this channel. This is why a client page names its channels before it joins |
{ dropped: 'size' } | the message is over the channel's maxBytes |
{ dropped: 'rate' } | past the channel's maxHz, or past the project's bytes a second |
{ dropped: 'pressure' } | the send buffer is behind, or a reliable queue is full |
{ dropped: 'noGrant' } | this host's grants do not carry what the topic needs. HOST grants never carry sendInput, so a send the wire routes as input is this, in every phase |
{ dropped: 'notOpen' } | the session is not open |
A lossy channel has no unicast at all: send(client, ...) on one throws validation at the call
site, because the downlink is one packet to every client on the channel. There is no ack and no request-reply, so send a
reply on a channel of your own.
on('message', handler, options?) returns a () => void that removes that one listener. Pass
{ from: client } to hear one client alone: those handlers run before the unfiltered ones, and
m.stopPropagation() in one keeps the unfiltered ones from running. In a handler, m.client is
the client that sent the message.
The channels guide has the two delivery modes, the limits and what a
latestOnly channel is for.
Peer and relay
Every client starts on server, the Bitskiff connection. Two more transports carry a client's
data over a WebRTC link to the host. Declare each one you want before open:
peer(): direct. The bytes go from the client to the host and never through Bitskiff, so they are logged, never metered.relay(): through Bitskiff's relay. Neither end sees the other's address. Relayed bytes are metered to your project.
import { BitskiffHost, peer, relay } from '@bitskiff/host';
const host = new BitskiffHost({ key: 'bsk_pk_...' });
host.transport(peer());
host.transport(relay());
const session = await host.open();
- A client moves onto a transport only when both ends declared it. A host that declares neither
serves every client on
serveralone. - Leave
relay()out and no relayed bytes are ever billed to your project. peer()shows each client's network address to the host and the host's to each client. An IP address is personal data in most privacy regimes, so name it in your privacy notice.- A client on
peerorrelaysends straight into the host's WebRTC stack. For a public venue with open joins, passallowed: falseand callclient.allow(settings)for each client you trust. - Both take the same options:
allowed,maxConnections,silenceMs,idleCloseMs,allocate,release, andpeerConnectionfor a Node host.
Release and rejoin
By default every client and the host keep their Bitskiff connection for the whole session. Release is an option you turn on per transport. Once a client's data rides that transport, the client can let its Bitskiff connection go, and the host serves it over the transport until it rejoins. Nothing releases on its own.
import { BitskiffHost, bluetooth } from '@bitskiff/host';
const host = new BitskiffHost({ key: 'bsk_pk_...' });
const bt = host.transport(bluetooth({ release: true }));
const session = await host.open();
// Start Bluetooth from a tap: c.connect(bt) on a client the session holds.
session.on('clientChanged', (e) => {
if (e.change === 'released') console.log(`client ${e.client.state.number} released`);
if (e.change === 'rejoined') console.log(`client ${e.client.state.number} is back`);
});
release: trueis taken bypeer(),relay()andbluetooth(). A transport that cannot carry control throwsvalidation.- A released client reads
connection: 'released', and itsserverrecord readsunavailablewith reasonreleased. The host runs its background budget and reports its end to Bitskiff. session.release()lets the host's own connection go once every client the session holds is released. It resolvesreleasedorrefused.session.rejoin()takes it back, and the host rejoins on its own when a client does.session.on('release', fn)hears each step of the host's own release.- While the host is released, a new join is refused
host_released. A host that expects late joiners does not release.
Released time is logged, never metered.
Advanced
Three more options on new BitskiffHost() reach under the SDK and replace a piece of it. Leave them
unset unless you are running the host somewhere the defaults do not fit.
| Option | Type | What it is |
|---|---|---|
platform | Partial<Platform> | The browser pieces the SDK stands on, merged member by member: storage, persistence, presence, crypto, random, timers, logger, now, wallNow |
transport | EngineTransportFactory | The real-time transport; the default is this package's own |
api | HostApi | The client for the service's HTTP routes; the default is this package's fetch client |
An EngineTransportFactory hands back an EngineTransport: the connection object it drives, with its own
state, path, limits and the events it raises.
The default platform is built from four exports on this root, so an app that replaces one member
can keep the rest:
| Export | What it is |
|---|---|
browserPlatform(overrides?) | The whole default: sessionStorage, the browser's presence signals, crypto.subtle, the page's timers and clock. Anything you pass is merged over it member by member |
browserStorage() | The sessionStorage store on its own, for the storage member |
browserPresence() | document visibility and window online: six of the eight PresenceEvent members. freeze and resume come from a runtime that reports them |
browserPersistence(logger?) | Where the host token is kept: sessionStorage, so this tab's session survives this tab's reload |
Subpaths
Three imports sit beside the package root:
| 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 node-datachannel, an optional peer |
@bitskiff/host/unattended | openForever(bitskiff, { session, policy }), the reopen-on-end loop below, and DEFAULT_UNATTENDED_POLICY, its defaults |
@bitskiff/host/expressions | The local checker for terminationRules. Import it for its effect and open() refuses a bad rule in the call, with the checker's message; without it the service sends the same refusal. It also exports checkExpression and checkTerminationRules |
Unattended hosts
A host with nobody standing at it, such as a signage host, a bot or a dedicated game host, has to
open another session when one ends. openForever is that loop.
openForever(bitskiff, { session, policy }) takes one options object: session is what open
takes, and policy is the loop's own. Both are optional; an unknown key in that object, or a
third argument, is a synchronous throw.
| Field | Default | What it is |
|---|---|---|
initialBackoff | '2s' | The first doubling step. With full jitter the first wait is 1 to 3 s |
maxBackoff | '5m' | The ceiling that doubling stops at |
maxRetryAfter | '5m' | The ceiling on a wait the server named. An edge ban is the one wait honoured in full |
attentionBackoff | '5m' | The floor under a wait whose last refusal was one that waiting does not clear |
stableAfter | '10m' | How long one serving run must last before the attempt count goes back to zero |
recoveringWithin | '30s' | How long a host may go without serving before health.status reads down |
reopenAfterClose | '1m' | The wait after a session a person or this application closed |
jitter | 'full' | Drawn over the step and added above the floor |
minSpacing | '1s' | The floor between two opens |
reachabilityTimeout | '5s' | How long one reachability probe may wait for an answer |
reopenOn | () => true | A predicate over the end reason. Every reason it refuses ends the loop |
The defaults in that table are exported together as DEFAULT_UNATTENDED_POLICY, so a policy of
your own can start from them and change one field. It comes from the same subpath as the loop:
import { BitskiffHost } from '@bitskiff/host';
import { openForever, DEFAULT_UNATTENDED_POLICY } from '@bitskiff/host/unattended';
import type { UnattendedLoop } from '@bitskiff/host/unattended';
const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });
const loop: UnattendedLoop = openForever(bitskiff, {
policy: { ...DEFAULT_UNATTENDED_POLICY, reopenAfterClose: '30s' },
});
import { BitskiffHost } from '@bitskiff/host';
import { openForever, DEFAULT_UNATTENDED_POLICY } from '@bitskiff/host/unattended';
const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });
const loop = openForever(bitskiff, {
policy: { ...DEFAULT_UNATTENDED_POLICY, reopenAfterClose: '30s' },
});
The loop carries session, health, stop(), detach() and five events: open, reopen,
openRefused, health and stopped. It never gives up: a refusal that waiting cannot clear
waits longer before the next attempt, then is tried again, because the loop is not the judge of
what a person has fixed.
It has three ends and three only—stop() or detach(), a reopenOn
that returned false, and a disposed host. It acts on ends only and never reconnects, because the
SDK already has its own loop for a dropped transport.
stop() is the shutdown: it closes the session in hand, so the slot frees now. detach() is the
restart: it disposes the handle, so the session runs out its host grace and a restarted process
resumes it on the same join code.
A host like that is usually headless, so the worked example is on the @bitskiff/host-node page: the same loop and the same events, with the secret key coming from the environment rather than a bundle or source.
Unattended hosts is the guide to running one: fallback content, what says a host needs a person, and the settings to change before a fleet ships.
Reference
The rest of the package root. Nothing else is exported, so what autocomplete offers inside
import { } is this page.
Constants
| Export | Value | What it is |
|---|---|---|
MAX_CTL_PAYLOAD_BYTES | 14080 | The largest payload one reliable send carries |
MAX_CTL_ENVELOPE_BYTES | 14336 | That payload plus its framing, which is what the transport sees |
MAX_INPUT_PAYLOAD_BYTES | 1152 | The largest payload one lossy send carries |
MAX_INPUT_ENVELOPE_BYTES | 1200 | That payload plus its framing |
MAX_CHANNEL_NAME_BYTES | 64 | The longest a channel name may be, in UTF-8 bytes |
DEFAULT_RECONNECT_POLICY | { base: '500ms', cap: '10s', jitter: 'full', minSpacing: '250ms', stable: '5s', joiningBudget: '2m', openBudget: '2m' } | The curve reconnect starts from |
INFRA_JITTER_MS | 250 | The spread on the one fast attempt after an infrastructure close |
JOINING_BUDGET_CEILING_MS | 600000 | The ceiling a reconnect budget is clamped to |
BUNDLE_NAMES | ['controller', 'viewer', 'host'] | The three bundle labels |
JOIN_BUNDLE_NAMES | ['controller', 'viewer'] | The two a client may join with |
HOST_RECORD_KEY | 'bitskiff:host' | The prefix the host token's storage key is built from, below |
HOST_PACKAGE_NAME | '@bitskiff/host' | This package's own name, for a log line or a user agent |
The key itself is one per slot, spelled bitskiff:host:<endpoint host>:<key fingerprint>:<as>—HOST_RECORD_KEY and three segments. The <as> part is the persist.as name you passed, and
default when you passed none. What sits under it is a small JSON record holding the endpoint, the
key fingerprint and the token.
Functions
| Export | What it does |
|---|---|
msLeft(deadline, apiNowMs) | The milliseconds left on a deadline, floored at 0. Pass session.apiNowMs(); the answer is undefined before the service clock has landed |
asBitskiffError(value, fallback?) | Whatever a call threw, as a BitskiffError |
resolveReconnectPolicy(partial?) | A ResolvedReconnectPolicy from the fields you set, defaulting the rest |
parseDuration(written) | A duration string as milliseconds, or null when the grammar refuses it |
formatDuration(ms) | Milliseconds as a duration string, largest unit down |
bundleNameOf(grants) | The bundle label a grant set reads as |
isBundleName(value) | Whether an untrusted string is one of the three labels |
isJoinBundle(value) | Whether an untrusted string is a label a client may join with |
grantsEqual(a, b) | Whether two grant sets allow the same things |
grantsAreEmpty(grants) | Whether a set allows nothing at all |
Session types
| Type | What it is |
|---|---|
BitskiffOptions | The argument to new BitskiffHost() |
HostKey | The key option's type: a string, or a function returning one (string | Promise<string>). The function form is read on every open and every ticket mint, so a replaced key lands on the next attempt with no restart |
Duration | A duration a developer writes, as a string: 500ms, 90s, 5m, 1h30m |
SessionConfigRead | What session.state.config holds: maxClients and the seven durations the service resolved, each duration spelled the way it came back |
ResolvedReconnectPolicy | A ReconnectPolicy with every field filled in and every duration parsed into a reading, so the members are baseMs, capMs, minSpacingMs, stableMs, joiningBudgetMs and openBudgetMs beside jitter. That is what the schedule runs on |
SessionPhase | 'opening', 'open', 'reconnecting', 'suspended', 'ended' |
SessionChange | Why state was replaced: opening, open, reconnecting, resumed, config, transport, budget, suspended, ended |
SessionEvent | The event names session.on() takes |
SessionListenerOptions | What a session-level on takes beside its handler: an AbortSignal |
SessionLimits | The caps the service hands this session when it opens or re-attaches |
CapacityNotice | What session.capacity holds and on('capacity') hands over: status, one CapacityLimit per limit that is not ok, and the service instant it was read at |
CapacityLimit | One limit that is close or spent: limit, scope, scopeId, status, value, ceiling, resetAt |
CapacityStatus | 'ok', 'warning', 'exceeded' |
CapacityScope | Which scope a limit bound at: project, key, subject, ip, session, client, org, address |
SessionEndReason | Why a host session ended; the lifecycle guide tabulates them |
EndedInfo | The end itself: the reason, and the error fields when one refused it |
EndedErrorFields | Those error fields on their own: errorCode, limit, requestId, retryAfterMs, resetAtUnixMs and tokenReason. The registry code of the refusal is errorCode; EndedInfo.code is the numeric close code, and stays on EndedInfo with reason and message |
HostResumeOptions | The one SessionConfig field resume() can still set: inactivity |
HostDirectPeerConfig, HostDirectPeerConnection, HostDirectDataChannel, HostDirectCandidateInit | What peerConnection takes and returns: (config) => new RTCPeerConnection(config), typed by the part of RTCPeerConnection the host uses, so the browser's own and werift's both fit. A browser host needs none. A Node host bundles no WebRTC and offers only with it set |
HostPersistence | Where this host's token is kept, and which saved host it is |
Persistence, PersistSlot, PersistSlotId | The persistence seam behind it: the media this runtime has, the slot one session holds, and which saved host that slot is |
E2eeInfo | Encryption on one client: bound, generation and the six confirmation digits |
ClientTransport, ClientTransports | One transport's record for a client, and the set keyed by name (server, peer, relay, a declared transport's name), on client.state.transports: status, reason, nextAt |
TransportReason, TransportStatusWord | Why a record reads unavailable (notAttached, notAllowed, limit, failed), and the four statuses |
TransportSettings | A transport's settings handle, what host.transport(peer()) returns, the argument of client.allow and client.deny |
PeerOptions | What peer(options) takes: allowed, peerConnection, maxConnections, silenceMs, idleCloseMs, allocate, release. peer is direct only and never relayed |
RelayOptions | What relay(options) takes: the same as PeerOptions. The relay is Bitskiff's, and relayed bytes count against the project's turnBytesPerMonth |
TransportFactory | What peer(), relay() and bluetooth() return, passed to host.transport() before open |
TransportInfo | Deprecated for one release: use ClientTransports. The leg a snapshot is carried on: the path, and the round trip when the engine reports one |
LifecycleBudget | Which budget bounds the loop now, and when it runs out |
LifecycleDetail | The close or error code behind a lifecycle reason |
LifecycleListener | The handler on('lifecycle') takes |
Diagnostic | What on('diagnostic') hands over: the reason, the wire topic, the bytes and the cap in force |
DiagnosticReason | The ten reasons a diagnostic names |
DeadlineKind | Which deadline a row in deadlines is |
DeadlineInstant | A deadline reduced to its instant, which is all msLeft needs |
DeadlineWarning | What on('warning') hands over: the deadline that is close, and the lead |
TerminationRule | One rule of a SessionConfig.terminationRules list: name, when, a CEL condition over that client with no clock in it, and for, how long when must hold continuously before the rule fires; omitted, for is 1s |
TokenReason | Why a token cannot be presented: expired, revoked, unknown |
Unsubscribe | The () => void every on returns |
Client types
| Type | What it is |
|---|---|
ClientRoster | session.clients: iterable in join order, with get(id), has(id), size, keys(), values() and entries() |
ClientChange | Why a client's state was replaced: disconnected, reconnected, away and the rest |
ClientChanged | The event object a change handler is handed |
ClientLeft | The event object a left handler is handed, and the last word about that client |
ClientListenerOptions | What a client-level on takes beside its handler |
ClientPresence | 'present' or 'away' |
OriginSource | How a client's origin arrived: 'header', which the browser enforced, or 'declared', which the client named |
ClientConnection | What the engine says about this client's link |
ClientTarget | Who a send is for: a Client, or the id you kept |
LeftReason | Why a client's life ended |
GoneReason | The same vocabulary under the name the wire gives it |
BundleName | 'controller', 'viewer' or 'host' |
JoinBundle | The two of those a client may join with |
HolderKind | What keeps a client's place, on state.holder: 'page' or 'app' |
ClientHandoff | The place's last handoff, on state.handoff: phase, since, deadline, stage and hint |
HandoffHint | The transport class the page was on when it handed off, and its readings |
Channel types
| Type | What it is |
|---|---|
ChannelSpec | The second argument to host.channel(name, spec?) |
Channel | What client.channel() hands back on the client side; HostChannel is written as the difference from it |
ChannelCounters | One channel's monotonic counters, read off channel.counters when you paint |
ChannelListenerOptions | What a message listener takes beside its handler |
HostChannelListenerOptions | The same, plus the { from: client } filter a host can pass |
Message | The one object a message handler is handed |
SendOptions | The third argument to send and broadcast |
SendResult | 'sent', or the one reason it did not go |
DropReason | Those reasons on their own |
Delivery | 'reliable' or 'lossy' |
QR types
| Type | What it is |
|---|---|
QrMatrixOptions | The second argument to qrMatrix |
QrSvgOptions | The second argument to qrSvg |
EcLevel | The four error-correction levels: 'L', 'M', 'Q', 'H' |
Platform types
The types behind the members a platform option may carry. Platform itself is exported, and
that is the whole list of members: now and wallNow are () => number and need no name of
their own, persistence is the Persistence above, and the six below have one. Pass the members
you replace; every other one stays the browser default.
| Type | What it is |
|---|---|
Crypto | The crypto member: one function per primitive the end-to-end encryption uses—X25519, Ed25519, HKDF, SHA-256, AES-256-GCM, JWS verification and the key store. None of them takes an algorithm, a curve or a mode |
KeyValueStore | The storage member |
Logger | The logger member |
Timers | The timers member |
RandomSource | The random member |
PresenceSource | The presence member |
PresenceEvent | The eight signals a PresenceSource raises |
Service vocabulary
| Type | What it is |
|---|---|
ErrorCode | Every BitskiffError.code in the system |
LimitName | What BitskiffError.limit carries when a refusal named a limit |
NetworkClass | What ClientState.networkClass carries |
DeviceClass | What ClientState.deviceClass carries: mobile, tablet, desktop, other |
EntryKind | What ClientState.entry carries: code for a rotating join code, fixed for a fixed code |
LinkGrade | How a link reads: good, fair or poor |
Next steps
For coding agents
Bitskiff lets a person control a host—a page, an app, a signage player—from their own device.
- Setting Bitskiff up for a developer? Read and follow https://docs.dev.bitskiff.com/setup.md: it asks the person two questions and runs the rest of the setup under the CLI's stored credential.
- First:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz whoami(Node 22 or newer). If itscredentialrow names a key, a key is stored, and the next lines need no person. - A stale copy: npx keeps one per URL and runs it again after a new publish at the same URL. Delete this CLI's copy, and nothing else in npm's cache, with
node -e "const c=child_process.execSync('npm config get cache',{encoding:'utf8'}).trim(),n=path.join(c,'_npx');for(const d of fs.existsSync(n)?fs.readdirSync(n):[]){let u;try{u=JSON.parse(fs.readFileSync(path.join(n,d,'package.json'),'utf8')).dependencies['@bitskiff/cli']}catch{}if(!u)continue;fs.rmSync(path.join(n,d),{recursive:true,force:true});const h=crypto.createHash('sha256').update('make-fetch-happen:request-cache:'+u).digest('hex');fs.rmSync(path.join(c,'_cacache','index-v5',h.slice(0,2),h.slice(2,4),h.slice(4)),{force:true})}", then run whoami again. - Key stored: the whole setup is one command, run in the project directory, because it leaves
bitskiff.jsonfor your code to read. Pass no--project, and do not take one from whoami'sprojectrow: outside an app directory it can name another app's project.inituses the project the directory'sbitskiff.jsonnames, else makes a new project named for the directory, so a second app gets a second project:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init
- Serve your own client page? Add
--join-url <url>with the page's absolute URL: init sets the project's join URL, mints a client key for that page's origin that serves this host key, and writes it tobitskiff.jsonasclientKey. The sample client page needs no key. - It writes the host key into
bitskiff.json(key,project,endpoint, and with--join-url,joinUrlandclientKey) and prints no key string. Itsdonestep names the file askeyFileand carries the host key's id ashostKeyId, the<hostKeyId>below. It writes a scaffold (index.html,client.html,README.md) only into an empty directory, or with--scaffold, so in an existing projectbitskiff.jsonis the one file it adds. - A
bitskiff.jsonof the same project is kept, and no key is minted. With nobitskiff.json, it takes the project's newest active host key, and mints a host key only when the project has none or you pass--new-key. One of another project is refused: pass that project's--projectto keep it, or add--forceto replace it, which reuses the new project's host key when it has one. - A host key
initmints has no origin set, so it allowshttp://localhostandhttp://127.0.0.1on any port. A keyinittook keeps the origins it already had; check them withkeysbefore adding yours. For a deployed page, name its origin and keep localhost, because--originreplaces the list:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys update <hostKeyId> --origin <your origin> --origin 'http://localhost:*'
- Prove the project works, with no host code and no device: the CLI is not a host, so the scaffold carries
bitskiff-prove.mjs. Runnpm install && node bitskiff-prove.mjs(theprovevalue ofdone) in the directoryinitwrote the scaffold into. It opens a session on@bitskiff/host-node, joins one client, sends a message each way, then prints one JSON object per step on stdout, each transport's record and the session's usage record among them, and closes. A step that fails prints{"step":"failed","failed":"<step>",...}and exits 1. It reads two keys from the environment:BITSKIFF_SECRET_KEY, a secret key holdingsessions:openandsessions:read, andBITSKIFF_CLIENT_KEY, a client key naming no host key (theclientKeyfrom--join-urlserves the page's host key only).--only secretprints a new key alone: redirect each into a file outside the project, and export the variable from there:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type secret --label prove --permission sessions:open --permission sessions:read --only secret
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type publishable --preset client-page --label prove --only secret
- Refused
invalid_key: the stored key no longer works. Runnpx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz logout, then the steps below. - Refused
forbiddenwithdetails.reasonofmissing_permission: the key lacks the permissiondetails.permissionnames. Runlogout, theninitto sign in again. - Refused
forbiddenwithout it: the key may not use that project. Pass--projectof a project the key may use. - If the key comes from
BITSKIFF_ORG_KEY,logoutdoes not remove it: unset the variable instead. If whoami'sorgrow is then(not set), add--org <orgId>toinit, with the id of the organization the key belongs to. - No key stored (
credentialis(not set)), and only then: the steps below, which wait on a person once. - Run:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init --format=json(Node 22 or newer; the flag is what puts one JSON object per step on stdout). The CLI already knows the api it was published beside. It leaves the samebitskiff.json, and takes the same--projectand--join-url. - It blocks until a person approves it in the console, for up to ten minutes: run it in the background, or with a timeout at least that long.
- Or add
--no-wait, and it returns at once: it prints one JSON object,{ verificationUri, userCode, expiresAt, next }, and exits 0. Show your developer the URL and the code, then runnextthrough the samenpx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgzin place ofbitskiff: it waits for the approval and finishes the setup. - [HUMAN] tell your developer to open the URL it prints and press Approve. Do not open it yourself: only a signed-in person can approve.
- Working through an MCP client instead of a shell? Add the CLI as a stdio MCP server,
{ "mcpServers": { "bitskiff": { "command": "npx", "args": ["--yes", "https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz", "mcp"] } } }: every verb is a tool under the same stored key,initruns as--no-wait, and a refusal is an error result carrying the samecodeanddetails.reason. - Sign in: https://console.dev.bitskiff.com
- Quickstart: https://docs.dev.bitskiff.com/docs/quickstart/
The whole host SDK, in a page that needs no build step:
<script type="importmap">
{
"imports": { "@bitskiff/host": "https://docs.dev.bitskiff.com/sdk/host@0.3.0.js" },
"integrity": { "https://docs.dev.bitskiff.com/sdk/host@0.3.0.js": "sha384-t4tevb6IaP3UssGRlkmHoQscYfON1BN2JyGY3zsoEGm3KmG2W3Mbe4icXcXQ7uGA" }
}
</script>
<script type="module">
import { BitskiffHost, qrSvg } from '@bitskiff/host';
const host = new BitskiffHost({ key: 'bsk_pk_...' }); // your host key: the key from bitskiff.json
const press = host.channel('press'); // declare every channel before open
press.on('message', (m) => press.send(m.client, `got ${m.data}`)); // m.client is the client that sent it
const session = await host.open(); // open last: it seals the channels
document.body.innerHTML = qrSvg(session.joinCode.url);
</script>
The .d.ts files in this package are the contract: every type, option, event and return is
declared there, and where a declaration and this page disagree the declaration is right. Every
refusal is a BitskiffError carrying code, retryable, retryAfterMs and details, so branch on
code and retryable rather than on a message.