@panphora/clayjs 1.13.0 → 1.14.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/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69739 -41784
- package/dist/server/checkpoint.cjs +70690 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/rooms.cjs +72039 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +95 -94
- package/entries/clay.js +22 -0
- package/package.json +16 -2
- package/packed-contract.json +17 -3
- package/src/core/conflict-download.js +11 -2
- package/src/core/host-meta.js +75 -0
- package/src/core/persist.js +11 -0
- package/src/core/save-core.js +266 -31
- package/src/core/save.js +62 -32
- package/src/core/snapshot.js +39 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/effect.js +130 -0
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +44 -0
- package/src/live/fast-lane.js +347 -60
- package/src/live/local-lane.js +32 -6
- package/src/live/ops.js +77 -0
- package/src/live/provenance.js +129 -0
- package/src/live/save-history.js +188 -0
- package/src/live/server-lane.js +9 -2
- package/src/live/store.js +194 -16
- package/src/loader-logic.js +7 -3
- package/src/loader.js +6 -0
- package/src/page/address.js +20 -0
- package/src/page/adopt-boot.js +189 -0
- package/src/page/adopt.js +240 -0
- package/src/page/ai-tool.js +188 -0
- package/src/page/announce.js +21 -0
- package/src/page/blocks.js +1121 -0
- package/src/page/blocksel.js +269 -0
- package/src/page/board-drag.js +226 -0
- package/src/page/board-panel.js +353 -0
- package/src/page/board-style.js +146 -0
- package/src/page/board-view.js +447 -0
- package/src/page/board.js +41 -0
- package/src/page/chart.js +220 -0
- package/src/page/checklist.js +143 -0
- package/src/page/chrome.js +587 -0
- package/src/page/csv-import.js +169 -0
- package/src/page/drag.js +202 -0
- package/src/page/drop.js +112 -0
- package/src/page/editor.js +384 -0
- package/src/page/highlight.js +268 -0
- package/src/page/inline.js +60 -1
- package/src/page/items.js +159 -0
- package/src/page/link.js +247 -0
- package/src/page/markdown.js +71 -0
- package/src/page/media.js +570 -0
- package/src/page/merge.js +130 -0
- package/src/page/model.js +71 -9
- package/src/page/placeholder.js +23 -0
- package/src/page/projection.js +647 -0
- package/src/page/recover.js +193 -0
- package/src/page/rich/cell.js +16 -0
- package/src/page/rich/chart.js +104 -0
- package/src/page/rich/csv.js +118 -0
- package/src/page/rich/page-tool.js +86 -0
- package/src/page/rich/tool-runtime.js +195 -0
- package/src/page/save-projection.js +53 -0
- package/src/page/save-states.js +85 -0
- package/src/page/schema.js +12 -5
- package/src/page/table-kinds.js +20 -0
- package/src/page/table-paste.js +57 -0
- package/src/page/table-select.js +244 -0
- package/src/page/table-style.js +25 -0
- package/src/page/table.js +650 -0
- package/src/page/themes/content.css +46 -0
- package/src/page/themes/light-1.css +269 -0
- package/src/page/themes/light-2.css +211 -0
- package/src/page/themes/light-3.css +130 -0
- package/src/page/themes/light-4.css +299 -0
- package/src/page/tool-data.js +25 -0
- package/src/page/tool-view.js +68 -0
- package/src/page/tools.js +133 -0
- package/src/page/view.js +136 -0
- package/src/page/ybind/author.js +25 -0
- package/src/page/ybind/binding.js +92 -0
- package/src/page/ybind/board-commands.js +170 -0
- package/src/page/ybind/board.js +89 -0
- package/src/page/ybind/bridge.js +382 -0
- package/src/page/ybind/conversion.js +26 -0
- package/src/page/ybind/core.js +7 -0
- package/src/page/ybind/decode.js +208 -0
- package/src/page/ybind/doc.js +58 -0
- package/src/page/ybind/external.js +190 -0
- package/src/page/ybind/group.js +124 -0
- package/src/page/ybind/index.js +12 -0
- package/src/page/ybind/layout.js +27 -0
- package/src/page/ybind/marks.js +18 -0
- package/src/page/ybind/migrate.js +152 -0
- package/src/page/ybind/moves.js +288 -0
- package/src/page/ybind/origins.js +257 -0
- package/src/page/ybind/parts.js +44 -0
- package/src/page/ybind/projection-map.js +92 -0
- package/src/page/ybind/provider.js +285 -0
- package/src/page/ybind/recovery.js +411 -0
- package/src/page/ybind/seed.js +27 -0
- package/src/page/ybind/table.js +166 -0
- package/src/page/ybind/tokens.js +93 -0
- package/src/page/ybind/walk.js +333 -0
- package/src/page/ybind/writer.js +307 -0
- package/src/plugins/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +60 -3
- package/src/plugins/visitor-drafts/prompt.js +4 -6
- package/src/server/checkpoint/index.js +218 -0
- package/src/server/rooms/index.js +59 -0
- package/src/server/rooms/live-data-ops.js +369 -0
- package/src/server/rooms/live-data-rooms.js +965 -0
- package/src/server/rooms/live-data-tags.js +49 -0
- package/src/server/rooms/live-page-history.js +41 -0
- package/src/server/rooms/live-provenance.js +142 -0
- package/src/server/rooms/page-admission.js +79 -0
- package/src/server/rooms/page-conversion.js +62 -0
- package/src/server/rooms/page-rebase.js +108 -0
- package/src/server/rooms/page-state.js +91 -0
- package/src/server/rooms/page-write-admission.js +63 -0
- package/src/server/rooms/page-write-guard.js +23 -0
- package/src/server/rooms/recent.js +18 -0
- package/src/server/rooms/spec-wire.js +17 -0
- package/src/standalone.js +22 -0
- package/src/sync/live-sync.js +13 -2
- package/src/sync/stream.js +62 -16
- package/src/ui/index.js +4 -0
- package/src/ui/toast.js +21 -5
- package/src/vendor/pierre/pierre.vendor.js +3 -0
- package/src/vendor/yjs.vendor.js +7 -0
- package/src/page/binding.js +0 -647
- package/src/page/replay.js +0 -578
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panphora/clayjs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "clayjs: malleable documents. Save lifecycle for self-saving HTML.",
|
|
6
6
|
"license": "MIT-0",
|
|
@@ -34,7 +34,16 @@
|
|
|
34
34
|
"./entries/*": "./entries/*",
|
|
35
35
|
"./clay.standalone.js": "./dist/clay.standalone.js",
|
|
36
36
|
"./tailwind": "./src/tailwind.js",
|
|
37
|
+
"./page/yjs": "./src/page/ybind/external.js",
|
|
37
38
|
"./dist/*": "./dist/*",
|
|
39
|
+
"./server/rooms": {
|
|
40
|
+
"import": "./dist/server/rooms.mjs",
|
|
41
|
+
"require": "./dist/server/rooms.cjs"
|
|
42
|
+
},
|
|
43
|
+
"./server/checkpoint": {
|
|
44
|
+
"import": "./dist/server/checkpoint.mjs",
|
|
45
|
+
"require": "./dist/server/checkpoint.cjs"
|
|
46
|
+
},
|
|
38
47
|
"./*": "./entries/*"
|
|
39
48
|
},
|
|
40
49
|
"files": [
|
|
@@ -63,6 +72,7 @@
|
|
|
63
72
|
"deploy": "node build.js && wrangler deploy && git rev-parse HEAD > .deploy",
|
|
64
73
|
"build:parse5": "node scripts/build-parse5-vendor.mjs",
|
|
65
74
|
"build:prosemirror": "node scripts/build-prosemirror-vendor.mjs",
|
|
75
|
+
"build:yjs": "node scripts/build-yjs-vendor.mjs",
|
|
66
76
|
"build:bevel": "node scripts/build-bevel-subset.mjs",
|
|
67
77
|
"check:bevel": "node scripts/build-bevel-subset.mjs --check",
|
|
68
78
|
"build:skins": "node scripts/build-vendor-skins.mjs",
|
|
@@ -74,10 +84,13 @@
|
|
|
74
84
|
"@web/test-runner": "^0.20.2",
|
|
75
85
|
"@web/test-runner-commands": "^0.9.0",
|
|
76
86
|
"@web/test-runner-playwright": "^0.11.1",
|
|
87
|
+
"cheerio": "1.2.0",
|
|
77
88
|
"esbuild": "^0.28.2",
|
|
89
|
+
"hyper-morph": "1.6.3",
|
|
78
90
|
"jest": "^29.7.0",
|
|
79
91
|
"jest-environment-jsdom": "^29.7.0",
|
|
80
92
|
"jsdom": "^20.0.3",
|
|
93
|
+
"lib0": "0.2.119",
|
|
81
94
|
"marked": "^18.0.14",
|
|
82
95
|
"parse5": "^7.3.0",
|
|
83
96
|
"playwright": "1.62.1",
|
|
@@ -93,7 +106,8 @@
|
|
|
93
106
|
"prosemirror-transform": "1.12.2",
|
|
94
107
|
"prosemirror-view": "1.42.6",
|
|
95
108
|
"turndown": "^7.2.4",
|
|
96
|
-
"turndown-plugin-gfm": "^1.0.2"
|
|
109
|
+
"turndown-plugin-gfm": "^1.0.2",
|
|
110
|
+
"yjs": "13.6.27"
|
|
97
111
|
},
|
|
98
112
|
"hyper": {
|
|
99
113
|
"kind": "lib",
|
package/packed-contract.json
CHANGED
|
@@ -11,8 +11,22 @@
|
|
|
11
11
|
"@panphora/clayjs/entries/clay-ui.js",
|
|
12
12
|
"@panphora/clayjs/entries/clay-utils.js",
|
|
13
13
|
"@panphora/clayjs/entries/sap.js",
|
|
14
|
-
"@panphora/clayjs/clay.standalone.js"
|
|
14
|
+
"@panphora/clayjs/clay.standalone.js",
|
|
15
|
+
"@panphora/clayjs/server/rooms",
|
|
16
|
+
"@panphora/clayjs/server/checkpoint",
|
|
17
|
+
"@panphora/clayjs/page/yjs"
|
|
15
18
|
],
|
|
16
|
-
"members": [
|
|
17
|
-
|
|
19
|
+
"members": [
|
|
20
|
+
"THIRD-PARTY-NOTICES.md",
|
|
21
|
+
"dist/server/rooms.cjs",
|
|
22
|
+
"dist/server/rooms.mjs",
|
|
23
|
+
"dist/server/checkpoint.cjs",
|
|
24
|
+
"dist/server/checkpoint.mjs",
|
|
25
|
+
"docs/guide.md"
|
|
26
|
+
],
|
|
27
|
+
"families": [
|
|
28
|
+
"./entries/*",
|
|
29
|
+
"./dist/*",
|
|
30
|
+
"./src/*"
|
|
31
|
+
]
|
|
18
32
|
}
|
|
@@ -67,10 +67,9 @@ export function buildRecovery(records, { refused = false } = {}) {
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export function
|
|
70
|
+
export function downloadHtml({ html, name }) {
|
|
71
71
|
let url = null;
|
|
72
72
|
try {
|
|
73
|
-
const { html, name } = buildRecovery(records, { refused });
|
|
74
73
|
url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
|
|
75
74
|
const a = document.createElement("a");
|
|
76
75
|
a.setAttribute("clay", "no-save no-watch no-snapshot");
|
|
@@ -89,3 +88,13 @@ export function downloadRecovery(records, { refused = false } = {}) {
|
|
|
89
88
|
return false;
|
|
90
89
|
}
|
|
91
90
|
}
|
|
91
|
+
|
|
92
|
+
export function downloadRecovery(records, { refused = false } = {}) {
|
|
93
|
+
try {
|
|
94
|
+
const { html, name } = buildRecovery(records, { refused });
|
|
95
|
+
return downloadHtml({ html, name });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error("clayjs: could not build the download", err);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/core/host-meta.js
CHANGED
|
@@ -107,6 +107,81 @@ export function resetHostMeta() {
|
|
|
107
107
|
refreshing = null;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Ask the host what became of one identified save.
|
|
112
|
+
*
|
|
113
|
+
* The same discovery document answers it, but this is a separate request with its
|
|
114
|
+
* own abort timer: it must not share the memoized discovery promise, must not
|
|
115
|
+
* update the cached document, and must not record any ETag. Two concurrent
|
|
116
|
+
* questions get two independent answers.
|
|
117
|
+
*
|
|
118
|
+
* The host must have advertised `save-history`, the response must be a spec
|
|
119
|
+
* document that still advertises it, and the echoed id and hash must match this
|
|
120
|
+
* question exactly. Anything else, including a network failure or a timeout at
|
|
121
|
+
* six seconds, is null; a wrong or missing answer is never read as "not applied".
|
|
122
|
+
*
|
|
123
|
+
* @param {Object} query
|
|
124
|
+
* @param {string} query.saveId
|
|
125
|
+
* @param {string} query.requestHash
|
|
126
|
+
* @param {AbortSignal} [query.signal]
|
|
127
|
+
* @returns {Promise<?{saveId: string, requestHash: string, status: "applied"|"unknown"}>}
|
|
128
|
+
*/
|
|
129
|
+
export async function querySaveOutcome({ saveId, requestHash, signal } = {}) {
|
|
130
|
+
if (isDraftMode) return null;
|
|
131
|
+
if (typeof saveId !== 'string' || !saveId) return null;
|
|
132
|
+
if (typeof requestHash !== 'string' || !requestHash) return null;
|
|
133
|
+
if (!/^https?:$/.test(window.location.protocol)) return null;
|
|
134
|
+
if (signal?.aborted) return null;
|
|
135
|
+
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
const abort = () => controller.abort();
|
|
138
|
+
const timeoutId = setTimeout(abort, META_TIMEOUT_MS);
|
|
139
|
+
signal?.addEventListener('abort', abort);
|
|
140
|
+
try {
|
|
141
|
+
const meta = await hostMeta();
|
|
142
|
+
if (hostMetaOutcome(meta) !== 'ok' || !meta.extensions.includes('save-history')) return null;
|
|
143
|
+
if (controller.signal.aborted) return null;
|
|
144
|
+
|
|
145
|
+
const token = saveToken();
|
|
146
|
+
const path = token ? `${META_PATH}/${token}` : META_PATH;
|
|
147
|
+
const res = await fetch(new URL(path, window.location.origin).href, {
|
|
148
|
+
method: 'GET',
|
|
149
|
+
credentials: token ? 'omit' : 'same-origin',
|
|
150
|
+
headers: {
|
|
151
|
+
'Document-URL': window.location.href,
|
|
152
|
+
'Save-ID': saveId,
|
|
153
|
+
'Save-Request-Hash': requestHash
|
|
154
|
+
},
|
|
155
|
+
cache: 'no-store',
|
|
156
|
+
redirect: 'manual',
|
|
157
|
+
signal: controller.signal
|
|
158
|
+
});
|
|
159
|
+
if (!res.ok) return null;
|
|
160
|
+
const text = await res.text();
|
|
161
|
+
if (!text) return null;
|
|
162
|
+
|
|
163
|
+
let body;
|
|
164
|
+
try {
|
|
165
|
+
body = JSON.parse(text);
|
|
166
|
+
} catch (_) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
if (!body || typeof body !== 'object' || typeof body.spec !== 'number') return null;
|
|
170
|
+
if (!Array.isArray(body.extensions) || !body.extensions.includes('save-history')) return null;
|
|
171
|
+
const outcome = body.document && typeof body.document === 'object' ? body.document.saveOutcome : null;
|
|
172
|
+
if (!outcome || typeof outcome !== 'object') return null;
|
|
173
|
+
if (outcome.saveId !== saveId || outcome.requestHash !== requestHash) return null;
|
|
174
|
+
if (outcome.status !== 'applied' && outcome.status !== 'unknown') return null;
|
|
175
|
+
|
|
176
|
+
return { saveId, requestHash, status: outcome.status };
|
|
177
|
+
} catch (_) {
|
|
178
|
+
return null;
|
|
179
|
+
} finally {
|
|
180
|
+
clearTimeout(timeoutId);
|
|
181
|
+
signal?.removeEventListener('abort', abort);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
110
185
|
async function fetchMeta() {
|
|
111
186
|
// A file opened from disk has no host to ask, and fetch would only throw.
|
|
112
187
|
if (!/^https?:$/.test(window.location.protocol)) return settle("none");
|
package/src/core/persist.js
CHANGED
|
@@ -76,6 +76,17 @@ export default function enablePersistentFormInputValues(filterBySelector = PERSI
|
|
|
76
76
|
if ((el.matches(inputSelector) && (el.type === 'checkbox' || el.type === 'radio')) ||
|
|
77
77
|
el.matches(selectSelector)) {
|
|
78
78
|
serializeControlToAttributes(el);
|
|
79
|
+
// Checking a radio unchecks its group without a change event on the others,
|
|
80
|
+
// so their checked attributes are cleared here; otherwise two radios in one
|
|
81
|
+
// group carry checked and an undo or reload picks the wrong one.
|
|
82
|
+
if (el.type === 'radio' && el.name) {
|
|
83
|
+
const group = el.form ? [...el.form.elements] : [...document.getElementsByName(el.name)];
|
|
84
|
+
for (const other of group) {
|
|
85
|
+
if (other !== el && other.type === 'radio' && other.name === el.name && other.form === el.form && other.matches(inputSelector)) {
|
|
86
|
+
serializeControlToAttributes(other);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
79
90
|
}
|
|
80
91
|
}, true);
|
|
81
92
|
|
package/src/core/save-core.js
CHANGED
|
@@ -16,7 +16,8 @@ import { hostMeta } from "./host-meta.js";
|
|
|
16
16
|
import {
|
|
17
17
|
getPageContents,
|
|
18
18
|
onSnapshot,
|
|
19
|
-
addDocumentTransform
|
|
19
|
+
addDocumentTransform,
|
|
20
|
+
currentCaptureId
|
|
20
21
|
} from "./snapshot.js";
|
|
21
22
|
|
|
22
23
|
// =============================================================================
|
|
@@ -99,16 +100,74 @@ export function onSaveAccepted(callback) {
|
|
|
99
100
|
saveAcceptedHooks.push(callback);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
function notifySaveAccepted(html) {
|
|
103
|
+
function notifySaveAccepted(html, captureId) {
|
|
103
104
|
for (const hook of saveAcceptedHooks) {
|
|
104
105
|
try {
|
|
105
|
-
hook(html);
|
|
106
|
+
hook(html, { captureId: captureId ?? null });
|
|
106
107
|
} catch (err) {
|
|
107
108
|
console.error('clayjs: onSaveAccepted hook failed:', err);
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
// Listeners for "this exact body is about to leave". One call per real fetch, with
|
|
114
|
+
// the capture identity the bytes came from, so a recovery can bind its evidence to
|
|
115
|
+
// the request rather than guess from timing or from the newest global capture.
|
|
116
|
+
const saveAttemptHooks = [];
|
|
117
|
+
// Listeners for "this logical save has settled", once each. The point is the pair
|
|
118
|
+
// (which capture, which actual sends), which is what an uncertain attempt later
|
|
119
|
+
// has to prove.
|
|
120
|
+
const saveOutcomeHooks = [];
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Observe each real send of a save, synchronously before the fetch.
|
|
124
|
+
*
|
|
125
|
+
* @param {Function} listener - Receives {captureId, saveId, html, etag}
|
|
126
|
+
* @returns {Function} unsubscribe
|
|
127
|
+
*/
|
|
128
|
+
export function onSaveAttempt(listener) {
|
|
129
|
+
saveAttemptHooks.push(listener);
|
|
130
|
+
return () => {
|
|
131
|
+
const index = saveAttemptHooks.indexOf(listener);
|
|
132
|
+
if (index >= 0) saveAttemptHooks.splice(index, 1);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Observe the settlement of one logical save, after any accepted hook and before
|
|
138
|
+
* the caller's callback.
|
|
139
|
+
*
|
|
140
|
+
* @param {Function} listener - Receives {captureId, result, attempts, sent}
|
|
141
|
+
* @returns {Function} unsubscribe
|
|
142
|
+
*/
|
|
143
|
+
export function onSaveOutcome(listener) {
|
|
144
|
+
saveOutcomeHooks.push(listener);
|
|
145
|
+
return () => {
|
|
146
|
+
const index = saveOutcomeHooks.indexOf(listener);
|
|
147
|
+
if (index >= 0) saveOutcomeHooks.splice(index, 1);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function notifySaveAttempt(attempt) {
|
|
152
|
+
for (const hook of saveAttemptHooks) {
|
|
153
|
+
try {
|
|
154
|
+
hook(attempt);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error('clayjs: onSaveAttempt hook failed:', err);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function notifySaveOutcome(outcome) {
|
|
162
|
+
for (const hook of saveOutcomeHooks) {
|
|
163
|
+
try {
|
|
164
|
+
hook(outcome);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.error('clayjs: onSaveOutcome hook failed:', err);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
112
171
|
// =============================================================================
|
|
113
172
|
// RE-EXPORTS FROM SNAPSHOT (for backwards compat)
|
|
114
173
|
// =============================================================================
|
|
@@ -238,12 +297,17 @@ function errorResult(err) {
|
|
|
238
297
|
// truncation refusal, a 409 that lifts itself within a second, which suspended
|
|
239
298
|
// autosave over a condition that had already cleared.
|
|
240
299
|
const conflicted = !timedOut && err.status === 412;
|
|
300
|
+
// A save the host has already applied is proof a write happened, but not proof
|
|
301
|
+
// of what the document holds now: the client's edits are neither confirmed on
|
|
302
|
+
// disk nor known to be absent. It is an uncertain outcome, like a timeout, and
|
|
303
|
+
// until recovery lands it is carried as one rather than reported as a failure.
|
|
304
|
+
const alreadyApplied = !timedOut && !conflicted && err.code === 'save-already-applied';
|
|
241
305
|
return {
|
|
242
306
|
ok: false,
|
|
243
307
|
msg: timedOut ? 'Server not responding' : (err.message || 'Save failed'),
|
|
244
308
|
// A timeout is not evidence the write failed. The request may well have landed,
|
|
245
309
|
// so this reports "we do not know" rather than asserting something false.
|
|
246
|
-
msgType: timedOut ? 'unknown' : (conflicted ? 'conflict' : 'error'),
|
|
310
|
+
msgType: timedOut || alreadyApplied ? 'unknown' : (conflicted ? 'conflict' : 'error'),
|
|
247
311
|
code: timedOut ? 'timeout' : (conflicted ? 'conflict' : (err.code ?? null)),
|
|
248
312
|
changedBy: conflicted ? (err.changedBy ?? null) : null,
|
|
249
313
|
// A refusal that may be answering this tab's own timed-out write. Only the
|
|
@@ -258,6 +322,15 @@ function errorResult(err) {
|
|
|
258
322
|
// An attempt of this save timed out, so it may have landed whatever the
|
|
259
323
|
// final answer says.
|
|
260
324
|
hadTimeout: timedOut || err.hadTimeout === true,
|
|
325
|
+
// The typed refusal behind `code`, kept for the caller: `page-capacity` carries
|
|
326
|
+
// which limit was hit, the conversion codes carry which condition failed.
|
|
327
|
+
reason: err.reason ?? null,
|
|
328
|
+
// Only meaningful while a save can still retry; a caller sees how long the
|
|
329
|
+
// host asked it to wait. Null when the host named no delay.
|
|
330
|
+
retryAfter: err.retryAfter ?? null,
|
|
331
|
+
// The host's answer to a save that already happened. Only `save-already-applied`
|
|
332
|
+
// produces one, and recovery is what will act on it.
|
|
333
|
+
saveOutcome: err.saveOutcome ?? null,
|
|
261
334
|
etag: null
|
|
262
335
|
};
|
|
263
336
|
}
|
|
@@ -266,6 +339,62 @@ function skippedResult(msg) {
|
|
|
266
339
|
return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
|
|
267
340
|
}
|
|
268
341
|
|
|
342
|
+
// The metadata a caller attaches to a capture was refused here, before anything
|
|
343
|
+
// went out. The host's own grammar is the only definition of valid, so an
|
|
344
|
+
// `invalid-header` here is the same answer a host would have given, without the
|
|
345
|
+
// request. Nothing about an ordinary save changes.
|
|
346
|
+
function invalidHeaderResult() {
|
|
347
|
+
return { ok: false, msg: 'Invalid page conversion metadata', msgType: 'error', code: 'invalid-header', etag: null };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// How many extra sends one logical save may make while the host is still
|
|
351
|
+
// converting the page. `page-converting` is a pre-commit refusal, so the same
|
|
352
|
+
// body is worth offering again; anything else is surfaced on the first answer.
|
|
353
|
+
const PAGE_CONVERTING_MAX_RETRIES = 3;
|
|
354
|
+
const HTTP_DATE = /^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: \d|\d{2}) \d{2}:\d{2}:\d{2} \d{4})$/;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Decode a `Retry-After` header into whole seconds.
|
|
358
|
+
*
|
|
359
|
+
* The header is either an integer number of seconds or an HTTP date, per RFC 7231.
|
|
360
|
+
* A date is measured against now, because that is the only clock both sides could
|
|
361
|
+
* mean. Clamped to 0..3600 so a bogus or hostile value cannot park a save for a
|
|
362
|
+
* day; anything that is neither shape, or absent, is null.
|
|
363
|
+
*/
|
|
364
|
+
function parseRetryAfter(value) {
|
|
365
|
+
if (value == null) return null;
|
|
366
|
+
const raw = String(value).trim();
|
|
367
|
+
if (!raw) return null;
|
|
368
|
+
if (/^-?\d+$/.test(raw)) return Math.max(0, Math.min(3600, Number(raw)));
|
|
369
|
+
if (!HTTP_DATE.test(raw)) return null;
|
|
370
|
+
const when = Date.parse(raw);
|
|
371
|
+
if (Number.isNaN(when)) return null;
|
|
372
|
+
return Math.max(0, Math.min(3600, Math.round((when - Date.now()) / 1000)));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// The host grammar for `Page-Conversion`, from `server-lib/page-conversion.js`:
|
|
376
|
+
// `file` takes a 64-lowercase-hex baseline and no epoch; `room` takes the same
|
|
377
|
+
// baseline plus an epoch of [A-Za-z0-9-]{1,64}. Validating here means a caller
|
|
378
|
+
// with bad metadata gets a typed answer instead of a wasted round trip.
|
|
379
|
+
const CONVERSION_BASE = /^[0-9a-f]{64}$/;
|
|
380
|
+
const CONVERSION_EPOCH = /^[A-Za-z0-9-]{1,64}$/;
|
|
381
|
+
|
|
382
|
+
function formatConversionHeader(conversion) {
|
|
383
|
+
if (conversion == null) return { header: null };
|
|
384
|
+
if (typeof conversion !== 'object') return { invalid: true };
|
|
385
|
+
const { source, base, epoch } = conversion;
|
|
386
|
+
if (typeof base !== 'string' || !CONVERSION_BASE.test(base)) return { invalid: true };
|
|
387
|
+
if (source === 'file') {
|
|
388
|
+
if (epoch != null) return { invalid: true };
|
|
389
|
+
return { header: `v1; source=file; base=${base}` };
|
|
390
|
+
}
|
|
391
|
+
if (source === 'room') {
|
|
392
|
+
if (typeof epoch !== 'string' || !CONVERSION_EPOCH.test(epoch)) return { invalid: true };
|
|
393
|
+
return { header: `v1; source=room; base=${base}; epoch=${epoch}` };
|
|
394
|
+
}
|
|
395
|
+
return { invalid: true };
|
|
396
|
+
}
|
|
397
|
+
|
|
269
398
|
// =============================================================================
|
|
270
399
|
// THE WIRE
|
|
271
400
|
// =============================================================================
|
|
@@ -305,9 +434,10 @@ function skippedResult(msg) {
|
|
|
305
434
|
* @param {string} saveId - this attempt's §6 receipt id
|
|
306
435
|
* @param {?string} etag - the stamp to send as If-Match, or null
|
|
307
436
|
* @param {boolean} keepalive - whether to ask the transport to see the request through
|
|
437
|
+
* @param {?string} conversionHeader - the `Page-Conversion` value for this logical save, or null
|
|
308
438
|
* @returns {{url: string, options: Object}}
|
|
309
439
|
*/
|
|
310
|
-
function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive = false) {
|
|
440
|
+
function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive = false, conversionHeader = null) {
|
|
311
441
|
const token = saveToken();
|
|
312
442
|
const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
|
|
313
443
|
const options = {
|
|
@@ -334,6 +464,11 @@ function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive =
|
|
|
334
464
|
|
|
335
465
|
for (const [name, value] of extraSaveHeaders) options.headers[name] = value;
|
|
336
466
|
|
|
467
|
+
// The conversion request belongs to ONE logical save, and goes on every attempt
|
|
468
|
+
// of it. Set after the process-global headers so the capture-bound value is the
|
|
469
|
+
// one that describes these bytes.
|
|
470
|
+
if (conversionHeader) options.headers['Page-Conversion'] = conversionHeader;
|
|
471
|
+
|
|
337
472
|
if (etag) options.headers['If-Match'] = etag;
|
|
338
473
|
|
|
339
474
|
if (keepalive && new Blob([content]).size < KEEPALIVE_MAX_BODY) options.keepalive = true;
|
|
@@ -411,9 +546,15 @@ function sendToDraftStore(content) {
|
|
|
411
546
|
* instead of the status.
|
|
412
547
|
*
|
|
413
548
|
* @param {string} content - HTML to save
|
|
549
|
+
* @param {string} saveId - this attempt's receipt id
|
|
550
|
+
* @param {?string} etag - the stamp to send as If-Match, or null
|
|
551
|
+
* @param {Object} [context]
|
|
552
|
+
* @param {?number} [context.captureId] - the capture these bytes came from
|
|
553
|
+
* @param {?string} [context.conversionHeader] - per-logical-save conversion header
|
|
554
|
+
* @param {Array} [context.attempts] - collects each unique actual send
|
|
414
555
|
* @returns {Promise<Object>} The server's response body
|
|
415
556
|
*/
|
|
416
|
-
function sendOnce(content, saveId, etag) {
|
|
557
|
+
function sendOnce(content, saveId, etag, { captureId = null, conversionHeader = null, attempts = [] } = {}) {
|
|
417
558
|
const controller = new AbortController();
|
|
418
559
|
const timeoutId = setTimeout(() => controller.abort(), SAVE_TIMEOUT_MS);
|
|
419
560
|
|
|
@@ -430,9 +571,18 @@ function sendOnce(content, saveId, etag) {
|
|
|
430
571
|
// returns early never reaches this point, so the hint cannot leak onto a later one.
|
|
431
572
|
const keepalive = nextSaveKeepalive;
|
|
432
573
|
nextSaveKeepalive = false;
|
|
433
|
-
const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag, keepalive);
|
|
574
|
+
const { url, options } = buildSaveRequest(content, userDriven, controller.signal, saveId, etag, keepalive, conversionHeader);
|
|
434
575
|
rememberSentId(saveId);
|
|
435
576
|
|
|
577
|
+
// A draft save is not a request to any host and has no receipt to recover, so it
|
|
578
|
+
// is not an attempt as far as this feature is concerned.
|
|
579
|
+
if (!isDraftMode) {
|
|
580
|
+
if (!attempts.some(attempt => attempt.saveId === saveId && attempt.etag === (etag ?? null))) {
|
|
581
|
+
attempts.push({ saveId, etag: etag ?? null });
|
|
582
|
+
}
|
|
583
|
+
notifySaveAttempt({ captureId, saveId, html: content, etag: etag ?? null });
|
|
584
|
+
}
|
|
585
|
+
|
|
436
586
|
return (isDraftMode ? sendToDraftStore(content) : fetch(url, options))
|
|
437
587
|
.then(res => res.text().then(text => {
|
|
438
588
|
let data = {};
|
|
@@ -457,6 +607,14 @@ function sendOnce(content, saveId, etag) {
|
|
|
457
607
|
// whether the document was moved by this tab's own earlier save.
|
|
458
608
|
error.etag = data.etag ?? null;
|
|
459
609
|
error.saveId = data.saveId ?? null;
|
|
610
|
+
// The refusal's own vocabulary. `reason` is the typed cause behind the
|
|
611
|
+
// stable `code`, and both ride out unchanged so a caller can act on the
|
|
612
|
+
// specific one. `saveOutcome` is the host's receipt answer to a duplicate
|
|
613
|
+
// that already landed, and it is the whole reply to a client whose own
|
|
614
|
+
// response was lost.
|
|
615
|
+
error.reason = typeof data.reason === 'string' ? data.reason : null;
|
|
616
|
+
error.saveOutcome = data.saveOutcome ?? null;
|
|
617
|
+
error.retryAfter = parseRetryAfter(res.headers?.get?.('Retry-After'));
|
|
460
618
|
throw error;
|
|
461
619
|
}
|
|
462
620
|
// The one place a stamp is ever learned from a save (§6). A response with no
|
|
@@ -579,9 +737,13 @@ async function recoverUnknownSave() {
|
|
|
579
737
|
* Each recovery sends at most one further request, so a save can never loop.
|
|
580
738
|
*
|
|
581
739
|
* @param {string} content - HTML to save
|
|
740
|
+
* @param {Object} [context]
|
|
741
|
+
* @param {?number} [context.captureId] - the capture these bytes came from
|
|
742
|
+
* @param {?string} [context.conversionHeader] - per-logical-save conversion header
|
|
743
|
+
* @param {Array} [context.attempts] - collects each unique actual send
|
|
582
744
|
* @returns {Promise<Object>} The server's response body
|
|
583
745
|
*/
|
|
584
|
-
async function sendSave(content) {
|
|
746
|
+
async function sendSave(content, { captureId = null, conversionHeader = null, attempts = [] } = {}) {
|
|
585
747
|
// The re-send below is a RETRY of this attempt, so it carries this same id:
|
|
586
748
|
// whichever of the two requests the host ends up storing, its receipt then
|
|
587
749
|
// answers for this save. A fresh id on the re-send would leave the first
|
|
@@ -595,9 +757,18 @@ async function sendSave(content) {
|
|
|
595
757
|
// is fast and conclusive, so it gets its own turn regardless.
|
|
596
758
|
let reSent = false;
|
|
597
759
|
let duplicateResolved = false;
|
|
760
|
+
// `page-converting` is the one refusal that is worth offering the same bytes
|
|
761
|
+
// again: the host has written nothing and is telling us when it expects to be
|
|
762
|
+
// ready. The capture these bytes came from is pinned here, so a capture made
|
|
763
|
+
// while we wait — newer edits, a disk frame — cancels the retry instead of
|
|
764
|
+
// being overwritten by stale bytes.
|
|
765
|
+
let convertingRetries = 0;
|
|
766
|
+
// A bound save retries only while its own capture is still the newest. A raw
|
|
767
|
+
// save has no capture identity, so it uses the newest capture at send time.
|
|
768
|
+
const retryGuard = captureId ?? currentCaptureId();
|
|
598
769
|
for (;;) {
|
|
599
770
|
try {
|
|
600
|
-
return await sendOnce(content, saveId, etag);
|
|
771
|
+
return await sendOnce(content, saveId, etag, { captureId, conversionHeader, attempts });
|
|
601
772
|
} catch (err) {
|
|
602
773
|
if (reSent) err.hadTimeout = true;
|
|
603
774
|
if (err.name === 'AbortError') {
|
|
@@ -626,6 +797,16 @@ async function sendSave(content) {
|
|
|
626
797
|
continue;
|
|
627
798
|
}
|
|
628
799
|
|
|
800
|
+
if (err.code === 'page-converting' && convertingRetries < PAGE_CONVERTING_MAX_RETRIES) {
|
|
801
|
+
if (currentCaptureId() !== retryGuard) throw err;
|
|
802
|
+
convertingRetries += 1;
|
|
803
|
+
// Default a missing or unparseable header to one second, so a host that
|
|
804
|
+
// asks for a retry without naming a delay still gets a bounded one.
|
|
805
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, err.retryAfter ?? 1) * 1000));
|
|
806
|
+
if (currentCaptureId() !== retryGuard) throw err;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
|
|
629
810
|
throw err;
|
|
630
811
|
}
|
|
631
812
|
}
|
|
@@ -635,32 +816,36 @@ async function sendSave(content) {
|
|
|
635
816
|
// SAVE FUNCTIONS
|
|
636
817
|
// =============================================================================
|
|
637
818
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
* passed to the callback. Never rejects.
|
|
643
|
-
*
|
|
644
|
-
* @param {string} html - HTML string to save
|
|
645
|
-
* @param {Function} callback - Called with the result on completion
|
|
646
|
-
* @returns {Promise<{msg: string, msgType: string, code: ?string, etag: ?string}>}
|
|
647
|
-
*
|
|
648
|
-
* @example
|
|
649
|
-
* const {msg, msgType} = await saveHtml(myHtml);
|
|
650
|
-
* if (msgType === 'error') console.error('Save failed:', msg);
|
|
651
|
-
*/
|
|
652
|
-
export function saveHtml(html, callback = () => {}) {
|
|
819
|
+
// One private implementation behind every entry point, so the hooks see the same
|
|
820
|
+
// thing whether the bytes came from a bound capture or a raw caller. `captureId`
|
|
821
|
+
// is null for a raw save: it belongs to no capture and can confirm none.
|
|
822
|
+
function runSave(html, captureId, callback, conversion) {
|
|
653
823
|
return new Promise((resolve) => {
|
|
654
|
-
const
|
|
824
|
+
const attempts = [];
|
|
825
|
+
const finished = (result, accepted = false) => {
|
|
826
|
+
if (accepted) notifySaveAccepted(html, captureId ?? null);
|
|
827
|
+
notifySaveOutcome({
|
|
828
|
+
captureId: captureId ?? null,
|
|
829
|
+
result,
|
|
830
|
+
attempts,
|
|
831
|
+
sent: attempts.length > 0
|
|
832
|
+
});
|
|
655
833
|
if (typeof callback === 'function') callback(result);
|
|
656
834
|
resolve(result);
|
|
657
835
|
};
|
|
658
836
|
|
|
659
837
|
if (!isEditMode && !window.clay?.testMode) {
|
|
660
|
-
return
|
|
838
|
+
return finished(skippedResult('Not in edit mode'));
|
|
661
839
|
}
|
|
662
840
|
if (saveInProgress) {
|
|
663
|
-
return
|
|
841
|
+
return finished(skippedResult('Save already in progress'));
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Refused locally and before the lane is claimed: no request is made, and the
|
|
845
|
+
// caller gets the same typed `invalid-header` a host would have sent back.
|
|
846
|
+
const formatted = formatConversionHeader(conversion);
|
|
847
|
+
if (formatted.invalid) {
|
|
848
|
+
return finished(invalidHeaderResult());
|
|
664
849
|
}
|
|
665
850
|
|
|
666
851
|
saveInProgress = true;
|
|
@@ -669,24 +854,74 @@ export function saveHtml(html, callback = () => {}) {
|
|
|
669
854
|
if (window.clay?.testMode) {
|
|
670
855
|
setTimeout(() => {
|
|
671
856
|
saveInProgress = false;
|
|
672
|
-
|
|
857
|
+
finished(successResult({ msg: 'Test mode: save skipped' }));
|
|
673
858
|
}, 0);
|
|
674
859
|
return;
|
|
675
860
|
}
|
|
676
861
|
|
|
677
|
-
sendSave(html)
|
|
862
|
+
sendSave(html, { captureId, conversionHeader: formatted.header, attempts })
|
|
678
863
|
.then(successResult, errorResult)
|
|
679
864
|
// Clear the flag BEFORE handing the result back, so a caller that queues a
|
|
680
865
|
// follow-up save can start it immediately rather than being told the lane
|
|
681
866
|
// is busy by the save that just finished.
|
|
682
867
|
.then(result => {
|
|
683
868
|
saveInProgress = false;
|
|
684
|
-
if (result.ok) notifySaveAccepted(html);
|
|
685
|
-
|
|
869
|
+
if (result.ok) notifySaveAccepted(html, captureId ?? null);
|
|
870
|
+
finished(result);
|
|
686
871
|
});
|
|
687
872
|
});
|
|
688
873
|
}
|
|
689
874
|
|
|
875
|
+
/**
|
|
876
|
+
* Save specific HTML content to the server.
|
|
877
|
+
*
|
|
878
|
+
* Returns a Promise resolving with {msg, msgType, code, etag} — the same object
|
|
879
|
+
* passed to the callback. Never rejects.
|
|
880
|
+
*
|
|
881
|
+
* @param {string} html - HTML string to save
|
|
882
|
+
* @param {Function} callback - Called with the result on completion
|
|
883
|
+
* @returns {Promise<{msg: string, msgType: string, code: ?string, etag: ?string}>}
|
|
884
|
+
*
|
|
885
|
+
* @example
|
|
886
|
+
* const {msg, msgType} = await saveHtml(myHtml);
|
|
887
|
+
* if (msgType === 'error') console.error('Save failed:', msg);
|
|
888
|
+
*/
|
|
889
|
+
export function saveHtml(html, callback = () => {}) {
|
|
890
|
+
return runSave(html, null, callback, null);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Save HTML that came from a specific capture.
|
|
895
|
+
*
|
|
896
|
+
* The capture id travels only in-process: it is what lets an attempt hook and an
|
|
897
|
+
* outcome hook agree that these bytes and that snapshot are the same save. A
|
|
898
|
+
* `conversion` request rides every attempt of this one logical save and no other.
|
|
899
|
+
*
|
|
900
|
+
* @param {string} html
|
|
901
|
+
* @param {?number} captureId
|
|
902
|
+
* @param {Function} [callback]
|
|
903
|
+
* @param {Object} [options]
|
|
904
|
+
* @param {Object} [options.conversion] - {source:'file', base} or {source:'room', base, epoch}
|
|
905
|
+
* @returns {Promise<Object>}
|
|
906
|
+
*/
|
|
907
|
+
export function saveCapturedHtml(html, captureId, callback = () => {}, { conversion } = {}) {
|
|
908
|
+
return runSave(html, captureId, callback, conversion);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Internal entry point for the dormant adoption path: request conversion while
|
|
913
|
+
* saving a capture's body. Not published on `window.clay`.
|
|
914
|
+
*
|
|
915
|
+
* @param {Object} request
|
|
916
|
+
* @param {string} request.html
|
|
917
|
+
* @param {?number} request.captureId
|
|
918
|
+
* @param {Object} request.conversion
|
|
919
|
+
* @returns {Promise<Object>}
|
|
920
|
+
*/
|
|
921
|
+
export function saveConversion({ html, captureId, conversion }) {
|
|
922
|
+
return runSave(html, captureId ?? null, undefined, conversion);
|
|
923
|
+
}
|
|
924
|
+
|
|
690
925
|
/**
|
|
691
926
|
* Fetch HTML from a URL and save it to replace the current page.
|
|
692
927
|
*
|