@inkandswitch/patchwork-bootloader 0.3.0 → 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.
@@ -26,13 +26,15 @@ 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
 
32
34
  // Small adapters — bundled directly into the worker
33
- import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
35
+ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
34
36
  import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
35
- import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
37
+ import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
36
38
  import {
37
39
  initializeAutomergeRepoKeyhiveRustWithRepo,
38
40
  initKeyhiveWasm,
@@ -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;
@@ -54,6 +60,143 @@ declare const __KEYHIVE_SYNC_SERVER__: boolean;
54
60
 
55
61
  let debugging = false;
56
62
 
63
+ // Per-boot identity so a tab can detect a worker *restart*: a fresh instance
64
+ // means a new repo peerId + cold in-memory state, so the tab's docs must be
65
+ // re-subscribed. Sent in `hello` (on connect) and every `pong`.
66
+ const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
67
+ const WORKER_BOOT_TIME = Date.now();
68
+
69
+ // ── Forward console output + uncaught errors to the main thread ─────────
70
+ // The SharedWorker has its own console that's a pain to find (chrome://inspect
71
+ // → shared workers). Patch console.* and the global error handlers to also
72
+ // post back over every connected tab's control port, tagged [automerge-worker].
73
+
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
+
115
+ // Logs emitted before any tab has connected (e.g. during wasm boot) would
116
+ // otherwise be lost — buffer a bounded number and flush on first connect.
117
+ const preConnectBuffer: Array<{ level: string; args: string[] }> = [];
118
+ const MAX_BUFFER = 200;
119
+
120
+ function serializeArg(arg: any): string {
121
+ if (typeof arg === "string") return arg;
122
+ if (arg instanceof Error) return arg.stack || `${arg.name}: ${arg.message}`;
123
+ try {
124
+ return JSON.stringify(arg);
125
+ } catch {
126
+ return String(arg);
127
+ }
128
+ }
129
+
130
+ function forwardToMainThread(level: string, rawArgs: any[]) {
131
+ const args = rawArgs.map(serializeArg);
132
+ if (!controlPorts.size) {
133
+ if (preConnectBuffer.length < MAX_BUFFER) {
134
+ preConnectBuffer.push({ level, args });
135
+ }
136
+ return;
137
+ }
138
+ for (const port of controlPorts) {
139
+ try {
140
+ port.postMessage({ type: "console", level, args });
141
+ } catch {
142
+ // Port may be closing — ignore.
143
+ }
144
+ }
145
+ }
146
+
147
+ for (const level of ["log", "info", "warn", "error", "debug"] as const) {
148
+ const original = console[level].bind(console);
149
+ console[level] = (...args: any[]) => {
150
+ original(...args);
151
+ forwardToMainThread(level, args);
152
+ };
153
+ }
154
+
155
+ self.addEventListener("error", (event) => {
156
+ const e = event as ErrorEvent;
157
+ forwardToMainThread("error", [
158
+ `uncaught error: ${e.message}`,
159
+ e.error instanceof Error ? e.error.stack : undefined,
160
+ ]);
161
+ });
162
+
163
+ self.addEventListener("unhandledrejection", (event) => {
164
+ const reason = (event as PromiseRejectionEvent).reason;
165
+ forwardToMainThread("error", [
166
+ "unhandled rejection:",
167
+ reason instanceof Error ? reason.stack || reason.message : reason,
168
+ ]);
169
+ });
170
+
171
+ // Boot marker, buffered until the first tab connects. A new instance id means
172
+ // the worker restarted (fresh peerId + cold state).
173
+ console.warn(
174
+ `[lifecycle] ${new Date(WORKER_BOOT_TIME).toISOString()} automerge ` +
175
+ `SharedWorker started (instance ${WORKER_INSTANCE_ID})`
176
+ );
177
+
178
+ // ── Suspension watchdog ─────────────────────────────────────────────────
179
+ // A SharedWorker gets no lifecycle events, so infer freeze/suspend from timer
180
+ // drift. A large gap means keepalive pongs stalled and the server may have
181
+ // reaped us.
182
+ const WATCHDOG_TICK_MS = 5_000;
183
+ const WATCHDOG_GAP_FACTOR = 2;
184
+ let watchdogLast = Date.now();
185
+ setInterval(() => {
186
+ const now = Date.now();
187
+ const gap = now - watchdogLast;
188
+ watchdogLast = now;
189
+ if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
190
+ console.warn(
191
+ `[lifecycle] worker resumed after ~${Math.round(gap / 1000)}s gap ` +
192
+ `(timer expected every ${WATCHDOG_TICK_MS / 1000}s) — likely ` +
193
+ `suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
194
+ `during this window, so the sync server may have reaped us. at ` +
195
+ `${new Date(now).toISOString()}`
196
+ );
197
+ }
198
+ }, WATCHDOG_TICK_MS);
199
+
57
200
  // Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
58
201
  // to target keyhive.sync.automerge.org.
59
202
  const useKeyhiveSyncServer =
@@ -75,10 +218,18 @@ const SUBDUCTION_ENDPOINTS = [
75
218
  ];
76
219
  const RESOLVE_TIMEOUT_MS = 30_000;
77
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
+
78
229
  const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
79
230
 
80
231
  let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
81
- let classicSyncAdapter: WebSocketClientAdapter | null = null;
232
+ let classicSyncAdapter: WebSocketWorkerClientAdapter | null = null;
82
233
  let classicSyncConnectPromise: Promise<void> | null = null;
83
234
 
84
235
  async function connectClassicSyncNetwork(server: string): Promise<void> {
@@ -97,7 +248,7 @@ async function connectClassicSyncNetwork(server: string): Promise<void> {
97
248
  classicSyncConnectPromise = (async () => {
98
249
  const { repo } = await getRepoHive();
99
250
  if (!classicSyncAdapter) {
100
- classicSyncAdapter = new WebSocketClientAdapter(url);
251
+ classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
101
252
  repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
102
253
  }
103
254
  await classicSyncAdapter.whenReady();
@@ -151,9 +302,18 @@ function getRepoHive() {
151
302
 
152
303
  if (!useKeyhive) {
153
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);
154
314
 
155
315
  const repo = new Repo({
156
- storage: new IndexedDBStorageAdapter(),
316
+ storage: new IndexedDBWorkerStorageAdapter(),
157
317
  signer,
158
318
  peerId: ("automerge-worker-" +
159
319
  Math.random()
@@ -167,6 +327,8 @@ function getRepoHive() {
167
327
  });
168
328
 
169
329
  (self as any).repo = repo;
330
+ (self as any).syncIdentity = identity;
331
+ setupSyncStateBroadcast(repo, identity);
170
332
  log("repo constructed (no keyhive), waiting for network subsystem");
171
333
 
172
334
  repo.networkSubsystem.whenReady().then(() => {
@@ -181,7 +343,7 @@ function getRepoHive() {
181
343
  // ARK variant for talking to the keyhive-enabled subduction sync server.
182
344
  const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
183
345
  createRepo: (config) => new Repo(config),
184
- storage: new IndexedDBStorageAdapter(`${siteName}-keyhive`),
346
+ storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
185
347
  peerIdSuffix:
186
348
  `${siteName}-worker` + Math.random().toString(36).slice(2),
187
349
  automaticArchiveIngestion: true,
@@ -191,7 +353,7 @@ function getRepoHive() {
191
353
  // defaults to "subduction".
192
354
  ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
193
355
  repo: {
194
- storage: new IndexedDBStorageAdapter(),
356
+ storage: new IndexedDBWorkerStorageAdapter(),
195
357
  subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
196
358
  enableRemoteHeadsGossiping: true,
197
359
  },
@@ -199,6 +361,7 @@ function getRepoHive() {
199
361
 
200
362
  (self as any).repo = repo;
201
363
  (self as any).hive = hive;
364
+ setupSyncStateBroadcast(repo);
202
365
  log("repo constructed, waiting for network subsystem");
203
366
 
204
367
  // Don't block getRepoHive() on whenReady() — the network subsystem starts
@@ -225,6 +388,306 @@ function getRepoHive() {
225
388
  return repoHivePromise;
226
389
  }
227
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
+
228
691
  // ── Tab connections ────────────────────────────────────────────────────
229
692
 
230
693
  // Each tab connects with a control port (the SharedWorker connect port) and
@@ -339,6 +802,14 @@ function handleControlMessage(
339
802
  });
340
803
  }
341
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
+ }
342
813
  } else if (data?.type === "debug") {
343
814
  debugging = data.debug;
344
815
  log("automerge worker debugging enabled");
@@ -362,6 +833,13 @@ function handleControlMessage(
362
833
  });
363
834
  replyPort?.close();
364
835
  });
836
+ } else if (data?.type === "ping") {
837
+ // Heartbeat: reply so the tab can detect our death or restart.
838
+ controlPort.postMessage({
839
+ type: "pong",
840
+ id: data.id,
841
+ instanceId: WORKER_INSTANCE_ID,
842
+ });
365
843
  }
366
844
  }
367
845
 
@@ -376,10 +854,35 @@ self.addEventListener("connect", (event) => {
376
854
  // Fires when the owning page is destroyed. Browsers without the close
377
855
  // event fall back to the adapters' lazy useWeakRef cleanup.
378
856
  controlPort.addEventListener("close", () => {
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);
379
861
  void dropConnection(connection);
380
862
  });
381
863
 
382
864
  controlPort.start();
865
+
866
+ // Greet the tab with our per-boot instance id so it can detect a restart
867
+ // (a different id than last seen) even if no port "close" fired.
868
+ controlPort.postMessage({
869
+ type: "hello",
870
+ instanceId: WORKER_INSTANCE_ID,
871
+ bootTime: WORKER_BOOT_TIME,
872
+ });
873
+
874
+ // Start forwarding console output to this tab, and flush anything buffered
875
+ // while no tab was connected (e.g. boot-time logs) to the first arrival.
876
+ controlPorts.add(controlPort);
877
+ if (preConnectBuffer.length) {
878
+ for (const { level, args } of preConnectBuffer.splice(0)) {
879
+ try {
880
+ controlPort.postMessage({ type: "console", level, args });
881
+ } catch {
882
+ // Port may already be gone — ignore.
883
+ }
884
+ }
885
+ }
383
886
  });
384
887
 
385
888
  // ── Automerge URL resolution ───────────────────────────────────────────
@@ -555,7 +1058,7 @@ async function handleHandoffRequest(message: HandoffRequestMessage) {
555
1058
  id,
556
1059
  type: "response",
557
1060
  response: {
558
- status: 500,
1061
+ status: 557,
559
1062
  body,
560
1063
  headers: { "content-type": "text/plain" },
561
1064
  },
@@ -601,7 +1104,7 @@ async function handleHandoffRequest(message: HandoffRequestMessage) {
601
1104
  id,
602
1105
  type: "response",
603
1106
  response: {
604
- status: 500,
1107
+ status: 558,
605
1108
  body: String(error),
606
1109
  headers: { "content-type": "text/plain" },
607
1110
  },
package/src/externals.ts CHANGED
@@ -7,10 +7,9 @@ const externals = [
7
7
  "@automerge/automerge-repo",
8
8
  "@automerge/automerge-repo/slim",
9
9
  "@automerge/automerge-repo-network-messagechannel",
10
+ "@automerge/automerge-repo-network-websocket",
10
11
  "@automerge/automerge-repo-storage-indexeddb",
11
12
  "@automerge/automerge-repo-keyhive",
12
- "@automerge/automerge-repo-network-messagechannel",
13
- "@automerge/automerge-repo-storage-indexeddb",
14
13
  "@automerge/automerge-subduction",
15
14
  "@automerge/automerge-subduction/slim",
16
15
  "@keyhive/keyhive",