@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.
- package/README.md +44 -0
- package/dist/document-manager.d.ts +91 -0
- package/dist/document-manager.d.ts.map +1 -0
- package/dist/document-manager.js +764 -0
- package/dist/document-manager.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/persistence.d.ts +4 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +22 -0
- package/dist/persistence.js.map +1 -0
- package/dist/presence-manager.d.ts +67 -0
- package/dist/presence-manager.d.ts.map +1 -0
- package/dist/presence-manager.js +311 -0
- package/dist/presence-manager.js.map +1 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +79 -0
- package/dist/types.js.map +1 -0
- package/package.json +43 -0
|
@@ -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 = ``;
|
|
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
|