/** * `SyncularRealtimeDO` — the Cloudflare Durable Object realtime host (§8, the * second binding of §1.1). The deferred half of * the Workers deployment matrix, now built. * * ## The shape * * One DO instance hosts **one `RealtimeHub`** and serves **one partition** * (the DO id is `idFromName(partition)`, see `realtimeStubFor`). All of a * partition's sync rounds, sockets, and commit fan-out live behind its * explicit FIFO — the per-partition serialization point D1 requires. Because * the hub is the * `RealtimeNotifier` (§8.2) *inside* the DO, a sync round that lands over the * socket fans its full delta out to the partition's other sockets with no * LISTEN/NOTIFY — writes and sockets are co-located. * * One-partition-per-DO is the natural §8.2 fan-out boundary and the rung we * ship. Many-partitions-per-shard (one DO fronting a bucket of low-traffic * partitions, to amortize the DO floor) is a future tuning knob: the hub * already keys every operation by partition, so a shard DO would host one hub * and route by `partition` — no protocol change, only the id-derivation and * the fan-out `partition` filter (already present). Deferred until a * cost/traffic signal asks for it. * * ## WebSocket hibernation (`state.acceptWebSocket`) * * Idle sockets must not pin the DO in memory or bill wall time. We use the * Hibernation API: `acceptWebSocket(ws)` hands the socket to the runtime, * `webSocketMessage`/`webSocketClose`/`webSocketError` are delivered as * class methods, and between deliveries the DO may be evicted from memory * while the sockets stay open. * * The per-connection state machine is the existing `RealtimeSession` * (`@syncular/server`) — unchanged, driven from the hibernation callbacks: * binary frames → `session.handleBinary` (§8.7 rounds + acks), text frames → * `session.handleMessage` (§8.2 acks, §8.6 presence), hub delta/wake sends → * `ws.send`. * * ### Hibernation-safe state: rehydration * * A `RealtimeSession` is *in-memory only* — on wake from hibernation the DO's * `#sessions` map is empty. The honest rule: * * - **Hibernation only happens between rounds.** An in-flight sync round is * an async generator draining over `ws.send`; while it is pending it holds * the DO's event loop, so the DO cannot be evicted mid-round. (This is the * same property the §8.7 "one round in flight" rule relies on.) * - **On the first message after a wake**, the socket has a serialized * attachment (`ws.serializeAttachment` — `{clientId, actorId, partition}`, * written at accept time) but no live session. We reconstruct the session * via `hub.connect(...)`, which reloads the registration list from the * client record in D1 (the §8.1 load-at-upgrade rule — exactly what a * fresh upgrade does), then dispatch the message into it. Rehydration is * transparent to the client: it saw `hello` once at the real upgrade, so * the rehydration `hello` is swallowed (a one-shot filter on the send * wrapper). Cursor/registration are the durable truth in D1; nothing * in-flight is lost because nothing in-flight can be hibernated. * * So the serialized attachment is deliberately minimal — the three identity * fields `connect` needs. Everything else (`cursor`, `registrations`, * `lastKnownSeq`) is re-derived from D1 by `connect`, which is authoritative. * * ## The wake path (external-command fan-out) * * Ordinary HTTP `/sync` is forwarded into this DO and fans out in-process. An * external authoritative command host that already provides equivalent D1 * partition serialization may call `/__wake` after its own commit so sockets * re-pull. See `durableObjectRealtimeNotifier` for that caller side. */ import { D1ServerStorage, type RealtimeHubConfig } from '@syncular/server'; export interface DurableObjectStateLike { acceptWebSocket(ws: WebSocketLike, tags?: string[]): void; getWebSockets(tag?: string): WebSocketLike[]; } export interface WebSocketLike { accept?(): void; send(data: string | ArrayBuffer | ArrayBufferView): void; close(code?: number, reason?: string): void; serializeAttachment(value: unknown): void; deserializeAttachment(): unknown; } /** The `WebSocketPair` constructor result: `[client, server]` by index. */ export type WebSocketPairLike = { 0: WebSocketLike; 1: WebSocketLike; }; /** * The host env a `SyncularRealtimeDO` reads. Supplied by the DO runtime via * the class constructor's second arg. `DB` is the D1 binding (the same one the * outer Worker config uses); `configFactory` builds the hub config from `env`. */ export type RealtimeDOConfig = { /** * Preferred: build the complete canonical sync config around the DO's * coordinated D1 storage. Reuse this factory for the outer HTTP adapter * so HTTP-forwarded and socket rounds cannot drift by capability. */ syncConfig(storage: D1ServerStorage): RealtimeHubConfig; readonly hubConfig?: never; } | { /** * @deprecated Use `syncConfig`. This compatibility shape predates the * canonical HTTP/realtime capability contract. */ hubConfig(storage: D1ServerStorage): RealtimeHubConfigInput; readonly syncConfig?: never; }; /** * The subset of `RealtimeHubConfig` the DO host supplies (storage is wired by * the DO from its D1 binding, so it is omitted here). */ export type RealtimeHubConfigInput = Omit; /** The identity the upgrade request must carry (resolved by the Worker). */ export interface RealtimeUpgradeIdentity { readonly partition: string; readonly actorId: string; readonly clientId: string; } /** Internal control-request paths on the DO stub (never client-facing). */ export declare const REALTIME_DO_WAKE_PATH = "/__syncular_realtime/wake"; export declare const REALTIME_DO_UPGRADE_PATH = "/__syncular_realtime/upgrade"; export declare const SYNC_DO_REQUEST_PATH = "/__syncular_realtime/sync"; /** * The base `SyncularRealtimeDO`. A host subclasses (or instantiates) it with a * `RealtimeDOConfig`. The class is platform-shaped: `state.acceptWebSocket` + * `webSocket*` handlers are the Cloudflare Durable Object hibernation contract. * * Because the platform types are declared structurally (no * `@cloudflare/workers-types` dependency), a real deployment declares: * * ```ts * export class SyncularRealtimeDO extends DurableObject { * #impl = new SyncularRealtimeHost(this.ctx, this.env, config); * fetch(req: Request) { return this.#impl.fetch(req); } * webSocketMessage(ws: WebSocket, msg: ArrayBuffer | string) { * return this.#impl.webSocketMessage(ws, msg); * } * webSocketClose(ws: WebSocket) { return this.#impl.webSocketClose(ws); } * webSocketError(ws: WebSocket) { return this.#impl.webSocketError(ws); } * } * ``` * * The reference host is `SyncularRealtimeHost` below; the tests drive it * directly over the DO double. */ export declare class SyncularRealtimeHost { #private; constructor(state: DurableObjectStateLike, db: D1Database, config: RealtimeDOConfig); /** * The DO `fetch` handler: routes the internal upgrade + wake control paths. * The Worker forwards `GET /realtime` here as an upgrade with the * resolved identity in headers (see `forwardRealtimeUpgrade` in `index.ts`), * and accepts external-command wakes at `/__syncular_realtime/wake`. */ fetch(request: Request): Promise; /** Hibernation callback: an inbound frame. */ webSocketMessage(ws: WebSocketLike, message: ArrayBuffer | string): Promise; /** Hibernation callback: the socket closed. */ webSocketClose(ws: WebSocketLike): Promise; /** Hibernation callback: a socket error — treat as a close. */ webSocketError(ws: WebSocketLike): Promise; /** Test/introspection: the number of live sessions on this DO. */ get sessionCount(): number; } /** Inject a `WebSocketPair` implementation (hermetic tests). */ export declare function setWebSocketPair(impl: (new () => WebSocketPairLike) | undefined): void; export declare function writeRequestIdentityHeaders(headers: Headers, identity: { readonly partition: string; readonly actorId: string; }): void; /** Write the resolved identity onto an upgrade request's headers (Worker side). */ export declare function writeIdentityHeaders(headers: Headers, identity: RealtimeUpgradeIdentity): void; /** The D1 binding, re-declared structurally (see `d1-storage.ts`). */ export type D1Database = ConstructorParameters[0];