@stratasync/y-doc 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,764 @@
1
+ // oxlint-disable no-use-before-define -- helper functions and class methods reference later-defined utilities
2
+ /**
3
+ * YjsDocumentManager - Manages local Y.Doc instances for collaborative editing.
4
+ *
5
+ * Responsibilities:
6
+ * - Create and manage Y.Doc instances per document field
7
+ * - Handle Yjs sync protocol (state vector exchange)
8
+ * - Apply local and remote updates
9
+ * - Track connection state per document
10
+ * - Generate derived content from Y.Doc
11
+ */
12
+ // biome-ignore lint/performance/noNamespaceImport: yjs conventionally uses namespace access (Y.Doc, Y.Text, etc.)
13
+ import * as Y from "yjs";
14
+ import { clearPersistedYjsDocuments, DEFAULT_PERSISTED_YJS_PREFIX, } from "./persistence.js";
15
+ import { DEFAULT_LIVE_EDITING_RETRY_CONFIG, fromDocumentKeyString, isLiveEditingErrorMessage, isRetryableLiveEditingErrorCode, isYjsSyncStep2Message, isYjsUpdateMessage, toDocumentKeyString, } from "./types.js";
16
+ const PROSEMIRROR_FIELD = "prosemirror";
17
+ const IMAGE_NODE_NAMES = new Set(["image", "imageblock", "taskimage"]);
18
+ const BLOCK_IMAGE_NODE_NAMES = new Set(["imageblock"]);
19
+ const EMBED_NODE_NAMES = new Set([
20
+ "embed",
21
+ "embedblock",
22
+ "iframelyembed",
23
+ "iframelyembedblock",
24
+ "iframe",
25
+ "iframeblock",
26
+ "taskembed",
27
+ ]);
28
+ const normalizeDerivedPart = (value, maxLength = 160) => {
29
+ const normalized = normalizeDerivedContent(value);
30
+ if (normalized.length <= maxLength) {
31
+ return normalized;
32
+ }
33
+ return `${normalized.slice(0, maxLength - 3).trimEnd()}...`;
34
+ };
35
+ const normalizeDerivedContent = (content) => content
36
+ .replaceAll("\u00A0", " ")
37
+ .replaceAll(/\n{3,}/g, "\n\n")
38
+ .trim();
39
+ const getStringAttribute = (node, attributeNames) => {
40
+ for (const attributeName of attributeNames) {
41
+ const value = node.getAttribute(attributeName);
42
+ if (typeof value !== "string") {
43
+ continue;
44
+ }
45
+ const normalized = normalizeDerivedPart(value);
46
+ if (normalized.length > 0) {
47
+ return normalized;
48
+ }
49
+ }
50
+ return null;
51
+ };
52
+ const uniqueDerivedParts = (parts) => {
53
+ const uniqueParts = [];
54
+ const seen = new Set();
55
+ for (const part of parts) {
56
+ if (!part) {
57
+ continue;
58
+ }
59
+ const key = part.toLowerCase();
60
+ if (seen.has(key)) {
61
+ continue;
62
+ }
63
+ seen.add(key);
64
+ uniqueParts.push(part);
65
+ }
66
+ return uniqueParts;
67
+ };
68
+ const formatPlaceholder = (label, parts) => {
69
+ if (parts.length === 0) {
70
+ return `[${label}]`;
71
+ }
72
+ return `[${label}: ${parts.join(" - ")}]`;
73
+ };
74
+ const renderBlockPlaceholder = (placeholder, children) => {
75
+ const content = normalizeDerivedContent([placeholder, children].filter(Boolean).join("\n"));
76
+ return content.length > 0 ? `${content}\n\n` : "";
77
+ };
78
+ const renderImagePlaceholder = (node, children) => {
79
+ const alt = getStringAttribute(node, ["alt", "title"]) ?? "Image";
80
+ const src = getStringAttribute(node, ["src"]);
81
+ if (src) {
82
+ const nodeType = node.nodeName.toLowerCase();
83
+ const markdown = `![${alt}](${src})`;
84
+ if (BLOCK_IMAGE_NODE_NAMES.has(nodeType)) {
85
+ const content = normalizeDerivedContent([markdown, children].filter(Boolean).join("\n"));
86
+ return content.length > 0 ? `${content}\n\n` : "";
87
+ }
88
+ return markdown;
89
+ }
90
+ const placeholder = formatPlaceholder("Image", [alt === "Image" ? null : alt].filter((part) => part !== null));
91
+ const nodeType = node.nodeName.toLowerCase();
92
+ if (BLOCK_IMAGE_NODE_NAMES.has(nodeType)) {
93
+ return renderBlockPlaceholder(placeholder, children);
94
+ }
95
+ return placeholder;
96
+ };
97
+ const renderEmbedPlaceholder = (node, children) => {
98
+ const title = getStringAttribute(node, ["title", "label"]);
99
+ const description = getStringAttribute(node, ["description", "caption"]);
100
+ const provider = getStringAttribute(node, ["provider", "providerName"]);
101
+ const url = getStringAttribute(node, [
102
+ "url",
103
+ "href",
104
+ "src",
105
+ "iframeSrc",
106
+ "iframeUrl",
107
+ ]);
108
+ const placeholder = formatPlaceholder("Embed", uniqueDerivedParts([title, title ? null : description, provider, url]));
109
+ return renderBlockPlaceholder(placeholder, children);
110
+ };
111
+ const renderProsemirrorNodes = (nodes) => {
112
+ let rendered = "";
113
+ for (const node of nodes) {
114
+ rendered += renderProsemirrorNode(node);
115
+ }
116
+ return rendered;
117
+ };
118
+ const getXmlTextContent = (node) => node.toDelta()
119
+ .map((op) => (typeof op.insert === "string" ? op.insert : ""))
120
+ .join("");
121
+ // oxlint-ignore-next-line complexity -- recursive ProseMirror renderer handling many node types
122
+ // oxlint-disable-next-line complexity -- complex but clear
123
+ const renderProsemirrorNode = (node) => {
124
+ if (node instanceof Y.XmlText) {
125
+ return getXmlTextContent(node);
126
+ }
127
+ if (!(node instanceof Y.XmlElement)) {
128
+ return "";
129
+ }
130
+ const children = normalizeDerivedContent(renderProsemirrorNodes(node.toArray()));
131
+ const nodeType = node.nodeName.toLowerCase();
132
+ if (IMAGE_NODE_NAMES.has(nodeType)) {
133
+ return renderImagePlaceholder(node, children);
134
+ }
135
+ if (EMBED_NODE_NAMES.has(nodeType)) {
136
+ return renderEmbedPlaceholder(node, children);
137
+ }
138
+ switch (node.nodeName) {
139
+ case "hardBreak": {
140
+ return "\n";
141
+ }
142
+ case "heading":
143
+ case "paragraph":
144
+ case "blockquote":
145
+ case "codeBlock": {
146
+ return children.length > 0 ? `${children}\n\n` : "";
147
+ }
148
+ case "listItem": {
149
+ return children.length > 0 ? `- ${children}\n` : "";
150
+ }
151
+ case "taskItem": {
152
+ const checkedAttribute = node.getAttribute("checked");
153
+ const isChecked = checkedAttribute === true ||
154
+ checkedAttribute === "true" ||
155
+ checkedAttribute === 1 ||
156
+ checkedAttribute === "1";
157
+ return children.length > 0
158
+ ? `- [${isChecked ? "x" : " "}] ${children}\n`
159
+ : "";
160
+ }
161
+ case "bulletList":
162
+ case "orderedList":
163
+ case "taskList": {
164
+ return children.length > 0 ? `${children}\n` : "";
165
+ }
166
+ default: {
167
+ return children;
168
+ }
169
+ }
170
+ };
171
+ const deriveProsemirrorContent = (doc) => {
172
+ const fragment = doc.getXmlFragment(PROSEMIRROR_FIELD);
173
+ return normalizeDerivedContent(renderProsemirrorNodes(fragment.toArray()));
174
+ };
175
+ const seedProsemirrorFragment = (doc, content) => {
176
+ const normalized = normalizeDerivedContent(content);
177
+ if (normalized.length === 0) {
178
+ return;
179
+ }
180
+ const fragment = doc.getXmlFragment(PROSEMIRROR_FIELD);
181
+ if (fragment.length > 0) {
182
+ return;
183
+ }
184
+ const paragraph = new Y.XmlElement("paragraph");
185
+ const textNode = new Y.XmlText();
186
+ textNode.insert(0, normalized);
187
+ paragraph.insert(0, [textNode]);
188
+ fragment.insert(fragment.length, [paragraph]);
189
+ };
190
+ /**
191
+ * Manages Y.Doc instances for collaborative editing.
192
+ */
193
+ export class YjsDocumentManager {
194
+ docs = new Map();
195
+ remoteUpdateOrigin = { source: "remote" };
196
+ config;
197
+ liveEditingRetryConfig;
198
+ persistenceKeyPrefix;
199
+ transport = null;
200
+ transportConnectionState = "disconnected";
201
+ unsubscribeTransportMessage = null;
202
+ unsubscribeTransportConnection = null;
203
+ connectionStateCallbacks = new Map();
204
+ contentCallbacks = new Map();
205
+ constructor(config) {
206
+ this.config = config;
207
+ this.liveEditingRetryConfig = normalizeRetryConfig(config.liveEditingRetry);
208
+ this.persistenceKeyPrefix =
209
+ config.persistenceKeyPrefix ?? DEFAULT_PERSISTED_YJS_PREFIX;
210
+ }
211
+ setPersistenceKeyPrefix(prefix) {
212
+ this.persistenceKeyPrefix = prefix || DEFAULT_PERSISTED_YJS_PREFIX;
213
+ }
214
+ clearPersistedDocuments() {
215
+ clearPersistedYjsDocuments(this.persistenceKeyPrefix);
216
+ }
217
+ setTransport(transport) {
218
+ this.unsubscribeTransportMessage?.();
219
+ this.unsubscribeTransportConnection?.();
220
+ this.transport = transport;
221
+ this.transportConnectionState = "disconnected";
222
+ this.unsubscribeTransportMessage = transport.onMessage((message) => {
223
+ this.handleMessage(message);
224
+ });
225
+ this.unsubscribeTransportConnection = transport.onConnectionStateChange((state) => {
226
+ this.handleTransportConnectionStateChange(state);
227
+ });
228
+ this.handleTransportConnectionStateChange(transport.isConnected() ? "connected" : "disconnected");
229
+ }
230
+ getDocument(docKey) {
231
+ const keyString = toDocumentKeyString(docKey);
232
+ return this.getOrCreateState(keyString).doc;
233
+ }
234
+ /**
235
+ * Connect to a document for collaborative editing.
236
+ */
237
+ connect(docKey, options = {}) {
238
+ const keyString = toDocumentKeyString(docKey);
239
+ const state = this.getOrCreateState(keyString);
240
+ const wasActive = YjsDocumentManager.isDocActive(state);
241
+ state.refCount += 1;
242
+ if (wasActive) {
243
+ return;
244
+ }
245
+ // Store initial content for immediate seeding in requestSyncStep1
246
+ // (regardless of connection state). The CRDT merge in handleSyncStep2
247
+ // reconciles if the server has different content.
248
+ state.pendingInitialContent = options.initialContent;
249
+ YjsDocumentManager.resetRetryState(state);
250
+ this.attachLocalUpdateHandler(docKey, keyString, state);
251
+ this.requestSyncStep1(docKey, keyString, state);
252
+ }
253
+ disconnect(docKey) {
254
+ const keyString = toDocumentKeyString(docKey);
255
+ const state = this.docs.get(keyString);
256
+ if (!(state && YjsDocumentManager.isDocActive(state))) {
257
+ return;
258
+ }
259
+ state.refCount -= 1;
260
+ if (state.refCount > 0) {
261
+ return;
262
+ }
263
+ state.pendingInitialContent = undefined;
264
+ YjsDocumentManager.resetRetryState(state);
265
+ YjsDocumentManager.detachLocalUpdateHandler(state);
266
+ this.setConnectionState(keyString, "disconnected");
267
+ }
268
+ getConnectionState(docKey) {
269
+ const keyString = toDocumentKeyString(docKey);
270
+ return this.docs.get(keyString)?.connectionState ?? "disconnected";
271
+ }
272
+ onConnectionStateChange(docKey,
273
+ // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
274
+ callback) {
275
+ const keyString = toDocumentKeyString(docKey);
276
+ let callbacks = this.connectionStateCallbacks.get(keyString);
277
+ if (!callbacks) {
278
+ callbacks = new Set();
279
+ this.connectionStateCallbacks.set(keyString, callbacks);
280
+ }
281
+ callbacks.add(callback);
282
+ const currentState = this.getConnectionState(docKey);
283
+ // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
284
+ callback(currentState);
285
+ return () => {
286
+ callbacks?.delete(callback);
287
+ };
288
+ }
289
+ onContentChange(docKey,
290
+ // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
291
+ callback) {
292
+ const keyString = toDocumentKeyString(docKey);
293
+ let callbacks = this.contentCallbacks.get(keyString);
294
+ if (!callbacks) {
295
+ callbacks = new Set();
296
+ this.contentCallbacks.set(keyString, callbacks);
297
+ }
298
+ callbacks.add(callback);
299
+ const currentContent = this.getDerivedContent(docKey);
300
+ // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
301
+ callback(currentContent);
302
+ return () => {
303
+ callbacks?.delete(callback);
304
+ };
305
+ }
306
+ /**
307
+ * Apply a remote update to a document.
308
+ */
309
+ applyRemoteUpdate(docKey, update) {
310
+ const keyString = toDocumentKeyString(docKey);
311
+ const state = this.docs.get(keyString);
312
+ if (!state) {
313
+ return;
314
+ }
315
+ Y.applyUpdate(state.doc, update, this.remoteUpdateOrigin);
316
+ this.persistDocument(keyString, state.doc);
317
+ this.notifyContentChange(keyString);
318
+ }
319
+ /**
320
+ * Apply a snapshot (full state) to a document.
321
+ * Semantically identical to applyRemoteUpdate — a Yjs snapshot
322
+ * is applied using the same Y.applyUpdate mechanism.
323
+ */
324
+ applySnapshot(docKey, snapshot) {
325
+ this.applyRemoteUpdate(docKey, snapshot);
326
+ }
327
+ getDerivedContent(docKey) {
328
+ const state = this.docs.get(toDocumentKeyString(docKey));
329
+ if (!state) {
330
+ return "";
331
+ }
332
+ return deriveProsemirrorContent(state.doc);
333
+ }
334
+ getStateVector(docKey) {
335
+ const state = this.docs.get(toDocumentKeyString(docKey));
336
+ if (!state) {
337
+ return new Uint8Array();
338
+ }
339
+ return Y.encodeStateVector(state.doc);
340
+ }
341
+ getUpdatesSince(docKey, stateVector) {
342
+ const state = this.docs.get(toDocumentKeyString(docKey));
343
+ if (!state) {
344
+ return null;
345
+ }
346
+ const update = Y.encodeStateAsUpdate(state.doc, stateVector);
347
+ // Check if update is empty (no changes)
348
+ if (update.length <= 2) {
349
+ return null;
350
+ }
351
+ return update;
352
+ }
353
+ getEncodedState(docKey) {
354
+ const state = this.docs.get(toDocumentKeyString(docKey));
355
+ if (!state) {
356
+ return null;
357
+ }
358
+ return Y.encodeStateAsUpdate(state.doc);
359
+ }
360
+ destroy(docKey) {
361
+ const keyString = toDocumentKeyString(docKey);
362
+ const state = this.docs.get(keyString);
363
+ if (state) {
364
+ state.refCount = 0;
365
+ state.pendingInitialContent = undefined;
366
+ YjsDocumentManager.resetRetryState(state);
367
+ YjsDocumentManager.detachLocalUpdateHandler(state);
368
+ this.setConnectionState(keyString, "disconnected");
369
+ state.doc.destroy();
370
+ this.docs.delete(keyString);
371
+ }
372
+ this.connectionStateCallbacks.delete(keyString);
373
+ this.contentCallbacks.delete(keyString);
374
+ }
375
+ destroyAll() {
376
+ for (const [keyString] of this.docs) {
377
+ const docKey = fromDocumentKeyString(keyString);
378
+ if (docKey) {
379
+ this.destroy(docKey);
380
+ }
381
+ }
382
+ }
383
+ getOrCreateState(keyString) {
384
+ let state = this.docs.get(keyString);
385
+ if (!state) {
386
+ state = YjsDocumentManager.createDocumentState();
387
+ this.restorePersistedDocument(keyString, state.doc);
388
+ this.docs.set(keyString, state);
389
+ }
390
+ return state;
391
+ }
392
+ static createDocumentState() {
393
+ return {
394
+ connectionState: "disconnected",
395
+ doc: new Y.Doc(),
396
+ lastSeq: 0,
397
+ pendingLocalUpdates: [],
398
+ refCount: 0,
399
+ retryAttempts: 0,
400
+ };
401
+ }
402
+ handleTransportConnectionStateChange(state) {
403
+ const wasConnected = this.transportConnectionState === "connected";
404
+ const isConnected = state === "connected";
405
+ this.transportConnectionState = state;
406
+ if (!isConnected) {
407
+ this.markActiveDocumentsAsConnecting();
408
+ return;
409
+ }
410
+ if (!wasConnected) {
411
+ this.replaySyncStep1ForActiveDocuments();
412
+ }
413
+ }
414
+ markActiveDocumentsAsConnecting() {
415
+ for (const [keyString, state] of this.docs) {
416
+ if (!YjsDocumentManager.isDocActive(state)) {
417
+ continue;
418
+ }
419
+ this.setConnectionState(keyString, "connecting");
420
+ }
421
+ }
422
+ replaySyncStep1ForActiveDocuments() {
423
+ for (const [keyString, state] of this.docs) {
424
+ if (!YjsDocumentManager.isDocActive(state)) {
425
+ continue;
426
+ }
427
+ const docKey = fromDocumentKeyString(keyString);
428
+ if (!docKey) {
429
+ continue;
430
+ }
431
+ this.requestSyncStep1(docKey, keyString, state);
432
+ }
433
+ }
434
+ requestSyncStep1(docKey, keyString, state) {
435
+ if (!YjsDocumentManager.isDocActive(state)) {
436
+ this.setConnectionState(keyString, "disconnected");
437
+ return;
438
+ }
439
+ // Seed initial content immediately so the editor can render without
440
+ // waiting for the sync handshake round-trip. The CRDT merge in
441
+ // handleSyncStep2 reconciles if the server has different content.
442
+ // Uses remoteUpdateOrigin so the local update handler does not buffer
443
+ // seeded content as a pending local update.
444
+ if (YjsDocumentManager.seedPendingContent(state, this.remoteUpdateOrigin)) {
445
+ this.notifyContentChange(keyString);
446
+ }
447
+ if (!this.transport?.isConnected()) {
448
+ this.setConnectionState(keyString, "connecting");
449
+ return;
450
+ }
451
+ this.setConnectionState(keyString, "syncing");
452
+ this.sendSyncStep1(docKey);
453
+ }
454
+ /**
455
+ * Seed pending initial content into the Y.Doc. Returns true if content was seeded.
456
+ *
457
+ * Uses `origin` so the local update handler ignores the insert (prevents
458
+ * seeded content from being buffered as a pending local update).
459
+ */
460
+ static seedPendingContent(state, origin) {
461
+ const { pendingInitialContent } = state;
462
+ if (pendingInitialContent === undefined) {
463
+ return false;
464
+ }
465
+ state.pendingInitialContent = undefined;
466
+ if (state.doc.getXmlFragment(PROSEMIRROR_FIELD).length > 0) {
467
+ return false;
468
+ }
469
+ state.doc.transact(() => {
470
+ seedProsemirrorFragment(state.doc, pendingInitialContent);
471
+ }, origin);
472
+ return true;
473
+ }
474
+ static clearRetryTimer(state) {
475
+ if (state.retryTimer !== undefined) {
476
+ clearTimeout(state.retryTimer);
477
+ state.retryTimer = undefined;
478
+ }
479
+ }
480
+ static resetRetryState(state) {
481
+ YjsDocumentManager.clearRetryTimer(state);
482
+ state.retryAttempts = 0;
483
+ }
484
+ scheduleRetrySyncStep1(docKey, keyString, state) {
485
+ if (!YjsDocumentManager.isDocActive(state)) {
486
+ this.setConnectionState(keyString, "disconnected");
487
+ return;
488
+ }
489
+ if (state.retryTimer !== undefined) {
490
+ return;
491
+ }
492
+ if (state.retryAttempts >= this.liveEditingRetryConfig.maxRetries) {
493
+ YjsDocumentManager.resetRetryState(state);
494
+ this.setConnectionState(keyString, "disconnected");
495
+ return;
496
+ }
497
+ const retryDelayMs = calculateRetryDelay(state.retryAttempts, this.liveEditingRetryConfig);
498
+ state.retryAttempts += 1;
499
+ this.setConnectionState(keyString, "connecting");
500
+ state.retryTimer = setTimeout(() => {
501
+ const currentState = this.docs.get(keyString);
502
+ if (!currentState) {
503
+ return;
504
+ }
505
+ currentState.retryTimer = undefined;
506
+ if (!YjsDocumentManager.isDocActive(currentState)) {
507
+ this.setConnectionState(keyString, "disconnected");
508
+ return;
509
+ }
510
+ this.requestSyncStep1(docKey, keyString, currentState);
511
+ }, retryDelayMs);
512
+ }
513
+ attachLocalUpdateHandler(docKey, keyString, state) {
514
+ if (state.unsubscribe) {
515
+ return;
516
+ }
517
+ const handler = (update, origin) => {
518
+ // Only process updates that originated locally.
519
+ if (origin === this.remoteUpdateOrigin) {
520
+ return;
521
+ }
522
+ this.persistDocument(keyString, state.doc);
523
+ this.notifyContentChange(keyString);
524
+ // During reconnect/syncing or transport outages, buffer local updates and
525
+ // flush once we are connected again.
526
+ if (state.connectionState === "connected" &&
527
+ this.transport?.isConnected() === true) {
528
+ this.sendUpdate(docKey, update);
529
+ return;
530
+ }
531
+ state.pendingLocalUpdates.push(update);
532
+ };
533
+ state.doc.on("update", handler);
534
+ state.unsubscribe = () => {
535
+ state.doc.off("update", handler);
536
+ };
537
+ }
538
+ static detachLocalUpdateHandler(state) {
539
+ state.unsubscribe?.();
540
+ state.unsubscribe = undefined;
541
+ }
542
+ static isDocActive(state) {
543
+ return state.refCount > 0;
544
+ }
545
+ shouldApplyRemoteMessage(state) {
546
+ return (YjsDocumentManager.isDocActive(state) &&
547
+ this.transport?.isConnected() === true);
548
+ }
549
+ sendIfConnected(message) {
550
+ if (this.transport?.isConnected()) {
551
+ this.transport.send(message);
552
+ }
553
+ }
554
+ setConnectionState(keyString, state) {
555
+ const docState = this.docs.get(keyString);
556
+ if (docState) {
557
+ docState.connectionState = state;
558
+ }
559
+ const callbacks = this.connectionStateCallbacks.get(keyString);
560
+ if (callbacks) {
561
+ for (const callback of callbacks) {
562
+ // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
563
+ callback(state);
564
+ }
565
+ }
566
+ }
567
+ notifyContentChange(keyString) {
568
+ const callbacks = this.contentCallbacks.get(keyString);
569
+ if (!callbacks) {
570
+ return;
571
+ }
572
+ const docKey = fromDocumentKeyString(keyString);
573
+ if (!docKey) {
574
+ return;
575
+ }
576
+ const content = this.getDerivedContent(docKey);
577
+ for (const callback of callbacks) {
578
+ // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration
579
+ callback(content);
580
+ }
581
+ }
582
+ sendSyncStep1(docKey) {
583
+ this.sendIfConnected({
584
+ clientId: this.config.clientId,
585
+ entityId: docKey.entityId,
586
+ entityType: docKey.entityType,
587
+ fieldName: docKey.fieldName,
588
+ payload: base64Encode(this.getStateVector(docKey)),
589
+ type: "yjs_sync_step1",
590
+ });
591
+ }
592
+ sendUpdate(docKey, update) {
593
+ this.sendIfConnected({
594
+ clientId: this.config.clientId,
595
+ connId: this.config.connId,
596
+ entityId: docKey.entityId,
597
+ entityType: docKey.entityType,
598
+ fieldName: docKey.fieldName,
599
+ payload: base64Encode(update),
600
+ type: "yjs_update",
601
+ });
602
+ }
603
+ flushPendingLocalUpdates(docKey, state) {
604
+ if (state.pendingLocalUpdates.length === 0) {
605
+ return;
606
+ }
607
+ const mergedUpdate = Y.mergeUpdates(state.pendingLocalUpdates);
608
+ state.pendingLocalUpdates = [];
609
+ this.sendUpdate(docKey, mergedUpdate);
610
+ }
611
+ handleMessage(message) {
612
+ if (isYjsSyncStep2Message(message)) {
613
+ this.handleSyncStep2(message);
614
+ }
615
+ else if (isYjsUpdateMessage(message)) {
616
+ this.handleUpdate(message);
617
+ }
618
+ else if (isLiveEditingErrorMessage(message)) {
619
+ this.handleError(message);
620
+ }
621
+ }
622
+ handleSyncStep2(message) {
623
+ const keyString = toDocumentKeyString(message);
624
+ const state = this.docs.get(keyString);
625
+ if (!(state && this.shouldApplyRemoteMessage(state))) {
626
+ return;
627
+ }
628
+ if (message.seq < state.lastSeq) {
629
+ return;
630
+ }
631
+ if (message.seq === state.lastSeq &&
632
+ state.connectionState === "connected") {
633
+ return;
634
+ }
635
+ const update = base64Decode(message.payload);
636
+ Y.applyUpdate(state.doc, update, this.remoteUpdateOrigin);
637
+ state.lastSeq = message.seq;
638
+ YjsDocumentManager.resetRetryState(state);
639
+ this.persistDocument(keyString, state.doc);
640
+ this.flushPendingLocalUpdates(message, state);
641
+ this.setConnectionState(keyString, "connected");
642
+ this.notifyContentChange(keyString);
643
+ }
644
+ handleUpdate(message) {
645
+ // Ignore updates echoed to the same live connection.
646
+ if (message.connId === this.config.connId) {
647
+ return;
648
+ }
649
+ const keyString = toDocumentKeyString(message);
650
+ const state = this.docs.get(keyString);
651
+ if (!(state && this.shouldApplyRemoteMessage(state))) {
652
+ return;
653
+ }
654
+ if (state.connectionState !== "connected") {
655
+ return;
656
+ }
657
+ if (message.seq <= state.lastSeq) {
658
+ return;
659
+ }
660
+ const expectedSeq = state.lastSeq + 1;
661
+ if (message.seq > expectedSeq) {
662
+ this.requestSyncStep1(message, keyString, state);
663
+ return;
664
+ }
665
+ const update = base64Decode(message.payload);
666
+ Y.applyUpdate(state.doc, update, this.remoteUpdateOrigin);
667
+ state.lastSeq = message.seq;
668
+ this.persistDocument(keyString, state.doc);
669
+ this.notifyContentChange(keyString);
670
+ }
671
+ handleError(message) {
672
+ const keyString = toDocumentKeyString(message);
673
+ const state = this.docs.get(keyString);
674
+ if (!state) {
675
+ return;
676
+ }
677
+ if (!isRetryableLiveEditingErrorCode(message.code)) {
678
+ YjsDocumentManager.resetRetryState(state);
679
+ this.setConnectionState(keyString, "disconnected");
680
+ return;
681
+ }
682
+ this.scheduleRetrySyncStep1(message, keyString, state);
683
+ }
684
+ getPersistedDocumentKey(keyString) {
685
+ return `${this.persistenceKeyPrefix}${keyString}`;
686
+ }
687
+ restorePersistedDocument(keyString, doc) {
688
+ if (typeof localStorage === "undefined") {
689
+ return;
690
+ }
691
+ try {
692
+ const encodedUpdate = localStorage.getItem(this.getPersistedDocumentKey(keyString));
693
+ if (!encodedUpdate) {
694
+ return;
695
+ }
696
+ const update = base64Decode(encodedUpdate);
697
+ Y.applyUpdate(doc, update, this.remoteUpdateOrigin);
698
+ }
699
+ catch {
700
+ // Ignore persistence read errors and continue with empty document state.
701
+ }
702
+ }
703
+ persistDocument(keyString, doc) {
704
+ if (typeof localStorage === "undefined") {
705
+ return;
706
+ }
707
+ try {
708
+ const encodedUpdate = base64Encode(Y.encodeStateAsUpdate(doc));
709
+ localStorage.setItem(this.getPersistedDocumentKey(keyString), encodedUpdate);
710
+ }
711
+ catch {
712
+ // Ignore persistence write errors (e.g. quota exceeded) and keep syncing.
713
+ }
714
+ }
715
+ }
716
+ // Base64 encoding/decoding utilities
717
+ const base64Encode = (data) => {
718
+ if (typeof Buffer !== "undefined") {
719
+ return Buffer.from(data).toString("base64");
720
+ }
721
+ if (typeof btoa === "function") {
722
+ let binary = "";
723
+ for (const byte of data) {
724
+ binary += String.fromCodePoint(byte);
725
+ }
726
+ return btoa(binary);
727
+ }
728
+ throw new Error("No base64 encoder available");
729
+ };
730
+ const base64Decode = (str) => {
731
+ if (typeof atob === "function") {
732
+ const binary = atob(str);
733
+ const bytes = new Uint8Array(binary.length);
734
+ for (let i = 0; i < binary.length; i += 1) {
735
+ bytes[i] = binary.codePointAt(i);
736
+ }
737
+ return bytes;
738
+ }
739
+ return new Uint8Array(Buffer.from(str, "base64"));
740
+ };
741
+ const normalizeRetryConfig = (retryConfig) => {
742
+ const baseDelayMs = Math.max(1, retryConfig?.baseDelayMs ?? DEFAULT_LIVE_EDITING_RETRY_CONFIG.baseDelayMs);
743
+ const maxDelayMs = Math.max(baseDelayMs, retryConfig?.maxDelayMs ?? DEFAULT_LIVE_EDITING_RETRY_CONFIG.maxDelayMs);
744
+ const maxRetries = Math.max(0, retryConfig?.maxRetries ?? DEFAULT_LIVE_EDITING_RETRY_CONFIG.maxRetries);
745
+ const jitter = clamp(retryConfig?.jitter ?? DEFAULT_LIVE_EDITING_RETRY_CONFIG.jitter, 0, 1);
746
+ return {
747
+ baseDelayMs,
748
+ jitter,
749
+ maxDelayMs,
750
+ maxRetries,
751
+ };
752
+ };
753
+ const calculateRetryDelay = (attempt, config) => {
754
+ const exponentialDelay = config.baseDelayMs * 2 ** attempt;
755
+ const clampedDelay = Math.min(exponentialDelay, config.maxDelayMs);
756
+ if (config.jitter <= 0) {
757
+ return clampedDelay;
758
+ }
759
+ const jitterWindow = clampedDelay * config.jitter;
760
+ const jitteredDelay = clampedDelay + (Math.random() * 2 - 1) * jitterWindow;
761
+ return Math.max(0, Math.round(jitteredDelay));
762
+ };
763
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
764
+ //# sourceMappingURL=document-manager.js.map