@zakkster/lite-room
v1.1.0
Published
Reactive presence and collaboration on @zakkster/lite-signal. Cross-tab leader transports, signal-native peers, zero-GC cursor hot path.
Maintainers
Readme
@zakkster/lite-room
Reactive presence and collaboration on
@zakkster/lite-signal. Cross-tab leader transports, signal-native peers, zero-GC cursor hot path. Built so one user with 6 tabs holds one connection, not six.
Status: 1.0 stable. Wire protocol, codec, loopback / BroadcastChannel / DataChannel / leader-elected transports, room core, presence primitives (cursor + selection + identity + cursor history), storage CRDT trilogy (LWW-Map + OR-Set + fractional-indexed List), text RGA with reclamation and incremental delta stream, awareness (heartbeat + eviction), optional credential-based peer admission (HELLO), and
selfIdFromStringfor deterministic peer-id derivation -- all shipped, audited, and committed to a stable public API. See CHANGELOG.md for the 1.0 stability commitment and known limitations carried into 1.x.
Table of contents
- What you get
- Why a
lite-roomexists - Architecture
- Demo
- Wire format
- Usage
- Async coordination (await helpers)
- Framework bindings (React + Vue 3)
- Design decisions baked in here
- Tests
- Observed numbers
- Roadmap
- License
For an LLM-oriented condensed reference, see llms.txt.
What you get
- Wire protocol -- version byte, 6-kind message enum, 10-byte header, fixed payload sizes
PeerId-- u32, hoisted random buffer, server-overridable- Modular
u32Lamport clock - Pooled
Messagestruct -- one instance per decoder, mutated in place - Zero-allocation encoder -- caller-owned
Uint8Array, oneDataViewat construction - Zero-allocation decoder -- bit-shift u32 reads, scratchpad f32 bit-pun, no
DataViewallocation per packet even when fed a freshArrayBufferper call (thewslibrary shape) - Send-queue ring buffer -- pre-allocated outbound buffer, power-of-2 capacity, length-prefixed records, integer
SEND_KINDenum, two-segment byte loops (nosubarray()allocation), per-kind backpressure policy - Transport interface + loopback transport -- the contract every adapter implements, plus an in-process implementation that drains on the microtask queue
- BroadcastChannel transport -- cross-tab cursor flow in a single browser. Two tabs of the same site share the same
channelName, and cursors flow between them via the browser's structured-clone fan-out. No upstream connection needed for in-browser-only collaboration; pairs with the leader-elected transport below for cross-browser rooms. - DataChannel (WebRTC) transport -- cross-browser cursor flow over
RTCDataChannel. Caller handles signaling (SDP/ICE) however they want; the transport just wraps the open channel.createDataChannelPair()helper provides paired in-process channel-likes for tests and single-process demos. - Leader-elected cross-tab transport --
createLeaderTransport({ bcTransport, upstreamFactory, elector }). Wraps a BC transport (same-browser) and a per-leader upstream transport (cross-browser). Exactly one tab per browser holds the upstream connection; followers route through the leader via BC. BundledcreateSimpleLeaderElectoruses BC heartbeats; lowest tabId wins ties; incumbent leader defends; followers re-elect on leader timeout. This is the "one network connection per user no matter how many tabs" delivery. - Room core --
createRoom(opts)returning{ self, peers, status, close }. Peer registry as aMap<PeerId, Peer>signal with always-different equals.room.self.setCursor(x, y, flags)updates local signals viabatch()and emits a packet. Incoming cursors decode + apply via batched signal sets so a watching effect fires once per logical update. - Presence primitives -- beyond cursor,
room.self.setSelection(start, end, flags)for text-range presence androom.self.setIdentity(username, color)for per-peer username + color. Per-peer signals:peer.cursor.{x,y,flags},peer.selection.{start,end,flags},peer.identity.{username, color}. Username uses string equality (no spurious fires on same-value repeats). Optional throttling viacursorThrottleMsandselectionThrottleMsopts: leading-edge-immediate + trailing-edge-final-value, so the receiver always sees the user's final position even when the wire is rate-limited and local signals stay synchronous. - Storage CRDT (LWW-Map) --
room.storageholds string-keyed JSON-serializable values with Last-Write-Wins conflict resolution.set/get/delete/has/sizeplus anentriessignal that fires on any change. Lamport-stamped writes; ties broken by peerId. Tombstones prevent resurrection by late SETs. Snapshot-on-first-sight ensures joiners converge on existing state. Routes throughSEND_KIND.STORAGE(lossless on loopback, escalates room status to'backpressure'on full buffer). The OR-Set, fractional-indexed List, and text RGA below complete the CRDT set. - Storage CRDT (OR-Set) --
room.sets.get(name)returns an Observed-Remove Set proxy.add(value),remove(value),has(value),size, and avaluessignal ofSet<parsedValue>. Each ADD creates a unique tag from the local Lamport; REMOVE targets specific observed tags. Concurrent ADDs of the same value coexist (multi-tag); a REMOVE only erases tags it observed, so an ADD with a fresh tag survives a concurrent REMOVE (the canonical OR-Set property). Multiple named sets are isolated; proxies cached. - Storage CRDT (Fractional-indexed List) --
room.lists.get(name)returns an ordered list proxy.push,unshift,insertAt(idx, value),removeAt(idx),get(idx),length, plus avaluessignal of the sorted array. Each item is identified by a(f64 position, peerId)pair; concurrent inserts at the same logical position both survive with deterministic peerId-tiebreak ordering. ~52 bits of midpoint precision for nested insertions (typical UI workloads). Multiple named lists are isolated. - Awareness -- automatic peer discovery and liveness.
heartbeatMscontrols how often each room emits a header-onlyKIND.PING; receivers refreshpeer.lastSeenon any inbound packet.evictionMscontrols how long a peer can be silent before being removed fromroom.peers. The first heartbeat fires shortly after construction so peers discover each other without needing presence or storage activity. Defaults 5000ms / 30000ms; set either to0to disable.
Why a lite-room exists
The lite-signal ecosystem already covers state (lite-store), persistence (lite-persist), and cross-tab sync (lite-channel). What's missing is the layer where multiple users show up in the same document: cursors, selections, shared documents converging across machines.
The libraries that own that space (Liveblocks, PartyKit, Yjs) all assume one user = one connection. Five tabs of the same Notion doc means five WebSockets. lite-room is built around the unique angle the lite-signal stack already enables: one network connection per user, regardless of tab count, via lite-channel's leader election. The leader holds the transport; followers mirror via BroadcastChannel. To the user and the other peers in the room, the handoff is invisible.
Everything from the bytes on the wire up through the room API, the presence primitives, and the CRDT set ships in this package -- this is the 1.0.
Architecture
A room sits between your renderer (which reads signals) and a byte transport (which moves frames). Every layer is allocation-free on the cursor hot path; the only per-peer allocation is the 8-node presence record, reclaimed when the peer leaves.
flowchart TB
subgraph App[Your app]
R[Renderer / effects<br/>read lite-signal signals]
end
subgraph Room[lite-room Room]
SELF[self<br/>setCursor / setSelection / setIdentity]
PEERS[peers signal<br/>Map<peerId, Peer>]
STORE[storage / sets / lists / texts<br/>CRDT signals]
ENC[Encoder<br/>one caller-owned buffer]
DEC[Decoder<br/>one reused Message]
AW[Awareness<br/>heartbeat + eviction]
end
subgraph Transport[Transport contract]
LB[Loopback]
BC[BroadcastChannel]
DC[DataChannel WebRTC]
LE[Leader-elected<br/>one upstream per browser]
end
R -->|read| SELF
R -->|read| PEERS
R -->|read| STORE
SELF -->|encode| ENC
STORE -->|encode| ENC
AW -->|PING| ENC
ENC -->|send bytes + SEND_KIND| Transport
Transport -->|inbound bytes| DEC
DEC -->|batched signal writes| PEERS
DEC -->|batched signal writes| STORE
LE -.->|wraps| BC
LE -.->|wraps| DCInbound bytes are decoded into a single reused Message, then applied with batch() so a watching effect fires once per logical update. Outbound, every encoder method writes into one Uint8Array and returns a length -- no packet allocates. Presence drop policy is steered by SEND_KIND: cursors are lossy, storage and control are not.
Demo
demo/index.html is a self-contained oscilloscope that runs the real library -- no mocks. Four channels, each driven by actual rooms over a loopback transport, rendering from the live signals:
- CH1 Presence -- four peers' cursors as phosphor beams with history-interpolated trails; your pointer drives the YOU beam. The zero-GC hot path, on screen.
- CH2 Text (RGA) -- two peers type into one document and converge; type to join in.
- CH3 State (OR-Set) -- three peers flip a shared membership grid and converge; click to toggle.
- CH4 Awareness -- ephemeral peers join via heartbeat and get evicted on silence, while the NODES meter stays flat -- the per-peer node reclamation, visualized.
Open the file directly in a browser, or rebuild it after editing demo/_app.js with:
npm run demo:build # bundles the real library + demo into demo/index.html via npx esbuildThe demo is repo-only; it is not part of the published npm package.
Wire format
Header (10 bytes, every message):
offset size field
0 1 version (u8, currently 1)
1 1 kind (u8)
2 4 peerId (u32 LE -- sender; server-stamped on relay)
6 4 lamport (u32 LE -- sender's clock at send time)PRESENCE_CURSOR (20 bytes -- header + 10):
10 4 x (f32 LE)
14 4 y (f32 LE)
18 1 flags (bit 0: in-viewport, bit 1: idle, bits 2-7: reserved)
19 1 reserved (0)Everything is little-endian. No length prefixes for fixed-size kinds. No JSON, no UTF-8 in the hot path.
Reserved kind ranges leave room to grow without protocol-version bumps: control 0x00-0x0F, presence 0x10-0x1F, awareness 0x20-0x2F, storage CRDT ops 0x30-0x3F, snapshot 0x40-0x4F, extensions 0x80+.
Usage
Multi-tab cursors with selection + identity
Each browser tab that should join the same room opens a BroadcastChannel transport on the same channelName. Cursors, selections, and per-peer identity (username + color) all flow between tabs through the browser's structured-clone fan-out.
import {
createRoom,
createBroadcastTransport,
CURSOR_FLAG,
} from '@zakkster/lite-room';
import { effect, computed } from '@zakkster/lite-signal';
const room = createRoom({
transport: createBroadcastTransport({ channelName: 'doc-123' }),
selfId: myUserId,
cursorThrottleMs: 16, // ~60 Hz; opt-in. Default 0 = no throttle.
selectionThrottleMs: 16,
});
// Identity (rare; set once at join, emit on change)
room.self.setIdentity('alice', 0xFF0080FF);
// Cursor + selection (frequent; throttled at the wire, local signals immediate)
window.addEventListener('mousemove', (e) => {
room.self.setCursor(e.clientX, e.clientY, CURSOR_FLAG.IN_VIEWPORT);
});
document.addEventListener('selectionchange', () => {
const s = window.getSelection();
if (s) room.self.setSelection(s.anchorOffset, s.focusOffset, CURSOR_FLAG.IN_VIEWPORT);
});
// Render: per-peer signals fire only when THAT field changes for THAT peer.
effect(() => {
for (const peer of room.peers().values()) {
if (peer.self) continue;
drawCursor(peer.id,
peer.cursor.x(), peer.cursor.y(),
peer.identity.username(), peer.identity.color());
if (peer.selection.start() !== peer.selection.end()) {
drawSelection(peer.id,
peer.selection.start(), peer.selection.end(),
peer.identity.color());
}
}
});One network connection per user no matter how many tabs
Each tab in the same browser opens a BroadcastChannel transport on the same channelName AND a leader elector on the same name. Only the leader tab opens the upstream connection (WebRTC, WebSocket, etc.); followers route through the leader via BC. When the leader closes (user closes the tab), the next-lowest-tabId follower takes over and opens a fresh upstream.
import {
createRoom,
createBroadcastTransport,
createSimpleLeaderElector,
createLeaderTransport,
createDataChannelTransport,
} from '@zakkster/lite-room';
// In every tab:
const channelName = 'doc-123';
const bcTransport = createBroadcastTransport({ channelName });
const elector = createSimpleLeaderElector({ channelName, tabId: myTabId });
// upstreamFactory is called ONLY when this tab becomes leader.
const transport = createLeaderTransport({
bcTransport,
upstreamFactory: () => {
// App-specific signaling: connect WebSocket / set up RTCPeerConnection /
// exchange SDP / open RTCDataChannel / etc. Return a Transport.
return createDataChannelTransport({ channel: openCrossBrowserChannel() });
},
elector,
});
const room = createRoom({ transport, selfId: myUserId });Six tabs of the same doc -> one WebRTC connection (held by the leader). Leader closes -> a follower takes over after the heartbeat timeout. To the room layer, this is invisible: room.peers, room.self.setCursor, and the status signal all behave the same regardless of role.
Shared mutable state via the storage CRDT
import { createRoom, createBroadcastTransport } from '@zakkster/lite-room';
import { effect } from '@zakkster/lite-signal';
const room = createRoom({
transport: createBroadcastTransport({ channelName: 'doc-123' }),
selfId: myUserId,
});
// Write: any JSON-serializable value. Lamport-stamped, propagated to all peers.
room.storage.set('title', 'My document');
room.storage.set('blocks', [
{ id: 'b1', type: 'paragraph', text: 'Hello world' },
{ id: 'b2', type: 'heading', text: 'Section 1' },
]);
// Read: synchronous, returns the JS value (undefined for missing/tombstoned).
console.log(room.storage.get('title')); // 'My document'
console.log(room.storage.size); // 2 (live entries only; tombstones excluded)
// Delete: tombstoned; later SETs with lower Lamport cannot resurrect.
room.storage.delete('blocks');
// Subscribe: coarse-grained signal of the underlying Map<string, Entry>.
effect(() => {
const m = room.storage.entries();
for (const [key, entry] of m) {
if (entry.deleted) continue;
console.log(key, '=', entry.value, '(from peer', entry.peerId, ')');
}
});When a new peer joins, every existing peer broadcasts their full LWW state as a snapshot replay carrying the original Lamports; the joiner converges on the union of all peers' state with LWW resolving conflicts deterministically. No central server, no manual sync.
Collaborative text editing via the RGA CRDT
import { createRoom, createBroadcastTransport } from '@zakkster/lite-room';
import { effect } from '@zakkster/lite-signal';
const room = createRoom({
transport: createBroadcastTransport({ channelName: 'doc-123' }),
selfId: myUserId,
});
// Get a text proxy. Multiple named texts live in the same room.
// Calling .get with the same name returns the SAME proxy.
const body = room.texts.get('body');
// Local edits. Offsets are UTF-16 code units (matching String.length
// and standard textarea/contenteditable selection ranges).
body.insert(0, 'Hello World');
body.insert(5, ' beautiful'); // -> "Hello beautiful World"
body.delete(5, 10); // -> "Hello World"
// Read: synchronous, returns the current full string.
console.log(body.toString()); // 'Hello World'
console.log(body.length); // 11 (O(1), maintained incrementally)
// Subscribe (full string): lazy computed signal. As of 0.13.0 the
// render runs only when a tracked reader pulls -- if you only need
// "did something change" or "what changed", subscribe to body.changes
// instead and skip the O(N) string rebuild.
effect(() => {
console.log('text changed:', body.value());
});
// Subscribe (incremental): emits Delta[] per edit using replace
// semantics { start, end, text, peerId }. Apply in order; doc state
// evolves between deltas in a single batch (so a multi-block delete
// emits multiple deltas all sharing the same `start`).
let dom = body.toString();
effect(() => {
for (const d of body.changes()) {
dom = dom.slice(0, d.start) + d.text + dom.slice(d.end);
// ... or apply each delta to a contenteditable Range, etc.
}
});Cross-peer convergence is automatic: concurrent inserts at the same position are ordered deterministically by (lamport, peerId) so all peers end up with the same string regardless of message arrival order. Deletes tombstone characters in place; concurrent insert-and-delete on the same region converge to the union of inserts with deletes applied.
Late joiners receive the full text state via the existing snapshot path -- the same mechanism that syncs the LWW-Map and List CRDTs. Tombstoned characters are preserved in the snapshot as structural anchors so late-arriving ops from third peers that reference them resolve correctly.
A few honest caveats for v1:
- Per-call allocation. Each
text.insert(offset, str)allocates oneTextBlock(~80 bytes plus the string), regardless of how many characters are in the string. A 1000-character paste is one block; a thousand single-character keystrokes are a thousand blocks. The original design had a contiguous-typing fast path that coalesced adjacent keystrokes from the same author into one block, but it produced multi-peer convergence failures and was deferred to v2; seeDESIGN-M4.3-text-rga.mdsection 5.1 for the trace. Sustained 60-keystroke-per-second typing produces ~5 KB/s of TextBlock allocations, well within V8's young-gen budget. text.valueis a lazy computed signal (0.13.0). Edits no longer pay for_renderTextunconditionally -- the full string is materialized only when a tracked reader pulls it. Workloads that issue many edits between render passes (game loops, animation frames) see a roughly 70x throughput improvement on the bench (bench/text-allocations.mjs: 540k ops/s, up from 7k ops/s). For renderers that need incremental updates without the full string, subscribe totext.changesinstead.- Tombstones accumulate -- but can be reclaimed via
text.compact(minLamport)(0.12.0). Deleted characters stay in the DLL by default so late-arriving remote ops can resolve their origins. Long edit sessions on a multi-peer document grow block count monotonically. The local-only primitivetext.compact(minLamport)splices out tombstones whose lamport range is below the watermark, returning the underlying blocks to the per-doc pool. SAFETY: this is local-only with no cross-peer wire coordination -- the caller must ensure no in-flight remote op references a compacted range, or those ops will silently drop on arrival. Safe usage patterns include single-user offline sessions, post-reconnect with a fresh snapshot, and known-quiescent windows. A fully-safe coordinated compaction protocol (vector-clock watermarks) is a v2 candidate. - Offsets are code units, not code points. A surrogate pair (emoji) is 2 code units; calling
delete(0, 1)on"\uD83D\uDE00B"leaves an orphan low surrogate. Aligning offsets to code points is the caller's responsibility -- standard for any UTF-16-native string API.
Cursor history for trails and render-tick interpolation
import { createRoom, createBroadcastTransport } from '@zakkster/lite-room';
const room = createRoom({
transport: createBroadcastTransport({ channelName: 'doc-123' }),
selfId: myUserId,
// Opt in: keep the last 60 cursor frames per peer. stride is fixed
// at 2 (x, y); flags are not history-tracked.
cursorHistoryCapacity: 60,
});
// History is populated automatically: every PRESENCE_CURSOR packet
// (yours and theirs) is recorded with Date.now() as its timestamp.
// Wire format unchanged; this is purely local interpolation.
// In your render loop:
const out = new Float32Array(2);
const INTERPOLATION_LAG_MS = 50; // smooth past stutters
function renderTick() {
const sampleTime = Date.now() - INTERPOLATION_LAG_MS;
for (const peer of room.peers.peek().values()) {
if (peer.self) continue;
out[0] = NaN; out[1] = NaN;
peer.cursor.sample(sampleTime, out);
if (!Number.isNaN(out[0])) drawCursor(peer.id, out[0], out[1]);
}
requestAnimationFrame(renderTick);
}peer.cursor.sample(time, out) is a zero-allocation wrapper around the underlying @zakkster/lite-history-buffer HistoryBuffer. It writes the linearly-interpolated (x, y) for the requested time into out[0] and out[1]. If time is past the newest frame, the newest frame is written verbatim (future-clamp). If time is before the oldest frame, the oldest is written (past-clamp). If history is disabled (cursorHistoryCapacity === 0) or no frames have been recorded yet, out is left untouched -- pre-initialize to a sentinel (NaN) if you need to detect that case.
For advanced use cases, peer.cursor.history exposes the raw HistoryBuffer instance (or null if disabled). You can iterate history.buffer directly to render a trail, or call history.sample(time, out) with a longer out array if you've configured a wider stride.
Capacity is per-peer; 60 frames at typical 60Hz cursor updates buys you ~1 second of history per peer. Memory cost: capacity * 3 * 4 bytes per peer (one Float32 for time, two for x/y), so 60 * 12 = 720 bytes per remote cursor. Self-cursor is also tracked when capacity > 0 so you can render your own trail symmetrically; if you don't need it, just don't read room.self.cursor.history.
Deriving a stable selfId from a session identifier
createPeerId() is fine when you don't need cross-session identity recovery -- each tab gets a fresh random u32, and that's the right answer for ephemeral presence. But if you want a returning user to be recognized as the same peer across reloads or devices, you need a deterministic mapping from their session identifier (a Twitch opaque_user_id, an OAuth subject, a session cookie) to a u32.
import { createRoom, selfIdFromString } from '@zakkster/lite-room';
// Twitch Extension example -- onAuthorized fires with a JWT that includes
// the user's stable opaque_user_id (scoped to your extension).
window.Twitch.ext.onAuthorized((auth) => {
const room = createRoom({
transport: createBroadcastTransport({ channelName: `twitch-${auth.channelId}` }),
selfId: selfIdFromString(auth.userId || auth.opaque_user_id),
});
});selfIdFromString(str) is a stable FNV-1a 32-bit hash over the UTF-8 bytes of str. Same input always yields the same output, across processes and across reloads.
Birthday-paradox collision caveat: at ~65k peers sharing one selfId space, collision probability hits 40%. For typical Twitch Extension rooms (a single channel's viewers) or small collaborative documents (a few dozen peers), this is irrelevant. For deployments expected to host tens of thousands of concurrent peers per room, the right pattern is a server that remaps client-supplied identifiers to a small-integer peer id at admission time. Empty-string input throws (would collide for every "no auth" user).
Peer admission with a credential handshake
For transports that don't authenticate peers themselves (cross-tab BroadcastChannel between untrusted contexts, WebRTC peer connections, third-party relay services), lite-room offers an OPTIONAL identity handshake. Pass a validateCredential callback at room creation; each peer calls room.identify(credentialBytes) to announce themselves. Until a peer's HELLO has been received and accepted, all content from that peer is dropped, and our storage snapshot is withheld.
import { createRoom, selfIdFromString } from '@zakkster/lite-room';
// Receive side -- validates incoming HELLOs.
const room = createRoom({
transport,
selfId: selfIdFromString(myUserId),
validateCredential(peerId, credentialBytes) {
// Synchronous. Return true to admit the peer, false to reject.
// credentialBytes is a BORROWED Uint8Array view, valid only for
// the duration of this call. Copy if you need to retain.
try {
const token = new TextDecoder().decode(credentialBytes);
return verifyJwtSignature(token, myPublicKey);
} catch (_) {
return false;
}
},
});
// Send side -- announce ourselves on connect.
const myJwt = await fetchMyToken();
room.identify(new TextEncoder().encode(myJwt));When validateCredential is omitted (or null), behavior is unchanged from earlier versions -- HELLO is informational only, all peers are auto-validated, and content flows immediately. Adding the validator is a strict opt-in.
The credential blob is OPAQUE -- format is your choice. JWT, signed claim, OAuth subject string, anything up to HELLO_CREDENTIAL_MAX_BYTES (64KB). lite-room transports it; your callback interprets it. The library deliberately ships no embedded JWT verifier -- bring your own (jose, tmi.js's helper, a minimal RS256 verifier, etc.). This keeps the core small.
Validation is synchronous and lazy. The first HELLO from a peer is validated; subsequent HELLOs from already-validated or already-rejected peers are no-ops. State is sticky for the room's lifetime -- there is no automatic re-validation, no token-expiry handling. Callers needing expiry should evict-and-reconnect at the transport layer rather than re-validate at this layer. Validator exceptions are caught and treated as reject; nothing leaks out of dispatch.
Each peer's state is observable through room.peers:
room.peers.subscribe((peers) => {
for (const [id, peer] of peers) {
console.log(id, peer.validationState); // 'validated' | 'unvalidated' | 'rejected' | 'auto'
}
});For a worked end-to-end example covering Twitch Extensions specifically -- including EBS-signed room tokens, Ed25519 verification with @noble/ed25519, and the three viable auth architectures -- see RECIPE-twitch-extension.md.
Lower-level: codec + send queue + loopback transport
import {
createEncoder, createDecoder,
createPeerId, createLamport,
createSendQueue, createLoopbackTransport,
KIND, SIZE, CURSOR_FLAG, SEND_KIND,
} from '@zakkster/lite-room';
const enc = createEncoder(new Uint8Array(64));
const dec = createDecoder();
const id = createPeerId();
const clock = createLamport();
// Encode a cursor packet
const len = enc.presenceCursor(0, id, clock.tick(), 123.5, -45.25, CURSOR_FLAG.IN_VIEWPORT);
// Hand it to any transport that satisfies the contract
const t = createLoopbackTransport();
t.onMessage((bytes, length) => {
const msg = dec.decode(bytes, 0, length);
// msg.x, msg.y, msg.flags valid until the next decode()
});
t.send(enc.buf, len, SEND_KIND.PRESENCE);The decoded Message is a borrowed reference. Copy fields out if you need them past the next decode() call. Same rule applies to onMessage callbacks.
Async coordination (await helpers)
Added in 1.1. Two promise-returning helpers on the room handle, for the common "do something once the room is in a certain state" need at lifecycle boundaries -- without hand-rolling (and forgetting to dispose) an effect.
// Resolve once the room is connected. 'backpressure' (connected but buffered)
// counts as connected.
await room.ready();
// Resolve with the remote peers once at least N (excluding self) are present.
const peers = await room.waitForPeers(2); // peers: RemotePeer[]Both accept { signal?: AbortSignal, timeoutMs?: number }:
const ctrl = new AbortController();
try {
await room.ready({ timeoutMs: 5000, signal: ctrl.signal });
} catch (e) {
if (e instanceof TimeoutError) { /* did not connect in 5s */ }
if (e instanceof RoomClosedError) { /* room was closed first */ }
if (e?.name === 'AbortError') { /* ctrl.abort() was called */ }
}TimeoutError and RoomClosedError are both exported from @zakkster/lite-room (TimeoutError is re-exported from @zakkster/lite-await).
Settlement contract -- identical for every helper, and every path tears down the internal subscription:
| Outcome | Settles as |
| --- | --- |
| condition satisfied | resolve (ready -> void; waitForPeers -> RemotePeer[]) |
| room.close() before satisfied | reject RoomClosedError |
| room already closed at call time | reject RoomClosedError (synchronously) |
| opts.timeoutMs elapses | reject TimeoutError |
| opts.signal aborts / is already aborted | reject the signal's abort reason |
The close-rejection is what keeps these honest: a closed room is terminal (see the single-shot note -- a room cannot reopen), so waitForPeers(5) against a room that only ever sees 2 peers and then closes rejects rather than hanging forever with a live subscription. That is the difference between these helpers removing the consumer-side leak (an undisposed readiness effect) and becoming one.
Details worth knowing:
waitForPeers(n)counts remote peers only; self is never included.waitForPeers(0)resolves immediately.nmust be a non-negative integer, orwaitForPeersthrowsRangeErrorsynchronously at the call site (not as a rejected promise) so a typo surfaces loudly.- These are for one-shot coordination, not per-frame work. For continuous reactions, read
room.status/room.peersin aneffectdirectly.
Built on @zakkster/lite-await's whenSignal, whose teardown is exercised by a 4096-cycle cleanup probe -- lite-room reuses that rather than re-implementing the abort/cleanup dance.
room.waitForSnapshot()is planned for 1.2: it requires a snapshot-framing wire-format addition first (1.0 replays initial state as an unframed op batch with no completion marker to await).
Framework bindings (React + Vue 3)
lite-room is framework-agnostic at the API level: every reactive output is a lite-signal signal() with .peek() (untracked read), .subscribe(fn) (value-now-and-on-change), and call syntax for tracked reads. That's enough to bridge into any UI framework with minimal glue. The two recipes below show the integration shape for React 18+ and Vue 3.
These are recipes, not packaged libraries -- copy into your app and adapt. They're under 50 lines each and have no exotic dependencies.
React 18+ recipe
The bridge uses useSyncExternalStore for primitive-valued signals (cursor x/y, status, identity strings) and a tick-counter useState pattern for object-valued signals (peers Map). Why the split: React's useSyncExternalStore requires getSnapshot() to return a referentially-stable value when nothing has changed, and lite-room's peers signal deliberately reuses the same Map reference across notifications (it's mutated in place). Returning the same Map ref to useSyncExternalStore would suppress the re-render. The tick-counter sidesteps this by triggering React's render via state change, then reading .peek() fresh.
// useRoom.js
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createRoom } from '@zakkster/lite-room';
// For primitive-valued signals: number, string, boolean.
// useSyncExternalStore handles tearing correctly across concurrent renders.
export function useSignal(sig) {
return useSyncExternalStore(
(notify) => sig.subscribe(() => notify()),
() => sig.peek(),
);
}
// For object-valued signals where lite-room reuses the same reference
// across updates (peers Map, identity object). Forces re-render on every
// fire without cloning the underlying object.
export function useObjectSignal(sig) {
const [, setTick] = useState(0);
useEffect(() => sig.subscribe(() => setTick((t) => t + 1)), [sig]);
return sig.peek();
}
// Owns the room's lifetime. opts must be referentially stable across
// renders (memoize outside or pass a primitive id and build opts inside).
export function useRoom(opts) {
const roomRef = useRef(null);
if (roomRef.current === null) roomRef.current = createRoom(opts);
useEffect(() => () => { roomRef.current?.close(); roomRef.current = null; }, []);
return roomRef.current;
}
// Convenience hooks.
export const useStatus = (room) => useSignal(room.status);
export const usePeers = (room) => useObjectSignal(room.peers);Component example:
import { useRoom, useStatus, usePeers, useSignal } from './useRoom';
import { createBroadcastTransport } from '@zakkster/lite-room';
import { useMemo } from 'react';
function Cursor({ peer }) {
const x = useSignal(peer.cursor.x);
const y = useSignal(peer.cursor.y);
const name = useSignal(peer.identity.name);
return (
<div style={{ position: 'absolute', left: x, top: y }}>
<span>{name}</span>
</div>
);
}
export function Workspace({ selfId }) {
// Memoize the transport so useRoom doesn't tear it down on re-render.
const transport = useMemo(() => createBroadcastTransport({ channelName: 'doc-1' }), []);
const room = useRoom({ transport, selfId });
const status = useStatus(room);
const peers = usePeers(room);
return (
<div onMouseMove={(e) => room.self.setCursor(e.clientX, e.clientY, 1)}>
<header>{status} -- {peers.size} peer(s)</header>
{[...peers.values()].filter((p) => !p.self).map((p) => (
<Cursor key={p.id} peer={p} />
))}
</div>
);
}For CRDT reads (room.storage, room.sets, room.lists), the same useSignal pattern works on whichever signals each CRDT exposes (e.g. room.lists.get('todos').values is a signal of the sorted array).
Vue 3 recipe
Vue 3's ref() compares with Object.is and skips notifications when the same reference is assigned. Same problem as React, same shape of fix: use shallowRef with triggerRef to force-notify on every signal fire. For primitives, plain ref is fine (different value = different identity).
// useRoom.js
import { ref, shallowRef, triggerRef, onBeforeUnmount, markRaw } from 'vue';
import { createRoom } from '@zakkster/lite-room';
// For primitive-valued signals: number, string, boolean.
export function useSignal(sig) {
const r = ref(sig.peek());
const unsub = sig.subscribe((v) => { r.value = v; });
onBeforeUnmount(unsub);
return r;
}
// For object-valued signals (peers Map, identity, etc) where lite-room
// reuses the same reference. shallowRef + triggerRef bypasses Vue's
// identity check.
export function useObjectSignal(sig) {
const r = shallowRef(sig.peek());
const unsub = sig.subscribe((v) => { r.value = v; triggerRef(r); });
onBeforeUnmount(unsub);
return r;
}
// Owns the room's lifetime. markRaw prevents Vue from making the room
// itself reactive -- its internals are already lite-signal-driven and
// would deadlock against Vue's reactivity if proxied.
export function useRoom(opts) {
const room = markRaw(createRoom(opts));
onBeforeUnmount(() => room.close());
return room;
}
export const useStatus = (room) => useSignal(room.status);
export const usePeers = (room) => useObjectSignal(room.peers);Component example:
<script setup>
import { useRoom, useStatus, usePeers, useSignal } from './useRoom';
import { createBroadcastTransport } from '@zakkster/lite-room';
import { computed } from 'vue';
const props = defineProps({ selfId: Number });
const transport = createBroadcastTransport({ channelName: 'doc-1' });
const room = useRoom({ transport, selfId: props.selfId });
const status = useStatus(room);
const peers = usePeers(room);
const others = computed(() =>
[...peers.value.values()].filter((p) => !p.self),
);
function onMove(e) { room.self.setCursor(e.clientX, e.clientY, 1); }
</script>
<template>
<div @mousemove="onMove">
<header>{{ status }} -- {{ peers.size }} peer(s)</header>
<Cursor v-for="p in others" :key="p.id" :peer="p" />
</div>
</template><!-- Cursor.vue -->
<script setup>
import { useSignal } from './useRoom';
const props = defineProps({ peer: Object });
const x = useSignal(props.peer.cursor.x);
const y = useSignal(props.peer.cursor.y);
const name = useSignal(props.peer.identity.name);
</script>
<template>
<div :style="{ position: 'absolute', left: x + 'px', top: y + 'px' }">
<span>{{ name }}</span>
</div>
</template>Gotchas common to both
- Throttle cursor sends with the built-in option, not in your handler.
createRoom({ ..., cursorThrottleMs: 16 })runs the trailing-edge-final-value throttle inside lite-room (leading-edge-immediate + trailing-edge after idle), with zero per-call allocations. AsetTimeoutin youronMouseMovehandler will allocate on every move and miss the trailing-edge semantics. - The room reference must survive re-renders. In React, use
useRef(the recipe above) oruseStatewith a lazy initializer. Don'tconst room = createRoom(opts)inside the component body -- that recreates it on every render. In Vue 3,<script setup>runs once per component instance soconst room = useRoom(opts)is fine there. room.close()is single-shot and irreversible. A closed handle's final values stay inspectable (statusreads'closed'; storage/sets/lists/texts and last-known peer cursors keep their final state), but the room cannot be reopened or reused -- there is noopen()/reconnect by design. To rejoin, create a new room with a fresh transport subscription. So tear down only on unmount, not on every effect re-run.opts.transportmust outlive the room. If you create the transport withuseMemoin React, the deps must be primitives (channel name, not the transport itself). In Vue, declare the transport at module top-level if it's a singleton, or alongside the room inside<script setup>.- Selection ranges, identity, and storage are the same pattern. Every reactive output on
room.*andpeer.*is a lite-signal signal. If.peek()returns a primitive, useuseSignal. If it returns an object whose identity is stable across updates (Maps, the identity object), useuseObjectSignal.
Design decisions baked in here
Modular u32 Lamport. At 1M ops/sec a room would wrap in ~70 minutes -- well above any realistic write rate. On wrap, peer-id tiebreak still produces a deterministic order. If a deployment ever needs to exceed this, the version byte gives a clean upgrade path.
No vector clocks. LWW-Map and OR-Set need only total order (Lamport + peerId tiebreak); fractional-indexed lists are commutative; rich-text CRDT is deferred. Adding vector clocks would make every snapshot O(peers)-bloated.
Decoder reads u32 via bit-shift, f32 via scratchpad bit-pun. A
DataViewallocated per incoming packet costs ~50 bytes/packet -- at 10k cursor updates/sec, ~500 KB/s of nursery pressure. Node'swslibrary hands each onmessage aUint8Arraybacked by a freshArrayBuffer, so the pre-fix DataView-caching approach allocated on every packet. The current implementation uses pure i32 arithmetic for u32 (portable across any host) and a permanent 4-byteFloat32Array-aliased scratchpad for f32 (LE-only; verified at module load). Test 04-zero-gc proves zero minor GCs over 200k decodes against rotating freshArrayBuffers.Pooled
Message, mutated in place. Same pattern aslite-rollback.decoder.msgis the same reference every call. Unused fields keep stale values across decodes -- callers must branch onkindbefore reading kind-specific fields.Encoder owns no buffer pool. Transports already need their own send queues (the ring buffer below) -- forcing them through a library pool just adds a hop.
createEncoder(buf)wraps a caller-ownedUint8Arrayonce; encode methods write into it allocation-free.Send-queue ring buffer is the backpressure boundary. A single pre-allocated
Uint8Arrayper transport, capacity is a power of 2 (so cursor wrap is one bitwise AND, not a modulo), length-prefixed records. Producer (Transport.send) decides the backpressure policy bySEND_KIND:PRESENCE/AWARENESS-- drop on full buffer;recordsDroppedcounter incrementsSTORAGE-- escalate tostatus === 'backpressure'(room layer surfaces "saving..." to the user)CONTROL-- treat full buffer as fatal; transport disconnects
SEND_KINDvalues are integers, not strings -- no property hash on the enqueue hot path.writeBytes/readBytesuse no-wrap fast inner loops and a second loop only when the record crosses the ring boundary -- nosubarray()allocation per write. The drain path's only alloc is the subarray view the transport hands to the wire -- one per drain call, not per record.Loopback dispatch uses an indexed array, not a
Set.for...ofover aSetallocates an iterator object per call; over anArraywithfor (let i = 0; i < length; i++)it does not. Unsubscribe writesnullinto the slot (tombstone); the nextonMessagereuses the slot.
Tests
npm test # full suite, 586 tests
npm run test:gc # with --expose-gc for strict scavenge gates
npm run bench # cursor-throughput micro-benchSuites (28 files spanning protocol, presence, the four CRDTs, all transports, leader election, awareness, zero-GC, and the await helpers):
01-protocol_test.mjs-- shape of the protocol constants02-lamport_test.mjs-- clock semantics including u32 wraparound03-codec_test.mjs-- round-trip every implemented kind; bad version, truncation, unknown kind, pool semantics, non-zero byteOffset views (the indexing-correctness test)04-zero-gc_test.mjs-- heap-delta sanity + minor-GC count, including the rotating-fresh-ArrayBuffer scenario that mirrors Node'swsand is the audit-mandated regression guard05-transport_test.mjs-- send-queue framing, wrap behavior, FIFO order, per-kind backpressure escalation, loopback delivery, status transitions06-26-- room core, presence (cursor/selection/identity), storage LWW-Map, OR-Set, List, RGA text, awareness, leader election, peer admission, and peer-leave coverage27-presence-node-reclaim_test.mjs-- proves per-peer presence nodes are returned to the lite-signal registry on PEER_LEAVE and on eviction, so unbounded peer churn never exhausts the node pool (4000-peer churn stays flat; reclamation is safe under a live consumer effect)28-await-helpers_test.mjs-- the 1.1ready/waitForPeerssettlement matrix (resolve, transition, close-before-satisfied, already-closed sync-reject, abort, pre-aborted signal, timeout, invalidn), the re-exports, and a leak probe asserting the internal effect is reclaimed to baseline across 600 resolve/timeout/abort cycles
Observed numbers (illustrative, single machine)
node --expose-gc bench/cursor-throughput.mjs on this dev box. Codec / queue / loopback rows at 2M iterations; BC and DataChannel rows at 200k (their per-op cost is dominated by the cross-process or microtask plumbing, so a smaller sample keeps the bench responsive):
| scenario | throughput | per op | minor GCs | major GCs | | ---------------------------------------------- | ---------------:| --------:| -----------:| ---------:| | encode/decode (stable buffer) | 15.0M ops/s | 67 ns | 0 | 0 | | decode-only (rotating fresh ArrayBuffers, ws) | 23.9M ops/s | 42 ns | 0 | 0 | | send-queue enqueue + drain | 5.8M ops/s | 171 ns | 0 | 0 | | room end-to-end (loopback, r2 -> r1) | 1.7M ops/s | 581 ns | ~1 / 22k | 0 | | BroadcastChannel (rA -> rB, multi-tab) | 213k ops/s | 4.7 us | ~1 / 3.2k | 0 | | DataChannel (fake pair, rA -> rB) | 321k ops/s | 3.1 us | ~1 / 11.8k | 0 |
The first three rows are strictly zero-GC across both the v0.0.2 audit (decoder f32 scratchpad killing the per-packet DataView allocation) and the v0.0.3 audit (queue subarray removal, integer SEND_KIND, bitmask cursors).
The room-loopback row integrates send + microtask drain + decode + batched signal apply. v0.1.1 closed the remote-scratch retention bug (Peer references were pinned across batch boundaries). At ~1 GC per 22k updates and a realistic 30 Hz cursor rate, that's one minor scavenge every ~12 minutes -- acceptable; further reduction tracked as an audit-round-4 target.
The BC row is the cross-tab cursor flow. ~1 GC per 3,200 updates = one scavenge every ~107 seconds at 30 Hz. The cost is dominated by structured-clone fan-out in the BC infrastructure (sender pays ~80 bytes of bytes.slice(0, length) per packet; receiver's allocations come from BC itself, not user code).
The DataChannel row uses createDataChannelPair(), an in-process channel-like pair that mimics RTCDataChannel via queueMicrotask + Uint8Array copy. ~1 GC per 11,800 updates at the lite-room layer. Production WebRTC adds DTLS-SCTP, UDP, and native networking costs NOT reflected here -- treat this row as a measurement of the transport-and-room layers' contribution, not as a WebRTC throughput claim.
Numbers depend on hardware and Node version. Treat as smoke signals, not published claims.
Roadmap
- M0 -- wire protocol primitives. Shipped.
- M1.0 -- transport interface + loopback transport + send-queue ring buffer with backpressure. Shipped.
- M1.1 -- room core:
createRoom, peer registry as a signal, per-peer cursor signals, status mirroring, lifecycle. Shipped. - M1.2 -- BroadcastChannel transport for in-browser cross-tab cursor flow. Shipped.
- M2.0 -- DataChannel (WebRTC) transport: cross-browser cursor flow over RTCDataChannel, with the signaling story left to the app. Shipped.
- M2.1 -- Cross-tab leader-election layer (
createLeaderTransport+createSimpleLeaderElector) that wraps BC + DataChannel so only the leader holds the upstream. THE headline differentiator. Shipped. - M3 -- Presence primitives as first-class: selection (
setSelection/peer.selection), identity (setIdentity/peer.identitywith username + color), opt-in cursor/selection throttling with leading-edge + trailing-edge semantics. Shipped. - M4.0 -- Storage CRDT: LWW-Map with snapshot-on-join. JSON-serializable values, deterministic conflict resolution by (Lamport, peerId), tombstones for delete-aware merging. Shipped (in this slice).
- M4.1 -- Storage CRDT: OR-Set (Observed-Remove Set). Concurrent ADDs commute (multi-tag); ADDs with unobserved tags survive concurrent REMOVEs. Per-name proxies via
room.sets.get(name). Shipped. - M4.2 -- Storage CRDT: fractional-indexed List. f64 position + peerId tiebreak; push/unshift/insertAt/removeAt; ~52 bits of midpoint precision; concurrent inserts at same logical position both survive deterministically. Shipped (in this slice).
- M4.3 -- Storage CRDT: text RGA for collaborative text editing.
- M5 -- Awareness: heartbeat-driven peer discovery + idle-peer eviction. Shipped (in this slice).
- M6 -- Auth via
@zakkster/lite-auth. Concrete integration points: deriveselfIdfromauth.session.peek()?.user?.id; passauth.token.peek()into the upstream factory's handshake; gate CRDT mutations onauth.session.peek()?.user?.role; close the room onauth.signOut. Cross-tab sign-out is automatic because lite-auth uses BroadcastChannel. - M7.0 -- Presence replay via
@zakkster/lite-history-buffer. Record each peer's cursor into aHistoryBuffer({ capacity: N, stride: 2 })and sample at any past timestamp for cursor trails, scrubbable timelines, or debugging. - M7.1 -- CRDT undo / redo. Distinct from M7.0: needs inverse operations per CRDT type (SET -> previous-SET-or-DELETE for LWW-Map; ADD -> REMOVE for OR-Set with the observed tag; INSERT -> REMOVE for List). Concurrent-edit-aware undo is the hard part and the multi-session piece.
- M8 -- Comments / threads (pro tier).
- M9 -- Reference server (Node/Bun + Cloudflare Workers).
- M10 -- Devtools integration via
lite-devtools/lite-studio.
License
MIT (c) Zahary Shinikchiev
Part of the @zakkster zero-GC stack:
lite-signal*lite-time*lite-store*lite-channel*lite-persist*lite-auth*lite-history-buffer*lite-rollback
