@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.

OptionTypeWhat it is
originstringWhere 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
tokenstringA join token (mrc_at_...) your backend minted for this person, so the seat carries their subject. Unset joins anonymously with the code alone
platformPartial<Platform>Merged member by member over the browser default: storage, presence, crypto, random, timers, logger, now
transportTransportFactoryThe engine adapter; the default fetches the engine SDK on the first connect
reconnectPartial<ReconnectPolicy>baseMs, capMs, minSpacingMs, stableMs, joiningBudgetMs
returnProbeMsnumberHow long a returning page waits for the host's Pong before it stops believing the link. Default 2000, clamped 500 to 10000
linkDeadAfterHiddenMsnumberHow 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

MemberTypeWhat it does
stateClientStateThe snapshot: phase, changed, host, presence, returning, grants, ended, suspended, lastError
lifecycleLifecycleThe last transition and the reason for it
channelsReadonlyMap<string, Channel>Every channel this page declared
deadlinesreadonly Deadline[]Every armed deadline this seat knows about
now()numberThe monotonic clock this SDK stamps receivedAt and sentAt on
apiNowMs()number | undefinedThe service's clock, absolute Unix ms, for counting a deadline down
channel(name, spec?)ChannelDeclare 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()voidLocal 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.