SDKs · JavaScript
JavaScript SDK
@folkways/realtime is the client for the Folkways Realtime Transport Engine (RTE v1). One call connects you; transport selection, heartbeats, token refresh, reconnects, resubscribing and cursors are handled for you.
Version 0.1.0 · MIT · TypeScript types included
Overview
The SDK speaks the RTE binary protocol (FlatBuffers) over a WebSocket and falls back to HTTP long-polling when WebSockets are unavailable. It runs in browsers, Node 20+, Deno, Bun and React Native, and its only runtime dependency is flatbuffers.
What it does for you:
- Picks a transport and keeps it alive: heartbeats, reconnects with exponential backoff and jitter, and the node's
retry_after_ms,DrainingandBackpressuresignals. - Refreshes tokens before they expire.
- Resubscribes after every reconnect from the last cursor, reports gaps and drops duplicates.
- Gives every publish an idempotency key and resends in-flight publishes after a reconnect, so each publish is applied at most once. Retries rate-limited publishes and queues publishes made while offline.
- Runs your handlers in order, one at a time, and can persist the last processed cursor.
Installation
npm install @folkways/realtime
# or
pnpm add @folkways/realtimeThe package ships ESM, CommonJS, type declarations and a browser bundle. To load it with a script tag, use the IIFE build, which defines a global Folkways:
<script src="https://unpkg.com/@folkways/realtime/dist/index.global.js"></script>
<script>
Folkways.connect({ url: 'https://rte.example.com', token }).then((client) => {
// …
});
</script>WebSocket. On Node 20, pass one from the ws package: webSocket: (await import('ws')).WebSocket.Quick start
import { Folkways } from '@folkways/realtime';
const client = await Folkways.connect({
url: 'https://rte.example.com', // wss://…/v1/ws and https://…/v1/*
token: () => fetch('/api/rte-token').then((r) => r.text()), // called again to refresh
});
// Durable topic: ordered, replayable
const lobby = client.topic('chat/lobby');
const sub = lobby.subscribe<{ text: string }>((msg) => {
console.log(msg.publisher, msg.data.text, msg.id);
}, { from: { last: 50 } });
await sub.ready;
const receipt = await lobby.publish({ text: 'hello' }); // { id, cursor, duplicate, … }
// LastValue topic: latest value per key
const ride = client.topic('rides/42/location');
await ride.set('driver-7', { lat: 59.43, lng: 24.75 }, { ttlMs: 30_000 });
const view = ride.subscribe();
view.on('snapshot', () => render(view.values)); // Map<key, Message>
view.on('message', () => render(view.values));
await ride.delete('driver-7');
// Ephemeral topic: fire-and-forget signals
client.topic('typing/lobby').signal({ typing: true });
await client.close();Connecting
| Function | Returns | Behaviour |
|---|---|---|
Folkways.connect(options) | Promise<RealtimeClient> | Resolves when connected. Rejects with a RealtimeError on a fatal failure (bad token, suspended segment, no usable transport), or with Timeout after connectTimeoutMs. A rejected client is closed. |
Folkways.createClient(options) | RealtimeClient | Returns at once and connects in the background (unless autoConnect: false; then call client.connect()). Publishes made before the connection is up are queued. |
Endpoints
Give a base url and the SDK derives both endpoints: https://rte.example.com becomes wss://rte.example.com/v1/ws for WebSocket and https://rte.example.com/v1/publish / /v1/poll for HTTP. Use endpoints when they live on different hosts or ports; each value overrides what url gives.
Folkways.connect({
endpoints: { websocket: 'ws://127.0.0.1:8080/v1/ws', http: 'http://127.0.0.1:3000' },
token,
});The client
| Member | Meaning |
|---|---|
state | idle, connecting, connected, reconnecting or closed |
transport | 'websocket', 'http' or null while not connected |
identity | The token's sub, as confirmed by the node |
sessionId | The current session id |
limits | Limits announced by the node (max payload, headers, frame size, heartbeat) |
tokenExpiresAt | Expiry of the current token (Unix ms) |
error | Why the client closed, when it did |
serverNow() | Date.now() corrected by the clock offset measured to the node |
topic(name) | The cached Topic handle; throws InvalidTopic for a malformed name |
publish(topic, data, opts?) | Shortcut for client.topic(topic).publish(…) |
subscribe(topic, handler?, opts?) | Shortcut for client.topic(topic).subscribe(…) |
connect() | Starts, or waits for, the connection |
close() | Closes for good. Queued publishes reject with ClientClosed; every subscription ends |
Client events
client.on(event, fn) returns a function that removes the listener; once and off are also available.
| Event | Payload |
|---|---|
state | (state, previous) |
connected | { transport, identity?, sessionId? } |
disconnected | { code, reason, retryAfterMs? } |
reconnecting | { attempt, delayMs, error } for the next attempt |
error | The client gave up on a fatal RealtimeError and is now closed |
tokenExpiring | Expiry time (Unix ms) announced by the node |
tokenRefreshed | New expiry time, when known |
const off = client.on('state', (state, previous) => console.log(previous, '→', state));
client.on('error', (err) => console.error('realtime client closed:', err.codeName, err.message));Authentication
Your backend mints short-lived RTE tokens (JWTs) for a segment and an identity (sub); the token also carries which topics the identity may publish and subscribe to. Pass either a string or a provider function:
const client = await Folkways.connect({
url: 'https://rte.example.com',
// ctx.reason is 'connect' | 'refresh' | 'expired' | 'invalid'
token: async ({ reason }) => {
const res = await fetch('/api/rte-token', { credentials: 'include' });
return res.text();
},
});- WebSocket: the node announces expiry 60 s ahead; the SDK calls your provider and re-authenticates on the open connection, retrying with backoff until the deadline.
- HTTP: the token goes in
Authorization: Beareron every request and is refreshed when itsexpis within 60 s, and once more on a401. - On
AuthInvalidthe provider is asked for a fresh token once; a second failure is fatal. - A static string token cannot be refreshed: when it expires the client closes with
AuthExpired. Use a provider in production. - A refreshed token must be for the same segment and identity. If it grants fewer rights, affected subscriptions end with an
endedevent (Forbidden).
Topics and delivery classes
A topic is named by /-separated levels, for example chat/lobby or rides/42/location. Names are ASCII, use the characters A-Z a-z 0-9 . _ ~ : @ = + - in each level, have at most 16 levels and 255 bytes, and cannot start with $. The SDK validates names locally.
A topic's delivery class is set by your segment's topic rules on the node, not by the client:
| Class | Stored | Delivery | A new subscriber gets | Use with |
|---|---|---|---|---|
| Durable | A log, capped by size and age | In order, replayable | Replay from the position it asks for | publish |
| LastValue | The latest event per key, until replaced, expired (ttlMs) or deleted | Latest wins | A snapshot of the current values | set, delete |
| Ephemeral | The latest signal per sender, for a few seconds | Best effort | A snapshot of current signals | signal |
To catch a misconfigured topic early, pass expect: 'durable' | 'last-value' | 'ephemeral' when publishing; the node rejects a mismatch with ClassMismatch. Once a subscription is confirmed, sub.deliveryClass tells you the class.
Publishing
const receipt = await client.topic('chat/lobby').publish(
{ text: 'hello' },
{ headers: { lang: 'en' } },
);
// receipt: { topic, topicId, cursor, id: 'epoch:seq', level: 'accepted', duplicate, serverTime }Every publish settles exactly once: with a receipt, a RealtimeError, or Timeout after publishTimeoutMs (30 s by default, retries included).
Payload encoding
| data | Sent as | Subscribers get in msg.data |
|---|---|---|
Uint8Array, ArrayBuffer, typed array | bytes | Uint8Array |
string | text | string |
| Anything else | json (JSON.stringify) | The parsed value |
Set contentType to label the payload yourself: json, text, bytes, flatbuffers, protobuf, msgpack, cbor, or an application-defined number ≥ 1024. With an explicit type, data must already be bytes (or a string for text/json). The node never parses payloads.
Publish options
| Option | Meaning |
|---|---|
contentType | Payload type; inferred from data when omitted |
headers | Record<string, string> passed through to subscribers (up to limits.maxHeaders, 16 by default) |
idempotencyKey | 32 hex digits or a UUID. Generated for you; set it yourself to retry the same logical publish across page reloads or processes |
expect | Assert the topic's class (ClassMismatch otherwise) |
key | LastValue item key (prefer topic.set) |
ttlMs | LastValue: how long the value lives |
clientTime | Your clock for the event (Unix ms); defaults to Date.now() |
signal | An AbortSignal to stop waiting for the receipt (the node may still apply the publish) |
Delivery guarantees
- At most once. A publish keeps its idempotency key and content across retries. If the node already accepted it, it answers with the original receipt and
duplicate: true, which is still a success. Reusing a key with different content fails withIdempotencyConflict. - Retries.
RateLimited,QuotaExceeded,InternalandOverloadedare retried up topublishRetries(3) times, waiting at least the node'sretry_after_ms. - Offline queue. Publishes made while disconnected are queued (up to
offlineQueue.maxSize, 1000 by default;QueueFullbeyond) and sent on reconnect. Publishes in flight when a connection drops are resent with the same key. SetofflineQueue: falseto reject withNotConnectedinstead. - Local validation. Bad topic names, oversized payloads and too many headers reject immediately with
InvalidTopic,PayloadTooLargeorInvalidArgument(markedlocal: true), without a round-trip.
LastValue topics
LastValue topics keep the latest value per key: presence, locations, settings, a scoreboard. Write with set, remove with delete, and read the live map from a subscription's values.
const ride = client.topic('rides/42/location');
await ride.set('driver-7', { lat: 59.43, lng: 24.75 }, { ttlMs: 30_000 }); // expires unless refreshed
await ride.delete('driver-7'); // publishes a tombstone
const view = ride.subscribe<{ lat: number; lng: number }>();
view.on('snapshot', (all) => console.log('current values:', all.length)); // on subscribe and after reconnects
view.on('message', (m) => console.log(m.key, m.deleted ? 'removed' : m.data));
// view.values: Map<key, Message> of the keys that are currently liveEphemeral signals
Ephemeral topics carry short-lived signals such as typing indicators and live cursors. signal() is fire-and-forget: it returns nothing, is dropped while offline, and failures are only logged.
const typing = client.topic('typing/lobby');
typing.signal({ typing: true });
const sub = typing.subscribe<{ typing: boolean }>();
// sub.values: Map<publisher, Message> of signals younger than signalTtlMs (5 s by default)
const whoIsTyping = () =>
[...sub.values.values()]
.filter((m) => m.data.typing && m.publisher !== client.identity)
.map((m) => m.publisher);publish() on an Ephemeral topic still waits for an acknowledgement. Use signal() when you do not need one.Subscribing
const sub = client.topic('chat/lobby').subscribe<{ text: string }>(
async (msg) => {
await saveToDb(msg); // the next event waits for this promise
},
{
from: 'latest', // 'latest' | 'oldest' | { after: cursor } | { last: n }
onGap: 'from-oldest', // 'from-oldest' | 'from-latest' | 'fail'
},
);
await sub.ready; // the node confirmed the subscription (rejects if it refused it)
sub.on('gap', (g) => backfill(g.requested, g.oldest));
sub.on('error', (err) => console.error(err));
await sub.unsubscribe();Subscribe options
| Option | Default | Meaning |
|---|---|---|
from | 'latest' | Where to start when there is no stored cursor: latest (live only), oldest (everything retained), { after: cursor } (a cursor or an "epoch:seq" string), { last: n } (the last n events) |
onGap | 'from-oldest' | What to do when the requested cursor is no longer retained: continue from the oldest retained event, jump to latest, or fail |
snapshot | true | State topics: deliver the current values first |
resume | true | Start after the cursor in the cursorStore, when there is one |
The subscription
| Member | Meaning |
|---|---|
ready | Resolves on the first confirmation; rejects on an error or a gap with onGap: 'fail' |
status | pending, active, resyncing (reconnecting), failed, ended or closed |
deliveryClass | durable, last-value or ephemeral, once known |
cursor | The last event this handle finished handling |
values | LastValue: live values by key. Ephemeral: recent signals by publisher. Durable: empty |
unsubscribe() | Stops this handle |
| Event | When |
|---|---|
message | Every event, after the handler ran |
snapshot | The full current state of a LastValue or Ephemeral topic (on subscribe and after reconnects) |
gap | Events were missed: { reason, requested, oldest, latest, resumed } |
error | The subscription failed, or your handler threw |
ended | The node ended the subscription (rights narrowed) or the client closed |
status | The status changed |
The message
interface Message<T = unknown> {
topic: string;
topicId: bigint;
cursor: Cursor; // position in the topic
id: string; // "epoch:seq": unique per topic, usable as a React key
data: T; // decoded by content type
payload: Uint8Array; // raw bytes (do not modify)
contentType: number;
publisher: string; // verified identity of the sender
key?: string; // LastValue key (Ephemeral: the publisher)
deleted: boolean; // LastValue delete
headers: Record<string, string>;
serverTime: number; // when the node accepted it (informational, not for ordering)
clientTime?: number; // the publisher's clock
replayed: boolean; // from retained history rather than live
fromSnapshot: boolean; // part of a state snapshot
}Ordering and shared subscriptions
- Handlers run in order, one at a time, per topic. Return a promise to hold the next event until it settles. The cursor advances only after the handler finishes.
- Several
subscribe()calls on one topic share a single subscription on the wire. The first call's options apply; later handles join live and, on state topics, immediately receive the current values as asnapshot. The wire subscription ends when the last handle unsubscribes. This also keeps React StrictMode double-mounts cheap. - Duplicates are dropped, so a reconnect never delivers the same event twice.
Async iteration
for await (const msg of client.topic('jobs/new').subscribe()) {
await handle(msg);
} // ends when the subscription fails, ends or is closedCursors, resume and gaps
Every event has a cursor, (epoch, seq), written as "epoch:seq". The SDK remembers the last cursor each topic processed and resumes after it on every reconnect. By default that memory lives only as long as the client. To resume across page reloads, persist it:
import { Folkways, localStorageCursorStore } from '@folkways/realtime';
const client = await Folkways.connect({
url,
token,
cursorStore: localStorageCursorStore(), // keys are prefixed with 'folkways:cursor'
});Or implement your own store, for example in IndexedDB or on your server:
interface CursorStore {
get(topic: string): Cursor | undefined | Promise<Cursor | undefined>;
set(topic: string, cursor: Cursor): void | Promise<void>;
}formatCursor, parseCursor and isAfter convert and compare cursors. Both parts are 64-bit and carried as bigint.
Gaps
Durable logs are capped, so a client that was away long enough may ask for events the node no longer holds. The subscription then emits gap with the cursor you asked for (requested) and the oldest one still retained (oldest); fetch what lies between them from your own backend if you need it. onGap decides whether to continue from oldest, jump to latest, or fail the subscription.
Connection lifecycle
connect() welcome connection lost
idle ──────────► connecting ──────────► connected ──────────────────► reconnecting
│ ▲ ▲ │
│ └ retryable failure ┴────── backoff elapsed ───────┘
▼ ▼
closed ◄──── fatal error / close() / maxAttempts ──── closed- Reconnects use exponential backoff with full jitter (500 ms up to 30 s by default), never sooner than the node's
retry_after_ms. In browsers, theonlineevent skips the remaining delay. Setreconnect: falseto give up on the first disconnect. - Fatal errors close the client and fire
error: an invalid token (twice in a row), an expired static token, a suspended segment, a protocol error,maxAttemptsreached, orclose(). - Draining (the node is shutting down) and Backpressure reconnect and resume from cursors; nothing is lost.
- Heartbeats: on WebSocket the SDK pings every
limits.heartbeatMs; if nothing arrives for that long pluspongTimeoutMs, the connection is treated as dead and replaced. - Fallback: transports are tried in the order of
transports(default['websocket', 'http']), skipping any the runtime lacks. If the WebSocket fails to openfallbackAfterFailures(2) times in a row, the client switches to HTTP long-polling for the rest of its life. An authentication error never triggers fallback.
Over HTTP, the SDK long-polls all subscribed topics together (25 s per poll by default) and publishes with plain POST requests. The API and delivery semantics are the same on both transports.
Errors
Every failure is a RealtimeError (use isRealtimeError(e) to narrow):
import { Code, isRealtimeError } from '@folkways/realtime';
try {
await client.topic('chat/lobby').publish({ text: 'hi' });
} catch (e) {
if (isRealtimeError(e) && e.code === Code.Forbidden) {
showError('You cannot post in this room');
}
}
// RealtimeError fields:
// code: number RTE code, or an SDK-local code ≥ 1000 (see Code)
// codeName: string 'Forbidden', 'RateLimited', …
// retry: 'never' | 'backoff' | 'after-reauth' | 'reconnect'
// retryAfterMs?: number
// topic?: string; topicId?: bigint
// local: boolean raised by the SDK without a server round-trip| Codes | Meaning |
|---|---|
BadFrame, InvalidArgument, UnsupportedVersion, FrameTooLarge, HelloRequired (100–106) | Protocol or argument errors; not retried |
AuthRequired, AuthInvalid, AuthExpired (200–202) | Token problems; the SDK asks your provider for a new token |
Forbidden (203) | The token does not grant this topic |
Suspended (204) | The segment is suspended; fatal |
RateLimited, QuotaExceeded (300–301) | Retried with backoff |
PayloadTooLarge (302) | Payload over limits.maxPayloadBytes |
Backpressure (303) | The client is reading too slowly; reconnect and resume |
InvalidTopic, ClassMismatch, InvalidCursor, IdempotencyConflict (400–404) | Topic and publish errors; not retried |
Internal, Unavailable, Draining, Overloaded, Timeout (500–504) | Server-side or timing; retried or reconnected |
ClientClosed (1000), QueueFull (1001), TransportUnavailable (1002), Aborted (1003), NotConnected (1004) | Raised locally by the SDK |
codeInfo(code) returns the name, HTTP status, retry class and whether the code closes the connection.
Configuration reference
| Option | Default | Meaning |
|---|---|---|
url | – | Base URL; gives the WebSocket and HTTP endpoints |
endpoints | from url | { websocket?, http? }; each overrides url |
token | required | A token string, or a provider function |
transports | ['websocket', 'http'] | Preference order |
clientName | folkways-js/0.1.0 | Reported to the node for troubleshooting |
autoConnect | true | createClient only |
connectTimeoutMs | 15000 | connect() rejects with Timeout after this (the client keeps trying); 0 waits forever |
openTimeoutMs | 10000 | One connection attempt |
requestTimeoutMs | 10000 | Subscribe, unsubscribe and re-auth replies |
publishTimeoutMs | 30000 | Whole life of a publish, retries included |
publishRetries | 3 | Retries for rate-limit and server errors |
reconnect | { initialDelayMs: 500, maxDelayMs: 30000, maxAttempts: Infinity } | Or false |
fallbackAfterFailures | 2 | WebSocket failures before switching to HTTP |
pongTimeoutMs | 10000 | Extra silence tolerated after a missed heartbeat |
cursorAckIntervalMs | 5000 | How often processed cursors are reported to the node; 0 disables |
offlineQueue | { maxSize: 1000 } | Or false to reject publishes while offline |
cursorStore | memoryCursorStore() | Where processed cursors are kept |
signalTtlMs | 5000 | How long an Ephemeral signal stays in values |
longPoll | { waitMs: 25000, maxEvents: 100, emptyStateRecheckMs: 2000 } | HTTP long-poll tuning |
webSocket | globalThis.WebSocket | WebSocket constructor (e.g. ws on Node 20) |
fetch | globalThis.fetch | fetch implementation |
logger | silent | { debug, info, warn, error }; try consoleLogger('debug') |
transportOverrides | – | { stream?, request? } to replace the transports (tests, custom runtimes) |
Using it with React
Create one client for the app and subscribe in effects. Unsubscribing in the cleanup is cheap because handles on the same topic share one wire subscription.
import { useEffect, useState } from 'react';
import { type Message, type RealtimeClient } from '@folkways/realtime';
function Chat({ client }: { client: RealtimeClient }) {
const [messages, setMessages] = useState<Message<{ text: string }>[]>([]);
const [state, setState] = useState(client.state);
useEffect(() => client.on('state', setState), [client]);
useEffect(() => {
const sub = client
.topic('chat/lobby')
.subscribe<{ text: string }>((m) => setMessages((prev) => [...prev, m]), { from: { last: 50 } });
return () => void sub.unsubscribe();
}, [client]);
return (
<>
<p>{state} via {client.transport}</p>
<ul>
{messages.map((m) => (
<li key={m.id}><b>{m.publisher}</b> {m.data.text}</li>
))}
</ul>
</>
);
}A complete chat with a typing indicator lives in the folkways-js under apps/react-example.
Runtimes and packaging
- Browsers, Node 20+, Deno, Bun and React Native (with a
crypto.getRandomValuespolyfill). - ES2020 output; requires
BigInt. - Builds: ESM
dist/index.mjs, CommonJSdist/index.js, typesdist/index.d.ts, and the IIFEdist/index.global.js(also exported as@folkways/realtime/iife). - One runtime dependency,
flatbuffers@24.3.25.sideEffects: falsefor tree-shaking.
Local development
Run against a local RTE node:
- In the
rterepository, start a node withmake dev. It listens for WebSockets onws://127.0.0.1:8080/v1/wsand HTTP onhttp://127.0.0.1:3000. - Mint a dev token:
make -s token SUB=alice. - Connect with explicit endpoints:
import { Folkways, consoleLogger } from '@folkways/realtime';
const client = await Folkways.connect({
endpoints: { websocket: 'ws://127.0.0.1:8080/v1/ws', http: 'http://127.0.0.1:3000' },
token: devToken,
logger: consoleLogger('debug'),
});Working on the SDK itself, in the folkways-js repository:
pnpm install
pnpm build # ESM, CJS, d.ts and IIFE into packages/realtime/dist
pnpm test # unit and engine tests; integration tests skip without a node
pnpm lint
# also run the integration tests against a real node
RTE_BIN=../../../rte/target/debug/realtime-transport-engine pnpm --filter @folkways/realtime testCurrent limitations
- Session resume is not available yet; after a reconnect the SDK resubscribes from cursors, which delivers the same events without loss or duplicates.
- The highest acknowledgement level is
accepted;committedis not offered by this RTE release. - Pattern (wildcard) subscriptions are not supported; subscribe to concrete topics.
- After falling back to HTTP, a client does not move back to WebSocket.