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