@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.
@@ -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
+ }