Channels
A channel is a name you choose. Every message travels on one. Each channel has its own send, its
own on('message') and its own counters.
import { BitskiffHost, type Message } from '@bitskiff/host';
const host = new BitskiffHost({ key: 'bsk_pk_...' });
// Declare.
const buzz = host.channel('buzz'); // reliable by default
const sticks = host.channel('sticks', { delivery: 'lossy', maxHz: 60, latestOnly: true });
// Listen.
buzz.on('message', (m: Message) => console.log(m.client.id, m.data)); // only buzz reaches this
sticks.on('message', (m: Message) => console.log(m.data)); // only sticks does
// Open.
const session = await host.open();
session.on('clientJoined', () => buzz.broadcast('go')); // every client on `buzz`
// that is listening now
import { BitskiffHost } from '@bitskiff/host';
const host = new BitskiffHost({ key: 'bsk_pk_...' });
// Declare.
const buzz = host.channel('buzz'); // reliable by default
const sticks = host.channel('sticks', { delivery: 'lossy', maxHz: 60, latestOnly: true });
// Listen.
buzz.on('message', (m) => console.log(m.client.id, m.data)); // only buzz reaches this
sticks.on('message', (m) => console.log(m.data)); // only sticks does
// Open.
const session = await host.open();
session.on('clientJoined', () => buzz.broadcast('go')); // every client on `buzz`
// that is listening now
Every block below carries on from that host and session. Each one shows the lines its section
is about; the complete programs are the quickstart and the worked examples.
A host has two send verbs. broadcast(data) reaches every client that named the channel.
send(client, data) reaches the one you name, and it takes an array to name a few. Every send
names its audience: the channel, or the clients. A client has one verb, send(data), and it
speaks only to its host.
Declarations
The host declares every channel before it opens; a client names the ones it wants. The spec the host passes becomes the channel's row, and the row is the one definition. The service clamps it and every client is handed the effective row when it joins.
open() seals the table. After it, host.channel() throws a validation BitskiffError naming
channel, so a session has the channels its host declared at open, for its whole life. A second
host.channel(name) with no spec, or the same spec, hands back the first object. A different spec
is refused: two rows under one name are two channels.
There are no default channels, and a host declares at most 64. After the open, session.channel(name) is the lookup, one session's view.
Listen before open() too. A host that resumes rejoins clients that can send at once, so a
handler attached after resume() resolves can miss their first messages.
On a client page, name channels before join. client.channel(name) takes a name and nothing
else. A name held before the join travels in the client's first message, so a greeting the host
sends on it lands. A host unicast on a name the client has not named answers
{ dropped: 'notListening' }.
Once the join is answered, channel.declared says whether the host
declared the name and client.channels is the host's table. A name the host lacks is a quiet
state: its sends answer { dropped: 'notDeclared' }.
Attach on('message') before the join too. A message goes to the handlers on the channel at that
moment and to nobody else. It is not buffered, and it is not replayed when a handler arrives a tick
later.
A broadcast goes only to clients that named the channel. The client.state.channels list names
the channels one client is listening on. Read it first when your two halves disagree about a
spelling.
Delivery modes
There are two, reliable and lossy.
reliable retransmits and arrives in order. It has no pace of its own: the lifetime and
latestOnly options require delivery: 'lossy', and a reliable channel that names either is a
validation throw at the call site. Its maxHz defaults to 20 and paces nothing: the service only
reserves it. The service's own rate still holds it, which Limits below has.
lossy never retransmits and never waits. The maxHz option paces the sender, 30 a second
unless the host sets it: past it a message is dropped rather than sent late. The latestOnly
option drops an arrival that is behind the newest packet the same sender already delivered, which
is what a joystick wants and a chat line does not.
The lifetime option is how long a message is
worth delivering, a duration string like 200ms: past it the
transport stops trying. You write lifetime as a duration string; channel.lifetimeMs reads it
back in milliseconds.
A lossy channel has no unicast. The lossy downlink is one packet to every client on the channel, so
send(client, ...) on a lossy channel throws BitskiffError{ code: 'validation' } naming
channel at the call site, the same way a reliable channel naming latestOnly is refused. On a lossy
channel, broadcast is the verb.
ordered says whether a receiver is handed a channel's messages in the order they were sent.
Omitted, it is true on a reliable channel and false on a lossy one. Every field is the host's
to set, and a client reads it off the row once the host's answer to the join has landed.
Each channel keeps its own order. Two channels have no order against each other, whatever their delivery, so two things that must arrive in a fixed order belong on one channel.
Use a lossy channel for a stick, a pad or any other stream of state. Controller input shows how to pack one, how to sample it on a tick, and what to do about a release packet that gets lost.
The send result
Both send and broadcast return 'sent' or a drop with its reason, and neither throws for a
network problem:
| Value | Which end returns it | What happened |
|---|---|---|
'sent' | host, client | on its way; a reliable message during a reconnect is held and goes when the link is back |
{ dropped: 'rate' } | host, client | past this channel's maxHz, or past your project's bytes a second |
{ dropped: 'size' } | host, client | over the channel's maxBytes |
{ dropped: 'pressure' } | host, client | the send buffer is behind, or a reliable queue is full |
{ dropped: 'notOpen' } | host, client | the session is not open |
{ dropped: 'notDeclared' } | client | the host never declared the name this send used, so nothing reached the wire. One unknownChannel diagnostic named the channel when the join was answered |
{ dropped: 'noClient' } | host | nobody to send to: every client you named has left, or a broadcast where no client has named the channel |
{ dropped: 'notListening' } | host | every client you named is live but has not named this channel. A different fact from noClient: the client is here, the line is not. Nothing is buffered waiting for a subscriber |
{ dropped: 'hostAway' } | client | the host is not attached, so there is nobody to deliver to |
{ dropped: 'noGrant' } | host, client | this end's grants do not carry what the send needs, such as a stick packet from a client with no sendInput. It is the same value on both ends for the same fact |
One send returns one reason: the first check that fails. The channel name is checked first, then
the message's size, then who is there to receive, then the link. So a host message over
maxBytes, sent to a client that never named the channel, comes back size and not
notListening, and the tooLarge diagnostic fires.
On a client the name comes first the same way: one the host never declared is notDeclared before
the message is weighed at all.
import type { SendResult } from '@bitskiff/host';
const result: SendResult = buzz.broadcast('go'); // 'sent' when it went out
if (result !== 'sent') {
console.log(result.dropped); // this one is gone; nothing to retry
}
const result = buzz.broadcast('go'); // 'sent' when it went out
if (result !== 'sent') {
console.log(result.dropped); // this one is gone; nothing to retry
}
There is no ack and no request-reply. A reply is a message on a channel of your own: ask on ask,
answer on answer. To match a reply to its request, put an id in the payload yourself.
Recipients
A client is an argument to send. You do not reach the channel through a client. Pass one client,
or an array of them:
import type { Client } from '@bitskiff/host';
session.on('clientJoined', (client: Client) => {
buzz.send(client, 'you are on'); // this one client
buzz.send([...session.clients], 'go'); // the array form, naming a few
buzz.broadcast('go'); // whoever named the channel
});
session.on('clientJoined', (client) => {
buzz.send(client, 'you are on'); // this one client
buzz.send([...session.clients], 'go'); // the array form, naming a few
buzz.broadcast('go'); // whoever named the channel
});
In a handler, the client that sent the message is m.client, so answering it is
buzz.send(m.client, ...) with no lookup.
Listeners for one client
The call on('message', handler, { from: client }) binds a handler for one client's messages on
that channel:
import type { Client, Message } from '@bitskiff/host';
buzz.on('message', (m: Message) => console.log('anyone', m.client.id)); // the general rule
session.on('clientJoined', (client: Client) => {
buzz.on('message', (m: Message) => { // this one client's messages, and only those
console.log('this one', m.data);
m.stopPropagation();
}, { from: client });
});
buzz.on('message', (m) => console.log('anyone', m.client.id)); // the general rule
session.on('clientJoined', (client) => {
buzz.on('message', (m) => { // this one client's messages, and only those
console.log('this one', m.data);
m.stopPropagation();
}, { from: client });
});
The filtered handler runs first, and m.stopPropagation() stops the unfiltered ones. It is the
rule the DOM uses: specific before general, and the specific one may answer for the whole event. It
never touches another client's handlers.
Listeners
Every on returns an Unsubscribe, a function that removes that one listener, and calling it
twice is calling it once. That returned Unsubscribe is what to keep; off(event, handler) exists
too, for a handler you already hold.
import type { Message, Unsubscribe } from '@bitskiff/host';
const unsubscribe: Unsubscribe = buzz.on('message', (m: Message) => console.log(m.data));
unsubscribe(); // this listener is gone
const page = new AbortController(); // or close a whole group at once
buzz.on('message', (m: Message) => console.log(m.data), { signal: page.signal });
sticks.on('message', (m: Message) => console.log(m.data), { signal: page.signal });
page.abort(); // both are gone
const unsubscribe = buzz.on('message', (m) => console.log(m.data));
unsubscribe(); // this listener is gone
const page = new AbortController(); // or close a whole group at once
buzz.on('message', (m) => console.log(m.data), { signal: page.signal });
sticks.on('message', (m) => console.log(m.data), { signal: page.signal });
page.abort(); // both are gone
The SDK releases a { from: client } listener when that client leaves, and every channel listener
when the session ends, so an Unsubscribe you never called does not leak across a session. A
listener that lives as long as your page is still yours to close.
Strings and bytes
A message is a string or bytes, the way a WebSocket frame is. To send a binary format, encode it at
the call site and send the Uint8Array:
import type { Message } from '@bitskiff/host';
const frame = new DataView(new ArrayBuffer(9)); // a struct of your own: 1 tag + 2 floats
frame.setUint8(0, 1);
frame.setFloat32(1, 0.5); // x
frame.setFloat32(5, -0.25); // y
sticks.broadcast(new Uint8Array(frame.buffer));
sticks.on('message', (m: Message) => {
if (typeof m.data !== 'string') console.log(m.data.byteLength); // a Uint8Array you may keep
});
const frame = new DataView(new ArrayBuffer(9)); // a struct of your own: 1 tag + 2 floats
frame.setUint8(0, 1);
frame.setFloat32(1, 0.5); // x
frame.setFloat32(5, -0.25); // y
sticks.broadcast(new Uint8Array(frame.buffer));
sticks.on('message', (m) => {
if (typeof m.data !== 'string') console.log(m.data.byteLength); // a Uint8Array you may keep
});
Pick the encoding yourself: raw structs, protobuf, CBOR, MessagePack. The SDK ships no encoder and wraps none. A receiver tells a string from bytes the way it would on a WebSocket. A string is measured in UTF-8 bytes, not in characters.
Limits
Five limits apply to every message, and two to the channels themselves:
| Rule | Value | Past it |
|---|---|---|
payloadSize, one message | 14080 bytes reliable, 1152 lossy | { dropped: 'size' } on send, and dropped before any handler on receipt |
maxBytes, this channel's own ceiling | whatever the channel declared, and the platform's number when it declares none. It lowers the ceiling for one channel and never raises it: a message over payloadSize is refused whatever a channel says | { dropped: 'size' }, the same as the platform's own |
| the lossy bucket, per client | 120 a second across all lossy channels on one end | { dropped: 'rate' } |
| the reliable rate, per sender | 20 a second across all reliable channels on one end | the receiving end drops the excess and answers rate_limited; nothing is dropped locally |
| the project's bytes a second, per client per direction | 8 KiB/s by default, on session.state.limits.bytesPerSecond. A project raises it up to 16 KiB/s with bitskiff limits set bytesPerSecond 16384; a raise past that is stored as a pending request. A client key may set a lower one for its own clients | dropped locally, and a repeat offender is removed |
| a channel name | 64 UTF-8 bytes, and not empty | host.channel() throws a validation BitskiffError at the call site |
| channels per session | 64 | the 65th host.channel() throws a validation BitskiffError at the call site |
A lossy send past its rate is dropped locally, on the end that made it. The call answers
{ dropped: 'rate' }, nothing reaches the wire, and a rateLimited diagnostic names the channel.
The row's maxHz, 30 when the host set none, paces it as well as the lossy bucket.
The receiving end holds a sender to the same 120 a second and drops what is over it, so a sender that paces itself never reaches it.
A reliable send is never paced or dropped locally for the rate. The SDK never holds one back, no
call answers { dropped: 'rate' } for the rate, and no rateLimited diagnostic names a reliable
channel.
The 20 a second is the receiving end's budget: a token bucket, per sender, that holds a
burst of twice the rate. Past it the receiver drops the excess and answers rate_limited at most
once a second, retryable and carrying a retryAfterMs.
Acknowledgements and other answers are never charged, on either side, which is what keeps the close below reachable. A heartbeat or a ping is free at the SDK's own pace and charged past it. Every other control frame a client sends is charged, such as its hello, a presence change or a stats report, so a client that floods them meets the same close.
A client that stays over the rate for ten seconds in a row is closed with code 4100 and booted at once. The host does not wait for the client to honour the close.
- The host gets
clientLeftwithe.reasonbooted, and nothing the client still sends reaches a handler. - The client goes to
joiningand tries again after theretryAfterMs, ten seconds by default. That return is refused, and the client ends withended.reasonbooted.
A host that stays over the rate is never closed for it. Closing a client is the host's own act, so the host's excess is dropped and answered like anyone's.
A host sees that answer on the session's error event, as a BitskiffError with
code: 'rate_limited', retryable: true, retryAfterMs, and details.from, the id of the client
that was over. The client SDK raises no event for it: it logs the error and the session carries on.
The receiving end raises nothing either; the drop is counted and logged at debug. Keep reliable
sends under the rate on both ends.
Each end has its own bucket. The host's sends are counted against the host's, and every client's against its own.
The project's bytes-a-second limit is the one to design against. The other four are the wire's own ceilings, and a message that stays inside them still spends bytes against yours.
Counters and diagnostics
Every channel carries monotonic counters on channel.counters: sent, received, dropped,
bytesSent and bytesReceived. Read them when you draw a frame; they do not fire events.
The usage the platform meters counts the same messages: what your code sent and received, never the handshake, roster, keepalive and key traffic the SDK exchanges on its own.
console.log(buzz.counters.sent, buzz.counters.dropped); // on `counters`, not on the channel
Four drop reasons are also reported on a diagnostic listener: tooLarge, rateLimited,
bufferPressure, or unknownChannel. On a host that is session.on('diagnostic'); on a client
page it is client.on('diagnostic'), on the BitskiffClient handle. A roster Client on the
host has no diagnostic of its own.
diagnostic.channel is optional. It carries a channel's name when a channel rule is what
dropped the message: tooLarge against the channel's maxBytes, rateLimited against a lossy
channel's maxHz or the lossy bucket, and unknownChannel. It is absent on bufferPressure,
which the transport's send buffer decided, and on a rateLimited the project's bytes-a-second cap
decided. Guard the field.
The bytes field is the payload's own bytes, the number you handed send. The envelope, the seal
and the engine's framing are the SDK's overhead and are not counted there. bytesPerSecond is the
cap in force, and it is absent when no cap named one.
import type { Diagnostic } from '@bitskiff/host';
session.on('diagnostic', (d: Diagnostic) => {
console.log(d.reason, d.channel ?? '(no channel)', d.bytes);
});
session.on('diagnostic', (d) => {
console.log(d.reason, d.channel ?? '(no channel)', d.bytes);
});
Five more reasons arrive on the same listener when the session runs encrypted. They are listed under Encryption below.
One more reason reaches this listener from the host's own side. peerBooted fires once, just
before the host boots a client that stayed over the bytesPerSecond limit above for ten seconds
in a row, or that kept sending malformed envelopes. Its detail carries the client's id and the
limit it broke, or the close code.
The host's logger writes the same line. The ordinary boot follows, and that is what the client sees.
The unknownChannel reason is a sender-side diagnostic: it fires once on the client that
named a channel the host never declared, and the message never reaches the
wire. Read it when the two halves disagree about a spelling.
It never fires on receipt: a broadcast on a name this end knows but never named is dropped before any handler and counted, with no diagnostic, because a broadcast reaches every client on the channel and not a subscription list. The rule is that the sender is told once, on the line that named the channel, and nowhere else.
Encryption
A session can run end-to-end encrypted. The host mints its own identity key at open, every
payload either way is sealed under a group key the host owns, and nothing in between can read what
you send.
Two switches turn it on. The project's setting is off or required. The host's constructor
option is e2ee: 'required', and leaving the option out is off. There is no third value and no
negotiation.
The project's switch is the one the service holds every session to. Set it from the command line
with bitskiff settings set e2ee required; the console's Settings panel shows it and does not
write it. The host's own switch is e2ee: 'required' on the constructor:
import { BitskiffHost, type Session } from '@bitskiff/host';
const session: Session = await new BitskiffHost({ key: 'bsk_pk_...', e2ee: 'required' }).open();
import { BitskiffHost } from '@bitskiff/host';
const session = await new BitskiffHost({ key: 'bsk_pk_...', e2ee: 'required' }).open();
A host opened plain against a required project is refused, and so is a required host against a
project that is off. Either way open() rejects with BitskiffError{ code: 'validation' } and
details.option of e2ee, before the transport attaches, so set the two to the same value.
Under encryption the join code carries the host's key fingerprint after a ., which is what binds
a client to this host and not another. A client whose runtime lacks the primitives is refused
e2ee_unsupported; one joining from a stored entry that carries no fingerprint is refused
e2ee_required. Both are answered before a place is reserved.
Each client's own standing is client.state.e2ee, an E2eeInfo: bound is true once that
client's key is bound and its first message verified, generation is the group-key generation it
holds, and confirmation is six digits to read aloud against the other end's, absent until
bound.
A client that has not confirmed yet has no e2ee object at all. A rekey the client
already holds a wrap for moves generation and arrives as a clientChanged with change: 'e2ee'.
Five diagnostic reasons belong to encryption, and each one is a message this end refused to
deliver:
reason | What happened |
|---|---|
e2eeSignature | the message's signature did not verify |
e2eeTag | its authentication tag failed |
e2eeReplay | it repeated a sequence number already seen |
e2eeGeneration | it was encrypted under a key generation this end no longer holds |
e2eePlaintext | it arrived unencrypted on an encrypted session |
None of the five is a hiccup to retry through. Treat any of them as a wire to distrust: log it with the client it came from, and end that client rather than carrying on.
Congestion
A channel reports its send buffer as congested, a boolean. Read it in the loop you already have
and skip a frame rather than hand the SDK one it will drop:
function onFrame(bytes: Uint8Array): void {
if (sticks.congested) return; // the link is behind; this frame is the one to lose
sticks.broadcast(bytes);
}
function onFrame(bytes) { // `bytes` is one encoded frame, a Uint8Array
if (sticks.congested) return; // the link is behind; this frame is the one to lose
sticks.broadcast(bytes);
}
Next steps
- Controller input
- Lifecycle
- Client pages
- Worked examples: cat and mouse, a queue and four slots, and a poll with a reveal