Bitskiff

Unattended hosts

On this page

A host with nobody standing at it—signage in a lobby, a bot, a dedicated game host—has one job: keep a session open. The openForever loop is what does it, and loop.health is what the host's display paints from.

The open-forever loop

A session ends for several reasons: a link that drops for longer than the grace, somebody closing it in the console, a revoked key. A program that called open once has nothing to show after that session ends. The openForever loop opens the next session, on a backoff table, for as long as the process runs.

import { BitskiffHost, qrTerminal } from '@bitskiff/host-node';
import type { JoinCode, Message, Session } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';
import type { ReopenInfo } from '@bitskiff/host-node/unattended';

function play(m: Message): void { /* ... */ }

const bitskiff = new BitskiffHost();                    // the key comes from BITSKIFF_SECRET_KEY
bitskiff.channel('press').on('message', (m: Message) => play(m));   // declared once, heard in every session

const loop = openForever(bitskiff, {
  session: { label: 'lobby-north', hostResumeWindow: '5m' },
});

loop.on('open', (session: Session) => {                 // every session, the first one included
  console.log(qrTerminal(session.joinCode.url));        // scannable out of the terminal
  session.on('joinCode', (code: JoinCode) => {          // the code rotates: draw the new one
    console.log(qrTerminal(code.url));
  });
});

loop.on('reopen', (_session: Session, { attempt, reason }: ReopenInfo) => {   // the second session on
  console.log(`reopened after ${reason} (attempt ${attempt})`);
});
import { BitskiffHost, qrTerminal } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

// Your own function: do something with a message from a client.
function play(m) { /* ... */ }

const bitskiff = new BitskiffHost();                    // the key comes from BITSKIFF_SECRET_KEY
bitskiff.channel('press').on('message', (m) => play(m));   // declared once, heard in every session

const loop = openForever(bitskiff, {
  session: { label: 'lobby-north', hostResumeWindow: '5m' },
});

loop.on('open', (session) => {                          // every session, the first one included
  console.log(qrTerminal(session.joinCode.url));        // scannable out of the terminal
  session.on('joinCode', (code) => {                    // the code rotates: draw the new one
    console.log(qrTerminal(code.url));
  });
});

loop.on('reopen', (_session, { attempt, reason }) => {  // the second session on
  console.log(`reopened after ${reason} (attempt ${attempt})`);
});

The blocks here are a Node host reading its secret key from the environment, which is what a permanent install runs. Node hosts has the install and the key.

A browser or Electron host runs the same loop from @bitskiff/host/unattended, and the last section has that import. A host whose usage you bill to a customer opens with a token instead of a key: Usage-based billing.

The open event fires for every session the loop opens, the first one included, so the wiring goes in one place and runs for every session. The reopen event fires for the ones after it and says which end it is recovering from.

That block is a whole program. The blocks after it are snippets: each shows the lines its section is about, on the loop and the bitskiff opened here.

Set hostResumeWindow to the longest drop a host should ride through. Inside it the session survives, its clients keep their places, and the host redraws the same session's code when it comes back. Past it the session ends and the next one is a new session with a new code.

5m is the number to ship; the 10s default covers a page reload and not a venue's wifi. It is a duration string, like every duration you write here.

Leave idleTimeout alone. The platform default is 0s, no limit, until you set one. Setting it on a host with nobody at it ends a good session on every quiet stretch and puts a new join code on the host's display each time.

Draw the code on the event as well as on open. The code rotates every 30 seconds, so a code a client sees stays accepted for at least one full step and at most two—30 to 60 seconds at the default. A host that draws it once shows a code that has stopped working while nobody is watching.

Labels

Give every host a label. It is that installation's identity across every session the loop opens, so the console, the session list and every webhook read as one host instead of a stream of unrelated sessions, and a refusal names which installation was turned away.

import { BitskiffHost, type Session } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  session: {
    label: 'lobby-north',                                // this installation, in every session
    metadata: { venue: 'north-building', floor: '2' },   // your own keys, carried with it
  },
});

loop.on('open', (session: Session) => console.log(session.joinCode.code));
import { BitskiffHost } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  session: {
    label: 'lobby-north',                                // this installation, in every session
    metadata: { venue: 'north-building', floor: '2' },   // your own keys, carried with it
  },
});

loop.on('open', (session) => console.log(session.joinCode.code));

The metadata map is yours: whatever your own tools need to place the host without looking it up.

Which of your hosts is up is one read: GET /v1/sessions?label=lobby-north matches the label exactly and pages like any other list. A label with no row is a host that is not serving. The row goes when the host grace expires, so the read lags a cut-off host by the grace.

It is still the only signal there is for a host cut off by its own network—Bitskiff sees no event for it.

Fallback content

What a host shows when a session is not available is yours, and the SDK renders none of it. Bind it to health.status and give "at capacity" its own branch.

import type { CapacityNotice, Session } from '@bitskiff/host-node';
import type { HostHealth } from '@bitskiff/host-node/unattended';

// Your own display.
function showJoinCode(): void { /* ... */ }
function showFallback(): void { /* ... */ }
function showAtCapacity(): void { /* ... */ }

loop.on('health', (health: HostHealth) => {
  if (health.status === 'serving') showJoinCode();       // back at once; nothing to debounce
  else if (health.lastRefusal?.limit) showAtCapacity();  // a cap turned the last open away
  else if (health.status === 'down') showFallback();
});

loop.on('open', (session: Session) => {
  session.on('capacity', (capacity: CapacityNotice) => {  // the same branch, session still live
    if (capacity.status !== 'ok') showAtCapacity();
  });
});
// Your own display.
function showJoinCode() { /* ... */ }
function showFallback() { /* ... */ }
function showAtCapacity() { /* ... */ }

loop.on('health', (health) => {
  if (health.status === 'serving') showJoinCode();       // back at once; nothing to debounce
  else if (health.lastRefusal?.limit) showAtCapacity();  // a cap turned the last open away
  else if (health.status === 'down') showFallback();
});

loop.on('open', (session) => {
  session.on('capacity', (capacity) => {                 // the same branch, session still live
    if (capacity.status !== 'ok') showAtCapacity();
  });
});

A status of down means not serving and not about to be: 30 seconds without serving. It ends only when a session is open and connected again, so a page that follows it needs no grace of its own and no debounce, and it does not flicker. A blip that comes back inside those 30 seconds never reaches down at all.

A status of recovering is the state in between, and a host that has just booted starts there.

The health.changed field names what moved, so a page switches on one value instead of comparing snapshots. The health.phase field says whether a session is still in hand: reconnecting while the transport comes back. The health.lastDrop field carries the last drop, which is what the host's display shows during the 30 seconds before down.

The capacity branch has two halves because a cap is met from two sides. While a session is live, session.capacity carries the project's standing and on('capacity') fires when it moves. Once the session has ended and the next open is refused by a cap, health.lastRefusal.limit names it. Nothing clears lastRefusal, so read it beside status, as the block above does.

The health.sessions and health.reconnects fields are counters, never a rate. They are what a host prints in a corner for a technician.

Two fields carry the clock behind status. health.servingSince is the instant the serving run in hand began, and it is undefined whenever nothing is being served. health.lastServingAt is the instant serving last ended, and it is undefined until a host has served once.

recovering is measured from lastServingAt, or from the loop's boot for a host that has never served, which is why a host that has just booted starts there.

Signs a host needs a person

Two things say a host needs a person: a refusal that waiting will not clear, still standing on a host that is not serving; and how long it has been down.

import { isBitskiffError } from '@bitskiff/host-node';
import type { HostHealth, OpenRefusedInfo } from '@bitskiff/host-node/unattended';

// Your own display, for whoever comes to look. `text` is one line to put on it.
function showAttention(text: string): void { /* ... */ }

loop.on('openRefused', (error: unknown, { attempt, nextAt }: OpenRefusedInfo) => {
  if (!isBitskiffError(error)) return;
  const inS = nextAt === undefined ? 0 : Math.round((nextAt - bitskiff.platform.now()) / 1000);
  console.error(`${error.code} from ${error.source}, attempt ${attempt}, next try in ${inS}s`);
});

loop.on('health', (health: HostHealth) => {
  const refusal = health.lastRefusal;
  const downForMs = bitskiff.platform.now() - health.since;
  if (health.status !== 'serving' && refusal?.clears === 'never') showAttention(refusal.code);
  else if (health.status === 'down' && downForMs > 900_000) showAttention('down for 15 minutes');
});
import { isBitskiffError } from '@bitskiff/host-node';

// Your own display, for whoever comes to look. `text` is one line to put on it.
function showAttention(text) { /* ... */ }

loop.on('openRefused', (error, { attempt, nextAt }) => {
  if (!isBitskiffError(error)) return;
  const inS = nextAt === undefined ? 0 : Math.round((nextAt - bitskiff.platform.now()) / 1000);
  console.error(`${error.code} from ${error.source}, attempt ${attempt}, next try in ${inS}s`);
});

loop.on('health', (health) => {
  const refusal = health.lastRefusal;
  const downForMs = bitskiff.platform.now() - health.since;
  if (health.status !== 'serving' && refusal?.clears === 'never') showAttention(refusal.code);
  else if (health.status === 'down' && downForMs > 900_000) showAttention('down for 15 minutes');
});

Read lastRefusal beside status. Nothing clears the field, so it stands where the last refusal left it through the whole next healthy session. Without the status gate, a host refused at 09:00, handed a new key and serving all day still lights the marker.

The lastEnd.reason field is not that marker either. Somebody closing a session in the console is not a fault, and the field is sticky the same way.

There is no needsAttention flag. How long is too long is your policy, and health.since is the instant the current status began, so platform.now() - since is the whole of the arithmetic.

The same facts reach your server as the open.refused webhook, which is how a fleet owner sees a host that cannot open. A host whose every open is refused has no session to move anything in the event stream. The open.refused event carries the label, the code, what clears it and the cap it hit, once per episode rather than once per retry. Webhooks has the endpoint and the fields.

Usage limits

Caps refuse new work; they never cut a live session or a live client. A host at a project's ceiling keeps serving everyone already in its session and is refused at its next open.

The session.capacity object is the project's standing, and capacity.limits carries a row only for a cap that is not ok—so a healthy project has an empty list.

import type { CapacityNotice, Session } from '@bitskiff/host-node';

loop.on('open', (session: Session) => {
  session.on('capacity', (capacity: CapacityNotice) => {  // fires when the standing moves
    for (const limit of capacity.limits) {               // a row for each cap that is not ok
      console.log(`${limit.limit} ${limit.status}: ${limit.value} of ${limit.ceiling}`);
    }
  });
});
loop.on('open', (session) => {
  session.on('capacity', (capacity) => {                 // fires when the standing moves
    for (const limit of capacity.limits) {               // a row for each cap that is not ok
      console.log(`${limit.limit} ${limit.status}: ${limit.value} of ${limit.ceiling}`);
    }
  });
});
CapWhat it refusesWhat the live session seesWhat clears it
concurrentSessions, 5the next open, never the one in handnothinganother session ending, or a raise
concurrentClients, 40the next joinnothing; the client's join page counts downa client leaving, or a raise
clientMinutesPerDaynew opens and new joinsa capacity row, warning at 80% and exceeded at 100%00:00 UTC, or a raise
monthlySpendCapnew opens and new joinsthe same rowthe month rolling, or a raise
samplePageSessions, 100 warn and 1000 refusejoins through the sample client page, past 1000 sessions counted for the life of the project. It refuses a client's join and never a host's open; at 100 the console and the CLI only warna capacity row with no reset, on the one samplePageSessions namepointing the project's joinUrl at a page of your own
samplePageJoinsPerDay, 100joins through the sample client page, past 100 joins in one UTC daynothing; the host learns of it only through a client's refused join00:00 UTC, or pointing the project's joinUrl at a page of your own
turnBytesPerMonthnothing: relay bytes are counted and warned on, and no join is refused for thema capacity row from 80%the month rolling, or a raise
sessionOpensPerMinute, ticketMintsPerMinutethe callnothing; the host reads lastRefusalthe window rolling
session ceilingsnothing: the values are clampedwhat you actually got, on session.state.configasking for a value inside the ceiling

A host that asked for reconnectBudget: '10m' and got 2m, the ceiling on that setting, is serving and healthy, and health says nothing about the clamp. The session.state.config object is where a host reads what it was given. Limits and capacity has every session setting and every project number, each with its default and its ceiling.

A revoked key is the one credential change that does cut a host: the session ends, every open after it is refused, and the fix is a new key. Pass key as a function and the loop reads it on every attempt, so a pushed credential brings the host back with nobody at the machine.

Retries

The loop retries every refusal. A refusal that waiting cannot clear is retried too, slowly. The loop is not the judge of what a person has fixed. A replaced key, an origin added to an allowlist or a joinUrl that was set changes nothing the host can see from where it stands. Asking again is the only way it learns.

What happenedWhat the loop waitsThe setting behind it
a session ended—an outage, a revoked key, an idle limit1 s plus a jittered step. The step starts at 2 s and doubles each attempt up to 300 sinitialBackoff, 2s, doubling to maxBackoff, 5m
a refusal that clears by waiting and names no waitthe same 1 s plus a doubling stepthe same two
a rate limit, which names the window it is pacingup to twice that window, and the attempt count stays where it wasthe window the refusal named is the floor, never longer than maxRetryAfter, 5m; the jitter on top spans the whole window
a cap that names a wait—a daily or a monthly quota300 to 600 s, and the attempt countsmaxRetryAfter, 5m, then a jitter up to maxBackoff, 5m
a refusal waiting will not clear—a bad key, a blocked origin300 to 600 s, foreverattentionBackoff, 5m
a session somebody closed in the console, or your own close()60 to 62 s, so the concurrency this host gives back stays free for somebody elsereopenAfterClose, 1m
a ban from the edge, which turns away one address at a timethe whole ban it named, never shortened: 600 to 1200 sno setting: the ban's own wait, which maxRetryAfter does not cut

Every one of those settings is a duration string on the policy you pass, and the defaults above are what a host runs with when you pass nothing.

Every wait is jittered, so a fleet refused in the same second does not come back in the same millisecond.

A wait is not a sleep either. When nothing answered at all—a dead link, a blackholed one—a Node host probes for reachability once health reads down, and opens on the first answer after a probe that failed. A browser host cuts any wait short on its page's own online, pageshow and visible events, whatever refused it, every wait but the one after a close.

Close in the console is not a stop. Nothing on the platform stops a loop running on somebody's machine, so the host is back about a minute later—with a new join code, because a new session is a new code and anybody holding the old one has to scan again.

The loop has three ends and three only: you called stop() or detach(), your own reopenOn returned false for the reason a session ended, or the host or the open session was disposed. The health.stopped field and the stopped event carry which.

The reopenOn predicate reopens after every end by default, which is what a host with nobody at it wants. The reasons it is asked about are hostClosed, apiClosed, idleTimeout, maxSessionTime, hostResumeExpired, ticketRejected, sessionNotFound, replaced, keyRevoked, protocol, refused and unknown.

Every reason your predicate refuses ends the loop for good, and nothing restarts it. A disposed end is never asked about: the host is gone, so the loop ends without calling reopenOn.

import { BitskiffHost } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';
import type { LoopStopped } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  policy: { attentionBackoff: '2m' },        // every other field keeps its default
});

loop.on('stopped', ({ reason }: LoopStopped) => console.log(`loop ended: ${reason}`));
import { BitskiffHost } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  policy: { attentionBackoff: '2m' },        // every other field keeps its default
});

loop.on('stopped', ({ reason }) => console.log(`loop ended: ${reason}`));

DEFAULT_UNATTENDED_POLICY is exported beside openForever when you want to read a default rather than set one. Pass only the fields you are changing: the rest fall back on their own.

Fleet settings

Every default here is sized for one host on a laptop. A thousand hosts meet several of them on the first day, so set these before the vans go out. The first two are per session; the rest are project and key settings you change in the console.

SettingSet it toWhy
hostResumeWindow5m, against a 30m ceilingThe host grace. Inside it a drop costs nothing; past it every affected host shows a new join code
maxSessionTime0s, unless something really must end a session on a clock0s is no limit, which is what an unattended host wants. Any other value puts a new code on every host in the fleet on that clock
concurrentSessionsat least the fleet size, with headroom for an overlapping rolloutEvery host holds one session. Below the fleet size, some hosts cannot open at all and take turns being down
sessionOpensPerMinutefleet size divided by the minutes you will accept for a full re-entry, on the project and on the keyIt paces re-entry after an outage. At the default 10, ten thousand hosts take about 17 hours; at 1000 they take ten minutes
clientMinutesPerDayexpected traffic, with headroom to growThe default 2000 is an anomaly cap, not a plan. A fleet with real client traffic spends it before lunch and every host is refused until 00:00 UTC
joinUrla client page of your ownThe sample client page is counted for the life of the project and nothing resets it: the console warns past 100 sessions and joins are refused past 1000, while every host keeps serving a code nobody can join
sessionOpensPerMinuteIpthe number of hosts behind the busiest address, up to 300Hosts behind one NAT are one address to this limit, and the default is 30
ticketMintsPerMinutefleet size divided by the grace's minutes, on the key the fleet usesEvery resume attempt mints a ticket, and the grace is all the time a resume has. At the default 120, most of a large fleet's sessions end instead of resuming

The hostTicketsPerMinuteIp row is 30 ticket mints a minute per address, and a project can raise it to 300. Raises lists every limit a project may raise. Raise it to your venue's host count, or put no more than 30 hosts behind one address, or stagger a venue's rollout so it does not come back all at once.

import { BitskiffHost } from '@bitskiff/host-node';
import { openForever, type UnattendedLoop } from '@bitskiff/host-node/unattended';

const loop: UnattendedLoop = openForever(new BitskiffHost(), {
  session: {
    label: 'lobby-north',
    hostResumeWindow: '5m',        // ride a five-minute drop without a new join code
    maxSessionTime: '0s',          // no clock ends a session this host is serving
  },
});
import { BitskiffHost } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  session: {
    label: 'lobby-north',
    hostResumeWindow: '5m',        // ride a five-minute drop without a new join code
    maxSessionTime: '0s',          // no clock ends a session this host is serving
  },
});

A venue also has an edge to stay under. The edge rule is 600 requests a minute per address, and an address past it is banned for ten minutes. The ban takes everything behind that address, hosts and clients alike, so plan for the number rather than measuring it.

What counts is the hosts' opens plus two requests per client arrival, so 300 hosts and their clients rejoining at once is the case to stagger.

Restarts and shutdown

Two verbs end the loop, and which one a signal means is yours to decide. Calling detach() ends the loop and leaves the session running out its host grace with its token in place, so the restarted process resumes the same session on the same join code. Calling stop() ends the loop and closes the session, which gives its place under concurrentSessions back at once.

On Node, createDrain is the recipe. It sorts each signal into one of the two verbs and, on a stop, waits a bounded time for the end to land before the process leaves.

import { BitskiffHost } from '@bitskiff/host-node';
import { createDrain, processSignals } from '@bitskiff/host-node/operator';
import { openForever } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  session: { hostResumeWindow: '5m' },             // long enough for the deploy to finish
});

const drain = createDrain({
  signals: processSignals(process, ['SIGTERM', 'SIGINT', 'SIGHUP']),
  detach: () => loop.detach(),                     // the session rides the restart
  close: () => loop.stop(),                        // the session closes and its place is given back
  err: (line: string) => process.stderr.write(`${line}\n`),
});

await drain.done;                                  // resolves once a signal has been handled
import { BitskiffHost } from '@bitskiff/host-node';
import { createDrain, processSignals } from '@bitskiff/host-node/operator';
import { openForever } from '@bitskiff/host-node/unattended';

const loop = openForever(new BitskiffHost(), {
  session: { hostResumeWindow: '5m' },             // long enough for the deploy to finish
});

const drain = createDrain({
  signals: processSignals(process, ['SIGTERM', 'SIGINT', 'SIGHUP']),
  detach: () => loop.detach(),                     // the session rides the restart
  close: () => loop.stop(),                        // the session closes and its place is given back
  err: (line) => process.stderr.write(`${line}\n`),
});

await drain.done;                                  // resolves once a signal has been handled

SIGTERM and SIGHUP detach, SIGINT closes; pass restartOn to sort them another way. A fleet that closes on SIGTERM changes the join code on every host at every deploy, because a closed session cannot be resumed. The SDK installs no signal handler of its own; the operator helper does, when you ask it to.

Both verbs are idempotent, and stop() returns a promise that never rejects—ignore it like void, or await it while shutting down. Nothing restarts a stopped loop; the way on is a fresh openForever.

If the replacement process starts before the old one exits, its open is refused rather than taking the session, and the loop tries again a second or two later. That clears itself the moment the old process is gone.

A host that runs on your own server, on a Kubernetes rollout or with its state in your own store, is under Server hosts.

Supervisors

Run the program under a supervisor—systemd, a container runtime, whatever the install already has—and let it handle the process dying. Keep the loop's own retries for the network: restarting a process that is waiting out a five-minute backoff buys nothing and gives up the session its host grace was holding.

Keep the secret key out of pages. A secret key presented from a browser is refused, and that refusal is one waiting never clears, so the same block copied into a browser host parks it on the slow branch for good. A browser or Electron host runs the identical loop from @bitskiff/host/unattended with the project's publishable key from an allowed origin:

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

const loop = openForever(new BitskiffHost({ key: 'bsk_pk_...' }), {
  session: { label: 'lobby-north', hostResumeWindow: '5m' },
});

loop.on('open', (session: Session) => console.log(session.joinCode.url));
import { BitskiffHost } from '@bitskiff/host';
import { openForever } from '@bitskiff/host/unattended';

const loop = openForever(new BitskiffHost({ key: 'bsk_pk_...' }), {
  session: { label: 'lobby-north', hostResumeWindow: '5m' },
});

loop.on('open', (session) => console.log(session.joinCode.url));

That host has no environment to read a key from and no reachability probe, so it opens earlier on its page's own network and visibility events instead. Everything else on this page is the same.

Next steps