@inkandswitch/patchwork-bootloader 0.2.8 → 0.3.1

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.
@@ -30,11 +30,11 @@ import {
30
30
  import { resolvePath } from "@inkandswitch/patchwork-filesystem";
31
31
 
32
32
  // Small adapters — bundled directly into the worker
33
- import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
33
+ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
34
34
  import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
35
- import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
35
+ import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
36
36
  import {
37
- initializeAutomergeRepoKeyhiveRust,
37
+ initializeAutomergeRepoKeyhiveRustWithRepo,
38
38
  initKeyhiveWasm,
39
39
  type AutomergeRepoKeyhiveRust,
40
40
  } from "@automerge/automerge-repo-keyhive";
@@ -54,6 +54,103 @@ declare const __KEYHIVE_SYNC_SERVER__: boolean;
54
54
 
55
55
  let debugging = false;
56
56
 
57
+ // Per-boot identity so a tab can detect a worker *restart*: a fresh instance
58
+ // means a new repo peerId + cold in-memory state, so the tab's docs must be
59
+ // re-subscribed. Sent in `hello` (on connect) and every `pong`.
60
+ const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
61
+ const WORKER_BOOT_TIME = Date.now();
62
+
63
+ // ── Forward console output + uncaught errors to the main thread ─────────
64
+ // The SharedWorker has its own console that's a pain to find (chrome://inspect
65
+ // → shared workers). Patch console.* and the global error handlers to also
66
+ // post back over every connected tab's control port, tagged [automerge-worker].
67
+
68
+ const controlPorts = new Set<MessagePort>();
69
+ // Logs emitted before any tab has connected (e.g. during wasm boot) would
70
+ // otherwise be lost — buffer a bounded number and flush on first connect.
71
+ const preConnectBuffer: Array<{ level: string; args: string[] }> = [];
72
+ const MAX_BUFFER = 200;
73
+
74
+ function serializeArg(arg: any): string {
75
+ if (typeof arg === "string") return arg;
76
+ if (arg instanceof Error) return arg.stack || `${arg.name}: ${arg.message}`;
77
+ try {
78
+ return JSON.stringify(arg);
79
+ } catch {
80
+ return String(arg);
81
+ }
82
+ }
83
+
84
+ function forwardToMainThread(level: string, rawArgs: any[]) {
85
+ const args = rawArgs.map(serializeArg);
86
+ if (!controlPorts.size) {
87
+ if (preConnectBuffer.length < MAX_BUFFER) {
88
+ preConnectBuffer.push({ level, args });
89
+ }
90
+ return;
91
+ }
92
+ for (const port of controlPorts) {
93
+ try {
94
+ port.postMessage({ type: "console", level, args });
95
+ } catch {
96
+ // Port may be closing — ignore.
97
+ }
98
+ }
99
+ }
100
+
101
+ for (const level of ["log", "info", "warn", "error", "debug"] as const) {
102
+ const original = console[level].bind(console);
103
+ console[level] = (...args: any[]) => {
104
+ original(...args);
105
+ forwardToMainThread(level, args);
106
+ };
107
+ }
108
+
109
+ self.addEventListener("error", (event) => {
110
+ const e = event as ErrorEvent;
111
+ forwardToMainThread("error", [
112
+ `uncaught error: ${e.message}`,
113
+ e.error instanceof Error ? e.error.stack : undefined,
114
+ ]);
115
+ });
116
+
117
+ self.addEventListener("unhandledrejection", (event) => {
118
+ const reason = (event as PromiseRejectionEvent).reason;
119
+ forwardToMainThread("error", [
120
+ "unhandled rejection:",
121
+ reason instanceof Error ? reason.stack || reason.message : reason,
122
+ ]);
123
+ });
124
+
125
+ // Boot marker, buffered until the first tab connects. A new instance id means
126
+ // the worker restarted (fresh peerId + cold state).
127
+ console.warn(
128
+ `[lifecycle] ${new Date(WORKER_BOOT_TIME).toISOString()} automerge ` +
129
+ `SharedWorker started (instance ${WORKER_INSTANCE_ID})`
130
+ );
131
+
132
+ // ── Suspension watchdog ─────────────────────────────────────────────────
133
+ // A SharedWorker gets no lifecycle events, so infer freeze/suspend from timer
134
+ // drift. A large gap means keepalive pongs stalled and the server may have
135
+ // reaped us.
136
+ const WATCHDOG_TICK_MS = 5_000;
137
+ const WATCHDOG_GAP_FACTOR = 2;
138
+ let watchdogLast = Date.now();
139
+ setInterval(() => {
140
+ const now = Date.now();
141
+ const gap = now - watchdogLast;
142
+ watchdogLast = now;
143
+ if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
144
+ console.warn(
145
+ `[lifecycle] worker resumed after ~${Math.round(gap / 1000)}s gap ` +
146
+ `(timer expected every ${WATCHDOG_TICK_MS / 1000}s) — likely ` +
147
+ `suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
148
+ `during this window, so the sync server may have reaped us. at ` +
149
+ `${new Date(now).toISOString()}`
150
+ );
151
+ }
152
+ }, WATCHDOG_TICK_MS);
153
+
57
154
  // Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
58
155
  // to target keyhive.sync.automerge.org.
59
156
  const useKeyhiveSyncServer =
@@ -68,12 +165,6 @@ if (useKeyhiveSyncServer) {
68
165
  };
69
166
  }
70
167
 
71
- // keyhive.sync.automerge.org's keyhive identity (issuer d7f41e6f…).
72
- const KEYHIVE_SYNC_SERVER_PEER_ID =
73
- "1/Qebw9O69oH8T/ejYMhFup0tNBh69I3ytGqsmIl358=";
74
- const KEYHIVE_SYNC_SERVER_CONTACT_CARD_JSON =
75
- '{"Rotate":{"payload":{"old":[73,163,230,244,111,233,153,119,133,211,134,237,111,36,52,131,22,50,54,144,150,45,227,235,128,36,33,217,190,198,55,75],"new":[109,115,204,144,178,114,182,238,113,124,4,139,249,76,220,44,128,104,194,68,187,184,82,241,94,145,104,198,159,122,186,43]},"issuer":[215,244,30,111,15,78,235,218,7,241,63,222,141,131,33,22,234,116,180,208,97,235,210,55,202,209,170,178,98,37,223,159],"signature":[178,64,85,76,51,199,196,151,129,14,191,53,127,191,34,223,97,238,95,109,118,179,152,17,205,188,204,177,116,166,147,231,192,201,48,137,19,214,180,45,108,104,34,8,14,63,115,139,215,142,4,179,233,89,150,218,174,168,107,23,8,109,228,6]}}';
76
-
77
168
  const SUBDUCTION_ENDPOINTS = [
78
169
  useKeyhiveSyncServer
79
170
  ? "wss://keyhive.sync.automerge.org"
@@ -84,7 +175,7 @@ const RESOLVE_TIMEOUT_MS = 30_000;
84
175
  const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
85
176
 
86
177
  let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
87
- let classicSyncAdapter: WebSocketClientAdapter | null = null;
178
+ let classicSyncAdapter: WebSocketWorkerClientAdapter | null = null;
88
179
  let classicSyncConnectPromise: Promise<void> | null = null;
89
180
 
90
181
  async function connectClassicSyncNetwork(server: string): Promise<void> {
@@ -103,7 +194,7 @@ async function connectClassicSyncNetwork(server: string): Promise<void> {
103
194
  classicSyncConnectPromise = (async () => {
104
195
  const { repo } = await getRepoHive();
105
196
  if (!classicSyncAdapter) {
106
- classicSyncAdapter = new WebSocketClientAdapter(url);
197
+ classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
107
198
  repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
108
199
  }
109
200
  await classicSyncAdapter.whenReady();
@@ -159,7 +250,7 @@ function getRepoHive() {
159
250
  const signer = await WebCryptoSigner.setup();
160
251
 
161
252
  const repo = new Repo({
162
- storage: new IndexedDBStorageAdapter(),
253
+ storage: new IndexedDBWorkerStorageAdapter(),
163
254
  signer,
164
255
  peerId: ("automerge-worker-" +
165
256
  Math.random()
@@ -183,48 +274,26 @@ function getRepoHive() {
183
274
  }
184
275
 
185
276
  initKeyhiveWasm();
186
- const keyhiveStorage = new IndexedDBStorageAdapter(`${siteName}-keyhive`);
187
-
188
- // Keyhive bootstrap needs to run before Repo creation but
189
- // the adapter needs the subduction instance from the Repo.
190
- // A deferred promise breaks the cycle.
191
- let resolveRepoSubduction!: (s: any) => void;
192
- const repoSubductionPromise = new Promise((resolve) => {
193
- resolveRepoSubduction = resolve;
194
- });
195
277
 
196
- // We use the Rust variant of Keyhive initialization to talk
197
- // to the Rust keyhive-enabled subduction sync server.
198
- const hive = await initializeAutomergeRepoKeyhiveRust({
199
- storage: keyhiveStorage,
278
+ // ARK variant for talking to the keyhive-enabled subduction sync server.
279
+ const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
280
+ createRepo: (config) => new Repo(config),
281
+ storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
200
282
  peerIdSuffix:
201
283
  `${siteName}-worker` + Math.random().toString(36).slice(2),
202
- subduction: repoSubductionPromise as any,
203
284
  automaticArchiveIngestion: true,
204
285
  cachingMode: "periodic",
205
- ...(useKeyhiveSyncServer
206
- ? {
207
- serverPeerId: KEYHIVE_SYNC_SERVER_PEER_ID as any,
208
- serverContactCardJson: KEYHIVE_SYNC_SERVER_CONTACT_CARD_JSON,
209
- }
210
- : {}),
211
- });
212
-
213
- const signer = await hive.constructSubductionSigner();
214
-
215
- const repo = new Repo({
216
- storage: new IndexedDBStorageAdapter(),
217
- signer,
218
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
219
- peerId: hive.peerId,
220
- enableRemoteHeadsGossiping: true,
221
- idFactory: hive.idFactory,
286
+ // ARK selects the relay via `syncServer` ("keyhive" | "subduction"),
287
+ // which pairs the contact card with the matching peer id. Omitting it
288
+ // defaults to "subduction".
289
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
290
+ repo: {
291
+ storage: new IndexedDBWorkerStorageAdapter(),
292
+ subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
293
+ enableRemoteHeadsGossiping: true,
294
+ },
222
295
  });
223
296
 
224
- repo.subduction.then(resolveRepoSubduction);
225
-
226
- hive.linkRepo(repo);
227
-
228
297
  (self as any).repo = repo;
229
298
  (self as any).hive = hive;
230
299
  log("repo constructed, waiting for network subsystem");
@@ -336,6 +405,7 @@ async function connectPort(port: MessagePort, connection: Connection) {
336
405
  });
337
406
 
338
407
  (keyhiveNetworkAdapter as any).on("ingest-remote", () => {
408
+ hive.notifySameAgentKeyhiveChange();
339
409
  (hive.networkAdapter as any).syncKeyhive?.();
340
410
  repo.shareConfigChanged();
341
411
  });
@@ -389,6 +459,13 @@ function handleControlMessage(
389
459
  });
390
460
  replyPort?.close();
391
461
  });
462
+ } else if (data?.type === "ping") {
463
+ // Heartbeat: reply so the tab can detect our death or restart.
464
+ controlPort.postMessage({
465
+ type: "pong",
466
+ id: data.id,
467
+ instanceId: WORKER_INSTANCE_ID,
468
+ });
392
469
  }
393
470
  }
394
471
 
@@ -403,10 +480,32 @@ self.addEventListener("connect", (event) => {
403
480
  // Fires when the owning page is destroyed. Browsers without the close
404
481
  // event fall back to the adapters' lazy useWeakRef cleanup.
405
482
  controlPort.addEventListener("close", () => {
483
+ controlPorts.delete(controlPort);
406
484
  void dropConnection(connection);
407
485
  });
408
486
 
409
487
  controlPort.start();
488
+
489
+ // Greet the tab with our per-boot instance id so it can detect a restart
490
+ // (a different id than last seen) even if no port "close" fired.
491
+ controlPort.postMessage({
492
+ type: "hello",
493
+ instanceId: WORKER_INSTANCE_ID,
494
+ bootTime: WORKER_BOOT_TIME,
495
+ });
496
+
497
+ // Start forwarding console output to this tab, and flush anything buffered
498
+ // while no tab was connected (e.g. boot-time logs) to the first arrival.
499
+ controlPorts.add(controlPort);
500
+ if (preConnectBuffer.length) {
501
+ for (const { level, args } of preConnectBuffer.splice(0)) {
502
+ try {
503
+ controlPort.postMessage({ type: "console", level, args });
504
+ } catch {
505
+ // Port may already be gone — ignore.
506
+ }
507
+ }
508
+ }
410
509
  });
411
510
 
412
511
  // ── Automerge URL resolution ───────────────────────────────────────────
@@ -582,7 +681,7 @@ async function handleHandoffRequest(message: HandoffRequestMessage) {
582
681
  id,
583
682
  type: "response",
584
683
  response: {
585
- status: 500,
684
+ status: 557,
586
685
  body,
587
686
  headers: { "content-type": "text/plain" },
588
687
  },
@@ -628,7 +727,7 @@ async function handleHandoffRequest(message: HandoffRequestMessage) {
628
727
  id,
629
728
  type: "response",
630
729
  response: {
631
- status: 500,
730
+ status: 558,
632
731
  body: String(error),
633
732
  headers: { "content-type": "text/plain" },
634
733
  },
package/src/externals.ts CHANGED
@@ -7,10 +7,9 @@ const externals = [
7
7
  "@automerge/automerge-repo",
8
8
  "@automerge/automerge-repo/slim",
9
9
  "@automerge/automerge-repo-network-messagechannel",
10
+ "@automerge/automerge-repo-network-websocket",
10
11
  "@automerge/automerge-repo-storage-indexeddb",
11
12
  "@automerge/automerge-repo-keyhive",
12
- "@automerge/automerge-repo-network-messagechannel",
13
- "@automerge/automerge-repo-storage-indexeddb",
14
13
  "@automerge/automerge-subduction",
15
14
  "@automerge/automerge-subduction/slim",
16
15
  "@keyhive/keyhive",
@@ -19,6 +18,7 @@ const externals = [
19
18
  "@inkandswitch/patchwork-elements",
20
19
  "@inkandswitch/patchwork-filesystem",
21
20
  "@inkandswitch/patchwork-plugins",
21
+ "@inkandswitch/patchwork-providers",
22
22
 
23
23
  // sad
24
24
  "@codemirror/state",
@@ -0,0 +1,68 @@
1
+ // Dedicated module worker for plugin-descriptor discovery.
2
+ //
3
+ // A module-settings doc lists Automerge folder-doc packages. To register the
4
+ // plugins a package provides we only need their *descriptions* (id, type,
5
+ // name, icon…), not their implementations. This worker imports a package's
6
+ // entry point off the main thread purely to read its exported `plugins` array,
7
+ // strips the non-cloneable `load()` / `import` machinery, and posts the plain
8
+ // descriptors back. The main thread re-imports the package (at the same heads)
9
+ // only when a plugin is actually loaded — see `importPluginFromFolderDocUrl`.
10
+ //
11
+ // Created with type:"module"; its dynamic `import()` of `/<automergeUrl>/…`
12
+ // entry points is served by the service worker that controls this worker.
13
+
14
+ import { importModuleFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
15
+ import type { AutomergeUrl } from "@automerge/automerge-repo/slim";
16
+
17
+ type DiscoverRequest = {
18
+ type: "discover";
19
+ id: number;
20
+ url: AutomergeUrl;
21
+ };
22
+
23
+ // Keep only the structured-cloneable description fields. `load` is a closure
24
+ // and `module` is the (possibly already-loaded) implementation — neither can
25
+ // cross the worker boundary. `import` is droppable too: the main thread
26
+ // rebuilds loading by re-importing the package and calling the live plugin.
27
+ function toDescriptor(plugin: any): Record<string, unknown> {
28
+ if (!plugin || typeof plugin !== "object") return {};
29
+ const { load, import: _import, module, ...description } = plugin;
30
+ return description;
31
+ }
32
+
33
+ function isDiscoverRequest(data: unknown): data is DiscoverRequest {
34
+ return (
35
+ typeof data === "object" &&
36
+ data !== null &&
37
+ (data as any).type === "discover" &&
38
+ typeof (data as any).id === "number" &&
39
+ typeof (data as any).url === "string"
40
+ );
41
+ }
42
+
43
+ self.addEventListener("message", (event: MessageEvent) => {
44
+ const data = event.data;
45
+ if (!isDiscoverRequest(data)) return;
46
+ const { id, url } = data;
47
+
48
+ importModuleFromFolderDocUrl(url)
49
+ .then((mod) => {
50
+ const plugins: any[] = Array.isArray(mod?.plugins) ? mod.plugins : [];
51
+ const descriptors = plugins.map(toDescriptor);
52
+ (self as unknown as Worker).postMessage({
53
+ type: "descriptors",
54
+ id,
55
+ descriptors,
56
+ });
57
+ })
58
+ .catch((error) => {
59
+ (self as unknown as Worker).postMessage({
60
+ type: "error",
61
+ id,
62
+ error:
63
+ error instanceof Error
64
+ ? (error.stack ?? error.message)
65
+ : String(error),
66
+ });
67
+ });
68
+ });
@@ -0,0 +1,85 @@
1
+ // Main-thread client for the module-loader worker (see module-loader-worker.ts).
2
+ //
3
+ // `importAutomergeModuleViaWorker` is wired into the ModuleWatcher in place of
4
+ // its default (direct, main-thread) package import. It asks the worker to
5
+ // import the package entry point and report which plugins it exports, then
6
+ // returns the same `{ plugins }` shape the watcher already feeds to
7
+ // `registerPlugins` — except each plugin's `load()` re-imports the package
8
+ // (pinned to the same heads) on this thread and runs the real plugin loader.
9
+
10
+ import { importPluginFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
11
+ import type { AutomergeUrl } from "@automerge/automerge-repo/slim";
12
+
13
+ type Descriptor = Record<string, unknown> & { id?: string; type?: string };
14
+
15
+ type WorkerReply =
16
+ | { type: "descriptors"; id: number; descriptors: Descriptor[] }
17
+ | { type: "error"; id: number; error: string };
18
+
19
+ const WORKER_PATH = "/module-loader-worker.js";
20
+
21
+ let worker: Worker | undefined;
22
+ let nextRequestId = 1;
23
+ const pending = new Map<
24
+ number,
25
+ { resolve: (d: Descriptor[]) => void; reject: (e: Error) => void }
26
+ >();
27
+
28
+ function getWorker(): Worker {
29
+ if (worker) return worker;
30
+ worker = new Worker(WORKER_PATH, {
31
+ type: "module",
32
+ name: "patchwork-module-loader",
33
+ });
34
+ worker.addEventListener("message", (event: MessageEvent<WorkerReply>) => {
35
+ const data = event.data;
36
+ if (!data || (data.type !== "descriptors" && data.type !== "error")) return;
37
+ const entry = pending.get(data.id);
38
+ if (!entry) return;
39
+ pending.delete(data.id);
40
+ if (data.type === "descriptors") entry.resolve(data.descriptors);
41
+ else entry.reject(new Error(data.error));
42
+ });
43
+ worker.addEventListener("error", (event) => {
44
+ // An uncaught worker error can't be tied to a single request — fail every
45
+ // outstanding one so callers don't hang.
46
+ const error = new Error(
47
+ `module-loader worker error: ${event.message ?? "unknown"}`
48
+ );
49
+ for (const [, entry] of pending) entry.reject(error);
50
+ pending.clear();
51
+ });
52
+ return worker;
53
+ }
54
+
55
+ /** Ask the worker which plugins the package at `urlAtHeads` exports. */
56
+ function discoverDescriptors(urlAtHeads: AutomergeUrl): Promise<Descriptor[]> {
57
+ const id = nextRequestId++;
58
+ return new Promise<Descriptor[]>((resolve, reject) => {
59
+ pending.set(id, { resolve, reject });
60
+ getWorker().postMessage({ type: "discover", id, url: urlAtHeads });
61
+ });
62
+ }
63
+
64
+ /**
65
+ * ModuleWatcher `importAutomergeModule` hook: discover descriptors in the
66
+ * worker, then return the `{ plugins }` shape with a main-thread `load()` per
67
+ * plugin that imports the package at heads and calls its real loader.
68
+ */
69
+ export async function importAutomergeModuleViaWorker(
70
+ urlAtHeads: string
71
+ ): Promise<{ plugins: Descriptor[] }> {
72
+ const url = urlAtHeads as AutomergeUrl;
73
+ const descriptors = await discoverDescriptors(url);
74
+ const plugins = descriptors.map((descriptor) => {
75
+ const { id, type } = descriptor;
76
+ // A plugin id is only unique within a plugin type, so both are needed to
77
+ // re-select the right plugin when its load() re-imports the package.
78
+ if (typeof id !== "string" || typeof type !== "string") return descriptor;
79
+ return {
80
+ ...descriptor,
81
+ load: () => importPluginFromFolderDocUrl(url, type, id),
82
+ };
83
+ });
84
+ return { plugins };
85
+ }
@@ -33,7 +33,49 @@ function log(...args: any[]) {
33
33
  );
34
34
  }
35
35
 
36
+ // ── Lifecycle diagnostics ──────────────────────────────────────────────
37
+ // [lifecycle] markers for SW (re)boots, install/activate, crashes, and stranded
38
+ // handoffs. The SW can't read localStorage, so it always emits and forwards to
39
+ // the tab, which gates rendering on the live toggle. The SW holds no sync
40
+ // socket — observability only.
41
+
42
+ async function postToClients(message: unknown) {
43
+ const clients = await self.clients.matchAll({
44
+ type: "window",
45
+ includeUncontrolled: true,
46
+ });
47
+ for (const client of clients) client.postMessage(message);
48
+ }
49
+
50
+ function lifecycle(level: "info" | "warn", text: string) {
51
+ const msg = `[lifecycle] ${new Date().toISOString()} ${text}`;
52
+ console[level](msg);
53
+ void postToClients({ type: "sw-lifecycle", level, msg });
54
+ }
55
+
56
+ lifecycle("info", `booted (scope ${self.registration?.scope ?? "?"})`);
57
+
58
+ self.addEventListener("error", (event) => {
59
+ const e = event as ErrorEvent;
60
+ lifecycle(
61
+ "warn",
62
+ `uncaught error: ${e.message}` +
63
+ (e.filename ? ` @ ${e.filename}:${e.lineno}:${e.colno}` : "")
64
+ );
65
+ });
66
+
67
+ self.addEventListener("unhandledrejection", (event) => {
68
+ const reason = (event as PromiseRejectionEvent).reason;
69
+ lifecycle(
70
+ "warn",
71
+ `unhandled rejection: ${
72
+ reason instanceof Error ? reason.stack || reason.message : String(reason)
73
+ }`
74
+ );
75
+ });
76
+
36
77
  self.addEventListener("install", (event) => {
78
+ lifecycle("info", "install (skipWaiting)");
37
79
  // waitUntil keeps the worker alive until skipWaiting resolves, so a freshly
38
80
  // installed SW reliably jumps the "waiting" queue instead of stalling until
39
81
  // every old tab closes.
@@ -52,13 +94,30 @@ async function clearOldCaches() {
52
94
  }
53
95
 
54
96
  self.addEventListener("activate", (event) => {
55
- // Without waitUntil the activate event settles immediately and clients.claim()
56
- // runs detached — the new worker can be killed before it takes control, so
57
- // existing tabs keep talking to the old SW. Extend the event instead.
97
+ lifecycle("info", "activate (claiming clients)");
58
98
  (event as ExtendableEvent).waitUntil(
59
99
  (async () => {
60
100
  await clearOldCaches();
61
101
  await self.clients.claim();
102
+ // Pre-cache pages of already-open clients so they survive going offline
103
+ // before the next navigation.
104
+ const allClients = await self.clients.matchAll({ type: "window" });
105
+ const cache = await caches.open(cachename);
106
+ await Promise.all(
107
+ allClients.map(async (client) => {
108
+ try {
109
+ const existing = await cache.match(client.url);
110
+ if (!existing) {
111
+ const response = await fetch(client.url);
112
+ if (cacheableStatuses.includes(response.status)) {
113
+ await cache.put(client.url, response);
114
+ }
115
+ }
116
+ } catch {
117
+ // Network may be unavailable during activation
118
+ }
119
+ })
120
+ );
62
121
  })()
63
122
  );
64
123
  });
@@ -102,7 +161,15 @@ handoffChannel.addEventListener("message", (event) => {
102
161
  } else if (data?.type === "online") {
103
162
  // The automerge worker (re)started — re-broadcast anything still in
104
163
  // flight so requests that raced its boot aren't stranded.
105
- for (const { message } of pendingHandoffs.values()) {
164
+ const stranded = [...pendingHandoffs.values()];
165
+ if (stranded.length > 0) {
166
+ lifecycle(
167
+ "info",
168
+ `automerge worker (re)started; re-broadcasting ${stranded.length} ` +
169
+ `in-flight asset handoff(s)`
170
+ );
171
+ }
172
+ for (const { message } of stranded) {
106
173
  log(`re-broadcasting handoff ${message.id} to the fresh worker`);
107
174
  handoffChannel.postMessage(message);
108
175
  }
@@ -132,6 +199,11 @@ function handoff(
132
199
  log(`broadcasting handoff request for cache ${cachename}`, message);
133
200
  handoffChannel.postMessage(message);
134
201
  const timeout = setTimeout(() => {
202
+ lifecycle(
203
+ "warn",
204
+ `asset handoff ${id} stranded: no reply from the automerge worker after ` +
205
+ `${HANDOFF_TIMEOUT_MS}ms (${handoffURL.href})`
206
+ );
135
207
  resolvers.reject(
136
208
  new Error(
137
209
  `no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms`
@@ -208,7 +280,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
208
280
  if (!cached) {
209
281
  return new Response(
210
282
  `the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`,
211
- { status: 500 }
283
+ { status: 555 }
212
284
  );
213
285
  }
214
286
  log(`serving ${handoffURL} from cache ${cachename} after handoff`);
@@ -220,7 +292,9 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
220
292
  cacheableStatuses.includes(response.status) &&
221
293
  response.url.match(/^https?\:/)
222
294
  ) {
223
- await cache.put(request, response.clone());
295
+ await cache.put(request, response.clone()).catch((error) => {
296
+ log(`error caching ${request.url} in ${cachename}`, error);
297
+ });
224
298
  } else {
225
299
  log(
226
300
  `skipping uncacheable response code from cache: ${response.status} for ${response.url}`
@@ -243,7 +317,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
243
317
  if (match) return match;
244
318
 
245
319
  return new Response(message, {
246
- status: 500,
320
+ status: 556,
247
321
  headers: { "content-type": "text/plain" },
248
322
  });
249
323
  }