@bitskiff/client
The phone SDK: BitskiffClient takes a seat in a session a host has opened and gives the page a
state machine, a send queue and a reconnect policy. The host side is @bitskiff/host, a separate
install.
This page is the reference. The guides walk the whole thing: quickstart · channels · lifecycle and errors · your phone page
Install
npm install @bitskiff/client
Hello world
import { BitskiffClient } from '@bitskiff/client';
const code = location.hash.slice(1); // your page read the link; see below
const client = new BitskiffClient();
await client.start(code); // resolves when you are seated
const press = client.channel('press'); // reliable by default, like the host's
const readout = client.channel('readout'); // the names are the host's, spelled its way
readout.on('message', (m) => console.log(m.data)); // only `readout` reaches this
press.send('down'); // a message is a string or bytes
new BitskiffClient(options?)
Synchronous, does no network. Every option is optional, so new BitskiffClient() is a whole
call—an anonymous phone on your own origin, which is what a page reached by scanning a code is.
| Option | Type | What it is |
|---|---|---|
origin | string | Where this client is, a URL or a custom scheme, matched against the join URL the session stored. Unset, the service reads the Origin header, which is what a plain browser page wants |
token | string | A join token (mrc_at_...) your backend minted for this person, so the seat carries their subject. Unset joins anonymously with the code alone |
platform | Partial<Platform> | Merged member by member over the browser default: storage, presence, crypto, random, timers, logger, now |
transport | TransportFactory | The engine adapter; the default fetches the engine SDK on the first connect |
reconnect | Partial<ReconnectPolicy> | baseMs, capMs, minSpacingMs, stableMs, joiningBudgetMs |
returnProbeMs | number | How long a returning page waits for the host's Pong before it stops believing the link. Default 2000, clamped 500 to 10000 |
linkDeadAfterHiddenMs | number | How long this page can have been hidden before the link is presumed dead and the return skips the probe. Default 10000, clamped 2000 to 120000 |
There is no endpoint option: the published package talks to the api it was built for. A value
outside its clamp throws BitskiffError{ code: 'validation' } naming the option, before anything
reaches the network.
loadDefaultTransport() is the same engine adapter, exported so a page can await the fetch at a
moment of its choosing and pass the result as transport.
BitskiffClient
| Member | Type | What it does |
|---|---|---|
state | ClientState | The snapshot: phase, changed, host, presence, returning, grants, ended, suspended, lastError |
lifecycle | Lifecycle | The last transition and the reason for it |
channels | ReadonlyMap<string, Channel> | Every channel this page declared |
deadlines | readonly Deadline[] | Every armed deadline this seat knows about |
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?) | Channel | Declare a channel, or read back the one under that name |
inspect(code) | Promise<Inspection> | What that code would do, without taking a seat |
start(code, opts?) | Promise<ClientState> | Take a seat. Resolves on the welcome; once per handle |
retry() | Promise<ClientState> | Start the join loop again from suspended |
leave() | Promise<void> | Give the seat back |
dispose() | void | Local teardown; calling it twice is calling it once |
Events, all through client.on(event, fn), each returning an unsubscribe function: state,
lifecycle, diagnostic, deadline, warning. Subscribing does not replay, so read
client.state first and then subscribe.
You pass the code; this SDK reads no URL
It never looks at location, never parses a link, and exports nothing that does. A page people
reach by scanning a QR reads its own fragment—location.hash.slice(1), in whatever shape the
host built the link—and hands the string to start. That line is your page's.
Pass the string the host handed out (session.joinCode.code) unchanged. Under end-to-end
encryption it carries the host key fingerprint after a ., and a page that split it and passed
only the first half joins with nothing to verify the host against.
inspect(code) takes the same string and consumes no seat: it answers what would happen, which is
how a page tells "this session is full" from "this code is wrong" before it commits.
A phone that has to be a named person
A join code is anonymous admission: whoever holds it gets a seat. When the seat has to carry *your*
user, your backend mints a join token for them and the page passes it to BitskiffClient:
import { BitskiffClient } from '@bitskiff/client';
// your backend: POST /v1/tokens with a secret key holding tokens:mint
// { "subject": "user_8123", "permissions": ["sessions:join"], "sessionId": "s_..." }
const { token } = await fetch('/api/join-token').then((r) => r.json()); // mrc_at_...
const client = new BitskiffClient({ token });
await client.start('1CSESSION1ABC123');
The phone still joins with the code; the token rides the join and the host reads the subject on the
participant. sessionId on the mint binds the token to one session, so one captured on its way to
a page is not redeemable in any other. A token the service refuses ends the handle with
state.ended.errorCode === 'invalid_token'—ended.reason is the coarse refused—and the
page's answer is to ask your backend for another rather than to retry.
Channels
client.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(data, opts?) takes a string or a Uint8Array and answers 'sent' | 'queued' | 'dropped'.
It 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.
You join the host's channels by naming them, spelled the way the host spells them, and you declare
no rates on the host's behalf: maxHz is your own pacing and maxBytes your own drop rule.
Serving your own phone page
Serve it with a Content-Security-Policy response header; the
phone page guide has the
policy and the reason a <meta http-equiv> copy is not enough.
@bitskiff/client/node
createNodeClient(options?) is the same client for a load generator, a test harness or an
embedded controller: the same BitskiffClientOptions, with the runtime-neutral platform and a
transport over @livekit/rtc-node (an optional peer, so a page's install pulls no native binary).
import { createNodeClient } from '@bitskiff/client/node';
const client = createNodeClient();
await client.start(process.argv[2]!); // the code the host's display is showing
Its store is in memory, so the one client record lives as long as the process and a restart joins
as a new participant; a process that wants it to survive passes its own storage through
platform. createNodeTransport() is exported too, for a caller that builds its own engine room.