@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/dist/setup.js CHANGED
@@ -70,12 +70,82 @@ function configureServiceWorker(sw) {
70
70
  }
71
71
  // ── The automerge worker ───────────────────────────────────────────────
72
72
  // The automerge repo lives in a SharedWorker (not the service worker). One
73
- // instance is shared by every tab and lives exactly as long as any tab
74
- // does, so there's no keepalive ping and no restart detection: if we're
75
- // alive, it's alive. Repo sync ports are passed to it over its connect
76
- // port; it talks to the service worker over a BroadcastChannel.
73
+ // instance is shared by every tab and normally lives as long as any tab
74
+ // does but browsers do reap SharedWorkers under memory pressure, so we
75
+ // heartbeat it and rebuild everything if it dies (see
76
+ // recoverAutomergeWorker). Repo sync ports are passed to it over its
77
+ // connect port; it talks to the service worker over a BroadcastChannel.
77
78
  let automergeWorkerPath = "/automerge-worker.js";
78
79
  let automergeWorker;
80
+ // Bumped whenever a new SharedWorker is constructed. A repo port opened
81
+ // against instance N is stale once instance N+1 exists (its channel ends in
82
+ // a dead worker), so deliveries are guarded on the generation they started in.
83
+ let workerGeneration = 0;
84
+ // Tears down the current worker's heartbeat when it's replaced.
85
+ let disposeWorkerDeathDetection;
86
+ // Every subscribeToRepoChannel listener, kept so a recovered worker can hand
87
+ // each subscriber a fresh repo port.
88
+ const repoChannelListeners = new Set();
89
+ let recoveringWorker = false;
90
+ let lastWorkerRecoveryAt = 0;
91
+ // Below this spacing, skip: if the fresh worker is dead too, its own
92
+ // heartbeat re-triggers recovery later rather than spinning in a tight loop.
93
+ const RECOVERY_MIN_INTERVAL_MS = 15_000;
94
+ /**
95
+ * The automerge SharedWorker died (browser reaped it, or it crashed): build a
96
+ * replacement and re-wire everything a live tab holds against it — console
97
+ * forwarding and the io-proxy port donation (both re-done by
98
+ * getAutomergeWorker), the per-doc sync-state subscriptions, and every
99
+ * subscriber's repo sync port. The new instance boots with cold state; its
100
+ * repo is reconstructed on the first port we send.
101
+ */
102
+ async function recoverAutomergeWorker(reason, deadWorker) {
103
+ if (deadWorker !== automergeWorker)
104
+ return; // already replaced
105
+ if (recoveringWorker)
106
+ return;
107
+ const now = Date.now();
108
+ if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS)
109
+ return;
110
+ recoveringWorker = true;
111
+ lastWorkerRecoveryAt = now;
112
+ console.warn(`[lifecycle] ${new Date().toISOString()} recreating the automerge ` +
113
+ `SharedWorker (${reason})`);
114
+ try {
115
+ disposeWorkerDeathDetection?.();
116
+ disposeWorkerDeathDetection = undefined;
117
+ automergeWorker = undefined;
118
+ try {
119
+ deadWorker.port.close();
120
+ }
121
+ catch {
122
+ // Port already dead.
123
+ }
124
+ const fresh = getAutomergeWorker();
125
+ // The fresh instance knows nothing — replay every doc subscription.
126
+ for (const documentId of syncStateListeners.keys()) {
127
+ fresh.port.postMessage({ type: "sync-sub", documentId });
128
+ }
129
+ // Hand every repo-channel subscriber a fresh port so their repos sync
130
+ // again (the old adapters sit on dead MessagePorts).
131
+ for (const listener of repoChannelListeners) {
132
+ try {
133
+ const generation = workerGeneration;
134
+ const port = await openRepoChannel();
135
+ // Replaced again while we waited — the newer recovery re-delivers.
136
+ if (generation !== workerGeneration)
137
+ break;
138
+ await listener(port);
139
+ }
140
+ catch (err) {
141
+ console.error("failed to re-wire a repo channel after worker recovery", err);
142
+ }
143
+ }
144
+ }
145
+ finally {
146
+ recoveringWorker = false;
147
+ }
148
+ }
79
149
  // SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
80
150
  // spawn workers from inside a SharedWorker, so each tab offers this proxy's
81
151
  // port to the automerge worker (which requests one via its port provider).
@@ -107,13 +177,23 @@ function workerBenchParams() {
107
177
  const qs = params.toString();
108
178
  return qs ? `?${qs}` : "";
109
179
  }
180
+ function automergeWorkerUrl() {
181
+ return `${automergeWorkerPath}${workerBenchParams()}`;
182
+ }
110
183
  export function getAutomergeWorker() {
111
184
  if (!automergeWorker) {
112
- const workerUrl = `${automergeWorkerPath}${workerBenchParams()}`;
113
- automergeWorker = new SharedWorker(workerUrl, {
185
+ workerGeneration++;
186
+ automergeWorker = new SharedWorker(automergeWorkerUrl(), {
114
187
  name: "patchwork-automerge",
115
188
  type: "module",
116
189
  });
190
+ // Fired when a message arrives that can't be structured-deserialized —
191
+ // e.g. a transfer list that named something unclonable. Silent otherwise:
192
+ // the message is simply dropped, which looks identical to a worker that
193
+ // never replied. Always loud, not gated on the lifecycle toggle.
194
+ automergeWorker.port.addEventListener("messageerror", (event) => {
195
+ console.error("[automerge-worker] undeserializable message from worker:", event);
196
+ });
117
197
  // Control replies (port-ready &c) come back on this port, so it needs
118
198
  // start() — we listen with addEventListener, not onmessage.
119
199
  automergeWorker.port.start();
@@ -169,16 +249,37 @@ export function getAutomergeWorker() {
169
249
  type: "module",
170
250
  name: "subduction-websocket",
171
251
  });
252
+ // This worker carries the websocket to the sync server, so if it fails
253
+ // to load, sync silently stops with no other symptom.
254
+ io.addEventListener("error", (event) => {
255
+ const error = event;
256
+ console.error(`[subduction-io] failed to load/run ${SUBDUCTION_IO_WORKER_URL}:`, error.message || event, error.filename ? `(${error.filename}:${error.lineno})` : "");
257
+ });
258
+ io.port.addEventListener("messageerror", (event) => {
259
+ console.error("[subduction-io] undeserializable message:", event);
260
+ });
172
261
  return io.port;
173
262
  });
174
- installWorkerDeathDetection(automergeWorker);
263
+ disposeWorkerDeathDetection = installWorkerDeathDetection(automergeWorker);
175
264
  }
176
265
  return automergeWorker;
177
266
  }
178
267
  /**
179
- * Detect when the automerge SharedWorker dies or restarts: control-port close,
180
- * worker error, changed instance id, or an unanswered heartbeat while the tab
181
- * is visible (a miss while hidden is more likely suspension). [lifecycle]-tagged.
268
+ * Detect when the automerge SharedWorker dies or its control port goes deaf.
269
+ *
270
+ * Silence alone is NOT proof of death: the worker may still be evaluating its
271
+ * (large) module graph on a cold boot, or its single thread may be busy with
272
+ * wasm/sync work — in both cases every queued message (including the repo
273
+ * ports the network adapters ride on) is delivered fine once it catches up,
274
+ * and tearing the port down would *lose* them. So silence only starts a
275
+ * non-destructive PROBE: a second SharedWorker connection to the same
276
+ * instance. Only when the probe gets a `hello` while this port stays silent do
277
+ * we know the instance is alive-and-responsive but our port is stranded (a
278
+ * failure mode observed in the wild) — or was replaced — and recovery is
279
+ * warranted. A `close` event (where supported) is a definitive death signal
280
+ * and recovers immediately. [lifecycle]-tagged. Returns a dispose that stops
281
+ * the heartbeat and any outstanding probe (called when this worker is
282
+ * replaced).
182
283
  */
183
284
  function installWorkerDeathDetection(worker) {
184
285
  const stamp = () => new Date().toISOString();
@@ -191,53 +292,135 @@ function installWorkerDeathDetection(worker) {
191
292
  console.info(`[lifecycle] ${stamp()} ${msg}`);
192
293
  };
193
294
  let instanceId;
194
- let lastPongAt = Date.now();
295
+ let lastHeardAt = Date.now();
195
296
  let warnedUnresponsive = false;
297
+ let disposed = false;
298
+ let probe;
299
+ const closeProbe = () => {
300
+ if (!probe)
301
+ return;
302
+ try {
303
+ probe.port.close();
304
+ }
305
+ catch {
306
+ // Already closed.
307
+ }
308
+ probe = undefined;
309
+ };
310
+ let warnedSendFailed = false;
311
+ let pingsSent = 0;
196
312
  worker.port.addEventListener("message", (event) => {
197
313
  const data = event.data;
198
314
  if (data?.type !== "hello" && data?.type !== "pong")
199
315
  return;
200
- if (data.type === "pong") {
201
- lastPongAt = Date.now();
202
- warnedUnresponsive = false;
203
- }
316
+ lastHeardAt = Date.now();
317
+ warnedUnresponsive = false;
318
+ // The port spoke — any outstanding probe is moot.
319
+ closeProbe();
204
320
  if (instanceId === undefined) {
205
321
  instanceId = data.instanceId;
206
322
  info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
207
323
  }
208
324
  else if (data.instanceId && data.instanceId !== instanceId) {
209
- warn(`automerge SharedWorker RESTARTED (instance ${data.instanceId}, ` +
210
- `was ${instanceId}) — fresh peerId + cold state; docs need re-subscribe`);
325
+ warn(`automerge SharedWorker instance changed (instance ${data.instanceId}, ` +
326
+ `was ${instanceId})`);
211
327
  instanceId = data.instanceId;
212
328
  }
213
329
  });
214
330
  // Fires when the SharedWorker is destroyed (where supported).
215
331
  worker.port.addEventListener("close", () => {
216
- warn("automerge SharedWorker control port CLOSED — worker terminated");
332
+ if (disposed)
333
+ return;
334
+ warn("automerge SharedWorker control port closed");
335
+ void recoverAutomergeWorker("control port closed", worker);
217
336
  });
337
+ // Not gated on the lifecycle toggle: a worker that fails to load never
338
+ // replies to anything, and this is the only signal that says so.
218
339
  worker.addEventListener("error", event => {
219
- warn(`automerge SharedWorker error: ${event.message || event}`);
340
+ const error = event;
341
+ console.error(`[lifecycle] ${stamp()} automerge SharedWorker error:`, error.message || event, error.filename ? `(${error.filename}:${error.lineno})` : "");
220
342
  });
221
- // A missed pong while the tab is visible means the worker likely died (an
222
- // active tab keeps it alive); a miss while hidden is more likely suspension.
223
- const HEARTBEAT_MS = 10_000;
343
+ // On probe hello, give the suspect port this long to also speak before
344
+ // concluding it's stranded: after a slow worker boot both connections hello
345
+ // at roughly the same moment and cross-port delivery order isn't guaranteed.
346
+ const PROBE_GRACE_MS = 500;
347
+ const startProbe = (reason) => {
348
+ if (probe || disposed)
349
+ return;
350
+ warn(`automerge SharedWorker ${reason}; probing with a second connection`);
351
+ const startedAt = Date.now();
352
+ const p = new SharedWorker(automergeWorkerUrl(), {
353
+ name: "patchwork-automerge",
354
+ type: "module",
355
+ });
356
+ probe = p;
357
+ p.port.start();
358
+ p.port.addEventListener("message", (event) => {
359
+ if (event.data?.type !== "hello")
360
+ return;
361
+ setTimeout(() => {
362
+ if (disposed || probe !== p)
363
+ return; // superseded or torn down
364
+ closeProbe();
365
+ // The suspect spoke while (or just after) the probe ran: it was
366
+ // merely slow/busy, and everything queued on it has been delivered.
367
+ if (lastHeardAt >= startedAt)
368
+ return;
369
+ void recoverAutomergeWorker(`port unresponsive on a live worker (${reason}; probe confirmed)`, worker);
370
+ }, PROBE_GRACE_MS);
371
+ });
372
+ // No hello on the probe means the instance is loading or busy (the probe
373
+ // waits indefinitely — its hello triggers the check above whenever it
374
+ // lands) — never tear anything down on a timer.
375
+ };
376
+ // A silent-too-long port while the tab is visible starts a probe (a miss
377
+ // while hidden is more likely suspension). Before first contact the budget
378
+ // is tighter: an idle worker hellos within milliseconds of connecting, so
379
+ // probing early costs nothing and rescues genuinely stranded boots fast.
380
+ const HEARTBEAT_MS = 5_000;
224
381
  const HEARTBEAT_TIMEOUT_MS = 25_000;
382
+ const FIRST_CONTACT_TIMEOUT_MS = 4_000;
225
383
  let seq = 0;
226
- setInterval(() => {
384
+ const heartbeat = setInterval(() => {
227
385
  try {
228
386
  worker.port.postMessage({ type: "ping", id: ++seq });
387
+ pingsSent++;
229
388
  }
230
- catch {
231
- // Port already torn down the "close" handler covers that case.
389
+ catch (error) {
390
+ // Swallowing this makes a failed send indistinguishable from a dead
391
+ // worker in the "no pong" warning below. Once, not every heartbeat.
392
+ if (!warnedSendFailed) {
393
+ warnedSendFailed = true;
394
+ console.error(`[lifecycle] ${stamp()} automerge SharedWorker ping send threw ` +
395
+ `after ${pingsSent} sent:`, error);
396
+ }
232
397
  }
233
- const silentMs = Date.now() - lastPongAt;
398
+ const neverHeard = instanceId === undefined;
399
+ const silentMs = Date.now() - lastHeardAt;
400
+ const timeoutMs = neverHeard
401
+ ? FIRST_CONTACT_TIMEOUT_MS
402
+ : HEARTBEAT_TIMEOUT_MS;
234
403
  const visible = typeof document === "undefined" || document.visibilityState === "visible";
235
- if (silentMs > HEARTBEAT_TIMEOUT_MS && visible && !warnedUnresponsive) {
236
- warnedUnresponsive = true;
237
- warn(`automerge SharedWorker UNRESPONSIVE ~${Math.round(silentMs / 1000)}s ` +
238
- `while tab visible likely died/crashed`);
404
+ // First contact probes regardless of visibility: SharedWorkers don't
405
+ // suspend with tab visibility, boots in background tabs must still get
406
+ // rescued, and the probe destroys nothing. Post-contact silence defers to
407
+ // visibility, since a hidden page's own throttling can fake it.
408
+ if (silentMs > timeoutMs && (neverHeard || visible)) {
409
+ const reason = neverHeard
410
+ ? `no hello ~${Math.round(silentMs / 1000)}s after connecting`
411
+ : `no pong for ~${Math.round(silentMs / 1000)}s`;
412
+ if (!warnedUnresponsive) {
413
+ warnedUnresponsive = true;
414
+ warn(`automerge SharedWorker ${reason} (tab visible)`);
415
+ }
416
+ startProbe(reason);
239
417
  }
240
418
  }, HEARTBEAT_MS);
419
+ return () => {
420
+ disposed = true;
421
+ clearInterval(heartbeat);
422
+ closeProbe();
423
+ };
241
424
  }
242
425
  const syncStateListeners = new Map();
243
426
  function dispatchSyncState(update) {
@@ -273,7 +456,10 @@ export function subscribeSyncState(documentId, listener) {
273
456
  set.delete(listener);
274
457
  if (set.size === 0) {
275
458
  syncStateListeners.delete(documentId);
276
- worker.port.postMessage({ type: "sync-unsub", documentId });
459
+ // The worker may have been replaced since we subscribed (recovery
460
+ // replays subscriptions onto the new instance) — unsubscribe from
461
+ // whichever instance is current, not the one captured above.
462
+ automergeWorker?.port.postMessage({ type: "sync-unsub", documentId });
277
463
  }
278
464
  };
279
465
  }
@@ -375,6 +561,11 @@ export default async function setupServiceWorker(options) {
375
561
  // install / activate markers from the controlling worker are rendered here.
376
562
  installServiceWorkerLogForwarding();
377
563
  localStorage.removeItem(key);
564
+ // Ask for persistent storage so cache growth can't trip origin-wide
565
+ // eviction, which would take the Automerge IndexedDB — the user's documents
566
+ // — with it. Chrome/Safari decide silently from site engagement; Firefox may
567
+ // show a one-time prompt. Best-effort: denial just means default eviction.
568
+ void navigator.storage?.persist?.().catch(() => { });
378
569
  if (options?.workerPath)
379
570
  automergeWorkerPath = options.workerPath;
380
571
  // Start the automerge worker right away so it boots (wasm, repo) while the
@@ -417,10 +608,22 @@ export default async function setupServiceWorker(options) {
417
608
  getRepoChannel,
418
609
  subscribeSyncState,
419
610
  async subscribeToRepoChannel(listener) {
420
- // The automerge worker outlives the page, so unlike the old in-service-
421
- // worker repo there's nothing to reconnect: one port, handed over once.
422
- await listener(await openRepoChannel());
423
- return () => { };
611
+ // Called once with the boot port. If the automerge worker later dies
612
+ // and is recreated (recoverAutomergeWorker), the listener is called
613
+ // again with a fresh port — treat every call as "(re)wire your repo's
614
+ // sync onto this port".
615
+ repoChannelListeners.add(listener);
616
+ const generation = workerGeneration;
617
+ const port = await openRepoChannel();
618
+ // If the worker was replaced while this channel was opening (e.g. the
619
+ // port-ready wait timed out against a stranded connection and recovery
620
+ // already delivered a good port to this listener), drop the stale one
621
+ // rather than wiring the repo to a dead channel.
622
+ if (generation === workerGeneration)
623
+ await listener(port);
624
+ return () => {
625
+ repoChannelListeners.delete(listener);
626
+ };
424
627
  },
425
628
  };
426
629
  }
package/dist/site.d.ts CHANGED
@@ -61,7 +61,7 @@ export interface SiteConfig {
61
61
  * folder docs or plain HTTP(S) bundles, so deployment targets can be freely
62
62
  * mixed.
63
63
  *
64
- * Can be overridden at runtime by setting `localStorage.defaultToolsUrl` to
64
+ * Can be overridden at runtime by setting `localStorage.systemPackageListURL` to
65
65
  * another `automerge:` URL or manifest URL — useful for local development
66
66
  * against an unpublished tool set.
67
67
  */
package/dist/site.js CHANGED
@@ -20,15 +20,15 @@ import { initKeyhiveWasm, initializeAutomergeRepoKeyhiveWithRepo, } from "@autom
20
20
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
21
21
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
22
22
  import { MemorySigner } from "@automerge/automerge-subduction/slim";
23
- const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
23
+ const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
24
24
  const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
25
25
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
26
- import { importAutomergeModuleViaWorker } from "./module-loader.js";
26
+ import { importAutomergePackageViaWorker } from "./module-loader.js";
27
27
  import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patchwork-elements";
28
28
  import { registerRepoProviderElement } from "@inkandswitch/patchwork-providers";
29
29
  import { getRegistry, registerPlugins, resolveAccountHandle, unregisterPlugins, } from "@inkandswitch/patchwork-plugins";
30
30
  import * as plugins from "@inkandswitch/patchwork-plugins";
31
- import setupServiceWorker, { lifecycleLoggingEnabled, } from "./setup.js";
31
+ import setupServiceWorker, { lifecycleLoggingEnabled } from "./setup.js";
32
32
  import debug from "debug";
33
33
  const log = debug("patchwork:bootloader:site");
34
34
  // Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
@@ -37,7 +37,7 @@ const log = debug("patchwork:bootloader:site");
37
37
  // characters ahead of it rather than a strict slug charset.
38
38
  const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
39
39
  const [automergeWasm, subductionWasm] = await Promise.all([
40
- fetch("/automerge.wasm?main").then((r) => r.bytes()),
40
+ fetch("/automerge.wasm").then((r) => r.bytes()),
41
41
  fetch("/subduction.wasm").then((r) => r.bytes()),
42
42
  ]);
43
43
  /**
@@ -64,6 +64,9 @@ export async function bootPatchworkSite(config) {
64
64
  let hive;
65
65
  let repo;
66
66
  let tabSignerIdentity;
67
+ // Called with a fresh port when the automerge worker dies and is recreated
68
+ // (see recoverAutomergeWorker in setup.ts); assigned once the repo exists.
69
+ let onWorkerPortRenewed;
67
70
  // If a Repo is already on `window` — an embedding context provided one before
68
71
  // this entry ran — reuse it and its keyhive instead of standing up a fresh
69
72
  // realm-local Repo, so we share the same documents and sync/keyhive context.
@@ -76,14 +79,34 @@ export async function bootPatchworkSite(config) {
76
79
  else {
77
80
  // Get the initial automerge-worker port via subscribeToRepoChannel,
78
81
  // then pass it to keyhive init which wraps it in its own network adapter.
79
- let resolvePort;
80
- const portPromise = new Promise((r) => {
81
- resolvePort = r;
82
+ // The listener is called again with a fresh port if the worker is ever
83
+ // recreated after dying.
84
+ let resolveFirstPort;
85
+ const firstPortPromise = new Promise((r) => {
86
+ resolveFirstPort = r;
82
87
  });
88
+ let seenFirstPort = false;
83
89
  log("subscribing to repo channel");
84
- await sw.subscribeToRepoChannel(resolvePort);
90
+ // Deliberately not awaited: subscribeToRepoChannel resolves only after
91
+ // the boot channel's port-ready handshake, which can take its full 30s
92
+ // timeout against a stranded worker connection. Boot should block on the
93
+ // first *delivered* port instead — if the boot channel stalls, worker
94
+ // recovery hands the listener a good port long before that timeout.
95
+ void sw.subscribeToRepoChannel((port) => {
96
+ if (!seenFirstPort) {
97
+ seenFirstPort = true;
98
+ resolveFirstPort(port);
99
+ }
100
+ else if (onWorkerPortRenewed) {
101
+ onWorkerPortRenewed(port);
102
+ }
103
+ else {
104
+ console.warn("automerge worker port renewed before the repo existed; dropping it");
105
+ }
106
+ });
107
+ const workerPort = await firstPortPromise;
85
108
  log("repo channel subscribed");
86
- const workerPort = await portPromise;
109
+ let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
87
110
  if (config.keyhive) {
88
111
  log("setting up keyhive");
89
112
  initKeyhiveWasm();
@@ -91,7 +114,7 @@ export async function bootPatchworkSite(config) {
91
114
  createRepo: (config) => new Repo(config),
92
115
  storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
93
116
  peerIdSuffix: siteName + Math.random().toString(36).slice(2),
94
- networkAdapter: new MessageChannelNetworkAdapter(workerPort),
117
+ networkAdapter: workerAdapter,
95
118
  automaticArchiveIngestion: true,
96
119
  cachingMode: "periodic",
97
120
  onlyShareWithHardcodedServerPeerId: false,
@@ -113,7 +136,7 @@ export async function bootPatchworkSite(config) {
113
136
  // id never goes on the wire.
114
137
  const tabSigner = new MemorySigner();
115
138
  repo = new Repo({
116
- network: [new MessageChannelNetworkAdapter(workerPort)],
139
+ network: [workerAdapter],
117
140
  storage: new IndexedDBWorkerStorageAdapter(),
118
141
  signer: tabSigner,
119
142
  async sharePolicy(peerId) {
@@ -129,6 +152,37 @@ export async function bootPatchworkSite(config) {
129
152
  console.log("[patchwork] tab subduction identity:", tabSignerIdentity);
130
153
  log("repo created");
131
154
  }
155
+ // The worker was recreated with cold state: wire the repo onto the fresh
156
+ // port and drop the adapter stranded on the dead one. `repo`/`hive` are
157
+ // settled by the time this can fire (recovery needs a missed heartbeat).
158
+ const bootHive = hive;
159
+ onWorkerPortRenewed = (port) => {
160
+ const fresh = new MessageChannelNetworkAdapter(port);
161
+ // Mirror the boot wiring: a keyhive repo talks to the worker through a
162
+ // keyhive adapter wrapped around the message channel (same parameters
163
+ // the worker uses for its side of the pair).
164
+ const registered = bootHive
165
+ ? bootHive.createKeyhiveNetworkAdapter(fresh, false, false, 2000)
166
+ : fresh;
167
+ repo.networkSubsystem.addNetworkAdapter(registered);
168
+ for (const adapter of [...repo.networkSubsystem.adapters]) {
169
+ if (adapter === registered)
170
+ continue;
171
+ // The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
172
+ const base = adapter.networkAdapter ?? adapter;
173
+ if (base !== workerAdapter)
174
+ continue;
175
+ try {
176
+ repo.networkSubsystem.removeNetworkAdapter(adapter);
177
+ }
178
+ catch (err) {
179
+ console.error("failed to remove stale worker network adapter", err);
180
+ }
181
+ }
182
+ workerAdapter = fresh;
183
+ console.warn(`[lifecycle] ${new Date().toISOString()} repo re-wired to the ` +
184
+ `recreated automerge worker`);
185
+ };
132
186
  }
133
187
  log("popping repo on window");
134
188
  window.repo = repo;
@@ -157,7 +211,7 @@ export async function bootPatchworkSite(config) {
157
211
  const moduleWatcher = new ModuleWatcher(repo, buildSystemSources(defaultModuleSources), onModuleLoaded, unregisterPlugins,
158
212
  // Discover an Automerge package's plugin descriptors off the main thread;
159
213
  // each plugin's load() re-imports the package (at heads) on this thread.
160
- importAutomergeModuleViaWorker);
214
+ importAutomergePackageViaWorker);
161
215
  const accountDocHandle = (await resolveAccountHandle(repo, {
162
216
  storageKey: config.accountStorageKey,
163
217
  hive,
@@ -204,21 +258,24 @@ function isValidModuleSource(source) {
204
258
  }
205
259
  /**
206
260
  * Resolve the site's default module-list sources, honouring the
207
- * `localStorage.defaultToolsUrl` dev override (which replaces the entire
261
+ * `localStorage.systemPackageListURL` dev override (which replaces the entire
208
262
  * built-in default bundle).
209
263
  */
210
264
  function resolveDefaultModules(config) {
211
265
  const builtin = config.defaultModules ?? config.defaultModulesUrl ?? [];
212
266
  const builtinList = (Array.isArray(builtin) ? builtin : [builtin]).filter(Boolean);
213
- const override = globalThis.localStorage?.getItem("defaultToolsUrl");
267
+ const storage = globalThis.localStorage;
268
+ // `defaultToolsUrl` is the pre-rename key, still honoured for existing browsers.
269
+ const override = storage?.getItem("systemPackageListURL") ??
270
+ storage?.getItem("defaultToolsUrl");
214
271
  if (override) {
215
272
  if (isValidModuleSource(override)) {
216
273
  if (!builtinList.includes(override)) {
217
- console.info(`using defaultToolsUrl override from localStorage: ${override}`);
274
+ console.info(`using systemPackageListURL override from localStorage: ${override}`);
218
275
  }
219
276
  return [override];
220
277
  }
221
- console.warn(`ignoring invalid defaultToolsUrl in localStorage: ${override}; using built-in default`);
278
+ console.warn(`ignoring invalid systemPackageListURL in localStorage: ${override}; using built-in default`);
222
279
  }
223
280
  if (builtinList.length === 0) {
224
281
  throw new Error("bootPatchworkSite: no default module sources configured (set `defaultModules`)");
@@ -269,8 +326,8 @@ function installLifecycleLogging() {
269
326
  document.addEventListener("visibilitychange", () => note(`visibilitychange → ${document.visibilityState}`), opts);
270
327
  document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
271
328
  document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
272
- window.addEventListener("pageshow", e => note("pageshow", { persisted: e.persisted }), opts);
273
- window.addEventListener("pagehide", e => note("pagehide", { persisted: e.persisted }), opts);
329
+ window.addEventListener("pageshow", (e) => note("pageshow", { persisted: e.persisted }), opts);
330
+ window.addEventListener("pagehide", (e) => note("pagehide", { persisted: e.persisted }), opts);
274
331
  window.addEventListener("online", () => note("online"), opts);
275
332
  window.addEventListener("offline", () => note("offline"), opts);
276
333
  note(`lifecycle logging installed (visibilityState=${document.visibilityState}, hasFocus=${document.hasFocus()})`);
package/dist/types.d.ts CHANGED
@@ -133,7 +133,24 @@ export interface HandoffResponseMessage {
133
133
  type: "response";
134
134
  response: HandoffResponse;
135
135
  }
136
- export type HandoffReplyMessage = HandoffCachedMessage | HandoffResponseMessage;
136
+ /**
137
+ * Automerge worker → service worker: fail the request as a network error
138
+ * rather than serving any response at all.
139
+ *
140
+ * "Heads haven't arrived yet" is not a 404 — the document may well exist, so
141
+ * a status implying it doesn't is a lie any HTTP cache is entitled to store.
142
+ * A network error is the honest answer and isn't storable as a response.
143
+ *
144
+ * This does *not* help with `import()`: the ES module map memoizes failed
145
+ * fetches, network errors included, so a retry needs a distinct URL either
146
+ * way (see `importModule` in patchwork-filesystem).
147
+ */
148
+ export interface HandoffAbortMessage {
149
+ id: string;
150
+ type: "abort";
151
+ reason: string;
152
+ }
153
+ export type HandoffReplyMessage = HandoffCachedMessage | HandoffResponseMessage | HandoffAbortMessage;
137
154
  /**
138
155
  * Automerge worker → world: broadcast once on startup so the service worker
139
156
  * can re-send any handoff requests that raced the worker's boot.
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
+ import { fileURLToPath } from "node:url";
3
4
  const require = createRequire(import.meta.url);
4
5
  /**
5
6
  * these dependencies will be built into the outdir,
@@ -7,6 +8,29 @@ const require = createRequire(import.meta.url);
7
8
  */
8
9
  import externals from "../externals.js";
9
10
  export const builtins = externals.reduce((builtins, name) => ((builtins[name] = `/packages/${name}.js`), builtins), {});
11
+ /**
12
+ * pretend the import came from inside this package, so node_modules resolution
13
+ * walks up from *our* directory and finds our copy of each external. that's why
14
+ * a consuming site never has to install them or agree with us about versions.
15
+ */
16
+ const self = fileURLToPath(import.meta.url);
17
+ /**
18
+ * resolve an external from our node_modules rather than the site's.
19
+ *
20
+ * this goes through rollup's resolver rather than import.meta.resolve or
21
+ * require.resolve because those apply node's conditions: subduction (and
22
+ * others) would hand us `dist/esm/node.js`, which imports node:path and blows
23
+ * up at bundle time. rollup applies the browser conditions vite configured.
24
+ */
25
+ async function resolveExternal(name) {
26
+ const resolved = await this.resolve(name, self, { skipSelf: true });
27
+ if (!resolved) {
28
+ throw new Error(`@patchwork/vite: couldn't resolve the external "${name}" from ` +
29
+ `@inkandswitch/patchwork-bootloader. it should be one of the ` +
30
+ `bootloader's own dependencies.`);
31
+ }
32
+ return resolved.id;
33
+ }
10
34
  /**
11
35
  * merge the importmap option with our builtins
12
36
  */
@@ -26,7 +50,7 @@ export function importmap(options) {
26
50
  this.emitFile({
27
51
  type: "chunk",
28
52
  fileName: fileName.slice(1),
29
- id,
53
+ id: await resolveExternal.call(this, id),
30
54
  preserveSignature: "strict",
31
55
  });
32
56
  }
@@ -51,8 +75,14 @@ export function importmap(options) {
51
75
  source: readFileSync(subdWasmPath),
52
76
  });
53
77
  },
54
- resolveId(id) {
55
- if (id in importmap.imports && !(id in builtins)) {
78
+ async resolveId(id) {
79
+ if (id in builtins) {
80
+ // point the site's own imports at the same copy we emit as a chunk,
81
+ // otherwise rollup bundles a second one out of the site's node_modules
82
+ // and you end up with two automerges racing to init the same wasm
83
+ return resolveExternal.call(this, id);
84
+ }
85
+ if (id in importmap.imports) {
56
86
  return { id: importmap.imports[id], external: true };
57
87
  }
58
88
  },