@zakkster/lite-ws
v1.0.0
Published
Reactive raw-WebSocket primitive: coarse lifecycle signals (status/reconnectAttempts/latency), reconnect with exponential backoff + jitter, an outbound buffer, a transport pipe that bypasses signals (batched once per frame), and a subscribe(topic) async-g
Maintainers
Readme
@zakkster/lite-ws
Reactive raw-WebSocket primitive — lifecycle manager and transport pipe.
A WebSocket helper that manages the connection reactively (reconnect, exponential
backoff with jitter, heartbeat, outbound buffering) and treats incoming data as a
transport pipe, not a per-message signal. The reactive-socket trap is firing
signal.set() on every frame of a 120Hz feed — lite-ws avoids it by design.
npm install @zakkster/lite-wsPeer:
@zakkster/lite-signal(the coarse lifecycle signals). ESM only. MIT. Optional wiring (not dependencies):@zakkster/lite-rafto drivepoll()once per frame;@zakkster/lite-streamto consumesubscribe()generators.Raw WebSocket only — no Socket.IO. Twitch EventSub / IRC / PubSub are all raw WS; bundling a 30KB+ client with its own reconnect/ack/room lifecycle would negate the zero-dependency, zero-GC point and duplicate the lifecycle this module owns.
The trap it avoids
// THE ENEMY OF 120FPS
socket.onmessage = (e) => latestSignal.set(JSON.parse(e.data));
// ^ allocates + reconciles the whole graph (lite-map, CRDT) on every network ticklite-ws splits the two concerns that the naive version conflates:
- Lifecycle is coarse signals —
status(),reconnectAttempts(),latency(). They change rarely, so they're a perfect reactive fit: render an "Offline" banner or disable Save with no per-message graph churn. - Data is a pipe —
onMessage(data)runs per message (hand the raw frame to a CRDT/buffer in place; nothing allocates on this side).poll()coalesces every message since the last frame into oneonBatch().
Lifecycle
import { createSocket } from "@zakkster/lite-ws";
import { effect } from "@zakkster/lite-signal";
const socket = createSocket("wss://eventsub.wss.twitch.tv/ws", {
backoff: { min: 250, max: 10000, factor: 2, jitter: 0.5 },
});
effect(() => { banner.hidden = socket.status() === "open"; });
effect(() => { saveBtn.disabled = !socket.isOpen(); });status() moves through "connecting" → "open" → "reconnecting" → "closed", with
reconnects following exponential backoff with jitter; close() is intentional and
does not reconnect. Sends issued while down are buffered (bounded, drop-oldest)
and flushed in order on the next open.
How data flows
flowchart LR
WS["WebSocket<br>(120 Hz)"] -->|onmessage| PIPE["onMessage(data)<br>-> CRDT / buffer, in place"]
PIPE --> CNT["+1 (no signal)"]
F["frame tick<br>(lite-raf)"] -->|poll| B{"any since last frame?"}
CNT --> B
B -->|yes| ONE["onBatch(n) -- ONE coarse UI bump"]
B -->|no| SKIP["skip"]
LC["status / attempts / latency"] -.->|change rarely| UI["reactive UI"]const socket = createSocket(url, {
onMessage: (buf) => crdt.applySyncMessage(buf), // per message, in place
onBatch: () => room.notifyUI(), // once per frame
});
import { frameDelta } from "@zakkster/lite-raf";
frameDelta.subscribe(socket.poll); // drive the batch once per frameThe firehose never touches the reactive graph; the UI wakes once per frame no matter how many messages arrived.
Event streams: subscribe(topic)
For specific lower-rate feeds, subscribe() returns an async generator (the
lite-stream bridge) with an explicit drop policy — because a socket is
push-only and you can't back-pressure the server:
const socket = createSocket(url, { topicOf: (m) => m.type });
for await (const tx of socket.subscribe("order", { policy: "ring", capacity: 512 })) {
render(tx); // if you lag, oldest are dropped — bounded memory
}"ring"(default) — bounded queue, drop oldest."latest"— keep only the newest."all"— unbounded (memory risk if the consumer lags; use deliberately).
Returning/breaking the generator unsubscribes it.
Latency is application-level, by necessity
The browser WebSocket API handles protocol ping/pong frames invisibly — JS can't
send or observe them. So heartbeat sends a payload your server echoes, and
isPong identifies it:
const socket = createSocket(url, {
heartbeat: {
interval: 15000,
timeout: 5000, // no pong in time -> force reconnect
isPong: (m) => (m.type === "pong" ? m.t : false), // return the echoed send-timestamp
},
});
effect(() => { pingLabel.textContent = `${socket.latency()} ms`; }); // smoothed RTT, -1 if unknownZero-GC
The message firehose is onMessage(data) plus a counter increment — no allocation
on this side, and the lifecycle signals do not fire per message. Verified:
1,000 messages leave the engine pool counters flat and a status() effect un-re-run.
(subscribe() queues do allocate on push — use them for event streams, not the CRDT
firehose.)
Testability
Everything is injectable — the WebSocket constructor, timers, clock, RNG — so the
entire state machine (connect/open, backoff geometry, reconnect, heartbeat RTT and
timeout, the batch coalesce, the stream drop policies) is tested headlessly with a
mock socket and a fake clock. No browser needed.
API
createSocket(url, opts?): Socket
createSocketFactory(registry): { createSocket } // non-default registry
interface Socket {
status(): "connecting" | "open" | "reconnecting" | "closed" // reactive
isOpen(): boolean // reactive
reconnectAttempts(): number // reactive
latency(): number // reactive, -1 if unknown
send(data): boolean // sent now, or buffered (bounded, drop-oldest)
poll(): number // coalesce -> one onBatch(); drive once per frame
subscribe(topic?, { policy?: "ring"|"latest"|"all", capacity? }): AsyncGenerator
connect(): void
close(code?, reason?): void // intentional — no reconnect
readonly raw // underlying WebSocket (null while down)
dispose(): void
}Created inside an owning scope it auto-disposes with it (onCleanup); otherwise call
dispose().
License
MIT (c) 2026 Zahary Shinikchiev <[email protected]>
