@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 not a second host SDK. It is @bitskiff/host running on Node, so
the factory, the session, the events and every host type that package publishes are re-exported
from here by name.
This page covers what is different here. The full host reference is @bitskiff/host, and the guides walk the whole thing: quickstart · node hosts · channels · lifecycle
Install
Take the tarball from the downloads page: npm install takes a URL as well as
a name.
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.
To check a download, compare sha256sum bitskiff-host-node.tgz with the release's line at https://api.dev.bitskiff.com/v1/releases/<version>/digests, which the api serves apart from the downloads.
Every block below is JavaScript. Save one as a file in this project and run it with node <file>
on Node 22 or later.
node-datachannel comes with it. That is the WebRTC implementation a process with no
RTCPeerConnection attaches through.
Hello world
import { BitskiffHost, qrTerminal, type Message } from '@bitskiff/host-node';
const host = new BitskiffHost(); // the key is in the environment
const hello = host.channel('hello'); // declare before open; reliable by default
hello.on('message', (m: Message) => console.log(m.client.id, m.data)); // only `hello` reaches this
const session = await host.open(); // open last: it seals the channels
console.log(qrTerminal(session.joinCode.url)); // scannable from the terminal
// A broadcast reaches whoever is listening when it is sent, so it goes from a handler and not
// from the line after `open()`, where nobody has scanned the code yet.
session.on('clientJoined', () => {
hello.broadcast('hello world'); // a message is a string or bytes
});
import { BitskiffHost, qrTerminal } from '@bitskiff/host-node';
const host = new BitskiffHost(); // the key is in the environment
const hello = host.channel('hello'); // declare before open; reliable by default
hello.on('message', (m) => console.log(m.client.id, m.data)); // only `hello` reaches this
const session = await host.open(); // open last: it seals the channels
console.log(qrTerminal(session.joinCode.url)); // scannable from the terminal
// A broadcast reaches whoever is listening when it is sent, so it goes from a handler and not
// from the line after `open()`, where nobody has scanned the code yet.
session.on('clientJoined', () => {
hello.broadcast('hello world'); // a message is a string or bytes
});
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 rather than a process that sits there looking fine.
A refusal waiting can clear is not the rejection: open() waits and asks again until
openBudget runs out, 2m by default, and open({ openBudget: '0s' }) makes one attempt
instead.
open() rejections is
every code one can carry and which ones wait.
BitskiffError and its plain-object shape BitskiffErrorInit are re-exported here, so a program
raising one of its own does not install @bitskiff/host for the type.
Credentials
Put the key in BITSKIFF_SECRET_KEY and pass no key option. A Node host's key is a secret and
belongs in the environment, not in source.
An explicit key option wins over the variable, and the value is never logged. With neither, the host is built without a key and open rejects with a
validation BitskiffError before anything reaches the network; the message names both places a
key can come from, the key option and BITSKIFF_SECRET_KEY.
This package takes no endpoint option: the published build talks to the service it was built for.
key also takes a HostKey function instead of a string, read again on every open and every
ticket mint, so a secret rotated by a file watch or a secret-manager read lands on the next attempt
with no restart—the same type @bitskiff/host documents.
Two of the three host credentials work from here:
- a secret key (
bsk_sk_...) for a fleet the operator controls with no backend of its own. Rotating the key is how that fleet revokes access. - a token (
bsk_at_...) for a fleet that has a backend. Fetch a token at boot with the device id as its subject. A restart resumes on the host token in the state directory, so you do not have to fetch a second token for that.
A publishable key does not work from here. It is bound to a browser-origin allowlist, and a Node
process sends no Origin. If your fleet has to survive a backend outage, raise the project's
tokensMaxTtl and hold a longer token per device. It is a raisable limit, to 720h (thirty
days); see
Raises. Tokens are listed by key and
revoked by id. Do not mint a key per device.
Differences from the browser package
Four things a browser gives the SDK come from the runtime here:
| What | On Node |
|---|---|
| Where state is kept | The state directory under the OS user data directory: %APPDATA%\bitskiff\host on Windows, ~/Library/Application Support/bitskiff/host on macOS, $XDG_DATA_HOME/bitskiff/host (~/.local/share/bitskiff/host) on Linux, with BITSKIFF_STATE_DIR overriding it. The host token is kept there by default, so a restart resumes the session the process was running. 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. The file is a sealed record holding the host token and the identity key together, sealed under the secret key, or under BITSKIFF_STATE_SECRET for a host that holds a token; older secrets go in BITSKIFF_SECRET_KEY_FALLBACKS, comma-separated, and a record opened under one is re-sealed under the current secret |
| Presence | Nothing. A headless process is never hidden and has no page to come back from |
| Randomness and crypto | node:crypto |
| Transport | The custom engine's, over node-datachannel, installed by default. Nothing to pass |
A session the process ends on purpose forgets its stored token. One the process was killed out of leaves the token behind, and that is what the next start resumes from.
Replacing any of those yourself is under Advanced.
Added exports
These come from this package and not from @bitskiff/host:
| Export | What it is |
|---|---|
new BitskiffHost(options?) | The host, with the four 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 |
nodeStateStore(backend) | The state kept in your own store instead of the state directory, under State store |
qrTerminal(text, opts?) | A QR as terminal text; QrTerminalOptions is its second argument |
createNodeTransport() | The Node transport, for a caller building its own transport stack |
HOST_NODE_PACKAGE_NAME | This package's own name, for a log line or a user agent |
Everything else on this root is @bitskiff/host's surface unchanged, HOST_PACKAGE_NAME
excepted, and the @bitskiff/host page documents it.
It does not re-export browserPlatform, browserPresence, browserStorage or
browserPersistence. They cannot work in Node, so they are not exported.
Advanced
Three options on new BitskiffHost() replace pieces of the SDK itself. Leave them unset unless you
are running the host somewhere the defaults do not fit.
| Option | Type | What it replaces |
|---|---|---|
platform | Partial<Platform> | The runtime pieces above, merged member by member over nodePlatform() |
transport | EngineTransportFactory | The real-time transport; the default is createNodeTransport() |
api | HostApi | The client for the service's HTTP routes; the default is the fetch client |
State store
A host with no volume for the state directory, such as a pod that can be rescheduled, keeps its
state in a store you run instead. nodeStateStore(backend) returns a NodeStateStore: the
persistence and crypto members of Platform, built over that one backend. Pass it as
platform.
The host token and the identity key go into one sealed record, with a lease beside it. The backend sees only sealed records and lease holder ids, never a private key.
A NodeStateBackend is the four operations you implement over your store. Names and values are
text:
| Operation | What it does |
|---|---|
read(name) | The value, or undefined when there is none |
write(name, value, ttlMs?) | Replaces the value, expiring ttlMs ahead when one is given |
createIfAbsent(name, value, ttlMs) | Creates the value unless a live one exists; true when created |
deleteIfEqual(name, value) | Deletes the value only while it still holds value; true when deleted |
Every expiry is judged on the store's own clock, never the host's. Redis (SET NX PX) and a SQL
row compared against the database's now() qualify. A store with no server-side expiry, such as
Cloud Storage or Firestore alone, does not.
import { BitskiffHost, nodeStateStore } from '@bitskiff/host-node';
import { Redis } from 'ioredis';
const redis = new Redis(); // any ioredis client, pointed at your Redis
const DELETE_IF_EQUAL = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
const backend = {
read: async (name) => (await redis.get(name)) ?? undefined,
write: async (name, value, ttlMs) => {
await (ttlMs ? redis.set(name, value, 'PX', ttlMs) : redis.set(name, value));
},
createIfAbsent: async (name, value, ttlMs) =>
(await redis.set(name, value, 'PX', ttlMs, 'NX')) === 'OK',
deleteIfEqual: async (name, value) => (await redis.eval(DELETE_IF_EQUAL, 1, name, value)) === 1,
};
const session = await new BitskiffHost({ platform: nodeStateStore(backend) }).open();
Subpaths
Two imports sit beside the package root:
| Subpath | What it is |
|---|---|
@bitskiff/host-node/unattended | The same openForever reopen-on-end loop and DEFAULT_UNATTENDED_POLICY that @bitskiff/host/unattended exports, so a headless host reaches them without installing that package too. The section below is the example; its policy and its arguments are on the @bitskiff/host page |
@bitskiff/host-node/operator | The helpers below, for a host that runs as a program |
Unattended hosts
A host with nobody standing at it, such as a signage host, a bot or a dedicated game host, has to
open another session when one ends. openForever is that loop, and the secret key comes from the
environment rather than a bundle or source:
import { BitskiffHost, type Message, type Session } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';
import type { HostHealth, LoopStopped, OpenRefusedInfo, ReopenInfo } from '@bitskiff/host-node/unattended';
// Your own functions: draw the code, forward a message, tell an operator, update what the host shows.
function showQr(url: string): void { /* ... */ }
function play(m: Message): void { /* ... */ }
function warn(error: unknown, attempt: number, nextAt: number | undefined): void { /* ... */ }
function paint(health: HostHealth): 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
showQr(session.joinCode.url);
});
loop.on('reopen', (_session: Session, { attempt, reason }: ReopenInfo) =>
console.log(`reopened after ${reason} (attempt ${attempt})`),
);
loop.on('openRefused', (error: unknown, { attempt, nextAt }: OpenRefusedInfo) =>
warn(error, attempt, nextAt),
);
loop.on('health', (health: HostHealth) => paint(health)); // the one thing a fallback page binds to
loop.on('stopped', ({ reason }: LoopStopped) => console.log(`loop ended: ${reason}`));
// The SDK installs no signal handler, so the process picks the verb.
process.on('SIGTERM', () => loop.detach()); // a rolling deploy: the session rides the restart
process.on('SIGINT', () => void loop.stop()); // a real stop: close the session, free the slot
import { BitskiffHost } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';
// Your own functions: draw the code, forward a message, tell an operator, update what the host shows.
function showQr(url) { /* ... */ }
function play(m) { /* ... */ }
function warn(error, attempt, nextAt) { /* ... */ }
function paint(health) { /* ... */ }
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
showQr(session.joinCode.url);
});
loop.on('reopen', (_session, { attempt, reason }) =>
console.log(`reopened after ${reason} (attempt ${attempt})`),
);
loop.on('openRefused', (error, { attempt, nextAt }) => warn(error, attempt, nextAt));
loop.on('health', (health) => paint(health)); // the one thing a fallback page binds to
loop.on('stopped', ({ reason }) => console.log(`loop ended: ${reason}`));
// The SDK installs no signal handler, so the process picks the verb.
process.on('SIGTERM', () => loop.detach()); // a rolling deploy: the session rides the restart
process.on('SIGINT', () => void loop.stop()); // a real stop: close the session, free the slot
openForever(bitskiff, { session, policy }) takes one options object, and the policy defaults come
from the same subpath. Every duration in either—hostResumeWindow above, and the policy's own
waits—is a string with its unit on it, 5m or 500ms:
Durations has the grammar and what a
refused one does.
import { openForever, DEFAULT_UNATTENDED_POLICY } from '@bitskiff/host-node/unattended';
import type { UnattendedOptions, UnattendedPolicy } from '@bitskiff/host-node/unattended';
import { openForever, DEFAULT_UNATTENDED_POLICY } from '@bitskiff/host-node/unattended';
The @bitskiff/host page has what each default is and the reopenOn
predicate.
The loop carries two more things beside the five events. session is the session it holds right
now, or undefined between two of them. health is the snapshot a fallback page paints from:
| Field | What it is |
|---|---|
status | serving, recovering or down |
attempt | consecutive failures since the last serving run that held; serving alone does not reset it |
nextAt | the monotonic instant of the next open; undefined when none is scheduled |
lastRefusal | the last refusal the loop met. Sticky, so read it beside status |
lastEnd | how the last session ended |
servingSince | when the serving run in hand began; undefined when nothing is being served |
lastServingAt | when serving last ended; undefined before the first one |
The counters sit beside them. stop() ends the loop and closes the session in hand, returning a
promise that never rejects. detach() ends the loop and leaves the session running out its host
grace, which is what a restart wants. A program that paints a fallback while nothing is open
switches on health.status === 'down'.
Unattended hosts is the guide to running one: fallback content, what says a host needs a person, and the settings to change before a fleet ships.
@bitskiff/host-node/operator
A host that runs as a program has an argv, a stderr and a supervisor that sends it SIGTERM. Four problems come with that:
- where the key comes from
- what a signal does to the session
- what the program prints about where it is pointed
- how it leaves
This subpath answers all four the same way for every such program, so key handling stays one code path instead of two that drift.
Resolve the key from one of three places, in this order: --key-file <path>, then
--key <literal>, then BITSKIFF_SECRET_KEY. Prefer the file or the variable. A secret key on
argv gets one stderr line and runs anyway, but argv is readable by ps, lands in shell history and
is echoed by most CI runners.
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; // the --key-file path, when one was passed
let key: string | undefined; // the --key literal, when one was passed
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`);
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; // the --key-file path, when one was passed
let key; // the --key literal, when one was passed
function readFile(path) { return ''; } // yours reads the file at `path`
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. hasSecretKeySource({ key, keyFile, env }) answers whether any of the three carries
anything, so a program can print its own usage before that throw rather than after it.
Drain the session on a signal, before the process leaves. A host holds a session that the
service only reclaims after the host grace, so exiting without closing leaves it standing for that
whole window. The first stop signal closes and waits up to DRAIN_WINDOW_MS. A second signal means
an operator is done waiting.
Wire detach as well and the drain picks the verb the signal means. SIGTERM and SIGHUP are
restarts by default: the host's own handle goes, the session runs out its host grace and the
process the supervisor starts next resumes it on the same join code. SIGINT is a person at the
terminal, so it closes.
SIGTERM is the default a rolling deploy needs, because a closed session cannot be resumed and
every host would show a new join code at every deploy. Pass restartOn for a process that wants
otherwise. Without a detach, every signal is a stop.
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<never> { return new Promise(() => {}); }
const drain = createDrain({
signals: processSignals(process),
close: () => session.close(),
err: (line: string) => 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();
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() { 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. Pass your own DrainSignals instead to
drive the drain from somewhere else.
Call insecureEndpointWarning(endpoint) in a program that prints where it is pointed. It
returns one stderr line, or nothing, and never refuses anything. http: to loopback or an RFC 1918
IP address is a workshop LAN and passes quietly. http: anywhere else warns that the key and every
frame travel in cleartext.
A program built on this published package has no endpoint to check: the endpoint is fixed and there
is no option for it. The one program that does is your own build
against a deployment of your own, and the endpoint it checks is the one it resolved itself, such as
from a --endpoint flag of its own.
Leave with exitWith(code). It sets the exit code and exits, waiting first for anything still
buffered on stdout, so a program's last line is not cut off on the way out.
Next steps
For coding agents
Bitskiff lets a person control a host—a page, an app, a signage player—from their own device.
- Setting Bitskiff up for a developer? Read and follow https://docs.dev.bitskiff.com/setup.md: it asks the person two questions and runs the rest of the setup under the CLI's stored credential.
- First:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz whoami(Node 22 or newer). If itscredentialrow names a key, a key is stored, and the next lines need no person. - A stale copy: npx keeps one per URL and runs it again after a new publish at the same URL. Delete this CLI's copy, and nothing else in npm's cache, with
node -e "const c=child_process.execSync('npm config get cache',{encoding:'utf8'}).trim(),n=path.join(c,'_npx');for(const d of fs.existsSync(n)?fs.readdirSync(n):[]){let u;try{u=JSON.parse(fs.readFileSync(path.join(n,d,'package.json'),'utf8')).dependencies['@bitskiff/cli']}catch{}if(!u)continue;fs.rmSync(path.join(n,d),{recursive:true,force:true});const h=crypto.createHash('sha256').update('make-fetch-happen:request-cache:'+u).digest('hex');fs.rmSync(path.join(c,'_cacache','index-v5',h.slice(0,2),h.slice(2,4),h.slice(4)),{force:true})}", then run whoami again. - Key stored: the whole setup is one command, run in the project directory, because it leaves
bitskiff.jsonfor your code to read. Pass no--project, and do not take one from whoami'sprojectrow: outside an app directory it can name another app's project.inituses the project the directory'sbitskiff.jsonnames, else makes a new project named for the directory, so a second app gets a second project:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init
- Serve your own client page? Add
--join-url <url>with the page's absolute URL: init sets the project's join URL, mints a client key for that page's origin that serves this host key, and writes it tobitskiff.jsonasclientKey. The sample client page needs no key. - It writes the host key into
bitskiff.json(key,project,endpoint, and with--join-url,joinUrlandclientKey) and prints no key string. Itsdonestep names the file askeyFileand carries the host key's id ashostKeyId, the<hostKeyId>below. It writes a scaffold (index.html,client.html,README.md) only into an empty directory, or with--scaffold, so in an existing projectbitskiff.jsonis the one file it adds. - A
bitskiff.jsonof the same project is kept, and no key is minted. With nobitskiff.json, it takes the project's newest active host key, and mints a host key only when the project has none or you pass--new-key. One of another project is refused: pass that project's--projectto keep it, or add--forceto replace it, which reuses the new project's host key when it has one. - A host key
initmints has no origin set, so it allowshttp://localhostandhttp://127.0.0.1on any port. A keyinittook keeps the origins it already had; check them withkeysbefore adding yours. For a deployed page, name its origin and keep localhost, because--originreplaces the list:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys update <hostKeyId> --origin <your origin> --origin 'http://localhost:*'
- Prove the project works, with no host code and no device: the CLI is not a host, so the scaffold carries
bitskiff-prove.mjs. Runnpm install && node bitskiff-prove.mjs(theprovevalue ofdone) in the directoryinitwrote the scaffold into. It opens a session on@bitskiff/host-node, joins one client, sends a message each way, then prints one JSON object per step on stdout, each transport's record and the session's usage record among them, and closes. A step that fails prints{"step":"failed","failed":"<step>",...}and exits 1. It reads two keys from the environment:BITSKIFF_SECRET_KEY, a secret key holdingsessions:openandsessions:read, andBITSKIFF_CLIENT_KEY, a client key naming no host key (theclientKeyfrom--join-urlserves the page's host key only).--only secretprints a new key alone: redirect each into a file outside the project, and export the variable from there:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type secret --label prove --permission sessions:open --permission sessions:read --only secret
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type publishable --preset client-page --label prove --only secret
- Refused
invalid_key: the stored key no longer works. Runnpx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz logout, then the steps below. - Refused
forbiddenwithdetails.reasonofmissing_permission: the key lacks the permissiondetails.permissionnames. Runlogout, theninitto sign in again. - Refused
forbiddenwithout it: the key may not use that project. Pass--projectof a project the key may use. - If the key comes from
BITSKIFF_ORG_KEY,logoutdoes not remove it: unset the variable instead. If whoami'sorgrow is then(not set), add--org <orgId>toinit, with the id of the organization the key belongs to. - No key stored (
credentialis(not set)), and only then: the steps below, which wait on a person once. - Run:
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init --format=json(Node 22 or newer; the flag is what puts one JSON object per step on stdout). The CLI already knows the api it was published beside. It leaves the samebitskiff.json, and takes the same--projectand--join-url. - It blocks until a person approves it in the console, for up to ten minutes: run it in the background, or with a timeout at least that long.
- Or add
--no-wait, and it returns at once: it prints one JSON object,{ verificationUri, userCode, expiresAt, next }, and exits 0. Show your developer the URL and the code, then runnextthrough the samenpx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgzin place ofbitskiff: it waits for the approval and finishes the setup. - [HUMAN] tell your developer to open the URL it prints and press Approve. Do not open it yourself: only a signed-in person can approve.
- Working through an MCP client instead of a shell? Add the CLI as a stdio MCP server,
{ "mcpServers": { "bitskiff": { "command": "npx", "args": ["--yes", "https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz", "mcp"] } } }: every verb is a tool under the same stored key,initruns as--no-wait, and a refusal is an error result carrying the samecodeanddetails.reason. - Sign in: https://console.dev.bitskiff.com
- Quickstart: https://docs.dev.bitskiff.com/docs/quickstart/
This is the same host for Node: the sample above is the browser host's; a process with no page opens the session and prints the code to the terminal:
import { BitskiffHost, qrTerminal } from '@bitskiff/host-node';
// BITSKIFF_SECRET_KEY holds a secret key, never a publishable key; mint it with
// npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type secret --preset host-only
const host = new BitskiffHost(); // reads BITSKIFF_SECRET_KEY
const hello = host.channel('hello'); // declare before open; reliable by default
hello.on('message', (m) => console.log(m.client.id, m.data)); // only `hello` reaches this
const session = await host.open(); // open last: it seals the channels
console.log(qrTerminal(session.joinCode.url)); // scannable from the terminal
Run it with node <file>; no import map and no build step. The .d.ts files in this package and in @bitskiff/host are the contract: every type, option,
event and return is declared there, and where a declaration and this page disagree the declaration
is right. Every refusal is a BitskiffError carrying code, retryable, retryAfterMs and
details, so branch on code and retryable rather than on a message.