wordinweb 0.1.21 → 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/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