# @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](/docs/quickstart/) ·
[channels](/docs/guides/channels/) ·
[lifecycle](/docs/guides/lifecycle/) ·
[node hosts](/docs/guides/node-hosts/)

## Install

Take the tarball from the [downloads page](/docs/downloads/): `npm install` takes a URL as well as
a name.

```sh
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

**JavaScript**

```js
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
});
```

**TypeScript**

```ts
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
});
```

## `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](/docs/guides/console/) 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](#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](/docs/guides/lifecycle/#open-rejections):

**JavaScript**

```js
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);
}
```

**TypeScript**

```ts
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);
}
```

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](/docs/guides/lifecycle/) 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](/docs/guides/limits/#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` |

**JavaScript**

```js
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
}
```

**TypeScript**

```ts
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
}
```

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](/docs/guides/channels/) 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.

```js
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 `server` alone.
- 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 `peer` or `relay` sends straight into the host's WebRTC stack. For a public venue
  with open joins, pass `allowed: false` and call `client.allow(settings)` for each client you
  trust.
- Both take the same options: `allowed`, `maxConnections`, `silenceMs`, `idleCloseMs`,
  `allocate`, `release`, and `peerConnection` for 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.

```js
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: true` is taken by `peer()`, `relay()` and `bluetooth()`. A transport that cannot carry control
  throws `validation`.
- A released client reads `connection: 'released'`, and its `server` record reads `unavailable`
  with reason `released`. 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 resolves `released` or `refused`. `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:

**JavaScript**

```js
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' },
});
```

**TypeScript**

```ts
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' },
});
```

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](/docs/packages/host-node/): the same loop and the same events, with the
secret key coming from the environment rather than a bundle or source.

[Unattended hosts](/docs/guides/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](/docs/guides/lifecycle/) 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`](/docs/guides/limits/#quotas) |
| `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

- [Channels](/docs/guides/channels/)
- [Lifecycle](/docs/guides/lifecycle/)
- [Limits and capacity](/docs/guides/limits/)
- [Unattended hosts](/docs/guides/unattended-hosts/)
- [REST API](/docs/reference/rest/)

## 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](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 its `credential` row 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.json` for your code to read. Pass no `--project`, and do not take one from whoami's `project` row: outside an app directory it can name another app's project. `init` uses the project the directory's `bitskiff.json` names, else makes a new project named for the directory, so a second app gets a second project:

```sh
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 to `bitskiff.json` as `clientKey`. The sample client page needs no key.
- It writes the host key into `bitskiff.json` (`key`, `project`, `endpoint`, and with `--join-url`, `joinUrl` and `clientKey`) and prints no key string. Its `done` step names the file as `keyFile` and carries the host key's id as `hostKeyId`, 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 project `bitskiff.json` is the one file it adds.
- A `bitskiff.json` of the same project is kept, and no key is minted. With no `bitskiff.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 `--project` to keep it, or add `--force` to replace it, which reuses the new project's host key when it has one.
- A host key `init` mints has no origin set, so it allows `http://localhost` and `http://127.0.0.1` on any port. A key `init` took keeps the origins it already had; check them with `keys` before adding yours. For a deployed page, name its origin and keep localhost, because `--origin` replaces the list:

```sh
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`. Run `npm install && node bitskiff-prove.mjs` (the `prove` value of `done`) in the directory `init` wrote 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 holding `sessions:open` and `sessions:read`, and `BITSKIFF_CLIENT_KEY`, a client key naming no host key (the `clientKey` from `--join-url` serves the page's host key only). `--only secret` prints a new key alone: redirect each into a file outside the project, and export the variable from there:

```sh
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. Run `npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz logout`, then the steps below.
- Refused `forbidden` with `details.reason` of `missing_permission`: the key lacks the permission `details.permission` names. Run `logout`, then `init` to sign in again.
- Refused `forbidden` without it: the key may not use that project. Pass `--project` of a project the key may use.
- If the key comes from `BITSKIFF_ORG_KEY`, `logout` does not remove it: unset the variable instead. If whoami's `org` row is then `(not set)`, add `--org <orgId>` to `init`, with the id of the organization the key belongs to.
- No key stored (`credential` is `(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 same `bitskiff.json`, and takes the same `--project` and `--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 run `next` through the same `npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz` in place of `bitskiff`: 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, `init` runs as `--no-wait`, and a refusal is an error result carrying the same `code` and `details.reason`.
- Sign in: [https://console.dev.bitskiff.com](https://console.dev.bitskiff.com)
- Quickstart: [https://docs.dev.bitskiff.com/docs/quickstart/](/docs/quickstart/)

The whole host SDK, in a page that needs no build step:

```html
<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.
