import type { OutputBlockData } from '../../../../types'; import type { CollaborationStatusChangedPayload } from '../../../../types/events/editor-events'; import type { ModuleConfig } from '../../../types-internal/module-config'; import { Module } from '../../__module'; import { CollaborationStatusChanged } from '../../events'; import { createTicketSource, readTicketClaims, type TicketRequest } from '../../utils/access-pass'; import { logLabeled } from '../../utils/logger'; import { readCaretPosition } from './caret-position'; import { buildParticipants } from './participants'; import { createPresence, hasDrawableIdentity, selectDrawableStates, type Presence, type PresenceState, } from './presence'; import { createPresenceRenderer } from './presence-renderer'; import { normalizeUserId } from '../userDirectory'; import { createOperationStore, type OperationStore, type OperationStoreStats } from './operation-store'; import { createCollabProvider, RELINEAGE_REASON, STALE_LINEAGE_REASON } from './provider'; import type { CollabDocSeam, CollabOutbox, CollabProvider, CollabSocketFactory, CollabStatus, CollabStatusDetail, CollabTicketSource, SessionProtocol, } from './types'; /** * The `collaboration` block as this module reads it: the published shape plus * injection points that are deliberately NOT in `types/`. A test (and the node * conformance tier) has to hand the provider a transport and a deterministic * clock; the published config surface must not grow a WebSocket-shaped key. */ export interface CollaborationConfig { /** The document id shared with the sync service. One path segment. */ doc: string; /** Display identity shown to peers; the colour defaults from the client id. */ user?: { name: string; color?: string; }; /** * Keep a local copy of the document so edits made while disconnected * survive a reload. Opt-in: it writes document content to origin-scoped * browser storage, and that storage belongs to the BROWSER, not to a * person — see the published config docs. */ offline?: boolean; /** * Opaque, stable partition for the signed-in identity. Required whenever * `offline` is on, so one person's local copy is never handed to the next * person on the same browser. */ offlineScope?: string; /** @internal Opens the transport; defaults to the global `WebSocket`. */ socketFactory?: CollabSocketFactory; /** @internal How long to wait for the server's control frame. */ handshakeTimeoutMs?: number; /** @internal Backoff jitter source. */ random?: () => number; } /** * Origin for updates replayed out of the offline cache. Unknown to the seam's * suppression set, so the observer classifies them 'remote' and undo ignores * them — a restored document must not be undoable back to empty. */ const CACHE_ORIGIN = { source: 'blok-offline-cache' }; /** Wrapper attribute a host (or an e2e test) reads the session state off. */ const COLLAB_STATE_ATTR = 'data-blok-collab'; /** The nanoid alphabet block ids already use, so a derived id looks like one. */ const SEED_ID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'; const SEED_ID_LENGTH = 10; const FNV_OFFSET_BASIS = 0x811c9dc5; const FNV_PRIME = 0x01000193; /** * FNV-1a over a string. Deterministic across peers and runtimes — which is the * only property that matters here. * @param input - the string to hash */ const fnv1a = (input: string): number => Array.from(input).reduce( (hash, character) => Math.imul(hash ^ character.charCodeAt(0), FNV_PRIME) >>> 0, FNV_OFFSET_BASIS ); /** * The id of the one block a peer writes into a document that synced empty. * * Derived from the document id, so two peers that reach an empty document at * the same moment write the SAME id: the Y.Map set converges last-writer-wins * and the doubled order entry is dropped by the doc's first-occurrence-only * order derivation. The race lands one paragraph, not one per peer — the same * trick `restoreDefaultBlockIfDocEmptied` plays with the removed block's id. * @param doc - the collaboration document id */ const seedBlockId = (doc: string): string => Array.from({ length: SEED_ID_LENGTH }, (_unused, slot) => SEED_ID_ALPHABET[fnv1a(`${slot}:${doc}`) % SEED_ID_ALPHABET.length]).join(''); /** * Whether a connection ticket grants writes. Only an explicit `write: false` * denies: a ticket without the claim, or one we cannot read, leaves the editor * editable — the server enforces the grant regardless, and refusing to let * someone type because we could not parse their ticket is the worse failure. * @param token - the raw ticket the host's endpoint minted */ const grantsWrite = (token: string): boolean => readTicketClaims(token)?.write !== false; /** * Turns the `server` option into the document's sync URL. * * A path-form value (`/api/blok`) resolves against the page origin, and the * scheme steps up to its WebSocket twin. Trailing slashes are counted rather * than matched with `/\/+$/`, which retries at every offset and goes quadratic * on a long run — the same reason `expandServerConfig` counts. * @param server - the `server` option as the host wrote it * @param doc - the collaboration document id */ const syncUrl = (server: string, doc: string): string => { const trailingSlashes = Array.from(server) .reduce((count, character) => (character === '/' ? count + 1 : 0), 0); const base = new URL(server.slice(0, server.length - trailingSlashes), window.location.origin); const scheme = base.protocol === 'https:' || base.protocol === 'wss:' ? 'wss:' : 'ws:'; const path = base.pathname === '/' ? '' : base.pathname; return `${scheme}//${base.host}${path}/sync/${encodeURIComponent(doc)}`; }; /** * Awareness map entries as the presence selector reads them, pulled lazily so * a fabricated map is never materialised past the cap. * @param states - the awareness map */ function* presenceStates(states: Map>): Generator { for (const [clientId, state] of states) { yield { clientId, state }; } } /** The `save` block of the published status payload. */ type SaveState = NonNullable; /** * What a store that cannot even be read reports. Its rows are unknowable, so * the counts are zero and the verdict is left to the module's own durability * latch — which the same failure sets. */ const UNREADABLE_STATS: OperationStoreStats = { pendingOperations: 0, pendingBytes: 0, quarantinedOperations: 0, appendInFlight: false, storageUnavailable: true, updateLost: true, }; /** * Whether two save states say the same thing. The coalescing gate: a retry * timer ticking, or a remote update landing, must not publish an event that * repeats what the host already has. * * Compared key by key rather than field by field. Naming the six members * individually leaves terms no reachable state can move on their own — a row * count never changes without its byte total — so those terms could be deleted * with every test still green. The key COUNT is the half that catches an * optional member appearing beside otherwise identical numbers, which is what * a `reason` arriving does. * @param left - the last published save state * @param right - the freshly read one */ const sameSaveState = (left: SaveState, right: SaveState): boolean => { const keys = Object.keys(left) as (keyof SaveState)[]; return keys.length === Object.keys(right).length && keys.every((key) => left[key] === right[key]); }; interface CollabSettings { doc: string; url: string; user: { name?: string; color?: string } | undefined; /** Attribution id from `config.user`, published so peers can name this editor. */ userId: string | undefined; offline: boolean; /** Identity partition the local copy is keyed by. Empty unless `offline`. */ offlineScope: string; ticketEndpoint: string | undefined; socketFactory: CollabSocketFactory | undefined; handshakeTimeoutMs: number | undefined; random: (() => number) | undefined; } /** * @module Collaboration * * Owns the sync-first load: with `collaboration` configured, core seeds * nothing — not the Yjs document, not the default empty block — and this module * drives the editor through connecting → connected off the provider's status. * * Three rules carry it: * * 1. ABSENT IS FREE. No `collaboration` key and the constructor returns before * allocating anything: no provider, no awareness, no socket. * 2. NEVER EDITABLE UNSYNCED. Until the first SyncStep2 lands, the editor is * read-only, because an edit made against a document that never carried * server lineage has nowhere to go. * 3. AFTER THE FIRST SYNC, OFFLINE IS STILL EDITABLE. The document now carries * server lineage, so a reconnect ships the diff. Only a TERMINAL provider — * one that will not reconnect — drops the editor back to read-only. */ export class Collaboration extends Module { /** Non-null exactly when collaboration is configured. The whole gate. */ private settings: CollabSettings | null = null; private provider: CollabProvider | null = null; private status: CollabStatus = 'connecting'; /** Latched: the document has carried server lineage since the first sync. */ private firstSynced = false; /** The provider gave up; no reconnect will ever ship pending edits. */ private terminal = false; /** * What the provider said about the LAST transition — why it closed, when it * will retry. Republished with every peer-list change, which is why it is a * field: the awareness hook emits with no transition of its own. */ private lastStatusDetail: CollabStatusDetail | undefined = undefined; /** * How many of this module's own observer suspensions are outstanding. * * `ModificationsObserver.disable()/enable()` keeps a suspension count of its * own, so this one is only about keeping the module to a SINGLE outstanding * suspension on the observer. The degrade render is the one window here that * spans an await — a tool with a genuinely async `render` holds it open for as * long as it likes — and a first sync landing inside it drops the degraded * view, which suspends again. */ private observerSuspensions = 0; /** * Bumped by every lineage reset. An in-flight `handleStatus` captures it * before it awaits and abandons its tail if the document was swapped * underneath — see the guard there. */ private resetGeneration = 0; /** An explicit `write: false` claim on the connection ticket. */ private writeDenied = false; /** What the host passed as `config.data`, shown read-only while offline. */ private lastKnown: OutputBlockData[] = []; /** The last-known DOM is on screen and has to go before remote blocks land. */ private degraded = false; /** Last-known is rendered at most once per unsynced lifetime. */ private degradeRendered = false; private awarenessUnhook: (() => void) | null = null; /** * The local copy plus the outbox. Present for every collaboration session: * without `offline` it runs in memory, which is where the queue contract * still has to hold. */ private store: OperationStore | null = null; /** * Origins this module applied FROM the network. The unfiltered tap sees * local and remote updates alike and has no other way to tell them apart. */ private readonly remoteOrigins = new WeakSet(); /** * The wire protocol local edits are routed by: the adopted copy's, then the * one the last completed sync negotiated. v1 has no receipt and no outbox * row; v2 journals every edit before it may be sent. */ private protocol: SessionProtocol = 'v1'; /** * An update the document already shows never reached the store, so the copy * has a hole in it and every later struct from this client depends on the * missing one. Latched for the session: editing stops and a recovery export * is what comes next. */ private durabilityLost = false; /** * A v1 server was selected while durable rows were still waiting. They are * KEPT — v1 can neither acknowledge nor reject them (protocol section 2) — so * editing stops until a connection that can drain them arrives. Recomputed on * every completed sync, unlike `durabilityLost`: a v2 reconnect makes * durability available again. */ private retainedUnderV1 = false; /** * Latched when the boot adopted a cached document. It stands in for * `firstSynced` everywhere editing is decided: the cache only ever becomes * adoptable behind a VALIDATED control frame, so an adopted document carries * server lineage exactly as a synced one does. */ private cacheAdopted = false; /** The unfiltered tap: every peer's update, for the local copy. */ private cacheUnhook: (() => void) | null = null; /** The filtered tap: this editor's own edits, for the outbox. */ private outboxUnhook: (() => void) | null = null; /** * Whether the cache already holds this lineage's history. Rows can only be * stamped once a validated tag names the lineage, so everything the document * held before that moment — the whole first sync — needs one snapshot write. */ private cacheSeeded = false; private flushOnPageHide: (() => void) | null = null; /** * The last save state published, so an identical one emits nothing. Absent * until the first store read answers. */ private save: SaveState | undefined = undefined; /** Where the server journalled the last acknowledged operation. */ private serverSequence: string | undefined = undefined; /** * Whether the LAST quarantine was a refusal rather than a room reset. * * Rewritten by every quarantine, never latched: a rejection earlier in the * session must not go on blaming itself for a later lineage reset, which is * a different cause entirely. */ private quarantineRejected = false; /** * Attached only in memory mode, and only while rows are waiting. An offline * session keeps its rows on disk, so a reload loses nothing and a confirm * dialog would be a lie. */ private unloadGuard: ((event: BeforeUnloadEvent) => void) | null = null; /** * The post-ready replay. `EventsDispatcher` has no replay and the session * starts connecting during `load`, so a host that subscribes once the ready * promise resolves would otherwise never hear the state it is already in. */ private readyReplay: number | null = null; /** Publishes this editor's presence and draws everybody else's. */ private presence: Presence | null = null; /** * Client id to verified actor id, from the room's `identities` frame. * Empty until the first frame lands, so a participant published before * then keys on its own client id. See `onVerifiedIdentities` below for why * this is replaced whole, never merged into. * * The frame and awareness states arrive on independent schedules, so the * first status emitted after joining can report `userId: null` for a peer * who does have one; a later emit, once the frame lands, corrects it. This * is deliberate, not a bug to fix — see `sync-first-load.test.ts`'s lazy * merge test. */ private identities = new Map(); /** * @param moduleConfig - the editor config and the shared event bus */ constructor(moduleConfig: ModuleConfig) { super(moduleConfig); // Typed as the widened shape, not asserted into it: the published // `collaboration` type carries none of the injection points, and the // structural assignment is what makes them readable here. const collaboration: CollaborationConfig | undefined = this.config.collaboration; const server = this.config.server; // Zero cost when absent. `server` is guaranteed by the config setter's // refusal matrix; the check is what makes that guarantee visible here. if (collaboration === undefined || server === undefined) { return; } this.settings = { doc: collaboration.doc, url: syncUrl(server, collaboration.doc), user: collaboration.user, userId: normalizeUserId(this.config.user?.id) ?? undefined, offline: collaboration.offline === true, // Core refuses `offline: true` without a non-empty scope, so the fallback // is only ever reached on a session that opens no database at all. offlineScope: collaboration.offlineScope ?? '', ticketEndpoint: this.config.ticket, socketFactory: collaboration.socketFactory, handshakeTimeoutMs: collaboration.handshakeTimeoutMs, random: collaboration.random, }; } /** True when this editor is a collaboration session. */ public get isEnabled(): boolean { return this.settings !== null; } /** * True while the last-known DOM (`config.data`) stands in for a document that * has never synced. The Renderer reads it: a view rebuild normally re-renders * the shared document, but while this is on the document is empty on purpose * and the stand-in is the only content there is. */ public get isDegraded(): boolean { return this.degraded; } /** * The collaboration half of read-only arbitration (`ReadOnly` reads it): * unsynced, write-denied, or terminally disconnected means "not editable", * whatever the host asked for. */ public get isEditingBlocked(): boolean { return this.settings !== null && (!(this.firstSynced || this.cacheAdopted) || this.writeDenied || this.terminal || this.durabilityLost || this.retainedUnderV1); } /** * Whether the document carries a lineage the store can stamp rows with — the * first sync, or a cache adoption. Before that the store REFUSES a local * edit rather than parking it, and by contract there is none: editing is * blocked until one of the two has happened. */ private get hasLineage(): boolean { return this.firstSynced || this.cacheAdopted; } /** * @param origin - the transaction origin an update arrived with */ private isRemoteOrigin(origin: unknown): boolean { return typeof origin === 'object' && origin !== null && this.remoteOrigins.has(origin); } /** * The store as the provider drains it, with a save-state read behind the two * writes the provider commits itself. The store's own `onCommitted` hint is * cross-tab only, so without this an acknowledgement retiring a row would * change nothing a host can see until the next connection transition. * * Every method delegates straight through, so the seam's ORDERING CONTRACT * still holds; the reads are fire-and-forget so the drain is not held up by * one. * @param store - this session's operation store */ private outboxSeam(store: OperationStore): CollabOutbox { return { appendLocal: (update) => store.appendLocal(update), oldestPending: () => store.oldestPending(), acknowledge: (operationId) => store.acknowledge(operationId).then(() => { void this.refreshSave(); }), quarantineLineage: (lineage, reason, snapshot) => store.quarantineLineage(lineage, reason, snapshot).then((moved) => { // Read off THIS quarantine's own reason. TWO of the four are not // refusals: a room reset, and a row of a lineage this session no // longer serves, which the drain sweeps without anyone judging it. // An oversized frame IS one — the client applying the verdict the // server's own `oversized-update` rejection carries. this.quarantineRejected = reason !== RELINEAGE_REASON && reason !== STALE_LINEAGE_REASON; void this.refreshSave(); return moved; }), onCommitted: (listener) => store.onCommitted(listener), }; } /** * Reads the local copy and publishes the save state when it changed. * @param force - publish even when nothing changed; the post-ready replay */ private async refreshSave(force = false): Promise { const store = this.store; if (store === null || this.isDestroyed) { return; } // A database that has gone is exactly the case that latches durability, // and it is also the case where the read itself throws. Returning here // would leave editing stopped with the host never told why. const stats = await store.stats().catch(() => UNREADABLE_STATS); if (this.isDestroyed) { return; } const next = this.saveStateOf(stats); this.syncUnloadGuard(stats.pendingOperations > 0); if (!force && this.save !== undefined && sameSaveState(this.save, next)) { return; } this.save = next; this.emitStatus(); } /** * What the local copy says about this browser's unsent work. * * `pendingOperations` comes from the store's counter, which counts rows * `oldestPending` refuses to hand out while storage is unavailable (Task * 4.1's contract). Two decisions read it — `pending` over `saved` here, and * the unload guard in `refreshSave` — and both are shadowed: the failure * that inflates the count latches `durabilityLost`, and `blocked` is the * first branch. Nothing else may branch on it. * @param stats - a fresh read of the store */ private saveStateOf(stats: OperationStoreStats): SaveState { const counts = { pendingOperations: stats.pendingOperations, pendingBytes: stats.pendingBytes, quarantinedOperations: stats.quarantinedOperations, ...(this.serverSequence === undefined ? {} : { serverSequence: this.serverSequence }), }; // The module's latch, not the store's `updateLost`: the recovery clear that // follows a lost write UN-poisons the store, so its own signal is back to // healthy while editing stays blocked for the rest of the session. if (this.durabilityLost) { return { state: 'blocked', reason: 'local-storage-failed', ...counts }; } // v1 acknowledges nothing (protocol section 2), so a v1 session can never // call an edit saved. Before a sync has negotiated anything the answer is // the same, with nobody to blame for it. if (this.protocol !== 'v2') { return { state: 'unavailable', ...(this.hasLineage ? { reason: 'legacy-protocol' as const } : {}), ...counts, }; } if (stats.quarantinedOperations > 0) { return { state: 'quarantined', ...(this.quarantineRejected ? { reason: 'operation-rejected' as const } : {}), ...counts, }; } // `appendInFlight` as well as the count: the store counts an append from // the CALL, and the window before it commits is the one moment a row // exists nowhere and calling the document saved would be wrong. if (stats.pendingOperations > 0 || stats.appendInFlight) { return { state: 'pending', ...counts }; } return { state: 'saved', ...counts }; } /** * Mirrors the save queue's unload guard (`utils/persistence.ts`): warn before * a reload that would throw work away. Memory mode only — an offline * session's rows are on disk and go out on the next boot. * @param pending - whether rows are still waiting to be taken */ private syncUnloadGuard(pending: boolean): void { const hasWork = pending && this.settings?.offline !== true && !this.isDestroyed; if (hasWork === (this.unloadGuard !== null)) { return; } if (this.unloadGuard !== null) { window.removeEventListener('beforeunload', this.unloadGuard); this.unloadGuard = null; return; } this.unloadGuard = (event: BeforeUnloadEvent): void => event.preventDefault(); window.addEventListener('beforeunload', this.unloadGuard); } /** * Protocol section 2: a client that negotiated v1 may claim no durable * acknowledgement. Rows a v2 session journalled are kept and sent to nobody, * and editing stops until a v2 connection can drain them. * * Returns a promise ONLY when the answer needs a database read, so the * caller can reach arbitration without a tick on every other transition. * @param status - the transition being published * @param priorProtocol - what local edits were routed by before this sync */ private applyDurabilityHold(status: CollabStatus, priorProtocol: SessionProtocol): Promise | undefined { const store = this.store; // Only a completed sync has negotiated anything: `recordCacheMeta` is what // reads the selected protocol, and it runs on 'connected' alone. if (status !== 'connected' || store === null) { return undefined; } if (this.protocol === 'v2') { // v2 drains whatever is waiting, so nothing is held back. this.retainedUnderV1 = false; return undefined; } // Only a tab that COULD have journalled rows owes the read: `appendLocal` // refuses unless this tab negotiated v2, so a session that has only ever // been v1 has nothing to look for. Worth the branch because the read is a // database round trip on the path that LIFTS read-only — an ordinary // connect must reach arbitration in the tick it always has. if (priorProtocol !== 'v2' && !this.retainedUnderV1) { return undefined; } // `oldestPending`, never `stats().pendingOperations` — that counter // includes rows the drain is never handed. return store.oldestPending().then((row) => { this.retainedUnderV1 = row !== null; }, () => { // Fail CLOSED. A read that cannot answer "are rows waiting?" must not be // read as "no": the same rejection also skips this transition's // arbitration, so editing would stay possible on a session that may be // holding work v1 can never receipt. Same principle as `recordSaveState`, // which reports an unreadable store as zero rows and leaves the verdict // to the module's own latch. this.retainedUnderV1 = true; }); } /** * @param write - a store write already in flight */ private captured(write: Promise): void { void write.catch((thrown) => this.loseDurability(thrown)); } /** * A write did not land, so the copy on disk is missing something the * document already shows. Editing stops, and the copy goes with it: the * store's own refusal lives in memory and would not survive a reload, so * without the clear the next boot adopts a broken copy as editable. * @param thrown - what the store refused with */ private async loseDurability(thrown: unknown): Promise { if (this.durabilityLost) { return; } this.durabilityLost = true; logLabeled('collaboration could not store an update; editing is blocked', 'warn', thrown); // The clear empties the copy, so the next `connected` owes it a fresh // snapshot — a meta recorded over an empty copy is what makes a later boot // adopt an empty document as editable. this.cacheSeeded = false; // Nothing may be asked of a closed store, and nothing of a torn-down // editor: every module is marked destroyed before any `destroy()` body // runs, so a rejection arriving after teardown would re-arbitrate a // ReadOnly that is already gone. It costs the recovery clear in that one // window — a write that fails during the final flush leaves the copy // adoptable and missing that row — because refusing a closed store is the // stronger rule. if (this.isDestroyed) { return; } if (this.store !== null) { await this.store.clearAdoptable().catch((failed) => { logLabeled('collaboration could not drop its local copy', 'warn', failed); }); } await this.applyArbitration(); await this.refreshSave(); } /** * Starts the session in place of core's ordinary render. * * Resolves immediately: readiness must NOT wait on the network, or an editor * that cannot reach the service would never finish booting. It comes up * empty and read-only, and the blocks arrive when the document does. * @param lastKnown - `config.data`, kept for the offline degrade path */ public async load(lastKnown: OutputBlockData[]): Promise { const settings = this.settings; if (settings === null) { return; } this.lastKnown = lastKnown; this.setStateAttribute('connecting'); // Whatever the cache setting: a dying tab has to land the write buffer on // the wire, and the cache is opt-in. this.flushOnPageHide = (): void => { this.Blok.YjsManager.flushPendingBlockWrites(); // The document first, then the goodbye. Nothing else tells the room this // tab is gone: a reload comes back under a NEW client id, so the entry // left behind is drawn as a second person for the 30s a peer takes to // sweep it. Must be the synchronous send — a queued frame dies here. this.provider?.announceDeparture(); }; window.addEventListener('pagehide', this.flushOnPageHide); // Before subscribing: `onAwarenessChange` throws until awareness exists. // Collab-gated, so "absent = zero cost" still holds. this.Blok.YjsManager.enableAwareness(); this.awarenessUnhook = this.Blok.YjsManager.onAwarenessChange(() => this.emitStatus()); // The room never sends this editor its own state back, and a name off the // wire is refused for the local id anyway — so a host that names itself // through `collaboration.user` alone teaches the directory directly. this.Blok.UserDirectory.identify(settings.user?.name); this.presence = createPresence({ yjs: this.Blok.YjsManager, user: settings.user, userId: settings.userId, currentBlockId: () => this.Blok.BlockManager.currentBlock?.id ?? null, currentCaret: () => { const block = this.Blok.BlockManager.currentBlock; return block === undefined ? null : readCaretPosition(block.id, block.inputs, window.getSelection()); }, renderer: createPresenceRenderer({ // The WRAPPER, not the redactor: the redactor is under the // modifications observer, and this host is only watched for reflow. host: this.Blok.UI.nodes.wrapper, resolveHolder: (blockId) => this.Blok.BlockManager.getBlockById(blockId)?.holder ?? null, resolveInputs: (blockId) => this.Blok.BlockManager.getBlockById(blockId)?.inputs ?? [], isHidden: () => this.Blok.ReadOnly.isControlsHidden, translate: (key) => this.Blok.I18n.t(key), // The same test `publishUser` applies to this editor's own name, so // the reader occupies a silhouette here exactly when their peers give // them one. isLocalAnonymous: () => (settings.user?.name ?? '').trim() === '', }), // Lazy: `this.provider` is not created until after `presence.start()` // below, so a captured reference here would close over `null` forever. onActivity: () => this.provider?.sendActivity() ?? false, }); this.presence.start(); // Awaited, unlike the network: this is a local-disk read, and the blocks // it restores have to be on screen before the first frame can race them. const adopted = await this.adoptCache(settings); this.provider = createCollabProvider({ url: settings.url, docId: settings.doc, yjs: this.seam(), ticketSource: this.ticketSource(), socketFactory: settings.socketFactory, handshakeTimeoutMs: settings.handshakeTimeoutMs, random: settings.random, // The whole point of the pre-seed: a cached document must have its // lineage COMPARED against the first control frame, never overwritten by // it. See the provider's `initialLineage`. initialLineage: adopted, // Wrapped rather than passed straight through: the two writes the // provider commits itself are the only wake this tab gets when a row is // retired. It is also what makes the provider offer v2 at all. outbox: this.store === null ? undefined : this.outboxSeam(this.store), keepsLocalCopy: settings.offline, onOperationAcknowledged: (serverSequence) => { this.serverSequence = serverSequence; }, // REPLACE, never merge: the frame is the room's whole map, sent again // on every change, so an id missing from a later frame means the room // revoked it — e.g. `CollabRoom.RecordAwarenessOwnersLocked` sends a // SHRUNK map when a client id's ownership moves to a differently // verified (or unverified) membership. Merging would keep drawing that // connection as the person it used to belong to. `buildParticipants` // only reads the field at emit time, so reassigning it here is enough // — nothing else has to be told. onVerifiedIdentities: (identities) => { this.identities = new Map(identities.map(({ clientId, actorId }) => [clientId, actorId])); this.emitStatus(); }, // The provider issues two store writes of its own — the post-drain // residual append and a lineage quarantine — and neither goes through // `captured`. Same policy either way: block editing and drop the copy. onOutboxFailure: (thrown) => { void this.loseDurability(thrown); }, onStatus: (status, detail) => { void this.handleStatus(status, detail); }, }); this.provider.connect(); // A MACROTASK: the ready promise resolves on the microtasks that follow // this call, so a timer lands after it — which is the first moment a host // that awaited `isReady` can be listening. this.readyReplay = window.setTimeout(() => { this.readyReplay = null; void this.refreshSave(true); }, 0); } /** * Opens the offline cache and replays what it holds, returning the lineage * the restored document belongs to (or undefined when nothing was adopted). * * Adoption is what makes an offline editor EDITABLE before it has spoken to * the server, so the gate is narrow: the cache hands back a document only * behind a validated control frame it recorded earlier, which is the same * "carries server lineage" test the first sync would apply. * @param settings - this session's settings */ private async adoptCache(settings: CollabSettings): Promise { // Partitioned by identity: the copy belongs to the browser, so an unscoped // key hands the next person on a shared profile the previous person's // document, drawn before any connection can refuse it. Without `offline` // the scope is null, which is the store's memory mode: no database. const store = createOperationStore({ url: settings.url, doc: settings.doc, offlineScope: settings.offline ? settings.offlineScope : null, }); this.store = store; const contents = await store.open(); // Offline was asked for and the database would not open. The queue runs in // memory so this tab's work survives until it is exported, but nothing in // it is durable — and an edit that cannot be stored must not be sent. if ((await store.stats()).storageUnavailable) { this.durabilityLost = true; } // The unfiltered tap, because remote updates have to be stored too — // `onDocUpdate` hides exactly what a reload needs. It skips its OWN // origin: the adoption replay below applies rows through this same // document, and without the check every boot would write the whole // document back as a fresh row. this.cacheUnhook = this.Blok.YjsManager.onAnyDocUpdate((update, origin) => { // Two other things happen to stop the replay here today — it runs before // `cacheAdopted`, so `hasLineage` is false, and `applyRemoteUpdate` puts // this origin in the DocumentStore's remote set, so the outbox tap never // sees it — which is why deleting this line breaks no test. Neither is // the law: this line is, and a reorder that moved the replay would need // it back. if (origin === CACHE_ORIGIN) { return; } if (this.isRemoteOrigin(origin)) { // Only a session that keeps a copy on disk has a reason to store what // peers sent; in memory mode nothing would ever read it back. if (settings.offline) { this.captured(store.appendRemote(update)); } return; } // A v2 session journals its own edits through the outbox tap below, in // one transaction with the cache row; a second row here would store the // same update twice. if (this.protocol !== 'v2' && this.hasLineage) { this.captured(store.appendCached(update)); } }); // The SECOND tap, and it must stay a second one. `onDocUpdate` hides // everything `applyRemoteUpdate` brought in, so this is the only hook that // sees this editor's own edits and nobody else's — one tap over // `onAnyDocUpdate` would journal every peer's work into this browser's // outbox and send it back to the room as ours. Neither tap belongs in the // provider's per-socket seam, which drops a local edit outright when the // socket is absent or not ready: exactly when an offline edit is made. this.outboxUnhook = this.Blok.YjsManager.onDocUpdate((update) => { if (this.protocol !== 'v2' || !this.hasLineage || this.store === null) { return; } // The store's `onCommitted` hint never fires for the tab that wrote the // row, so this is the only wake the drain gets for our own edit. this.captured(this.store.appendLocal(update).then(() => { this.provider?.drain(); void this.refreshSave(); })); // AFTER the append has started, never before: the store counts the // transaction from the call, so this read is the one that reports work // no row carries yet. void this.refreshSave(); }); if (contents === null) { return undefined; } try { for (const update of contents.updates) { this.Blok.YjsManager.applyRemoteUpdate(update, CACHE_ORIGIN); } } catch (thrown) { // A row yjs cannot decode would otherwise fail `load()` on every reload // until site data is cleared. The rows before it may already be in the // document, so it is thrown away whole — the same lever a lineage // change pulls, which also drops the cache — and this boot goes on // unadopted: read-only until the first sync, like a boot with no cache. logLabeled(`collaboration discarded an unreadable offline copy of ${settings.doc}`, 'warn', thrown); this.resetForRelineage(); return undefined; } // The member's last known write verdict: without it an offline reload // hands a read-only member an editable document whose edits the server // will refuse the moment it reconnects. Only with a ticket source, which // is the one thing that can ever re-derive it — restored without one it // would hold, and be re-persisted, for the rest of this browser's life. this.writeDenied = settings.ticketEndpoint !== undefined && contents.meta.writeDenied; // The protocol the copy was written under. A v1-only deployment must not // come back from a reload accumulating outbox rows nothing can drain. this.protocol = contents.meta.protocol; this.cacheAdopted = true; this.cacheSeeded = true; return contents.meta.lineage; } /** * Stops the session. Runs BEFORE `YjsManager.destroy` (module order in * `modules/index.ts`), so the provider's teardown still has a live document * and a live awareness to clear. */ public destroy(): void { if (this.settings === null) { return; } // Presence first: awareness prunes a vanished peer only after 30 seconds, // so the outlines and the gutter faces have to come down now, not then. this.presence?.stop(); this.presence = null; this.awarenessUnhook?.(); this.awarenessUnhook = null; // BEFORE the provider comes down: the coalescing write buffer may still // hold the last thing typed, and YjsManager.destroy — which flushes it — // runs AFTER this module, once nothing listens for the wire or the cache. this.Blok.YjsManager.flushPendingBlockWrites(); this.provider?.destroy(); this.provider = null; this.cacheUnhook?.(); this.cacheUnhook = null; this.outboxUnhook?.(); this.outboxUnhook = null; if (this.flushOnPageHide !== null) { window.removeEventListener('pagehide', this.flushOnPageHide); this.flushOnPageHide = null; } if (this.readyReplay !== null) { window.clearTimeout(this.readyReplay); this.readyReplay = null; } this.syncUnloadGuard(false); if (this.store !== null) { this.captured(this.store.close()); this.store = null; } } /** * The real YjsManager for every method, with ONE interception. * * `applyRemoteUpdate` is the only moment between "the first sync's bytes * arrived" and "the blocks exist": BlockYjsSync materialises them * synchronously from the document observer inside that call. So the degraded * last-known DOM is dropped here, not on the `connected` status that follows * it — by then the remote blocks would already be sitting next to it. */ private seam(): CollabDocSeam { const yjs = this.Blok.YjsManager; return { applyRemoteUpdate: (update, origin) => { // The only place a server update enters this document, so the only // place the unfiltered tap can learn which origins are not ours. if (typeof origin === 'object' && origin !== null) { this.remoteOrigins.add(origin); } this.dropDegradedView(); yjs.applyRemoteUpdate(update, origin); }, onDocUpdate: (callback) => yjs.onDocUpdate(callback), onAnyDocUpdate: (callback) => yjs.onAnyDocUpdate(callback), getStateVector: () => yjs.getStateVector(), encodeStateAsUpdate: (stateVector) => yjs.encodeStateAsUpdate(stateVector), enableAwareness: () => yjs.enableAwareness(), setAwarenessField: (field, value) => yjs.setAwarenessField(field, value), getAwarenessStates: () => yjs.getAwarenessStates(), onAwarenessChange: (callback) => yjs.onAwarenessChange(callback), onAwarenessUpdate: (callback) => yjs.onAwarenessUpdate(callback), encodeAwarenessUpdate: (clients) => yjs.encodeAwarenessUpdate(clients), encodeLocalAwarenessDeparture: () => yjs.encodeLocalAwarenessDeparture(), applyAwarenessUpdate: (update, origin) => yjs.applyAwarenessUpdate(update, origin), clearRemoteAwarenessStates: () => yjs.clearRemoteAwarenessStates(), resetForRelineage: () => this.resetForRelineage(), // The relineage quarantine walks the outbox, so the last thing typed has // to be a row before it runs — this is the same flush `destroy` performs. flushPendingWrites: () => yjs.flushPendingBlockWrites(), }; } /** * The room was reset: throw this session's document away and start over. * * The DOM goes FIRST and the document second. `BlockManager.clear` runs block * teardown, and any stray write it provokes must land in the document we are * discarding — landing it in the FRESH one would put pre-reset content back on * the wire on the very next connection, which is the leak this whole reset * exists to prevent. `skipYjsSync` keeps the clear itself out of the document * either way; the ordering is the belt to that brace. * * `clear` is declared async but its body never awaits, so the holders are gone * before this returns — the same contract `dropDegradedView` relies on. The * provider reconnects immediately after, and the room's blocks materialise * through the ordinary remote path. */ private resetForRelineage(): void { const { BlockManager, YjsManager } = this.Blok; this.suspendObserver(); void BlockManager.clear(false, { skipYjsSync: true }); this.resumeObserver(); this.degraded = false; // Awareness subscriptions bind to the Awareness INSTANCE, and the reset // builds a new one. Unhook before and re-subscribe after, or the published // peer list silently stops updating for the rest of the session. Presence // rides the same instance AND caches which client id is local — the new // Awareness binds a new one — so it is stopped and started, not kept. this.presence?.stop(); this.awarenessUnhook?.(); YjsManager.resetForRelineage(); this.awarenessUnhook = YjsManager.onAwarenessChange(() => this.emitStatus()); this.presence?.start(); // The document no longer carries server lineage, so decision 7's // "offline is still editable" asymmetry no longer applies: this is an // unsynced document again, and unsynced is read-only. this.firstSynced = false; // The cached rows belong to the lineage that was just discarded. Dropping // them here is what stops the next boot adopting a dead room's history. this.cacheAdopted = false; this.cacheSeeded = false; if (this.store !== null) { this.captured(this.store.clearAdoptable()); } this.resetGeneration += 1; void this.applyArbitration(); } /** * Wraps the shared ticket source so the `write` claim is read on every mint — * a refreshed ticket can downgrade a session that started with write access. */ private ticketSource(): CollabTicketSource | undefined { const settings = this.settings; if (settings === null || settings.ticketEndpoint === undefined) { return undefined; } const mint = createTicketSource(settings.ticketEndpoint, { doc: settings.doc }); // Load-bearing forward of `request`: the provider's one retry after a 4401 // asks for a fresh mint, and a wrapper that swallowed the argument would // hand the rejected ticket straight back. return async (request?: TicketRequest): Promise => { const token = await mint(request); const denied = !grantsWrite(token); if (denied !== this.writeDenied) { this.writeDenied = denied; await this.applyArbitration(); // A grant that arrives LATE still has to find the room habitable. The // first-sync seed is skipped for a member who may not write, and an // editor that came up empty has no block to type in and no gesture that // makes one — so the moment writes are granted, seed the empty document // the same way the first sync would have. this.seedEmptyDocument(); } return token; }; } /** * The state machine. Every transition is driven from the provider's status; * nothing here inspects the socket. * @param status - the provider's new connection state * @param detail - why, when the provider had something to say about it */ private async handleStatus(status: CollabStatus, detail?: CollabStatusDetail): Promise { if (this.isDestroyed) { return; } const isFirstSync = status === 'connected' && !this.firstSynced; const generation = this.resetGeneration; this.status = status; // REPLACED, never merged — including with `undefined`. A merge would carry // the previous transition's `retryInMs` onto the `connected` that ended the // wait, telling the host a live session is about to reconnect. this.lastStatusDetail = this.discardCacheIfOversized(detail); // 'error' is the provider's last word — it never reports again — so this // only ever latches on. this.terminal = status === 'error'; this.firstSynced = this.firstSynced || status === 'connected'; // Read before `recordCacheMeta` overwrites it: only a session that used to // route edits through the outbox can be holding rows a v1 server cannot take. const priorProtocol = this.protocol; // BEFORE anything can observe the transition: `emitStatus` reaches host // listeners, and a listener that writes to the document would produce a // local edit the store has no lineage to stamp yet. this.recordCacheMeta(status); this.setStateAttribute(status); this.emitStatus(); // BEFORE arbitration, which is what lifts read-only: a session that starts // editing and only then learns it negotiated v1 has already produced local // updates it has nowhere to route. const hold = this.applyDurabilityHold(status, priorProtocol); if (hold !== undefined) { await hold; if (this.isDestroyed) { return; } } await this.applyArbitration(); // Arbitration re-renders, so a lineage reset can land while it is in // flight. Everything below writes to the document or the DOM, and doing so // for a transition that belongs to a document we have since thrown away // seeds a stale block into the FRESH one — which the next connection then // broadcasts into the reset room. if (this.resetGeneration !== generation || this.isDestroyed) { return; } // A FOLLOW-UP event, not the one above: the counts and the negotiated // protocol need a store read, and `emitStatus` has to stay synchronous // with the transition. void this.refreshSave(); if (isFirstSync) { this.seedEmptyDocument(); return; } if (status === 'offline' || status === 'error') { await this.renderLastKnown(); } } /** * An oversized update that ended the session is in the cache too: every * later boot would replay it, and the resync answer would carry it back into * the same refusal — a permanent lockout for this browser. Dropping the cache * makes the next load sync from the room, as it would without one. * @param detail - what the provider said about the transition */ private discardCacheIfOversized(detail: CollabStatusDetail | undefined): CollabStatusDetail | undefined { if (this.store === null || this.settings?.offline !== true || detail?.error !== 'oversized-update') { return detail; } this.captured(this.store.clearAdoptable()); const note = 'the offline copy was discarded so the next load syncs from the server'; const reason = detail.reason === undefined || detail.reason === '' ? note : `${detail.reason}; ${note}`; return { ...detail, reason }; } /** * Records the working-set tag the cache adopts behind. * * Written on `connected` — a completed sync, not merely a validated control * frame — so the gate the cache enforces is the strongest one available: a * session that never finished syncing leaves nothing adoptable behind, and an * adopted document is one the server has actually agreed with. * @param status - the transition being published */ private recordCacheMeta(status: CollabStatus): void { const store = this.store; const tag = this.provider?.tag; if (status !== 'connected' || store === null || tag === undefined || tag === null) { return; } // ONE call, not a chain: the store orders the meta and the snapshot // internally, so the pair survives an editor torn down in between — which // is exactly when the seed matters, since it is what the next boot adopts. // Skipped without `offline` as well as when already seeded: the store runs // in memory there and discards the snapshot at `db === null`, so encoding // the whole document would cost a full serialisation on the first-sync // path for nothing. const snapshot = this.cacheSeeded || this.settings?.offline !== true ? undefined : this.Blok.YjsManager.encodeStateAsUpdate(); this.protocol = this.provider?.protocol ?? 'v1'; this.captured(store.recordSession(tag, this.writeDenied, this.protocol, snapshot)); this.cacheSeeded = true; } /** * Shows `config.data` read-only while the first sync has never happened — * "here is what we last saw", not an editable document. The Yjs document is * left untouched (`skipYjsSync`), so nothing rendered here can ever be * mistaken for content the server sent. */ private async renderLastKnown(): Promise { // `cacheAdopted` belongs in this guard as much as `firstSynced`: the // degrade CLEARS the editor before rendering, so running it over a restored // document would swap real offline work for a stale `config.data` snapshot. if (this.firstSynced || this.cacheAdopted || this.degradeRendered || this.lastKnown.length === 0) { return; } this.degradeRendered = true; this.degraded = true; const { BlockManager, Renderer } = this.Blok; this.suspendObserver(); try { await BlockManager.withViewRebuild(async () => { await BlockManager.clear(false, { skipYjsSync: true }); await Renderer.render(this.lastKnown, { skipYjsSync: true }); }); } finally { this.resumeObserver(); } } /** * Drops the degraded DOM. `BlockManager.clear` is declared async but its body * never awaits, so the holders are gone before this returns — which is the * whole contract: the caller is `applyRemoteUpdate`, and the remote blocks * materialise synchronously in the very next statement. Adding an `await` * inside `clear` would leave the last-known blocks stacked on top of the * server's; the degrade-swap test is the tripwire for that. */ private dropDegradedView(): void { if (!this.degraded) { return; } this.degraded = false; this.suspendObserver(); void this.Blok.BlockManager.clear(false, { skipYjsSync: true }); this.resumeObserver(); } /** * Takes the observer out of service for the caller's DOM rewrite. Nests: see * {@link observerSuspensions}. */ private suspendObserver(): void { if (this.observerSuspensions === 0) { this.Blok.ModificationsObserver.disable(); } this.observerSuspensions += 1; } /** * Releases one suspension, re-arming the observer only when the last one is * gone. */ private resumeObserver(): void { this.observerSuspensions = Math.max(0, this.observerSuspensions - 1); if (this.observerSuspensions === 0) { this.Blok.ModificationsObserver.enable(); } } /** * A document that synced empty gets exactly one block, so the user has * something to type in. Write-gated: a read-only member must not author the * first block of somebody else's document. * * "Read-only" is the APPLIED state, not just the ticket's `write` claim: a * pure viewer — a host that mounted the editor with `readOnly: true` — is * every bit as much a reader as a member the server denies writes to, and a * write-granting ticket does not make them the author of somebody else's * first paragraph. Arbitration has already run by the time this is called, so * `ReadOnly.isEnabled` is exactly that applied state. * * Idempotent, and deliberately so: it is re-run whenever the write grant * changes, and the empty-document guard plus the derived id make a second run * a no-op. */ private seedEmptyDocument(): void { const settings = this.settings; // Never before the first sync: a block written into a document that has // never carried server lineage has nowhere to go (rule 2). if (settings === null || !(this.firstSynced || this.cacheAdopted) || this.writeDenied || this.terminal) { return; } if (this.Blok.ReadOnly.isEnabled) { return; } const yjs = this.Blok.YjsManager; if (yjs.toJSON().length > 0) { return; } const block = this.Blok.BlockManager.insert({ id: seedBlockId(settings.doc), skipYjsSync: true }); // 'no-capture': this is reactive infrastructure, not a user edit, so it // must not become an undo step. It still broadcasts — peers materialise it // through their ordinary remote-add path. yjs.transactWithoutCapture(() => { yjs.addBlock({ id: block.id, type: block.name, data: block.preservedData, }); }); } private async applyArbitration(): Promise { await this.Blok.ReadOnly.reapplyCollaborationArbitration(); } /** * @param status - the state to publish on the wrapper */ private setStateAttribute(status: CollabStatus): void { this.Blok.UI.nodes.wrapper?.setAttribute(COLLAB_STATE_ATTR, status); } /** * Publishes the session state, with whatever the provider said about the last * transition. `error` is published as `error`: it means the session stopped * for good, and calling that "offline" told a host the opposite of the truth * — offline is the state where edits stay pending until a reconnect. * * The detail is read from the field rather than taken as an argument because * this also fires from the awareness hook, where nothing transitioned and the * peer list is the only thing that changed. */ private emitStatus(): void { if (this.settings === null || this.isDestroyed) { return; } const detail = this.lastStatusDetail; // The walk the presence renderer takes: not this client, carrying an // identity, at most MAX_PEERS after a bounded scan — so one hostile frame // full of fabricated states cannot hand the host a list its size. // What bounds the directory is its own cap, not this walk: a peer minting // a fresh id per frame accumulates across frames and can push honest names // out of the cache. Losing a name degrades the footer to a date; it cannot // grow the map. const drawable = selectDrawableStates( presenceStates(this.Blok.YjsManager.getAwarenessStates()), this.presence?.localClientId ?? null ); for (const entry of drawable) { this.Blok.UserDirectory.learn(entry.state.user.id, entry.state.user.name); } // `selectDrawableStates` drops the reader, because the renderer must never // draw the reader's own caret. This list includes them: every product that // draws "who is in this document" draws the person reading it. const localClientId = this.presence?.localClientId ?? null; const local = localClientId === null ? undefined : this.Blok.YjsManager.getAwarenessStates().get(localClientId); const localEntry = localClientId !== null && local !== undefined ? { clientId: localClientId, state: local } : null; const states = localEntry !== null && hasDrawableIdentity(localEntry) ? [...drawable, localEntry] : drawable; const participants = buildParticipants( states, localClientId, this.identities, (key) => this.Blok.I18n.t(key), Date.now() ); // A host listener that throws must not reach the frame handler above this // (where it would end the session) or skip the arbitration that follows a // status transition. Its failure is its own. try { this.eventsDispatcher.emit(CollaborationStatusChanged, { status: this.status, participants, ...(detail?.error === undefined ? {} : { error: detail.error }), ...(detail?.code === undefined ? {} : { code: detail.code }), ...(detail?.reason === undefined ? {} : { reason: detail.reason }), ...(detail?.retryInMs === undefined ? {} : { retryInMs: detail.retryInMs }), ...(this.save === undefined ? {} : { save: this.save }), }); } catch (thrown) { logLabeled('a collaboration:status listener threw', 'warn', thrown); } } }