@inkandswitch/patchwork-bootloader 0.4.2 → 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,622 +108,524 @@ 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] worker resumed after ~${Math.round(gap / 1000)}s gap ` +
176
- `(timer expected every ${WATCHDOG_TICK_MS / 1000}s) — likely ` +
177
- `suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
178
- `during this window, so the sync server may have reaped us. at ` +
179
- `${new Date(now).toISOString()}`);
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)`);
180
121
  }
181
122
  }, WATCHDOG_TICK_MS);
182
- // Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
183
- // to target keyhive.sync.automerge.org.
184
- const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
185
- // Set the correct env var for automerge_repo_keyhive if need be.
186
- if (useKeyhiveSyncServer) {
187
- globalThis.process = globalThis.process ?? {};
188
- globalThis.process.env = {
189
- ...(globalThis.process.env ?? {}),
190
- KEYHIVE_SERVER_IDENTITY: "keyhive-sync",
191
- };
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
+ }
192
149
  }
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
150
  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.
151
+ // Memoized so a construction retry reuses the endpoint instead of leaking one
152
+ // per attempt.
218
153
  let subductionEndpoints = null;
219
154
  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;
155
+ return (subductionEndpoints ??= [
156
+ new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
157
+ worker: subductionPortProvider.source,
158
+ }),
159
+ ]);
235
160
  }
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
244
- const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
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
+ });
170
+ }
171
+ return repoHivePromise;
172
+ }
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 ───────────────────────────────────────────────────────
245
242
  let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
246
243
  let classicSyncAdapter = null;
247
- let classicSyncConnectPromise = null;
248
- async function connectClassicSyncNetwork(server) {
244
+ let classicSyncConnect = null;
245
+ function connectClassicSyncNetwork(server) {
249
246
  const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
250
- if (classicSyncConnectPromise && classicSyncServer === url) {
251
- return classicSyncConnectPromise;
252
- }
247
+ if (classicSyncConnect && classicSyncServer === url)
248
+ return classicSyncConnect;
253
249
  if (classicSyncAdapter && classicSyncServer !== url) {
254
250
  classicSyncAdapter.disconnect();
255
251
  classicSyncAdapter = null;
256
- classicSyncConnectPromise = null;
257
252
  }
258
253
  classicSyncServer = url;
259
- classicSyncConnectPromise = (async () => {
254
+ const connecting = (async () => {
260
255
  const { repo } = await getRepoHive();
261
256
  if (!classicSyncAdapter) {
262
257
  classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
263
258
  repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
264
259
  }
265
260
  await classicSyncAdapter.whenReady();
266
- log("classic sync connected", { server: url });
261
+ log("classic sync connected", url);
267
262
  })();
268
- try {
269
- await classicSyncConnectPromise;
270
- }
271
- catch (err) {
272
- classicSyncConnectPromise = null;
273
- throw err;
274
- }
275
- }
276
- const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
277
- const cacheableStatuses = [200, 203, 204];
278
- function log(...args) {
279
- if (!debugging)
280
- return;
281
- console.log.call(console, `%cpatchwork:automergeworker%c\n`, `color: #ffaa00; font-weight: bold`, "color: inherit", ...args);
282
- }
283
- let repoHivePromise = null;
284
- const useKeyhive = typeof __KEYHIVE__ !== "undefined" && __KEYHIVE__;
285
- function getRepoHive() {
286
- if (!repoHivePromise) {
287
- repoHivePromise = (async () => {
288
- log("getRepo: starting");
289
- log("fetching wasm modules");
290
- const [amWasmBuf, sdnWasmBuf] = await Promise.all([
291
- fetch("/automerge.wasm?worker").then((r) => r.arrayBuffer()),
292
- fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
293
- ]);
294
- initSubductionSync(new Uint8Array(sdnWasmBuf));
295
- await initializeWasm(new Uint8Array(amWasmBuf));
296
- log("wasm initialized");
297
- if (!useKeyhive) {
298
- const signer = await WebCryptoSigner.setup();
299
- const identity = {
300
- peerId: signer.peerId().toString(),
301
- verifyingKey: signer.verifyingKey().toHex(),
302
- };
303
- const repo = new Repo({
304
- storage: new IndexedDBWorkerStorageAdapter(),
305
- signer,
306
- peerId: ("automerge-worker-" +
307
- Math.random()
308
- .toString(36)
309
- .slice(2)),
310
- async sharePolicy(peerId) {
311
- return peerId.includes("storage-server");
312
- },
313
- enableRemoteHeadsGossiping: true,
314
- subductionWebsocketEndpoints: getSubductionEndpoints(),
315
- });
316
- console.log("[patchwork] shared-worker subduction identity:", identity, "networkSubsystem.adapters:", repo.networkSubsystem.adapters.length);
317
- self.repo = repo;
318
- self.syncIdentity = identity;
319
- setupSyncStateBroadcast(repo, identity);
320
- log("repo constructed (no keyhive), waiting for network subsystem");
321
- repo.networkSubsystem.whenReady().then(() => {
322
- log("repo network subsystem ready");
323
- });
324
- return { repo };
325
- }
326
- initKeyhiveWasm();
327
- // ARK variant for talking to the keyhive-enabled subduction sync server.
328
- const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
329
- createRepo: (config) => new Repo(config),
330
- storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
331
- peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
332
- automaticArchiveIngestion: true,
333
- cachingMode: "periodic",
334
- // ARK selects the relay via `syncServer` ("keyhive" | "subduction"),
335
- // which pairs the contact card with the matching peer id. Omitting it
336
- // defaults to "subduction".
337
- ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
338
- repo: {
339
- storage: new IndexedDBWorkerStorageAdapter(),
340
- subductionWebsocketEndpoints: getSubductionEndpoints(),
341
- enableRemoteHeadsGossiping: true,
342
- },
343
- });
344
- self.repo = repo;
345
- self.hive = hive;
346
- setupSyncStateBroadcast(repo);
347
- log("repo constructed, waiting for network subsystem");
348
- // Don't block getRepoHive() on whenReady() — the network subsystem starts
349
- // with only the subduction adapter, and the MessageChannel adapter is
350
- // added later via connectPort (which awaits getRepoHive). Blocking here
351
- // would deadlock that path and starve the handoff handler.
352
- repo.networkSubsystem.whenReady().then(() => {
353
- log("repo network subsystem ready");
354
- });
355
- hive.networkAdapter.whenReady().then(() => {
356
- hive.networkAdapter.syncKeyhive();
357
- });
358
- return { hive, repo };
359
- })();
360
- // If construction fails (e.g. wasm fetch errors out), don't permanently
361
- // cache the rejection — clear the slot so the next caller can retry from
362
- // scratch.
363
- repoHivePromise.catch(() => {
364
- repoHivePromise = null;
365
- });
366
- }
367
- 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;
368
272
  }
369
273
  // ── 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.
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;
378
285
  let syncStateWired = false;
379
- function setupSyncStateBroadcast(repo, identity) {
286
+ function setUpSyncStateBroadcast(repo, identity) {
380
287
  if (syncStateWired)
381
288
  return;
382
289
  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
- });
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(),
398
299
  };
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({
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({
404
364
  type: "sync-state",
405
365
  documentId,
406
366
  storageId,
407
367
  heads,
408
368
  timestamp,
409
369
  });
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);
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
+ });
436
383
  }
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;
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++) {
548
388
  try {
549
- 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
+ }
550
395
  }
551
396
  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);
397
+ // No subduction source yet.
603
398
  }
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);
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);
611
440
  }
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);
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
+ }
647
510
  }
648
511
  function dropRepoChannel(repo, channel) {
649
512
  // removeNetworkAdapter pulls the adapter out of networkSubsystem.adapters and
650
- // calls adapter.disconnect(), which (for the MessageChannel adapter) emits the
651
- // "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.
652
515
  try {
653
516
  repo.networkSubsystem.removeNetworkAdapter(channel.adapter);
654
517
  }
655
518
  catch (err) {
656
519
  console.error("removeNetworkAdapter failed", err);
657
520
  }
658
- // Belt and braces for the keyhive path, where the registered adapter is a
659
- // 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.
660
523
  try {
661
524
  channel.mcAdapter.disconnect();
662
525
  }
663
- catch {
664
- // Already disconnected by removeNetworkAdapter above.
665
- }
526
+ catch { }
666
527
  try {
667
528
  channel.port.close();
668
529
  }
669
- catch {
670
- // Port already closed by the departing tab.
671
- }
530
+ catch { }
672
531
  }
673
532
  async function dropConnection(connection) {
674
533
  if (!connection.channels.size || !repoHivePromise)
675
534
  return;
676
535
  const { repo } = await getRepoHive();
677
536
  log(`tab gone — removing ${connection.channels.size} network adapter(s)`);
678
- for (const channel of connection.channels) {
537
+ for (const channel of connection.channels)
679
538
  dropRepoChannel(repo, channel);
680
- }
681
539
  connection.channels.clear();
682
540
  }
683
- // Connect client MessagePorts to the repo for sync
684
541
  async function connectPort(port, connection) {
685
542
  const { hive, repo } = await getRepoHive();
686
- const networkAdapter = new MessageChannelNetworkAdapter(port, {
543
+ const mcAdapter = new MessageChannelNetworkAdapter(port, {
687
544
  useWeakRef: true,
688
545
  });
689
- const track = (adapter) => {
690
- connection.channels.add({ adapter, mcAdapter: networkAdapter, port });
691
- };
692
546
  if (!hive) {
693
- repo.networkSubsystem.addNetworkAdapter(networkAdapter);
694
- track(networkAdapter);
547
+ repo.networkSubsystem.addNetworkAdapter(mcAdapter);
548
+ connection.channels.add({ adapter: mcAdapter, mcAdapter, port });
695
549
  return;
696
550
  }
697
551
  const onlyShareWithHardcodedServerPeerId = false;
698
552
  const periodicallyRequestKeyhiveSync = false;
699
- const keyhiveNetworkAdapter = hive.createKeyhiveNetworkAdapter(networkAdapter, onlyShareWithHardcodedServerPeerId, periodicallyRequestKeyhiveSync, 2000);
700
- keyhiveNetworkAdapter.on("message", async (msg) => {
701
- if ((msg.type === "sync" || msg.type === "request") && msg.documentId) {
702
- const handle = repo.handles[msg.documentId];
703
- if (!handle || handle.state === "unavailable") {
704
- const url = `automerge:${msg.documentId}`;
705
- repo.findWithProgress(url);
706
- repo.shareConfigChanged();
707
- }
708
- }
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();
709
564
  });
710
- keyhiveNetworkAdapter.on("ingest-remote", () => {
565
+ adapter.on("ingest-remote", () => {
711
566
  hive.notifySameAgentKeyhiveChange();
712
567
  hive.networkAdapter.syncKeyhive?.();
713
568
  repo.shareConfigChanged();
714
569
  });
715
- repo.networkSubsystem.addNetworkAdapter(keyhiveNetworkAdapter);
716
- track(keyhiveNetworkAdapter);
570
+ repo.networkSubsystem.addNetworkAdapter(adapter);
571
+ connection.channels.add({ adapter, mcAdapter, port });
717
572
  }
718
573
  function handleControlMessage(event, controlPort, connection) {
719
574
  const data = event.data;
720
- if (data?.type === "port") {
721
- log("received repo channel");
722
- const [repoPort] = event.ports;
723
- const id = data.id;
724
- connectPort(repoPort, connection).then(() => controlPort.postMessage({ type: "port-ready", id }), (err) => {
725
- console.error("connectPort failed", err);
726
- // Tell the client we failed so it doesn't hang forever.
727
- controlPort.postMessage({
728
- type: "port-failed",
729
- id,
730
- 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
+ });
731
587
  });
732
- });
733
- }
734
- else if (data?.type === "sync-sub") {
735
- if (typeof data.documentId === "string") {
736
- syncSubscribe(controlPort, data.documentId);
588
+ return;
737
589
  }
738
- }
739
- else if (data?.type === "sync-unsub") {
740
- if (typeof data.documentId === "string") {
741
- 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;
742
621
  }
743
- }
744
- else if (data?.type === "debug") {
745
- debugging = data.debug;
746
- log("automerge worker debugging enabled");
747
- }
748
- else if (data?.type === "connect-classic-sync") {
749
- const [replyPort] = event.ports;
750
- const server = typeof data.server === "string"
751
- ? data.server
752
- : DEFAULT_CLASSIC_SYNC_SERVER;
753
- connectClassicSyncNetwork(server)
754
- .then(() => {
755
- replyPort?.postMessage({ type: "connect-classic-sync-ready" });
756
- replyPort?.close();
757
- log("classic sync connected on demand", { server });
758
- })
759
- .catch((err) => {
760
- console.error("connectClassicSyncNetwork failed", err);
761
- replyPort?.postMessage({
762
- type: "connect-classic-sync-failed",
763
- error: String(err),
622
+ case "ping":
623
+ controlPort.postMessage({
624
+ type: "pong",
625
+ id: data.id,
626
+ instanceId: WORKER_INSTANCE_ID,
764
627
  });
765
- replyPort?.close();
766
- });
767
- }
768
- else if (data?.type === "ping") {
769
- // Heartbeat: reply so the tab can detect our death or restart.
770
- controlPort.postMessage({
771
- type: "pong",
772
- id: data.id,
773
- instanceId: WORKER_INSTANCE_ID,
774
- });
628
+ return;
775
629
  }
776
630
  }
777
631
  self.addEventListener("connect", (event) => {
@@ -780,53 +634,37 @@ self.addEventListener("connect", (event) => {
780
634
  controlPort.addEventListener("message", (messageEvent) => {
781
635
  handleControlMessage(messageEvent, controlPort, connection);
782
636
  });
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).
637
+ // The tab side runs donatePort; the messages are channel-tagged so they
638
+ // coexist with the control protocol above.
786
639
  subductionPortProvider.attachClient(controlPort);
787
- // Fires when the owning page is destroyed. Browsers without the close
788
- // 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.
789
642
  controlPort.addEventListener("close", () => {
790
643
  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
644
  syncWatchers.delete(controlPort);
794
645
  void dropConnection(connection);
795
646
  });
796
647
  controlPort.start();
797
- // Greet the tab with our per-boot instance id so it can detect a restart
798
- // (a different id than last seen) even if no port "close" fired.
799
648
  controlPort.postMessage({
800
649
  type: "hello",
801
650
  instanceId: WORKER_INSTANCE_ID,
802
651
  bootTime: WORKER_BOOT_TIME,
803
652
  });
804
- // Start forwarding console output to this tab, and flush anything buffered
805
- // while no tab was connected (e.g. boot-time logs) to the first arrival.
806
653
  controlPorts.add(controlPort);
807
- if (preConnectBuffer.length) {
808
- for (const { level, args } of preConnectBuffer.splice(0)) {
809
- try {
810
- controlPort.postMessage({ type: "console", level, args });
811
- }
812
- catch {
813
- // Port may already be gone — ignore.
814
- }
815
- }
654
+ for (const { level, args } of preConnectBuffer.splice(0)) {
655
+ postToPort(controlPort, { type: "console", level, args });
816
656
  }
817
657
  });
818
- // ── Automerge URL resolution ───────────────────────────────────────────
819
- /**
820
- * Wait for the requested heads to appear in the handle's local history —
821
- * they may still be syncing toward us when the request lands. Resolves
822
- * false if the signal aborts before they arrive.
823
- */
824
658
  function waitForHeads(handle, hexHeads, signal) {
825
659
  if (hasHeads(handle.doc(), hexHeads))
826
660
  return Promise.resolve(true);
827
661
  if (signal.aborted)
828
662
  return Promise.resolve(false);
829
663
  return new Promise((resolve) => {
664
+ const cleanup = () => {
665
+ handle.off("heads-changed", check);
666
+ signal.removeEventListener("abort", onAbort);
667
+ };
830
668
  const check = () => {
831
669
  if (!hasHeads(handle.doc(), hexHeads))
832
670
  return;
@@ -837,51 +675,43 @@ function waitForHeads(handle, hexHeads, signal) {
837
675
  cleanup();
838
676
  resolve(false);
839
677
  };
840
- const cleanup = () => {
841
- handle.off("heads-changed", check);
842
- signal.removeEventListener("abort", onAbort);
843
- };
844
678
  handle.on("heads-changed", check);
845
679
  signal.addEventListener("abort", onAbort);
846
- // The heads may have landed between the synchronous check above and
847
- // subscribing.
680
+ // The heads may have landed between the check above and subscribing.
848
681
  check();
849
682
  });
850
683
  }
851
- async function resolveAutomergeUrl(automergeURL) {
684
+ /**
685
+ * Thrown instead of returning a Response when the request should fail as a
686
+ * network error rather than resolve to something the caller can memoize.
687
+ * See {@link HandoffAbortMessage}.
688
+ */
689
+ class AbortHandoff extends Error {
690
+ }
691
+ async function resolveAutomergeUrl(automergeURL, signal) {
852
692
  const { repo } = await getRepoHive();
853
- const href = automergeURL.href;
854
- const [maybeAutomergeUrl, ...path] = href.split("/");
693
+ const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/");
855
694
  if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
856
695
  return new Response("invalid automerge url", { status: 400 });
857
696
  }
858
- // Trim trailing empty path segment
859
697
  if (path.length && !path[path.length - 1])
860
698
  path.pop();
861
699
  const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
862
- 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
863
702
  if (!heads) {
864
703
  const folder = await repo.find(maybeAutomergeUrl, { signal });
865
- const latestHeads = folder.heads();
866
- const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
867
- let location = `/${encodeURIComponent(url)}`;
868
- if (path.length)
869
- location += `/${path.join("/")}`;
704
+ const url = stringifyAutomergeUrl({ documentId, heads: folder.heads() });
705
+ const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`;
870
706
  return Response.redirect(location, 307);
871
707
  }
872
- // Load by documentId only so we can verify the requested heads are actually
873
- // in our local history. repo.find with a heads-bearing URL returns a view
874
- // at those heads, which silently materializes garbage if we never synced them.
875
708
  const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
876
709
  signal,
877
710
  });
878
- // The heads may not have synced to us yet — give them the rest of the
879
- // resolve window to arrive before giving up.
880
711
  if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
881
- return new Response("heads not found", { status: 404 });
712
+ throw new AbortHandoff(`heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`);
882
713
  }
883
- const rootHandle = baseHandle.view(heads);
884
- const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
714
+ const resolved = await resolvePath(repo, baseHandle.view(heads), path.map(decodeURIComponent));
885
715
  if (!resolved) {
886
716
  throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
887
717
  }
@@ -893,99 +723,63 @@ async function resolveAutomergeUrl(automergeURL) {
893
723
  headers: { "content-type": resolved.type },
894
724
  });
895
725
  }
896
- // ── Handoff: resolve special URLs for the service worker ──────────────
897
726
  const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
898
- /**
899
- * Pull the special URL out of a handoff request, whichever generation of
900
- * service worker sent it. Returns null rather than throwing — a stale
901
- * worker on the other end of the channel can send us anything.
902
- */
903
- function parseHandoffhandoffURL(request) {
904
- try {
905
- // The service worker already decoded the special URL out of the
906
- // request it's holding and sends it alongside.
907
- if (request.handoffURL)
908
- return new URL(request.handoffURL);
909
- // TODO(backcompat): a briefly-deployed shape sent the special URL in
910
- // request.url and the http URL in cacheKey.
911
- if (request.cacheKey)
912
- return new URL(request.url);
913
- // TODO(backcompat): older service workers send only the http URL,
914
- // special URL still URI-encoded in its pathname.
915
- return new URL(decodeURIComponent(new URL(request.url).pathname.slice(1)));
916
- }
917
- catch {
918
- return null;
919
- }
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));
920
736
  }
921
737
  async function handleHandoffRequest(message) {
922
738
  const { id, cachename, request } = message;
923
- const handoffURL = parseHandoffhandoffURL(request);
924
- if (!handoffURL) {
925
- console.error(`automerge worker couldn't parse a special url out of handoff request`, request);
926
- handoffChannel.postMessage({
927
- id,
928
- type: "response",
929
- response: {
930
- status: 400,
931
- body: `couldn't parse a special url out of ${request.url}`,
932
- headers: { "content-type": "text/plain" },
933
- },
934
- });
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}`);
935
746
  return;
936
747
  }
937
- if (handoffURL.protocol != "automerge:") {
938
- // This worker only resolves automerge: URLs. Other handlers may be
939
- // listening on the channel for other schemes — stay quiet rather than
940
- // clobbering their reply with an error.
941
- 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;
942
753
  }
943
754
  let response;
944
755
  try {
945
- log(`resolving handoff ${id} for ${handoffURL}`);
756
+ log(`resolving handoff ${id} for ${handoff}`);
757
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
946
758
  response = await Promise.race([
947
- resolveAutomergeUrl(handoffURL),
948
- 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),
949
761
  ]);
950
762
  }
951
763
  catch (error) {
952
- const body = error instanceof Error
953
- ? `${error.message}\n\n${error.stack}`
954
- : String(error);
955
- console.error(`automerge worker error resolving ${request.url}`, error);
956
- handoffChannel.postMessage({
957
- id,
958
- type: "response",
959
- response: {
960
- status: 557,
961
- body,
962
- headers: { "content-type": "text/plain" },
963
- },
964
- });
965
- return;
966
- }
967
- try {
968
- if (cacheableStatuses.includes(response.status)) {
969
- // Reconstruct the request the service worker is holding so the entry
970
- // matches on its cache.match. (destination isn't constructible, but it
971
- // doesn't participate in cache matching.) request.url is the http URL
972
- // the SW is holding except in the briefly-deployed cacheKey shape.
973
- const cacheKey = new Request(request.cacheKey ?? request.url, {
974
- method: request.method,
975
- headers: request.headers,
976
- referrer: request.referrer,
977
- });
978
- const cache = await caches.open(cachename);
979
- await cache.put(cacheKey, response);
980
- log(`cached ${cacheKey.url} in ${cachename}`);
764
+ if (error instanceof AbortHandoff) {
981
765
  handoffChannel.postMessage({
982
766
  id,
983
- type: "cached",
767
+ type: "abort",
768
+ reason: error.message,
984
769
  });
770
+ return;
985
771
  }
986
- else {
987
- // Errors, redirects &c — things that shouldn't be cached — go back
988
- // inline for the service worker to serve directly.
772
+ console.error(`error resolving ${request.url}`, error);
773
+ replyToHandoff(id, 557, error instanceof Error
774
+ ? `${error.message}\n\n${error.stack}`
775
+ : String(error));
776
+ return;
777
+ }
778
+ try {
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)
989
783
  log(`responding inline to ${request.url} with ${response.status}`);
990
784
  handoffChannel.postMessage({
991
785
  id,
@@ -996,26 +790,34 @@ async function handleHandoffRequest(message) {
996
790
  body: response.body ? await response.text() : undefined,
997
791
  },
998
792
  });
793
+ return;
999
794
  }
1000
- }
1001
- catch (error) {
1002
- 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}`);
1003
806
  handoffChannel.postMessage({
1004
807
  id,
1005
- type: "response",
1006
- response: {
1007
- status: 558,
1008
- body: String(error),
1009
- headers: { "content-type": "text/plain" },
1010
- },
808
+ type: "cached",
1011
809
  });
1012
810
  }
811
+ catch (error) {
812
+ console.error(`failed to reply for ${request.url}`, error);
813
+ replyToHandoff(id, 558, String(error));
814
+ }
1013
815
  }
1014
816
  handoffChannel.addEventListener("message", (event) => {
1015
817
  if (event.data?.type === "request") {
1016
818
  void handleHandoffRequest(event.data);
1017
819
  }
1018
820
  });
1019
- // Announce ourselves so the service worker can re-broadcast any handoff
1020
- // 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.
1021
823
  handoffChannel.postMessage({ type: "online" });