Bitskiff

Client pages

On this page

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, so a scanned code lands there. Change the join URL in the console's project settings.

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

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
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

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 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:

ChannelDeliveryWho sendsEach message is
pressreliablethe page"down" when the button goes down, "up" when it comes back
textreliablethe pagethe string the person typed, on its own
movelossythe pageeight bytes: a zero uint32 button field then x and y as int16, sixty times a second
readoutreliablethe hosta 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 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:

LimitWhat it refusesWhat clears it
samplePageJoinsPerDay, 100joins through the sample client page, past 100 joins in one UTC day00:00 UTC, or a joinUrl of your own
samplePageSessions, 100 warn and 1000 refusejoins through the sample client page, past 1000 sessions counted for the life of the project. At 100 the console and the command line only warna 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.

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, which carries @bitskiff/client and its hash. Save 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: npm install takes a URL as well as a name.

Shell
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:

Shell
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, 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. To make a client key by hand, or one that joins only some host keys' sessions, see 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.

The script

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;
}
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;
}

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.

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'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:

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
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

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:

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');
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');

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