# Client pages

A new project's join code opens the sample client page, so your first host needs no client code at
all. Build your own page when you ship, with a client key.

## The sample client page

It is one page for every project: a button, a joystick and a text field, plus a
readout the host writes. It draws those same three controls whatever host it joins.

A new project's join URL is the [sample client page](https://join.dev.bitskiff.com/), so a scanned code lands there. Change the join URL in the [console](https://console.dev.bitskiff.com)'s project settings.

Each message on the sample client page is the value itself, so a host reads it with no
`JSON.parse`:

```js
import { BitskiffHost } from '@bitskiff/host';

const host = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

// Declare every channel and listen, then open.
host.channel('press').on('message', (m) => console.log('press', m.client.id, m.data));
host.channel('text').on('message', (m) => console.log('typed', m.client.id, m.data));
host.channel('move', { delivery: 'lossy', maxHz: 60, latestOnly: true }).on('message', (m) => {
  if (typeof m.data === 'string') return;                    // the joystick sends bytes
  const v = new DataView(m.data.buffer, m.data.byteOffset, m.data.byteLength);
  console.log('move', v.getInt16(4, true), v.getInt16(6, true));
});
const readout = host.channel('readout');

const session = await host.open();
session.on('clientJoined', () => readout.broadcast('you are on'));   // somebody is listening now
```

```ts
import { BitskiffHost, type Message } from '@bitskiff/host';

const host = new BitskiffHost({ key: 'bsk_pk_...' });   // your host key

// Declare every channel and listen, then open.
host.channel('press').on('message', (m: Message) => console.log('press', m.client.id, m.data));
host.channel('text').on('message', (m: Message) => console.log('typed', m.client.id, m.data));
host.channel('move', { delivery: 'lossy', maxHz: 60, latestOnly: true }).on('message', (m: Message) => {
  if (typeof m.data === 'string') return;                    // the joystick sends bytes
  const v = new DataView(m.data.buffer, m.data.byteOffset, m.data.byteLength);
  console.log('move', v.getInt16(4, true), v.getInt16(6, true));
});
const readout = host.channel('readout');

const session = await host.open();
session.on('clientJoined', () => readout.broadcast('you are on'));   // somebody is listening now
```

The sample client page needs no key. It is the one client page that joins without one, and only
into a session whose join URL is the sample client page.

There is a [sample host](/host/) to scan it against: open it to get a session and a join code
without writing a host page.

Each control has its own channel. Nothing carries a tag, because the channel already says which
kind of message it is:

| Channel | Delivery | Who sends | Each message is |
|---|---|---|---|
| `press` | `reliable` | the page | `"down"` when the button goes down, `"up"` when it comes back |
| `text` | `reliable` | the page | the string the person typed, on its own |
| `move` | `lossy` | the page | eight bytes: a zero `uint32` button field then `x` and `y` as `int16`, sixty times a second |
| `readout` | `reliable` | the host | a string the page shows in its readout, whatever it says |

The joystick is the one row that sends bytes, because a pad is a struct the two halves agree on.
[Controller input](/docs/guides/controller/) has that layout.

Those four names belong to this page alone. Nothing in the service checks them, no session is
refused for sending something else, and your own client page never has to know they exist.

The sample client page is for getting started, so ship your own before you go to production. Two
limits sit on it, and both refuse a client's join rather than a host's `open`:

| Limit | What it refuses | What clears it |
|---|---|---|
| `samplePageJoinsPerDay`, 100 | joins through the sample client page, past 100 joins in one UTC day | 00:00 UTC, or a `joinUrl` of your own |
| `samplePageSessions`, 100 warn and 1000 refuse | joins through the sample client page, past 1000 sessions counted for the life of the project. At 100 the [console](https://console.dev.bitskiff.com) and the command line only warn | a `joinUrl` of your own. Nothing resets it |

Neither is raisable: a raise naming either is refused `validation`. Pointing the project's
`joinUrl` at a page of your own is the way past both. See
[Limits and capacity](/docs/guides/limits/).

## Custom client pages

A page of your own joins with a **client key**: a publishable key that joins sessions and does
nothing else. Load the client SDK, give it the key, and join.

### Loading the SDK

A client page needs no build step: the client module loads from the same import map as the host
page's. Copy the map of [No build step](/docs/guides/script-tag/), which carries `@bitskiff/client`
and its hash. Save [the script](#the-script) as `client.js` and load it after the map:

```html
<!-- the whole import map of No build step goes here -->
<script type="module" src="./client.js"></script>
```

An app that already has a build installs the package instead. Take the tarball from the
[downloads page](/docs/downloads/): `npm install` takes a URL as well as a name.

```sh
npm install https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-client.tgz
```

It then bundles the script with the rest of the app. With esbuild, that is one line:

```sh
npx esbuild client.js --bundle --format=esm --outfile=bundle.js
```

The UI pieces a client page usually wants -- a button, a joystick, a text field -- are in
[@bitskiff/ui-kit](/docs/packages/ui-kit/), which is what the sample client page is built from.
It is installed and bundled; it is not served as a module.

### The client key

Set the project's join URL to your page, and a client key for its origin is created with it:

- **In the console:** set the join URL in the project's settings. A project that holds no client
  key gets one.
- **From the command line:** `init --join-url <url>` mints the key and writes it to
  `bitskiff.json` as `clientKey`.

A scanned code then lands on your page instead of the sample client page: see [Join URL](#join-url). To make a client key by
hand, or one that joins only some host keys' sessions, see
[Client keys](/docs/guides/credentials/#client-keys).

The key allows your page's origin and no other. To serve the page from another origin as well,
such as `http://localhost` while you work, add it with
`keys update <keyId> --origin https://play.example.com --origin http://localhost`. `<keyId>` is the client key's id:
`init --join-url` printed it, and `keys list` shows it. `--origin` replaces the whole list, so
name every origin you keep: see
[Credentials](/docs/guides/credentials/#publishable-keys).

### The script

```js
import { BitskiffClient, isBitskiffError } from '@bitskiff/client';

const status = document.querySelector('#status');   // your page's own line of text

const code = location.hash.slice(1);             // your page read the link; see below
const client = new BitskiffClient({ key: 'bsk_pk_...' });   // your client key

const press = client.channel('press');
client.channel('readout').on('message', (m) => console.log(m.data));

document.addEventListener('visibilitychange', () =>   // the person looked away, or came back
  client.setPresence(document.visibilityState === 'hidden' ? 'away' : 'present'));

try {
  await client.join(code);                       // name first, then join; resolves when you are in
  press.send('down');
} catch (error) {
  if (!isBitskiffError(error)) throw error;
  // A refused join throws, and the four a first run meets are a full session (`clients_full`),
  // a code past its 30 to 60 s window (`invalid_code`), a client key that is not for this
  // session (`wrong_client`) and a host key where the client key goes (`forbidden` with
  // `details.reason` `missing_permission`).
  if (status) status.textContent = error.message;
}
```

```ts
import { BitskiffClient, isBitskiffError, type Message } from '@bitskiff/client';

const status = document.querySelector('#status');   // your page's own line of text

const code = location.hash.slice(1);             // your page read the link; see below
const client = new BitskiffClient({ key: 'bsk_pk_...' });   // your client key

const press = client.channel('press');
client.channel('readout').on('message', (m: Message) => console.log(m.data));

document.addEventListener('visibilitychange', () =>   // the person looked away, or came back
  client.setPresence(document.visibilityState === 'hidden' ? 'away' : 'present'));

try {
  await client.join(code);                       // name first, then join; resolves when you are in
  press.send('down');
} catch (error) {
  if (!isBitskiffError(error)) throw error;
  // A refused join throws, and the four a first run meets are a full session (`clients_full`),
  // a code past its 30 to 60 s window (`invalid_code`), a client key that is not for this
  // session (`wrong_client`) and a host key where the client key goes (`forbidden` with
  // `details.reason` `missing_permission`).
  if (status) status.textContent = error.message;
}
```

The blocks below carry on from that `client`, each showing the lines its section is about.

**Catch the join.** It rejects with the `BitskiffError` that refused it and leaves the handle
`ended`; a page that does not catch it shows a working page with nothing behind it. "Try again" is
a `join` on a new `BitskiffClient`, which is the same line the page ran the first time.

**Say when the person is looking.** The SDK never guesses whether a person is looking; the app
tells it with `client.setPresence('away')` and `client.setPresence('present')`. Read visibility,
not only `focus`: an Android WebView fires no `focus` or `blur`. An away client
keeps its place for `backgroundBudget`, `30s` by default: see
[Background clients](/docs/guides/lifecycle/#background-clients).

The client is the host side's mirror image: the same names, the same deliveries, the same string or
bytes. It has one send verb and no `broadcast`, because a client has one host to talk to.

## The join code

The link is your `joinUrl` with the code in the fragment, `<joinUrl>#<code>`: a fragment never
reaches a server, a proxy or an access log, which is what keeps the code out of them. So a page at
`https://play.example.com/join` is opened at `https://play.example.com/join#1CAbCdEfG1HPWtg`.

Read the code out of your own page's link and pass it to `join()` unchanged. The SDK never reads
`location` and never parses a link, so that line is yours to write.

The code is 15 letters and digits, unbroken, and case matters: show it exactly as you got it. Where
one is shown to a person who will type it, it reads in two groups of six and one of three --
`1CXXXX XXXXXX XXX` -- and the SDK strips spaces and dashes back out, so either form joins.

Pass the whole string and nothing less. Under encryption the string is `<code>.<fingerprint>`: the
host's key fingerprint follows the 15 characters after a `.`, so a page that passes only the part a person reads joins with
nothing to verify the host against.

## Join URL

A project's `joinUrl` is where a scanned code sends a client. It starts as the sample client page.
Set **`joinUrl`** to your page in the [console](https://console.dev.bitskiff.com)'s project settings.

The `joinUrl` is not what lets a client in. The client key is:

- **Its project.** A key joins its own project's sessions only.
- **Its host keys.** A key with a `hostKeys` list joins only sessions opened by those host keys.
- **Its origins.** A browser page joins only from an origin on the key's allowlist. Scheme, host
  and port are compared, so `https://play.example.com` and `https://play.example.com:8443` are two
  origins.

A join the key is not for is refused before the client takes a place, with the error code
`wrong_client`. A join from an origin the key does not allow is refused `origin_not_allowed`.

The sample client page holds no key, so it has its own rule: it joins only sessions whose `joinUrl`
is the sample client page. Point `joinUrl` at a page of your own and the sample client page can no
longer join that project's new sessions.

## Content Security Policy

Serve the page with a `Content-Security-Policy` **response header** carrying at least
`frame-ancestors 'none'`. Without it, another site can frame your page, and whoever framed it takes
the taps of a live client.

The `<meta http-equiv>` form does not work here: browsers ignore `frame-ancestors` in a meta tag.

Serve the page from a web server, never from disk. A page opened from disk gets no camera or clipboard
permission from the browser, and its relative module URLs do not resolve. A response header needs
a response, too.

## Client states

A client is in one of four phases, and four arms cover them all:

```js
const show = (banner) => console.log(banner);    // your page paints the banner instead

client.on('state', (s) => {                      // bind before you join: nothing is replayed
  switch (s.phase) {
    case 'joining':
      return show('connecting');                 // the first join and every reconnect
    case 'connected':
      return show(s.host === 'away' ? 'the host stepped out' : 'ready');
    case 'suspended':
      return show('try again');                  // `client.retry()` is what the button calls
    case 'ended':
      return show(s.ended?.reason ?? 'ended');
  }
});

show(client.state.phase);                        // the snapshot is what you paint from
```

```ts
import type { ClientState } from '@bitskiff/client';

const show = (banner: string): void => console.log(banner);   // your page paints the banner instead

client.on('state', (s: ClientState) => {         // bind before you join: nothing is replayed
  switch (s.phase) {
    case 'joining':
      return show('connecting');                 // the first join and every reconnect
    case 'connected':
      return show(s.host === 'away' ? 'the host stepped out' : 'ready');
    case 'suspended':
      return show('try again');                  // `client.retry()` is what the button calls
    case 'ended':
      return show(s.ended?.reason ?? 'ended');
  }
});

show(client.state.phase);                        // the snapshot is what you paint from
```

The `suspended` state means the SDK stopped trying on its own and this client's place is not known
to be gone, so only `ended` is final. Call `client.retry()` to start it again. Calling `join()`
stays a once-per-`BitskiffClient` move.

## Native apps

The C++ SDK and the Unreal plugin are clients: they join a host's session. The host runs in
TypeScript, in the browser or in Node; a host in native code is not built.

A native app or a game engine holds a client key too. An app that sends no `Origin` header
declares one with `origin`, beside the key. It is matched against the key's allowlist, which may
list a custom scheme:

```js
import { BitskiffClient } from '@bitskiff/client';

const client = new BitskiffClient({ key: 'bsk_pk_...', origin: 'myapp://join' });
const press = client.channel('press');                 // name first, then join

await client.join('1CAbCdEfG1HPWtg');
press.send('down');
```

```ts
import { BitskiffClient, type Channel } from '@bitskiff/client';

const client = new BitskiffClient({ key: 'bsk_pk_...', origin: 'myapp://join' });
const press: Channel = client.channel('press');        // name first, then join

await client.join('1CAbCdEfG1HPWtg');
press.send('down');
```

An app shell that does send a real `Origin` header, such as Capacitor on iOS with
`capacitor://localhost`, is matched on that header. A code scanned
into an app whose key is not for that session is refused `wrong_client`.

A load generator, a test harness or an embedded controller imports `BitskiffClient` from
`@bitskiff/client/node` instead. It is the same class with the browser's pieces swapped for the
runtime's, so only the import line changes and there is no second package to install.

## Next steps

- [Channels](/docs/guides/channels/)
- [Controller input](/docs/guides/controller/)
- [Lifecycle](/docs/guides/lifecycle/)
