# Server hosts

Run the host on your own server, and let the host's display only draw what the server
sends it.

## Quickstart

Five steps. Steps 2 to 4 are one file, `server.mjs`, and step 5 is the page it serves. The steps
assume a host's display that has already paired: the pairing flow is the sample's.

### 1. Key, packages and TLS

Make a secret key that holds `sessions:open` (see [Secret keys](/docs/guides/node-hosts/#secret-keys))
and put it in `BITSKIFF_SECRET_KEY`. Install
`@bitskiff/host-node` from the [downloads page](/docs/downloads/), and `ws`. Put a certificate for
your server's name in `tls.crt` and `tls.key`. Behind an ingress that terminates TLS, listen on
plain HTTP instead. The host's display needs nothing of ours.

### 2. Open a session that stays open

```js
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import { WebSocketServer } from 'ws';
import { BitskiffHost, qrSvg } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

const host = new BitskiffHost();   // the secret key comes from BITSKIFF_SECRET_KEY
const pad = host.channel('pad', { delivery: 'lossy' });   // declared before the loop opens
const loop = openForever(host, {
  session: { label: 'arena-1', maxClients: 8, hostResumeWindow: '5m' },
});   // eight clients; the host's display is not one of them
process.on('SIGTERM', () => loop.detach());   // a deploy: the session rides the restart
```

```ts
import { readFileSync } from 'node:fs';
import { createServer } from 'node:https';
import { WebSocketServer, type WebSocket } from 'ws';
import { BitskiffHost, qrSvg } from '@bitskiff/host-node';
import { openForever } from '@bitskiff/host-node/unattended';

const host = new BitskiffHost();   // the secret key comes from BITSKIFF_SECRET_KEY
const pad = host.channel('pad', { delivery: 'lossy' });   // declared before the loop opens
const loop = openForever(host, {
  session: { label: 'arena-1', maxClients: 8, hostResumeWindow: '5m' },
});   // eight clients; the host's display is not one of them
process.on('SIGTERM', () => loop.detach());   // a deploy: the session rides the restart
```

### 3. Serve the host's display, to paired devices only

```js
// What your pairing flow issued, one line per device: `<device id> <credential>`.
const devices = new Map();   // credential -> device id
for (const line of readFileSync('devices.txt', 'utf8').trim().split('\n')) {
  const [id, credential] = line.split(' ');
  devices.set(credential, id);
}
const deviceOf = (cookie = '') => devices.get(/(?:^|;\s*)device=([^;]+)/.exec(cookie)?.[1]);
const links = new Map();   // open link -> device id
const toLinks = (text) => { if (text) for (const ws of links.keys()) ws.send(text); };
const codeFrame = () => loop.session && JSON.stringify({ qr: qrSvg(loop.session.joinCode.url) });

const https = createServer({ key: readFileSync('tls.key'), cert: readFileSync('tls.crt') },
  (req, res) => res.end(readFileSync('index.html'))).listen(443);
new WebSocketServer({ server: https, verifyClient: ({ req }) => Boolean(deviceOf(req.headers.cookie)) })
  .on('connection', (ws, req) => {
    links.set(ws, deviceOf(req.headers.cookie));
    ws.on('close', () => links.delete(ws));
    toLinks(codeFrame());
  });

export function revoke(credential) {   // your admin page calls this, and removes the line
  const device = devices.get(credential);
  devices.delete(credential);
  for (const [ws, id] of links) if (id === device) ws.close();   // the open link closes too
}
```

```ts
// What your pairing flow issued, one line per device: `<device id> <credential>`.
const devices = new Map<string, string>();   // credential -> device id
for (const line of readFileSync('devices.txt', 'utf8').trim().split('\n')) {
  const [id, credential] = line.split(' ') as [string, string];
  devices.set(credential, id);
}
const deviceOf = (cookie = ''): string | undefined => {
  const credential = /(?:^|;\s*)device=([^;]+)/.exec(cookie)?.[1];
  return credential === undefined ? undefined : devices.get(credential);
};
const links = new Map<WebSocket, string | undefined>();   // open link -> device id
const toLinks = (text: string | undefined) => { if (text) for (const ws of links.keys()) ws.send(text); };
const codeFrame = () => loop.session && JSON.stringify({ qr: qrSvg(loop.session.joinCode.url) });

const https = createServer({ key: readFileSync('tls.key'), cert: readFileSync('tls.crt') },
  (req, res) => res.end(readFileSync('index.html'))).listen(443);
new WebSocketServer({ server: https, verifyClient: ({ req }) => Boolean(deviceOf(req.headers.cookie)) })
  .on('connection', (ws, req) => {
    links.set(ws, deviceOf(req.headers.cookie));
    ws.on('close', () => links.delete(ws));
    toLinks(codeFrame());
  });

export function revoke(credential: string): void {   // your admin page calls this, and removes the line
  const device = devices.get(credential);
  devices.delete(credential);
  for (const [ws, id] of links) if (id === device) ws.close();   // the open link closes too
}
```

### 4. Take input, and send the code and the state

```js
const inputs = new Map();   // the game: each client's last input
pad.on('message', (m) => inputs.set(m.client.id, String(m.data)));   // hears every session the loop opens
loop.on('open', (session) => {
  session.on('joinCode', () => toLinks(codeFrame()));   // every 30 s
  toLinks(codeFrame());
});
setInterval(() => toLinks(JSON.stringify({ inputs: Object.fromEntries(inputs) })), 50);
```

```ts
const inputs = new Map<string, string>();   // the game: each client's last input
pad.on('message', (m) => inputs.set(m.client.id, String(m.data)));   // hears every session the loop opens
loop.on('open', (session) => {
  session.on('joinCode', () => toLinks(codeFrame()));   // every 30 s
  toLinks(codeFrame());
});
setInterval(() => toLinks(JSON.stringify({ inputs: Object.fromEntries(inputs) })), 50);
```

### 5. Draw it

`index.html`, served from the same origin, so the `device` cookie the pairing set rides the
WebSocket handshake. It reconnects on its own, with a backoff.

```html
<div id="qr"></div><p id="pause" hidden>Back in a moment</p><pre id="state"></pre>
<script type="module">
  const byId = (id) => {
    const element = document.getElementById(id);
    if (!element) throw new Error(`index.html has no #${id}`);
    return element;
  };
  const [qr, pause, state] = ['qr', 'pause', 'state'].map(byId);
  function connect(wait = 1000) {
    const ws = new WebSocket(`wss://${location.host}/`);
    ws.onopen = () => { wait = 1000; pause.hidden = true; };
    ws.onmessage = (e) => {
      const m = JSON.parse(e.data);
      if (m.qr) qr.innerHTML = m.qr;   // an SVG the server drew with qrSvg
      if (m.inputs) state.textContent = JSON.stringify(m.inputs);
    };
    ws.onclose = () => {
      pause.hidden = false;
      qr.innerHTML = '';   // no code on show while the server is away
      setTimeout(() => connect(Math.min(wait * 2, 30_000)), wait);
    };
  }
  connect();
</script>
```

Clients join the way they always do. They scan the code on the host's display into the
[sample client page](/docs/guides/client-page/) or your own, and send strings on `pad`.

## What a server host is

Some games keep their state in a process on your server. That process is the host. It runs
`@bitskiff/host-node`, opens a session that stays open, and takes every client's input.

The host's display is a page in the venue. It runs no Bitskiff package. Your server feeds it over
your own link, the way a browser host's page feeds its own monitor.

A server host is a [Node host](/docs/guides/node-hosts/) that stays up, and it uses the same
[open-forever loop](/docs/guides/unattended-hosts/). There is no separate server package.

The host's display is not a video stream. Bitskiff carries bytes on named channels and no
pictures, so the server sends state and the host's display draws it.

## The host's display

The host's display is your page on your server's origin. Your server sends it two things:

- **The join code.** Send `session.joinCode.url` when a session opens and on every `joinCode`
  event. The code rotates every 30 seconds.
- **The state.** Whatever your game draws, as often as it needs.

Send the join code only to a host's display you have paired. The SDK does not send it anywhere on its own:
the join code is the host's to draw.

## Pairing the host's display

Your link to the host's display is authenticated as a device you own. Bitskiff sees none of it and
stores none of it. The recommended shape is the device authorization flow of RFC 8628:

1. A new host's display shows a one-time pairing code.
2. A person approves the code on your own admin page.
3. The host's display receives a device credential of yours, kept as an `HttpOnly`, `Secure`
   cookie on your server's origin.

The browser sends that cookie on the WebSocket handshake. A browser `WebSocket` sets no headers,
and a credential in a query string lands in every access log, so it never goes there. Take the
device's id from the approved record, never from anything the device sends.

The host's display is a device in a public place. Its credential is a live feed of join codes, so
whoever steals it can join from anywhere. Revoke the pairing to end the feed, and close its open
link at the same time. The last code the thief saw dies within 60 seconds.

A client the thief already admitted keeps its place until the host removes it. If that window is
too long for your project, set its `requireToken` limit to require a
[join token](/docs/guides/credentials/#join-tokens), so a code alone admits nobody.

The host's display holds no key or token of ours. Nothing it holds opens a session, mints a token
or reads one.

## Get the sample

The server host sample is the whole of this guide as one folder: the server, the host's display
page, the pairing flow with its approval page, a Dockerfile and a Kubernetes manifest. Take it from
the Samples table on the [downloads page](/docs/downloads/), unpack it with `tar -xzf`, and read its
README. Copy it and run it on your own infrastructure. Bitskiff does not host it.

## Keys at rest

A Node host keeps one record per slot so it can resume after a restart. A slot is the name the
host is saved under, `persist.as`, and `default` when you set none. The SDK seals that record, so no file and
no store holds a key or a token in clear.

- **The secret key is read, never written.** It comes from `BITSKIFF_SECRET_KEY` or from a file your
  platform mounts, such as a Kubernetes Secret.
- **One sealed record per slot.** It holds the host token, the session id and the host's identity
  key, together. In the state directory it is one owner-only file.
- **The seal uses the secret key.** A host that holds a token instead of a secret key seals under
  `BITSKIFF_STATE_SECRET`: at least 32 random bytes you generate.
- **Old secrets keep working.** Put them in `BITSKIFF_SECRET_KEY_FALLBACKS`, comma-separated. The
  SDK tries them in order after the current one, and re-seals a record under the current secret the
  first time it opens one under a fallback.

A host that holds a token and no state secret cannot seal, so `open` refuses it before anything is
written:

```
BitskiffError { code: 'validation', details: { option: 'persist.in', reason: 'no_sealing_secret' } }
```

Set `BITSKIFF_STATE_SECRET`, or pass `persist: { in: 'none' }` to keep nothing.

A record that no secret opens is ignored, and the next `open` replaces it. The record is deleted on
`ended` and on `close()`, and kept on `dispose()`. Disk encryption underneath is your platform's
layer and does not replace the seal.

## Restarts and crashes

Set `hostResumeWindow` to `5m` for a server host. The default is `10s`, which covers little more
than a page reload, and the ceiling is `30m`.

- **A deploy.** Call `loop.detach()` on `SIGTERM`. The session runs out its host grace, and the new
  process finds the sealed record and resumes the same session on the same join code.
- **A crash.** The process never detaches, and the result is the same: the next process resumes
  inside host grace.
- **An overlap.** Two processes cannot hold one slot. A replacement waits at most one slot lease,
  5 seconds, for its predecessor's lock, and never trusts a process id.

From the moment the server stops until the new one serves and each host's display reconnects, no
code is on show and nobody new joins. The clients already in the session keep their places.

## Kubernetes

A pod's own disk does not survive a rollout, so the state directory is a volume.

- Run a StatefulSet with one volume claim per replica, mounted at `BITSKIFF_STATE_DIR`.
- Set `persist.as` and the session `label` to the pod name.
- Set `terminationGracePeriodSeconds` longer than `detach()` takes.
- Each replica is its own host. Give every pod a stable DNS name with a headless Service, expose
  each one with its own ingress rule, and record in the pairing which replica a host's display
  belongs to. Never route a host's display through a load-balanced Service.

The sample does all of this.

| Event | What happens | What you do |
|---|---|---|
| A rollout restarts a pod | The new pod finds the sealed record on the same volume and resumes inside host grace | `detach()` on `SIGTERM` |
| The secret key rotates | The new pod opens the record under the old key and re-seals it under the new one | Set the new key, move the old one to `BITSKIFF_SECRET_KEY_FALLBACKS`, and remove it when the overlap ends |
| A pod moves where its volume cannot follow, or the replica count goes down | The pod finds no record and opens a fresh session. The old one ends `hostResumeExpired`, and its clients are told `hostGone` | Use a regional disk, or `nodeStateStore` |
| A rollback to an SDK from before the sealed record | The older SDK finds no slot and opens a fresh session | Nothing: each session costs its host grace |

## A platform with no volume

`nodeStateStore(backend)` keeps the sealed record in your own store instead of the state
directory. It builds the host's persistence and its key store over one backend, so the token and
the key never land in two places. The backend only ever sees sealed records.

A backend has four operations:

| Operation | What it does |
|---|---|
| `read(name)` | The entry's value, or `undefined` |
| `write(name, value, ttlMs?)` | Replaces the entry, with an expiry when one is given |
| `createIfAbsent(name, value, ttlMs)` | Creates the entry unless a live one exists; `true` when created |
| `deleteIfEqual(name, value)` | Deletes the entry only while it still holds `value`; `true` when deleted |

Expiry is judged on the backend's own clock. Redis (`SET NX PX`) and a SQL row compared against the
database's `now()` both qualify. A store with no server-side expiry, such as Cloud Storage or
Firestore alone, does not.

```js
import { BitskiffHost, nodeStateStore } from '@bitskiff/host-node';

// Your own store: read, write, createIfAbsent and deleteIfEqual over Redis or a SQL row.
const backend = {
  async read(name) { /* ... */ return undefined; },
  async write(name, value, ttlMs) { /* ... */ },
  async createIfAbsent(name, value, ttlMs) { /* ... */ return true; },
  async deleteIfEqual(name, value) { /* ... */ return true; },
};

const host = new BitskiffHost({ platform: nodeStateStore(backend) });
```

```ts
import { BitskiffHost, nodeStateStore, type NodeStateBackend } from '@bitskiff/host-node';

// Your own store: read, write, createIfAbsent and deleteIfEqual over Redis or a SQL row.
const backend: NodeStateBackend = {
  async read(name) { /* ... */ return undefined; },
  async write(name, value, ttlMs) { /* ... */ },
  async createIfAbsent(name, value, ttlMs) { /* ... */ return true; },
  async deleteIfEqual(name, value) { /* ... */ return true; },
};

const host = new BitskiffHost({ platform: nodeStateStore(backend) });
```

## Where the server runs

Run the server in the same region as Bitskiff, `us-central1`. Input then travels client to
Bitskiff, Bitskiff to your server inside one region, and your server to the host's display. A
server in another region adds two long legs.

The [`peer`](/docs/guides/transports/) transport rarely fits a server host: its clients are not on
the server's network, so a link crosses the internet as the engine does and exposes your server.

## Metering

The server is the host: the session's time is its time, and host grace across a deploy is counted
as away time. The host's display on your own link is not a participant. It takes no place and no
client minutes, and its traffic is yours.

## A host's display that joins as a client

You can build the host's display as a page on `@bitskiff/client` instead. The design does not build
for it, and it costs more. A host's display that joins as a client is metered as a client: it takes
a place, counts against `maxClients`, `concurrentClients` and `clientMinutesPerDay`, and keeps the
idle limit from firing while it is attached. One that is always on uses 1440 client minutes a day.

If you build it anyway:

- **Never broadcast the join code.** Send it with `channel.send(client, ...)` to each client whose
  subject names a host's display, and to nobody else. Any client on a broadcast could forward live
  codes.
- **Join with a token.** Your backend mints it with `POST /v1/tokens`: `sessions:join`, bound to the
  session, with the subject taken from the paired device record and never from the request. The
  minting key holds `tokens:mint` with the `subjects` condition `display:`.
- **Grant it on arrival.** A token carries no grants. The host sets `VIEWER` when the client arrives
  and ignores its input until then.
- **Resume, then pair.** A reload resumes from its stored record first, and only a place that is
  gone sends it back to your backend, with a backoff.

## Next steps

- [Node hosts](/docs/guides/node-hosts/)
- [Unattended hosts](/docs/guides/unattended-hosts/)
- [Credentials](/docs/guides/credentials/)
- [Session lifecycle](/docs/guides/lifecycle/)
