Bitskiff

Session lifecycle

On this page

A session moves through five phases, and only one of them is an end. The open() call rejects when the session could not be opened, so every line after the await runs on a session that exists:

import { BitskiffHost, isBitskiffError, type SessionState } from '@bitskiff/host';

try {
  const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();   // your host key
  session.on('state', (s: SessionState) => console.log(s.phase, s.ended?.reason ?? ''));
} catch (err) {
  if (isBitskiffError(err)) console.log(err.code);      // `origin_not_allowed`, `invalid_key`, ...
}
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

try {
  const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();   // your host key
  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`, ...
}

Phases

Read the current phase on session.state.phase:

phaseWhat it means
openingthe open is in flight
openthere is a join code and clients can join
reconnectingthe link dropped and the SDK is getting it back. The clients keep their places and see the host as away
suspendedthe SDK stopped trying. The session is not known to be gone. Call session.retry() from your Try again button
endedfinal, with a reason

The state object is a frozen snapshot, replaced whole. An on('state') listener fires on every change.

Join codes

Every setting on this page is written as a duration string: 30s, 10s, 1h30m.

A join code rotates. The rotation is joinCodeRotation, 30s by default, and the backend accepts the code in force plus the one either side. A code is accepted for its own step and the next. At the default that is 30 to 60 seconds. An older code is refused, and the client is told invalidCode.

The session.joinCode object carries the current code and its URL, and an on('joinCode') listener fires on every rotation, so the QR a host draws is always the current one. The code itself is session.joinCode.code, 15 letters and digits, and case matters. Any grouping a page prints it in—the spacing the sample client page shows, for one—is display only, and a person may type it back with spaces or dashes.

Reloads and host grace

A reload is not an end. A browser host keeps its session for the tab it is in, so a reload resumes the same session with its clients still in it. It has the host grace (hostResumeWindow, 10s by default) to get back.

open() rejections

A rejected open() carries a code on error.code. This table is every code an open() can be rejected with; a switch written from it has no default case that surprises you in production.

Waits says whether open() retries that code before it rejects. open() waits only on what a wait alone fixes: a request that never reached the service, a service that is not ready, and the pacing a rate limit named.

It waits error.retryAfterMs and asks again, for as long as its open budget lasts, and rejects with the last refusal when the budget runs out. The budget is openBudget, 2m by default.

Everything else is handed to you on the first answer, including a project at its concurrent-sessions ceiling: there you get concurrency_limit straight away, with error.retryAfterMs to count down and error.details.scope naming the level that filled.

Waiting for a session to end is your own loop to write, on your own page, where you can say what is happening. The section under the table is how to watch the waits open() does take, and how to bound them.

codeWaitsWhat to do
origin_not_allowednoadd the page's origin to the host key's allowlist with keys update <keyId> --origin. It replaces the list: see Credentials. Serve the page over HTTP: a page opened straight off disk has no origin to add
invalid_keynocheck that you pasted the whole key string, and that it has not been revoked or expired
secret_key_in_browsernoreplace the bsk_sk_... in the page with a host key or a token. A secret key never belongs in page source
invalid_tokennoask your backend for a new token. This one is expired, revoked or unknown, and retrying with it gets the same answer
restricted_modenothe project takes no publishable-key opens. Open from your backend with a secret key, or with a token your backend minted
forbiddennothe credential may not open sessions. Open with a host key, or a secret key or token that holds sessions:open: see Credentials
validationnothe session config is malformed. error.details names the field
join_url_missingnoset the project's join URL in the console. Without one there is nowhere to point a client
concurrency_limitnoyou are at the concurrent-sessions ceiling. Close a session, or raise concurrentSessions in the console. error.retryAfterMs is 10000: draw that countdown and call open() again, or wait for a session of yours to end
rate_limitedyesyou are opening faster than sessionOpensPerMinute. open() waits error.retryAfterMs and asks again
idempotency_in_flightyesan open with the same Idempotency-Key is still running. open() waits and asks again for its answer
unavailableyesthe service or something under it is down. open() waits error.retryAfterMs and asks again
overloadedyesthe service is at capacity. open() waits error.retryAfterMs and asks again
networkyesthe request never reached the service. open() waits and asks again; check that the device is online
timeoutyesthe request or the handshake ran out of budget. open() waits and asks again
quota_exceedednoa usage cap or the spend cap is spent. It clears at the period roll, or when you raise it; error.retryAfterMs is the time to the reset
plan_expirednoyour organization's plan has expired. Opens and joins are refused until it is renewed; error.details.expiredAt is the instant it ended
protocol_unsupportednothe service does not speak this SDK's protocol version. Upgrade the package
internalnothe service failed. Call open() again yourself; if it stays, error.requestId is the handle to quote
protocolnoan answer did not match the protocol this package is pinned to. Upgrade the package
enginenothe transport failed to come up; error.cause carries what it threw
disposednothe host was disposed while the open was in flight. Nothing to do: it was on its way out

The open budget

A page whose open keeps hitting a rate window or an unready service sits inside open() for up to two minutes with nothing drawn on it. Two things make that visible.

Pass a logger to see each wait. The SDK writes one warn line at the first refusal it waits on, bitskiff: open refused, retrying, with the refusal's code, its retryAfterMs and nextAt, the instant of the next attempt:

import { BitskiffHost, type Session } from '@bitskiff/host';

const bitskiff = new BitskiffHost({
  key: 'bsk_pk_...',               // your host key
  platform: { logger: console },   // the SDK's own logger; warn and above by default
});

const session: Session = await bitskiff.open();
import { BitskiffHost } from '@bitskiff/host';

const bitskiff = new BitskiffHost({
  key: 'bsk_pk_...',               // your host key
  platform: { logger: console },   // the SDK's own logger; warn and above by default
});

const session = await bitskiff.open();

Pass openBudget: '0s' to make one attempt. The first refusal is then the rejection, whatever its code, which is what a page that would rather paint its own "try again" wants—a rate window included, which is otherwise the one thing open() will sit through:

import { BitskiffHost, isBitskiffError, type Session } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

try {
  const session: Session = await bitskiff.open({ openBudget: '0s' });   // one attempt, no waiting
  console.log(session.joinCode.url);
} catch (err) {
  if (!isBitskiffError(err)) throw err;
  console.log(err.code, err.retryAfterMs);   // `rate_limited`, 30000
}
import { BitskiffHost, isBitskiffError } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

try {
  const session = await bitskiff.open({ openBudget: '0s' });   // one attempt, no waiting
  console.log(session.joinCode.url);
} catch (err) {
  if (!isBitskiffError(err)) throw err;
  console.log(err.code, err.retryAfterMs);   // `rate_limited`, 30000
}

openBudget on the open is this one call's budget. reconnect: { openBudget } on the constructor sets the default for every open(), resume() and retry() this host makes.

A handle you already hold reports each wait as an event. session.on('lifecycle') fires apiRefused once per refusal, carrying detail.errorCode and detail.requestId, and then retryScheduled, carrying nextAt, the session.now() instant of the next attempt. Neither moves phase, which stays opening, so neither raises a state event. When the budget runs out the reason is openFailed, phase becomes suspended, and the call rejects with the refusal.

These events reach you during session.retry() and bitskiff.resume(hostToken), which run on a handle you hold. The first open() has no handle yet, so the log line above is how a page watches it.

import type { SessionLifecycle } from '@bitskiff/host';

session.on('lifecycle', (e: SessionLifecycle) => {
  if (e.reason === 'apiRefused') console.log('refused', e.detail?.errorCode);
  if (e.reason === 'retryScheduled') console.log('next attempt at', e.nextAt);
});
session.on('lifecycle', (e) => {
  if (e.reason === 'apiRefused') console.log('refused', e.detail?.errorCode);
  if (e.reason === 'retryScheduled') console.log('next attempt at', e.nextAt);
});

Session end reasons

Key on session.state.ended.reason. A host session ends for one of these reasons:

ended.reasonWhat it means
hostClosedYour own code called session.close()—here, or in another tab holding the same session
apiClosedSomething outside the SDK closed it: the console's Close, bitskiff sessions close, or the REST route. Revoking the key the session was opened with ends it this way too
idleTimeoutThe idle limit ran out with no client joined. Only a session opened with idleTimeout ends this way
maxSessionTimeThe session reached maxSessionTime, the longest it was allowed to run
releasedSessionCeilingThe host stayed released for a day, the longest a session runs without its host on Bitskiff
hostResumeExpiredThe host did not come back inside the host grace. Its clients are told hostGone
ticketRejectedThe ticket this host connected with was invalid, expired or already used
sessionNotFoundThe service has no record of this session any more
replacedA newer connection presented this host identity. This handle is finished and never resumes itself; the newer one holds the session
keyRevokedThe key the session was opened with was revoked
protocolThe two ends do not agree on a protocol version, or this one sent envelopes the other could not read
refusedThe service refused the open for a reason about the project it does not spell out to the caller. ended.errorCode carries the code
disposedYour own code called dispose(). Nothing about it reached the service
unknownThe session ended for a reason this SDK release does not know

Handle the reasons your host acts on, and always write a default arm. Without one, a session ends and the person standing at the host is told nothing.

Clients

The session.clients list is the live roster, in join order, and three events follow it. The clientJoined event hands you the Client; clientChanged and clientLeft each hand you one event object:

EventThe objectWhat it carries
clientJoinedthe Clientthe client is in the roster and input may flow
clientChangedClientChanged{ client, change, previous, at, stopPropagation() }
clientLeftClientLeft{ client, reason, at, stopPropagation() }

The at field is a session.now() instant, so "how long ago" is one subtraction and never a wall clock. The previous field is the state the client was in before the change, which is where a grants change reads what the grants came from. A client's grants are what it may do inside the session—sendInput, receiveInput, broadcast, receiveBroadcast, manage—and the host sets them.

import type { Client, ClientChanged, ClientLeft } from '@bitskiff/host';

const paint = (id: string, live: boolean): void => console.log(id, live);   // your own roster
const drop = (id: string, why: string): void => console.log(id, why);      // and why it went

for (const client of session.clients) paint(client.id, true);   // what is already here

session.on('clientJoined', (client: Client) => paint(client.id, true));
session.on('clientLeft', (e: ClientLeft) => drop(e.client.id, e.reason));
session.on('clientChanged', (e: ClientChanged) => {
  switch (e.change) {
    case 'reconnected':
    case 'present':                        // the page called setPresence('present')
      return paint(e.client.id, true);
    case 'disconnected':                   // link down, the client keeps its place
    case 'away':                           // setPresence('away'), the client keeps its place
      return paint(e.client.id, false);
    default:                               // `grants`, `channels`, `e2ee`, `network`
      return;
  }
});
const paint = (id, live) => console.log(id, live);   // your own roster: a client id, live or not
const drop = (id, why) => console.log(id, why);      // and the reason it went

for (const client of session.clients) paint(client.id, true);   // what is already here

session.on('clientJoined', (client) => paint(client.id, true));
session.on('clientLeft', (e) => drop(e.client.id, e.reason));
session.on('clientChanged', (e) => {
  switch (e.change) {
    case 'reconnected':
    case 'present':                        // the page called setPresence('present')
      return paint(e.client.id, true);
    case 'disconnected':                   // link down, the client keeps its place
    case 'away':                           // setPresence('away'), the client keeps its place
      return paint(e.client.id, false);
    default:                               // `grants`, `channels`, `e2ee`, `network`
      return;
  }
});

A client is one object for as long as it is in the session—the same instance in the roster, in every message and in every callback, across a reconnect. What changes is client.state, an immutable snapshot replaced whole; it is already the new one when your handler runs.

A client you hold can also be listened to on its own: client.on('change', fn) and client.on('left', fn) are handed the same event object, run before the session-wide handlers, and e.stopPropagation() in one of them stops the session-wide handler for that event. Specific before general, as in the DOM, and the same rule a channel's { from } handlers follow.

ClientChange is disconnected, reconnected, away, present, grants, channels (this client declared or dropped a name), e2ee (its key generation moved) and network (this client walked from wifi onto cellular, or the other way, without losing its connection; previous carries the class it left). Every member that moves on its own has a value here; deadlines and transport move with the change that caused them.

Background clients

A client whose page calls client.setPresence('away') keeps its place. Its presence goes to away and the host gets an away change. The SDK never guesses whether a person is looking: the page says so, and client.setPresence('present') says the person is back.

The place is held for backgroundBudget, 30s by default. A client that comes back inside that window gets a present change on the same object. Past it, it ends with backgroundBudget.

The client's own page sees the same thing from the inside, on its BitskiffClient: client.state.presence is present or away. client.state.returning is true from the moment the page calls setPresence('present') until its place is confirmed still held. Take the greyed-out look off when returning goes false, not at the call.

Deliberate ends

Three calls end something on purpose. Two of them are the host's and one is the client page's, and the handle each is called on is a different object:

CallThe handleWhat it ends
await session.close()the host's Sessionthe session, for every client in it. Clients end with hostClosed
await client.boot()a roster Client on the hostone client. It ends with booted
await client.leave()the client page's BitskiffClientthis page's own place. The host sees clientLeft with left

Two different objects are called a client, one in each SDK. A roster Client is the host's view of one client, and it has boot() and no leave(). A BitskiffClient is the client page's own handle, and it has leave() and no boot(). Every snippet on this site says which of the two it is holding.

Call close() as soon as the session is over. Closing the tab does not close the session. To the service it looks like a dropped link. The session stays open for the host grace, and clients show the host as away. Then it ends with hostResumeExpired, and they get hostGone.

What the host is told is one word on clientLeft's e.reason:

e.reasonWhat it means
leftthe client said so
bootedyou, a console action or a termination rule removed it, or it stayed over the message rate
reconnectBudgetthe link was down too long while the client was present
backgroundBudgetit was away too long
replaceda newer connection presented the same identity
sessionEndedthe session ended and no finer reason reached this client
keyRevokedthe host key that opened the session was revoked
keyUnverifiedunder end-to-end encryption, the client's encryption key failed its binding, so the host booted it
ratethe host booted it for sending over its byte rate: see Limits

keyRevoked is about the host key. A client whose own client key is revoked with compromise loses its place within 5 seconds: see Client keys.

Termination

A client can lose its place without a deliberate end. That is termination. Two ends come built in: the two budgets. The host's inactivity window is a countdown you act on, and termination rules add ends of your own.

Built-in ends

Every session has two budgets. They are built in, not rules:

BudgetDefaultIt ends a client that
reconnectBudget10slost its link while present and did not come back in time
backgroundBudget30swas set away and did not come back in time

Change either one on open(). Setting one to 0s ends the client at once. It does not turn the budget off. See Other session settings.

The host's inactivity window

The host can also keep its own window for each client. Pass inactivity to open(). The host SDK starts a countdown for a client when it attaches, and client.markActive() restarts it. You decide what counts as activity: a tap, a move past the joystick's dead zone, anything.

The client page sees the same countdown. It is an inactivity entry on client.deadlines, and on('warning') fires warningLead before it runs out. When it runs out, the SDK neither boots the client nor raises an event. Watch the entry, and call client.boot('idle') if you want the client gone. The window lives in the host, so it stops when the host does.

Termination rules

For anything else, add rules. A session's terminationRules is a list, and each rule is a condition plus a duration:

FieldWhat it is
nameyour name for the rule
whena CEL condition about one client. It has no clock in it
forhow long when must hold before the rule fires. Optional, 1s when omitted

The service enforces every rule, not the host. A rule still fires while the host is away or restarting, which is when a stuck client most needs to go. The host SDK only shows the countdown.

Rules are set when the session opens. Omit terminationRules and the session takes your project's default list, which is empty until you set one. Pass [] for no rules at all.

How a rule fires

These three sentences are the whole behaviour:

  1. when is re-evaluated whenever a fact about the client changes.
  2. A rule arms when when becomes true and disarms when it becomes false.
  3. When a rule has been armed for for, when is evaluated once more: if it is still true the rule fires, and otherwise the rule disarms.

A fact that goes false and true again inside for starts the countdown from zero. There is no sampling interval. The one delay is for the two session facts, session.clients and session.hostState: a rule over them can arm up to 250 ms after the change.

Examples

import { BitskiffHost, type TerminationRule } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

// 1. The default: no rules. The two budgets are enough.
await bitskiff.open();

// 2. End a byte hog.
const byteHog: TerminationRule = { name: 'byte-hog', when: 'bytesOut > 50000000' };
await bitskiff.open({ terminationRules: [byteHog] });

// 3. No flapping.
const noFlapping: TerminationRule = {
  name: 'no-flapping',
  when: "reconnects >= 5 && connection == 'detached'",
  for: '10s',
};
await bitskiff.open({ terminationRules: [noFlapping] });
import { BitskiffHost } from '@bitskiff/host';

const bitskiff = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

// 1. The default: no rules. The two budgets are enough.
await bitskiff.open();

// 2. End a byte hog.
await bitskiff.open({
  terminationRules: [{ name: 'byte-hog', when: 'bytesOut > 50000000' }],
});

// 3. No flapping.
await bitskiff.open({
  terminationRules: [
    { name: 'no-flapping', when: "reconnects >= 5 && connection == 'detached'", for: '10s' },
  ],
});
  1. The default adds no rule. A client ends when a budget runs out or the host boots it.
  2. byte-hog ends a client 1 second after a stats report shows it has sent more than 50 MB since it joined. It does nothing while the host is away.
  3. no-flapping ends a client that has reconnected five times and dropped again, 10 seconds later. A client that gets back inside those 10 seconds keeps its place.

What a rule can read

A when can name ten facts about one client, and nothing else. Any other name is refused.

FactWhat it holds
connection'reserved' (has a ticket, not attached yet), 'attached' or 'detached'
presence'present' or 'away'
reconnectshow many times the client has reconnected
grantsthe client's grants. Test one with in: !('sendInput' in grants)
bytesIn, bytesOutbytes delivered to the client, and bytes it sent, since it joined
messagesIn, messagesOutthe same, counted in messages
session.clientshow many clients the session holds now
session.hostState'attached' or 'away'

See TerminationRule in the host reference. Two traps: a rule over connection == 'reserved' can end a client mid-join, and session.clients == 0 never fires, because a rule only runs for a client that is there.

The four counters are different from the other six:

  • They move only when a host posts a stats report, about once a minute. With no host attached, they stand still.
  • The client counts its own traffic and reports it to the host, which posts the figures. A client that stops reporting never trips a counter rule.

Use a counter rule to clear out a client that uses more than its share. Do not use it as a defence. A per-second ceiling belongs in Rate limits.

Rule limits

LimitValue
Rules per session16
name1 to 32 characters of a-z, 0-9, _ and -, unique in the list
for1s to 24h. Your project can lower the ceiling
when512 bytes, and it must return true or false

A rule outside a limit is refused, never clamped. open() rejects with a BitskiffError whose code is validation, details.field names the rule, such as terminationRules[1].when, and details.reason says what is wrong. Import @bitskiff/host/expressions to get that refusal before the request goes out. Without it, the service sends the same refusal.

When a rule fires

While a rule is armed, both ends see a policy countdown. The host reads it on the roster client's client.state.deadlines, and the client page on its own client.deadlines. On both, on('warning') fires warningLead before it, like any other ending. If the rule fires, the client is removed:

  • The host gets clientLeft with e.reason booted.
  • The client ends with ended.reason booted and ended.bootReason policy.

The client is told policy, never the rule's name, because the name is your own string. The service records the name on the project's client.gone event.

Client end reasons

Key on client.state.ended.reason on the client:

ended.reasonWhat it means
invalidCodethe code is past its window (see Join codes). Scan the current one
hostGonethe host slept or lost its link for longer than the host grace
clientsFullthe session is at maxClients, 4 by default. Open the next one with a higher number: see Session capacity
wrongClientthis client key is not for this session: the session is another project's, or its host key is one this client key does not serve
bootedthe host removed this client, or a termination rule did. For a rule, ended.bootReason is policy
leftthis page called leave()
reconnectBudgetthe link was down too long while the client was present
backgroundBudgetit was away too long
hostClosedthe host closed the session
refusedthe service refused the join. ended.errorCode says which refusal it was, such as invalid_token for a token that is expired, revoked or unknown

Those ten are the common ones. A client can also see sessionEnded, apiClosed, idleTimeout, maxSessionTime, replaced, ticketRejected, sessionNotFound, keyRevoked, hostUnverified, keyUnverified, rate, protocol and disposed. rate means the host booted this client for sending over its byte rate. A ticket, in ticketRejected, is the single-use credential the service mints for one connection attempt; it expires in a minute and no SDK hands you one.

Turn the reasons you recognise into your own words, and write a fallback message for the rest. A reason your page has never seen still has to say something true.

Close codes

Under every ended.reason is the code the connection was closed with. The SDK reads it for you: it turns one of these into an ended.reason, or into a reconnect, and a page that keys on ended.reason never has to look at the number.

It is here because two other places hand you one: bitskiff sessions close says every client is closed with 4000, and a REST refusal that also ended something carries the code in closeCode.

Close codeWhat it means
1000This end called leave() or close()
4000The session ended. Every client in it is closed with this code
4001This client was removed by the host or from the console
4002The ticket was invalid, expired or already used
4003The reconnect or background budget is spent. The place is gone and the way back is a fresh join code
4004No session with this id is live
4005The caller's protocol range and the session's pinned version do not meet
4006A newer connection presented the same identity. This one stops
4007The host did not come back inside the host grace
4008The key the session was opened with was revoked
4009Malformed or oversize envelopes kept coming after the first was refused
4100Too many attempts. The SDK waits out retryAfterMs, or backs off on its own—jittered, up to 10 s—when the close carries none, and comes back
4101The region is shedding load. The SDK backs off and comes back
4102A planned drain. The SDK backs off and comes back
4200The server this connection was on is going away. The SDK mints a fresh ticket and reconnects
4201The link was lost and could not be resumed. The SDK mints a fresh ticket and reconnects
4202The session moved. Reserved; not sent
4203This client released its server connection and it sent on it anyway. The place is held: the SDK does not reconnect, and rejoins only when you call rejoin() or the released transport is lost

Read the range, not the individual number. 1000 and 4000 to 4099 end the session, and the SDK does not reconnect. 4100 to 4199 mean back off, and the SDK tries again after the wait. 4200 to 4299 mean reconnect now, and the SDK does. Anything else is treated as 4201.

Next steps