@panphora/clayjs 1.1.0 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT-0",
package/src/core/etag.js CHANGED
@@ -15,8 +15,9 @@
15
15
  * - an accepted save replaces it with the one that response carried, and clears
16
16
  * it when a response carries none: after our own write, any stamp still held
17
17
  * here is known to describe bytes the host has stopped storing,
18
- * - a disk-sourced live-sync frame clears it and asks for a fresh one, because
19
- * the file changed under a tab that never saved.
18
+ * - a disk-sourced live-sync frame replaces it with the stamp that frame
19
+ * carried, because the file changed under a tab that never saved. A frame
20
+ * with no stamp on it falls back to clearing and asking the host.
20
21
  *
21
22
  * A peer's SNAPSHOT does not move it. §10 relays never write to disk, so the
22
23
  * stamp is still true after one lands, and treating one as a disk change would
@@ -99,8 +100,19 @@ export async function seedEtag({ fresh = false, clearIfMissing = fresh } = {}) {
99
100
  // A disk-sourced frame is the one live-sync outcome that changes the file under a
100
101
  // tab which did not save it. Holding the old stamp would refuse this tab's next
101
102
  // save against bytes it has already morphed to and is looking at, so the stamp
102
- // goes immediately and the replacement is asked for in the background: the save
103
- // lane never waits on it.
103
+ // has to move.
104
+ //
105
+ // The frame's own stamp is the right one, and live-sync has already taken it as
106
+ // part of applying the frame's content. Nothing is left to do here: this listener
107
+ // exists for the frames that carry no stamp.
108
+ //
109
+ // Asking the host is the fallback and not the rule, because discovery answers
110
+ // about whatever is on disk when the ANSWER is built, which is a later moment
111
+ // than the frame. If a second write lands in between, the reply describes bytes
112
+ // this tab has never seen, and adopting it makes the next save overwrite that
113
+ // write silently. The frames without a stamp are an old host, and the fetch
114
+ // fallback for a change too large to send, whose body is the served page rather
115
+ // than anything the host stamped.
104
116
  //
105
117
  // A frame that live-sync HELD is deliberately not covered, because it never
106
118
  // dispatches this event. That tab has unsaved local edits and has not seen the
@@ -108,6 +120,7 @@ export async function seedEtag({ fresh = false, clearIfMissing = fresh } = {}) {
108
120
  if (isEditMode) {
109
121
  document.addEventListener("clay:sync-applied", (event) => {
110
122
  if (event.detail?.source !== "disk") return;
123
+ if (typeof event.detail.etag === "string" && event.detail.etag) return;
111
124
  forgetEtag();
112
125
  seedEtag({ fresh: true });
113
126
  });
@@ -7,7 +7,33 @@
7
7
  * cannot drift between the edit-mode ladder and the save lane.
8
8
  */
9
9
 
10
- import { SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
10
+ import { SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
11
+
12
+ let warnedAboutLegacyToken = false;
13
+
14
+ /**
15
+ * Say so, once, when this response carries only the pre-rename save token.
16
+ *
17
+ * Dropping the old spelling is a deliberate break (see root-attrs.js), and its failure
18
+ * mode is the kind worth spending five lines on: the host that serves the old name also
19
+ * sets the edit-mode cookie, so the page stays editable and every save 404s. Without
20
+ * this the reader sees a working page that quietly keeps nothing. With it the console
21
+ * names the cause and the fix, which is the whole difference between a break and a bug.
22
+ */
23
+ function warnAboutLegacyToken() {
24
+ if (warnedAboutLegacyToken) return;
25
+ const carries = LEGACY_SAVE_TOKEN_ATTRS.some(
26
+ (attr) => document.documentElement.getAttribute(attr)
27
+ );
28
+ if (!carries) return;
29
+ warnedAboutLegacyToken = true;
30
+ console.warn(
31
+ "[clay] This page was served with the pre-1.9.0 save token (" +
32
+ LEGACY_SAVE_TOKEN_ATTRS.join(", ") +
33
+ "), which this version no longer accepts. Saving will fail until the host is " +
34
+ "updated. If this is HTML Clay, upgrade it to 1.9.0 or newer."
35
+ );
36
+ }
11
37
 
12
38
  /**
13
39
  * The per-document save token this response carries, or null.
@@ -24,13 +50,37 @@ export function saveToken() {
24
50
  const value = document.documentElement.getAttribute(attr);
25
51
  if (value) return value;
26
52
  }
53
+ warnAboutLegacyToken();
27
54
  return null;
28
55
  }
29
56
 
30
57
  /**
31
- * True when the host handed this response a save token of either spelling.
58
+ * True when the host handed this response a save token this version accepts.
59
+ *
60
+ * Only `savetoken` counts. A response carrying nothing but the pre-rename spelling is
61
+ * FALSE here and true from `servedStaleToken()`, which is the whole point of the pair.
62
+ *
32
63
  * @returns {boolean}
33
64
  */
34
65
  export function hasSaveToken() {
35
66
  return saveToken() !== null;
36
67
  }
68
+
69
+ /**
70
+ * True when this response carries ONLY the pre-rename save token.
71
+ *
72
+ * That combination means one thing: the host is older than the rename and cannot be
73
+ * saved to by this version. It is worth its own name because two separate things act
74
+ * on it. Edit mode goes off, so the page does not offer editing it cannot keep, and
75
+ * stale-host-notice.js says why on the page, since the console line reaches a
76
+ * developer and nobody else.
77
+ *
78
+ * @returns {boolean}
79
+ */
80
+ export function servedStaleToken() {
81
+ if (typeof document === "undefined") return false;
82
+ if (saveToken() !== null) return false;
83
+ return LEGACY_SAVE_TOKEN_ATTRS.some(
84
+ (attr) => document.documentElement.getAttribute(attr)
85
+ );
86
+ }
@@ -1,11 +1,11 @@
1
1
  import cookie from "../lib/cookie.js";
2
2
  import query from "../lib/query.js";
3
- import { hasSaveToken } from "./host-attrs.js";
3
+ import { hasSaveToken, servedStaleToken } from "./host-attrs.js";
4
4
 
5
5
  // Edit-mode precedence: an explicit ?editmode=true|false URL param wins, then an
6
6
  // opt-in window.clayEditMode global (with the legacy window.__hyperclayEditMode
7
7
  // still honored as a fallback for older standalone embedders — htmlclay itself
8
- // uses the injected htmlclaytoken plus the admin cookie, not this global), then
8
+ // uses its injected save token plus the admin cookie, not this global), then
9
9
  // a save token the host put on the root, then the isAdminOfCurrentResource cookie. The
10
10
  // global is for standalone uses (demos, htmlclay, any self-saving file) that are
11
11
  // always editable and have no owner cookie; setting it before clayjs loads turns
@@ -25,11 +25,20 @@ if (typeof window !== "undefined") {
25
25
  }
26
26
  }
27
27
 
28
+ // A host older than the save-token rename takes edit mode off the table, above every
29
+ // rung below it. Such a host still sets the owner cookie, so without this the page
30
+ // would look fully editable while every save 404s against a route that no longer
31
+ // matches: an editable page that keeps nothing is worse than a read-only one. Only an
32
+ // explicit ?editmode=true outranks it, because that is a person at the keyboard asking
33
+ // for it this load, not a decision baked into the document by an author who could not
34
+ // have known. stale-host-notice.js puts the reason on the page.
28
35
  const isEditMode = query.editmode
29
36
  ? query.editmode === "true" // takes precedence over the global, token and cookie
30
- : forcedEditMode != null
31
- ? forcedEditMode
32
- : hasSaveToken() || Boolean(cookie.get("isAdminOfCurrentResource"));
37
+ : servedStaleToken()
38
+ ? false
39
+ : forcedEditMode != null
40
+ ? forcedEditMode
41
+ : hasSaveToken() || Boolean(cookie.get("isAdminOfCurrentResource"));
33
42
 
34
43
  const isOwner = Boolean(cookie.get("isAdminOfCurrentResource"));
35
44
 
@@ -1,5 +1,6 @@
1
1
  import { isEditMode } from "./is-edit-mode.js";
2
2
  import onDomReady from "../lib/dom-ready.js";
3
+ import { style, set, make } from "../lib/hostile-css.js";
3
4
 
4
5
  // The page's only word on a refused save. Without it a document whose host said no
5
6
  // looks exactly like one that is saving fine, while autosave sits suspended: the
@@ -33,31 +34,6 @@ let armTimer = null, armed = false, busy = false;
33
34
 
34
35
  const still = () => !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
35
36
 
36
- // Every declaration goes on as !important, and this is the whole reason the notice
37
- // survives a stranger's page. A plain inline style loses to an author rule that
38
- // carries !important, and `button { ... !important }` is a thing real pages do: the
39
- // first browser run of this module had both controls repainted in the host's colours
40
- // and font. Only an inline !important outranks an author !important.
41
- function style(el, rules) {
42
- for (const rule of rules) {
43
- const at = rule.indexOf(":");
44
- el.style.setProperty(rule.slice(0, at).trim(), rule.slice(at + 1).trim(), "important");
45
- }
46
- }
47
-
48
- // Same reason: a later assignment must be able to beat the !important one already
49
- // sitting on the element, and a plain style.foo = x silently cannot.
50
- function set(el, prop, value) {
51
- el.style.setProperty(prop, value, "important");
52
- }
53
-
54
- function make(tag, rules, text) {
55
- const el = document.createElement(tag);
56
- style(el, rules);
57
- if (text) el.textContent = text;
58
- return el;
59
- }
60
-
61
37
  // all:initial first, because a page restyling every button is the normal case, not
62
38
  // the adversarial one. Everything the control needs is restated after it.
63
39
  function button(label, rules) {
@@ -171,8 +147,27 @@ function onKeydown(e) {
171
147
  function show(e) {
172
148
  if (!root) build();
173
149
  set(root, "display", "flex");
174
- const where = SOURCES[e?.detail?.changedBy] || "elsewhere";
175
- line.textContent = `This page was updated ${where}. Saving is paused.`;
150
+ const named = SOURCES[e?.detail?.changedBy];
151
+ // Reassurance first, then the fact. What a person needs to know at this moment
152
+ // is that nothing of theirs is gone and nothing is about to be overwritten; the
153
+ // detail of what happened is the second half of the sentence, not the first.
154
+ // "Saving is paused" led here once and read as a failure, which it is not: the
155
+ // page is holding a version, and the two buttons below are the whole decision.
156
+ //
157
+ // An earlier save of this tab's timed out and the host could not say what became
158
+ // of it, so this refusal may be answering that write rather than anybody else's.
159
+ // Saying so is the whole reason the stamp is kept across a timeout instead of
160
+ // quietly reconciled. A host that NAMED the writer, or that answered the question
161
+ // with a receipt, knows better than this guess, so the name wins.
162
+ if (!named && e?.detail?.afterTimeout) {
163
+ line.textContent =
164
+ "This page changed elsewhere, possibly by your own save that timed out. " +
165
+ "Your edits here are safe, and nothing will be overwritten until you choose.";
166
+ } else {
167
+ line.textContent =
168
+ `This page changed ${named || "elsewhere"}. ` +
169
+ "Your edits here are safe, and nothing will be overwritten until you choose.";
170
+ }
176
171
  keep.textContent = "Keep mine";
177
172
  set(keep, "opacity", "1");
178
173
  busy = false;
@@ -10,7 +10,8 @@
10
10
  import { isEditMode } from "./is-edit-mode.js";
11
11
  import { consumeUserDriven, consumeExplicitSave, markUserDriven } from "../lib/user-gesture.js";
12
12
  import { saveToken } from "./host-attrs.js";
13
- import { lastSeenEtag, conditionalSaves, recordEtag, seedEtag } from "./etag.js";
13
+ import { lastSeenEtag, conditionalSaves, recordEtag } from "./etag.js";
14
+ import { hostMeta } from "./host-meta.js";
14
15
  import {
15
16
  getPageContents,
16
17
  onSnapshot,
@@ -61,6 +62,93 @@ function successResult(data) {
61
62
  };
62
63
  }
63
64
 
65
+ // =============================================================================
66
+ // THE ATTEMPT ID (spec §6, `receipts`)
67
+ // =============================================================================
68
+ // A save that times out leaves two questions open at once: did our write land,
69
+ // and did somebody else write. The client cannot answer either from the outside.
70
+ // Guessing "it was me" is what the old reconcile-on-timeout did, and when the
71
+ // guess was wrong it adopted a stamp describing a peer's bytes this tab had never
72
+ // seen, so the next save overwrote them with no refusal and no notice. Refusing to
73
+ // guess is safe but shows a person a conflict bar for a few seconds of bad wifi.
74
+ //
75
+ // So this tab stamps every attempt with an opaque id and asks the host the one
76
+ // question only the host can answer: are the bytes you are storing the ones this
77
+ // save sent? A host that answers turns an indeterminate save into a determinate
78
+ // one, and the person sees nothing at all.
79
+ //
80
+ // The ids this tab has sent, newest last. A 412 carrying one of them is the host
81
+ // saying "your OWN earlier save is what moved this document", which is a late
82
+ // duplicate and not a conflict with anybody. Bounded because it is only ever
83
+ // searched for a recent send.
84
+ const sentIds = [];
85
+ const SENT_ID_MEMORY = 8;
86
+
87
+ // The attempt whose answer never arrived, or null. It holds only what is needed to
88
+ // ask about it later: which id it carried, and which stamp it was sent against.
89
+ //
90
+ // It survives ONLY while the question is genuinely open. A host that answers, in
91
+ // either direction, closes it, which is what keeps the conflict bar from claiming
92
+ // a refusal might be the person's own save when the host has already said it is
93
+ // not. An unreachable host leaves it set, and that is the honest old behaviour:
94
+ // the stamp is kept, the next save is refused rather than accepted, and the notice
95
+ // says so.
96
+ let unknownAttempt = null;
97
+
98
+ /** True while an earlier save may or may not have landed (spec §7). */
99
+ export function saveFateIsUnknown() {
100
+ return unknownAttempt !== null;
101
+ }
102
+
103
+ /**
104
+ * A fresh id for one save attempt.
105
+ *
106
+ * Uniqueness per attempt is the whole property: two tabs sharing an id would each
107
+ * read the other's receipt as proof of their own write. `getRandomValues` into a
108
+ * zeroed array is the failure that would do exactly that, silently, so a missing
109
+ * crypto implementation falls back to something random rather than to zeros.
110
+ */
111
+ function mintSaveId() {
112
+ const uuid = window.crypto?.randomUUID?.();
113
+ if (uuid) return uuid;
114
+ if (window.crypto?.getRandomValues) {
115
+ const bytes = new Uint8Array(16);
116
+ window.crypto.getRandomValues(bytes);
117
+ return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
118
+ }
119
+ // Three segments, not one: §6 asks for at least 128 bits, and one `Math.random()`
120
+ // truncated to ten base-36 characters carries about 52. A timestamp is not entropy,
121
+ // so it cannot make up the difference between two tabs minting in the same
122
+ // millisecond, which is the collision that matters.
123
+ const segment = () => Math.random().toString(36).slice(2, 12).padEnd(10, "0");
124
+ return `${Date.now().toString(36)}-${segment()}${segment()}${segment()}`;
125
+ }
126
+
127
+ function rememberSentId(saveId) {
128
+ sentIds.push(saveId);
129
+ if (sentIds.length > SENT_ID_MEMORY) sentIds.shift();
130
+ }
131
+
132
+ /**
133
+ * Did this tab send this id at all?
134
+ *
135
+ * The weaker of the two questions asked of a receipt, and it belongs only to the
136
+ * late-duplicate rule: a 412 naming ANY id this tab sent means this tab's own
137
+ * earlier save is what moved the document, which is enough to adopt that stamp and
138
+ * send again, because sending again is all that follows. It is never enough to
139
+ * conclude that a particular save landed. That needs identity with that save's own
140
+ * id, which is what recoverUnknownSave asks.
141
+ */
142
+ function sentByThisTab(saveId) {
143
+ return typeof saveId === "string" && saveId !== "" && sentIds.includes(saveId);
144
+ }
145
+
146
+ /** Test-only: forget every id and any open question. */
147
+ export function resetSaveAttempts() {
148
+ sentIds.length = 0;
149
+ unknownAttempt = null;
150
+ }
151
+
64
152
  function errorResult(err) {
65
153
  const timedOut = err.name === 'AbortError';
66
154
  // Spec §6: a 412 is a REFUSED save, not a failed one. The host wrote nothing and
@@ -68,9 +156,13 @@ function errorResult(err) {
68
156
  // are still on this page. Reporting that as 'error' would give the one outcome
69
157
  // where nothing went wrong the one severity that means something did, and would
70
158
  // put it through the same retry-and-toast path as a dead server.
71
- // The status is authoritative (§3), and `code` is honoured too because a proxy
72
- // can answer 412 on the host's behalf with no body at all.
73
- const conflicted = !timedOut && (err.status === 412 || err.code === 'conflict');
159
+ // The status is authoritative (§3), so the 412 alone decides this. Reading `code`
160
+ // as well let any other status claim a conflict, and it bought nothing: a proxy
161
+ // answering 412 with no body carries no code to read, so the fallback never
162
+ // covered the case it was written for. What it did cover was htmlclay's
163
+ // truncation refusal, a 409 that lifts itself within a second, which suspended
164
+ // autosave over a condition that had already cleared.
165
+ const conflicted = !timedOut && err.status === 412;
74
166
  return {
75
167
  ok: false,
76
168
  msg: timedOut ? 'Server not responding' : (err.message || 'Save failed'),
@@ -78,6 +170,10 @@ function errorResult(err) {
78
170
  // so this reports "we do not know" rather than asserting something false.
79
171
  msgType: timedOut ? 'unknown' : (conflicted ? 'conflict' : 'error'),
80
172
  code: timedOut ? 'timeout' : (conflicted ? 'conflict' : (err.code ?? null)),
173
+ changedBy: conflicted ? (err.changedBy ?? null) : null,
174
+ // A refusal that may be answering this tab's own timed-out write. Only the
175
+ // notice uses it, and only to word itself; nothing decides anything on it.
176
+ afterTimeout: conflicted && unknownAttempt !== null,
81
177
  etag: null
82
178
  };
83
179
  }
@@ -122,9 +218,11 @@ function skippedResult(msg) {
122
218
  * @param {string} content - HTML to save
123
219
  * @param {boolean} userDriven - Whether a human gesture is behind this save
124
220
  * @param {AbortSignal} signal
221
+ * @param {string} saveId - this attempt's §6 receipt id
222
+ * @param {?string} etag - the stamp to send as If-Match, or null
125
223
  * @returns {{url: string, options: Object}}
126
224
  */
127
- function buildSaveRequest(content, userDriven, signal) {
225
+ function buildSaveRequest(content, userDriven, signal, saveId, etag) {
128
226
  const token = saveToken();
129
227
  const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
130
228
  const options = {
@@ -138,25 +236,36 @@ function buildSaveRequest(content, userDriven, signal) {
138
236
  // Spec §9's name for the provenance bit. `X-Hyperclay-User-Driven` was the
139
237
  // pre-spec spelling; every host reads Save-Trigger first and falls back to
140
238
  // it, so stored documents running an older client keep working.
141
- 'Save-Trigger': userDriven ? 'user' : 'auto'
239
+ 'Save-Trigger': userDriven ? 'user' : 'auto',
240
+ // Spec §6 (`receipts`): which attempt this is. Sent to every host, because a
241
+ // host that does not implement receipts ignores an unknown header, and
242
+ // gating it on discovery would mean holding the save until an async lookup
243
+ // resolved. Nothing is inferred from the header being accepted: only a host
244
+ // that ANSWERS with the id has said anything.
245
+ 'Save-ID': saveId
142
246
  },
143
247
  body: content
144
248
  };
145
249
 
146
- // Spec §6: the stamp this tab last saw, so a host that advertises `conditional`
147
- // can refuse rather than overwrite a version nobody here has read.
148
- //
149
- // Two gates, and both are the point. The capability must have been announced by
150
- // name, because §5 forbids inferring one any other way, and a host that never
151
- // promised to honour If-Match may do anything at all with it. And a stamp must
152
- // actually be held: a host reads this header's PRESENCE, so an empty value is
153
- // not a softer version of the request, it is a save asking to be refused.
154
- const etag = lastSeenEtag();
155
- if (conditionalSaves() && etag) options.headers['If-Match'] = etag;
250
+ if (etag) options.headers['If-Match'] = etag;
156
251
 
157
252
  return { url: resolveSaveUrl(path), options };
158
253
  }
159
254
 
255
+ /**
256
+ * The stamp to send as If-Match, or null for a save that goes out unconditional.
257
+ *
258
+ * Two gates, and both are the point. The capability must have been announced by
259
+ * name, because §5 forbids inferring one any other way, and a host that never
260
+ * promised to honour If-Match may do anything at all with it. And a stamp must
261
+ * actually be held: a host reads the header's PRESENCE, so an empty value is not a
262
+ * softer version of the request, it is a save asking to be refused.
263
+ */
264
+ function stampToSend() {
265
+ const etag = lastSeenEtag();
266
+ return conditionalSaves() && etag ? etag : null;
267
+ }
268
+
160
269
  /**
161
270
  * The absolute URL a save goes to.
162
271
  *
@@ -197,7 +306,7 @@ function resolveSaveUrl(path) {
197
306
  * @param {string} content - HTML to save
198
307
  * @returns {Promise<Object>} The server's response body
199
308
  */
200
- function sendSave(content) {
309
+ function sendOnce(content, saveId, etag) {
201
310
  const controller = new AbortController();
202
311
  const timeoutId = setTimeout(() => controller.abort(), SAVE_TIMEOUT_MS);
203
312
 
@@ -210,7 +319,8 @@ function sendSave(content) {
210
319
  const gestureDriven = consumeUserDriven();
211
320
  const explicitlyAsked = consumeExplicitSave();
212
321
  const userDriven = gestureDriven || explicitlyAsked;
213
- const { url, options } = buildSaveRequest(content, userDriven, controller.signal);
322
+ const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag);
323
+ rememberSentId(saveId);
214
324
 
215
325
  return fetch(url, options)
216
326
  .then(res => res.text().then(text => {
@@ -226,24 +336,35 @@ function sendSave(content) {
226
336
  const error = new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
227
337
  error.code = data.code ?? null;
228
338
  error.status = res.status;
339
+ // Spec §6: a conflict may name what moved the document. Only the host can
340
+ // know, and it often cannot, so this rides through as-is and the notice
341
+ // falls back to a phrase that is true either way.
342
+ error.changedBy = data.changedBy ?? null;
343
+ // §6: the stamp of the bytes that caused the refusal, and the receipt for
344
+ // them. Both are what let a refusal be recovered from instead of merely
345
+ // reported: the stamp is what the retry has to carry, and the receipt says
346
+ // whether the document was moved by this tab's own earlier save.
347
+ error.etag = data.etag ?? null;
348
+ error.saveId = data.saveId ?? null;
229
349
  throw error;
230
350
  }
231
351
  // The one place a stamp is ever learned from a save (§6). A response with no
232
352
  // etag clears it rather than keeping the old one, because the write we just
233
353
  // made means any stamp held here describes bytes the host no longer stores.
234
354
  recordEtag(data.etag ?? null);
355
+ // A write the host answered: whatever an earlier timeout left uncertain,
356
+ // this document's version is known again.
357
+ unknownAttempt = null;
235
358
  return data;
236
359
  }))
237
360
  .catch(err => {
238
361
  console.error('Failed to save page:', err);
239
- // Spec §7: a timeout is INDETERMINATE, so this write may well have landed,
240
- // and the stamp held here may already describe bytes the host has replaced.
241
- // Reconcile against the host before anything retries, or a save that times
242
- // out and lands is followed by a 412 the person cannot explain and did not
243
- // cause. The only save that can have moved the document inside that window
244
- // is almost always this one, so taking the host's current stamp is taking
245
- // back our own.
246
- if (err.name === 'AbortError') seedEtag({ fresh: true });
362
+ // Spec §7: a timeout is INDETERMINATE. The stamp is deliberately NOT
363
+ // reconciled against the host here; what this records is the question, so
364
+ // recovery below can ask it. The old code adopted the host's current stamp
365
+ // on the guess that our own write was what moved the document, which was
366
+ // wrong exactly when somebody else wrote.
367
+ if (err.name === 'AbortError') unknownAttempt = { saveId, etag };
247
368
  // The save never landed: re-arm the user-driven bit so the next (retry)
248
369
  // save still reports the human gesture instead of reading as background.
249
370
  if (userDriven) markUserDriven();
@@ -254,6 +375,150 @@ function sendSave(content) {
254
375
  });
255
376
  }
256
377
 
378
+ /**
379
+ * Ask the host what became of the attempt whose answer never arrived (spec §7).
380
+ *
381
+ * One question, one round trip, and only two things to do with the answer:
382
+ *
383
+ * - the host's receipt is ours, so the bytes it stores are the ones this save
384
+ * sent. The save landed. Adopt the stamp for those bytes and report success:
385
+ * the person sees a save that worked, because one did.
386
+ * - anything else, including a host that keeps no receipts at all: send the save
387
+ * again, under the ORIGINAL If-Match.
388
+ *
389
+ * The second case looks like it needs to know more than it does, and it does not,
390
+ * because the stamp is what makes it safe. If our write did land, the stamp is now
391
+ * stale and the re-send is REFUSED, carrying the host's receipt for the bytes that
392
+ * refused it; that receipt is ours, and the late-duplicate rule in sendSave turns
393
+ * it back into a finished save without a word to anybody. If somebody else wrote,
394
+ * the same refusal carries their bytes and becomes an honest conflict. And if
395
+ * nothing landed, the stamp still matches and the save simply goes through.
396
+ *
397
+ * So there is no branch here that guesses. Every outcome is decided by a host
398
+ * answering a conditional request, which is the one thing in this protocol that
399
+ * cannot be wrong.
400
+ *
401
+ * A host that cannot be reached leaves the question open, which is the older
402
+ * behaviour and the honest one: the stamp is kept, the next save is refused rather
403
+ * than accepted, and the notice says the refusal may be answering this tab's own
404
+ * timed-out save.
405
+ *
406
+ * @returns {Promise<{landed: ?Object, resend: boolean}>}
407
+ */
408
+ async function recoverUnknownSave() {
409
+ const attempt = unknownAttempt;
410
+ if (!attempt) return { landed: null, resend: false };
411
+
412
+ const meta = await hostMeta({ fresh: true });
413
+ const doc = meta.document;
414
+ const stored = typeof doc?.etag === 'string' && doc.etag ? doc.etag : null;
415
+ if (!stored) return { landed: null, resend: false };
416
+
417
+ // THIS attempt's id, and no other. A receipt is proof about the save that carried
418
+ // that exact id, so an earlier save of this same tab naming itself proves only
419
+ // that the earlier save landed, which is a thing already known and says nothing
420
+ // about this one. Matching any id this tab has sent turns the most ordinary
421
+ // failure there is, a request that never left the browser, into a reported
422
+ // success: the tab shows Saved, advances its baselines, drops the close warning,
423
+ // and the bytes are on no disk anywhere. Membership is the right test one line
424
+ // down in sendSave, where a 412 only ever leads to another conditional send.
425
+ if (doc.saveId && doc.saveId === attempt.saveId) {
426
+ // Proof, not inference: the host is saying the bytes it stores are the stored
427
+ // form of the body THIS save sent. §6 lets a client adopt a stamp on exactly that.
428
+ recordEtag(stored);
429
+ unknownAttempt = null;
430
+ return { landed: { msg: 'Saved', msgType: 'success', etag: stored }, resend: false };
431
+ }
432
+
433
+ // Positive proof, and nothing weaker, closes the question. A non-empty id that is
434
+ // not ours names another save as the author of these bytes. An ABSENT id proves
435
+ // nothing at all: §6 lets a host lose its pair to a restart or an eviction and stay
436
+ // conforming, so a host that keeps receipts can report none for bytes this tab
437
+ // really did write. Reading absence as "somebody else wrote" makes the notice tell
438
+ // a person their own save was somebody else's change.
439
+ if (doc.saveId) unknownAttempt = null;
440
+
441
+ // The re-send carries the stamp the ORIGINAL save carried, which is the normative
442
+ // rule and not a detail: the stamp this tab holds is mutable and moves for reasons
443
+ // that have nothing to do with this save, because a disk-sourced live-sync frame
444
+ // records the stamp of the bytes it applied. Re-sending under that one is a write
445
+ // conditional on a version this save was never judged against, and the host accepts
446
+ // it, replacing bytes the person has just been shown with bytes captured before
447
+ // they arrived.
448
+ //
449
+ // No stamp at all means there is nothing to re-send under, so nothing is re-sent.
450
+ // An unconditional recovery write has no comparison to refuse it and would replace
451
+ // whatever landed while this tab was waiting, which is the loss this whole
452
+ // capability exists to prevent.
453
+ return { landed: null, resend: !!attempt.etag, etag: attempt.etag };
454
+ }
455
+
456
+ /**
457
+ * Send one save, and see it through.
458
+ *
459
+ * Two things can happen that are not the save's own outcome, and both are handled
460
+ * here rather than reported:
461
+ *
462
+ * - the request timed out, so the host is asked what became of it,
463
+ * - the host refused with a receipt this tab recognises, which means its own
464
+ * earlier save is what moved the document. That is a late duplicate, not a
465
+ * conflict with anybody: take the stamp the refusal handed back and finish
466
+ * the save that was refused.
467
+ *
468
+ * Each recovery sends at most one further request, so a save can never loop.
469
+ *
470
+ * @param {string} content - HTML to save
471
+ * @returns {Promise<Object>} The server's response body
472
+ */
473
+ async function sendSave(content) {
474
+ // The re-send below is a RETRY of this attempt, so it carries this same id:
475
+ // whichever of the two requests the host ends up storing, its receipt then
476
+ // answers for this save. A fresh id on the re-send would leave the first
477
+ // request's landing unprovable.
478
+ let saveId = mintSaveId();
479
+ let etag = stampToSend();
480
+ // Each recovery runs at most once, and they are counted apart because they are
481
+ // different things. Re-sending after a timeout is worth doing once: a second
482
+ // timeout says the host is not answering this document in time, and sending a
483
+ // whole document a third time will not change that. Resolving a late duplicate
484
+ // is fast and conclusive, so it gets its own turn regardless.
485
+ let reSent = false;
486
+ let duplicateResolved = false;
487
+ for (;;) {
488
+ try {
489
+ return await sendOnce(content, saveId, etag);
490
+ } catch (err) {
491
+ if (err.name === 'AbortError') {
492
+ if (reSent) throw err;
493
+ const { landed, resend, etag: original } = await recoverUnknownSave();
494
+ if (landed) return landed;
495
+ if (!resend) throw err;
496
+ // Explicitly the original, never `stampToSend()` again.
497
+ etag = original;
498
+ reSent = true;
499
+ continue;
500
+ }
501
+
502
+ if (err.status === 412 && !duplicateResolved && sentByThisTab(err.saveId)) {
503
+ // Our own earlier save is what the host is refusing us against: a late
504
+ // duplicate, not a conflict with anybody. Adopt the stamp for those bytes
505
+ // and send the current ones on top of it. The retry is still conditional,
506
+ // so anything that arrives in between still refuses it.
507
+ recordEtag(err.etag ?? null);
508
+ unknownAttempt = null;
509
+ saveId = mintSaveId();
510
+ // A new attempt, deliberately on the base the host just proved is this tab's
511
+ // own work, rather than on the stamp the refused attempt carried.
512
+ etag = stampToSend();
513
+ duplicateResolved = true;
514
+ continue;
515
+ }
516
+
517
+ throw err;
518
+ }
519
+ }
520
+ }
521
+
257
522
  // =============================================================================
258
523
  // SAVE FUNCTIONS
259
524
  // =============================================================================