@inkandswitch/patchwork-bootloader 0.4.3 → 0.4.4

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.
@@ -1,17 +1,11 @@
1
- // The automerge repo for a patchwork site. This runs in a SharedWorker, so
2
- // one instance serves every tab and lives exactly as long as any tab does
3
- // no keepalive pings, no idle teardown.
1
+ // The automerge repo for a patchwork site, in a SharedWorker: one instance
2
+ // serves every tab and lives as long as any tab does.
4
3
  //
5
- // The service worker holds no repo. When it misses the cache for a special
6
- // URL it broadcasts a HandoffRequestMessage on HANDOFF_CHANNEL; we resolve
7
- // the automerge URL, write the response into the service worker's cache
8
- // (keyed by a Request reconstructed to match the one it's holding), and
9
- // reply on the same channel.
10
- // Heavy imports — marked external by the service-worker vite plugin,
11
- // resolved to /packages/... URLs at build time. The worker is created with
12
- // type:"module" so the browser fetches these as regular network requests.
13
- // Uses /slim so wasm is fetched from /automerge.wasm (emitted by the vite
14
- // plugin) instead of bundling the ~3MB base64 string.
4
+ // The service worker holds no repo. When it misses the cache for a request that
5
+ // looks like a URL encoded URL, it broadcasts a HandoffRequestMessage on
6
+ // HANDOFF_CHANNEL; we resolve the automerge URL, write the response into the
7
+ // service worker's cache (keyed by a Request reconstructed to match the one
8
+ // it's holding), and reply on the same channel.
15
9
  import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
16
10
  // eslint-disable-next-line
17
11
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
@@ -20,90 +14,46 @@ import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
20
14
  import { makePortProvider } from "@automerge/automerge-repo/worker-port";
21
15
  import { Repo, WorkerWebSocketEndpoint, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
22
16
  import { resolvePath } from "@inkandswitch/patchwork-filesystem";
23
- // Small adapters — bundled directly into the worker
24
17
  import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
25
18
  import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
26
19
  import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
27
20
  import { initializeAutomergeRepoKeyhiveRustWithRepo, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
21
+ import { DEFAULT_CLASSIC_SYNC_SERVER } from "./sync-config.js";
28
22
  import { HANDOFF_CHANNEL, SYNCSTATE_CHANNEL, } from "./types.js";
29
- let debugging = false;
30
- // Per-boot identity so a tab can detect a worker *restart*: a fresh instance
31
- // means a new repo peerId + cold in-memory state, so the tab's docs must be
32
- // re-subscribed. Sent in `hello` (on connect) and every `pong`.
23
+ const siteName = typeof __SITE_NAME__ !== "undefined"
24
+ ? __SITE_NAME__
25
+ : "patchwork.inkandswitch.com";
26
+ const useKeyhive = typeof __KEYHIVE__ !== "undefined" && __KEYHIVE__;
27
+ const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
28
+ const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
29
+ ? "wss://keyhive.sync.automerge.org"
30
+ : "wss://subduction.sync.inkandswitch.com";
31
+ if (useKeyhiveSyncServer) {
32
+ const g = globalThis;
33
+ g.process ??= {};
34
+ g.process.env = {
35
+ ...(g.process.env ?? {}),
36
+ KEYHIVE_SERVER_IDENTITY: "keyhive-sync",
37
+ };
38
+ }
39
+ const RESOLVE_TIMEOUT_MS = 30_000;
40
+ const CACHEABLE_STATUSES = [200, 203, 204];
41
+ // A fresh instance means a new repo peerId and cold in-memory state, so a tab
42
+ // seeing a changed id knows to re-subscribe. Sent in `hello` and every `pong`.
33
43
  const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
34
44
  const WORKER_BOOT_TIME = Date.now();
35
- // ── Forward console output + uncaught errors to the main thread ─────────
36
- // The SharedWorker has its own console that's a pain to find (chrome://inspect
37
- // shared workers). Patch console.* and the global error handlers to also
38
- // post back over every connected tab's control port, tagged [automerge-worker].
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
- }
45
+ // `debug` reads localStorage, which a SharedWorker doesn't have, so debugging is
46
+ // toggled by a control message from a tab instead.
47
+ let debugging = false;
48
+ function log(...args) {
49
+ if (debugging)
50
+ console.log("[automerge-worker]", ...args);
104
51
  }
105
- // Logs emitted before any tab has connected (e.g. during wasm boot) would
106
- // otherwise be lost buffer a bounded number and flush on first connect.
52
+ // ── Console forwarding ─────────────────────────────────────────────────
53
+ // The SharedWorker's own console is buried in chrome://inspect, so mirror
54
+ // everything over each connected tab's control port.
55
+ const controlPorts = new Set();
56
+ // Logs emitted before any tab connects (wasm boot) would otherwise be lost.
107
57
  const preConnectBuffer = [];
108
58
  const MAX_BUFFER = 200;
109
59
  function serializeArg(arg) {
@@ -118,21 +68,23 @@ function serializeArg(arg) {
118
68
  return String(arg);
119
69
  }
120
70
  }
71
+ function postToPort(port, message) {
72
+ try {
73
+ port.postMessage(message);
74
+ }
75
+ catch (error) {
76
+ console.warn(`sending failed`, error);
77
+ }
78
+ }
121
79
  function forwardToMainThread(level, rawArgs) {
122
80
  const args = rawArgs.map(serializeArg);
123
81
  if (!controlPorts.size) {
124
- if (preConnectBuffer.length < MAX_BUFFER) {
82
+ if (preConnectBuffer.length < MAX_BUFFER)
125
83
  preConnectBuffer.push({ level, args });
126
- }
127
84
  return;
128
85
  }
129
86
  for (const port of controlPorts) {
130
- try {
131
- port.postMessage({ type: "console", level, args });
132
- }
133
- catch {
134
- // Port may be closing — ignore.
135
- }
87
+ postToPort(port, { type: "console", level, args });
136
88
  }
137
89
  }
138
90
  for (const level of ["log", "info", "warn", "error", "debug"]) {
@@ -156,689 +108,563 @@ self.addEventListener("unhandledrejection", (event) => {
156
108
  reason instanceof Error ? reason.stack || reason.message : reason,
157
109
  ]);
158
110
  });
159
- // Boot marker, buffered until the first tab connects. A new instance id means
160
- // the worker restarted (fresh peerId + cold state).
161
- console.warn(`[lifecycle] ${new Date(WORKER_BOOT_TIME).toISOString()} automerge ` +
162
- `SharedWorker started (instance ${WORKER_INSTANCE_ID})`);
163
- // ── Suspension watchdog ─────────────────────────────────────────────────
164
- // A SharedWorker gets no lifecycle events, so infer freeze/suspend from timer
165
- // drift. A large gap means keepalive pongs stalled and the server may have
166
- // reaped us.
111
+ console.warn(`[lifecycle] automerge SharedWorker started (instance ${WORKER_INSTANCE_ID})`);
167
112
  const WATCHDOG_TICK_MS = 5_000;
168
- const WATCHDOG_GAP_FACTOR = 2;
169
113
  let watchdogLast = Date.now();
170
114
  setInterval(() => {
171
115
  const now = Date.now();
172
116
  const gap = now - watchdogLast;
173
117
  watchdogLast = now;
174
- if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
175
- console.warn(`[lifecycle] ${new Date(now).toISOString()} watchdog timer gap ` +
176
- `~${Math.round(gap / 1000)}s (expected every ` +
177
- `${WATCHDOG_TICK_MS / 1000}s)`);
118
+ if (gap > WATCHDOG_TICK_MS * 2) {
119
+ console.warn(`[lifecycle] watchdog timer gap ~${Math.round(gap / 1000)}s ` +
120
+ `(expected every ${WATCHDOG_TICK_MS / 1000}s)`);
178
121
  }
179
122
  }, WATCHDOG_TICK_MS);
180
- // Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
181
- // to target keyhive.sync.automerge.org.
182
- const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
183
- // Set the correct env var for automerge_repo_keyhive if need be.
184
- if (useKeyhiveSyncServer) {
185
- globalThis.process = globalThis.process ?? {};
186
- globalThis.process.env = {
187
- ...(globalThis.process.env ?? {}),
188
- KEYHIVE_SERVER_IDENTITY: "keyhive-sync",
189
- };
123
+ // ── Per-tab sync-state subscriptions ───────────────────────────────────
124
+ // A tab's control port subscribes to the documents it cares about and we push
125
+ // only those docs' heads down that port, so tab A never sees tab B's docs. A
126
+ // port's whole subscription set is dropped when it closes, so there's nothing
127
+ // to reference-count or time out.
128
+ const syncWatchers = new Map();
129
+ // Set once the repo's snapshot exists, so a `sync-sub` arriving during boot can
130
+ // be replayed the doc's current heads as soon as it does.
131
+ let replaySyncForPort = null;
132
+ function syncSubscribe(port, documentId) {
133
+ let docs = syncWatchers.get(port);
134
+ if (!docs)
135
+ syncWatchers.set(port, (docs = new Set()));
136
+ if (docs.has(documentId))
137
+ return;
138
+ docs.add(documentId);
139
+ replaySyncForPort?.(documentId, port);
140
+ }
141
+ function syncUnsubscribe(port, documentId) {
142
+ syncWatchers.get(port)?.delete(documentId);
143
+ }
144
+ function pushSyncState(message) {
145
+ for (const [port, docs] of syncWatchers) {
146
+ if (docs.has(message.documentId))
147
+ postToPort(port, message);
148
+ }
190
149
  }
191
- const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
192
- ? "wss://keyhive.sync.automerge.org"
193
- : "wss://subduction.sync.inkandswitch.com";
194
- // The subduction WebSocket lives in its own worker so socket I/O (and
195
- // keepalive pongs) keep flowing even when this SharedWorker's thread is busy
196
- // syncing. We can't spawn that worker ourselves — Chrome doesn't expose the
197
- // Worker constructor inside SharedWorkerGlobalScope — so tabs spawn the
198
- // shipped SharedWorker proxy entry and donate its port to us (donatePort in
199
- // setup.ts). The provider hands WorkerWebSocketEndpoint whichever port is
200
- // current, healing across late arrival and proxy-worker restarts.
201
150
  const subductionPortProvider = makePortProvider();
202
- // A/B bench toggle: the tab appends ?ws-mode=inline to our URL (SharedWorker
203
- // scope has no localStorage; see getAutomergeWorker in setup.ts). "inline"
204
- // passes the bare URL string so the socket lives on this thread — the
205
- // pre-worker behaviour — as the control arm for benchmarking the
206
- // worker-based endpoint. Default: "worker".
207
- const WS_MODE = new URL(self.location.href).searchParams.get("ws-mode") === "inline"
208
- ? "inline"
209
- : "worker";
210
- // Optional windowFrames override (bench knob — max un-acked frames the io
211
- // proxy delivers before pausing; endpoint default is 128).
212
- const WS_WINDOW_FRAMES = Number(new URL(self.location.href).searchParams.get("ws-window")) ||
213
- undefined;
214
- // Memoized so a repo-construction retry (getRepoHive clears its promise on
215
- // failure) reuses the same endpoint instead of leaking one per attempt.
151
+ // Memoized so a construction retry reuses the endpoint instead of leaking one
152
+ // per attempt.
216
153
  let subductionEndpoints = null;
217
154
  function getSubductionEndpoints() {
218
- if (!subductionEndpoints) {
219
- log(`subduction websocket mode: ${WS_MODE}`);
220
- subductionEndpoints =
221
- WS_MODE === "inline"
222
- ? [SUBDUCTION_SYNC_URL]
223
- : [
224
- new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
225
- worker: subductionPortProvider.source,
226
- ...(WS_WINDOW_FRAMES ? { windowFrames: WS_WINDOW_FRAMES } : {}),
227
- }),
228
- ];
155
+ return (subductionEndpoints ??= [
156
+ new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
157
+ worker: subductionPortProvider.source,
158
+ }),
159
+ ]);
160
+ }
161
+ let repoHivePromise = null;
162
+ function getRepoHive() {
163
+ if (!repoHivePromise) {
164
+ repoHivePromise = setUpRepoHive();
165
+ // Don't permanently cache a rejection (e.g. the wasm fetch failed) — clear
166
+ // the slot so the next caller retries from scratch.
167
+ repoHivePromise.catch(() => {
168
+ repoHivePromise = null;
169
+ });
229
170
  }
230
- return subductionEndpoints;
171
+ return repoHivePromise;
231
172
  }
232
- const RESOLVE_TIMEOUT_MS = 30_000;
233
- // Backoff re-sync of stuck/diverged docs. Only this worker is connected to the
234
- // sync server, so it's the only place that can notice a doc whose heads have
235
- // settled out of sync with the server and re-arm a sync round for it.
236
- const RESYNC_GRACE_MS = 8_000; // must be *stably* diverged this long first
237
- const RESYNC_INITIAL_DELAY_MS = 5_000; // first backoff cooldown after a resync
238
- const RESYNC_MAX_DELAY_MS = 60_000; // backoff cap
239
- const RESYNC_REVIEW_INTERVAL_MS = 5_000; // how often stuck docs are re-checked
240
- const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
173
+ async function setUpRepoHive() {
174
+ log("fetching wasm");
175
+ const [automergeWasm, subductionWasm] = await Promise.all([
176
+ fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
177
+ fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
178
+ ]);
179
+ initSubductionSync(new Uint8Array(subductionWasm));
180
+ await initializeWasm(new Uint8Array(automergeWasm));
181
+ log("wasm initialized");
182
+ const built = useKeyhive
183
+ ? await buildKeyhiveRepo()
184
+ : await buildPlainRepo();
185
+ self.repo = built.repo;
186
+ if (built.hive)
187
+ self.hive = built.hive;
188
+ if (built.identity)
189
+ self.syncIdentity = built.identity;
190
+ setUpSyncStateBroadcast(built.repo, built.identity);
191
+ // Deliberately not awaited: the network subsystem starts with only the
192
+ // subduction adapter, and the MessageChannel adapter is added later by
193
+ // connectPort, which itself awaits getRepoHive. Blocking here would deadlock
194
+ // that path and starve the handoff handler.
195
+ built.repo.networkSubsystem
196
+ .whenReady()
197
+ .then(() => log("repo network subsystem ready"));
198
+ return { repo: built.repo, hive: built.hive };
199
+ }
200
+ async function buildPlainRepo() {
201
+ const signer = await WebCryptoSigner.setup();
202
+ const identity = {
203
+ peerId: signer.peerId().toString(),
204
+ verifyingKey: signer.verifyingKey().toHex(),
205
+ };
206
+ const repo = new Repo({
207
+ storage: new IndexedDBWorkerStorageAdapter(),
208
+ signer,
209
+ peerId: `automerge-worker-${Math.random().toString(36).slice(2)}`,
210
+ async sharePolicy(peerId) {
211
+ return peerId.includes("storage-server");
212
+ },
213
+ enableRemoteHeadsGossiping: true,
214
+ subductionWebsocketEndpoints: getSubductionEndpoints(),
215
+ });
216
+ console.log("[patchwork] shared-worker subduction identity:", identity);
217
+ return { repo, identity };
218
+ }
219
+ async function buildKeyhiveRepo() {
220
+ initKeyhiveWasm();
221
+ const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
222
+ createRepo: (config) => new Repo(config),
223
+ storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
224
+ peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
225
+ automaticArchiveIngestion: true,
226
+ cachingMode: "periodic",
227
+ // ARK selects the relay via `syncServer`, which pairs the contact card with
228
+ // the matching peer id. Omitting it defaults to "subduction".
229
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
230
+ repo: {
231
+ storage: new IndexedDBWorkerStorageAdapter(),
232
+ subductionWebsocketEndpoints: getSubductionEndpoints(),
233
+ enableRemoteHeadsGossiping: true,
234
+ },
235
+ });
236
+ hive.networkAdapter.whenReady().then(() => {
237
+ hive.networkAdapter.syncKeyhive();
238
+ });
239
+ return { repo, hive };
240
+ }
241
+ // ── Classic sync ───────────────────────────────────────────────────────
241
242
  let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
242
243
  let classicSyncAdapter = null;
243
- let classicSyncConnectPromise = null;
244
- async function connectClassicSyncNetwork(server) {
244
+ let classicSyncConnect = null;
245
+ function connectClassicSyncNetwork(server) {
245
246
  const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
246
- if (classicSyncConnectPromise && classicSyncServer === url) {
247
- return classicSyncConnectPromise;
248
- }
247
+ if (classicSyncConnect && classicSyncServer === url)
248
+ return classicSyncConnect;
249
249
  if (classicSyncAdapter && classicSyncServer !== url) {
250
250
  classicSyncAdapter.disconnect();
251
251
  classicSyncAdapter = null;
252
- classicSyncConnectPromise = null;
253
252
  }
254
253
  classicSyncServer = url;
255
- classicSyncConnectPromise = (async () => {
254
+ const connecting = (async () => {
256
255
  const { repo } = await getRepoHive();
257
256
  if (!classicSyncAdapter) {
258
257
  classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
259
258
  repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
260
259
  }
261
260
  await classicSyncAdapter.whenReady();
262
- log("classic sync connected", { server: url });
261
+ log("classic sync connected", url);
263
262
  })();
264
- try {
265
- await classicSyncConnectPromise;
266
- }
267
- catch (err) {
268
- classicSyncConnectPromise = null;
269
- throw err;
270
- }
271
- }
272
- const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
273
- const cacheableStatuses = [200, 203, 204];
274
- function log(...args) {
275
- if (!debugging)
276
- return;
277
- console.log.call(console, `%cpatchwork:automergeworker%c\n`, `color: #ffaa00; font-weight: bold`, "color: inherit", ...args);
278
- }
279
- let repoHivePromise = null;
280
- const useKeyhive = typeof __KEYHIVE__ !== "undefined" && __KEYHIVE__;
281
- function getRepoHive() {
282
- if (!repoHivePromise) {
283
- repoHivePromise = (async () => {
284
- log("getRepo: starting");
285
- log("fetching wasm modules");
286
- const [amWasmBuf, sdnWasmBuf] = await Promise.all([
287
- fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
288
- fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
289
- ]);
290
- initSubductionSync(new Uint8Array(sdnWasmBuf));
291
- await initializeWasm(new Uint8Array(amWasmBuf));
292
- log("wasm initialized");
293
- if (!useKeyhive) {
294
- const signer = await WebCryptoSigner.setup();
295
- const identity = {
296
- peerId: signer.peerId().toString(),
297
- verifyingKey: signer.verifyingKey().toHex(),
298
- };
299
- const repo = new Repo({
300
- storage: new IndexedDBWorkerStorageAdapter(),
301
- signer,
302
- peerId: ("automerge-worker-" +
303
- Math.random()
304
- .toString(36)
305
- .slice(2)),
306
- async sharePolicy(peerId) {
307
- return peerId.includes("storage-server");
308
- },
309
- enableRemoteHeadsGossiping: true,
310
- subductionWebsocketEndpoints: getSubductionEndpoints(),
311
- });
312
- console.log("[patchwork] shared-worker subduction identity:", identity, "networkSubsystem.adapters:", repo.networkSubsystem.adapters.length);
313
- self.repo = repo;
314
- self.syncIdentity = identity;
315
- setupSyncStateBroadcast(repo, identity);
316
- log("repo constructed (no keyhive), waiting for network subsystem");
317
- repo.networkSubsystem.whenReady().then(() => {
318
- log("repo network subsystem ready");
319
- });
320
- return { repo };
321
- }
322
- initKeyhiveWasm();
323
- // ARK variant for talking to the keyhive-enabled subduction sync server.
324
- const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
325
- createRepo: (config) => new Repo(config),
326
- storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
327
- peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
328
- automaticArchiveIngestion: true,
329
- cachingMode: "periodic",
330
- // ARK selects the relay via `syncServer` ("keyhive" | "subduction"),
331
- // which pairs the contact card with the matching peer id. Omitting it
332
- // defaults to "subduction".
333
- ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
334
- repo: {
335
- storage: new IndexedDBWorkerStorageAdapter(),
336
- subductionWebsocketEndpoints: getSubductionEndpoints(),
337
- enableRemoteHeadsGossiping: true,
338
- },
339
- });
340
- self.repo = repo;
341
- self.hive = hive;
342
- setupSyncStateBroadcast(repo);
343
- log("repo constructed, waiting for network subsystem");
344
- // Don't block getRepoHive() on whenReady() — the network subsystem starts
345
- // with only the subduction adapter, and the MessageChannel adapter is
346
- // added later via connectPort (which awaits getRepoHive). Blocking here
347
- // would deadlock that path and starve the handoff handler.
348
- repo.networkSubsystem.whenReady().then(() => {
349
- log("repo network subsystem ready");
350
- });
351
- hive.networkAdapter.whenReady().then(() => {
352
- hive.networkAdapter.syncKeyhive();
353
- });
354
- return { hive, repo };
355
- })();
356
- // If construction fails (e.g. wasm fetch errors out), don't permanently
357
- // cache the rejection — clear the slot so the next caller can retry from
358
- // scratch.
359
- repoHivePromise.catch(() => {
360
- repoHivePromise = null;
361
- });
362
- }
363
- return repoHivePromise;
263
+ // Clear the memo on failure so a later attempt can retry, and swallow the
264
+ // rejection on this copy so it isn't reported as unhandled — callers get it
265
+ // from the promise we return.
266
+ classicSyncConnect = connecting;
267
+ connecting.catch(() => {
268
+ if (classicSyncConnect === connecting)
269
+ classicSyncConnect = null;
270
+ });
271
+ return connecting;
364
272
  }
365
273
  // ── Sync-state broadcast ───────────────────────────────────────────────
366
- //
367
- // Only this worker is directly connected to the sync server, so it's the only
368
- // place that learns the server's heads (the repo's "subduction-remote-heads"
369
- // event, keyed by each Subduction peer's verifying-key storageId) and whether
370
- // the server link is up ("subduction-connection"). We rebroadcast both on
371
- // SYNCSTATE_CHANNEL so every tab can render a sync indicator without holding
372
- // its own server connection. A tab that opens mid-stream posts {type:"request"}
373
- // to get the current snapshot replayed.
274
+ // Only this worker is connected to the sync server, so it's the only place that
275
+ // learns the server's heads ("subduction-remote-heads", keyed by each Subduction
276
+ // peer's verifying-key storageId) and whether the link is up
277
+ // ("subduction-connection"). Global signals go out on SYNCSTATE_CHANNEL so any
278
+ // tab can render a sync indicator; per-document heads are addressed to
279
+ // subscribers instead (see pushSyncState).
280
+ const RESYNC_GRACE_MS = 8_000; // must be stably diverged this long first
281
+ const RESYNC_INITIAL_DELAY_MS = 5_000;
282
+ const RESYNC_MAX_DELAY_MS = 60_000;
283
+ const RESYNC_REVIEW_INTERVAL_MS = 5_000;
284
+ const OWN_HANDLE_SCAN_INTERVAL_MS = 3_000;
374
285
  let syncStateWired = false;
375
- function setupSyncStateBroadcast(repo, identity) {
286
+ function setUpSyncStateBroadcast(repo, identity) {
376
287
  if (syncStateWired)
377
288
  return;
378
289
  syncStateWired = true;
379
- const channel = new BroadcastChannel(SYNCSTATE_CHANNEL);
380
- // documentId -> storageId (verifying key) -> last-known heads
381
- const snapshot = new Map();
382
- let connected = repo.isSubductionConnected();
383
- // Directly-connected sync-server peer ids (verifying keys). Stable once
384
- // known; tabs use this to judge "synced" against the server specifically.
385
- let serverPeerIds = [];
386
- const postWhoAmI = () => {
387
- if (!identity)
388
- return;
389
- channel.postMessage({
390
- type: "whoami",
391
- peerId: identity.peerId,
392
- verifyingKey: identity.verifyingKey,
393
- });
290
+ const state = {
291
+ repo,
292
+ channel: new BroadcastChannel(SYNCSTATE_CHANNEL),
293
+ identity,
294
+ snapshot: new Map(),
295
+ connected: repo.isSubductionConnected(),
296
+ serverPeerIds: [],
297
+ tracked: new Set(),
298
+ resync: new Map(),
394
299
  };
395
- // Announce our identity so tabs can label which peer rows are this worker.
396
- postWhoAmI();
397
- // Heads are addressed, not broadcast: push a doc's heads only to the control
398
- // ports that subscribed to it (see syncWatchers / pushSyncState).
399
- const postHeads = (documentId, storageId, heads, timestamp) => pushSyncState({
300
+ postWhoAmI(state);
301
+ replaySyncForPort = (documentId, port) => replayDoc(state, documentId, port);
302
+ for (const [port, docs] of syncWatchers) {
303
+ for (const documentId of docs)
304
+ replayDoc(state, documentId, port);
305
+ }
306
+ repo.on("subduction-remote-heads", ({ documentId, storageId, heads, timestamp }) => {
307
+ recordHeads(state, documentId, storageId, [...heads], timestamp);
308
+ // A doc the server reported is one we hold, so advertise our heads for it
309
+ // too. Only this doc: a full scan per event is O(all handles) and goes
310
+ // quadratic during sync bursts. The tick covers general discovery.
311
+ const handle = repo.handles[documentId];
312
+ if (handle)
313
+ trackOwnHandle(state, handle);
314
+ reviewResync(state, documentId);
315
+ });
316
+ repo.on("subduction-connection", ({ connected }) => {
317
+ state.connected = connected;
318
+ postConnection(state);
319
+ if (connected)
320
+ void refreshServerPeers(state);
321
+ });
322
+ // A BroadcastChannel never receives its own posts, so this only sees tabs'
323
+ // requests. Only the global signals are replayed; a late tab gets per-doc
324
+ // heads by subscribing.
325
+ state.channel.addEventListener("message", (event) => {
326
+ if (event.data?.type !== "request")
327
+ return;
328
+ postWhoAmI(state);
329
+ postConnection(state);
330
+ });
331
+ void refreshServerPeers(state);
332
+ scanOwnHandles(state);
333
+ if (!identity)
334
+ return;
335
+ // Subduction-pushed docs don't surface via the "document" event, so discover
336
+ // them by re-scanning repo.handles on a tick.
337
+ setInterval(() => scanOwnHandles(state), OWN_HANDLE_SCAN_INTERVAL_MS);
338
+ // The "stuck" case is precisely when no head events are firing, so the
339
+ // grace/backoff timers can only advance on a tick.
340
+ setInterval(() => reviewAllResync(state), RESYNC_REVIEW_INTERVAL_MS);
341
+ }
342
+ function postWhoAmI(state) {
343
+ if (!state.identity)
344
+ return;
345
+ state.channel.postMessage({
346
+ type: "whoami",
347
+ peerId: state.identity.peerId,
348
+ verifyingKey: state.identity.verifyingKey,
349
+ });
350
+ }
351
+ function postConnection(state) {
352
+ state.channel.postMessage({
353
+ type: "connection",
354
+ connected: state.connected,
355
+ serverPeerIds: state.serverPeerIds,
356
+ });
357
+ }
358
+ function recordHeads(state, documentId, storageId, heads, timestamp) {
359
+ let byStorage = state.snapshot.get(documentId);
360
+ if (!byStorage)
361
+ state.snapshot.set(documentId, (byStorage = new Map()));
362
+ byStorage.set(storageId, { heads, timestamp });
363
+ pushSyncState({
400
364
  type: "sync-state",
401
365
  documentId,
402
366
  storageId,
403
367
  heads,
404
368
  timestamp,
405
369
  });
406
- // Let a `sync-sub` (which may have arrived while the repo was still booting)
407
- // replay this doc's current snapshot to the subscribing port immediately.
408
- const replayDoc = (documentId, port) => {
409
- const byStorage = snapshot.get(documentId);
410
- if (!byStorage)
411
- return;
412
- for (const [storageId, { heads, timestamp }] of byStorage) {
413
- try {
414
- port.postMessage({
415
- type: "sync-state",
416
- documentId,
417
- storageId,
418
- heads,
419
- timestamp,
420
- });
421
- }
422
- catch {
423
- // Port gone; its close handler reaps it.
424
- }
425
- }
426
- };
427
- replaySyncForPort = replayDoc;
428
- // Catch up any ports that subscribed before this wiring existed.
429
- for (const [port, docs] of syncWatchers) {
430
- for (const documentId of docs)
431
- replayDoc(documentId, port);
370
+ }
371
+ function replayDoc(state, documentId, port) {
372
+ const byStorage = state.snapshot.get(documentId);
373
+ if (!byStorage)
374
+ return;
375
+ for (const [storageId, { heads, timestamp }] of byStorage) {
376
+ postToPort(port, {
377
+ type: "sync-state",
378
+ documentId,
379
+ storageId,
380
+ heads,
381
+ timestamp,
382
+ });
432
383
  }
433
- const postConnection = () => channel.postMessage({
434
- type: "connection",
435
- connected,
436
- serverPeerIds,
437
- });
438
- // Learn (and re-announce) which connected Subduction peer is the sync server.
439
- // The peer list is empty until the handshake finishes, so retry briefly.
440
- const refreshServerPeers = async () => {
441
- for (let attempt = 0; attempt < 6; attempt++) {
442
- try {
443
- const ids = await repo.connectedSubductionPeerIds();
444
- if (ids.length > 0) {
445
- serverPeerIds = ids;
446
- postConnection();
447
- return;
448
- }
449
- }
450
- catch {
451
- // repo has no subduction source / not ready yet
452
- }
453
- await new Promise((r) => setTimeout(r, 500));
454
- }
455
- };
456
- // Advertise the worker's OWN heads for every doc it holds (keyed by our
457
- // verifying key), so the worker hop is visible on every document.
458
- //
459
- // Docs pushed in by Subduction that this worker never explicitly opened don't
460
- // surface via the repo's "document" event, so we discover them by re-scanning
461
- // repo.handles (on a tick, and whenever the server reports a doc) and attach a
462
- // heads-changed listener once per doc. No-op when there's no identity (keyhive
463
- // path).
464
- const ownTracked = new Set();
465
- const broadcastOwnHeads = (handle) => {
466
- if (!identity)
467
- return;
468
- const documentId = handle.documentId;
469
- let heads;
470
- try {
471
- heads = [...handle.heads()];
472
- }
473
- catch {
474
- return; // handle not ready yet
475
- }
476
- const timestamp = Date.now();
477
- let byStorage = snapshot.get(documentId);
478
- if (!byStorage) {
479
- byStorage = new Map();
480
- snapshot.set(documentId, byStorage);
481
- }
482
- byStorage.set(identity.peerId, { heads, timestamp });
483
- postHeads(documentId, identity.peerId, heads, timestamp);
484
- reviewResync(documentId);
485
- };
486
- const trackOwnHandle = (handle) => {
487
- if (!identity || ownTracked.has(handle.documentId))
488
- return;
489
- ownTracked.add(handle.documentId);
490
- handle.on("heads-changed", () => broadcastOwnHeads(handle));
491
- broadcastOwnHeads(handle);
492
- };
493
- const scanOwnHandles = () => {
494
- if (!identity)
495
- return;
496
- for (const handle of Object.values(repo.handles)) {
497
- trackOwnHandle(handle);
498
- }
499
- };
500
- // ── Backoff re-sync of stuck/diverged docs ──────────────────────────
501
- //
502
- // Subduction sync is event-driven and only retries syncs it observed *fail*;
503
- // a doc that settles missing commits the server holds — or whose heal retries
504
- // were exhausted — is otherwise never retried. When we're behind and the
505
- // server's advertised heads haven't advanced for a grace window (so it's
506
- // genuinely stuck, not just lagging a live edit), we re-arm its sync round
507
- // with per-doc exponential backoff. Convergence clears the state.
508
- const serverHeadSetsFor = (documentId) => {
509
- const byStorage = snapshot.get(documentId);
510
- if (!byStorage)
511
- return [];
512
- const sets = [];
513
- for (const [storageId, { heads }] of byStorage) {
514
- if (serverPeerIds.includes(storageId))
515
- sets.push(heads);
516
- }
517
- return sets;
518
- };
519
- const resyncState = new Map();
520
- // Inspectable from the SharedWorker console as `self.patchworkResync` to see
521
- // whether/how often a doc is being re-synced and against which server heads.
522
- const resyncDiag = (self.patchworkResync ??= { fires: 0, byDoc: {} });
523
- const reviewResync = (documentId) => {
524
- if (!identity || !connected) {
525
- resyncState.delete(documentId);
526
- return;
527
- }
528
- const handle = repo.handles[documentId];
529
- if (!handle)
530
- return;
531
- const serverSets = serverHeadSetsFor(documentId);
532
- if (serverSets.length === 0) {
533
- resyncState.delete(documentId); // no server signal to compare against
534
- return;
535
- }
536
- // The server advertises subduction *sedimentree* heads (loose-commit +
537
- // fragment-boundary commit ids), which are NOT the Automerge frontier — so
538
- // never compare them to handle.heads() for equality. Instead ask whether we
539
- // already hold every commit the server advertises (`DocHandle.containsHeads`).
540
- // If we do, the server has nothing we're missing → caught up. If not, we're
541
- // genuinely behind and a re-sync can pull the rest.
542
- const serverHeadsUrl = [...new Set(serverSets.flat())];
543
- let haveAll;
384
+ }
385
+ /** The peer list is empty until the handshake finishes, so retry briefly. */
386
+ async function refreshServerPeers(state) {
387
+ for (let attempt = 0; attempt < 6; attempt++) {
544
388
  try {
545
- haveAll = handle.containsHeads(serverHeadsUrl);
389
+ const ids = await state.repo.connectedSubductionPeerIds();
390
+ if (ids.length > 0) {
391
+ state.serverPeerIds = ids;
392
+ postConnection(state);
393
+ return;
394
+ }
546
395
  }
547
396
  catch {
548
- return; // doc not ready, or an undecodable head
549
- }
550
- if (haveAll) {
551
- resyncState.delete(documentId); // we hold everything the server has
552
- return;
553
- }
554
- // Behind. "Stuck" = the server's advertised set hasn't advanced (no
555
- // progress) for a while. Key the grace timer on the server heads only, so
556
- // your own edits churning don't keep resetting it.
557
- const serverSig = [...serverHeadsUrl].sort().join(",");
558
- const now = Date.now();
559
- const prev = resyncState.get(documentId);
560
- if (!prev || prev.serverSig !== serverSig) {
561
- // First sighting, or the server advanced its view (progress): restart.
562
- resyncState.set(documentId, {
563
- serverSig,
564
- since: now,
565
- delay: RESYNC_INITIAL_DELAY_MS,
566
- lastResyncAt: 0,
567
- });
568
- return;
569
- }
570
- if (now - prev.since < RESYNC_GRACE_MS)
571
- return; // not stuck long enough yet
572
- if (now - prev.lastResyncAt < prev.delay)
573
- return; // within backoff cooldown
574
- log("re-syncing behind doc", documentId, { serverSets });
575
- resyncDiag.fires++;
576
- resyncDiag.byDoc[documentId] = {
577
- at: now,
578
- count: (resyncDiag.byDoc[documentId]
579
- ?.count ?? 0) + 1,
580
- serverSets,
581
- };
582
- try {
583
- repo.resyncSubduction(documentId);
584
- }
585
- catch (e) {
586
- log("resyncSubduction failed", e);
397
+ // No subduction source yet.
587
398
  }
588
- prev.lastResyncAt = now;
589
- prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
590
- };
591
- const reviewAllResync = () => {
592
- if (!identity)
593
- return;
594
- for (const documentId of snapshot.keys())
595
- reviewResync(documentId);
596
- for (const id of [...resyncState.keys()]) {
597
- if (!snapshot.has(id))
598
- resyncState.delete(id);
599
- }
600
- };
601
- repo.on("subduction-remote-heads", ({ documentId, storageId, heads, timestamp }) => {
602
- const headsCopy = [...heads];
603
- let byStorage = snapshot.get(documentId);
604
- if (!byStorage) {
605
- byStorage = new Map();
606
- snapshot.set(documentId, byStorage);
399
+ await new Promise((r) => setTimeout(r, 500));
400
+ }
401
+ }
402
+ // Advertise this worker's own heads for every doc it holds, so the worker hop is
403
+ // visible on every document. No-op on the keyhive path, which has no identity.
404
+ function broadcastOwnHeads(state, handle) {
405
+ if (!state.identity)
406
+ return;
407
+ let heads;
408
+ try {
409
+ heads = [...handle.heads()];
410
+ }
411
+ catch {
412
+ return; // handle not ready
413
+ }
414
+ recordHeads(state, handle.documentId, state.identity.peerId, heads, Date.now());
415
+ reviewResync(state, handle.documentId);
416
+ }
417
+ function trackOwnHandle(state, handle) {
418
+ if (!state.identity || state.tracked.has(handle.documentId))
419
+ return;
420
+ state.tracked.add(handle.documentId);
421
+ handle.on("heads-changed", () => broadcastOwnHeads(state, handle));
422
+ broadcastOwnHeads(state, handle);
423
+ }
424
+ function scanOwnHandles(state) {
425
+ if (!state.identity)
426
+ return;
427
+ for (const handle of Object.values(state.repo.handles)) {
428
+ trackOwnHandle(state, handle);
429
+ }
430
+ }
431
+ function serverHeadsFor(state, documentId) {
432
+ const byStorage = state.snapshot.get(documentId);
433
+ if (!byStorage)
434
+ return [];
435
+ const heads = new Set();
436
+ for (const [storageId, entry] of byStorage) {
437
+ if (state.serverPeerIds.includes(storageId)) {
438
+ for (const head of entry.heads)
439
+ heads.add(head);
607
440
  }
608
- byStorage.set(storageId, { heads: headsCopy, timestamp });
609
- postHeads(documentId, storageId, headsCopy, timestamp);
610
- // A doc the server reported is one we hold — make sure we're advertising
611
- // our own heads for it too. Track just this doc: a full scanOwnHandles()
612
- // per event is O(all handles) and goes quadratic during sync bursts,
613
- // starving the thread that's doing the syncing. The 3s tick still covers
614
- // general discovery.
615
- const handle = repo.handles[documentId];
616
- if (handle)
617
- trackOwnHandle(handle);
618
- reviewResync(documentId);
619
- });
620
- repo.on("subduction-connection", ({ connected: isConnected }) => {
621
- connected = isConnected;
622
- postConnection();
623
- if (isConnected)
624
- void refreshServerPeers();
625
- });
626
- // A BroadcastChannel never receives its own posts, so this only sees tabs'
627
- // requests, never our own broadcasts. We replay just the global signals here;
628
- // a late tab gets per-doc heads by subscribing (sync-sub), not from this.
629
- channel.addEventListener("message", (event) => {
630
- const data = event.data;
631
- if (data?.type !== "request")
632
- return;
633
- postWhoAmI();
634
- postConnection();
635
- });
636
- // In case we're already connected by the time this wires up.
637
- void refreshServerPeers();
638
- // Discover the worker's docs by re-scanning repo.handles initially and on a
639
- // tick (Subduction-pushed docs don't surface via the "document" event).
640
- scanOwnHandles();
641
- if (identity)
642
- setInterval(scanOwnHandles, 3000);
643
- // Drive the backoff re-sync of stuck/diverged docs. A tick is essential here:
644
- // the "stuck" case is precisely when no head events are firing, so the
645
- // grace/backoff timers can only advance on a timer.
646
- if (identity)
647
- setInterval(reviewAllResync, RESYNC_REVIEW_INTERVAL_MS);
441
+ }
442
+ return [...heads];
443
+ }
444
+ function reviewResync(state, documentId) {
445
+ if (!state.identity || !state.connected) {
446
+ state.resync.delete(documentId);
447
+ return;
448
+ }
449
+ const handle = state.repo.handles[documentId];
450
+ if (!handle)
451
+ return;
452
+ const serverHeads = serverHeadsFor(state, documentId);
453
+ if (serverHeads.length === 0) {
454
+ state.resync.delete(documentId); // nothing to compare against
455
+ return;
456
+ }
457
+ // The server advertises subduction sedimentree heads (loose-commit and
458
+ // fragment-boundary commit ids), which are NOT the Automerge frontier, so
459
+ // never compare them to handle.heads() for equality. Ask instead whether we
460
+ // already hold every commit the server advertises.
461
+ let haveAll;
462
+ try {
463
+ haveAll = handle.containsHeads(serverHeads);
464
+ }
465
+ catch {
466
+ return; // doc not ready, or an undecodable head
467
+ }
468
+ if (haveAll) {
469
+ state.resync.delete(documentId);
470
+ return;
471
+ }
472
+ // Behind. Key the grace timer on the server heads alone, so your own edits
473
+ // churning don't keep resetting it.
474
+ const serverSig = [...serverHeads].sort().join(",");
475
+ const now = Date.now();
476
+ const prev = state.resync.get(documentId);
477
+ if (!prev || prev.serverSig !== serverSig) {
478
+ // First sighting, or the server made progress: restart the clock.
479
+ state.resync.set(documentId, {
480
+ serverSig,
481
+ since: now,
482
+ delay: RESYNC_INITIAL_DELAY_MS,
483
+ lastResyncAt: 0,
484
+ });
485
+ return;
486
+ }
487
+ if (now - prev.since < RESYNC_GRACE_MS)
488
+ return;
489
+ if (now - prev.lastResyncAt < prev.delay)
490
+ return;
491
+ log("re-syncing behind doc", documentId);
492
+ try {
493
+ state.repo.resyncSubduction(documentId);
494
+ }
495
+ catch (e) {
496
+ log("resyncSubduction failed", e);
497
+ }
498
+ prev.lastResyncAt = now;
499
+ prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
500
+ }
501
+ function reviewAllResync(state) {
502
+ if (!state.identity)
503
+ return;
504
+ for (const documentId of state.snapshot.keys())
505
+ reviewResync(state, documentId);
506
+ for (const id of [...state.resync.keys()]) {
507
+ if (!state.snapshot.has(id))
508
+ state.resync.delete(id);
509
+ }
648
510
  }
649
511
  function dropRepoChannel(repo, channel) {
650
512
  // removeNetworkAdapter pulls the adapter out of networkSubsystem.adapters and
651
- // calls adapter.disconnect(), which (for the MessageChannel adapter) emits the
652
- // "close"/"peer-disconnected" events that also clear #adaptersByPeer.
513
+ // calls disconnect(), which for the MessageChannel adapter emits the
514
+ // close/peer-disconnected events that clear #adaptersByPeer.
653
515
  try {
654
516
  repo.networkSubsystem.removeNetworkAdapter(channel.adapter);
655
517
  }
656
518
  catch (err) {
657
519
  console.error("removeNetworkAdapter failed", err);
658
520
  }
659
- // Belt and braces for the keyhive path, where the registered adapter is a
660
- // wrapper: make sure the underlying port is disconnected and closed too.
521
+ // On the keyhive path the registered adapter is a wrapper, so make sure the
522
+ // underlying port is disconnected and closed too.
661
523
  try {
662
524
  channel.mcAdapter.disconnect();
663
525
  }
664
- catch {
665
- // Already disconnected by removeNetworkAdapter above.
666
- }
526
+ catch { }
667
527
  try {
668
528
  channel.port.close();
669
529
  }
670
- catch {
671
- // Port already closed by the departing tab.
672
- }
530
+ catch { }
673
531
  }
674
532
  async function dropConnection(connection) {
675
533
  if (!connection.channels.size || !repoHivePromise)
676
534
  return;
677
535
  const { repo } = await getRepoHive();
678
536
  log(`tab gone — removing ${connection.channels.size} network adapter(s)`);
679
- for (const channel of connection.channels) {
537
+ for (const channel of connection.channels)
680
538
  dropRepoChannel(repo, channel);
681
- }
682
539
  connection.channels.clear();
683
540
  }
684
- // Connect client MessagePorts to the repo for sync
685
541
  async function connectPort(port, connection) {
686
542
  const { hive, repo } = await getRepoHive();
687
- const networkAdapter = new MessageChannelNetworkAdapter(port, {
543
+ const mcAdapter = new MessageChannelNetworkAdapter(port, {
688
544
  useWeakRef: true,
689
545
  });
690
- const track = (adapter) => {
691
- connection.channels.add({ adapter, mcAdapter: networkAdapter, port });
692
- };
693
546
  if (!hive) {
694
- repo.networkSubsystem.addNetworkAdapter(networkAdapter);
695
- track(networkAdapter);
547
+ repo.networkSubsystem.addNetworkAdapter(mcAdapter);
548
+ connection.channels.add({ adapter: mcAdapter, mcAdapter, port });
696
549
  return;
697
550
  }
698
551
  const onlyShareWithHardcodedServerPeerId = false;
699
552
  const periodicallyRequestKeyhiveSync = false;
700
- const keyhiveNetworkAdapter = hive.createKeyhiveNetworkAdapter(networkAdapter, onlyShareWithHardcodedServerPeerId, periodicallyRequestKeyhiveSync, 2000);
701
- keyhiveNetworkAdapter.on("message", async (msg) => {
702
- if ((msg.type === "sync" || msg.type === "request") && msg.documentId) {
703
- const handle = repo.handles[msg.documentId];
704
- if (!handle || handle.state === "unavailable") {
705
- const url = `automerge:${msg.documentId}`;
706
- repo.findWithProgress(url);
707
- repo.shareConfigChanged();
708
- }
709
- }
553
+ const adapter = hive.createKeyhiveNetworkAdapter(mcAdapter, onlyShareWithHardcodedServerPeerId, periodicallyRequestKeyhiveSync, 2000);
554
+ adapter.on("message", (msg) => {
555
+ if (msg.type !== "sync" && msg.type !== "request")
556
+ return;
557
+ if (!msg.documentId)
558
+ return;
559
+ const handle = repo.handles[msg.documentId];
560
+ if (handle && handle.state !== "unavailable")
561
+ return;
562
+ repo.findWithProgress(`automerge:${msg.documentId}`);
563
+ repo.shareConfigChanged();
710
564
  });
711
- keyhiveNetworkAdapter.on("ingest-remote", () => {
565
+ adapter.on("ingest-remote", () => {
712
566
  hive.notifySameAgentKeyhiveChange();
713
567
  hive.networkAdapter.syncKeyhive?.();
714
568
  repo.shareConfigChanged();
715
569
  });
716
- repo.networkSubsystem.addNetworkAdapter(keyhiveNetworkAdapter);
717
- track(keyhiveNetworkAdapter);
570
+ repo.networkSubsystem.addNetworkAdapter(adapter);
571
+ connection.channels.add({ adapter, mcAdapter, port });
718
572
  }
719
573
  function handleControlMessage(event, controlPort, connection) {
720
574
  const data = event.data;
721
- // Tally of control messages received, readable from the SharedWorker console
722
- // as `self.patchworkControl`. Not using log(): that's gated on `debugging`,
723
- // which is only enabled by a {type:"debug"} message arriving over this same
724
- // channel.
725
- const stats = (self.patchworkControl ??= {
726
- connects: 0,
727
- byType: {},
728
- });
729
- stats.byType[String(data?.type ?? "<untyped>")] =
730
- (stats.byType[String(data?.type ?? "<untyped>")] ?? 0) + 1;
731
- if (data?.type === "port") {
732
- log("received repo channel");
733
- const [repoPort] = event.ports;
734
- const id = data.id;
735
- connectPort(repoPort, connection).then(() => controlPort.postMessage({ type: "port-ready", id }), (err) => {
736
- console.error("connectPort failed", err);
737
- // Tell the client we failed so it doesn't hang forever.
738
- controlPort.postMessage({
739
- type: "port-failed",
740
- id,
741
- error: String(err),
575
+ switch (data?.type) {
576
+ case "port": {
577
+ log("received repo channel");
578
+ const [repoPort] = event.ports;
579
+ connectPort(repoPort, connection).then(() => controlPort.postMessage({ type: "port-ready", id: data.id }), (err) => {
580
+ console.error("connectPort failed", err);
581
+ // Tell the tab so it doesn't hang until its timeout.
582
+ controlPort.postMessage({
583
+ type: "port-failed",
584
+ id: data.id,
585
+ error: String(err),
586
+ });
742
587
  });
743
- });
744
- }
745
- else if (data?.type === "sync-sub") {
746
- if (typeof data.documentId === "string") {
747
- syncSubscribe(controlPort, data.documentId);
588
+ return;
748
589
  }
749
- }
750
- else if (data?.type === "sync-unsub") {
751
- if (typeof data.documentId === "string") {
752
- syncUnsubscribe(controlPort, data.documentId);
590
+ case "sync-sub":
591
+ if (typeof data.documentId === "string") {
592
+ syncSubscribe(controlPort, data.documentId);
593
+ }
594
+ return;
595
+ case "sync-unsub":
596
+ if (typeof data.documentId === "string") {
597
+ syncUnsubscribe(controlPort, data.documentId);
598
+ }
599
+ return;
600
+ case "debug":
601
+ debugging = data.debug;
602
+ log("automerge worker debugging enabled");
603
+ return;
604
+ case "connect-classic-sync": {
605
+ const [replyPort] = event.ports;
606
+ const server = typeof data.server === "string"
607
+ ? data.server
608
+ : DEFAULT_CLASSIC_SYNC_SERVER;
609
+ connectClassicSyncNetwork(server).then(() => {
610
+ replyPort?.postMessage({ type: "connect-classic-sync-ready" });
611
+ replyPort?.close();
612
+ }, (err) => {
613
+ console.error("connectClassicSyncNetwork failed", err);
614
+ replyPort?.postMessage({
615
+ type: "connect-classic-sync-failed",
616
+ error: String(err),
617
+ });
618
+ replyPort?.close();
619
+ });
620
+ return;
753
621
  }
754
- }
755
- else if (data?.type === "debug") {
756
- debugging = data.debug;
757
- log("automerge worker debugging enabled");
758
- }
759
- else if (data?.type === "connect-classic-sync") {
760
- const [replyPort] = event.ports;
761
- const server = typeof data.server === "string"
762
- ? data.server
763
- : DEFAULT_CLASSIC_SYNC_SERVER;
764
- connectClassicSyncNetwork(server)
765
- .then(() => {
766
- replyPort?.postMessage({ type: "connect-classic-sync-ready" });
767
- replyPort?.close();
768
- log("classic sync connected on demand", { server });
769
- })
770
- .catch((err) => {
771
- console.error("connectClassicSyncNetwork failed", err);
772
- replyPort?.postMessage({
773
- type: "connect-classic-sync-failed",
774
- error: String(err),
622
+ case "ping":
623
+ controlPort.postMessage({
624
+ type: "pong",
625
+ id: data.id,
626
+ instanceId: WORKER_INSTANCE_ID,
775
627
  });
776
- replyPort?.close();
777
- });
778
- }
779
- else if (data?.type === "ping") {
780
- // Heartbeat: reply so the tab can detect our death or restart.
781
- controlPort.postMessage({
782
- type: "pong",
783
- id: data.id,
784
- instanceId: WORKER_INSTANCE_ID,
785
- });
628
+ return;
786
629
  }
787
630
  }
788
631
  self.addEventListener("connect", (event) => {
789
632
  const controlPort = event.ports[0];
790
633
  const connection = { channels: new Set() };
791
- (self.patchworkControl ??= { connects: 0, byType: {} }).connects++;
792
634
  controlPort.addEventListener("message", (messageEvent) => {
793
635
  handleControlMessage(messageEvent, controlPort, connection);
794
636
  });
795
- // Let the subduction port provider negotiate over this tab's control port
796
- // (the tab side runs donatePort; the messages are channel-tagged so they
797
- // coexist with our control protocol above).
637
+ // The tab side runs donatePort; the messages are channel-tagged so they
638
+ // coexist with the control protocol above.
798
639
  subductionPortProvider.attachClient(controlPort);
799
- // Fires when the owning page is destroyed. Browsers without the close
800
- // event fall back to the adapters' lazy useWeakRef cleanup.
640
+ // Fires when the owning page is destroyed. Browsers without the close event
641
+ // fall back to the adapters' lazy useWeakRef cleanup.
801
642
  controlPort.addEventListener("close", () => {
802
643
  controlPorts.delete(controlPort);
803
- // The tab is gone — drop its sync subscriptions wholesale so we stop
804
- // pushing it heads (no per-doc unsub needed, no leak).
805
644
  syncWatchers.delete(controlPort);
806
645
  void dropConnection(connection);
807
646
  });
808
647
  controlPort.start();
809
- // Greet the tab with our per-boot instance id so it can detect a restart
810
- // (a different id than last seen) even if no port "close" fired.
811
648
  controlPort.postMessage({
812
649
  type: "hello",
813
650
  instanceId: WORKER_INSTANCE_ID,
814
651
  bootTime: WORKER_BOOT_TIME,
815
652
  });
816
- // Start forwarding console output to this tab, and flush anything buffered
817
- // while no tab was connected (e.g. boot-time logs) to the first arrival.
818
653
  controlPorts.add(controlPort);
819
- if (preConnectBuffer.length) {
820
- for (const { level, args } of preConnectBuffer.splice(0)) {
821
- try {
822
- controlPort.postMessage({ type: "console", level, args });
823
- }
824
- catch {
825
- // Port may already be gone — ignore.
826
- }
827
- }
654
+ for (const { level, args } of preConnectBuffer.splice(0)) {
655
+ postToPort(controlPort, { type: "console", level, args });
828
656
  }
829
657
  });
830
- // ── Automerge URL resolution ───────────────────────────────────────────
831
- /**
832
- * Wait for the requested heads to appear in the handle's local history —
833
- * they may still be syncing toward us when the request lands. Resolves
834
- * false if the signal aborts before they arrive.
835
- */
836
658
  function waitForHeads(handle, hexHeads, signal) {
837
659
  if (hasHeads(handle.doc(), hexHeads))
838
660
  return Promise.resolve(true);
839
661
  if (signal.aborted)
840
662
  return Promise.resolve(false);
841
663
  return new Promise((resolve) => {
664
+ const cleanup = () => {
665
+ handle.off("heads-changed", check);
666
+ signal.removeEventListener("abort", onAbort);
667
+ };
842
668
  const check = () => {
843
669
  if (!hasHeads(handle.doc(), hexHeads))
844
670
  return;
@@ -849,14 +675,9 @@ function waitForHeads(handle, hexHeads, signal) {
849
675
  cleanup();
850
676
  resolve(false);
851
677
  };
852
- const cleanup = () => {
853
- handle.off("heads-changed", check);
854
- signal.removeEventListener("abort", onAbort);
855
- };
856
678
  handle.on("heads-changed", check);
857
679
  signal.addEventListener("abort", onAbort);
858
- // The heads may have landed between the synchronous check above and
859
- // subscribing.
680
+ // The heads may have landed between the check above and subscribing.
860
681
  check();
861
682
  });
862
683
  }
@@ -867,43 +688,30 @@ function waitForHeads(handle, hexHeads, signal) {
867
688
  */
868
689
  class AbortHandoff extends Error {
869
690
  }
870
- async function resolveAutomergeUrl(automergeURL) {
691
+ async function resolveAutomergeUrl(automergeURL, signal) {
871
692
  const { repo } = await getRepoHive();
872
- const href = automergeURL.href;
873
- const [maybeAutomergeUrl, ...path] = href.split("/");
693
+ const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/");
874
694
  if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
875
695
  return new Response("invalid automerge url", { status: 400 });
876
696
  }
877
- // Trim trailing empty path segment
878
697
  if (path.length && !path[path.length - 1])
879
698
  path.pop();
880
699
  const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
881
- const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
700
+ // todo, maybe a bad idea? maybe we should throw instead of es-module-caching
701
+ // the headless req
882
702
  if (!heads) {
883
703
  const folder = await repo.find(maybeAutomergeUrl, { signal });
884
- const latestHeads = folder.heads();
885
- const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
886
- let location = `/${encodeURIComponent(url)}`;
887
- if (path.length)
888
- location += `/${path.join("/")}`;
704
+ const url = stringifyAutomergeUrl({ documentId, heads: folder.heads() });
705
+ const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`;
889
706
  return Response.redirect(location, 307);
890
707
  }
891
- // Load by documentId only so we can verify the requested heads are actually
892
- // in our local history. repo.find with a heads-bearing URL returns a view
893
- // at those heads, which silently materializes garbage if we never synced them.
894
708
  const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
895
709
  signal,
896
710
  });
897
- // The heads may not have synced to us yet — give them the rest of the
898
- // resolve window to arrive before giving up.
899
711
  if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
900
- // Not a 404: the heads may still be on their way, and this exact URL will
901
- // be requested again once they land. Fail it as a network error so the
902
- // caller doesn't memoize the miss.
903
712
  throw new AbortHandoff(`heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`);
904
713
  }
905
- const rootHandle = baseHandle.view(heads);
906
- const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
714
+ const resolved = await resolvePath(repo, baseHandle.view(heads), path.map(decodeURIComponent));
907
715
  if (!resolved) {
908
716
  throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
909
717
  }
@@ -915,59 +723,41 @@ async function resolveAutomergeUrl(automergeURL) {
915
723
  headers: { "content-type": resolved.type },
916
724
  });
917
725
  }
918
- // ── Handoff: resolve special URLs for the service worker ──────────────
919
726
  const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
920
- /**
921
- * Pull the special URL out of a handoff request, whichever generation of
922
- * service worker sent it. Returns null rather than throwing — a stale
923
- * worker on the other end of the channel can send us anything.
924
- */
925
- function parseHandoffhandoffURL(request) {
926
- try {
927
- // The service worker already decoded the special URL out of the
928
- // request it's holding and sends it alongside.
929
- if (request.handoffURL)
930
- return new URL(request.handoffURL);
931
- // TODO(backcompat): a briefly-deployed shape sent the special URL in
932
- // request.url and the http URL in cacheKey.
933
- if (request.cacheKey)
934
- return new URL(request.url);
935
- // TODO(backcompat): older service workers send only the http URL,
936
- // special URL still URI-encoded in its pathname.
937
- return new URL(decodeURIComponent(new URL(request.url).pathname.slice(1)));
938
- }
939
- catch {
940
- return null;
941
- }
727
+ function replyToHandoff(id, status, body) {
728
+ handoffChannel.postMessage({
729
+ id,
730
+ type: "response",
731
+ response: { status, body, headers: { "content-type": "text/plain" } },
732
+ });
733
+ }
734
+ function impatience(limit) {
735
+ return new Promise((_, reject) => setTimeout(() => reject(new Error(`resolve timeout after ${limit}ms`)), limit));
942
736
  }
943
737
  async function handleHandoffRequest(message) {
944
738
  const { id, cachename, request } = message;
945
- const handoffURL = parseHandoffhandoffURL(request);
946
- if (!handoffURL) {
947
- console.error(`automerge worker couldn't parse a special url out of handoff request`, request);
948
- handoffChannel.postMessage({
949
- id,
950
- type: "response",
951
- response: {
952
- status: 400,
953
- body: `couldn't parse a special url out of ${request.url}`,
954
- headers: { "content-type": "text/plain" },
955
- },
956
- });
739
+ let handoff;
740
+ try {
741
+ handoff = new URL(request.handoffURL);
742
+ }
743
+ catch {
744
+ console.error("couldn't parse handoff url", request);
745
+ replyToHandoff(id, 400, `couldn't parse a special url out of ${request.url}`);
957
746
  return;
958
747
  }
959
- if (handoffURL.protocol != "automerge:") {
960
- // This worker only resolves automerge: URLs. Other handlers may be
961
- // listening on the channel for other schemes — stay quiet rather than
962
- // clobbering their reply with an error.
963
- return log(`ignoring handoff ${id} for non-automerge url ${handoffURL}`);
748
+ // Other handlers may be listening on the channel for other schemes, so stay
749
+ // quiet rather than clobbering their reply with an error.
750
+ if (handoff.protocol !== "automerge:") {
751
+ log(`ignoring handoff ${id} for non-automerge url ${handoff}. not my circus, not my monkeys`);
752
+ return;
964
753
  }
965
754
  let response;
966
755
  try {
967
- log(`resolving handoff ${id} for ${handoffURL}`);
756
+ log(`resolving handoff ${id} for ${handoff}`);
757
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
968
758
  response = await Promise.race([
969
- resolveAutomergeUrl(handoffURL),
970
- new Promise((_, reject) => setTimeout(() => reject(new Error(`resolve timeout after ${RESOLVE_TIMEOUT_MS}ms`)), RESOLVE_TIMEOUT_MS)),
759
+ resolveAutomergeUrl(handoff, signal),
760
+ impatience(RESOLVE_TIMEOUT_MS),
971
761
  ]);
972
762
  }
973
763
  catch (error) {
@@ -979,43 +769,17 @@ async function handleHandoffRequest(message) {
979
769
  });
980
770
  return;
981
771
  }
982
- const body = error instanceof Error
772
+ console.error(`error resolving ${request.url}`, error);
773
+ replyToHandoff(id, 557, error instanceof Error
983
774
  ? `${error.message}\n\n${error.stack}`
984
- : String(error);
985
- console.error(`automerge worker error resolving ${request.url}`, error);
986
- handoffChannel.postMessage({
987
- id,
988
- type: "response",
989
- response: {
990
- status: 557,
991
- body,
992
- headers: { "content-type": "text/plain" },
993
- },
994
- });
775
+ : String(error));
995
776
  return;
996
777
  }
997
778
  try {
998
- if (cacheableStatuses.includes(response.status)) {
999
- // Reconstruct the request the service worker is holding so the entry
1000
- // matches on its cache.match. (destination isn't constructible, but it
1001
- // doesn't participate in cache matching.) request.url is the http URL
1002
- // the SW is holding except in the briefly-deployed cacheKey shape.
1003
- const cacheKey = new Request(request.cacheKey ?? request.url, {
1004
- method: request.method,
1005
- headers: request.headers,
1006
- referrer: request.referrer,
1007
- });
1008
- const cache = await caches.open(cachename);
1009
- await cache.put(cacheKey, response);
1010
- log(`cached ${cacheKey.url} in ${cachename}`);
1011
- handoffChannel.postMessage({
1012
- id,
1013
- type: "cached",
1014
- });
1015
- }
1016
- else {
1017
- // Errors, redirects &c — things that shouldn't be cached — go back
1018
- // inline for the service worker to serve directly.
779
+ if (!CACHEABLE_STATUSES.includes(response.status)) {
780
+ // Errors, redirects and the like go back inline for the service worker to
781
+ // serve directly, so they aren't cached forever (still in esmodulecache,
782
+ // cleared after a refresh)
1019
783
  log(`responding inline to ${request.url} with ${response.status}`);
1020
784
  handoffChannel.postMessage({
1021
785
  id,
@@ -1026,26 +790,34 @@ async function handleHandoffRequest(message) {
1026
790
  body: response.body ? await response.text() : undefined,
1027
791
  },
1028
792
  });
793
+ return;
1029
794
  }
1030
- }
1031
- catch (error) {
1032
- console.error(`automerge worker failed to reply for ${request.url}`, error);
795
+ // Reconstruct the request the service worker is holding so the entry matches
796
+ // its cache.match. `destination` isn't constructible but doesn't participate
797
+ // in cache matching.
798
+ const cacheKey = new Request(request.url, {
799
+ method: request.method,
800
+ headers: request.headers,
801
+ referrer: request.referrer,
802
+ });
803
+ const cache = await caches.open(cachename);
804
+ await cache.put(cacheKey, response);
805
+ log(`cached ${cacheKey.url} in ${cachename}`);
1033
806
  handoffChannel.postMessage({
1034
807
  id,
1035
- type: "response",
1036
- response: {
1037
- status: 558,
1038
- body: String(error),
1039
- headers: { "content-type": "text/plain" },
1040
- },
808
+ type: "cached",
1041
809
  });
1042
810
  }
811
+ catch (error) {
812
+ console.error(`failed to reply for ${request.url}`, error);
813
+ replyToHandoff(id, 558, String(error));
814
+ }
1043
815
  }
1044
816
  handoffChannel.addEventListener("message", (event) => {
1045
817
  if (event.data?.type === "request") {
1046
818
  void handleHandoffRequest(event.data);
1047
819
  }
1048
820
  });
1049
- // Announce ourselves so the service worker can re-broadcast any handoff
1050
- // requests that were sent while we were still booting.
821
+ // Announce ourselves so the service worker can re-broadcast handoff requests
822
+ // sent while we were booting.
1051
823
  handoffChannel.postMessage({ type: "online" });