@inkandswitch/patchwork-bootloader 0.4.2 → 0.4.4

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