@inkandswitch/patchwork-bootloader 0.4.1 → 0.4.3

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.
package/src/setup.ts CHANGED
@@ -89,13 +89,85 @@ function configureServiceWorker(sw: ServiceWorker | null) {
89
89
 
90
90
  // ── The automerge worker ───────────────────────────────────────────────
91
91
  // The automerge repo lives in a SharedWorker (not the service worker). One
92
- // instance is shared by every tab and lives exactly as long as any tab
93
- // does, so there's no keepalive ping and no restart detection: if we're
94
- // alive, it's alive. Repo sync ports are passed to it over its connect
95
- // port; it talks to the service worker over a BroadcastChannel.
92
+ // instance is shared by every tab and normally lives as long as any tab
93
+ // does but browsers do reap SharedWorkers under memory pressure, so we
94
+ // heartbeat it and rebuild everything if it dies (see
95
+ // recoverAutomergeWorker). Repo sync ports are passed to it over its
96
+ // connect port; it talks to the service worker over a BroadcastChannel.
96
97
 
97
98
  let automergeWorkerPath = "/automerge-worker.js";
98
99
  let automergeWorker: SharedWorker | undefined;
100
+ // Bumped whenever a new SharedWorker is constructed. A repo port opened
101
+ // against instance N is stale once instance N+1 exists (its channel ends in
102
+ // a dead worker), so deliveries are guarded on the generation they started in.
103
+ let workerGeneration = 0;
104
+ // Tears down the current worker's heartbeat when it's replaced.
105
+ let disposeWorkerDeathDetection: (() => void) | undefined;
106
+ // Every subscribeToRepoChannel listener, kept so a recovered worker can hand
107
+ // each subscriber a fresh repo port.
108
+ const repoChannelListeners = new Set<ServiceWorkerRepoChannelListener>();
109
+ let recoveringWorker = false;
110
+ let lastWorkerRecoveryAt = 0;
111
+ // Below this spacing, skip: if the fresh worker is dead too, its own
112
+ // heartbeat re-triggers recovery later rather than spinning in a tight loop.
113
+ const RECOVERY_MIN_INTERVAL_MS = 15_000;
114
+
115
+ /**
116
+ * The automerge SharedWorker died (browser reaped it, or it crashed): build a
117
+ * replacement and re-wire everything a live tab holds against it — console
118
+ * forwarding and the io-proxy port donation (both re-done by
119
+ * getAutomergeWorker), the per-doc sync-state subscriptions, and every
120
+ * subscriber's repo sync port. The new instance boots with cold state; its
121
+ * repo is reconstructed on the first port we send.
122
+ */
123
+ async function recoverAutomergeWorker(
124
+ reason: string,
125
+ deadWorker: SharedWorker
126
+ ): Promise<void> {
127
+ if (deadWorker !== automergeWorker) return; // already replaced
128
+ if (recoveringWorker) return;
129
+ const now = Date.now();
130
+ if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS) return;
131
+ recoveringWorker = true;
132
+ lastWorkerRecoveryAt = now;
133
+ console.warn(
134
+ `[lifecycle] ${new Date().toISOString()} recreating the automerge ` +
135
+ `SharedWorker (${reason})`
136
+ );
137
+ try {
138
+ disposeWorkerDeathDetection?.();
139
+ disposeWorkerDeathDetection = undefined;
140
+ automergeWorker = undefined;
141
+ try {
142
+ deadWorker.port.close();
143
+ } catch {
144
+ // Port already dead.
145
+ }
146
+ const fresh = getAutomergeWorker();
147
+ // The fresh instance knows nothing — replay every doc subscription.
148
+ for (const documentId of syncStateListeners.keys()) {
149
+ fresh.port.postMessage({ type: "sync-sub", documentId });
150
+ }
151
+ // Hand every repo-channel subscriber a fresh port so their repos sync
152
+ // again (the old adapters sit on dead MessagePorts).
153
+ for (const listener of repoChannelListeners) {
154
+ try {
155
+ const generation = workerGeneration;
156
+ const port = await openRepoChannel();
157
+ // Replaced again while we waited — the newer recovery re-delivers.
158
+ if (generation !== workerGeneration) break;
159
+ await listener(port);
160
+ } catch (err) {
161
+ console.error(
162
+ "failed to re-wire a repo channel after worker recovery",
163
+ err
164
+ );
165
+ }
166
+ }
167
+ } finally {
168
+ recoveringWorker = false;
169
+ }
170
+ }
99
171
 
100
172
  // SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
101
173
  // spawn workers from inside a SharedWorker, so each tab offers this proxy's
@@ -129,13 +201,27 @@ function workerBenchParams(): string {
129
201
  return qs ? `?${qs}` : "";
130
202
  }
131
203
 
204
+ function automergeWorkerUrl(): string {
205
+ return `${automergeWorkerPath}${workerBenchParams()}`;
206
+ }
207
+
132
208
  export function getAutomergeWorker(): SharedWorker {
133
209
  if (!automergeWorker) {
134
- const workerUrl = `${automergeWorkerPath}${workerBenchParams()}`;
135
- automergeWorker = new SharedWorker(workerUrl, {
210
+ workerGeneration++;
211
+ automergeWorker = new SharedWorker(automergeWorkerUrl(), {
136
212
  name: "patchwork-automerge",
137
213
  type: "module",
138
214
  });
215
+ // Fired when a message arrives that can't be structured-deserialized —
216
+ // e.g. a transfer list that named something unclonable. Silent otherwise:
217
+ // the message is simply dropped, which looks identical to a worker that
218
+ // never replied. Always loud, not gated on the lifecycle toggle.
219
+ automergeWorker.port.addEventListener("messageerror", (event) => {
220
+ console.error(
221
+ "[automerge-worker] undeserializable message from worker:",
222
+ event
223
+ );
224
+ });
139
225
  // Control replies (port-ready &c) come back on this port, so it needs
140
226
  // start() — we listen with addEventListener, not onmessage.
141
227
  automergeWorker.port.start();
@@ -191,20 +277,45 @@ export function getAutomergeWorker(): SharedWorker {
191
277
  type: "module",
192
278
  name: "subduction-websocket",
193
279
  });
280
+ // This worker carries the websocket to the sync server, so if it fails
281
+ // to load, sync silently stops with no other symptom.
282
+ io.addEventListener("error", (event) => {
283
+ const error = event as ErrorEvent;
284
+ console.error(
285
+ `[subduction-io] failed to load/run ${SUBDUCTION_IO_WORKER_URL}:`,
286
+ error.message || event,
287
+ error.filename ? `(${error.filename}:${error.lineno})` : ""
288
+ );
289
+ });
290
+ io.port.addEventListener("messageerror", (event) => {
291
+ console.error("[subduction-io] undeserializable message:", event);
292
+ });
194
293
  return io.port;
195
294
  });
196
295
 
197
- installWorkerDeathDetection(automergeWorker);
296
+ disposeWorkerDeathDetection = installWorkerDeathDetection(automergeWorker);
198
297
  }
199
298
  return automergeWorker;
200
299
  }
201
300
 
202
301
  /**
203
- * Detect when the automerge SharedWorker dies or restarts: control-port close,
204
- * worker error, changed instance id, or an unanswered heartbeat while the tab
205
- * is visible (a miss while hidden is more likely suspension). [lifecycle]-tagged.
302
+ * Detect when the automerge SharedWorker dies or its control port goes deaf.
303
+ *
304
+ * Silence alone is NOT proof of death: the worker may still be evaluating its
305
+ * (large) module graph on a cold boot, or its single thread may be busy with
306
+ * wasm/sync work — in both cases every queued message (including the repo
307
+ * ports the network adapters ride on) is delivered fine once it catches up,
308
+ * and tearing the port down would *lose* them. So silence only starts a
309
+ * non-destructive PROBE: a second SharedWorker connection to the same
310
+ * instance. Only when the probe gets a `hello` while this port stays silent do
311
+ * we know the instance is alive-and-responsive but our port is stranded (a
312
+ * failure mode observed in the wild) — or was replaced — and recovery is
313
+ * warranted. A `close` event (where supported) is a definitive death signal
314
+ * and recovers immediately. [lifecycle]-tagged. Returns a dispose that stops
315
+ * the heartbeat and any outstanding probe (called when this worker is
316
+ * replaced).
206
317
  */
207
- function installWorkerDeathDetection(worker: SharedWorker): void {
318
+ function installWorkerDeathDetection(worker: SharedWorker): () => void {
208
319
  const stamp = () => new Date().toISOString();
209
320
  const warn = (msg: string) => {
210
321
  if (lifecycleLoggingEnabled()) console.warn(`[lifecycle] ${stamp()} ${msg}`);
@@ -214,23 +325,37 @@ function installWorkerDeathDetection(worker: SharedWorker): void {
214
325
  };
215
326
 
216
327
  let instanceId: string | undefined;
217
- let lastPongAt = Date.now();
328
+ let lastHeardAt = Date.now();
218
329
  let warnedUnresponsive = false;
330
+ let disposed = false;
331
+ let probe: SharedWorker | undefined;
332
+
333
+ const closeProbe = () => {
334
+ if (!probe) return;
335
+ try {
336
+ probe.port.close();
337
+ } catch {
338
+ // Already closed.
339
+ }
340
+ probe = undefined;
341
+ };
342
+ let warnedSendFailed = false;
343
+ let pingsSent = 0;
219
344
 
220
345
  worker.port.addEventListener("message", (event: MessageEvent) => {
221
346
  const data = event.data;
222
347
  if (data?.type !== "hello" && data?.type !== "pong") return;
223
- if (data.type === "pong") {
224
- lastPongAt = Date.now();
225
- warnedUnresponsive = false;
226
- }
348
+ lastHeardAt = Date.now();
349
+ warnedUnresponsive = false;
350
+ // The port spoke — any outstanding probe is moot.
351
+ closeProbe();
227
352
  if (instanceId === undefined) {
228
353
  instanceId = data.instanceId;
229
354
  info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
230
355
  } else if (data.instanceId && data.instanceId !== instanceId) {
231
356
  warn(
232
- `automerge SharedWorker RESTARTED (instance ${data.instanceId}, ` +
233
- `was ${instanceId}) — fresh peerId + cold state; docs need re-subscribe`
357
+ `automerge SharedWorker instance changed (instance ${data.instanceId}, ` +
358
+ `was ${instanceId})`
234
359
  );
235
360
  instanceId = data.instanceId;
236
361
  }
@@ -238,35 +363,107 @@ function installWorkerDeathDetection(worker: SharedWorker): void {
238
363
 
239
364
  // Fires when the SharedWorker is destroyed (where supported).
240
365
  worker.port.addEventListener("close", () => {
241
- warn("automerge SharedWorker control port CLOSED — worker terminated");
366
+ if (disposed) return;
367
+ warn("automerge SharedWorker control port closed");
368
+ void recoverAutomergeWorker("control port closed", worker);
242
369
  });
243
370
 
371
+ // Not gated on the lifecycle toggle: a worker that fails to load never
372
+ // replies to anything, and this is the only signal that says so.
244
373
  worker.addEventListener("error", event => {
245
- warn(`automerge SharedWorker error: ${(event as ErrorEvent).message || event}`);
374
+ const error = event as ErrorEvent;
375
+ console.error(
376
+ `[lifecycle] ${stamp()} automerge SharedWorker error:`,
377
+ error.message || event,
378
+ error.filename ? `(${error.filename}:${error.lineno})` : ""
379
+ );
246
380
  });
247
381
 
248
- // A missed pong while the tab is visible means the worker likely died (an
249
- // active tab keeps it alive); a miss while hidden is more likely suspension.
250
- const HEARTBEAT_MS = 10_000;
382
+ // On probe hello, give the suspect port this long to also speak before
383
+ // concluding it's stranded: after a slow worker boot both connections hello
384
+ // at roughly the same moment and cross-port delivery order isn't guaranteed.
385
+ const PROBE_GRACE_MS = 500;
386
+ const startProbe = (reason: string) => {
387
+ if (probe || disposed) return;
388
+ warn(`automerge SharedWorker ${reason}; probing with a second connection`);
389
+ const startedAt = Date.now();
390
+ const p = new SharedWorker(automergeWorkerUrl(), {
391
+ name: "patchwork-automerge",
392
+ type: "module",
393
+ });
394
+ probe = p;
395
+ p.port.start();
396
+ p.port.addEventListener("message", (event: MessageEvent) => {
397
+ if (event.data?.type !== "hello") return;
398
+ setTimeout(() => {
399
+ if (disposed || probe !== p) return; // superseded or torn down
400
+ closeProbe();
401
+ // The suspect spoke while (or just after) the probe ran: it was
402
+ // merely slow/busy, and everything queued on it has been delivered.
403
+ if (lastHeardAt >= startedAt) return;
404
+ void recoverAutomergeWorker(
405
+ `port unresponsive on a live worker (${reason}; probe confirmed)`,
406
+ worker
407
+ );
408
+ }, PROBE_GRACE_MS);
409
+ });
410
+ // No hello on the probe means the instance is loading or busy (the probe
411
+ // waits indefinitely — its hello triggers the check above whenever it
412
+ // lands) — never tear anything down on a timer.
413
+ };
414
+
415
+ // A silent-too-long port while the tab is visible starts a probe (a miss
416
+ // while hidden is more likely suspension). Before first contact the budget
417
+ // is tighter: an idle worker hellos within milliseconds of connecting, so
418
+ // probing early costs nothing and rescues genuinely stranded boots fast.
419
+ const HEARTBEAT_MS = 5_000;
251
420
  const HEARTBEAT_TIMEOUT_MS = 25_000;
421
+ const FIRST_CONTACT_TIMEOUT_MS = 4_000;
252
422
  let seq = 0;
253
- setInterval(() => {
423
+ const heartbeat = setInterval(() => {
254
424
  try {
255
425
  worker.port.postMessage({ type: "ping", id: ++seq });
256
- } catch {
257
- // Port already torn down — the "close" handler covers that case.
426
+ pingsSent++;
427
+ } catch (error) {
428
+ // Swallowing this makes a failed send indistinguishable from a dead
429
+ // worker in the "no pong" warning below. Once, not every heartbeat.
430
+ if (!warnedSendFailed) {
431
+ warnedSendFailed = true;
432
+ console.error(
433
+ `[lifecycle] ${stamp()} automerge SharedWorker ping send threw ` +
434
+ `after ${pingsSent} sent:`,
435
+ error
436
+ );
437
+ }
258
438
  }
259
- const silentMs = Date.now() - lastPongAt;
439
+ const neverHeard = instanceId === undefined;
440
+ const silentMs = Date.now() - lastHeardAt;
441
+ const timeoutMs = neverHeard
442
+ ? FIRST_CONTACT_TIMEOUT_MS
443
+ : HEARTBEAT_TIMEOUT_MS;
260
444
  const visible =
261
445
  typeof document === "undefined" || document.visibilityState === "visible";
262
- if (silentMs > HEARTBEAT_TIMEOUT_MS && visible && !warnedUnresponsive) {
263
- warnedUnresponsive = true;
264
- warn(
265
- `automerge SharedWorker UNRESPONSIVE ~${Math.round(silentMs / 1000)}s ` +
266
- `while tab visible likely died/crashed`
267
- );
268
- }
446
+ // First contact probes regardless of visibility: SharedWorkers don't
447
+ // suspend with tab visibility, boots in background tabs must still get
448
+ // rescued, and the probe destroys nothing. Post-contact silence defers to
449
+ // visibility, since a hidden page's own throttling can fake it.
450
+ if (silentMs > timeoutMs && (neverHeard || visible)) {
451
+ const reason = neverHeard
452
+ ? `no hello ~${Math.round(silentMs / 1000)}s after connecting`
453
+ : `no pong for ~${Math.round(silentMs / 1000)}s`;
454
+ if (!warnedUnresponsive) {
455
+ warnedUnresponsive = true;
456
+ warn(`automerge SharedWorker ${reason} (tab visible)`);
457
+ }
458
+ startProbe(reason);
459
+ }
269
460
  }, HEARTBEAT_MS);
461
+
462
+ return () => {
463
+ disposed = true;
464
+ clearInterval(heartbeat);
465
+ closeProbe();
466
+ };
270
467
  }
271
468
 
272
469
  // ── Sync-state subscriptions ────────────────────────────────────────────
@@ -311,7 +508,10 @@ export function subscribeSyncState(
311
508
  set.delete(listener);
312
509
  if (set.size === 0) {
313
510
  syncStateListeners.delete(documentId);
314
- worker.port.postMessage({ type: "sync-unsub", documentId });
511
+ // The worker may have been replaced since we subscribed (recovery
512
+ // replays subscriptions onto the new instance) — unsubscribe from
513
+ // whichever instance is current, not the one captured above.
514
+ automergeWorker?.port.postMessage({ type: "sync-unsub", documentId });
315
515
  }
316
516
  };
317
517
  }
@@ -424,6 +624,12 @@ export default async function setupServiceWorker(
424
624
  installServiceWorkerLogForwarding();
425
625
  localStorage.removeItem(key);
426
626
 
627
+ // Ask for persistent storage so cache growth can't trip origin-wide
628
+ // eviction, which would take the Automerge IndexedDB — the user's documents
629
+ // — with it. Chrome/Safari decide silently from site engagement; Firefox may
630
+ // show a one-time prompt. Best-effort: denial just means default eviction.
631
+ void navigator.storage?.persist?.().catch(() => {});
632
+
427
633
  if (options?.workerPath) automergeWorkerPath = options.workerPath;
428
634
 
429
635
  // Start the automerge worker right away so it boots (wasm, repo) while the
@@ -481,10 +687,21 @@ export default async function setupServiceWorker(
481
687
  getRepoChannel,
482
688
  subscribeSyncState,
483
689
  async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
484
- // The automerge worker outlives the page, so unlike the old in-service-
485
- // worker repo there's nothing to reconnect: one port, handed over once.
486
- await listener(await openRepoChannel());
487
- return () => {};
690
+ // Called once with the boot port. If the automerge worker later dies
691
+ // and is recreated (recoverAutomergeWorker), the listener is called
692
+ // again with a fresh port — treat every call as "(re)wire your repo's
693
+ // sync onto this port".
694
+ repoChannelListeners.add(listener);
695
+ const generation = workerGeneration;
696
+ const port = await openRepoChannel();
697
+ // If the worker was replaced while this channel was opening (e.g. the
698
+ // port-ready wait timed out against a stranded connection and recovery
699
+ // already delivered a good port to this listener), drop the stale one
700
+ // rather than wiring the repo to a dead channel.
701
+ if (generation === workerGeneration) await listener(port);
702
+ return () => {
703
+ repoChannelListeners.delete(listener);
704
+ };
488
705
  },
489
706
  };
490
707
  }
package/src/site.ts CHANGED
@@ -37,7 +37,7 @@ import { MemorySigner } from "@automerge/automerge-subduction/slim";
37
37
 
38
38
  declare const __SITE_NAME__: string;
39
39
  const siteName =
40
- typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
40
+ typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
41
41
 
42
42
  // Sync-server selection for keyhive. Defaults to "subduction". Build with
43
43
  // KEYHIVE_SYNC_SERVER=true to target keyhive.sync.automerge.org. This must match
@@ -48,7 +48,7 @@ const useKeyhiveSyncServer =
48
48
  typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
49
49
 
50
50
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
51
- import { importAutomergeModuleViaWorker } from "./module-loader.js";
51
+ import { importAutomergePackageViaWorker } from "./module-loader.js";
52
52
  import {
53
53
  openDocument,
54
54
  registerPatchworkViewElement,
@@ -65,10 +65,7 @@ import {
65
65
  } from "@inkandswitch/patchwork-plugins";
66
66
  import * as plugins from "@inkandswitch/patchwork-plugins";
67
67
 
68
- import setupServiceWorker, {
69
- getAutomergeWorker,
70
- lifecycleLoggingEnabled,
71
- } from "./setup.js";
68
+ import setupServiceWorker, { lifecycleLoggingEnabled } from "./setup.js";
72
69
  import type {
73
70
  ServiceWorkerRepoChannelListener,
74
71
  SyncStateDocMessage,
@@ -126,7 +123,7 @@ export interface SiteConfig {
126
123
  * folder docs or plain HTTP(S) bundles, so deployment targets can be freely
127
124
  * mixed.
128
125
  *
129
- * Can be overridden at runtime by setting `localStorage.defaultToolsUrl` to
126
+ * Can be overridden at runtime by setting `localStorage.systemPackageListURL` to
130
127
  * another `automerge:` URL or manifest URL — useful for local development
131
128
  * against an unpublished tool set.
132
129
  */
@@ -179,7 +176,7 @@ const BIG_PATCHWORK_HASH_REGEX =
179
176
  /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
180
177
 
181
178
  const [automergeWasm, subductionWasm] = await Promise.all([
182
- fetch("/automerge.wasm?main").then((r) => r.bytes()),
179
+ fetch("/automerge.wasm").then((r) => r.bytes()),
183
180
  fetch("/subduction.wasm").then((r) => r.bytes()),
184
181
  ]);
185
182
 
@@ -211,6 +208,10 @@ export async function bootPatchworkSite(
211
208
  let repo: Repo;
212
209
  let tabSignerIdentity: { peerId: string; verifyingKey: string } | undefined;
213
210
 
211
+ // Called with a fresh port when the automerge worker dies and is recreated
212
+ // (see recoverAutomergeWorker in setup.ts); assigned once the repo exists.
213
+ let onWorkerPortRenewed: ((port: MessagePort) => void) | undefined;
214
+
214
215
  // If a Repo is already on `window` — an embedding context provided one before
215
216
  // this entry ran — reuse it and its keyhive instead of standing up a fresh
216
217
  // realm-local Repo, so we share the same documents and sync/keyhive context.
@@ -222,14 +223,34 @@ export async function bootPatchworkSite(
222
223
  } else {
223
224
  // Get the initial automerge-worker port via subscribeToRepoChannel,
224
225
  // then pass it to keyhive init which wraps it in its own network adapter.
225
- let resolvePort!: (port: MessagePort) => void;
226
- const portPromise = new Promise<MessagePort>((r) => {
227
- resolvePort = r;
226
+ // The listener is called again with a fresh port if the worker is ever
227
+ // recreated after dying.
228
+ let resolveFirstPort!: (port: MessagePort) => void;
229
+ const firstPortPromise = new Promise<MessagePort>((r) => {
230
+ resolveFirstPort = r;
228
231
  });
232
+ let seenFirstPort = false;
229
233
  log("subscribing to repo channel");
230
- await sw.subscribeToRepoChannel(resolvePort);
234
+ // Deliberately not awaited: subscribeToRepoChannel resolves only after
235
+ // the boot channel's port-ready handshake, which can take its full 30s
236
+ // timeout against a stranded worker connection. Boot should block on the
237
+ // first *delivered* port instead — if the boot channel stalls, worker
238
+ // recovery hands the listener a good port long before that timeout.
239
+ void sw.subscribeToRepoChannel((port) => {
240
+ if (!seenFirstPort) {
241
+ seenFirstPort = true;
242
+ resolveFirstPort(port);
243
+ } else if (onWorkerPortRenewed) {
244
+ onWorkerPortRenewed(port);
245
+ } else {
246
+ console.warn(
247
+ "automerge worker port renewed before the repo existed; dropping it"
248
+ );
249
+ }
250
+ });
251
+ const workerPort = await firstPortPromise;
231
252
  log("repo channel subscribed");
232
- const workerPort = await portPromise;
253
+ let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
233
254
 
234
255
  if (config.keyhive) {
235
256
  log("setting up keyhive");
@@ -239,7 +260,7 @@ export async function bootPatchworkSite(
239
260
  createRepo: (config) => new Repo(config),
240
261
  storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
241
262
  peerIdSuffix: siteName + Math.random().toString(36).slice(2),
242
- networkAdapter: new MessageChannelNetworkAdapter(workerPort),
263
+ networkAdapter: workerAdapter,
243
264
  automaticArchiveIngestion: true,
244
265
  cachingMode: "periodic",
245
266
  onlyShareWithHardcodedServerPeerId: false,
@@ -260,7 +281,7 @@ export async function bootPatchworkSite(
260
281
  // id never goes on the wire.
261
282
  const tabSigner = new MemorySigner();
262
283
  repo = new Repo({
263
- network: [new MessageChannelNetworkAdapter(workerPort)],
284
+ network: [workerAdapter],
264
285
  storage: new IndexedDBWorkerStorageAdapter(),
265
286
  signer: tabSigner,
266
287
  async sharePolicy(peerId) {
@@ -281,6 +302,37 @@ export async function bootPatchworkSite(
281
302
  console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
282
303
  log("repo created");
283
304
  }
305
+
306
+ // The worker was recreated with cold state: wire the repo onto the fresh
307
+ // port and drop the adapter stranded on the dead one. `repo`/`hive` are
308
+ // settled by the time this can fire (recovery needs a missed heartbeat).
309
+ const bootHive = hive;
310
+ onWorkerPortRenewed = (port) => {
311
+ const fresh = new MessageChannelNetworkAdapter(port);
312
+ // Mirror the boot wiring: a keyhive repo talks to the worker through a
313
+ // keyhive adapter wrapped around the message channel (same parameters
314
+ // the worker uses for its side of the pair).
315
+ const registered = bootHive
316
+ ? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
317
+ : fresh;
318
+ repo.networkSubsystem.addNetworkAdapter(registered as any);
319
+ for (const adapter of [...repo.networkSubsystem.adapters]) {
320
+ if (adapter === (registered as any)) continue;
321
+ // The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
322
+ const base = (adapter as any).networkAdapter ?? adapter;
323
+ if (base !== workerAdapter) continue;
324
+ try {
325
+ repo.networkSubsystem.removeNetworkAdapter(adapter as any);
326
+ } catch (err) {
327
+ console.error("failed to remove stale worker network adapter", err);
328
+ }
329
+ }
330
+ workerAdapter = fresh;
331
+ console.warn(
332
+ `[lifecycle] ${new Date().toISOString()} repo re-wired to the ` +
333
+ `recreated automerge worker`
334
+ );
335
+ };
284
336
  }
285
337
  log("popping repo on window");
286
338
  window.repo = repo;
@@ -322,7 +374,7 @@ export async function bootPatchworkSite(
322
374
  unregisterPlugins,
323
375
  // Discover an Automerge package's plugin descriptors off the main thread;
324
376
  // each plugin's load() re-imports the package (at heads) on this thread.
325
- importAutomergeModuleViaWorker
377
+ importAutomergePackageViaWorker
326
378
  );
327
379
 
328
380
  const accountDocHandle = (await resolveAccountHandle(repo, {
@@ -381,7 +433,7 @@ function isValidModuleSource(source: string): boolean {
381
433
 
382
434
  /**
383
435
  * Resolve the site's default module-list sources, honouring the
384
- * `localStorage.defaultToolsUrl` dev override (which replaces the entire
436
+ * `localStorage.systemPackageListURL` dev override (which replaces the entire
385
437
  * built-in default bundle).
386
438
  */
387
439
  function resolveDefaultModules(config: SiteConfig): string[] {
@@ -390,18 +442,22 @@ function resolveDefaultModules(config: SiteConfig): string[] {
390
442
  Boolean
391
443
  );
392
444
 
393
- const override = globalThis.localStorage?.getItem("defaultToolsUrl");
445
+ const storage = globalThis.localStorage;
446
+ // `defaultToolsUrl` is the pre-rename key, still honoured for existing browsers.
447
+ const override =
448
+ storage?.getItem("systemPackageListURL") ??
449
+ storage?.getItem("defaultToolsUrl");
394
450
  if (override) {
395
451
  if (isValidModuleSource(override)) {
396
452
  if (!builtinList.includes(override)) {
397
453
  console.info(
398
- `using defaultToolsUrl override from localStorage: ${override}`
454
+ `using systemPackageListURL override from localStorage: ${override}`
399
455
  );
400
456
  }
401
457
  return [override];
402
458
  }
403
459
  console.warn(
404
- `ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`
460
+ `ignoring invalid systemPackageListURL in localStorage: ${override}; using built-in default`
405
461
  );
406
462
  }
407
463
 
@@ -462,16 +518,26 @@ function installLifecycleLogging(): void {
462
518
  () => note(`visibilitychange → ${document.visibilityState}`),
463
519
  opts
464
520
  );
465
- document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
466
- document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
521
+ document.addEventListener(
522
+ "freeze",
523
+ () => note("freeze (tab suspended)"),
524
+ opts
525
+ );
526
+ document.addEventListener(
527
+ "resume",
528
+ () => note("resume (tab unsuspended)"),
529
+ opts
530
+ );
467
531
  window.addEventListener(
468
532
  "pageshow",
469
- e => note("pageshow", { persisted: (e as PageTransitionEvent).persisted }),
533
+ (e) =>
534
+ note("pageshow", { persisted: (e as PageTransitionEvent).persisted }),
470
535
  opts
471
536
  );
472
537
  window.addEventListener(
473
538
  "pagehide",
474
- e => note("pagehide", { persisted: (e as PageTransitionEvent).persisted }),
539
+ (e) =>
540
+ note("pagehide", { persisted: (e as PageTransitionEvent).persisted }),
475
541
  opts
476
542
  );
477
543
  window.addEventListener("online", () => note("online"), opts);
package/src/types.ts CHANGED
@@ -161,7 +161,28 @@ export interface HandoffResponseMessage {
161
161
  response: HandoffResponse;
162
162
  }
163
163
 
164
- export type HandoffReplyMessage = HandoffCachedMessage | HandoffResponseMessage;
164
+ /**
165
+ * Automerge worker → service worker: fail the request as a network error
166
+ * rather than serving any response at all.
167
+ *
168
+ * "Heads haven't arrived yet" is not a 404 — the document may well exist, so
169
+ * a status implying it doesn't is a lie any HTTP cache is entitled to store.
170
+ * A network error is the honest answer and isn't storable as a response.
171
+ *
172
+ * This does *not* help with `import()`: the ES module map memoizes failed
173
+ * fetches, network errors included, so a retry needs a distinct URL either
174
+ * way (see `importModule` in patchwork-filesystem).
175
+ */
176
+ export interface HandoffAbortMessage {
177
+ id: string;
178
+ type: "abort";
179
+ reason: string;
180
+ }
181
+
182
+ export type HandoffReplyMessage =
183
+ | HandoffCachedMessage
184
+ | HandoffResponseMessage
185
+ | HandoffAbortMessage;
165
186
 
166
187
  /**
167
188
  * Automerge worker → world: broadcast once on startup so the service worker