@inkandswitch/patchwork-bootloader 0.3.1 → 0.4.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.
@@ -18,14 +18,18 @@ import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
18
18
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
19
19
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
20
20
  import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
21
+ import { makePortProvider } from "@automerge/automerge-repo/worker-port";
21
22
 
22
23
  import {
23
24
  Repo,
25
+ WorkerWebSocketEndpoint,
24
26
  isValidAutomergeUrl,
25
27
  parseAutomergeUrl,
26
28
  stringifyAutomergeUrl,
27
29
  type AutomergeUrl,
28
30
  type DocHandle,
31
+ type DocumentId,
32
+ type UrlHeads,
29
33
  } from "@automerge/automerge-repo/slim";
30
34
  import { resolvePath } from "@inkandswitch/patchwork-filesystem";
31
35
 
@@ -41,11 +45,15 @@ import {
41
45
 
42
46
  import {
43
47
  HANDOFF_CHANNEL,
48
+ SYNCSTATE_CHANNEL,
44
49
  type HandoffCachedMessage,
45
50
  type HandoffOnlineMessage,
46
51
  type HandoffRequest,
47
52
  type HandoffRequestMessage,
48
53
  type HandoffResponseMessage,
54
+ type SyncStateBroadcast,
55
+ type SyncStateDocMessage,
56
+ type SyncStateRequestMessage,
49
57
  } from "./types.js";
50
58
 
51
59
  declare const __SITE_NAME__: string;
@@ -66,6 +74,75 @@ const WORKER_BOOT_TIME = Date.now();
66
74
  // post back over every connected tab's control port, tagged [automerge-worker].
67
75
 
68
76
  const controlPorts = new Set<MessagePort>();
77
+
78
+ // ── Keepalive-drift probe (bench instrumentation) ───────────────────────
79
+ // Measures how late a 1s timer fires on this thread — i.e. how late an
80
+ // in-thread keepalive would be under sync/wasm load. Cheap (one Date.now()
81
+ // per second); samples are batched to every connected tab as drift-samples
82
+ // messages, which setup.ts accumulates on window.__driftSamples for the
83
+ // Playwright bench (e2e/tests/bench-ws.spec.ts).
84
+ const DRIFT_INTERVAL_MS = 1_000;
85
+ const DRIFT_BATCH_SIZE = 5;
86
+ {
87
+ let expected = Date.now() + DRIFT_INTERVAL_MS;
88
+ let batch: number[] = [];
89
+ setInterval(() => {
90
+ const now = Date.now();
91
+ batch.push(Math.max(0, now - expected));
92
+ expected = now + DRIFT_INTERVAL_MS;
93
+ if (batch.length >= DRIFT_BATCH_SIZE) {
94
+ const samples = batch;
95
+ batch = [];
96
+ for (const port of controlPorts) {
97
+ try {
98
+ port.postMessage({ type: "drift-samples", samples });
99
+ } catch {
100
+ // Port torn down mid-iteration — its close handler cleans up.
101
+ }
102
+ }
103
+ }
104
+ }, DRIFT_INTERVAL_MS);
105
+ }
106
+
107
+ // ── Per-tab sync-state subscriptions ────────────────────────────────────
108
+ // Each tab's control port subscribes to the documents it cares about; we push
109
+ // only those docs' heads back down that port (addressed — tab A never sees tab
110
+ // B's docs), and drop a port's whole subscription set when it closes (the tab
111
+ // went away), so there's nothing to reference-count or time out. The global
112
+ // connection/whoami signals still go over SYNCSTATE_CHANNEL.
113
+ const syncWatchers = new Map<MessagePort, Set<string>>();
114
+
115
+ // Installed by setupSyncStateBroadcast once the repo's snapshot exists, so a
116
+ // fresh `sync-sub` can be replayed the doc's current heads immediately. Null
117
+ // until then; subscriptions taken during boot are replayed when it installs.
118
+ let replaySyncForPort:
119
+ | ((documentId: string, port: MessagePort) => void)
120
+ | null = null;
121
+
122
+ function syncSubscribe(port: MessagePort, documentId: string): void {
123
+ let docs = syncWatchers.get(port);
124
+ if (!docs) syncWatchers.set(port, (docs = new Set()));
125
+ if (docs.has(documentId)) return;
126
+ docs.add(documentId);
127
+ replaySyncForPort?.(documentId, port);
128
+ }
129
+
130
+ function syncUnsubscribe(port: MessagePort, documentId: string): void {
131
+ syncWatchers.get(port)?.delete(documentId);
132
+ }
133
+
134
+ // Push one document's heads to every control port currently watching it.
135
+ function pushSyncState(message: SyncStateDocMessage): void {
136
+ for (const [port, docs] of syncWatchers) {
137
+ if (!docs.has(message.documentId)) continue;
138
+ try {
139
+ port.postMessage(message);
140
+ } catch {
141
+ // Port already gone; its close handler will reap the entry.
142
+ }
143
+ }
144
+ }
145
+
69
146
  // Logs emitted before any tab has connected (e.g. during wasm boot) would
70
147
  // otherwise be lost — buffer a bounded number and flush on first connect.
71
148
  const preConnectBuffer: Array<{ level: string; args: string[] }> = [];
@@ -165,13 +242,65 @@ if (useKeyhiveSyncServer) {
165
242
  };
166
243
  }
167
244
 
168
- const SUBDUCTION_ENDPOINTS = [
169
- useKeyhiveSyncServer
170
- ? "wss://keyhive.sync.automerge.org"
171
- : "wss://subduction.sync.inkandswitch.com",
172
- ];
245
+ const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
246
+ ? "wss://keyhive.sync.automerge.org"
247
+ : "wss://subduction.sync.inkandswitch.com";
248
+
249
+ // The subduction WebSocket lives in its own worker so socket I/O (and
250
+ // keepalive pongs) keep flowing even when this SharedWorker's thread is busy
251
+ // syncing. We can't spawn that worker ourselves — Chrome doesn't expose the
252
+ // Worker constructor inside SharedWorkerGlobalScope — so tabs spawn the
253
+ // shipped SharedWorker proxy entry and donate its port to us (donatePort in
254
+ // setup.ts). The provider hands WorkerWebSocketEndpoint whichever port is
255
+ // current, healing across late arrival and proxy-worker restarts.
256
+ const subductionPortProvider = makePortProvider();
257
+
258
+ // A/B bench toggle: the tab appends ?ws-mode=inline to our URL (SharedWorker
259
+ // scope has no localStorage; see getAutomergeWorker in setup.ts). "inline"
260
+ // passes the bare URL string so the socket lives on this thread — the
261
+ // pre-worker behaviour — as the control arm for benchmarking the
262
+ // worker-based endpoint. Default: "worker".
263
+ const WS_MODE =
264
+ new URL(self.location.href).searchParams.get("ws-mode") === "inline"
265
+ ? "inline"
266
+ : "worker";
267
+
268
+ // Optional windowFrames override (bench knob — max un-acked frames the io
269
+ // proxy delivers before pausing; endpoint default is 128).
270
+ const WS_WINDOW_FRAMES =
271
+ Number(new URL(self.location.href).searchParams.get("ws-window")) ||
272
+ undefined;
273
+
274
+ // Memoized so a repo-construction retry (getRepoHive clears its promise on
275
+ // failure) reuses the same endpoint instead of leaking one per attempt.
276
+ let subductionEndpoints: (WorkerWebSocketEndpoint | string)[] | null = null;
277
+ function getSubductionEndpoints(): (WorkerWebSocketEndpoint | string)[] {
278
+ if (!subductionEndpoints) {
279
+ log(`subduction websocket mode: ${WS_MODE}`);
280
+ subductionEndpoints =
281
+ WS_MODE === "inline"
282
+ ? [SUBDUCTION_SYNC_URL]
283
+ : [
284
+ new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
285
+ worker: subductionPortProvider.source,
286
+ ...(WS_WINDOW_FRAMES
287
+ ? { windowFrames: WS_WINDOW_FRAMES }
288
+ : {}),
289
+ }),
290
+ ];
291
+ }
292
+ return subductionEndpoints;
293
+ }
173
294
  const RESOLVE_TIMEOUT_MS = 30_000;
174
295
 
296
+ // Backoff re-sync of stuck/diverged docs. Only this worker is connected to the
297
+ // sync server, so it's the only place that can notice a doc whose heads have
298
+ // settled out of sync with the server and re-arm a sync round for it.
299
+ const RESYNC_GRACE_MS = 8_000; // must be *stably* diverged this long first
300
+ const RESYNC_INITIAL_DELAY_MS = 5_000; // first backoff cooldown after a resync
301
+ const RESYNC_MAX_DELAY_MS = 60_000; // backoff cap
302
+ const RESYNC_REVIEW_INTERVAL_MS = 5_000; // how often stuck docs are re-checked
303
+
175
304
  const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
176
305
 
177
306
  let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
@@ -248,7 +377,14 @@ function getRepoHive() {
248
377
 
249
378
  if (!useKeyhive) {
250
379
  const signer = await WebCryptoSigner.setup();
251
-
380
+ const identity = {
381
+ peerId: signer.peerId().toString(),
382
+ verifyingKey: (
383
+ signer.verifyingKey() as Uint8Array<ArrayBufferLike> & {
384
+ toHex(): string;
385
+ }
386
+ ).toHex(),
387
+ };
252
388
  const repo = new Repo({
253
389
  storage: new IndexedDBWorkerStorageAdapter(),
254
390
  signer,
@@ -260,10 +396,19 @@ function getRepoHive() {
260
396
  return peerId.includes("storage-server");
261
397
  },
262
398
  enableRemoteHeadsGossiping: true,
263
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
399
+ subductionWebsocketEndpoints: getSubductionEndpoints(),
264
400
  });
265
401
 
402
+ console.log(
403
+ "[patchwork] shared-worker subduction identity:",
404
+ identity,
405
+ "networkSubsystem.adapters:",
406
+ repo.networkSubsystem.adapters.length
407
+ );
408
+
266
409
  (self as any).repo = repo;
410
+ (self as any).syncIdentity = identity;
411
+ setupSyncStateBroadcast(repo, identity);
267
412
  log("repo constructed (no keyhive), waiting for network subsystem");
268
413
 
269
414
  repo.networkSubsystem.whenReady().then(() => {
@@ -289,13 +434,14 @@ function getRepoHive() {
289
434
  ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
290
435
  repo: {
291
436
  storage: new IndexedDBWorkerStorageAdapter(),
292
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
437
+ subductionWebsocketEndpoints: getSubductionEndpoints(),
293
438
  enableRemoteHeadsGossiping: true,
294
439
  },
295
440
  });
296
441
 
297
442
  (self as any).repo = repo;
298
443
  (self as any).hive = hive;
444
+ setupSyncStateBroadcast(repo);
299
445
  log("repo constructed, waiting for network subsystem");
300
446
 
301
447
  // Don't block getRepoHive() on whenReady() — the network subsystem starts
@@ -322,6 +468,306 @@ function getRepoHive() {
322
468
  return repoHivePromise;
323
469
  }
324
470
 
471
+ // ── Sync-state broadcast ───────────────────────────────────────────────
472
+ //
473
+ // Only this worker is directly connected to the sync server, so it's the only
474
+ // place that learns the server's heads (the repo's "subduction-remote-heads"
475
+ // event, keyed by each Subduction peer's verifying-key storageId) and whether
476
+ // the server link is up ("subduction-connection"). We rebroadcast both on
477
+ // SYNCSTATE_CHANNEL so every tab can render a sync indicator without holding
478
+ // its own server connection. A tab that opens mid-stream posts {type:"request"}
479
+ // to get the current snapshot replayed.
480
+
481
+ let syncStateWired = false;
482
+
483
+ function setupSyncStateBroadcast(
484
+ repo: Repo,
485
+ identity?: { peerId: string; verifyingKey: string }
486
+ ): void {
487
+ if (syncStateWired) return;
488
+ syncStateWired = true;
489
+
490
+ const channel = new BroadcastChannel(SYNCSTATE_CHANNEL);
491
+ // documentId -> storageId (verifying key) -> last-known heads
492
+ const snapshot = new Map<
493
+ string,
494
+ Map<string, { heads: string[]; timestamp: number }>
495
+ >();
496
+ let connected = repo.isSubductionConnected();
497
+ // Directly-connected sync-server peer ids (verifying keys). Stable once
498
+ // known; tabs use this to judge "synced" against the server specifically.
499
+ let serverPeerIds: string[] = [];
500
+
501
+ const postWhoAmI = () => {
502
+ if (!identity) return;
503
+ channel.postMessage({
504
+ type: "whoami",
505
+ peerId: identity.peerId,
506
+ verifyingKey: identity.verifyingKey,
507
+ } satisfies SyncStateBroadcast);
508
+ };
509
+ // Announce our identity so tabs can label which peer rows are this worker.
510
+ postWhoAmI();
511
+
512
+ // Heads are addressed, not broadcast: push a doc's heads only to the control
513
+ // ports that subscribed to it (see syncWatchers / pushSyncState).
514
+ const postHeads = (
515
+ documentId: string,
516
+ storageId: string,
517
+ heads: string[],
518
+ timestamp: number
519
+ ) =>
520
+ pushSyncState({
521
+ type: "sync-state",
522
+ documentId,
523
+ storageId,
524
+ heads,
525
+ timestamp,
526
+ });
527
+
528
+ // Let a `sync-sub` (which may have arrived while the repo was still booting)
529
+ // replay this doc's current snapshot to the subscribing port immediately.
530
+ const replayDoc = (documentId: string, port: MessagePort) => {
531
+ const byStorage = snapshot.get(documentId);
532
+ if (!byStorage) return;
533
+ for (const [storageId, { heads, timestamp }] of byStorage) {
534
+ try {
535
+ port.postMessage({
536
+ type: "sync-state",
537
+ documentId,
538
+ storageId,
539
+ heads,
540
+ timestamp,
541
+ } satisfies SyncStateDocMessage);
542
+ } catch {
543
+ // Port gone; its close handler reaps it.
544
+ }
545
+ }
546
+ };
547
+ replaySyncForPort = replayDoc;
548
+ // Catch up any ports that subscribed before this wiring existed.
549
+ for (const [port, docs] of syncWatchers) {
550
+ for (const documentId of docs) replayDoc(documentId, port);
551
+ }
552
+
553
+ const postConnection = () =>
554
+ channel.postMessage({
555
+ type: "connection",
556
+ connected,
557
+ serverPeerIds,
558
+ } satisfies SyncStateBroadcast);
559
+
560
+ // Learn (and re-announce) which connected Subduction peer is the sync server.
561
+ // The peer list is empty until the handshake finishes, so retry briefly.
562
+ const refreshServerPeers = async () => {
563
+ for (let attempt = 0; attempt < 6; attempt++) {
564
+ try {
565
+ const ids = await repo.connectedSubductionPeerIds();
566
+ if (ids.length > 0) {
567
+ serverPeerIds = ids;
568
+ postConnection();
569
+ return;
570
+ }
571
+ } catch {
572
+ // repo has no subduction source / not ready yet
573
+ }
574
+ await new Promise((r) => setTimeout(r, 500));
575
+ }
576
+ };
577
+
578
+ // Advertise the worker's OWN heads for every doc it holds (keyed by our
579
+ // verifying key), so the worker hop is visible on every document.
580
+ //
581
+ // Docs pushed in by Subduction that this worker never explicitly opened don't
582
+ // surface via the repo's "document" event, so we discover them by re-scanning
583
+ // repo.handles (on a tick, and whenever the server reports a doc) and attach a
584
+ // heads-changed listener once per doc. No-op when there's no identity (keyhive
585
+ // path).
586
+ const ownTracked = new Set<string>();
587
+ const broadcastOwnHeads = (handle: {
588
+ documentId: string;
589
+ heads: () => string[];
590
+ }) => {
591
+ if (!identity) return;
592
+ const documentId = handle.documentId;
593
+ let heads: string[];
594
+ try {
595
+ heads = [...handle.heads()];
596
+ } catch {
597
+ return; // handle not ready yet
598
+ }
599
+ const timestamp = Date.now();
600
+ let byStorage = snapshot.get(documentId);
601
+ if (!byStorage) {
602
+ byStorage = new Map();
603
+ snapshot.set(documentId, byStorage);
604
+ }
605
+ byStorage.set(identity.peerId, { heads, timestamp });
606
+ postHeads(documentId, identity.peerId, heads, timestamp);
607
+ reviewResync(documentId);
608
+ };
609
+ const trackOwnHandle = (handle: {
610
+ documentId: string;
611
+ heads: () => string[];
612
+ on: (ev: "heads-changed", cb: () => void) => void;
613
+ }) => {
614
+ if (!identity || ownTracked.has(handle.documentId)) return;
615
+ ownTracked.add(handle.documentId);
616
+ handle.on("heads-changed", () => broadcastOwnHeads(handle));
617
+ broadcastOwnHeads(handle);
618
+ };
619
+ const scanOwnHandles = () => {
620
+ if (!identity) return;
621
+ for (const handle of Object.values(repo.handles)) {
622
+ trackOwnHandle(handle as never);
623
+ }
624
+ };
625
+
626
+ // ── Backoff re-sync of stuck/diverged docs ──────────────────────────
627
+ //
628
+ // Subduction sync is event-driven and only retries syncs it observed *fail*;
629
+ // a doc that settles missing commits the server holds — or whose heal retries
630
+ // were exhausted — is otherwise never retried. When we're behind and the
631
+ // server's advertised heads haven't advanced for a grace window (so it's
632
+ // genuinely stuck, not just lagging a live edit), we re-arm its sync round
633
+ // with per-doc exponential backoff. Convergence clears the state.
634
+ const serverHeadSetsFor = (documentId: string): string[][] => {
635
+ const byStorage = snapshot.get(documentId);
636
+ if (!byStorage) return [];
637
+ const sets: string[][] = [];
638
+ for (const [storageId, { heads }] of byStorage) {
639
+ if (serverPeerIds.includes(storageId)) sets.push(heads);
640
+ }
641
+ return sets;
642
+ };
643
+ const resyncState = new Map<
644
+ string,
645
+ { serverSig: string; since: number; delay: number; lastResyncAt: number }
646
+ >();
647
+ // Inspectable from the SharedWorker console as `self.patchworkResync` to see
648
+ // whether/how often a doc is being re-synced and against which server heads.
649
+ const resyncDiag: { fires: number; byDoc: Record<string, unknown> } =
650
+ ((self as any).patchworkResync ??= { fires: 0, byDoc: {} });
651
+ const reviewResync = (documentId: string) => {
652
+ if (!identity || !connected) {
653
+ resyncState.delete(documentId);
654
+ return;
655
+ }
656
+ const handle = repo.handles[documentId as DocumentId];
657
+ if (!handle) return;
658
+ const serverSets = serverHeadSetsFor(documentId);
659
+ if (serverSets.length === 0) {
660
+ resyncState.delete(documentId); // no server signal to compare against
661
+ return;
662
+ }
663
+ // The server advertises subduction *sedimentree* heads (loose-commit +
664
+ // fragment-boundary commit ids), which are NOT the Automerge frontier — so
665
+ // never compare them to handle.heads() for equality. Instead ask whether we
666
+ // already hold every commit the server advertises (`DocHandle.containsHeads`).
667
+ // If we do, the server has nothing we're missing → caught up. If not, we're
668
+ // genuinely behind and a re-sync can pull the rest.
669
+ const serverHeadsUrl = [...new Set(serverSets.flat())] as UrlHeads;
670
+ let haveAll: boolean;
671
+ try {
672
+ haveAll = handle.containsHeads(serverHeadsUrl);
673
+ } catch {
674
+ return; // doc not ready, or an undecodable head
675
+ }
676
+ if (haveAll) {
677
+ resyncState.delete(documentId); // we hold everything the server has
678
+ return;
679
+ }
680
+ // Behind. "Stuck" = the server's advertised set hasn't advanced (no
681
+ // progress) for a while. Key the grace timer on the server heads only, so
682
+ // your own edits churning don't keep resetting it.
683
+ const serverSig = [...serverHeadsUrl].sort().join(",");
684
+ const now = Date.now();
685
+ const prev = resyncState.get(documentId);
686
+ if (!prev || prev.serverSig !== serverSig) {
687
+ // First sighting, or the server advanced its view (progress): restart.
688
+ resyncState.set(documentId, {
689
+ serverSig,
690
+ since: now,
691
+ delay: RESYNC_INITIAL_DELAY_MS,
692
+ lastResyncAt: 0,
693
+ });
694
+ return;
695
+ }
696
+ if (now - prev.since < RESYNC_GRACE_MS) return; // not stuck long enough yet
697
+ if (now - prev.lastResyncAt < prev.delay) return; // within backoff cooldown
698
+ log("re-syncing behind doc", documentId, { serverSets });
699
+ resyncDiag.fires++;
700
+ resyncDiag.byDoc[documentId] = {
701
+ at: now,
702
+ count:
703
+ ((resyncDiag.byDoc[documentId] as { count?: number } | undefined)
704
+ ?.count ?? 0) + 1,
705
+ serverSets,
706
+ };
707
+ try {
708
+ repo.resyncSubduction(documentId as DocumentId);
709
+ } catch (e) {
710
+ log("resyncSubduction failed", e);
711
+ }
712
+ prev.lastResyncAt = now;
713
+ prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
714
+ };
715
+ const reviewAllResync = () => {
716
+ if (!identity) return;
717
+ for (const documentId of snapshot.keys()) reviewResync(documentId);
718
+ for (const id of [...resyncState.keys()]) {
719
+ if (!snapshot.has(id)) resyncState.delete(id);
720
+ }
721
+ };
722
+
723
+ repo.on(
724
+ "subduction-remote-heads",
725
+ ({ documentId, storageId, heads, timestamp }) => {
726
+ const headsCopy = [...heads];
727
+ let byStorage = snapshot.get(documentId);
728
+ if (!byStorage) {
729
+ byStorage = new Map();
730
+ snapshot.set(documentId, byStorage);
731
+ }
732
+ byStorage.set(storageId, { heads: headsCopy, timestamp });
733
+ postHeads(documentId, storageId, headsCopy, timestamp);
734
+ // A doc the server reported is one we hold — make sure we're advertising
735
+ // our own heads for it too.
736
+ scanOwnHandles();
737
+ reviewResync(documentId);
738
+ }
739
+ );
740
+
741
+ repo.on("subduction-connection", ({ connected: isConnected }) => {
742
+ connected = isConnected;
743
+ postConnection();
744
+ if (isConnected) void refreshServerPeers();
745
+ });
746
+
747
+ // A BroadcastChannel never receives its own posts, so this only sees tabs'
748
+ // requests, never our own broadcasts. We replay just the global signals here;
749
+ // a late tab gets per-doc heads by subscribing (sync-sub), not from this.
750
+ channel.addEventListener("message", (event: MessageEvent) => {
751
+ const data = event.data as SyncStateRequestMessage;
752
+ if (data?.type !== "request") return;
753
+ postWhoAmI();
754
+ postConnection();
755
+ });
756
+
757
+ // In case we're already connected by the time this wires up.
758
+ void refreshServerPeers();
759
+
760
+ // Discover the worker's docs by re-scanning repo.handles initially and on a
761
+ // tick (Subduction-pushed docs don't surface via the "document" event).
762
+ scanOwnHandles();
763
+ if (identity) setInterval(scanOwnHandles, 3000);
764
+
765
+ // Drive the backoff re-sync of stuck/diverged docs. A tick is essential here:
766
+ // the "stuck" case is precisely when no head events are firing, so the
767
+ // grace/backoff timers can only advance on a timer.
768
+ if (identity) setInterval(reviewAllResync, RESYNC_REVIEW_INTERVAL_MS);
769
+ }
770
+
325
771
  // ── Tab connections ────────────────────────────────────────────────────
326
772
 
327
773
  // Each tab connects with a control port (the SharedWorker connect port) and
@@ -351,10 +797,14 @@ function dropRepoChannel(repo: Repo, channel: RepoChannel) {
351
797
  // wrapper: make sure the underlying port is disconnected and closed too.
352
798
  try {
353
799
  channel.mcAdapter.disconnect();
354
- } catch {}
800
+ } catch {
801
+ // Already disconnected by removeNetworkAdapter above.
802
+ }
355
803
  try {
356
804
  channel.port.close();
357
- } catch {}
805
+ } catch {
806
+ // Port already closed by the departing tab.
807
+ }
358
808
  }
359
809
 
360
810
  async function dropConnection(connection: Connection) {
@@ -436,6 +886,14 @@ function handleControlMessage(
436
886
  });
437
887
  }
438
888
  );
889
+ } else if (data?.type === "sync-sub") {
890
+ if (typeof data.documentId === "string") {
891
+ syncSubscribe(controlPort, data.documentId);
892
+ }
893
+ } else if (data?.type === "sync-unsub") {
894
+ if (typeof data.documentId === "string") {
895
+ syncUnsubscribe(controlPort, data.documentId);
896
+ }
439
897
  } else if (data?.type === "debug") {
440
898
  debugging = data.debug;
441
899
  log("automerge worker debugging enabled");
@@ -477,10 +935,18 @@ self.addEventListener("connect", (event) => {
477
935
  handleControlMessage(messageEvent as MessageEvent, controlPort, connection);
478
936
  });
479
937
 
938
+ // Let the subduction port provider negotiate over this tab's control port
939
+ // (the tab side runs donatePort; the messages are channel-tagged so they
940
+ // coexist with our control protocol above).
941
+ subductionPortProvider.attachClient(controlPort);
942
+
480
943
  // Fires when the owning page is destroyed. Browsers without the close
481
944
  // event fall back to the adapters' lazy useWeakRef cleanup.
482
945
  controlPort.addEventListener("close", () => {
483
946
  controlPorts.delete(controlPort);
947
+ // The tab is gone — drop its sync subscriptions wholesale so we stop
948
+ // pushing it heads (no per-doc unsub needed, no leak).
949
+ syncWatchers.delete(controlPort);
484
950
  void dropConnection(connection);
485
951
  });
486
952
 
@@ -598,11 +1064,10 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
598
1064
  ? (new Uint8Array(resolved.content) as BlobPart)
599
1065
  : resolved.content;
600
1066
 
601
- const headers = new Headers({ "content-type": resolved.type });
602
- headers.set("cross-origin-embedder-policy", "credentialless");
603
- headers.set("cross-origin-resource-policy", "cross-origin");
604
-
605
- return new Response(body, { status: 200, headers });
1067
+ return new Response(body, {
1068
+ status: 200,
1069
+ headers: { "content-type": resolved.type },
1070
+ });
606
1071
  }
607
1072
 
608
1073
  // ── Handoff: resolve special URLs for the service worker ──────────────
package/src/externals.ts CHANGED
@@ -6,6 +6,11 @@ const externals = [
6
6
  "@automerge/automerge/slim",
7
7
  "@automerge/automerge-repo",
8
8
  "@automerge/automerge-repo/slim",
9
+ // Port-donation plumbing for WorkerWebSocketEndpoint: tabs spawn the shared
10
+ // proxy entry and donate its port to the automerge worker (Chrome can't
11
+ // spawn workers from inside a SharedWorker). See setup.ts/automerge-worker.ts.
12
+ "@automerge/automerge-repo/worker-port",
13
+ "@automerge/automerge-repo/subduction-websocket-worker-shared",
9
14
  "@automerge/automerge-repo-network-messagechannel",
10
15
  "@automerge/automerge-repo-network-websocket",
11
16
  "@automerge/automerge-repo-storage-indexeddb",