Bitskiff

Node hosts

On this page

Run a host as a program instead of a page. @bitskiff/host-node is the host SDK with the Node platform and transport in place of the browser ones: same factory, same session, same channels. The one difference is peer. This package bundles no WebRTC, so you pass your own peer connection. Peer-to-peer connections below shows how, with werift.

Install

Take the tarball from the downloads page: npm install takes a URL as well as a name. This package depends on @bitskiff/host, so npm installs that tarball with it, both at the top level of your node_modules.

Shell
npm install https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-host-node.tgz
npm pkg set type=module

The second line is what lets the import blocks below run. A package.json npm init made says "type": "commonjs", and Node refuses an import statement in a file under that.

Every example below is shown in JavaScript and TypeScript. Pick a tab and the site keeps that choice on every page. Save the JavaScript as a .js file in this project and run it with node <file>. Save the TypeScript as a .ts file and run it the same way.

Node 22.18 or later strips the types for you. Earlier Node 22 needs node --experimental-strip-types <file> for the TypeScript. Node 22 or later is what @bitskiff/host-node needs either way.

The first block below is a whole program. The blocks after it are snippets: each shows the lines its section is about. The complete copy-paste programs on this site are the quickstart and the three worked examples.

Open with the project's secret key (bsk_sk_...), read from the environment and never written in source. A publishable key does not work here, because a Node process sends no browser origin for the allowlist to check.

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

const bitskiff = new BitskiffHost();                  // the key comes from BITSKIFF_SECRET_KEY

const press = bitskiff.channel('press');              // declare every channel before the first open
const readout = bitskiff.channel('readout');

press.on('message', (m: Message) => {                 // only `press` reaches this, in every session
  readout.send(m.client, `pressed ${m.data}`);        // a message is a string or bytes
});

const loop = openForever(bitskiff, {                  // opens a session, and another when one ends
  session: { hostResumeWindow: '5m' },                // what `open` takes, for every session
});

loop.on('open', (session: Session) => {               // every session, the first one included
  console.log(qrTerminal(session.joinCode.url));      // scannable straight out of the terminal
  console.log(session.joinCode.url);                  // and the link itself, for a machine with no camera
  session.on('joinCode', (code: JoinCode) => {        // the code rotates: draw the new one
    console.log(qrTerminal(code.url));
    console.log(code.url);
  });
});
import { BitskiffHost, qrTerminal } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

const bitskiff = new BitskiffHost();                  // the key comes from BITSKIFF_SECRET_KEY

const press = bitskiff.channel('press');              // declare every channel before the first open
const readout = bitskiff.channel('readout');

press.on('message', (m) => {                          // only `press` reaches this, in every session
  readout.send(m.client, `pressed ${m.data}`);        // a message is a string or bytes
});

const loop = openForever(bitskiff, {                  // opens a session, and another when one ends
  session: { hostResumeWindow: '5m' },                // what `open` takes, for every session
});

loop.on('open', (session) => {                        // every session, the first one included
  console.log(qrTerminal(session.joinCode.url));      // scannable straight out of the terminal
  console.log(session.joinCode.url);                  // and the link itself, for a machine with no camera
  session.on('joinCode', (code) => {                  // the code rotates: draw the new one
    console.log(qrTerminal(code.url));
    console.log(code.url);
  });
});

That is the quickstart's handler, unchanged. The sample client page's button sends one message when it goes down and one when it comes up. So the client reads pressed down and then pressed up on its own readout, the same as it does against a page.

The join code rotates, so draw it on the event too. A code a client sees stays accepted for at least one full step and at most two, 30 to 60 seconds at the default. It survives to the end of the step after its own.

The open event hands you the first code and on('joinCode') hands you every one after it. A host that draws the code once ends up showing a code that no longer works.

Both places above draw the same way, and both print joinCode.url under the QR. A machine with no camera in front of it is paired by copying that link, as in the quickstart.

Open in the loop, not once. Sessions end. The host grace (hostResumeWindow) is how long a session waits for its host to come back after its link drops. A link that stays down past the host grace ends the session, and so does a close from the console. A program that called open once serves nobody from then on.

The openForever loop opens the next one for you, on a backoff table. Put the wiring in on('open') so it runs for every session. The loop never gives up. A refusal that waiting cannot clear is retried too, just more slowly, so a host that gets a new key comes back by itself.

Unattended hosts is the guide to running one. It covers what a fallback page paints from loop.health, what says a host needs a person, and the settings to change before a fleet ships.

BitskiffHost reads BITSKIFF_SECRET_KEY when you pass no key, which is why the block above has no key line. An explicit key option wins over the environment and is never logged. With neither, open refuses at the call site and nothing reaches the service.

Secret keys

Create one in the console, beside the publishable key, or from the command line. The CLI tarball is on the downloads page too:

Shell
npm i -g https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz
bitskiff config set endpoint https://api.dev.bitskiff.com
echo "$BITSKIFF_ORG_KEY" | bitskiff login
bitskiff config set project proj_...
bitskiff keys create --type secret --preset host-only --label node-host

The CLI already talks to the api of the docs site it came from, and the config set endpoint line pins that choice. It has no project until you set one, and keys create writes into the project it resolves, so the project comes before it. The CLI page has the endpoint each environment answers on and the other ways to pass a project.

The host-only preset is sessions:open and nothing else, which is all a machine that only hosts needs. A preset is only a starting list. Add permissions to the key later if you need them.

Restarts

A Node host remembers its session. Restart the process and it resumes the one it was running, with its clients still in it if the restart finished inside the host grace.

Set hostResumeWindow to cover a redeploy, as the block above does with 5m. The default is 10s, which covers a page reload and not much else. Past the window, the session ends, the clients are told the host went offline, and the next session has a new join code. It is a duration string, like every duration you write.

Two verbs end the loop, and which one a signal means is yours to decide. Calling loop.detach() ends the loop and leaves the session running out its host grace, with its token in place. That is what a redeploy wants: the restarted process resumes the same session on the same join code.

Calling loop.stop() ends the session and forgets its token. That is what a real shutdown wants, and the wrong verb for a redeploy. A program that calls it on every SIGTERM puts a new join code on every host each time it ships.

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

const bitskiff = new BitskiffHost();
const loop: UnattendedLoop = openForever(bitskiff, { session: { hostResumeWindow: '5m' } });

process.on('SIGTERM', () => loop.detach());     // a rolling deploy
process.on('SIGINT', () => void loop.stop());   // a real stop
import { BitskiffHost } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

const bitskiff = new BitskiffHost();
const loop = openForever(bitskiff, { session: { hostResumeWindow: '5m' } });

process.on('SIGTERM', () => loop.detach());     // a rolling deploy
process.on('SIGINT', () => void loop.stop());   // a real stop

The session is remembered in a sealed record, and a restart on Kubernetes needs that record on a volume. Server hosts has both.

Several hosts on one machine

Give each process its own name, so each remembers its own session:

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

const bitskiff = new BitskiffHost();
const loop = openForever(bitskiff, { session: { persist: { as: 'front-desk-host' } } });

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

const bitskiff = new BitskiffHost();
const loop = openForever(bitskiff, { session: { persist: { as: 'front-desk-host' } } });

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

Pass persist: { in: 'none' } to turn the remembering off instead. Two processes cannot share one name. The second open is refused rather than taking the first one's session. The loop tries again in a second or two, which is what a restart that overlaps the process it replaces needs.

Peer-to-peer connections

A Node host turns on peer with a peer connection of its own. The SDK is tested against werift. Install it beside this package:

Shell
npm install werift

Import RTCPeerConnection from it, then pass the peer connection in peer({ peerConnection }). Declare it on the host before the loop starts, so every session the loop opens has it:

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

const bitskiff = new BitskiffHost();
bitskiff.transport(peer({ peerConnection: (config) => new RTCPeerConnection(config) }));
const loop: UnattendedLoop = openForever(bitskiff);
import { BitskiffHost, peer } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';
import { RTCPeerConnection } from 'werift';

const bitskiff = new BitskiffHost();
bitskiff.transport(peer({ peerConnection: (config) => new RTCPeerConnection(config) }));
const loop = openForever(bitskiff);

Without peerConnection, every client's peer reads unavailable with reason notAttached.

Next steps