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, 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:
| 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 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 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.
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:
<!-- 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.
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:
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 tobitskiff.jsonasclientKey.
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
hostKeyslist 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.comandhttps://play.example.com:8443are 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.