@tutti-os/ws-client
v0.2.0
Published
Thin browser WebSocket V2 client for the tsh-websocket realtime gateway.
Keywords
Readme
@tutti-os/ws-client
Thin browser client for the tsh-websocket realtime gateway. It owns the parts every consumer would otherwise re-implement and get wrong — connect, app-level heartbeat, reconnect with backoff, and subscription replay on reconnect — and stays out of business semantics.
Wire contract: see ../../docs/PROTOCOL.md (V2).
Uplink control messages use { action, data }. Downlink business messages use
the strict V2 envelope and are dispatched by event_type. Business event names
and payloads are opaque to this client, so adding a business event does not
require an SDK release.
Install
pnpm add @tutti-os/ws-clientSupported Web model
The browser-native WebSocket cannot set handshake headers, so auth relies on
the same-site session_id cookie. Deploy the page and the gateway on the same
site. Create one client per page. When the signed-in account changes, reload the
page so the old socket is closed and the new page connects with the new cookie.
deviceId is a stable routing dimension, not a credential. Generate one per
page/client and keep it unchanged for that client's physical reconnects. Do not
share one ID through localStorage: concurrent tabs with the same account and
ID represent the same server-side slot and will replace each other's socket.
import { createWsClient } from "@tutti-os/ws-client";
const endpoint = new URL("wss://ws.tutti.sh/");
// Created once while this page initializes; the client reuses the URL on every
// reconnect. A reload (including an account switch) creates a fresh page slot.
const deviceId = `web-page:${crypto.randomUUID()}`;
endpoint.searchParams.set("deviceId", deviceId);
const ws = createWsClient({
url: endpoint.toString()
});
// Account/user-scoped events do not require a room subscription. The gateway
// resolves the user from the authenticated session cookie.
ws.on("user.notification.changed", () => refreshUnreadCount());
ws.onError((err) => console.warn("ws error", err.code, err.message));
await ws.connect();
// `open` keeps its transport meaning for compatibility. Use `ready` when
// deciding whether Account-scoped realtime delivery can replace HTTP fallback.
ws.onReadyChange((ready) => setAccountRealtimeHealthy(ready));
// Room-scoped events require an ACKed room registration.
const room = ws.acquireRoom("room_123");
room.onReadyChange((ready) => setRoomRealtimeHealthy("room_123", ready));
ws.on("room.presence", (payload) => renderPresence(payload));
// Release when the page no longer needs room-scoped delivery.
room.release();Room acquisition is reference counted inside a page: multiple components can acquire the same room, and the SDK removes it from the desired set only after the final handle is released. A page can hold at most 45 distinct rooms, matching the server-side device limit.
Delivery is best effort. A received event is validated and deduplicated, but the WebSocket channel is not durable storage and does not replace the business API's normal reconciliation/read path.
Node and standalone clients are outside the supported 0.1.x surface; use the
Go client for those environments.
API
createWsClient(options) → WsClient
| option | default | notes |
|---|---|---|
| url | — | ws:/wss: V2 endpoint including a valid deviceId query |
| init | — | sent as {action:"init", data:init} on every (re)connect |
| heartbeat | { intervalMs: 180000, timeoutMs: 360000 } | ping cadence / pong deadline |
| reconnect | { initialDelayMs: 1000, maxDelayMs: 15000, factor: 1.5 } | false to disable |
| maxQueuedMessages | 100 | maximum business frames buffered before V2 is ready |
WsClient:
connect(): Promise<void>— concurrent calls share one attempt; resolves on first open, including after automatic retries.send(action, data?)— uplink; buffered until init+replay, with a 30 KiB frame limit and bounded outbox.on(type, handler) => unsubscribe— downlink dispatch bytype.onError(handler) => unsubscribe— reservedtype:"error"frames.onStateChange(handler) => unsubscribe—idle|connecting|open|reconnecting|closed.ready/onReadyChange(handler)— Account-channel readiness afterconnection.ready; this does not redefineopen.acquireRoom(roomId) => { subscriptionId, ready, onReadyChange, release }— reference-counts one desired room; Room readiness follows the exact ACKed registration.refreshSubscriptions()— reauthorizes the complete desired room set.onSubscriptionsAck(handler) => unsubscribe— observes authoritative room subscription ACKs.replacePresenceSubscriptions(subscriptions)— atomically replaces up to 100 observed users; stable tokens are owned by the caller/coordinator.onPresenceSubscriptionsAck(handler) => unsubscribe— observes the compact Presence ACK used to trigger the initial BatchGet snapshot.presenceSessionEpoch— process-scoped epoch replayed across physical reconnects.close()— stop reconnect, clear timers, drop the socket.
What this client deliberately does NOT do
- No business
type/actionenumeration — keep them in your app layer. - No store/UI binding — wrap it in your own service if you need that.
- No account-switch API — reload the page after the session account changes.
- No supported Node/standalone adapter in
0.1.x.
Adding any of the above into this package breaks its transport-only contract; that is what keeps "upper layers onboard without touching the client" true.
Dev
pnpm test # node --test, no network
pnpm typecheck
pnpm build # emits dist/