@panphora/clayjs 1.13.1 → 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 +69667 -41708
- 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 +94 -93
- 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/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/src/live/local-lane.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// where in the log it starts, the moves after that go back on top, and the
|
|
19
19
|
// merge itself is logged as a diff so a later file stamped before it keeps it.
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import { applyOp, applyTracked, checkOp, cloneJson, undoTracked, validateOp, serializeLive, textHash, diffOps } from "./ops.js";
|
|
22
22
|
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
23
23
|
import { movesSince } from "./moves.js";
|
|
24
24
|
|
|
@@ -28,6 +28,28 @@ export const CLOSE_WAIT_MS = 500;
|
|
|
28
28
|
export const LOG_ENTRIES = 1024;
|
|
29
29
|
export const LOG_BYTES = 256 * 1024;
|
|
30
30
|
|
|
31
|
+
// ops applied to value in place, all or none: a throw takes back the ones
|
|
32
|
+
// before it. Returns the value to go on with, and how to take the ops back.
|
|
33
|
+
function applyAll(value, ops) {
|
|
34
|
+
const done = [];
|
|
35
|
+
const undo = () => {
|
|
36
|
+
for (let i = done.length - 1; i >= 0; i--) undoTracked(done[i]);
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
for (const op of ops) {
|
|
40
|
+
const rec = applyTracked(value, op);
|
|
41
|
+
if (rec) {
|
|
42
|
+
done.push(rec);
|
|
43
|
+
value = rec.root;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
undo();
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
return { value, undo };
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
const encoder = new TextEncoder();
|
|
32
54
|
|
|
33
55
|
export function localLaneSupported() {
|
|
@@ -99,7 +121,7 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
|
|
|
99
121
|
function advance(data) {
|
|
100
122
|
const m = mirror.stores.get(data.name);
|
|
101
123
|
if (m) {
|
|
102
|
-
m.value =
|
|
124
|
+
m.value = applyAll(m.value, data.ops).value;
|
|
103
125
|
m.last = data.seq;
|
|
104
126
|
if (!m.ephemeral) pushLog(m, { seq: data.seq, ops: data.ops });
|
|
105
127
|
}
|
|
@@ -119,7 +141,10 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
|
|
|
119
141
|
|
|
120
142
|
const landedIn = (m) => (m.behind || m.landed === null ? -1 : m.landed);
|
|
121
143
|
|
|
122
|
-
|
|
144
|
+
// A client's own read (since is its { epoch, seq }, or null for a first read)
|
|
145
|
+
// carries moves, as the host's state() does: those after its seq in its own
|
|
146
|
+
// epoch, else this epoch's from its start. A broadcast passes no since.
|
|
147
|
+
function roomState(since) {
|
|
123
148
|
const stores = {};
|
|
124
149
|
const meta = {};
|
|
125
150
|
for (const [name, m] of mirror.stores) {
|
|
@@ -127,9 +152,10 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
|
|
|
127
152
|
meta[name] = { log: m.log, last: m.last, landed: m.landed, floor: m.floor, behind: m.behind, file: m.file, inFile: m.inFile };
|
|
128
153
|
}
|
|
129
154
|
const state = { epoch: room.epoch, seq: room.seq, writer: clientId, stores, mirror: { seen: [...mirror.seen], stores: meta } };
|
|
130
|
-
if (since
|
|
155
|
+
if (since !== undefined) {
|
|
156
|
+
const from = since && since.epoch === mirror.epoch && Number.isInteger(since.seq) ? since.seq : 0;
|
|
131
157
|
const moves = {};
|
|
132
|
-
for (const [name, m] of mirror.stores) moves[name] = movesSince(m,
|
|
158
|
+
for (const [name, m] of mirror.stores) moves[name] = movesSince(m, { seq: from });
|
|
133
159
|
state.moves = moves;
|
|
134
160
|
}
|
|
135
161
|
return state;
|
|
@@ -322,7 +348,7 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
|
|
|
322
348
|
let ops;
|
|
323
349
|
try {
|
|
324
350
|
ops = frame.ops.map(validateOp);
|
|
325
|
-
|
|
351
|
+
applyAll(m.value, ops).undo();
|
|
326
352
|
} catch (err) {
|
|
327
353
|
return { status: 422, body: { error: err.message }, retryAfter: 0 };
|
|
328
354
|
}
|
package/src/live/ops.js
CHANGED
|
@@ -176,6 +176,83 @@ export function applyOps(root, ops) {
|
|
|
176
176
|
return value;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// applyOp, keeping what it changed so a caller can size the change or take it
|
|
180
|
+
// back. null when the op changes nothing. Otherwise root is the value to go on
|
|
181
|
+
// with (a new one only for a whole-value set, where at is null and old is the
|
|
182
|
+
// previous root), and at[key] is the one slot the op changed, at path. kind
|
|
183
|
+
// says how: "put" replaced old, "add" made the slot (a new key, or an item
|
|
184
|
+
// appended to a list of size items), "remove" took old out. For an object, keys
|
|
185
|
+
// lists its keys before a remove, and empty says it had none before an add.
|
|
186
|
+
// Checks the op first, so a throw changes nothing.
|
|
187
|
+
export function applyTracked(root, op) {
|
|
188
|
+
checkOp(root, op);
|
|
189
|
+
const { path } = op;
|
|
190
|
+
if (path.length === 0) {
|
|
191
|
+
return { root: cloneJson(op.value), at: null, key: null, kind: "put", old: root, path: [], size: 0, keys: null, empty: false };
|
|
192
|
+
}
|
|
193
|
+
let at = root;
|
|
194
|
+
let depth = path.length - 1;
|
|
195
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
196
|
+
const next = read(at, path[i]);
|
|
197
|
+
if (!isContainer(next)) {
|
|
198
|
+
if (op.op === "del") return null;
|
|
199
|
+
depth = i;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
at = next;
|
|
203
|
+
}
|
|
204
|
+
const list = Array.isArray(at);
|
|
205
|
+
const key = list ? indexOf(at, path[depth]) : String(path[depth]);
|
|
206
|
+
const size = list ? at.length : 0;
|
|
207
|
+
const had = list ? key < size : has(at, key);
|
|
208
|
+
if (op.op === "del" && !had) return null;
|
|
209
|
+
const kind = op.op === "del" ? "remove" : had ? "put" : "add";
|
|
210
|
+
const rec = { root, at, key, kind, old: had ? at[key] : undefined, path: path.slice(0, depth + 1), size, keys: null, empty: false };
|
|
211
|
+
if (!list && kind === "remove") rec.keys = Object.keys(at);
|
|
212
|
+
if (!list && kind === "add") rec.empty = isEmpty(at);
|
|
213
|
+
applyOp(root, op);
|
|
214
|
+
return rec;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isEmpty(obj) {
|
|
218
|
+
for (const key in obj) if (has(obj, key)) return false;
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Takes back one applyTracked change; a frame's records go back newest first.
|
|
223
|
+
// Returns the root as it was before that op.
|
|
224
|
+
export function undoTracked(rec) {
|
|
225
|
+
const { at, key, kind, old } = rec;
|
|
226
|
+
if (at === null) return old;
|
|
227
|
+
if (Array.isArray(at)) {
|
|
228
|
+
if (kind === "put") at[key] = old;
|
|
229
|
+
else if (kind === "add") at.length = rec.size;
|
|
230
|
+
else at.splice(key, 0, old);
|
|
231
|
+
} else if (kind === "put") setOwn(at, key, old);
|
|
232
|
+
else if (kind === "add") delete at[key];
|
|
233
|
+
else if (rec.keys[rec.keys.length - 1] === key) setOwn(at, key, old);
|
|
234
|
+
else {
|
|
235
|
+
// Back in its place: every key after it is put back behind it.
|
|
236
|
+
const values = rec.keys.map((k) => (k === key ? old : at[k]));
|
|
237
|
+
for (const k of rec.keys) if (k !== key) delete at[k];
|
|
238
|
+
rec.keys.forEach((k, i) => setOwn(at, k, values[i]));
|
|
239
|
+
}
|
|
240
|
+
return rec.root;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// What one applyTracked change did to the length of serializeLive(root), with
|
|
244
|
+
// size(v) the length of serializeLive(v) in the caller's unit and total the
|
|
245
|
+
// root's length before. Call it before the next op touches the slot.
|
|
246
|
+
export function sizeChange(rec, size, total) {
|
|
247
|
+
const { at, key, kind, old } = rec;
|
|
248
|
+
if (at === null) return size(rec.root) - total;
|
|
249
|
+
const list = Array.isArray(at);
|
|
250
|
+
const slot = list ? 0 : size(key) + 1;
|
|
251
|
+
if (kind === "put") return size(at[key]) - size(old);
|
|
252
|
+
if (kind === "add") return slot + size(at[key]) + ((list ? rec.size > 0 : !rec.empty) ? 1 : 0);
|
|
253
|
+
return -(slot + size(old)) - ((list ? rec.size : rec.keys.length) > 1 ? 1 : 0);
|
|
254
|
+
}
|
|
255
|
+
|
|
179
256
|
const samePath = (a, b) => a.length === b.length && a.every((s, i) => String(s) === String(b[i]));
|
|
180
257
|
|
|
181
258
|
// Merges neighbouring ops on the same path, without changing the result:
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const CLIENT = /^[A-Za-z0-9_-]{1,64}$/;
|
|
2
|
+
const NUMBER = /^[1-9]\d{0,15}$/;
|
|
3
|
+
export const RANGE_LIMIT = 256;
|
|
4
|
+
|
|
5
|
+
const integer = (text) => NUMBER.test(text) && Number.isSafeInteger(Number(text));
|
|
6
|
+
|
|
7
|
+
export function unionRanges(...sets) {
|
|
8
|
+
const clients = new Map();
|
|
9
|
+
for (const set of sets) for (const [client, ranges] of set ?? []) {
|
|
10
|
+
if (!clients.has(client)) clients.set(client, []);
|
|
11
|
+
clients.get(client).push(...ranges.map(([lo, hi]) => [lo, hi]));
|
|
12
|
+
}
|
|
13
|
+
return [...clients].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([client, ranges]) => {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const [lo, hi] of ranges.sort((a, b) => a[0] - b[0])) {
|
|
16
|
+
const last = out[out.length - 1];
|
|
17
|
+
if (last && lo <= last[1] + 1) last[1] = Math.max(last[1], hi);
|
|
18
|
+
else out.push([lo, hi]);
|
|
19
|
+
}
|
|
20
|
+
return [client, out];
|
|
21
|
+
}).filter(([, ranges]) => ranges.length);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const rangeCount = (set) => set.reduce((n, [, ranges]) => n + ranges.length, 0);
|
|
25
|
+
export const hasNumber = (ranges, n) => !!ranges?.some(([lo, hi]) => lo <= n && n <= hi);
|
|
26
|
+
export const contains = (set, client, n) => hasNumber(set?.find(([id]) => id === client)?.[1], n);
|
|
27
|
+
export const identitySet = ([client, n]) => [[client, [[n, n]]]];
|
|
28
|
+
export const frameSet = (by) => by?.v === 2 ? by.frames : by ? [[by.client, [[1, by.n]]]] : [];
|
|
29
|
+
export const descendants = (by) => by?.v === 2 ? unionRanges(by.ancestors, identitySet(by.capture)) : [];
|
|
30
|
+
|
|
31
|
+
function parseRanges(text) {
|
|
32
|
+
if (!text) return [];
|
|
33
|
+
const out = [];
|
|
34
|
+
let count = 0;
|
|
35
|
+
for (const part of text.split(';')) {
|
|
36
|
+
const fields = part.split(':');
|
|
37
|
+
if (fields.length !== 2 || !CLIENT.test(fields[0]) || !fields[1]) return null;
|
|
38
|
+
const ranges = [];
|
|
39
|
+
for (const raw of fields[1].split(',')) {
|
|
40
|
+
if (++count > RANGE_LIMIT) return null;
|
|
41
|
+
const ends = raw.split('-');
|
|
42
|
+
if (ends.length > 2 || !ends.every(integer)) return null;
|
|
43
|
+
const lo = Number(ends[0]), hi = Number(ends[1] ?? ends[0]);
|
|
44
|
+
if (hi < lo) return null;
|
|
45
|
+
ranges.push([lo, hi]);
|
|
46
|
+
}
|
|
47
|
+
out.push([fields[0], ranges]);
|
|
48
|
+
}
|
|
49
|
+
return unionRanges(out);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function printRanges(set) {
|
|
53
|
+
return unionRanges(set).map(([client, ranges]) => `${client}:${ranges.map(([lo, hi]) => lo === hi ? String(lo) : `${lo}-${hi}`).join(',')}`).join(';');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseBy(text) {
|
|
57
|
+
if (typeof text !== 'string' || text.length > 32768) return null;
|
|
58
|
+
if (!text.startsWith('v2|')) {
|
|
59
|
+
const m = /^([A-Za-z0-9_-]{1,64}):([1-9]\d{0,15})$/.exec(text);
|
|
60
|
+
return m && integer(m[2]) ? { client: m[1], n: Number(m[2]) } : null;
|
|
61
|
+
}
|
|
62
|
+
const parts = text.split('|');
|
|
63
|
+
if (parts.length !== 4) return null;
|
|
64
|
+
const frames = parseRanges(parts[1]), ancestors = parseRanges(parts[2]);
|
|
65
|
+
const capture = /^([A-Za-z0-9_-]{1,64}):([1-9]\d{0,15})$/.exec(parts[3]);
|
|
66
|
+
if (!frames || !ancestors || !capture || !integer(capture[2])) return null;
|
|
67
|
+
if (rangeCount(frames) + rangeCount(ancestors) + 1 > RANGE_LIMIT) return null;
|
|
68
|
+
const id = [capture[1], Number(capture[2])];
|
|
69
|
+
if (contains(ancestors, ...id)) return null;
|
|
70
|
+
return { v: 2, frames, ancestors, capture: id };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatBy(by) {
|
|
74
|
+
const text = `v2|${printRanges(by.frames)}|${printRanges(by.ancestors)}|${by.capture[0]}:${by.capture[1]}`;
|
|
75
|
+
return parseBy(text) ? text : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function receiptHas(receipt, n) {
|
|
79
|
+
return !!receipt && (n <= receipt.n || hasNumber(receipt.saved, n));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function receiptBytes(receipt) {
|
|
83
|
+
return receipt?.saved ? JSON.stringify({ saved: receipt.saved, savedSeq: receipt.savedSeq }).length : 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function replaceReceipt(room, key, next) {
|
|
87
|
+
const delta = receiptBytes(next) - receiptBytes(room.lastN.get(key));
|
|
88
|
+
room.receiptBytes = (room.receiptBytes ?? 0) + delta;
|
|
89
|
+
room.lastN.delete(key);
|
|
90
|
+
room.lastN.set(key, next);
|
|
91
|
+
return delta;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function retireReceipt(room, key) {
|
|
95
|
+
const bytes = receiptBytes(room.lastN.get(key));
|
|
96
|
+
room.lastN.delete(key);
|
|
97
|
+
room.receiptBytes = (room.receiptBytes ?? 0) - bytes;
|
|
98
|
+
room.receiptGeneration = (room.receiptGeneration ?? 0) + 1;
|
|
99
|
+
return -bytes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function saveReceipt(room, key, ranges, seq, limit) {
|
|
103
|
+
const last = room.lastN.get(key);
|
|
104
|
+
if (!last && room.lastN.size >= limit) {
|
|
105
|
+
retireReceipt(room, room.lastN.keys().next().value);
|
|
106
|
+
}
|
|
107
|
+
const next = last ? { ...last } : { n: 0, seq: 0 };
|
|
108
|
+
const kept = unionRanges([['r', next.saved ?? []]], [['r', ranges]])[0]?.[1]
|
|
109
|
+
.filter(([, hi]) => hi > next.n).map(([lo, hi]) => [Math.max(lo, next.n + 1), hi]) ?? [];
|
|
110
|
+
if (kept.length > RANGE_LIMIT) {
|
|
111
|
+
retireReceipt(room, key);
|
|
112
|
+
next.saved = kept.slice(-RANGE_LIMIT);
|
|
113
|
+
next.savedSeq = Math.max(next.savedSeq ?? 0, seq);
|
|
114
|
+
} else {
|
|
115
|
+
if (kept.length) {
|
|
116
|
+
next.saved = kept;
|
|
117
|
+
next.savedSeq = Math.max(next.savedSeq ?? 0, seq);
|
|
118
|
+
} else {
|
|
119
|
+
delete next.saved;
|
|
120
|
+
delete next.savedSeq;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
replaceReceipt(room, key, next);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function postReceipt(last, n, seq) {
|
|
127
|
+
const saved = (last?.saved ?? []).filter(([, hi]) => hi > n).map(([lo, hi]) => [Math.max(lo, n + 1), hi]);
|
|
128
|
+
return { n, seq, ...(saved.length && { saved, savedSeq: last.savedSeq }) };
|
|
129
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
export const RECOVERY_WINDOW_MS = 45_000;
|
|
2
|
+
export const RECOVERY_ROUNDS_MS = [0, 10_000, 30_000];
|
|
3
|
+
export const QUERY_TIMEOUT_MS = 6_000;
|
|
4
|
+
export const MAX_HISTORY_IDS = 2;
|
|
5
|
+
|
|
6
|
+
export async function sha256Hex(html) {
|
|
7
|
+
const subtle = globalThis.crypto && globalThis.crypto.subtle;
|
|
8
|
+
if (!subtle) return null;
|
|
9
|
+
try {
|
|
10
|
+
const bytes = new TextEncoder().encode(String(html));
|
|
11
|
+
const digest = await subtle.digest("SHA-256", bytes);
|
|
12
|
+
const view = new Uint8Array(digest);
|
|
13
|
+
let hex = "";
|
|
14
|
+
for (let i = 0; i < view.length; i += 1) hex += view[i].toString(16).padStart(2, "0");
|
|
15
|
+
return hex;
|
|
16
|
+
} catch (_) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function uniqueSaveIds(attempts) {
|
|
22
|
+
const ids = [];
|
|
23
|
+
for (const attempt of attempts || []) {
|
|
24
|
+
const saveId = attempt && attempt.saveId;
|
|
25
|
+
if (typeof saveId === "string" && saveId && !ids.includes(saveId)) ids.push(saveId);
|
|
26
|
+
if (ids.length >= MAX_HISTORY_IDS) break;
|
|
27
|
+
}
|
|
28
|
+
return ids;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function positive(answer, saveId, requestHash) {
|
|
32
|
+
return Boolean(
|
|
33
|
+
answer &&
|
|
34
|
+
typeof answer === "object" &&
|
|
35
|
+
answer.saveId === saveId &&
|
|
36
|
+
answer.requestHash === requestHash &&
|
|
37
|
+
answer.status === "applied"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createSaveHistoryRecovery({
|
|
42
|
+
query,
|
|
43
|
+
digest = sha256Hex,
|
|
44
|
+
now = () => Date.now(),
|
|
45
|
+
setTimer = setTimeout,
|
|
46
|
+
clearTimer = clearTimeout,
|
|
47
|
+
} = {}) {
|
|
48
|
+
function start({ captureId, html, attempts, initialOutcome, startedAt, onApplied, onUnknown }) {
|
|
49
|
+
const deadline = startedAt + RECOVERY_WINDOW_MS;
|
|
50
|
+
const ids = uniqueSaveIds(attempts);
|
|
51
|
+
const timers = new Set();
|
|
52
|
+
const controllers = new Set();
|
|
53
|
+
let body = html;
|
|
54
|
+
let hash = null;
|
|
55
|
+
let terminal = false;
|
|
56
|
+
|
|
57
|
+
function call(fn, arg) {
|
|
58
|
+
try {
|
|
59
|
+
if (typeof fn === "function") fn(arg);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error("clayjs: save-history callback failed:", err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function finish() {
|
|
66
|
+
terminal = true;
|
|
67
|
+
body = null;
|
|
68
|
+
for (const timer of timers) clearTimer(timer);
|
|
69
|
+
timers.clear();
|
|
70
|
+
for (const controller of controllers) controller.abort();
|
|
71
|
+
controllers.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function apply(saveId, requestHash) {
|
|
75
|
+
if (terminal) return;
|
|
76
|
+
finish();
|
|
77
|
+
call(onApplied, { captureId, saveId, requestHash });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function giveUp(reason) {
|
|
81
|
+
if (terminal) return;
|
|
82
|
+
finish();
|
|
83
|
+
call(onUnknown, { captureId, reason });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function addTimer(fn, delay) {
|
|
87
|
+
let id = null;
|
|
88
|
+
id = setTimer(() => {
|
|
89
|
+
if (id !== null) timers.delete(id);
|
|
90
|
+
fn();
|
|
91
|
+
}, Math.max(0, delay));
|
|
92
|
+
timers.add(id);
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function dropTimer(id) {
|
|
97
|
+
if (id !== null) {
|
|
98
|
+
clearTimer(id);
|
|
99
|
+
timers.delete(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function probe(saveId) {
|
|
104
|
+
if (terminal) return;
|
|
105
|
+
const remaining = deadline - now();
|
|
106
|
+
if (remaining <= 0) return;
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
controllers.add(controller);
|
|
109
|
+
const timer = addTimer(() => controller.abort(), Math.min(QUERY_TIMEOUT_MS, remaining));
|
|
110
|
+
let answer = null;
|
|
111
|
+
try {
|
|
112
|
+
answer = await query({ saveId, requestHash: hash, signal: controller.signal });
|
|
113
|
+
} catch (_) {
|
|
114
|
+
answer = null;
|
|
115
|
+
} finally {
|
|
116
|
+
dropTimer(timer);
|
|
117
|
+
controllers.delete(controller);
|
|
118
|
+
}
|
|
119
|
+
if (terminal) return;
|
|
120
|
+
if (positive(answer, saveId, hash)) apply(saveId, hash);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runRound() {
|
|
124
|
+
if (terminal) return;
|
|
125
|
+
if (now() >= deadline) return;
|
|
126
|
+
for (const saveId of ids) probe(saveId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function run() {
|
|
130
|
+
let computed = null;
|
|
131
|
+
try {
|
|
132
|
+
computed = await digest(body);
|
|
133
|
+
} catch (_) {
|
|
134
|
+
computed = null;
|
|
135
|
+
}
|
|
136
|
+
body = null;
|
|
137
|
+
if (terminal) return;
|
|
138
|
+
if (typeof computed !== "string" || !computed) {
|
|
139
|
+
giveUp("unavailable");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
hash = computed;
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
initialOutcome &&
|
|
146
|
+
typeof initialOutcome === "object" &&
|
|
147
|
+
initialOutcome.status === "applied" &&
|
|
148
|
+
initialOutcome.requestHash === hash &&
|
|
149
|
+
ids.includes(initialOutcome.saveId)
|
|
150
|
+
) {
|
|
151
|
+
apply(initialOutcome.saveId, hash);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const elapsed = now() - startedAt;
|
|
156
|
+
if (elapsed >= RECOVERY_WINDOW_MS) {
|
|
157
|
+
giveUp("expired");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let due = -1;
|
|
162
|
+
for (let i = 0; i < RECOVERY_ROUNDS_MS.length; i += 1) {
|
|
163
|
+
if (RECOVERY_ROUNDS_MS[i] <= elapsed) due = i;
|
|
164
|
+
}
|
|
165
|
+
if (due < 0) {
|
|
166
|
+
for (let i = 0; i < RECOVERY_ROUNDS_MS.length; i += 1) {
|
|
167
|
+
addTimer(runRound, startedAt + RECOVERY_ROUNDS_MS[i] - now());
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
runRound();
|
|
172
|
+
for (let i = due + 1; i < RECOVERY_ROUNDS_MS.length; i += 1) {
|
|
173
|
+
addTimer(runRound, startedAt + RECOVERY_ROUNDS_MS[i] - now());
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
addTimer(() => giveUp("expired"), deadline - now());
|
|
178
|
+
run();
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
cancel() {
|
|
182
|
+
finish();
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { start };
|
|
188
|
+
}
|
package/src/live/server-lane.js
CHANGED
|
@@ -39,7 +39,7 @@ export async function relayData(clientId, frame) {
|
|
|
39
39
|
* The room's { epoch, seq, writer, stores }; { status } when the host refused;
|
|
40
40
|
* null when the request never got an answer. clientId marks this tab as taking part.
|
|
41
41
|
*/
|
|
42
|
-
export async function fetchLiveState(clientId, since = null) {
|
|
42
|
+
export async function fetchLiveState(clientId, since = null, { signal } = {}) {
|
|
43
43
|
const url = dataUrl();
|
|
44
44
|
if (clientId) url.searchParams.set("client-id", clientId);
|
|
45
45
|
if (since && typeof since.epoch === "string" && Number.isInteger(since.seq)) url.searchParams.set("since", `${since.epoch}:${since.seq}`);
|
|
@@ -48,8 +48,15 @@ export async function fetchLiveState(clientId, since = null) {
|
|
|
48
48
|
credentials: "same-origin",
|
|
49
49
|
cache: "no-store",
|
|
50
50
|
headers: { "Document-URL": window.location.href },
|
|
51
|
+
signal,
|
|
51
52
|
});
|
|
52
|
-
if (!response.ok)
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
let body = null;
|
|
55
|
+
try {
|
|
56
|
+
body = await response.json();
|
|
57
|
+
} catch {}
|
|
58
|
+
return { status: response.status, code: body?.code ?? null, retryAfter: Number(response.headers?.get?.("Retry-After")) || 0 };
|
|
59
|
+
}
|
|
53
60
|
const state = await response.json();
|
|
54
61
|
// The stream is open but the host has not seen its subscription yet: not
|
|
55
62
|
// ready, and the lane reads again.
|