@panphora/clayjs 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/THIRD-PARTY-NOTICES.md +10 -0
- package/dist/clay.standalone.js +21751 -13942
- package/entries/clay-data.js +1 -1
- package/package.json +7 -2
- package/packed-contract.json +17 -0
- package/src/attrs/save-freeze.js +10 -18
- package/src/core/admin-contenteditable.js +5 -6
- package/src/core/etag.js +17 -4
- package/src/core/host-attrs.js +52 -2
- package/src/core/is-edit-mode.js +14 -5
- package/src/core/persist.js +5 -10
- package/src/core/save-conflict-notice.js +22 -27
- package/src/core/save-core.js +326 -26
- package/src/core/save.js +19 -4
- package/src/core/snapshot.js +148 -15
- package/src/core/source-map.js +817 -0
- package/src/core/stale-host-notice.js +95 -0
- package/src/core/unsaved-warning.js +3 -0
- package/src/dom/dom-helpers.js +5 -1
- package/src/lib/content-dom.js +108 -0
- package/src/lib/hostile-css.js +49 -0
- package/src/lib/mutation.js +26 -3
- package/src/lib/region-capabilities.js +69 -0
- package/src/lib/region-policy.js +18 -13
- package/src/lib/root-attrs.js +52 -13
- package/src/loader-logic.js +25 -4
- package/src/loader.js +4 -0
- package/src/plugins/demo.js +3 -0
- package/src/plugins/sortable.js +6 -1
- package/src/plugins/source.js +326 -0
- package/src/plugins/wire.js +248 -47
- package/src/sync/live-sync.js +275 -63
- package/src/sync/presence.js +303 -0
- package/src/sync/section-notice.js +230 -0
- package/src/sync/splice-merge.js +7 -10
- package/src/sync/stream.js +190 -0
- package/src/vendor/hyper-morph.vendor.js +2 -2
- package/src/vendor/hyper-undo.vendor.js +1 -1
- package/src/vendor/hypercms.vendor.js +438 -45
- package/src/vendor/parse5.vendor.js +3 -0
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/src/vendor/richclay.vendor.js +22 -15
package/src/sync/live-sync.js
CHANGED
|
@@ -40,12 +40,17 @@ import Mutation from "../lib/mutation.js";
|
|
|
40
40
|
import { isSnapshotRemoved } from "../lib/region-policy.js";
|
|
41
41
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
42
42
|
import { mergeTagRecognizers } from "./merge-tags.js";
|
|
43
|
-
import { serializeForSync, captureForComparisonAndDirty, captureSnapshot } from '../core/snapshot.js';
|
|
43
|
+
import { serializeForSync, captureForComparisonAndDirty, captureSnapshot, originalSnapshotNode } from '../core/snapshot.js';
|
|
44
44
|
import { isTabLocalRootAttr } from '../lib/root-attrs.js';
|
|
45
45
|
import { protectPeerDoc, protectDiskDoc, activateIncomingDoc } from './splice-merge.js';
|
|
46
|
+
import { presence } from './presence.js';
|
|
47
|
+
// Side-effect import: the section-changed notice wires itself to
|
|
48
|
+
// `clay:sync-applied`, which this file is the only dispatcher of.
|
|
49
|
+
import './section-notice.js';
|
|
46
50
|
import { hostMeta } from '../core/host-meta.js';
|
|
47
|
-
import { recordEtag, seedEtag } from '../core/etag.js';
|
|
51
|
+
import { recordEtag, seedEtag, lastSeenEtag } from '../core/etag.js';
|
|
48
52
|
import { pageMaybeDirty, pauseGate, resumeGate } from '../lib/dirty-gate.js';
|
|
53
|
+
import { SyncStream } from './stream.js';
|
|
49
54
|
|
|
50
55
|
// The page just took a frame verified clean against its baseline, so it now IS
|
|
51
56
|
// the file on disk. Both saved baselines move together from one capture: leaving
|
|
@@ -75,6 +80,12 @@ import { savePageThrottled, setLastSavedBaselines, setUnsavedChanges } from '../
|
|
|
75
80
|
* One profile is chosen once and used for BOTH directions for the life of the page.
|
|
76
81
|
* Letting send and receive decide independently is exactly the bug this replaces: a
|
|
77
82
|
* client that posted to the spec address while streaming from the legacy one.
|
|
83
|
+
*
|
|
84
|
+
* Both stream addresses carry `client-id`, in the same kebab spelling as the rest of
|
|
85
|
+
* the query. It is the tab's own sender id — the one every outbound frame already
|
|
86
|
+
* carries — so a host that reads it at admission knows this connection by the value
|
|
87
|
+
* its frames arrive under, and can tell two tabs of one cookie-less guest apart. A
|
|
88
|
+
* host that does not read it ignores an unknown parameter.
|
|
78
89
|
*/
|
|
79
90
|
const WIRE_PROFILES = {
|
|
80
91
|
spec: {
|
|
@@ -82,8 +93,8 @@ const WIRE_PROFILES = {
|
|
|
82
93
|
relayPath: '/_/sync',
|
|
83
94
|
documentHeader: 'Document-URL',
|
|
84
95
|
snapshotKey: 'snapshot',
|
|
85
|
-
streamPath: (href, lane, resumeId) =>
|
|
86
|
-
`/_/sync?document-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
|
|
96
|
+
streamPath: (href, lane, resumeId, clientId) =>
|
|
97
|
+
`/_/sync?document-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}&client-id=${encodeURIComponent(clientId)}`,
|
|
87
98
|
},
|
|
88
99
|
// What every published clayjs speaks, and what hyperclayjs and the inline script in
|
|
89
100
|
// every Collection dashboard hardcode. Hosts keep these addresses permanently.
|
|
@@ -92,8 +103,8 @@ const WIRE_PROFILES = {
|
|
|
92
103
|
relayPath: '/_/live-sync/save',
|
|
93
104
|
documentHeader: 'Page-URL',
|
|
94
105
|
snapshotKey: 'html',
|
|
95
|
-
streamPath: (href, lane, resumeId) =>
|
|
96
|
-
`/_/live-sync/stream?page-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}`,
|
|
106
|
+
streamPath: (href, lane, resumeId, clientId) =>
|
|
107
|
+
`/_/live-sync/stream?page-url=${encodeURIComponent(href)}&lane=${lane}&resume-id=${resumeId}&client-id=${encodeURIComponent(clientId)}`,
|
|
97
108
|
},
|
|
98
109
|
};
|
|
99
110
|
|
|
@@ -114,6 +125,15 @@ class LiveSync {
|
|
|
114
125
|
|
|
115
126
|
this.debounceMs = 150;
|
|
116
127
|
this.debounceTimer = null;
|
|
128
|
+
|
|
129
|
+
// The sync serialization of the clone the CURRENT save was captured from,
|
|
130
|
+
// held so the commit relay can pair the host's stamp with the content that
|
|
131
|
+
// stamp actually describes. Set at snapshot-ready, which fires before the
|
|
132
|
+
// save POST goes out, and consumed by _relayCommit when the response lands.
|
|
133
|
+
// Never reconstructed from lastHtml: that is the last relay that COMPLETED,
|
|
134
|
+
// which lags the save whenever the response beats the 150ms debounce, and
|
|
135
|
+
// pairing a fresh stamp with older bytes is the one thing spec §10 forbids.
|
|
136
|
+
this._savedSnapshot = null;
|
|
117
137
|
this._sendInFlight = false;
|
|
118
138
|
this._queuedSend = null;
|
|
119
139
|
|
|
@@ -155,13 +175,22 @@ class LiveSync {
|
|
|
155
175
|
this._pendingHtml = null;
|
|
156
176
|
this._pendingSeq = null;
|
|
157
177
|
this._pendingIdentityMap = null;
|
|
178
|
+
// The stamp a pending frame carries, adopted only if that frame applies
|
|
179
|
+
// cleanly. It rides in the slot beside the bytes it describes so the two can
|
|
180
|
+
// never be separated, which is the whole rule (§6, and §22 of the plan).
|
|
181
|
+
this._pendingEtag = null;
|
|
182
|
+
// Who the server says sent the pending frame, `{ id, name }`. Rides in the
|
|
183
|
+
// slot for the same reason the stamp does: it describes these bytes and
|
|
184
|
+
// nothing else, so a newer frame replacing them takes its author with it.
|
|
185
|
+
// Reported on `clay:sync-applied` once the frame has actually applied.
|
|
186
|
+
this._pendingBy = null;
|
|
158
187
|
this._morphInFlight = false;
|
|
159
188
|
this._rafHandle = null;
|
|
160
189
|
|
|
161
190
|
// Disk-sourced external changes (htmlclay watcher content) get their own
|
|
162
191
|
// one-deep slot so a peer frame arriving in the same window can't
|
|
163
192
|
// displace them. Drained in seq order alongside the peer slot.
|
|
164
|
-
this._pendingExternal = null; // { html, seq, saveEpoch }
|
|
193
|
+
this._pendingExternal = null; // { html, seq, saveEpoch, etag }
|
|
165
194
|
|
|
166
195
|
// Newest external-change seq accepted for apply. Replayed or reordered
|
|
167
196
|
// disk frames (and their fetch fallbacks) are dropped against this.
|
|
@@ -274,6 +303,7 @@ class LiveSync {
|
|
|
274
303
|
// so mint a fresh resume id — this stream must not resume the previous one.
|
|
275
304
|
this.lastHtml = null;
|
|
276
305
|
this._lastIdentityMap = null;
|
|
306
|
+
this._savedSnapshot = null;
|
|
277
307
|
this._applyGen++;
|
|
278
308
|
this.lastSeenSeq = 0;
|
|
279
309
|
this._lastExternalSeq = 0;
|
|
@@ -295,7 +325,10 @@ class LiveSync {
|
|
|
295
325
|
// snapshot listener would never fire — skip registering it.
|
|
296
326
|
if (this.lane === 'live') {
|
|
297
327
|
this.listenForSnapshots();
|
|
298
|
-
this._saveSavedHandler = () => {
|
|
328
|
+
this._saveSavedHandler = () => {
|
|
329
|
+
this._saveEpoch++;
|
|
330
|
+
this._relayCommit();
|
|
331
|
+
};
|
|
299
332
|
document.addEventListener('clay:save-saved', this._saveSavedHandler);
|
|
300
333
|
}
|
|
301
334
|
}
|
|
@@ -310,6 +343,10 @@ class LiveSync {
|
|
|
310
343
|
this.sse = null;
|
|
311
344
|
}
|
|
312
345
|
|
|
346
|
+
// Nothing feeds the roster once the stream is gone, so leaving the stack up
|
|
347
|
+
// would show a list of people who may all have left.
|
|
348
|
+
presence.clear();
|
|
349
|
+
|
|
313
350
|
if (this._snapshotHandler) {
|
|
314
351
|
document.removeEventListener('clay:snapshot-ready', this._snapshotHandler);
|
|
315
352
|
this._snapshotHandler = null;
|
|
@@ -333,6 +370,7 @@ class LiveSync {
|
|
|
333
370
|
this._pendingHtml = null;
|
|
334
371
|
this._pendingSeq = null;
|
|
335
372
|
this._pendingIdentityMap = null;
|
|
373
|
+
this._pendingBy = null;
|
|
336
374
|
this._pendingExternal = null;
|
|
337
375
|
clearTimeout(this._holdRetryPeer);
|
|
338
376
|
clearTimeout(this._holdRetryExt);
|
|
@@ -365,33 +403,25 @@ class LiveSync {
|
|
|
365
403
|
const map = {};
|
|
366
404
|
if (!liveRoot || !cloneRoot) return map;
|
|
367
405
|
|
|
368
|
-
const visit = (
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
id = this.
|
|
372
|
-
|
|
406
|
+
const visit = (clone, path) => {
|
|
407
|
+
const live = originalSnapshotNode(clone);
|
|
408
|
+
if (live) {
|
|
409
|
+
let id = this.liveWeakMap.get(live);
|
|
410
|
+
if (!id) {
|
|
411
|
+
id = this._mintId();
|
|
412
|
+
this.liveWeakMap.set(live, id);
|
|
413
|
+
}
|
|
414
|
+
map[path] = id;
|
|
373
415
|
}
|
|
374
|
-
map[path] = id;
|
|
375
416
|
|
|
376
|
-
const liveKids = [];
|
|
377
|
-
for (const c of live.children) {
|
|
378
|
-
if (!isSnapshotRemoved(c)) liveKids.push(c);
|
|
379
|
-
}
|
|
380
417
|
const cloneKids = clone.children;
|
|
381
418
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
`identity map: subtree skipped at "${path}" (live=${liveKids.length}, clone=${cloneKids.length})`
|
|
385
|
-
);
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
for (let i = 0; i < liveKids.length; i++) {
|
|
390
|
-
visit(liveKids[i], cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
419
|
+
for (let i = 0; i < cloneKids.length; i++) {
|
|
420
|
+
visit(cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
391
421
|
}
|
|
392
422
|
};
|
|
393
423
|
|
|
394
|
-
visit(
|
|
424
|
+
visit(cloneRoot, '');
|
|
395
425
|
return map;
|
|
396
426
|
}
|
|
397
427
|
|
|
@@ -494,7 +524,11 @@ class LiveSync {
|
|
|
494
524
|
_resolveProfile() {
|
|
495
525
|
if (!this._profilePromise) {
|
|
496
526
|
this._profilePromise = hostMeta()
|
|
497
|
-
.then((meta) =>
|
|
527
|
+
.then((meta) => {
|
|
528
|
+
const extensions = meta?.extensions || [];
|
|
529
|
+
this._sharedSync = extensions.includes('sync') && extensions.includes('sync-worker') && !extensions.includes('presence');
|
|
530
|
+
return extensions.includes('sync') ? WIRE_PROFILES.spec : WIRE_PROFILES.legacy;
|
|
531
|
+
})
|
|
498
532
|
.catch(() => WIRE_PROFILES.legacy)
|
|
499
533
|
.then((profile) => {
|
|
500
534
|
this._profile = profile;
|
|
@@ -534,31 +568,32 @@ class LiveSync {
|
|
|
534
568
|
}
|
|
535
569
|
|
|
536
570
|
/**
|
|
537
|
-
*
|
|
571
|
+
* Drop a stamp that arrived with no document (spec §6).
|
|
538
572
|
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
573
|
+
* ⚠️ This used to TAKE it, and taking it was wrong. A stamp says "disk is at this
|
|
574
|
+
* version"; adopting it says "and I hold those bytes". Only the second claim
|
|
575
|
+
* makes the next save safe, and a frame with no content is no evidence for it.
|
|
542
576
|
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
577
|
+
* The race is real and it loses work. A peer's save and that peer's snapshot
|
|
578
|
+
* relay are two concurrent requests, so a stamp travelling on its own can arrive
|
|
579
|
+
* first. This tab would record it while its DOM still held the older content,
|
|
580
|
+
* and its next save would then pass If-Match and overwrite the save it had never
|
|
581
|
+
* received. The hold flags cannot cover that: a lane holds when an incoming
|
|
582
|
+
* change could NOT be merged, not when one has yet to arrive.
|
|
546
583
|
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
* IS the overwrite. Refusing the stamp turns it into a conflict somebody is
|
|
554
|
-
* told about.
|
|
584
|
+
* So a stamp now rides on the snapshot the saving tab relays after its save
|
|
585
|
+
* returns, and `_doApplyUpdate` records it only once that content has merged.
|
|
586
|
+
* Nothing sends a bare stamp any more — hyperclay's was deleted, and it had
|
|
587
|
+
* never reached anyone, because the relay library refuses a payload with no
|
|
588
|
+
* document in it. This stays as the rule rather than as compatibility: if some
|
|
589
|
+
* host does send one, dropping it is correct.
|
|
555
590
|
*
|
|
556
591
|
* @param {Object} data - The decoded frame
|
|
557
|
-
* @returns {boolean} True when this was a stamp frame and nothing should morph
|
|
592
|
+
* @returns {boolean} True when this was a stamp-only frame and nothing should morph
|
|
558
593
|
*/
|
|
559
594
|
_applyEtagFrame(data) {
|
|
560
595
|
if (typeof data.etag !== 'string' || typeof data.html === 'string') return false;
|
|
561
|
-
|
|
596
|
+
this._log('Dropping a stamp that arrived with no document: nothing proves this tab holds those bytes');
|
|
562
597
|
return true;
|
|
563
598
|
}
|
|
564
599
|
|
|
@@ -572,10 +607,14 @@ class LiveSync {
|
|
|
572
607
|
// Whichever wire discovery selected. start() does not call connect() until the
|
|
573
608
|
// profile is known, so this is never null on the normal path.
|
|
574
609
|
const profile = this._profile || WIRE_PROFILES.legacy;
|
|
575
|
-
const path = profile.streamPath(window.location.href, this.lane, this.resumeId);
|
|
610
|
+
const path = profile.streamPath(window.location.href, this.lane, this.resumeId, this.clientId);
|
|
576
611
|
// Resolved against the real origin: a <base href> in the authored document
|
|
577
612
|
// would otherwise point the sync stream at an origin the document chose.
|
|
578
|
-
this.sse = new
|
|
613
|
+
this.sse = new SyncStream(new URL(path, window.location.origin).href, {
|
|
614
|
+
shared: this._sharedSync,
|
|
615
|
+
documentURL: window.location.href,
|
|
616
|
+
lane: this.lane,
|
|
617
|
+
});
|
|
579
618
|
|
|
580
619
|
this.sse.onopen = () => {
|
|
581
620
|
console.log('[LiveSync] Connected');
|
|
@@ -610,6 +649,21 @@ class LiveSync {
|
|
|
610
649
|
});
|
|
611
650
|
});
|
|
612
651
|
|
|
652
|
+
// The roster is a NAMED event for the same reason the cursor frame is: a tab
|
|
653
|
+
// with no listener for it never sees a bare data line, so every published
|
|
654
|
+
// client is counted in the roster without being sent one. The frame is
|
|
655
|
+
// handed on untouched — who is named is the host's decision, taken per
|
|
656
|
+
// recipient from that recipient's own access, and nothing here can widen it.
|
|
657
|
+
this.sse.addEventListener('presence', (event) => {
|
|
658
|
+
let data;
|
|
659
|
+
try {
|
|
660
|
+
data = JSON.parse(event.data);
|
|
661
|
+
} catch {
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
presence.update(data);
|
|
665
|
+
});
|
|
666
|
+
|
|
613
667
|
this.sse.onmessage = (event) => {
|
|
614
668
|
const data = JSON.parse(event.data);
|
|
615
669
|
|
|
@@ -665,9 +719,21 @@ class LiveSync {
|
|
|
665
719
|
return;
|
|
666
720
|
}
|
|
667
721
|
|
|
722
|
+
const etag = typeof data.etag === 'string' && data.etag ? data.etag : null;
|
|
723
|
+
|
|
724
|
+
// The common case, and it costs nothing: a peer saved bytes this tab has
|
|
725
|
+
// already applied, so the frame's only news is the stamp. Taking it without
|
|
726
|
+
// a morph is exactly as safe as taking it after one, because the baseline
|
|
727
|
+
// being equal IS the proof that this tab holds those bytes.
|
|
728
|
+
if (etag && html === this.lastHtml) {
|
|
729
|
+
this._log(`Taking the stamp from a peer save of content already applied (seq=${seq})`);
|
|
730
|
+
recordEtag(etag);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
668
734
|
this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
|
|
669
|
-
this.applyUpdate(html, seq, identityMap);
|
|
670
|
-
if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap });
|
|
735
|
+
this.applyUpdate(html, seq, identityMap, etag, data.by);
|
|
736
|
+
if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap, etag });
|
|
671
737
|
};
|
|
672
738
|
|
|
673
739
|
// Native EventSource auto-reconnects on transient errors
|
|
@@ -704,6 +770,12 @@ class LiveSync {
|
|
|
704
770
|
this._log('snapshot-ready received, preparing to send');
|
|
705
771
|
const html = serializeForSync(clone);
|
|
706
772
|
const identityMap = this._buildIdentityMap(document.documentElement, clone);
|
|
773
|
+
|
|
774
|
+
// The save that follows this capture stores these bytes, so this is the
|
|
775
|
+
// content its stamp will describe. Held for _relayCommit, and overwritten
|
|
776
|
+
// by the next capture, so it always names the save currently in flight.
|
|
777
|
+
this._savedSnapshot = { html, identityMap };
|
|
778
|
+
|
|
707
779
|
this.sendUpdate(html, identityMap);
|
|
708
780
|
};
|
|
709
781
|
|
|
@@ -750,6 +822,77 @@ class LiveSync {
|
|
|
750
822
|
this._postUpdate(queued.html, queued.identityMap);
|
|
751
823
|
}
|
|
752
824
|
|
|
825
|
+
/**
|
|
826
|
+
* Tell the other editors what this tab's save stored, and under which stamp.
|
|
827
|
+
*
|
|
828
|
+
* Runs on clay:save-saved, by which point the save response has already been
|
|
829
|
+
* recorded, so `lastSeenEtag()` is the stamp for the bytes that just landed.
|
|
830
|
+
*
|
|
831
|
+
* The content is `lastHtml`, the snapshot this tab most recently relayed, which
|
|
832
|
+
* is the state the save was taken from: the save pipeline dispatches
|
|
833
|
+
* clay:snapshot-ready on its way to capturing what to send, so that relay has
|
|
834
|
+
* already gone out. Re-sending those same bytes is not redundant, because the
|
|
835
|
+
* stamp is the new information and a stamp may never travel without the content
|
|
836
|
+
* it describes. A receiver whose baseline already matches takes the stamp and
|
|
837
|
+
* skips the morph.
|
|
838
|
+
*/
|
|
839
|
+
_relayCommit() {
|
|
840
|
+
// Holding a stamp is the whole condition. It is set only from a save response
|
|
841
|
+
// that carried one, and a host that does not do conditional saves returns
|
|
842
|
+
// none, so this is the same test as "the host stamps what it stores" without
|
|
843
|
+
// a second flag that would have to be reached through discovery to exercise.
|
|
844
|
+
const etag = lastSeenEtag();
|
|
845
|
+
if (!etag) return;
|
|
846
|
+
if (this.isDestroyed || this.isPaused) return;
|
|
847
|
+
|
|
848
|
+
// The content this save stored, captured with it. Consumed rather than left
|
|
849
|
+
// behind, so a save-saved with no capture of its own can never reuse an
|
|
850
|
+
// older save's bytes under a newer stamp.
|
|
851
|
+
const pending = this._savedSnapshot;
|
|
852
|
+
this._savedSnapshot = null;
|
|
853
|
+
|
|
854
|
+
// No captured snapshot means nothing here knows which bytes this stamp
|
|
855
|
+
// describes, and §10 is explicit that a stamp must never travel on its own.
|
|
856
|
+
// Staying silent costs the other editors one refusal they recover from;
|
|
857
|
+
// guessing costs somebody their work.
|
|
858
|
+
if (!pending || typeof pending.html !== 'string') return;
|
|
859
|
+
|
|
860
|
+
this._postCommit(pending.html, etag, pending.identityMap);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Post a snapshot whose point is the stamp attached to it.
|
|
865
|
+
*
|
|
866
|
+
* Deliberately not `_postUpdate`: that one returns early when the html matches
|
|
867
|
+
* `lastHtml`, and the whole point here is that the stamp is the new information
|
|
868
|
+
* even when the bytes are not. It also does not touch `lastHtml` or the
|
|
869
|
+
* single-flight queue, because it must not displace a real snapshot waiting to
|
|
870
|
+
* go out. It carries the identityMap captured with these bytes, so a receiver
|
|
871
|
+
* that morphs on this frame pairs elements the same way it would on the
|
|
872
|
+
* ordinary relay of the same content.
|
|
873
|
+
*/
|
|
874
|
+
_postCommit(html, etag, identityMap) {
|
|
875
|
+
const profile = this._profile;
|
|
876
|
+
if (!profile) return;
|
|
877
|
+
fetch(new URL(profile.relayPath, window.location.origin).href, {
|
|
878
|
+
method: 'POST',
|
|
879
|
+
headers: {
|
|
880
|
+
'Content-Type': 'application/json',
|
|
881
|
+
[profile.documentHeader]: window.location.href,
|
|
882
|
+
},
|
|
883
|
+
body: JSON.stringify({
|
|
884
|
+
[profile.snapshotKey]: html,
|
|
885
|
+
sender: this.clientId,
|
|
886
|
+
identityMap,
|
|
887
|
+
etag
|
|
888
|
+
})
|
|
889
|
+
}).catch(err => {
|
|
890
|
+
// Losing this costs the other editors one spurious refusal on their next
|
|
891
|
+
// save, which they recover from. It is not worth surfacing as an error.
|
|
892
|
+
this._log('Commit relay failed: ' + (err && err.message));
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
753
896
|
_postUpdate(html, identityMap) {
|
|
754
897
|
// Skip if unchanged
|
|
755
898
|
if (html === this.lastHtml) {
|
|
@@ -817,7 +960,7 @@ class LiveSync {
|
|
|
817
960
|
const info = data.data;
|
|
818
961
|
if (info && info.kind === 'external-change') {
|
|
819
962
|
if (typeof info.html === 'string') {
|
|
820
|
-
this._enqueueExternal(info.html, data.seq);
|
|
963
|
+
this._enqueueExternal(info.html, data.seq, info.etag, info.by);
|
|
821
964
|
} else {
|
|
822
965
|
this._fetchExternalChange(data.seq);
|
|
823
966
|
}
|
|
@@ -830,7 +973,7 @@ class LiveSync {
|
|
|
830
973
|
return false;
|
|
831
974
|
}
|
|
832
975
|
|
|
833
|
-
_enqueueExternal(html, seq) {
|
|
976
|
+
_enqueueExternal(html, seq, etag, by) {
|
|
834
977
|
if (typeof seq === 'number') {
|
|
835
978
|
if (seq <= this._lastExternalSeq) {
|
|
836
979
|
this._log(`Dropping replayed external change: seq=${seq}`);
|
|
@@ -838,7 +981,17 @@ class LiveSync {
|
|
|
838
981
|
}
|
|
839
982
|
this._lastExternalSeq = seq;
|
|
840
983
|
}
|
|
841
|
-
this._pendingExternal = {
|
|
984
|
+
this._pendingExternal = {
|
|
985
|
+
html,
|
|
986
|
+
seq,
|
|
987
|
+
saveEpoch: this._saveEpoch,
|
|
988
|
+
etag: typeof etag === 'string' && etag ? etag : null,
|
|
989
|
+
// Carried for the same reason the stamp is, and null on every disk frame
|
|
990
|
+
// today: hyperclay stamps an author on the two live relays and nowhere
|
|
991
|
+
// else, so a change that arrived from the filesystem has no author to
|
|
992
|
+
// name. The slot carries it so a host that does stamp one is believed.
|
|
993
|
+
by: by ?? null,
|
|
994
|
+
};
|
|
842
995
|
this._scheduleNextFrame();
|
|
843
996
|
}
|
|
844
997
|
|
|
@@ -865,6 +1018,7 @@ class LiveSync {
|
|
|
865
1018
|
*/
|
|
866
1019
|
_fetchServedDocument(seq, { attempt = 0, repair = false } = {}) {
|
|
867
1020
|
const epoch = this._saveEpoch;
|
|
1021
|
+
if (repair && typeof seq !== 'number') seq = this._lastExternalSeq;
|
|
868
1022
|
fetch(new URL(window.location.href), { cache: 'no-store' })
|
|
869
1023
|
.then((response) => (response.ok ? response.text() : null))
|
|
870
1024
|
.then((html) => {
|
|
@@ -875,7 +1029,7 @@ class LiveSync {
|
|
|
875
1029
|
// said replay cannot fix this page, so if the newer fetch fails there is
|
|
876
1030
|
// nothing else coming. Refetch rather than drop the only repair.
|
|
877
1031
|
if (repair && attempt < 3) {
|
|
878
|
-
this._fetchServedDocument(
|
|
1032
|
+
this._fetchServedDocument(this._lastExternalSeq, { attempt: attempt + 1, repair });
|
|
879
1033
|
}
|
|
880
1034
|
return;
|
|
881
1035
|
}
|
|
@@ -889,7 +1043,11 @@ class LiveSync {
|
|
|
889
1043
|
}
|
|
890
1044
|
return;
|
|
891
1045
|
}
|
|
892
|
-
this
|
|
1046
|
+
// No stamp: this body came from a GET of the served page, which nobody
|
|
1047
|
+
// stamped, so the apply leaves the held stamp alone and etag.js asks the
|
|
1048
|
+
// host for a replacement. No author either, for the same reason — a GET
|
|
1049
|
+
// answers what disk holds, not who put it there.
|
|
1050
|
+
this._pendingExternal = { html, seq, saveEpoch: epoch, etag: null, by: null };
|
|
893
1051
|
this._scheduleNextFrame();
|
|
894
1052
|
})
|
|
895
1053
|
.catch((err) => {
|
|
@@ -916,12 +1074,19 @@ class LiveSync {
|
|
|
916
1074
|
* @param {string} html - Full document HTML
|
|
917
1075
|
* @param {number} [seq] - Optional monotonic seq from the server
|
|
918
1076
|
* @param {Object} [identityMap] - Optional element-identity map from sender
|
|
1077
|
+
* @param {string} [etag] - Optional version stamp for these bytes
|
|
1078
|
+
* @param {Object} [by] - Optional `{ id, name }` author stamp for these bytes
|
|
919
1079
|
*/
|
|
920
|
-
applyUpdate(html, seq, identityMap) {
|
|
1080
|
+
applyUpdate(html, seq, identityMap, etag, by) {
|
|
921
1081
|
if (this.isDestroyed) return;
|
|
922
1082
|
this._pendingHtml = html;
|
|
923
1083
|
this._pendingSeq = seq;
|
|
924
1084
|
this._pendingIdentityMap = identityMap;
|
|
1085
|
+
// A newer frame replacing this one takes its stamp with it, and that is
|
|
1086
|
+
// correct: the stamp belongs to bytes that are no longer what will apply.
|
|
1087
|
+
// Losing it costs one honest 412 later, which is the safe direction.
|
|
1088
|
+
this._pendingEtag = etag ?? null;
|
|
1089
|
+
this._pendingBy = by ?? null;
|
|
925
1090
|
this._scheduleNextFrame();
|
|
926
1091
|
}
|
|
927
1092
|
|
|
@@ -979,7 +1144,7 @@ class LiveSync {
|
|
|
979
1144
|
} else {
|
|
980
1145
|
this._morphInFlight = true;
|
|
981
1146
|
try {
|
|
982
|
-
await this._doApplyExternal(ext.html, ext.seq);
|
|
1147
|
+
await this._doApplyExternal(ext.html, ext.seq, ext.etag, ext.by);
|
|
983
1148
|
} catch (err) {
|
|
984
1149
|
console.error('[LiveSync] applyExternal failed:', err);
|
|
985
1150
|
} finally {
|
|
@@ -990,14 +1155,18 @@ class LiveSync {
|
|
|
990
1155
|
const html = this._pendingHtml;
|
|
991
1156
|
const seq = this._pendingSeq;
|
|
992
1157
|
const identityMap = this._pendingIdentityMap;
|
|
1158
|
+
const etag = this._pendingEtag;
|
|
1159
|
+
const by = this._pendingBy;
|
|
993
1160
|
this._pendingHtml = null;
|
|
994
1161
|
this._pendingSeq = null;
|
|
995
1162
|
this._pendingIdentityMap = null;
|
|
1163
|
+
this._pendingEtag = null;
|
|
1164
|
+
this._pendingBy = null;
|
|
996
1165
|
if (html == null) return;
|
|
997
1166
|
|
|
998
1167
|
this._morphInFlight = true;
|
|
999
1168
|
try {
|
|
1000
|
-
await this._doApplyUpdate(html, seq, identityMap);
|
|
1169
|
+
await this._doApplyUpdate(html, seq, identityMap, etag, by);
|
|
1001
1170
|
} catch (err) {
|
|
1002
1171
|
console.error('[LiveSync] applyUpdate failed:', err);
|
|
1003
1172
|
} finally {
|
|
@@ -1036,9 +1205,11 @@ class LiveSync {
|
|
|
1036
1205
|
* @param {string} html
|
|
1037
1206
|
* @param {number} [seq]
|
|
1038
1207
|
* @param {Object} [identityMap]
|
|
1208
|
+
* @param {string} [etag]
|
|
1209
|
+
* @param {Object} [by]
|
|
1039
1210
|
* @returns {Promise<void>}
|
|
1040
1211
|
*/
|
|
1041
|
-
async _doApplyUpdate(html, seq, identityMap) {
|
|
1212
|
+
async _doApplyUpdate(html, seq, identityMap, etag, by = null) {
|
|
1042
1213
|
this._log('applyUpdate - pausing mutations and morphing');
|
|
1043
1214
|
this.isPaused = true;
|
|
1044
1215
|
|
|
@@ -1126,6 +1297,7 @@ class LiveSync {
|
|
|
1126
1297
|
this._pendingHtml = html;
|
|
1127
1298
|
this._pendingSeq = seq;
|
|
1128
1299
|
this._pendingIdentityMap = identityMap;
|
|
1300
|
+
this._pendingBy = by;
|
|
1129
1301
|
this._scheduleNextFrame();
|
|
1130
1302
|
}, 3000);
|
|
1131
1303
|
return;
|
|
@@ -1135,6 +1307,18 @@ class LiveSync {
|
|
|
1135
1307
|
|
|
1136
1308
|
this._setHeld('live', false);
|
|
1137
1309
|
|
|
1310
|
+
// The frame merged, so this tab may take its stamp — but not yet. Adopting
|
|
1311
|
+
// the stamp and applying the bytes have to be the same event, and they only
|
|
1312
|
+
// are once the apply has actually happened. The morph below is awaited and
|
|
1313
|
+
// can reject, and a stamp recorded before it would sit on the old DOM with
|
|
1314
|
+
// nothing to undo it, so the next save would pass If-Match and replace a
|
|
1315
|
+
// version this tab never received. It is recorded after the await, beside
|
|
1316
|
+
// `lastHtml`, on the one path where the bytes definitely landed.
|
|
1317
|
+
//
|
|
1318
|
+
// The hold path above returns before that point, deliberately. A held tab is
|
|
1319
|
+
// knowingly missing what disk holds, and a stamp there would let its next
|
|
1320
|
+
// save replace that change with nobody told.
|
|
1321
|
+
|
|
1138
1322
|
// Morph entire document. We MUST await — HyperMorph.morph returns a
|
|
1139
1323
|
// Promise when `scripts: { handle: true }` needs to wait for external
|
|
1140
1324
|
// scripts to load. If we don't await, Mutation.resume() fires before
|
|
@@ -1190,6 +1374,13 @@ class LiveSync {
|
|
|
1190
1374
|
: null;
|
|
1191
1375
|
this._applyGen++;
|
|
1192
1376
|
|
|
1377
|
+
// The stamp of section 6, taken now that the bytes it describes are the
|
|
1378
|
+
// bytes this tab is holding. Same line of reasoning as lastHtml directly
|
|
1379
|
+
// above, and deliberately the same moment: the two claims a page makes
|
|
1380
|
+
// when it adopts a stamp are "the host stores this version" and "I hold
|
|
1381
|
+
// it", and only the second one is this tab's to make.
|
|
1382
|
+
if (typeof etag === 'string' && etag) recordEtag(etag);
|
|
1383
|
+
|
|
1193
1384
|
// Cross-lane baseline: the DOM now holds this frame's content, but the
|
|
1194
1385
|
// DISK baseline (lastSavedContents) still describes pre-frame state. A
|
|
1195
1386
|
// later dirty disk apply diffing against that stale baseline would
|
|
@@ -1214,8 +1405,13 @@ class LiveSync {
|
|
|
1214
1405
|
// `source` is what lets a listener tell the two apply paths apart. clay.wire
|
|
1215
1406
|
// waits for a DISK frame to call an agent's write landed, and another tab's
|
|
1216
1407
|
// edit arriving first would otherwise report the wrong bytes as delivered.
|
|
1408
|
+
//
|
|
1409
|
+
// `by` is the server's answer about who sent these bytes, and it is on the
|
|
1410
|
+
// event rather than on the frame's arrival for one reason: a frame that held
|
|
1411
|
+
// returns above without reaching here, so nothing can name an author for a
|
|
1412
|
+
// change this tab never took. Null on every frame the host did not stamp.
|
|
1217
1413
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
1218
|
-
detail: { seq, source: 'peer' }
|
|
1414
|
+
detail: { seq, source: 'peer', by: by || null }
|
|
1219
1415
|
}));
|
|
1220
1416
|
} finally {
|
|
1221
1417
|
this._log('applyUpdate - morph complete, resuming mutations');
|
|
@@ -1252,7 +1448,7 @@ class LiveSync {
|
|
|
1252
1448
|
* holds or a later dirty peer apply misclassifies this frame's content as
|
|
1253
1449
|
* local edits.
|
|
1254
1450
|
*/
|
|
1255
|
-
async _doApplyExternal(html, seq) {
|
|
1451
|
+
async _doApplyExternal(html, seq, etag = null, by = null) {
|
|
1256
1452
|
this._log(`applyExternal - external disk change (seq=${seq})`);
|
|
1257
1453
|
this.isPaused = true;
|
|
1258
1454
|
|
|
@@ -1292,7 +1488,7 @@ class LiveSync {
|
|
|
1292
1488
|
this._holdRetryExt = setTimeout(() => {
|
|
1293
1489
|
this._holdRetryExt = null;
|
|
1294
1490
|
if (this.isDestroyed || this._pendingExternal != null) return;
|
|
1295
|
-
this._pendingExternal = { html, seq, saveEpoch: epochAtHold };
|
|
1491
|
+
this._pendingExternal = { html, seq, saveEpoch: epochAtHold, etag, by };
|
|
1296
1492
|
this._scheduleNextFrame();
|
|
1297
1493
|
}, 3000);
|
|
1298
1494
|
return;
|
|
@@ -1329,6 +1525,17 @@ class LiveSync {
|
|
|
1329
1525
|
|
|
1330
1526
|
window.scrollTo(scrollX, scrollY);
|
|
1331
1527
|
|
|
1528
|
+
// The stamp of section 6, adopted here and nowhere else: this is the moment
|
|
1529
|
+
// the bytes it describes reached this tab. It is taken on the retained-roots
|
|
1530
|
+
// path too, because a merge still incorporates the disk bytes, and the
|
|
1531
|
+
// convergence save at the bottom needs a stamp the host will accept or the
|
|
1532
|
+
// merge is refused and lost.
|
|
1533
|
+
//
|
|
1534
|
+
// A frame with no stamp (an older host, or the content-less fetch fallback,
|
|
1535
|
+
// which serves bytes nobody stamped) leaves this alone, and the listener in
|
|
1536
|
+
// etag.js falls back to asking the host.
|
|
1537
|
+
if (typeof etag === 'string' && etag) recordEtag(etag);
|
|
1538
|
+
|
|
1332
1539
|
if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
|
|
1333
1540
|
// Clean apply: the DOM now IS the disk state, so a local comparison
|
|
1334
1541
|
// capture of it is the truthful baseline. The next no-op save skips,
|
|
@@ -1350,7 +1557,12 @@ class LiveSync {
|
|
|
1350
1557
|
}
|
|
1351
1558
|
|
|
1352
1559
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
1353
|
-
detail: {
|
|
1560
|
+
detail: {
|
|
1561
|
+
seq,
|
|
1562
|
+
source: 'disk',
|
|
1563
|
+
etag: typeof etag === 'string' && etag ? etag : null,
|
|
1564
|
+
by: by || null,
|
|
1565
|
+
}
|
|
1354
1566
|
}));
|
|
1355
1567
|
} finally {
|
|
1356
1568
|
this._log('applyExternal - morph complete, resuming mutations');
|