Node hosts
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.
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:
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:
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.