Channels
A channel is a name you wrote with a delivery class behind it, and it is the only place a message
is sent or received: its own send, its own on('message'), its own counters.
import { BitskiffHost, everyone } from '@bitskiff/host';
const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
const buzz = session.channel('buzz'); // reliable by default
const sticks = session.channel('sticks', { delivery: 'lossy', maxHz: 30, latestOnly: true });
buzz.on('message', (m) => console.log(m.from.id, m.data)); // only buzz reaches this
sticks.on('message', (m) => console.log(m.data)); // only sticks does
buzz.send(everyone, 'go');
The two ends agree on the name and the delivery and on nothing else. Every other setting is
this end's own: maxHz paces this sender, maxBytes is this receiver's ceiling, and neither end
learns the other's.
Declaring is local and reaches no wire, so the call is the same before the handle has connected and
after, and a second channel(name) hands back the object the first one made—two modules
reaching for one channel hold one. Naming a different delivery the second time is refused at the
call site, because two deliveries under one name are two channels. There is no default set: a
session that declared nothing has no channels.
reliable and lossy
They are the two classes, not two names out of a longer list.
reliable retransmits and arrives in order. It is never paced: maxHz and latestOnly are
lossy-only, and a reliable channel that names either is refused at the call site.
lossy never retransmits and never waits. maxHz is this end's own ceiling—past it a
message is dropped here rather than sent late—and latestOnly drops an arrival that is behind
the newest packet that sender already delivered, which is what a joystick wants and a chat line
does not. A host's send to one named participant on a lossy channel is dropped: lossy goes to
everyone.
What send answers
send answers immediately with one of three words, and throws for nothing the network does:
| It answers | What happened |
|---|---|
sent | handed to the transport |
queued | reliable, during a reconnect—it goes when the link is back |
dropped | over maxBytes, past this channel's pace, or one named participant on a lossy channel |
import { BitskiffHost, everyone } from '@bitskiff/host';
const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
const readout = session.channel('readout');
if (readout.send(everyone, 'you are on') === 'dropped') {
// this one is gone; there is nothing to await and 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—and anything that has to match the two up is a field in a payload you
wrote.
A host's to is one participant id, a list of them, or everyone:
import { BitskiffHost } from '@bitskiff/host';
const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
const readout = session.channel('readout');
const ids = [...session.participants.keys()];
readout.send(ids.slice(0, 2), 'your turn');
Strings and bytes
A message is a string or bytes, the way a WebSocket frame is. When both ends of your pair want a
binary format, encode it at the call site and send the Uint8Array:
import { BitskiffHost, everyone } from '@bitskiff/host';
const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
const sticks = session.channel('sticks', { delivery: 'lossy' });
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);
sticks.send(everyone, 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 the two apart the way it would on a WebSocket, and a string is measured as UTF-8 bytes rather than by its length in characters.
Limits
| Rule | Value | Past it |
|---|---|---|
maxBytes, one message | 14080 bytes reliable, 1152 lossy | dropped on send, and dropped before any handler on receipt |
| the lossy class's bucket, per sender | 120 a second across every lossy channel of one end | dropped |
| the project's bytes a second, per participant per direction | 16 KiB/s by default, on session.state.limits.bytesPerSecond and raised in the console | dropped locally, and a repeat offender is removed |
The third row is the one to design against. The first two are the wire's ceiling.
Counters and diagnostics
Every channel carries monotonic counters—sent, received, dropped, bytesSent,
bytesReceived—read them when you paint rather than on an event.
Every drop is also reported on session.on('diagnostic') and client.on('diagnostic'), with the
channel's name beside the reason: tooLarge, rateLimited, bufferPressure, and
unknownChannel for a message that arrived for a name this end never declared. That last one is
what to read when your two halves disagree about a spelling: the message is dropped before any
handler, and the sender is told nothing, because nothing on the wire refused it.
Pacing a producer
A channel reports its class's send buffer as pressure, 0 or 1. A producer reads it in the loop it
already has and skips a frame rather than handing the SDK one it will drop:
import { BitskiffHost, everyone } from '@bitskiff/host';
const session = await new BitskiffHost({ key: 'mrc_pk_...' }).open();
const frames = session.channel('frames', { delivery: 'lossy' });
function onFrame(bytes: Uint8Array) {
if (frames.pressure === 1) return; // the link is behind; this frame is the one to lose
frames.send(everyone, bytes);
}