@panphora/clayjs 1.10.0 → 1.11.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/README.md +2 -2
- package/dist/clay.standalone.js +6070 -4017
- package/package.json +1 -1
- package/src/attrs/add-from.js +72 -0
- package/src/core/conflict-notice.js +2 -5
- package/src/core/conflict-presentation.js +42 -0
- package/src/core/save-core.js +22 -0
- package/src/core/save.js +11 -2
- package/src/lib/region-capabilities.js +1 -0
- package/src/lib/region-policy.js +10 -3
- package/src/live/checkpoint.js +106 -0
- package/src/live/fast-lane.js +590 -0
- package/src/live/local-lane.js +582 -0
- package/src/live/ops.js +291 -0
- package/src/live/server-lane.js +84 -0
- package/src/live/store.js +291 -0
- package/src/live/stream-bridge.js +60 -0
- package/src/loader-logic.js +7 -2
- package/src/loader.js +2 -0
- package/src/plugins/live.js +446 -0
- package/src/sync/conflict-revert.js +1 -1
- package/src/sync/live-sync.js +21 -5
- package/src/sync/presence.js +4 -10
- package/src/sync/stream.js +2 -1
- package/src/vendor/hyper-morph.vendor.js +6 -4
- package/src/sync/section-notice.js +0 -254
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Live sync owns the document's stream. clay.live's fast lane needs to know when
|
|
2
|
+
// that stream opens (and as which client), when it drops, and three of its named
|
|
3
|
+
// events. This module carries them across, so neither side imports the other and
|
|
4
|
+
// a page without the live plugin pays for one small module.
|
|
5
|
+
|
|
6
|
+
export const LIVE_EVENTS = Object.freeze(["data", "data-state", "data-writer"]);
|
|
7
|
+
|
|
8
|
+
const handlers = new Set();
|
|
9
|
+
let current = null;
|
|
10
|
+
|
|
11
|
+
function each(method, ...args) {
|
|
12
|
+
for (const handler of handlers) {
|
|
13
|
+
try {
|
|
14
|
+
handler[method]?.(...args);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.error("clay.live: stream handler failed:", err);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Live sync: the stream is open as this client on this lane. Every (re)open. */
|
|
22
|
+
export function streamOpened({ clientId, lane }) {
|
|
23
|
+
current = { clientId, lane };
|
|
24
|
+
each("open", current);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Live sync: the stream dropped or stopped. */
|
|
28
|
+
export function streamClosed() {
|
|
29
|
+
if (!current) return;
|
|
30
|
+
current = null;
|
|
31
|
+
each("close");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Live sync: one of LIVE_EVENTS arrived, its data already parsed. */
|
|
35
|
+
export function streamEvent(type, data) {
|
|
36
|
+
if (!LIVE_EVENTS.includes(type)) return;
|
|
37
|
+
each("event", type, data);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Listen: { open(info), close(), event(type, data) }, each optional. A stream
|
|
42
|
+
* that is already open is reported to the new handler at once. Returns an
|
|
43
|
+
* unsubscribe function.
|
|
44
|
+
*/
|
|
45
|
+
export function onStream(handler) {
|
|
46
|
+
handlers.add(handler);
|
|
47
|
+
if (current) {
|
|
48
|
+
try {
|
|
49
|
+
handler.open?.(current);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error("clay.live: stream handler failed:", err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return () => handlers.delete(handler);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The open stream's { clientId, lane }, or null. */
|
|
58
|
+
export function currentStream() {
|
|
59
|
+
return current;
|
|
60
|
+
}
|
package/src/loader-logic.js
CHANGED
|
@@ -19,7 +19,7 @@ export const CORE_WAVES = {
|
|
|
19
19
|
"core/save-core.js", "core/save.js",
|
|
20
20
|
"core/conflict-notice.js",
|
|
21
21
|
"core/unsaved-warning.js", "core/autosave.js",
|
|
22
|
-
"attrs/save-freeze.js", "attrs/onaftersave.js", "attrs/refetch-on-save.js",
|
|
22
|
+
"attrs/save-freeze.js", "attrs/onaftersave.js", "attrs/refetch-on-save.js", "attrs/add-from.js",
|
|
23
23
|
"lib/cache-bust.js",
|
|
24
24
|
],
|
|
25
25
|
};
|
|
@@ -28,6 +28,9 @@ export const PLUGIN_PATHS = {
|
|
|
28
28
|
richclay: { path: "plugins/richclay.js", editOnly: true, default: true },
|
|
29
29
|
indicator: { path: "plugins/indicator.js", editOnly: true, default: false },
|
|
30
30
|
sync: { path: "sync/live-sync.js", editOnly: false, default: false },
|
|
31
|
+
// A live JSON tag as an in-memory object (clay.live). Not editOnly: a reader's
|
|
32
|
+
// tab still shows the game, and its writes stay in that tab.
|
|
33
|
+
live: { path: "plugins/live.js", editOnly: false, default: false },
|
|
31
34
|
sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
|
|
32
35
|
undo: { path: "plugins/undo.js", editOnly: true, default: false },
|
|
33
36
|
cms: { path: "plugins/cms.js", editOnly: false, default: false },
|
|
@@ -81,11 +84,13 @@ export const MODULES = {
|
|
|
81
84
|
"attrs/save-freeze.js": () => import("./attrs/save-freeze.js"),
|
|
82
85
|
"attrs/onaftersave.js": () => import("./attrs/onaftersave.js"),
|
|
83
86
|
"attrs/refetch-on-save.js": () => import("./attrs/refetch-on-save.js"),
|
|
87
|
+
"attrs/add-from.js": () => import("./attrs/add-from.js"),
|
|
84
88
|
"lib/cache-bust.js": () => import("./lib/cache-bust.js"),
|
|
85
89
|
"vendor/hyper-morph.vendor.js": () => import("./vendor/hyper-morph.vendor.js"),
|
|
86
90
|
"plugins/richclay.js": () => import("./plugins/richclay.js"),
|
|
87
91
|
"plugins/indicator.js": () => import("./plugins/indicator.js"),
|
|
88
92
|
"sync/live-sync.js": () => import("./sync/live-sync.js"),
|
|
93
|
+
"plugins/live.js": () => import("./plugins/live.js"),
|
|
89
94
|
"plugins/sortable.js": () => import("./plugins/sortable.js"),
|
|
90
95
|
"plugins/undo.js": () => import("./plugins/undo.js"),
|
|
91
96
|
"plugins/cms.js": () => import("./plugins/cms.js"),
|
|
@@ -100,7 +105,7 @@ export const MODULES = {
|
|
|
100
105
|
|
|
101
106
|
// `source` is last on purpose: its install captures a save clone, so it wants every
|
|
102
107
|
// plugin that registers a document transform to have registered it first.
|
|
103
|
-
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "wire", "ai-edit", "demo", "source"];
|
|
108
|
+
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "wire", "ai-edit", "demo", "source"];
|
|
104
109
|
|
|
105
110
|
// A plugin that cannot do its whole job alone. hypercms reads the cropper through
|
|
106
111
|
// a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
|
package/src/loader.js
CHANGED
|
@@ -124,6 +124,8 @@ function attachPluginMember(path, mod, loaded) {
|
|
|
124
124
|
clay.morph = mod.morph;
|
|
125
125
|
clay.conflicts = mod.conflicts;
|
|
126
126
|
loaded["core/page-data.js"].setPageDataLiveSync(mod.liveSync);
|
|
127
|
+
} else if (path === "plugins/live.js") {
|
|
128
|
+
clay.live = mod.live;
|
|
127
129
|
} else if (path === "plugins/cms.js") {
|
|
128
130
|
clay.cms = mod.cms || mod.default;
|
|
129
131
|
} else if (path === "plugins/upload.js") {
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// clay.live(name): this page's <script type="application/json" merge="name"
|
|
2
|
+
// clay="live"> tag as a live object. The store (src/live/store.js) holds the
|
|
3
|
+
// value in memory and never writes the tag. This module carries the value in and
|
|
4
|
+
// out at file speed: it writes the value into every capture, confirms this tab's
|
|
5
|
+
// ops when the save that carried them is accepted (never from an incoming frame),
|
|
6
|
+
// and takes the values other tabs saved or relayed from each live-sync frame.
|
|
7
|
+
|
|
8
|
+
import { createStore } from "../live/store.js";
|
|
9
|
+
import { serializeLive, stampOf, parseStamp } from "../live/ops.js";
|
|
10
|
+
import { onSnapshot } from "../core/snapshot.js";
|
|
11
|
+
import { onSaveAccepted, isSaveInProgress } from "../core/save-core.js";
|
|
12
|
+
import { savePageForce, baselineSettled, isSaveConflicted, autosaveIsSuspended } from "../core/save.js";
|
|
13
|
+
import { withKeepalive } from "../core/save-core.js";
|
|
14
|
+
import { isEditMode } from "../core/is-edit-mode.js";
|
|
15
|
+
import { createCheckpoints } from "../live/checkpoint.js";
|
|
16
|
+
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
17
|
+
import { hostSupports } from "../core/host-meta.js";
|
|
18
|
+
import { createFastLane } from "../live/fast-lane.js";
|
|
19
|
+
import { serverTransport } from "../live/server-lane.js";
|
|
20
|
+
import { localTransport, localLaneSupported } from "../live/local-lane.js";
|
|
21
|
+
|
|
22
|
+
const NAME = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
23
|
+
const entries = new Map();
|
|
24
|
+
|
|
25
|
+
// What the save on the wire captured, per saved store: { mode, mark, canonical,
|
|
26
|
+
// at }. Set by the save's own capture, taken by its acceptance or its failure.
|
|
27
|
+
// Only one save is on the wire at a time.
|
|
28
|
+
let pinned = null;
|
|
29
|
+
|
|
30
|
+
// A JSON text as the value it holds, so formatting (spacing, key order) never
|
|
31
|
+
// decides a match.
|
|
32
|
+
function sortedKeys(value) {
|
|
33
|
+
if (Array.isArray(value)) return value.map(sortedKeys);
|
|
34
|
+
if (value === null || typeof value !== "object") return value;
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const key of Object.keys(value).sort()) out[key] = sortedKeys(value[key]);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function canonical(text) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.stringify(sortedKeys(HyperMorph.parseJsonRelaxed(text)));
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Saved stores with changes no accepted save holds yet. Only an edit-mode tab
|
|
49
|
+
// saves. In file mode that is this tab's unconfirmed ops; in fast mode the room
|
|
50
|
+
// says the file does not reach its latest change, and only the writer saves.
|
|
51
|
+
function unsavedLiveChanges() {
|
|
52
|
+
if (!isEditMode) return false;
|
|
53
|
+
for (const entry of entries.values()) {
|
|
54
|
+
if (entry.store.ephemeral) continue;
|
|
55
|
+
if (entry.store.handle.status.mode === "fast") {
|
|
56
|
+
if (fast?.isWriter() && entry.landed < entry.last) return true;
|
|
57
|
+
} else if (entry.store.pending.length > 0) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const checkpoints = createCheckpoints({
|
|
65
|
+
needsSave: unsavedLiveChanges,
|
|
66
|
+
blocked: () => !baselineSettled() || isSaveConflicted() || autosaveIsSuspended(),
|
|
67
|
+
save: () => savePageForce(),
|
|
68
|
+
saveNow: () => withKeepalive(true, () => savePageForce()),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The fast lane, on a host that advertises live-data. Started by the first
|
|
72
|
+
// clay.live() call in an edit-mode tab.
|
|
73
|
+
let fast = null;
|
|
74
|
+
let laneRequested = false;
|
|
75
|
+
let local = null;
|
|
76
|
+
// True from pagehide until the page is shown again (a page kept for back and
|
|
77
|
+
// forward): only a closing page's save is reported to the local room at capture.
|
|
78
|
+
let closing = false;
|
|
79
|
+
|
|
80
|
+
// The room's numbers moved: a file the room cannot rebuild from (landed -1) is
|
|
81
|
+
// saved over now, anything else on the usual deadline.
|
|
82
|
+
function liveMoved() {
|
|
83
|
+
for (const entry of entries.values()) {
|
|
84
|
+
if (!entry.store.ephemeral && entry.store.handle.status.mode === "fast" && entry.landed === -1) {
|
|
85
|
+
checkpoints.soon();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
checkpoints.changed();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The fast lane frames nothing of a store while some of its pending ops are in
|
|
93
|
+
// a file-mode save on the wire: the file may hold them, and the room merges the file.
|
|
94
|
+
function heldBySave(name) {
|
|
95
|
+
const entry = entries.get(name);
|
|
96
|
+
if (!entry) return false;
|
|
97
|
+
const saved = pinned?.get(name);
|
|
98
|
+
const through = saved?.mode === "file" && isSaveInProgress() ? saved.mark : 0;
|
|
99
|
+
return through > 0 && entry.store.pendingEntries().some((pending) => pending.id <= through);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Drop a store's ops through mark: a save that may have landed holds them.
|
|
103
|
+
function dropThrough(entry, mark) {
|
|
104
|
+
entry.store.rebase(entry.store.authoritative, { source: "state", through: mark });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A save ended without being accepted. maybeLanded: it may have reached the
|
|
108
|
+
// file (an error or a lost answer). Ops it carried in file mode go on (to the
|
|
109
|
+
// room, or the next save) when it surely did not land. When it may have, they
|
|
110
|
+
// are dropped, never sent again: if it landed, the file brings them back.
|
|
111
|
+
function endPin(pin, maybeLanded) {
|
|
112
|
+
if (!pin) return;
|
|
113
|
+
for (const [name, saved] of pin) {
|
|
114
|
+
const entry = entries.get(name);
|
|
115
|
+
if (!entry || saved.mode !== "file") continue;
|
|
116
|
+
if (maybeLanded) dropThrough(entry, saved.mark);
|
|
117
|
+
if (entry.store.handle.status.mode === "fast") fast?.release(name);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function laneOptions(transport) {
|
|
122
|
+
return {
|
|
123
|
+
transport,
|
|
124
|
+
entries,
|
|
125
|
+
onWriter: () => checkpoints.changed(),
|
|
126
|
+
onApplied: liveMoved,
|
|
127
|
+
onLanded: liveMoved,
|
|
128
|
+
onCheckpoint: () => checkpoints.soon(),
|
|
129
|
+
onLeave: (name, at) => {
|
|
130
|
+
const entry = entries.get(name);
|
|
131
|
+
if (entry) entry.leftAt = at;
|
|
132
|
+
},
|
|
133
|
+
held: heldBySave,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// What this tab holds for every live tag, for the first room this browser
|
|
138
|
+
// starts: an open store's authoritative value (never its own unconfirmed ops,
|
|
139
|
+
// which its lane sends), or the tag's value.
|
|
140
|
+
function seedLocalRoom() {
|
|
141
|
+
const values = new Map();
|
|
142
|
+
for (const [name, tag] of liveTags(document, { warn: false })) {
|
|
143
|
+
const entry = entries.get(name);
|
|
144
|
+
if (entry) {
|
|
145
|
+
values.set(name, { value: entry.store.authoritative, ephemeral: entry.store.ephemeral });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const value = HyperMorph.parseJsonRelaxed(tag.element.textContent);
|
|
150
|
+
if (value !== null && typeof value === "object") values.set(name, { value, ephemeral: tag.ephemeral });
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
return values;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ensureLane() {
|
|
157
|
+
if (laneRequested || !isEditMode) return;
|
|
158
|
+
laneRequested = true;
|
|
159
|
+
hostSupports("live-data").then(
|
|
160
|
+
(supported) => {
|
|
161
|
+
if (supported) {
|
|
162
|
+
fast = createFastLane(laneOptions(serverTransport()));
|
|
163
|
+
} else if (localLaneSupported()) {
|
|
164
|
+
local = localTransport({ seed: seedLocalRoom });
|
|
165
|
+
fast = createFastLane(laneOptions(local));
|
|
166
|
+
} else {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// The writer saves every store in the room, so every live tag is opened,
|
|
170
|
+
// not only the ones this page's code asked for.
|
|
171
|
+
for (const name of liveTags(document, { warn: false }).keys()) {
|
|
172
|
+
if (entries.has(name)) continue;
|
|
173
|
+
try {
|
|
174
|
+
live(name);
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|
|
177
|
+
fast.start();
|
|
178
|
+
},
|
|
179
|
+
() => {},
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isJsonType(type) {
|
|
184
|
+
const t = (type || "").split(";")[0].trim().toLowerCase();
|
|
185
|
+
return t === "application/json" || t.endsWith("+json");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The live tags under root, by name. A name used twice is left out: neither tag
|
|
189
|
+
// can be the store.
|
|
190
|
+
export function liveTags(root = document, { warn = true } = {}) {
|
|
191
|
+
const found = new Map();
|
|
192
|
+
const duplicated = new Set();
|
|
193
|
+
for (const el of root.querySelectorAll('script[merge][clay~="live"]')) {
|
|
194
|
+
if (el.hasAttribute("src") || !isJsonType(el.getAttribute("type"))) continue;
|
|
195
|
+
const name = el.getAttribute("merge");
|
|
196
|
+
if (!NAME.test(name)) continue;
|
|
197
|
+
if (found.has(name)) {
|
|
198
|
+
duplicated.add(name);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
found.set(name, { name, ephemeral: el.getAttribute("clay").split(/\s+/).includes("ephemeral"), element: el });
|
|
202
|
+
}
|
|
203
|
+
for (const name of duplicated) {
|
|
204
|
+
if (warn) console.warn(`clay.live: more than one live tag is named "${name}", so none of them is live`);
|
|
205
|
+
found.delete(name);
|
|
206
|
+
}
|
|
207
|
+
return found;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseHtml(html) {
|
|
211
|
+
return new DOMParser().parseFromString(html, "text/html");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// In fast mode a capture writes the room's value, never this tab's unconfirmed
|
|
215
|
+
// ops (the room may still refuse them).
|
|
216
|
+
function textOf(entry) {
|
|
217
|
+
const value = entry.store.handle.status.mode === "fast" ? entry.store.authoritative : entry.store.value;
|
|
218
|
+
return entry.pretty ? JSON.stringify(value, null, 2).replace(/</g, "\\u003c") : serializeLive(value);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A value another tab saved or relayed. It never confirms this tab's ops: equal
|
|
222
|
+
// text from a peer is not this tab's save.
|
|
223
|
+
function receive(entry, text, source) {
|
|
224
|
+
let value;
|
|
225
|
+
try {
|
|
226
|
+
value = HyperMorph.parseJsonRelaxed(text);
|
|
227
|
+
} catch {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (entry.store.rebase(value, { source })) entry.framesSince = true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function live(name) {
|
|
234
|
+
const existing = entries.get(name);
|
|
235
|
+
if (existing) return existing.store.handle;
|
|
236
|
+
const tag = liveTags().get(name);
|
|
237
|
+
if (!tag) throw new Error(`clay.live: this page has no <script type="application/json" merge="${name}" clay="live"> tag`);
|
|
238
|
+
const text = tag.element.textContent;
|
|
239
|
+
let value;
|
|
240
|
+
try {
|
|
241
|
+
value = HyperMorph.parseJsonRelaxed(text);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
throw new SyntaxError(`clay.live("${name}"): the tag's JSON does not parse: ${err.message}`);
|
|
244
|
+
}
|
|
245
|
+
const store = createStore({ name, value, ephemeral: tag.ephemeral });
|
|
246
|
+
const entry = { store, pretty: text.includes("\n"), framesSince: false, last: 0, landed: 0, leftAt: null };
|
|
247
|
+
entries.set(name, entry);
|
|
248
|
+
if (!tag.ephemeral) store.onLocal(() => checkpoints.changed());
|
|
249
|
+
store.onLocal((ops) => fast?.local(name, ops, store.mark));
|
|
250
|
+
store.handle.on("change", () => {
|
|
251
|
+
if (!store.ephemeral && store.handle.status.mode === "fast") checkpoints.changed();
|
|
252
|
+
});
|
|
253
|
+
fast?.storeOpened(name);
|
|
254
|
+
ensureLane();
|
|
255
|
+
return store.handle;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Every capture carries each saved store's current value. A never-saved store
|
|
259
|
+
// writes nothing: its tag keeps the text the file has.
|
|
260
|
+
onSnapshot((clone) => {
|
|
261
|
+
if (!entries.size) return;
|
|
262
|
+
const tags = liveTags(clone, { warn: false });
|
|
263
|
+
for (const entry of entries.values()) {
|
|
264
|
+
if (entry.store.ephemeral) continue;
|
|
265
|
+
const tag = tags.get(entry.store.name);
|
|
266
|
+
if (!tag) continue;
|
|
267
|
+
const text = textOf(entry);
|
|
268
|
+
tag.element.textContent = text;
|
|
269
|
+
// The room position this value was written at, and a hash of the value: the
|
|
270
|
+
// host merges a later write of the file from that position.
|
|
271
|
+
if (entry.store.handle.status.mode === "fast" && fast) {
|
|
272
|
+
tag.element.setAttribute("live-at", stampOf(fast.status.epoch, fast.status.seq, serializeLive(entry.store.authoritative)));
|
|
273
|
+
} else if (entry.leftAt) {
|
|
274
|
+
// Left the room: the value descends from the room position it left at.
|
|
275
|
+
tag.element.setAttribute("live-at", stampOf(entry.leftAt.epoch, entry.leftAt.seq, serializeLive(entry.store.value)));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// The save's own capture (the one that is sent): pin, per saved store, the mode
|
|
281
|
+
// it was captured in, the newest op it held, the value it wrote and (fast mode)
|
|
282
|
+
// the room position. onSnapshot has already written the clone. A pin still set
|
|
283
|
+
// belonged to a save that ended without acceptance.
|
|
284
|
+
document.addEventListener("clay:snapshot-ready", (event) => {
|
|
285
|
+
if (event.detail?.forSave !== true || !entries.size) return;
|
|
286
|
+
const tags = liveTags(event.detail.documentElement, { warn: false });
|
|
287
|
+
const stores = new Map();
|
|
288
|
+
for (const entry of entries.values()) {
|
|
289
|
+
if (entry.store.ephemeral) continue;
|
|
290
|
+
const tag = tags.get(entry.store.name);
|
|
291
|
+
if (!tag) continue;
|
|
292
|
+
const mode = entry.store.handle.status.mode === "fast" && fast ? "fast" : "file";
|
|
293
|
+
const at = mode === "fast" ? { epoch: fast.status.epoch, seq: fast.status.seq } : null;
|
|
294
|
+
stores.set(entry.store.name, { mode, mark: entry.store.mark, canonical: canonical(tag.element.textContent), at });
|
|
295
|
+
entry.framesSince = false;
|
|
296
|
+
// A close save is answered after the page is gone: the local room learns
|
|
297
|
+
// how far the file reaches now. Should the save fail, a later merge drops
|
|
298
|
+
// those moves, never counts them twice.
|
|
299
|
+
if (mode === "fast" && closing && checkpoints.flushing) local?.landed(entry.store.name, at);
|
|
300
|
+
}
|
|
301
|
+
// A pin still set at the next capture is a save that sent nothing (a clean
|
|
302
|
+
// page's save is skipped without an event): an accepted or failed save
|
|
303
|
+
// clears its pin.
|
|
304
|
+
const previous = pinned;
|
|
305
|
+
pinned = stores;
|
|
306
|
+
endPin(previous, false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// A refusal proves the host wrote nothing, unless an earlier attempt of the
|
|
310
|
+
// same save timed out: that one may have landed.
|
|
311
|
+
document.addEventListener("clay:save-conflict", (event) => {
|
|
312
|
+
const previous = pinned;
|
|
313
|
+
pinned = null;
|
|
314
|
+
endPin(previous, event.detail?.hadTimeout === true);
|
|
315
|
+
});
|
|
316
|
+
// Only a 4xx answer proves the host wrote nothing, and only when no attempt of
|
|
317
|
+
// the save timed out. A lost connection, a timeout or a 5xx (a proxy's 502 can
|
|
318
|
+
// follow a write) may have landed.
|
|
319
|
+
for (const type of ["clay:save-error", "clay:save-offline"]) {
|
|
320
|
+
document.addEventListener(type, (event) => {
|
|
321
|
+
const status = event.detail?.status;
|
|
322
|
+
const previous = pinned;
|
|
323
|
+
pinned = null;
|
|
324
|
+
endPin(previous, event.detail?.hadTimeout === true || !(status >= 400 && status < 500));
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The bytes a host just accepted. A store whose tag in them holds the value the
|
|
329
|
+
// save's own capture wrote (formatting aside):
|
|
330
|
+
// captured in file mode: the ops it held are in the file. Folded into the
|
|
331
|
+
// store's base when nothing arrived since the capture, only dropped when a
|
|
332
|
+
// frame did (that frame descends from this save). A store now in fast mode
|
|
333
|
+
// drops them: the room merges the file, and the lane, which held them,
|
|
334
|
+
// frames what is left.
|
|
335
|
+
// captured in fast mode: the file holds the room's value, none of this tab's
|
|
336
|
+
// own ops. The host room sees the file land; a local room is told.
|
|
337
|
+
export function confirmSaved(html) {
|
|
338
|
+
const pin = pinned;
|
|
339
|
+
pinned = null;
|
|
340
|
+
if (!pin || typeof html !== "string") return;
|
|
341
|
+
const tags = liveTags(parseHtml(html), { warn: false });
|
|
342
|
+
for (const [name, saved] of pin) {
|
|
343
|
+
const entry = entries.get(name);
|
|
344
|
+
if (!entry) continue;
|
|
345
|
+
const tag = tags.get(name);
|
|
346
|
+
const mode = entry.store.handle.status.mode;
|
|
347
|
+
if (!tag || canonical(tag.element.textContent) !== saved.canonical) {
|
|
348
|
+
if (saved.mode === "file" && mode === "fast") fast?.release(name);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (saved.mode === "fast") {
|
|
352
|
+
if (saved.at) local?.landed(name, saved.at);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (mode === "fast") {
|
|
356
|
+
// The room merges the file, which holds these ops: they leave pending
|
|
357
|
+
// without touching the room's value here. A local room never sees this
|
|
358
|
+
// tab's own save come back, so it is handed the file.
|
|
359
|
+
dropThrough(entry, saved.mark);
|
|
360
|
+
local?.fileFrame(new Map([[name, { text: tag.element.textContent, at: parseStamp(tag.element.getAttribute("live-at")), seq: null }]]));
|
|
361
|
+
fast?.release(name);
|
|
362
|
+
// The room merged the file before answering, but its state may arrive
|
|
363
|
+
// after this answer: read it, so the view holds these ops again.
|
|
364
|
+
fast?.refresh();
|
|
365
|
+
} else if (entry.framesSince) {
|
|
366
|
+
dropThrough(entry, saved.mark);
|
|
367
|
+
} else {
|
|
368
|
+
entry.store.confirm(saved.mark);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
onSaveAccepted(confirmSaved);
|
|
374
|
+
|
|
375
|
+
// A live-sync frame. A saved store in file mode takes the frame's value and
|
|
376
|
+
// replays its own unconfirmed ops over it. Every other live tag (never-saved
|
|
377
|
+
// stores, and tags no code has opened) takes the frame's text into the page, so
|
|
378
|
+
// the next save keeps what the file now says instead of the text this tab
|
|
379
|
+
// loaded with.
|
|
380
|
+
export function receiveFrame(html, seq = null) {
|
|
381
|
+
if (typeof html !== "string" || !html) return;
|
|
382
|
+
const incoming = liveTags(parseHtml(html), { warn: false });
|
|
383
|
+
if (!incoming.size) return;
|
|
384
|
+
if (local) {
|
|
385
|
+
const frames = new Map();
|
|
386
|
+
for (const [name, tag] of incoming) {
|
|
387
|
+
if (tag.ephemeral) continue;
|
|
388
|
+
frames.set(name, { text: tag.element.textContent, at: parseStamp(tag.element.getAttribute("live-at")), seq: Number.isInteger(seq) ? seq : null });
|
|
389
|
+
}
|
|
390
|
+
local.fileFrame(frames);
|
|
391
|
+
}
|
|
392
|
+
const page = liveTags(document, { warn: false });
|
|
393
|
+
for (const [name, tag] of incoming) {
|
|
394
|
+
const text = tag.element.textContent;
|
|
395
|
+
const entry = entries.get(name);
|
|
396
|
+
const at = tag.element.getAttribute("live-at");
|
|
397
|
+
const pageTag = page.get(name)?.element;
|
|
398
|
+
// The page's tag keeps the lineage of the version this tab last took, so a
|
|
399
|
+
// file-mode save claims what it descends from.
|
|
400
|
+
if (entry && entry.store.handle.status.mode === "file") entry.leftAt = null;
|
|
401
|
+
if (pageTag && (!entry || entry.store.handle.status.mode === "file")) {
|
|
402
|
+
if (at === null) pageTag.removeAttribute("live-at");
|
|
403
|
+
else if (pageTag.getAttribute("live-at") !== at) pageTag.setAttribute("live-at", at);
|
|
404
|
+
}
|
|
405
|
+
if (entry && !entry.store.ephemeral) {
|
|
406
|
+
if (entry.store.handle.status.mode === "file") receive(entry, text, "remote");
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (pageTag && pageTag.textContent !== text) pageTag.textContent = text;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
document.addEventListener("clay:sync-applied", (event) => receiveFrame(event.detail?.html, event.detail?.seq));
|
|
414
|
+
|
|
415
|
+
// The last chance to save live changes made since the previous checkpoint. A
|
|
416
|
+
// keepalive body is capped near 60 KB; a larger page's save may not finish.
|
|
417
|
+
document.addEventListener("visibilitychange", () => {
|
|
418
|
+
if (document.visibilityState === "hidden") checkpoints.flush();
|
|
419
|
+
});
|
|
420
|
+
window.addEventListener("pagehide", () => {
|
|
421
|
+
closing = true;
|
|
422
|
+
// A hide save still on the wire is answered after the page is gone too.
|
|
423
|
+
if (checkpoints.flushing && pinned) {
|
|
424
|
+
for (const [name, saved] of pinned) if (saved.mode === "fast" && saved.at) local?.landed(name, saved.at);
|
|
425
|
+
}
|
|
426
|
+
checkpoints.flush();
|
|
427
|
+
});
|
|
428
|
+
window.addEventListener("pageshow", () => {
|
|
429
|
+
closing = false;
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// For the other live modules: the store behind a name, or every open store.
|
|
433
|
+
export function storeOf(name) {
|
|
434
|
+
return entries.get(name)?.store || null;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function openStores() {
|
|
438
|
+
return [...entries.values()].map((entry) => entry.store);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// The fast lane, for tests and the local tab lane.
|
|
442
|
+
export function fastLane() {
|
|
443
|
+
return fast;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export default live;
|
|
@@ -999,7 +999,7 @@ export function prepareRevert(ids) {
|
|
|
999
999
|
seen.add(id);
|
|
1000
1000
|
const rec = conflicts.get(id);
|
|
1001
1001
|
if (!rec) continue;
|
|
1002
|
-
if (rec.claimedBy || rec.kind === "apply-incomplete" || !rec.recovery) blocked.set(id, BLOCKED);
|
|
1002
|
+
if (rec.claimedBy || rec.kind === "apply-incomplete" || rec.kind === "json" || !rec.recovery) blocked.set(id, BLOCKED);
|
|
1003
1003
|
else selected.push(rec);
|
|
1004
1004
|
}
|
|
1005
1005
|
if (conflicts.hasPendingApply()) {
|
package/src/sync/live-sync.js
CHANGED
|
@@ -39,7 +39,7 @@ import { HyperMorph, morph } from "../vendor/hyper-morph.vendor.js";
|
|
|
39
39
|
|
|
40
40
|
const { createIdentityStore } = HyperMorph;
|
|
41
41
|
import Mutation from "../lib/mutation.js";
|
|
42
|
-
import { NO_DIRTY_SELECTOR } from "../lib/region-policy.js";
|
|
42
|
+
import { NO_DIRTY_SELECTOR, LIVE_SELECTOR } from "../lib/region-policy.js";
|
|
43
43
|
import { EXTENSION_NODE_SELECTOR } from '../lib/extension-noise.js';
|
|
44
44
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
45
45
|
import { mergeTagRecognizers } from "./merge-tags.js";
|
|
@@ -52,9 +52,6 @@ import { enableAdminInputs } from '../core/admin-inputs.js';
|
|
|
52
52
|
import { enableAdminResources } from '../core/admin-resources.js';
|
|
53
53
|
import { presence } from './presence.js';
|
|
54
54
|
import { editingMarker } from './editing-marker.js';
|
|
55
|
-
// Side-effect import: the section-changed notice wires itself to
|
|
56
|
-
// `clay:sync-applied`, which this file is the only dispatcher of.
|
|
57
|
-
import './section-notice.js';
|
|
58
55
|
import { hostMeta, hostSupports } from '../core/host-meta.js';
|
|
59
56
|
import { EditingSender } from './editing-sender.js';
|
|
60
57
|
import { servedDocumentEtag } from '../core/host-attrs.js';
|
|
@@ -62,6 +59,7 @@ import { recordEtag, seedEtag, lastSeenEtag, conditionalSaves, representedEtag,
|
|
|
62
59
|
import { pageMaybeDirty, pauseGate, resumeGate, gateCaptureToken, gateClearIfUnchanged } from '../lib/dirty-gate.js';
|
|
63
60
|
import { gestureSeen } from '../lib/user-gesture.js';
|
|
64
61
|
import { SyncStream } from './stream.js';
|
|
62
|
+
import { streamOpened, streamClosed, streamEvent, LIVE_EVENTS } from '../live/stream-bridge.js';
|
|
65
63
|
import { conflicts, beginApply, completeApply, failApply } from './conflicts.js';
|
|
66
64
|
import { beginLineageCapture } from './conflict-footprints.js';
|
|
67
65
|
|
|
@@ -73,6 +71,7 @@ const SYNC_IGNORE_SELECTOR = [
|
|
|
73
71
|
'[editor-ui]', '[clay~="editor-ui"]', '[save-ignore]', '[snapshot-remove]',
|
|
74
72
|
'[no-snapshot]', '[no-save]', '[save-remove]', '[freeze] *', '[save-freeze] *',
|
|
75
73
|
'[clay~="no-save"]', '[clay~="no-snapshot"]', '[clay~="freeze"] *',
|
|
74
|
+
LIVE_SELECTOR,
|
|
76
75
|
EXTENSION_NODE_SELECTOR,
|
|
77
76
|
].join(',');
|
|
78
77
|
|
|
@@ -599,6 +598,7 @@ class LiveSync {
|
|
|
599
598
|
this.sse.close();
|
|
600
599
|
this.sse = null;
|
|
601
600
|
}
|
|
601
|
+
streamClosed();
|
|
602
602
|
|
|
603
603
|
// Nothing feeds the roster once the stream is gone, so leaving the stack up
|
|
604
604
|
// would show a list of people who may all have left.
|
|
@@ -798,6 +798,22 @@ class LiveSync {
|
|
|
798
798
|
if (this.onConnect) this.onConnect();
|
|
799
799
|
};
|
|
800
800
|
|
|
801
|
+
// clay.live's fast lane rides this stream: report it open and closed, and pass
|
|
802
|
+
// on its named events (stream-bridge.js).
|
|
803
|
+
this.sse.addEventListener('open', () => streamOpened({ clientId: this.clientId, lane: this.lane }));
|
|
804
|
+
this.sse.addEventListener('error', () => streamClosed());
|
|
805
|
+
for (const type of LIVE_EVENTS) {
|
|
806
|
+
this.sse.addEventListener(type, (event) => {
|
|
807
|
+
let data;
|
|
808
|
+
try {
|
|
809
|
+
data = JSON.parse(event.data);
|
|
810
|
+
} catch {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
streamEvent(type, data);
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
801
817
|
// The cursor frame is a NAMED SSE event, so it never reaches onmessage and
|
|
802
818
|
// never looks like data. Its `resync` flag says the server could not retain
|
|
803
819
|
// everything between where this client resumed and the baseline it is
|
|
@@ -1970,7 +1986,7 @@ class LiveSync {
|
|
|
1970
1986
|
// returns above without reaching here, so nothing can name an author for a
|
|
1971
1987
|
// change this tab never took. Null on every frame the host did not stamp.
|
|
1972
1988
|
document.dispatchEvent(new CustomEvent('clay:sync-applied', {
|
|
1973
|
-
detail: { seq, source: 'peer', by: by || null, report, unresolved: this.unresolvedConflicts, conflictIds }
|
|
1989
|
+
detail: { seq, source: 'peer', by: by || null, html, report, unresolved: this.unresolvedConflicts, conflictIds }
|
|
1974
1990
|
}));
|
|
1975
1991
|
} finally {
|
|
1976
1992
|
this._log('applyUpdate - morph complete, resuming mutations');
|
package/src/sync/presence.js
CHANGED
|
@@ -53,9 +53,9 @@ const LABEL_FONT = `500 11px/1.4 ${FONT_MONO}`;
|
|
|
53
53
|
// resets color-scheme and direction too, which are put back straight after.
|
|
54
54
|
const RESET = ['all:initial', 'color-scheme:inherit', 'direction:ltr', 'unicode-bidi:isolate'];
|
|
55
55
|
|
|
56
|
-
// The
|
|
57
|
-
//
|
|
58
|
-
//
|
|
56
|
+
// The fill of every square, under the participant's colour. One literal in both
|
|
57
|
+
// schemes, because every colour the shared person-face palette draws with was
|
|
58
|
+
// chosen against it.
|
|
59
59
|
const FACE = '#FFFCF6';
|
|
60
60
|
|
|
61
61
|
/** Every element this module creates, marked the same way. */
|
|
@@ -78,7 +78,6 @@ function normalize(data) {
|
|
|
78
78
|
people.push({
|
|
79
79
|
id: person.id,
|
|
80
80
|
name,
|
|
81
|
-
canEdit: person.canEdit === true,
|
|
82
81
|
you: person.you === true,
|
|
83
82
|
});
|
|
84
83
|
}
|
|
@@ -239,12 +238,7 @@ class Presence {
|
|
|
239
238
|
|
|
240
239
|
avatar(person) {
|
|
241
240
|
const color = personColor(person.id);
|
|
242
|
-
|
|
243
|
-
// reading it. The two are the same three colours inverted, so a stack of
|
|
244
|
-
// both reads as one set rather than as two kinds of thing.
|
|
245
|
-
const skin = person.canEdit
|
|
246
|
-
? [`background-color:${color}`, `color:${FACE}`, `border-color:${FACE}`]
|
|
247
|
-
: [`background-color:${FACE}`, `color:${color}`, `border-color:${color}`];
|
|
241
|
+
const skin = [`background-color:${FACE}`, `color:${color}`, `border-color:${color}`];
|
|
248
242
|
|
|
249
243
|
const el = runtimeRoot(make('div', [
|
|
250
244
|
...RESET,
|