/** * Broadcast channels and presence, as an SDK surface. * * The realtime engine has supported `join_channel`, `broadcast`, * `presence_track`, `presence_untrack` and `presence_state` for a while, but * the client only recognised those types well enough to send them * fire-and-forget: there were no methods to call and no way to receive channel * or broadcast events, since `on()` handles only connect / disconnect / * reconnect / error. Anything wanting presence therefore opened a *second* * socket and reimplemented the AUTHENTICATE → AUTH_SUCCESS handshake, the * reconnect backoff, and the presence heartbeat — a couple of hundred lines * per app, all of it duplicating this package. * * Two protocol details this hides, because both are easy to get wrong and * neither is discoverable from the message list: * * - **A joining client is told only about its own join.** The `presence_diff` * it receives after `presence_track` contains just itself. The existing * roster arrives only in response to an explicit `presence_state` request, * so `join()` sends one. * - **Presence expires after 30s** (`PRESENCE_TIMEOUT_MS` server-side). A * client that tracks once and goes quiet silently vanishes from everyone * else's roster while still sitting in the document, so `track()` starts a * heartbeat and `leave()` stops it. */ /** Presence state keyed by the server's client id. */ export type PresenceState = Record>; export interface PresenceDiff { joins: PresenceState; leaves: PresenceState; } export interface BroadcastEvent { event: string; payload: unknown; /** * Per-channel sequence number, present only on retained channels. * * Monotonically increasing and dense, so a consumer that remembers the last * one it applied can tell the server exactly where to resume from. */ seq?: number; /** * True when this arrived through catch-up rather than live. * * Handlers do not have to care — replayed messages are delivered to the * same `onBroadcast` handlers, in sequence order, so an operation stream * needs no second code path. It is exposed for consumers that want to, * for example, skip an animation while fast-forwarding. */ replayed?: boolean; } /** * One retained message, as returned by {@link RebaseRealtimeChannel.history}. * * Re-exported rather than re-declared: the copy that used to live here had * drifted `at` to optional, while the server always sends it. */ export type { ChannelHistoryEntry } from "@rebasepro/types"; import type { ChannelHistoryEntry } from "@rebasepro/types"; /** The answer to a catch-up request. */ export interface ChannelHistoryResult { messages: ChannelHistoryEntry[]; /** * Whether the server retains anything for this channel. * * False means there is no retention rule configured for it, so the empty * list means "never keeps history" rather than "you missed nothing" — a * client that needs to converge has to fall back to a full resync. */ retained: boolean; /** Highest sequence the server holds, even if this batch was capped. */ latestSeq?: number; } /** Options for a channel handle. */ export interface ChannelOptions { /** * Ask the server to replay what this client missed, on join and on every * reconnect. * * Only meaningful for a channel the *server* has a retention rule for — * retention is configured on the backend, since a channel is created by * whoever names it and a client-chosen history depth would let any visitor * commit the backend to unbounded storage. On a channel with no rule the * server answers `retained: false` and this is inert. */ history?: boolean; } /** The socket operations a channel needs; satisfied by RebaseWebSocketClient. */ export interface ChannelTransport { sendMessage(message: Record): Promise; onChannelMessage(channel: string, handler: (message: Record) => void): () => void; onReconnect(handler: () => void): () => void; } export declare class RebaseRealtimeChannel { readonly name: string; private transport; private presenceHandlers; private broadcastHandlers; private unsubscribers; /** Last known roster, kept so handlers always get a full picture. */ private presences; /** What this client last tracked, replayed on reconnect and heartbeat. */ private trackedState; private heartbeat; private joined; /** Whether this handle asks the server to replay missed messages. */ private wantsHistory; /** * Highest sequence number delivered to handlers so far. * * This is the resume point sent as `sinceSeq`, and the watermark that makes * replay idempotent: catch-up ranges overlap with what arrived live, and * anything at or below this has already been seen. */ private lastSeq; /** * Live messages that arrived while a catch-up was in flight. * * Without this they would be delivered ahead of the older messages being * fetched, and — worse — would advance {@link lastSeq} past them, so the * catch-up response would then be discarded as already-seen and those * messages would be lost for good. Held here and flushed, in order, once * the replay lands. */ private pendingLive; private catchUpInFlight; /** * Deadline for a catch-up response. * * Buffering live messages is only safe because the wait is bounded. A * catch-up frame that never arrives — a server that dropped it, a socket * that died between request and reply — would otherwise leave the channel * silently holding every subsequent edit forever, which is a worse failure * than the one replay was added to fix. */ private catchUpTimeout; /** * Callers of {@link history} awaiting the next `channel_history` frame. * * These frames are addressed by channel rather than by request id, so they * are matched in arrival order. Requests on one channel are serialized by * the socket, so FIFO is the right correlation here. */ private historyWaiters; constructor(name: string, transport: ChannelTransport, options?: ChannelOptions); /** * Turn on catch-up for a handle that was created without it. * * The client hands back the same channel object for a given name, so a * later `channel(name, { history: true })` has no new object to configure — * it upgrades this one instead. Idempotent, and never downgrades: one * caller asking for history must not be switched off by another that did * not ask. */ enableHistory(): void; /** * Join the channel and ask for the current roster. * * Called automatically by `track`, `broadcast`, `onPresence` and * `onBroadcast`; calling it directly is only needed to start receiving * before there is anything to send. */ /** * Send a channel message. * * Every channel message is read by the server out of a `payload` envelope * (`payload?.channel`, `payload?.state`, `payload?.event`). Sending those * fields flat does not error: `payload?.channel` simply reads as * `undefined`, so the client is registered into channel `undefined` with * empty state, and the echo comes back with no `channel` for * `onChannelMessage` to match — presence and broadcast both go quiet with * nothing logged. Funnelled through one place so a new message type cannot * reintroduce that. */ private send; join(): Promise; private rejoin; /** * Ask the server for everything after {@link lastSeq}. * * Live messages are buffered from here until the answer arrives — see * {@link pendingLive}. */ private requestHistory; /** * Give up waiting for a catch-up and release what was held back. * * The buffered messages are still the freshest thing this client has, so * they are delivered rather than dropped. Callers of {@link history} are * answered with `retained: false` — accurate in the sense that matters: * this client has no history to work from and has to resync. */ private abandonCatchUp; /** * Publish this client's presence state, and keep publishing it. * * Calling `track` again replaces the state (and restarts the heartbeat), * which is how you update e.g. a cursor position. */ track(state: Record): Promise; /** Stop publishing presence, without leaving the channel. */ untrack(): Promise; /** * Observe the roster. The handler fires immediately with what is already * known, then on every change. */ onPresence(handler: (state: PresenceState, diff?: PresenceDiff) => void): () => void; /** Send a broadcast. The sender does not receive its own message. */ broadcast(event: string, payload: unknown): Promise; /** Observe broadcasts. Pass an event name to filter. */ onBroadcast(handler: (event: BroadcastEvent) => void): () => void; onBroadcast(event: string, handler: (payload: unknown) => void): () => void; /** * The last sequence number this channel has delivered. * * Zero on a channel that retains nothing. Persist it if you want catch-up * to survive a page reload as well as a reconnect, and pass it back via * {@link history}. */ get sequence(): number; /** * Fetch retained messages explicitly, instead of waiting for join or * reconnect to do it. * * Defaults to resuming from {@link sequence}. Messages are delivered to * `onBroadcast` handlers as usual — the returned value is for callers that * want to inspect the batch, or to learn from `retained` that the channel * keeps no history at all. */ history(options?: { sinceSeq?: number; limit?: number; }): Promise; /** Leave the channel and release every listener and timer. */ leave(): Promise; private stopHeartbeat; /** Fold an incoming frame into the roster and fan it out. */ private handle; /** Deliver everything held back during a catch-up, in sequence order. */ private flushPendingLive; private deliver; private emitPresence; }