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, Draining and Backpressure signals.
  • 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

bash
npm install @folkways/realtime
# or
pnpm add @folkways/realtime

The 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:

html
<script src="https://unpkg.com/@folkways/realtime/dist/index.global.js"></script>
<script>
  Folkways.connect({ url: 'https://rte.example.com', token }).then((client) => {
    // …
  });
</script>
Node 22+ has a global WebSocket. On Node 20, pass one from the ws package: webSocket: (await import('ws')).WebSocket.

Quick start

ts
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

FunctionReturnsBehaviour
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)RealtimeClientReturns 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.

ts
Folkways.connect({
  endpoints: { websocket: 'ws://127.0.0.1:8080/v1/ws', http: 'http://127.0.0.1:3000' },
  token,
});

The client

MemberMeaning
stateidle, connecting, connected, reconnecting or closed
transport'websocket', 'http' or null while not connected
identityThe token's sub, as confirmed by the node
sessionIdThe current session id
limitsLimits announced by the node (max payload, headers, frame size, heartbeat)
tokenExpiresAtExpiry of the current token (Unix ms)
errorWhy 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.

EventPayload
state(state, previous)
connected{ transport, identity?, sessionId? }
disconnected{ code, reason, retryAfterMs? }
reconnecting{ attempt, delayMs, error } for the next attempt
errorThe client gave up on a fatal RealtimeError and is now closed
tokenExpiringExpiry time (Unix ms) announced by the node
tokenRefreshedNew expiry time, when known
ts
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:

ts
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: Bearer on every request and is refreshed when its exp is within 60 s, and once more on a 401.
  • On AuthInvalid the 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 ended event (Forbidden).
Tokens are never logged and never put in URLs.

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:

ClassStoredDeliveryA new subscriber getsUse with
DurableA log, capped by size and ageIn order, replayableReplay from the position it asks forpublish
LastValueThe latest event per key, until replaced, expired (ttlMs) or deletedLatest winsA snapshot of the current valuesset, delete
EphemeralThe latest signal per sender, for a few secondsBest effortA snapshot of current signalssignal

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

ts
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

dataSent asSubscribers get in msg.data
Uint8Array, ArrayBuffer, typed arraybytesUint8Array
stringtextstring
Anything elsejson (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

OptionMeaning
contentTypePayload type; inferred from data when omitted
headersRecord<string, string> passed through to subscribers (up to limits.maxHeaders, 16 by default)
idempotencyKey32 hex digits or a UUID. Generated for you; set it yourself to retry the same logical publish across page reloads or processes
expectAssert the topic's class (ClassMismatch otherwise)
keyLastValue item key (prefer topic.set)
ttlMsLastValue: how long the value lives
clientTimeYour clock for the event (Unix ms); defaults to Date.now()
signalAn 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 with IdempotencyConflict.
  • Retries. RateLimited, QuotaExceeded, Internal and Overloaded are retried up to publishRetries (3) times, waiting at least the node's retry_after_ms.
  • Offline queue. Publishes made while disconnected are queued (up to offlineQueue.maxSize, 1000 by default; QueueFull beyond) and sent on reconnect. Publishes in flight when a connection drops are resent with the same key. Set offlineQueue: false to reject with NotConnected instead.
  • Local validation. Bad topic names, oversized payloads and too many headers reject immediately with InvalidTopic, PayloadTooLarge or InvalidArgument (marked local: 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.

ts
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 live

Ephemeral 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.

ts
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

ts
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

OptionDefaultMeaning
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
snapshottrueState topics: deliver the current values first
resumetrueStart after the cursor in the cursorStore, when there is one

The subscription

MemberMeaning
readyResolves on the first confirmation; rejects on an error or a gap with onGap: 'fail'
statuspending, active, resyncing (reconnecting), failed, ended or closed
deliveryClassdurable, last-value or ephemeral, once known
cursorThe last event this handle finished handling
valuesLastValue: live values by key. Ephemeral: recent signals by publisher. Durable: empty
unsubscribe()Stops this handle
EventWhen
messageEvery event, after the handler ran
snapshotThe full current state of a LastValue or Ephemeral topic (on subscribe and after reconnects)
gapEvents were missed: { reason, requested, oldest, latest, resumed }
errorThe subscription failed, or your handler threw
endedThe node ended the subscription (rights narrowed) or the client closed
statusThe status changed

The message

ts
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 a snapshot. 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

ts
for await (const msg of client.topic('jobs/new').subscribe()) {
  await handle(msg);
} // ends when the subscription fails, ends or is closed

Cursors, 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:

ts
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:

ts
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

text
          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, the online event skips the remaining delay. Set reconnect: false to 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, maxAttempts reached, or close().
  • 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 plus pongTimeoutMs, 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 open fallbackAfterFailures (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):

ts
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
CodesMeaning
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

OptionDefaultMeaning
url–Base URL; gives the WebSocket and HTTP endpoints
endpointsfrom url{ websocket?, http? }; each overrides url
tokenrequiredA token string, or a provider function
transports['websocket', 'http']Preference order
clientNamefolkways-js/0.1.0Reported to the node for troubleshooting
autoConnecttruecreateClient only
connectTimeoutMs15000connect() rejects with Timeout after this (the client keeps trying); 0 waits forever
openTimeoutMs10000One connection attempt
requestTimeoutMs10000Subscribe, unsubscribe and re-auth replies
publishTimeoutMs30000Whole life of a publish, retries included
publishRetries3Retries for rate-limit and server errors
reconnect{ initialDelayMs: 500, maxDelayMs: 30000, maxAttempts: Infinity }Or false
fallbackAfterFailures2WebSocket failures before switching to HTTP
pongTimeoutMs10000Extra silence tolerated after a missed heartbeat
cursorAckIntervalMs5000How often processed cursors are reported to the node; 0 disables
offlineQueue{ maxSize: 1000 }Or false to reject publishes while offline
cursorStorememoryCursorStore()Where processed cursors are kept
signalTtlMs5000How long an Ephemeral signal stays in values
longPoll{ waitMs: 25000, maxEvents: 100, emptyStateRecheckMs: 2000 }HTTP long-poll tuning
webSocketglobalThis.WebSocketWebSocket constructor (e.g. ws on Node 20)
fetchglobalThis.fetchfetch implementation
loggersilent{ 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.

tsx
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.getRandomValues polyfill).
  • ES2020 output; requires BigInt.
  • Builds: ESM dist/index.mjs, CommonJS dist/index.js, types dist/index.d.ts, and the IIFE dist/index.global.js (also exported as @folkways/realtime/iife).
  • One runtime dependency, flatbuffers@24.3.25. sideEffects: false for tree-shaking.

Local development

Run against a local RTE node:

  1. In the rte repository, start a node with make dev. It listens for WebSockets on ws://127.0.0.1:8080/v1/ws and HTTP on http://127.0.0.1:3000.
  2. Mint a dev token: make -s token SUB=alice.
  3. Connect with explicit endpoints:
ts
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:

bash
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 test

Current 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; committed is 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.