@panphora/clayjs 0.2.0 → 0.2.1
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/package.json +15 -2
- package/src/core/save.js +5 -4
- package/src/core/snapshot.js +12 -9
- package/src/sync/live-sync.js +49 -18
- package/src/ui/index.js +2 -1
- package/src/ui/toast.js +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panphora/clayjs",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,10 +28,23 @@
|
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
31
|
+
"test:conformance": "UPDATE_GOLDENS=0 web-test-runner",
|
|
32
|
+
"conformance:update": "UPDATE_GOLDENS=1 web-test-runner",
|
|
33
|
+
"prepublishOnly": "npm test && npm run test:conformance",
|
|
31
34
|
"dev": "node tests/stub-server.mjs"
|
|
32
35
|
},
|
|
33
36
|
"devDependencies": {
|
|
37
|
+
"@esm-bundle/chai": "^4.3.4-fix.0",
|
|
38
|
+
"@web/test-runner": "^0.20.2",
|
|
39
|
+
"@web/test-runner-commands": "^0.9.0",
|
|
40
|
+
"@web/test-runner-playwright": "^0.11.1",
|
|
34
41
|
"jest": "^29.7.0",
|
|
35
|
-
"jest-environment-jsdom": "^29.7.0"
|
|
42
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
43
|
+
"playwright": "1.56.1"
|
|
44
|
+
},
|
|
45
|
+
"hyper": {
|
|
46
|
+
"kind": "lib",
|
|
47
|
+
"status": "active",
|
|
48
|
+
"url": "https://clayjs.com"
|
|
36
49
|
}
|
|
37
50
|
}
|
package/src/core/save.js
CHANGED
|
@@ -38,8 +38,9 @@ let savingTimeout = null;
|
|
|
38
38
|
*
|
|
39
39
|
* @param {string} state - One of: 'saving', 'saved', 'offline', 'error'
|
|
40
40
|
* @param {string} msg - Optional message (e.g., error details)
|
|
41
|
+
* @param {string} msgType - Optional severity from the server (e.g., 'warning')
|
|
41
42
|
*/
|
|
42
|
-
function setSaveState(state, msg = '') {
|
|
43
|
+
function setSaveState(state, msg = '', msgType = '') {
|
|
43
44
|
if (savingTimeout) {
|
|
44
45
|
clearTimeout(savingTimeout);
|
|
45
46
|
savingTimeout = null;
|
|
@@ -48,7 +49,7 @@ function setSaveState(state, msg = '') {
|
|
|
48
49
|
document.documentElement.setAttribute('savestatus', state);
|
|
49
50
|
|
|
50
51
|
const event = new CustomEvent(`clay:save-${state}`, {
|
|
51
|
-
detail: { msg, timestamp: Date.now() }
|
|
52
|
+
detail: { msg, msgType, timestamp: Date.now() }
|
|
52
53
|
});
|
|
53
54
|
document.dispatchEvent(event);
|
|
54
55
|
}
|
|
@@ -187,7 +188,7 @@ export function savePage(callback = () => {}) {
|
|
|
187
188
|
// SUCCESS - store stripped version for future comparisons
|
|
188
189
|
lastSavedContents = forComparison;
|
|
189
190
|
unsavedChanges = false;
|
|
190
|
-
setSaveState('saved', data?.msg || 'Saved');
|
|
191
|
+
setSaveState('saved', data?.msg || 'Saved', data?.msgType);
|
|
191
192
|
logBaseline('updated after save', `${lastSavedContents.length} chars`);
|
|
192
193
|
} else {
|
|
193
194
|
// FAILED - determine if it's offline or server error
|
|
@@ -255,7 +256,7 @@ export function savePageForce(callback = () => {}) {
|
|
|
255
256
|
if (!err) {
|
|
256
257
|
lastSavedContents = forComparison;
|
|
257
258
|
unsavedChanges = false;
|
|
258
|
-
setSaveState('saved', data?.msg || 'Saved');
|
|
259
|
+
setSaveState('saved', data?.msg || 'Saved', data?.msgType);
|
|
259
260
|
logBaseline('updated after force save', `${lastSavedContents.length} chars`);
|
|
260
261
|
} else {
|
|
261
262
|
if (!navigator.onLine) {
|
package/src/core/snapshot.js
CHANGED
|
@@ -133,16 +133,18 @@ function prepareCloneForSave(clone) {
|
|
|
133
133
|
new Function(el.getAttribute('onbeforesave')).call(el);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
// Remove elements that shouldn't be saved ([no-save] / legacy [save-remove])
|
|
137
|
-
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
138
|
-
el.remove();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
136
|
// Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
|
|
142
137
|
for (const hook of prepareForSaveHooks) {
|
|
143
138
|
hook(clone);
|
|
144
139
|
}
|
|
145
140
|
|
|
141
|
+
// Strip [no-save] / legacy [save-remove] LAST (snapshot-algorithm step 7): a
|
|
142
|
+
// prepare hook (freeze restore) can re-inject [no-save] content into the clone,
|
|
143
|
+
// so the strip must run after the hooks or that content leaks to disk.
|
|
144
|
+
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
145
|
+
el.remove();
|
|
146
|
+
}
|
|
147
|
+
|
|
146
148
|
return "<!DOCTYPE html>" + clone.outerHTML;
|
|
147
149
|
}
|
|
148
150
|
|
|
@@ -212,13 +214,14 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
|
212
214
|
// Clone for comparison before stripping (cheaper than cloning live DOM)
|
|
213
215
|
const compareClone = clonePreventingOnclone(clone);
|
|
214
216
|
|
|
215
|
-
// Save clone: strip [no-save]/[save-remove]
|
|
216
|
-
|
|
217
|
-
el.remove();
|
|
218
|
-
}
|
|
217
|
+
// Save clone: run hooks (freeze restore lives here), THEN strip [no-save]/[save-remove]
|
|
218
|
+
// LAST (snapshot-algorithm step 7) so freeze-restored [no-save] content can't leak to disk.
|
|
219
219
|
for (const hook of prepareForSaveHooks) {
|
|
220
220
|
hook(clone);
|
|
221
221
|
}
|
|
222
|
+
for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
|
|
223
|
+
el.remove();
|
|
224
|
+
}
|
|
222
225
|
const forSave = "<!DOCTYPE html>" + clone.outerHTML;
|
|
223
226
|
|
|
224
227
|
// Compare clone: strip every autosave-off region, then run hooks
|
package/src/sync/live-sync.js
CHANGED
|
@@ -47,6 +47,15 @@ class LiveSync {
|
|
|
47
47
|
this.currentFile = null;
|
|
48
48
|
this.lastHtml = null;
|
|
49
49
|
this.clientId = this.generateClientId();
|
|
50
|
+
|
|
51
|
+
// Per-stream resume id for the htmlclay replay server's wire contract.
|
|
52
|
+
// Regenerated on every start() (see below) so an explicit stop/start is a
|
|
53
|
+
// brand-new stream that never inherits the old baseline. Native
|
|
54
|
+
// EventSource reuses the same URL — and thus this id — on its own
|
|
55
|
+
// automatic reconnect. Deliberately NOT the sender clientId, which is the
|
|
56
|
+
// durable per-tab identity used for echo suppression.
|
|
57
|
+
this.resumeId = null;
|
|
58
|
+
|
|
50
59
|
this.debounceMs = 150;
|
|
51
60
|
this.debounceTimer = null;
|
|
52
61
|
this.isPaused = false;
|
|
@@ -133,6 +142,21 @@ class LiveSync {
|
|
|
133
142
|
return id;
|
|
134
143
|
}
|
|
135
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Generate a fresh per-stream resume id (URL-safe, well under the 128-byte
|
|
147
|
+
* wire limit). Minted on every start(), never persisted: an explicit
|
|
148
|
+
* stop/start must not reuse the previous stream's baseline.
|
|
149
|
+
*/
|
|
150
|
+
generateResumeId() {
|
|
151
|
+
try {
|
|
152
|
+
const bytes = new Uint8Array(16);
|
|
153
|
+
(globalThis.crypto || window.crypto).getRandomValues(bytes);
|
|
154
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
136
160
|
/**
|
|
137
161
|
* Start the LiveSync system
|
|
138
162
|
* Can be called after stop() to restart with a new file
|
|
@@ -153,9 +177,11 @@ class LiveSync {
|
|
|
153
177
|
return;
|
|
154
178
|
}
|
|
155
179
|
|
|
156
|
-
// Reset state for new connection
|
|
180
|
+
// Reset state for new connection. Resetting lastSeenSeq is a new baseline,
|
|
181
|
+
// so mint a fresh resume id — this stream must not resume the previous one.
|
|
157
182
|
this.lastHtml = null;
|
|
158
183
|
this.lastSeenSeq = 0;
|
|
184
|
+
this.resumeId = this.generateResumeId();
|
|
159
185
|
|
|
160
186
|
console.log(`[LiveSync] Starting for: ${this.currentFile} (lane=${this.lane})`);
|
|
161
187
|
this.connect();
|
|
@@ -361,7 +387,7 @@ class LiveSync {
|
|
|
361
387
|
if (this.isDestroyed) return;
|
|
362
388
|
|
|
363
389
|
const pageUrl = encodeURIComponent(window.location.href);
|
|
364
|
-
const url = `/_/live-sync/stream?page-url=${pageUrl}&lane=${this.lane}`;
|
|
390
|
+
const url = `/_/live-sync/stream?page-url=${pageUrl}&lane=${this.lane}&resume-id=${this.resumeId}`;
|
|
365
391
|
this.sse = new EventSource(url);
|
|
366
392
|
|
|
367
393
|
this.sse.onopen = () => {
|
|
@@ -372,6 +398,26 @@ class LiveSync {
|
|
|
372
398
|
this.sse.onmessage = (event) => {
|
|
373
399
|
const data = JSON.parse(event.data);
|
|
374
400
|
|
|
401
|
+
// Staleness check + watermark advance run FIRST — before the notification
|
|
402
|
+
// and error branches — against the high-water mark of seqs we've seen
|
|
403
|
+
// (own echoes count too, see below). Notification frames now carry a seq,
|
|
404
|
+
// so a conservative-cursor replay after reconnect must not duplicate a
|
|
405
|
+
// toast or let a later stale data frame apply an older snapshot. `seq` is
|
|
406
|
+
// optional for back-compat with older servers that don't stamp it.
|
|
407
|
+
const { seq } = data;
|
|
408
|
+
if (typeof seq === 'number' && seq <= this.lastSeenSeq) {
|
|
409
|
+
this._log(`Dropping stale message: seq=${seq}, lastSeen=${this.lastSeenSeq}`);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Advance the watermark before the notification/sender branches so that
|
|
414
|
+
// our own save echoes count toward "seen". Without this, a later
|
|
415
|
+
// buffered/replayed message with a smaller seq could rewind us past our
|
|
416
|
+
// local edit.
|
|
417
|
+
if (typeof seq === 'number') {
|
|
418
|
+
this.lastSeenSeq = seq;
|
|
419
|
+
}
|
|
420
|
+
|
|
375
421
|
// Handle notifications (show toast, don't morph)
|
|
376
422
|
if (data.type === "notification") {
|
|
377
423
|
this.handleNotification(data);
|
|
@@ -385,22 +431,7 @@ class LiveSync {
|
|
|
385
431
|
return;
|
|
386
432
|
}
|
|
387
433
|
|
|
388
|
-
const { html, sender,
|
|
389
|
-
|
|
390
|
-
// Staleness check runs FIRST — compared against the high-water mark of
|
|
391
|
-
// seqs we've seen (own echoes count too, see below). `seq` is optional
|
|
392
|
-
// for back-compat with older server builds that don't stamp it.
|
|
393
|
-
if (typeof seq === 'number' && seq <= this.lastSeenSeq) {
|
|
394
|
-
this._log(`Dropping stale message: seq=${seq}, lastSeen=${this.lastSeenSeq}`);
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// Advance the watermark before the sender filter so that our own save
|
|
399
|
-
// echoes count toward "seen". Without this, a later buffered/replayed
|
|
400
|
-
// peer message with a smaller seq could rewind us past our local edit.
|
|
401
|
-
if (typeof seq === 'number') {
|
|
402
|
-
this.lastSeenSeq = seq;
|
|
403
|
-
}
|
|
434
|
+
const { html, sender, identityMap } = data;
|
|
404
435
|
|
|
405
436
|
// Ignore own changes — already reflected in the DOM, nothing to morph
|
|
406
437
|
if (sender === this.clientId) {
|
package/src/ui/index.js
CHANGED
|
@@ -30,7 +30,8 @@ else document.addEventListener("clay:mutation-ready", mirrorConsent, { once: tru
|
|
|
30
30
|
|
|
31
31
|
// Automatic save feedback. clay:save-saving stays deliberately silent (a toast for
|
|
32
32
|
// a sub-second transient is noise). Keep your own toast lib? The events are public.
|
|
33
|
-
document.addEventListener("clay:save-saved", () =>
|
|
33
|
+
document.addEventListener("clay:save-saved", (e) =>
|
|
34
|
+
toast(e.detail?.msg || "Saved", e.detail?.msgType === "warning" ? "warning" : "success"));
|
|
34
35
|
document.addEventListener("clay:save-error", () => toastPersistent("Couldn't save", "error"));
|
|
35
36
|
document.addEventListener("clay:save-offline", () => toastPersistent("Offline, not saved", "warning"));
|
|
36
37
|
|
package/src/ui/toast.js
CHANGED
|
@@ -280,11 +280,14 @@ export function toastCore(message, messageType = "success", config = {}) {
|
|
|
280
280
|
const template = templates.toast[messageType] || templates.toast.success;
|
|
281
281
|
const toastHtml = template
|
|
282
282
|
.replace('{icon}', icon)
|
|
283
|
-
.replace('{message}',
|
|
283
|
+
.replace('{message}', '');
|
|
284
284
|
|
|
285
285
|
const tempDiv = document.createElement('div');
|
|
286
286
|
tempDiv.innerHTML = toastHtml.trim();
|
|
287
287
|
const toastElement = tempDiv.firstElementChild;
|
|
288
|
+
// A message can carry a filename, and a filename is whatever whoever wrote the
|
|
289
|
+
// file chose. Assign it as text so markup in a name can never become markup here.
|
|
290
|
+
toastElement.querySelector('.toast-message, .message').textContent = message;
|
|
288
291
|
|
|
289
292
|
// Add click handler
|
|
290
293
|
toastElement.addEventListener('click', () => {
|
|
@@ -393,11 +396,14 @@ function toastPersistent(message, messageType = "warning") {
|
|
|
393
396
|
const template = templates.toast[messageType] || templates.toast.warning;
|
|
394
397
|
const toastHtml = template
|
|
395
398
|
.replace('{icon}', icon)
|
|
396
|
-
.replace('{message}',
|
|
399
|
+
.replace('{message}', '');
|
|
397
400
|
|
|
398
401
|
const tempDiv = document.createElement('div');
|
|
399
402
|
tempDiv.innerHTML = toastHtml.trim();
|
|
400
403
|
const toastElement = tempDiv.firstElementChild;
|
|
404
|
+
// A message can carry a filename, and a filename is whatever whoever wrote the
|
|
405
|
+
// file chose. Assign it as text so markup in a name can never become markup here.
|
|
406
|
+
toastElement.querySelector('.toast-message, .message').textContent = message;
|
|
401
407
|
toastElement.classList.add('toast-persistent');
|
|
402
408
|
|
|
403
409
|
// Add close button as sibling (not inside message)
|