@bitskiff/client
The client SDK. BitskiffClient joins 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 cover the rest: quickstart · channels · lifecycle · client pages
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-client.tgz
To check a download, compare sha256sum bitskiff-client.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 { BitskiffClient, type Message } from '@bitskiff/client';
const code = location.hash.slice(1); // your page read the link; see below
const client = new BitskiffClient({ key: 'bsk_pk_...' });
const press = client.channel('press'); // the host's row sets delivery and rate
const readout = client.channel('readout'); // the names are the host's, spelled its way
readout.on('message', (m: Message) => console.log(m.data)); // only `readout` reaches this
await client.join(code); // name first, then join: see below
press.send('down'); // a message is a string or bytes
import { BitskiffClient } from '@bitskiff/client';
const code = location.hash.slice(1); // your page read the link; see below
const client = new BitskiffClient({ key: 'bsk_pk_...' });
const press = client.channel('press'); // the host's row sets delivery and rate
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
await client.join(code); // name first, then join: see below
press.send('down'); // a message is a string or bytes
new BitskiffClient({ key })
Synchronous, and it touches no network. key is the one required option: the client key your page
or app was built with. Without it, or with anything but a client key, the constructor throws
validation naming key. Every other option is optional.
| Option | Type | What it is |
|---|---|---|
key | string | () => Promise<string> | Required. The client key (bsk_pk_...), sent on inspect and join. The service admits a join only when the key belongs to the session's project and, when the key names host keys, to one of them; otherwise the verdict is wrongClient. A function is called on every inspect and join |
origin | string | Where this client is, a URL or a custom scheme, for a native app, an app shell or a process that sends no Origin header. The service reads it against the key's allowed origins only when there is no Origin header. A page in a browser never needs it |
platform | Partial<Platform> | Merged member by member over the browser default: storage, persistence, presence, crypto, random, timers, logger, now, wallNow |
transport | EngineTransportFactory | The transport adapter; the default fetches its WebRTC bundle on the first connect |
reconnect | Partial<ReconnectPolicy> | base, cap, jitter, minSpacing, stable, joiningBudget, openBudget |
returnProbe | Duration | How long a returning page waits for the host's Pong before it stops believing the link. Default 2s, refused outside 500ms to 10s |
linkDeadAfterHidden | Duration | How long this page can have been hidden before the link is presumed dead and the return skips the probe. Default 10s, refused outside 2s to 2m |
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.
There is no endpoint option: the published package talks to the service it was built for. A value
outside its range is refused, not pulled to the nearest end: the constructor throws
BitskiffError{ code: 'validation' } naming the option in details.option, before anything
reaches the network.
loadDefaultTransport() is the same transport adapter, exported so a page can await the fetch when
it chooses and pass the result as transport.
BitskiffClient
The client's members:
| Member | Type | What it does |
|---|---|---|
state | ClientState | The whole snapshot: phase (joining, connected, suspended, ended), changed, clientId, sessionId, number, config, grants, host, hostEverAttached, presence, returning, transport (path and rttMs), attempt, e2ee, lastError, suspended, ended |
lifecycle | Lifecycle | The last transition and the reason for it |
channels | ReadonlyMap<string, ChannelRow> | The host's channel table, name to row, once the welcome arrives; empty before |
deadlines | readonly Deadline[] | Every armed deadline this client 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) | Channel | Name one of the host's channels, or read back the one under that name |
inspect(code, opts?) | Promise<Inspection> | What that code would do, without joining. { reserve: true } also reserves a join grant, see Sign in after the scan |
join(code, opts?) | Promise<BitskiffClient> | Join the session. Resolves to this client on the welcome, rejects with the BitskiffError that refused it; once per client. opts.token is a join token, see Join tokens. Takes a Reservation in the code's place |
heldReservation() | Promise<Reservation | undefined> | The join grant this tab reserved and has not redeemed, for the load that comes back from a sign-in |
retry() | Promise<BitskiffClient> | Start the join loop again from suspended; resolves the same way |
leave() | Promise<void> | Leave the session |
dispose() | void | Local teardown; calling it twice is calling it once |
Events all go through client.on(event, handler, options?), which returns a () => void that
removes that one listener: state, lifecycle, diagnostic, deadline, warning.
client.off(event, handler) stands beside it for a handler you already hold. Pass
{ signal } to close a group of listeners with one AbortController. Read client.state first
and then subscribe: subscribing does not replay.
join() rejects when the service refuses. Four refusals are the common ones: a code past its
30 to 60 s window, a session already at maxClients, a client key that is not for that session, and
a token the service will not take. Each arrives as a thrown BitskiffError. The handle is left at
phase: 'ended' with state.ended. A refused join never becomes a session, so catch the
rejection:
import { BitskiffClient, isBitskiffError, type Channel } from '@bitskiff/client';
const code = location.hash.slice(1); // your page read its own link
const status = document.querySelector('#status'); // your page's own line of text
const client = new BitskiffClient({ key: 'bsk_pk_...' });
const press: Channel = client.channel('press');
try {
await client.join(code);
press.send('down');
} catch (error) {
if (!isBitskiffError(error)) throw error;
// `error.code` is one of `invalid_code`, `clients_full`, `wrong_client`, `invalid_token` and
// `rate_limited`. `client.state.ended.reason` carries the same refusal as one word.
if (status) status.textContent = error.message;
}
import { BitskiffClient, isBitskiffError } from '@bitskiff/client';
const code = location.hash.slice(1); // your page read its own link
const status = document.querySelector('#status'); // your page's own line of text
const client = new BitskiffClient({ key: 'bsk_pk_...' });
const press = client.channel('press');
try {
await client.join(code);
press.send('down');
} catch (error) {
if (!isBitskiffError(error)) throw error;
// `error.code` is one of `invalid_code`, `clients_full`, `wrong_client`, `invalid_token` and
// `rate_limited`. `client.state.ended.reason` carries the same refusal as one word.
if (status) status.textContent = error.message;
}
The one-word form is the list under end reasons, which is where to look when your page writes its own message for each of them.
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.
"Try again" is join on a new BitskiffClient, which is the same line the page ran the first
time. A link that drops after the join is not a refusal: the client goes suspended and
retry() starts the loop again on the same handle.
Join codes
Read the code out of your own page's link and pass it to join. A page reached by scanning a QR
usually reads its own fragment with location.hash.slice(1), in whatever shape the host built the
link. That line is yours to write: this SDK never looks at location, never parses a link, and
exports nothing that does.
Pass the string the host handed out (session.joinCode.code) unchanged. Under end-to-end
encryption it carries the host key fingerprint after a ., so a page that passes only the first
half joins with nothing to verify the host against.
inspect(code) takes the same string and joins nothing. Call it to tell "this session is full"
from "this code is wrong" before the page commits.
Join tokens
A join code is anonymous admission: whoever holds it gets in. To make the client carry one of your own users, pass a join token your backend minted for them:
import { BitskiffClient } from '@bitskiff/client';
const code = location.hash.slice(1); // your page read its own link
// 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())) as { token: string };
const client = new BitskiffClient({ key: 'bsk_pk_...' });
await client.join(code, { token }); // the code admits, the token names who
import { BitskiffClient } from '@bitskiff/client';
const code = location.hash.slice(1); // your page read its own link
// 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()); // bsk_at_...
const client = new BitskiffClient({ key: 'bsk_pk_...' });
await client.join(code, { token }); // the code admits, the token names who
The client still joins with the join code; the token rides on that call and names who is joining.
It is a join option and not a constructor option: new BitskiffClient({ key, token }) throws
validation naming token. token may also be a function that returns one, called at each join
request, so a token your backend replaced lands on the next attempt.
The service meters, limits and attributes the client under that subject, and the usage rows read by
it. The host reads it too: the client's row on the host's roster carries it as
client.state.subject, so the host knows which of your own users this is. A client that joined with
no token carries no subject.
Set sessionId on the mint once your backend knows which session this person is joining, so a
token captured on its way to a page cannot be redeemed in any other.
A token the service refuses ends the client with state.ended.errorCode === 'invalid_token', under
the coarser ended.reason of refused; ask your backend for a new token rather than retrying with
that one. The credentials guide has the mint call and the permissions.
A code a client sees stays accepted for at least one full step and at most two—30 to 60 seconds at the default. A sign-in that takes longer reserves a join grant first.
Sign in after the scan
inspect(code, { reserve: true }) turns the fresh code into a join grant that lasts 10 minutes,
and join(reservation, { token }) redeems it once the person has signed in:
import { BitskiffClient, type Reservation } from '@bitskiff/client';
const client = new BitskiffClient({ key: 'bsk_pk_...' });
let reservation: Reservation | undefined = await client.heldReservation();
if (reservation === undefined) {
const inspection = await client.inspect(location.hash.slice(1), { reserve: true });
if (inspection.reservationRefused) throw inspection.reservationRefused; // too many outstanding
reservation = inspection.reservation;
}
if (reservation === undefined) throw new Error('no join grant');
// your sign-in, then your backend's mint of a sessions:join token for that person
const { token } = (await fetch('/api/join-token').then((r) => r.json())) as { token: string };
await client.join(reservation, { token });
import { BitskiffClient } from '@bitskiff/client';
const client = new BitskiffClient({ key: 'bsk_pk_...' });
let reservation = await client.heldReservation(); // set when this load is the sign-in's return
if (reservation === undefined) {
const inspection = await client.inspect(location.hash.slice(1), { reserve: true });
if (inspection.reservationRefused) throw inspection.reservationRefused; // too many outstanding
reservation = inspection.reservation;
}
if (reservation === undefined) throw new Error('no join grant');
// your sign-in, then your backend's mint of a sessions:join token for that person
const { token } = await fetch('/api/join-token').then((r) => r.json());
await client.join(reservation, { token });
The grant is useless alone: it redeems only beside a holder secret the SDK made, which stays in
this tab's sessionStorage and never reaches your code. A sign-in that redirects away and back
comes back to a fresh load, and heldReservation() hands that load the reservation to redeem,
with no second scan and no Join button. The token reaches your page in a response body or a URL
fragment, never a query string. A project that does not require a token needs none of this.
A grant that does not redeem is invalid_code, with details.reason naming why:
grant_expired, grant_used or grant_holder_mismatch. The way on is another scan. A refusal
before the grant is spent, such as a missing token, keeps the reservation for a join that can pass.
Channels
The host declares the channels. client.channel(name) names one and hands back the object every
message goes through, with its own send, its own on('message'), its own counters on
channel.counters and its own congested reading. It takes a name and nothing else: a second
argument is a validation throw with details.option: 'channel'.
The welcome binds each channel to the host's row. From then on the channel reads that row—delivery, ordered, lifetimeMs, maxHz, maxBytes, latestOnly—and channel.declared
says whether the host declared the name:
declared | When |
|---|---|
undefined | before the welcome: nothing is known yet |
true | the host's table has this name |
false | the host's table does not. Sends answer { dropped: 'notDeclared' } and one unknownChannel diagnostic names the channel |
client.channels is the host's table, name to row. Read it to decide what your own page shows,
such as a joystick only when the host declared move.
Name your channels before join. A name held on the handle first rides the hello, so the
channel is live the instant the host sees you arrive and a greeting sent on that line reaches
you. A host unicast on a channel you have not named is dropped at the host with
{ dropped: 'notListening' }; nothing is buffered waiting for a subscriber.
Before the welcome, a
send is held and answers 'sent', and the welcome delivers it or drops it notDeclared.
send(data, opts?) takes a string, an ArrayBuffer, or any view over one (Sendable, exported
from this root). It returns 'sent' or { dropped } with one of the seven reasons below. A client
speaks only to its host, so send takes no target and there is no broadcast on this side. It
never throws for anything the network did.
| Result | What happened |
|---|---|
{ dropped: 'size' } | the message is over the channel's maxBytes. A tooLarge diagnostic fires with it |
{ dropped: 'rate' } | the lossy pace held it back |
{ dropped: 'hostAway' } | the host is not attached |
{ dropped: 'noGrant' } | this client's sendInput grant does not cover a lossy send. A lossy channel is what "input channel" means, and sendInput gates nothing else |
{ dropped: 'pressure' } | the send buffer is behind, or a reliable queue is full |
{ dropped: 'notOpen' } | the session is not open |
{ dropped: 'notDeclared' } | the host never declared this name |
There is no ack and no request-reply, so send a reply on a channel of your own.
channel.on(event, handler, options?) returns a () => void that removes that one listener, the
same as every other on in both SDKs. { from } is a host-side option and means nothing here;
{ signal } works on either side.
Join the host's channels by naming them, spelled the way the host spells them. You set no rates:
a lossy send is paced to the row's maxHz, and maxBytes is the row's on send and on receipt.
Peer and relay
Every client starts on server, the Bitskiff connection. Two more transports carry its data over
a WebRTC link to the host. Declare each one you want before join:
peer(): direct. The bytes go from this client to the host and never through Bitskiff, so they are not billed.relay(): through Bitskiff's relay. Neither end sees the other's address. Relayed bytes are metered to the project.
import { BitskiffClient, orderedHopPolicy, peer, relay } from '@bitskiff/client';
const client = new BitskiffClient({ key: 'bsk_pk_...', hopPolicy: orderedHopPolicy() });
const direct = client.transport(peer());
client.transport(relay());
await client.join(location.hash.slice(1));
- The client moves onto a transport only when the host declared it too.
orderedHopPolicy()triespeerfirst andrelaynext, and moves a client onrelaytopeerwhen the network allows. - Without a policy, nothing moves by itself: call
direct.connect()to hop anddirect.disconnect()to go back toserver. peer()shows this client's network address to the host. Name it in your privacy notice.- Both take
silenceMs(250 to 3000 ms, default 750), after which a quiet link falls back toserver, andask, the transport's own ask hook.
Release and rejoin
By default a client keeps its Bitskiff connection for the whole session. When the host allows release on a transport and your data rides it, you can let the connection go and run on that transport alone. Nothing releases on its own: only your call does.
import { bluetooth } from '@bitskiff/client';
const viaBt = client.transport(bluetooth({ name: 'Racer' }));
await client.join(code);
client.on('hop', async (e) => {
if (e.outcome === 'done' && e.to === viaBt) await client.server.release();
});
client.on('release', (e) => console.log(e.outcome, e.cause, e.boundMs));
client.server.release({ ifLost })resolvesreleasedorrefusedand never throws. A refusal says why in thereleaseevent'sstage:notActive,notAllowed,noControl,hoporhost.ifLost: 'rejoin'(the default) rejoins at once when the transport is lost, inside the reconnect budget.ifLost: 'end'ends the place instead.client.server.rejoin()takes the connection back. It resolvesrejoinedorfailed, and the data stays on its transport.- While released, the hop policy takes no step: each one is a
decisionwithvetoedBy: 'released'. Disconnecting or connecting another transport rejoins first.
Content Security Policy
Serve your client page with a Content-Security-Policy response header. The
client page guide has the policy and the reason a <meta http-equiv>
copy is not enough.
@bitskiff/client/node
Import from this subpath for a load generator, a test harness or an embedded controller. It exports
the same class, taking the same options, with the runtime-neutral platform and the custom engine's
transport over node-datachannel. Its exports are BitskiffClient and createNodeTransport;
BitskiffClientOptions and the rest of the types come from the package root.
That peer is optional, so a page's install pulls no native binary. Only the import line changes between a page client and a process one.
import { BitskiffClient } from '@bitskiff/client/node';
const client = new BitskiffClient({ key: 'bsk_pk_...' });
const code: string = process.argv[2] ?? ''; // the code the host's display is showing
await client.join(code);
import { BitskiffClient } from '@bitskiff/client/node';
const client = new BitskiffClient({ key: 'bsk_pk_...' });
const code = process.argv[2] ?? ''; // the code the host's display is showing
await client.join(code);
Its store is in memory, so the client record lives as long as the process and a restart joins as a
new client. To keep it across restarts, pass your own storage through platform.
createNodeTransport() is exported too, for a caller that builds its own transport stack.
The stored record
A page that reloads can say where it stands before it reconnects. The SDK keeps one record per
client key in storage, under the name clientRecordKey(key) answers, and two functions read it
without a BitskiffClient:
import { clientRecordKey, isRecordExpired, parseClientRecord } from '@bitskiff/client';
import type { StoredClientRecord } from '@bitskiff/client';
const key = 'bsk_pk_...';
const record: StoredClientRecord | undefined = parseClientRecord(
localStorage.getItem(clientRecordKey(key)) ?? undefined,
);
const returning = record !== undefined && !isRecordExpired(record, Date.now());
import { clientRecordKey, isRecordExpired, parseClientRecord } from '@bitskiff/client';
const key = 'bsk_pk_...';
const record = parseClientRecord(localStorage.getItem(clientRecordKey(key)) ?? undefined);
const returning = record !== undefined && !isRecordExpired(record, Date.now());
clientRecordKey(key) is synchronous: bitskiff:client: and the last 16 characters of the key
string, so the page names its record before the SDK boots.
parseClientRecord answers a StoredClientRecord—sessionId, clientId, the resume token,
expiresAt, and the public half of this client's key—or undefined when there is nothing to
read. isRecordExpired(record, nowUnixMs) says whether the place the record names has run out;
an expiresAt of 0 means the service named no deadline, so such a record never expires here.
Neither function touches storage and neither joins. What they answer is provisional until
inspect(code) matches the record against the session that code names.
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_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 |
RETURN_PROBE_MS | 2000 | The default returnProbe, as a millisecond reading. formatDuration(RETURN_PROBE_MS) is the string the option takes |
RETURN_PROBE_MIN_MS / RETURN_PROBE_MAX_MS | 500 / 10000 | The range returnProbe is refused outside of, as millisecond readings |
LINK_DEAD_AFTER_HIDDEN_MS | 10000 | The default linkDeadAfterHidden, as a millisecond reading |
LINK_DEAD_AFTER_HIDDEN_MIN_MS / LINK_DEAD_AFTER_HIDDEN_MAX_MS | 2000 / 120000 | The range that option is refused outside of, as millisecond readings |
CLIENT_PACKAGE_NAME | '@bitskiff/client' | This package's own name, for a log line or a user agent |
Beyond the record, the SDK also keeps its end-to-end-encryption keys in an IndexedDB
database named bitskiff-client-crypto-keys.
Functions
| Export | What it does |
|---|---|
msLeft(deadline, apiNowMs) | The milliseconds left on a deadline, floored at 0. Pass client.apiNowMs(); the answer is undefined before the service clock has landed |
clientRecordKey(key) | The storage name of the record a client built with that key keeps, synchronously |
parseClientRecord(raw) | The stored record, or undefined |
parseDuration(written) | A duration string as milliseconds, or null when the grammar refuses it |
formatDuration(ms) | Milliseconds as a duration string, largest unit down |
resolveReconnectPolicy(partial?) | A ResolvedReconnectPolicy from the fields you set, defaulting the rest |
isRecordExpired(record, nowUnixMs) | Whether that record has run out |
browserPlatform(overrides?) | The default platform, with anything you pass merged over it member by member |
browserStorage() | The localStorage store on its own, for the storage member—a camera-app scan opens a new tab, so the client keeps its record per browser, not per tab |
browserPresence() | document visibility and window online: six of the eight PresenceEvent members. freeze and resume come from a runtime that reports them |
Client types
| Type | What it is |
|---|---|
ClientPhase | 'joining', 'connected', 'suspended', 'ended' |
ClientChange | Why state was replaced: joining, connected, away and the rest |
ClientEvent | The event names client.on() takes: state, lifecycle, diagnostic, deadline, warning |
ClientEnded | The end itself, on state.ended |
ClientEndReason | Why a client ended; the lifecycle guide tabulates them |
ClientSuspended | On state.suspended: the loop stopped itself and the place is not known to be gone |
ClientLastError | What the page can see of a refusal the SDK is still retrying |
BootReason | Why the host removed this client: booted, idle, policy. The REST boot route takes a fourth value, BOOT_REASON_KEY_UNVERIFIED, and it arrives as state.ended.reason === 'keyUnverified' with no bootReason set |
Verdict | What inspect(code) decided: fresh, resume, replace, expired, invalid, wrongClient, e2eeUnsupported, e2eeRequired |
WrongClientReason | Why a verdict is wrongClient, on its details.reason: other_project (the code is another project's) or other_host (this client key does not serve that host) |
JoinOptions | The second argument of join(code, opts): { token }, the join token a project that requires a signed-in person takes beside the code |
JoinToken | The token itself: a bsk_at_... string, or a function that returns one, read at each join request |
InspectOptions | The second argument of inspect(code, opts): { reserve }, to reserve a join grant |
Reservation | A join grant and its expiresAt on the service's clock, from inspection.reservation; join(reservation, { token }) redeems it |
StoredClientRecord | The record above |
Handoff | What client.handoff(opts?) resolves: the ticket, its expiresAt and cancel() |
HandoffOptions | What client.handoff(opts?) takes: data for the app, and hint to replace the SDK's own |
HandoffHint | The transport class this page is on when it hands off, and its readings |
ClientHandoff | This place's last handoff, on state.handoff: phase, since, deadline, stage and hint |
HolderKind | What keeps this place: 'page' or 'app' |
Grants | What this client may do, on state.grants |
SessionConfigRead | The session's resolved settings, on state.config, each duration spelled the way it came back |
Duration | A duration a developer writes, as a string: 500ms, 90s, 5m, 1h30m |
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 |
E2eeInfo | Encryption on this client: bound, generation and the six confirmation digits |
ServerTransport | client.server: a Transport that also has release(options?) and rejoin() |
ReleaseEvent | One step of a release or a rejoin: outcome, via, by, cause, stage, boundMs |
Transport | A transport on the client, client.server or what client.transport(peer()) returns: status, reason, nextAt, connect(), disconnect() and on('status'), a view over its record in state.transports |
ClientTransport, ClientTransports | One transport's record, and the set keyed by name |
TransportReason, TransportStatusWord, TransportStatusEvent | Why a record reads unavailable, the four statuses, and what on('status') hands over |
TransportFactory | What peer(), relay() and bluetooth() return, passed to client.transport() before join |
ClientPeerOptions, ClientRelayOptions | What peer(options) and relay(options) take: silenceMs and ask |
BluetoothOptions | What bluetooth({ ask }) takes: ask, the app's own nearby-devices prompt, called when the host first reaches for the link and resolving 'granted' or 'refused' |
TransportInfo | Deprecated for one release: use ClientTransports and client.link. The leg this client is carried on. In a browser the path is udp, tcp or turn, read off the selected candidate pair, with rttMs beside it. A Node client reports unknown and no rttMs |
EnginePath | The leg a client is carried on: 'udp', 'tcp', 'relay' or 'unknown' |
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 |
DeadlineListener / DeadlineWarningListener | The handlers on('deadline') and on('warning') take |
ListenOptions | What every on takes beside its handler: an AbortSignal |
Unsubscribe | The () => void every on returns |
Channel types
| Type | What it is |
|---|---|
ChannelRow | One row of the host's channel table, as client.channels maps each name to it |
ChannelCounters | One channel's monotonic counters, read off channel.counters when you paint |
Message | The one object a message handler is handed |
ClientMessage | The payload on its own: { data }, a string or the bytes, and none of the channel, seq or clock fields Message adds |
SendOptions | The second argument to send |
SendResult | 'sent', or the one reason it did not go |
ClientDropReason | Those reasons on their own |
Delivery | 'reliable' or 'lossy' |
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, and the seven 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 |
Persistence | The persistence member: which media this runtime has, which one it uses by default, and the slot a session takes in one |
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 |
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/
This is the client side of the session above; the host side is @bitskiff/host, a separate install.
A client page joins with its client key and the code its own link carries:
import { BitskiffClient } from '@bitskiff/client';
const client = new BitskiffClient({ key: 'bsk_pk_...' }); // your client key
const press = client.channel('press'); // name the channels first, then join
await client.join(location.hash.slice(1)); // your page read the code off its own link
press.send('down'); // a message is a string or bytes
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.