@inkandswitch/patchwork-bootloader 0.3.1 → 0.3.2
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/CHANGELOG.md +6 -0
- package/dist/automerge-worker.js +344 -1
- package/dist/setup.d.ts +4 -1
- package/dist/setup.js +43 -0
- package/dist/site.d.ts +6 -1
- package/dist/site.js +100 -29
- package/dist/types.d.ts +64 -0
- package/package.json +4 -4
- package/src/automerge-worker.ts +377 -0
- package/src/setup.ts +53 -0
- package/src/site.ts +117 -33
- package/src/types.ts +88 -0
package/src/automerge-worker.ts
CHANGED
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
stringifyAutomergeUrl,
|
|
27
27
|
type AutomergeUrl,
|
|
28
28
|
type DocHandle,
|
|
29
|
+
type DocumentId,
|
|
30
|
+
type UrlHeads,
|
|
29
31
|
} from "@automerge/automerge-repo/slim";
|
|
30
32
|
import { resolvePath } from "@inkandswitch/patchwork-filesystem";
|
|
31
33
|
|
|
@@ -41,11 +43,15 @@ import {
|
|
|
41
43
|
|
|
42
44
|
import {
|
|
43
45
|
HANDOFF_CHANNEL,
|
|
46
|
+
SYNCSTATE_CHANNEL,
|
|
44
47
|
type HandoffCachedMessage,
|
|
45
48
|
type HandoffOnlineMessage,
|
|
46
49
|
type HandoffRequest,
|
|
47
50
|
type HandoffRequestMessage,
|
|
48
51
|
type HandoffResponseMessage,
|
|
52
|
+
type SyncStateBroadcast,
|
|
53
|
+
type SyncStateDocMessage,
|
|
54
|
+
type SyncStateRequestMessage,
|
|
49
55
|
} from "./types.js";
|
|
50
56
|
|
|
51
57
|
declare const __SITE_NAME__: string;
|
|
@@ -66,6 +72,46 @@ const WORKER_BOOT_TIME = Date.now();
|
|
|
66
72
|
// post back over every connected tab's control port, tagged [automerge-worker].
|
|
67
73
|
|
|
68
74
|
const controlPorts = new Set<MessagePort>();
|
|
75
|
+
|
|
76
|
+
// ── Per-tab sync-state subscriptions ────────────────────────────────────
|
|
77
|
+
// Each tab's control port subscribes to the documents it cares about; we push
|
|
78
|
+
// only those docs' heads back down that port (addressed — tab A never sees tab
|
|
79
|
+
// B's docs), and drop a port's whole subscription set when it closes (the tab
|
|
80
|
+
// went away), so there's nothing to reference-count or time out. The global
|
|
81
|
+
// connection/whoami signals still go over SYNCSTATE_CHANNEL.
|
|
82
|
+
const syncWatchers = new Map<MessagePort, Set<string>>();
|
|
83
|
+
|
|
84
|
+
// Installed by setupSyncStateBroadcast once the repo's snapshot exists, so a
|
|
85
|
+
// fresh `sync-sub` can be replayed the doc's current heads immediately. Null
|
|
86
|
+
// until then; subscriptions taken during boot are replayed when it installs.
|
|
87
|
+
let replaySyncForPort:
|
|
88
|
+
| ((documentId: string, port: MessagePort) => void)
|
|
89
|
+
| null = null;
|
|
90
|
+
|
|
91
|
+
function syncSubscribe(port: MessagePort, documentId: string): void {
|
|
92
|
+
let docs = syncWatchers.get(port);
|
|
93
|
+
if (!docs) syncWatchers.set(port, (docs = new Set()));
|
|
94
|
+
if (docs.has(documentId)) return;
|
|
95
|
+
docs.add(documentId);
|
|
96
|
+
replaySyncForPort?.(documentId, port);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function syncUnsubscribe(port: MessagePort, documentId: string): void {
|
|
100
|
+
syncWatchers.get(port)?.delete(documentId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Push one document's heads to every control port currently watching it.
|
|
104
|
+
function pushSyncState(message: SyncStateDocMessage): void {
|
|
105
|
+
for (const [port, docs] of syncWatchers) {
|
|
106
|
+
if (!docs.has(message.documentId)) continue;
|
|
107
|
+
try {
|
|
108
|
+
port.postMessage(message);
|
|
109
|
+
} catch {
|
|
110
|
+
// Port already gone; its close handler will reap the entry.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
69
115
|
// Logs emitted before any tab has connected (e.g. during wasm boot) would
|
|
70
116
|
// otherwise be lost — buffer a bounded number and flush on first connect.
|
|
71
117
|
const preConnectBuffer: Array<{ level: string; args: string[] }> = [];
|
|
@@ -172,6 +218,14 @@ const SUBDUCTION_ENDPOINTS = [
|
|
|
172
218
|
];
|
|
173
219
|
const RESOLVE_TIMEOUT_MS = 30_000;
|
|
174
220
|
|
|
221
|
+
// Backoff re-sync of stuck/diverged docs. Only this worker is connected to the
|
|
222
|
+
// sync server, so it's the only place that can notice a doc whose heads have
|
|
223
|
+
// settled out of sync with the server and re-arm a sync round for it.
|
|
224
|
+
const RESYNC_GRACE_MS = 8_000; // must be *stably* diverged this long first
|
|
225
|
+
const RESYNC_INITIAL_DELAY_MS = 5_000; // first backoff cooldown after a resync
|
|
226
|
+
const RESYNC_MAX_DELAY_MS = 60_000; // backoff cap
|
|
227
|
+
const RESYNC_REVIEW_INTERVAL_MS = 5_000; // how often stuck docs are re-checked
|
|
228
|
+
|
|
175
229
|
const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
|
|
176
230
|
|
|
177
231
|
let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
|
|
@@ -248,6 +302,15 @@ function getRepoHive() {
|
|
|
248
302
|
|
|
249
303
|
if (!useKeyhive) {
|
|
250
304
|
const signer = await WebCryptoSigner.setup();
|
|
305
|
+
const identity = {
|
|
306
|
+
peerId: signer.peerId().toString(),
|
|
307
|
+
verifyingKey: (
|
|
308
|
+
signer.verifyingKey() as Uint8Array<ArrayBufferLike> & {
|
|
309
|
+
toHex(): string;
|
|
310
|
+
}
|
|
311
|
+
).toHex(),
|
|
312
|
+
};
|
|
313
|
+
console.log("[patchwork] shared-worker subduction identity:", identity);
|
|
251
314
|
|
|
252
315
|
const repo = new Repo({
|
|
253
316
|
storage: new IndexedDBWorkerStorageAdapter(),
|
|
@@ -264,6 +327,8 @@ function getRepoHive() {
|
|
|
264
327
|
});
|
|
265
328
|
|
|
266
329
|
(self as any).repo = repo;
|
|
330
|
+
(self as any).syncIdentity = identity;
|
|
331
|
+
setupSyncStateBroadcast(repo, identity);
|
|
267
332
|
log("repo constructed (no keyhive), waiting for network subsystem");
|
|
268
333
|
|
|
269
334
|
repo.networkSubsystem.whenReady().then(() => {
|
|
@@ -296,6 +361,7 @@ function getRepoHive() {
|
|
|
296
361
|
|
|
297
362
|
(self as any).repo = repo;
|
|
298
363
|
(self as any).hive = hive;
|
|
364
|
+
setupSyncStateBroadcast(repo);
|
|
299
365
|
log("repo constructed, waiting for network subsystem");
|
|
300
366
|
|
|
301
367
|
// Don't block getRepoHive() on whenReady() — the network subsystem starts
|
|
@@ -322,6 +388,306 @@ function getRepoHive() {
|
|
|
322
388
|
return repoHivePromise;
|
|
323
389
|
}
|
|
324
390
|
|
|
391
|
+
// ── Sync-state broadcast ───────────────────────────────────────────────
|
|
392
|
+
//
|
|
393
|
+
// Only this worker is directly connected to the sync server, so it's the only
|
|
394
|
+
// place that learns the server's heads (the repo's "subduction-remote-heads"
|
|
395
|
+
// event, keyed by each Subduction peer's verifying-key storageId) and whether
|
|
396
|
+
// the server link is up ("subduction-connection"). We rebroadcast both on
|
|
397
|
+
// SYNCSTATE_CHANNEL so every tab can render a sync indicator without holding
|
|
398
|
+
// its own server connection. A tab that opens mid-stream posts {type:"request"}
|
|
399
|
+
// to get the current snapshot replayed.
|
|
400
|
+
|
|
401
|
+
let syncStateWired = false;
|
|
402
|
+
|
|
403
|
+
function setupSyncStateBroadcast(
|
|
404
|
+
repo: Repo,
|
|
405
|
+
identity?: { peerId: string; verifyingKey: string }
|
|
406
|
+
): void {
|
|
407
|
+
if (syncStateWired) return;
|
|
408
|
+
syncStateWired = true;
|
|
409
|
+
|
|
410
|
+
const channel = new BroadcastChannel(SYNCSTATE_CHANNEL);
|
|
411
|
+
// documentId -> storageId (verifying key) -> last-known heads
|
|
412
|
+
const snapshot = new Map<
|
|
413
|
+
string,
|
|
414
|
+
Map<string, { heads: string[]; timestamp: number }>
|
|
415
|
+
>();
|
|
416
|
+
let connected = repo.isSubductionConnected();
|
|
417
|
+
// Directly-connected sync-server peer ids (verifying keys). Stable once
|
|
418
|
+
// known; tabs use this to judge "synced" against the server specifically.
|
|
419
|
+
let serverPeerIds: string[] = [];
|
|
420
|
+
|
|
421
|
+
const postWhoAmI = () => {
|
|
422
|
+
if (!identity) return;
|
|
423
|
+
channel.postMessage({
|
|
424
|
+
type: "whoami",
|
|
425
|
+
peerId: identity.peerId,
|
|
426
|
+
verifyingKey: identity.verifyingKey,
|
|
427
|
+
} satisfies SyncStateBroadcast);
|
|
428
|
+
};
|
|
429
|
+
// Announce our identity so tabs can label which peer rows are this worker.
|
|
430
|
+
postWhoAmI();
|
|
431
|
+
|
|
432
|
+
// Heads are addressed, not broadcast: push a doc's heads only to the control
|
|
433
|
+
// ports that subscribed to it (see syncWatchers / pushSyncState).
|
|
434
|
+
const postHeads = (
|
|
435
|
+
documentId: string,
|
|
436
|
+
storageId: string,
|
|
437
|
+
heads: string[],
|
|
438
|
+
timestamp: number
|
|
439
|
+
) =>
|
|
440
|
+
pushSyncState({
|
|
441
|
+
type: "sync-state",
|
|
442
|
+
documentId,
|
|
443
|
+
storageId,
|
|
444
|
+
heads,
|
|
445
|
+
timestamp,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Let a `sync-sub` (which may have arrived while the repo was still booting)
|
|
449
|
+
// replay this doc's current snapshot to the subscribing port immediately.
|
|
450
|
+
const replayDoc = (documentId: string, port: MessagePort) => {
|
|
451
|
+
const byStorage = snapshot.get(documentId);
|
|
452
|
+
if (!byStorage) return;
|
|
453
|
+
for (const [storageId, { heads, timestamp }] of byStorage) {
|
|
454
|
+
try {
|
|
455
|
+
port.postMessage({
|
|
456
|
+
type: "sync-state",
|
|
457
|
+
documentId,
|
|
458
|
+
storageId,
|
|
459
|
+
heads,
|
|
460
|
+
timestamp,
|
|
461
|
+
} satisfies SyncStateDocMessage);
|
|
462
|
+
} catch {
|
|
463
|
+
// Port gone; its close handler reaps it.
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
replaySyncForPort = replayDoc;
|
|
468
|
+
// Catch up any ports that subscribed before this wiring existed.
|
|
469
|
+
for (const [port, docs] of syncWatchers) {
|
|
470
|
+
for (const documentId of docs) replayDoc(documentId, port);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const postConnection = () =>
|
|
474
|
+
channel.postMessage({
|
|
475
|
+
type: "connection",
|
|
476
|
+
connected,
|
|
477
|
+
serverPeerIds,
|
|
478
|
+
} satisfies SyncStateBroadcast);
|
|
479
|
+
|
|
480
|
+
// Learn (and re-announce) which connected Subduction peer is the sync server.
|
|
481
|
+
// The peer list is empty until the handshake finishes, so retry briefly.
|
|
482
|
+
const refreshServerPeers = async () => {
|
|
483
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
484
|
+
try {
|
|
485
|
+
const ids = await repo.connectedSubductionPeerIds();
|
|
486
|
+
if (ids.length > 0) {
|
|
487
|
+
serverPeerIds = ids;
|
|
488
|
+
postConnection();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
} catch {
|
|
492
|
+
// repo has no subduction source / not ready yet
|
|
493
|
+
}
|
|
494
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// Advertise the worker's OWN heads for every doc it holds (keyed by our
|
|
499
|
+
// verifying key), so the worker hop is visible on every document.
|
|
500
|
+
//
|
|
501
|
+
// Docs pushed in by Subduction that this worker never explicitly opened don't
|
|
502
|
+
// surface via the repo's "document" event, so we discover them by re-scanning
|
|
503
|
+
// repo.handles (on a tick, and whenever the server reports a doc) and attach a
|
|
504
|
+
// heads-changed listener once per doc. No-op when there's no identity (keyhive
|
|
505
|
+
// path).
|
|
506
|
+
const ownTracked = new Set<string>();
|
|
507
|
+
const broadcastOwnHeads = (handle: {
|
|
508
|
+
documentId: string;
|
|
509
|
+
heads: () => string[];
|
|
510
|
+
}) => {
|
|
511
|
+
if (!identity) return;
|
|
512
|
+
const documentId = handle.documentId;
|
|
513
|
+
let heads: string[];
|
|
514
|
+
try {
|
|
515
|
+
heads = [...handle.heads()];
|
|
516
|
+
} catch {
|
|
517
|
+
return; // handle not ready yet
|
|
518
|
+
}
|
|
519
|
+
const timestamp = Date.now();
|
|
520
|
+
let byStorage = snapshot.get(documentId);
|
|
521
|
+
if (!byStorage) {
|
|
522
|
+
byStorage = new Map();
|
|
523
|
+
snapshot.set(documentId, byStorage);
|
|
524
|
+
}
|
|
525
|
+
byStorage.set(identity.peerId, { heads, timestamp });
|
|
526
|
+
postHeads(documentId, identity.peerId, heads, timestamp);
|
|
527
|
+
reviewResync(documentId);
|
|
528
|
+
};
|
|
529
|
+
const trackOwnHandle = (handle: {
|
|
530
|
+
documentId: string;
|
|
531
|
+
heads: () => string[];
|
|
532
|
+
on: (ev: "heads-changed", cb: () => void) => void;
|
|
533
|
+
}) => {
|
|
534
|
+
if (!identity || ownTracked.has(handle.documentId)) return;
|
|
535
|
+
ownTracked.add(handle.documentId);
|
|
536
|
+
handle.on("heads-changed", () => broadcastOwnHeads(handle));
|
|
537
|
+
broadcastOwnHeads(handle);
|
|
538
|
+
};
|
|
539
|
+
const scanOwnHandles = () => {
|
|
540
|
+
if (!identity) return;
|
|
541
|
+
for (const handle of Object.values(repo.handles)) {
|
|
542
|
+
trackOwnHandle(handle as never);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// ── Backoff re-sync of stuck/diverged docs ──────────────────────────
|
|
547
|
+
//
|
|
548
|
+
// Subduction sync is event-driven and only retries syncs it observed *fail*;
|
|
549
|
+
// a doc that settles missing commits the server holds — or whose heal retries
|
|
550
|
+
// were exhausted — is otherwise never retried. When we're behind and the
|
|
551
|
+
// server's advertised heads haven't advanced for a grace window (so it's
|
|
552
|
+
// genuinely stuck, not just lagging a live edit), we re-arm its sync round
|
|
553
|
+
// with per-doc exponential backoff. Convergence clears the state.
|
|
554
|
+
const serverHeadSetsFor = (documentId: string): string[][] => {
|
|
555
|
+
const byStorage = snapshot.get(documentId);
|
|
556
|
+
if (!byStorage) return [];
|
|
557
|
+
const sets: string[][] = [];
|
|
558
|
+
for (const [storageId, { heads }] of byStorage) {
|
|
559
|
+
if (serverPeerIds.includes(storageId)) sets.push(heads);
|
|
560
|
+
}
|
|
561
|
+
return sets;
|
|
562
|
+
};
|
|
563
|
+
const resyncState = new Map<
|
|
564
|
+
string,
|
|
565
|
+
{ serverSig: string; since: number; delay: number; lastResyncAt: number }
|
|
566
|
+
>();
|
|
567
|
+
// Inspectable from the SharedWorker console as `self.patchworkResync` to see
|
|
568
|
+
// whether/how often a doc is being re-synced and against which server heads.
|
|
569
|
+
const resyncDiag: { fires: number; byDoc: Record<string, unknown> } =
|
|
570
|
+
((self as any).patchworkResync ??= { fires: 0, byDoc: {} });
|
|
571
|
+
const reviewResync = (documentId: string) => {
|
|
572
|
+
if (!identity || !connected) {
|
|
573
|
+
resyncState.delete(documentId);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const handle = repo.handles[documentId as DocumentId];
|
|
577
|
+
if (!handle) return;
|
|
578
|
+
const serverSets = serverHeadSetsFor(documentId);
|
|
579
|
+
if (serverSets.length === 0) {
|
|
580
|
+
resyncState.delete(documentId); // no server signal to compare against
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// The server advertises subduction *sedimentree* heads (loose-commit +
|
|
584
|
+
// fragment-boundary commit ids), which are NOT the Automerge frontier — so
|
|
585
|
+
// never compare them to handle.heads() for equality. Instead ask whether we
|
|
586
|
+
// already hold every commit the server advertises (`DocHandle.containsHeads`).
|
|
587
|
+
// If we do, the server has nothing we're missing → caught up. If not, we're
|
|
588
|
+
// genuinely behind and a re-sync can pull the rest.
|
|
589
|
+
const serverHeadsUrl = [...new Set(serverSets.flat())] as UrlHeads;
|
|
590
|
+
let haveAll: boolean;
|
|
591
|
+
try {
|
|
592
|
+
haveAll = handle.containsHeads(serverHeadsUrl);
|
|
593
|
+
} catch {
|
|
594
|
+
return; // doc not ready, or an undecodable head
|
|
595
|
+
}
|
|
596
|
+
if (haveAll) {
|
|
597
|
+
resyncState.delete(documentId); // we hold everything the server has
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// Behind. "Stuck" = the server's advertised set hasn't advanced (no
|
|
601
|
+
// progress) for a while. Key the grace timer on the server heads only, so
|
|
602
|
+
// your own edits churning don't keep resetting it.
|
|
603
|
+
const serverSig = [...serverHeadsUrl].sort().join(",");
|
|
604
|
+
const now = Date.now();
|
|
605
|
+
const prev = resyncState.get(documentId);
|
|
606
|
+
if (!prev || prev.serverSig !== serverSig) {
|
|
607
|
+
// First sighting, or the server advanced its view (progress): restart.
|
|
608
|
+
resyncState.set(documentId, {
|
|
609
|
+
serverSig,
|
|
610
|
+
since: now,
|
|
611
|
+
delay: RESYNC_INITIAL_DELAY_MS,
|
|
612
|
+
lastResyncAt: 0,
|
|
613
|
+
});
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (now - prev.since < RESYNC_GRACE_MS) return; // not stuck long enough yet
|
|
617
|
+
if (now - prev.lastResyncAt < prev.delay) return; // within backoff cooldown
|
|
618
|
+
log("re-syncing behind doc", documentId, { serverSets });
|
|
619
|
+
resyncDiag.fires++;
|
|
620
|
+
resyncDiag.byDoc[documentId] = {
|
|
621
|
+
at: now,
|
|
622
|
+
count:
|
|
623
|
+
((resyncDiag.byDoc[documentId] as { count?: number } | undefined)
|
|
624
|
+
?.count ?? 0) + 1,
|
|
625
|
+
serverSets,
|
|
626
|
+
};
|
|
627
|
+
try {
|
|
628
|
+
repo.resyncSubduction(documentId as DocumentId);
|
|
629
|
+
} catch (e) {
|
|
630
|
+
log("resyncSubduction failed", e);
|
|
631
|
+
}
|
|
632
|
+
prev.lastResyncAt = now;
|
|
633
|
+
prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
|
|
634
|
+
};
|
|
635
|
+
const reviewAllResync = () => {
|
|
636
|
+
if (!identity) return;
|
|
637
|
+
for (const documentId of snapshot.keys()) reviewResync(documentId);
|
|
638
|
+
for (const id of [...resyncState.keys()]) {
|
|
639
|
+
if (!snapshot.has(id)) resyncState.delete(id);
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
repo.on(
|
|
644
|
+
"subduction-remote-heads",
|
|
645
|
+
({ documentId, storageId, heads, timestamp }) => {
|
|
646
|
+
const headsCopy = [...heads];
|
|
647
|
+
let byStorage = snapshot.get(documentId);
|
|
648
|
+
if (!byStorage) {
|
|
649
|
+
byStorage = new Map();
|
|
650
|
+
snapshot.set(documentId, byStorage);
|
|
651
|
+
}
|
|
652
|
+
byStorage.set(storageId, { heads: headsCopy, timestamp });
|
|
653
|
+
postHeads(documentId, storageId, headsCopy, timestamp);
|
|
654
|
+
// A doc the server reported is one we hold — make sure we're advertising
|
|
655
|
+
// our own heads for it too.
|
|
656
|
+
scanOwnHandles();
|
|
657
|
+
reviewResync(documentId);
|
|
658
|
+
}
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
repo.on("subduction-connection", ({ connected: isConnected }) => {
|
|
662
|
+
connected = isConnected;
|
|
663
|
+
postConnection();
|
|
664
|
+
if (isConnected) void refreshServerPeers();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// A BroadcastChannel never receives its own posts, so this only sees tabs'
|
|
668
|
+
// requests, never our own broadcasts. We replay just the global signals here;
|
|
669
|
+
// a late tab gets per-doc heads by subscribing (sync-sub), not from this.
|
|
670
|
+
channel.addEventListener("message", (event: MessageEvent) => {
|
|
671
|
+
const data = event.data as SyncStateRequestMessage;
|
|
672
|
+
if (data?.type !== "request") return;
|
|
673
|
+
postWhoAmI();
|
|
674
|
+
postConnection();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// In case we're already connected by the time this wires up.
|
|
678
|
+
void refreshServerPeers();
|
|
679
|
+
|
|
680
|
+
// Discover the worker's docs by re-scanning repo.handles initially and on a
|
|
681
|
+
// tick (Subduction-pushed docs don't surface via the "document" event).
|
|
682
|
+
scanOwnHandles();
|
|
683
|
+
if (identity) setInterval(scanOwnHandles, 3000);
|
|
684
|
+
|
|
685
|
+
// Drive the backoff re-sync of stuck/diverged docs. A tick is essential here:
|
|
686
|
+
// the "stuck" case is precisely when no head events are firing, so the
|
|
687
|
+
// grace/backoff timers can only advance on a timer.
|
|
688
|
+
if (identity) setInterval(reviewAllResync, RESYNC_REVIEW_INTERVAL_MS);
|
|
689
|
+
}
|
|
690
|
+
|
|
325
691
|
// ── Tab connections ────────────────────────────────────────────────────
|
|
326
692
|
|
|
327
693
|
// Each tab connects with a control port (the SharedWorker connect port) and
|
|
@@ -436,6 +802,14 @@ function handleControlMessage(
|
|
|
436
802
|
});
|
|
437
803
|
}
|
|
438
804
|
);
|
|
805
|
+
} else if (data?.type === "sync-sub") {
|
|
806
|
+
if (typeof data.documentId === "string") {
|
|
807
|
+
syncSubscribe(controlPort, data.documentId);
|
|
808
|
+
}
|
|
809
|
+
} else if (data?.type === "sync-unsub") {
|
|
810
|
+
if (typeof data.documentId === "string") {
|
|
811
|
+
syncUnsubscribe(controlPort, data.documentId);
|
|
812
|
+
}
|
|
439
813
|
} else if (data?.type === "debug") {
|
|
440
814
|
debugging = data.debug;
|
|
441
815
|
log("automerge worker debugging enabled");
|
|
@@ -481,6 +855,9 @@ self.addEventListener("connect", (event) => {
|
|
|
481
855
|
// event fall back to the adapters' lazy useWeakRef cleanup.
|
|
482
856
|
controlPort.addEventListener("close", () => {
|
|
483
857
|
controlPorts.delete(controlPort);
|
|
858
|
+
// The tab is gone — drop its sync subscriptions wholesale so we stop
|
|
859
|
+
// pushing it heads (no per-doc unsub needed, no leak).
|
|
860
|
+
syncWatchers.delete(controlPort);
|
|
484
861
|
void dropConnection(connection);
|
|
485
862
|
});
|
|
486
863
|
|
package/src/setup.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ServiceWorkerRepoChannelListener,
|
|
3
3
|
SetupServiceWorkerOptions,
|
|
4
4
|
SetupServiceWorkerResult,
|
|
5
|
+
SyncStateDocMessage,
|
|
5
6
|
} from "./types.js";
|
|
6
7
|
import {
|
|
7
8
|
readClassicSyncServer,
|
|
@@ -107,6 +108,10 @@ export function getAutomergeWorker(): SharedWorker {
|
|
|
107
108
|
// Surface the SharedWorker's console output and uncaught errors in this
|
|
108
109
|
// tab's console (it has its own console that's awkward to find otherwise).
|
|
109
110
|
automergeWorker.port.addEventListener("message", (event: MessageEvent) => {
|
|
111
|
+
if (event.data?.type === "sync-state") {
|
|
112
|
+
dispatchSyncState(event.data as SyncStateDocMessage);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
110
115
|
if (event.data?.type !== "console") return;
|
|
111
116
|
const { level, args } = event.data;
|
|
112
117
|
// Gate forwarded [lifecycle] logs on the toggle too.
|
|
@@ -205,6 +210,53 @@ function installWorkerDeathDetection(worker: SharedWorker): void {
|
|
|
205
210
|
}, HEARTBEAT_MS);
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
// ── Sync-state subscriptions ────────────────────────────────────────────
|
|
214
|
+
// The automerge worker pushes per-document heads only to the tabs that ask for
|
|
215
|
+
// them (see SyncStateDocMessage). We ref-count locally so several callers in
|
|
216
|
+
// this tab can watch the same doc with a single worker subscription, and tear
|
|
217
|
+
// the worker subscription down when the last local watcher drops.
|
|
218
|
+
type SyncStateListener = (update: SyncStateDocMessage) => void;
|
|
219
|
+
const syncStateListeners = new Map<string, Set<SyncStateListener>>();
|
|
220
|
+
|
|
221
|
+
function dispatchSyncState(update: SyncStateDocMessage): void {
|
|
222
|
+
const listeners = syncStateListeners.get(update.documentId);
|
|
223
|
+
if (!listeners) return;
|
|
224
|
+
for (const listener of listeners) {
|
|
225
|
+
try {
|
|
226
|
+
listener(update);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
console.error("sync-state listener threw", err);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function subscribeSyncState(
|
|
234
|
+
documentId: string,
|
|
235
|
+
listener: SyncStateListener
|
|
236
|
+
): () => void {
|
|
237
|
+
const worker = getAutomergeWorker();
|
|
238
|
+
let listeners = syncStateListeners.get(documentId);
|
|
239
|
+
if (!listeners) {
|
|
240
|
+
syncStateListeners.set(documentId, (listeners = new Set()));
|
|
241
|
+
// First local watcher for this doc — ask the worker to start pushing it.
|
|
242
|
+
worker.port.postMessage({ type: "sync-sub", documentId });
|
|
243
|
+
}
|
|
244
|
+
listeners.add(listener);
|
|
245
|
+
|
|
246
|
+
let active = true;
|
|
247
|
+
return () => {
|
|
248
|
+
if (!active) return; // idempotent
|
|
249
|
+
active = false;
|
|
250
|
+
const set = syncStateListeners.get(documentId);
|
|
251
|
+
if (!set) return;
|
|
252
|
+
set.delete(listener);
|
|
253
|
+
if (set.size === 0) {
|
|
254
|
+
syncStateListeners.delete(documentId);
|
|
255
|
+
worker.port.postMessage({ type: "sync-unsub", documentId });
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
208
260
|
export function connectClassicSync(
|
|
209
261
|
server: string = readClassicSyncServer()
|
|
210
262
|
): Promise<void> {
|
|
@@ -367,6 +419,7 @@ export default async function setupServiceWorker(
|
|
|
367
419
|
shared,
|
|
368
420
|
connectClassicSync,
|
|
369
421
|
getRepoChannel,
|
|
422
|
+
subscribeSyncState,
|
|
370
423
|
async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
|
|
371
424
|
// The automerge worker outlives the page, so unlike the old in-service-
|
|
372
425
|
// worker repo there's nothing to reconnect: one port, handed over once.
|