@panphora/clayjs 1.5.1 → 1.5.2
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/clay.standalone.js +119 -88
- package/package.json +1 -1
- package/src/core/etag.js +4 -1
- package/src/core/save-conflict-notice.js +1 -0
- package/src/core/save-core.js +4 -0
- package/src/core/save.js +35 -3
- package/src/core/snapshot.js +1 -1
- package/src/sync/live-sync.js +104 -91
package/dist/clay.standalone.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* clayjs 1.5.
|
|
1
|
+
/* clayjs 1.5.2 standalone build: every clayjs module in one file. https://clayjs.com/offline
|
|
2
2
|
Third-party code inside keeps its own license (Sortable MIT, Squire MIT, DOMPurify Apache-2.0 OR MPL-2.0, MicroModal MIT, parse5 MIT): https://clayjs.com/THIRD-PARTY-NOTICES.md */
|
|
3
3
|
(() => {
|
|
4
4
|
var __create = Object.create;
|
|
@@ -1555,7 +1555,7 @@
|
|
|
1555
1555
|
const clone = captureSnapshot({ flushUndo });
|
|
1556
1556
|
if (emitForSync) {
|
|
1557
1557
|
document.dispatchEvent(new CustomEvent("clay:snapshot-ready", {
|
|
1558
|
-
detail: { documentElement: clone }
|
|
1558
|
+
detail: { documentElement: clone, forSave: true }
|
|
1559
1559
|
}));
|
|
1560
1560
|
}
|
|
1561
1561
|
runAuthoredHandlers(clone, "onbeforesave");
|
|
@@ -1898,7 +1898,7 @@
|
|
|
1898
1898
|
async function seedEtag({ fresh = false, clearIfMissing = fresh } = {}) {
|
|
1899
1899
|
const at3 = generation;
|
|
1900
1900
|
const meta = await hostMeta({ fresh });
|
|
1901
|
-
conditional = meta.extensions.includes("conditional");
|
|
1901
|
+
if (typeof meta.spec === "number") conditional = meta.extensions.includes("conditional");
|
|
1902
1902
|
if (generation !== at3) return lastSeen;
|
|
1903
1903
|
const seed = meta.document?.etag;
|
|
1904
1904
|
if (typeof seed === "string" && seed !== "") {
|
|
@@ -2005,6 +2005,10 @@
|
|
|
2005
2005
|
// A refusal that may be answering this tab's own timed-out write. Only the
|
|
2006
2006
|
// notice uses it, and only to word itself; nothing decides anything on it.
|
|
2007
2007
|
afterTimeout: conflicted && unknownAttempt !== null,
|
|
2008
|
+
// The stamp the host refused this save against: the version that beat it.
|
|
2009
|
+
// Live sync compares it with the frames it merges to know when the refusal
|
|
2010
|
+
// is answered.
|
|
2011
|
+
conflictEtag: conflicted ? err.etag ?? null : null,
|
|
2008
2012
|
etag: null
|
|
2009
2013
|
};
|
|
2010
2014
|
}
|
|
@@ -2377,6 +2381,20 @@
|
|
|
2377
2381
|
}
|
|
2378
2382
|
});
|
|
2379
2383
|
|
|
2384
|
+
// src/lib/autosave-state.js
|
|
2385
|
+
function setAutosaveActive(value) {
|
|
2386
|
+
active = !!value;
|
|
2387
|
+
}
|
|
2388
|
+
function autosaveActive() {
|
|
2389
|
+
return active;
|
|
2390
|
+
}
|
|
2391
|
+
var active;
|
|
2392
|
+
var init_autosave_state = __esm({
|
|
2393
|
+
"src/lib/autosave-state.js"() {
|
|
2394
|
+
active = false;
|
|
2395
|
+
}
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2380
2398
|
// src/lib/autosave-debug.js
|
|
2381
2399
|
function isDebugEnabled() {
|
|
2382
2400
|
try {
|
|
@@ -2479,6 +2497,7 @@
|
|
|
2479
2497
|
var save_exports = {};
|
|
2480
2498
|
__export(save_exports, {
|
|
2481
2499
|
addDocumentTransform: () => addDocumentTransform,
|
|
2500
|
+
conflictResolvedBySync: () => conflictResolvedBySync,
|
|
2482
2501
|
default: () => save_default,
|
|
2483
2502
|
getLastSavedBytes: () => getLastSavedBytes,
|
|
2484
2503
|
getLastSavedContents: () => getLastSavedContents,
|
|
@@ -2533,7 +2552,8 @@
|
|
|
2533
2552
|
autosaveMissed = false;
|
|
2534
2553
|
savePageThrottled();
|
|
2535
2554
|
}
|
|
2536
|
-
function holdForConflict() {
|
|
2555
|
+
function holdForConflict(etag) {
|
|
2556
|
+
conflictEtag = etag ?? null;
|
|
2537
2557
|
if (conflictHold) return;
|
|
2538
2558
|
conflictHold = true;
|
|
2539
2559
|
suspendAutosave();
|
|
@@ -2541,11 +2561,28 @@
|
|
|
2541
2561
|
function releaseConflictHold() {
|
|
2542
2562
|
if (!conflictHold) return;
|
|
2543
2563
|
conflictHold = false;
|
|
2564
|
+
conflictEtag = null;
|
|
2544
2565
|
resumeAutosave();
|
|
2545
2566
|
}
|
|
2546
2567
|
function isSaveConflicted() {
|
|
2547
2568
|
return conflictHold;
|
|
2548
2569
|
}
|
|
2570
|
+
function conflictResolvedBySync(etag) {
|
|
2571
|
+
if (!conflictHold) return;
|
|
2572
|
+
if (conflictEtag && etag !== conflictEtag) return;
|
|
2573
|
+
conflictHold = false;
|
|
2574
|
+
conflictEtag = null;
|
|
2575
|
+
const awaitingManualSave = !autosaveActive() && pageMaybeDirty();
|
|
2576
|
+
if (!awaitingManualSave) {
|
|
2577
|
+
if (document.documentElement.getAttribute("savestatus") === "conflict") {
|
|
2578
|
+
document.documentElement.setAttribute("savestatus", "saved");
|
|
2579
|
+
}
|
|
2580
|
+
document.dispatchEvent(new CustomEvent("clay:save-conflict-resolved", {
|
|
2581
|
+
detail: { timestamp: Date.now() }
|
|
2582
|
+
}));
|
|
2583
|
+
}
|
|
2584
|
+
resumeAutosave();
|
|
2585
|
+
}
|
|
2549
2586
|
function skipped_(msg) {
|
|
2550
2587
|
return { ok: false, msg, msgType: "skipped", code: null, etag: null };
|
|
2551
2588
|
}
|
|
@@ -2560,10 +2597,11 @@
|
|
|
2560
2597
|
logBaseline(label, `${lastSavedContents.length} chars`);
|
|
2561
2598
|
releaseConflictHold();
|
|
2562
2599
|
} else if (result2.msgType === "conflict") {
|
|
2563
|
-
holdForConflict();
|
|
2600
|
+
holdForConflict(result2.conflictEtag);
|
|
2564
2601
|
setSaveState("conflict", result2.msg, result2.msgType, {
|
|
2565
2602
|
changedBy: result2.changedBy ?? null,
|
|
2566
|
-
afterTimeout: result2.afterTimeout === true
|
|
2603
|
+
afterTimeout: result2.afterTimeout === true,
|
|
2604
|
+
etag: result2.conflictEtag ?? null
|
|
2567
2605
|
});
|
|
2568
2606
|
} else if (result2.msgType !== "skipped") {
|
|
2569
2607
|
if (!navigator.onLine) {
|
|
@@ -2818,7 +2856,7 @@
|
|
|
2818
2856
|
initSaveKeyboardShortcut();
|
|
2819
2857
|
initHyperclaySaveButton();
|
|
2820
2858
|
}
|
|
2821
|
-
var savingTimeout, unsavedChanges, lastSavedContents, lastSavedDirty, pendingSave, autosaveSuspended, autosaveMissed, conflictHold, lastSavedBytes, throttledSave, baselineContents, baselineActive, SETTLE_MS, MAX_SETTLE_MS, save_default;
|
|
2859
|
+
var savingTimeout, unsavedChanges, lastSavedContents, lastSavedDirty, pendingSave, autosaveSuspended, autosaveMissed, conflictHold, conflictEtag, lastSavedBytes, throttledSave, baselineContents, baselineActive, SETTLE_MS, MAX_SETTLE_MS, save_default;
|
|
2822
2860
|
var init_save = __esm({
|
|
2823
2861
|
"src/core/save.js"() {
|
|
2824
2862
|
init_throttle();
|
|
@@ -2828,6 +2866,7 @@
|
|
|
2828
2866
|
init_snapshot();
|
|
2829
2867
|
init_etag();
|
|
2830
2868
|
init_dirty_gate();
|
|
2869
|
+
init_autosave_state();
|
|
2831
2870
|
init_root_attrs();
|
|
2832
2871
|
init_autosave_debug();
|
|
2833
2872
|
init_user_gesture();
|
|
@@ -2856,6 +2895,7 @@
|
|
|
2856
2895
|
autosaveSuspended = 0;
|
|
2857
2896
|
autosaveMissed = false;
|
|
2858
2897
|
conflictHold = false;
|
|
2898
|
+
conflictEtag = null;
|
|
2859
2899
|
lastSavedBytes = null;
|
|
2860
2900
|
throttledSave = throttle_default(savePage, 1200);
|
|
2861
2901
|
baselineContents = "";
|
|
@@ -3012,6 +3052,7 @@
|
|
|
3012
3052
|
if (!isEditMode) return;
|
|
3013
3053
|
document.addEventListener("clay:save-conflict", show);
|
|
3014
3054
|
document.addEventListener("clay:save-saved", hide);
|
|
3055
|
+
document.addEventListener("clay:save-conflict-resolved", hide);
|
|
3015
3056
|
document.addEventListener("keydown", onKeydown);
|
|
3016
3057
|
}
|
|
3017
3058
|
var BG2, INK2, EDGE2, WARN, FONT2, SOURCES, ARM_MS, root2, line, keep, drop, armTimer, armed, busy, still;
|
|
@@ -3369,20 +3410,6 @@
|
|
|
3369
3410
|
}
|
|
3370
3411
|
});
|
|
3371
3412
|
|
|
3372
|
-
// src/lib/autosave-state.js
|
|
3373
|
-
function setAutosaveActive(value) {
|
|
3374
|
-
active = !!value;
|
|
3375
|
-
}
|
|
3376
|
-
function autosaveActive() {
|
|
3377
|
-
return active;
|
|
3378
|
-
}
|
|
3379
|
-
var active;
|
|
3380
|
-
var init_autosave_state = __esm({
|
|
3381
|
-
"src/lib/autosave-state.js"() {
|
|
3382
|
-
active = false;
|
|
3383
|
-
}
|
|
3384
|
-
});
|
|
3385
|
-
|
|
3386
3413
|
// src/core/autosave.js
|
|
3387
3414
|
var autosave_exports = {};
|
|
3388
3415
|
__export(autosave_exports, {
|
|
@@ -12222,8 +12249,6 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12222
12249
|
this._conflictTickets = /* @__PURE__ */ new WeakMap();
|
|
12223
12250
|
this.clientId = this.generateClientId();
|
|
12224
12251
|
this.resumeId = null;
|
|
12225
|
-
this.debounceMs = 150;
|
|
12226
|
-
this.debounceTimer = null;
|
|
12227
12252
|
this._savedSnapshot = null;
|
|
12228
12253
|
this._sendInFlight = false;
|
|
12229
12254
|
this._queuedSend = null;
|
|
@@ -12248,6 +12273,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12248
12273
|
this._lastExternalSeq = 0;
|
|
12249
12274
|
this._saveEpoch = 0;
|
|
12250
12275
|
this._saveSavedHandler = null;
|
|
12276
|
+
this._saveConflictHandler = null;
|
|
12251
12277
|
this._lastIdentityMap = null;
|
|
12252
12278
|
this._applyGen = 0;
|
|
12253
12279
|
this._holdRetryPeer = null;
|
|
@@ -12348,6 +12374,18 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12348
12374
|
this._relayCommit();
|
|
12349
12375
|
};
|
|
12350
12376
|
document.addEventListener("clay:save-saved", this._saveSavedHandler);
|
|
12377
|
+
this._saveConflictHandler = (event) => {
|
|
12378
|
+
const refusedBy = event.detail && event.detail.etag;
|
|
12379
|
+
if (!refusedBy) return;
|
|
12380
|
+
queueMicrotask(() => {
|
|
12381
|
+
if (this.isDestroyed) return;
|
|
12382
|
+
if (refusedBy !== lastSeenEtag()) return;
|
|
12383
|
+
if (this.unresolvedConflicts.length) return;
|
|
12384
|
+
conflictResolvedBySync(refusedBy);
|
|
12385
|
+
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
12386
|
+
});
|
|
12387
|
+
};
|
|
12388
|
+
document.addEventListener("clay:save-conflict", this._saveConflictHandler);
|
|
12351
12389
|
}
|
|
12352
12390
|
}
|
|
12353
12391
|
/**
|
|
@@ -12368,7 +12406,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12368
12406
|
document.removeEventListener("clay:save-saved", this._saveSavedHandler);
|
|
12369
12407
|
this._saveSavedHandler = null;
|
|
12370
12408
|
}
|
|
12371
|
-
|
|
12409
|
+
if (this._saveConflictHandler) {
|
|
12410
|
+
document.removeEventListener("clay:save-conflict", this._saveConflictHandler);
|
|
12411
|
+
this._saveConflictHandler = null;
|
|
12412
|
+
}
|
|
12372
12413
|
this._queuedSend = null;
|
|
12373
12414
|
if (this._rafHandle != null) {
|
|
12374
12415
|
this._cancelFrame(this._rafHandle);
|
|
@@ -12548,9 +12589,17 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12548
12589
|
return;
|
|
12549
12590
|
}
|
|
12550
12591
|
const etag = typeof data.etag === "string" && data.etag ? data.etag : null;
|
|
12592
|
+
if (this.lane === "live" && !etag && conditionalSaves()) {
|
|
12593
|
+
this._log(`Dropping an unstamped live frame on a stamping host (seq=${seq})`);
|
|
12594
|
+
return;
|
|
12595
|
+
}
|
|
12551
12596
|
if (etag && html === this.lastHtml) {
|
|
12552
12597
|
this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
|
|
12553
12598
|
recordEtag(etag);
|
|
12599
|
+
if (isSaveConflicted() && this.unresolvedConflicts.length === 0) {
|
|
12600
|
+
conflictResolvedBySync(etag);
|
|
12601
|
+
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
12602
|
+
}
|
|
12554
12603
|
return;
|
|
12555
12604
|
}
|
|
12556
12605
|
this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
|
|
@@ -12573,115 +12622,71 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12573
12622
|
*/
|
|
12574
12623
|
listenForSnapshots() {
|
|
12575
12624
|
this._snapshotHandler = (event) => {
|
|
12625
|
+
if (!event.detail || event.detail.forSave !== true) return;
|
|
12576
12626
|
this._saveTicket = this._ticket();
|
|
12577
|
-
if (this.isPaused) {
|
|
12578
|
-
this._log("snapshot-ready received but isPaused, skipping");
|
|
12579
|
-
return;
|
|
12580
|
-
}
|
|
12581
12627
|
const { documentElement: clone } = event.detail;
|
|
12582
12628
|
if (!clone) return;
|
|
12583
12629
|
this._log("snapshot-ready received, preparing to send");
|
|
12584
12630
|
const html = serializeForSync(clone);
|
|
12585
12631
|
const identityMap = this.identity.exportMap(clone, originalSnapshotNode);
|
|
12586
12632
|
this._savedSnapshot = { html, identityMap };
|
|
12587
|
-
this.sendUpdate(html, identityMap);
|
|
12588
12633
|
};
|
|
12589
12634
|
document.addEventListener("clay:snapshot-ready", this._snapshotHandler);
|
|
12590
12635
|
}
|
|
12591
12636
|
/**
|
|
12592
|
-
* Send
|
|
12637
|
+
* Send a landed save's HTML to the relay (single-flight).
|
|
12593
12638
|
*
|
|
12594
12639
|
* One POST on the wire at a time, with at most one newer payload queued; a
|
|
12595
12640
|
* fresher snapshot replaces the queued one, because peers only ever need the
|
|
12596
12641
|
* latest state. Two concurrent fetches could reach the server in either order,
|
|
12597
12642
|
* and last-write-wins then stored the OLDER snapshot for every peer.
|
|
12598
12643
|
*/
|
|
12599
|
-
|
|
12600
|
-
clearTimeout(this.debounceTimer);
|
|
12601
|
-
this.debounceTimer = setTimeout(() => {
|
|
12602
|
-
this._enqueueSend(html, identityMap);
|
|
12603
|
-
}, this.debounceMs);
|
|
12604
|
-
}
|
|
12605
|
-
_enqueueSend(html, identityMap) {
|
|
12644
|
+
_enqueueSend(html, identityMap, etag = null) {
|
|
12606
12645
|
if (!this._profile) {
|
|
12607
|
-
this._queuedSend = { html, identityMap };
|
|
12646
|
+
this._queuedSend = { html, identityMap, etag };
|
|
12608
12647
|
this._resolveProfile().then(() => this._flushQueuedSend());
|
|
12609
12648
|
return;
|
|
12610
12649
|
}
|
|
12611
12650
|
if (this._sendInFlight) {
|
|
12612
|
-
this._queuedSend = { html, identityMap };
|
|
12651
|
+
this._queuedSend = { html, identityMap, etag };
|
|
12613
12652
|
return;
|
|
12614
12653
|
}
|
|
12615
|
-
this._postUpdate(html, identityMap);
|
|
12654
|
+
this._postUpdate(html, identityMap, etag);
|
|
12616
12655
|
}
|
|
12617
12656
|
_flushQueuedSend() {
|
|
12618
12657
|
if (this.isDestroyed || this._sendInFlight) return;
|
|
12619
12658
|
const queued = this._queuedSend;
|
|
12620
12659
|
if (!queued) return;
|
|
12621
12660
|
this._queuedSend = null;
|
|
12622
|
-
this._postUpdate(queued.html, queued.identityMap);
|
|
12661
|
+
this._postUpdate(queued.html, queued.identityMap, queued.etag);
|
|
12623
12662
|
}
|
|
12624
12663
|
/**
|
|
12625
12664
|
* Tell the other editors what this tab's save stored, and under which stamp.
|
|
12626
12665
|
*
|
|
12627
12666
|
* Runs on clay:save-saved, by which point the save response has already been
|
|
12628
12667
|
* recorded, so `lastSeenEtag()` is the stamp for the bytes that just landed.
|
|
12629
|
-
*
|
|
12630
|
-
*
|
|
12631
|
-
*
|
|
12632
|
-
*
|
|
12633
|
-
* already gone out. Re-sending those same bytes is not redundant, because the
|
|
12634
|
-
* stamp is the new information and a stamp may never travel without the content
|
|
12635
|
-
* it describes. A receiver whose baseline already matches takes the stamp and
|
|
12636
|
-
* skips the morph.
|
|
12668
|
+
* The content is `_savedSnapshot`, captured by that save's own snapshot-ready
|
|
12669
|
+
* event, never `lastHtml`: a capture is not relayed until its save lands, so
|
|
12670
|
+
* peers only ever see versions the host accepted, each descending from the one
|
|
12671
|
+
* before. A host that returns no stamp still gets the relay, without one.
|
|
12637
12672
|
*/
|
|
12638
12673
|
_relayCommit() {
|
|
12674
|
+
if (this.isDestroyed) return;
|
|
12639
12675
|
const etag = lastSeenEtag();
|
|
12640
|
-
if (!etag) return;
|
|
12641
|
-
if (this.isDestroyed || this.isPaused) return;
|
|
12642
12676
|
const pending2 = this._savedSnapshot;
|
|
12643
12677
|
this._savedSnapshot = null;
|
|
12644
12678
|
if (!pending2 || typeof pending2.html !== "string") return;
|
|
12645
|
-
this.
|
|
12646
|
-
}
|
|
12647
|
-
/**
|
|
12648
|
-
* Post a snapshot whose point is the stamp attached to it.
|
|
12649
|
-
*
|
|
12650
|
-
* Deliberately not `_postUpdate`: that one returns early when the html matches
|
|
12651
|
-
* `lastHtml`, and the whole point here is that the stamp is the new information
|
|
12652
|
-
* even when the bytes are not. It also does not touch `lastHtml` or the
|
|
12653
|
-
* single-flight queue, because it must not displace a real snapshot waiting to
|
|
12654
|
-
* go out. It carries the identityMap captured with these bytes, so a receiver
|
|
12655
|
-
* that morphs on this frame pairs elements the same way it would on the
|
|
12656
|
-
* ordinary relay of the same content.
|
|
12657
|
-
*/
|
|
12658
|
-
_postCommit(html, etag, identityMap) {
|
|
12659
|
-
const profile = this._profile;
|
|
12660
|
-
if (!profile) return;
|
|
12661
|
-
fetch(new URL(profile.relayPath, window.location.origin).href, {
|
|
12662
|
-
method: "POST",
|
|
12663
|
-
headers: {
|
|
12664
|
-
"Content-Type": "application/json",
|
|
12665
|
-
[profile.documentHeader]: window.location.href
|
|
12666
|
-
},
|
|
12667
|
-
body: JSON.stringify({
|
|
12668
|
-
[profile.snapshotKey]: html,
|
|
12669
|
-
sender: this.clientId,
|
|
12670
|
-
identityMap,
|
|
12671
|
-
etag
|
|
12672
|
-
})
|
|
12673
|
-
}).catch((err) => {
|
|
12674
|
-
this._log("Commit relay failed: " + (err && err.message));
|
|
12675
|
-
});
|
|
12679
|
+
this._enqueueSend(pending2.html, pending2.identityMap, etag || null);
|
|
12676
12680
|
}
|
|
12677
|
-
_postUpdate(html, identityMap) {
|
|
12678
|
-
if (html === this.lastHtml) {
|
|
12681
|
+
_postUpdate(html, identityMap, etag = null, attempt = 0) {
|
|
12682
|
+
if (!etag && html === this.lastHtml) {
|
|
12679
12683
|
this._log("Skipping send - HTML unchanged");
|
|
12680
12684
|
return;
|
|
12681
12685
|
}
|
|
12682
12686
|
this._log(`Sending update (HTML length: ${html.length}, lastHtml length: ${this.lastHtml?.length || 0})`);
|
|
12683
12687
|
this._sendInFlight = true;
|
|
12684
12688
|
const gen = this._applyGen;
|
|
12689
|
+
let retry = false;
|
|
12685
12690
|
const profile = this._profile || WIRE_PROFILES.legacy;
|
|
12686
12691
|
fetch(new URL(profile.relayPath, window.location.origin).href, {
|
|
12687
12692
|
method: "POST",
|
|
@@ -12692,7 +12697,8 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12692
12697
|
body: JSON.stringify({
|
|
12693
12698
|
[profile.snapshotKey]: html,
|
|
12694
12699
|
sender: this.clientId,
|
|
12695
|
-
identityMap
|
|
12700
|
+
identityMap,
|
|
12701
|
+
...etag ? { etag } : {}
|
|
12696
12702
|
})
|
|
12697
12703
|
}).then((response) => {
|
|
12698
12704
|
if (response.ok) {
|
|
@@ -12704,15 +12710,30 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
12704
12710
|
}
|
|
12705
12711
|
} else {
|
|
12706
12712
|
console.warn("[LiveSync] Save returned status:", response.status);
|
|
12713
|
+
retry = response.status >= 500 || response.status === 408 || response.status === 429;
|
|
12707
12714
|
}
|
|
12708
12715
|
}).catch((err) => {
|
|
12709
12716
|
console.error("[LiveSync] Save failed:", err);
|
|
12710
12717
|
if (this.onError) this.onError(err);
|
|
12718
|
+
retry = true;
|
|
12711
12719
|
}).finally(() => {
|
|
12712
12720
|
this._sendInFlight = false;
|
|
12713
12721
|
const queued = this._queuedSend;
|
|
12714
12722
|
this._queuedSend = null;
|
|
12715
|
-
if (queued)
|
|
12723
|
+
if (queued) {
|
|
12724
|
+
this._postUpdate(queued.html, queued.identityMap, queued.etag);
|
|
12725
|
+
return;
|
|
12726
|
+
}
|
|
12727
|
+
if (!retry || !etag || attempt >= 3 || this.isDestroyed) return;
|
|
12728
|
+
this._sendInFlight = true;
|
|
12729
|
+
setTimeout(() => {
|
|
12730
|
+
this._sendInFlight = false;
|
|
12731
|
+
if (this.isDestroyed) return;
|
|
12732
|
+
const newer = this._queuedSend;
|
|
12733
|
+
this._queuedSend = null;
|
|
12734
|
+
if (newer) this._postUpdate(newer.html, newer.identityMap, newer.etag);
|
|
12735
|
+
else this._postUpdate(html, identityMap, etag, attempt + 1);
|
|
12736
|
+
}, 500 * 2 ** attempt);
|
|
12716
12737
|
});
|
|
12717
12738
|
}
|
|
12718
12739
|
/**
|
|
@@ -13070,6 +13091,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13070
13091
|
pauseGate();
|
|
13071
13092
|
let diverged = false;
|
|
13072
13093
|
let typedDuringWait = false;
|
|
13094
|
+
let stamped = false;
|
|
13073
13095
|
try {
|
|
13074
13096
|
if (this.lane === "live" && pageMaybeDirty() && this.lastHtml === null) {
|
|
13075
13097
|
console.log("[LiveSync] Holding incoming update: unsaved local edits and no baseline to merge against");
|
|
@@ -13103,7 +13125,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13103
13125
|
this.lastHtml = html;
|
|
13104
13126
|
this._lastIdentityMap = identityMap && typeof identityMap === "object" && !Array.isArray(identityMap) ? identityMap : null;
|
|
13105
13127
|
this._applyGen++;
|
|
13106
|
-
if (typeof etag === "string" && etag)
|
|
13128
|
+
if (typeof etag === "string" && etag) {
|
|
13129
|
+
recordEtag(etag);
|
|
13130
|
+
stamped = true;
|
|
13131
|
+
}
|
|
13107
13132
|
if (this.lane === "live" && !diverged && !pageMaybeDirty() && this.unresolvedConflicts.length === 0) {
|
|
13108
13133
|
const { forSave, forComparison, forDirty } = captureForSaveAndComparison({
|
|
13109
13134
|
emitForSync: false,
|
|
@@ -13122,6 +13147,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13122
13147
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
13123
13148
|
this.isPaused = false;
|
|
13124
13149
|
}
|
|
13150
|
+
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
13125
13151
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
13126
13152
|
}
|
|
13127
13153
|
/**
|
|
@@ -13148,6 +13174,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13148
13174
|
pauseGate();
|
|
13149
13175
|
let diverged = false;
|
|
13150
13176
|
let typedDuringWait = false;
|
|
13177
|
+
let stamped = false;
|
|
13151
13178
|
try {
|
|
13152
13179
|
if (this.lane === "live" && pageMaybeDirty() && this._diskBase === null) {
|
|
13153
13180
|
console.log("[LiveSync] Holding external change: unsaved local edits and no baseline to merge against");
|
|
@@ -13173,7 +13200,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13173
13200
|
typedDuringWait = typed;
|
|
13174
13201
|
this._setDiskBase(html, ticket);
|
|
13175
13202
|
window.scrollTo(scrollX, scrollY);
|
|
13176
|
-
if (typeof etag === "string" && etag)
|
|
13203
|
+
if (typeof etag === "string" && etag) {
|
|
13204
|
+
recordEtag(etag);
|
|
13205
|
+
stamped = true;
|
|
13206
|
+
}
|
|
13177
13207
|
if (this.lane === "live" && !diverged && !pageMaybeDirty() && this.unresolvedConflicts.length === 0) {
|
|
13178
13208
|
setLastSavedBaselines(...pairedBaseline());
|
|
13179
13209
|
setUnsavedChanges(false);
|
|
@@ -13201,6 +13231,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho
|
|
|
13201
13231
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
13202
13232
|
this.isPaused = false;
|
|
13203
13233
|
}
|
|
13234
|
+
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
13204
13235
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
13205
13236
|
}
|
|
13206
13237
|
/**
|
package/package.json
CHANGED
package/src/core/etag.js
CHANGED
|
@@ -82,7 +82,10 @@ export function forgetEtag() {
|
|
|
82
82
|
export async function seedEtag({ fresh = false, clearIfMissing = fresh } = {}) {
|
|
83
83
|
const at = generation;
|
|
84
84
|
const meta = await hostMeta({ fresh });
|
|
85
|
-
|
|
85
|
+
// Only a real answer (spec is a number) says what the host supports: a refresh
|
|
86
|
+
// that failed comes back as a bare host with no extensions, and must not switch
|
|
87
|
+
// conditional saves off for the rest of the page's life.
|
|
88
|
+
if (typeof meta.spec === "number") conditional = meta.extensions.includes("conditional");
|
|
86
89
|
|
|
87
90
|
if (generation !== at) return lastSeen;
|
|
88
91
|
|
|
@@ -185,6 +185,7 @@ function init() {
|
|
|
185
185
|
if (!isEditMode) return;
|
|
186
186
|
document.addEventListener("clay:save-conflict", show);
|
|
187
187
|
document.addEventListener("clay:save-saved", hide);
|
|
188
|
+
document.addEventListener("clay:save-conflict-resolved", hide);
|
|
188
189
|
document.addEventListener("keydown", onKeydown);
|
|
189
190
|
}
|
|
190
191
|
|
package/src/core/save-core.js
CHANGED
|
@@ -208,6 +208,10 @@ function errorResult(err) {
|
|
|
208
208
|
// A refusal that may be answering this tab's own timed-out write. Only the
|
|
209
209
|
// notice uses it, and only to word itself; nothing decides anything on it.
|
|
210
210
|
afterTimeout: conflicted && unknownAttempt !== null,
|
|
211
|
+
// The stamp the host refused this save against: the version that beat it.
|
|
212
|
+
// Live sync compares it with the frames it merges to know when the refusal
|
|
213
|
+
// is answered.
|
|
214
|
+
conflictEtag: conflicted ? (err.etag ?? null) : null,
|
|
211
215
|
etag: null
|
|
212
216
|
};
|
|
213
217
|
}
|
package/src/core/save.js
CHANGED
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
} from "./save-core.js";
|
|
23
23
|
import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
|
|
24
24
|
import { seedEtag } from "./etag.js";
|
|
25
|
-
import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
|
|
25
|
+
import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
|
|
26
|
+
import { autosaveActive } from "../lib/autosave-state.js";
|
|
26
27
|
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
|
|
27
28
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
28
29
|
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
@@ -227,8 +228,10 @@ export function resumeAutosave() {
|
|
|
227
228
|
// The hold is released when a save lands, whatever produced it: clay.save.overwrite,
|
|
228
229
|
// a live-sync frame that brought the page back in step, or the other tab going away.
|
|
229
230
|
let conflictHold = false;
|
|
231
|
+
let conflictEtag = null;
|
|
230
232
|
|
|
231
|
-
function holdForConflict() {
|
|
233
|
+
function holdForConflict(etag) {
|
|
234
|
+
conflictEtag = etag ?? null;
|
|
232
235
|
if (conflictHold) return;
|
|
233
236
|
conflictHold = true;
|
|
234
237
|
suspendAutosave();
|
|
@@ -237,6 +240,7 @@ function holdForConflict() {
|
|
|
237
240
|
function releaseConflictHold() {
|
|
238
241
|
if (!conflictHold) return;
|
|
239
242
|
conflictHold = false;
|
|
243
|
+
conflictEtag = null;
|
|
240
244
|
resumeAutosave();
|
|
241
245
|
}
|
|
242
246
|
|
|
@@ -245,6 +249,33 @@ export function isSaveConflicted() {
|
|
|
245
249
|
return conflictHold;
|
|
246
250
|
}
|
|
247
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Live sync merged the version the host refused this tab over, and the tab now
|
|
254
|
+
* holds that version's stamp, so the next save carries a stamp the host accepts.
|
|
255
|
+
* Only that version answers the refusal: a frame with any other stamp (an older
|
|
256
|
+
* save arriving late) leaves the hold alone. Autosave resumes, replaying one
|
|
257
|
+
* missed save. On a manual-save page with unsaved work the root stays in
|
|
258
|
+
* 'conflict' and the notice stays up until the person saves, since nothing has
|
|
259
|
+
* been written yet. Not clay:save-saved: no save happened, and that event runs
|
|
260
|
+
* every [onaftersave] handler.
|
|
261
|
+
*/
|
|
262
|
+
export function conflictResolvedBySync(etag) {
|
|
263
|
+
if (!conflictHold) return;
|
|
264
|
+
if (conflictEtag && etag !== conflictEtag) return;
|
|
265
|
+
conflictHold = false;
|
|
266
|
+
conflictEtag = null;
|
|
267
|
+
const awaitingManualSave = !autosaveActive() && pageMaybeDirty();
|
|
268
|
+
if (!awaitingManualSave) {
|
|
269
|
+
if (document.documentElement.getAttribute('savestatus') === 'conflict') {
|
|
270
|
+
document.documentElement.setAttribute('savestatus', 'saved');
|
|
271
|
+
}
|
|
272
|
+
document.dispatchEvent(new CustomEvent('clay:save-conflict-resolved', {
|
|
273
|
+
detail: { timestamp: Date.now() }
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
resumeAutosave();
|
|
277
|
+
}
|
|
278
|
+
|
|
248
279
|
function skipped_(msg) {
|
|
249
280
|
return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
|
|
250
281
|
}
|
|
@@ -275,10 +306,11 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
|
|
|
275
306
|
logBaseline(label, `${lastSavedContents.length} chars`);
|
|
276
307
|
releaseConflictHold();
|
|
277
308
|
} else if (result.msgType === 'conflict') {
|
|
278
|
-
holdForConflict();
|
|
309
|
+
holdForConflict(result.conflictEtag);
|
|
279
310
|
setSaveState('conflict', result.msg, result.msgType, {
|
|
280
311
|
changedBy: result.changedBy ?? null,
|
|
281
312
|
afterTimeout: result.afterTimeout === true,
|
|
313
|
+
etag: result.conflictEtag ?? null,
|
|
282
314
|
});
|
|
283
315
|
} else if (result.msgType !== 'skipped') {
|
|
284
316
|
if (!navigator.onLine) {
|
package/src/core/snapshot.js
CHANGED
|
@@ -464,7 +464,7 @@ export function captureForSaveAndComparison({ emitForSync = true, flushUndo = tr
|
|
|
464
464
|
// and a baseline the dirty check cannot reproduce warns on close forever.
|
|
465
465
|
if (emitForSync) {
|
|
466
466
|
document.dispatchEvent(new CustomEvent('clay:snapshot-ready', {
|
|
467
|
-
detail: { documentElement: clone }
|
|
467
|
+
detail: { documentElement: clone, forSave: true }
|
|
468
468
|
}));
|
|
469
469
|
}
|
|
470
470
|
|
package/src/sync/live-sync.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* ▼
|
|
12
12
|
* ┌─────────────────────────────────────────────────────────┐
|
|
13
13
|
* │ 2. SEND POST snapshot to the relay address │
|
|
14
|
-
* │
|
|
14
|
+
* │ (after the save lands, with its stamp) │
|
|
15
15
|
* └─────────────────────────────────────────────────────────┘
|
|
16
16
|
* │
|
|
17
17
|
* ▼
|
|
@@ -55,7 +55,7 @@ import { presence } from './presence.js';
|
|
|
55
55
|
// `clay:sync-applied`, which this file is the only dispatcher of.
|
|
56
56
|
import './section-notice.js';
|
|
57
57
|
import { hostMeta } from '../core/host-meta.js';
|
|
58
|
-
import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
|
|
58
|
+
import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves } from '../core/etag.js';
|
|
59
59
|
import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged, gateMarkDirty } from '../lib/dirty-gate.js';
|
|
60
60
|
import { SyncStream } from './stream.js';
|
|
61
61
|
|
|
@@ -144,7 +144,7 @@ function pairedBaseline() {
|
|
|
144
144
|
const { forComparison, forDirty } = captureForComparisonAndDirty({ flushUndo: false });
|
|
145
145
|
return [forComparison, forDirty];
|
|
146
146
|
}
|
|
147
|
-
import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes } from '../core/save.js';
|
|
147
|
+
import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges, getLastSavedBytes, conflictResolvedBySync, isSaveConflicted } from '../core/save.js';
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
150
|
* The two live-sync wires, and the rule for choosing between them.
|
|
@@ -227,15 +227,12 @@ class LiveSync {
|
|
|
227
227
|
// durable per-tab identity used for echo suppression.
|
|
228
228
|
this.resumeId = null;
|
|
229
229
|
|
|
230
|
-
this.debounceMs = 150;
|
|
231
|
-
this.debounceTimer = null;
|
|
232
|
-
|
|
233
230
|
// The sync serialization of the clone the CURRENT save was captured from,
|
|
234
231
|
// held so the commit relay can pair the host's stamp with the content that
|
|
235
232
|
// stamp actually describes. Set at snapshot-ready, which fires before the
|
|
236
233
|
// save POST goes out, and consumed by _relayCommit when the response lands.
|
|
237
234
|
// Never reconstructed from lastHtml: that is the last relay that COMPLETED,
|
|
238
|
-
// which lags the save whenever
|
|
235
|
+
// which lags the save whenever an earlier relay is still in flight, and
|
|
239
236
|
// pairing a fresh stamp with older bytes is the one thing spec §10 forbids.
|
|
240
237
|
this._savedSnapshot = null;
|
|
241
238
|
this._sendInFlight = false;
|
|
@@ -305,6 +302,7 @@ class LiveSync {
|
|
|
305
302
|
// equal our state.
|
|
306
303
|
this._saveEpoch = 0;
|
|
307
304
|
this._saveSavedHandler = null;
|
|
305
|
+
this._saveConflictHandler = null;
|
|
308
306
|
|
|
309
307
|
// The identityMap that came with lastHtml, so the peer-lane dirty diff
|
|
310
308
|
// can resolve synthetic identities on its base tree.
|
|
@@ -467,6 +465,23 @@ class LiveSync {
|
|
|
467
465
|
this._relayCommit();
|
|
468
466
|
};
|
|
469
467
|
document.addEventListener('clay:save-saved', this._saveSavedHandler);
|
|
468
|
+
// A 412 can arrive after this tab already merged the version that beat it:
|
|
469
|
+
// the winner's relay overtook the refusal. The refusal then names a stamp
|
|
470
|
+
// this tab already holds, and nothing else would ever release the hold.
|
|
471
|
+
// Deferred a microtask so the conflict notice's own listener has shown the
|
|
472
|
+
// bar before this hides it.
|
|
473
|
+
this._saveConflictHandler = (event) => {
|
|
474
|
+
const refusedBy = event.detail && event.detail.etag;
|
|
475
|
+
if (!refusedBy) return;
|
|
476
|
+
queueMicrotask(() => {
|
|
477
|
+
if (this.isDestroyed) return;
|
|
478
|
+
if (refusedBy !== lastSeenEtag()) return;
|
|
479
|
+
if (this.unresolvedConflicts.length) return;
|
|
480
|
+
conflictResolvedBySync(refusedBy);
|
|
481
|
+
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
document.addEventListener('clay:save-conflict', this._saveConflictHandler);
|
|
470
485
|
}
|
|
471
486
|
}
|
|
472
487
|
|
|
@@ -494,7 +509,11 @@ class LiveSync {
|
|
|
494
509
|
this._saveSavedHandler = null;
|
|
495
510
|
}
|
|
496
511
|
|
|
497
|
-
|
|
512
|
+
if (this._saveConflictHandler) {
|
|
513
|
+
document.removeEventListener('clay:save-conflict', this._saveConflictHandler);
|
|
514
|
+
this._saveConflictHandler = null;
|
|
515
|
+
}
|
|
516
|
+
|
|
498
517
|
this._queuedSend = null;
|
|
499
518
|
|
|
500
519
|
// Cancel any pending frame and clear the queue. A morph already in
|
|
@@ -751,6 +770,14 @@ class LiveSync {
|
|
|
751
770
|
|
|
752
771
|
const etag = typeof data.etag === 'string' && data.etag ? data.etag : null;
|
|
753
772
|
|
|
773
|
+
// On a host that stamps saves, every landed save is relayed with its stamp.
|
|
774
|
+
// A live-lane frame without one is an older client's preview of a save that
|
|
775
|
+
// may yet be refused; the stamped frame follows if it lands.
|
|
776
|
+
if (this.lane === 'live' && !etag && conditionalSaves()) {
|
|
777
|
+
this._log(`Dropping an unstamped live frame on a stamping host (seq=${seq})`);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
754
781
|
// The common case, and it costs nothing: a peer saved bytes this tab has
|
|
755
782
|
// already applied, so the frame's only news is the stamp. Taking it without
|
|
756
783
|
// a morph is exactly as safe as taking it after one, because the baseline
|
|
@@ -758,6 +785,10 @@ class LiveSync {
|
|
|
758
785
|
if (etag && html === this.lastHtml) {
|
|
759
786
|
this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
|
|
760
787
|
recordEtag(etag);
|
|
788
|
+
if (isSaveConflicted() && this.unresolvedConflicts.length === 0) {
|
|
789
|
+
conflictResolvedBySync(etag);
|
|
790
|
+
this._saveAfterMerge(pageMaybeDirty(), false);
|
|
791
|
+
}
|
|
761
792
|
return;
|
|
762
793
|
}
|
|
763
794
|
|
|
@@ -785,15 +816,15 @@ class LiveSync {
|
|
|
785
816
|
*/
|
|
786
817
|
listenForSnapshots() {
|
|
787
818
|
this._snapshotHandler = (event) => {
|
|
819
|
+
// Only a save's own capture names what the next landed save stored, and
|
|
820
|
+
// when. A capture made for any other reason (the public captureForSave)
|
|
821
|
+
// must not replace the payload that save will relay, nor move its ticket.
|
|
822
|
+
if (!event.detail || event.detail.forSave !== true) return;
|
|
788
823
|
// Every save captures through this event, before its request goes out,
|
|
789
|
-
// so this is the moment the save's bytes were true of the document.
|
|
790
|
-
//
|
|
791
|
-
//
|
|
824
|
+
// so this is the moment the save's bytes were true of the document. A
|
|
825
|
+
// save made during a frame's await is still a save, and it is newer than
|
|
826
|
+
// that frame.
|
|
792
827
|
this._saveTicket = this._ticket();
|
|
793
|
-
if (this.isPaused) {
|
|
794
|
-
this._log('snapshot-ready received but isPaused, skipping');
|
|
795
|
-
return;
|
|
796
|
-
}
|
|
797
828
|
|
|
798
829
|
const { documentElement: clone } = event.detail;
|
|
799
830
|
if (!clone) return;
|
|
@@ -810,43 +841,33 @@ class LiveSync {
|
|
|
810
841
|
// content its stamp will describe. Held for _relayCommit, and overwritten
|
|
811
842
|
// by the next capture, so it always names the save currently in flight.
|
|
812
843
|
this._savedSnapshot = { html, identityMap };
|
|
813
|
-
|
|
814
|
-
this.sendUpdate(html, identityMap);
|
|
815
844
|
};
|
|
816
845
|
|
|
817
846
|
document.addEventListener('clay:snapshot-ready', this._snapshotHandler);
|
|
818
847
|
}
|
|
819
848
|
|
|
820
849
|
/**
|
|
821
|
-
* Send
|
|
850
|
+
* Send a landed save's HTML to the relay (single-flight).
|
|
822
851
|
*
|
|
823
852
|
* One POST on the wire at a time, with at most one newer payload queued; a
|
|
824
853
|
* fresher snapshot replaces the queued one, because peers only ever need the
|
|
825
854
|
* latest state. Two concurrent fetches could reach the server in either order,
|
|
826
855
|
* and last-write-wins then stored the OLDER snapshot for every peer.
|
|
827
856
|
*/
|
|
828
|
-
|
|
829
|
-
clearTimeout(this.debounceTimer);
|
|
830
|
-
|
|
831
|
-
this.debounceTimer = setTimeout(() => {
|
|
832
|
-
this._enqueueSend(html, identityMap);
|
|
833
|
-
}, this.debounceMs);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
_enqueueSend(html, identityMap) {
|
|
857
|
+
_enqueueSend(html, identityMap, etag = null) {
|
|
837
858
|
// Same one-deep queue the in-flight case uses, for the same reason: peers only
|
|
838
859
|
// ever need the newest state, so a fresher snapshot replaces the waiting one.
|
|
839
860
|
// Sending before the profile is known would have to guess an address.
|
|
840
861
|
if (!this._profile) {
|
|
841
|
-
this._queuedSend = { html, identityMap };
|
|
862
|
+
this._queuedSend = { html, identityMap, etag };
|
|
842
863
|
this._resolveProfile().then(() => this._flushQueuedSend());
|
|
843
864
|
return;
|
|
844
865
|
}
|
|
845
866
|
if (this._sendInFlight) {
|
|
846
|
-
this._queuedSend = { html, identityMap };
|
|
867
|
+
this._queuedSend = { html, identityMap, etag };
|
|
847
868
|
return;
|
|
848
869
|
}
|
|
849
|
-
this._postUpdate(html, identityMap);
|
|
870
|
+
this._postUpdate(html, identityMap, etag);
|
|
850
871
|
}
|
|
851
872
|
|
|
852
873
|
_flushQueuedSend() {
|
|
@@ -854,7 +875,7 @@ class LiveSync {
|
|
|
854
875
|
const queued = this._queuedSend;
|
|
855
876
|
if (!queued) return;
|
|
856
877
|
this._queuedSend = null;
|
|
857
|
-
this._postUpdate(queued.html, queued.identityMap);
|
|
878
|
+
this._postUpdate(queued.html, queued.identityMap, queued.etag);
|
|
858
879
|
}
|
|
859
880
|
|
|
860
881
|
/**
|
|
@@ -862,23 +883,14 @@ class LiveSync {
|
|
|
862
883
|
*
|
|
863
884
|
* Runs on clay:save-saved, by which point the save response has already been
|
|
864
885
|
* recorded, so `lastSeenEtag()` is the stamp for the bytes that just landed.
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
* already gone out. Re-sending those same bytes is not redundant, because the
|
|
870
|
-
* stamp is the new information and a stamp may never travel without the content
|
|
871
|
-
* it describes. A receiver whose baseline already matches takes the stamp and
|
|
872
|
-
* skips the morph.
|
|
886
|
+
* The content is `_savedSnapshot`, captured by that save's own snapshot-ready
|
|
887
|
+
* event, never `lastHtml`: a capture is not relayed until its save lands, so
|
|
888
|
+
* peers only ever see versions the host accepted, each descending from the one
|
|
889
|
+
* before. A host that returns no stamp still gets the relay, without one.
|
|
873
890
|
*/
|
|
874
891
|
_relayCommit() {
|
|
875
|
-
|
|
876
|
-
// that carried one, and a host that does not do conditional saves returns
|
|
877
|
-
// none, so this is the same test as "the host stamps what it stores" without
|
|
878
|
-
// a second flag that would have to be reached through discovery to exercise.
|
|
892
|
+
if (this.isDestroyed) return;
|
|
879
893
|
const etag = lastSeenEtag();
|
|
880
|
-
if (!etag) return;
|
|
881
|
-
if (this.isDestroyed || this.isPaused) return;
|
|
882
894
|
|
|
883
895
|
// The content this save stored, captured with it. Consumed rather than left
|
|
884
896
|
// behind, so a save-saved with no capture of its own can never reuse an
|
|
@@ -886,51 +898,18 @@ class LiveSync {
|
|
|
886
898
|
const pending = this._savedSnapshot;
|
|
887
899
|
this._savedSnapshot = null;
|
|
888
900
|
|
|
889
|
-
// No captured snapshot means nothing here knows which bytes this
|
|
890
|
-
//
|
|
891
|
-
// Staying silent costs the other editors one refusal they recover from;
|
|
892
|
-
// guessing costs somebody their work.
|
|
901
|
+
// No captured snapshot means nothing here knows which bytes this save
|
|
902
|
+
// stored, and §10 is explicit that a stamp must never travel on its own.
|
|
893
903
|
if (!pending || typeof pending.html !== 'string') return;
|
|
894
904
|
|
|
895
|
-
this.
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
/**
|
|
899
|
-
* Post a snapshot whose point is the stamp attached to it.
|
|
900
|
-
*
|
|
901
|
-
* Deliberately not `_postUpdate`: that one returns early when the html matches
|
|
902
|
-
* `lastHtml`, and the whole point here is that the stamp is the new information
|
|
903
|
-
* even when the bytes are not. It also does not touch `lastHtml` or the
|
|
904
|
-
* single-flight queue, because it must not displace a real snapshot waiting to
|
|
905
|
-
* go out. It carries the identityMap captured with these bytes, so a receiver
|
|
906
|
-
* that morphs on this frame pairs elements the same way it would on the
|
|
907
|
-
* ordinary relay of the same content.
|
|
908
|
-
*/
|
|
909
|
-
_postCommit(html, etag, identityMap) {
|
|
910
|
-
const profile = this._profile;
|
|
911
|
-
if (!profile) return;
|
|
912
|
-
fetch(new URL(profile.relayPath, window.location.origin).href, {
|
|
913
|
-
method: 'POST',
|
|
914
|
-
headers: {
|
|
915
|
-
'Content-Type': 'application/json',
|
|
916
|
-
[profile.documentHeader]: window.location.href,
|
|
917
|
-
},
|
|
918
|
-
body: JSON.stringify({
|
|
919
|
-
[profile.snapshotKey]: html,
|
|
920
|
-
sender: this.clientId,
|
|
921
|
-
identityMap,
|
|
922
|
-
etag
|
|
923
|
-
})
|
|
924
|
-
}).catch(err => {
|
|
925
|
-
// Losing this costs the other editors one spurious refusal on their next
|
|
926
|
-
// save, which they recover from. It is not worth surfacing as an error.
|
|
927
|
-
this._log('Commit relay failed: ' + (err && err.message));
|
|
928
|
-
});
|
|
905
|
+
this._enqueueSend(pending.html, pending.identityMap, etag || null);
|
|
929
906
|
}
|
|
930
907
|
|
|
931
|
-
_postUpdate(html, identityMap) {
|
|
932
|
-
// Skip
|
|
933
|
-
|
|
908
|
+
_postUpdate(html, identityMap, etag = null, attempt = 0) {
|
|
909
|
+
// Skip only an unstamped repeat. A landed save is news even when its bytes
|
|
910
|
+
// equal the last relay: "Keep mine" can restore exactly those bytes after
|
|
911
|
+
// the peers moved on, and they must hear it.
|
|
912
|
+
if (!etag && html === this.lastHtml) {
|
|
934
913
|
this._log('Skipping send - HTML unchanged');
|
|
935
914
|
return;
|
|
936
915
|
}
|
|
@@ -939,6 +918,7 @@ class LiveSync {
|
|
|
939
918
|
|
|
940
919
|
this._sendInFlight = true;
|
|
941
920
|
const gen = this._applyGen;
|
|
921
|
+
let retry = false;
|
|
942
922
|
|
|
943
923
|
// Absolute against the real origin, so a <base href> in the page cannot
|
|
944
924
|
// redirect the whole document to an origin the author picked.
|
|
@@ -952,7 +932,8 @@ class LiveSync {
|
|
|
952
932
|
body: JSON.stringify({
|
|
953
933
|
[profile.snapshotKey]: html,
|
|
954
934
|
sender: this.clientId,
|
|
955
|
-
identityMap: identityMap
|
|
935
|
+
identityMap: identityMap,
|
|
936
|
+
...(etag ? { etag } : {})
|
|
956
937
|
})
|
|
957
938
|
}).then(response => {
|
|
958
939
|
if (response.ok) {
|
|
@@ -968,15 +949,33 @@ class LiveSync {
|
|
|
968
949
|
}
|
|
969
950
|
} else {
|
|
970
951
|
console.warn('[LiveSync] Save returned status:', response.status);
|
|
952
|
+
retry = response.status >= 500 || response.status === 408 || response.status === 429;
|
|
971
953
|
}
|
|
972
954
|
}).catch(err => {
|
|
973
955
|
console.error('[LiveSync] Save failed:', err);
|
|
974
956
|
if (this.onError) this.onError(err);
|
|
957
|
+
retry = true;
|
|
975
958
|
}).finally(() => {
|
|
976
959
|
this._sendInFlight = false;
|
|
977
960
|
const queued = this._queuedSend;
|
|
978
961
|
this._queuedSend = null;
|
|
979
|
-
if (queued)
|
|
962
|
+
if (queued) {
|
|
963
|
+
this._postUpdate(queued.html, queued.identityMap, queued.etag);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
// The relay of a landed save is the only one peers get, so a transient
|
|
967
|
+
// failure is retried a few times. A newer save queued meanwhile replaces
|
|
968
|
+
// it: peers only need the latest.
|
|
969
|
+
if (!retry || !etag || attempt >= 3 || this.isDestroyed) return;
|
|
970
|
+
this._sendInFlight = true;
|
|
971
|
+
setTimeout(() => {
|
|
972
|
+
this._sendInFlight = false;
|
|
973
|
+
if (this.isDestroyed) return;
|
|
974
|
+
const newer = this._queuedSend;
|
|
975
|
+
this._queuedSend = null;
|
|
976
|
+
if (newer) this._postUpdate(newer.html, newer.identityMap, newer.etag);
|
|
977
|
+
else this._postUpdate(html, identityMap, etag, attempt + 1);
|
|
978
|
+
}, 500 * 2 ** attempt);
|
|
980
979
|
});
|
|
981
980
|
}
|
|
982
981
|
|
|
@@ -1445,6 +1444,7 @@ class LiveSync {
|
|
|
1445
1444
|
|
|
1446
1445
|
let diverged = false;
|
|
1447
1446
|
let typedDuringWait = false;
|
|
1447
|
+
let stamped = false;
|
|
1448
1448
|
try {
|
|
1449
1449
|
// Hold the whole frame only when this tab has unsaved edits and no
|
|
1450
1450
|
// baseline to merge them against (the first frame of a fresh
|
|
@@ -1522,7 +1522,10 @@ class LiveSync {
|
|
|
1522
1522
|
// above, and deliberately the same moment: the two claims a page makes
|
|
1523
1523
|
// when it adopts a stamp are "the host stores this version" and "I hold
|
|
1524
1524
|
// it", and only the second one is this tab's to make.
|
|
1525
|
-
if (typeof etag === 'string' && etag)
|
|
1525
|
+
if (typeof etag === 'string' && etag) {
|
|
1526
|
+
recordEtag(etag);
|
|
1527
|
+
stamped = true;
|
|
1528
|
+
}
|
|
1526
1529
|
|
|
1527
1530
|
// Cross-lane baseline: the DOM now holds this frame's content, but the
|
|
1528
1531
|
// DISK baseline (lastSavedContents) still describes pre-frame state. A
|
|
@@ -1593,9 +1596,12 @@ class LiveSync {
|
|
|
1593
1596
|
// edits + their frame) that exists only in this DOM. Push it out explicitly
|
|
1594
1597
|
// — the morph ran under Mutation.pause, so no autosave was triggered, and a
|
|
1595
1598
|
// pending autosave debounce may already have fired mid-flight. Runs after
|
|
1596
|
-
// isPaused is lifted so the save's
|
|
1599
|
+
// isPaused is lifted so the convergence save's relay reaches peers.
|
|
1597
1600
|
// Only where autosave is on: a manual-save page must never auto-write, so
|
|
1598
1601
|
// there the merge stays local, still dirty, until the person saves.
|
|
1602
|
+
// Not over a lost conflict: the hold and its bar stay up as the signal that
|
|
1603
|
+
// this tab lost text, until the person saves.
|
|
1604
|
+
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
1599
1605
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1600
1606
|
}
|
|
1601
1607
|
|
|
@@ -1626,6 +1632,7 @@ class LiveSync {
|
|
|
1626
1632
|
|
|
1627
1633
|
let diverged = false;
|
|
1628
1634
|
let typedDuringWait = false;
|
|
1635
|
+
let stamped = false;
|
|
1629
1636
|
try {
|
|
1630
1637
|
// Hold the whole frame only when this tab has unsaved edits and no
|
|
1631
1638
|
// baseline to merge them against. Lane-guarded like the peer path: a
|
|
@@ -1682,7 +1689,10 @@ class LiveSync {
|
|
|
1682
1689
|
// A frame with no stamp (an older host, or the content-less fetch fallback,
|
|
1683
1690
|
// which serves bytes nobody stamped) leaves this alone, and the listener in
|
|
1684
1691
|
// etag.js falls back to asking the host.
|
|
1685
|
-
if (typeof etag === 'string' && etag)
|
|
1692
|
+
if (typeof etag === 'string' && etag) {
|
|
1693
|
+
recordEtag(etag);
|
|
1694
|
+
stamped = true;
|
|
1695
|
+
}
|
|
1686
1696
|
|
|
1687
1697
|
if (
|
|
1688
1698
|
this.lane === 'live' &&
|
|
@@ -1733,6 +1743,9 @@ class LiveSync {
|
|
|
1733
1743
|
// (our edits + their bytes). The baseline was left pre-external, so the
|
|
1734
1744
|
// save sees both as changes and writes the merge back. Only where
|
|
1735
1745
|
// autosave is on, as in the peer lane.
|
|
1746
|
+
// Not over a lost conflict: the hold and its bar stay up as the signal that
|
|
1747
|
+
// this tab lost text, until the person saves.
|
|
1748
|
+
if (stamped && this.unresolvedConflicts.length === 0) conflictResolvedBySync(etag);
|
|
1736
1749
|
this._saveAfterMerge(diverged, typedDuringWait);
|
|
1737
1750
|
}
|
|
1738
1751
|
|