@panphora/clayjs 0.1.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/LICENSE +21 -0
- package/README.md +57 -0
- package/clay.js +21 -0
- package/package.json +27 -0
- package/src/attrs/onaftersave.js +38 -0
- package/src/attrs/refetch-on-save.js +36 -0
- package/src/attrs/save-freeze.js +102 -0
- package/src/core/admin-attrs.js +22 -0
- package/src/core/admin-contenteditable.js +47 -0
- package/src/core/admin-inputs.js +48 -0
- package/src/core/admin-onclick.js +50 -0
- package/src/core/admin-resources.js +49 -0
- package/src/core/autosave.js +61 -0
- package/src/core/edit-mode.js +38 -0
- package/src/core/is-edit-mode.js +30 -0
- package/src/core/persist.js +103 -0
- package/src/core/save-core.js +385 -0
- package/src/core/save.js +475 -0
- package/src/core/snapshot.js +282 -0
- package/src/core/unsaved-warning.js +38 -0
- package/src/lib/autosave-debug.js +223 -0
- package/src/lib/cache-bust.js +12 -0
- package/src/lib/cookie.js +37 -0
- package/src/lib/dom-ready.js +9 -0
- package/src/lib/extension-noise.js +63 -0
- package/src/lib/load-vendor-script.js +57 -0
- package/src/lib/mutation.js +719 -0
- package/src/lib/query.js +3 -0
- package/src/lib/region-policy.js +220 -0
- package/src/lib/throttle.js +41 -0
- package/src/lib/user-gesture.js +126 -0
- package/src/loader-logic.js +62 -0
- package/src/loader.js +123 -0
- package/src/plugins/indicator.js +51 -0
- package/src/plugins/sortable.js +119 -0
- package/src/plugins/undo.js +23 -0
- package/src/sync/live-sync.js +752 -0
- package/src/vendor/Sortable.vendor.js +2 -0
- package/src/vendor/control-serialize.vendor.js +88 -0
- package/src/vendor/hyper-morph.vendor.js +22 -0
- package/src/vendor/hyper-undo.vendor.js +11 -0
- package/src/vendor/hypercms.vendor.js +1751 -0
- package/src/vendor/richclay.vendor.js +456 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* live-sync.js — Real-time sync between admin users
|
|
3
|
+
*
|
|
4
|
+
* HOW IT WORKS:
|
|
5
|
+
*
|
|
6
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
7
|
+
* │ 1. LISTEN snapshot-ready event from save │
|
|
8
|
+
* │ (full document with form values) │
|
|
9
|
+
* └─────────────────────────────────────────────────────────┘
|
|
10
|
+
* │
|
|
11
|
+
* ▼
|
|
12
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
13
|
+
* │ 2. SEND POST html to /_/live-sync/save │
|
|
14
|
+
* │ (debounced, skip if unchanged) │
|
|
15
|
+
* └─────────────────────────────────────────────────────────┘
|
|
16
|
+
* │
|
|
17
|
+
* ▼
|
|
18
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
19
|
+
* │ 3. RECEIVE SSE stream from server │
|
|
20
|
+
* │ (other clients' changes) │
|
|
21
|
+
* └─────────────────────────────────────────────────────────┘
|
|
22
|
+
* │
|
|
23
|
+
* ▼
|
|
24
|
+
* ┌─────────────────────────────────────────────────────────┐
|
|
25
|
+
* │ 4. MORPH HyperMorph full documentElement │
|
|
26
|
+
* │ (preserves focus, input values) │
|
|
27
|
+
* └─────────────────────────────────────────────────────────┘
|
|
28
|
+
*
|
|
29
|
+
* LANES: edit-mode tabs ride the 'live' lane (steps 1-4: they broadcast
|
|
30
|
+
* pre-strip snapshots to peers and receive theirs). View-mode tabs ride the
|
|
31
|
+
* 'saved' lane: receive-only (steps 3-4), fed post-strip on-disk HTML by the
|
|
32
|
+
* server whenever the file is persisted (browser save, code editor, device
|
|
33
|
+
* sync, template/restore, local external edits).
|
|
34
|
+
*
|
|
35
|
+
* INTEGRATES WITH: snapshot.js (receives snapshot-ready events)
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { HyperMorph, morph } from "../vendor/hyper-morph.vendor.js";
|
|
39
|
+
import Mutation from "../lib/mutation.js";
|
|
40
|
+
import { isSnapshotRemoved } from "../lib/region-policy.js";
|
|
41
|
+
import { isEditMode } from "../core/is-edit-mode.js";
|
|
42
|
+
|
|
43
|
+
class LiveSync {
|
|
44
|
+
constructor() {
|
|
45
|
+
this.sse = null;
|
|
46
|
+
this.currentFile = null;
|
|
47
|
+
this.lastHtml = null;
|
|
48
|
+
this.clientId = this.generateClientId();
|
|
49
|
+
this.debounceMs = 150;
|
|
50
|
+
this.debounceTimer = null;
|
|
51
|
+
this.isPaused = false;
|
|
52
|
+
this.isDestroyed = false;
|
|
53
|
+
this.debug = false;
|
|
54
|
+
|
|
55
|
+
// Lane on the shared per-file SSE channel. Edit mode: 'live' (owner-gated,
|
|
56
|
+
// pre-strip peer snapshots + notifications). View mode: 'saved' (receive-
|
|
57
|
+
// only post-strip on-disk HTML). Overridable per-instance for tests.
|
|
58
|
+
this.lane = isEditMode ? 'live' : 'saved';
|
|
59
|
+
|
|
60
|
+
// Store handler reference for cleanup
|
|
61
|
+
this._snapshotHandler = null;
|
|
62
|
+
|
|
63
|
+
// High-water mark of server seqs we've seen on this channel (own echoes
|
|
64
|
+
// count too). Server-broadcast payloads carry a monotonic seq
|
|
65
|
+
// (Date.now()-based). We drop anything <= this to guard against rare
|
|
66
|
+
// cases where a stale message lands after a newer one, e.g. buffered
|
|
67
|
+
// replay, alt backend after reconnect, or an own-save echo arriving
|
|
68
|
+
// after a peer's newer broadcast.
|
|
69
|
+
this.lastSeenSeq = 0;
|
|
70
|
+
|
|
71
|
+
// rAF-paced single-flight queue. Incoming updates overwrite a pending
|
|
72
|
+
// slot; on each animation frame, if a slot is set and no morph is in
|
|
73
|
+
// flight, run one morph against the latest pending payload. Burst
|
|
74
|
+
// arrivals collapse to one morph per frame, keeping the receiver from
|
|
75
|
+
// falling behind under load. A morph with external scripts returns a
|
|
76
|
+
// Promise, so the in-flight flag prevents overlap.
|
|
77
|
+
this._pendingHtml = null;
|
|
78
|
+
this._pendingSeq = null;
|
|
79
|
+
this._pendingIdentityMap = null;
|
|
80
|
+
this._morphInFlight = false;
|
|
81
|
+
this._rafHandle = null;
|
|
82
|
+
|
|
83
|
+
// Identity tracking for content-based morphing across live-sync updates.
|
|
84
|
+
// Synthetic IDs (`<clientId>:<counter>`) live here only — never written to
|
|
85
|
+
// the DOM, never serialized into saved HTML. The WeakMap holds them
|
|
86
|
+
// against the live elements so that the next save can produce the same
|
|
87
|
+
// identityMap, and afterNodeMorphed transfers IDs from incoming parsed
|
|
88
|
+
// elements onto the live elements they morphed into.
|
|
89
|
+
this.idCounter = this._loadIdCounter();
|
|
90
|
+
this.liveWeakMap = new WeakMap();
|
|
91
|
+
|
|
92
|
+
// Callbacks
|
|
93
|
+
this.onConnect = null;
|
|
94
|
+
this.onDisconnect = null;
|
|
95
|
+
this.onUpdate = null;
|
|
96
|
+
this.onError = null;
|
|
97
|
+
this.onNotification = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
_log(message, data = null) {
|
|
101
|
+
if (!this.debug) return;
|
|
102
|
+
const prefix = `[LiveSync ${new Date().toISOString()}]`;
|
|
103
|
+
if (data !== null) {
|
|
104
|
+
console.log(prefix, message, data);
|
|
105
|
+
} else {
|
|
106
|
+
console.log(prefix, message);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generate or retrieve a tab-specific client ID
|
|
112
|
+
* Uses sessionStorage (unique per tab) not localStorage (shared across tabs)
|
|
113
|
+
*/
|
|
114
|
+
generateClientId() {
|
|
115
|
+
let id = null;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
id = sessionStorage.getItem('livesync-client-id');
|
|
119
|
+
} catch (e) {
|
|
120
|
+
// sessionStorage might not be available
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!id) {
|
|
124
|
+
id = Math.random().toString(36).slice(2, 11) + Date.now().toString(36);
|
|
125
|
+
try {
|
|
126
|
+
sessionStorage.setItem('livesync-client-id', id);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// That's okay
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return id;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Start the LiveSync system
|
|
137
|
+
* Can be called after stop() to restart with a new file
|
|
138
|
+
*/
|
|
139
|
+
start(file = null) {
|
|
140
|
+
// Reset destroyed flag to allow restart after stop()
|
|
141
|
+
this.isDestroyed = false;
|
|
142
|
+
|
|
143
|
+
// Prevent double-connect: clean up existing connection first
|
|
144
|
+
if (this.sse || this._snapshotHandler) {
|
|
145
|
+
this.cleanup();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.currentFile = file || this.detectCurrentFile();
|
|
149
|
+
|
|
150
|
+
if (!this.currentFile) {
|
|
151
|
+
console.warn('[LiveSync] No file detected');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Reset state for new connection
|
|
156
|
+
this.lastHtml = null;
|
|
157
|
+
this.lastSeenSeq = 0;
|
|
158
|
+
|
|
159
|
+
console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
|
|
160
|
+
this.connect();
|
|
161
|
+
// View-mode tabs are receive-only: saves are edit-gated upstream, so a
|
|
162
|
+
// snapshot listener would never fire — skip registering it.
|
|
163
|
+
if (this.lane === 'live') {
|
|
164
|
+
this.listenForSnapshots();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Clean up resources without marking as destroyed
|
|
170
|
+
* Used internally by start() to prevent double-connect
|
|
171
|
+
*/
|
|
172
|
+
cleanup() {
|
|
173
|
+
if (this.sse) {
|
|
174
|
+
this.sse.close();
|
|
175
|
+
this.sse = null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (this._snapshotHandler) {
|
|
179
|
+
document.removeEventListener('clay:snapshot-ready', this._snapshotHandler);
|
|
180
|
+
this._snapshotHandler = null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
clearTimeout(this.debounceTimer);
|
|
184
|
+
|
|
185
|
+
// Cancel any pending frame and clear the queue. A morph already in
|
|
186
|
+
// flight cannot be aborted; the isDestroyed check in _runPending guards
|
|
187
|
+
// its post-morph rescheduling so the queue stops cleanly.
|
|
188
|
+
if (this._rafHandle != null) {
|
|
189
|
+
this._cancelFrame(this._rafHandle);
|
|
190
|
+
this._rafHandle = null;
|
|
191
|
+
}
|
|
192
|
+
this._pendingHtml = null;
|
|
193
|
+
this._pendingSeq = null;
|
|
194
|
+
this._pendingIdentityMap = null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_loadIdCounter() {
|
|
198
|
+
try {
|
|
199
|
+
const raw = sessionStorage.getItem('livesync-id-counter');
|
|
200
|
+
const n = parseInt(raw || '0', 10);
|
|
201
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
202
|
+
} catch (e) {
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_persistIdCounter() {
|
|
208
|
+
try {
|
|
209
|
+
sessionStorage.setItem('livesync-id-counter', String(this.idCounter));
|
|
210
|
+
} catch (e) {
|
|
211
|
+
// Storage full / sandboxed — fall back to in-memory only.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_mintId() {
|
|
216
|
+
this.idCounter++;
|
|
217
|
+
this._persistIdCounter();
|
|
218
|
+
return `${this.clientId}:${this.idCounter}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Walk the live DOM and the snapshot clone in lockstep. Path keys come
|
|
223
|
+
* from the clone (= what the receiver will see, after [snapshot-remove]
|
|
224
|
+
* and snapshotHooks). WeakMap lookup happens against the live element so
|
|
225
|
+
* synthetic IDs persist across saves.
|
|
226
|
+
*
|
|
227
|
+
* The live walk filters [snapshot-remove] to mirror the clone's earlier
|
|
228
|
+
* strip in captureSnapshot. If child counts diverge anywhere (an
|
|
229
|
+
* onbeforesnapshot handler added/removed siblings on the clone), the
|
|
230
|
+
* subtree is skipped — better to fall back to content scoring there than
|
|
231
|
+
* emit misaligned IDs.
|
|
232
|
+
*
|
|
233
|
+
* @param {Element} liveRoot
|
|
234
|
+
* @param {Element} cloneRoot
|
|
235
|
+
* @returns {Object} identityMap keyed by dot-path
|
|
236
|
+
*/
|
|
237
|
+
_buildIdentityMap(liveRoot, cloneRoot) {
|
|
238
|
+
const map = {};
|
|
239
|
+
if (!liveRoot || !cloneRoot) return map;
|
|
240
|
+
|
|
241
|
+
const visit = (live, clone, path) => {
|
|
242
|
+
let id = this.liveWeakMap.get(live);
|
|
243
|
+
if (!id) {
|
|
244
|
+
id = this._mintId();
|
|
245
|
+
this.liveWeakMap.set(live, id);
|
|
246
|
+
}
|
|
247
|
+
map[path] = id;
|
|
248
|
+
|
|
249
|
+
const liveKids = [];
|
|
250
|
+
for (const c of live.children) {
|
|
251
|
+
if (!isSnapshotRemoved(c)) liveKids.push(c);
|
|
252
|
+
}
|
|
253
|
+
const cloneKids = clone.children;
|
|
254
|
+
|
|
255
|
+
if (liveKids.length !== cloneKids.length) {
|
|
256
|
+
this._log(
|
|
257
|
+
`identity map: subtree skipped at "${path}" (live=${liveKids.length}, clone=${cloneKids.length})`
|
|
258
|
+
);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (let i = 0; i < liveKids.length; i++) {
|
|
263
|
+
visit(liveKids[i], cloneKids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
visit(liveRoot, cloneRoot, '');
|
|
268
|
+
return map;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Walk a single parsed tree, invoking cb(element, path) at each Element.
|
|
273
|
+
* Paths use the same dot-segment scheme as _buildIdentityMap so the
|
|
274
|
+
* receiver can look up IDs by the path the sender emitted.
|
|
275
|
+
*/
|
|
276
|
+
_walkParsedTree(root, cb) {
|
|
277
|
+
if (!root) return;
|
|
278
|
+
const visit = (el, path) => {
|
|
279
|
+
cb(el, path);
|
|
280
|
+
const kids = el.children;
|
|
281
|
+
for (let i = 0; i < kids.length; i++) {
|
|
282
|
+
visit(kids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
visit(root, '');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Fill liveWeakMap entries for live elements that the matcher's
|
|
290
|
+
* afterNodeMorphed didn't reach. createNode's no-id-children
|
|
291
|
+
* optimization (hyper-morph importNode path) inserts a clone of the
|
|
292
|
+
* parsed element without invoking morphNode, so afterNodeMorphed never
|
|
293
|
+
* fires for those subtrees and their synthetic IDs would be lost. On
|
|
294
|
+
* the receiver's next save, _buildIdentityMap would mint fresh IDs for
|
|
295
|
+
* the same logical elements, breaking convergence for newly-added
|
|
296
|
+
* ambiguous siblings — exactly the case identity-map exists to fix.
|
|
297
|
+
*
|
|
298
|
+
* Walks live and parsed in lockstep using the same path scheme as
|
|
299
|
+
* _buildIdentityMap. Filters [snapshot-remove] from the live side to
|
|
300
|
+
* stay aligned with the sender's clone view. Aborts a subtree on
|
|
301
|
+
* child-count divergence (e.g. local save-ignore additions) — those
|
|
302
|
+
* elements fall through to content scoring on the next round, which
|
|
303
|
+
* is the same fallback as a sender-side lockstep skip.
|
|
304
|
+
*
|
|
305
|
+
* @param {Element} liveRoot - post-morph live tree root
|
|
306
|
+
* @param {Element} parsedRoot - parsed-tree root (still has identityMap WeakMap entries)
|
|
307
|
+
* @param {Object} identityMap - path → id map from the SSE payload
|
|
308
|
+
*/
|
|
309
|
+
_fillInIdsAfterMorph(liveRoot, parsedRoot, identityMap) {
|
|
310
|
+
if (!liveRoot || !parsedRoot || !identityMap) return;
|
|
311
|
+
const visit = (live, parsed, path) => {
|
|
312
|
+
const id = identityMap[path];
|
|
313
|
+
if (id && !this.liveWeakMap.has(live)) {
|
|
314
|
+
this.liveWeakMap.set(live, id);
|
|
315
|
+
}
|
|
316
|
+
const liveKids = [];
|
|
317
|
+
for (const c of live.children) {
|
|
318
|
+
if (!isSnapshotRemoved(c)) liveKids.push(c);
|
|
319
|
+
}
|
|
320
|
+
const parsedKids = parsed.children;
|
|
321
|
+
if (liveKids.length !== parsedKids.length) return;
|
|
322
|
+
for (let i = 0; i < liveKids.length; i++) {
|
|
323
|
+
visit(liveKids[i], parsedKids[i], path === '' ? String(i) : `${path}.${i}`);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
visit(liveRoot, parsedRoot, '');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Auto-detect the current site file path from the URL
|
|
331
|
+
* Returns the path including extension (e.g., card-canvas.html)
|
|
332
|
+
*
|
|
333
|
+
* Handles:
|
|
334
|
+
* - / -> index.html
|
|
335
|
+
* - /about.html -> about.html
|
|
336
|
+
* - /about.htmlclay -> about.htmlclay
|
|
337
|
+
* - /about.html/dashboard -> about.html (SPA route stripped)
|
|
338
|
+
* - /blog/app.htmlclay/settings -> blog/app.htmlclay (SPA route stripped)
|
|
339
|
+
*/
|
|
340
|
+
detectCurrentFile() {
|
|
341
|
+
let pathname = window.location.pathname;
|
|
342
|
+
|
|
343
|
+
if (pathname === '/') {
|
|
344
|
+
return 'index.html';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
pathname = pathname.replace(/^\//, '');
|
|
348
|
+
|
|
349
|
+
const htmlMatch = pathname.match(/^(.*?\.html(?:clay)?)/);
|
|
350
|
+
if (htmlMatch) return htmlMatch[1];
|
|
351
|
+
|
|
352
|
+
return pathname;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Connect to the SSE endpoint
|
|
357
|
+
* Uses native EventSource reconnection behavior
|
|
358
|
+
*/
|
|
359
|
+
connect() {
|
|
360
|
+
if (this.isDestroyed) return;
|
|
361
|
+
|
|
362
|
+
const pageUrl = encodeURIComponent(window.location.href);
|
|
363
|
+
const url = `/_/live-sync/stream?page-url=${pageUrl}&lane=${this.lane}`;
|
|
364
|
+
this.sse = new EventSource(url);
|
|
365
|
+
|
|
366
|
+
this.sse.onopen = () => {
|
|
367
|
+
console.log('[LiveSync] Connected');
|
|
368
|
+
if (this.onConnect) this.onConnect();
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
this.sse.onmessage = (event) => {
|
|
372
|
+
const data = JSON.parse(event.data);
|
|
373
|
+
|
|
374
|
+
// Handle notifications (show toast, don't morph)
|
|
375
|
+
if (data.type === "notification") {
|
|
376
|
+
this.handleNotification(data);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Handle error events from server
|
|
381
|
+
if (data.error) {
|
|
382
|
+
console.error('[LiveSync] Server error:', data.error);
|
|
383
|
+
if (this.onError) this.onError(new Error(data.error));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const { html, sender, seq, identityMap } = data;
|
|
388
|
+
|
|
389
|
+
// Staleness check runs FIRST — compared against the high-water mark of
|
|
390
|
+
// seqs we've seen (own echoes count too, see below). `seq` is optional
|
|
391
|
+
// for back-compat with older server builds that don't stamp it.
|
|
392
|
+
if (typeof seq === 'number' && seq <= this.lastSeenSeq) {
|
|
393
|
+
this._log(`Dropping stale message: seq=${seq}, lastSeen=${this.lastSeenSeq}`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Advance the watermark before the sender filter so that our own save
|
|
398
|
+
// echoes count toward "seen". Without this, a later buffered/replayed
|
|
399
|
+
// peer message with a smaller seq could rewind us past our local edit.
|
|
400
|
+
if (typeof seq === 'number') {
|
|
401
|
+
this.lastSeenSeq = seq;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Ignore own changes — already reflected in the DOM, nothing to morph
|
|
405
|
+
if (sender === this.clientId) {
|
|
406
|
+
this._log('Ignoring own message (sender matches clientId)');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Guard against invalid html
|
|
411
|
+
if (typeof html !== 'string') {
|
|
412
|
+
console.error('[LiveSync] Received invalid html, ignoring');
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
this._log(`Received update from: ${sender} (my clientId: ${this.clientId}, seq=${seq})`);
|
|
417
|
+
this.applyUpdate(html, seq, identityMap);
|
|
418
|
+
if (this.onUpdate) this.onUpdate({ html, sender, seq, identityMap });
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Native EventSource auto-reconnects on transient errors
|
|
422
|
+
// We just surface the status via callbacks
|
|
423
|
+
this.sse.onerror = () => {
|
|
424
|
+
if (this.sse.readyState === EventSource.CONNECTING) {
|
|
425
|
+
console.log('[LiveSync] Reconnecting...');
|
|
426
|
+
} else if (this.sse.readyState === EventSource.CLOSED) {
|
|
427
|
+
console.log('[LiveSync] Connection closed');
|
|
428
|
+
if (this.onError) this.onError(new Error('Connection closed'));
|
|
429
|
+
}
|
|
430
|
+
if (this.onDisconnect) this.onDisconnect();
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Listen for snapshot-ready events from the save system.
|
|
436
|
+
* Receives the full cloned documentElement and sends it.
|
|
437
|
+
*/
|
|
438
|
+
listenForSnapshots() {
|
|
439
|
+
this._snapshotHandler = (event) => {
|
|
440
|
+
if (this.isPaused) {
|
|
441
|
+
this._log('snapshot-ready received but isPaused, skipping');
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const { documentElement: clone } = event.detail;
|
|
446
|
+
if (!clone) return;
|
|
447
|
+
|
|
448
|
+
// Capture both synchronously inside the event handler — captureSnapshot's
|
|
449
|
+
// caller continues to mutate the clone (strip [save-remove], run hooks)
|
|
450
|
+
// after dispatchEvent returns, so reading outerHTML and walking children
|
|
451
|
+
// must happen now.
|
|
452
|
+
this._log('snapshot-ready received, preparing to send');
|
|
453
|
+
const html = clone.outerHTML;
|
|
454
|
+
const identityMap = this._buildIdentityMap(document.documentElement, clone);
|
|
455
|
+
this.sendUpdate(html, identityMap);
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
document.addEventListener('clay:snapshot-ready', this._snapshotHandler);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Send full HTML to the server (debounced)
|
|
463
|
+
* Only updates lastHtml after successful save
|
|
464
|
+
*/
|
|
465
|
+
sendUpdate(html, identityMap) {
|
|
466
|
+
clearTimeout(this.debounceTimer);
|
|
467
|
+
|
|
468
|
+
this.debounceTimer = setTimeout(() => {
|
|
469
|
+
// Skip if unchanged
|
|
470
|
+
if (html === this.lastHtml) {
|
|
471
|
+
this._log('Skipping send - HTML unchanged');
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
this._log(`Sending update (HTML length: ${html.length}, lastHtml length: ${this.lastHtml?.length || 0})`);
|
|
476
|
+
|
|
477
|
+
fetch('/_/live-sync/save', {
|
|
478
|
+
method: 'POST',
|
|
479
|
+
headers: { 'Content-Type': 'application/json', 'Page-URL': window.location.href },
|
|
480
|
+
body: JSON.stringify({
|
|
481
|
+
html: html,
|
|
482
|
+
sender: this.clientId,
|
|
483
|
+
identityMap: identityMap
|
|
484
|
+
})
|
|
485
|
+
}).then(response => {
|
|
486
|
+
if (response.ok) {
|
|
487
|
+
this.lastHtml = html;
|
|
488
|
+
} else {
|
|
489
|
+
console.warn('[LiveSync] Save returned status:', response.status);
|
|
490
|
+
}
|
|
491
|
+
}).catch(err => {
|
|
492
|
+
console.error('[LiveSync] Save failed:', err);
|
|
493
|
+
if (this.onError) this.onError(err);
|
|
494
|
+
});
|
|
495
|
+
}, this.debounceMs);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Apply an update received from the server. Morphs the entire document.
|
|
500
|
+
*
|
|
501
|
+
* Updates land in a single pending slot. On each animation frame, the
|
|
502
|
+
* latest pending payload is morphed once; intermediate updates that
|
|
503
|
+
* arrived between frames are skipped because they would be replaced
|
|
504
|
+
* milliseconds later anyway. Burst arrivals collapse to roughly one morph
|
|
505
|
+
* per frame, so the receiver always shows current state instead of
|
|
506
|
+
* playing back a backlog of stale snapshots.
|
|
507
|
+
*
|
|
508
|
+
* @param {string} html - Full document HTML
|
|
509
|
+
* @param {number} [seq] - Optional monotonic seq from the server
|
|
510
|
+
* @param {Object} [identityMap] - Optional element-identity map from sender
|
|
511
|
+
*/
|
|
512
|
+
applyUpdate(html, seq, identityMap) {
|
|
513
|
+
if (this.isDestroyed) return;
|
|
514
|
+
this._pendingHtml = html;
|
|
515
|
+
this._pendingSeq = seq;
|
|
516
|
+
this._pendingIdentityMap = identityMap;
|
|
517
|
+
this._scheduleNextFrame();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Schedule the next-frame morph if one isn't already pending. Skipped when
|
|
522
|
+
* a morph is in flight; the morph's post-completion check will reschedule
|
|
523
|
+
* if a newer payload arrived during it.
|
|
524
|
+
*/
|
|
525
|
+
_scheduleNextFrame() {
|
|
526
|
+
if (this.isDestroyed) return;
|
|
527
|
+
if (this._rafHandle != null) return;
|
|
528
|
+
if (this._morphInFlight) return;
|
|
529
|
+
this._rafHandle = this._requestFrame(() => this._runPending());
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Drain the pending slot once. Errors are caught and logged so a single
|
|
534
|
+
* failed morph does not stop the queue.
|
|
535
|
+
*/
|
|
536
|
+
async _runPending() {
|
|
537
|
+
this._rafHandle = null;
|
|
538
|
+
if (this.isDestroyed) return;
|
|
539
|
+
|
|
540
|
+
const html = this._pendingHtml;
|
|
541
|
+
const seq = this._pendingSeq;
|
|
542
|
+
const identityMap = this._pendingIdentityMap;
|
|
543
|
+
this._pendingHtml = null;
|
|
544
|
+
this._pendingSeq = null;
|
|
545
|
+
this._pendingIdentityMap = null;
|
|
546
|
+
if (html == null) return;
|
|
547
|
+
|
|
548
|
+
this._morphInFlight = true;
|
|
549
|
+
try {
|
|
550
|
+
await this._doApplyUpdate(html, seq, identityMap);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
console.error('[LiveSync] applyUpdate failed:', err);
|
|
553
|
+
} finally {
|
|
554
|
+
this._morphInFlight = false;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// A newer payload may have arrived during the morph. Schedule another
|
|
558
|
+
// frame to drain it. Without this, late-arriving updates would sit
|
|
559
|
+
// forever until the next applyUpdate call.
|
|
560
|
+
if (!this.isDestroyed && this._pendingHtml != null) {
|
|
561
|
+
this._scheduleNextFrame();
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
_requestFrame(cb) {
|
|
566
|
+
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
|
567
|
+
return window.requestAnimationFrame(cb);
|
|
568
|
+
}
|
|
569
|
+
return setTimeout(cb, 16);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
_cancelFrame(handle) {
|
|
573
|
+
if (typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
|
574
|
+
window.cancelAnimationFrame(handle);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
clearTimeout(handle);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Actual morph work. Do not call directly. Use applyUpdate() so calls
|
|
582
|
+
* pass through the rAF queue and don't overlap.
|
|
583
|
+
* @param {string} html
|
|
584
|
+
* @param {number} [seq]
|
|
585
|
+
* @param {Object} [identityMap]
|
|
586
|
+
* @returns {Promise<void>}
|
|
587
|
+
*/
|
|
588
|
+
async _doApplyUpdate(html, seq, identityMap) {
|
|
589
|
+
this._log('applyUpdate - pausing mutations and morphing');
|
|
590
|
+
this.isPaused = true;
|
|
591
|
+
|
|
592
|
+
// Preserve scroll position: a remote edit that inserts or removes content
|
|
593
|
+
// above the viewport would otherwise cause a visible jump. Capturing here
|
|
594
|
+
// and restoring after the morph keeps the viewport stable. Browser
|
|
595
|
+
// scroll-clamping handles the case where the document is now shorter.
|
|
596
|
+
const scrollX = window.scrollX;
|
|
597
|
+
const scrollY = window.scrollY;
|
|
598
|
+
|
|
599
|
+
// Pause mutation observer so morph doesn't trigger autosave
|
|
600
|
+
Mutation.pause();
|
|
601
|
+
|
|
602
|
+
// Parse as full document
|
|
603
|
+
const parser = new DOMParser();
|
|
604
|
+
const newDoc = parser.parseFromString(html, 'text/html');
|
|
605
|
+
|
|
606
|
+
// Build the parsed-tree WeakMap from the incoming identityMap. The
|
|
607
|
+
// sender emitted paths off its clone, which is exactly what we just
|
|
608
|
+
// parsed, so the same path scheme indexes into both trees.
|
|
609
|
+
const parsedWeakMap = new WeakMap();
|
|
610
|
+
if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
|
|
611
|
+
this._walkParsedTree(newDoc.documentElement, (el, path) => {
|
|
612
|
+
const id = identityMap[path];
|
|
613
|
+
if (id) parsedWeakMap.set(el, id);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const liveWeakMap = this.liveWeakMap;
|
|
618
|
+
// Priority: synthetic IDs win when present (they're updated after every
|
|
619
|
+
// morph via afterNodeMorphed). data-id / id is the durable fallback that
|
|
620
|
+
// covers the bootstrap window and any element that hasn't been paired yet.
|
|
621
|
+
const key = (el) =>
|
|
622
|
+
liveWeakMap.get(el) ||
|
|
623
|
+
parsedWeakMap.get(el) ||
|
|
624
|
+
(el.getAttribute && el.getAttribute('data-id')) ||
|
|
625
|
+
(el.getAttribute && el.getAttribute('id')) ||
|
|
626
|
+
null;
|
|
627
|
+
const afterNodeMorphed = (oldEl, newEl) => {
|
|
628
|
+
const id = parsedWeakMap.get(newEl);
|
|
629
|
+
if (id) liveWeakMap.set(oldEl, id);
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
// Morph entire document. We MUST await — HyperMorph.morph returns a
|
|
634
|
+
// Promise when `scripts: { handle: true }` needs to wait for external
|
|
635
|
+
// scripts to load. If we don't await, Mutation.resume() fires before
|
|
636
|
+
// late-loading scripts execute, and any DOM mutations they trigger look
|
|
637
|
+
// like user edits → the receiving tab rebroadcasts them (feedback loop).
|
|
638
|
+
await HyperMorph.morph(document.documentElement, newDoc.documentElement, {
|
|
639
|
+
morphStyle: 'outerHTML',
|
|
640
|
+
ignoreActiveValue: true,
|
|
641
|
+
head: { style: 'merge' },
|
|
642
|
+
scripts: { handle: true, matchMode: 'smart' },
|
|
643
|
+
key,
|
|
644
|
+
callbacks: { afterNodeMorphed }
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// Restore viewport. Done after morph so layout has settled.
|
|
648
|
+
window.scrollTo(scrollX, scrollY);
|
|
649
|
+
|
|
650
|
+
// Fill in any IDs the matcher's afterNodeMorphed missed. Brand-new
|
|
651
|
+
// elements come in via hyper-morph's importNode optimization, which
|
|
652
|
+
// skips morphNode and thus afterNodeMorphed; their parsedWeakMap IDs
|
|
653
|
+
// never make it onto liveWeakMap. Without this pass, the receiver
|
|
654
|
+
// would mint fresh IDs on its next save for those elements,
|
|
655
|
+
// breaking convergence exactly for newly-added ambiguous siblings.
|
|
656
|
+
if (identityMap && typeof identityMap === 'object' && !Array.isArray(identityMap)) {
|
|
657
|
+
this._fillInIdsAfterMorph(document.documentElement, newDoc.documentElement, identityMap);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Only mark lastHtml after a successful morph so that a failed apply
|
|
661
|
+
// doesn't desync our state and cause the next outbound save to be
|
|
662
|
+
// mistakenly skipped as "unchanged". Note: lastSeenSeq is advanced at
|
|
663
|
+
// receive time (in onmessage) so the staleness check covers own-save
|
|
664
|
+
// echoes even when they don't reach this point.
|
|
665
|
+
this.lastHtml = html;
|
|
666
|
+
|
|
667
|
+
// Announce that a remote morph just landed, so document-level listeners
|
|
668
|
+
// that are deaf to Mutation.pause (e.g. the hypercms form panel) can
|
|
669
|
+
// re-sync. Fires only on a successful apply, and only for genuine remote
|
|
670
|
+
// morphs — own-sender and stale-seq echoes are filtered upstream in
|
|
671
|
+
// onmessage before applyUpdate is ever called. Covers every SSE morph
|
|
672
|
+
// source (peer edit, version restore, body-swap) since they all funnel
|
|
673
|
+
// through this single choke point.
|
|
674
|
+
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
675
|
+
detail: { seq }
|
|
676
|
+
}));
|
|
677
|
+
// vendor-compat: hypercms's form panel listens for the legacy name.
|
|
678
|
+
document.dispatchEvent(new CustomEvent('hyperclay:livesync-applied', {
|
|
679
|
+
detail: { seq }
|
|
680
|
+
}));
|
|
681
|
+
} finally {
|
|
682
|
+
this._log('applyUpdate - morph complete, resuming mutations');
|
|
683
|
+
Mutation.resume();
|
|
684
|
+
// Defer past microtask boundary — MutationObserver callbacks fire before
|
|
685
|
+
// this, so isPaused catches any stray snapshots from the morph itself.
|
|
686
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
687
|
+
this.isPaused = false;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Handle a notification message from the server
|
|
693
|
+
* Shows a toast and emits an event for custom handling
|
|
694
|
+
* @param {Object} data - { msgType, msg, action?, persistent? }
|
|
695
|
+
*/
|
|
696
|
+
handleNotification({ msgType, msg, action, persistent, data }) {
|
|
697
|
+
this._log(`Notification received: ${msgType} - ${msg}`);
|
|
698
|
+
|
|
699
|
+
// The data-loss guard rides this channel but is NOT a toast — the panel
|
|
700
|
+
// module handles it via the clay:notification event below.
|
|
701
|
+
const isDataLoss = msgType === 'data-loss';
|
|
702
|
+
|
|
703
|
+
// Show toast if available
|
|
704
|
+
if (!isDataLoss) {
|
|
705
|
+
if (persistent && window.toastPersistent) {
|
|
706
|
+
window.toastPersistent(msg, msgType);
|
|
707
|
+
} else if (window.toast) {
|
|
708
|
+
window.toast(msg, msgType);
|
|
709
|
+
} else {
|
|
710
|
+
console.log(`[LiveSync] Notification: ${msg}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Emit event for custom handling (e.g., reload button, data-loss chip)
|
|
715
|
+
document.dispatchEvent(new CustomEvent('clay:notification', {
|
|
716
|
+
detail: { msgType, msg, action, persistent, data }
|
|
717
|
+
}));
|
|
718
|
+
|
|
719
|
+
// Call notification callback if set
|
|
720
|
+
if (this.onNotification) {
|
|
721
|
+
this.onNotification({ msgType, msg, action, persistent, data });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Stop LiveSync and clean up resources
|
|
727
|
+
* Can call start() again to restart
|
|
728
|
+
*/
|
|
729
|
+
stop() {
|
|
730
|
+
this.cleanup();
|
|
731
|
+
this.isDestroyed = true;
|
|
732
|
+
console.log('[LiveSync] Stopped');
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Singleton instance
|
|
737
|
+
const liveSync = new LiveSync();
|
|
738
|
+
|
|
739
|
+
// Auto-initialize when DOM is ready
|
|
740
|
+
if (typeof window !== 'undefined') {
|
|
741
|
+
if (document.readyState === 'loading') {
|
|
742
|
+
document.addEventListener('DOMContentLoaded', () => liveSync.start());
|
|
743
|
+
} else {
|
|
744
|
+
liveSync.start();
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Export for the clayjs module system. The class itself is exported so
|
|
749
|
+
// tests can create fresh instances without driving the singleton's
|
|
750
|
+
// EventSource/snapshot wiring.
|
|
751
|
+
export { liveSync, LiveSync, morph };
|
|
752
|
+
export default liveSync;
|