@bitskiff/host-node

The host SDK for a headless Node 22 process: a server, a bot, a signage box or a dedicated game host with no browser in it. It is @bitskiff/host with two rows of the platform table swapped for the runtime's—not a second host SDK—so the factory, the session handle, the events and every type that package publishes for a host are re-exported from here by name.

This page is what is different here. The full host reference is @bitskiff/host, and the guides walk the whole thing: quickstart · node hosts · channels · lifecycle and errors

Install

npm install @bitskiff/host-node

@livekit/rtc-node comes with it: the engine's Node SDK, which is what a process with no RTCPeerConnection attaches through.

Hello world

import { BitskiffHost, everyone, qrTerminal } from '@bitskiff/host-node';

const session = await new BitskiffHost().open();              // the key is in the environment
console.log(qrTerminal(session.joinCode.url));                // scannable from the terminal
const hello = session.channel('hello');                       // reliable by default
hello.on('message', (m) => console.log(m.from.id, m.data));   // only `hello` reaches this
hello.send(everyone, JSON.stringify({ hello: 'world' }));

open() resolves when the session is open and rejects with the BitskiffError that refused it, so a supervisor sees a bad key as a thrown error and not as a process that sat there looking fine.

key comes from the environment when you pass none

With no key option, BitskiffHost reads BITSKIFF_SECRET_KEY: a Node host's key is a secret that belongs in the environment and not in source. An explicit key wins over the variable, the value is never logged, and with neither there the host is built without a key and open rejects with a validation BitskiffError before anything reaches the network. There is no API address to pass or to read from the environment: this build talks to the api it was built for.

Two of the three host credentials work from here:

A publishable key cannot work from here: it is bound to a browser-origin allowlist and a Node process sends no Origin. A fleet that has to survive a backend outage raises the project's token TTL and holds a longer token per device, listed by key and revocable by id; a key per device is still not offered.

What is different from the browser package

Here
platform.storage, platform.persistenceThe state directory under the OS user data directory, BITSKIFF_STATE_DIR overriding it. The host token is kept there by default, so a restart resumes the session it was running—the one thing a browser tab could not give a host. One owner-only file per slot, every write landing by rename, and a lock beside it so two processes on one slot refuse each other rather than flap
platform.presenceNothing: a headless process is never hidden and has no page to come back from
platform.random, platform.cryptonode:crypto
transportThe engine's Node SDK, installed by default—nothing to pass

A session the process ends on purpose forgets its stored token; one it was killed out of leaves it, which is what the next start resumes from.

What this package adds

ExportWhat it is
new BitskiffHost(options?)The host, with the two rows above already in place
nodePlatform(overrides?)That platform row, for a caller replacing one member
nodeStorage(), nodePersistence()The file store and the slot store on their own
stateDirPath()Where the state directory is, printed
qrTerminal(text, opts?)A QR as terminal text
createNodeTransport(options?)The Node transport, for a caller building its own engine room

What it does not re-export is browserPlatform, browserPresence and browserStorage: a name that cannot work in the runtime the package is for is worse than an import away.

Subpaths

SubpathWhat it is
@bitskiff/host-node/unattendedThe same openForever reopen-on-end loop @bitskiff/host/unattended exports, so a headless host reaches it without installing that package too. The loop, its policy and its four events are on the @bitskiff/host page
@bitskiff/host-node/operatorRunning a host as a *program*: the three below

Running one as a program: @bitskiff/host-node/operator

A host that is a program—an argv, a stderr, a supervisor that sends it SIGTERM—has three problems the session layer does not answer, and this subpath answers all three the same way for every such program, so the key handling stays one code path instead of two that drift.

The key comes from one of three places, in this order: --key-file <path>, then --key <literal>, then BITSKIFF_SECRET_KEY. A secret key on argv gets one stderr line and runs anyway: argv is readable by ps, lands in shell history and is echoed by most CI runners, and typing one against a local stack is still how the first ten minutes go.

import { KEY_SOURCES, SECRET_KEY_ENV, resolveSecretKey } from '@bitskiff/host-node/operator';

// Your own argv parsing -- --key-file, --key, and a plain file reader.
let keyFile: string | undefined;
let key: string | undefined;
function readFile(path: string): string { return ''; }

console.log(KEY_SOURCES);                    // the second usage line: where the key can come from
const secret = resolveSecretKey({ keyFile, key, env: process.env[SECRET_KEY_ENV], readFile });
for (const warning of secret.warnings) process.stderr.write(`${warning}\n`);

resolveSecretKey throws SecretKeyError when there is no key to be had; its message is written for stderr.

SIGTERM closes the session before the process leaves. A host holds a seat and a session the service only reclaims after the host grace, so exiting without closing leaves both standing for that whole window. The first signal closes and waits up to DRAIN_WINDOW_MS; a second signal is an operator who is done waiting.

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

const session = await new BitskiffHost().open();
// Your own await for whatever else keeps the process alive.
function whateverEndsTheRun(): Promise<unknown> { return new Promise(() => {}); }

const drain = createDrain({
  signals: processSignals(process),
  close: () => session.close(),
  err: (line) => process.stderr.write(`${line}\n`),
  subject: 'the session',        // what the two stderr lines call it; a fleet names its own
});
const ended = await Promise.race([whateverEndsTheRun(), drain.done]);
drain.dispose();

processSignals is the only piece that touches process, so a spec drives the whole drain through its own DrainSignals and installs no handler on the test process. Nothing in this subpath imports the transport, so a program can import it while it is still checking its arguments.

insecureEndpointWarning(endpoint) is the third: one stderr line, or nothing. http: to loopback or an RFC 1918 address is a workshop LAN and passes quietly; http: anywhere else says that the key and every frame travel in cleartext. It is for a program that prints where it is pointed—the SDK's own api address is fixed by the build—and it never refuses anything.