Client API
Full reference for @useceleris/client: createClient, Channel, Segment, payload helpers, types and error codes.
npm install @useceleris/client
Runs in browsers, Node.js 22.15+, Bun 1.3+ and Deno 2.5+. 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 |
presenceQueryTimeoutMs | number | 10000 | Deadline for one presenceList() |
Performs no network work. Known options are validated; unknown option keys are stripped at runtime. TypeScript callers should use only the documented options.
Both timeout options must be positive safe-integer JavaScript numbers in milliseconds. 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 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 if one is already running, 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. |
segment(id) | Side-effect free. The id is required. |
defaultSegment() | The "default" segment every connection joins automatically; the same as segment("default"). |
failed is recoverable by calling connect() again. closed is not. Initial connection failures are not automatically retried. 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/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. |
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; your plan's payload cap is enforced by the server afterwards. Empty is valid. Auto-joins the segment server-side. |
subscribePresence() | Also joins the segment for messages; cancelling presence does not leave it. |
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
function createPayloadCodec<T>(codec: {
encode(value: T): Uint8Array;
decode(bytes: Uint8Array): T;
}): {
encodePayload(value: T): Uint8Array;
readPayload(payload: Uint8Array): 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 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, .presenceQueryTimeoutMs | number: positive safe integers, 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 may 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. PresenceEvent.joined is exposed as a boolean, not a numeric flag.
Presence request values are validated, not coerced or clamped: use { page: 1, perPage: 25 }, not strings or bigint literals. Returned page figures do not need conversion before arithmetic. Empty pages may have from > to.
JSON.stringify() handles number fields directly but throws on bigint values. Convert timestamps and any bigint error-resource values to decimal strings using the serialization examples. Avoid blanket conversion to Number(), which 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;
}
Each returns its own disposer. Listeners run synchronously in registration order; a synchronous failure is contained and reported through onError, whose own callback failures are guarded 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.
onNotice delivers raw server prose — subscription acknowledgements and refusals. It carries no segment id and no correlation id. Do not parse it.
Errors
Four classes. Three are raised by the SDK itself — ConfigurationError (code: "Configuration"), ConnectionError (a code from the table below) and ProtocolError (with field and offset) — and 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), but never carry your input, credentials or server text. A ProtocolError message also names its field and byte offset. The fourth, ServerError, carries an error the server sent, described below. 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 deadline elapsed, or a presence query timed out |
Cancelled | Your abort signal fired, or close() cancelled a pending query |
Transport | Handshake or socket failure, or a provider that threw |
NotConnected | Publishing or querying while not connected, or acting on a closed channel |
Backpressure | 64 publishes are already waiting for room in the writer |
OperationInProgress | A concurrent connect(), 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 one message could not be decoded. That message is dropped, the error is reported, and the connection stays up. A command this SDK version does not recognise is skipped silently, so a newer server cannot break a deployed client.
There is no Authentication code: no runtime exposes the WebSocket handshake status to script, so a rejected handshake is reported as Transport rather than guessing.
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
}
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 reading 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 using 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 publish exceeded your plan's payload cap, 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 |
| Publish payload | Per plan — 64 KiB free, 128 KiB standard, 512 KiB pro, 1024 KiB prime; enforced by the server |
| Writer bounds | 64 pending commands, 2 MiB buffered; up to 64 more publishes wait in a queue, behind subscription changes |
| 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 | 1024 message ids per channel |
| Reconnect | 10 failed attempts per budget, full-jitter delays up to 30 s; budget resets at a drop after at least 60 s connected |
| Connect deadline | 15 s, covering credentials and handshake |
| Presence query | 10 s, one in flight per channel |
| Close | 5 s graceful, then forced |
Received messages are never size-checked: the platform has already buffered a message by the time it arrives, so the SDK processes whatever the server sends. A publish rejected by the server for its plan payload limit still counts toward your usage; a local encoded-command size rejection sends nothing.
What this SDK will not do
- Confirm delivery. There are no receipts anywhere in the protocol.
- Queue while offline. Publishes are resent 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.