@panphora/clayjs 1.10.0 → 1.11.1
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 +7104 -4924
- package/entries/sap.js +6 -6
- 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,582 @@
|
|
|
1
|
+
// clay.live's local tab lane: on a host without live-data, tabs of one document
|
|
2
|
+
// in one browser share live stores at full speed. One tab, the sequencer (held
|
|
3
|
+
// with a Web Lock), runs the room; the others reach it over a BroadcastChannel.
|
|
4
|
+
// The transport interface is fast-lane.js's; the frames are the server lane's.
|
|
5
|
+
//
|
|
6
|
+
// Every tab keeps a mirror of the room: each store's value, the senders'
|
|
7
|
+
// receipts, and per store a log of moves with how far the file reaches, all
|
|
8
|
+
// advanced together by each data message in order. The room keeps one epoch
|
|
9
|
+
// across hand-offs: a sequencer that stops posts its whole room last
|
|
10
|
+
// ("closed"), and the next lock holder continues from it. BroadcastChannel
|
|
11
|
+
// keeps one sender's messages in order, so nothing the old sequencer posted can
|
|
12
|
+
// arrive after it. Without a closed message (a crash) no tab can know whether
|
|
13
|
+
// it missed the old room's last messages, so the successor starts a new epoch
|
|
14
|
+
// from its mirror: tabs ahead of it take the new room rather than diverge.
|
|
15
|
+
//
|
|
16
|
+
// A file frame merges like an outside save on the host: the frame's live-at
|
|
17
|
+
// stamp (or, without one, the last version known to be in the file) picks
|
|
18
|
+
// where in the log it starts, the moves after that go back on top, and the
|
|
19
|
+
// merge itself is logged as a diff so a later file stamped before it keeps it.
|
|
20
|
+
|
|
21
|
+
import { applyOps, applyOp, checkOp, cloneJson, validateOp, serializeLive, textHash, diffOps } from "./ops.js";
|
|
22
|
+
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
23
|
+
|
|
24
|
+
const VERSION = 4;
|
|
25
|
+
export const REQUEST_MS = 2000;
|
|
26
|
+
export const CLOSE_WAIT_MS = 500;
|
|
27
|
+
export const LOG_ENTRIES = 1024;
|
|
28
|
+
export const LOG_BYTES = 256 * 1024;
|
|
29
|
+
|
|
30
|
+
const encoder = new TextEncoder();
|
|
31
|
+
|
|
32
|
+
export function localLaneSupported() {
|
|
33
|
+
return typeof BroadcastChannel === "function" && typeof navigator !== "undefined" && typeof navigator.locks?.request === "function";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function randomId() {
|
|
37
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
38
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} options
|
|
43
|
+
* @param {() => Map<string, {value: any, ephemeral: boolean}>} options.seed -
|
|
44
|
+
* what this tab holds for each live tag, used for stores the room does not have yet
|
|
45
|
+
* @param {string} [options.key]
|
|
46
|
+
*/
|
|
47
|
+
export function localTransport({ seed, key = "clay-live:" + window.location.pathname }) {
|
|
48
|
+
const clientId = randomId();
|
|
49
|
+
let channel = null;
|
|
50
|
+
let handlers = null;
|
|
51
|
+
let room = null;
|
|
52
|
+
let stopped = false;
|
|
53
|
+
let releaseLock = null;
|
|
54
|
+
let lockRequested = false;
|
|
55
|
+
let awaitingClose = null;
|
|
56
|
+
let rid = 0;
|
|
57
|
+
// Bumped whenever the mirror is replaced or this tab starts a room: a state
|
|
58
|
+
// answer asked for before either is obsolete.
|
|
59
|
+
let gen = 0;
|
|
60
|
+
const waiting = new Map();
|
|
61
|
+
// This tab's posts still waiting: request id -> the sequencer they went to.
|
|
62
|
+
const ownPosts = new Map();
|
|
63
|
+
// Epochs this tab has moved past: their messages are ignored.
|
|
64
|
+
const retired = new Set();
|
|
65
|
+
// The newest file frame per store seen while not the sequencer, merged if
|
|
66
|
+
// this tab takes the room and the room has not merged it already.
|
|
67
|
+
const kept = new Map();
|
|
68
|
+
|
|
69
|
+
// The mirror. epoch and seq: the room position it has reached. sequencer: the
|
|
70
|
+
// tab whose room it follows; closedBy: whose closed state it took. seen:
|
|
71
|
+
// "name\nclientId" -> the newest n applied. stores: name -> { value,
|
|
72
|
+
// ephemeral, log: [{ seq, ops, bytes }], logBytes, last, landed (how far the
|
|
73
|
+
// file reaches, null when unknown), floor (the newest move the log dropped),
|
|
74
|
+
// behind (the file holds a version the log cannot rebuild from), file (the
|
|
75
|
+
// last file frame the room merged: { seq, hash }), inFile (seqs of logged
|
|
76
|
+
// merges whose edit the file holds) }. stale: a message was
|
|
77
|
+
// missed, so the mirror stopped advancing.
|
|
78
|
+
let mirror = { epoch: null, seq: 0, sequencer: null, closedBy: null, stale: false, seen: new Map(), stores: new Map() };
|
|
79
|
+
|
|
80
|
+
function post(message) {
|
|
81
|
+
channel?.postMessage({ v: VERSION, ...message });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Over either cap the oldest entries go, and a file older than them can no
|
|
85
|
+
// longer be merged (floor).
|
|
86
|
+
function pushLog(m, entry) {
|
|
87
|
+
entry.bytes = encoder.encode(JSON.stringify(entry.ops)).length;
|
|
88
|
+
m.log.push(entry);
|
|
89
|
+
m.logBytes += entry.bytes;
|
|
90
|
+
while (m.log.length > LOG_ENTRIES || m.logBytes > LOG_BYTES) {
|
|
91
|
+
const gone = m.log.shift();
|
|
92
|
+
m.logBytes -= gone.bytes;
|
|
93
|
+
m.floor = gone.seq;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// One move, applied to the mirror: value, receipt, log and position together.
|
|
98
|
+
function advance(data) {
|
|
99
|
+
const m = mirror.stores.get(data.name);
|
|
100
|
+
if (m) {
|
|
101
|
+
m.value = applyOps(cloneJson(m.value), data.ops);
|
|
102
|
+
m.last = data.seq;
|
|
103
|
+
if (!m.ephemeral) pushLog(m, { seq: data.seq, ops: data.ops });
|
|
104
|
+
}
|
|
105
|
+
mirror.seq = data.seq;
|
|
106
|
+
const nKey = data.name + "\n" + data.from;
|
|
107
|
+
if ((mirror.seen.get(nKey) ?? 0) < data.n) mirror.seen.set(nKey, data.n);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function receiptsFor(name) {
|
|
111
|
+
const out = {};
|
|
112
|
+
for (const [nKey, n] of mirror.seen) {
|
|
113
|
+
const cut = nKey.indexOf("\n");
|
|
114
|
+
if (nKey.slice(0, cut) === name) out[nKey.slice(cut + 1)] = n;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const landedIn = (m) => (m.behind || m.landed === null ? -1 : m.landed);
|
|
120
|
+
|
|
121
|
+
function roomState() {
|
|
122
|
+
const stores = {};
|
|
123
|
+
const meta = {};
|
|
124
|
+
for (const [name, m] of mirror.stores) {
|
|
125
|
+
stores[name] = { value: cloneJson(m.value), ephemeral: m.ephemeral, seen: receiptsFor(name), last: m.last, landed: landedIn(m) };
|
|
126
|
+
meta[name] = { log: m.log, last: m.last, landed: m.landed, floor: m.floor, behind: m.behind, file: m.file, inFile: m.inFile };
|
|
127
|
+
}
|
|
128
|
+
return { epoch: room.epoch, seq: room.seq, writer: clientId, stores, mirror: { seen: [...mirror.seen], stores: meta } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function alreadyMerged(m, frame) {
|
|
132
|
+
if (!m?.file) return false;
|
|
133
|
+
if (frame.seq != null && m.file.seq != null) return frame.seq <= m.file.seq;
|
|
134
|
+
return m.file.hash === textHash(serializeLive(HyperMorph.parseJsonRelaxed(frame.text)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function dropMerged() {
|
|
138
|
+
for (const [name, frame] of kept) {
|
|
139
|
+
try {
|
|
140
|
+
if (alreadyMerged(mirror.stores.get(name), frame)) kept.delete(name);
|
|
141
|
+
} catch {
|
|
142
|
+
kept.delete(name);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// A state from the sequencer replaces the mirror when it is newer than what
|
|
148
|
+
// this tab holds: a later point of this room, or a room this tab has not had.
|
|
149
|
+
function install(state) {
|
|
150
|
+
if (room || !state || typeof state.epoch !== "string" || !Number.isInteger(state.seq) || !state.mirror) return false;
|
|
151
|
+
if (retired.has(state.epoch)) return false;
|
|
152
|
+
if (state.epoch === mirror.epoch && state.seq < mirror.seq) return false;
|
|
153
|
+
if (mirror.epoch !== null && state.epoch !== mirror.epoch) retired.add(mirror.epoch);
|
|
154
|
+
const stores = new Map();
|
|
155
|
+
for (const [name, held] of Object.entries(state.stores || {})) {
|
|
156
|
+
const meta = state.mirror.stores?.[name] || {};
|
|
157
|
+
const log = Array.isArray(meta.log) ? meta.log : [];
|
|
158
|
+
stores.set(name, {
|
|
159
|
+
value: cloneJson(held.value),
|
|
160
|
+
ephemeral: held.ephemeral === true,
|
|
161
|
+
log,
|
|
162
|
+
logBytes: log.reduce((sum, entry) => sum + (entry.bytes || 0), 0),
|
|
163
|
+
last: Number.isInteger(meta.last) ? meta.last : 0,
|
|
164
|
+
landed: Number.isInteger(meta.landed) ? meta.landed : null,
|
|
165
|
+
floor: Number.isInteger(meta.floor) ? meta.floor : 0,
|
|
166
|
+
behind: meta.behind === true,
|
|
167
|
+
file: meta.file || null,
|
|
168
|
+
inFile: Array.isArray(meta.inFile) ? [...meta.inFile] : [],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
mirror = {
|
|
172
|
+
epoch: state.epoch,
|
|
173
|
+
seq: state.seq,
|
|
174
|
+
sequencer: state.writer ?? null,
|
|
175
|
+
closedBy: null,
|
|
176
|
+
stale: false,
|
|
177
|
+
seen: new Map(state.mirror.seen || []),
|
|
178
|
+
stores,
|
|
179
|
+
};
|
|
180
|
+
dropMerged();
|
|
181
|
+
gen += 1;
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function announce() {
|
|
186
|
+
const state = roomState();
|
|
187
|
+
post({ type: "data-state", state });
|
|
188
|
+
handlers.state(state);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A new epoch from the mirror's values: every saved store behind, so its next
|
|
192
|
+
// checkpoint writes the file.
|
|
193
|
+
function renewEpoch() {
|
|
194
|
+
if (mirror.epoch !== null) retired.add(mirror.epoch);
|
|
195
|
+
mirror.epoch = randomId();
|
|
196
|
+
mirror.seq = 0;
|
|
197
|
+
for (const m of mirror.stores.values()) {
|
|
198
|
+
m.log = [];
|
|
199
|
+
m.logBytes = 0;
|
|
200
|
+
m.last = 0;
|
|
201
|
+
m.landed = null;
|
|
202
|
+
m.floor = 0;
|
|
203
|
+
m.behind = true;
|
|
204
|
+
m.file = null;
|
|
205
|
+
m.inFile = [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// This tab's room, from the mirror. Same epoch when the mirror holds the old
|
|
210
|
+
// room's closed state; a new epoch otherwise. Saved files this tab kept merge
|
|
211
|
+
// first, while the old room's positions still place them. Stores the room
|
|
212
|
+
// never had take the page's values.
|
|
213
|
+
function startRoom({ fresh }) {
|
|
214
|
+
const first = mirror.epoch === null;
|
|
215
|
+
if (first) renewEpoch();
|
|
216
|
+
for (const [name, held] of seed()) {
|
|
217
|
+
if (mirror.stores.has(name)) continue;
|
|
218
|
+
mirror.stores.set(name, { value: cloneJson(held.value), ephemeral: held.ephemeral, log: [], logBytes: 0, last: 0, landed: 0, floor: 0, behind: false, file: null, inFile: [] });
|
|
219
|
+
}
|
|
220
|
+
mirror.stale = false;
|
|
221
|
+
mirror.sequencer = clientId;
|
|
222
|
+
mirror.closedBy = null;
|
|
223
|
+
const lastN = new Map();
|
|
224
|
+
for (const [nKey, n] of mirror.seen) lastN.set(nKey, { n, seq: 0 });
|
|
225
|
+
room = { epoch: mirror.epoch, seq: mirror.seq, lastN };
|
|
226
|
+
for (const [name, frame] of kept) {
|
|
227
|
+
const m = mirror.stores.get(name);
|
|
228
|
+
if (!m || m.ephemeral) continue;
|
|
229
|
+
try {
|
|
230
|
+
if (alreadyMerged(m, frame)) continue;
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
mergeFile(name, frame);
|
|
235
|
+
}
|
|
236
|
+
kept.clear();
|
|
237
|
+
if (fresh && !first) {
|
|
238
|
+
renewEpoch();
|
|
239
|
+
room.epoch = mirror.epoch;
|
|
240
|
+
room.seq = 0;
|
|
241
|
+
}
|
|
242
|
+
gen += 1;
|
|
243
|
+
announce();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Another sequencer ran this epoch while this tab held an older view of it (a
|
|
247
|
+
// hand-off it had not heard of yet). Neither room holds the other's moves, so
|
|
248
|
+
// this one starts a new epoch, which every tab then takes.
|
|
249
|
+
function forked() {
|
|
250
|
+
renewEpoch();
|
|
251
|
+
room.epoch = mirror.epoch;
|
|
252
|
+
room.seq = 0;
|
|
253
|
+
gen += 1;
|
|
254
|
+
announce();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// The sequencer leaves: its whole room goes out last, then the lock.
|
|
258
|
+
function closeRoom() {
|
|
259
|
+
if (!room) return;
|
|
260
|
+
post({ type: "closed", from: clientId, state: roomState() });
|
|
261
|
+
room = null;
|
|
262
|
+
releaseLock?.();
|
|
263
|
+
releaseLock = null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Resolves true once the closed state of sequencer (any, when null) is in.
|
|
267
|
+
function closedFor(sequencer) {
|
|
268
|
+
if (mirror.closedBy !== null && (sequencer === null || mirror.closedBy === sequencer)) return Promise.resolve(true);
|
|
269
|
+
return new Promise((resolve) => {
|
|
270
|
+
const timer = setTimeout(() => {
|
|
271
|
+
awaitingClose = null;
|
|
272
|
+
resolve(false);
|
|
273
|
+
}, CLOSE_WAIT_MS);
|
|
274
|
+
awaitingClose = {
|
|
275
|
+
sequencer,
|
|
276
|
+
resolve(ok) {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
awaitingClose = null;
|
|
279
|
+
resolve(ok);
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function requestLock() {
|
|
286
|
+
if (lockRequested || stopped) return;
|
|
287
|
+
lockRequested = true;
|
|
288
|
+
navigator.locks
|
|
289
|
+
.request(key, async () => {
|
|
290
|
+
if (stopped) return undefined;
|
|
291
|
+
const ordered = await closedFor(mirror.sequencer);
|
|
292
|
+
if (stopped) return undefined;
|
|
293
|
+
startRoom({ fresh: mirror.epoch === null || !ordered });
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
releaseLock = resolve;
|
|
296
|
+
});
|
|
297
|
+
})
|
|
298
|
+
.catch(() => {})
|
|
299
|
+
.finally(() => {
|
|
300
|
+
lockRequested = false;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function roomApply(from, frame) {
|
|
305
|
+
if (!frame || typeof frame.name !== "string" || !Number.isInteger(frame.n) || frame.n < 1 || !Array.isArray(frame.ops) || !frame.ops.length) {
|
|
306
|
+
return { status: 400, body: { error: "bad-frame" }, retryAfter: 0 };
|
|
307
|
+
}
|
|
308
|
+
const m = mirror.stores.get(frame.name);
|
|
309
|
+
if (!m) return { status: 404, body: { error: "unknown-store" }, retryAfter: 0 };
|
|
310
|
+
const nKey = frame.name + "\n" + from;
|
|
311
|
+
const last = room.lastN.get(nKey);
|
|
312
|
+
if (last && frame.n <= last.n) {
|
|
313
|
+
return { status: 200, body: { duplicate: true, seq: last.seq, epoch: room.epoch }, retryAfter: 0 };
|
|
314
|
+
}
|
|
315
|
+
let ops;
|
|
316
|
+
try {
|
|
317
|
+
ops = frame.ops.map(validateOp);
|
|
318
|
+
applyOps(cloneJson(m.value), ops);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
return { status: 422, body: { error: err.message }, retryAfter: 0 };
|
|
321
|
+
}
|
|
322
|
+
room.seq += 1;
|
|
323
|
+
room.lastN.set(nKey, { n: frame.n, seq: room.seq });
|
|
324
|
+
const data = { epoch: room.epoch, seq: room.seq, name: frame.name, ops, from, n: frame.n };
|
|
325
|
+
advance(data);
|
|
326
|
+
post({ type: "data", data });
|
|
327
|
+
handlers.frame(cloneJson(data));
|
|
328
|
+
return { status: 200, body: { seq: room.seq, epoch: room.epoch }, retryAfter: 0 };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// How far the file reaches moved for some stores: the sequencer's lane hears it.
|
|
332
|
+
function tellLanded(names) {
|
|
333
|
+
if (!room || !names.length) return;
|
|
334
|
+
const landed = {};
|
|
335
|
+
for (const name of names) {
|
|
336
|
+
const m = mirror.stores.get(name);
|
|
337
|
+
if (m) landed[name] = landedIn(m);
|
|
338
|
+
}
|
|
339
|
+
handlers.writer({ epoch: room.epoch, writer: clientId, landed });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// A save of store name holding the room's value at position at was accepted
|
|
343
|
+
// (or, for a hide or close save, sent): the file reaches at least that far.
|
|
344
|
+
function applyLanded(name, at) {
|
|
345
|
+
const m = mirror.stores.get(name);
|
|
346
|
+
if (!m || m.ephemeral || !at || at.epoch !== mirror.epoch || !Number.isInteger(at.seq)) return false;
|
|
347
|
+
if (!m.behind && m.landed !== null && at.seq <= m.landed) return false;
|
|
348
|
+
m.landed = at.seq;
|
|
349
|
+
m.behind = false;
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// The sequencer merges one file frame ({ text, at, seq }) into store name.
|
|
354
|
+
// The base is the stamp's seq when it is this room's, else the last version
|
|
355
|
+
// known to be in the file. A base the log no longer reaches leaves the value
|
|
356
|
+
// alone and marks the store behind (its next checkpoint writes the file over).
|
|
357
|
+
// Returns null when the frame does not apply, else { changed, moved }.
|
|
358
|
+
function mergeFile(name, { text, at, seq }) {
|
|
359
|
+
const m = mirror.stores.get(name);
|
|
360
|
+
if (!m || m.ephemeral) return null;
|
|
361
|
+
let value;
|
|
362
|
+
try {
|
|
363
|
+
value = cloneJson(HyperMorph.parseJsonRelaxed(text));
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
if (value === null || typeof value !== "object" || Array.isArray(value) !== Array.isArray(m.value)) return null;
|
|
368
|
+
const file = { seq: seq ?? null, hash: textHash(serializeLive(value)) };
|
|
369
|
+
// A stamp of this room places the file. Still the value its stamp names, it
|
|
370
|
+
// is a capture and holds no merge past the stamp; edited, or unstamped, it
|
|
371
|
+
// descends from the file before it, which holds the merges in inFile.
|
|
372
|
+
const placed = !!at && at.epoch === mirror.epoch && at.seq <= room.seq;
|
|
373
|
+
const stamped = placed && at.hash === file.hash;
|
|
374
|
+
const base = placed ? at.seq : m.behind ? null : m.landed;
|
|
375
|
+
if (base === null || base < m.floor) {
|
|
376
|
+
const moved = !m.behind;
|
|
377
|
+
m.behind = true;
|
|
378
|
+
m.file = file;
|
|
379
|
+
return { changed: false, moved };
|
|
380
|
+
}
|
|
381
|
+
const entries = m.log.filter((entry) => entry.seq > base && (stamped || !m.inFile.includes(entry.seq)));
|
|
382
|
+
if (stamped) m.inFile = [];
|
|
383
|
+
for (const entry of entries) {
|
|
384
|
+
for (const op of entry.ops) {
|
|
385
|
+
try {
|
|
386
|
+
checkOp(value, op);
|
|
387
|
+
value = applyOp(value, op);
|
|
388
|
+
} catch {}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
let changed = false;
|
|
392
|
+
if (serializeLive(value) !== serializeLive(m.value)) {
|
|
393
|
+
// Logged like a move, so a file stamped before this merge keeps it.
|
|
394
|
+
const ops = diffOps(m.value, value);
|
|
395
|
+
m.value = value;
|
|
396
|
+
room.seq += 1;
|
|
397
|
+
mirror.seq = room.seq;
|
|
398
|
+
m.last = room.seq;
|
|
399
|
+
pushLog(m, { seq: room.seq, ops });
|
|
400
|
+
m.inFile = [...m.inFile.filter((seq) => seq > m.floor), room.seq];
|
|
401
|
+
changed = true;
|
|
402
|
+
}
|
|
403
|
+
m.landed = entries.length ? base : m.last;
|
|
404
|
+
m.behind = false;
|
|
405
|
+
m.file = file;
|
|
406
|
+
return { changed, moved: true };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// A file frame (another tab's or device's save, or this browser's own
|
|
410
|
+
// checkpoint coming back), per saved store: { text, at, seq }. The sequencer
|
|
411
|
+
// merges it; every other tab keeps the newest, for the room it may take.
|
|
412
|
+
function onFileFrame(frames) {
|
|
413
|
+
let changed = false;
|
|
414
|
+
const moved = [];
|
|
415
|
+
for (const [name, frame] of frames) {
|
|
416
|
+
const m = mirror.stores.get(name);
|
|
417
|
+
if (!m || m.ephemeral) continue;
|
|
418
|
+
if (!room) {
|
|
419
|
+
kept.set(name, frame);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const result = mergeFile(name, frame);
|
|
423
|
+
if (!result) continue;
|
|
424
|
+
if (result.changed) changed = true;
|
|
425
|
+
else if (result.moved) moved.push(name);
|
|
426
|
+
}
|
|
427
|
+
if (changed) announce();
|
|
428
|
+
else tellLanded(moved);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function request(message) {
|
|
432
|
+
return new Promise((resolve) => {
|
|
433
|
+
const id = ++rid;
|
|
434
|
+
const timer = setTimeout(() => {
|
|
435
|
+
waiting.delete(id);
|
|
436
|
+
ownPosts.delete(id);
|
|
437
|
+
resolve(null);
|
|
438
|
+
}, REQUEST_MS);
|
|
439
|
+
if (message.type === "post") ownPosts.set(id, mirror.sequencer);
|
|
440
|
+
waiting.set(id, (result) => {
|
|
441
|
+
clearTimeout(timer);
|
|
442
|
+
waiting.delete(id);
|
|
443
|
+
ownPosts.delete(id);
|
|
444
|
+
resolve(result);
|
|
445
|
+
});
|
|
446
|
+
post({ ...message, from: clientId, rid: id });
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// A sequencer ignores every data message: the room has one sequencer, and an
|
|
451
|
+
// earlier one's messages all came before its closed state.
|
|
452
|
+
function onData(data) {
|
|
453
|
+
if (!data || typeof data.name !== "string" || typeof data.epoch !== "string" || !Number.isInteger(data.seq)) return;
|
|
454
|
+
if (room || retired.has(data.epoch)) return;
|
|
455
|
+
if (data.epoch !== mirror.epoch) {
|
|
456
|
+
mirror.stale = true;
|
|
457
|
+
} else if (!mirror.stale && data.seq === mirror.seq + 1) {
|
|
458
|
+
try {
|
|
459
|
+
advance(data);
|
|
460
|
+
} catch {
|
|
461
|
+
mirror.stale = true;
|
|
462
|
+
}
|
|
463
|
+
} else if (data.seq > mirror.seq) {
|
|
464
|
+
mirror.stale = true;
|
|
465
|
+
}
|
|
466
|
+
handlers.frame(data);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function onClosed(from, state) {
|
|
470
|
+
if (typeof from !== "string" || !state) return;
|
|
471
|
+
if (install(state)) {
|
|
472
|
+
mirror.closedBy = from;
|
|
473
|
+
handlers.state(state);
|
|
474
|
+
}
|
|
475
|
+
// Unanswered by a sequencer that has gone: the lane retries with the same n,
|
|
476
|
+
// and the room's receipts make that count once.
|
|
477
|
+
for (const [id, to] of ownPosts) if (to === from) waiting.get(id)?.(null);
|
|
478
|
+
if (awaitingClose && (awaitingClose.sequencer === null || awaitingClose.sequencer === from)) awaitingClose.resolve(mirror.closedBy === from);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function onMessage({ data: m }) {
|
|
482
|
+
if (stopped || !m || m.v !== VERSION) return;
|
|
483
|
+
if (room && ((m.type === "data" && m.data?.epoch === room.epoch) || ((m.type === "data-state" || m.type === "closed") && m.state?.epoch === room.epoch && m.state.writer !== clientId))) {
|
|
484
|
+
forked();
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
// A sequencer announcing this epoch behind where this tab's mirror is, and not
|
|
488
|
+
// the one it follows, missed a hand-off: it is told, and starts a new epoch.
|
|
489
|
+
if (!room && m.type === "data-state" && m.state?.epoch === mirror.epoch && m.state.writer !== mirror.sequencer && Number.isInteger(m.state.seq) && m.state.seq < mirror.seq) {
|
|
490
|
+
post({ type: "forked", epoch: mirror.epoch, to: m.state.writer });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (m.type === "forked") {
|
|
494
|
+
if (room && m.epoch === room.epoch && m.to === clientId) forked();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (m.type === "data") {
|
|
498
|
+
onData(m.data);
|
|
499
|
+
} else if (m.type === "data-state") {
|
|
500
|
+
if (install(m.state)) handlers.state(m.state);
|
|
501
|
+
} else if (m.type === "closed") {
|
|
502
|
+
onClosed(m.from, m.state);
|
|
503
|
+
} else if (m.type === "landed") {
|
|
504
|
+
if (typeof m.name === "string" && applyLanded(m.name, m.at)) tellLanded([m.name]);
|
|
505
|
+
} else if (m.type === "answer") {
|
|
506
|
+
if (m.to === clientId) waiting.get(m.rid)?.(m.result);
|
|
507
|
+
} else if (room && m.type === "post") {
|
|
508
|
+
if (m.epoch === room.epoch) post({ type: "answer", to: m.from, rid: m.rid, result: roomApply(m.from, m.frame) });
|
|
509
|
+
} else if (room && m.type === "state-request") {
|
|
510
|
+
post({ type: "answer", to: m.from, rid: m.rid, result: roomState() });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function onPageHide() {
|
|
515
|
+
closeRoom();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Back from the back/forward cache: take part again, and have the lane read
|
|
519
|
+
// the room this tab may have handed off.
|
|
520
|
+
function onPageShow(event) {
|
|
521
|
+
if (!event.persisted || stopped || room || lockRequested) return;
|
|
522
|
+
mirror.sequencer = null;
|
|
523
|
+
mirror.closedBy = null;
|
|
524
|
+
requestLock();
|
|
525
|
+
handlers.close();
|
|
526
|
+
handlers.open({ clientId, lane: "live" });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
clientId,
|
|
531
|
+
start(next) {
|
|
532
|
+
handlers = next;
|
|
533
|
+
channel = new BroadcastChannel(key);
|
|
534
|
+
channel.onmessage = onMessage;
|
|
535
|
+
window.addEventListener("pagehide", onPageHide);
|
|
536
|
+
window.addEventListener("pageshow", onPageShow);
|
|
537
|
+
requestLock();
|
|
538
|
+
handlers.open({ clientId, lane: "live" });
|
|
539
|
+
return () => {
|
|
540
|
+
closeRoom();
|
|
541
|
+
stopped = true;
|
|
542
|
+
awaitingClose?.resolve(false);
|
|
543
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
544
|
+
window.removeEventListener("pageshow", onPageShow);
|
|
545
|
+
for (const resolve of waiting.values()) resolve(null);
|
|
546
|
+
channel?.close();
|
|
547
|
+
channel = null;
|
|
548
|
+
};
|
|
549
|
+
},
|
|
550
|
+
async send(_clientId, frame) {
|
|
551
|
+
if (room) return roomApply(clientId, frame);
|
|
552
|
+
return (await request({ type: "post", frame, epoch: mirror.epoch })) || { status: 0, body: null, retryAfter: 0 };
|
|
553
|
+
},
|
|
554
|
+
// The sequencer's state, installed into the mirror first. An answer to a
|
|
555
|
+
// request made before the mirror changed (another state, or this tab
|
|
556
|
+
// starting a room) is dropped: the lane asks again or already has it.
|
|
557
|
+
async fetchState() {
|
|
558
|
+
if (room) return roomState();
|
|
559
|
+
const asked = gen;
|
|
560
|
+
const state = await request({ type: "state-request" });
|
|
561
|
+
if (room) return roomState();
|
|
562
|
+
if (asked !== gen || !install(state)) return null;
|
|
563
|
+
return state;
|
|
564
|
+
},
|
|
565
|
+
// A file frame arrived: Map<name, { text, at, seq }>, at being the tag's
|
|
566
|
+
// parsed live-at stamp or null, seq the stream's frame number or null.
|
|
567
|
+
fileFrame(frames) {
|
|
568
|
+
if (!stopped) onFileFrame(frames);
|
|
569
|
+
},
|
|
570
|
+
// This tab's save holding store name at position at ({ epoch, seq }) was
|
|
571
|
+
// accepted, or is a hide or close save on its way. Its own save never comes
|
|
572
|
+
// back as a frame, so every tab is told.
|
|
573
|
+
landed(name, at) {
|
|
574
|
+
if (stopped || !at) return;
|
|
575
|
+
if (applyLanded(name, at)) tellLanded([name]);
|
|
576
|
+
post({ type: "landed", name, at: { epoch: at.epoch, seq: at.seq } });
|
|
577
|
+
},
|
|
578
|
+
get isSequencer() {
|
|
579
|
+
return room !== null;
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
}
|