wordinweb 0.1.22 → 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 +21 -2
- package/dist/chunk-XGTWJMDG.js +38208 -0
- package/dist/chunk-XGTWJMDG.js.map +1 -0
- package/dist/collab.d.ts +1281 -0
- package/dist/collab.js +4909 -0
- package/dist/collab.js.map +1 -0
- package/dist/index-C2J9egkh.d.ts +4314 -0
- package/dist/index.d.ts +2 -2401
- package/dist/index.js +13 -35778
- package/dist/index.js.map +1 -1
- package/package.json +13 -3
package/dist/collab.js
ADDED
|
@@ -0,0 +1,4909 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DocxDocument,
|
|
3
|
+
DocxToolbar,
|
|
4
|
+
DocxView,
|
|
5
|
+
acceptAllRevisions,
|
|
6
|
+
acceptRevision,
|
|
7
|
+
addComment,
|
|
8
|
+
adjustIndent,
|
|
9
|
+
applyDeleteRange,
|
|
10
|
+
applyInsertText,
|
|
11
|
+
applyRunFormat,
|
|
12
|
+
applySplitParagraph,
|
|
13
|
+
applyTableOp,
|
|
14
|
+
checkboxStateElement,
|
|
15
|
+
collectRevisions,
|
|
16
|
+
deleteComment,
|
|
17
|
+
deleteMath,
|
|
18
|
+
deleteSuggestedRange,
|
|
19
|
+
insertBlankPageAt,
|
|
20
|
+
insertBookmarkAroundSelection,
|
|
21
|
+
insertBookmarkAt,
|
|
22
|
+
insertBreakAt,
|
|
23
|
+
insertChartAt,
|
|
24
|
+
insertCoverPage,
|
|
25
|
+
insertCrossReference,
|
|
26
|
+
insertDateTimeField,
|
|
27
|
+
insertField,
|
|
28
|
+
insertFootnote,
|
|
29
|
+
insertImageAt,
|
|
30
|
+
insertMathAt,
|
|
31
|
+
insertPageField,
|
|
32
|
+
insertSectionBreak,
|
|
33
|
+
insertShapeAt,
|
|
34
|
+
insertSmartArtAt,
|
|
35
|
+
insertTableAfter,
|
|
36
|
+
insertWordArtAt,
|
|
37
|
+
isSafeUrl,
|
|
38
|
+
localName,
|
|
39
|
+
markParagraphGlyph,
|
|
40
|
+
mergeParagraphBackward,
|
|
41
|
+
moveMath,
|
|
42
|
+
moveTableTo,
|
|
43
|
+
parseXml,
|
|
44
|
+
recordedProvenance,
|
|
45
|
+
rejectRevision,
|
|
46
|
+
removeDrawingRun,
|
|
47
|
+
removeLink,
|
|
48
|
+
replyToComment,
|
|
49
|
+
resizeDrawing,
|
|
50
|
+
resizeTableColumn,
|
|
51
|
+
resizeTableRow,
|
|
52
|
+
resolveRunOffset,
|
|
53
|
+
runWireLength,
|
|
54
|
+
serializeXml,
|
|
55
|
+
setChartData,
|
|
56
|
+
setDrawingFill,
|
|
57
|
+
setDrawingLineStyle,
|
|
58
|
+
setDrawingOrder,
|
|
59
|
+
setDrawingRotation,
|
|
60
|
+
setDrawingWordArtText,
|
|
61
|
+
setDropCapAt,
|
|
62
|
+
setFloatingPagePosition,
|
|
63
|
+
setImageAltText,
|
|
64
|
+
setImageWrap,
|
|
65
|
+
setLineNumbering,
|
|
66
|
+
setLink,
|
|
67
|
+
setListLevel,
|
|
68
|
+
setListType,
|
|
69
|
+
setMathLinear,
|
|
70
|
+
setPageLayout,
|
|
71
|
+
setParagraphAlignment,
|
|
72
|
+
setParagraphDivider,
|
|
73
|
+
setParagraphSpacing,
|
|
74
|
+
setParagraphStyle,
|
|
75
|
+
setSmartArtData,
|
|
76
|
+
setSmartArtFill,
|
|
77
|
+
setSmartArtNodeText,
|
|
78
|
+
setSmartArtTextFormat,
|
|
79
|
+
toggleCheckbox,
|
|
80
|
+
validatePastedOoxml
|
|
81
|
+
} from "./chunk-XGTWJMDG.js";
|
|
82
|
+
import "./chunk-7D4SUZUM.js";
|
|
83
|
+
|
|
84
|
+
// src/collab.tsx
|
|
85
|
+
import { useEffect, useMemo, useRef, useState, createElement } from "react";
|
|
86
|
+
|
|
87
|
+
// ../collab/src/intents.ts
|
|
88
|
+
function idempotencyKey(i) {
|
|
89
|
+
return `${i.clientId}:${i.clientSeq}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ../collab/src/transform.ts
|
|
93
|
+
function runEditsOf(intent) {
|
|
94
|
+
switch (intent.kind) {
|
|
95
|
+
case "insertText":
|
|
96
|
+
return [{ runId: intent.at.runId, at: intent.at.offset, del: 0, ins: intent.text.length }];
|
|
97
|
+
case "deleteText":
|
|
98
|
+
return [{ runId: intent.runId, at: intent.start, del: intent.end - intent.start, ins: 0 }];
|
|
99
|
+
case "splitParagraph":
|
|
100
|
+
return [{ runId: intent.at.runId, at: intent.at.offset, del: 0, ins: 0, movedToRunId: intent.newRunId }];
|
|
101
|
+
case "formatRun":
|
|
102
|
+
return [];
|
|
103
|
+
case "formatParagraph":
|
|
104
|
+
return [];
|
|
105
|
+
case "setListType":
|
|
106
|
+
return [];
|
|
107
|
+
case "formatRange":
|
|
108
|
+
return [{
|
|
109
|
+
runId: intent.runId,
|
|
110
|
+
at: intent.start,
|
|
111
|
+
del: 0,
|
|
112
|
+
ins: 0,
|
|
113
|
+
formatSplit: { start: intent.start, end: intent.end, beforeId: intent.beforeId, middleId: intent.middleId, afterId: intent.afterId }
|
|
114
|
+
}];
|
|
115
|
+
case "tableOp":
|
|
116
|
+
return [];
|
|
117
|
+
case "mergeParagraph":
|
|
118
|
+
return [];
|
|
119
|
+
case "commentRun":
|
|
120
|
+
return [];
|
|
121
|
+
case "pasteBlocks":
|
|
122
|
+
return [];
|
|
123
|
+
case "suggestRevision":
|
|
124
|
+
return [];
|
|
125
|
+
case "insertImage":
|
|
126
|
+
return [];
|
|
127
|
+
case "insertBreak":
|
|
128
|
+
return [];
|
|
129
|
+
case "insertMath":
|
|
130
|
+
case "insertShape":
|
|
131
|
+
return [];
|
|
132
|
+
case "replyComment":
|
|
133
|
+
return [];
|
|
134
|
+
case "adjustIndent":
|
|
135
|
+
case "setSpacing":
|
|
136
|
+
return [];
|
|
137
|
+
case "insertPageField":
|
|
138
|
+
return [];
|
|
139
|
+
case "setLink":
|
|
140
|
+
case "insertFootnote":
|
|
141
|
+
case "setDropCap":
|
|
142
|
+
case "setDivider":
|
|
143
|
+
case "insertBookmark":
|
|
144
|
+
case "insertBlankPage":
|
|
145
|
+
case "insertSectionBreak":
|
|
146
|
+
case "insertCrossRef":
|
|
147
|
+
case "insertCoverPage":
|
|
148
|
+
case "setPageLayout":
|
|
149
|
+
case "setListLevel":
|
|
150
|
+
case "insertWordArt":
|
|
151
|
+
case "insertChart":
|
|
152
|
+
case "insertSmartArt":
|
|
153
|
+
case "setLineNumbering":
|
|
154
|
+
case "insertDateTimeField":
|
|
155
|
+
case "insertField":
|
|
156
|
+
case "setDrawingRotation":
|
|
157
|
+
case "setDrawingFill":
|
|
158
|
+
case "setChartData":
|
|
159
|
+
case "setSmartArtNodeText":
|
|
160
|
+
case "setDrawingWordArtText":
|
|
161
|
+
case "setDrawingLineStyle":
|
|
162
|
+
case "setImageAltText":
|
|
163
|
+
case "removeLink":
|
|
164
|
+
case "setImageWrap":
|
|
165
|
+
case "setDrawingOrder":
|
|
166
|
+
case "setSmartArtData":
|
|
167
|
+
case "setSmartArtFill":
|
|
168
|
+
case "setSmartArtTextFormat":
|
|
169
|
+
case "setFloatingPagePosition":
|
|
170
|
+
case "resizeDrawing":
|
|
171
|
+
case "resizeTableColumn":
|
|
172
|
+
case "resizeTableRow":
|
|
173
|
+
case "moveTable":
|
|
174
|
+
case "removeDrawing":
|
|
175
|
+
case "setMathLinear":
|
|
176
|
+
case "deleteMath":
|
|
177
|
+
case "ensureHeaderFooter":
|
|
178
|
+
case "deleteComment":
|
|
179
|
+
case "insertBookmarkRange":
|
|
180
|
+
case "toggleCheckbox":
|
|
181
|
+
return [];
|
|
182
|
+
case "moveMath":
|
|
183
|
+
return [];
|
|
184
|
+
case "insertTable":
|
|
185
|
+
case "acceptRevision":
|
|
186
|
+
case "rejectRevision":
|
|
187
|
+
case "acceptAllRevisions":
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function transformPositionAgainstEdit(pos, edit, leftGravity = false) {
|
|
192
|
+
if (pos.runId !== edit.runId) return pos;
|
|
193
|
+
if (edit.formatSplit) {
|
|
194
|
+
const { start, end, beforeId, middleId, afterId } = edit.formatSplit;
|
|
195
|
+
if (pos.offset < start && beforeId !== void 0) {
|
|
196
|
+
return { blockId: pos.blockId, runId: beforeId, offset: pos.offset };
|
|
197
|
+
}
|
|
198
|
+
if (pos.offset <= end) {
|
|
199
|
+
return { blockId: pos.blockId, runId: middleId, offset: pos.offset - start };
|
|
200
|
+
}
|
|
201
|
+
if (afterId !== void 0) {
|
|
202
|
+
return { blockId: pos.blockId, runId: afterId, offset: pos.offset - end };
|
|
203
|
+
}
|
|
204
|
+
return { blockId: pos.blockId, runId: middleId, offset: pos.offset - start };
|
|
205
|
+
}
|
|
206
|
+
if (edit.movedToRunId !== void 0) {
|
|
207
|
+
if (pos.offset > edit.at) {
|
|
208
|
+
return { blockId: pos.blockId, runId: edit.movedToRunId, offset: pos.offset - edit.at };
|
|
209
|
+
}
|
|
210
|
+
return pos;
|
|
211
|
+
}
|
|
212
|
+
const delEnd = edit.at + edit.del;
|
|
213
|
+
if (edit.del === 0 && edit.at === pos.offset) {
|
|
214
|
+
return leftGravity ? pos : { ...pos, offset: pos.offset + edit.ins };
|
|
215
|
+
}
|
|
216
|
+
if (delEnd <= pos.offset) {
|
|
217
|
+
return { ...pos, offset: pos.offset + edit.ins - edit.del };
|
|
218
|
+
}
|
|
219
|
+
if (edit.at >= pos.offset) {
|
|
220
|
+
return pos;
|
|
221
|
+
}
|
|
222
|
+
return { ...pos, offset: edit.at + edit.ins };
|
|
223
|
+
}
|
|
224
|
+
function transformPosition(pos, ahead, leftGravity = false) {
|
|
225
|
+
let out = pos;
|
|
226
|
+
for (const intent of ahead) {
|
|
227
|
+
for (const edit of runEditsOf(intent)) {
|
|
228
|
+
out = transformPositionAgainstEdit(out, edit, leftGravity);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
function transformIntent(intent, ahead) {
|
|
234
|
+
const newBase = intent.base + ahead.length;
|
|
235
|
+
switch (intent.kind) {
|
|
236
|
+
case "insertText":
|
|
237
|
+
return { ...intent, at: transformPosition(intent.at, ahead), base: newBase };
|
|
238
|
+
case "splitParagraph":
|
|
239
|
+
return { ...intent, at: transformPosition(intent.at, ahead), base: newBase };
|
|
240
|
+
case "formatRun":
|
|
241
|
+
return { ...intent, base: newBase };
|
|
242
|
+
case "formatParagraph":
|
|
243
|
+
return { ...intent, base: newBase };
|
|
244
|
+
case "setListType":
|
|
245
|
+
return { ...intent, base: newBase };
|
|
246
|
+
case "formatRange": {
|
|
247
|
+
const s = transformPosition({ blockId: intent.blockId, runId: intent.runId, offset: intent.start }, ahead);
|
|
248
|
+
const e = transformPosition({ blockId: intent.blockId, runId: intent.runId, offset: intent.end }, ahead);
|
|
249
|
+
if (e.runId !== s.runId) return { ...intent, runId: s.runId, blockId: s.blockId, start: s.offset, end: s.offset, base: newBase };
|
|
250
|
+
return { ...intent, runId: s.runId, blockId: s.blockId, start: s.offset, end: e.offset, base: newBase };
|
|
251
|
+
}
|
|
252
|
+
case "tableOp":
|
|
253
|
+
return { ...intent, base: newBase };
|
|
254
|
+
case "mergeParagraph":
|
|
255
|
+
return { ...intent, base: newBase };
|
|
256
|
+
case "commentRun":
|
|
257
|
+
return { ...intent, base: newBase };
|
|
258
|
+
case "pasteBlocks":
|
|
259
|
+
return { ...intent, base: newBase };
|
|
260
|
+
case "suggestRevision": {
|
|
261
|
+
if (!intent.ranges?.length) return { ...intent, base: newBase };
|
|
262
|
+
const ranges = intent.ranges.map((r) => {
|
|
263
|
+
const s2 = transformPosition({ blockId: r.blockId, runId: r.runId, offset: r.start }, ahead);
|
|
264
|
+
const e2 = transformPosition({ blockId: r.blockId, runId: r.runId, offset: r.end }, ahead);
|
|
265
|
+
if (s2.runId !== e2.runId || e2.offset <= s2.offset) return { ...r, start: 0, end: 0 };
|
|
266
|
+
return { blockId: s2.blockId, runId: s2.runId, start: s2.offset, end: e2.offset };
|
|
267
|
+
}).filter((r) => r.end > r.start);
|
|
268
|
+
return { ...intent, ranges, base: newBase };
|
|
269
|
+
}
|
|
270
|
+
case "insertImage":
|
|
271
|
+
return { ...intent, base: newBase };
|
|
272
|
+
case "insertBreak":
|
|
273
|
+
return { ...intent, base: newBase };
|
|
274
|
+
case "insertMath":
|
|
275
|
+
case "insertShape":
|
|
276
|
+
return { ...intent, base: newBase };
|
|
277
|
+
case "moveMath":
|
|
278
|
+
return { ...intent, at: transformPosition(intent.at, ahead), base: newBase };
|
|
279
|
+
case "replyComment":
|
|
280
|
+
return { ...intent, base: newBase };
|
|
281
|
+
case "adjustIndent":
|
|
282
|
+
case "setSpacing":
|
|
283
|
+
case "insertPageField":
|
|
284
|
+
case "setLink":
|
|
285
|
+
case "insertFootnote":
|
|
286
|
+
case "setDropCap":
|
|
287
|
+
case "setDivider":
|
|
288
|
+
case "insertBookmark":
|
|
289
|
+
case "insertBlankPage":
|
|
290
|
+
case "insertSectionBreak":
|
|
291
|
+
case "insertCrossRef":
|
|
292
|
+
case "insertCoverPage":
|
|
293
|
+
case "setPageLayout":
|
|
294
|
+
case "setListLevel":
|
|
295
|
+
case "insertWordArt":
|
|
296
|
+
case "insertChart":
|
|
297
|
+
case "insertSmartArt":
|
|
298
|
+
case "setLineNumbering":
|
|
299
|
+
case "insertDateTimeField":
|
|
300
|
+
case "insertField":
|
|
301
|
+
case "setDrawingRotation":
|
|
302
|
+
case "setDrawingFill":
|
|
303
|
+
case "setChartData":
|
|
304
|
+
case "setSmartArtNodeText":
|
|
305
|
+
case "setDrawingWordArtText":
|
|
306
|
+
case "setDrawingLineStyle":
|
|
307
|
+
case "setImageAltText":
|
|
308
|
+
case "removeLink":
|
|
309
|
+
case "setImageWrap":
|
|
310
|
+
case "setDrawingOrder":
|
|
311
|
+
case "setSmartArtData":
|
|
312
|
+
case "setSmartArtFill":
|
|
313
|
+
case "setSmartArtTextFormat":
|
|
314
|
+
case "setFloatingPagePosition":
|
|
315
|
+
case "resizeDrawing":
|
|
316
|
+
case "resizeTableColumn":
|
|
317
|
+
case "resizeTableRow":
|
|
318
|
+
case "moveTable":
|
|
319
|
+
case "removeDrawing":
|
|
320
|
+
case "setMathLinear":
|
|
321
|
+
case "deleteMath":
|
|
322
|
+
case "ensureHeaderFooter":
|
|
323
|
+
case "deleteComment":
|
|
324
|
+
case "insertBookmarkRange":
|
|
325
|
+
case "toggleCheckbox":
|
|
326
|
+
case "insertTable":
|
|
327
|
+
case "acceptRevision":
|
|
328
|
+
case "rejectRevision":
|
|
329
|
+
case "acceptAllRevisions":
|
|
330
|
+
return { ...intent, base: newBase };
|
|
331
|
+
case "deleteText": {
|
|
332
|
+
const s = transformPosition({ blockId: intent.blockId, runId: intent.runId, offset: intent.start }, ahead);
|
|
333
|
+
const e = transformPosition({ blockId: intent.blockId, runId: intent.runId, offset: intent.end }, ahead, true);
|
|
334
|
+
return { ...intent, runId: s.runId, blockId: s.blockId, start: s.offset, end: e.runId === s.runId ? e.offset : s.offset, base: newBase };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ../collab/src/apply.ts
|
|
340
|
+
var DOC_SCOPE = { kind: "doc" };
|
|
341
|
+
function unionScopes(a, b) {
|
|
342
|
+
if (!a) return b;
|
|
343
|
+
if (a.kind === "doc" || b.kind === "doc") return DOC_SCOPE;
|
|
344
|
+
if (a.kind === "split" && b.kind === "split") {
|
|
345
|
+
return a.before === b.before && a.after === b.after ? a : DOC_SCOPE;
|
|
346
|
+
}
|
|
347
|
+
if (a.kind === "split" || b.kind === "split") {
|
|
348
|
+
const split = a.kind === "split" ? a : b;
|
|
349
|
+
const block = a.kind === "block" ? a : b;
|
|
350
|
+
return block.blocks.every((el) => el === split.before || el === split.after) ? split : DOC_SCOPE;
|
|
351
|
+
}
|
|
352
|
+
const blocks = [...a.blocks];
|
|
353
|
+
for (const el of b.blocks) if (!blocks.includes(el)) blocks.push(el);
|
|
354
|
+
return { kind: "block", blocks };
|
|
355
|
+
}
|
|
356
|
+
function applyIntentScoped(doc, ids, intent) {
|
|
357
|
+
const out = { scope: DOC_SCOPE };
|
|
358
|
+
try {
|
|
359
|
+
const applied = applyIntentInner(doc, ids, intent, out);
|
|
360
|
+
return { ...out.scope, applied };
|
|
361
|
+
} catch {
|
|
362
|
+
return { kind: "doc", applied: false };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function resyncScope(doc, ids, scope) {
|
|
366
|
+
if (scope.kind === "block") {
|
|
367
|
+
let ok = true;
|
|
368
|
+
for (const block of scope.blocks) ok = doc.reparseBodyParagraph(block) !== null && ok;
|
|
369
|
+
if (ok) {
|
|
370
|
+
ids.assignFromSubtrees(scope.blocks);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
} else if (scope.kind === "split") {
|
|
374
|
+
if (doc.reparseDirectBodyParagraphSplit(scope.before, scope.after)) {
|
|
375
|
+
ids.assignFromSubtrees([scope.before, scope.after]);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
doc.refresh();
|
|
380
|
+
ids.assignFromRoots(doc.editableRoots());
|
|
381
|
+
}
|
|
382
|
+
function applyIntentInner(doc, ids, intent, out) {
|
|
383
|
+
const runOf = makeRunLookup(doc, ids);
|
|
384
|
+
const ctx = { suggesting: false, revMeta: () => {
|
|
385
|
+
throw new Error("suggesting mode unsupported headlessly");
|
|
386
|
+
} };
|
|
387
|
+
switch (intent.kind) {
|
|
388
|
+
case "insertText": {
|
|
389
|
+
const caret = resolveCaret(ids, runOf, intent.at);
|
|
390
|
+
if (!caret) return false;
|
|
391
|
+
if (intent.suggest) {
|
|
392
|
+
const s = intent.suggest;
|
|
393
|
+
const suggestCtx = { suggesting: true, revMeta: () => ({ author: s.author, date: s.date, nextId: () => doc.nextRevisionId() }) };
|
|
394
|
+
const scope = blockScope(ids, intent.at.blockId, intent.at.runId);
|
|
395
|
+
applyInsertText(doc, caret, intent.text, suggestCtx);
|
|
396
|
+
if (doc.stableIds) resyncScope(doc, ids, scope);
|
|
397
|
+
out.scope = scope;
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
applyInsertText(doc, caret, intent.text, ctx);
|
|
401
|
+
out.scope = blockScope(ids, intent.at.blockId, intent.at.runId);
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
case "deleteText": {
|
|
405
|
+
if (intent.end <= intent.start) return false;
|
|
406
|
+
const caret = resolveCaret(ids, runOf, { blockId: intent.blockId, runId: intent.runId, offset: intent.start });
|
|
407
|
+
if (!caret) return false;
|
|
408
|
+
const localEnd = caret.offset + (intent.end - intent.start);
|
|
409
|
+
if (localEnd > caret.t.text.length) return false;
|
|
410
|
+
applyDeleteRange(caret, caret.offset, localEnd);
|
|
411
|
+
out.scope = blockScope(ids, intent.blockId, intent.runId);
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
case "splitParagraph": {
|
|
415
|
+
const caret = resolveCaret(ids, runOf, intent.at);
|
|
416
|
+
if (!caret) return false;
|
|
417
|
+
const res = applySplitParagraph(doc, caret, ctx);
|
|
418
|
+
if (!res) return false;
|
|
419
|
+
ids.assign(res.after, intent.newBlockId);
|
|
420
|
+
const newRun = res.after.children.find((c) => localRun(c));
|
|
421
|
+
if (newRun) ids.assign(newRun, intent.newRunId);
|
|
422
|
+
out.scope = { kind: "split", before: res.before, after: res.after };
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
case "formatRun": {
|
|
426
|
+
const runEl = ids.elOf(intent.runId);
|
|
427
|
+
if (!runEl) return false;
|
|
428
|
+
const entry = runOf(runEl);
|
|
429
|
+
if (!entry) return false;
|
|
430
|
+
const seg = { run: entry.run, t: null, start: 0, end: 0, props: entry.run.props };
|
|
431
|
+
applyRunFormat(doc, [seg], intent.patch);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
case "formatParagraph": {
|
|
435
|
+
const blockEl = ids.elOf(intent.blockId);
|
|
436
|
+
if (!blockEl) return false;
|
|
437
|
+
const target = firstTextDescendant(blockEl) ?? blockEl;
|
|
438
|
+
let changed = false;
|
|
439
|
+
if (intent.align) changed = setParagraphAlignment(doc, [target], intent.align) || changed;
|
|
440
|
+
if (intent.styleId !== void 0) changed = setParagraphStyle(doc, [target], intent.styleId) || changed;
|
|
441
|
+
return changed;
|
|
442
|
+
}
|
|
443
|
+
case "setListType": {
|
|
444
|
+
const blockEl = ids.elOf(intent.blockId);
|
|
445
|
+
if (!blockEl) return false;
|
|
446
|
+
const target = firstTextDescendant(blockEl) ?? blockEl;
|
|
447
|
+
return setListType(doc, [target], intent.listKind);
|
|
448
|
+
}
|
|
449
|
+
case "formatRange": {
|
|
450
|
+
if (intent.end <= intent.start) return false;
|
|
451
|
+
const runEl = ids.elOf(intent.runId);
|
|
452
|
+
if (!runEl) return false;
|
|
453
|
+
const entry = runOf(runEl);
|
|
454
|
+
if (!entry || !entry.firstT) return false;
|
|
455
|
+
const len = runWireLength(runEl);
|
|
456
|
+
if (intent.start < 0 || intent.end > len) return false;
|
|
457
|
+
const hit = resolveRunOffset(runEl, intent.start);
|
|
458
|
+
if (!hit) return false;
|
|
459
|
+
const localEnd = hit.offset + (intent.end - intent.start);
|
|
460
|
+
if (localEnd > hit.t.text.length) return false;
|
|
461
|
+
const seg = { run: entry.run, t: hit.t, start: hit.offset, end: localEnd, props: entry.run.props };
|
|
462
|
+
const formatted = applyRunFormat(doc, [seg], intent.patch);
|
|
463
|
+
if (formatted.length === 0) return false;
|
|
464
|
+
const middleT = formatted[0].t;
|
|
465
|
+
const middleRun = doc.findParentOf(middleT);
|
|
466
|
+
if (!middleRun) return true;
|
|
467
|
+
const container = doc.findParentOf(middleRun);
|
|
468
|
+
if (!container) return true;
|
|
469
|
+
const mIdx = container.children.indexOf(middleRun);
|
|
470
|
+
ids.reassign(middleRun, intent.middleId);
|
|
471
|
+
if (intent.start > 0 && intent.beforeId !== void 0) {
|
|
472
|
+
const before = container.children[mIdx - 1];
|
|
473
|
+
if (before && localRun(before)) ids.reassign(before, intent.beforeId);
|
|
474
|
+
}
|
|
475
|
+
if (intent.end < len && intent.afterId !== void 0) {
|
|
476
|
+
const after = container.children[mIdx + 1];
|
|
477
|
+
if (after && localRun(after)) ids.reassign(after, intent.afterId);
|
|
478
|
+
}
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
case "tableOp": {
|
|
482
|
+
const paraEl = ids.elOf(intent.cellParagraphId);
|
|
483
|
+
if (!paraEl) return false;
|
|
484
|
+
const target = firstTextDescendant(paraEl) ?? paraEl;
|
|
485
|
+
const isInsert = intent.op === "rowAbove" || intent.op === "rowBelow" || intent.op === "colLeft" || intent.op === "colRight";
|
|
486
|
+
const before = isInsert ? trackedSet(ids, doc) : null;
|
|
487
|
+
const ok = applyTableOp(doc, target, intent.op);
|
|
488
|
+
if (!ok) return false;
|
|
489
|
+
if (isInsert && before && intent.nodeIds) {
|
|
490
|
+
const fresh = [];
|
|
491
|
+
walkTracked(doc, (el) => {
|
|
492
|
+
if (!before.has(el)) fresh.push(el);
|
|
493
|
+
});
|
|
494
|
+
for (let k = 0; k < fresh.length && k < intent.nodeIds.length; k++) {
|
|
495
|
+
ids.reassign(fresh[k], intent.nodeIds[k]);
|
|
496
|
+
}
|
|
497
|
+
} else {
|
|
498
|
+
ids.prune(doc.editableRoots());
|
|
499
|
+
}
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
case "mergeParagraph": {
|
|
503
|
+
const pEl = ids.elOf(intent.blockId);
|
|
504
|
+
if (!pEl) return false;
|
|
505
|
+
const ok = mergeParagraphBackward(doc, pEl);
|
|
506
|
+
if (ok) ids.prune(doc.editableRoots());
|
|
507
|
+
return ok;
|
|
508
|
+
}
|
|
509
|
+
case "commentRun": {
|
|
510
|
+
const runEl = ids.elOf(intent.runId);
|
|
511
|
+
if (!runEl) return false;
|
|
512
|
+
const entry = runOf(runEl);
|
|
513
|
+
if (!entry) return false;
|
|
514
|
+
const seg = { run: entry.run, t: null, start: 0, end: 0, props: entry.run.props };
|
|
515
|
+
const prov = recordedProvenance({ dates: [intent.date], paraIds: [intent.paraId] });
|
|
516
|
+
const ok = addComment(doc, [seg], intent.text, intent.author, intent.initials, prov);
|
|
517
|
+
if (ok && doc.stableIds) doc.stableIds.assignFromRoots(doc.editableRoots());
|
|
518
|
+
return ok;
|
|
519
|
+
}
|
|
520
|
+
case "pasteBlocks": {
|
|
521
|
+
const afterEl = ids.elOf(intent.afterBlockId);
|
|
522
|
+
if (!afterEl) return false;
|
|
523
|
+
const parent = doc.findParentOf(afterEl);
|
|
524
|
+
if (!parent) return false;
|
|
525
|
+
let blocks;
|
|
526
|
+
try {
|
|
527
|
+
const root = parseXml(`<w:root xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">${intent.blocksXml}</w:root>`);
|
|
528
|
+
blocks = root.children.filter((c) => localName(c.name) === "p" || localName(c.name) === "tbl");
|
|
529
|
+
} catch {
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
if (blocks.length === 0) return false;
|
|
533
|
+
const check = validatePastedOoxml(blocks);
|
|
534
|
+
if (!check.ok) return false;
|
|
535
|
+
const at = parent.children.indexOf(afterEl);
|
|
536
|
+
parent.children.splice(at + 1, 0, ...blocks);
|
|
537
|
+
doc.refresh();
|
|
538
|
+
const newTracked = [];
|
|
539
|
+
const walk = (el) => {
|
|
540
|
+
const ln = localName(el.name);
|
|
541
|
+
if (ln === "p" || ln === "tbl" || ln === "r") newTracked.push(el);
|
|
542
|
+
el.children.forEach(walk);
|
|
543
|
+
};
|
|
544
|
+
blocks.forEach(walk);
|
|
545
|
+
for (let k = 0; k < newTracked.length && k < intent.nodeIds.length; k++) {
|
|
546
|
+
ids.reassign(newTracked[k], intent.nodeIds[k]);
|
|
547
|
+
}
|
|
548
|
+
return true;
|
|
549
|
+
}
|
|
550
|
+
case "suggestRevision": {
|
|
551
|
+
const meta = {
|
|
552
|
+
author: intent.suggest.author,
|
|
553
|
+
date: intent.suggest.date,
|
|
554
|
+
nextId: () => doc.nextRevisionId()
|
|
555
|
+
};
|
|
556
|
+
const resolved = [];
|
|
557
|
+
for (const r of intent.ranges ?? []) {
|
|
558
|
+
const caret = resolveCaret(ids, runOf, { blockId: r.blockId, runId: r.runId, offset: r.start });
|
|
559
|
+
if (!caret) continue;
|
|
560
|
+
const localEnd = caret.offset + (r.end - r.start);
|
|
561
|
+
if (localEnd > caret.t.text.length) continue;
|
|
562
|
+
const sc = blockScope(ids, r.blockId, r.runId);
|
|
563
|
+
resolved.push({ t: caret.t, start: caret.offset, end: localEnd, block: sc.kind === "block" ? sc.blocks[0] : null });
|
|
564
|
+
}
|
|
565
|
+
const markEls = [];
|
|
566
|
+
for (const m of intent.marks ?? []) {
|
|
567
|
+
const el = ids.elOf(m.blockId);
|
|
568
|
+
if (el) markEls.push({ el, glyph: m.glyph, block: localName(el.name) === "p" ? el : null });
|
|
569
|
+
}
|
|
570
|
+
if (resolved.length === 0 && markEls.length === 0) return false;
|
|
571
|
+
if (resolved.length) deleteSuggestedRange(doc, resolved, meta);
|
|
572
|
+
for (const m of markEls) markParagraphGlyph(m.el, m.glyph, meta);
|
|
573
|
+
const parts = [...resolved.map((x) => x.block), ...markEls.map((x) => x.block)];
|
|
574
|
+
if (parts.every((b) => b !== null)) {
|
|
575
|
+
const blocks = [];
|
|
576
|
+
for (const b of parts) if (!blocks.includes(b)) blocks.push(b);
|
|
577
|
+
const scope = { kind: "block", blocks };
|
|
578
|
+
resyncScope(doc, ids, scope);
|
|
579
|
+
out.scope = scope;
|
|
580
|
+
} else {
|
|
581
|
+
doc.refresh();
|
|
582
|
+
ids.assignFromRoots(doc.editableRoots());
|
|
583
|
+
}
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
case "insertImage": {
|
|
587
|
+
const runEl = ids.elOf(intent.runId);
|
|
588
|
+
if (!runEl) return false;
|
|
589
|
+
const entry = runOf(runEl);
|
|
590
|
+
if (!entry || !entry.firstT) return false;
|
|
591
|
+
const relId = doc.registerPendingImage(intent.blobSha, intent.ext, { iv: intent.iv });
|
|
592
|
+
const before = trackedSet(ids, doc);
|
|
593
|
+
const newRun = insertImageAt(doc, entry.firstT, relId, intent.widthPx, intent.heightPx);
|
|
594
|
+
if (!newRun) return false;
|
|
595
|
+
doc.refresh();
|
|
596
|
+
const fresh = [];
|
|
597
|
+
walkTracked(doc, (el) => {
|
|
598
|
+
if (!before.has(el)) fresh.push(el);
|
|
599
|
+
});
|
|
600
|
+
for (let k = 0; k < fresh.length && k < intent.nodeIds.length; k++) {
|
|
601
|
+
ids.reassign(fresh[k], intent.nodeIds[k]);
|
|
602
|
+
}
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
case "insertBlankPage": {
|
|
606
|
+
const runEl = ids.elOf(intent.runId);
|
|
607
|
+
if (!runEl) return false;
|
|
608
|
+
const entry = runOf(runEl);
|
|
609
|
+
if (!entry || !entry.firstT) return false;
|
|
610
|
+
const before = trackedSet(ids, doc);
|
|
611
|
+
const ok = insertBlankPageAt(doc, entry.firstT, entry.firstT.text.length);
|
|
612
|
+
if (ok) assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
613
|
+
return ok;
|
|
614
|
+
}
|
|
615
|
+
case "insertSectionBreak": {
|
|
616
|
+
const runEl = ids.elOf(intent.runId);
|
|
617
|
+
if (!runEl) return false;
|
|
618
|
+
const entry = runOf(runEl);
|
|
619
|
+
if (!entry || !entry.firstT) return false;
|
|
620
|
+
const before = trackedSet(ids, doc);
|
|
621
|
+
const ok = insertSectionBreak(doc, entry.firstT, intent.breakType);
|
|
622
|
+
if (ok) assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
623
|
+
return ok;
|
|
624
|
+
}
|
|
625
|
+
case "insertCrossRef": {
|
|
626
|
+
const runEl = ids.elOf(intent.runId);
|
|
627
|
+
if (!runEl) return false;
|
|
628
|
+
const entry = runOf(runEl);
|
|
629
|
+
if (!entry || !entry.firstT) return false;
|
|
630
|
+
const before = trackedSet(ids, doc);
|
|
631
|
+
const ok = insertCrossReference(doc, entry.firstT, entry.firstT.text.length, intent.bookmark, intent.refKind);
|
|
632
|
+
if (ok) assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
633
|
+
return ok;
|
|
634
|
+
}
|
|
635
|
+
case "insertCoverPage": {
|
|
636
|
+
const before = trackedSet(ids, doc);
|
|
637
|
+
const ok = insertCoverPage(doc, intent.content);
|
|
638
|
+
if (ok) assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
639
|
+
return ok;
|
|
640
|
+
}
|
|
641
|
+
case "setPageLayout":
|
|
642
|
+
return setPageLayout(doc, intent.patch);
|
|
643
|
+
case "setListLevel": {
|
|
644
|
+
const blockEl = ids.elOf(intent.blockId);
|
|
645
|
+
if (!blockEl) return false;
|
|
646
|
+
const target = firstTextDescendant(blockEl) ?? blockEl;
|
|
647
|
+
return setListLevel(doc, [target], intent.delta);
|
|
648
|
+
}
|
|
649
|
+
case "insertWordArt": {
|
|
650
|
+
const runEl = ids.elOf(intent.runId);
|
|
651
|
+
if (!runEl) return false;
|
|
652
|
+
const entry = runOf(runEl);
|
|
653
|
+
if (!entry || !entry.firstT) return false;
|
|
654
|
+
const before = trackedSet(ids, doc);
|
|
655
|
+
const drawing = insertWordArtAt(doc, entry.firstT, intent.text, intent.preset);
|
|
656
|
+
if (!drawing) return false;
|
|
657
|
+
assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
case "insertChart": {
|
|
661
|
+
const runEl = ids.elOf(intent.runId);
|
|
662
|
+
if (!runEl) return false;
|
|
663
|
+
const entry = runOf(runEl);
|
|
664
|
+
if (!entry || !entry.firstT) return false;
|
|
665
|
+
const before = trackedSet(ids, doc);
|
|
666
|
+
const drawing = insertChartAt(doc, entry.firstT, intent.chart);
|
|
667
|
+
if (!drawing) return false;
|
|
668
|
+
assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
669
|
+
return true;
|
|
670
|
+
}
|
|
671
|
+
case "insertSmartArt": {
|
|
672
|
+
const runEl = ids.elOf(intent.runId);
|
|
673
|
+
if (!runEl) return false;
|
|
674
|
+
const entry = runOf(runEl);
|
|
675
|
+
if (!entry || !entry.firstT) return false;
|
|
676
|
+
const before = trackedSet(ids, doc);
|
|
677
|
+
const drawing = insertSmartArtAt(doc, entry.firstT, intent.smartArt);
|
|
678
|
+
if (!drawing) return false;
|
|
679
|
+
assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
case "setLineNumbering":
|
|
683
|
+
return setLineNumbering(doc, intent.patch);
|
|
684
|
+
case "insertDateTimeField": {
|
|
685
|
+
const runEl = ids.elOf(intent.runId);
|
|
686
|
+
if (!runEl) return false;
|
|
687
|
+
const entry = runOf(runEl);
|
|
688
|
+
if (!entry || !entry.firstT) return false;
|
|
689
|
+
const before = trackedSet(ids, doc);
|
|
690
|
+
const ok = insertDateTimeField(doc, entry.firstT, entry.firstT.text.length, intent.dtKind, intent.picture);
|
|
691
|
+
if (ok) assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
692
|
+
return ok;
|
|
693
|
+
}
|
|
694
|
+
case "insertField": {
|
|
695
|
+
const runEl = ids.elOf(intent.runId);
|
|
696
|
+
if (!runEl) return false;
|
|
697
|
+
const entry = runOf(runEl);
|
|
698
|
+
if (!entry || !entry.firstT) return false;
|
|
699
|
+
const before = trackedSet(ids, doc);
|
|
700
|
+
const ok = insertField(doc, entry.firstT, entry.firstT.text.length, intent.instruction, intent.cachedResult ?? "");
|
|
701
|
+
if (ok) assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
702
|
+
return ok;
|
|
703
|
+
}
|
|
704
|
+
case "setDrawingRotation": {
|
|
705
|
+
const runEl = ids.elOf(intent.runId);
|
|
706
|
+
if (!runEl) return false;
|
|
707
|
+
const drawing = firstDrawingIn(runEl);
|
|
708
|
+
if (!drawing) return false;
|
|
709
|
+
return setDrawingRotation(doc, drawing, intent.degrees);
|
|
710
|
+
}
|
|
711
|
+
case "setDrawingFill": {
|
|
712
|
+
const runEl = ids.elOf(intent.runId);
|
|
713
|
+
if (!runEl) return false;
|
|
714
|
+
const drawing = firstDrawingIn(runEl);
|
|
715
|
+
if (!drawing) return false;
|
|
716
|
+
return setDrawingFill(doc, drawing, intent.color);
|
|
717
|
+
}
|
|
718
|
+
case "setChartData": {
|
|
719
|
+
const runEl = ids.elOf(intent.runId);
|
|
720
|
+
if (!runEl) return false;
|
|
721
|
+
const drawing = firstDrawingIn(runEl);
|
|
722
|
+
if (!drawing) return false;
|
|
723
|
+
return setChartData(doc, drawing, intent.chart);
|
|
724
|
+
}
|
|
725
|
+
case "setSmartArtNodeText": {
|
|
726
|
+
const runEl = ids.elOf(intent.runId);
|
|
727
|
+
if (!runEl) return false;
|
|
728
|
+
const drawing = firstDrawingIn(runEl);
|
|
729
|
+
if (!drawing) return false;
|
|
730
|
+
return setSmartArtNodeText(doc, drawing, intent.index, intent.text);
|
|
731
|
+
}
|
|
732
|
+
case "setDrawingWordArtText": {
|
|
733
|
+
const runEl = ids.elOf(intent.runId);
|
|
734
|
+
if (!runEl) return false;
|
|
735
|
+
const drawing = firstDrawingIn(runEl);
|
|
736
|
+
if (!drawing) return false;
|
|
737
|
+
return setDrawingWordArtText(doc, drawing, intent.text);
|
|
738
|
+
}
|
|
739
|
+
case "setDrawingLineStyle": {
|
|
740
|
+
const runEl = ids.elOf(intent.runId);
|
|
741
|
+
if (!runEl) return false;
|
|
742
|
+
const drawing = firstDrawingIn(runEl);
|
|
743
|
+
if (!drawing) return false;
|
|
744
|
+
return setDrawingLineStyle(doc, drawing, intent.color, intent.widthPx, intent.dash);
|
|
745
|
+
}
|
|
746
|
+
case "setImageAltText": {
|
|
747
|
+
const runEl = ids.elOf(intent.runId);
|
|
748
|
+
if (!runEl) return false;
|
|
749
|
+
const drawing = firstDrawingIn(runEl);
|
|
750
|
+
if (!drawing) return false;
|
|
751
|
+
return setImageAltText(doc, drawing, intent.alt);
|
|
752
|
+
}
|
|
753
|
+
case "removeLink": {
|
|
754
|
+
const runEl = ids.elOf(intent.runId);
|
|
755
|
+
if (!runEl) return false;
|
|
756
|
+
const entry = runOf(runEl);
|
|
757
|
+
if (!entry || !entry.firstT) return false;
|
|
758
|
+
return removeLink(doc, entry.firstT);
|
|
759
|
+
}
|
|
760
|
+
case "setImageWrap": {
|
|
761
|
+
const runEl = ids.elOf(intent.runId);
|
|
762
|
+
if (!runEl) return false;
|
|
763
|
+
const drawing = firstDrawingIn(runEl);
|
|
764
|
+
if (!drawing) return false;
|
|
765
|
+
return setImageWrap(doc, drawing, intent.mode);
|
|
766
|
+
}
|
|
767
|
+
case "setDrawingOrder": {
|
|
768
|
+
const runEl = ids.elOf(intent.runId);
|
|
769
|
+
if (!runEl) return false;
|
|
770
|
+
const drawing = firstDrawingIn(runEl);
|
|
771
|
+
if (!drawing) return false;
|
|
772
|
+
return setDrawingOrder(doc, drawing, intent.order);
|
|
773
|
+
}
|
|
774
|
+
case "setSmartArtData": {
|
|
775
|
+
const runEl = ids.elOf(intent.runId);
|
|
776
|
+
if (!runEl) return false;
|
|
777
|
+
const drawing = firstDrawingIn(runEl);
|
|
778
|
+
if (!drawing) return false;
|
|
779
|
+
return setSmartArtData(doc, drawing, intent.smartArt);
|
|
780
|
+
}
|
|
781
|
+
case "setSmartArtFill": {
|
|
782
|
+
const runEl = ids.elOf(intent.runId);
|
|
783
|
+
if (!runEl) return false;
|
|
784
|
+
const drawing = firstDrawingIn(runEl);
|
|
785
|
+
if (!drawing) return false;
|
|
786
|
+
return setSmartArtFill(doc, drawing, intent.color, intent.nodeIndex);
|
|
787
|
+
}
|
|
788
|
+
case "setSmartArtTextFormat": {
|
|
789
|
+
const runEl = ids.elOf(intent.runId);
|
|
790
|
+
if (!runEl) return false;
|
|
791
|
+
const drawing = firstDrawingIn(runEl);
|
|
792
|
+
if (!drawing) return false;
|
|
793
|
+
return setSmartArtTextFormat(doc, drawing, intent.format, intent.nodeIndex);
|
|
794
|
+
}
|
|
795
|
+
case "setFloatingPagePosition": {
|
|
796
|
+
const runEl = ids.elOf(intent.runId);
|
|
797
|
+
if (!runEl) return false;
|
|
798
|
+
const drawing = firstDrawingIn(runEl);
|
|
799
|
+
if (!drawing) return false;
|
|
800
|
+
return setFloatingPagePosition(doc, drawing, intent.xPx, intent.yPx);
|
|
801
|
+
}
|
|
802
|
+
case "resizeDrawing": {
|
|
803
|
+
const runEl = ids.elOf(intent.runId);
|
|
804
|
+
if (!runEl) return false;
|
|
805
|
+
const drawing = firstDrawingIn(runEl);
|
|
806
|
+
if (!drawing) return false;
|
|
807
|
+
return resizeDrawing(doc, drawing, intent.widthPx, intent.heightPx);
|
|
808
|
+
}
|
|
809
|
+
case "resizeTableColumn": {
|
|
810
|
+
const tbl = tableOfParagraph(doc, ids, intent.cellParagraphId);
|
|
811
|
+
if (!tbl) return false;
|
|
812
|
+
return resizeTableColumn(doc, tbl, intent.boundary, intent.deltaPx, intent.renderedWidths);
|
|
813
|
+
}
|
|
814
|
+
case "resizeTableRow": {
|
|
815
|
+
const tbl = tableOfParagraph(doc, ids, intent.cellParagraphId);
|
|
816
|
+
if (!tbl) return false;
|
|
817
|
+
return resizeTableRow(doc, tbl, intent.rowIdx, intent.heightPx);
|
|
818
|
+
}
|
|
819
|
+
case "moveTable": {
|
|
820
|
+
const tbl = tableOfParagraph(doc, ids, intent.cellParagraphId);
|
|
821
|
+
if (!tbl) return false;
|
|
822
|
+
return moveTableTo(doc, tbl, intent.xPx, intent.yPx, intent.preservePageStart, intent.pageDelta);
|
|
823
|
+
}
|
|
824
|
+
case "removeDrawing": {
|
|
825
|
+
const runEl = ids.elOf(intent.runId);
|
|
826
|
+
if (!runEl) return false;
|
|
827
|
+
const drawing = firstDrawingIn(runEl);
|
|
828
|
+
if (!drawing) return false;
|
|
829
|
+
const ok = removeDrawingRun(doc, drawing);
|
|
830
|
+
if (ok) ids.prune(doc.editableRoots());
|
|
831
|
+
return ok;
|
|
832
|
+
}
|
|
833
|
+
case "setMathLinear": {
|
|
834
|
+
const blockEl = ids.elOf(intent.blockId);
|
|
835
|
+
if (!blockEl) return false;
|
|
836
|
+
const math = firstMathIn(blockEl);
|
|
837
|
+
if (!math) return false;
|
|
838
|
+
return setMathLinear(doc, math, intent.mathText);
|
|
839
|
+
}
|
|
840
|
+
case "deleteMath": {
|
|
841
|
+
const blockEl = ids.elOf(intent.blockId);
|
|
842
|
+
if (!blockEl) return false;
|
|
843
|
+
const math = firstMathIn(blockEl);
|
|
844
|
+
if (!math) return false;
|
|
845
|
+
return deleteMath(doc, math);
|
|
846
|
+
}
|
|
847
|
+
case "moveMath": {
|
|
848
|
+
const blockEl = ids.elOf(intent.blockId);
|
|
849
|
+
if (!blockEl) return false;
|
|
850
|
+
const math = firstMathIn(blockEl);
|
|
851
|
+
if (!math) return false;
|
|
852
|
+
const dest = resolveCaret(ids, runOf, intent.at);
|
|
853
|
+
if (!dest) return false;
|
|
854
|
+
const before = trackedSet(ids, doc);
|
|
855
|
+
if (!moveMath(doc, math, dest.t, dest.offset)) return false;
|
|
856
|
+
const fresh = [];
|
|
857
|
+
walkTracked(doc, (el) => {
|
|
858
|
+
if (!before.has(el)) fresh.push(el);
|
|
859
|
+
});
|
|
860
|
+
for (let k = 0; k < fresh.length && k < intent.nodeIds.length; k++) ids.reassign(fresh[k], intent.nodeIds[k]);
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
863
|
+
case "ensureHeaderFooter": {
|
|
864
|
+
if (doc.hasHfPart(intent.hfKind)) return false;
|
|
865
|
+
const before = trackedSet(ids, doc);
|
|
866
|
+
doc.ensureHfPart(intent.hfKind);
|
|
867
|
+
const fresh = [];
|
|
868
|
+
walkTracked(doc, (el) => {
|
|
869
|
+
if (!before.has(el)) fresh.push(el);
|
|
870
|
+
});
|
|
871
|
+
for (let k = 0; k < fresh.length && k < intent.nodeIds.length; k++) ids.reassign(fresh[k], intent.nodeIds[k]);
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
case "deleteComment":
|
|
875
|
+
return deleteComment(doc, intent.commentId);
|
|
876
|
+
case "insertBookmarkRange": {
|
|
877
|
+
const runEl = ids.elOf(intent.runId);
|
|
878
|
+
if (!runEl) return false;
|
|
879
|
+
const entry = runOf(runEl);
|
|
880
|
+
if (!entry || !entry.firstT) return false;
|
|
881
|
+
if (intent.end > runWireLength(runEl)) return false;
|
|
882
|
+
const hit = resolveRunOffset(runEl, intent.start);
|
|
883
|
+
if (!hit) return false;
|
|
884
|
+
const localEnd = hit.offset + (intent.end - intent.start);
|
|
885
|
+
if (localEnd > hit.t.text.length) return false;
|
|
886
|
+
const seg = { run: entry.run, t: hit.t, start: hit.offset, end: localEnd, props: entry.run.props };
|
|
887
|
+
return insertBookmarkAroundSelection(doc, [seg], intent.name);
|
|
888
|
+
}
|
|
889
|
+
case "toggleCheckbox": {
|
|
890
|
+
const runEl = ids.elOf(intent.runId);
|
|
891
|
+
if (!runEl) return false;
|
|
892
|
+
const entry = runOf(runEl);
|
|
893
|
+
if (!entry) return false;
|
|
894
|
+
const cbEl = checkboxStateElement(entry.run, entry.firstT);
|
|
895
|
+
if (!cbEl) return false;
|
|
896
|
+
toggleCheckbox(doc, cbEl);
|
|
897
|
+
return true;
|
|
898
|
+
}
|
|
899
|
+
case "acceptRevision": {
|
|
900
|
+
const refs = collectRevisions(doc);
|
|
901
|
+
if (intent.index >= refs.length) return false;
|
|
902
|
+
return acceptRevision(doc, refs[intent.index]);
|
|
903
|
+
}
|
|
904
|
+
case "rejectRevision": {
|
|
905
|
+
const refs = collectRevisions(doc);
|
|
906
|
+
if (intent.index >= refs.length) return false;
|
|
907
|
+
return rejectRevision(doc, refs[intent.index]);
|
|
908
|
+
}
|
|
909
|
+
case "acceptAllRevisions":
|
|
910
|
+
return acceptAllRevisions(doc) > 0;
|
|
911
|
+
case "insertTable": {
|
|
912
|
+
const runEl = ids.elOf(intent.runId);
|
|
913
|
+
if (!runEl) return false;
|
|
914
|
+
const entry = runOf(runEl);
|
|
915
|
+
if (!entry || !entry.firstT) return false;
|
|
916
|
+
const before = trackedSet(ids, doc);
|
|
917
|
+
const ok = insertTableAfter(doc, entry.firstT, intent.rows, intent.cols);
|
|
918
|
+
if (ok) assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
919
|
+
return ok;
|
|
920
|
+
}
|
|
921
|
+
case "setLink": {
|
|
922
|
+
if (!isSafeUrl(intent.url)) return false;
|
|
923
|
+
const runEl = ids.elOf(intent.runId);
|
|
924
|
+
if (!runEl) return false;
|
|
925
|
+
const entry = runOf(runEl);
|
|
926
|
+
if (!entry || !entry.firstT) return false;
|
|
927
|
+
const seg = { run: entry.run, t: entry.firstT, start: 0, end: entry.firstT.text.length, props: entry.run.props };
|
|
928
|
+
const before = trackedSet(ids, doc);
|
|
929
|
+
const ok = setLink(doc, [seg], intent.url);
|
|
930
|
+
if (ok) assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
931
|
+
return ok;
|
|
932
|
+
}
|
|
933
|
+
case "insertFootnote": {
|
|
934
|
+
const runEl = ids.elOf(intent.runId);
|
|
935
|
+
if (!runEl) return false;
|
|
936
|
+
const entry = runOf(runEl);
|
|
937
|
+
if (!entry || !entry.firstT) return false;
|
|
938
|
+
const before = trackedSet(ids, doc);
|
|
939
|
+
const id = insertFootnote(doc, entry.firstT, entry.firstT.text.length, intent.text);
|
|
940
|
+
if (id === null) return false;
|
|
941
|
+
assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
case "setDropCap": {
|
|
945
|
+
const blockEl = ids.elOf(intent.blockId);
|
|
946
|
+
if (!blockEl) return false;
|
|
947
|
+
const target = firstTextDescendant(blockEl) ?? blockEl;
|
|
948
|
+
const before = trackedSet(ids, doc);
|
|
949
|
+
const ok = setDropCapAt(doc, target, intent.mode);
|
|
950
|
+
if (ok) assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
951
|
+
return ok;
|
|
952
|
+
}
|
|
953
|
+
case "setDivider": {
|
|
954
|
+
const blockEl = ids.elOf(intent.blockId);
|
|
955
|
+
if (!blockEl) return false;
|
|
956
|
+
const target = firstTextDescendant(blockEl) ?? blockEl;
|
|
957
|
+
return setParagraphDivider(doc, [target], intent.divider);
|
|
958
|
+
}
|
|
959
|
+
case "insertBookmark": {
|
|
960
|
+
const runEl = ids.elOf(intent.runId);
|
|
961
|
+
if (!runEl) return false;
|
|
962
|
+
const entry = runOf(runEl);
|
|
963
|
+
if (!entry || !entry.firstT) return false;
|
|
964
|
+
return insertBookmarkAt(doc, entry.firstT, entry.firstT.text.length, intent.name);
|
|
965
|
+
}
|
|
966
|
+
case "adjustIndent": {
|
|
967
|
+
const blockEl = ids.elOf(intent.blockId);
|
|
968
|
+
if (!blockEl) return false;
|
|
969
|
+
const target = firstTextDescendant(blockEl) ?? blockEl;
|
|
970
|
+
return adjustIndent(doc, [target], intent.direction);
|
|
971
|
+
}
|
|
972
|
+
case "setSpacing": {
|
|
973
|
+
const blockEl = ids.elOf(intent.blockId);
|
|
974
|
+
if (!blockEl) return false;
|
|
975
|
+
const target = firstTextDescendant(blockEl) ?? blockEl;
|
|
976
|
+
return setParagraphSpacing(doc, [target], intent.patch);
|
|
977
|
+
}
|
|
978
|
+
case "insertPageField": {
|
|
979
|
+
const runEl = ids.elOf(intent.runId);
|
|
980
|
+
if (!runEl) return false;
|
|
981
|
+
const entry = runOf(runEl);
|
|
982
|
+
if (!entry || !entry.firstT) return false;
|
|
983
|
+
const before = trackedSet(ids, doc);
|
|
984
|
+
const ok = insertPageField(doc, entry.firstT, entry.firstT.text.length, intent.fieldKind);
|
|
985
|
+
if (!ok) return false;
|
|
986
|
+
assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
case "replyComment": {
|
|
990
|
+
const prov = recordedProvenance({ dates: [intent.date], paraIds: intent.paraIds });
|
|
991
|
+
const ok = replyToComment(doc, intent.parentId, intent.text, intent.author, intent.initials, prov);
|
|
992
|
+
if (ok && doc.stableIds) doc.stableIds.assignFromRoots(doc.editableRoots());
|
|
993
|
+
return ok;
|
|
994
|
+
}
|
|
995
|
+
case "insertBreak": {
|
|
996
|
+
const runEl = ids.elOf(intent.runId);
|
|
997
|
+
if (!runEl) return false;
|
|
998
|
+
const entry = runOf(runEl);
|
|
999
|
+
if (!entry || !entry.firstT) return false;
|
|
1000
|
+
const before = trackedSet(ids, doc);
|
|
1001
|
+
const res = insertBreakAt(doc, entry.firstT, entry.firstT.text.length, intent.breakKind);
|
|
1002
|
+
if (!res) return false;
|
|
1003
|
+
const fresh = [];
|
|
1004
|
+
walkTracked(doc, (el) => {
|
|
1005
|
+
if (!before.has(el)) fresh.push(el);
|
|
1006
|
+
});
|
|
1007
|
+
for (let k = 0; k < fresh.length && k < intent.nodeIds.length; k++) {
|
|
1008
|
+
ids.reassign(fresh[k], intent.nodeIds[k]);
|
|
1009
|
+
}
|
|
1010
|
+
return true;
|
|
1011
|
+
}
|
|
1012
|
+
case "insertMath": {
|
|
1013
|
+
const runEl = ids.elOf(intent.runId);
|
|
1014
|
+
if (!runEl) return false;
|
|
1015
|
+
const entry = runOf(runEl);
|
|
1016
|
+
if (!entry || !entry.firstT) return false;
|
|
1017
|
+
const before = trackedSet(ids, doc);
|
|
1018
|
+
const res = insertMathAt(doc, entry.firstT, entry.firstT.text.length, intent.mathText);
|
|
1019
|
+
if (!res) return false;
|
|
1020
|
+
assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
1021
|
+
return true;
|
|
1022
|
+
}
|
|
1023
|
+
case "insertShape": {
|
|
1024
|
+
const runEl = ids.elOf(intent.runId);
|
|
1025
|
+
if (!runEl) return false;
|
|
1026
|
+
const entry = runOf(runEl);
|
|
1027
|
+
if (!entry || !entry.firstT) return false;
|
|
1028
|
+
const before = trackedSet(ids, doc);
|
|
1029
|
+
const res = insertShapeAt(doc, entry.firstT, intent.preset, intent.text ?? "");
|
|
1030
|
+
if (!res) return false;
|
|
1031
|
+
assignFreshTracked(ids, doc, before, intent.nodeIds);
|
|
1032
|
+
return true;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
function assignFreshTracked(ids, doc, before, nodeIds) {
|
|
1037
|
+
const fresh = [];
|
|
1038
|
+
walkTracked(doc, (el) => {
|
|
1039
|
+
if (!before.has(el)) fresh.push(el);
|
|
1040
|
+
});
|
|
1041
|
+
for (let k = 0; k < fresh.length && k < nodeIds.length; k++) ids.reassign(fresh[k], nodeIds[k]);
|
|
1042
|
+
}
|
|
1043
|
+
function localRun(el) {
|
|
1044
|
+
const n = el.name;
|
|
1045
|
+
return n === "w:r" || n.endsWith(":r") || n === "r";
|
|
1046
|
+
}
|
|
1047
|
+
function walkTracked(doc, visit) {
|
|
1048
|
+
const walk = (el) => {
|
|
1049
|
+
const ln = localName(el.name);
|
|
1050
|
+
if (ln === "p" || ln === "tbl" || ln === "r") visit(el);
|
|
1051
|
+
el.children.forEach(walk);
|
|
1052
|
+
};
|
|
1053
|
+
doc.editableRoots().forEach(walk);
|
|
1054
|
+
}
|
|
1055
|
+
function trackedSet(_ids, doc) {
|
|
1056
|
+
const set = /* @__PURE__ */ new Set();
|
|
1057
|
+
walkTracked(doc, (el) => set.add(el));
|
|
1058
|
+
return set;
|
|
1059
|
+
}
|
|
1060
|
+
function firstTextDescendant(el) {
|
|
1061
|
+
if (el.name === "w:t" || el.name.endsWith(":t")) return el;
|
|
1062
|
+
for (const c of el.children) {
|
|
1063
|
+
const found = firstTextDescendant(c);
|
|
1064
|
+
if (found) return found;
|
|
1065
|
+
}
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
function tableOfParagraph(doc, ids, cellParagraphId) {
|
|
1069
|
+
const paraEl = ids.elOf(cellParagraphId);
|
|
1070
|
+
if (!paraEl) return null;
|
|
1071
|
+
for (let cur = paraEl; cur; cur = doc.findParentOf(cur) ?? null) {
|
|
1072
|
+
if (localName(cur.name) === "tbl") return cur;
|
|
1073
|
+
}
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
function firstDrawingIn(el) {
|
|
1077
|
+
if (localName(el.name) === "drawing") return el;
|
|
1078
|
+
for (const c of el.children) {
|
|
1079
|
+
const found = firstDrawingIn(c);
|
|
1080
|
+
if (found) return found;
|
|
1081
|
+
}
|
|
1082
|
+
return null;
|
|
1083
|
+
}
|
|
1084
|
+
function firstMathIn(el) {
|
|
1085
|
+
if (localName(el.name) === "oMath") return el;
|
|
1086
|
+
for (const c of el.children) {
|
|
1087
|
+
const found = firstMathIn(c);
|
|
1088
|
+
if (found) return found;
|
|
1089
|
+
}
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
function runEntry(run) {
|
|
1093
|
+
return { run, firstT: run.content.find((c) => c.kind === "text")?.srcT };
|
|
1094
|
+
}
|
|
1095
|
+
function makeRunLookup(doc, ids) {
|
|
1096
|
+
let full = null;
|
|
1097
|
+
const scoped = /* @__PURE__ */ new Map();
|
|
1098
|
+
return (runEl, blockIdHint) => {
|
|
1099
|
+
if (!full) {
|
|
1100
|
+
const cached = scoped.get(runEl);
|
|
1101
|
+
if (cached) return cached;
|
|
1102
|
+
if (blockIdHint !== void 0) {
|
|
1103
|
+
const blockEl = ids.elOf(blockIdHint);
|
|
1104
|
+
const para = blockEl ? doc.paragraphBySource(blockEl) : null;
|
|
1105
|
+
if (para) {
|
|
1106
|
+
for (const item of para.children) {
|
|
1107
|
+
for (const run of item.type === "run" ? [item] : item.runs) {
|
|
1108
|
+
if (run.src) scoped.set(run.src, runEntry(run));
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
const hit = scoped.get(runEl);
|
|
1112
|
+
if (hit) return hit;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
full = buildRunMap(doc);
|
|
1116
|
+
}
|
|
1117
|
+
return full.get(runEl);
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
function contains(el, needle) {
|
|
1121
|
+
if (el === needle) return true;
|
|
1122
|
+
for (const c of el.children) if (contains(c, needle)) return true;
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
function blockScope(ids, blockId, runId) {
|
|
1126
|
+
const blockEl = ids.elOf(blockId);
|
|
1127
|
+
const runEl = ids.elOf(runId);
|
|
1128
|
+
if (!blockEl || !runEl || localName(blockEl.name) !== "p") return DOC_SCOPE;
|
|
1129
|
+
return contains(blockEl, runEl) ? { kind: "block", blocks: [blockEl] } : DOC_SCOPE;
|
|
1130
|
+
}
|
|
1131
|
+
function buildRunMap(doc) {
|
|
1132
|
+
const map = /* @__PURE__ */ new Map();
|
|
1133
|
+
const visitRun = (run) => {
|
|
1134
|
+
if (run.src) map.set(run.src, runEntry(run));
|
|
1135
|
+
for (const c of run.content) {
|
|
1136
|
+
if (c.kind === "anchor" && c.shape.type === "textbox") visitBlocks(c.shape.blocks);
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
const visitBlocks = (blocks) => {
|
|
1140
|
+
for (const b of blocks) {
|
|
1141
|
+
if (b.type === "paragraph") {
|
|
1142
|
+
for (const item of b.children) {
|
|
1143
|
+
if (item.type === "run") visitRun(item);
|
|
1144
|
+
else for (const r of item.runs) visitRun(r);
|
|
1145
|
+
}
|
|
1146
|
+
} else if (b.type === "table") {
|
|
1147
|
+
for (const row of b.rows) for (const cell of row.cells) visitBlocks(cell.blocks);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
for (const section of doc.sections) visitBlocks(section.blocks);
|
|
1152
|
+
for (const hf of doc.headers.values()) visitBlocks(hf.blocks);
|
|
1153
|
+
for (const hf of doc.footers.values()) visitBlocks(hf.blocks);
|
|
1154
|
+
return map;
|
|
1155
|
+
}
|
|
1156
|
+
function resolveCaret(ids, runOf, pos) {
|
|
1157
|
+
const runEl = ids.elOf(pos.runId);
|
|
1158
|
+
if (!runEl) return null;
|
|
1159
|
+
const entry = runOf(runEl, pos.blockId);
|
|
1160
|
+
if (!entry || !entry.firstT) return null;
|
|
1161
|
+
if (pos.offset < 0 || pos.offset > runWireLength(runEl)) return null;
|
|
1162
|
+
const hit = resolveRunOffset(runEl, pos.offset);
|
|
1163
|
+
if (!hit) return null;
|
|
1164
|
+
return { t: hit.t, run: entry.run, offset: hit.offset };
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ../collab/src/replica.ts
|
|
1168
|
+
var _ClientReplica = class _ClientReplica {
|
|
1169
|
+
constructor(bytes, sidecar) {
|
|
1170
|
+
/** Server seq the confirmed baseline reflects. */
|
|
1171
|
+
this.confirmedSeq = 0;
|
|
1172
|
+
this.pending = [];
|
|
1173
|
+
/** Confirmed entries NOT yet folded into confirmedBytes: own-echoes
|
|
1174
|
+
* deferred mid-burst (the original role) AND — since the lazy-baseline
|
|
1175
|
+
* change — every fast-path confirmed entry, so the O(doc) save() stays
|
|
1176
|
+
* off the receive hot path entirely. restoreConfirmed replays the tail
|
|
1177
|
+
* onto the baseline; exportBundleState folds it for persistence; the
|
|
1178
|
+
* fast path folds eagerly once the tail reaches FOLD_TAIL_AT (bounding
|
|
1179
|
+
* conflict-replay cost while amortizing saves to ~1/FOLD_TAIL_AT ops). */
|
|
1180
|
+
this.confirmedTail = [];
|
|
1181
|
+
/**
|
|
1182
|
+
* Bumped every time THIS CLASS mutates the live doc (optimistic apply,
|
|
1183
|
+
* remote apply, replay, restore) — and deliberately NOT on bookkeeping
|
|
1184
|
+
* that leaves the doc untouched (trackLocal, own-echo confirmation).
|
|
1185
|
+
*
|
|
1186
|
+
* This is the renderer's repaint gate. The react layer used to repaint on
|
|
1187
|
+
* EVERY onChange, but for the editor-driven typing path (trackLocal + the
|
|
1188
|
+
* own-echo fast path) the doc was already mutated AND painted by the
|
|
1189
|
+
* editor, so each keystroke queued a redundant whole-document relayout —
|
|
1190
|
+
* invisible on 5 pages, catastrophic past the background-layout threshold
|
|
1191
|
+
* (500 pages: the layout ran async with the container inert until it
|
|
1192
|
+
* finished, blocking further typing for seconds per keystroke).
|
|
1193
|
+
*/
|
|
1194
|
+
this.docVersion = 0;
|
|
1195
|
+
/**
|
|
1196
|
+
* Union of the dirty scopes behind the docVersion bumps not yet consumed by
|
|
1197
|
+
* a repaint (see takeRenderScope). Every path that bumps docVersion also
|
|
1198
|
+
* records what it disturbed: applyAndResync its intent's ApplyScope,
|
|
1199
|
+
* restoreConfirmed doc scope (the doc OBJECT was replaced). The renderer
|
|
1200
|
+
* drains this when it repaints, so a coalesced repaint covering several
|
|
1201
|
+
* bumps sees the union — the batching rule lives in unionScopes.
|
|
1202
|
+
*/
|
|
1203
|
+
this.renderScope = null;
|
|
1204
|
+
/**
|
|
1205
|
+
* Receive the server's canonical broadcast entries (in seq order) and
|
|
1206
|
+
* reconcile. The invariant is: live doc = confirmed baseline + remaining
|
|
1207
|
+
* pending. So every receive restores to the confirmed baseline, applies the
|
|
1208
|
+
* canonical batch (advancing the baseline and dropping matched pending),
|
|
1209
|
+
* then replays the still-pending intents transformed against the remote
|
|
1210
|
+
* intents in the batch (plan doc 03). Idempotent for already-seen seqs.
|
|
1211
|
+
*/
|
|
1212
|
+
/** True after a receive() that reloaded the doc object (a true conflict
|
|
1213
|
+
* reconciliation) — the renderer must re-key/rebind. Stays false for the
|
|
1214
|
+
* in-place fast paths so the editor updates without a re-mount. */
|
|
1215
|
+
this.reloaded = false;
|
|
1216
|
+
this.doc = DocxDocument.load(bytes);
|
|
1217
|
+
this.ids = this.doc.enableStableIds();
|
|
1218
|
+
if (sidecar) this.ids.importSidecar(this.doc.editableRoots(), sidecar);
|
|
1219
|
+
this.confirmedBytes = this.doc.save();
|
|
1220
|
+
this.confirmedSidecar = this.ids.exportSidecar(this.doc.editableRoots());
|
|
1221
|
+
}
|
|
1222
|
+
/** Drain the dirty scope accumulated since the last take (null when no
|
|
1223
|
+
* doc-mutating change was recorded). The caller repaints exactly this. */
|
|
1224
|
+
takeRenderScope() {
|
|
1225
|
+
const s = this.renderScope;
|
|
1226
|
+
this.renderScope = null;
|
|
1227
|
+
return s;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Apply a locally produced intent optimistically and enqueue it as pending.
|
|
1231
|
+
* The intent's `base` must be the current confirmed seq.
|
|
1232
|
+
*
|
|
1233
|
+
* CONSTRAINT (one-in-flight): a client should hold at most one un-confirmed
|
|
1234
|
+
* pending intent at a time — submit the next only after the previous is
|
|
1235
|
+
* confirmed. This is the model production OT servers use (e.g. ShareDB).
|
|
1236
|
+
* Multiple concurrent same-client pending whose offsets assume each other
|
|
1237
|
+
* cannot be correctly rebased against an interleaved remote edit without
|
|
1238
|
+
* operation inverses, which the OT-lite transform deliberately does not
|
|
1239
|
+
* implement (plan doc 03 marks adjusted-sibling replay a scoped next step).
|
|
1240
|
+
* The transform below is correct for the single-in-flight case; the replay
|
|
1241
|
+
* still stores transformed forms so a burst that stays same-client-only
|
|
1242
|
+
* (no remote interleave) also composes.
|
|
1243
|
+
*/
|
|
1244
|
+
submitLocal(intent) {
|
|
1245
|
+
this.applyAndResync(intent);
|
|
1246
|
+
this.pending.push(intent);
|
|
1247
|
+
}
|
|
1248
|
+
/** Track an intent the caller ALREADY applied to this replica's live doc
|
|
1249
|
+
* (the editor-driven path: DocxEditor mutates `doc` through its own command
|
|
1250
|
+
* and emits the intent afterwards). Applying it again here would double it —
|
|
1251
|
+
* the demo typed "Hello" and got "Hello" + its reversal. Pending tracking
|
|
1252
|
+
* and reconciliation behave exactly as for submitLocal. */
|
|
1253
|
+
trackLocal(intent) {
|
|
1254
|
+
this.pending.push(intent);
|
|
1255
|
+
}
|
|
1256
|
+
/** Number of un-confirmed local intents (for the one-in-flight discipline). */
|
|
1257
|
+
get pendingCount() {
|
|
1258
|
+
return this.pending.length;
|
|
1259
|
+
}
|
|
1260
|
+
/** Identity of the OLDEST un-confirmed local intent — the delivery-retry
|
|
1261
|
+
* probe (a stuck front pending means the op or its echo was lost). */
|
|
1262
|
+
firstPending() {
|
|
1263
|
+
const p = this.pending[0];
|
|
1264
|
+
return p ? { clientId: p.clientId, clientSeq: p.clientSeq } : null;
|
|
1265
|
+
}
|
|
1266
|
+
/** Copies of every pending intent in submit order (their reconciliation-
|
|
1267
|
+
* transformed forms) — the rate-limit re-drive resends the WHOLE queue:
|
|
1268
|
+
* chained bursts (each op addressing ids a previous op allocated) strand
|
|
1269
|
+
* everything behind one refused op, and dedup makes resends free. */
|
|
1270
|
+
pendingCopies() {
|
|
1271
|
+
return [...this.pending];
|
|
1272
|
+
}
|
|
1273
|
+
/** The current pending copy of an intent by idempotency key, or null if it
|
|
1274
|
+
* was confirmed or dropped. This is the RECONCILIATION-TRANSFORMED form —
|
|
1275
|
+
* replayPending rewrites pending against every remote entry ingested since
|
|
1276
|
+
* submission — and therefore the only correct body to re-seal on a
|
|
1277
|
+
* stale-base retry: resealing the raw original resubmits untransformed
|
|
1278
|
+
* coordinates that the server then applies verbatim, while the optimistic
|
|
1279
|
+
* doc holds the transformed form — a silently divergent baseline once the
|
|
1280
|
+
* own-echo fast path snapshots it (the checkpoint-boundary divergence). */
|
|
1281
|
+
pendingIntent(clientId, clientSeq) {
|
|
1282
|
+
return this.pending.find((p) => p.clientId === clientId && p.clientSeq === clientSeq) ?? null;
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* The replica's durable state — exactly the fields the doc-12 §4 bundle
|
|
1286
|
+
* persists (the persistence layer is a thin observer over this). The bytes
|
|
1287
|
+
* and sidecar are the CONFIRMED baseline, never the live optimistic doc:
|
|
1288
|
+
* a bundle must be replayable (confirmed + pending re-submit), and
|
|
1289
|
+
* snapshotting optimistic state would bake unsequenced edits into what a
|
|
1290
|
+
* re-seed later presents as canonical (doc 03 re-capture rule).
|
|
1291
|
+
* `pending` is a copy — callers can't mutate reconciliation state.
|
|
1292
|
+
*/
|
|
1293
|
+
exportBundleState() {
|
|
1294
|
+
let confirmedBytes = this.confirmedBytes;
|
|
1295
|
+
let confirmedSidecar = this.confirmedSidecar;
|
|
1296
|
+
if (this.confirmedTail.length) {
|
|
1297
|
+
if (this.pending.length === 0) {
|
|
1298
|
+
confirmedBytes = this.doc.save();
|
|
1299
|
+
confirmedSidecar = this.ids.exportSidecar(this.doc.editableRoots());
|
|
1300
|
+
} else {
|
|
1301
|
+
const base = DocxDocument.load(this.confirmedBytes);
|
|
1302
|
+
const baseIds = base.enableStableIds();
|
|
1303
|
+
baseIds.importSidecar(base.editableRoots(), this.confirmedSidecar);
|
|
1304
|
+
for (const e of this.confirmedTail) {
|
|
1305
|
+
if (e.kind !== "applied") continue;
|
|
1306
|
+
const res = applyIntentScoped(base, baseIds, e.intent);
|
|
1307
|
+
if (res.applied) resyncScope(base, baseIds, res);
|
|
1308
|
+
}
|
|
1309
|
+
this.carryInstalledMedia(this.doc, base);
|
|
1310
|
+
confirmedBytes = base.save();
|
|
1311
|
+
confirmedSidecar = baseIds.exportSidecar(base.editableRoots());
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
return {
|
|
1315
|
+
confirmedSeq: this.confirmedSeq,
|
|
1316
|
+
confirmedBytes,
|
|
1317
|
+
confirmedSidecar,
|
|
1318
|
+
pending: [...this.pending],
|
|
1319
|
+
mediaMeta: [...this.doc.mediaMeta]
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
async exportBundleStateAsync() {
|
|
1323
|
+
let confirmedBytes = this.confirmedBytes;
|
|
1324
|
+
let confirmedSidecar = this.confirmedSidecar;
|
|
1325
|
+
if (this.confirmedTail.length) {
|
|
1326
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1327
|
+
if (this.pending.length === 0) {
|
|
1328
|
+
confirmedBytes = await this.doc.saveAsync();
|
|
1329
|
+
confirmedSidecar = this.ids.exportSidecar(this.doc.editableRoots());
|
|
1330
|
+
} else {
|
|
1331
|
+
const base = DocxDocument.load(this.confirmedBytes);
|
|
1332
|
+
const baseIds = base.enableStableIds();
|
|
1333
|
+
baseIds.importSidecar(base.editableRoots(), this.confirmedSidecar);
|
|
1334
|
+
for (const e of this.confirmedTail) {
|
|
1335
|
+
if (e.kind !== "applied") continue;
|
|
1336
|
+
const res = applyIntentScoped(base, baseIds, e.intent);
|
|
1337
|
+
if (res.applied) resyncScope(base, baseIds, res);
|
|
1338
|
+
}
|
|
1339
|
+
this.carryInstalledMedia(this.doc, base);
|
|
1340
|
+
confirmedBytes = await base.saveAsync();
|
|
1341
|
+
confirmedSidecar = baseIds.exportSidecar(base.editableRoots());
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return {
|
|
1345
|
+
confirmedSeq: this.confirmedSeq,
|
|
1346
|
+
confirmedBytes,
|
|
1347
|
+
confirmedSidecar,
|
|
1348
|
+
pending: [...this.pending],
|
|
1349
|
+
mediaMeta: [...this.doc.mediaMeta]
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
receive(entries) {
|
|
1353
|
+
this.reloaded = false;
|
|
1354
|
+
const fresh = entries.filter((e) => e.seq > this.confirmedSeq);
|
|
1355
|
+
if (fresh.length === 0) return;
|
|
1356
|
+
const remoteAhead = fresh.filter((e) => e.kind === "applied" && !isOurs(e, this.pending)).map((e) => e.intent);
|
|
1357
|
+
const rejectedOurs = fresh.some((e) => e.kind === "rejected" && isOurs(e, this.pending));
|
|
1358
|
+
if (rejectedOurs) {
|
|
1359
|
+
this.restoreConfirmed();
|
|
1360
|
+
this.reloaded = true;
|
|
1361
|
+
for (const e of fresh) this.advanceConfirmed(e, e.kind === "applied");
|
|
1362
|
+
this.snapshotConfirmed();
|
|
1363
|
+
this.replayPending(remoteAhead);
|
|
1364
|
+
this.resync();
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const freshKeys = new Set(
|
|
1368
|
+
fresh.map((e) => e.kind === "applied" ? idempotencyKey(e.intent) : `${e.clientId}:${e.clientSeq}`)
|
|
1369
|
+
);
|
|
1370
|
+
const drainsAllPending = this.pending.every((p) => freshKeys.has(idempotencyKey(p)));
|
|
1371
|
+
if (this.pending.length === 0 || remoteAhead.length === 0 && drainsAllPending) {
|
|
1372
|
+
const applyToDoc = remoteAhead.length > 0;
|
|
1373
|
+
for (const e of fresh) {
|
|
1374
|
+
this.advanceConfirmed(
|
|
1375
|
+
e,
|
|
1376
|
+
/*applyToDoc*/
|
|
1377
|
+
applyToDoc
|
|
1378
|
+
);
|
|
1379
|
+
if (e.kind === "applied") this.confirmedTail.push(e);
|
|
1380
|
+
}
|
|
1381
|
+
if (this.confirmedTail.length >= _ClientReplica.FOLD_TAIL_AT) this.snapshotConfirmed();
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (remoteAhead.length === 0) {
|
|
1385
|
+
for (const e of fresh) {
|
|
1386
|
+
if (e.kind === "applied") this.confirmedTail.push(e);
|
|
1387
|
+
const key = e.kind === "applied" ? idempotencyKey(e.intent) : `${e.clientId}:${e.clientSeq}`;
|
|
1388
|
+
this.pending = this.pending.filter((p) => idempotencyKey(p) !== key);
|
|
1389
|
+
this.confirmedSeq = e.seq;
|
|
1390
|
+
}
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
this.restoreConfirmed();
|
|
1394
|
+
this.reloaded = true;
|
|
1395
|
+
for (const e of fresh) this.advanceConfirmed(e, true);
|
|
1396
|
+
this.snapshotConfirmed();
|
|
1397
|
+
this.replayPending(remoteAhead);
|
|
1398
|
+
this.resync();
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Apply one intent to the live doc and reconcile ONLY what it touched
|
|
1402
|
+
* (perf B9/B10). Every applied intent used to cost a full model reparse plus
|
|
1403
|
+
* a full id walk somewhere on this path, so a keystroke arriving into a
|
|
1404
|
+
* 600-paragraph document cost 600 paragraphs of work — the seeding curve and
|
|
1405
|
+
* the stall under an incoming flood. Text-level intents now reparse their
|
|
1406
|
+
* own paragraph; anything structural or unverifiable still takes the full
|
|
1407
|
+
* refresh (see resyncScope). Returns false when the intent didn't apply.
|
|
1408
|
+
*/
|
|
1409
|
+
applyAndResync(intent) {
|
|
1410
|
+
const res = applyIntentScoped(this.doc, this.ids, intent);
|
|
1411
|
+
if (!res.applied) return false;
|
|
1412
|
+
resyncScope(this.doc, this.ids, res);
|
|
1413
|
+
this.docVersion++;
|
|
1414
|
+
this.renderScope = unionScopes(this.renderScope, res);
|
|
1415
|
+
return true;
|
|
1416
|
+
}
|
|
1417
|
+
advanceConfirmed(e, applyToDoc) {
|
|
1418
|
+
if (e.kind === "applied" && applyToDoc) this.applyAndResync(e.intent);
|
|
1419
|
+
const key = e.kind === "applied" ? idempotencyKey(e.intent) : `${e.clientId}:${e.clientSeq}`;
|
|
1420
|
+
this.pending = this.pending.filter((p) => idempotencyKey(p) !== key);
|
|
1421
|
+
this.confirmedSeq = e.seq;
|
|
1422
|
+
}
|
|
1423
|
+
replayPending(remoteAhead) {
|
|
1424
|
+
const stillPending = this.pending;
|
|
1425
|
+
this.pending = [];
|
|
1426
|
+
for (const p of stillPending) {
|
|
1427
|
+
const transformed = transformIntent(p, remoteAhead);
|
|
1428
|
+
if (this.applyAndResync(transformed)) this.pending.push(transformed);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
/** Copy installed media pixels (out-of-band state — bytes arrive via the
|
|
1432
|
+
* relay, not the intent log) from one doc into another whose matching
|
|
1433
|
+
* parts are still pending. */
|
|
1434
|
+
carryInstalledMedia(from, to) {
|
|
1435
|
+
for (const [part] of from.mediaMeta) {
|
|
1436
|
+
if (from.pendingMedia.has(part)) continue;
|
|
1437
|
+
const bytes = from.pkg.binary(part);
|
|
1438
|
+
if (bytes) to.installMedia(part, bytes);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
restoreConfirmed() {
|
|
1442
|
+
this.docVersion++;
|
|
1443
|
+
this.renderScope = { kind: "doc" };
|
|
1444
|
+
const pixelSource = this.doc;
|
|
1445
|
+
this.doc = DocxDocument.load(this.confirmedBytes);
|
|
1446
|
+
this.ids = this.doc.enableStableIds();
|
|
1447
|
+
this.ids.importSidecar(this.doc.editableRoots(), this.confirmedSidecar);
|
|
1448
|
+
for (const e of this.confirmedTail) {
|
|
1449
|
+
if (e.kind === "applied") this.applyAndResync(e.intent);
|
|
1450
|
+
}
|
|
1451
|
+
this.carryInstalledMedia(pixelSource, this.doc);
|
|
1452
|
+
}
|
|
1453
|
+
snapshotConfirmed() {
|
|
1454
|
+
this.confirmedBytes = this.doc.save();
|
|
1455
|
+
this.confirmedSidecar = this.ids.exportSidecar(this.doc.editableRoots());
|
|
1456
|
+
this.confirmedTail = [];
|
|
1457
|
+
}
|
|
1458
|
+
resync() {
|
|
1459
|
+
this.doc.refresh();
|
|
1460
|
+
this.ids.assignFromRoots(this.doc.editableRoots());
|
|
1461
|
+
}
|
|
1462
|
+
};
|
|
1463
|
+
/** Fold cadence for the lazy confirmed baseline (see confirmedTail). */
|
|
1464
|
+
_ClientReplica.FOLD_TAIL_AT = 100;
|
|
1465
|
+
var ClientReplica = _ClientReplica;
|
|
1466
|
+
function isOurs(e, pending) {
|
|
1467
|
+
const key = e.kind === "applied" ? idempotencyKey(e.intent) : `${e.clientId}:${e.clientSeq}`;
|
|
1468
|
+
return pending.some((p) => idempotencyKey(p) === key);
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// ../collab/src/bundle.ts
|
|
1472
|
+
function versionKey(docId, savedAt, label) {
|
|
1473
|
+
return `${docId}#version-${savedAt}${label ? `-${label}` : ""}`;
|
|
1474
|
+
}
|
|
1475
|
+
function draftKey(docId, genesisId) {
|
|
1476
|
+
return `${docId}#draft-${genesisId}`;
|
|
1477
|
+
}
|
|
1478
|
+
function supersededKey(docId, genesisId) {
|
|
1479
|
+
return `${docId}#superseded-${genesisId}`;
|
|
1480
|
+
}
|
|
1481
|
+
function parseBundleKey(key) {
|
|
1482
|
+
if (key.startsWith("local:")) return { docId: key, kind: "local" };
|
|
1483
|
+
const hash = key.indexOf("#");
|
|
1484
|
+
if (hash === -1) return { docId: key, kind: "live" };
|
|
1485
|
+
const docId = key.slice(0, hash);
|
|
1486
|
+
const suffix = key.slice(hash + 1);
|
|
1487
|
+
const version = /^version-(\d+)(?:-(.*))?$/.exec(suffix);
|
|
1488
|
+
if (version) return { docId, kind: "version", versionSavedAt: Number(version[1]), label: version[2] };
|
|
1489
|
+
if (suffix.startsWith("draft-")) return { docId, kind: "draft" };
|
|
1490
|
+
if (suffix.startsWith("superseded-")) return { docId, kind: "superseded" };
|
|
1491
|
+
return { docId, kind: "unknown" };
|
|
1492
|
+
}
|
|
1493
|
+
var InMemoryBundleStore = class {
|
|
1494
|
+
constructor() {
|
|
1495
|
+
this.bundles = /* @__PURE__ */ new Map();
|
|
1496
|
+
/** Write count — lets tests assert the throttle's coalescing precisely. */
|
|
1497
|
+
this.writes = 0;
|
|
1498
|
+
}
|
|
1499
|
+
async get(docId) {
|
|
1500
|
+
return this.bundles.get(docId) ?? null;
|
|
1501
|
+
}
|
|
1502
|
+
async put(bundle) {
|
|
1503
|
+
this.writes++;
|
|
1504
|
+
this.bundles.set(bundle.docId, bundle);
|
|
1505
|
+
}
|
|
1506
|
+
async delete(docId) {
|
|
1507
|
+
this.bundles.delete(docId);
|
|
1508
|
+
}
|
|
1509
|
+
async list() {
|
|
1510
|
+
return [...this.bundles.values()].map((b) => ({
|
|
1511
|
+
...parseBundleKey(b.docId),
|
|
1512
|
+
key: b.docId,
|
|
1513
|
+
savedAt: b.savedAt,
|
|
1514
|
+
byteLength: b.confirmedBytes.byteLength
|
|
1515
|
+
}));
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
var BundlePersister = class {
|
|
1519
|
+
constructor(conn, store, docId, opts = {}) {
|
|
1520
|
+
this.conn = conn;
|
|
1521
|
+
this.store = store;
|
|
1522
|
+
this.docId = docId;
|
|
1523
|
+
this.opts = opts;
|
|
1524
|
+
this.lastWrite = -Infinity;
|
|
1525
|
+
this.trailing = null;
|
|
1526
|
+
this.stopped = false;
|
|
1527
|
+
/** Writes are async (digest + store I/O) and must not overlap on one
|
|
1528
|
+
* store; a serial chain guarantees ordering and lets flush() await
|
|
1529
|
+
* every in-flight write (round-4 F8: the flush is only meaningful if it
|
|
1530
|
+
* durably lands the latest state before pagehide). */
|
|
1531
|
+
this.chain = Promise.resolve();
|
|
1532
|
+
}
|
|
1533
|
+
get throttleMs() {
|
|
1534
|
+
return this.opts.throttleMs ?? 1e3;
|
|
1535
|
+
}
|
|
1536
|
+
now() {
|
|
1537
|
+
return (this.opts.now ?? Date.now)();
|
|
1538
|
+
}
|
|
1539
|
+
/** State changed — write now if the window allows, else arm ONE trailing
|
|
1540
|
+
* write for the window's end (never more than one timer in flight; N
|
|
1541
|
+
* notifies inside a window coalesce into a single trailing write). */
|
|
1542
|
+
notify() {
|
|
1543
|
+
if (this.stopped) return;
|
|
1544
|
+
const elapsed = this.now() - this.lastWrite;
|
|
1545
|
+
if (elapsed >= this.throttleMs) {
|
|
1546
|
+
this.lastWrite = this.now();
|
|
1547
|
+
this.enqueueWrite();
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
if (this.trailing === null) {
|
|
1551
|
+
const set = this.opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
1552
|
+
this.trailing = set(() => {
|
|
1553
|
+
this.trailing = null;
|
|
1554
|
+
this.lastWrite = this.now();
|
|
1555
|
+
this.enqueueWrite();
|
|
1556
|
+
}, this.throttleMs - elapsed);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
/** Immediate best-effort write (pagehide/visibilitychange/session-end).
|
|
1560
|
+
* Awaits ALL in-flight writes plus this one so the latest state is
|
|
1561
|
+
* durably landed before the tab goes away. */
|
|
1562
|
+
async flush() {
|
|
1563
|
+
if (this.stopped) return;
|
|
1564
|
+
if (this.trailing !== null) {
|
|
1565
|
+
(this.opts.clearTimer ?? clearTimeout)(this.trailing);
|
|
1566
|
+
this.trailing = null;
|
|
1567
|
+
}
|
|
1568
|
+
this.lastWrite = this.now();
|
|
1569
|
+
this.enqueueWrite();
|
|
1570
|
+
await this.chain;
|
|
1571
|
+
}
|
|
1572
|
+
/** Append a write to the serial chain (never overlapping). */
|
|
1573
|
+
enqueueWrite() {
|
|
1574
|
+
this.chain = this.chain.then(() => this.write()).catch((err) => {
|
|
1575
|
+
this.opts.onError?.(err);
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
/** Detach (unmount): cancel the trailing timer; no further writes. */
|
|
1579
|
+
stop() {
|
|
1580
|
+
this.stopped = true;
|
|
1581
|
+
if (this.trailing !== null) {
|
|
1582
|
+
(this.opts.clearTimer ?? clearTimeout)(this.trailing);
|
|
1583
|
+
this.trailing = null;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
async write() {
|
|
1587
|
+
const bundle = await this.conn.exportBundleAsync(this.docId);
|
|
1588
|
+
if (!bundle) return;
|
|
1589
|
+
bundle.savedAt = this.lastWrite;
|
|
1590
|
+
const prior = await this.store.get(this.docId);
|
|
1591
|
+
const chain = [...prior?.lineage ?? []];
|
|
1592
|
+
const digest = await crypto.subtle.digest("SHA-256", bundle.confirmedBytes);
|
|
1593
|
+
let hash = "";
|
|
1594
|
+
for (const b of new Uint8Array(digest)) hash += b.toString(16).padStart(2, "0");
|
|
1595
|
+
const head = { genesisId: bundle.genesisId, seq: bundle.confirmedSeq, docHash: hash };
|
|
1596
|
+
if (chain.length && chain[chain.length - 1].genesisId === bundle.genesisId) chain[chain.length - 1] = head;
|
|
1597
|
+
else chain.push(head);
|
|
1598
|
+
bundle.lineage = chain.slice(-50);
|
|
1599
|
+
const off = this.opts.offlineTail?.();
|
|
1600
|
+
if (off && off.tail.length) {
|
|
1601
|
+
bundle.offlineTail = off.tail;
|
|
1602
|
+
bundle.offlineTailEpoch = off.epoch;
|
|
1603
|
+
}
|
|
1604
|
+
await this.store.put(bundle);
|
|
1605
|
+
}
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
// ../collab/src/rebase.ts
|
|
1609
|
+
var OFFLINE_TAIL_CAP = 2e3;
|
|
1610
|
+
function toSuggestions(tail, author, date) {
|
|
1611
|
+
const suggestions = [];
|
|
1612
|
+
const dropped = [];
|
|
1613
|
+
for (const intent of tail) {
|
|
1614
|
+
if (intent.kind === "insertText") {
|
|
1615
|
+
suggestions.push({ kind: "insertText", at: intent.at, text: intent.text, suggest: { author, date } });
|
|
1616
|
+
} else if (intent.kind === "deleteText") {
|
|
1617
|
+
suggestions.push({
|
|
1618
|
+
kind: "suggestRevision",
|
|
1619
|
+
ranges: [{ blockId: intent.blockId, runId: intent.runId, start: intent.start, end: intent.end }],
|
|
1620
|
+
suggest: { author, date }
|
|
1621
|
+
});
|
|
1622
|
+
} else {
|
|
1623
|
+
dropped.push(intent);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return { suggestions, dropped };
|
|
1627
|
+
}
|
|
1628
|
+
function arrivalMode(tailLength, diverged, opts = {}) {
|
|
1629
|
+
if (!diverged) return "fast-forward";
|
|
1630
|
+
const threshold = opts.suggestThreshold ?? 50;
|
|
1631
|
+
return tailLength <= threshold ? "suggest" : "draft";
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// ../collab/src/protocol.ts
|
|
1635
|
+
var ENGINE_VERSION = "e7";
|
|
1636
|
+
var PRESENCE_MAX_RANGES = 64;
|
|
1637
|
+
function sanitizePresencePosition(pos) {
|
|
1638
|
+
if (!pos || !pos.ranges) return pos;
|
|
1639
|
+
const int = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
|
|
1640
|
+
const clean = [];
|
|
1641
|
+
for (const r of Array.isArray(pos.ranges) ? pos.ranges : []) {
|
|
1642
|
+
if (clean.length >= PRESENCE_MAX_RANGES) break;
|
|
1643
|
+
if (!r || typeof r !== "object") continue;
|
|
1644
|
+
if (!int(r.blockId) || !int(r.runId) || !int(r.start) || !int(r.end)) continue;
|
|
1645
|
+
if (r.end <= r.start) continue;
|
|
1646
|
+
clean.push({ blockId: r.blockId, runId: r.runId, start: r.start, end: r.end });
|
|
1647
|
+
}
|
|
1648
|
+
if (clean.length === pos.ranges.length) return pos;
|
|
1649
|
+
return clean.length > 0 ? { ...pos, ranges: clean } : { ...pos, ranges: void 0 };
|
|
1650
|
+
}
|
|
1651
|
+
var PROTOCOL_VERSION = 3;
|
|
1652
|
+
|
|
1653
|
+
// ../collab/src/media.ts
|
|
1654
|
+
function mediaAddressesOf(doc) {
|
|
1655
|
+
const out = [];
|
|
1656
|
+
for (const [part, meta] of doc.mediaMeta) {
|
|
1657
|
+
if (!meta.sha) continue;
|
|
1658
|
+
out.push({ part, sha: meta.sha, ...meta.iv ? { iv: meta.iv } : {} });
|
|
1659
|
+
}
|
|
1660
|
+
return out;
|
|
1661
|
+
}
|
|
1662
|
+
function applyMediaAddresses(doc, addresses) {
|
|
1663
|
+
if (!addresses?.length) return;
|
|
1664
|
+
for (const { part, sha, iv } of addresses) {
|
|
1665
|
+
if (!sha || doc.mediaStatus(part) === "ready") continue;
|
|
1666
|
+
const meta = { sha, ...iv ? { iv } : {} };
|
|
1667
|
+
doc.pendingMedia.set(part, meta);
|
|
1668
|
+
doc.mediaMeta.set(part, meta);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
async function sha256Hex(bytes) {
|
|
1672
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
1673
|
+
let hex = "";
|
|
1674
|
+
for (const b of new Uint8Array(digest)) hex += b.toString(16).padStart(2, "0");
|
|
1675
|
+
return hex;
|
|
1676
|
+
}
|
|
1677
|
+
function mediaUrl(httpBase, docId, sha) {
|
|
1678
|
+
return `${httpBase.replace(/\/$/, "")}/docs/${encodeURIComponent(docId)}/media/${sha}`;
|
|
1679
|
+
}
|
|
1680
|
+
async function putBlob(opts, docId, sha, blob) {
|
|
1681
|
+
const f = opts.fetchImpl ?? globalThis.fetch;
|
|
1682
|
+
try {
|
|
1683
|
+
const res = await f(mediaUrl(opts.httpBase, docId, sha), { method: "PUT", body: blob });
|
|
1684
|
+
return { ok: res.status === 200 || res.status === 201, status: res.status };
|
|
1685
|
+
} catch {
|
|
1686
|
+
return { ok: false, status: 0 };
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
async function getBlob(opts, docId, sha) {
|
|
1690
|
+
const f = opts.fetchImpl ?? globalThis.fetch;
|
|
1691
|
+
try {
|
|
1692
|
+
const res = await f(mediaUrl(opts.httpBase, docId, sha));
|
|
1693
|
+
if (!res.ok) return null;
|
|
1694
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
1695
|
+
} catch {
|
|
1696
|
+
return null;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
var MediaClient = class {
|
|
1700
|
+
constructor(opts, getDoc, cb, crypto2) {
|
|
1701
|
+
this.opts = opts;
|
|
1702
|
+
this.getDoc = getDoc;
|
|
1703
|
+
this.cb = cb;
|
|
1704
|
+
this.crypto = crypto2;
|
|
1705
|
+
this.inFlight = /* @__PURE__ */ new Set();
|
|
1706
|
+
this.states = /* @__PURE__ */ new Map();
|
|
1707
|
+
/** Shas we asked the room for, awaiting a media-ready. */
|
|
1708
|
+
this.waitingFor = /* @__PURE__ */ new Set();
|
|
1709
|
+
/** Plaintext of blobs THIS client uploaded, so its own images never take a
|
|
1710
|
+
* network round trip. Bounded: an entry is dropped once installed. */
|
|
1711
|
+
this.ownBlobs = /* @__PURE__ */ new Map();
|
|
1712
|
+
}
|
|
1713
|
+
/** Current placeholder state of a pending part (undefined once ready). */
|
|
1714
|
+
stateOf(part) {
|
|
1715
|
+
return this.states.get(part);
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* PLACER (doc 16 §5.1). Seal (E2EE) → address → upload. Returns the intent
|
|
1719
|
+
* fields on success, or null when the relay refused — in which case the
|
|
1720
|
+
* caller MUST NOT emit an insertImage, or the room gets a reservation
|
|
1721
|
+
* pointing at a blob that does not exist.
|
|
1722
|
+
*/
|
|
1723
|
+
async upload(docId, plaintext) {
|
|
1724
|
+
const sealed = this.crypto ? await this.crypto.seal(plaintext) : null;
|
|
1725
|
+
const blob = sealed ? sealed.blob : plaintext;
|
|
1726
|
+
const blobSha = await sha256Hex(blob);
|
|
1727
|
+
const res = await putBlob(this.opts, docId, blobSha, blob);
|
|
1728
|
+
if (!res.ok) return null;
|
|
1729
|
+
this.ownBlobs.set(blobSha, plaintext);
|
|
1730
|
+
return { blobSha, bytesLen: blob.length, iv: sealed?.iv };
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* RECEIVER (doc 16 §5.2). Eager-fetch every pending part the doc knows
|
|
1734
|
+
* about. Idempotent and safe to call after every applied broadcast: parts
|
|
1735
|
+
* already ready or already in flight are skipped.
|
|
1736
|
+
*/
|
|
1737
|
+
async fetchPending(docId) {
|
|
1738
|
+
const doc = this.getDoc();
|
|
1739
|
+
if (!doc) return;
|
|
1740
|
+
for (const [part, meta] of [...doc.pendingMedia]) {
|
|
1741
|
+
if (!meta.sha || this.inFlight.has(part)) continue;
|
|
1742
|
+
this.inFlight.add(part);
|
|
1743
|
+
void this.fetchOne(docId, part, meta).finally(() => this.inFlight.delete(part));
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
setState(part, state) {
|
|
1747
|
+
this.states.set(part, state);
|
|
1748
|
+
this.getDoc()?.mediaTransferState.set(part, state);
|
|
1749
|
+
this.cb.onState?.(part, state);
|
|
1750
|
+
}
|
|
1751
|
+
async fetchOne(docId, part, meta) {
|
|
1752
|
+
const doc = this.getDoc();
|
|
1753
|
+
if (!doc || doc.mediaStatus(part) === "ready") return;
|
|
1754
|
+
const own = this.ownBlobs.get(meta.sha);
|
|
1755
|
+
if (own) {
|
|
1756
|
+
doc.installMedia(part, own);
|
|
1757
|
+
this.states.delete(part);
|
|
1758
|
+
doc.mediaTransferState.delete(part);
|
|
1759
|
+
this.ownBlobs.delete(meta.sha);
|
|
1760
|
+
this.cb.onChange?.();
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
this.setState(part, "fetching");
|
|
1764
|
+
const blob = await getBlob(this.opts, docId, meta.sha);
|
|
1765
|
+
if (!blob) {
|
|
1766
|
+
this.setState(part, "waiting");
|
|
1767
|
+
this.waitingFor.add(meta.sha);
|
|
1768
|
+
this.cb.need(meta.sha);
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
await this.installVerified(part, meta, blob);
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Verify then install. The sha check is against the value COMMITTED IN THE
|
|
1775
|
+
* INTENT, never against anything the deliverer said — defense in depth
|
|
1776
|
+
* behind the relay's own check (doc 16 §5.2 step 3), and the only thing
|
|
1777
|
+
* standing between a compromised relay and arbitrary bytes in the document.
|
|
1778
|
+
*/
|
|
1779
|
+
async installVerified(part, meta, blob) {
|
|
1780
|
+
if (await sha256Hex(blob) !== meta.sha) {
|
|
1781
|
+
this.setState(part, "waiting");
|
|
1782
|
+
return false;
|
|
1783
|
+
}
|
|
1784
|
+
let bytes = blob;
|
|
1785
|
+
if (this.crypto) {
|
|
1786
|
+
if (!meta.iv) return false;
|
|
1787
|
+
try {
|
|
1788
|
+
bytes = await this.crypto.open(blob, meta.iv);
|
|
1789
|
+
} catch {
|
|
1790
|
+
this.setState(part, "waiting");
|
|
1791
|
+
return false;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
const doc = this.getDoc();
|
|
1795
|
+
if (!doc) return false;
|
|
1796
|
+
doc.installMedia(part, bytes);
|
|
1797
|
+
this.states.delete(part);
|
|
1798
|
+
doc.mediaTransferState.delete(part);
|
|
1799
|
+
this.waitingFor.delete(meta.sha);
|
|
1800
|
+
this.cb.onChange?.();
|
|
1801
|
+
return true;
|
|
1802
|
+
}
|
|
1803
|
+
/** Server says the blob is fetchable now — retry the parts waiting on it. */
|
|
1804
|
+
async onReady(docId, sha) {
|
|
1805
|
+
this.waitingFor.delete(sha);
|
|
1806
|
+
const doc = this.getDoc();
|
|
1807
|
+
if (!doc) return;
|
|
1808
|
+
for (const [part, meta] of [...doc.pendingMedia]) {
|
|
1809
|
+
if (meta.sha !== sha || this.inFlight.has(part)) continue;
|
|
1810
|
+
this.inFlight.add(part);
|
|
1811
|
+
void this.fetchOne(docId, part, meta).finally(() => this.inFlight.delete(part));
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
/** No holder is online. The registration stays; a later media-ready (or a
|
|
1815
|
+
* holder rejoining, §5.4) recovers it with no action from the user. */
|
|
1816
|
+
onUnavailable(sha) {
|
|
1817
|
+
const doc = this.getDoc();
|
|
1818
|
+
if (!doc) return;
|
|
1819
|
+
for (const [part, meta] of doc.pendingMedia) {
|
|
1820
|
+
if (meta.sha === sha) this.setState(part, "unavailable");
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
/** Shas this replica can serve: parts whose bytes are PRESENT. */
|
|
1824
|
+
heldShas() {
|
|
1825
|
+
const doc = this.getDoc();
|
|
1826
|
+
if (!doc) return [];
|
|
1827
|
+
const out = [];
|
|
1828
|
+
for (const [part, meta] of doc.mediaMeta) {
|
|
1829
|
+
if (doc.mediaStatus(part) === "ready") out.push(meta.sha);
|
|
1830
|
+
}
|
|
1831
|
+
return out;
|
|
1832
|
+
}
|
|
1833
|
+
/** §5.4: intersect the room's outstanding needs with local holdings and
|
|
1834
|
+
* volunteer — this is what makes evicted media reappear when a holder
|
|
1835
|
+
* comes back, with no polling anywhere. */
|
|
1836
|
+
volunteer(mediaNeeded) {
|
|
1837
|
+
if (!mediaNeeded?.length) return;
|
|
1838
|
+
const held = new Set(this.heldShas());
|
|
1839
|
+
const intersection = mediaNeeded.filter((sha) => held.has(sha));
|
|
1840
|
+
if (intersection.length) this.cb.have(intersection);
|
|
1841
|
+
}
|
|
1842
|
+
/** Someone in the room needs a sha: answer if we hold it (§5.3). */
|
|
1843
|
+
answerRequest(sha) {
|
|
1844
|
+
if (this.heldShas().includes(sha)) this.cb.have([sha]);
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* HOLDER DUTY (doc 16 §5.3): chosen to re-supply — upload our copy.
|
|
1848
|
+
*
|
|
1849
|
+
* In an encrypted room this RE-SEALS with the IV recorded on the part, not
|
|
1850
|
+
* a fresh one: same key + same IV + same plaintext reproduces the exact
|
|
1851
|
+
* ciphertext, which is the only thing that still hashes to the address the
|
|
1852
|
+
* intent committed to. A fresh IV would produce a perfectly valid blob at
|
|
1853
|
+
* the WRONG address, and the upload would be rejected — do not "fix" it
|
|
1854
|
+
* that way (doc 16 §5.3 / doc 13 §4 both carry this warning).
|
|
1855
|
+
*
|
|
1856
|
+
* The local sha assertion before PUT is deliberate: failing it means THIS
|
|
1857
|
+
* replica's pixels are corrupt, and uploading them would just burn the
|
|
1858
|
+
* re-supply rotation on bytes the relay will reject anyway.
|
|
1859
|
+
*/
|
|
1860
|
+
async resupply(docId, sha) {
|
|
1861
|
+
const doc = this.getDoc();
|
|
1862
|
+
if (!doc) return false;
|
|
1863
|
+
for (const [part, meta] of doc.mediaMeta) {
|
|
1864
|
+
if (meta.sha !== sha || doc.mediaStatus(part) !== "ready") continue;
|
|
1865
|
+
const pixels = doc.media(part);
|
|
1866
|
+
if (!pixels) continue;
|
|
1867
|
+
let blob = pixels;
|
|
1868
|
+
if (this.crypto) {
|
|
1869
|
+
if (!meta.iv) return false;
|
|
1870
|
+
blob = (await this.crypto.seal(pixels, meta.iv)).blob;
|
|
1871
|
+
}
|
|
1872
|
+
if (await sha256Hex(blob) !== sha) return false;
|
|
1873
|
+
const res = await putBlob(this.opts, docId, sha, blob);
|
|
1874
|
+
return res.ok;
|
|
1875
|
+
}
|
|
1876
|
+
return false;
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
|
|
1880
|
+
// ../collab/src/e2ee.ts
|
|
1881
|
+
var te = new TextEncoder();
|
|
1882
|
+
var td = new TextDecoder();
|
|
1883
|
+
var INTENT_PAD_RUNGS = [384, 1024, 4096];
|
|
1884
|
+
var INTENT_PAD_STEP = 4096;
|
|
1885
|
+
var INTENT_PLAIN_CAP = 256 * 1024 * 3 / 4 - 16;
|
|
1886
|
+
var PRESENCE_PAD_RUNGS = [128, 1024];
|
|
1887
|
+
var PRESENCE_PAD_STEP = 1024;
|
|
1888
|
+
var PRESENCE_PLAIN_CAP = INTENT_PLAIN_CAP;
|
|
1889
|
+
var CHECKPOINT_PAD_STEP = 64 * 1024;
|
|
1890
|
+
var CHECKPOINT_PLAIN_CAP = 16 * 1024 * 1024 * 3 / 4 - 16;
|
|
1891
|
+
function padBody(body, rungs, step, cap) {
|
|
1892
|
+
const need = 4 + body.length;
|
|
1893
|
+
let target = rungs.find((r) => need <= r) ?? Math.ceil(need / step) * step;
|
|
1894
|
+
if (target > cap) target = cap;
|
|
1895
|
+
if (target < need) target = need;
|
|
1896
|
+
const out = new Uint8Array(target);
|
|
1897
|
+
new DataView(out.buffer).setUint32(0, body.length);
|
|
1898
|
+
out.set(body, 4);
|
|
1899
|
+
return out;
|
|
1900
|
+
}
|
|
1901
|
+
function unpadBody(pt) {
|
|
1902
|
+
const bytes = new Uint8Array(pt);
|
|
1903
|
+
if (bytes.length < 4) throw new Error("padded body: too short");
|
|
1904
|
+
const len = new DataView(pt).getUint32(0);
|
|
1905
|
+
if (len > bytes.length - 4) throw new Error("padded body: bad length prefix");
|
|
1906
|
+
return bytes.subarray(4, 4 + len);
|
|
1907
|
+
}
|
|
1908
|
+
function bytesToB64(bytes) {
|
|
1909
|
+
let bin = "";
|
|
1910
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
1911
|
+
return typeof btoa === "function" ? btoa(bin) : Buffer.from(bytes).toString("base64");
|
|
1912
|
+
}
|
|
1913
|
+
function b64ToBytes(b64) {
|
|
1914
|
+
const bin = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
1915
|
+
const out = new Uint8Array(bin.length);
|
|
1916
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
1917
|
+
return out;
|
|
1918
|
+
}
|
|
1919
|
+
function mintDocKey() {
|
|
1920
|
+
const bytes = new Uint8Array(32);
|
|
1921
|
+
crypto.getRandomValues(bytes);
|
|
1922
|
+
return bytesToB64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1923
|
+
}
|
|
1924
|
+
function docKeyFromFragment(fragment) {
|
|
1925
|
+
const m = /(?:^|[#&])k=([A-Za-z0-9_-]+)/.exec(fragment);
|
|
1926
|
+
return m ? m[1] : null;
|
|
1927
|
+
}
|
|
1928
|
+
async function deriveEpochKeys(docKeyB64url, genesisId, stretchedCode) {
|
|
1929
|
+
const raw = b64ToBytes(docKeyB64url.replace(/-/g, "+").replace(/_/g, "/"));
|
|
1930
|
+
const master = await crypto.subtle.importKey("raw", raw, "HKDF", false, ["deriveKey"]);
|
|
1931
|
+
const salt = te.encode(`wordinweb-epoch:${genesisId}`);
|
|
1932
|
+
const mix = stretchedCode ?? new Uint8Array(0);
|
|
1933
|
+
const derive = (info) => crypto.subtle.deriveKey(
|
|
1934
|
+
{ name: "HKDF", hash: "SHA-256", salt, info: concat(te.encode(info), mix) },
|
|
1935
|
+
master,
|
|
1936
|
+
{ name: "AES-GCM", length: 256 },
|
|
1937
|
+
false,
|
|
1938
|
+
["encrypt", "decrypt"]
|
|
1939
|
+
);
|
|
1940
|
+
return {
|
|
1941
|
+
kContent: await derive("content"),
|
|
1942
|
+
kMedia: await derive("media"),
|
|
1943
|
+
kPresence: await derive("presence")
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
async function stretchShareCode(code, docId) {
|
|
1947
|
+
const key = await crypto.subtle.importKey("raw", te.encode(code), "PBKDF2", false, ["deriveBits"]);
|
|
1948
|
+
const bits = await crypto.subtle.deriveBits(
|
|
1949
|
+
{ name: "PBKDF2", hash: "SHA-256", salt: te.encode(`wordinweb-code:${docId}`), iterations: 6e5 },
|
|
1950
|
+
key,
|
|
1951
|
+
256
|
|
1952
|
+
);
|
|
1953
|
+
return new Uint8Array(bits);
|
|
1954
|
+
}
|
|
1955
|
+
async function sealIntent(kContent, docId, genesisId, intent) {
|
|
1956
|
+
const iv = new Uint8Array(12);
|
|
1957
|
+
crypto.getRandomValues(iv);
|
|
1958
|
+
const aad = intentAad(docId, genesisId, intent.clientId, intent.clientSeq, intent.base);
|
|
1959
|
+
const body = padBody(te.encode(JSON.stringify(intent)), INTENT_PAD_RUNGS, INTENT_PAD_STEP, INTENT_PLAIN_CAP);
|
|
1960
|
+
const ct = await crypto.subtle.encrypt(
|
|
1961
|
+
{ name: "AES-GCM", iv, additionalData: aad },
|
|
1962
|
+
kContent,
|
|
1963
|
+
body
|
|
1964
|
+
);
|
|
1965
|
+
return {
|
|
1966
|
+
clientId: intent.clientId,
|
|
1967
|
+
clientSeq: intent.clientSeq,
|
|
1968
|
+
base: intent.base,
|
|
1969
|
+
iv: bytesToB64(iv),
|
|
1970
|
+
ciphertext: bytesToB64(new Uint8Array(ct))
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
async function openIntent(kContent, docId, genesisId, env) {
|
|
1974
|
+
const aad = intentAad(docId, genesisId, env.clientId, env.clientSeq, env.base);
|
|
1975
|
+
const pt = await crypto.subtle.decrypt(
|
|
1976
|
+
{ name: "AES-GCM", iv: b64ToBytes(env.iv), additionalData: aad },
|
|
1977
|
+
kContent,
|
|
1978
|
+
b64ToBytes(env.ciphertext)
|
|
1979
|
+
);
|
|
1980
|
+
const intent = JSON.parse(td.decode(unpadBody(pt)));
|
|
1981
|
+
if (intent.clientId !== env.clientId || intent.clientSeq !== env.clientSeq || intent.base !== env.base) {
|
|
1982
|
+
throw new Error("envelope bookkeeping mismatch");
|
|
1983
|
+
}
|
|
1984
|
+
return intent;
|
|
1985
|
+
}
|
|
1986
|
+
async function sealCheckpoint(kContent, docId, genesisId, seq, body) {
|
|
1987
|
+
const iv = new Uint8Array(12);
|
|
1988
|
+
crypto.getRandomValues(iv);
|
|
1989
|
+
const aad = te.encode(`cp:${docId}:${genesisId}:${seq}`);
|
|
1990
|
+
const padded = padBody(te.encode(JSON.stringify(body)), [], CHECKPOINT_PAD_STEP, CHECKPOINT_PLAIN_CAP);
|
|
1991
|
+
const ct = await crypto.subtle.encrypt(
|
|
1992
|
+
{ name: "AES-GCM", iv, additionalData: aad },
|
|
1993
|
+
kContent,
|
|
1994
|
+
padded
|
|
1995
|
+
);
|
|
1996
|
+
return { iv: bytesToB64(iv), ciphertext: bytesToB64(new Uint8Array(ct)) };
|
|
1997
|
+
}
|
|
1998
|
+
async function openCheckpoint(kContent, docId, genesisId, seq, sealed) {
|
|
1999
|
+
const aad = te.encode(`cp:${docId}:${genesisId}:${seq}`);
|
|
2000
|
+
const pt = await crypto.subtle.decrypt(
|
|
2001
|
+
{ name: "AES-GCM", iv: b64ToBytes(sealed.iv), additionalData: aad },
|
|
2002
|
+
kContent,
|
|
2003
|
+
b64ToBytes(sealed.ciphertext)
|
|
2004
|
+
);
|
|
2005
|
+
return JSON.parse(td.decode(unpadBody(pt)));
|
|
2006
|
+
}
|
|
2007
|
+
function intentAad(docId, genesisId, clientId, clientSeq, base) {
|
|
2008
|
+
return te.encode(`in:${docId}:${genesisId}:${clientId}:${clientSeq}:${base}`);
|
|
2009
|
+
}
|
|
2010
|
+
function presenceAad(docId, genesisId, clientId) {
|
|
2011
|
+
return te.encode(`pr:${docId}:${genesisId}:${clientId}`);
|
|
2012
|
+
}
|
|
2013
|
+
function isSealedPresence(pos) {
|
|
2014
|
+
return !!pos && typeof pos === "object" && typeof pos.iv === "string" && typeof pos.ciphertext === "string";
|
|
2015
|
+
}
|
|
2016
|
+
async function sealPresence(kPresence, docId, genesisId, clientId, position) {
|
|
2017
|
+
const iv = new Uint8Array(12);
|
|
2018
|
+
crypto.getRandomValues(iv);
|
|
2019
|
+
const ct = await crypto.subtle.encrypt(
|
|
2020
|
+
{
|
|
2021
|
+
name: "AES-GCM",
|
|
2022
|
+
iv,
|
|
2023
|
+
additionalData: presenceAad(docId, genesisId, clientId)
|
|
2024
|
+
},
|
|
2025
|
+
kPresence,
|
|
2026
|
+
padBody(te.encode(JSON.stringify(position)), PRESENCE_PAD_RUNGS, PRESENCE_PAD_STEP, PRESENCE_PLAIN_CAP)
|
|
2027
|
+
);
|
|
2028
|
+
return { iv: bytesToB64(iv), ciphertext: bytesToB64(new Uint8Array(ct)) };
|
|
2029
|
+
}
|
|
2030
|
+
async function openPresence(kPresence, docId, genesisId, clientId, sealed) {
|
|
2031
|
+
const pt = await crypto.subtle.decrypt(
|
|
2032
|
+
{
|
|
2033
|
+
name: "AES-GCM",
|
|
2034
|
+
iv: b64ToBytes(sealed.iv),
|
|
2035
|
+
additionalData: presenceAad(docId, genesisId, clientId)
|
|
2036
|
+
},
|
|
2037
|
+
kPresence,
|
|
2038
|
+
b64ToBytes(sealed.ciphertext)
|
|
2039
|
+
);
|
|
2040
|
+
return JSON.parse(td.decode(unpadBody(pt)));
|
|
2041
|
+
}
|
|
2042
|
+
function concat(a, b) {
|
|
2043
|
+
const out = new Uint8Array(a.length + b.length);
|
|
2044
|
+
out.set(a, 0);
|
|
2045
|
+
out.set(b, a.length);
|
|
2046
|
+
return out;
|
|
2047
|
+
}
|
|
2048
|
+
async function sealMediaBlob(kMedia, plaintext, ivB64) {
|
|
2049
|
+
let iv;
|
|
2050
|
+
if (ivB64) iv = b64ToBytes(ivB64);
|
|
2051
|
+
else {
|
|
2052
|
+
iv = new Uint8Array(12);
|
|
2053
|
+
crypto.getRandomValues(iv);
|
|
2054
|
+
}
|
|
2055
|
+
const ct = new Uint8Array(
|
|
2056
|
+
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, kMedia, plaintext)
|
|
2057
|
+
);
|
|
2058
|
+
return { blob: ct, iv: bytesToB64(iv) };
|
|
2059
|
+
}
|
|
2060
|
+
async function openMediaBlob(kMedia, blob, ivB64) {
|
|
2061
|
+
return new Uint8Array(
|
|
2062
|
+
await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64ToBytes(ivB64) }, kMedia, blob)
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// ../collab/src/connection.ts
|
|
2067
|
+
function base64ToBytes(b64) {
|
|
2068
|
+
const bin = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
|
|
2069
|
+
const out = new Uint8Array(bin.length);
|
|
2070
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
2071
|
+
return out;
|
|
2072
|
+
}
|
|
2073
|
+
var _CollabConnection = class _CollabConnection {
|
|
2074
|
+
constructor(transport, clientId, cb = {}, mediaOpts) {
|
|
2075
|
+
this.transport = transport;
|
|
2076
|
+
this.clientId = clientId;
|
|
2077
|
+
this.cb = cb;
|
|
2078
|
+
this.replica = null;
|
|
2079
|
+
this.clientSeq = 0;
|
|
2080
|
+
/** Bumps only when reconciliation RELOADED the document (a true conflict) —
|
|
2081
|
+
* the renderer re-mounts on this, but updates in place otherwise (no flash
|
|
2082
|
+
* for the common non-conflicting edits). */
|
|
2083
|
+
this.docEpoch = 0;
|
|
2084
|
+
/** Per-client id allocator for carried node ids (split/format-range/insert):
|
|
2085
|
+
* a client-specific base block keeps concurrently-allocated ids disjoint
|
|
2086
|
+
* across clients so they never collide. */
|
|
2087
|
+
this.idCounter = 0;
|
|
2088
|
+
this.idBase = -1;
|
|
2089
|
+
/** The epoch id of the session this connection joined (from the welcome).
|
|
2090
|
+
* The resume layer compares it against the bundle's stored genesisId:
|
|
2091
|
+
* same ⇒ seamless rejoin; different ⇒ someone re-seeded while away
|
|
2092
|
+
* (doc 12 §5 case 2 — take server state, keep the local copy; the doc-15
|
|
2093
|
+
* lineage check decides fast-forward vs draft). Null until welcomed. */
|
|
2094
|
+
this.genesisId = null;
|
|
2095
|
+
/** Session encryption mode from the welcome (doc 13 §6). The E2EE layer
|
|
2096
|
+
* hard-refuses a value contradicting the link's `#k` fragment. */
|
|
2097
|
+
this.mode = null;
|
|
2098
|
+
/**
|
|
2099
|
+
* The relay's per-blob upload limit in bytes, from the welcome — so a file
|
|
2100
|
+
* can be checked BEFORE it is uploaded rather than after a 413.
|
|
2101
|
+
*
|
|
2102
|
+
* `null` means the server did not publish one (an older host). Callers must
|
|
2103
|
+
* treat that as "skip the pre-check", NOT as "no limit" and not as a
|
|
2104
|
+
* default they invent: the server still enforces its real limit either way,
|
|
2105
|
+
* and a client guessing a larger number would promise the user an upload
|
|
2106
|
+
* that cannot succeed.
|
|
2107
|
+
*/
|
|
2108
|
+
this.mediaMaxBlobBytes = null;
|
|
2109
|
+
/** Out-of-band media transfer (doc 16 §5). Null until `httpBase` is
|
|
2110
|
+
* supplied — without a relay origin a client simply has no media duties
|
|
2111
|
+
* (every existing caller keeps working unchanged). */
|
|
2112
|
+
this.media = null;
|
|
2113
|
+
/** The room this connection joined; media URLs are per-doc. */
|
|
2114
|
+
this.docId = "";
|
|
2115
|
+
/** The latest roster snapshot (doc 14 §2); empty until the first fan-out. */
|
|
2116
|
+
this.roster = [];
|
|
2117
|
+
/**
|
|
2118
|
+
* Attribution layer 1 (doc 14 §3): the canonical log IS the attribution
|
|
2119
|
+
* record — every applied entry carries its author's bound clientId. This
|
|
2120
|
+
* is the bounded client-side derivation an activity panel renders
|
|
2121
|
+
* (clientId → roster name/color). Newest last, capped, derived state:
|
|
2122
|
+
* never persisted, dies with the connection (zero custody).
|
|
2123
|
+
*/
|
|
2124
|
+
this.activity = [];
|
|
2125
|
+
/** Bundle being resumed from (set by resume(), consumed at welcome). */
|
|
2126
|
+
this.resuming = null;
|
|
2127
|
+
/** Accumulates doc changes across replica rebuilds (welcome), so the getter
|
|
2128
|
+
* below stays monotonic per connection. */
|
|
2129
|
+
this.docVersionBase = 0;
|
|
2130
|
+
/** docVersionBase at the last takeRenderScope — a base bump between takes
|
|
2131
|
+
* (welcome doc replacement, media install) has no narrow scope. */
|
|
2132
|
+
this.takenDocVersionBase = 0;
|
|
2133
|
+
/** Submits dropped because the connection was not ready (see
|
|
2134
|
+
* onSubmitDropped). Exposed like `selfHeals` so a harness or a UI can read
|
|
2135
|
+
* it: when a run loses intents, this number says how many died HERE rather
|
|
2136
|
+
* than anywhere else on the path. */
|
|
2137
|
+
this.droppedPreReady = 0;
|
|
2138
|
+
this.rlRedriveTimer = null;
|
|
2139
|
+
this.rlRedriveBackoffMs = 0;
|
|
2140
|
+
this.transport.onMessage((msg) => this.onServer(msg));
|
|
2141
|
+
if (mediaOpts) {
|
|
2142
|
+
this.media = new MediaClient(mediaOpts, () => this.replica?.doc ?? null, {
|
|
2143
|
+
// Media installs mutate the doc outside the replica's counter — bump
|
|
2144
|
+
// the render signal so the skeleton repaints (see docVersion).
|
|
2145
|
+
onChange: () => {
|
|
2146
|
+
this.docVersionBase++;
|
|
2147
|
+
this.cb.onChange?.();
|
|
2148
|
+
},
|
|
2149
|
+
onState: (part, state) => this.cb.onMediaState?.(part, state),
|
|
2150
|
+
need: (sha) => this.mediaNeed(sha),
|
|
2151
|
+
have: (shas) => this.mediaHave(shas)
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
/** Upload bytes and get the intent's media fields, or null if the relay
|
|
2156
|
+
* refused — the caller must not emit an insertImage in that case. */
|
|
2157
|
+
async uploadMedia(plaintext) {
|
|
2158
|
+
return this.media ? this.media.upload(this.docId, plaintext) : null;
|
|
2159
|
+
}
|
|
2160
|
+
/** Replace the callbacks (used by bindEditor to attach after construction). */
|
|
2161
|
+
setCallbacks(cb) {
|
|
2162
|
+
this.cb = cb;
|
|
2163
|
+
}
|
|
2164
|
+
/** Allocate `n` fresh carried node ids in this client's disjoint block. */
|
|
2165
|
+
allocIds(n) {
|
|
2166
|
+
if (this.idBase < 0) {
|
|
2167
|
+
let h = 0;
|
|
2168
|
+
for (let i = 0; i < this.clientId.length; i++) h = h * 31 + this.clientId.charCodeAt(i) >>> 0;
|
|
2169
|
+
this.idBase = 1e9 + h % 1e5 * 1e7;
|
|
2170
|
+
}
|
|
2171
|
+
const out = [];
|
|
2172
|
+
for (let i = 0; i < n; i++) out.push(this.idBase + this.idCounter++);
|
|
2173
|
+
return out;
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Join a document. The server replies with a welcome (snapshot + sidecar +
|
|
2177
|
+
* tail). The hello carries this connection's clientId — the hub binds it to
|
|
2178
|
+
* the socket and refuses submits under any other id (doc 11 decision 8).
|
|
2179
|
+
* `takeover: true` claims the identity from an existing live connection
|
|
2180
|
+
* (the doc-12 §7 "use here instead" path for a second same-profile tab);
|
|
2181
|
+
* without it, a duplicate join is refused `already-open`.
|
|
2182
|
+
*/
|
|
2183
|
+
join(docId, token, opts) {
|
|
2184
|
+
this.docId = docId;
|
|
2185
|
+
this.transport.send({
|
|
2186
|
+
t: "hello",
|
|
2187
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
2188
|
+
docId,
|
|
2189
|
+
clientId: this.clientId,
|
|
2190
|
+
takeover: opts?.takeover,
|
|
2191
|
+
token,
|
|
2192
|
+
sinceSeq: 0,
|
|
2193
|
+
profile: opts?.profile,
|
|
2194
|
+
codeProof: opts?.codeProof,
|
|
2195
|
+
ownerToken: opts?.ownerToken
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
/** Owner admin op (doc 14 §2.5): honored only if this connection proved
|
|
2199
|
+
* the owner token at hello — otherwise the server refuses `not-owner`. */
|
|
2200
|
+
admin(action) {
|
|
2201
|
+
this.transport.send({ t: "admin", action });
|
|
2202
|
+
}
|
|
2203
|
+
recordActivity(entries) {
|
|
2204
|
+
for (const e of entries) {
|
|
2205
|
+
if (e.kind === "applied" && e.intent) {
|
|
2206
|
+
this.activity.push({ seq: e.seq, clientId: e.intent.clientId, kind: e.intent.kind });
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
if (this.activity.length > _CollabConnection.ACTIVITY_CAP) {
|
|
2210
|
+
this.activity.splice(0, this.activity.length - _CollabConnection.ACTIVITY_CAP);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
/** Ask the room for a blob this replica needs (doc 16 §5.2). */
|
|
2214
|
+
mediaNeed(sha) {
|
|
2215
|
+
this.transport.send({ t: "media-need", sha });
|
|
2216
|
+
}
|
|
2217
|
+
/** Volunteer holdings (reply to a request, or after a welcome whose
|
|
2218
|
+
* mediaNeeded intersects local media — §5.4). */
|
|
2219
|
+
mediaHave(shas) {
|
|
2220
|
+
if (shas.length) this.transport.send({ t: "media-have", shas });
|
|
2221
|
+
}
|
|
2222
|
+
/** Shas this replica can re-supply: metadata of parts whose bytes are
|
|
2223
|
+
* PRESENT (pending parts are exactly what we can't serve). */
|
|
2224
|
+
heldMediaShas() {
|
|
2225
|
+
const doc = this.replica?.doc;
|
|
2226
|
+
if (!doc) return [];
|
|
2227
|
+
const out = [];
|
|
2228
|
+
for (const [part, meta] of doc.mediaMeta) {
|
|
2229
|
+
if (doc.mediaStatus(part) === "ready") out.push(meta.sha);
|
|
2230
|
+
}
|
|
2231
|
+
return out;
|
|
2232
|
+
}
|
|
2233
|
+
/** Rename/recolor this participant mid-session. The server sanitizes and
|
|
2234
|
+
* fans out the updated roster; the local copy updates on that echo (no
|
|
2235
|
+
* optimistic roster — a 1-RTT lag on your own rename is imperceptible and
|
|
2236
|
+
* keeps one code path). */
|
|
2237
|
+
setProfile(profile) {
|
|
2238
|
+
this.transport.send({ t: "profile", profile });
|
|
2239
|
+
}
|
|
2240
|
+
/**
|
|
2241
|
+
* Rejoin a document from a persisted bundle (doc 12 §5). Restores the
|
|
2242
|
+
* clientSeq watermark FIRST — a fresh counter would reuse already-
|
|
2243
|
+
* sequenced (clientId, clientSeq) keys and the server would dedup this
|
|
2244
|
+
* client's NEW edits as re-sends (silent edit loss). The welcome decides
|
|
2245
|
+
* the case: same epoch ⇒ replay pending (below); different ⇒
|
|
2246
|
+
* onEpochChange, pending withheld.
|
|
2247
|
+
*/
|
|
2248
|
+
resume(bundle, token, opts) {
|
|
2249
|
+
this.clientSeq = Math.max(this.clientSeq, bundle.clientSeq);
|
|
2250
|
+
this.resuming = bundle;
|
|
2251
|
+
this.docId = bundle.docId;
|
|
2252
|
+
this.transport.send({
|
|
2253
|
+
profile: opts?.profile,
|
|
2254
|
+
codeProof: opts?.codeProof,
|
|
2255
|
+
t: "hello",
|
|
2256
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
2257
|
+
docId: bundle.docId,
|
|
2258
|
+
clientId: this.clientId,
|
|
2259
|
+
// Resume IS the zombie-takeover case (doc 12 §7): after a crash or
|
|
2260
|
+
// refresh, the previous socket for this identity may still be inside
|
|
2261
|
+
// the 60s grace window server-side. This connection is that identity's
|
|
2262
|
+
// continuation — claiming it is correct, and the incumbent (if any)
|
|
2263
|
+
// is a dead tab by definition of "we are resuming from the bundle".
|
|
2264
|
+
takeover: true,
|
|
2265
|
+
token,
|
|
2266
|
+
sinceSeq: bundle.confirmedSeq,
|
|
2267
|
+
genesisId: bundle.genesisId,
|
|
2268
|
+
ownerToken: opts?.ownerToken
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
/**
|
|
2272
|
+
* The current durable state as a doc-12 bundle, or null before welcome.
|
|
2273
|
+
* `savedAt` is stamped by the persister at write time (clock injection —
|
|
2274
|
+
* this module never reads Date.now itself).
|
|
2275
|
+
*/
|
|
2276
|
+
exportBundle(docId) {
|
|
2277
|
+
if (!this.replica || this.genesisId === null) return null;
|
|
2278
|
+
return {
|
|
2279
|
+
docId,
|
|
2280
|
+
genesisId: this.genesisId,
|
|
2281
|
+
...this.replica.exportBundleState(),
|
|
2282
|
+
clientSeq: this.clientSeq,
|
|
2283
|
+
savedAt: 0,
|
|
2284
|
+
lineage: []
|
|
2285
|
+
// the persister maintains the chain across writes
|
|
2286
|
+
};
|
|
2287
|
+
}
|
|
2288
|
+
async exportBundleAsync(docId) {
|
|
2289
|
+
if (!this.replica || this.genesisId === null) return null;
|
|
2290
|
+
return {
|
|
2291
|
+
docId,
|
|
2292
|
+
genesisId: this.genesisId,
|
|
2293
|
+
...await this.replica.exportBundleStateAsync(),
|
|
2294
|
+
clientSeq: this.clientSeq,
|
|
2295
|
+
savedAt: 0,
|
|
2296
|
+
lineage: []
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
/** The live document (null until welcome). The editor renders this. */
|
|
2300
|
+
get doc() {
|
|
2301
|
+
return this.replica?.doc ?? null;
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Counts changes TO THE RENDERED DOCUMENT — the repaint signal, as opposed
|
|
2305
|
+
* to onChange (which also fires for pure bookkeeping such as a tracked own
|
|
2306
|
+
* echo). The editor-driven typing path mutates and paints the doc itself,
|
|
2307
|
+
* so its submit + echo leave this untouched; remote applies, optimistic
|
|
2308
|
+
* canonical applies (toolbar ops), reloads, and media installs advance it.
|
|
2309
|
+
* The react layer repaints on THIS, not on onChange — repainting on
|
|
2310
|
+
* onChange queued a whole-document relayout per keystroke, catastrophic
|
|
2311
|
+
* past the background-layout page threshold.
|
|
2312
|
+
*/
|
|
2313
|
+
get docVersion() {
|
|
2314
|
+
return this.docVersionBase + (this.replica?.docVersion ?? 0);
|
|
2315
|
+
}
|
|
2316
|
+
/**
|
|
2317
|
+
* Drain the dirty scope behind the docVersion movement since the last take
|
|
2318
|
+
* — what the repaint answering `docVersion` must relayout. Replica applies
|
|
2319
|
+
* carry their per-intent scope; connection-level bumps (a replaced doc
|
|
2320
|
+
* object, media pixels landing) report document scope. Null means NOTHING
|
|
2321
|
+
* was recorded since the last take — the caller already painted everything
|
|
2322
|
+
* and may skip the repaint.
|
|
2323
|
+
*/
|
|
2324
|
+
takeRenderScope() {
|
|
2325
|
+
const replicaScope = this.replica?.takeRenderScope() ?? null;
|
|
2326
|
+
if (this.docVersionBase !== this.takenDocVersionBase) {
|
|
2327
|
+
this.takenDocVersionBase = this.docVersionBase;
|
|
2328
|
+
return { kind: "doc" };
|
|
2329
|
+
}
|
|
2330
|
+
return replicaScope;
|
|
2331
|
+
}
|
|
2332
|
+
get ready() {
|
|
2333
|
+
return this.replica !== null;
|
|
2334
|
+
}
|
|
2335
|
+
/** Un-confirmed local intents in flight — the drained-replay discipline
|
|
2336
|
+
* (doc 15 §2 / rebase.ts) polls this to submit one intent at a time. */
|
|
2337
|
+
get pendingCount() {
|
|
2338
|
+
return this.replica?.pendingCount ?? 0;
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Submit a local edit. The caller supplies the intent minus its wire
|
|
2342
|
+
* bookkeeping (clientId/clientSeq/base) — the connection fills those from
|
|
2343
|
+
* the current confirmed seq and applies it optimistically before sending.
|
|
2344
|
+
*/
|
|
2345
|
+
submit(intent) {
|
|
2346
|
+
this.submitFull(
|
|
2347
|
+
intent,
|
|
2348
|
+
/*preApplied*/
|
|
2349
|
+
false
|
|
2350
|
+
);
|
|
2351
|
+
}
|
|
2352
|
+
/** Submit an intent whose mutation the caller ALREADY performed on this
|
|
2353
|
+
* connection's live doc (the editor-driven path: DocxEditor applies the
|
|
2354
|
+
* command to `conn.doc` and then emits the intent). Skips the optimistic
|
|
2355
|
+
* re-apply — applying twice doubled every keystroke — but tracks pending and
|
|
2356
|
+
* sends identically, so echoes and reconciliation work unchanged. */
|
|
2357
|
+
submitPreApplied(intent) {
|
|
2358
|
+
this.submitFull(
|
|
2359
|
+
intent,
|
|
2360
|
+
/*preApplied*/
|
|
2361
|
+
true
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
submitFull(intent, preApplied) {
|
|
2365
|
+
if (!this.replica) {
|
|
2366
|
+
this.droppedPreReady++;
|
|
2367
|
+
this.cb.onSubmitDropped?.("not-ready");
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
const full = {
|
|
2371
|
+
...intent,
|
|
2372
|
+
clientId: this.clientId,
|
|
2373
|
+
clientSeq: ++this.clientSeq,
|
|
2374
|
+
base: this.replica.confirmedSeq
|
|
2375
|
+
};
|
|
2376
|
+
if (preApplied) this.replica.trackLocal(full);
|
|
2377
|
+
else this.replica.submitLocal(full);
|
|
2378
|
+
this.transport.send({ t: "submit", intent: full });
|
|
2379
|
+
this.cb.onChange?.();
|
|
2380
|
+
}
|
|
2381
|
+
/** Broadcast this client's cursor/selection (ephemeral). Selection ranges
|
|
2382
|
+
* are clamped on the way OUT too, so an over-long selection costs the room
|
|
2383
|
+
* a bounded payload rather than every receiver a discard. */
|
|
2384
|
+
setPresence(position) {
|
|
2385
|
+
this.transport.send({ t: "presence", position: sanitizePresencePosition(position) });
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Surface any of OUR OWN intents that canonical validation rejected.
|
|
2389
|
+
*
|
|
2390
|
+
* A rejection is sequenced and agreed by every replica, so it produces no
|
|
2391
|
+
* divergence to notice — which is exactly how a stale image-size bound
|
|
2392
|
+
* silently ate a user's photos. See ConnectionCallbacks.onIntentRejected.
|
|
2393
|
+
*/
|
|
2394
|
+
reportRejections(entries) {
|
|
2395
|
+
if (!this.cb.onIntentRejected) return;
|
|
2396
|
+
for (const e of entries) {
|
|
2397
|
+
if (e.kind === "rejected" && e.clientId === this.clientId) {
|
|
2398
|
+
this.cb.onIntentRejected({ reason: e.reason, clientSeq: e.clientSeq });
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
onServer(msg) {
|
|
2403
|
+
switch (msg.t) {
|
|
2404
|
+
case "welcome": {
|
|
2405
|
+
this.docVersionBase += (this.replica?.docVersion ?? 0) + 1;
|
|
2406
|
+
this.replica = new ClientReplica(base64ToBytes(msg.snapshot), msg.sidecar);
|
|
2407
|
+
this.replica.confirmedSeq = msg.seq;
|
|
2408
|
+
this.genesisId = msg.genesisId;
|
|
2409
|
+
this.mode = msg.mode;
|
|
2410
|
+
this.mediaMaxBlobBytes = msg.mediaMaxBlobBytes ?? null;
|
|
2411
|
+
if (msg.tail.length) this.replica.receive(msg.tail);
|
|
2412
|
+
if (this.resuming?.mediaMeta) {
|
|
2413
|
+
const held = DocxDocument.load(this.resuming.confirmedBytes);
|
|
2414
|
+
for (const [part, meta] of this.resuming.mediaMeta) {
|
|
2415
|
+
this.replica.doc.mediaMeta.set(part, meta);
|
|
2416
|
+
const bytes = held.media(part);
|
|
2417
|
+
if (bytes && this.replica.doc.mediaStatus(part) === "pending") {
|
|
2418
|
+
this.replica.doc.installMedia(part, bytes);
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
if (this.replica) applyMediaAddresses(this.replica.doc, msg.media);
|
|
2423
|
+
if (this.media) this.media.volunteer(msg.mediaNeeded);
|
|
2424
|
+
else if (msg.mediaNeeded?.length) {
|
|
2425
|
+
const held = new Set(this.heldMediaShas());
|
|
2426
|
+
this.mediaHave(msg.mediaNeeded.filter((sha) => held.has(sha)));
|
|
2427
|
+
}
|
|
2428
|
+
this.recordActivity(msg.tail);
|
|
2429
|
+
const resumed = this.resuming;
|
|
2430
|
+
this.resuming = null;
|
|
2431
|
+
if (resumed) {
|
|
2432
|
+
if (resumed.genesisId === msg.genesisId) {
|
|
2433
|
+
for (const intent of resumed.pending) this.transport.send({ t: "submit", intent });
|
|
2434
|
+
} else if (isAncestorOf(resumed, msg.lineage)) {
|
|
2435
|
+
this.cb.onFastForward?.(resumed.genesisId, msg.genesisId);
|
|
2436
|
+
} else {
|
|
2437
|
+
this.cb.onEpochChange?.(resumed.genesisId, msg.genesisId);
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
this.cb.onChange?.();
|
|
2441
|
+
void this.media?.fetchPending(this.docId);
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
case "broadcast": {
|
|
2445
|
+
this.replica?.receive(msg.entries);
|
|
2446
|
+
this.reportRejections(msg.entries);
|
|
2447
|
+
this.recordActivity(msg.entries);
|
|
2448
|
+
if (this.replica?.reloaded) this.docEpoch++;
|
|
2449
|
+
this.cb.onChange?.();
|
|
2450
|
+
void this.media?.fetchPending(this.docId);
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
case "presence": {
|
|
2454
|
+
if (isSealedPresence(msg.position)) return;
|
|
2455
|
+
this.cb.onPresence?.(msg.participant, sanitizePresencePosition(msg.position));
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
case "roster": {
|
|
2459
|
+
this.roster = msg.roster;
|
|
2460
|
+
this.cb.onRoster?.(msg.roster);
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
case "session-warning": {
|
|
2464
|
+
this.cb.onSessionWarning?.({ reason: msg.reason, inMs: msg.inMs });
|
|
2465
|
+
return;
|
|
2466
|
+
}
|
|
2467
|
+
case "session-warning-cleared": {
|
|
2468
|
+
this.cb.onSessionWarningCleared?.({ reason: msg.reason });
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
case "media-request": {
|
|
2472
|
+
this.media?.answerRequest(msg.sha);
|
|
2473
|
+
this.cb.onMediaRequest?.(msg.sha);
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
case "media-ready": {
|
|
2477
|
+
void this.media?.onReady(this.docId, msg.sha);
|
|
2478
|
+
this.cb.onMediaReady?.(msg.sha);
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
case "media-unavailable": {
|
|
2482
|
+
this.media?.onUnavailable(msg.sha);
|
|
2483
|
+
this.cb.onMediaUnavailable?.(msg.sha);
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
case "media-upload": {
|
|
2487
|
+
void this.media?.resupply(this.docId, msg.sha);
|
|
2488
|
+
this.cb.onMediaRequest?.(msg.sha);
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
2491
|
+
case "refused": {
|
|
2492
|
+
if (msg.reason === "rate-limit") {
|
|
2493
|
+
if (this.rlRedriveTimer !== null) return;
|
|
2494
|
+
this.rlRedriveBackoffMs = Math.min(this.rlRedriveBackoffMs === 0 ? 300 : this.rlRedriveBackoffMs * 2, 5e3);
|
|
2495
|
+
this.rlRedriveTimer = setTimeout(() => {
|
|
2496
|
+
this.rlRedriveTimer = null;
|
|
2497
|
+
if (!this.replica) return;
|
|
2498
|
+
const queue = this.replica.pendingCopies();
|
|
2499
|
+
if (queue.length === 0) {
|
|
2500
|
+
this.rlRedriveBackoffMs = 0;
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
for (const body of queue) {
|
|
2504
|
+
this.transport.send({ t: "submit", intent: { ...body, base: this.replica.confirmedSeq } });
|
|
2505
|
+
}
|
|
2506
|
+
}, this.rlRedriveBackoffMs);
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
this.cb.onRefused?.(msg.reason);
|
|
2510
|
+
return;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
};
|
|
2515
|
+
_CollabConnection.ACTIVITY_CAP = 100;
|
|
2516
|
+
var CollabConnection = _CollabConnection;
|
|
2517
|
+
function isAncestorOf(bundle, seedLineage) {
|
|
2518
|
+
if (!seedLineage) return false;
|
|
2519
|
+
const ownHead = bundle.lineage?.[bundle.lineage.length - 1];
|
|
2520
|
+
if (!ownHead) return false;
|
|
2521
|
+
return seedLineage.some(
|
|
2522
|
+
(h) => h.genesisId === ownHead.genesisId && h.seq >= ownHead.seq && h.docHash === ownHead.docHash
|
|
2523
|
+
);
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// ../collab/src/invert.ts
|
|
2527
|
+
function invertIntent(doc, ids, intent) {
|
|
2528
|
+
switch (intent.kind) {
|
|
2529
|
+
case "insertText": {
|
|
2530
|
+
return {
|
|
2531
|
+
kind: "deleteText",
|
|
2532
|
+
blockId: intent.at.blockId,
|
|
2533
|
+
runId: intent.at.runId,
|
|
2534
|
+
start: intent.at.offset,
|
|
2535
|
+
end: intent.at.offset + intent.text.length
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
case "deleteText": {
|
|
2539
|
+
const runEl = ids.elOf(intent.runId);
|
|
2540
|
+
if (!runEl) return null;
|
|
2541
|
+
const hit = resolveRunOffset(runEl, intent.start);
|
|
2542
|
+
if (!hit) return null;
|
|
2543
|
+
const localEnd = hit.offset + (intent.end - intent.start);
|
|
2544
|
+
if (localEnd > hit.t.text.length) return null;
|
|
2545
|
+
const removed = hit.t.text.slice(hit.offset, localEnd);
|
|
2546
|
+
if (removed.length === 0) return null;
|
|
2547
|
+
return {
|
|
2548
|
+
kind: "insertText",
|
|
2549
|
+
at: { blockId: intent.blockId, runId: intent.runId, offset: intent.start },
|
|
2550
|
+
text: removed
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
case "splitParagraph": {
|
|
2554
|
+
return { kind: "mergeParagraph", blockId: intent.newBlockId };
|
|
2555
|
+
}
|
|
2556
|
+
case "insertImage": {
|
|
2557
|
+
const carrier = intent.nodeIds?.[0];
|
|
2558
|
+
if (carrier === void 0) return null;
|
|
2559
|
+
return { kind: "removeDrawing", runId: carrier };
|
|
2560
|
+
}
|
|
2561
|
+
// Formatting / table / comment / merge inverses need prior-state capture
|
|
2562
|
+
// (was-bold?, the merged paragraph's structure, …) — a documented
|
|
2563
|
+
// extension; not undoable yet.
|
|
2564
|
+
default:
|
|
2565
|
+
return null;
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
// ../collab/src/validate.ts
|
|
2570
|
+
var MAX_IMAGE_BYTES = 256 * 1024 * 1024;
|
|
2571
|
+
var DEFAULT_INTENT_LIMITS = {
|
|
2572
|
+
maxInsertLength: 1e5,
|
|
2573
|
+
maxDeleteLength: 1e6,
|
|
2574
|
+
maxCommentLength: 2e4,
|
|
2575
|
+
maxPasteBytes: 2e6
|
|
2576
|
+
};
|
|
2577
|
+
function chartError(c, who) {
|
|
2578
|
+
if (typeof c !== "object" || c === null) return `${who}: bad chart`;
|
|
2579
|
+
if (!["column", "bar", "line", "pie"].includes(c.type)) return `${who}: bad type`;
|
|
2580
|
+
if (!Array.isArray(c.categories) || c.categories.length === 0 || c.categories.length > 100) return `${who}: bad categories`;
|
|
2581
|
+
if (c.categories.some((x) => typeof x !== "string" || x.length > 200)) return `${who}: bad category`;
|
|
2582
|
+
if (c.title !== void 0 && (typeof c.title !== "string" || c.title.length > 200)) return `${who}: bad title`;
|
|
2583
|
+
if (!Array.isArray(c.series) || c.series.length === 0 || c.series.length > 24) return `${who}: bad series`;
|
|
2584
|
+
for (const s of c.series) {
|
|
2585
|
+
if (typeof s !== "object" || s === null || typeof s.name !== "string" || s.name.length > 200) return `${who}: bad series name`;
|
|
2586
|
+
if (!Array.isArray(s.values) || s.values.length > 100 || s.values.some((v) => typeof v !== "number" || !Number.isFinite(v))) return `${who}: bad series values`;
|
|
2587
|
+
}
|
|
2588
|
+
return null;
|
|
2589
|
+
}
|
|
2590
|
+
function smartArtError(a, who) {
|
|
2591
|
+
if (typeof a !== "object" || a === null) return `${who}: bad smartArt`;
|
|
2592
|
+
if (!["process", "cycle", "hierarchy", "list"].includes(a.layout)) return `${who}: bad layout`;
|
|
2593
|
+
if (!Array.isArray(a.items) || a.items.length === 0 || a.items.length > 50) return `${who}: bad items`;
|
|
2594
|
+
if (a.items.some((x) => typeof x !== "string" || x.length > 500)) return `${who}: bad item`;
|
|
2595
|
+
return null;
|
|
2596
|
+
}
|
|
2597
|
+
function validateIntent(intent, limits = DEFAULT_INTENT_LIMITS) {
|
|
2598
|
+
const nonNegInt = (n) => Number.isInteger(n) && n >= 0;
|
|
2599
|
+
switch (intent.kind) {
|
|
2600
|
+
case "insertText":
|
|
2601
|
+
if (typeof intent.text !== "string") return "insertText: text not a string";
|
|
2602
|
+
if (intent.text.length === 0) return "insertText: empty";
|
|
2603
|
+
if (intent.text.length > limits.maxInsertLength) return "insertText: too long";
|
|
2604
|
+
if (!nonNegInt(intent.at.offset)) return "insertText: bad offset";
|
|
2605
|
+
return null;
|
|
2606
|
+
case "suggestRevision": {
|
|
2607
|
+
const nRanges = intent.ranges?.length ?? 0;
|
|
2608
|
+
const nMarks = intent.marks?.length ?? 0;
|
|
2609
|
+
if (nRanges + nMarks === 0) return "suggestRevision: empty";
|
|
2610
|
+
if (nRanges > 100 || nMarks > 20) return "suggestRevision: too many";
|
|
2611
|
+
for (const r of intent.ranges ?? []) {
|
|
2612
|
+
if (!nonNegInt(r.start) || !nonNegInt(r.end) || r.end <= r.start) return "suggestRevision: bad range";
|
|
2613
|
+
if (r.end - r.start > limits.maxDeleteLength) return "suggestRevision: too large";
|
|
2614
|
+
}
|
|
2615
|
+
for (const m of intent.marks ?? []) {
|
|
2616
|
+
if (m.glyph !== "ins" && m.glyph !== "del") return "suggestRevision: bad glyph";
|
|
2617
|
+
}
|
|
2618
|
+
if (typeof intent.suggest?.author !== "string" || intent.suggest.author.length > 100) return "suggestRevision: bad author";
|
|
2619
|
+
if (typeof intent.suggest?.date !== "string" || intent.suggest.date.length > 40) return "suggestRevision: bad date";
|
|
2620
|
+
return null;
|
|
2621
|
+
}
|
|
2622
|
+
case "deleteText":
|
|
2623
|
+
if (!nonNegInt(intent.start) || !nonNegInt(intent.end)) return "deleteText: bad range";
|
|
2624
|
+
if (intent.end <= intent.start) return "deleteText: empty range";
|
|
2625
|
+
if (intent.end - intent.start > limits.maxDeleteLength) return "deleteText: too large";
|
|
2626
|
+
return null;
|
|
2627
|
+
case "splitParagraph":
|
|
2628
|
+
if (!nonNegInt(intent.at.offset)) return "splitParagraph: bad offset";
|
|
2629
|
+
if (!nonNegInt(intent.newBlockId) || !nonNegInt(intent.newRunId)) return "splitParagraph: bad ids";
|
|
2630
|
+
return null;
|
|
2631
|
+
case "formatRange":
|
|
2632
|
+
if (!nonNegInt(intent.start) || !nonNegInt(intent.end) || intent.end <= intent.start) return "formatRange: bad range";
|
|
2633
|
+
if (!nonNegInt(intent.middleId)) return "formatRange: bad middleId";
|
|
2634
|
+
return null;
|
|
2635
|
+
case "commentRun":
|
|
2636
|
+
if (typeof intent.text !== "string" || intent.text.length === 0) return "commentRun: empty";
|
|
2637
|
+
if (intent.text.length > limits.maxCommentLength) return "commentRun: too long";
|
|
2638
|
+
if (typeof intent.paraId !== "string" || typeof intent.date !== "string") return "commentRun: bad provenance";
|
|
2639
|
+
return null;
|
|
2640
|
+
case "pasteBlocks":
|
|
2641
|
+
if (typeof intent.blocksXml !== "string" || intent.blocksXml.length === 0) return "pasteBlocks: empty";
|
|
2642
|
+
if (intent.blocksXml.length > limits.maxPasteBytes) return "pasteBlocks: too large";
|
|
2643
|
+
if (!Array.isArray(intent.nodeIds)) return "pasteBlocks: bad nodeIds";
|
|
2644
|
+
return null;
|
|
2645
|
+
case "insertImage":
|
|
2646
|
+
if (!/^[0-9a-f]{64}$/.test(intent.blobSha)) return "insertImage: bad sha";
|
|
2647
|
+
if (!nonNegInt(intent.bytesLen) || intent.bytesLen === 0 || intent.bytesLen > MAX_IMAGE_BYTES)
|
|
2648
|
+
return "insertImage: bad size";
|
|
2649
|
+
if (!["png", "jpg", "jpeg", "gif", "bmp", "webp"].includes(intent.ext)) return "insertImage: bad ext";
|
|
2650
|
+
if (intent.iv !== void 0 && !/^[A-Za-z0-9+/]{16}$/.test(intent.iv)) return "insertImage: bad iv";
|
|
2651
|
+
if (!Number.isFinite(intent.widthPx) || !Number.isFinite(intent.heightPx)) return "insertImage: bad size";
|
|
2652
|
+
return null;
|
|
2653
|
+
case "insertBreak":
|
|
2654
|
+
if (intent.breakKind !== "page" && intent.breakKind !== "column") return "insertBreak: bad kind";
|
|
2655
|
+
return null;
|
|
2656
|
+
case "insertMath":
|
|
2657
|
+
if (typeof intent.mathText !== "string" || intent.mathText.length === 0) return "insertMath: empty";
|
|
2658
|
+
if (intent.mathText.length > 1e4) return "insertMath: too long";
|
|
2659
|
+
return null;
|
|
2660
|
+
case "insertShape": {
|
|
2661
|
+
const presets = ["line", "verticalLine", "rectangle", "roundedRectangle", "ellipse", "diamond", "textBox"];
|
|
2662
|
+
if (!presets.includes(intent.preset)) return "insertShape: bad preset";
|
|
2663
|
+
return null;
|
|
2664
|
+
}
|
|
2665
|
+
case "replyComment":
|
|
2666
|
+
if (typeof intent.text !== "string" || intent.text.length === 0) return "replyComment: empty";
|
|
2667
|
+
if (intent.text.length > 2e4) return "replyComment: too long";
|
|
2668
|
+
if (typeof intent.parentId !== "string" || !Array.isArray(intent.paraIds) || typeof intent.date !== "string") return "replyComment: bad fields";
|
|
2669
|
+
return null;
|
|
2670
|
+
case "adjustIndent":
|
|
2671
|
+
if (intent.direction !== 1 && intent.direction !== -1) return "adjustIndent: bad direction";
|
|
2672
|
+
return null;
|
|
2673
|
+
case "setSpacing":
|
|
2674
|
+
if (typeof intent.patch !== "object" || intent.patch === null) return "setSpacing: bad patch";
|
|
2675
|
+
return null;
|
|
2676
|
+
case "insertPageField":
|
|
2677
|
+
if (intent.fieldKind !== "page" && intent.fieldKind !== "pageOfTotal") return "insertPageField: bad kind";
|
|
2678
|
+
return null;
|
|
2679
|
+
case "setLink":
|
|
2680
|
+
if (typeof intent.url !== "string" || intent.url.length === 0 || intent.url.length > 4e3) return "setLink: bad url";
|
|
2681
|
+
return null;
|
|
2682
|
+
case "insertFootnote":
|
|
2683
|
+
if (typeof intent.text !== "string" || intent.text.trim().length === 0) return "insertFootnote: empty";
|
|
2684
|
+
if (intent.text.length > 2e4) return "insertFootnote: too long";
|
|
2685
|
+
return null;
|
|
2686
|
+
case "setDropCap":
|
|
2687
|
+
if (intent.mode !== null && intent.mode !== "drop" && intent.mode !== "margin") return "setDropCap: bad mode";
|
|
2688
|
+
return null;
|
|
2689
|
+
case "setDivider":
|
|
2690
|
+
return null;
|
|
2691
|
+
case "insertBookmark":
|
|
2692
|
+
if (typeof intent.name !== "string" || intent.name.length === 0 || intent.name.length > 400) return "insertBookmark: bad name";
|
|
2693
|
+
return null;
|
|
2694
|
+
case "insertBlankPage":
|
|
2695
|
+
return null;
|
|
2696
|
+
case "insertSectionBreak":
|
|
2697
|
+
if (intent.breakType !== "nextPage" && intent.breakType !== "continuous") return "insertSectionBreak: bad type";
|
|
2698
|
+
return null;
|
|
2699
|
+
case "insertCrossRef":
|
|
2700
|
+
if (typeof intent.bookmark !== "string" || intent.bookmark.length === 0) return "insertCrossRef: bad bookmark";
|
|
2701
|
+
if (intent.refKind !== "text" && intent.refKind !== "page") return "insertCrossRef: bad kind";
|
|
2702
|
+
return null;
|
|
2703
|
+
case "insertCoverPage":
|
|
2704
|
+
if (typeof intent.content !== "object" || intent.content === null) return "insertCoverPage: bad content";
|
|
2705
|
+
return null;
|
|
2706
|
+
case "setPageLayout": {
|
|
2707
|
+
const p = intent.patch;
|
|
2708
|
+
if (typeof p !== "object" || p === null) return "setPageLayout: bad patch";
|
|
2709
|
+
const rec = p;
|
|
2710
|
+
const allowed = ["margins", "mirrorMargins", "size", "orientation", "columns", "columnSeparator", "pageBorders"];
|
|
2711
|
+
for (const k of Object.keys(rec)) if (!allowed.includes(k)) return `setPageLayout: unknown key ${k}`;
|
|
2712
|
+
const inRange = (v, lo, hi) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
|
|
2713
|
+
if (rec.orientation !== void 0 && rec.orientation !== "portrait" && rec.orientation !== "landscape") return "setPageLayout: bad orientation";
|
|
2714
|
+
if (rec.columns !== void 0 && !inRange(rec.columns, 1, 12)) return "setPageLayout: bad columns";
|
|
2715
|
+
if (rec.margins !== void 0) {
|
|
2716
|
+
if (typeof rec.margins !== "object" || rec.margins === null) return "setPageLayout: bad margins";
|
|
2717
|
+
for (const [mk, mv] of Object.entries(rec.margins)) {
|
|
2718
|
+
if (!["top", "right", "bottom", "left"].includes(mk)) return `setPageLayout: bad margin ${mk}`;
|
|
2719
|
+
if (!inRange(mv, 0, 22)) return "setPageLayout: margin out of range";
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
if (rec.size !== void 0) {
|
|
2723
|
+
const s = rec.size;
|
|
2724
|
+
if (typeof s !== "object" || s === null || !inRange(s.width, 1, 22) || !inRange(s.height, 1, 22)) return "setPageLayout: bad size";
|
|
2725
|
+
}
|
|
2726
|
+
return null;
|
|
2727
|
+
}
|
|
2728
|
+
case "setListLevel":
|
|
2729
|
+
if (intent.delta !== 1 && intent.delta !== -1) return "setListLevel: bad delta";
|
|
2730
|
+
return null;
|
|
2731
|
+
case "insertWordArt": {
|
|
2732
|
+
if (typeof intent.text !== "string" || intent.text.length === 0) return "insertWordArt: empty text";
|
|
2733
|
+
if (intent.text.length > 500) return "insertWordArt: text too long";
|
|
2734
|
+
if (!["plain", "archUp", "archDown", "wave", "chevron"].includes(intent.preset)) return "insertWordArt: bad preset";
|
|
2735
|
+
return null;
|
|
2736
|
+
}
|
|
2737
|
+
case "insertChart":
|
|
2738
|
+
return chartError(intent.chart, "insertChart");
|
|
2739
|
+
case "setChartData":
|
|
2740
|
+
return chartError(intent.chart, "setChartData");
|
|
2741
|
+
case "setSmartArtNodeText":
|
|
2742
|
+
if (typeof intent.index !== "number" || !Number.isInteger(intent.index) || intent.index < 0 || intent.index > 1e3) return "setSmartArtNodeText: bad index";
|
|
2743
|
+
if (typeof intent.text !== "string" || intent.text.length > 500) return "setSmartArtNodeText: bad text";
|
|
2744
|
+
return null;
|
|
2745
|
+
case "setDrawingWordArtText":
|
|
2746
|
+
if (typeof intent.text !== "string" || intent.text.length === 0 || intent.text.length > 500) return "setDrawingWordArtText: bad text";
|
|
2747
|
+
return null;
|
|
2748
|
+
case "setDrawingLineStyle":
|
|
2749
|
+
if (!/^[0-9a-fA-F]{6}$/.test(intent.color)) return "setDrawingLineStyle: bad color";
|
|
2750
|
+
if (typeof intent.widthPx !== "number" || !Number.isFinite(intent.widthPx) || intent.widthPx <= 0 || intent.widthPx > 100) return "setDrawingLineStyle: bad width";
|
|
2751
|
+
if (!["solid", "dashed", "dotted"].includes(intent.dash)) return "setDrawingLineStyle: bad dash";
|
|
2752
|
+
return null;
|
|
2753
|
+
case "setImageAltText":
|
|
2754
|
+
if (typeof intent.alt !== "string" || intent.alt.length > 1e3) return "setImageAltText: bad alt";
|
|
2755
|
+
return null;
|
|
2756
|
+
case "removeLink":
|
|
2757
|
+
return null;
|
|
2758
|
+
case "insertSmartArt":
|
|
2759
|
+
return smartArtError(intent.smartArt, "insertSmartArt");
|
|
2760
|
+
case "setSmartArtData":
|
|
2761
|
+
return smartArtError(intent.smartArt, "setSmartArtData");
|
|
2762
|
+
case "setImageWrap":
|
|
2763
|
+
if (!["inline", "square", "topAndBottom", "none", "behind"].includes(intent.mode)) return "setImageWrap: bad mode";
|
|
2764
|
+
return null;
|
|
2765
|
+
case "setDrawingOrder":
|
|
2766
|
+
if (intent.order !== "front" && intent.order !== "back") return "setDrawingOrder: bad order";
|
|
2767
|
+
return null;
|
|
2768
|
+
case "setSmartArtFill":
|
|
2769
|
+
if (intent.color !== null && !/^[0-9a-fA-F]{6}$/.test(intent.color)) return "setSmartArtFill: bad color";
|
|
2770
|
+
if (intent.nodeIndex !== void 0 && (typeof intent.nodeIndex !== "number" || !Number.isInteger(intent.nodeIndex) || intent.nodeIndex < 0 || intent.nodeIndex > 1e3)) return "setSmartArtFill: bad nodeIndex";
|
|
2771
|
+
return null;
|
|
2772
|
+
case "setSmartArtTextFormat": {
|
|
2773
|
+
const f = intent.format;
|
|
2774
|
+
if (typeof f !== "object" || f === null) return "setSmartArtTextFormat: bad format";
|
|
2775
|
+
if (typeof f.fontFamily !== "string" || f.fontFamily.length === 0 || f.fontFamily.length > 100) return "setSmartArtTextFormat: bad fontFamily";
|
|
2776
|
+
if (typeof f.fontSizePt !== "number" || !Number.isFinite(f.fontSizePt) || f.fontSizePt < 1 || f.fontSizePt > 1638) return "setSmartArtTextFormat: bad fontSizePt";
|
|
2777
|
+
if (!/^[0-9a-fA-F]{6}$/.test(f.color)) return "setSmartArtTextFormat: bad color";
|
|
2778
|
+
if (typeof f.bold !== "boolean" || typeof f.italic !== "boolean") return "setSmartArtTextFormat: bad bold/italic";
|
|
2779
|
+
if (!["left", "center", "right"].includes(f.alignment)) return "setSmartArtTextFormat: bad alignment";
|
|
2780
|
+
if (intent.nodeIndex !== void 0 && (typeof intent.nodeIndex !== "number" || !Number.isInteger(intent.nodeIndex) || intent.nodeIndex < 0 || intent.nodeIndex > 1e3)) return "setSmartArtTextFormat: bad nodeIndex";
|
|
2781
|
+
return null;
|
|
2782
|
+
}
|
|
2783
|
+
case "setFloatingPagePosition": {
|
|
2784
|
+
const ok = (v) => typeof v === "number" && Number.isFinite(v) && v >= -5e3 && v <= 5e3;
|
|
2785
|
+
if (!ok(intent.xPx) || !ok(intent.yPx)) return "setFloatingPagePosition: bad position";
|
|
2786
|
+
return null;
|
|
2787
|
+
}
|
|
2788
|
+
case "resizeDrawing": {
|
|
2789
|
+
const ok = (v) => typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 5e3;
|
|
2790
|
+
if (!ok(intent.widthPx) || !ok(intent.heightPx)) return "resizeDrawing: bad extent";
|
|
2791
|
+
return null;
|
|
2792
|
+
}
|
|
2793
|
+
case "resizeTableColumn": {
|
|
2794
|
+
const num = (v, lo, hi) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
|
|
2795
|
+
if (!Number.isInteger(intent.boundary) || intent.boundary < 1 || intent.boundary > 200) return "resizeTableColumn: bad boundary";
|
|
2796
|
+
if (!num(intent.deltaPx, -5e3, 5e3)) return "resizeTableColumn: bad delta";
|
|
2797
|
+
if (intent.renderedWidths !== void 0) {
|
|
2798
|
+
if (!Array.isArray(intent.renderedWidths) || intent.renderedWidths.length > 200 || !intent.renderedWidths.every((w) => num(w, 0, 2e4))) return "resizeTableColumn: bad widths";
|
|
2799
|
+
}
|
|
2800
|
+
return null;
|
|
2801
|
+
}
|
|
2802
|
+
case "resizeTableRow": {
|
|
2803
|
+
if (!Number.isInteger(intent.rowIdx) || intent.rowIdx < 0 || intent.rowIdx > 5e3) return "resizeTableRow: bad row";
|
|
2804
|
+
if (typeof intent.heightPx !== "number" || !Number.isFinite(intent.heightPx) || intent.heightPx < 1 || intent.heightPx > 2e4) return "resizeTableRow: bad height";
|
|
2805
|
+
return null;
|
|
2806
|
+
}
|
|
2807
|
+
case "moveTable": {
|
|
2808
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) && v >= -5e3 && v <= 2e4;
|
|
2809
|
+
if (!num(intent.xPx) || !num(intent.yPx)) return "moveTable: bad position";
|
|
2810
|
+
if (typeof intent.preservePageStart !== "boolean") return "moveTable: bad flag";
|
|
2811
|
+
if (!Number.isInteger(intent.pageDelta) || Math.abs(intent.pageDelta) > 500) return "moveTable: bad pageDelta";
|
|
2812
|
+
return null;
|
|
2813
|
+
}
|
|
2814
|
+
case "removeDrawing":
|
|
2815
|
+
return null;
|
|
2816
|
+
// run-addressed only; resolution is the whole check
|
|
2817
|
+
case "setMathLinear":
|
|
2818
|
+
if (typeof intent.mathText !== "string" || intent.mathText.length === 0 || intent.mathText.length > 1e3) return "setMathLinear: bad mathText";
|
|
2819
|
+
return null;
|
|
2820
|
+
case "deleteMath":
|
|
2821
|
+
return null;
|
|
2822
|
+
case "moveMath":
|
|
2823
|
+
if (!nonNegInt(intent.at?.offset)) return "moveMath: bad offset";
|
|
2824
|
+
if (!Array.isArray(intent.nodeIds) || intent.nodeIds.length > 64) return "moveMath: bad nodeIds";
|
|
2825
|
+
return null;
|
|
2826
|
+
case "ensureHeaderFooter":
|
|
2827
|
+
if (intent.hfKind !== "header" && intent.hfKind !== "footer") return "ensureHeaderFooter: bad kind";
|
|
2828
|
+
if (!Array.isArray(intent.nodeIds) || intent.nodeIds.length > 64) return "ensureHeaderFooter: bad nodeIds";
|
|
2829
|
+
return null;
|
|
2830
|
+
case "deleteComment":
|
|
2831
|
+
if (typeof intent.commentId !== "string" || intent.commentId.length === 0 || intent.commentId.length > 64) return "deleteComment: bad id";
|
|
2832
|
+
return null;
|
|
2833
|
+
case "insertBookmarkRange":
|
|
2834
|
+
if (typeof intent.name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]{0,39}$/.test(intent.name)) return "insertBookmarkRange: bad name";
|
|
2835
|
+
if (!nonNegInt(intent.start) || !nonNegInt(intent.end) || intent.end <= intent.start) return "insertBookmarkRange: bad range";
|
|
2836
|
+
return null;
|
|
2837
|
+
case "toggleCheckbox":
|
|
2838
|
+
return null;
|
|
2839
|
+
case "acceptRevision":
|
|
2840
|
+
case "rejectRevision":
|
|
2841
|
+
if (!nonNegInt(intent.index) || intent.index > 1e5) return `${intent.kind}: bad index`;
|
|
2842
|
+
return null;
|
|
2843
|
+
case "acceptAllRevisions":
|
|
2844
|
+
return null;
|
|
2845
|
+
case "insertTable": {
|
|
2846
|
+
const okDim = (v) => typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 50;
|
|
2847
|
+
if (!okDim(intent.rows) || !okDim(intent.cols)) return "insertTable: bad dimensions";
|
|
2848
|
+
return null;
|
|
2849
|
+
}
|
|
2850
|
+
case "setLineNumbering": {
|
|
2851
|
+
const p = intent.patch;
|
|
2852
|
+
if (typeof p !== "object" || p === null || typeof p.enabled !== "boolean") return "setLineNumbering: bad patch";
|
|
2853
|
+
if (p.countBy !== void 0 && (typeof p.countBy !== "number" || !Number.isInteger(p.countBy) || p.countBy < 1 || p.countBy > 100)) return "setLineNumbering: bad countBy";
|
|
2854
|
+
if (p.restart !== void 0 && !["continuous", "newPage", "newSection"].includes(p.restart)) return "setLineNumbering: bad restart";
|
|
2855
|
+
if (p.start !== void 0 && (typeof p.start !== "number" || !Number.isInteger(p.start) || p.start < 0 || p.start > 1e5)) return "setLineNumbering: bad start";
|
|
2856
|
+
return null;
|
|
2857
|
+
}
|
|
2858
|
+
case "insertDateTimeField": {
|
|
2859
|
+
if (intent.dtKind !== "date" && intent.dtKind !== "time") return "insertDateTimeField: bad kind";
|
|
2860
|
+
if (typeof intent.picture !== "string" || intent.picture.length === 0 || intent.picture.length > 100) return "insertDateTimeField: bad picture";
|
|
2861
|
+
if (/[\\{}"]/.test(intent.picture)) return "insertDateTimeField: illegal picture char";
|
|
2862
|
+
return null;
|
|
2863
|
+
}
|
|
2864
|
+
case "insertField": {
|
|
2865
|
+
if (typeof intent.instruction !== "string" || intent.instruction.length === 0 || intent.instruction.length > 200) return "insertField: bad instruction";
|
|
2866
|
+
if (intent.cachedResult !== void 0 && (typeof intent.cachedResult !== "string" || intent.cachedResult.length > 1e3)) return "insertField: bad cachedResult";
|
|
2867
|
+
const SAFE_FIELDS = /* @__PURE__ */ new Set([
|
|
2868
|
+
"PAGE",
|
|
2869
|
+
"NUMPAGES",
|
|
2870
|
+
"SECTIONPAGES",
|
|
2871
|
+
"SECTION",
|
|
2872
|
+
"DATE",
|
|
2873
|
+
"TIME",
|
|
2874
|
+
"CREATEDATE",
|
|
2875
|
+
"SAVEDATE",
|
|
2876
|
+
"PRINTDATE",
|
|
2877
|
+
"AUTHOR",
|
|
2878
|
+
"TITLE",
|
|
2879
|
+
"SUBJECT",
|
|
2880
|
+
"KEYWORDS",
|
|
2881
|
+
"COMMENTS",
|
|
2882
|
+
"FILENAME",
|
|
2883
|
+
"NUMWORDS",
|
|
2884
|
+
"NUMCHARS",
|
|
2885
|
+
"PAGEREF",
|
|
2886
|
+
"REF",
|
|
2887
|
+
"SEQ",
|
|
2888
|
+
"STYLEREF",
|
|
2889
|
+
"TOC",
|
|
2890
|
+
"INDEX",
|
|
2891
|
+
"LISTNUM",
|
|
2892
|
+
"QUOTE"
|
|
2893
|
+
]);
|
|
2894
|
+
const type = intent.instruction.trim().split(/\s+/)[0]?.toUpperCase();
|
|
2895
|
+
if (!type || !SAFE_FIELDS.has(type)) return "insertField: field type not allowed";
|
|
2896
|
+
if (!/^[\x20-\x7e]+$/.test(intent.instruction)) return "insertField: illegal instruction char";
|
|
2897
|
+
return null;
|
|
2898
|
+
}
|
|
2899
|
+
case "setDrawingRotation":
|
|
2900
|
+
if (typeof intent.degrees !== "number" || !Number.isFinite(intent.degrees)) return "setDrawingRotation: bad degrees";
|
|
2901
|
+
return null;
|
|
2902
|
+
case "setDrawingFill":
|
|
2903
|
+
if (intent.color !== null && !/^[0-9a-fA-F]{6}$/.test(intent.color)) return "setDrawingFill: bad color";
|
|
2904
|
+
return null;
|
|
2905
|
+
case "formatRun":
|
|
2906
|
+
case "formatParagraph":
|
|
2907
|
+
case "setListType":
|
|
2908
|
+
case "mergeParagraph":
|
|
2909
|
+
return null;
|
|
2910
|
+
case "tableOp": {
|
|
2911
|
+
const op = intent.op;
|
|
2912
|
+
if (typeof op === "string") {
|
|
2913
|
+
return ["deleteRow", "deleteCol", "deleteTable", "rowAbove", "rowBelow", "colLeft", "colRight"].includes(op) ? null : "tableOp: bad operation";
|
|
2914
|
+
}
|
|
2915
|
+
if (op.kind === "cellShading") {
|
|
2916
|
+
return op.fill === null || /^[0-9a-fA-F]{6}$/.test(op.fill) ? null : "tableOp: bad shading";
|
|
2917
|
+
}
|
|
2918
|
+
if (op.kind === "cellVAlign") {
|
|
2919
|
+
return ["top", "center", "bottom"].includes(op.v) ? null : "tableOp: bad vertical alignment";
|
|
2920
|
+
}
|
|
2921
|
+
if (op.kind !== "textWrapping") return "tableOp: bad operation";
|
|
2922
|
+
if (op.wrapping !== "none" && op.wrapping !== "around" || !Number.isFinite(op.xPx) || !Number.isFinite(op.yPx)) return "tableOp: bad text wrapping";
|
|
2923
|
+
return null;
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
// ../collab/src/session.ts
|
|
2929
|
+
var UNDO_CLIENT_SEQ_BASE = 1e9;
|
|
2930
|
+
var DocumentSession = class {
|
|
2931
|
+
constructor(doc) {
|
|
2932
|
+
this.log = [];
|
|
2933
|
+
this.seen = /* @__PURE__ */ new Set();
|
|
2934
|
+
/** Base sequence number when the session was seeded from a mid-history
|
|
2935
|
+
* checkpoint (E2EE mirrors, doc 13 §3): entries 1..floor are baked into
|
|
2936
|
+
* the seed bytes; numbering continues from here. */
|
|
2937
|
+
this.seqFloor = 0;
|
|
2938
|
+
/**
|
|
2939
|
+
* Undo stack per client: EVERY applied intent that client issued, newest
|
|
2940
|
+
* last, with its pre-computed inverse — or `null` when the kind has no
|
|
2941
|
+
* inverse yet.
|
|
2942
|
+
*
|
|
2943
|
+
* The null MARKERS are load-bearing, not bookkeeping. Stacking only the
|
|
2944
|
+
* invertible intents makes undo mean "your last action THAT HAPPENS TO BE
|
|
2945
|
+
* INVERTIBLE", which silently reaches past the others: type "hello", bold
|
|
2946
|
+
* it, press undo, and the bold stays while the typing disappears (measured,
|
|
2947
|
+
* not theorised — the bold applies and the depth never moves). Recording
|
|
2948
|
+
* every action lets undo mean "your last action" and answer honestly when
|
|
2949
|
+
* that action cannot be reversed yet.
|
|
2950
|
+
*/
|
|
2951
|
+
this.undoStacks = /* @__PURE__ */ new Map();
|
|
2952
|
+
this.undoSeq = /* @__PURE__ */ new Map();
|
|
2953
|
+
this.doc = doc;
|
|
2954
|
+
this.ids = doc.enableStableIds();
|
|
2955
|
+
}
|
|
2956
|
+
/** Newest assigned sequence number (0 before any entry). Derived from the
|
|
2957
|
+
* last log entry so it stays correct after a snapshot prunes the log
|
|
2958
|
+
* prefix (rehydration). */
|
|
2959
|
+
get seq() {
|
|
2960
|
+
return this.log.length === 0 ? this.seqFloor : this.log[this.log.length - 1].seq;
|
|
2961
|
+
}
|
|
2962
|
+
/** Declare that this session's document already reflects seqs 1..n (it
|
|
2963
|
+
* was seeded from a checkpoint at n) — numbering continues from there. */
|
|
2964
|
+
setSeqFloor(n) {
|
|
2965
|
+
this.seqFloor = n;
|
|
2966
|
+
}
|
|
2967
|
+
entriesSince(base) {
|
|
2968
|
+
return this.log.filter((e) => e.seq > base);
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* A checkpoint bundle (plan doc 03): the snapshot docx bytes at the current
|
|
2972
|
+
* seq plus the ID sidecar, so a client bootstrapping from it reproduces the
|
|
2973
|
+
* exact id table (parse order alone cannot, because split-created nodes
|
|
2974
|
+
* carry non-sequential ids).
|
|
2975
|
+
*/
|
|
2976
|
+
checkpoint() {
|
|
2977
|
+
return {
|
|
2978
|
+
seq: this.seq,
|
|
2979
|
+
docx: this.doc.save(),
|
|
2980
|
+
sidecar: this.ids.exportSidecar(this.doc.editableRoots())
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
/** Install an ID sidecar (from a checkpoint bundle) onto the current
|
|
2984
|
+
* document — used when rehydrating from a snapshot so subsequent tail
|
|
2985
|
+
* entries resolve their carried ids correctly. */
|
|
2986
|
+
installSidecar(sidecar) {
|
|
2987
|
+
this.ids.importSidecar(this.doc.editableRoots(), sidecar);
|
|
2988
|
+
}
|
|
2989
|
+
/**
|
|
2990
|
+
* Rehydrate from a persisted log tail whose entries are already sequenced
|
|
2991
|
+
* and canonical (their positions were transformed when first submitted).
|
|
2992
|
+
* Applies them in order WITHOUT re-transforming, and restores the log,
|
|
2993
|
+
* dedup set, and seq. The document passed to the constructor must be the
|
|
2994
|
+
* snapshot the tail continues from.
|
|
2995
|
+
*
|
|
2996
|
+
* NOTE (plan round-2 F1): this reconstructs stable ids by the snapshot's
|
|
2997
|
+
* parse order plus the carried ids in split entries. That is exact for
|
|
2998
|
+
* histories without pre-snapshot splits; a snapshot taken after splits
|
|
2999
|
+
* needs the ID sidecar (documented next step) to reproduce the id table.
|
|
3000
|
+
*/
|
|
3001
|
+
loadCanonical(tail) {
|
|
3002
|
+
for (const e of tail) {
|
|
3003
|
+
if (e.kind === "applied") {
|
|
3004
|
+
const res = applyIntentScoped(this.doc, this.ids, e.intent);
|
|
3005
|
+
if (res.applied && res.kind === "split") resyncScope(this.doc, this.ids, res);
|
|
3006
|
+
this.seen.add(idempotencyKey(e.intent));
|
|
3007
|
+
} else {
|
|
3008
|
+
this.seen.add(`${e.clientId}:${e.clientSeq}`);
|
|
3009
|
+
}
|
|
3010
|
+
this.log.push(e);
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
/**
|
|
3014
|
+
* Submit an intent from a client. Deduplicates re-sends by idempotency key,
|
|
3015
|
+
* transforms against everything sequenced since the intent's base, applies
|
|
3016
|
+
* (or rejects), logs, and returns the sequenced entry(ies) to broadcast.
|
|
3017
|
+
*/
|
|
3018
|
+
submit(intent) {
|
|
3019
|
+
const key = idempotencyKey(intent);
|
|
3020
|
+
if (this.seen.has(key)) {
|
|
3021
|
+
const prior = this.log.find(
|
|
3022
|
+
(e) => e.kind === "applied" && idempotencyKey(e.intent) === key || e.kind === "rejected" && `${e.clientId}:${e.clientSeq}` === key
|
|
3023
|
+
);
|
|
3024
|
+
if (prior) return prior;
|
|
3025
|
+
}
|
|
3026
|
+
this.seen.add(key);
|
|
3027
|
+
const invalid = validateIntent(intent);
|
|
3028
|
+
if (invalid) return this.reject(intent, invalid);
|
|
3029
|
+
if (intent.base < 0 || intent.base > this.seq) {
|
|
3030
|
+
return this.reject(intent, "invalid base");
|
|
3031
|
+
}
|
|
3032
|
+
const ahead = this.log.filter(
|
|
3033
|
+
(e) => e.kind === "applied" && e.seq > intent.base && e.intent.clientId !== intent.clientId
|
|
3034
|
+
).map((e) => e.intent);
|
|
3035
|
+
const canonical = transformIntent(intent, ahead);
|
|
3036
|
+
const inverse = invertIntent(this.doc, this.ids, canonical);
|
|
3037
|
+
let result;
|
|
3038
|
+
try {
|
|
3039
|
+
result = applyIntentScoped(this.doc, this.ids, canonical);
|
|
3040
|
+
} catch {
|
|
3041
|
+
result = { kind: "doc", applied: false };
|
|
3042
|
+
}
|
|
3043
|
+
if (!result.applied) return this.reject(intent, "apply failed");
|
|
3044
|
+
if (result.kind === "split") resyncScope(this.doc, this.ids, result);
|
|
3045
|
+
const entry = { seq: this.seq + 1, kind: "applied", intent: canonical };
|
|
3046
|
+
this.log.push(entry);
|
|
3047
|
+
if (!this.isUndoIntent(intent)) {
|
|
3048
|
+
const stack = this.undoStacks.get(intent.clientId) ?? [];
|
|
3049
|
+
stack.push({ seq: entry.seq, inverse });
|
|
3050
|
+
this.undoStacks.set(intent.clientId, stack);
|
|
3051
|
+
}
|
|
3052
|
+
return entry;
|
|
3053
|
+
}
|
|
3054
|
+
isUndoIntent(intent) {
|
|
3055
|
+
return intent.clientSeq >= UNDO_CLIENT_SEQ_BASE;
|
|
3056
|
+
}
|
|
3057
|
+
/**
|
|
3058
|
+
* Take this client's most recent action off its undo stack WITHOUT applying
|
|
3059
|
+
* anything — the caller decides how to submit the inverse.
|
|
3060
|
+
*
|
|
3061
|
+
* This exists because `undo()` below both pops AND submits, and an
|
|
3062
|
+
* ENCRYPTED client must never do the second half here: its mirror is a
|
|
3063
|
+
* local re-derivation of the canonical log and may only advance by
|
|
3064
|
+
* ingesting sequenced envelopes. Applying an inverse directly to the mirror
|
|
3065
|
+
* would desynchronise it from every other client's mirror — the one thing
|
|
3066
|
+
* the mirror architecture exists to prevent. So the encrypted path takes
|
|
3067
|
+
* the inverse, seals it, and submits it as an ordinary intent; it arrives
|
|
3068
|
+
* back through ingest like anything else.
|
|
3069
|
+
*
|
|
3070
|
+
* `cannot-undo` does NOT pop: undo stops at an action it cannot reverse
|
|
3071
|
+
* rather than skipping to an older one. That is a deliberate dead end — the
|
|
3072
|
+
* cure is implementing more inverses, not quietly undoing something the
|
|
3073
|
+
* user did not ask about.
|
|
3074
|
+
*/
|
|
3075
|
+
takeUndo(clientId) {
|
|
3076
|
+
const stack = this.undoStacks.get(clientId);
|
|
3077
|
+
if (!stack || stack.length === 0) return { kind: "nothing-to-undo" };
|
|
3078
|
+
const top = stack[stack.length - 1];
|
|
3079
|
+
if (!top.inverse) return { kind: "cannot-undo" };
|
|
3080
|
+
stack.pop();
|
|
3081
|
+
return { kind: "undoable", seq: top.seq, inverse: top.inverse };
|
|
3082
|
+
}
|
|
3083
|
+
/** The clientSeq an undo intent must carry so it is not itself stacked.
|
|
3084
|
+
* Exposed because the encrypted client builds its own undo intents. */
|
|
3085
|
+
nextUndoClientSeq(clientId) {
|
|
3086
|
+
const n = (this.undoSeq.get(clientId) ?? 0) + 1;
|
|
3087
|
+
this.undoSeq.set(clientId, n);
|
|
3088
|
+
return UNDO_CLIENT_SEQ_BASE + n;
|
|
3089
|
+
}
|
|
3090
|
+
/**
|
|
3091
|
+
* Selective per-user undo (plan doc 03 Phase 8): revert the given client's
|
|
3092
|
+
* most recent action by submitting its inverse with `base` set to the
|
|
3093
|
+
* ORIGINAL intent's seq — the canonical transform then rebases the inverse
|
|
3094
|
+
* against everything sequenced since, which is what makes undo correct under
|
|
3095
|
+
* concurrency. Returns the resulting log entry, or null when there is
|
|
3096
|
+
* nothing to undo OR the last action has no inverse yet (use `takeUndo` when
|
|
3097
|
+
* the difference matters to the caller). The inverse is applied through the
|
|
3098
|
+
* normal pipeline, so it converges and broadcasts like any intent.
|
|
3099
|
+
*
|
|
3100
|
+
* PLAINTEXT path only — see takeUndo for why an encrypted client cannot use
|
|
3101
|
+
* this method.
|
|
3102
|
+
*/
|
|
3103
|
+
undo(clientId) {
|
|
3104
|
+
const candidate = this.takeUndo(clientId);
|
|
3105
|
+
if (candidate.kind !== "undoable") return null;
|
|
3106
|
+
const undoIntent = {
|
|
3107
|
+
...candidate.inverse,
|
|
3108
|
+
clientId,
|
|
3109
|
+
clientSeq: this.nextUndoClientSeq(clientId),
|
|
3110
|
+
base: candidate.seq
|
|
3111
|
+
// rebase the inverse through everything applied since.
|
|
3112
|
+
};
|
|
3113
|
+
return this.submit(undoIntent);
|
|
3114
|
+
}
|
|
3115
|
+
/** How many of this client's actions are on its undo stack — including ones
|
|
3116
|
+
* whose inverse doesn't exist yet, because they are still actions the user
|
|
3117
|
+
* took. Ask `takeUndo` (or peek via `undoState`) for reversibility. */
|
|
3118
|
+
undoDepth(clientId) {
|
|
3119
|
+
return this.undoStacks.get(clientId)?.length ?? 0;
|
|
3120
|
+
}
|
|
3121
|
+
/**
|
|
3122
|
+
* Do the nodes an inverse addresses still exist in CANONICAL state?
|
|
3123
|
+
*
|
|
3124
|
+
* A cheap NECESSARY condition, deliberately not a full validation: every
|
|
3125
|
+
* inverse this session produces is id-addressed, so a retired id means the
|
|
3126
|
+
* target is definitively gone (someone else's delete already sequenced) and
|
|
3127
|
+
* the undo cannot possibly land. An encrypted client checks this against
|
|
3128
|
+
* its mirror before painting optimistically — declining up front is both
|
|
3129
|
+
* honest and flash-free, where submitting would paint the undo and then
|
|
3130
|
+
* visibly revert it on the rejection.
|
|
3131
|
+
*
|
|
3132
|
+
* It does NOT prove the inverse WILL apply: a target that still exists may
|
|
3133
|
+
* have changed in ways only the real apply can reject (a text range that no
|
|
3134
|
+
* longer spans what it did). Those fall through to the ordinary optimistic
|
|
3135
|
+
* rollback every other op already has. Sound about "no", silent about "yes"
|
|
3136
|
+
* — which is the right asymmetry for a pre-flight check, since a false
|
|
3137
|
+
* "can't" would block a legitimate undo while a false "can" only costs the
|
|
3138
|
+
* flash we already tolerate.
|
|
3139
|
+
*/
|
|
3140
|
+
targetsResolve(body) {
|
|
3141
|
+
const ids = [];
|
|
3142
|
+
const b = body;
|
|
3143
|
+
for (const key of ["blockId", "runId", "cellParagraphId"]) {
|
|
3144
|
+
if (b[key] !== void 0) ids.push(b[key]);
|
|
3145
|
+
}
|
|
3146
|
+
const at = b.at;
|
|
3147
|
+
if (at) {
|
|
3148
|
+
if (at.blockId !== void 0) ids.push(at.blockId);
|
|
3149
|
+
if (at.runId !== void 0) ids.push(at.runId);
|
|
3150
|
+
}
|
|
3151
|
+
return ids.every((id) => typeof id === "number" && this.ids.elOf(id) !== void 0);
|
|
3152
|
+
}
|
|
3153
|
+
/** What the next undo WOULD do, without consuming anything — for enabling
|
|
3154
|
+
* a toolbar button and choosing its tooltip. */
|
|
3155
|
+
undoState(clientId) {
|
|
3156
|
+
const stack = this.undoStacks.get(clientId);
|
|
3157
|
+
if (!stack || stack.length === 0) return "nothing-to-undo";
|
|
3158
|
+
return stack[stack.length - 1].inverse ? "undoable" : "cannot-undo";
|
|
3159
|
+
}
|
|
3160
|
+
/**
|
|
3161
|
+
* E2EE mode (doc 13 §2): an envelope that fails to open (garbage from a
|
|
3162
|
+
* malicious participant, or any tamper) still consumed a sequence number
|
|
3163
|
+
* — every honest client must agree that seq is a no-op, deterministically,
|
|
3164
|
+
* because the applied/rejected verdict feeds the transform's `ahead` set.
|
|
3165
|
+
* GCM authentication IS deterministic (same bytes fail for everyone), so
|
|
3166
|
+
* ingesting the failure as a sequenced rejection keeps all mirrors
|
|
3167
|
+
* byte-identical. Client-side use only; the blind server never calls this.
|
|
3168
|
+
*/
|
|
3169
|
+
ingestOpaqueFailure(clientId, clientSeq) {
|
|
3170
|
+
this.seen.add(`${clientId}:${clientSeq}`);
|
|
3171
|
+
return this.reject({ clientId, clientSeq }, "undecryptable");
|
|
3172
|
+
}
|
|
3173
|
+
reject(intent, reason) {
|
|
3174
|
+
const entry = {
|
|
3175
|
+
seq: this.seq + 1,
|
|
3176
|
+
kind: "rejected",
|
|
3177
|
+
clientId: intent.clientId,
|
|
3178
|
+
clientSeq: intent.clientSeq,
|
|
3179
|
+
reason
|
|
3180
|
+
};
|
|
3181
|
+
this.log.push(entry);
|
|
3182
|
+
return entry;
|
|
3183
|
+
}
|
|
3184
|
+
};
|
|
3185
|
+
|
|
3186
|
+
// ../collab/src/hash.ts
|
|
3187
|
+
async function docHash(doc) {
|
|
3188
|
+
const parts = doc.editableRoots().map((r) => serializeXml(r));
|
|
3189
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("\0")));
|
|
3190
|
+
let hex = "";
|
|
3191
|
+
for (const b of new Uint8Array(digest)) hex += b.toString(16).padStart(2, "0");
|
|
3192
|
+
return hex;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
// ../collab/src/enc-connection.ts
|
|
3196
|
+
var _EncryptedCollabConnection = class _EncryptedCollabConnection {
|
|
3197
|
+
constructor(transport, clientId, docKey, cb = {}, stretchedCode, selfCheckDelayMs = 400, mediaOpts) {
|
|
3198
|
+
this.transport = transport;
|
|
3199
|
+
this.clientId = clientId;
|
|
3200
|
+
this.docKey = docKey;
|
|
3201
|
+
this.cb = cb;
|
|
3202
|
+
this.stretchedCode = stretchedCode;
|
|
3203
|
+
this.selfCheckDelayMs = selfCheckDelayMs;
|
|
3204
|
+
this.mirror = null;
|
|
3205
|
+
this.replica = null;
|
|
3206
|
+
this.keys = null;
|
|
3207
|
+
this.clientSeq = 0;
|
|
3208
|
+
this.docId = "";
|
|
3209
|
+
this.genesisId = null;
|
|
3210
|
+
/** Relay per-blob upload limit from the welcome, or `null` when the server
|
|
3211
|
+
* published none (an older host) — in which case callers SKIP the
|
|
3212
|
+
* pre-check rather than inventing a limit. See the plaintext connection's
|
|
3213
|
+
* field of the same name. */
|
|
3214
|
+
this.mediaMaxBlobBytes = null;
|
|
3215
|
+
this.docEpoch = 0;
|
|
3216
|
+
/** Envelope processing is async (WebCrypto) but MUST be strictly ordered
|
|
3217
|
+
* (seq order is the convergence contract) — a serial promise chain. */
|
|
3218
|
+
this.queue = Promise.resolve();
|
|
3219
|
+
this.idCounter = 0;
|
|
3220
|
+
this.idBase = -1;
|
|
3221
|
+
/** Own doc hashes at gossip points (seq → hash), a small ring — peers'
|
|
3222
|
+
* gossip for a seq we also hashed is comparable; anything else is skipped
|
|
3223
|
+
* (clients hash at the SAME seq cadence, so overlap is the common case). */
|
|
3224
|
+
this.ownHashes = /* @__PURE__ */ new Map();
|
|
3225
|
+
/** Server-designated checkpointer flag (doc 13 §3) + cadence. */
|
|
3226
|
+
this.isCheckpointer = false;
|
|
3227
|
+
/** The last intent this connection sealed+sent (one-in-flight makes this
|
|
3228
|
+
* the only candidate a `stale-base` refusal can refer to). */
|
|
3229
|
+
this.lastSent = null;
|
|
3230
|
+
/** SELF-HEAL (the B6a typist-drift catch): at quiescence the optimistic
|
|
3231
|
+
* replica must equal the mirror's canonical doc — the mirror is LOCAL
|
|
3232
|
+
* ground truth (the same authority code the plaintext server runs, fed
|
|
3233
|
+
* the identical ordered inputs), so drift in the optimistic
|
|
3234
|
+
* rollback-replay machinery is detectable and repairable without a
|
|
3235
|
+
* network round-trip. Debounced past the drain; staleness-guarded. */
|
|
3236
|
+
this.selfCheckTimer = null;
|
|
3237
|
+
this.lastSelfCheckSeq = -1;
|
|
3238
|
+
/** Number of times the replica was rebuilt from the mirror (telemetry). */
|
|
3239
|
+
this.selfHeals = 0;
|
|
3240
|
+
/** STUCK-PENDING RECOVERY (swarm finding): under load a submit can be
|
|
3241
|
+
* lost between pending-tracking and delivery (or its echo lost), leaving
|
|
3242
|
+
* the client permanently un-quiescent — which also blocks the drift
|
|
3243
|
+
* self-check. The front pending is watched; if it makes no progress, the
|
|
3244
|
+
* RECONCILIATION-TRANSFORMED copy is re-sealed at the current base and
|
|
3245
|
+
* resent (server dedup by (clientId, clientSeq) makes a resend of an
|
|
3246
|
+
* already-sequenced op a no-op). After MAX retries the pending is dropped
|
|
3247
|
+
* and the replica heals from the mirror so the room converges. */
|
|
3248
|
+
this.stuckTimer = null;
|
|
3249
|
+
this.stuckKey = null;
|
|
3250
|
+
this.stuckRetries = 0;
|
|
3251
|
+
/** Rate-limit recovery (see the "refused" handler): one scheduled
|
|
3252
|
+
* full-queue re-drive at a time, exponential backoff 300ms→5s, reset
|
|
3253
|
+
* when the queue drains. */
|
|
3254
|
+
this.redriveTimer = null;
|
|
3255
|
+
this.redriveBackoffMs = 0;
|
|
3256
|
+
/** Out-of-band media transfer (doc 16 §5), sealed under K_media. */
|
|
3257
|
+
this.media = null;
|
|
3258
|
+
/** Accumulates doc changes across replica rebuilds (self-heal/welcome), so
|
|
3259
|
+
* the getter below stays monotonic per connection. */
|
|
3260
|
+
this.docVersionBase = 0;
|
|
3261
|
+
/** docVersionBase at the last takeRenderScope — a base bump between takes
|
|
3262
|
+
* (welcome/heal doc replacement, media install) has no narrow scope. */
|
|
3263
|
+
this.takenDocVersionBase = 0;
|
|
3264
|
+
/** Roster + activity parity with the plaintext connection (doc 14). */
|
|
3265
|
+
this.roster = [];
|
|
3266
|
+
this.activity = [];
|
|
3267
|
+
/** Bundle being resumed from (consumed after the welcome replay). */
|
|
3268
|
+
this.resuming = null;
|
|
3269
|
+
/**
|
|
3270
|
+
* Throws on the ingest/submit chain — in practice a seal or transport send
|
|
3271
|
+
* that failed AFTER the edit was accepted locally and counted as pending.
|
|
3272
|
+
*
|
|
3273
|
+
* Separate from droppedPreReady on purpose, and the arithmetic is why: that
|
|
3274
|
+
* one counts edits refused BEFORE they were ever applied, this one counts
|
|
3275
|
+
* edits lost AFTER. Summing them against a swarm run's "lost" figure is how
|
|
3276
|
+
* B13's 231 would have been attributed in minutes instead of a night, and
|
|
3277
|
+
* merging the two would have made that sum ambiguous again.
|
|
3278
|
+
*/
|
|
3279
|
+
this.sendFailures = 0;
|
|
3280
|
+
/** Submits dropped because the connection was not ready — see
|
|
3281
|
+
* ConnectionCallbacks.onSubmitDropped. The window is WIDER here than in the
|
|
3282
|
+
* plaintext connection: this one must also derive epoch keys and open the
|
|
3283
|
+
* seeded checkpoint before it can honour anything, which on a large
|
|
3284
|
+
* document is real time during which the editor's submit path is reachable. */
|
|
3285
|
+
this.droppedPreReady = 0;
|
|
3286
|
+
/**
|
|
3287
|
+
* OUTBOUND presence: a COALESCING single-flight sealer, not a queue.
|
|
3288
|
+
*
|
|
3289
|
+
* Sealing is async, so the send can no longer be a straight-line call, and
|
|
3290
|
+
* three shapes were considered. Fire-and-forget lets two rapid caret moves
|
|
3291
|
+
* finish encrypting out of order and paint a stale cursor. Reusing
|
|
3292
|
+
* `this.queue` would put a high-frequency, low-value stream behind — and
|
|
3293
|
+
* ahead of — broadcast ingest, which is the ordering the document's
|
|
3294
|
+
* correctness depends on; perf B10 and B13 were both about work piling up
|
|
3295
|
+
* on that chain, and this is that lesson applied before the fact rather
|
|
3296
|
+
* than after. Chaining every move onto a private promise fixes ordering but
|
|
3297
|
+
* still queues N seals for N twitches of the mouse.
|
|
3298
|
+
*
|
|
3299
|
+
* So: at most ONE seal in flight, and at most ONE position waiting. A move
|
|
3300
|
+
* that arrives while a seal is running REPLACES the waiting one — which is
|
|
3301
|
+
* not a compromise but the correct semantics, because presence is
|
|
3302
|
+
* last-write-wins and a superseded caret has no reader. Bounded by
|
|
3303
|
+
* construction: two slots, whatever the input rate.
|
|
3304
|
+
*/
|
|
3305
|
+
/** Serial chain for INBOUND presence opens (see the receive branch). */
|
|
3306
|
+
this.presenceInQueue = Promise.resolve();
|
|
3307
|
+
this.presenceLatest = null;
|
|
3308
|
+
this.presenceFlushing = false;
|
|
3309
|
+
this.transport.onMessage((msg) => this.onServer(msg));
|
|
3310
|
+
if (mediaOpts) {
|
|
3311
|
+
this.media = new MediaClient(
|
|
3312
|
+
mediaOpts,
|
|
3313
|
+
() => this.replica?.doc ?? null,
|
|
3314
|
+
{
|
|
3315
|
+
// Media installs mutate the doc (pixels land) outside the replica's
|
|
3316
|
+
// counter — bump the render signal so the skeleton repaints.
|
|
3317
|
+
onChange: () => {
|
|
3318
|
+
this.docVersionBase++;
|
|
3319
|
+
this.cb.onChange?.();
|
|
3320
|
+
},
|
|
3321
|
+
onState: (part, state) => this.cb.onMediaState?.(part, state),
|
|
3322
|
+
need: (sha) => this.transport.send({ t: "media-need", sha }),
|
|
3323
|
+
have: (shas) => {
|
|
3324
|
+
if (shas.length) this.transport.send({ t: "media-have", shas });
|
|
3325
|
+
}
|
|
3326
|
+
},
|
|
3327
|
+
{
|
|
3328
|
+
seal: async (plaintext, iv) => {
|
|
3329
|
+
if (!this.keys) throw new Error("media seal before keys");
|
|
3330
|
+
return sealMediaBlob(this.keys.kMedia, plaintext, iv);
|
|
3331
|
+
},
|
|
3332
|
+
open: async (blob, iv) => {
|
|
3333
|
+
if (!this.keys) throw new Error("media open before keys");
|
|
3334
|
+
return openMediaBlob(this.keys.kMedia, blob, iv);
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
/** Upload bytes and get the intent's media fields (blobSha over the
|
|
3341
|
+
* CIPHERTEXT, plus the IV the re-supply path must reuse), or null when the
|
|
3342
|
+
* relay refused — the caller must not emit an insertImage in that case. */
|
|
3343
|
+
async uploadMedia(plaintext) {
|
|
3344
|
+
return this.media ? this.media.upload(this.docId, plaintext) : null;
|
|
3345
|
+
}
|
|
3346
|
+
join(docId, token, opts) {
|
|
3347
|
+
this.docId = docId;
|
|
3348
|
+
this.transport.send({
|
|
3349
|
+
t: "hello",
|
|
3350
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
3351
|
+
docId,
|
|
3352
|
+
clientId: this.clientId,
|
|
3353
|
+
takeover: opts?.takeover,
|
|
3354
|
+
token,
|
|
3355
|
+
sinceSeq: 0,
|
|
3356
|
+
profile: opts?.profile,
|
|
3357
|
+
codeProof: opts?.codeProof,
|
|
3358
|
+
ownerToken: opts?.ownerToken,
|
|
3359
|
+
// owner-capability proof (doc 14 §2.5)
|
|
3360
|
+
engineVersion: ENGINE_VERSION
|
|
3361
|
+
// the fence for client-derived canon (doc 13 §2)
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
3364
|
+
get doc() {
|
|
3365
|
+
return this.replica?.doc ?? null;
|
|
3366
|
+
}
|
|
3367
|
+
get ready() {
|
|
3368
|
+
return this.replica !== null;
|
|
3369
|
+
}
|
|
3370
|
+
/**
|
|
3371
|
+
* Counts changes TO THE RENDERED DOCUMENT — the repaint signal, as opposed
|
|
3372
|
+
* to onChange (which also fires for pure bookkeeping: a tracked own echo, a
|
|
3373
|
+
* roster-adjacent state change). The editor-driven typing path mutates and
|
|
3374
|
+
* paints the doc itself, so its submit + echo leave this untouched; remote
|
|
3375
|
+
* applies, optimistic canonical applies (toolbar ops), reloads, heals, and
|
|
3376
|
+
* media installs advance it. The react layer repaints on THIS, not on
|
|
3377
|
+
* onChange — repainting on onChange queued a whole-document relayout per
|
|
3378
|
+
* keystroke, catastrophic past the background-layout page threshold.
|
|
3379
|
+
*/
|
|
3380
|
+
get docVersion() {
|
|
3381
|
+
return this.docVersionBase + (this.replica?.docVersion ?? 0);
|
|
3382
|
+
}
|
|
3383
|
+
/**
|
|
3384
|
+
* Drain the dirty scope behind the docVersion movement since the last take
|
|
3385
|
+
* — parity with the plaintext connection (see connection.ts). Replica
|
|
3386
|
+
* applies carry their per-intent scope; base bumps report document scope;
|
|
3387
|
+
* null means nothing was recorded (the caller may skip the repaint).
|
|
3388
|
+
*/
|
|
3389
|
+
takeRenderScope() {
|
|
3390
|
+
const replicaScope = this.replica?.takeRenderScope() ?? null;
|
|
3391
|
+
if (this.docVersionBase !== this.takenDocVersionBase) {
|
|
3392
|
+
this.takenDocVersionBase = this.docVersionBase;
|
|
3393
|
+
return { kind: "doc" };
|
|
3394
|
+
}
|
|
3395
|
+
return replicaScope;
|
|
3396
|
+
}
|
|
3397
|
+
/** Un-confirmed local intents in flight — drained-replay parity with the
|
|
3398
|
+
* plaintext connection (doc 15 §2 / rebase.ts). */
|
|
3399
|
+
get pendingCount() {
|
|
3400
|
+
return this.replica?.pendingCount ?? 0;
|
|
3401
|
+
}
|
|
3402
|
+
setProfile(profile) {
|
|
3403
|
+
this.transport.send({ t: "profile", profile });
|
|
3404
|
+
}
|
|
3405
|
+
/**
|
|
3406
|
+
* Rejoin from a persisted bundle (doc 12 §5, encrypted flavor). Identical
|
|
3407
|
+
* contract to the plaintext connection: clientSeq watermark restored
|
|
3408
|
+
* FIRST; pending replayed fire-and-observe on same-epoch welcomes (the
|
|
3409
|
+
* sequencer's plaintext-bookkeeping dedup gives exactly-once, and the
|
|
3410
|
+
* whole-epoch welcome-enc replay already reconstructed everything that
|
|
3411
|
+
* was sequenced pre-crash); different epoch ⇒ onEpochChange, pending
|
|
3412
|
+
* withheld (fork rule). The bundle's confirmed bytes are NOT loaded here
|
|
3413
|
+
* — encrypted joiners always rebuild from the sealed checkpoint + tail,
|
|
3414
|
+
* which is both simpler and verifiable (hash gossip).
|
|
3415
|
+
*/
|
|
3416
|
+
resume(bundle, token, opts) {
|
|
3417
|
+
this.clientSeq = Math.max(this.clientSeq, bundle.clientSeq);
|
|
3418
|
+
this.resuming = bundle;
|
|
3419
|
+
this.join(bundle.docId, token, { ...opts, takeover: true });
|
|
3420
|
+
}
|
|
3421
|
+
/** Owner admin op (doc 14 §2.5): honored only if this connection proved
|
|
3422
|
+
* the owner token at hello — otherwise the server refuses `not-owner`.
|
|
3423
|
+
* The admin channel is PLAINTEXT bookkeeping by design (roles are
|
|
3424
|
+
* integrity, not confidentiality — doc 14 §2.5 honest limits), so the
|
|
3425
|
+
* encrypted connection sends the same frame the plaintext one does.
|
|
3426
|
+
* HISTORY: this method was MISSING here while present on the plaintext
|
|
3427
|
+
* connection — the react layer's `as unknown as CollabConnection` cast
|
|
3428
|
+
* hid it from the typechecker, so the demo's owner controls were silent
|
|
3429
|
+
* TypeErrors on encrypted docs (the default), long misattributed to a
|
|
3430
|
+
* Vite prebundle race ("stale session w/o admin"). */
|
|
3431
|
+
admin(action) {
|
|
3432
|
+
this.transport.send({ t: "admin", action });
|
|
3433
|
+
}
|
|
3434
|
+
/** Durable state as a doc-12 bundle (kDoc rides in it for revival). */
|
|
3435
|
+
exportBundle(docId) {
|
|
3436
|
+
if (!this.replica || this.genesisId === null) return null;
|
|
3437
|
+
return {
|
|
3438
|
+
docId,
|
|
3439
|
+
genesisId: this.genesisId,
|
|
3440
|
+
...this.replica.exportBundleState(),
|
|
3441
|
+
clientSeq: this.clientSeq,
|
|
3442
|
+
savedAt: 0,
|
|
3443
|
+
lineage: []
|
|
3444
|
+
// the persister maintains the chain across writes
|
|
3445
|
+
};
|
|
3446
|
+
}
|
|
3447
|
+
async exportBundleAsync(docId) {
|
|
3448
|
+
if (!this.replica || this.genesisId === null) return null;
|
|
3449
|
+
return {
|
|
3450
|
+
docId,
|
|
3451
|
+
genesisId: this.genesisId,
|
|
3452
|
+
...await this.replica.exportBundleStateAsync(),
|
|
3453
|
+
clientSeq: this.clientSeq,
|
|
3454
|
+
savedAt: 0,
|
|
3455
|
+
lineage: []
|
|
3456
|
+
};
|
|
3457
|
+
}
|
|
3458
|
+
/** Same disjoint carried-id allocation as the plaintext connection. */
|
|
3459
|
+
allocIds(n) {
|
|
3460
|
+
if (this.idBase < 0) {
|
|
3461
|
+
let h = 0;
|
|
3462
|
+
for (let i = 0; i < this.clientId.length; i++) h = h * 31 + this.clientId.charCodeAt(i) >>> 0;
|
|
3463
|
+
this.idBase = 1e9 + h % 1e5 * 1e7;
|
|
3464
|
+
}
|
|
3465
|
+
const out = [];
|
|
3466
|
+
for (let i = 0; i < n; i++) out.push(this.idBase + this.idCounter++);
|
|
3467
|
+
return out;
|
|
3468
|
+
}
|
|
3469
|
+
/** Submit a local edit (optimistic canonical apply, like the plaintext
|
|
3470
|
+
* `submit`); sealing happens on the ordered queue. */
|
|
3471
|
+
submit(intent) {
|
|
3472
|
+
this.submitFull(intent, false);
|
|
3473
|
+
}
|
|
3474
|
+
/** Editor-driven path: the mutation is already in the live doc. */
|
|
3475
|
+
submitPreApplied(intent) {
|
|
3476
|
+
this.submitFull(intent, true);
|
|
3477
|
+
}
|
|
3478
|
+
/**
|
|
3479
|
+
* COLLABORATIVE UNDO (plan doc 03 Phase 8), encrypted flavour.
|
|
3480
|
+
*
|
|
3481
|
+
* The mirror is a real DocumentSession fed the canonical log, so it already
|
|
3482
|
+
* knows the inverse of this client's last action — no new wire message and
|
|
3483
|
+
* no server involvement are needed. The inverse is submitted as an ORDINARY
|
|
3484
|
+
* sealed intent with `base` set to the ORIGINAL action's seq, which is what
|
|
3485
|
+
* makes undo correct under concurrency: the canonical transform rebases it
|
|
3486
|
+
* through everything sequenced since, and if the target has been deleted or
|
|
3487
|
+
* changed the apply rejects it identically on every replica.
|
|
3488
|
+
*
|
|
3489
|
+
* Two things this deliberately does NOT do. It does not call the mirror's
|
|
3490
|
+
* `undo()`, because that would apply to the mirror out of band and the
|
|
3491
|
+
* mirror may only advance by ingesting sequenced envelopes. And it does not
|
|
3492
|
+
* paint an undo it can already prove is dead: `targetsResolve` checks the
|
|
3493
|
+
* canonical id table first, so the common conflict (someone else removed
|
|
3494
|
+
* the thing) is declined up front rather than painted and yanked back.
|
|
3495
|
+
*/
|
|
3496
|
+
undoLast() {
|
|
3497
|
+
if (!this.mirror || !this.replica || !this.keys || !this.genesisId) return "unavailable";
|
|
3498
|
+
const candidate = this.mirror.takeUndo(this.clientId);
|
|
3499
|
+
if (candidate.kind !== "undoable") return candidate.kind;
|
|
3500
|
+
if (!this.mirror.targetsResolve(candidate.inverse)) return "changed-since";
|
|
3501
|
+
const ahead = this.mirror.entriesSince(candidate.seq).filter((e) => e.kind === "applied" && e.intent.clientId !== this.clientId).map((e) => e.intent);
|
|
3502
|
+
const rebased = transformIntent(
|
|
3503
|
+
{
|
|
3504
|
+
...candidate.inverse,
|
|
3505
|
+
clientId: this.clientId,
|
|
3506
|
+
// Reserved range: keeps the undo itself off every replica's undo stack.
|
|
3507
|
+
clientSeq: this.mirror.nextUndoClientSeq(this.clientId),
|
|
3508
|
+
base: candidate.seq
|
|
3509
|
+
},
|
|
3510
|
+
ahead
|
|
3511
|
+
);
|
|
3512
|
+
const full = { ...rebased, base: this.replica.confirmedSeq };
|
|
3513
|
+
this.replica.submitLocal(full);
|
|
3514
|
+
this.enqueue(async () => {
|
|
3515
|
+
try {
|
|
3516
|
+
this.lastSent = full;
|
|
3517
|
+
const env = await sealIntent(this.keys.kContent, this.docId, this.genesisId, full);
|
|
3518
|
+
this.transport.send({ t: "submit-enc", envelope: env });
|
|
3519
|
+
} catch (err) {
|
|
3520
|
+
this.sendFailures++;
|
|
3521
|
+
this.cb.onError?.({ where: "enc.submit", error: err });
|
|
3522
|
+
}
|
|
3523
|
+
});
|
|
3524
|
+
this.scheduleSelfCheck();
|
|
3525
|
+
this.cb.onChange?.();
|
|
3526
|
+
return "undone";
|
|
3527
|
+
}
|
|
3528
|
+
/** What the next undo WOULD do, for enabling a button and its tooltip. */
|
|
3529
|
+
undoState() {
|
|
3530
|
+
if (!this.mirror) return "unavailable";
|
|
3531
|
+
const state = this.mirror.undoState(this.clientId);
|
|
3532
|
+
return state === "undoable" ? "undone" : state;
|
|
3533
|
+
}
|
|
3534
|
+
submitFull(intent, preApplied) {
|
|
3535
|
+
if (!this.replica || !this.keys || !this.genesisId) {
|
|
3536
|
+
this.droppedPreReady++;
|
|
3537
|
+
this.cb.onSubmitDropped?.("not-ready");
|
|
3538
|
+
return;
|
|
3539
|
+
}
|
|
3540
|
+
const full = {
|
|
3541
|
+
...intent,
|
|
3542
|
+
clientId: this.clientId,
|
|
3543
|
+
clientSeq: ++this.clientSeq,
|
|
3544
|
+
base: this.replica.confirmedSeq
|
|
3545
|
+
};
|
|
3546
|
+
if (preApplied) this.replica.trackLocal(full);
|
|
3547
|
+
else this.replica.submitLocal(full);
|
|
3548
|
+
this.enqueue(async () => {
|
|
3549
|
+
try {
|
|
3550
|
+
this.lastSent = full;
|
|
3551
|
+
const env = await sealIntent(this.keys.kContent, this.docId, this.genesisId, full);
|
|
3552
|
+
this.transport.send({ t: "submit-enc", envelope: env });
|
|
3553
|
+
} catch (err) {
|
|
3554
|
+
this.sendFailures++;
|
|
3555
|
+
this.cb.onError?.({ where: "enc.submit", error: err });
|
|
3556
|
+
}
|
|
3557
|
+
});
|
|
3558
|
+
this.scheduleSelfCheck();
|
|
3559
|
+
this.cb.onChange?.();
|
|
3560
|
+
}
|
|
3561
|
+
setPresence(position) {
|
|
3562
|
+
const clean = sanitizePresencePosition(position);
|
|
3563
|
+
const keys = this.keys;
|
|
3564
|
+
const genesisId = this.genesisId;
|
|
3565
|
+
if (!keys || !genesisId) {
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
this.presenceLatest = { position: clean };
|
|
3569
|
+
if (this.presenceFlushing) return;
|
|
3570
|
+
this.presenceFlushing = true;
|
|
3571
|
+
void (async () => {
|
|
3572
|
+
try {
|
|
3573
|
+
while (this.presenceLatest) {
|
|
3574
|
+
const next = this.presenceLatest;
|
|
3575
|
+
this.presenceLatest = null;
|
|
3576
|
+
const sealed = await sealPresence(keys.kPresence, this.docId, genesisId, this.clientId, next.position);
|
|
3577
|
+
this.transport.send({ t: "presence", position: sealed });
|
|
3578
|
+
}
|
|
3579
|
+
} catch {
|
|
3580
|
+
} finally {
|
|
3581
|
+
this.presenceFlushing = false;
|
|
3582
|
+
}
|
|
3583
|
+
})();
|
|
3584
|
+
}
|
|
3585
|
+
enqueue(task) {
|
|
3586
|
+
this.queue = this.queue.then(task).catch((err) => {
|
|
3587
|
+
this.sendFailures++;
|
|
3588
|
+
this.cb.onError?.({ where: "enc.queue", error: err });
|
|
3589
|
+
});
|
|
3590
|
+
}
|
|
3591
|
+
onServer(msg) {
|
|
3592
|
+
switch (msg.t) {
|
|
3593
|
+
case "welcome-enc": {
|
|
3594
|
+
this.mediaMaxBlobBytes = msg.mediaMaxBlobBytes ?? null;
|
|
3595
|
+
this.enqueue(async () => {
|
|
3596
|
+
const perfOn = !!globalThis.__dxwPerf;
|
|
3597
|
+
const t = {};
|
|
3598
|
+
let t0 = performance.now();
|
|
3599
|
+
this.genesisId = msg.genesisId;
|
|
3600
|
+
this.keys = await deriveEpochKeys(this.docKey, msg.genesisId, this.stretchedCode);
|
|
3601
|
+
t.deriveMs = performance.now() - t0;
|
|
3602
|
+
t0 = performance.now();
|
|
3603
|
+
const cp = await openCheckpoint(this.keys.kContent, msg.docId, msg.genesisId, msg.checkpoint.seq, msg.checkpoint);
|
|
3604
|
+
const bytes = b64ToBytes(cp.docx);
|
|
3605
|
+
t.openMs = performance.now() - t0;
|
|
3606
|
+
t0 = performance.now();
|
|
3607
|
+
this.mirror = new DocumentSession(DocxDocument.load(bytes));
|
|
3608
|
+
this.mirror.setSeqFloor(msg.checkpoint.seq);
|
|
3609
|
+
if (cp.sidecar) this.mirror.installSidecar(cp.sidecar);
|
|
3610
|
+
t.mirrorParseMs = performance.now() - t0;
|
|
3611
|
+
t0 = performance.now();
|
|
3612
|
+
this.docVersionBase += (this.replica?.docVersion ?? 0) + 1;
|
|
3613
|
+
this.replica = new ClientReplica(bytes, cp.sidecar ?? void 0);
|
|
3614
|
+
this.replica.confirmedSeq = msg.checkpoint.seq;
|
|
3615
|
+
t.replicaParseMs = performance.now() - t0;
|
|
3616
|
+
t0 = performance.now();
|
|
3617
|
+
applyMediaAddresses(this.replica.doc, cp.mediaMeta);
|
|
3618
|
+
applyMediaAddresses(this.mirror.doc, cp.mediaMeta);
|
|
3619
|
+
for (const env of msg.tail) await this.ingest(env);
|
|
3620
|
+
t.tailMs = performance.now() - t0;
|
|
3621
|
+
if (perfOn) {
|
|
3622
|
+
console.log(
|
|
3623
|
+
`STRESS-METRIC join-welcome docxBytes=${bytes.byteLength} tail=${msg.tail.length} ` + Object.entries(t).map(([k, v]) => `${k}=${v.toFixed(2)}`).join(" ")
|
|
3624
|
+
);
|
|
3625
|
+
}
|
|
3626
|
+
const resumed = this.resuming;
|
|
3627
|
+
this.resuming = null;
|
|
3628
|
+
if (resumed) {
|
|
3629
|
+
if (resumed.genesisId === msg.genesisId) {
|
|
3630
|
+
for (const intent of resumed.pending) {
|
|
3631
|
+
const env2 = await sealIntent(this.keys.kContent, this.docId, msg.genesisId, intent);
|
|
3632
|
+
this.transport.send({ t: "submit-enc", envelope: env2 });
|
|
3633
|
+
}
|
|
3634
|
+
} else {
|
|
3635
|
+
this.cb.onEpochChange?.(resumed.genesisId, msg.genesisId);
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
this.cb.onChange?.();
|
|
3639
|
+
if (resumed?.mediaMeta) {
|
|
3640
|
+
const held = DocxDocument.load(resumed.confirmedBytes);
|
|
3641
|
+
for (const [part, meta] of resumed.mediaMeta) {
|
|
3642
|
+
this.replica.doc.mediaMeta.set(part, meta);
|
|
3643
|
+
const pixels = held.media(part);
|
|
3644
|
+
if (pixels && this.replica.doc.mediaStatus(part) === "pending") {
|
|
3645
|
+
this.replica.doc.installMedia(part, pixels);
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
}
|
|
3649
|
+
this.media?.volunteer(msg.mediaNeeded);
|
|
3650
|
+
void this.media?.fetchPending(this.docId);
|
|
3651
|
+
});
|
|
3652
|
+
return;
|
|
3653
|
+
}
|
|
3654
|
+
case "broadcast-enc": {
|
|
3655
|
+
this.enqueue(async () => {
|
|
3656
|
+
if (!this.replica) return;
|
|
3657
|
+
for (const env of msg.entries) await this.ingest(env);
|
|
3658
|
+
if (this.replica.reloaded) this.docEpoch++;
|
|
3659
|
+
this.cb.onChange?.();
|
|
3660
|
+
this.scheduleSelfCheck();
|
|
3661
|
+
void this.media?.fetchPending(this.docId);
|
|
3662
|
+
});
|
|
3663
|
+
return;
|
|
3664
|
+
}
|
|
3665
|
+
case "media-request": {
|
|
3666
|
+
this.media?.answerRequest(msg.sha);
|
|
3667
|
+
this.cb.onMediaRequest?.(msg.sha);
|
|
3668
|
+
return;
|
|
3669
|
+
}
|
|
3670
|
+
case "media-upload": {
|
|
3671
|
+
void this.media?.resupply(this.docId, msg.sha);
|
|
3672
|
+
this.cb.onMediaRequest?.(msg.sha);
|
|
3673
|
+
return;
|
|
3674
|
+
}
|
|
3675
|
+
case "media-ready": {
|
|
3676
|
+
void this.media?.onReady(this.docId, msg.sha);
|
|
3677
|
+
this.cb.onMediaReady?.(msg.sha);
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
case "media-unavailable": {
|
|
3681
|
+
this.media?.onUnavailable(msg.sha);
|
|
3682
|
+
this.cb.onMediaUnavailable?.(msg.sha);
|
|
3683
|
+
return;
|
|
3684
|
+
}
|
|
3685
|
+
case "welcome": {
|
|
3686
|
+
this.cb.onRefused?.("mode-downgrade");
|
|
3687
|
+
return;
|
|
3688
|
+
}
|
|
3689
|
+
case "presence": {
|
|
3690
|
+
const raw = msg.position;
|
|
3691
|
+
if (raw === null) {
|
|
3692
|
+
this.cb.onPresence?.(msg.participant, null);
|
|
3693
|
+
return;
|
|
3694
|
+
}
|
|
3695
|
+
if (!isSealedPresence(raw)) {
|
|
3696
|
+
return;
|
|
3697
|
+
}
|
|
3698
|
+
const keys = this.keys;
|
|
3699
|
+
const genesisId = this.genesisId;
|
|
3700
|
+
if (!keys || !genesisId) return;
|
|
3701
|
+
this.presenceInQueue = this.presenceInQueue.then(async () => {
|
|
3702
|
+
const opened = await openPresence(keys.kPresence, this.docId, genesisId, msg.participant, raw);
|
|
3703
|
+
this.cb.onPresence?.(msg.participant, sanitizePresencePosition(opened));
|
|
3704
|
+
}).catch(() => {
|
|
3705
|
+
});
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3708
|
+
case "roster": {
|
|
3709
|
+
this.roster = msg.roster;
|
|
3710
|
+
this.cb.onRoster?.(msg.roster);
|
|
3711
|
+
return;
|
|
3712
|
+
}
|
|
3713
|
+
// Session lifecycle warnings are PLAINTEXT bookkeeping and identical in
|
|
3714
|
+
// both modes: the deadline is the server's own policy, not document
|
|
3715
|
+
// content, so a blind sequencer announces it in the clear. Dispatched
|
|
3716
|
+
// directly rather than through the serial queue — nothing here touches
|
|
3717
|
+
// keys, the mirror, or the replica.
|
|
3718
|
+
case "session-warning": {
|
|
3719
|
+
this.cb.onSessionWarning?.({ reason: msg.reason, inMs: msg.inMs });
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
case "session-warning-cleared": {
|
|
3723
|
+
this.cb.onSessionWarningCleared?.({ reason: msg.reason });
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3726
|
+
case "gossip": {
|
|
3727
|
+
this.enqueue(() => this.onGossip(msg.iv, msg.ciphertext));
|
|
3728
|
+
return;
|
|
3729
|
+
}
|
|
3730
|
+
case "checkpointer": {
|
|
3731
|
+
this.isCheckpointer = msg.active;
|
|
3732
|
+
return;
|
|
3733
|
+
}
|
|
3734
|
+
case "refused": {
|
|
3735
|
+
if (msg.reason === "stale-base") {
|
|
3736
|
+
const retry = this.lastSent;
|
|
3737
|
+
if (retry && this.replica && this.keys && this.genesisId) {
|
|
3738
|
+
this.enqueue(async () => {
|
|
3739
|
+
const body = this.replica.pendingIntent(retry.clientId, retry.clientSeq);
|
|
3740
|
+
if (!body) return;
|
|
3741
|
+
const rebased = { ...body, base: this.replica.confirmedSeq };
|
|
3742
|
+
const env = await sealIntent(this.keys.kContent, this.docId, this.genesisId, rebased);
|
|
3743
|
+
this.transport.send({ t: "submit-enc", envelope: env });
|
|
3744
|
+
});
|
|
3745
|
+
}
|
|
3746
|
+
return;
|
|
3747
|
+
}
|
|
3748
|
+
if (msg.reason === "rate-limit") {
|
|
3749
|
+
this.stuckRetries = 0;
|
|
3750
|
+
this.scheduleRateLimitRedrive();
|
|
3751
|
+
return;
|
|
3752
|
+
}
|
|
3753
|
+
this.cb.onRefused?.(msg.reason);
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
/** Re-drive the whole pending queue after rate-limit backoff. One timer
|
|
3759
|
+
* at a time; backoff doubles per refusal and resets when the queue
|
|
3760
|
+
* drains (see scheduleSelfCheck). */
|
|
3761
|
+
scheduleRateLimitRedrive() {
|
|
3762
|
+
if (this.redriveTimer !== null) return;
|
|
3763
|
+
this.redriveBackoffMs = Math.min(this.redriveBackoffMs === 0 ? 300 : this.redriveBackoffMs * 2, 5e3);
|
|
3764
|
+
this.redriveTimer = setTimeout(() => {
|
|
3765
|
+
this.redriveTimer = null;
|
|
3766
|
+
this.enqueue(async () => {
|
|
3767
|
+
if (!this.replica || !this.keys || !this.genesisId) return;
|
|
3768
|
+
for (const body of this.replica.pendingCopies()) {
|
|
3769
|
+
const rebased = { ...body, base: this.replica.confirmedSeq };
|
|
3770
|
+
const env = await sealIntent(this.keys.kContent, this.docId, this.genesisId, rebased);
|
|
3771
|
+
this.transport.send({ t: "submit-enc", envelope: env });
|
|
3772
|
+
}
|
|
3773
|
+
});
|
|
3774
|
+
}, this.redriveBackoffMs);
|
|
3775
|
+
}
|
|
3776
|
+
/** Debounce a quiescent self-check: only when nothing is pending (a burst
|
|
3777
|
+
* still in flight legitimately diverges live from canonical), re-armed by
|
|
3778
|
+
* every broadcast so the check runs once the wire goes quiet. While
|
|
3779
|
+
* pending exists, arm the stuck-pending watchdog instead. */
|
|
3780
|
+
scheduleSelfCheck() {
|
|
3781
|
+
if (!this.replica) return;
|
|
3782
|
+
if (this.replica.pendingCount !== 0) {
|
|
3783
|
+
this.scheduleStuckCheck();
|
|
3784
|
+
return;
|
|
3785
|
+
}
|
|
3786
|
+
this.redriveBackoffMs = 0;
|
|
3787
|
+
if (this.redriveTimer !== null) {
|
|
3788
|
+
clearTimeout(this.redriveTimer);
|
|
3789
|
+
this.redriveTimer = null;
|
|
3790
|
+
}
|
|
3791
|
+
if (this.stuckTimer !== null) {
|
|
3792
|
+
clearTimeout(this.stuckTimer);
|
|
3793
|
+
this.stuckTimer = null;
|
|
3794
|
+
}
|
|
3795
|
+
this.stuckKey = null;
|
|
3796
|
+
this.stuckRetries = 0;
|
|
3797
|
+
if (this.selfCheckTimer !== null) clearTimeout(this.selfCheckTimer);
|
|
3798
|
+
this.selfCheckTimer = setTimeout(() => {
|
|
3799
|
+
this.selfCheckTimer = null;
|
|
3800
|
+
this.enqueue(() => this.selfCheck());
|
|
3801
|
+
}, this.selfCheckDelayMs);
|
|
3802
|
+
}
|
|
3803
|
+
/** Watch the FRONT pending op; progress (a different front, or empty)
|
|
3804
|
+
* resets the retry budget. The window is 5× the self-check debounce. */
|
|
3805
|
+
scheduleStuckCheck() {
|
|
3806
|
+
const front = this.replica?.firstPending();
|
|
3807
|
+
if (!front) return;
|
|
3808
|
+
const key = `${front.clientId}:${front.clientSeq}`;
|
|
3809
|
+
if (this.stuckKey !== key) {
|
|
3810
|
+
this.stuckKey = key;
|
|
3811
|
+
this.stuckRetries = 0;
|
|
3812
|
+
}
|
|
3813
|
+
if (this.stuckTimer !== null) clearTimeout(this.stuckTimer);
|
|
3814
|
+
this.stuckTimer = setTimeout(() => {
|
|
3815
|
+
this.stuckTimer = null;
|
|
3816
|
+
this.enqueue(() => this.stuckCheck(front.clientId, front.clientSeq));
|
|
3817
|
+
}, this.selfCheckDelayMs * 5);
|
|
3818
|
+
}
|
|
3819
|
+
/** Runs inside the serial queue: if the same front pending is still
|
|
3820
|
+
* stuck, resend its transformed copy at the current base; past the retry
|
|
3821
|
+
* budget, drop it and heal from the mirror (convergence over delivery). */
|
|
3822
|
+
async stuckCheck(clientId, clientSeq) {
|
|
3823
|
+
if (!this.replica || !this.mirror || !this.keys || !this.genesisId) return;
|
|
3824
|
+
const front = this.replica.firstPending();
|
|
3825
|
+
if (!front || front.clientId !== clientId || front.clientSeq !== clientSeq) {
|
|
3826
|
+
this.scheduleSelfCheck();
|
|
3827
|
+
return;
|
|
3828
|
+
}
|
|
3829
|
+
if (this.stuckRetries >= _EncryptedCollabConnection.STUCK_MAX_RETRIES) {
|
|
3830
|
+
const liveHash = await docHash(this.replica.doc);
|
|
3831
|
+
const canonicalHash = await docHash(this.mirror.doc);
|
|
3832
|
+
const cp = this.mirror.checkpoint();
|
|
3833
|
+
this.docVersionBase += this.replica.docVersion + 1;
|
|
3834
|
+
this.replica = new ClientReplica(cp.docx, cp.sidecar);
|
|
3835
|
+
this.replica.confirmedSeq = cp.seq;
|
|
3836
|
+
this.docEpoch++;
|
|
3837
|
+
this.selfHeals++;
|
|
3838
|
+
this.stuckKey = null;
|
|
3839
|
+
this.stuckRetries = 0;
|
|
3840
|
+
this.cb.onSelfHeal?.({ seq: cp.seq, liveHash, canonicalHash });
|
|
3841
|
+
this.cb.onChange?.();
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
this.stuckRetries++;
|
|
3845
|
+
const body = this.replica.pendingIntent(clientId, clientSeq);
|
|
3846
|
+
if (body) {
|
|
3847
|
+
const rebased = { ...body, base: this.replica.confirmedSeq };
|
|
3848
|
+
const env = await sealIntent(this.keys.kContent, this.docId, this.genesisId, rebased);
|
|
3849
|
+
this.transport.send({ t: "submit-enc", envelope: env });
|
|
3850
|
+
}
|
|
3851
|
+
this.scheduleStuckCheck();
|
|
3852
|
+
}
|
|
3853
|
+
/** Runs INSIDE the serial queue (no ingest can interleave): compare the
|
|
3854
|
+
* optimistic replica against the mirror at quiescence; on drift, rebuild
|
|
3855
|
+
* the replica from the mirror — the same shape as a reload heal, but
|
|
3856
|
+
* automatic, local, and scoped to the one client that drifted. */
|
|
3857
|
+
async selfCheck() {
|
|
3858
|
+
if (!this.replica || !this.mirror) return;
|
|
3859
|
+
if (this.replica.pendingCount !== 0) return;
|
|
3860
|
+
const seq = this.mirror.seq;
|
|
3861
|
+
if (seq === this.lastSelfCheckSeq) return;
|
|
3862
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3863
|
+
if (this.replica.pendingCount !== 0 || this.mirror.seq !== seq) return;
|
|
3864
|
+
const liveHash = await docHash(this.replica.doc);
|
|
3865
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3866
|
+
const canonicalHash = await docHash(this.mirror.doc);
|
|
3867
|
+
if (this.replica.pendingCount !== 0 || this.mirror.seq !== seq) return;
|
|
3868
|
+
this.lastSelfCheckSeq = seq;
|
|
3869
|
+
if (liveHash === canonicalHash) return;
|
|
3870
|
+
const cp = this.mirror.checkpoint();
|
|
3871
|
+
this.docVersionBase += this.replica.docVersion + 1;
|
|
3872
|
+
this.replica = new ClientReplica(cp.docx, cp.sidecar);
|
|
3873
|
+
this.replica.confirmedSeq = cp.seq;
|
|
3874
|
+
this.docEpoch++;
|
|
3875
|
+
this.selfHeals++;
|
|
3876
|
+
this.cb.onSelfHeal?.({ seq, liveHash, canonicalHash });
|
|
3877
|
+
this.cb.onChange?.();
|
|
3878
|
+
}
|
|
3879
|
+
/** Feed one sequenced envelope through mirror + replica (in seq order). */
|
|
3880
|
+
async ingest(env) {
|
|
3881
|
+
let entry;
|
|
3882
|
+
try {
|
|
3883
|
+
const intent = await openIntent(this.keys.kContent, this.docId, this.genesisId, env);
|
|
3884
|
+
entry = this.mirror.submit(intent);
|
|
3885
|
+
} catch {
|
|
3886
|
+
entry = this.mirror.ingestOpaqueFailure(env.clientId, env.clientSeq);
|
|
3887
|
+
}
|
|
3888
|
+
if (entry.seq !== env.seq) {
|
|
3889
|
+
this.cb.onRefused?.("sequence-desync");
|
|
3890
|
+
return;
|
|
3891
|
+
}
|
|
3892
|
+
this.replica.receive([entry]);
|
|
3893
|
+
if (entry.kind === "rejected" && entry.clientId === this.clientId) {
|
|
3894
|
+
this.cb.onIntentRejected?.({ reason: entry.reason, clientSeq: entry.clientSeq });
|
|
3895
|
+
}
|
|
3896
|
+
if (entry.kind === "applied") {
|
|
3897
|
+
this.activity.push({ seq: entry.seq, clientId: entry.intent.clientId, kind: entry.intent.kind });
|
|
3898
|
+
if (this.activity.length > 100) this.activity.splice(0, this.activity.length - 100);
|
|
3899
|
+
}
|
|
3900
|
+
if (this.isCheckpointer && env.seq % _EncryptedCollabConnection.CHECKPOINT_EVERY === 0) {
|
|
3901
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3902
|
+
const cp = this.mirror.checkpoint();
|
|
3903
|
+
const sealed = await sealCheckpoint(this.keys.kContent, this.docId, this.genesisId, cp.seq, {
|
|
3904
|
+
docx: bytesToB64(cp.docx),
|
|
3905
|
+
sidecar: cp.sidecar,
|
|
3906
|
+
docHash: await docHash(this.mirror.doc),
|
|
3907
|
+
// Doc 16 §6 late-join, sealed half: the MIRROR never installs pixels,
|
|
3908
|
+
// but it holds every REGISTRATION — which is all a joiner needs to go
|
|
3909
|
+
// and fetch them. Inside the sealed body, so a blind server still
|
|
3910
|
+
// learns nothing about the document's part structure.
|
|
3911
|
+
mediaMeta: mediaAddressesOf(this.mirror.doc)
|
|
3912
|
+
});
|
|
3913
|
+
this.transport.send({ t: "checkpoint", checkpoint: { seq: cp.seq, ...sealed } });
|
|
3914
|
+
}
|
|
3915
|
+
if (env.seq % _EncryptedCollabConnection.GOSSIP_EVERY === 0) {
|
|
3916
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
3917
|
+
const hash = await docHash(this.mirror.doc);
|
|
3918
|
+
this.ownHashes.set(env.seq, hash);
|
|
3919
|
+
if (this.ownHashes.size > 5) {
|
|
3920
|
+
this.ownHashes.delete(Math.min(...this.ownHashes.keys()));
|
|
3921
|
+
}
|
|
3922
|
+
const iv = new Uint8Array(12);
|
|
3923
|
+
crypto.getRandomValues(iv);
|
|
3924
|
+
const body = new TextEncoder().encode(JSON.stringify({ seq: env.seq, hash }));
|
|
3925
|
+
const aad = new TextEncoder().encode(`gs:${this.docId}:${this.genesisId}`);
|
|
3926
|
+
const ct = await crypto.subtle.encrypt(
|
|
3927
|
+
{ name: "AES-GCM", iv, additionalData: aad },
|
|
3928
|
+
this.keys.kContent,
|
|
3929
|
+
body
|
|
3930
|
+
);
|
|
3931
|
+
this.transport.send({ t: "gossip", iv: bytesToB64(iv), ciphertext: bytesToB64(new Uint8Array(ct)) });
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
/** Verify a peer's gossiped hash against our own at the same seq. */
|
|
3935
|
+
async onGossip(iv, ciphertext) {
|
|
3936
|
+
if (!this.keys || !this.genesisId) return;
|
|
3937
|
+
let claim;
|
|
3938
|
+
try {
|
|
3939
|
+
const aad = new TextEncoder().encode(`gs:${this.docId}:${this.genesisId}`);
|
|
3940
|
+
const pt = await crypto.subtle.decrypt(
|
|
3941
|
+
{ name: "AES-GCM", iv: b64ToBytes(iv), additionalData: aad },
|
|
3942
|
+
this.keys.kContent,
|
|
3943
|
+
b64ToBytes(ciphertext)
|
|
3944
|
+
);
|
|
3945
|
+
claim = JSON.parse(new TextDecoder().decode(pt));
|
|
3946
|
+
} catch {
|
|
3947
|
+
return;
|
|
3948
|
+
}
|
|
3949
|
+
const own = this.ownHashes.get(claim.seq);
|
|
3950
|
+
if (own !== void 0 && own !== claim.hash) {
|
|
3951
|
+
this.cb.onRefused?.("divergence");
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3954
|
+
};
|
|
3955
|
+
/** Every K ingested seqs, hash + gossip (doc 13 §2). */
|
|
3956
|
+
_EncryptedCollabConnection.GOSSIP_EVERY = 20;
|
|
3957
|
+
_EncryptedCollabConnection.CHECKPOINT_EVERY = 50;
|
|
3958
|
+
_EncryptedCollabConnection.STUCK_MAX_RETRIES = 3;
|
|
3959
|
+
var EncryptedCollabConnection = _EncryptedCollabConnection;
|
|
3960
|
+
|
|
3961
|
+
// ../collab/src/ws-transport.ts
|
|
3962
|
+
function createWebSocketTransport(socket) {
|
|
3963
|
+
let open = false;
|
|
3964
|
+
const backlog = [];
|
|
3965
|
+
socket.addEventListener("open", () => {
|
|
3966
|
+
open = true;
|
|
3967
|
+
for (const frame of backlog.splice(0)) socket.send(frame);
|
|
3968
|
+
});
|
|
3969
|
+
let handler = null;
|
|
3970
|
+
socket.addEventListener("message", (ev) => {
|
|
3971
|
+
let msg;
|
|
3972
|
+
try {
|
|
3973
|
+
msg = JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data));
|
|
3974
|
+
} catch {
|
|
3975
|
+
return;
|
|
3976
|
+
}
|
|
3977
|
+
handler?.(msg);
|
|
3978
|
+
});
|
|
3979
|
+
return {
|
|
3980
|
+
send(msg) {
|
|
3981
|
+
const frame = JSON.stringify(msg);
|
|
3982
|
+
if (open) socket.send(frame);
|
|
3983
|
+
else backlog.push(frame);
|
|
3984
|
+
},
|
|
3985
|
+
onMessage(cb) {
|
|
3986
|
+
handler = cb;
|
|
3987
|
+
}
|
|
3988
|
+
};
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
// ../collab/src/liveness.ts
|
|
3992
|
+
var DEFAULT_INTERVAL_MS = 15e3;
|
|
3993
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
3994
|
+
var LivenessMonitor = class {
|
|
3995
|
+
constructor(send, onDead, opts = {}) {
|
|
3996
|
+
this.send = send;
|
|
3997
|
+
this.onDead = onDead;
|
|
3998
|
+
this.opts = opts;
|
|
3999
|
+
this.probeTimer = null;
|
|
4000
|
+
this.deadlineTimer = null;
|
|
4001
|
+
this.nonce = 0;
|
|
4002
|
+
/** The nonce of the probe currently awaiting an answer; null when idle. */
|
|
4003
|
+
this.awaiting = null;
|
|
4004
|
+
this.stopped = false;
|
|
4005
|
+
}
|
|
4006
|
+
get intervalMs() {
|
|
4007
|
+
return this.opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
4008
|
+
}
|
|
4009
|
+
get timeoutMs() {
|
|
4010
|
+
return this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
4011
|
+
}
|
|
4012
|
+
setTimer(fn, ms) {
|
|
4013
|
+
return (this.opts.setTimer ?? ((f, m) => setTimeout(f, m)))(fn, ms);
|
|
4014
|
+
}
|
|
4015
|
+
clearTimer(t) {
|
|
4016
|
+
if (t !== null) (this.opts.clearTimer ?? clearTimeout)(t);
|
|
4017
|
+
}
|
|
4018
|
+
/** Begin probing. Call when the socket opens. */
|
|
4019
|
+
start() {
|
|
4020
|
+
if (this.stopped) return;
|
|
4021
|
+
this.arm();
|
|
4022
|
+
}
|
|
4023
|
+
/** Stop every timer and answer nothing further. Idempotent — the reconnect
|
|
4024
|
+
* path calls it from both the close handler and the effect teardown. */
|
|
4025
|
+
stop() {
|
|
4026
|
+
this.stopped = true;
|
|
4027
|
+
this.clearTimer(this.probeTimer);
|
|
4028
|
+
this.clearTimer(this.deadlineTimer);
|
|
4029
|
+
this.probeTimer = null;
|
|
4030
|
+
this.deadlineTimer = null;
|
|
4031
|
+
this.awaiting = null;
|
|
4032
|
+
}
|
|
4033
|
+
/** Schedule the next probe one interval out. */
|
|
4034
|
+
arm() {
|
|
4035
|
+
if (this.stopped) return;
|
|
4036
|
+
this.clearTimer(this.probeTimer);
|
|
4037
|
+
this.probeTimer = this.setTimer(() => {
|
|
4038
|
+
this.probeTimer = null;
|
|
4039
|
+
this.probe();
|
|
4040
|
+
}, this.intervalMs);
|
|
4041
|
+
}
|
|
4042
|
+
/**
|
|
4043
|
+
* Probe NOW rather than waiting for the interval — wired to
|
|
4044
|
+
* `visibilitychange`, because a tab waking from sleep is the exact moment
|
|
4045
|
+
* the connection is most likely already dead and the worst moment to make
|
|
4046
|
+
* the user wait a full interval to find out. Without this the motivating
|
|
4047
|
+
* scenario (unlock phone, start typing) spends up to 25s in the silent-loss
|
|
4048
|
+
* state the arc exists to close.
|
|
4049
|
+
*
|
|
4050
|
+
* A probe already in flight is left alone: re-arming its deadline on every
|
|
4051
|
+
* focus event would let a user who keeps switching tabs postpone the verdict
|
|
4052
|
+
* indefinitely.
|
|
4053
|
+
*/
|
|
4054
|
+
probe() {
|
|
4055
|
+
if (this.stopped || this.awaiting !== null) return;
|
|
4056
|
+
const nonce = ++this.nonce;
|
|
4057
|
+
this.awaiting = nonce;
|
|
4058
|
+
this.deadlineTimer = this.setTimer(() => {
|
|
4059
|
+
this.deadlineTimer = null;
|
|
4060
|
+
if (this.stopped || this.awaiting !== nonce) return;
|
|
4061
|
+
this.stop();
|
|
4062
|
+
this.onDead();
|
|
4063
|
+
}, this.timeoutMs);
|
|
4064
|
+
try {
|
|
4065
|
+
this.send({ t: "ping", nonce });
|
|
4066
|
+
} catch {
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
/**
|
|
4070
|
+
* Feed an inbound frame to the monitor.
|
|
4071
|
+
*
|
|
4072
|
+
* ONLY A MATCHING PONG COUNTS AS PROOF OF LIFE — deliberately, and this is
|
|
4073
|
+
* the subtle decision in the file. The obvious optimisation is to treat any
|
|
4074
|
+
* inbound traffic as evidence the connection works, which is what most
|
|
4075
|
+
* heartbeats do. It is wrong here: it assumes both directions fail together.
|
|
4076
|
+
* A connection whose OUTBOUND half is broken while broadcasts still arrive
|
|
4077
|
+
* would look permanently healthy under that rule, and that is precisely the
|
|
4078
|
+
* user-visible bug being fixed — edits going nowhere while the screen keeps
|
|
4079
|
+
* updating with everyone else's. A pong proves the full round trip an edit
|
|
4080
|
+
* actually needs, so nothing weaker is accepted.
|
|
4081
|
+
*/
|
|
4082
|
+
noteInbound(msg) {
|
|
4083
|
+
if (this.stopped) return;
|
|
4084
|
+
if (msg.t !== "pong") return;
|
|
4085
|
+
if (this.awaiting === null || msg.nonce !== this.awaiting) return;
|
|
4086
|
+
this.awaiting = null;
|
|
4087
|
+
this.clearTimer(this.deadlineTimer);
|
|
4088
|
+
this.deadlineTimer = null;
|
|
4089
|
+
this.arm();
|
|
4090
|
+
}
|
|
4091
|
+
};
|
|
4092
|
+
function monitorTransport(inner, onDead, opts = {}) {
|
|
4093
|
+
const monitor = new LivenessMonitor((msg) => inner.send(msg), onDead, opts);
|
|
4094
|
+
const transport = {
|
|
4095
|
+
send: (msg) => inner.send(msg),
|
|
4096
|
+
onMessage: (cb) => {
|
|
4097
|
+
inner.onMessage((msg) => {
|
|
4098
|
+
monitor.noteInbound(msg);
|
|
4099
|
+
if (msg.t === "pong") return;
|
|
4100
|
+
cb(msg);
|
|
4101
|
+
});
|
|
4102
|
+
}
|
|
4103
|
+
};
|
|
4104
|
+
return { transport, monitor };
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
// src/bundle-store.ts
|
|
4108
|
+
var LEGACY_BACKFILL_PER_LIST = 4;
|
|
4109
|
+
var IndexedDbBundleStore = class {
|
|
4110
|
+
constructor(dbName = "wordinweb-bundles") {
|
|
4111
|
+
this.dbName = dbName;
|
|
4112
|
+
this.db = null;
|
|
4113
|
+
this.persistRequested = false;
|
|
4114
|
+
}
|
|
4115
|
+
open() {
|
|
4116
|
+
if (!this.db) {
|
|
4117
|
+
this.db = new Promise((resolve, reject) => {
|
|
4118
|
+
const req = indexedDB.open(this.dbName, 2);
|
|
4119
|
+
req.onupgradeneeded = () => {
|
|
4120
|
+
const db = req.result;
|
|
4121
|
+
if (!db.objectStoreNames.contains("bundles")) db.createObjectStore("bundles", { keyPath: "docId" });
|
|
4122
|
+
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta", { keyPath: "docId" });
|
|
4123
|
+
};
|
|
4124
|
+
req.onsuccess = () => resolve(req.result);
|
|
4125
|
+
req.onerror = () => reject(req.error);
|
|
4126
|
+
});
|
|
4127
|
+
}
|
|
4128
|
+
return this.db;
|
|
4129
|
+
}
|
|
4130
|
+
/** One request in one transaction, as a promise. */
|
|
4131
|
+
tx(stores, mode, run) {
|
|
4132
|
+
return this.open().then(
|
|
4133
|
+
(db) => new Promise((resolve, reject) => {
|
|
4134
|
+
const t = db.transaction(stores, mode);
|
|
4135
|
+
const req = run(t);
|
|
4136
|
+
req.onsuccess = () => resolve(req.result);
|
|
4137
|
+
req.onerror = () => reject(req.error);
|
|
4138
|
+
t.onabort = () => reject(t.error ?? new Error("IndexedDB transaction aborted"));
|
|
4139
|
+
})
|
|
4140
|
+
);
|
|
4141
|
+
}
|
|
4142
|
+
async get(docId) {
|
|
4143
|
+
return await this.tx("bundles", "readonly", (t) => t.objectStore("bundles").get(docId)) ?? null;
|
|
4144
|
+
}
|
|
4145
|
+
async put(bundle) {
|
|
4146
|
+
if (!this.persistRequested) {
|
|
4147
|
+
this.persistRequested = true;
|
|
4148
|
+
void globalThis.navigator?.storage?.persist?.();
|
|
4149
|
+
}
|
|
4150
|
+
const db = await this.open();
|
|
4151
|
+
await new Promise((resolve, reject) => {
|
|
4152
|
+
const t = db.transaction(["bundles", "meta"], "readwrite");
|
|
4153
|
+
t.objectStore("bundles").put(bundle);
|
|
4154
|
+
const meta = { docId: bundle.docId, savedAt: bundle.savedAt, byteLength: bundle.confirmedBytes.byteLength };
|
|
4155
|
+
t.objectStore("meta").put(meta);
|
|
4156
|
+
t.oncomplete = () => resolve();
|
|
4157
|
+
t.onerror = () => reject(t.error);
|
|
4158
|
+
t.onabort = () => reject(t.error ?? new Error("IndexedDB transaction aborted"));
|
|
4159
|
+
});
|
|
4160
|
+
}
|
|
4161
|
+
async delete(docId) {
|
|
4162
|
+
const db = await this.open();
|
|
4163
|
+
await new Promise((resolve, reject) => {
|
|
4164
|
+
const t = db.transaction(["bundles", "meta"], "readwrite");
|
|
4165
|
+
t.objectStore("bundles").delete(docId);
|
|
4166
|
+
t.objectStore("meta").delete(docId);
|
|
4167
|
+
t.oncomplete = () => resolve();
|
|
4168
|
+
t.onerror = () => reject(t.error);
|
|
4169
|
+
t.onabort = () => reject(t.error ?? new Error("IndexedDB transaction aborted"));
|
|
4170
|
+
});
|
|
4171
|
+
}
|
|
4172
|
+
async list() {
|
|
4173
|
+
const keys = await this.tx("bundles", "readonly", (t) => t.objectStore("bundles").getAllKeys());
|
|
4174
|
+
const metas = await this.tx("meta", "readonly", (t) => t.objectStore("meta").getAll());
|
|
4175
|
+
const byKey = new Map(metas.map((m) => [m.docId, m]));
|
|
4176
|
+
const out = [];
|
|
4177
|
+
let budget = LEGACY_BACKFILL_PER_LIST;
|
|
4178
|
+
for (const key of keys) {
|
|
4179
|
+
let meta = byKey.get(key);
|
|
4180
|
+
if (!meta && budget > 0) {
|
|
4181
|
+
budget--;
|
|
4182
|
+
const bundle = await this.get(key);
|
|
4183
|
+
if (!bundle) continue;
|
|
4184
|
+
meta = { docId: key, savedAt: bundle.savedAt, byteLength: bundle.confirmedBytes.byteLength };
|
|
4185
|
+
await this.tx("meta", "readwrite", (t) => t.objectStore("meta").put(meta));
|
|
4186
|
+
}
|
|
4187
|
+
out.push({
|
|
4188
|
+
...parseBundleKey(key),
|
|
4189
|
+
key,
|
|
4190
|
+
// Unmeasured legacy record: 0 means UNKNOWN. A byte-budget caller must
|
|
4191
|
+
// not treat it as free, and must not delete it for being large either.
|
|
4192
|
+
savedAt: meta?.savedAt ?? 0,
|
|
4193
|
+
byteLength: meta?.byteLength ?? 0
|
|
4194
|
+
});
|
|
4195
|
+
}
|
|
4196
|
+
return out;
|
|
4197
|
+
}
|
|
4198
|
+
};
|
|
4199
|
+
|
|
4200
|
+
// src/collab.tsx
|
|
4201
|
+
var TIMED_OUT = /* @__PURE__ */ Symbol("store-timeout");
|
|
4202
|
+
var STORE_DEADLINE_MS = 2e3;
|
|
4203
|
+
function withDeadline(p, ms, fallback) {
|
|
4204
|
+
return new Promise((resolve) => {
|
|
4205
|
+
let done = false;
|
|
4206
|
+
const t = setTimeout(() => {
|
|
4207
|
+
if (!done) {
|
|
4208
|
+
done = true;
|
|
4209
|
+
resolve(fallback);
|
|
4210
|
+
}
|
|
4211
|
+
}, ms);
|
|
4212
|
+
void p.then(
|
|
4213
|
+
(v) => {
|
|
4214
|
+
if (!done) {
|
|
4215
|
+
done = true;
|
|
4216
|
+
clearTimeout(t);
|
|
4217
|
+
resolve(v);
|
|
4218
|
+
}
|
|
4219
|
+
},
|
|
4220
|
+
() => {
|
|
4221
|
+
if (!done) {
|
|
4222
|
+
done = true;
|
|
4223
|
+
clearTimeout(t);
|
|
4224
|
+
resolve(fallback);
|
|
4225
|
+
}
|
|
4226
|
+
}
|
|
4227
|
+
);
|
|
4228
|
+
});
|
|
4229
|
+
}
|
|
4230
|
+
function useCollab(opts) {
|
|
4231
|
+
const { url, docId, clientId, token, createSocket, store, profile, takeover, docKey, shareCode, ownerToken, httpBase } = opts;
|
|
4232
|
+
const connRef = useRef(null);
|
|
4233
|
+
const [doc, setDoc] = useState(null);
|
|
4234
|
+
const [version, setVersion] = useState(0);
|
|
4235
|
+
const [renderVersion, setRenderVersion] = useState(0);
|
|
4236
|
+
const lastDocVersionRef = useRef(0);
|
|
4237
|
+
const renderScopeRef = useRef(null);
|
|
4238
|
+
const [docEpoch, setDocEpoch] = useState(0);
|
|
4239
|
+
const [ready, setReady] = useState(false);
|
|
4240
|
+
const [presence, setPresence] = useState({});
|
|
4241
|
+
const [refused, setRefused] = useState(null);
|
|
4242
|
+
const [deadlines, setDeadlines] = useState({ idle: null, lifetime: null });
|
|
4243
|
+
const clearDeadlines = () => setDeadlines((d) => d.idle === null && d.lifetime === null ? d : { idle: null, lifetime: null });
|
|
4244
|
+
const sessionWarning = useMemo(() => {
|
|
4245
|
+
const { idle, lifetime } = deadlines;
|
|
4246
|
+
const reason = idle !== null && (lifetime === null || idle <= lifetime) ? "idle" : lifetime !== null ? "lifetime" : null;
|
|
4247
|
+
if (!reason) return null;
|
|
4248
|
+
return { reason, inMs: Math.max(0, (reason === "idle" ? idle : lifetime) - Date.now()) };
|
|
4249
|
+
}, [deadlines]);
|
|
4250
|
+
const [readOnlyBlocked, setReadOnlyBlocked] = useState(false);
|
|
4251
|
+
const [notOwner, setNotOwner] = useState(false);
|
|
4252
|
+
const [epochChanged, setEpochChanged] = useState(null);
|
|
4253
|
+
const [roster, setRoster] = useState([]);
|
|
4254
|
+
const [arrival, setArrival] = useState(null);
|
|
4255
|
+
const [selfHeals, setSelfHeals] = useState(0);
|
|
4256
|
+
const [droppedPreReady, setDroppedPreReady] = useState(0);
|
|
4257
|
+
const [persistErrors, setPersistErrors] = useState(0);
|
|
4258
|
+
const [storeSlow, setStoreSlow] = useState(false);
|
|
4259
|
+
const [sendFailures, setSendFailures] = useState(0);
|
|
4260
|
+
const offlineTailRef = useRef(void 0);
|
|
4261
|
+
const offlineEpochRef = useRef(null);
|
|
4262
|
+
const [offlineHeld, setOfflineHeld] = useState(0);
|
|
4263
|
+
const lastGenesisRef = useRef(null);
|
|
4264
|
+
const persisterRef = useRef(null);
|
|
4265
|
+
const tailConnRef = useRef(null);
|
|
4266
|
+
const offlinePersistTimerRef = useRef(null);
|
|
4267
|
+
const [connection, setConnection] = useState("live");
|
|
4268
|
+
const [reconnectNonce, setReconnectNonce] = useState(0);
|
|
4269
|
+
const backoffRef = useRef(0);
|
|
4270
|
+
const attemptRef = useRef(0);
|
|
4271
|
+
const retryTimerRef = useRef(null);
|
|
4272
|
+
const flushDoneRef = useRef(Promise.resolve());
|
|
4273
|
+
const maxRetries = opts.liveness?.maxRetries ?? 6;
|
|
4274
|
+
const livenessRef = useRef(opts.liveness);
|
|
4275
|
+
livenessRef.current = opts.liveness;
|
|
4276
|
+
const reconnect = () => {
|
|
4277
|
+
if (retryTimerRef.current !== null) clearTimeout(retryTimerRef.current);
|
|
4278
|
+
retryTimerRef.current = null;
|
|
4279
|
+
backoffRef.current = 0;
|
|
4280
|
+
attemptRef.current = 0;
|
|
4281
|
+
setConnection("reconnecting");
|
|
4282
|
+
setReconnectNonce((n) => n + 1);
|
|
4283
|
+
};
|
|
4284
|
+
const persistOfflineTail = () => {
|
|
4285
|
+
if (!store) return;
|
|
4286
|
+
flushDoneRef.current = flushDoneRef.current.then(async () => {
|
|
4287
|
+
const prev = await store.get(docId);
|
|
4288
|
+
if (!prev) return;
|
|
4289
|
+
const tail = offlineTailRef.current ?? [];
|
|
4290
|
+
const next = { ...prev };
|
|
4291
|
+
if (tail.length) {
|
|
4292
|
+
next.offlineTail = [...tail];
|
|
4293
|
+
next.offlineTailEpoch = offlineEpochRef.current ?? void 0;
|
|
4294
|
+
} else {
|
|
4295
|
+
delete next.offlineTail;
|
|
4296
|
+
delete next.offlineTailEpoch;
|
|
4297
|
+
}
|
|
4298
|
+
await store.put(next);
|
|
4299
|
+
}).catch((err) => {
|
|
4300
|
+
setPersistErrors((n) => n + 1);
|
|
4301
|
+
console.error("[wordinweb] offline-tail-persist", err);
|
|
4302
|
+
});
|
|
4303
|
+
};
|
|
4304
|
+
const scheduleOfflinePersist = () => {
|
|
4305
|
+
if (!store || offlinePersistTimerRef.current !== null) return;
|
|
4306
|
+
offlinePersistTimerRef.current = setTimeout(() => {
|
|
4307
|
+
offlinePersistTimerRef.current = null;
|
|
4308
|
+
persistOfflineTail();
|
|
4309
|
+
}, 1e3);
|
|
4310
|
+
};
|
|
4311
|
+
const flushOfflinePersist = () => {
|
|
4312
|
+
if (offlinePersistTimerRef.current === null) return;
|
|
4313
|
+
clearTimeout(offlinePersistTimerRef.current);
|
|
4314
|
+
offlinePersistTimerRef.current = null;
|
|
4315
|
+
persistOfflineTail();
|
|
4316
|
+
};
|
|
4317
|
+
const captureOffline = (intent, preApplied) => {
|
|
4318
|
+
if (!preApplied) {
|
|
4319
|
+
if (!doc) return;
|
|
4320
|
+
const ids = doc.enableStableIds();
|
|
4321
|
+
const res = applyIntentScoped(doc, ids, intent);
|
|
4322
|
+
if (!res.applied) return;
|
|
4323
|
+
resyncScope(doc, ids, res);
|
|
4324
|
+
renderScopeRef.current = unionScopes(renderScopeRef.current, res);
|
|
4325
|
+
setRenderVersion((v) => v + 1);
|
|
4326
|
+
}
|
|
4327
|
+
const tail = offlineTailRef.current ?? [];
|
|
4328
|
+
tail.push(intent);
|
|
4329
|
+
offlineTailRef.current = tail;
|
|
4330
|
+
offlineEpochRef.current ?? (offlineEpochRef.current = lastGenesisRef.current);
|
|
4331
|
+
setOfflineHeld(tail.length);
|
|
4332
|
+
setVersion((v) => v + 1);
|
|
4333
|
+
if (connRef.current?.ready) persisterRef.current?.notify();
|
|
4334
|
+
else scheduleOfflinePersist();
|
|
4335
|
+
};
|
|
4336
|
+
const runTailReplay = (conn, kind) => {
|
|
4337
|
+
const author = profile?.name || clientId;
|
|
4338
|
+
const date = (/* @__PURE__ */ new Date()).toISOString();
|
|
4339
|
+
const step = () => {
|
|
4340
|
+
if (connRef.current !== conn || !conn.ready) return;
|
|
4341
|
+
if (conn.pendingCount > 0) {
|
|
4342
|
+
setTimeout(step, 25);
|
|
4343
|
+
return;
|
|
4344
|
+
}
|
|
4345
|
+
const tail = offlineTailRef.current ?? [];
|
|
4346
|
+
if (!tail.length) {
|
|
4347
|
+
offlineTailRef.current = void 0;
|
|
4348
|
+
offlineEpochRef.current = null;
|
|
4349
|
+
setOfflineHeld(0);
|
|
4350
|
+
setArrival(null);
|
|
4351
|
+
return;
|
|
4352
|
+
}
|
|
4353
|
+
const next = tail[0];
|
|
4354
|
+
offlineTailRef.current = tail.slice(1);
|
|
4355
|
+
setOfflineHeld(tail.length - 1);
|
|
4356
|
+
if (kind === "suggest") {
|
|
4357
|
+
const { suggestions } = toSuggestions([next], author, date);
|
|
4358
|
+
if (suggestions.length) conn.submit(suggestions[0]);
|
|
4359
|
+
} else {
|
|
4360
|
+
conn.submit(next);
|
|
4361
|
+
}
|
|
4362
|
+
void persisterRef.current?.flush();
|
|
4363
|
+
setTimeout(step, 0);
|
|
4364
|
+
};
|
|
4365
|
+
step();
|
|
4366
|
+
};
|
|
4367
|
+
useEffect(() => {
|
|
4368
|
+
clearDeadlines();
|
|
4369
|
+
const socket = (createSocket ?? ((u) => new WebSocket(u)))(url);
|
|
4370
|
+
let disposed = false;
|
|
4371
|
+
let dropHandled = false;
|
|
4372
|
+
let monitor = null;
|
|
4373
|
+
const dropped = () => {
|
|
4374
|
+
if (dropHandled || disposed) return;
|
|
4375
|
+
dropHandled = true;
|
|
4376
|
+
monitor?.stop();
|
|
4377
|
+
try {
|
|
4378
|
+
socket.close?.();
|
|
4379
|
+
} catch {
|
|
4380
|
+
}
|
|
4381
|
+
if (attemptRef.current >= maxRetries) {
|
|
4382
|
+
setConnection("lost");
|
|
4383
|
+
return;
|
|
4384
|
+
}
|
|
4385
|
+
attemptRef.current += 1;
|
|
4386
|
+
backoffRef.current = Math.min(backoffRef.current === 0 ? 300 : backoffRef.current * 2, 5e3);
|
|
4387
|
+
setConnection("reconnecting");
|
|
4388
|
+
retryTimerRef.current = setTimeout(() => {
|
|
4389
|
+
retryTimerRef.current = null;
|
|
4390
|
+
setReconnectNonce((n) => n + 1);
|
|
4391
|
+
}, backoffRef.current);
|
|
4392
|
+
};
|
|
4393
|
+
socket.onclose = () => dropped();
|
|
4394
|
+
socket.onerror = () => dropped();
|
|
4395
|
+
const monitored = monitorTransport(
|
|
4396
|
+
createWebSocketTransport(socket),
|
|
4397
|
+
() => dropped(),
|
|
4398
|
+
livenessRef.current ?? {}
|
|
4399
|
+
);
|
|
4400
|
+
const transport = monitored.transport;
|
|
4401
|
+
monitor = monitored.monitor;
|
|
4402
|
+
const onVisible = () => {
|
|
4403
|
+
if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
|
|
4404
|
+
monitor?.probe();
|
|
4405
|
+
};
|
|
4406
|
+
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible);
|
|
4407
|
+
monitor.start();
|
|
4408
|
+
let persister = null;
|
|
4409
|
+
const callbacks = {
|
|
4410
|
+
onChange: () => {
|
|
4411
|
+
const c = connRef.current;
|
|
4412
|
+
if (!c) return;
|
|
4413
|
+
setDoc(c.doc);
|
|
4414
|
+
setReady(c.ready);
|
|
4415
|
+
setVersion((v) => v + 1);
|
|
4416
|
+
const dv = c.docVersion;
|
|
4417
|
+
if (dv !== lastDocVersionRef.current) {
|
|
4418
|
+
lastDocVersionRef.current = dv;
|
|
4419
|
+
setRenderVersion((v) => v + 1);
|
|
4420
|
+
}
|
|
4421
|
+
setDocEpoch(c.docEpoch);
|
|
4422
|
+
persister?.notify();
|
|
4423
|
+
if (c.ready) {
|
|
4424
|
+
backoffRef.current = 0;
|
|
4425
|
+
attemptRef.current = 0;
|
|
4426
|
+
setConnection((s) => s === "live" ? s : "live");
|
|
4427
|
+
lastGenesisRef.current = c.genesisId;
|
|
4428
|
+
if (tailConnRef.current !== c) {
|
|
4429
|
+
tailConnRef.current = c;
|
|
4430
|
+
const tail = offlineTailRef.current ?? [];
|
|
4431
|
+
if (tail.length) {
|
|
4432
|
+
if (offlineEpochRef.current === c.genesisId) {
|
|
4433
|
+
runTailReplay(c, "plain");
|
|
4434
|
+
} else {
|
|
4435
|
+
const structural = toSuggestions(tail, "", "").dropped.length;
|
|
4436
|
+
const mode = arrivalMode(tail.length, true) === "draft" ? "draft" : "suggest";
|
|
4437
|
+
setArrival({ mode, tailLength: tail.length, structural });
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4442
|
+
},
|
|
4443
|
+
onPresence: (participant, pos) => setPresence((prev) => ({ ...prev, [participant]: pos })),
|
|
4444
|
+
onRefused: (reason) => {
|
|
4445
|
+
if (reason === "read-only") {
|
|
4446
|
+
setReadOnlyBlocked(true);
|
|
4447
|
+
return;
|
|
4448
|
+
}
|
|
4449
|
+
if (reason === "not-owner") {
|
|
4450
|
+
setNotOwner(true);
|
|
4451
|
+
return;
|
|
4452
|
+
}
|
|
4453
|
+
clearDeadlines();
|
|
4454
|
+
setRefused(reason);
|
|
4455
|
+
},
|
|
4456
|
+
onSessionWarning: ({ reason, inMs }) => setDeadlines((d) => ({ ...d, [reason]: Date.now() + inMs })),
|
|
4457
|
+
// Only ever `idle` — the lifetime deadline cannot be cancelled, so
|
|
4458
|
+
// there is deliberately no per-reason branch here to get wrong.
|
|
4459
|
+
onSessionWarningCleared: () => setDeadlines((d) => d.idle === null ? d : { ...d, idle: null }),
|
|
4460
|
+
onRoster: (r) => setRoster(r),
|
|
4461
|
+
onSelfHeal: () => setSelfHeals((n) => n + 1),
|
|
4462
|
+
// A submit that never left the client. Counted, never swallowed.
|
|
4463
|
+
onSubmitDropped: () => setDroppedPreReady((n) => n + 1),
|
|
4464
|
+
// Async failures with nowhere to return (seal, transport send). Surfaced
|
|
4465
|
+
// rather than swallowed — this callback exists because three bugs this
|
|
4466
|
+
// session lived inside empty catches. console.error is the floor: an app
|
|
4467
|
+
// embedding this should report it properly.
|
|
4468
|
+
onError: (info) => {
|
|
4469
|
+
if (info.where === "enc.submit") setSendFailures((n) => n + 1);
|
|
4470
|
+
console.error(`[wordinweb] ${info.where}`, info.error);
|
|
4471
|
+
},
|
|
4472
|
+
onEpochChange: (from, to) => {
|
|
4473
|
+
if (store) {
|
|
4474
|
+
void store.get(docId).then((old) => {
|
|
4475
|
+
if (old && old.genesisId === from) {
|
|
4476
|
+
return store.put({ ...old, docId: draftKey(docId, from) });
|
|
4477
|
+
}
|
|
4478
|
+
});
|
|
4479
|
+
}
|
|
4480
|
+
setEpochChanged({ from, to });
|
|
4481
|
+
},
|
|
4482
|
+
onFastForward: (from, _to) => {
|
|
4483
|
+
if (store) {
|
|
4484
|
+
void store.get(docId).then((old) => {
|
|
4485
|
+
if (old && old.genesisId === from) {
|
|
4486
|
+
return store.put({ ...old, docId: supersededKey(docId, from) });
|
|
4487
|
+
}
|
|
4488
|
+
});
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
};
|
|
4492
|
+
const flush = () => {
|
|
4493
|
+
void persister?.flush();
|
|
4494
|
+
flushOfflinePersist();
|
|
4495
|
+
};
|
|
4496
|
+
const rejoining = reconnectNonce > 0;
|
|
4497
|
+
void (async () => {
|
|
4498
|
+
const stretched = docKey && shareCode ? await stretchShareCode(shareCode, docId) : void 0;
|
|
4499
|
+
const codeProof = stretched ? btoa(String.fromCharCode(...stretched)) : void 0;
|
|
4500
|
+
if (disposed) return;
|
|
4501
|
+
const mediaOpts = httpBase ? { httpBase } : void 0;
|
|
4502
|
+
const conn = docKey ? new EncryptedCollabConnection(transport, clientId, docKey, callbacks, stretched, void 0, mediaOpts) : new CollabConnection(transport, clientId, callbacks, mediaOpts);
|
|
4503
|
+
connRef.current = conn;
|
|
4504
|
+
if (store) {
|
|
4505
|
+
persister = new BundlePersister(conn, store, docId, {
|
|
4506
|
+
// The browser's bundle IS the durable copy in a zero-custody design,
|
|
4507
|
+
// so a failed write (quota, blocked storage, private mode) means
|
|
4508
|
+
// "saved" is a lie. Never silent.
|
|
4509
|
+
onError: (err) => {
|
|
4510
|
+
setPersistErrors((n) => n + 1);
|
|
4511
|
+
console.error("[wordinweb] bundle-persist", err);
|
|
4512
|
+
},
|
|
4513
|
+
// The offline tail rides every bundle write (doc 15 §4.3 "tail
|
|
4514
|
+
// hygiene"): without this, the first post-resume write would
|
|
4515
|
+
// ERASE a stored tail the user had not reconciled yet.
|
|
4516
|
+
offlineTail: () => {
|
|
4517
|
+
const tail = offlineTailRef.current ?? [];
|
|
4518
|
+
return tail.length ? { tail: [...tail], epoch: offlineEpochRef.current ?? void 0 } : null;
|
|
4519
|
+
}
|
|
4520
|
+
});
|
|
4521
|
+
persisterRef.current = persister;
|
|
4522
|
+
if (typeof window !== "undefined") {
|
|
4523
|
+
window.addEventListener("pagehide", flush);
|
|
4524
|
+
document.addEventListener("visibilitychange", flush);
|
|
4525
|
+
}
|
|
4526
|
+
await withDeadline(flushDoneRef.current, STORE_DEADLINE_MS, void 0);
|
|
4527
|
+
if (disposed || connRef.current !== conn) return;
|
|
4528
|
+
const read = await withDeadline(
|
|
4529
|
+
store.get(docId),
|
|
4530
|
+
STORE_DEADLINE_MS,
|
|
4531
|
+
TIMED_OUT
|
|
4532
|
+
);
|
|
4533
|
+
if (disposed || connRef.current !== conn) return;
|
|
4534
|
+
const storeTimedOut = read === TIMED_OUT;
|
|
4535
|
+
const bundle = storeTimedOut ? null : read;
|
|
4536
|
+
if (storeTimedOut) setStoreSlow(true);
|
|
4537
|
+
if (!offlineTailRef.current?.length) {
|
|
4538
|
+
offlineTailRef.current = bundle?.offlineTail;
|
|
4539
|
+
offlineEpochRef.current = bundle?.offlineTailEpoch ?? null;
|
|
4540
|
+
setOfflineHeld(bundle?.offlineTail?.length ?? 0);
|
|
4541
|
+
}
|
|
4542
|
+
if (bundle) conn.resume(bundle, token, { profile, codeProof, ownerToken });
|
|
4543
|
+
else conn.join(docId, token, { profile, takeover: takeover || rejoining, codeProof, ownerToken });
|
|
4544
|
+
} else {
|
|
4545
|
+
conn.join(docId, token, { profile, takeover: takeover || rejoining, codeProof });
|
|
4546
|
+
}
|
|
4547
|
+
})();
|
|
4548
|
+
return () => {
|
|
4549
|
+
disposed = true;
|
|
4550
|
+
monitor?.stop();
|
|
4551
|
+
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible);
|
|
4552
|
+
if (store && typeof window !== "undefined") {
|
|
4553
|
+
window.removeEventListener("pagehide", flush);
|
|
4554
|
+
document.removeEventListener("visibilitychange", flush);
|
|
4555
|
+
}
|
|
4556
|
+
flushOfflinePersist();
|
|
4557
|
+
flushDoneRef.current = Promise.all([flushDoneRef.current, persister?.flush() ?? Promise.resolve()]).then(() => {
|
|
4558
|
+
});
|
|
4559
|
+
persister?.stop();
|
|
4560
|
+
if (persisterRef.current === persister) persisterRef.current = null;
|
|
4561
|
+
connRef.current = null;
|
|
4562
|
+
};
|
|
4563
|
+
}, [url, docId, clientId, token, createSocket, store, takeover, docKey, shareCode, ownerToken, httpBase, reconnectNonce]);
|
|
4564
|
+
useEffect(() => () => {
|
|
4565
|
+
if (retryTimerRef.current !== null) clearTimeout(retryTimerRef.current);
|
|
4566
|
+
retryTimerRef.current = null;
|
|
4567
|
+
}, []);
|
|
4568
|
+
const myWrite = roster.find((r) => r.clientId === clientId)?.write;
|
|
4569
|
+
const refusalBlocked = refused !== null || (myWrite !== void 0 ? myWrite !== "allowed" : readOnlyBlocked);
|
|
4570
|
+
const offlineActive = !refusalBlocked && connection !== "live" && ready;
|
|
4571
|
+
const offlineCapped = offlineActive && offlineHeld >= (opts.offlineTailCap ?? OFFLINE_TAIL_CAP);
|
|
4572
|
+
return {
|
|
4573
|
+
doc,
|
|
4574
|
+
version,
|
|
4575
|
+
renderVersion,
|
|
4576
|
+
// Union of what the connection applied (its replica tracks per-intent
|
|
4577
|
+
// scopes) and any local offline applies, drained together at the repaint.
|
|
4578
|
+
takeRenderScope: () => {
|
|
4579
|
+
const local = renderScopeRef.current;
|
|
4580
|
+
renderScopeRef.current = null;
|
|
4581
|
+
const conn = connRef.current?.takeRenderScope() ?? null;
|
|
4582
|
+
if (local && conn) return unionScopes(local, conn);
|
|
4583
|
+
return conn ?? local;
|
|
4584
|
+
},
|
|
4585
|
+
docEpoch,
|
|
4586
|
+
ready,
|
|
4587
|
+
// DocxView's editor applies every command to the live doc BEFORE emitting
|
|
4588
|
+
// its intent, so the connection must not optimistically re-apply it (that
|
|
4589
|
+
// doubled each keystroke: "Hello" rendered as "Hello" + its reversal, and
|
|
4590
|
+
// the corrupted offsets got everything after the first char rejected
|
|
4591
|
+
// server-side). Pre-applied semantics track pending + send only.
|
|
4592
|
+
//
|
|
4593
|
+
// OFFLINE, the same intent is CAPTURED instead (doc 15 §2): the edit is
|
|
4594
|
+
// already in the doc, so it only needs recording. Past the cap nothing
|
|
4595
|
+
// routes here — writesBlocked has withdrawn the editor — and anything
|
|
4596
|
+
// that slips to the connection anyway lands in the loud droppedPreReady
|
|
4597
|
+
// backstop rather than vanishing.
|
|
4598
|
+
submit: (intent) => {
|
|
4599
|
+
if (offlineActive && !offlineCapped) return captureOffline(
|
|
4600
|
+
intent,
|
|
4601
|
+
/*preApplied*/
|
|
4602
|
+
true
|
|
4603
|
+
);
|
|
4604
|
+
connRef.current?.submitPreApplied(intent);
|
|
4605
|
+
},
|
|
4606
|
+
// Toolbar/API ops: canonical-apply optimistic path (see CollabSession).
|
|
4607
|
+
// Offline, the capture applies through the SAME canonical applyIntent
|
|
4608
|
+
// code before recording, so the local result stays byte-identical to
|
|
4609
|
+
// what every replica will derive when the tail replays.
|
|
4610
|
+
submitOp: (intent) => {
|
|
4611
|
+
if (offlineActive && !offlineCapped) return captureOffline(
|
|
4612
|
+
intent,
|
|
4613
|
+
/*preApplied*/
|
|
4614
|
+
false
|
|
4615
|
+
);
|
|
4616
|
+
connRef.current?.submit(intent);
|
|
4617
|
+
},
|
|
4618
|
+
setPresence: (pos) => connRef.current?.setPresence(pos),
|
|
4619
|
+
allocIds: (n) => connRef.current?.allocIds(n) ?? [],
|
|
4620
|
+
uploadMedia: async (bytes) => await connRef.current?.uploadMedia(bytes) ?? null,
|
|
4621
|
+
// Read straight off the connection rather than mirrored into state: it is
|
|
4622
|
+
// set once when the welcome lands and never changes for the session, and
|
|
4623
|
+
// in the encrypted connection it is assigned OUTSIDE the serial rehydrate
|
|
4624
|
+
// chain, so it is readable before `ready` — which is what lets the picker
|
|
4625
|
+
// open and the pre-check run while the document is still rehydrating.
|
|
4626
|
+
mediaMaxBlobBytes: connRef.current?.mediaMaxBlobBytes ?? null,
|
|
4627
|
+
// Collaborative undo lives on the ENCRYPTED connection: it needs the
|
|
4628
|
+
// mirror (a local re-derivation of the canonical log) to compute the
|
|
4629
|
+
// inverse. A plaintext room's authority is the server, so undo there
|
|
4630
|
+
// needs a wire message and is not wired yet — the optional call reports
|
|
4631
|
+
// "unavailable" rather than pretending.
|
|
4632
|
+
// The ref is TYPED as the plaintext class (the encrypted one is cast in
|
|
4633
|
+
// at construction — a pre-existing modelling wrinkle, not this arc's), so
|
|
4634
|
+
// the access is structural. The TYPE is the real one now, so a change to
|
|
4635
|
+
// UndoOutcome breaks here rather than silently disagreeing.
|
|
4636
|
+
undoLast: () => connRef.current?.undoLast?.() ?? "unavailable",
|
|
4637
|
+
presence,
|
|
4638
|
+
refused,
|
|
4639
|
+
sessionWarning,
|
|
4640
|
+
readOnlyBlocked,
|
|
4641
|
+
notOwner,
|
|
4642
|
+
// Derived, not stored: one place decides "writes won't land", so the gate
|
|
4643
|
+
// and the banner can never disagree about whether the user may type.
|
|
4644
|
+
//
|
|
4645
|
+
// THE SERVER'S ROSTER SIGNAL WINS WHEN PRESENT, and that is the fix for a
|
|
4646
|
+
// real user-visible bug: `readOnlyBlocked` is refusal-driven, so it can
|
|
4647
|
+
// only ever say "blocked" — nothing tells it a lock was LIFTED. A blocked
|
|
4648
|
+
// participant therefore sat in viewer mode until reload, and needed the
|
|
4649
|
+
// "Try editing again" button to escape. The roster status is POSITIVE: it
|
|
4650
|
+
// says `allowed` too, and it is re-fanned on every transition, so a lift
|
|
4651
|
+
// reaches this client without anyone attempting an edit to discover it.
|
|
4652
|
+
//
|
|
4653
|
+
// `undefined` means an older server that publishes no status. Fall back to
|
|
4654
|
+
// the refusal-driven flag rather than assuming `allowed` — a permissive
|
|
4655
|
+
// default would put the user's first keystrokes straight back in the
|
|
4656
|
+
// appear-then-vanish loop this exists to remove.
|
|
4657
|
+
//
|
|
4658
|
+
// A NON-LIVE CONNECTION DOES NOT BLOCK: unreachable is not refused (see
|
|
4659
|
+
// refusalBlocked above, and the field's own doc). What DOES fold in is
|
|
4660
|
+
// the offline tail CAP — past it an edit cannot be durably kept, which
|
|
4661
|
+
// is the same "cannot land" fact this predicate exists to gate, and
|
|
4662
|
+
// folding it here is what keeps `editable: … && !writesBlocked` the one
|
|
4663
|
+
// gate every editor surface already reads.
|
|
4664
|
+
//
|
|
4665
|
+
// A REFUSAL BLOCKS WRITES FIRST OF ALL: the session is dead, and note the
|
|
4666
|
+
// hub does NOT close the socket after a kick, so `connection` can still
|
|
4667
|
+
// read "live" and the roster can still hold a stale `allowed` — neither
|
|
4668
|
+
// may win over a refusal. This is what keeps a document rendered behind a
|
|
4669
|
+
// "session ended" dialog read-only through the same single predicate.
|
|
4670
|
+
writesBlocked: refusalBlocked || offlineCapped,
|
|
4671
|
+
// Offline editing state (doc 15 §2) — see the CollabSession doc. Null
|
|
4672
|
+
// whenever a refusal already owns the story: the two must never both
|
|
4673
|
+
// claim the banner.
|
|
4674
|
+
offline: offlineActive ? { editsHeld: offlineHeld, capped: offlineCapped } : null,
|
|
4675
|
+
// Left UNSET by a disconnect on purpose. These four values describe what
|
|
4676
|
+
// the SERVER decided about this participant, and a client that cannot
|
|
4677
|
+
// reach the server has not been told anything new — reporting a stale
|
|
4678
|
+
// `allowed` beside `writesBlocked: true` would be a straight
|
|
4679
|
+
// contradiction, and inventing a fifth value would put a transport fault
|
|
4680
|
+
// into a permissions vocabulary. `connection` is where that story lives.
|
|
4681
|
+
writeStatus: myWrite,
|
|
4682
|
+
connection,
|
|
4683
|
+
reconnect,
|
|
4684
|
+
retryWrites: () => setReadOnlyBlocked(false),
|
|
4685
|
+
epochChanged,
|
|
4686
|
+
roster,
|
|
4687
|
+
setProfile: (p) => connRef.current?.setProfile(p),
|
|
4688
|
+
activity: connRef.current?.activity ?? [],
|
|
4689
|
+
admin: (action) => connRef.current?.admin(action),
|
|
4690
|
+
selfHeals,
|
|
4691
|
+
droppedPreReady,
|
|
4692
|
+
persistErrors,
|
|
4693
|
+
storeSlow,
|
|
4694
|
+
sendFailures,
|
|
4695
|
+
arrival,
|
|
4696
|
+
reconcile: (mode) => {
|
|
4697
|
+
const conn = connRef.current;
|
|
4698
|
+
const tail = offlineTailRef.current ?? [];
|
|
4699
|
+
if (!conn || !tail.length) {
|
|
4700
|
+
offlineTailRef.current = void 0;
|
|
4701
|
+
offlineEpochRef.current = null;
|
|
4702
|
+
setOfflineHeld(0);
|
|
4703
|
+
setArrival(null);
|
|
4704
|
+
return;
|
|
4705
|
+
}
|
|
4706
|
+
setArrival(null);
|
|
4707
|
+
if (mode === "suggest") {
|
|
4708
|
+
runTailReplay(conn, "suggest");
|
|
4709
|
+
} else {
|
|
4710
|
+
offlineTailRef.current = void 0;
|
|
4711
|
+
offlineEpochRef.current = null;
|
|
4712
|
+
setOfflineHeld(0);
|
|
4713
|
+
void persisterRef.current?.flush();
|
|
4714
|
+
}
|
|
4715
|
+
}
|
|
4716
|
+
};
|
|
4717
|
+
}
|
|
4718
|
+
var COLLAB_TOOLBAR_DEFAULTS = {
|
|
4719
|
+
// Images ride the doc-16 media relay (bytes out of band, address committed
|
|
4720
|
+
// in the intent). The rest of the upload surface stays closed per the demo
|
|
4721
|
+
// threat model — no object upload, no stored-payload surface.
|
|
4722
|
+
icon: false,
|
|
4723
|
+
screenshot: false,
|
|
4724
|
+
model3D: false,
|
|
4725
|
+
media: false,
|
|
4726
|
+
object: false,
|
|
4727
|
+
history: false,
|
|
4728
|
+
// toolbar undo/redo drives the LOCAL history; collaborative undo is server-side
|
|
4729
|
+
drawing: false,
|
|
4730
|
+
// ink strokes have no intent yet
|
|
4731
|
+
arrange: false
|
|
4732
|
+
// selected-object arrange ops are not collab-anchored yet
|
|
4733
|
+
};
|
|
4734
|
+
function CollabEditor(opts) {
|
|
4735
|
+
const session = useCollab(opts);
|
|
4736
|
+
useEffect(() => {
|
|
4737
|
+
opts.onSession?.(session);
|
|
4738
|
+
}, [session.version, session.ready, session.refused, session.roster, session.epochChanged, session.sessionWarning, session.writesBlocked, session.connection]);
|
|
4739
|
+
const [api, setApi] = useState(null);
|
|
4740
|
+
const pendingCaretRef = useRef(null);
|
|
4741
|
+
const seenEpochRef = useRef(session.docEpoch);
|
|
4742
|
+
if (session.docEpoch !== seenEpochRef.current) {
|
|
4743
|
+
seenEpochRef.current = session.docEpoch;
|
|
4744
|
+
pendingCaretRef.current = api?.getEncodedCaret() ?? null;
|
|
4745
|
+
}
|
|
4746
|
+
const bytes = useMemo(
|
|
4747
|
+
() => session.refused && session.doc ? session.doc.save() : null,
|
|
4748
|
+
[session.refused, session.doc, session.docEpoch]
|
|
4749
|
+
);
|
|
4750
|
+
const liveSource = useMemo(() => new Uint8Array(0), []);
|
|
4751
|
+
if (session.refused) {
|
|
4752
|
+
const docVisible = !!(session.doc && bytes);
|
|
4753
|
+
const content = opts.refusedContent ? opts.refusedContent(session.refused, { docVisible }) : createElement(
|
|
4754
|
+
"div",
|
|
4755
|
+
// Legible over a rendered page in the overlay case; inert extra
|
|
4756
|
+
// styling on the plain full-screen path costs nothing.
|
|
4757
|
+
docVisible ? { style: { margin: 16, padding: "10px 14px", width: "fit-content", background: "rgba(255,255,255,.95)", borderRadius: 8, font: "14px system-ui", boxShadow: "0 2px 8px rgba(0,0,0,.25)" } } : void 0,
|
|
4758
|
+
`Please refresh \u2014 ${session.refused}.`
|
|
4759
|
+
);
|
|
4760
|
+
if (!docVisible) {
|
|
4761
|
+
return createElement("div", { className: "dxw-collab-refused" }, content);
|
|
4762
|
+
}
|
|
4763
|
+
return createElement(
|
|
4764
|
+
"div",
|
|
4765
|
+
{ className: "dxw-collab-refused-host", style: { position: "relative", height: "100%" } },
|
|
4766
|
+
createElement(DocxView, {
|
|
4767
|
+
source: bytes,
|
|
4768
|
+
// NO `collab` prop: a refused session is dead — no socket, nothing to
|
|
4769
|
+
// submit to — so this is a plain document view, not a live editor.
|
|
4770
|
+
// And the SAME single gate as the live path below: refusal folds into
|
|
4771
|
+
// writesBlocked (see CollabSession), so this is always false here —
|
|
4772
|
+
// an editable surface over a dead session would apply keystrokes
|
|
4773
|
+
// locally and lose them.
|
|
4774
|
+
editable: (opts.editable ?? true) && !session.writesBlocked,
|
|
4775
|
+
style: { height: "100%" }
|
|
4776
|
+
}),
|
|
4777
|
+
// The overlay covers the document (blocking pointer interaction with a
|
|
4778
|
+
// dead page); the host's content styles itself on top of it. The
|
|
4779
|
+
// load-bearing `dxw-collab-refused` class keeps its meaning — "the
|
|
4780
|
+
// element holding the refusal content" — with the overlay placement
|
|
4781
|
+
// carried by the additional class. DELIBERATELY NO z-index: it would
|
|
4782
|
+
// create a stacking context that traps a host's `position: fixed`
|
|
4783
|
+
// dialog/scrim at this layer, silently re-scoping the host's own
|
|
4784
|
+
// z-values. DOM order (after the DocxView) already paints it above the
|
|
4785
|
+
// document.
|
|
4786
|
+
createElement(
|
|
4787
|
+
"div",
|
|
4788
|
+
{ className: "dxw-collab-refused dxw-collab-refused-overlay", style: { position: "absolute", inset: 0 } },
|
|
4789
|
+
content
|
|
4790
|
+
)
|
|
4791
|
+
);
|
|
4792
|
+
}
|
|
4793
|
+
if (!session.ready || !session.doc) {
|
|
4794
|
+
return createElement(ConnectingNotice, null);
|
|
4795
|
+
}
|
|
4796
|
+
const view = createElement(DocxView, {
|
|
4797
|
+
source: liveSource,
|
|
4798
|
+
// height:100% is LOAD-BEARING for virtualization (the refused path below
|
|
4799
|
+
// already passes it): DocxView's outer wrapper only gets a height when
|
|
4800
|
+
// the caller passes one. Without it the wrapper grows to the document's
|
|
4801
|
+
// full height, its overflow:auto container never scrolls, and the
|
|
4802
|
+
// virtualizer mounts EVERY page — each keystroke then pays O(total
|
|
4803
|
+
// pages) in DOM scans (measured: p50 52ms at 500 pages, 10ms bounded).
|
|
4804
|
+
style: { height: "100%" },
|
|
4805
|
+
onReady: (a) => {
|
|
4806
|
+
setApi(a);
|
|
4807
|
+
opts.onReady?.(a);
|
|
4808
|
+
const caret = pendingCaretRef.current;
|
|
4809
|
+
if (caret) {
|
|
4810
|
+
pendingCaretRef.current = null;
|
|
4811
|
+
a.setCaretFromEncoded(caret);
|
|
4812
|
+
}
|
|
4813
|
+
},
|
|
4814
|
+
// Render the live doc object directly; repaint in place on each version
|
|
4815
|
+
// bump. submit + presence + id allocator flow out; DocxView draws carets.
|
|
4816
|
+
collab: {
|
|
4817
|
+
submit: session.submit,
|
|
4818
|
+
submitOp: session.submitOp,
|
|
4819
|
+
presence: session.presence,
|
|
4820
|
+
allocIds: session.allocIds,
|
|
4821
|
+
// Image bytes go out of band over the relay (doc 16); its presence is
|
|
4822
|
+
// what makes the toolbar's image button real in a room.
|
|
4823
|
+
uploadMedia: session.uploadMedia,
|
|
4824
|
+
mediaMaxBlobBytes: session.mediaMaxBlobBytes,
|
|
4825
|
+
// Cmd+Z: reverse my last sequenced action over the wire.
|
|
4826
|
+
undoLast: () => {
|
|
4827
|
+
session.undoLast();
|
|
4828
|
+
},
|
|
4829
|
+
doc: session.doc,
|
|
4830
|
+
// The repaint signal is renderVersion, NOT version: version moves on
|
|
4831
|
+
// every onChange (including the editor's own submit + echo, which the
|
|
4832
|
+
// editor already painted), and DocxView answers each renderSignal move
|
|
4833
|
+
// with a whole-document relayout — behind an inert container past the
|
|
4834
|
+
// background-layout page threshold. Riding version made every keystroke
|
|
4835
|
+
// in a 500-page room queue that relayout: the "collab editor is
|
|
4836
|
+
// unusable on a big document" report.
|
|
4837
|
+
renderSignal: session.renderVersion,
|
|
4838
|
+
// The dirty scope behind renderSignal: a remote text edit repaints as
|
|
4839
|
+
// ONE incremental paragraph relayout instead of the whole document.
|
|
4840
|
+
takeRenderScope: session.takeRenderScope,
|
|
4841
|
+
// Outbound presence: the editor reports caret moves; remote tabs draw
|
|
4842
|
+
// this user's cursor (inbound presence above draws theirs here).
|
|
4843
|
+
setPresence: session.setPresence,
|
|
4844
|
+
// Name flags on remote carets (doc 14 §2): presence and roster share
|
|
4845
|
+
// the bound-clientId keyspace, so this join is exact.
|
|
4846
|
+
participantNames: Object.fromEntries(session.roster.map((r) => [r.clientId, r.profile.name]))
|
|
4847
|
+
},
|
|
4848
|
+
// VIEWER MODE when the server will not take this client's writes. Not a
|
|
4849
|
+
// banner over a live editor: that shape applied every keystroke locally
|
|
4850
|
+
// and lost it, which is why the read-only banner could be on screen while
|
|
4851
|
+
// the user's text appeared and then vanished.
|
|
4852
|
+
editable: (opts.editable ?? true) && !session.writesBlocked,
|
|
4853
|
+
// Re-key only on docEpoch (a true-conflict reload) — NOT on every change.
|
|
4854
|
+
// Between reloads the live doc mutates in place and the key stays stable,
|
|
4855
|
+
// so DocxView repaints in place instead of re-mounting (no flash/jump).
|
|
4856
|
+
key: session.docEpoch
|
|
4857
|
+
});
|
|
4858
|
+
if (opts.toolbar === false) return view;
|
|
4859
|
+
return createElement(
|
|
4860
|
+
"div",
|
|
4861
|
+
{ className: "dxw-collab-shell", style: { display: "flex", flexDirection: "column", height: "100%" } },
|
|
4862
|
+
createElement(DocxToolbar, {
|
|
4863
|
+
api,
|
|
4864
|
+
mode: opts.toolbarMode ?? "advanced",
|
|
4865
|
+
features: { ...COLLAB_TOOLBAR_DEFAULTS, ...opts.toolbarFeatures }
|
|
4866
|
+
}),
|
|
4867
|
+
createElement("div", { style: { flex: 1, minHeight: 0 } }, view)
|
|
4868
|
+
);
|
|
4869
|
+
}
|
|
4870
|
+
function ConnectingNotice() {
|
|
4871
|
+
const [slow, setSlow] = useState(false);
|
|
4872
|
+
useEffect(() => {
|
|
4873
|
+
const t = setTimeout(() => setSlow(true), 5e3);
|
|
4874
|
+
return () => clearTimeout(t);
|
|
4875
|
+
}, []);
|
|
4876
|
+
return createElement(
|
|
4877
|
+
"div",
|
|
4878
|
+
{
|
|
4879
|
+
className: "dxw-collab-connecting",
|
|
4880
|
+
style: { padding: 16, font: "14px system-ui" },
|
|
4881
|
+
role: "status",
|
|
4882
|
+
"aria-live": "polite",
|
|
4883
|
+
"aria-busy": "true"
|
|
4884
|
+
},
|
|
4885
|
+
createElement("span", { className: "dxw-collab-spinner", "aria-hidden": "true", key: "s" }),
|
|
4886
|
+
slow ? "Still connecting \u2014 is the collab server running? (start it, then reload this page)" : "Connecting\u2026"
|
|
4887
|
+
);
|
|
4888
|
+
}
|
|
4889
|
+
export {
|
|
4890
|
+
BundlePersister,
|
|
4891
|
+
COLLAB_TOOLBAR_DEFAULTS,
|
|
4892
|
+
CollabEditor,
|
|
4893
|
+
InMemoryBundleStore,
|
|
4894
|
+
IndexedDbBundleStore,
|
|
4895
|
+
bytesToB64,
|
|
4896
|
+
deriveEpochKeys,
|
|
4897
|
+
docHash,
|
|
4898
|
+
docKeyFromFragment,
|
|
4899
|
+
draftKey,
|
|
4900
|
+
mediaAddressesOf,
|
|
4901
|
+
mintDocKey,
|
|
4902
|
+
parseBundleKey,
|
|
4903
|
+
sealCheckpoint,
|
|
4904
|
+
stretchShareCode,
|
|
4905
|
+
supersededKey,
|
|
4906
|
+
useCollab,
|
|
4907
|
+
versionKey
|
|
4908
|
+
};
|
|
4909
|
+
//# sourceMappingURL=collab.js.map
|