1/** 2 * @factory/collaboration — structural types for the realtime bridge. 3 * 4 * Wire protocol: `docs/architecture/realtime-architecture.md` §2 as 5 * implemented by TASK-0091 (the phase-07 task sheet, R3). **The 6 * authoritative frame schemas land in `packages/shared/src/realtime/frames.ts` 7 * (TASK-0091 backend); this module is the client-side mirror and must stay in 8 * lockstep** — the apps/realtime scaffold's earlier frame set 9 * (sync / sync-reply / presence / ping-pong) is superseded per the task 10 * header note 3 ("the scaffold is a starting point, not a contract"). 11 * 12 * Framing (recorded client-side inference, TASK-0091 finalizes in 13 * shared/realtime/frames.ts): 14 * - **Text frames**: one JSON object per frame, `type` discriminant. 15 * - **Binary frames**: a 1-byte kind prefix + raw Yjs bytes. 16 * `0x01` — doc update (`Y.applyUpdate`-compatible) 17 * `0x02` — awareness update (`y-protocols` `applyAwarenessUpdate`) 18 * 19 * Yjs binary updates cross the wire base64-encoded **inside** the JSON 20 * control frames (`sync.request.stateVector`, `sync.state.chunk`); the 21 * `update` / `awareness` frames are binary (prefix + raw bytes). 22 */ 23 24/** Base64 of a Yjs binary update / state vector (the JSON-frame encoding). */ 25export type Base64Yjs = string; 26 27/** Binary frame kind prefix (see the module doc). */ 28export const BINARY_KIND = { 29 Update: 0x01, 30 Awareness: 0x02, 31} as const; 32 33// --- client → server (JSON) -------------------------------------------------- 34 35/** Join-sync request. Empty `stateVector` = full doc; set = state-vector diff. */ 36export interface SyncRequestFrame { 37 type: "sync.request"; 38 /** Base64 state vector; omitted = the local doc is empty (full sync). */ 39 stateVector?: Base64Yjs; 40 /** Last acked server seq (the Redis update-log resume fast path, R5). */ 41 lastSeq?: number; 42} 43 44/** 45 * Application-level heartbeat. Carries the client's last-ack seq so a 46 * reconnect can use the update-log resume even between pings (R5). 47 */ 48export interface PingFrame { 49 type: "ping"; 50 ts: number; 51 lastSeq?: number; 52} 53 54/** 55 * Non-editor mutation count pending in the local outbox (sent after `hello` 56 * on (re)connect so the server can estimate the drain load; client-side 57 * inference — the final shape lands in shared/realtime/frames.ts, recorded). 58 */ 59export interface OutboxPendingFrame { 60 type: "outbox.pending"; 61 count: number; 62} 63 64/** JSON client frames. Binary client frames: `update` / `awareness` (prefixed). */ 65export type ClientFrame = SyncRequestFrame | PingFrame | OutboxPendingFrame; 66 67// --- server → client (JSON) -------------------------------------------------- 68 69/** A roster peer (the `hello` payload — users only, no cursors; cursors 70 * arrive as awareness frames within 50 ms, TASK-0093 R2). */ 71export interface RoomPeer { 72 readonly user: { readonly id: string; readonly name: string; readonly color: string }; 73} 74 75/** 76 * Emitted on a successful room join (R1): identity + capability + clock. 77 * `canEdit` is the defense-in-depth flag the client also enforces locally 78 * (a VIEW-only peer's local updates are dropped, mirroring the server). 79 */ 80export interface HelloFrame { 81 type: "hello"; 82 connId: string; 83 canEdit: boolean; 84 serverTime: number; 85 roomPeers: readonly RoomPeer[]; 86} 87 88/** 89 * Paged doc state: one base64 Yjs update per chunk (≤ 64 KB server-side), 90 * `done` marks the end of the transfer (R3; paged hydration, TASK-0092). 91 */ 92export interface SyncStateFrame { 93 type: "sync.state"; 94 chunk: Base64Yjs; 95 done: boolean; 96} 97 98/** 99 * Server seq ack for a logged update (client-side inference, recorded — 100 * the Redis update log is per-room; `doc.persisted.version` is the 101 * persistence-bound ack, `ack.seq` the log-bound one). When the server 102 * sends no acks the client falls back to pure state-vector resume 103 * (always correct — the log is a speed optimization only, R5). 104 */ 105export interface AckFrame { 106 type: "ack"; 107 seq: number; 108} 109 110/** A persisted doc state reached Postgres (the "Saved" indicator + the 111 * offline outbox settle signal, TASK-0092 R3 / TASK-0095). */ 112export interface DocPersistedFrame { 113 type: "doc.persisted"; 114 pageId: string; 115 version: number; 116} 117 118/** Comment fan-out over the page room (TASK-0094 R1). */ 119export interface CommentEventFrame { 120 type: "comment.event"; 121 kind: "created" | "replied" | "resolved" | "reopened" | "reacted" | "deleted"; 122 commentId: string; 123 pageId: string; 124 actorId: string; 125 /** First ~200 chars + render hint (the full comment is fetched on demand). */ 126 preview: string; 127} 128 129/** Notification push over the `user:{userId}` control room (TASK-0094 R7). */ 130export interface NotificationPushFrame { 131 type: "notification.push"; 132 notificationId: string; 133 /** The notification kind (comment.reply, mention, …) — `type` is the 134 * frame discriminator, so the payload field is `kind`. */ 135 kind: string; 136 title: string; 137 deepLink: { type: string; id: string }; 138 actorName?: string; 139} 140 141/** Long-hydration notice (the client shows "Loading page…", TASK-0092 R2). */ 142export interface HydratingFrame { 143 type: "hydrating"; 144 pageId: string; 145} 146 147/** Protocol error (a close is NOT implied — the socket stays open). */ 148export interface ErrorFrame { 149 type: "error"; 150 code: string; 151 message: string; 152} 153 154/** Heartbeat echo. */ 155export interface PingPongFrame { 156 type: "ping-pong"; 157 ts: number; 158} 159 160export type ServerFrame = 161 | HelloFrame 162 | SyncStateFrame 163 | AckFrame 164 | DocPersistedFrame 165 | CommentEventFrame 166 | NotificationPushFrame 167 | HydratingFrame 168 | ErrorFrame 169 | PingPongFrame; 170 171/** One decoded wire frame (text = JSON object, binary = prefixed Yjs bytes). */ 172export type WireFrame = 173 | { readonly kind: "text"; readonly frame: ClientFrame | ServerFrame } 174 | { readonly kind: "binary"; readonly binary: "update" | "awareness"; readonly bytes: Uint8Array }; 175 176// --- close codes (TASK-0091 R6 — the scaffold's 44xx set is superseded) ------ 177 178export const WS_CLOSE = { 179 /** Protocol error / heartbeat timeout (no pong in 90 s). */ 180 ProtocolOrHeartbeat: 4000, 181 /** Unauthorized: invalid/expired JWT (no room join happened). */ 182 Unauthorized: 4001, 183 /** Forbidden: wrong workspace / not the socket's own control room / no VIEW. */ 184 Forbidden: 4002, 185 /** Rate limit / policy violation (connect rate, msg rate, 10 strikes). */ 186 RateOrPolicy: 4008, 187 /** Frame larger than the 256 KB cap. */ 188 PayloadTooLarge: 4009, 189 /** Graceful server shutdown (SIGTERM drain — retry after 5 s). */ 190 ServerShutdown: 4010, 191} as const; 192 193/** Close code → user-facing reconnect hint (the client toast text). */ 194export const CLOSE_HINTS: Readonly<Record<number, string>> = { 195 [WS_CLOSE.ProtocolOrHeartbeat]: "Connection lost — reconnecting…", 196 [WS_CLOSE.Unauthorized]: "Session expired — signing you back in…", 197 [WS_CLOSE.Forbidden]: "You no longer have access to this page.", 198 [WS_CLOSE.RateOrPolicy]: "Too many changes — giving the server a moment…", 199 [WS_CLOSE.PayloadTooLarge]: "A change was too large to send — retrying…", 200 [WS_CLOSE.ServerShutdown]: "The server is restarting — reconnecting in a moment…", 201}; 202 203/** Provider connection state (the top-bar StatusChip input). */ 204export type ConnectionState = 205 | "connecting" 206 | "syncing" 207 | "connected" 208 | "reconnecting" 209 | "offline" 210 | "closed"; 211 212/** 213 * A minimal, injectable WebSocket (the browser `WebSocket`, a fake in 214 * tests). Data is `string` (JSON frames) or `ArrayBuffer` / `Uint8Array` 215 * (binary frames). 216 */ 217export interface WSAdapter { 218 readonly readyState: number; 219 send(data: string | ArrayBuffer | Uint8Array): void; 220 close(code?: number, reason?: string): void; 221 onopen: (() => void) | null; 222 onmessage: ((event: { data: string | ArrayBuffer | Uint8Array }) => void) | null; 223 onclose: ((event: { code: number; reason: string }) => void) | null; 224 onerror: ((event: unknown) => void) | null; 225} 226 227/** 228 * The Yjs update source/sink the provider drives. The canvas implements it 229 * with `y-prosemirror` (`syncWrapper` over the editor's view, the Phase-4 230 * node set registered per `mapping.ts`); tests inject a fake. 231 */ 232export interface YjsDocBinding { 233 /** The Y.Doc (state vectors, awareness, offline persistence). */ 234 readonly doc: import("yjs").Doc; 235 /** Apply a decoded binary update (network or offline store). */ 236 applyUpdate(update: Uint8Array): void; 237 /** Register a local-change listener; returns an unregister fn. */ 238 onLocalUpdate(cb: (update: Uint8Array) => void): () => void; 239 /** True when the local doc has no blocks yet (empty = full sync needed). */ 240 hasLocalState(): boolean; 241 /** Encode the current local state vector (for `sync.request`). */ 242 stateVector(): Uint8Array; 243 /** Encode the full local doc state as a binary update. */ 244 encodeStateAsUpdate(): Uint8Array; 245} 246 247/** The awareness self-identity (color derived from the shared hash). */ 248export interface PresenceIdentity { 249 readonly userId: string; 250 readonly name: string; 251} 252 253/** Provider configuration (`createRealtimeProvider`, TASK-0092 R4). 254 * 255 * The task's `createRealtimeProvider({pageId, workspaceId, token, 256 * pageBlocks, editor})` maps to: `token` → `getToken` (the Phase-1 257 * cookie-refresh BFF hands out access JWTs; a 15-min expiry mid-session 258 * refreshes silently), `pageBlocks` → `initialBlocks`, `editor` → 259 * `binding` (the canvas builds the binding from the editor via 260 * y-prosemirror — the package itself stays tiptap-free). */ 261export interface RealtimeProviderConfig { 262 readonly pageId: string; 263 readonly workspaceId: string; 264 /** The realtime origin (e.g. `ws://localhost:4001` — the room path is 265 * appended by the provider: `/realtime?token=…&room=page:{pageId}`). */ 266 readonly wsUrl: string; 267 /** Current access token (JWT) for the WS handshake. */ 268 readonly getToken: () => string; 269 /** Refresh the access token (cookie-refresh BFF) on 4001. */ 270 readonly refreshAccessToken: () => Promise<string>; 271 /** The Yjs doc binding (y-prosemirror on the canvas, a fake in tests). */ 272 readonly binding: YjsDocBinding; 273 /** The WebSocket factory (browser `new WebSocket`; fake in tests). */ 274 readonly createSocket: (url: string) => WSAdapter; 275 /** Initial blocks to seed the doc when neither an offline doc nor the 276 * ProseMirror view provides state (the task's `pageBlocks`). */ 277 readonly initialBlocks?: readonly Record<string, unknown>[]; 278 /** The awareness self-identity (absence = presence disabled). */ 279 readonly presence?: PresenceIdentity; 280 /** Cursor validity probe (the AwarenessStore's R3 rule; the canvas 281 * implements it against the doc / ProseMirror view). */ 282 readonly hasBlock?: (blockId: string) => boolean; 283 /** Connectivity probe (defaults to `navigator.onLine`; Node = online). 284 * The app's online/offline listeners drive {@link setOnline} directly. */ 285 readonly isOnline?: () => boolean; 286 /** The offline persistence factory (y-indexeddb by default; a no-op 287 * where IndexedDB is unavailable, e.g. node — recorded). */ 288 readonly offlineFactory?: OfflinePersistenceFactory; 289 /** Reconnect backoff cap (the < 3 s reconnect budget, TASK-0095). */ 290 readonly maxReconnectDelayMs?: number; 291 /** Application-level heartbeat interval (server ping cadence: 30 s). */ 292 readonly pingIntervalMs?: number; 293 /** Injectable clock (defaults to Date.now) for tests. */ 294 readonly now?: () => number; 295 readonly setTimeout?: typeof globalThis.setTimeout; 296 readonly clearTimeout?: typeof globalThis.clearTimeout; 297 readonly onStateChange?: (state: ConnectionState) => void; 298 readonly onPeerChange?: (peers: readonly PeerInfo[]) => void; 299 /** A persisted doc state landed (the "Saved" indicator, version = pages.version). */ 300 readonly onDocPersisted?: (info: { pageId: string; version: number }) => void; 301 /** Comment fan-out (the panel subscribes — TASK-0094 R2). */ 302 readonly onCommentEvent?: (event: CommentEventFrame) => void; 303 /** Notification push (the bell subscribes — TASK-0094 R7). */ 304 readonly onNotificationPush?: (event: NotificationPushFrame) => void; 305 /** A local update was dropped because the room is VIEW-only (canEdit=false). */ 306 readonly onLocalUpdateBlocked?: () => void; 307 /** Pending outbox count, reported to the server after `hello` (TASK-0095 R4.2). */ 308 readonly getOutboxPendingCount?: () => number; 309 /** A protocol `error` frame arrived (code + message, toast material). */ 310 readonly onProtocolError?: (info: { code: string; message: string }) => void; 311 /** A terminal close (4002 forbidden) — the provider will not reconnect. */ 312 readonly onClosed?: (code: number, hint: string) => void; 313 /** An inbound awareness binary frame (the canvas applies it with 314 * y-protocols `applyAwarenessUpdate` and feeds the decoded per-peer 315 * state into the provider's `awareness` store). */ 316 readonly onAwarenessUpdate?: (bytes: Uint8Array) => void; 317 /** A long-hydration notice arrived (the canvas shows "Loading page…"). */ 318 readonly onHydrating?: (pageId: string) => void; 319} 320 321/** A connected peer's presence (the awareness state, self excluded). */ 322export interface PeerInfo { 323 readonly userId: string; 324 readonly name: string; 325 readonly color: string; 326 readonly cursor: { blockId: string; offset: number; selection?: string | null } | null; 327 /** The peer's current selection (a block id or null), from awareness. */ 328 readonly selection: string | null; 329 readonly viewing: boolean; 330 /** Last frame time (ms clock) — the 90 s prune basis. */ 331 readonly lastSeen: number; 332} 333 334// --- outbox (TASK-0095 R1) ---------------------------------------------------- 335 336/** One non-editor REST mutation in the offline outbox. */ 337export interface OutboxItem { 338 /** Client-generated idempotency key (unpredictable — `key_` + 32 hex). */ 339 readonly key: string; 340 readonly method: "POST" | "PATCH" | "PUT" | "DELETE"; 341 readonly path: string; 342 readonly body?: Record<string, unknown>; 343 /** Queued at (ms clock) — the 24 h GC basis + the LWW timestamp. */ 344 readonly queuedAt: number; 345 /** The resource this mutation targets (per-resource ordering). */ 346 readonly resource: { type: string; id: string }; 347 /** Attempt count (backoff basis: 1 s → 30 s max). */ 348 readonly attempts: number; 349 /** Earliest retry time (ms clock) after a 5xx/network failure. */ 350 readonly nextAttemptAt?: number; 351 readonly lastError?: string; 352} 353 354/** Outbox execution result (the api client / middleware reports). */ 355export interface OutboxResult { 356 readonly ok: boolean; 357 readonly status?: number; 358 /** The server honored a stored idempotent response (replay, not re-run). */ 359 readonly idempotentHit?: boolean; 360 readonly error?: string; 361} 362 363/** The outbox persistence backend (apps/web implements with `idb`). */ 364export interface OutboxStore { 365 load(): Promise<readonly OutboxItem[]>; 366 save(items: readonly OutboxItem[]): Promise<void>; 367} 368 369// --- offline (TASK-0092 R5) ---------------------------------------------------- 370 371/** A Y.Doc persistence handle (y-indexeddb or a fake). */ 372export interface OfflinePersistence { 373 readonly docName: string; 374 /** Flush the current doc state to storage. */ 375 flush(): Promise<void>; 376 /** Flush (if pending) + release. */ 377 dispose(): Promise<void>; 378} 379 380/** Creates an `OfflinePersistence` (the y-indexeddb default). */ 381export interface OfflinePersistenceFactory { 382 create( 383 docName: string, 384 doc: import("yjs").Doc, 385 opts: { autoSaveInterval: number }, 386 ): Promise<OfflinePersistence>; 387} 388