@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @inkandswitch/patchwork-bootloader
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b1bd763: Hash routing: `doc=` now holds the full (un-encoded) automerge URL — heads, if
8
+ any, live inside it — and the separate `heads=` param is gone. `doc=` values
9
+ that are a bare document id are still accepted for backwards compatibility, and
10
+ legacy big-patchwork links (`<slug>--<docId>?…`, including slugs with
11
+ characters like `drawing-(branch-1)`) are normalized to `#doc=automerge:<docId>`.
12
+
13
+ ## 0.3.2
14
+
15
+ ### Patch Changes
16
+
17
+ - 0e1eb95: add syncstate info shape
18
+
3
19
  ## 0.3.1
4
20
 
5
21
  ### Patch Changes
@@ -17,14 +17,15 @@ import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
17
17
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
18
18
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
19
19
  import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
20
- import { Repo, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
20
+ import { makePortProvider } from "@automerge/automerge-repo/worker-port";
21
+ import { Repo, WorkerWebSocketEndpoint, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
21
22
  import { resolvePath } from "@inkandswitch/patchwork-filesystem";
22
23
  // Small adapters — bundled directly into the worker
23
24
  import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
24
25
  import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
25
26
  import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
26
27
  import { initializeAutomergeRepoKeyhiveRustWithRepo, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
27
- import { HANDOFF_CHANNEL, } from "./types.js";
28
+ import { HANDOFF_CHANNEL, SYNCSTATE_CHANNEL, } from "./types.js";
28
29
  let debugging = false;
29
30
  // Per-boot identity so a tab can detect a worker *restart*: a fresh instance
30
31
  // means a new repo peerId + cold in-memory state, so the tab's docs must be
@@ -36,6 +37,71 @@ const WORKER_BOOT_TIME = Date.now();
36
37
  // → shared workers). Patch console.* and the global error handlers to also
37
38
  // post back over every connected tab's control port, tagged [automerge-worker].
38
39
  const controlPorts = new Set();
40
+ // ── Keepalive-drift probe (bench instrumentation) ───────────────────────
41
+ // Measures how late a 1s timer fires on this thread — i.e. how late an
42
+ // in-thread keepalive would be under sync/wasm load. Cheap (one Date.now()
43
+ // per second); samples are batched to every connected tab as drift-samples
44
+ // messages, which setup.ts accumulates on window.__driftSamples for the
45
+ // Playwright bench (e2e/tests/bench-ws.spec.ts).
46
+ const DRIFT_INTERVAL_MS = 1_000;
47
+ const DRIFT_BATCH_SIZE = 5;
48
+ {
49
+ let expected = Date.now() + DRIFT_INTERVAL_MS;
50
+ let batch = [];
51
+ setInterval(() => {
52
+ const now = Date.now();
53
+ batch.push(Math.max(0, now - expected));
54
+ expected = now + DRIFT_INTERVAL_MS;
55
+ if (batch.length >= DRIFT_BATCH_SIZE) {
56
+ const samples = batch;
57
+ batch = [];
58
+ for (const port of controlPorts) {
59
+ try {
60
+ port.postMessage({ type: "drift-samples", samples });
61
+ }
62
+ catch {
63
+ // Port torn down mid-iteration — its close handler cleans up.
64
+ }
65
+ }
66
+ }
67
+ }, DRIFT_INTERVAL_MS);
68
+ }
69
+ // ── Per-tab sync-state subscriptions ────────────────────────────────────
70
+ // Each tab's control port subscribes to the documents it cares about; we push
71
+ // only those docs' heads back down that port (addressed — tab A never sees tab
72
+ // B's docs), and drop a port's whole subscription set when it closes (the tab
73
+ // went away), so there's nothing to reference-count or time out. The global
74
+ // connection/whoami signals still go over SYNCSTATE_CHANNEL.
75
+ const syncWatchers = new Map();
76
+ // Installed by setupSyncStateBroadcast once the repo's snapshot exists, so a
77
+ // fresh `sync-sub` can be replayed the doc's current heads immediately. Null
78
+ // until then; subscriptions taken during boot are replayed when it installs.
79
+ let replaySyncForPort = null;
80
+ function syncSubscribe(port, documentId) {
81
+ let docs = syncWatchers.get(port);
82
+ if (!docs)
83
+ syncWatchers.set(port, (docs = new Set()));
84
+ if (docs.has(documentId))
85
+ return;
86
+ docs.add(documentId);
87
+ replaySyncForPort?.(documentId, port);
88
+ }
89
+ function syncUnsubscribe(port, documentId) {
90
+ syncWatchers.get(port)?.delete(documentId);
91
+ }
92
+ // Push one document's heads to every control port currently watching it.
93
+ function pushSyncState(message) {
94
+ for (const [port, docs] of syncWatchers) {
95
+ if (!docs.has(message.documentId))
96
+ continue;
97
+ try {
98
+ port.postMessage(message);
99
+ }
100
+ catch {
101
+ // Port already gone; its close handler will reap the entry.
102
+ }
103
+ }
104
+ }
39
105
  // Logs emitted before any tab has connected (e.g. during wasm boot) would
40
106
  // otherwise be lost — buffer a bounded number and flush on first connect.
41
107
  const preConnectBuffer = [];
@@ -124,12 +190,57 @@ if (useKeyhiveSyncServer) {
124
190
  KEYHIVE_SERVER_IDENTITY: "keyhive-sync",
125
191
  };
126
192
  }
127
- const SUBDUCTION_ENDPOINTS = [
128
- useKeyhiveSyncServer
129
- ? "wss://keyhive.sync.automerge.org"
130
- : "wss://subduction.sync.inkandswitch.com",
131
- ];
193
+ const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
194
+ ? "wss://keyhive.sync.automerge.org"
195
+ : "wss://subduction.sync.inkandswitch.com";
196
+ // The subduction WebSocket lives in its own worker so socket I/O (and
197
+ // keepalive pongs) keep flowing even when this SharedWorker's thread is busy
198
+ // syncing. We can't spawn that worker ourselves — Chrome doesn't expose the
199
+ // Worker constructor inside SharedWorkerGlobalScope — so tabs spawn the
200
+ // shipped SharedWorker proxy entry and donate its port to us (donatePort in
201
+ // setup.ts). The provider hands WorkerWebSocketEndpoint whichever port is
202
+ // current, healing across late arrival and proxy-worker restarts.
203
+ const subductionPortProvider = makePortProvider();
204
+ // A/B bench toggle: the tab appends ?ws-mode=inline to our URL (SharedWorker
205
+ // scope has no localStorage; see getAutomergeWorker in setup.ts). "inline"
206
+ // passes the bare URL string so the socket lives on this thread — the
207
+ // pre-worker behaviour — as the control arm for benchmarking the
208
+ // worker-based endpoint. Default: "worker".
209
+ const WS_MODE = new URL(self.location.href).searchParams.get("ws-mode") === "inline"
210
+ ? "inline"
211
+ : "worker";
212
+ // Optional windowFrames override (bench knob — max un-acked frames the io
213
+ // proxy delivers before pausing; endpoint default is 128).
214
+ const WS_WINDOW_FRAMES = Number(new URL(self.location.href).searchParams.get("ws-window")) ||
215
+ undefined;
216
+ // Memoized so a repo-construction retry (getRepoHive clears its promise on
217
+ // failure) reuses the same endpoint instead of leaking one per attempt.
218
+ let subductionEndpoints = null;
219
+ function getSubductionEndpoints() {
220
+ if (!subductionEndpoints) {
221
+ log(`subduction websocket mode: ${WS_MODE}`);
222
+ subductionEndpoints =
223
+ WS_MODE === "inline"
224
+ ? [SUBDUCTION_SYNC_URL]
225
+ : [
226
+ new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
227
+ worker: subductionPortProvider.source,
228
+ ...(WS_WINDOW_FRAMES
229
+ ? { windowFrames: WS_WINDOW_FRAMES }
230
+ : {}),
231
+ }),
232
+ ];
233
+ }
234
+ return subductionEndpoints;
235
+ }
132
236
  const RESOLVE_TIMEOUT_MS = 30_000;
237
+ // Backoff re-sync of stuck/diverged docs. Only this worker is connected to the
238
+ // sync server, so it's the only place that can notice a doc whose heads have
239
+ // settled out of sync with the server and re-arm a sync round for it.
240
+ const RESYNC_GRACE_MS = 8_000; // must be *stably* diverged this long first
241
+ const RESYNC_INITIAL_DELAY_MS = 5_000; // first backoff cooldown after a resync
242
+ const RESYNC_MAX_DELAY_MS = 60_000; // backoff cap
243
+ const RESYNC_REVIEW_INTERVAL_MS = 5_000; // how often stuck docs are re-checked
133
244
  const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
134
245
  let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
135
246
  let classicSyncAdapter = null;
@@ -185,6 +296,10 @@ function getRepoHive() {
185
296
  log("wasm initialized");
186
297
  if (!useKeyhive) {
187
298
  const signer = await WebCryptoSigner.setup();
299
+ const identity = {
300
+ peerId: signer.peerId().toString(),
301
+ verifyingKey: signer.verifyingKey().toHex(),
302
+ };
188
303
  const repo = new Repo({
189
304
  storage: new IndexedDBWorkerStorageAdapter(),
190
305
  signer,
@@ -196,9 +311,12 @@ function getRepoHive() {
196
311
  return peerId.includes("storage-server");
197
312
  },
198
313
  enableRemoteHeadsGossiping: true,
199
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
314
+ subductionWebsocketEndpoints: getSubductionEndpoints(),
200
315
  });
316
+ console.log("[patchwork] shared-worker subduction identity:", identity, "networkSubsystem.adapters:", repo.networkSubsystem.adapters.length);
201
317
  self.repo = repo;
318
+ self.syncIdentity = identity;
319
+ setupSyncStateBroadcast(repo, identity);
202
320
  log("repo constructed (no keyhive), waiting for network subsystem");
203
321
  repo.networkSubsystem.whenReady().then(() => {
204
322
  log("repo network subsystem ready");
@@ -219,12 +337,13 @@ function getRepoHive() {
219
337
  ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
220
338
  repo: {
221
339
  storage: new IndexedDBWorkerStorageAdapter(),
222
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
340
+ subductionWebsocketEndpoints: getSubductionEndpoints(),
223
341
  enableRemoteHeadsGossiping: true,
224
342
  },
225
343
  });
226
344
  self.repo = repo;
227
345
  self.hive = hive;
346
+ setupSyncStateBroadcast(repo);
228
347
  log("repo constructed, waiting for network subsystem");
229
348
  // Don't block getRepoHive() on whenReady() — the network subsystem starts
230
349
  // with only the subduction adapter, and the MessageChannel adapter is
@@ -247,6 +366,285 @@ function getRepoHive() {
247
366
  }
248
367
  return repoHivePromise;
249
368
  }
369
+ // ── Sync-state broadcast ───────────────────────────────────────────────
370
+ //
371
+ // Only this worker is directly connected to the sync server, so it's the only
372
+ // place that learns the server's heads (the repo's "subduction-remote-heads"
373
+ // event, keyed by each Subduction peer's verifying-key storageId) and whether
374
+ // the server link is up ("subduction-connection"). We rebroadcast both on
375
+ // SYNCSTATE_CHANNEL so every tab can render a sync indicator without holding
376
+ // its own server connection. A tab that opens mid-stream posts {type:"request"}
377
+ // to get the current snapshot replayed.
378
+ let syncStateWired = false;
379
+ function setupSyncStateBroadcast(repo, identity) {
380
+ if (syncStateWired)
381
+ return;
382
+ syncStateWired = true;
383
+ const channel = new BroadcastChannel(SYNCSTATE_CHANNEL);
384
+ // documentId -> storageId (verifying key) -> last-known heads
385
+ const snapshot = new Map();
386
+ let connected = repo.isSubductionConnected();
387
+ // Directly-connected sync-server peer ids (verifying keys). Stable once
388
+ // known; tabs use this to judge "synced" against the server specifically.
389
+ let serverPeerIds = [];
390
+ const postWhoAmI = () => {
391
+ if (!identity)
392
+ return;
393
+ channel.postMessage({
394
+ type: "whoami",
395
+ peerId: identity.peerId,
396
+ verifyingKey: identity.verifyingKey,
397
+ });
398
+ };
399
+ // Announce our identity so tabs can label which peer rows are this worker.
400
+ postWhoAmI();
401
+ // Heads are addressed, not broadcast: push a doc's heads only to the control
402
+ // ports that subscribed to it (see syncWatchers / pushSyncState).
403
+ const postHeads = (documentId, storageId, heads, timestamp) => pushSyncState({
404
+ type: "sync-state",
405
+ documentId,
406
+ storageId,
407
+ heads,
408
+ timestamp,
409
+ });
410
+ // Let a `sync-sub` (which may have arrived while the repo was still booting)
411
+ // replay this doc's current snapshot to the subscribing port immediately.
412
+ const replayDoc = (documentId, port) => {
413
+ const byStorage = snapshot.get(documentId);
414
+ if (!byStorage)
415
+ return;
416
+ for (const [storageId, { heads, timestamp }] of byStorage) {
417
+ try {
418
+ port.postMessage({
419
+ type: "sync-state",
420
+ documentId,
421
+ storageId,
422
+ heads,
423
+ timestamp,
424
+ });
425
+ }
426
+ catch {
427
+ // Port gone; its close handler reaps it.
428
+ }
429
+ }
430
+ };
431
+ replaySyncForPort = replayDoc;
432
+ // Catch up any ports that subscribed before this wiring existed.
433
+ for (const [port, docs] of syncWatchers) {
434
+ for (const documentId of docs)
435
+ replayDoc(documentId, port);
436
+ }
437
+ const postConnection = () => channel.postMessage({
438
+ type: "connection",
439
+ connected,
440
+ serverPeerIds,
441
+ });
442
+ // Learn (and re-announce) which connected Subduction peer is the sync server.
443
+ // The peer list is empty until the handshake finishes, so retry briefly.
444
+ const refreshServerPeers = async () => {
445
+ for (let attempt = 0; attempt < 6; attempt++) {
446
+ try {
447
+ const ids = await repo.connectedSubductionPeerIds();
448
+ if (ids.length > 0) {
449
+ serverPeerIds = ids;
450
+ postConnection();
451
+ return;
452
+ }
453
+ }
454
+ catch {
455
+ // repo has no subduction source / not ready yet
456
+ }
457
+ await new Promise((r) => setTimeout(r, 500));
458
+ }
459
+ };
460
+ // Advertise the worker's OWN heads for every doc it holds (keyed by our
461
+ // verifying key), so the worker hop is visible on every document.
462
+ //
463
+ // Docs pushed in by Subduction that this worker never explicitly opened don't
464
+ // surface via the repo's "document" event, so we discover them by re-scanning
465
+ // repo.handles (on a tick, and whenever the server reports a doc) and attach a
466
+ // heads-changed listener once per doc. No-op when there's no identity (keyhive
467
+ // path).
468
+ const ownTracked = new Set();
469
+ const broadcastOwnHeads = (handle) => {
470
+ if (!identity)
471
+ return;
472
+ const documentId = handle.documentId;
473
+ let heads;
474
+ try {
475
+ heads = [...handle.heads()];
476
+ }
477
+ catch {
478
+ return; // handle not ready yet
479
+ }
480
+ const timestamp = Date.now();
481
+ let byStorage = snapshot.get(documentId);
482
+ if (!byStorage) {
483
+ byStorage = new Map();
484
+ snapshot.set(documentId, byStorage);
485
+ }
486
+ byStorage.set(identity.peerId, { heads, timestamp });
487
+ postHeads(documentId, identity.peerId, heads, timestamp);
488
+ reviewResync(documentId);
489
+ };
490
+ const trackOwnHandle = (handle) => {
491
+ if (!identity || ownTracked.has(handle.documentId))
492
+ return;
493
+ ownTracked.add(handle.documentId);
494
+ handle.on("heads-changed", () => broadcastOwnHeads(handle));
495
+ broadcastOwnHeads(handle);
496
+ };
497
+ const scanOwnHandles = () => {
498
+ if (!identity)
499
+ return;
500
+ for (const handle of Object.values(repo.handles)) {
501
+ trackOwnHandle(handle);
502
+ }
503
+ };
504
+ // ── Backoff re-sync of stuck/diverged docs ──────────────────────────
505
+ //
506
+ // Subduction sync is event-driven and only retries syncs it observed *fail*;
507
+ // a doc that settles missing commits the server holds — or whose heal retries
508
+ // were exhausted — is otherwise never retried. When we're behind and the
509
+ // server's advertised heads haven't advanced for a grace window (so it's
510
+ // genuinely stuck, not just lagging a live edit), we re-arm its sync round
511
+ // with per-doc exponential backoff. Convergence clears the state.
512
+ const serverHeadSetsFor = (documentId) => {
513
+ const byStorage = snapshot.get(documentId);
514
+ if (!byStorage)
515
+ return [];
516
+ const sets = [];
517
+ for (const [storageId, { heads }] of byStorage) {
518
+ if (serverPeerIds.includes(storageId))
519
+ sets.push(heads);
520
+ }
521
+ return sets;
522
+ };
523
+ const resyncState = new Map();
524
+ // Inspectable from the SharedWorker console as `self.patchworkResync` to see
525
+ // whether/how often a doc is being re-synced and against which server heads.
526
+ const resyncDiag = (self.patchworkResync ??= { fires: 0, byDoc: {} });
527
+ const reviewResync = (documentId) => {
528
+ if (!identity || !connected) {
529
+ resyncState.delete(documentId);
530
+ return;
531
+ }
532
+ const handle = repo.handles[documentId];
533
+ if (!handle)
534
+ return;
535
+ const serverSets = serverHeadSetsFor(documentId);
536
+ if (serverSets.length === 0) {
537
+ resyncState.delete(documentId); // no server signal to compare against
538
+ return;
539
+ }
540
+ // The server advertises subduction *sedimentree* heads (loose-commit +
541
+ // fragment-boundary commit ids), which are NOT the Automerge frontier — so
542
+ // never compare them to handle.heads() for equality. Instead ask whether we
543
+ // already hold every commit the server advertises (`DocHandle.containsHeads`).
544
+ // If we do, the server has nothing we're missing → caught up. If not, we're
545
+ // genuinely behind and a re-sync can pull the rest.
546
+ const serverHeadsUrl = [...new Set(serverSets.flat())];
547
+ let haveAll;
548
+ try {
549
+ haveAll = handle.containsHeads(serverHeadsUrl);
550
+ }
551
+ catch {
552
+ return; // doc not ready, or an undecodable head
553
+ }
554
+ if (haveAll) {
555
+ resyncState.delete(documentId); // we hold everything the server has
556
+ return;
557
+ }
558
+ // Behind. "Stuck" = the server's advertised set hasn't advanced (no
559
+ // progress) for a while. Key the grace timer on the server heads only, so
560
+ // your own edits churning don't keep resetting it.
561
+ const serverSig = [...serverHeadsUrl].sort().join(",");
562
+ const now = Date.now();
563
+ const prev = resyncState.get(documentId);
564
+ if (!prev || prev.serverSig !== serverSig) {
565
+ // First sighting, or the server advanced its view (progress): restart.
566
+ resyncState.set(documentId, {
567
+ serverSig,
568
+ since: now,
569
+ delay: RESYNC_INITIAL_DELAY_MS,
570
+ lastResyncAt: 0,
571
+ });
572
+ return;
573
+ }
574
+ if (now - prev.since < RESYNC_GRACE_MS)
575
+ return; // not stuck long enough yet
576
+ if (now - prev.lastResyncAt < prev.delay)
577
+ return; // within backoff cooldown
578
+ log("re-syncing behind doc", documentId, { serverSets });
579
+ resyncDiag.fires++;
580
+ resyncDiag.byDoc[documentId] = {
581
+ at: now,
582
+ count: (resyncDiag.byDoc[documentId]
583
+ ?.count ?? 0) + 1,
584
+ serverSets,
585
+ };
586
+ try {
587
+ repo.resyncSubduction(documentId);
588
+ }
589
+ catch (e) {
590
+ log("resyncSubduction failed", e);
591
+ }
592
+ prev.lastResyncAt = now;
593
+ prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
594
+ };
595
+ const reviewAllResync = () => {
596
+ if (!identity)
597
+ return;
598
+ for (const documentId of snapshot.keys())
599
+ reviewResync(documentId);
600
+ for (const id of [...resyncState.keys()]) {
601
+ if (!snapshot.has(id))
602
+ resyncState.delete(id);
603
+ }
604
+ };
605
+ repo.on("subduction-remote-heads", ({ documentId, storageId, heads, timestamp }) => {
606
+ const headsCopy = [...heads];
607
+ let byStorage = snapshot.get(documentId);
608
+ if (!byStorage) {
609
+ byStorage = new Map();
610
+ snapshot.set(documentId, byStorage);
611
+ }
612
+ byStorage.set(storageId, { heads: headsCopy, timestamp });
613
+ postHeads(documentId, storageId, headsCopy, timestamp);
614
+ // A doc the server reported is one we hold — make sure we're advertising
615
+ // our own heads for it too.
616
+ scanOwnHandles();
617
+ reviewResync(documentId);
618
+ });
619
+ repo.on("subduction-connection", ({ connected: isConnected }) => {
620
+ connected = isConnected;
621
+ postConnection();
622
+ if (isConnected)
623
+ void refreshServerPeers();
624
+ });
625
+ // A BroadcastChannel never receives its own posts, so this only sees tabs'
626
+ // requests, never our own broadcasts. We replay just the global signals here;
627
+ // a late tab gets per-doc heads by subscribing (sync-sub), not from this.
628
+ channel.addEventListener("message", (event) => {
629
+ const data = event.data;
630
+ if (data?.type !== "request")
631
+ return;
632
+ postWhoAmI();
633
+ postConnection();
634
+ });
635
+ // In case we're already connected by the time this wires up.
636
+ void refreshServerPeers();
637
+ // Discover the worker's docs by re-scanning repo.handles initially and on a
638
+ // tick (Subduction-pushed docs don't surface via the "document" event).
639
+ scanOwnHandles();
640
+ if (identity)
641
+ setInterval(scanOwnHandles, 3000);
642
+ // Drive the backoff re-sync of stuck/diverged docs. A tick is essential here:
643
+ // the "stuck" case is precisely when no head events are firing, so the
644
+ // grace/backoff timers can only advance on a timer.
645
+ if (identity)
646
+ setInterval(reviewAllResync, RESYNC_REVIEW_INTERVAL_MS);
647
+ }
250
648
  function dropRepoChannel(repo, channel) {
251
649
  // removeNetworkAdapter pulls the adapter out of networkSubsystem.adapters and
252
650
  // calls adapter.disconnect(), which (for the MessageChannel adapter) emits the
@@ -262,11 +660,15 @@ function dropRepoChannel(repo, channel) {
262
660
  try {
263
661
  channel.mcAdapter.disconnect();
264
662
  }
265
- catch { }
663
+ catch {
664
+ // Already disconnected by removeNetworkAdapter above.
665
+ }
266
666
  try {
267
667
  channel.port.close();
268
668
  }
269
- catch { }
669
+ catch {
670
+ // Port already closed by the departing tab.
671
+ }
270
672
  }
271
673
  async function dropConnection(connection) {
272
674
  if (!connection.channels.size || !repoHivePromise)
@@ -329,6 +731,16 @@ function handleControlMessage(event, controlPort, connection) {
329
731
  });
330
732
  });
331
733
  }
734
+ else if (data?.type === "sync-sub") {
735
+ if (typeof data.documentId === "string") {
736
+ syncSubscribe(controlPort, data.documentId);
737
+ }
738
+ }
739
+ else if (data?.type === "sync-unsub") {
740
+ if (typeof data.documentId === "string") {
741
+ syncUnsubscribe(controlPort, data.documentId);
742
+ }
743
+ }
332
744
  else if (data?.type === "debug") {
333
745
  debugging = data.debug;
334
746
  log("automerge worker debugging enabled");
@@ -368,10 +780,17 @@ self.addEventListener("connect", (event) => {
368
780
  controlPort.addEventListener("message", (messageEvent) => {
369
781
  handleControlMessage(messageEvent, controlPort, connection);
370
782
  });
783
+ // Let the subduction port provider negotiate over this tab's control port
784
+ // (the tab side runs donatePort; the messages are channel-tagged so they
785
+ // coexist with our control protocol above).
786
+ subductionPortProvider.attachClient(controlPort);
371
787
  // Fires when the owning page is destroyed. Browsers without the close
372
788
  // event fall back to the adapters' lazy useWeakRef cleanup.
373
789
  controlPort.addEventListener("close", () => {
374
790
  controlPorts.delete(controlPort);
791
+ // The tab is gone — drop its sync subscriptions wholesale so we stop
792
+ // pushing it heads (no per-doc unsub needed, no leak).
793
+ syncWatchers.delete(controlPort);
375
794
  void dropConnection(connection);
376
795
  });
377
796
  controlPort.start();
@@ -469,10 +888,10 @@ async function resolveAutomergeUrl(automergeURL) {
469
888
  const body = resolved.content instanceof Uint8Array
470
889
  ? new Uint8Array(resolved.content)
471
890
  : resolved.content;
472
- const headers = new Headers({ "content-type": resolved.type });
473
- headers.set("cross-origin-embedder-policy", "credentialless");
474
- headers.set("cross-origin-resource-policy", "cross-origin");
475
- return new Response(body, { status: 200, headers });
891
+ return new Response(body, {
892
+ status: 200,
893
+ headers: { "content-type": resolved.type },
894
+ });
476
895
  }
477
896
  // ── Handoff: resolve special URLs for the service worker ──────────────
478
897
  const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
package/dist/externals.js 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",