@inkandswitch/patchwork-bootloader 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,36 @@ 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 syncServer = typeof __SYNC_SERVER__ !== "undefined"
27
+ ? __SYNC_SERVER__
28
+ : { url: "wss://subduction.sync.inkandswitch.com" };
29
+ const RESOLVE_TIMEOUT_MS = 30_000;
30
+ const CACHEABLE_STATUSES = [200, 203, 204];
31
+ // A fresh instance means a new repo peerId and cold in-memory state, so a tab
32
+ // seeing a changed id knows to re-subscribe. Sent in `hello` and every `pong`.
33
33
  const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
34
34
  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
- }
35
+ // `debug` reads localStorage, which a SharedWorker doesn't have, so debugging is
36
+ // toggled by a control message from a tab instead.
37
+ let debugging = false;
38
+ function log(...args) {
39
+ if (debugging)
40
+ console.log("[automerge-worker]", ...args);
104
41
  }
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.
42
+ // ── Console forwarding ─────────────────────────────────────────────────
43
+ // The SharedWorker's own console is buried in chrome://inspect, so mirror
44
+ // everything over each connected tab's control port.
45
+ const controlPorts = new Set();
46
+ // Logs emitted before any tab connects (wasm boot) would otherwise be lost.
107
47
  const preConnectBuffer = [];
108
48
  const MAX_BUFFER = 200;
109
49
  function serializeArg(arg) {
@@ -118,21 +58,23 @@ function serializeArg(arg) {
118
58
  return String(arg);
119
59
  }
120
60
  }
61
+ function postToPort(port, message) {
62
+ try {
63
+ port.postMessage(message);
64
+ }
65
+ catch (error) {
66
+ console.warn(`sending failed`, error);
67
+ }
68
+ }
121
69
  function forwardToMainThread(level, rawArgs) {
122
70
  const args = rawArgs.map(serializeArg);
123
71
  if (!controlPorts.size) {
124
- if (preConnectBuffer.length < MAX_BUFFER) {
72
+ if (preConnectBuffer.length < MAX_BUFFER)
125
73
  preConnectBuffer.push({ level, args });
126
- }
127
74
  return;
128
75
  }
129
76
  for (const port of controlPorts) {
130
- try {
131
- port.postMessage({ type: "console", level, args });
132
- }
133
- catch {
134
- // Port may be closing — ignore.
135
- }
77
+ postToPort(port, { type: "console", level, args });
136
78
  }
137
79
  }
138
80
  for (const level of ["log", "info", "warn", "error", "debug"]) {
@@ -156,689 +98,563 @@ self.addEventListener("unhandledrejection", (event) => {
156
98
  reason instanceof Error ? reason.stack || reason.message : reason,
157
99
  ]);
158
100
  });
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.
101
+ console.warn(`[lifecycle] automerge SharedWorker started (instance ${WORKER_INSTANCE_ID})`);
167
102
  const WATCHDOG_TICK_MS = 5_000;
168
- const WATCHDOG_GAP_FACTOR = 2;
169
103
  let watchdogLast = Date.now();
170
104
  setInterval(() => {
171
105
  const now = Date.now();
172
106
  const gap = now - watchdogLast;
173
107
  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)`);
108
+ if (gap > WATCHDOG_TICK_MS * 2) {
109
+ console.warn(`[lifecycle] watchdog timer gap ~${Math.round(gap / 1000)}s ` +
110
+ `(expected every ${WATCHDOG_TICK_MS / 1000}s)`);
178
111
  }
179
112
  }, 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
- };
113
+ // ── Per-tab sync-state subscriptions ───────────────────────────────────
114
+ // A tab's control port subscribes to the documents it cares about and we push
115
+ // only those docs' heads down that port, so tab A never sees tab B's docs. A
116
+ // port's whole subscription set is dropped when it closes, so there's nothing
117
+ // to reference-count or time out.
118
+ const syncWatchers = new Map();
119
+ // Set once the repo's snapshot exists, so a `sync-sub` arriving during boot can
120
+ // be replayed the doc's current heads as soon as it does.
121
+ let replaySyncForPort = null;
122
+ function syncSubscribe(port, documentId) {
123
+ let docs = syncWatchers.get(port);
124
+ if (!docs)
125
+ syncWatchers.set(port, (docs = new Set()));
126
+ if (docs.has(documentId))
127
+ return;
128
+ docs.add(documentId);
129
+ replaySyncForPort?.(documentId, port);
130
+ }
131
+ function syncUnsubscribe(port, documentId) {
132
+ syncWatchers.get(port)?.delete(documentId);
133
+ }
134
+ function pushSyncState(message) {
135
+ for (const [port, docs] of syncWatchers) {
136
+ if (docs.has(message.documentId))
137
+ postToPort(port, message);
138
+ }
190
139
  }
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
140
  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.
141
+ // Memoized so a construction retry reuses the endpoint instead of leaking one
142
+ // per attempt.
216
143
  let subductionEndpoints = null;
217
144
  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
- ];
145
+ return (subductionEndpoints ??= [
146
+ new WorkerWebSocketEndpoint(syncServer.url, {
147
+ worker: subductionPortProvider.source,
148
+ }),
149
+ ]);
150
+ }
151
+ let repoHivePromise = null;
152
+ function getRepoHive() {
153
+ if (!repoHivePromise) {
154
+ repoHivePromise = setUpRepoHive();
155
+ // Don't permanently cache a rejection (e.g. the wasm fetch failed) — clear
156
+ // the slot so the next caller retries from scratch.
157
+ repoHivePromise.catch(() => {
158
+ repoHivePromise = null;
159
+ });
229
160
  }
230
- return subductionEndpoints;
161
+ return repoHivePromise;
231
162
  }
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";
163
+ async function setUpRepoHive() {
164
+ log("fetching wasm");
165
+ const [automergeWasm, subductionWasm] = await Promise.all([
166
+ fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
167
+ fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
168
+ ]);
169
+ initSubductionSync(new Uint8Array(subductionWasm));
170
+ await initializeWasm(new Uint8Array(automergeWasm));
171
+ log("wasm initialized");
172
+ const built = syncServer.keyhive
173
+ ? await buildKeyhiveRepo(syncServer.keyhive)
174
+ : await buildPlainRepo();
175
+ self.repo = built.repo;
176
+ if (built.hive)
177
+ self.hive = built.hive;
178
+ if (built.identity)
179
+ self.syncIdentity = built.identity;
180
+ setUpSyncStateBroadcast(built.repo, built.identity);
181
+ // Deliberately not awaited: the network subsystem starts with only the
182
+ // subduction adapter, and the MessageChannel adapter is added later by
183
+ // connectPort, which itself awaits getRepoHive. Blocking here would deadlock
184
+ // that path and starve the handoff handler.
185
+ built.repo.networkSubsystem
186
+ .whenReady()
187
+ .then(() => log("repo network subsystem ready"));
188
+ return { repo: built.repo, hive: built.hive };
189
+ }
190
+ async function buildPlainRepo() {
191
+ const signer = await WebCryptoSigner.setup();
192
+ const identity = {
193
+ peerId: signer.peerId().toString(),
194
+ verifyingKey: signer.verifyingKey().toHex(),
195
+ };
196
+ const repo = new Repo({
197
+ storage: new IndexedDBWorkerStorageAdapter(),
198
+ signer,
199
+ peerId: `automerge-worker-${Math.random().toString(36).slice(2)}`,
200
+ async sharePolicy(peerId) {
201
+ return peerId.includes("storage-server");
202
+ },
203
+ enableRemoteHeadsGossiping: true,
204
+ subductionWebsocketEndpoints: getSubductionEndpoints(),
205
+ });
206
+ console.log("[patchwork] shared-worker subduction identity:", identity);
207
+ return { repo, identity };
208
+ }
209
+ async function buildKeyhiveRepo(keyhiveSyncServer) {
210
+ initKeyhiveWasm();
211
+ const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
212
+ createRepo: (config) => new Repo(config),
213
+ storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
214
+ peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
215
+ automaticArchiveIngestion: true,
216
+ cachingMode: "periodic",
217
+ // ARK selects the relay via `syncServer`, which pairs the contact card with
218
+ // the matching peer id. Omitting it defaults to "subduction".
219
+ syncServer: keyhiveSyncServer,
220
+ repo: {
221
+ storage: new IndexedDBWorkerStorageAdapter(),
222
+ subductionWebsocketEndpoints: getSubductionEndpoints(),
223
+ enableRemoteHeadsGossiping: true,
224
+ },
225
+ });
226
+ hive.networkAdapter.whenReady().then(() => {
227
+ hive.networkAdapter.syncKeyhive();
228
+ });
229
+ return { repo, hive };
230
+ }
231
+ // ── Classic sync ───────────────────────────────────────────────────────
241
232
  let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
242
233
  let classicSyncAdapter = null;
243
- let classicSyncConnectPromise = null;
244
- async function connectClassicSyncNetwork(server) {
234
+ let classicSyncConnect = null;
235
+ function connectClassicSyncNetwork(server) {
245
236
  const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
246
- if (classicSyncConnectPromise && classicSyncServer === url) {
247
- return classicSyncConnectPromise;
248
- }
237
+ if (classicSyncConnect && classicSyncServer === url)
238
+ return classicSyncConnect;
249
239
  if (classicSyncAdapter && classicSyncServer !== url) {
250
240
  classicSyncAdapter.disconnect();
251
241
  classicSyncAdapter = null;
252
- classicSyncConnectPromise = null;
253
242
  }
254
243
  classicSyncServer = url;
255
- classicSyncConnectPromise = (async () => {
244
+ const connecting = (async () => {
256
245
  const { repo } = await getRepoHive();
257
246
  if (!classicSyncAdapter) {
258
247
  classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
259
248
  repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
260
249
  }
261
250
  await classicSyncAdapter.whenReady();
262
- log("classic sync connected", { server: url });
251
+ log("classic sync connected", url);
263
252
  })();
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;
253
+ // Clear the memo on failure so a later attempt can retry, and swallow the
254
+ // rejection on this copy so it isn't reported as unhandled — callers get it
255
+ // from the promise we return.
256
+ classicSyncConnect = connecting;
257
+ connecting.catch(() => {
258
+ if (classicSyncConnect === connecting)
259
+ classicSyncConnect = null;
260
+ });
261
+ return connecting;
364
262
  }
365
263
  // ── 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.
264
+ // Only this worker is connected to the sync server, so it's the only place that
265
+ // learns the server's heads ("subduction-remote-heads", keyed by each Subduction
266
+ // peer's verifying-key storageId) and whether the link is up
267
+ // ("subduction-connection"). Global signals go out on SYNCSTATE_CHANNEL so any
268
+ // tab can render a sync indicator; per-document heads are addressed to
269
+ // subscribers instead (see pushSyncState).
270
+ const RESYNC_GRACE_MS = 8_000; // must be stably diverged this long first
271
+ const RESYNC_INITIAL_DELAY_MS = 5_000;
272
+ const RESYNC_MAX_DELAY_MS = 60_000;
273
+ const RESYNC_REVIEW_INTERVAL_MS = 5_000;
274
+ const OWN_HANDLE_SCAN_INTERVAL_MS = 3_000;
374
275
  let syncStateWired = false;
375
- function setupSyncStateBroadcast(repo, identity) {
276
+ function setUpSyncStateBroadcast(repo, identity) {
376
277
  if (syncStateWired)
377
278
  return;
378
279
  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
- });
280
+ const state = {
281
+ repo,
282
+ channel: new BroadcastChannel(SYNCSTATE_CHANNEL),
283
+ identity,
284
+ snapshot: new Map(),
285
+ connected: repo.isSubductionConnected(),
286
+ serverPeerIds: [],
287
+ tracked: new Set(),
288
+ resync: new Map(),
394
289
  };
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({
290
+ postWhoAmI(state);
291
+ replaySyncForPort = (documentId, port) => replayDoc(state, documentId, port);
292
+ for (const [port, docs] of syncWatchers) {
293
+ for (const documentId of docs)
294
+ replayDoc(state, documentId, port);
295
+ }
296
+ repo.on("subduction-remote-heads", ({ documentId, storageId, heads, timestamp }) => {
297
+ recordHeads(state, documentId, storageId, [...heads], timestamp);
298
+ // A doc the server reported is one we hold, so advertise our heads for it
299
+ // too. Only this doc: a full scan per event is O(all handles) and goes
300
+ // quadratic during sync bursts. The tick covers general discovery.
301
+ const handle = repo.handles[documentId];
302
+ if (handle)
303
+ trackOwnHandle(state, handle);
304
+ reviewResync(state, documentId);
305
+ });
306
+ repo.on("subduction-connection", ({ connected }) => {
307
+ state.connected = connected;
308
+ postConnection(state);
309
+ if (connected)
310
+ void refreshServerPeers(state);
311
+ });
312
+ // A BroadcastChannel never receives its own posts, so this only sees tabs'
313
+ // requests. Only the global signals are replayed; a late tab gets per-doc
314
+ // heads by subscribing.
315
+ state.channel.addEventListener("message", (event) => {
316
+ if (event.data?.type !== "request")
317
+ return;
318
+ postWhoAmI(state);
319
+ postConnection(state);
320
+ });
321
+ void refreshServerPeers(state);
322
+ scanOwnHandles(state);
323
+ if (!identity)
324
+ return;
325
+ // Subduction-pushed docs don't surface via the "document" event, so discover
326
+ // them by re-scanning repo.handles on a tick.
327
+ setInterval(() => scanOwnHandles(state), OWN_HANDLE_SCAN_INTERVAL_MS);
328
+ // The "stuck" case is precisely when no head events are firing, so the
329
+ // grace/backoff timers can only advance on a tick.
330
+ setInterval(() => reviewAllResync(state), RESYNC_REVIEW_INTERVAL_MS);
331
+ }
332
+ function postWhoAmI(state) {
333
+ if (!state.identity)
334
+ return;
335
+ state.channel.postMessage({
336
+ type: "whoami",
337
+ peerId: state.identity.peerId,
338
+ verifyingKey: state.identity.verifyingKey,
339
+ });
340
+ }
341
+ function postConnection(state) {
342
+ state.channel.postMessage({
343
+ type: "connection",
344
+ connected: state.connected,
345
+ serverPeerIds: state.serverPeerIds,
346
+ });
347
+ }
348
+ function recordHeads(state, documentId, storageId, heads, timestamp) {
349
+ let byStorage = state.snapshot.get(documentId);
350
+ if (!byStorage)
351
+ state.snapshot.set(documentId, (byStorage = new Map()));
352
+ byStorage.set(storageId, { heads, timestamp });
353
+ pushSyncState({
400
354
  type: "sync-state",
401
355
  documentId,
402
356
  storageId,
403
357
  heads,
404
358
  timestamp,
405
359
  });
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);
360
+ }
361
+ function replayDoc(state, documentId, port) {
362
+ const byStorage = state.snapshot.get(documentId);
363
+ if (!byStorage)
364
+ return;
365
+ for (const [storageId, { heads, timestamp }] of byStorage) {
366
+ postToPort(port, {
367
+ type: "sync-state",
368
+ documentId,
369
+ storageId,
370
+ heads,
371
+ timestamp,
372
+ });
432
373
  }
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;
374
+ }
375
+ /** The peer list is empty until the handshake finishes, so retry briefly. */
376
+ async function refreshServerPeers(state) {
377
+ for (let attempt = 0; attempt < 6; attempt++) {
544
378
  try {
545
- haveAll = handle.containsHeads(serverHeadsUrl);
379
+ const ids = await state.repo.connectedSubductionPeerIds();
380
+ if (ids.length > 0) {
381
+ state.serverPeerIds = ids;
382
+ postConnection(state);
383
+ return;
384
+ }
546
385
  }
547
386
  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);
587
- }
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);
387
+ // No subduction source yet.
599
388
  }
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);
389
+ await new Promise((r) => setTimeout(r, 500));
390
+ }
391
+ }
392
+ // Advertise this worker's own heads for every doc it holds, so the worker hop is
393
+ // visible on every document. No-op on the keyhive path, which has no identity.
394
+ function broadcastOwnHeads(state, handle) {
395
+ if (!state.identity)
396
+ return;
397
+ let heads;
398
+ try {
399
+ heads = [...handle.heads()];
400
+ }
401
+ catch {
402
+ return; // handle not ready
403
+ }
404
+ recordHeads(state, handle.documentId, state.identity.peerId, heads, Date.now());
405
+ reviewResync(state, handle.documentId);
406
+ }
407
+ function trackOwnHandle(state, handle) {
408
+ if (!state.identity || state.tracked.has(handle.documentId))
409
+ return;
410
+ state.tracked.add(handle.documentId);
411
+ handle.on("heads-changed", () => broadcastOwnHeads(state, handle));
412
+ broadcastOwnHeads(state, handle);
413
+ }
414
+ function scanOwnHandles(state) {
415
+ if (!state.identity)
416
+ return;
417
+ for (const handle of Object.values(state.repo.handles)) {
418
+ trackOwnHandle(state, handle);
419
+ }
420
+ }
421
+ function serverHeadsFor(state, documentId) {
422
+ const byStorage = state.snapshot.get(documentId);
423
+ if (!byStorage)
424
+ return [];
425
+ const heads = new Set();
426
+ for (const [storageId, entry] of byStorage) {
427
+ if (state.serverPeerIds.includes(storageId)) {
428
+ for (const head of entry.heads)
429
+ heads.add(head);
607
430
  }
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);
431
+ }
432
+ return [...heads];
433
+ }
434
+ function reviewResync(state, documentId) {
435
+ if (!state.identity || !state.connected) {
436
+ state.resync.delete(documentId);
437
+ return;
438
+ }
439
+ const handle = state.repo.handles[documentId];
440
+ if (!handle)
441
+ return;
442
+ const serverHeads = serverHeadsFor(state, documentId);
443
+ if (serverHeads.length === 0) {
444
+ state.resync.delete(documentId); // nothing to compare against
445
+ return;
446
+ }
447
+ // The server advertises subduction sedimentree heads (loose-commit and
448
+ // fragment-boundary commit ids), which are NOT the Automerge frontier, so
449
+ // never compare them to handle.heads() for equality. Ask instead whether we
450
+ // already hold every commit the server advertises.
451
+ let haveAll;
452
+ try {
453
+ haveAll = handle.containsHeads(serverHeads);
454
+ }
455
+ catch {
456
+ return; // doc not ready, or an undecodable head
457
+ }
458
+ if (haveAll) {
459
+ state.resync.delete(documentId);
460
+ return;
461
+ }
462
+ // Behind. Key the grace timer on the server heads alone, so your own edits
463
+ // churning don't keep resetting it.
464
+ const serverSig = [...serverHeads].sort().join(",");
465
+ const now = Date.now();
466
+ const prev = state.resync.get(documentId);
467
+ if (!prev || prev.serverSig !== serverSig) {
468
+ // First sighting, or the server made progress: restart the clock.
469
+ state.resync.set(documentId, {
470
+ serverSig,
471
+ since: now,
472
+ delay: RESYNC_INITIAL_DELAY_MS,
473
+ lastResyncAt: 0,
474
+ });
475
+ return;
476
+ }
477
+ if (now - prev.since < RESYNC_GRACE_MS)
478
+ return;
479
+ if (now - prev.lastResyncAt < prev.delay)
480
+ return;
481
+ log("re-syncing behind doc", documentId);
482
+ try {
483
+ state.repo.resyncSubduction(documentId);
484
+ }
485
+ catch (e) {
486
+ log("resyncSubduction failed", e);
487
+ }
488
+ prev.lastResyncAt = now;
489
+ prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
490
+ }
491
+ function reviewAllResync(state) {
492
+ if (!state.identity)
493
+ return;
494
+ for (const documentId of state.snapshot.keys())
495
+ reviewResync(state, documentId);
496
+ for (const id of [...state.resync.keys()]) {
497
+ if (!state.snapshot.has(id))
498
+ state.resync.delete(id);
499
+ }
648
500
  }
649
501
  function dropRepoChannel(repo, channel) {
650
502
  // 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.
503
+ // calls disconnect(), which for the MessageChannel adapter emits the
504
+ // close/peer-disconnected events that clear #adaptersByPeer.
653
505
  try {
654
506
  repo.networkSubsystem.removeNetworkAdapter(channel.adapter);
655
507
  }
656
508
  catch (err) {
657
509
  console.error("removeNetworkAdapter failed", err);
658
510
  }
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.
511
+ // On the keyhive path the registered adapter is a wrapper, so make sure the
512
+ // underlying port is disconnected and closed too.
661
513
  try {
662
514
  channel.mcAdapter.disconnect();
663
515
  }
664
- catch {
665
- // Already disconnected by removeNetworkAdapter above.
666
- }
516
+ catch { }
667
517
  try {
668
518
  channel.port.close();
669
519
  }
670
- catch {
671
- // Port already closed by the departing tab.
672
- }
520
+ catch { }
673
521
  }
674
522
  async function dropConnection(connection) {
675
523
  if (!connection.channels.size || !repoHivePromise)
676
524
  return;
677
525
  const { repo } = await getRepoHive();
678
526
  log(`tab gone — removing ${connection.channels.size} network adapter(s)`);
679
- for (const channel of connection.channels) {
527
+ for (const channel of connection.channels)
680
528
  dropRepoChannel(repo, channel);
681
- }
682
529
  connection.channels.clear();
683
530
  }
684
- // Connect client MessagePorts to the repo for sync
685
531
  async function connectPort(port, connection) {
686
532
  const { hive, repo } = await getRepoHive();
687
- const networkAdapter = new MessageChannelNetworkAdapter(port, {
533
+ const mcAdapter = new MessageChannelNetworkAdapter(port, {
688
534
  useWeakRef: true,
689
535
  });
690
- const track = (adapter) => {
691
- connection.channels.add({ adapter, mcAdapter: networkAdapter, port });
692
- };
693
536
  if (!hive) {
694
- repo.networkSubsystem.addNetworkAdapter(networkAdapter);
695
- track(networkAdapter);
537
+ repo.networkSubsystem.addNetworkAdapter(mcAdapter);
538
+ connection.channels.add({ adapter: mcAdapter, mcAdapter, port });
696
539
  return;
697
540
  }
698
541
  const onlyShareWithHardcodedServerPeerId = false;
699
542
  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
- }
543
+ const adapter = hive.createKeyhiveNetworkAdapter(mcAdapter, onlyShareWithHardcodedServerPeerId, periodicallyRequestKeyhiveSync, 2000);
544
+ adapter.on("message", (msg) => {
545
+ if (msg.type !== "sync" && msg.type !== "request")
546
+ return;
547
+ if (!msg.documentId)
548
+ return;
549
+ const handle = repo.handles[msg.documentId];
550
+ if (handle && handle.state !== "unavailable")
551
+ return;
552
+ repo.findWithProgress(`automerge:${msg.documentId}`);
553
+ repo.shareConfigChanged();
710
554
  });
711
- keyhiveNetworkAdapter.on("ingest-remote", () => {
555
+ adapter.on("ingest-remote", () => {
712
556
  hive.notifySameAgentKeyhiveChange();
713
557
  hive.networkAdapter.syncKeyhive?.();
714
558
  repo.shareConfigChanged();
715
559
  });
716
- repo.networkSubsystem.addNetworkAdapter(keyhiveNetworkAdapter);
717
- track(keyhiveNetworkAdapter);
560
+ repo.networkSubsystem.addNetworkAdapter(adapter);
561
+ connection.channels.add({ adapter, mcAdapter, port });
718
562
  }
719
563
  function handleControlMessage(event, controlPort, connection) {
720
564
  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),
565
+ switch (data?.type) {
566
+ case "port": {
567
+ log("received repo channel");
568
+ const [repoPort] = event.ports;
569
+ connectPort(repoPort, connection).then(() => controlPort.postMessage({ type: "port-ready", id: data.id }), (err) => {
570
+ console.error("connectPort failed", err);
571
+ // Tell the tab so it doesn't hang until its timeout.
572
+ controlPort.postMessage({
573
+ type: "port-failed",
574
+ id: data.id,
575
+ error: String(err),
576
+ });
742
577
  });
743
- });
744
- }
745
- else if (data?.type === "sync-sub") {
746
- if (typeof data.documentId === "string") {
747
- syncSubscribe(controlPort, data.documentId);
578
+ return;
748
579
  }
749
- }
750
- else if (data?.type === "sync-unsub") {
751
- if (typeof data.documentId === "string") {
752
- syncUnsubscribe(controlPort, data.documentId);
580
+ case "sync-sub":
581
+ if (typeof data.documentId === "string") {
582
+ syncSubscribe(controlPort, data.documentId);
583
+ }
584
+ return;
585
+ case "sync-unsub":
586
+ if (typeof data.documentId === "string") {
587
+ syncUnsubscribe(controlPort, data.documentId);
588
+ }
589
+ return;
590
+ case "debug":
591
+ debugging = data.debug;
592
+ log("automerge worker debugging enabled");
593
+ return;
594
+ case "connect-classic-sync": {
595
+ const [replyPort] = event.ports;
596
+ const server = typeof data.server === "string"
597
+ ? data.server
598
+ : DEFAULT_CLASSIC_SYNC_SERVER;
599
+ connectClassicSyncNetwork(server).then(() => {
600
+ replyPort?.postMessage({ type: "connect-classic-sync-ready" });
601
+ replyPort?.close();
602
+ }, (err) => {
603
+ console.error("connectClassicSyncNetwork failed", err);
604
+ replyPort?.postMessage({
605
+ type: "connect-classic-sync-failed",
606
+ error: String(err),
607
+ });
608
+ replyPort?.close();
609
+ });
610
+ return;
753
611
  }
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),
612
+ case "ping":
613
+ controlPort.postMessage({
614
+ type: "pong",
615
+ id: data.id,
616
+ instanceId: WORKER_INSTANCE_ID,
775
617
  });
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
- });
618
+ return;
786
619
  }
787
620
  }
788
621
  self.addEventListener("connect", (event) => {
789
622
  const controlPort = event.ports[0];
790
623
  const connection = { channels: new Set() };
791
- (self.patchworkControl ??= { connects: 0, byType: {} }).connects++;
792
624
  controlPort.addEventListener("message", (messageEvent) => {
793
625
  handleControlMessage(messageEvent, controlPort, connection);
794
626
  });
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).
627
+ // The tab side runs donatePort; the messages are channel-tagged so they
628
+ // coexist with the control protocol above.
798
629
  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.
630
+ // Fires when the owning page is destroyed. Browsers without the close event
631
+ // fall back to the adapters' lazy useWeakRef cleanup.
801
632
  controlPort.addEventListener("close", () => {
802
633
  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
634
  syncWatchers.delete(controlPort);
806
635
  void dropConnection(connection);
807
636
  });
808
637
  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
638
  controlPort.postMessage({
812
639
  type: "hello",
813
640
  instanceId: WORKER_INSTANCE_ID,
814
641
  bootTime: WORKER_BOOT_TIME,
815
642
  });
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
643
  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
- }
644
+ for (const { level, args } of preConnectBuffer.splice(0)) {
645
+ postToPort(controlPort, { type: "console", level, args });
828
646
  }
829
647
  });
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
648
  function waitForHeads(handle, hexHeads, signal) {
837
649
  if (hasHeads(handle.doc(), hexHeads))
838
650
  return Promise.resolve(true);
839
651
  if (signal.aborted)
840
652
  return Promise.resolve(false);
841
653
  return new Promise((resolve) => {
654
+ const cleanup = () => {
655
+ handle.off("heads-changed", check);
656
+ signal.removeEventListener("abort", onAbort);
657
+ };
842
658
  const check = () => {
843
659
  if (!hasHeads(handle.doc(), hexHeads))
844
660
  return;
@@ -849,14 +665,9 @@ function waitForHeads(handle, hexHeads, signal) {
849
665
  cleanup();
850
666
  resolve(false);
851
667
  };
852
- const cleanup = () => {
853
- handle.off("heads-changed", check);
854
- signal.removeEventListener("abort", onAbort);
855
- };
856
668
  handle.on("heads-changed", check);
857
669
  signal.addEventListener("abort", onAbort);
858
- // The heads may have landed between the synchronous check above and
859
- // subscribing.
670
+ // The heads may have landed between the check above and subscribing.
860
671
  check();
861
672
  });
862
673
  }
@@ -867,43 +678,30 @@ function waitForHeads(handle, hexHeads, signal) {
867
678
  */
868
679
  class AbortHandoff extends Error {
869
680
  }
870
- async function resolveAutomergeUrl(automergeURL) {
681
+ async function resolveAutomergeUrl(automergeURL, signal) {
871
682
  const { repo } = await getRepoHive();
872
- const href = automergeURL.href;
873
- const [maybeAutomergeUrl, ...path] = href.split("/");
683
+ const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/");
874
684
  if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
875
685
  return new Response("invalid automerge url", { status: 400 });
876
686
  }
877
- // Trim trailing empty path segment
878
687
  if (path.length && !path[path.length - 1])
879
688
  path.pop();
880
689
  const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
881
- const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
690
+ // todo, maybe a bad idea? maybe we should throw instead of es-module-caching
691
+ // the headless req
882
692
  if (!heads) {
883
693
  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("/")}`;
694
+ const url = stringifyAutomergeUrl({ documentId, heads: folder.heads() });
695
+ const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`;
889
696
  return Response.redirect(location, 307);
890
697
  }
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
698
  const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
895
699
  signal,
896
700
  });
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
701
  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
702
  throw new AbortHandoff(`heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`);
904
703
  }
905
- const rootHandle = baseHandle.view(heads);
906
- const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
704
+ const resolved = await resolvePath(repo, baseHandle.view(heads), path.map(decodeURIComponent));
907
705
  if (!resolved) {
908
706
  throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
909
707
  }
@@ -915,59 +713,41 @@ async function resolveAutomergeUrl(automergeURL) {
915
713
  headers: { "content-type": resolved.type },
916
714
  });
917
715
  }
918
- // ── Handoff: resolve special URLs for the service worker ──────────────
919
716
  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
- }
717
+ function replyToHandoff(id, status, body) {
718
+ handoffChannel.postMessage({
719
+ id,
720
+ type: "response",
721
+ response: { status, body, headers: { "content-type": "text/plain" } },
722
+ });
723
+ }
724
+ function impatience(limit) {
725
+ return new Promise((_, reject) => setTimeout(() => reject(new Error(`resolve timeout after ${limit}ms`)), limit));
942
726
  }
943
727
  async function handleHandoffRequest(message) {
944
728
  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
- });
729
+ let handoff;
730
+ try {
731
+ handoff = new URL(request.handoffURL);
732
+ }
733
+ catch {
734
+ console.error("couldn't parse handoff url", request);
735
+ replyToHandoff(id, 400, `couldn't parse a special url out of ${request.url}`);
957
736
  return;
958
737
  }
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}`);
738
+ // Other handlers may be listening on the channel for other schemes, so stay
739
+ // quiet rather than clobbering their reply with an error.
740
+ if (handoff.protocol !== "automerge:") {
741
+ log(`ignoring handoff ${id} for non-automerge url ${handoff}. not my circus, not my monkeys`);
742
+ return;
964
743
  }
965
744
  let response;
966
745
  try {
967
- log(`resolving handoff ${id} for ${handoffURL}`);
746
+ log(`resolving handoff ${id} for ${handoff}`);
747
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
968
748
  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)),
749
+ resolveAutomergeUrl(handoff, signal),
750
+ impatience(RESOLVE_TIMEOUT_MS),
971
751
  ]);
972
752
  }
973
753
  catch (error) {
@@ -979,43 +759,17 @@ async function handleHandoffRequest(message) {
979
759
  });
980
760
  return;
981
761
  }
982
- const body = error instanceof Error
762
+ console.error(`error resolving ${request.url}`, error);
763
+ replyToHandoff(id, 557, error instanceof Error
983
764
  ? `${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
- });
765
+ : String(error));
995
766
  return;
996
767
  }
997
768
  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.
769
+ if (!CACHEABLE_STATUSES.includes(response.status)) {
770
+ // Errors, redirects and the like go back inline for the service worker to
771
+ // serve directly, so they aren't cached forever (still in esmodulecache,
772
+ // cleared after a refresh)
1019
773
  log(`responding inline to ${request.url} with ${response.status}`);
1020
774
  handoffChannel.postMessage({
1021
775
  id,
@@ -1026,26 +780,34 @@ async function handleHandoffRequest(message) {
1026
780
  body: response.body ? await response.text() : undefined,
1027
781
  },
1028
782
  });
783
+ return;
1029
784
  }
1030
- }
1031
- catch (error) {
1032
- console.error(`automerge worker failed to reply for ${request.url}`, error);
785
+ // Reconstruct the request the service worker is holding so the entry matches
786
+ // its cache.match. `destination` isn't constructible but doesn't participate
787
+ // in cache matching.
788
+ const cacheKey = new Request(request.url, {
789
+ method: request.method,
790
+ headers: request.headers,
791
+ referrer: request.referrer,
792
+ });
793
+ const cache = await caches.open(cachename);
794
+ await cache.put(cacheKey, response);
795
+ log(`cached ${cacheKey.url} in ${cachename}`);
1033
796
  handoffChannel.postMessage({
1034
797
  id,
1035
- type: "response",
1036
- response: {
1037
- status: 558,
1038
- body: String(error),
1039
- headers: { "content-type": "text/plain" },
1040
- },
798
+ type: "cached",
1041
799
  });
1042
800
  }
801
+ catch (error) {
802
+ console.error(`failed to reply for ${request.url}`, error);
803
+ replyToHandoff(id, 558, String(error));
804
+ }
1043
805
  }
1044
806
  handoffChannel.addEventListener("message", (event) => {
1045
807
  if (event.data?.type === "request") {
1046
808
  void handleHandoffRequest(event.data);
1047
809
  }
1048
810
  });
1049
- // Announce ourselves so the service worker can re-broadcast any handoff
1050
- // requests that were sent while we were still booting.
811
+ // Announce ourselves so the service worker can re-broadcast handoff requests
812
+ // sent while we were booting.
1051
813
  handoffChannel.postMessage({ type: "online" });