Lifecycle
A session has five phases, and only one of them is an end. open() rejects when the session could
not be opened, so everything below the await is a session that exists:
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';
try {
const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
session.on('state', (s) => console.log(s.phase, s.ended?.reason ?? ''));
} catch (err) {
if (isBitskiffError(err)) console.log(err.code); // `origin_not_allowed`, `invalid_key`, ...
}
phase | What it is |
|---|---|
opening | the open is in flight |
open | there is a join code and phones can take seats |
reconnecting | the link dropped and the SDK is getting it back; the seats are held and the phones see the host as away |
suspended | the SDK stopped trying, with the session not known to be gone; session.retry() is the way out, and it is what a Try again button calls |
ended | terminal, with a reason |
state is a frozen snapshot replaced whole, and on('state') fires on every change to it.
A reload is not an end. A browser host keeps its session for the tab it is in, so reloading resumes the same session with the phones still seated.
Why an open was refused
The rejected open() carries a code:
code | What it is |
|---|---|
origin_not_allowed | the origin the page is served from is not on the publishable key's allowlist. Add it in the console; a page opened straight off disk has no origin to add |
invalid_key | the key is not the whole string, or it was revoked |
secret_key_in_browser | an mrc_sk_... reached a page. A page holds a publishable key or a token, never that one |
invalid_token | the token your backend handed the page is expired, revoked or unknown. Ask your backend for another; the same one answers the same way |
Why a session ended
session.state.ended.reason is the row to key on. These thirteen are every reason a host handle
can carry:
ended.reason | What it is |
|---|---|
hostClosed | you called session.close(), here or in another tab |
idleTimeout | idleTimeoutMs passed with nobody joined. Off unless you set it |
maxSessionTime | maxSessionTimeMs passed. Off unless you set it |
hostResumeExpired | the host went away for longer than the host grace, 10 s by default |
replaced | another handle presented this host's identity and took the session |
keyRevoked | the key that opened it was revoked |
apiClosed | it was closed from outside this page |
left | this host's own seat left the session, its own goodbye or the leave route |
ticketRejected | the ticket this handle presented was invalid, expired or already used |
sessionNotFound | the session id is unknown; its record is gone |
protocol | the two sides disagree on the protocol version, or one sent repeated malformed envelopes |
refused | the api refused for a project-side reason it does not spell out; ended.errorCode carries its own code |
disposed | you called session.dispose(): local teardown, nothing on the wire |
Key on the ones your host acts on and keep a fallback anyway: a switch with no default is a
session that ended with nothing said to whoever is standing at the host.
Who is seated
session.participants is the roster, by id, and on('participant') is every change to it:
import { BitskiffHost } from '@bitskiff/host';
const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
const paint = (id: string, live: boolean) => console.log(id, live);
const drop = (id: string, why: string) => console.log(id, why);
session.on('participant', (p, change) => {
switch (change.kind) {
case 'connected': // welcomed; messages may flow
case 'present': // back in the foreground
return paint(p.id, true);
case 'disconnected': // link down, seat held
case 'away': // phone hidden, seat held
return paint(p.id, false);
case 'gone': // the last event for this id, ever
return drop(p.id, change.reason);
default: // `joined`, `grantsChanged`, `e2eeChanged`
return;
}
});
A participant is an immutable value: each change hands you a new one, and session.participants
holds the current set.
A phone in the background
A phone that is locked or switched away from does not lose its seat. Its presence goes to away
and the host gets an away change; the seat is held for five minutes, and a phone that comes back
inside that gets a present change with the same participant id. Past it the seat ends with
backgroundBudget.
The phone's own page sees the same thing from the inside: client.state.presence is present or
away, and client.state.returning is true from the moment the page runs again until the seat is
confirmed still held, which is when to take the greyed-out look off rather than the moment the tab
is visible.
Ending
| Call | What it ends |
|---|---|
await session.close() | ends the session for everyone; the phones end with hostClosed |
await session.boot(id) | ends one seat; that phone ends with booted |
await client.leave() | gives up this phone's seat; the host sees gone with left |
Nothing runs on your behalf when a page goes away. A host that navigates or closes its tab is a
dropped link: the session is held for the host grace, the phones sit on the host being away, and
then it ends with hostResumeExpired and they are told hostGone. Call close() when the session
is over and you save everyone that wait.
Why a seat ended
client.state.ended.reason is the phone's own row:
ended.reason | What it is |
|---|---|
invalidCode | the code rotates every 30 s, and this one is past it. Scan the current one |
hostGone | the host reloaded, slept or lost its link for longer than the host grace |
seatsFull | four controller seats by default: raise maxControllers, or set maxViewers above 0 for a watch seat |
wrongClient | the session's join URL points at a different page than the one this phone is on |
booted | the host ended this seat |
left | this page called leave() |
reconnectBudget | the link was down too long, with the phone in the foreground |
backgroundBudget | the phone was away too long |
hostClosed | the host closed the session |
Turn the ones you recognise into your own words and keep a fallback: a reason your page has never heard of still has to say something true.