Session lifecycle
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:
phase | What it means |
|---|---|
opening | the open is in flight |
open | there is a join code and clients can join |
reconnecting | the link dropped and the SDK is getting it back. The clients keep their places and see the host as away |
suspended | the SDK stopped trying. The session is not known to be gone. Call session.retry() from your Try again button |
ended | final, 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.
code | Waits | What to do |
|---|---|---|
origin_not_allowed | no | add 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_key | no | check that you pasted the whole key string, and that it has not been revoked or expired |
secret_key_in_browser | no | replace the bsk_sk_... in the page with a host key or a token. A secret key never belongs in page source |
invalid_token | no | ask your backend for a new token. This one is expired, revoked or unknown, and retrying with it gets the same answer |
restricted_mode | no | the project takes no publishable-key opens. Open from your backend with a secret key, or with a token your backend minted |
forbidden | no | the credential may not open sessions. Open with a host key, or a secret key or token that holds sessions:open: see Credentials |
validation | no | the session config is malformed. error.details names the field |
join_url_missing | no | set the project's join URL in the console. Without one there is nowhere to point a client |
concurrency_limit | no | you 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_limited | yes | you are opening faster than sessionOpensPerMinute. open() waits error.retryAfterMs and asks again |
idempotency_in_flight | yes | an open with the same Idempotency-Key is still running. open() waits and asks again for its answer |
unavailable | yes | the service or something under it is down. open() waits error.retryAfterMs and asks again |
overloaded | yes | the service is at capacity. open() waits error.retryAfterMs and asks again |
network | yes | the request never reached the service. open() waits and asks again; check that the device is online |
timeout | yes | the request or the handshake ran out of budget. open() waits and asks again |
quota_exceeded | no | a 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_expired | no | your organization's plan has expired. Opens and joins are refused until it is renewed; error.details.expiredAt is the instant it ended |
protocol_unsupported | no | the service does not speak this SDK's protocol version. Upgrade the package |
internal | no | the service failed. Call open() again yourself; if it stays, error.requestId is the handle to quote |
protocol | no | an answer did not match the protocol this package is pinned to. Upgrade the package |
engine | no | the transport failed to come up; error.cause carries what it threw |
disposed | no | the 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.reason | What it means |
|---|---|
hostClosed | Your own code called session.close()—here, or in another tab holding the same session |
apiClosed | Something 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 |
idleTimeout | The idle limit ran out with no client joined. Only a session opened with idleTimeout ends this way |
maxSessionTime | The session reached maxSessionTime, the longest it was allowed to run |
releasedSessionCeiling | The host stayed released for a day, the longest a session runs without its host on Bitskiff |
hostResumeExpired | The host did not come back inside the host grace. Its clients are told hostGone |
ticketRejected | The ticket this host connected with was invalid, expired or already used |
sessionNotFound | The service has no record of this session any more |
replaced | A newer connection presented this host identity. This handle is finished and never resumes itself; the newer one holds the session |
keyRevoked | The key the session was opened with was revoked |
protocol | The two ends do not agree on a protocol version, or this one sent envelopes the other could not read |
refused | The service refused the open for a reason about the project it does not spell out to the caller. ended.errorCode carries the code |
disposed | Your own code called dispose(). Nothing about it reached the service |
unknown | The 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:
| Event | The object | What it carries |
|---|---|---|
clientJoined | the Client | the client is in the roster and input may flow |
clientChanged | ClientChanged | { client, change, previous, at, stopPropagation() } |
clientLeft | ClientLeft | { 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:
| Call | The handle | What it ends |
|---|---|---|
await session.close() | the host's Session | the session, for every client in it. Clients end with hostClosed |
await client.boot() | a roster Client on the host | one client. It ends with booted |
await client.leave() | the client page's BitskiffClient | this 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.reason | What it means |
|---|---|
left | the client said so |
booted | you, a console action or a termination rule removed it, or it stayed over the message rate |
reconnectBudget | the link was down too long while the client was present |
backgroundBudget | it was away too long |
replaced | a newer connection presented the same identity |
sessionEnded | the session ended and no finer reason reached this client |
keyRevoked | the host key that opened the session was revoked |
keyUnverified | under end-to-end encryption, the client's encryption key failed its binding, so the host booted it |
rate | the 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:
| Budget | Default | It ends a client that |
|---|---|---|
reconnectBudget | 10s | lost its link while present and did not come back in time |
backgroundBudget | 30s | was 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:
| Field | What it is |
|---|---|
name | your name for the rule |
when | a CEL condition about one client. It has no clock in it |
for | how 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:
whenis re-evaluated whenever a fact about the client changes.- A rule arms when
whenbecomes true and disarms when it becomes false. - When a rule has been armed for
for,whenis 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' },
],
});
- The default adds no rule. A client ends when a budget runs out or the host boots it.
byte-hogends 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.no-flappingends 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.
| Fact | What it holds |
|---|---|
connection | 'reserved' (has a ticket, not attached yet), 'attached' or 'detached' |
presence | 'present' or 'away' |
reconnects | how many times the client has reconnected |
grants | the client's grants. Test one with in: !('sendInput' in grants) |
bytesIn, bytesOut | bytes delivered to the client, and bytes it sent, since it joined |
messagesIn, messagesOut | the same, counted in messages |
session.clients | how 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
| Limit | Value |
|---|---|
| Rules per session | 16 |
name | 1 to 32 characters of a-z, 0-9, _ and -, unique in the list |
for | 1s to 24h. Your project can lower the ceiling |
when | 512 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
clientLeftwithe.reasonbooted. - The client ends with
ended.reasonbootedandended.bootReasonpolicy.
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.reason | What it means |
|---|---|
invalidCode | the code is past its window (see Join codes). Scan the current one |
hostGone | the host slept or lost its link for longer than the host grace |
clientsFull | the session is at maxClients, 4 by default. Open the next one with a higher number: see Session capacity |
wrongClient | this 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 |
booted | the host removed this client, or a termination rule did. For a rule, ended.bootReason is policy |
left | this page called leave() |
reconnectBudget | the link was down too long while the client was present |
backgroundBudget | it was away too long |
hostClosed | the host closed the session |
refused | the 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 code | What it means |
|---|---|
1000 | This end called leave() or close() |
4000 | The session ended. Every client in it is closed with this code |
4001 | This client was removed by the host or from the console |
4002 | The ticket was invalid, expired or already used |
4003 | The reconnect or background budget is spent. The place is gone and the way back is a fresh join code |
4004 | No session with this id is live |
4005 | The caller's protocol range and the session's pinned version do not meet |
4006 | A newer connection presented the same identity. This one stops |
4007 | The host did not come back inside the host grace |
4008 | The key the session was opened with was revoked |
4009 | Malformed or oversize envelopes kept coming after the first was refused |
4100 | Too 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 |
4101 | The region is shedding load. The SDK backs off and comes back |
4102 | A planned drain. The SDK backs off and comes back |
4200 | The server this connection was on is going away. The SDK mints a fresh ticket and reconnects |
4201 | The link was lost and could not be resumed. The SDK mints a fresh ticket and reconnects |
4202 | The session moved. Reserved; not sent |
4203 | This 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.