Client API
The full reference for @useceleris/client covers createClient, Channel, Segment, the payload helpers, the types and the error codes.
npm install @useceleris/client
The package runs in browsers, Node.js 22.15+, Bun 1.3+ and Deno 2.5+. It ships as ESM with a tested CommonJS entry point. The only runtime dependency is Zod.
createClient(options)
function createClient(options: ClientOptions): Client;
| Option | Type | Default | Description |
|---|---|---|---|
credentialProvider | CredentialProvider | — | Required. Called for every connection attempt. |
baseUrl | string | production endpoint | Only for a local or self-hosted stack |
allowInsecureLoopback | boolean | false | Permits ws:// for loopback hosts. Never set in production. |
connectTimeoutMs | number | 15000 | Covers credential acquisition and handshake together. From 1 to 900000 (15 minutes) |
presenceQueryTimeoutMs | number | 10000 | Deadline for one presenceList(). From 1 to 900000 (15 minutes) |
reconnectTimeoutMs | number | connectTimeoutMs | The same combined deadline, per reconnect attempt. From 1 to 900000 (15 minutes) |
publishQueueSize | number | 64 | Publishes that can wait for writer room before Backpressure |
deduplicationWindowSize | number | 1024 | Message ids remembered per channel for duplicate filtering |
maximumReconnectAttempts | number | 10 | Failed reconnect attempts in one budget before the channel stops in failed. From 1 to 100 |
createClient performs no network work. The SDK validates known options and strips unknown option keys at runtime. TypeScript callers should use only the documented options.
The timeout options must be integers from 1 to 900000, as JavaScript numbers in milliseconds. A value larger than 15 minutes gives a ConfigurationError. The two size options must also be positive safe integers. maximumReconnectAttempts must be an integer from 1 to 100. Any other value gives a ConfigurationError. Strings, bigint, fractional values, and non-finite values are invalid. The default URL is wss://realtime.useceleris.com. baseUrl is the realtime socket endpoint, not your HTTP credential endpoint.
Client
class Client {
channel(reference: string): Channel;
}
channel() is side-effect free and returns a new connection handle on every call. A reference is 1–255 characters of [a-zA-Z0-9_-].
Channel
class Channel {
readonly state: ChannelState;
connect(options?: { signal?: AbortSignal }): Promise<void>;
close(): Promise<void>;
segment(segmentId: string): Segment;
defaultSegment(): Segment; // segment("default")
events(): ChannelEventHandler;
}
| Member | Notes |
|---|---|
state | One of idle, connecting, connected, reconnecting, failed, closing, closed |
connect() | Rejects OperationInProgress while the channel is connecting, connected or reconnecting, and NotConnected once closed. An initial failure both rejects and sets failed. |
close() | Idempotent and terminal, with a 5 s graceful budget. Closes every segment handle with it. Queued publishes and a pending presence query reject with Cancelled. |
segment(id) | Side-effect free. The id is required. |
defaultSegment() | The "default" segment every connection joins automatically. The same as segment("default"). |
You can recover a failed channel when you call connect() again. You cannot recover a closed channel. The SDK does not automatically retry an initial connection failure. See Reconnection and Recovery for triggers, deadlines, retry budgets, and restored subscriptions.
A successful connect() automatically joins default. Use defaultSegment().onMessage(listener) to receive its messages. defaultSegment().subscribe() is not required. Read and write permissions still apply, and default membership does not include other segments or presence subscriptions. This automatic join also happens on reconnect.
Segment
class Segment {
readonly segmentId: string;
subscribe(): Subscription;
onMessage(listener: MessageListener): () => void;
onPresence(listener: (event: PresenceEvent) => void): () => void;
publish(options: {
payload: Uint8Array;
messageId?: string;
signal?: AbortSignal;
}): Promise<void>;
subscribePresence(): Subscription;
presenceList(options: {
page: number;
perPage: number;
signal?: AbortSignal;
}): Promise<PresencePage>;
}
| Method | Notes |
|---|---|
subscribe() | A real server operation. Never sent for "default", which is joined automatically. Throws NotConnected on a closing or closed channel. |
onMessage() / onPresence() | Return a function that removes that listener. Handles for the same segment share one listener set. |
publish() | Resolves on local acceptance only. Rejects a command over 2 MiB encoded. The server enforces your plan's size limit afterwards. Empty is valid. Does not join the connection to the segment. To receive messages, call subscribe(). Accepted only while connected or reconnecting, otherwise rejects NotConnected. Waits in the publish queue (publishQueueSize) until the writer has room, also across a reconnect, and resolves when the socket takes it. |
subscribePresence() | Tells the server to send join and leave events. It does not join the segment for messages, and it does not keep the segment. |
presenceList() | One in flight per channel. A second rejects OperationInProgress. page is 1–2147483647, perPage 1–100, both validated rather than clamped. A query the server refuses or cannot answer rejects at once with a ServerError whose subType is "PRES_LIST". A timeout or an abort rejects the query and leaves the connection up. |
Payload helpers
function textPayload(value: string): Uint8Array;
function jsonPayload(value: unknown): Uint8Array;
function readText(payload: Uint8Array): string;
function readJson<T>(payload: Uint8Array): T; // asserts, does not validate
type PayloadCodec<T> = {
encode(value: T): Uint8Array;
decode(bytes: Uint8Array): T;
};
type BoundPayloadCodec<T> = {
encodePayload(value: T): Uint8Array;
readPayload(payload: Uint8Array): T;
};
function createPayloadCodec<T>(codec: PayloadCodec<T>): BoundPayloadCodec<T>;
jsonPayload throws ConfigurationError when JSON serialization fails. readText and readJson reject invalid UTF-8 or JSON. textPayload uses the platform's UTF-8 TextEncoder. A failure thrown by your own encode or decode propagates unchanged. No size check happens here: publish() enforces the encoded-command bound.
Types
type ChannelState =
| "idle"
| "connecting"
| "connected"
| "reconnecting"
| "failed"
| "closing"
| "closed";
type ChannelError =
ConfigurationError | ConnectionError | ProtocolError | ServerError;
type MessageMetadata = {
readonly tokenReference: string;
readonly segmentId: string;
readonly messageId: string; // always present; the SDK generates one if you omit it
readonly timestamp: bigint;
};
type MessageListener = (payload: Uint8Array, metadata: MessageMetadata) => void;
type PresenceEvent = {
readonly segmentId: string;
readonly tokenReference: string;
readonly connectionId: string;
readonly joined: boolean; // false = left
readonly timestamp: bigint;
};
type PresencePage = {
readonly segmentId: string;
readonly total: number;
readonly perPage: number;
readonly currentPage: number;
readonly from: number; // from > to past the last page
readonly to: number;
readonly connections: readonly PresenceConnection[];
};
type PresenceConnection = {
readonly tokenReference: string;
readonly connectionId: string;
readonly timestamp: bigint;
};
type ServerNotice = {
readonly timestamp: bigint;
readonly payload: Uint8Array;
};
type RecoveryEvent = {
readonly retryIndex: number;
readonly possibleGaps: true;
readonly possibleDuplicates: true;
};
type CredentialRequest = {
readonly channelReference: string;
readonly reason: "initial" | "reconnect";
readonly disconnectedAt?: number;
readonly replayLookbackMs?: number;
readonly signal: AbortSignal;
};
type Credentials = { readonly payload: string; readonly signature: string };
type CredentialProvider = (request: CredentialRequest) => Promise<Credentials>;
interface Subscription {
cancel(): void;
}
Numeric values
| Field | Type and contract |
|---|---|
presenceList({ page }) | number: integer from 1 through 2,147,483,647 |
presenceList({ perPage }) | number: integer from 1 through 100 |
PresencePage.total, .perPage, .currentPage, .from, .to | number: decoded signed 32-bit integers, preserved as received |
MessageMetadata.timestamp, ServerNotice.timestamp, PresenceEvent.timestamp, PresenceConnection.timestamp | bigint: exact decoded signed 64-bit integer |
ClientOptions.connectTimeoutMs, .reconnectTimeoutMs, .presenceQueryTimeoutMs | number: integers from 1 through 900,000 (15 minutes), in milliseconds |
CredentialRequest.disconnectedAt | Optional number: Unix time in milliseconds at the start of the outage |
CredentialRequest.replayLookbackMs | Optional number: suggested lookback in milliseconds, capped at 4,294,967,295 |
RecoveryEvent.retryIndex | number: failed reconnect attempts already consumed in the current budget. Starts at 0, not a count of socket drops |
ProtocolError.offset | number: byte offset associated with the decoding failure |
Numeric values inside ServerError.resource | number or bigint: depends on the server's integer encoding. Arrays can contain either |
The protocol's Integer32 values decode to number. Integer64 values decode to bigint. A timestamp remains a bigint even when its value would fit in a safe JavaScript number. The SDK exposes PresenceEvent.joined as a boolean, not a numeric flag.
The SDK validates presence request values and does not coerce or clamp them. Use { page: 1, perPage: 25 }, not strings or bigint literals. Returned page figures do not need conversion before arithmetic. Empty pages can have from > to.
JSON.stringify() handles number fields directly but throws on bigint values. Use the serialization examples to convert timestamps, and any bigint values in error resources, to decimal strings. Do not convert every value with Number(), because that can lose precision.
ChannelEventHandler
interface ChannelEventHandler {
onStateChange(listener: (state: ChannelState) => void): () => void;
onRecovery(listener: (event: RecoveryEvent) => void): () => void;
onNotice(listener: (notice: ServerNotice) => void): () => void;
onError(listener: (error: ChannelError) => void): () => void;
onMessage(listener: MessageListener): () => void;
}
Each method returns its own disposer. Listeners run synchronously in registration order. The SDK contains a synchronous failure and reports it through onError. The SDK also guards failures in onError callbacks against recursive error dispatch. Handle promise rejections inside asynchronous listener work yourself. A recovery event follows the connected state change. It does not signal replay completion. See Reconnection and Recovery.
onMessage receives all messages from all segments of the connection. It gets each message after the segment listeners, and it gets each message ID one time. All listeners of a message get the same payload. Do not change the payload. The function that onMessage returns removes only this listener. The other listeners and the subscriptions do not change, and when you call it again, it has no effect.
onNotice delivers raw server prose, such as subscription acknowledgments and refusals. It carries no segment id and no correlation id. Do not parse it.
Errors
There are four error classes. The SDK itself raises three: ConfigurationError (code: "Configuration"), ConnectionError (a code from the table below) and ProtocolError (with field and offset). Their messages say what failed and which rule or limit it broke, for example Invalid channel reference. Must not be empty., or a field path such as connectTimeoutMs. The messages never carry your input, credentials or server text. A ProtocolError message also names its field and byte offset. The fourth class, ServerError, carries an error that the server sent. The section below describes it. Match SDK errors on code and server errors on type, never on message text.
| Code | Raised when |
|---|---|
Configuration | The call site is wrong. Fix the code. Do not retry. |
Timeout | The connect or reconnect deadline elapsed, or a presence query timed out |
Cancelled | Your abort signal fired, or close() cancelled a connect, a queued publish or a pending query |
Transport | Handshake or socket failure, a provider that threw, a listener that threw, or a connection loss during a presence query |
NotConnected | A publish while the channel is idle, connecting, failed, closing or closed, a presence query while not connected, or an action on a closed channel |
Backpressure | The publish queue (publishQueueSize, 64 by default) is full, or a presence query found the writer full or paused after a rate limit. For a full queue, the message is "The publish queue is full (size N). Retry once some publishes have gone out." |
OperationInProgress | connect() while connecting, connected or reconnecting, or a second presenceList() |
DeliveryUnknown | The send threw after hand-off — acceptance genuinely unknown |
ProtocolError | One received frame could not be decoded. Its error also includes field and offset |
ProtocolError means the SDK could not decode one message. The SDK drops that message and reports the error, and the connection stays up. The SDK silently skips a command that this SDK version does not recognize, so a newer server cannot break a deployed client.
There is no Authentication code. No runtime exposes the WebSocket handshake status to script, so the SDK reports a rejected handshake as Transport and does not guess.
ServerError
type ServerErrorType =
| "ParserError"
| "SendError"
| "PermissionDeniedError"
| "RateLimitError"
| "MessageSizeLimitError"
| "InternalError";
type ServerErrorResource =
null | string | number | bigint | readonly ServerErrorResource[];
class ServerError extends Error {
readonly type: ServerErrorType | (string & {});
readonly subType: string | null; // the command it answers, e.g. "PUB"
readonly message: string; // the server's own text, unchanged
readonly resource: ServerErrorResource; // what that command names, e.g. the segment
}
A ServerError is an error the server sent, with every field exactly as sent, and the connection stays up. It arrives through onError, after the call that caused it, except for a presence query error, which rejects presenceList() instead. type also accepts types a newer server adds, so switch on the six below and keep a default branch. ServerError has no code, so narrow with instanceof before you read code on an error from onError.
resource is not always a string. Integer32 resources are numbers, Integer64 resources are bigints, and arrays can nest these alongside strings and null. Narrow the value with typeof or Array.isArray() before you use it. Do not assume every numeric resource is a timestamp.
| Error | subType | resource |
|---|---|---|
| A permission denial for a publish, subscribe or presence subscription | "PUB", "SUB", "UNSUB", "PRES_SUB" or "PRES_UNSUB" | The segment id |
| A refused or failed presence query | "PRES_LIST" | The query's request id, which the SDK sets |
| Anything else | null | null |
type | Sent when |
|---|---|
PermissionDeniedError | The token lacks access to what the command tried |
MessageSizeLimitError | A command exceeded your plan's size limit, or a message exceeded 2 MiB |
RateLimitError | A per-second, per-hour, per-month or per-connection message limit was exceeded. The SDK pauses, then resends recent commands |
ParserError | The server could not parse a command |
SendError | The server failed to send |
InternalError | A fault inside the server, such as a failed presence read |
Limits
| Limit | Value |
|---|---|
| Outbound command | 2 MiB encoded, rejected before any write |
| Encoded command, plan limit | Set by your plan (Limits). Counts the framing, the identifiers and the payload. Enforced by the server |
| Writer bounds | 64 pending commands, 2 MiB buffered. Up to publishQueueSize more publishes (64 by default) wait in a queue, behind subscription changes. The queue stays across a reconnect |
| Rate-limit recovery | Pause of 1 s plus growing jitter (≤31 s), then a resend of the last 2 s of commands (at most 64 publishes, each once). After 8 limits in a row, dropped subscriptions are retried after 1 min, doubling to 1 h |
| Duplicate filtering | deduplicationWindowSize message ids per channel, 1024 by default |
| Reconnect | maximumReconnectAttempts failed attempts per budget (10 by default), full-jitter delays up to 30 s. Only network failures and timeouts count. Other failures stop recovery at once. The budget resets at a drop after at least 60 s connected |
| Connect deadline | connectTimeoutMs, 15 s by default, covering credentials and handshake. A reconnect attempt uses reconnectTimeoutMs, defaulting to it |
| Presence query | presenceQueryTimeoutMs, 10 s by default, one in flight per channel |
| Close | 5 s graceful, then forced |
The SDK does not check the size of received messages. A message is already in the platform's buffer when it arrives, so the SDK processes whatever the server sends. A publish rejected by the server for your plan's size limit still counts toward your usage. A local encoded-command size rejection sends nothing.
What this SDK will not do
The SDK does not do these tasks:
- Confirm delivery. There are no receipts anywhere in the protocol.
- Keep a durable offline queue. The publish queue is in memory. It keeps publishes across a reconnect, but not after
failedorclose(). The SDK resends publishes only after a rate limit, once each, with their original ID. - Provide durable history or global ordering. Replay is a bounded window, not a cursor.
- Sign anything, or expose a signing facility to the browser.