@inkandswitch/patchwork-bootloader 0.4.3 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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,6 +36,7 @@ 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,
@@ -61,91 +55,51 @@ declare const __SITE_NAME__: string;
61
55
  declare const __KEYHIVE__: boolean;
62
56
  declare const __KEYHIVE_SYNC_SERVER__: boolean;
63
57
 
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();
71
-
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].
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__;
76
65
 
77
- const controlPorts = new Set<MessagePort>();
66
+ const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
67
+ ? "wss://keyhive.sync.automerge.org"
68
+ : "wss://subduction.sync.inkandswitch.com";
78
69
 
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);
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
+ };
106
77
  }
107
78
 
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>>();
79
+ const RESOLVE_TIMEOUT_MS = 30_000;
115
80
 
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;
81
+ const CACHEABLE_STATUSES = [200, 203, 204];
122
82
 
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);
129
- }
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();
130
87
 
131
- function syncUnsubscribe(port: MessagePort, documentId: string): void {
132
- syncWatchers.get(port)?.delete(documentId);
133
- }
88
+ type Identity = { peerId: string; verifyingKey: string };
134
89
 
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
- }
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);
145
95
  }
146
96
 
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.
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.
149
103
  const preConnectBuffer: Array<{ level: string; args: string[] }> = [];
150
104
  const MAX_BUFFER = 200;
151
105
 
@@ -159,20 +113,23 @@ function serializeArg(arg: any): string {
159
113
  }
160
114
  }
161
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
+
162
124
  function forwardToMainThread(level: string, rawArgs: any[]) {
163
125
  const args = rawArgs.map(serializeArg);
164
126
  if (!controlPorts.size) {
165
- if (preConnectBuffer.length < MAX_BUFFER) {
127
+ if (preConnectBuffer.length < MAX_BUFFER)
166
128
  preConnectBuffer.push({ level, args });
167
- }
168
129
  return;
169
130
  }
170
131
  for (const port of controlPorts) {
171
- try {
172
- port.postMessage({ type: "console", level, args });
173
- } catch {
174
- // Port may be closing — ignore.
175
- }
132
+ postToPort(port, { type: "console", level, args });
176
133
  }
177
134
  }
178
135
 
@@ -200,669 +157,570 @@ self.addEventListener("unhandledrejection", (event) => {
200
157
  ]);
201
158
  });
202
159
 
203
- // Boot marker, buffered until the first tab connects. A new instance id means
204
- // the worker restarted (fresh peerId + cold state).
205
160
  console.warn(
206
- `[lifecycle] ${new Date(WORKER_BOOT_TIME).toISOString()} automerge ` +
207
- `SharedWorker started (instance ${WORKER_INSTANCE_ID})`
161
+ `[lifecycle] automerge SharedWorker started (instance ${WORKER_INSTANCE_ID})`
208
162
  );
209
163
 
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
164
  const WATCHDOG_TICK_MS = 5_000;
215
- const WATCHDOG_GAP_FACTOR = 2;
216
165
  let watchdogLast = Date.now();
217
166
  setInterval(() => {
218
167
  const now = Date.now();
219
168
  const gap = now - watchdogLast;
220
169
  watchdogLast = now;
221
- if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
170
+ if (gap > WATCHDOG_TICK_MS * 2) {
222
171
  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)`
172
+ `[lifecycle] watchdog timer gap ~${Math.round(gap / 1000)}s ` +
173
+ `(expected every ${WATCHDOG_TICK_MS / 1000}s)`
226
174
  );
227
175
  }
228
176
  }, WATCHDOG_TICK_MS);
229
177
 
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__;
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.
234
183
 
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
- };
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);
242
197
  }
243
198
 
244
- const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
245
- ? "wss://keyhive.sync.automerge.org"
246
- : "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
+ }
247
208
 
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
209
  const subductionPortProvider = makePortProvider();
256
210
 
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
- ];
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
+ });
288
235
  }
289
- return subductionEndpoints;
236
+ return repoHivePromise;
290
237
  }
291
- const RESOLVE_TIMEOUT_MS = 30_000;
292
238
 
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
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();
252
+
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
+ }
300
318
 
301
- const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
319
+ // ── Classic sync ───────────────────────────────────────────────────────
302
320
 
303
321
  let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
304
322
  let classicSyncAdapter: WebSocketWorkerClientAdapter | null = null;
305
- let classicSyncConnectPromise: Promise<void> | null = null;
323
+ let classicSyncConnect: Promise<void> | null = null;
306
324
 
307
- async function connectClassicSyncNetwork(server: string): Promise<void> {
325
+ function connectClassicSyncNetwork(server: string): Promise<void> {
308
326
  const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
309
- if (classicSyncConnectPromise && classicSyncServer === url) {
310
- return classicSyncConnectPromise;
311
- }
327
+ if (classicSyncConnect && classicSyncServer === url)
328
+ return classicSyncConnect;
312
329
 
313
330
  if (classicSyncAdapter && classicSyncServer !== url) {
314
331
  classicSyncAdapter.disconnect();
315
332
  classicSyncAdapter = null;
316
- classicSyncConnectPromise = null;
317
333
  }
318
334
 
319
335
  classicSyncServer = url;
320
- classicSyncConnectPromise = (async () => {
336
+ const connecting = (async () => {
321
337
  const { repo } = await getRepoHive();
322
338
  if (!classicSyncAdapter) {
323
339
  classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
324
340
  repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
325
341
  }
326
342
  await classicSyncAdapter.whenReady();
327
- log("classic sync connected", { server: url });
343
+ log("classic sync connected", url);
328
344
  })();
329
345
 
330
- try {
331
- await classicSyncConnectPromise;
332
- } catch (err) {
333
- classicSyncConnectPromise = null;
334
- throw err;
335
- }
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;
336
354
  }
337
355
 
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
- }
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
+ };
353
377
 
354
- let repoHivePromise: Promise<{
378
+ type SyncState = {
355
379
  repo: Repo;
356
- hive?: AutomergeRepoKeyhiveRust;
357
- }> | 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
+ };
358
390
 
359
- const useKeyhive = typeof __KEYHIVE__ !== "undefined" && __KEYHIVE__;
391
+ type OwnHandle = {
392
+ documentId: string;
393
+ heads: () => string[];
394
+ on: (ev: "heads-changed", cb: () => void) => void;
395
+ };
360
396
 
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
- }
397
+ let syncStateWired = false;
416
398
 
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
- });
399
+ function setUpSyncStateBroadcast(repo: Repo, identity?: Identity): void {
400
+ if (syncStateWired) return;
401
+ syncStateWired = true;
437
402
 
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
- });
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
+ };
450
413
 
451
- hive.networkAdapter.whenReady().then(() => {
452
- (hive.networkAdapter as any).syncKeyhive();
453
- });
414
+ postWhoAmI(state);
454
415
 
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
- });
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);
463
419
  }
464
- 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);
465
459
  }
466
460
 
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.
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
+ }
476
469
 
477
- 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
+ }
478
477
 
479
- function setupSyncStateBroadcast(
480
- repo: Repo,
481
- identity?: { peerId: string; verifyingKey: string }
478
+ function recordHeads(
479
+ state: SyncState,
480
+ documentId: string,
481
+ storageId: string,
482
+ heads: string[],
483
+ timestamp: number
482
484
  ): void {
483
- if (syncStateWired) return;
484
- 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
+ }
485
496
 
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({
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, {
517
506
  type: "sync-state",
518
507
  documentId,
519
508
  storageId,
520
509
  heads,
521
510
  timestamp,
522
- });
511
+ } satisfies SyncStateDocMessage);
512
+ }
513
+ }
523
514
 
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.
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;
540
524
  }
525
+ } catch {
526
+ // No subduction source yet.
541
527
  }
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);
528
+ await new Promise((r) => setTimeout(r, 500));
547
529
  }
530
+ }
548
531
 
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
- };
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.
573
534
 
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
- };
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
+ }
621
552
 
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
- };
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
+ }
719
559
 
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);
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);
739
574
  }
740
- );
575
+ }
576
+ return [...heads] as UrlHeads;
577
+ }
741
578
 
742
- repo.on("subduction-connection", ({ connected: isConnected }) => {
743
- connected = isConnected;
744
- postConnection();
745
- if (isConnected) void refreshServerPeers();
746
- });
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;
747
586
 
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
- });
587
+ const serverHeads = serverHeadsFor(state, documentId);
588
+ if (serverHeads.length === 0) {
589
+ state.resync.delete(documentId); // nothing to compare against
590
+ return;
591
+ }
757
592
 
758
- // In case we're already connected by the time this wires up.
759
- void refreshServerPeers();
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
+ }
760
607
 
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);
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;
765
625
 
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);
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
+ }
635
+
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
+ }
770
643
  }
771
644
 
772
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.
773
651
 
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
652
  type RepoChannel = {
780
653
  adapter: { disconnect(): void };
781
654
  mcAdapter: MessageChannelNetworkAdapter;
782
655
  port: MessagePort;
783
656
  };
784
- type Connection = {
785
- channels: Set<RepoChannel>;
786
- };
657
+ type Connection = { channels: Set<RepoChannel> };
787
658
 
788
659
  function dropRepoChannel(repo: Repo, channel: RepoChannel) {
789
660
  // 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.
661
+ // calls disconnect(), which for the MessageChannel adapter emits the
662
+ // close/peer-disconnected events that clear #adaptersByPeer.
792
663
  try {
793
664
  repo.networkSubsystem.removeNetworkAdapter(channel.adapter as any);
794
665
  } catch (err) {
795
666
  console.error("removeNetworkAdapter failed", err);
796
667
  }
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.
668
+ // On the keyhive path the registered adapter is a wrapper, so make sure the
669
+ // underlying port is disconnected and closed too.
799
670
  try {
800
671
  channel.mcAdapter.disconnect();
801
- } catch {
802
- // Already disconnected by removeNetworkAdapter above.
803
- }
672
+ } catch {}
804
673
  try {
805
674
  channel.port.close();
806
- } catch {
807
- // Port already closed by the departing tab.
808
- }
675
+ } catch {}
809
676
  }
810
677
 
811
678
  async function dropConnection(connection: Connection) {
812
679
  if (!connection.channels.size || !repoHivePromise) return;
813
680
  const { repo } = await getRepoHive();
814
681
  log(`tab gone — removing ${connection.channels.size} network adapter(s)`);
815
- for (const channel of connection.channels) {
816
- dropRepoChannel(repo, channel);
817
- }
682
+ for (const channel of connection.channels) dropRepoChannel(repo, channel);
818
683
  connection.channels.clear();
819
684
  }
820
685
 
821
- // Connect client MessagePorts to the repo for sync
822
686
  async function connectPort(port: MessagePort, connection: Connection) {
823
687
  const { hive, repo } = await getRepoHive();
824
- const networkAdapter = new MessageChannelNetworkAdapter(port, {
688
+ const mcAdapter = new MessageChannelNetworkAdapter(port, {
825
689
  useWeakRef: true,
826
690
  });
827
691
 
828
- const track = (adapter: { disconnect(): void }) => {
829
- connection.channels.add({ adapter, mcAdapter: networkAdapter, port });
830
- };
831
-
832
692
  if (!hive) {
833
- repo.networkSubsystem.addNetworkAdapter(networkAdapter);
834
- track(networkAdapter);
693
+ repo.networkSubsystem.addNetworkAdapter(mcAdapter);
694
+ connection.channels.add({ adapter: mcAdapter, mcAdapter, port });
835
695
  return;
836
696
  }
837
697
 
838
698
  const onlyShareWithHardcodedServerPeerId = false;
839
699
  const periodicallyRequestKeyhiveSync = false;
840
- const keyhiveNetworkAdapter = hive.createKeyhiveNetworkAdapter(
841
- networkAdapter,
700
+ const adapter = hive.createKeyhiveNetworkAdapter(
701
+ mcAdapter,
842
702
  onlyShareWithHardcodedServerPeerId,
843
703
  periodicallyRequestKeyhiveSync,
844
704
  2000
845
705
  );
846
706
 
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
- }
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();
856
714
  });
857
715
 
858
- (keyhiveNetworkAdapter as any).on("ingest-remote", () => {
716
+ (adapter as any).on("ingest-remote", () => {
859
717
  hive.notifySameAgentKeyhiveChange();
860
718
  (hive.networkAdapter as any).syncKeyhive?.();
861
719
  repo.shareConfigChanged();
862
720
  });
863
721
 
864
- repo.networkSubsystem.addNetworkAdapter(keyhiveNetworkAdapter);
865
- track(keyhiveNetworkAdapter);
722
+ repo.networkSubsystem.addNetworkAdapter(adapter);
723
+ connection.channels.add({ adapter, mcAdapter, port });
866
724
  }
867
725
 
868
726
  function handleControlMessage(
@@ -871,128 +729,110 @@ function handleControlMessage(
871
729
  connection: Connection
872
730
  ) {
873
731
  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);
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;
903
750
  }
904
- } else if (data?.type === "sync-unsub") {
905
- if (typeof data.documentId === "string") {
906
- 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;
907
790
  }
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();
791
+
792
+ case "ping":
793
+ controlPort.postMessage({
794
+ type: "pong",
795
+ id: data.id,
796
+ instanceId: WORKER_INSTANCE_ID,
930
797
  });
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
- });
798
+ return;
938
799
  }
939
800
  }
940
801
 
941
802
  self.addEventListener("connect", (event) => {
942
803
  const controlPort = (event as MessageEvent).ports[0];
943
804
  const connection: Connection = { channels: new Set() };
944
- ((self as any).patchworkControl ??= { connects: 0, byType: {} }).connects++;
945
805
 
946
806
  controlPort.addEventListener("message", (messageEvent) => {
947
807
  handleControlMessage(messageEvent as MessageEvent, controlPort, connection);
948
808
  });
949
809
 
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).
810
+ // The tab side runs donatePort; the messages are channel-tagged so they
811
+ // coexist with the control protocol above.
953
812
  subductionPortProvider.attachClient(controlPort);
954
813
 
955
- // Fires when the owning page is destroyed. Browsers without the close
956
- // 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.
957
816
  controlPort.addEventListener("close", () => {
958
817
  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
818
  syncWatchers.delete(controlPort);
962
819
  void dropConnection(connection);
963
820
  });
964
821
 
965
822
  controlPort.start();
966
823
 
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
824
  controlPort.postMessage({
970
825
  type: "hello",
971
826
  instanceId: WORKER_INSTANCE_ID,
972
827
  bootTime: WORKER_BOOT_TIME,
973
828
  });
974
829
 
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
830
  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
- }
831
+ for (const { level, args } of preConnectBuffer.splice(0)) {
832
+ postToPort(controlPort, { type: "console", level, args });
986
833
  }
987
834
  });
988
835
 
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
836
  function waitForHeads(
997
837
  handle: DocHandle<unknown>,
998
838
  hexHeads: string[],
@@ -1001,6 +841,10 @@ function waitForHeads(
1001
841
  if (hasHeads(handle.doc(), hexHeads)) return Promise.resolve(true);
1002
842
  if (signal.aborted) return Promise.resolve(false);
1003
843
  return new Promise((resolve) => {
844
+ const cleanup = () => {
845
+ handle.off("heads-changed", check);
846
+ signal.removeEventListener("abort", onAbort);
847
+ };
1004
848
  const check = () => {
1005
849
  if (!hasHeads(handle.doc(), hexHeads)) return;
1006
850
  cleanup();
@@ -1010,14 +854,9 @@ function waitForHeads(
1010
854
  cleanup();
1011
855
  resolve(false);
1012
856
  };
1013
- const cleanup = () => {
1014
- handle.off("heads-changed", check);
1015
- signal.removeEventListener("abort", onAbort);
1016
- };
1017
857
  handle.on("heads-changed", check);
1018
858
  signal.addEventListener("abort", onAbort);
1019
- // The heads may have landed between the synchronous check above and
1020
- // subscribing.
859
+ // The heads may have landed between the check above and subscribing.
1021
860
  check();
1022
861
  });
1023
862
  }
@@ -1029,54 +868,44 @@ function waitForHeads(
1029
868
  */
1030
869
  class AbortHandoff extends Error {}
1031
870
 
1032
- async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
871
+ async function resolveAutomergeUrl(
872
+ automergeURL: URL,
873
+ signal: AbortSignal
874
+ ): Promise<Response> {
1033
875
  const { repo } = await getRepoHive();
1034
- const href = automergeURL.href;
1035
- const [maybeAutomergeUrl, ...path] = href.split("/");
876
+ const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/");
1036
877
 
1037
878
  if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
1038
879
  return new Response("invalid automerge url", { status: 400 });
1039
880
  }
1040
881
 
1041
- // Trim trailing empty path segment
1042
882
  if (path.length && !path[path.length - 1]) path.pop();
1043
883
 
1044
884
  const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
1045
- const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1046
885
 
886
+ // todo, maybe a bad idea? maybe we should throw instead of es-module-caching
887
+ // the headless req
1047
888
  if (!heads) {
1048
889
  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("/")}`;
890
+ const url = stringifyAutomergeUrl({ documentId, heads: folder.heads() });
891
+ const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`;
1053
892
  return Response.redirect(location, 307);
1054
893
  }
1055
894
 
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
895
  const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
1060
896
  signal,
1061
897
  });
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
898
  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
899
  throw new AbortHandoff(
1069
900
  `heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`
1070
901
  );
1071
902
  }
1072
- const rootHandle = baseHandle.view(heads);
1073
903
 
1074
904
  const resolved = await resolvePath(
1075
905
  repo,
1076
- rootHandle,
906
+ baseHandle.view(heads),
1077
907
  path.map(decodeURIComponent)
1078
908
  );
1079
-
1080
909
  if (!resolved) {
1081
910
  throw new Error(
1082
911
  `couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`
@@ -1094,71 +923,57 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
1094
923
  });
1095
924
  }
1096
925
 
1097
- // ── Handoff: resolve special URLs for the service worker ──────────────
1098
-
1099
926
  const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
1100
927
 
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
- }
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
+ );
1120
943
  }
1121
944
 
1122
945
  async function handleHandoffRequest(message: HandoffRequestMessage) {
1123
946
  const { id, cachename, request } = message;
1124
947
 
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({
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(
1132
954
  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);
955
+ 400,
956
+ `couldn't parse a special url out of ${request.url}`
957
+ );
1140
958
  return;
1141
959
  }
1142
960
 
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}`);
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;
1148
968
  }
1149
969
 
1150
970
  let response: Response;
1151
971
  try {
1152
- log(`resolving handoff ${id} for ${handoffURL}`);
972
+ log(`resolving handoff ${id} for ${handoff}`);
973
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1153
974
  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
- ),
975
+ resolveAutomergeUrl(handoff, signal),
976
+ impatience(RESOLVE_TIMEOUT_MS),
1162
977
  ]);
1163
978
  } catch (error) {
1164
979
  if (error instanceof AbortHandoff) {
@@ -1169,44 +984,22 @@ async function handleHandoffRequest(message: HandoffRequestMessage) {
1169
984
  } satisfies HandoffAbortMessage);
1170
985
  return;
1171
986
  }
1172
- const body =
987
+ console.error(`error resolving ${request.url}`, error);
988
+ replyToHandoff(
989
+ id,
990
+ 557,
1173
991
  error instanceof Error
1174
992
  ? `${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);
993
+ : String(error)
994
+ );
1186
995
  return;
1187
996
  }
1188
997
 
1189
998
  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.
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)
1210
1003
  log(`responding inline to ${request.url} with ${response.status}`);
1211
1004
  handoffChannel.postMessage({
1212
1005
  id,
@@ -1217,18 +1010,27 @@ async function handleHandoffRequest(message: HandoffRequestMessage) {
1217
1010
  body: response.body ? await response.text() : undefined,
1218
1011
  },
1219
1012
  } satisfies HandoffResponseMessage);
1013
+ return;
1220
1014
  }
1221
- } catch (error) {
1222
- 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}`);
1223
1027
  handoffChannel.postMessage({
1224
1028
  id,
1225
- type: "response",
1226
- response: {
1227
- status: 558,
1228
- body: String(error),
1229
- headers: { "content-type": "text/plain" },
1230
- },
1231
- } 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));
1232
1034
  }
1233
1035
  }
1234
1036
 
@@ -1238,6 +1040,6 @@ handoffChannel.addEventListener("message", (event) => {
1238
1040
  }
1239
1041
  });
1240
1042
 
1241
- // Announce ourselves so the service worker can re-broadcast any handoff
1242
- // 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.
1243
1045
  handoffChannel.postMessage({ type: "online" } satisfies HandoffOnlineMessage);