@panphora/clayjs 1.6.1 → 1.8.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.
@@ -0,0 +1,321 @@
1
+ import { hostSupports } from "./host-meta.js";
2
+ import { saveToken } from "./host-attrs.js";
3
+
4
+ const DATA_READ = "data-read";
5
+ const DATA_WRITE = "data-write";
6
+ const WRITE_HOLD_REASON = "Waiting for the page data write to finish";
7
+ const SYNC_WAIT_MS = 12000;
8
+ const HTTP_WAIT_MS = 30000;
9
+
10
+ function refusal(code, message, details) {
11
+ const error = new Error(message);
12
+ error.code = code;
13
+ error.details = details;
14
+ return error;
15
+ }
16
+
17
+ async function responseBody(response) {
18
+ if (typeof response.json === "function") return response.json();
19
+ const text = await response.text();
20
+ return text ? JSON.parse(text) : null;
21
+ }
22
+
23
+ async function responseError(response) {
24
+ let body = {};
25
+ try {
26
+ body = (await responseBody(response)) || {};
27
+ } catch {}
28
+
29
+ const message = body.message || body.error || response.statusText || `Request failed (${response.status})`;
30
+ const error = new Error(message);
31
+ error.status = response.status;
32
+ error.error = body.error;
33
+ error.details = body.details;
34
+ return error;
35
+ }
36
+
37
+ function responseEtag(response) {
38
+ const value = response.headers?.get?.("ETag");
39
+ return typeof value === "string" && value ? value : null;
40
+ }
41
+
42
+ function dataUrl(rules, location) {
43
+ const base = `${location.origin}${location.pathname}`;
44
+ if (rules !== undefined) return `${base}?data=${encodeURIComponent(JSON.stringify(rules))}`;
45
+ return `${location.origin}/_/api${location.pathname}`;
46
+ }
47
+
48
+ function connected(sync) {
49
+ return !!sync && !sync.isDestroyed && sync.sse?.readyState === 1;
50
+ }
51
+
52
+ function syncWaiter(eventTarget, timeoutMs) {
53
+ const seen = [];
54
+ let expected = null;
55
+ let resolvePending = null;
56
+ let timer = null;
57
+
58
+ const matchesExact = (detail) =>
59
+ detail?.source === "disk" && (expected === null || detail.etag === expected);
60
+
61
+ const matchesPending = (detail) =>
62
+ detail?.source === "disk" && (detail.etag == null || expected === null || detail.etag === expected);
63
+
64
+ const cleanup = () => {
65
+ clearTimeout(timer);
66
+ eventTarget.removeEventListener("clay:sync-applied", onApplied);
67
+ };
68
+
69
+ const onApplied = (event) => {
70
+ seen.push(event.detail);
71
+ if (!resolvePending || !matchesPending(event.detail)) return;
72
+ const resolve = resolvePending;
73
+ resolvePending = null;
74
+ cleanup();
75
+ resolve(true);
76
+ };
77
+
78
+ eventTarget.addEventListener("clay:sync-applied", onApplied);
79
+
80
+ return {
81
+ waitFor(etag) {
82
+ expected = etag;
83
+ if (seen.some(matchesExact)) {
84
+ cleanup();
85
+ return Promise.resolve(true);
86
+ }
87
+ return new Promise((resolve) => {
88
+ resolvePending = resolve;
89
+ timer = setTimeout(() => {
90
+ resolvePending = null;
91
+ cleanup();
92
+ resolve(false);
93
+ }, timeoutMs);
94
+ });
95
+ },
96
+ cancel: cleanup,
97
+ };
98
+ }
99
+
100
+ function httpDeadline(timeoutMs) {
101
+ const controller = new AbortController();
102
+ let rejectDeadline;
103
+ const expired = new Promise((_, reject) => {
104
+ rejectDeadline = reject;
105
+ });
106
+ const timer = setTimeout(() => {
107
+ const error = new Error("The page data write timed out.");
108
+ error.name = "TimeoutError";
109
+ controller.abort(error);
110
+ rejectDeadline(error);
111
+ }, timeoutMs);
112
+
113
+ return {
114
+ signal: controller.signal,
115
+ waitFor(promise) {
116
+ return Promise.race([promise, expired]);
117
+ },
118
+ cancel() {
119
+ clearTimeout(timer);
120
+ },
121
+ };
122
+ }
123
+
124
+ async function defaultWriteModules() {
125
+ const [save, saveCore, etag, warning, editMode] = await Promise.all([
126
+ import("./save.js"),
127
+ import("./save-core.js"),
128
+ import("./etag.js"),
129
+ import("./unsaved-warning.js"),
130
+ import("./is-edit-mode.js"),
131
+ ]);
132
+ return { ...save, ...saveCore, ...etag, ...warning, ...editMode };
133
+ }
134
+
135
+ export function createPageDataApi({
136
+ supports = hostSupports,
137
+ token = saveToken,
138
+ fetchImpl = (...args) => fetch(...args),
139
+ getLocation = () => window.location,
140
+ eventTarget = document,
141
+ loadWriteModules = defaultWriteModules,
142
+ syncTimeoutMs = SYNC_WAIT_MS,
143
+ httpTimeoutMs = HTTP_WAIT_MS,
144
+ } = {}) {
145
+ let liveSync = null;
146
+ let writeRunning = false;
147
+ let writeOutcomeUnknown = false;
148
+
149
+ function setLiveSync(sync) {
150
+ liveSync = sync;
151
+ }
152
+
153
+ async function readData(rules) {
154
+ if (!(await supports(DATA_READ))) {
155
+ throw refusal("DataReadUnsupported", "This host does not support reading page data.");
156
+ }
157
+
158
+ let response;
159
+ try {
160
+ response = await fetchImpl(dataUrl(rules, getLocation()), {
161
+ method: "GET",
162
+ credentials: "same-origin",
163
+ cache: "no-store",
164
+ redirect: "error",
165
+ });
166
+ } catch (cause) {
167
+ throw Object.assign(refusal("DataReadFailed", cause?.message || "Could not read page data."), { cause });
168
+ }
169
+
170
+ if (!response.ok) throw await responseError(response);
171
+ return { data: await responseBody(response), etag: responseEtag(response) };
172
+ }
173
+
174
+ function assertWriteState(modules, { checkHold = true } = {}) {
175
+ if (writeOutcomeUnknown) {
176
+ throw refusal("DataWriteOutcomeUnknown", "A previous page data write has an unknown outcome. Reload before writing again.");
177
+ }
178
+ if (writeRunning) {
179
+ throw refusal("DataWriteInProgress", "Another page data write is already running.");
180
+ }
181
+ if (modules.isSaveInProgress()) {
182
+ throw refusal("SaveInProgress", "The page is already being saved.");
183
+ }
184
+ if (modules.saveFateIsUnknown()) {
185
+ throw refusal("SaveOutcomeUnknown", "The page's last save has an unknown outcome.");
186
+ }
187
+ if (checkHold && (modules.savesHeld() || modules.isSaveConflicted())) {
188
+ throw refusal("SavesHeld", "The page cannot write data while another save hold is active.");
189
+ }
190
+ if (modules.hasUnsavedChanges()) {
191
+ throw refusal("UnsavedChanges", "Save or discard the page's unsaved changes before writing data.");
192
+ }
193
+ if (!connected(liveSync)) {
194
+ throw refusal("SyncRequired", "Page data writes require the sync plugin to be connected.");
195
+ }
196
+ }
197
+
198
+ async function waitForBaseline(modules) {
199
+ if (modules.baselineSettled()) return;
200
+ await new Promise((resolve) => eventTarget.addEventListener("clay:baseline-settled", resolve, { once: true }));
201
+ }
202
+
203
+ async function writeData(values, { rules, ifMatch } = {}) {
204
+ if (!(await supports(DATA_WRITE))) {
205
+ throw refusal("DataWriteUnsupported", "This host does not support writing page data.");
206
+ }
207
+
208
+ const modules = await loadWriteModules();
209
+ if (!modules.isEditMode) {
210
+ throw refusal("DataWriteUnavailable", "Page data writes require an editable page.");
211
+ }
212
+
213
+ await waitForBaseline(modules);
214
+ assertWriteState(modules);
215
+
216
+ let match = ifMatch;
217
+ if (match == null) match = modules.lastSeenEtag() || await modules.seedEtag();
218
+ if (typeof match !== "string" || match === "") {
219
+ throw refusal("DataWriteVersionRequired", "Page data writes require a trustworthy document ETag.");
220
+ }
221
+
222
+ assertWriteState(modules);
223
+ writeRunning = true;
224
+ modules.holdAllSaves(WRITE_HOLD_REASON);
225
+
226
+ let waiter = null;
227
+ try {
228
+ if (modules.isSaveInProgress()) {
229
+ throw refusal("SaveInProgress", "The page is already being saved.");
230
+ }
231
+ if (modules.saveFateIsUnknown()) {
232
+ throw refusal("SaveOutcomeUnknown", "The page's last save has an unknown outcome.");
233
+ }
234
+ if (modules.isSaveConflicted()) {
235
+ throw refusal("SavesHeld", "The page cannot write data while another save hold is active.");
236
+ }
237
+ if (modules.hasUnsavedChanges()) {
238
+ throw refusal("UnsavedChanges", "Save or discard the page's unsaved changes before writing data.");
239
+ }
240
+ if (!connected(liveSync)) {
241
+ throw refusal("SyncRequired", "Page data writes require the sync plugin to be connected.");
242
+ }
243
+
244
+ const headers = {
245
+ "Content-Type": "application/json",
246
+ "If-Match": match,
247
+ };
248
+ const currentToken = token();
249
+ if (currentToken) headers["Save-Token"] = currentToken;
250
+
251
+ const body = JSON.stringify(values);
252
+ waiter = syncWaiter(eventTarget, syncTimeoutMs);
253
+ const deadline = httpDeadline(httpTimeoutMs);
254
+
255
+ let response;
256
+ try {
257
+ response = await deadline.waitFor(fetchImpl(dataUrl(rules, getLocation()), {
258
+ method: "POST",
259
+ credentials: "same-origin",
260
+ redirect: "error",
261
+ headers,
262
+ body,
263
+ signal: deadline.signal,
264
+ }));
265
+ } catch (cause) {
266
+ deadline.cancel();
267
+ writeOutcomeUnknown = true;
268
+ throw Object.assign(
269
+ refusal("DataWriteOutcomeUnknown", "The page data write has an unknown outcome. Do not retry automatically."),
270
+ { cause, unknownOutcome: true }
271
+ );
272
+ }
273
+
274
+ let data;
275
+ try {
276
+ if (!response.ok) throw await deadline.waitFor(responseError(response));
277
+ data = await deadline.waitFor(responseBody(response));
278
+ } catch (cause) {
279
+ if (cause?.status) throw cause;
280
+ writeOutcomeUnknown = true;
281
+ throw Object.assign(
282
+ refusal("DataWriteOutcomeUnknown", "The page data was written, but its response could not be read."),
283
+ { cause, unknownOutcome: true }
284
+ );
285
+ } finally {
286
+ deadline.cancel();
287
+ }
288
+
289
+ const etag = responseEtag(response);
290
+ if (etag && etag === modules.lastSeenEtag()) {
291
+ waiter.cancel();
292
+ waiter = null;
293
+ return { data, etag };
294
+ }
295
+
296
+ const applied = await waiter.waitFor(etag);
297
+ waiter = null;
298
+ if (!applied) {
299
+ return {
300
+ data,
301
+ etag,
302
+ pageUpdatePending: true,
303
+ message: "Written, page update pending.",
304
+ };
305
+ }
306
+ return { data, etag };
307
+ } finally {
308
+ waiter?.cancel();
309
+ writeRunning = false;
310
+ modules.releaseAllSaves({ reason: WRITE_HOLD_REASON });
311
+ }
312
+ }
313
+
314
+ return { readData, writeData, setLiveSync };
315
+ }
316
+
317
+ const pageData = createPageDataApi();
318
+
319
+ export const readData = pageData.readData;
320
+ export const writeData = pageData.writeData;
321
+ export const setPageDataLiveSync = pageData.setLiveSync;
package/src/core/save.js CHANGED
@@ -26,7 +26,7 @@ import { seedEtag, lastSeenEtag } from "./etag.js";
26
26
  import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
27
27
  import { hasUnsavedState } from "../lib/unsaved-state.js";
28
28
  import { autosaveActive } from "../lib/autosave-state.js";
29
- import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
29
+ import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS, HOST_RESPONSE_ATTRS } from "../lib/root-attrs.js";
30
30
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
31
31
  import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
32
32
  // A deliberate import cycle: unsaved-warning reads this module's saved baseline, and
@@ -51,16 +51,24 @@ addDocumentTransform(clone => {
51
51
  for (const name of ROOT_LIBRARY_ATTRS) clone.removeAttribute(name);
52
52
  });
53
53
 
54
- // Keep the host's save token out of the saved bytes, both spellings.
54
+ // Keep the host's save token out of the saved bytes, both spellings, and the response
55
+ // metadata with it.
55
56
  //
56
- // It is a credential for this response, never file content: htmlclay strips it from
57
- // every save body on arrival, so it never reached disk anyway. Sending it made the
57
+ // A save token is a credential for this response, never file content: htmlclay strips it
58
+ // from every save body on arrival, so it never reached disk anyway. Sending it made the
58
59
  // source map, which models the bytes a save sent, describe a root tag one attribute
59
60
  // longer than the file, and every offset after it was off by that much. The save
60
61
  // itself is authorized by the URL, which reads the token from the live page. The
61
62
  // document id is NOT stripped: htmlclay keeps it on disk on purpose.
63
+ //
64
+ // `documentetag` is the same kind of thing for a different reason. It names the version
65
+ // of the response this tab loaded, and it is replaced on every serve, so writing it to
66
+ // disk would freeze one response's stamp into the file and give the next reader a
67
+ // provenance claim about bytes nobody built a response from. Root only, like the rest of
68
+ // this transform: the same name on a child is the author's, and stripping those would
69
+ // delete page content.
62
70
  addDocumentTransform(clone => {
63
- for (const name of [...SAVE_TOKEN_ATTRS, ...LEGACY_SAVE_TOKEN_ATTRS]) clone.removeAttribute(name);
71
+ for (const name of [...SAVE_TOKEN_ATTRS, ...LEGACY_SAVE_TOKEN_ATTRS, ...HOST_RESPONSE_ATTRS]) clone.removeAttribute(name);
64
72
  });
65
73
 
66
74
  // ============================================
@@ -214,23 +222,26 @@ export function resumeAutosave() {
214
222
  }
215
223
 
216
224
  // ============================================
217
- // THE PREVIEW HOLD
225
+ // THE ALL-SAVE HOLD
218
226
  // ============================================
219
227
  //
220
- // While an AI edit's rewrite is on screen and not yet kept, the DOM holds words the
221
- // person has not accepted. Every save would write them, the explicit ones included,
222
- // so for that window no save goes out at all. A save asked for meanwhile is
223
- // remembered and runs on release; Keep releases without the replay because it saves
224
- // right after.
228
+ // While an operation owns the page's persisted state, no save goes out at all. A
229
+ // save asked for meanwhile is remembered and runs on release.
225
230
  let previewHold = 0;
226
231
  let previewMissed = false;
232
+ const PREVIEW_HOLD_REASON = 'Waiting for the AI edit to be kept or reverted';
233
+ const holdReasons = new Map();
227
234
 
228
- export function holdAllSaves() {
235
+ export function holdAllSaves(reason = PREVIEW_HOLD_REASON) {
229
236
  previewHold++;
237
+ holdReasons.set(reason, (holdReasons.get(reason) || 0) + 1);
230
238
  }
231
239
 
232
- export function releaseAllSaves({ replay = true } = {}) {
233
- if (previewHold === 0) return;
240
+ export function releaseAllSaves({ replay = true, reason = PREVIEW_HOLD_REASON } = {}) {
241
+ const count = holdReasons.get(reason) || 0;
242
+ if (count === 0) return;
243
+ if (count === 1) holdReasons.delete(reason);
244
+ else holdReasons.set(reason, count - 1);
234
245
  previewHold--;
235
246
  if (previewHold > 0) return;
236
247
  const missed = previewMissed;
@@ -242,11 +253,16 @@ export function savesHeld() {
242
253
  return previewHold > 0;
243
254
  }
244
255
 
256
+ export function saveHoldReason() {
257
+ if (previewHold === 0) return null;
258
+ return holdReasons.keys().next().value || PREVIEW_HOLD_REASON;
259
+ }
260
+
245
261
  function heldForPreview(callback, resolve) {
246
262
  if (previewHold === 0) return false;
247
263
  previewMissed = true;
248
264
  clearExplicitSave();
249
- const skipped = skipped_('Waiting for the AI edit to be kept or reverted');
265
+ const skipped = skipped_(saveHoldReason());
250
266
  callback(skipped);
251
267
  resolve(skipped);
252
268
  return true;
@@ -854,7 +870,7 @@ export function savePageThrottled(callback = () => {}) {
854
870
 
855
871
  if (previewHold > 0) {
856
872
  previewMissed = true;
857
- const skipped = skipped_('Waiting for the AI edit to be kept or reverted');
873
+ const skipped = skipped_(saveHoldReason());
858
874
  callback(skipped);
859
875
  return Promise.resolve(skipped);
860
876
  }
@@ -5,9 +5,9 @@ const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visite
5
5
  function copyControlState(source, copy) {
6
6
  if (!/^(INPUT|TEXTAREA|SELECT|OPTION)$/.test(source.tagName || '')) return
7
7
  const type = (source.getAttribute?.('type') || '').toLowerCase()
8
- if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio') copy.value = source.value
9
- if ('checked' in source && 'checked' in copy) copy.checked = source.checked
10
- if ('selected' in source && 'selected' in copy) copy.selected = source.selected
8
+ if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio' && type !== 'file') copy.value = source.value
9
+ if (source.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) copy.checked = source.checked
10
+ if (source.tagName === 'OPTION') copy.selected = source.selected
11
11
  if (source.tagName === 'SELECT') {
12
12
  for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
13
13
  }
@@ -63,6 +63,19 @@ export const LEGACY_SAVE_TOKEN_ATTRS = ["htmlclaytoken"];
63
63
  // what fixed that, and the split holds whatever token spellings are read above.
64
64
  export const HOST_IDENTITY_ATTRS = ["documentid", "htmlclayid"];
65
65
 
66
+ // Response metadata, and neither of the other two. `documentetag` names the version
67
+ // of the bytes the host built THIS response from, which is the one thing neither the
68
+ // token nor the identity can say: a token grants a capability and an identity names a
69
+ // file, while the stamp names a revision. It rides on the response only, so an
70
+ // incoming morph must never apply a peer's copy and an outgoing sync must never carry
71
+ // this tab's. Read once, at module evaluation, by host-attrs.js — never re-read from
72
+ // the live root, which a morph may since have rewritten.
73
+ //
74
+ // Kept out of SAVE_TOKEN_ATTRS on purpose: host-attrs.js returns the first name it
75
+ // finds in that list straight into the save URL, and a version stamp is not a
76
+ // credential.
77
+ export const HOST_RESPONSE_ATTRS = ["documentetag"];
78
+
66
79
  // What a host may have injected, and therefore what has to be stripped before a save
67
80
  // and kept out of an incoming morph. Wider than what is READ, on purpose: the old token
68
81
  // spelling is still injected by every htmlclay, so it still has to be stripped, whether
@@ -87,6 +100,7 @@ export const ROOT_LIBRARY_ATTRS = ["savestatus", "editmode", "pageowner"];
87
100
  // can no longer save at all.
88
101
  export const TAB_LOCAL_ROOT_ATTRS = new Set([
89
102
  ...HOST_TOKEN_ATTRS,
103
+ ...HOST_RESPONSE_ATTRS,
90
104
  ...ROOT_LIBRARY_ATTRS,
91
105
  ]);
92
106
 
@@ -8,6 +8,7 @@ export const CORE_WAVES = {
8
8
  // is exactly the case where edit mode is off, so the editOnly wave would never
9
9
  // reach it. It draws nothing on any other page.
10
10
  "core/stale-host-notice.js",
11
+ "core/page-data.js",
11
12
  ],
12
13
  editOnly: [
13
14
  "vendor/hyper-morph.vendor.js",
@@ -66,6 +67,7 @@ export const MODULES = {
66
67
  "lib/mutation.js": () => import("./lib/mutation.js"),
67
68
  "core/edit-mode.js": () => import("./core/edit-mode.js"),
68
69
  "core/stale-host-notice.js": () => import("./core/stale-host-notice.js"),
70
+ "core/page-data.js": () => import("./core/page-data.js"),
69
71
  "core/snapshot.js": () => import("./core/snapshot.js"),
70
72
  "core/save-core.js": () => import("./core/save-core.js"),
71
73
  "core/save.js": () => import("./core/save.js"),
package/src/loader.js CHANGED
@@ -50,7 +50,7 @@ export async function boot(base, params, readyResolve) {
50
50
  continue;
51
51
  }
52
52
  loaded[path] = mod;
53
- attachPluginMember(path, mod); // immediately, not after the loop: hypercms's ?cms=true
53
+ attachPluginMember(path, mod, loaded); // immediately, not after the loop: hypercms's ?cms=true
54
54
  // auto-open runs as a microtask queued during ITS evaluation
55
55
  // (before boot resumes) and reads clay.RichClay and clay.undo,
56
56
  // which earlier plugins must have attached by then
@@ -83,6 +83,10 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
83
83
  const save = loaded["core/save.js"];
84
84
  const cacheBustMod = loaded["lib/cache-bust.js"];
85
85
  const hyperMorph = loaded["vendor/hyper-morph.vendor.js"];
86
+ const pageData = loaded["core/page-data.js"];
87
+
88
+ clay.readData = pageData.readData;
89
+ clay.writeData = pageData.writeData;
86
90
 
87
91
  if (save) {
88
92
  const saveFn = save.savePage || save.default;
@@ -107,7 +111,7 @@ function assembleCore(loaded, { isEditMode, isOwner }, regionPolicy) {
107
111
  if (hyperMorph) clay.morph = hyperMorph.morph;
108
112
  }
109
113
 
110
- function attachPluginMember(path, mod) {
114
+ function attachPluginMember(path, mod, loaded) {
111
115
  const clay = window.clay;
112
116
 
113
117
  if (path === "plugins/undo.js") {
@@ -115,6 +119,7 @@ function attachPluginMember(path, mod) {
115
119
  } else if (path === "sync/live-sync.js") {
116
120
  clay.morph = mod.morph;
117
121
  clay.conflicts = mod.conflicts;
122
+ loaded["core/page-data.js"].setPageDataLiveSync(mod.liveSync);
118
123
  } else if (path === "plugins/cms.js") {
119
124
  clay.cms = mod.cms || mod.default;
120
125
  } else if (path === "plugins/upload.js") {