# Connect a browser tab

`@lessly/realtime-client` connects a browser tab to the realtime service over a WebSocket, subscribes it to channels, and keeps it connected. The current published version is **0.3.0**.

It never sees your product API key. It authenticates with a short-lived capability token that it asks your own backend for.

## Install and connect

```bash
npm install @lessly/realtime-client
```

`connect(options)` constructs a client and starts connecting. It returns immediately — the connection is established in the background.

```ts

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

`RealtimeClientOptions`:

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

`new RealtimeClient(options)` is exported too, for when you want to construct the client without connecting yet and call `connect()` later. The class method `connect(): Promise<void>` resolves once the socket is open, rejects if the client is closed, and resolves immediately when already connected. The standalone `connect()` function swallows that first rejection — with it, connection problems surface through `onError` and `onStateChange` instead.

## The token provider

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

The provider is the only place a credential enters the client. Point it at an endpoint on your own backend that mints a capability token for the signed-in user with [the server SDK](/ship/realtime/server-sdk), and returns the token together with the WebSocket endpoint the SDK gave you.

The client calls the provider before **every** connection attempt, the first one and each reconnect. There is no refresh timer and no cached token: a reconnect always carries a freshly minted token, so a token that expired while the tab was offline is not a problem. If the provider throws, the error is reported to `onError` and the attempt is rescheduled with backoff.

The token is appended to the WebSocket URL as a `token` query parameter, URL encoded, using `&` when the URL already has a query string.

## Subscribe

```ts
subscribe(
  channel: string,
  onMessage: (m: RealtimeMessage) => void,
  options?: SubscribeOptions,
): Subscription
```

```ts
const subscription = client.subscribe('chat:room-1', (message) => {
  console.log(message.data)
}, {
  history: { lastN: 50 },
  onSubscribed: ({ recovered }) => {
    if (recovered === false) reloadFromScratch()
  },
})

subscription.unsubscribe()
```

You may subscribe before the connection is open. Channels are remembered and sent as soon as the socket opens, and re-sent after every reconnect.

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

`HistoryOptions` is one of `{ cursor: { offset, epoch } }`, `{ lastN }` or `{ lastMs }`. A `RealtimeMessage` carries `channel` and `data`, and optionally `id`, `offset`, `epoch`, `client` and `ts`.

Several handlers can share one channel: subscribing again adds a handler rather than opening a second subscription, and the last `options` passed wins. `unsubscribe()` removes that handler, and the client forgets the channel once its last handler is gone.

> There is no wire unsubscribe in this protocol version, so the removal is local. Messages for a forgotten channel are dropped on arrival, and the channel is not re-subscribed on the next reconnect.

A subscribe can be refused — `forbidden`, for instance, when the token lacks a capability the subscribe needs. There is no partial fallback: the whole subscribe is rejected. The refusal reaches `onSubscribeError` with the service's code, and also `onError`.

## Presence

Pass `presence` in the subscribe options to join the channel roster. The token must carry the `presence` capability for that channel.

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

client.presence('chat:room-1')            // PresenceMember[]
await client.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 `PresenceMember` is `{ identity, connections, info? }`. The client keeps the roster for you: `presence(channel)` returns the current members, empty until the first snapshot arrives. Rebuild your view from `onState` whenever it fires — after a reconnect it is the truth, and any deltas you accumulated are not. The full guarantees are on [presence](/ship/realtime/presence).

`updatePresence(channel, info)` replaces this connection's `info`. It resolves on the service's acknowledgement and rejects when the connection is not open, on disconnect, or with `invalid` — you are not in presence on that channel, or the info is too large.

## Publish from the client

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

This works where the namespace enables client events and the token carries the `publish` capability for the channel. Client events are ephemeral: they are fanned out and never stored, so they carry no `offset` or `epoch` and can never be replayed. The publishing connection is excluded from delivery, which makes the acknowledgement its only confirmation — render your own message locally.

`publish` resolves on the `published` acknowledgement. It rejects when the connection is not open, on disconnect, and on `forbidden` (no publish capability), `invalid` (missing channel, or serialized data over 32KB) or `rate_limited` (the per-connection bucket).

## Connection state

```ts
type ConnectionState =
  | 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'suspended' | 'closed'
```

```ts
const stop = client.onStateChange((state) => setBanner(state))
const stopErrors = client.onError((error) => console.warn(error.message))

client.state    // current state
client.close()  // stop for good
```

`onStateChange` and `onError` each return a function that removes the listener. The state is also readable at any time as `client.state`.

`idle` is the state before the first connect. `suspended` means the client has failed `suspendAfterAttempts` times in a row — it keeps trying, but it is a reasonable moment to tell the user that live updates are not arriving. `closed` is terminal: `close()` rejects everything in flight, stops reconnecting, and after it `connect()` rejects and `subscribe()` throws.

## Reconnecting

When a connection drops the client reconnects on its own, with a fresh token, and re-subscribes every channel it still has handlers for.

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

Those are the values in `DEFAULT_BACKOFF`, which is exported; anything you pass in `backoff` is merged over them. The delay before attempt `n` (1-based) is `min(maxMs, initialMs * factor ** (n - 1))`, reduced by a random fraction of up to `jitter` of that base. With the defaults, attempt 1 waits between 125 ms and 250 ms and the delay grows to a 15–30 s band. The jitter spreads a crowd of tabs that all lost the connection at the same moment.

The delay is skipped entirely after close codes 4100 and 4001. `backoffDelay(attempt, cfg, random)` is exported if you want to compute the same number yourself.

## Recovery after a reconnect

The client tracks the `offset` and `epoch` of the last message it received on each channel. On reconnect it resubscribes from that cursor instead of the `history` option you originally passed, so a tab that was away gets what it missed rather than the same window again. Until a first message arrives on a channel there is no cursor, and the original `history` option is used.

Recovery is not always possible — the entries may have aged out, or the epoch may have changed. Each `subscribed` acknowledgement therefore carries `recovered`, delivered to `onSubscribed`. When it is `false`, the gap was not filled: reload the channel's state from your own API rather than assuming the stream is continuous. See [history](/ship/realtime/history).

## Close codes

Three close codes are exported from the package:

| Constant | Value | What it means | What the client 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

The wire protocol is exported for tooling that speaks to the realtime service directly: `parseFrame(raw)`, which never throws and returns `{ type: 'unknown', raw }` for anything it does not recognize, and the frame builders `authFrame(token)`, `subscribeFrame(channel, history?, presence?)`, `publishFrame(channel, data)` and `presenceUpdateFrame(channel, info)`. The `ServerFrame` union describes everything the service sends. Frame types this version does not know are ignored, so a newer service does not break an older client.

`auth(token?)` sends an auth frame on the open connection, using the token provider when no token is given. Normal use does not need it: the connection is already authenticated by the token in its URL.

## Next steps

- [Show who is on a channel](/ship/realtime/presence): the roster guarantees behind the presence callbacks.
- [Replay what a client missed](/ship/realtime/history): what `recovered: false` means and what to do about it.
- [Call Realtime from your backend](/ship/realtime/server-sdk): the endpoint your token provider calls.
- [Look up a limit or an error](/ship/realtime/limits-and-errors): close codes, error frames and the client-event budget.
