import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; import type { SessionStore } from './session.js'; import type { CrowdyLogger } from './logger.js'; import { CrowdyRealtimeError } from './errors.js'; import type { LbCookieStore } from './lb-cookie-store.js'; import type { RealtimeMetrics } from './metrics.js'; import { type UdpNotificationsSubscription } from './generated/graphql.js'; import type { RelaySignContext } from './binary-wire.js'; /** * Lifecycle state of the realtime WebSocket connection, as reported by * {@link RealtimeClient.status} and {@link RealtimeClient.onStatus}. * * - `idle` — created but never connected; no socket open yet. * - `connecting` — opening the socket / performing the initial handshake. * - `connected` — the subscription is live and receiving notifications. * - `reconnecting` — the socket dropped (or a retry is in progress) while a * connection is still desired; backoff is running and it will resubscribe. * - `disconnected` — intentionally closed (e.g. {@link RealtimeClient.disconnect} * or the last subscriber unsubscribing). * - `failed` — a fatal, non-retryable error (e.g. not authenticated, or a * non-retryable `RealtimeConnectionEvent` such as `APP_ID_REQUIRED`); it will * not reconnect on its own. */ export type RealtimeStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed'; /** * Any single message delivered on the `udpNotifications` subscription — the * union of every spatial echo/fan-out notification plus `GenericErrorResponse` * and `RealtimeConnectionEvent`. This is the codegen-derived (canonical) * shape, narrowed to the non-null payload; discriminate the members by their * `__typename`. */ export type UdpNotification = NonNullable; /** * The members of {@link UdpNotification} that carry a `sequenceNumber` and can * therefore be correlated back to the send that produced them — the spatial * echoes/fan-out (actor/voxel/audio/text/event notifications and responses, * single-actor and channel messages) plus `GenericErrorResponse`. Excludes * `RealtimeConnectionEvent`, which has no sequence number. * * {@link RealtimeClient.waitForSequence} resolves with one of these when a * matching success arrives (it rejects instead when the match is a * `GenericErrorResponse`), which is what powers the `...AndWait` spatial sends. */ export type SpatialNotification = Extract; /** * Per-notification callbacks passed to `client.udp.subscribe(handlers, appId)` * (or `client.world(appId).subscribe`). Every handler is optional — supply * only the ones you care about. Each key maps a notification's GraphQL * `__typename` to its callback, except {@link any} and {@link error}, which are * special (see below). * * Handlers are dispatched synchronously as messages arrive, and exceptions * thrown inside one are caught and logged so a single bad handler can't tear * down the stream. For each notification {@link any} runs first, then the * matching typed handler. */ export interface UdpNotificationHandlers { /** * Another actor's position/state changed within your area of interest — * the spatial fan-out of someone else's `sendActorUpdate`. `state` is * base64-encoded actor state. */ actorUpdate?: (notification: Extract) => void; /** * Legacy handler for `ActorUpdateResponse`. Current game servers never emit * this type; the echo of your own actor update arrives as an * `ActorUpdateNotification` (see {@link actorUpdate}), which is what * `sendActorUpdateAndWait` correlates to via `sequenceNumber`. Retained for * backward compatibility. */ actorUpdateResponse?: (notification: Extract) => void; /** * A voxel changed within range — the fan-out of another client's voxel edit. * `voxelState` is base64-encoded. */ voxelUpdate?: (notification: Extract) => void; /** * Legacy handler for `VoxelUpdateResponse`. Current game servers never emit * this type; the echo of your own voxel update arrives as a * `VoxelUpdateNotification` (see {@link voxelUpdate}), which is what * `sendVoxelUpdateAndWait` correlates to via `sequenceNumber`. Retained for * backward compatibility. */ voxelUpdateResponse?: (notification: Extract) => void; /** * A nearby client sent a voice/audio packet; `audioData` is base64-encoded * compressed audio (decode with {@link decodeBase64}). */ audio?: (notification: Extract) => void; /** A nearby client sent a text/chat message (`text` is UTF-8). */ text?: (notification: Extract) => void; /** * A nearby client emitted a custom client event (a client-defined * `eventType` with a base64 `state` payload). */ clientEvent?: (notification: Extract) => void; /** * A server-originated spatial event broadcast to a region (e.g. world or NPC * events), shaped like a client event (`eventType` + base64 `state`). */ serverEvent?: (notification: Extract) => void; /** * A direct actor-to-actor message addressed specifically to you; `payload` * is base64. There is no sender echo, so this only ever arrives on the * recipient's subscription. */ singleActorMessage?: (notification: Extract) => void; /** * A message broadcast on a channel (group) you're subscribed to; `payload` * is base64 and opaque to the server. */ channelMessage?: (notification: Extract) => void; /** * An asynchronous error for a previously sent datagram. Correlate it to the * originating send via `sequenceNumber` and read `errorCode` * ({@link UdpErrorCode}) for the reason. The matching `...AndWait` promise * rejects on this; the handler still fires for observability. */ genericError?: (notification: Extract) => void; /** * A connection-lifecycle event from the game-api (handshake / auth / * routing), carrying `status`, `code`, `message`, and `retryable`. A * non-retryable event such as `code: 'APP_ID_REQUIRED'` means the * subscription was rejected and will not be retried automatically. */ connectionEvent?: (notification: Extract) => void; /** * SDK-level realtime failures surfaced as a {@link CrowdyRealtimeError} * (socket error, auth token cleared, subscription failed, wait timeout). * This is a **client-side** signal, not a server notification. */ error?: (error: CrowdyRealtimeError) => void; /** * Catch-all invoked for **every** notification, before the specific typed * handler above. Handy for logging, metrics, or custom dispatch. */ any?: (notification: UdpNotification) => void; } /** * Tuning options for {@link RealtimeClient} (the WebSocket subscription layer), * passed through from `CrowdyClient`'s `realtime` config. Every field is * optional and has a default. */ export interface RealtimeConfig { /** * WebSocket URL of the game-api GraphQL endpoint (e.g. * `wss://game.example.com/graphql`). Used when {@link wsEndpoint} is not set; * falls back to {@link CROWDY_DEFAULT_WS_ORIGIN} — the public CK API origin * for the tier this build was published for — when both are omitted. */ wsUrl?: string; /** Alias for {@link wsUrl}; used only when {@link wsUrl} is not provided. */ wsEndpoint?: string; /** * Maximum number of automatic reconnect attempts after the socket drops * before giving up. Defaults to `8`. */ retryAttempts?: number; /** * Base delay in **milliseconds** for the exponential reconnect backoff (also * the upper bound of the random jitter added to each wait). Defaults to * `250`. */ retryInitialDelayMs?: number; /** * Ceiling in **milliseconds** for the reconnect backoff, so the delay never * grows past this between attempts. Defaults to `5000`. */ retryMaxDelayMs?: number; /** * Default time in **milliseconds** a `...AndWait` send waits for its matching * echo before timing out (overridable per call via * {@link RealtimeClient.waitForSequence}). Defaults to `5000`. */ waitTimeoutMs?: number; /** * Ask where to connect, when the current instance stops answering. * * Under direct connect a client is pinned to ONE api instance, so a * reconnect loop against a dead or drained address never recovers — the * instance the URL names is gone. This is called after * {@link rediscoverAfterFailures} consecutive failures, and immediately when * the server says it is draining, to ask the load balancer for a new one. * The same idea as CrowdyCPP's `doAssign()` on Buddy's COMMAND_RECONNECT. * * Return null to keep the current URL (nothing better is available). * Omitting it entirely keeps the old behaviour: retry one URL forever. */ rediscover?: (appId: string | null) => Promise<{ wsUrl?: string | null; } | null>; /** * Consecutive connection failures before re-discovery. Defaults to `3`. * * Not 1: an instance is usually still there and a single failure is far * more often a blip than a dead server, and re-resolving on every blip * would move clients off healthy instances for no reason. */ rediscoverAfterFailures?: number; /** * Called when the server directs this client to a specific instance, so the * owner can move the HTTP endpoint alongside the WebSocket. * * Moving one without the other splits a session across two instances, and * both the UDP proxy session and the relay worker are per-process — the * client would look connected and receive nothing. */ onEndpointMove?: (target: { httpUrl: string; wsUrl: string; }) => void; /** Optional logger for realtime diagnostics. Defaults to a silent logger. */ logger?: CrowdyLogger; /** * Sticky-LB cookie jar shared with the game-api HTTP client. When set in * Node, the WebSocket upgrade forwards `cks_ga` so HTTP mutations and the * subscription land on the same game-api upstream. */ lbCookieStore?: LbCookieStore; /** * When true, spatial `sendActorUpdate` mutations are sent over the existing * graphql-transport-ws connection instead of HTTP POST. Requires an active * `udpNotifications` subscription on the same socket. Falls back to HTTP when * the socket is not connected. */ wsUplinkMutations?: boolean; /** * When true, realtime traffic uses the game-api's **binary relay** * (`crowdy-relay-v1`): a raw WebSocket that carries complete client-signed * Buddy wire datagrams as BINARY frames in both directions, bypassing the * GraphQL send mutations + `udpNotifications` hot path entirely. Handlers * and `...AndWait` correlation behave identically. Falls back to the * GraphQL transport automatically when the relay endpoint is unavailable * (older servers). Discover server support via * `gameClientBootstrap.binaryRelayEnabled`. */ binaryTransport?: boolean; /** * Absolute ws(s) URL of the binary relay endpoint. Defaults to the realtime * `wsUrl` with its path replaced by `/realtime` (the game-api default). */ binaryRelayUrl?: string; } /** * Manages the single WebSocket subscription to the game-api's * `udpNotifications` stream — the realtime layer behind `client.udp` and * `client.realtime`. It opens the socket lazily on the first {@link subscribe}, * authenticates with the shared session token, scopes the session to one * `appId`, reconnects with jittered exponential backoff, re-reads the token and * resubscribes on reconnect, fans each notification out to the registered * {@link UdpNotificationHandlers}, and resolves `...AndWait` sends via * {@link waitForSequence}. * * The connection lifecycle is observable through {@link status} / * {@link onStatus} ({@link RealtimeStatus}). A realtime session is scoped to a * single app, so run one client per app (sharing the same token store) for a * player who is in multiple apps at once. * * You normally interact with this through `client.udp` / `client.realtime` * rather than constructing it directly. */ export declare class RealtimeClient { private readonly session; private readonly metrics?; /** Mutable: re-discovery replaces it in place. */ private wsUrl; private readonly logger; private readonly retryAttempts; private readonly rediscover?; private readonly rediscoverAfterFailures; private readonly onEndpointMove?; /** Consecutive failed connects; reset by a successful one. */ private connectFailures; /** Guards against several failing attempts all re-resolving at once. */ private rediscovering; private readonly retryInitialDelayMs; private readonly retryMaxDelayMs; private readonly waitTimeoutMs; private readonly lbCookieStore?; private readonly wsUplinkMutations; private readonly binaryTransport; /** Mutable alongside wsUrl: re-discovery moves both. */ private binaryRelayUrl; private binaryRelay; private binaryUnavailable; private client; private release; private desired; private statusValue; private readonly statusListeners; private readonly subscribers; private readonly pending; private nextSubscriberId; private subscribedAppId; private opening; /** * @param config - Reconnect/timeout/endpoint tuning; see * {@link RealtimeConfig}. * @param session - Shared session store. The client reads the Bearer token * from it for the connection handshake and watches it for changes: clearing * the token tears the connection down (emitting an `AUTH_CLEARED` * {@link CrowdyRealtimeError}), while a token change made while connected * forces a reconnect using the new token. * @param metrics - Optional traffic counters (`client.metrics`); each * delivered notification is recorded once, regardless of subscriber count. */ constructor(config: RealtimeConfig | undefined, session: SessionStore, metrics?: RealtimeMetrics | undefined); /** * The current connection state. * * @returns The latest {@link RealtimeStatus}. */ status(): RealtimeStatus; /** * Subscribe to connection-state changes. The listener is invoked * **immediately** with the current status, then again on every transition. * * @param listener - Called with each new {@link RealtimeStatus}. * @returns An unsubscribe function that removes the listener. */ onStatus(listener: (status: RealtimeStatus) => void): () => void; /** * Mark the connection as desired and open the subscription if it isn't * already open. You usually don't call this directly — {@link subscribe} * calls it for you; use it (or `client.realtime.connect()`) only to pre-warm * the socket. * * @throws {CrowdyRealtimeError} `AUTH_REQUIRED` if there is no session token. */ connect(): void; /** * Close the socket and stop wanting a connection. Outstanding * {@link waitForSequence} promises are left intact (they will time out on * their own); use {@link close} to also reject those and drop all * subscribers. Safe to call when already disconnected. */ disconnect(): void; /** * Fully tear down the client: {@link disconnect}, drop all notification * subscribers, and reject every outstanding {@link waitForSequence} promise * with a non-retryable {@link CrowdyRealtimeError}. Call this when disposing * the SDK instance. */ close(): void; /** * Register a set of {@link UdpNotificationHandlers} and ensure the realtime * connection is open, scoping the session to `appId`. The game-api requires * an app id and rejects an app-agnostic subscription with a * `RealtimeConnectionEvent` (`code: 'APP_ID_REQUIRED'`). * * Multiple handler sets can be registered at once; the returned function * unregisters this one, and the socket closes automatically once the last * subscriber unsubscribes. * * @param handlers - Callbacks for the notification types you care about. * @param appId - The app to scope this realtime session to (decimal id; * coerced to a string). Required. * @returns An unsubscribe function that removes these handlers (and * disconnects when none remain). */ subscribe(handlers: UdpNotificationHandlers, appId: string): () => void; /** * Return a promise that resolves when a notification carrying the given * `sequenceNumber` arrives — the mechanism behind the `...AndWait` spatial * sends. Resolves with the matching {@link SpatialNotification}, or rejects * if that match is a `GenericErrorResponse` or the wait times out. * * @param sequenceNumber - The sequence number to wait for (as allocated by * {@link SequenceAllocator} and stamped on the send). * @param timeoutMs - How long to wait before rejecting, in milliseconds. * Defaults to the configured {@link RealtimeConfig.waitTimeoutMs}. * @returns The matching spatial notification. * @throws {CrowdyRealtimeError} `UDP_SEQUENCE_TIMEOUT` (retryable) on timeout, * or carrying the server `errorCode` when the match is a * `GenericErrorResponse`. */ /** * Whether {@link RealtimeConfig.wsUplinkMutations} is enabled for this client. */ wsUplinkEnabled(): boolean; /** * True when the graphql-transport-ws socket is open and can carry mutations. */ wsUplinkReady(): boolean; /** * Execute a GraphQL mutation over the existing WebSocket (graphql-ws * `subscribe` message with a mutation operation). Same JSON protocol as HTTP. */ executeMutation>(document: TypedDocumentNode, variables?: TVariables): Promise; waitForSequence(sequenceNumber: number, timeoutMs?: number): Promise; /** * Whether the binary relay transport is currently active and able to carry * spatial sends (socket open + handshake complete). */ binarySendReady(): boolean; /** Whether this client is configured (and still eligible) to use the relay. */ usingBinaryTransport(): boolean; /** * Serialize (with the session signing context) and send one datagram over * the binary relay. Throws `BINARY_RELAY_UNAVAILABLE` when the relay is not * connected — callers fall back to the GraphQL mutation. */ sendBinaryFrame(serialize: (ctx: RelaySignContext) => Promise): Promise; /** * Resolve once the binary relay is ready (or reject on timeout). Requires a * prior {@link subscribe} (the relay session is app-scoped). */ ensureBinaryReady(timeoutMs?: number): Promise; private ensureSubscription; private openSubscription; private ensureBinarySubscription; private restart; /** * Ask discovery for a different instance and reconnect to it. * * Coalesced: several failing attempts arriving together must not produce * several discovery calls, which would spread one client's reconnect across * several instances and defeat the point of being pinned to one. * * Never throws. Discovery being unreachable is not worse than the situation * that prompted the call, and the existing backoff keeps retrying the URL we * already have. */ private rediscoverEndpoint; /** * Move to the instance the server named. * * The difference from re-discovery is who chose the destination: here the * server did, because it is shedding load or draining, so there is no * discovery round trip and no reason to consult the rediscover callback. The * binary relay has already refused any target outside this estate. */ /** * Follow an HTTP redirect that has already happened. * * Called when a GraphQL call was refused with `WRONG_DATACENTER` and the * transport moved itself. The websocket has to follow in the same step or the * session is split across two DATACENTERS rather than merely two instances — * strictly worse than the split this file already guards against, because the * subscription would be talking to a datacenter that holds none of the app's * shards and will refuse it too. * * Idempotent: moving to the URL already in use returns without touching the * connection, so a burst of refused calls produces one move rather than one * reconnect each. */ /** * Act on a `WRONG_DATACENTER` carried in a websocket `errors[]`. * * @returns true when the connection was moved, so the caller can skip * reporting the error as an ordinary failure. */ private applyDatacenterRedirect; moveToDatacenter(target: { httpUrl: string; wsUrl?: string | null; datacenter?: string; }): void; private moveToDirectedEndpoint; /** Drop the relay so the next subscribe builds one against the new URL. */ /** * The binary relay could not be established. Try to move before degrading. * * Under direct connect the relay URL names ONE instance, so "the relay is * unavailable" usually means that instance is gone — not that the relay is * unavailable anywhere. Falling straight back to GraphQL used to be the only * response, and it fails for the same reason: the GraphQL websocket points at * the same dead host. The client then sat retrying a dead hostname while the * rest of the fleet was healthy. * * So ask for a new endpoint first. If one arrives, rediscoverEndpoint has * already rebuilt the relay against it and there is nothing to degrade. */ private handleBinaryRelayUnavailable; private restartBinaryRelay; private dispatch; private resolvePending; private removePending; private rejectAllPending; private dispatchError; private setStatus; } /** * Emitted by an instance the control plane has asked to drain. Matches the * server's RealtimeConnectionEvent code so a rolling deploy can move clients * off before it stops, instead of dropping them and letting them find out. */ export declare const SERVER_DRAINING_CODE = "SERVER_DRAINING"; //# sourceMappingURL=realtime.d.ts.map