Skip to content

Connect a browser tab

Connect a browser to Realtime, subscribe to channels, and handle reconnects.

Use connectRealtime from @lepsto/sdk/realtime to subscribe a browser tab to channels over a WebSocket. The client reconnects after a drop and can replay retained messages.

Authenticate the socket with a short-lived capability token from your backend. Keep the product public key on that backend; it must never enter the browser.

Before you start

  • A backend endpoint that authenticates the user, issues a capability token and returns the issuance response.
  • A registered namespace that allows the operations you request. Namespace policy can remove operations from a token.
  • The @lepsto/sdk package. If running the socket in Node without a global WebSocket, supply an implementation.

Pick the subpath first

Use @lepsto/sdk/realtime for the browser socket. The same import also contains an HTTP client, but the two clients use different credentials:

  • connectRealtime gets a delegated token from your backend’s token endpoint.
  • createRealtime sends a product public key with HTTP requests and must run on your backend.
  • @lepsto/sdk/realtime/server manages Realtime using a server credential and explicit base URL. It cannot be bundled for browsers.

The server SDK guide covers management. The steps here connect a socket, subscribe it and check delivery.

What is on this subpath

On the backend, createRealtime({ productId, publicKey }) exposes tokens.issue, messages.publish, history.get, presence.get and presence.stats. The response from tokens.issue supplies the token and URL required by the socket’s token provider.

The socket exports are connectRealtime(options), the RealtimeSocket class and RealtimeSocketOptions. RealtimeClient is the HTTP client’s type, and connect() is an instance method on RealtimeSocket.

Install and connect

npm install @lepsto/sdk
  1. Install @lepsto/sdk with the command above. There is no separate browser package.

  2. Call connectRealtime(options) with your token provider. It returns a socket immediately and connects in the background. Replace /api/realtime-token with your backend endpoint:

import { connectRealtime } from '@lepsto/sdk/realtime'

const socket = connectRealtime({
  tokenProvider: async () => {
    const res = await fetch('/api/realtime-token')
    return res.json()          // { token, gatewayUrl }
  },
})

RealtimeSocketOptions:

Option
Type
Meaning
tokenProvider
TokenProvider
Returns a fresh token and WebSocket endpoint. Required.
webSocketFactory
(url: string) => WebSocketLike
Defaults to (url) => new globalThis.WebSocket(url). Inject ws in Node.
backoff
Partial<BackoffConfig>
Reconnect timing, see below.
random
() => number
Randomness for backoff jitter. Defaults to Math.random.

For control over when connection starts, construct new RealtimeSocket(options) and call connect() yourself. Its promise resolves when open, resolves immediately if already connected, and rejects if closed. The connectRealtime() factory handles the initial rejection internally; observe failures through onError and onStateChange.

If your Node environment has no global WebSocket, inject one:

import WebSocket from 'ws'
import { RealtimeSocket } from '@lepsto/sdk/realtime'

const socket = new RealtimeSocket({
  tokenProvider,
  webSocketFactory: (url) => new WebSocket(url) as never,
})
await socket.connect()

The token provider

type TokenProvider = () => Promise<{ token: string; gatewayUrl: string }>

Have the provider call your backend, where tokens.issue authenticates through your product public key. Return the issuance response to the browser; it contains the required token and gatewayUrl fields.

The socket invokes the provider before the first connection and every reconnect. It neither caches tokens nor runs a refresh timer. A provider error reaches onError, and the client schedules another attempt with backoff.

The client URL-encodes the token and adds it as a token query parameter on the WebSocket URL. It uses & if the URL already has a query string.

Subscribe

subscribe(
  channel: string,
  onMessage: (m: RealtimeMessage) => void,
  options?: SubscribeOptions,
): Subscription
const subscription = socket.subscribe('chat:room-1', (message) => {
  console.log(message.data)
}, {
  history: { lastN: 50 },
  onSubscribed: ({ recovered }) => {
    if (recovered === false) reloadFromScratch()
  },
})

subscription.unsubscribe()
  1. Subscribe to a channel your token allows. You can do this before connection opens; the socket sends remembered subscriptions when it connects and after each reconnect. Replace chat:room-1 and reloadFromScratch with your channel and application resync function. Call unsubscribe() when that handler is no longer needed.

SubscribeOptions:

Option
Type
Meaning
history
HistoryOptions
Initial replay.
onSubscribed
(info: { recovered?: boolean }) => void
Fired on every subscribed ack, the first one and after each reconnect.
presence
PresenceOptions
Enter presence on the channel.
onSubscribeError
(error: { code: string }) => void
Fired when the subscribe is rejected.

For initial history, choose { cursor: { offset, epoch } }, { lastN } or { lastMs }. Each RealtimeMessage has channel and data, with optional id, offset, epoch, client and ts.

Multiple handlers can share a channel. Another subscribe call adds a handler and replaces the channel’s options with the latest ones. unsubscribe() removes that handler; removing the last handler makes the socket forget the channel.

Unsubscribing is local in this protocol version. The client drops subsequent messages for that channel and will not subscribe to it on reconnect; it does not send an unsubscribe frame.

If the token lacks a required capability, the whole subscription is rejected with a code such as forbidden. The client passes the service’s code to onSubscribeError and also reports the failure through onError.

Presence

To join the roster, include presence in your subscription options. Enable presence on the namespace and request the channel’s presence capability when issuing the token.

socket.subscribe('chat:room-1', onMessage, {
  presence: {
    info: { name: 'Ada' },
    onState: (members) => render(members),
    onJoin: (member) => add(member),
    onLeave: (identity) => remove(identity),
  },
})

socket.presence('chat:room-1')            // PresenceMember[]
await socket.updatePresence('chat:room-1', { name: 'Ada', typing: true })
Option
Type
Meaning
info
unknown
Opaque member metadata, up to 10KB serialized.
onState
(members: PresenceMember[]) => void
An authoritative snapshot: the initial one and one after each resubscribe.
onJoin
(member: PresenceMember) => void
A member was added or updated.
onLeave
(identity: string) => void
A member left.

A member has the shape { identity, connections, info? }. presence(channel) returns the client’s current roster, initially empty. Replace your displayed roster with every onState snapshot, including after reconnecting, to repair any missed updates. See presence for the delivery rules.

Call updatePresence(channel, info) to replace this connection’s metadata. It resolves on acknowledgement. It rejects if disconnected or not open, or with invalid if the connection has not joined presence or the metadata exceeds the size limit.

Publish from the client

await socket.publish('chat:room-1', { text: 'hello' })

Client publishing requires clientEvents on the namespace and a publish capability in the token. These messages are delivered live only: they are not stored and have no offset or epoch. The sending connection does not receive its own message, so render it locally after acknowledgement.

The promise resolves on published. It rejects if the connection is not open or disconnects, or on forbidden (missing capability), invalid (missing channel or serialized data over 32 KB), or rate_limited (the connection’s publish budget).

Verify it worked

  1. Observe onStateChange reaching connected. If it keeps reconnecting, inspect onError and the close code to distinguish network, token and service failures.
  2. Publish a backend message to the subscribed channel and confirm your handler receives the payload.
  3. Disconnect the network briefly, then restore it. Confirm a new token request, another onSubscribed callback and resumed delivery.

Connection state

type ConnectionState =
  | 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'suspended' | 'closed'
const stop = socket.onStateChange((state) => setBanner(state))
const stopErrors = socket.onError((error) => console.warn(error.message))

socket.state    // current state
socket.close()  // stop for good

Both listener methods return an unsubscribe function. Read socket.state whenever you need the current state.

Before its first connection the socket is idle. After suspendAfterAttempts consecutive failures it becomes suspended but continues trying; show the user that live updates are unavailable. Calling close() is permanent: it rejects pending operations, stops retries, makes later connect() calls reject and makes subscribe() throw.

Reconnecting

After a connection drops, the socket fetches a fresh token and reconnects. It resubscribes to every channel that still has a handler.

interface BackoffConfig {
  initialMs: number             // 250
  maxMs: number                 // 30_000
  factor: number                // 2
  jitter: number                // 0.5
  suspendAfterAttempts: number  // 8
}

These defaults are exported as DEFAULT_BACKOFF; your backoff options override individual fields. For attempt n, starting at 1, the base delay is min(maxMs, initialMs * factor ** (n - 1)). A random reduction of up to jitter times that base spreads reconnect attempts across clients. The defaults wait 125–250 ms on the first attempt, eventually reaching 15–30 seconds.

Close codes 4100 and 4001 trigger an immediate reconnect. To calculate other delays, use the exported backoffDelay(attempt, cfg, random).

Recovery after a reconnect

The client saves each channel’s last received offset and epoch. On reconnect, it requests messages after that cursor. Until it has received a message and saved a cursor, it uses your original history option.

Check recovered in onSubscribed. If it is false, the epoch changed or required messages expired, so the client could not fill the gap. Reload current state from your API before relying on live updates again. See history.

Close codes

Three close codes are exported from the package:

Constant
Value
What it means
What the socket does
CLOSE_UNAUTHORIZED
4001
The token was rejected or has expired.
Reconnects immediately. The next attempt fetches a fresh token from your provider, which is normally enough. A persistent 4001 means the provider is returning a token the service will not accept — check what your backend mints.
CLOSE_DRAINING
4100
The service node is shutting down and is moving connections off.
Reconnects immediately, with no backoff — another node is ready.
CLOSE_SLOW_CONSUMER
4200
The connection could not keep up with the message rate and was dropped.
Reconnects with the normal backoff. Reconnecting alone does not fix it: consume faster, or subscribe to less.

Any other close code, including a network drop, reconnects with the normal backoff.

Protocol helpers

For direct protocol integrations, the package exports authFrame(token), subscribeFrame(channel, history?, presence?), publishFrame(channel, data) and presenceUpdateFrame(channel, info). ServerFrame describes incoming frames. parseFrame(raw) never throws and returns { type: 'unknown', raw } for unrecognized input; the client ignores unknown frame types for compatibility with newer services.

To refresh authentication on an open connection, call auth(token?). Omitting the token invokes the provider. The initial connection already authenticates through the token in its URL.

Next steps

Was this page helpful?
Esc

Start typing to search the docs.

↑↓navigate↵select