@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @inkandswitch/patchwork-bootloader
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 099e931: Discover a package's plugin descriptors in a dedicated module worker off the
8
+ main thread, then re-import the package (pinned to the same heads) on the main
9
+ thread to run each plugin's real loader. Adds
10
+ `importPluginFromFolderDocUrl(folderDocUrl, pluginType, pluginId)`, which selects
11
+ the plugin by both its `type` and `id` — a plugin `id` is only unique within a
12
+ plugin type, so a package may export e.g. a `patchwork:datatype` and a
13
+ `patchwork:tool` that share the same id.
14
+ - Updated dependencies [099e931]
15
+ - @inkandswitch/patchwork-filesystem@0.1.1
16
+
3
17
  ## 0.2.8
4
18
 
5
19
  ### Patch Changes
@@ -20,12 +20,99 @@ import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
20
20
  import { Repo, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
21
21
  import { resolvePath } from "@inkandswitch/patchwork-filesystem";
22
22
  // Small adapters — bundled directly into the worker
23
- import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
23
+ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
24
24
  import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
25
- import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
26
- import { initializeAutomergeRepoKeyhiveRust, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
25
+ import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
26
+ import { initializeAutomergeRepoKeyhiveRustWithRepo, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
27
27
  import { HANDOFF_CHANNEL, } from "./types.js";
28
28
  let debugging = false;
29
+ // Per-boot identity so a tab can detect a worker *restart*: a fresh instance
30
+ // means a new repo peerId + cold in-memory state, so the tab's docs must be
31
+ // re-subscribed. Sent in `hello` (on connect) and every `pong`.
32
+ const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
33
+ const WORKER_BOOT_TIME = Date.now();
34
+ // ── Forward console output + uncaught errors to the main thread ─────────
35
+ // The SharedWorker has its own console that's a pain to find (chrome://inspect
36
+ // → shared workers). Patch console.* and the global error handlers to also
37
+ // post back over every connected tab's control port, tagged [automerge-worker].
38
+ const controlPorts = new Set();
39
+ // Logs emitted before any tab has connected (e.g. during wasm boot) would
40
+ // otherwise be lost — buffer a bounded number and flush on first connect.
41
+ const preConnectBuffer = [];
42
+ const MAX_BUFFER = 200;
43
+ function serializeArg(arg) {
44
+ if (typeof arg === "string")
45
+ return arg;
46
+ if (arg instanceof Error)
47
+ return arg.stack || `${arg.name}: ${arg.message}`;
48
+ try {
49
+ return JSON.stringify(arg);
50
+ }
51
+ catch {
52
+ return String(arg);
53
+ }
54
+ }
55
+ function forwardToMainThread(level, rawArgs) {
56
+ const args = rawArgs.map(serializeArg);
57
+ if (!controlPorts.size) {
58
+ if (preConnectBuffer.length < MAX_BUFFER) {
59
+ preConnectBuffer.push({ level, args });
60
+ }
61
+ return;
62
+ }
63
+ for (const port of controlPorts) {
64
+ try {
65
+ port.postMessage({ type: "console", level, args });
66
+ }
67
+ catch {
68
+ // Port may be closing — ignore.
69
+ }
70
+ }
71
+ }
72
+ for (const level of ["log", "info", "warn", "error", "debug"]) {
73
+ const original = console[level].bind(console);
74
+ console[level] = (...args) => {
75
+ original(...args);
76
+ forwardToMainThread(level, args);
77
+ };
78
+ }
79
+ self.addEventListener("error", (event) => {
80
+ const e = event;
81
+ forwardToMainThread("error", [
82
+ `uncaught error: ${e.message}`,
83
+ e.error instanceof Error ? e.error.stack : undefined,
84
+ ]);
85
+ });
86
+ self.addEventListener("unhandledrejection", (event) => {
87
+ const reason = event.reason;
88
+ forwardToMainThread("error", [
89
+ "unhandled rejection:",
90
+ reason instanceof Error ? reason.stack || reason.message : reason,
91
+ ]);
92
+ });
93
+ // Boot marker, buffered until the first tab connects. A new instance id means
94
+ // the worker restarted (fresh peerId + cold state).
95
+ console.warn(`[lifecycle] ${new Date(WORKER_BOOT_TIME).toISOString()} automerge ` +
96
+ `SharedWorker started (instance ${WORKER_INSTANCE_ID})`);
97
+ // ── Suspension watchdog ─────────────────────────────────────────────────
98
+ // A SharedWorker gets no lifecycle events, so infer freeze/suspend from timer
99
+ // drift. A large gap means keepalive pongs stalled and the server may have
100
+ // reaped us.
101
+ const WATCHDOG_TICK_MS = 5_000;
102
+ const WATCHDOG_GAP_FACTOR = 2;
103
+ let watchdogLast = Date.now();
104
+ setInterval(() => {
105
+ const now = Date.now();
106
+ const gap = now - watchdogLast;
107
+ watchdogLast = now;
108
+ if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
109
+ console.warn(`[lifecycle] worker resumed after ~${Math.round(gap / 1000)}s gap ` +
110
+ `(timer expected every ${WATCHDOG_TICK_MS / 1000}s) — likely ` +
111
+ `suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
112
+ `during this window, so the sync server may have reaped us. at ` +
113
+ `${new Date(now).toISOString()}`);
114
+ }
115
+ }, WATCHDOG_TICK_MS);
29
116
  // Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
30
117
  // to target keyhive.sync.automerge.org.
31
118
  const useKeyhiveSyncServer = typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
@@ -37,9 +124,6 @@ if (useKeyhiveSyncServer) {
37
124
  KEYHIVE_SERVER_IDENTITY: "keyhive-sync",
38
125
  };
39
126
  }
40
- // keyhive.sync.automerge.org's keyhive identity (issuer d7f41e6f…).
41
- const KEYHIVE_SYNC_SERVER_PEER_ID = "1/Qebw9O69oH8T/ejYMhFup0tNBh69I3ytGqsmIl358=";
42
- const KEYHIVE_SYNC_SERVER_CONTACT_CARD_JSON = '{"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]}}';
43
127
  const SUBDUCTION_ENDPOINTS = [
44
128
  useKeyhiveSyncServer
45
129
  ? "wss://keyhive.sync.automerge.org"
@@ -64,7 +148,7 @@ async function connectClassicSyncNetwork(server) {
64
148
  classicSyncConnectPromise = (async () => {
65
149
  const { repo } = await getRepoHive();
66
150
  if (!classicSyncAdapter) {
67
- classicSyncAdapter = new WebSocketClientAdapter(url);
151
+ classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
68
152
  repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
69
153
  }
70
154
  await classicSyncAdapter.whenReady();
@@ -102,7 +186,7 @@ function getRepoHive() {
102
186
  if (!useKeyhive) {
103
187
  const signer = await WebCryptoSigner.setup();
104
188
  const repo = new Repo({
105
- storage: new IndexedDBStorageAdapter(),
189
+ storage: new IndexedDBWorkerStorageAdapter(),
106
190
  signer,
107
191
  peerId: ("automerge-worker-" +
108
192
  Math.random()
@@ -122,40 +206,23 @@ function getRepoHive() {
122
206
  return { repo };
123
207
  }
124
208
  initKeyhiveWasm();
125
- const keyhiveStorage = new IndexedDBStorageAdapter(`${siteName}-keyhive`);
126
- // Keyhive bootstrap needs to run before Repo creation but
127
- // the adapter needs the subduction instance from the Repo.
128
- // A deferred promise breaks the cycle.
129
- let resolveRepoSubduction;
130
- const repoSubductionPromise = new Promise((resolve) => {
131
- resolveRepoSubduction = resolve;
132
- });
133
- // We use the Rust variant of Keyhive initialization to talk
134
- // to the Rust keyhive-enabled subduction sync server.
135
- const hive = await initializeAutomergeRepoKeyhiveRust({
136
- storage: keyhiveStorage,
209
+ // ARK variant for talking to the keyhive-enabled subduction sync server.
210
+ const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
211
+ createRepo: (config) => new Repo(config),
212
+ storage: new IndexedDBWorkerStorageAdapter(`${siteName}-keyhive`),
137
213
  peerIdSuffix: `${siteName}-worker` + Math.random().toString(36).slice(2),
138
- subduction: repoSubductionPromise,
139
214
  automaticArchiveIngestion: true,
140
215
  cachingMode: "periodic",
141
- ...(useKeyhiveSyncServer
142
- ? {
143
- serverPeerId: KEYHIVE_SYNC_SERVER_PEER_ID,
144
- serverContactCardJson: KEYHIVE_SYNC_SERVER_CONTACT_CARD_JSON,
145
- }
146
- : {}),
147
- });
148
- const signer = await hive.constructSubductionSigner();
149
- const repo = new Repo({
150
- storage: new IndexedDBStorageAdapter(),
151
- signer,
152
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
153
- peerId: hive.peerId,
154
- enableRemoteHeadsGossiping: true,
155
- idFactory: hive.idFactory,
216
+ // ARK selects the relay via `syncServer` ("keyhive" | "subduction"),
217
+ // which pairs the contact card with the matching peer id. Omitting it
218
+ // defaults to "subduction".
219
+ ...(useKeyhiveSyncServer ? { syncServer: "keyhive" } : {}),
220
+ repo: {
221
+ storage: new IndexedDBWorkerStorageAdapter(),
222
+ subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
223
+ enableRemoteHeadsGossiping: true,
224
+ },
156
225
  });
157
- repo.subduction.then(resolveRepoSubduction);
158
- hive.linkRepo(repo);
159
226
  self.repo = repo;
160
227
  self.hive = hive;
161
228
  log("repo constructed, waiting for network subsystem");
@@ -239,6 +306,7 @@ async function connectPort(port, connection) {
239
306
  }
240
307
  });
241
308
  keyhiveNetworkAdapter.on("ingest-remote", () => {
309
+ hive.notifySameAgentKeyhiveChange();
242
310
  hive.networkAdapter.syncKeyhive?.();
243
311
  repo.shareConfigChanged();
244
312
  });
@@ -285,6 +353,14 @@ function handleControlMessage(event, controlPort, connection) {
285
353
  replyPort?.close();
286
354
  });
287
355
  }
356
+ else if (data?.type === "ping") {
357
+ // Heartbeat: reply so the tab can detect our death or restart.
358
+ controlPort.postMessage({
359
+ type: "pong",
360
+ id: data.id,
361
+ instanceId: WORKER_INSTANCE_ID,
362
+ });
363
+ }
288
364
  }
289
365
  self.addEventListener("connect", (event) => {
290
366
  const controlPort = event.ports[0];
@@ -295,9 +371,30 @@ self.addEventListener("connect", (event) => {
295
371
  // Fires when the owning page is destroyed. Browsers without the close
296
372
  // event fall back to the adapters' lazy useWeakRef cleanup.
297
373
  controlPort.addEventListener("close", () => {
374
+ controlPorts.delete(controlPort);
298
375
  void dropConnection(connection);
299
376
  });
300
377
  controlPort.start();
378
+ // Greet the tab with our per-boot instance id so it can detect a restart
379
+ // (a different id than last seen) even if no port "close" fired.
380
+ controlPort.postMessage({
381
+ type: "hello",
382
+ instanceId: WORKER_INSTANCE_ID,
383
+ bootTime: WORKER_BOOT_TIME,
384
+ });
385
+ // Start forwarding console output to this tab, and flush anything buffered
386
+ // while no tab was connected (e.g. boot-time logs) to the first arrival.
387
+ controlPorts.add(controlPort);
388
+ if (preConnectBuffer.length) {
389
+ for (const { level, args } of preConnectBuffer.splice(0)) {
390
+ try {
391
+ controlPort.postMessage({ type: "console", level, args });
392
+ }
393
+ catch {
394
+ // Port may already be gone — ignore.
395
+ }
396
+ }
397
+ }
301
398
  });
302
399
  // ── Automerge URL resolution ───────────────────────────────────────────
303
400
  /**
@@ -441,7 +538,7 @@ async function handleHandoffRequest(message) {
441
538
  id,
442
539
  type: "response",
443
540
  response: {
444
- status: 500,
541
+ status: 557,
445
542
  body,
446
543
  headers: { "content-type": "text/plain" },
447
544
  },
@@ -488,7 +585,7 @@ async function handleHandoffRequest(message) {
488
585
  id,
489
586
  type: "response",
490
587
  response: {
491
- status: 500,
588
+ status: 558,
492
589
  body: String(error),
493
590
  headers: { "content-type": "text/plain" },
494
591
  },
package/dist/externals.js 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
  // sad
23
23
  "@codemirror/state",
24
24
  "@codemirror/view",
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
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
+ import { importModuleFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
14
+ // Keep only the structured-cloneable description fields. `load` is a closure
15
+ // and `module` is the (possibly already-loaded) implementation — neither can
16
+ // cross the worker boundary. `import` is droppable too: the main thread
17
+ // rebuilds loading by re-importing the package and calling the live plugin.
18
+ function toDescriptor(plugin) {
19
+ if (!plugin || typeof plugin !== "object")
20
+ return {};
21
+ const { load, import: _import, module, ...description } = plugin;
22
+ return description;
23
+ }
24
+ function isDiscoverRequest(data) {
25
+ return (typeof data === "object" &&
26
+ data !== null &&
27
+ data.type === "discover" &&
28
+ typeof data.id === "number" &&
29
+ typeof data.url === "string");
30
+ }
31
+ self.addEventListener("message", (event) => {
32
+ const data = event.data;
33
+ if (!isDiscoverRequest(data))
34
+ return;
35
+ const { id, url } = data;
36
+ importModuleFromFolderDocUrl(url)
37
+ .then((mod) => {
38
+ const plugins = Array.isArray(mod?.plugins) ? mod.plugins : [];
39
+ const descriptors = plugins.map(toDescriptor);
40
+ self.postMessage({
41
+ type: "descriptors",
42
+ id,
43
+ descriptors,
44
+ });
45
+ })
46
+ .catch((error) => {
47
+ self.postMessage({
48
+ type: "error",
49
+ id,
50
+ error: error instanceof Error
51
+ ? (error.stack ?? error.message)
52
+ : String(error),
53
+ });
54
+ });
55
+ });
@@ -0,0 +1,13 @@
1
+ type Descriptor = Record<string, unknown> & {
2
+ id?: string;
3
+ type?: string;
4
+ };
5
+ /**
6
+ * ModuleWatcher `importAutomergeModule` hook: discover descriptors in the
7
+ * worker, then return the `{ plugins }` shape with a main-thread `load()` per
8
+ * plugin that imports the package at heads and calls its real loader.
9
+ */
10
+ export declare function importAutomergeModuleViaWorker(urlAtHeads: string): Promise<{
11
+ plugins: Descriptor[];
12
+ }>;
13
+ export {};
@@ -0,0 +1,72 @@
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
+ import { importPluginFromFolderDocUrl } from "@inkandswitch/patchwork-filesystem";
10
+ const WORKER_PATH = "/module-loader-worker.js";
11
+ let worker;
12
+ let nextRequestId = 1;
13
+ const pending = new Map();
14
+ function getWorker() {
15
+ if (worker)
16
+ return worker;
17
+ worker = new Worker(WORKER_PATH, {
18
+ type: "module",
19
+ name: "patchwork-module-loader",
20
+ });
21
+ worker.addEventListener("message", (event) => {
22
+ const data = event.data;
23
+ if (!data || (data.type !== "descriptors" && data.type !== "error"))
24
+ return;
25
+ const entry = pending.get(data.id);
26
+ if (!entry)
27
+ return;
28
+ pending.delete(data.id);
29
+ if (data.type === "descriptors")
30
+ entry.resolve(data.descriptors);
31
+ else
32
+ entry.reject(new Error(data.error));
33
+ });
34
+ worker.addEventListener("error", (event) => {
35
+ // An uncaught worker error can't be tied to a single request — fail every
36
+ // outstanding one so callers don't hang.
37
+ const error = new Error(`module-loader worker error: ${event.message ?? "unknown"}`);
38
+ for (const [, entry] of pending)
39
+ entry.reject(error);
40
+ pending.clear();
41
+ });
42
+ return worker;
43
+ }
44
+ /** Ask the worker which plugins the package at `urlAtHeads` exports. */
45
+ function discoverDescriptors(urlAtHeads) {
46
+ const id = nextRequestId++;
47
+ return new Promise((resolve, reject) => {
48
+ pending.set(id, { resolve, reject });
49
+ getWorker().postMessage({ type: "discover", id, url: urlAtHeads });
50
+ });
51
+ }
52
+ /**
53
+ * ModuleWatcher `importAutomergeModule` hook: discover descriptors in the
54
+ * worker, then return the `{ plugins }` shape with a main-thread `load()` per
55
+ * plugin that imports the package at heads and calls its real loader.
56
+ */
57
+ export async function importAutomergeModuleViaWorker(urlAtHeads) {
58
+ const url = urlAtHeads;
59
+ const descriptors = await discoverDescriptors(url);
60
+ const plugins = descriptors.map((descriptor) => {
61
+ const { id, type } = descriptor;
62
+ // A plugin id is only unique within a plugin type, so both are needed to
63
+ // re-select the right plugin when its load() re-imports the package.
64
+ if (typeof id !== "string" || typeof type !== "string")
65
+ return descriptor;
66
+ return {
67
+ ...descriptor,
68
+ load: () => importPluginFromFolderDocUrl(url, type, id),
69
+ };
70
+ });
71
+ return { plugins };
72
+ }
@@ -17,7 +17,36 @@ function log(...args) {
17
17
  return;
18
18
  console.log.call(console, `%cpatchwork:serviceworker%c\n`, `color: #00ffcc; font-weight: bold`, "color: inherit", ...args);
19
19
  }
20
+ // ── Lifecycle diagnostics ──────────────────────────────────────────────
21
+ // [lifecycle] markers for SW (re)boots, install/activate, crashes, and stranded
22
+ // handoffs. The SW can't read localStorage, so it always emits and forwards to
23
+ // the tab, which gates rendering on the live toggle. The SW holds no sync
24
+ // socket — observability only.
25
+ async function postToClients(message) {
26
+ const clients = await self.clients.matchAll({
27
+ type: "window",
28
+ includeUncontrolled: true,
29
+ });
30
+ for (const client of clients)
31
+ client.postMessage(message);
32
+ }
33
+ function lifecycle(level, text) {
34
+ const msg = `[lifecycle] ${new Date().toISOString()} ${text}`;
35
+ console[level](msg);
36
+ void postToClients({ type: "sw-lifecycle", level, msg });
37
+ }
38
+ lifecycle("info", `booted (scope ${self.registration?.scope ?? "?"})`);
39
+ self.addEventListener("error", (event) => {
40
+ const e = event;
41
+ lifecycle("warn", `uncaught error: ${e.message}` +
42
+ (e.filename ? ` @ ${e.filename}:${e.lineno}:${e.colno}` : ""));
43
+ });
44
+ self.addEventListener("unhandledrejection", (event) => {
45
+ const reason = event.reason;
46
+ lifecycle("warn", `unhandled rejection: ${reason instanceof Error ? reason.stack || reason.message : String(reason)}`);
47
+ });
20
48
  self.addEventListener("install", (event) => {
49
+ lifecycle("info", "install (skipWaiting)");
21
50
  // waitUntil keeps the worker alive until skipWaiting resolves, so a freshly
22
51
  // installed SW reliably jumps the "waiting" queue instead of stalling until
23
52
  // every old tab closes.
@@ -34,12 +63,28 @@ async function clearOldCaches() {
34
63
  await Promise.all(deletePromises);
35
64
  }
36
65
  self.addEventListener("activate", (event) => {
37
- // Without waitUntil the activate event settles immediately and clients.claim()
38
- // runs detached — the new worker can be killed before it takes control, so
39
- // existing tabs keep talking to the old SW. Extend the event instead.
66
+ lifecycle("info", "activate (claiming clients)");
40
67
  event.waitUntil((async () => {
41
68
  await clearOldCaches();
42
69
  await self.clients.claim();
70
+ // Pre-cache pages of already-open clients so they survive going offline
71
+ // before the next navigation.
72
+ const allClients = await self.clients.matchAll({ type: "window" });
73
+ const cache = await caches.open(cachename);
74
+ await Promise.all(allClients.map(async (client) => {
75
+ try {
76
+ const existing = await cache.match(client.url);
77
+ if (!existing) {
78
+ const response = await fetch(client.url);
79
+ if (cacheableStatuses.includes(response.status)) {
80
+ await cache.put(client.url, response);
81
+ }
82
+ }
83
+ }
84
+ catch {
85
+ // Network may be unavailable during activation
86
+ }
87
+ }));
43
88
  })());
44
89
  });
45
90
  self.addEventListener("message", async (event) => {
@@ -72,7 +117,12 @@ handoffChannel.addEventListener("message", (event) => {
72
117
  else if (data?.type === "online") {
73
118
  // The automerge worker (re)started — re-broadcast anything still in
74
119
  // flight so requests that raced its boot aren't stranded.
75
- for (const { message } of pendingHandoffs.values()) {
120
+ const stranded = [...pendingHandoffs.values()];
121
+ if (stranded.length > 0) {
122
+ lifecycle("info", `automerge worker (re)started; re-broadcasting ${stranded.length} ` +
123
+ `in-flight asset handoff(s)`);
124
+ }
125
+ for (const { message } of stranded) {
76
126
  log(`re-broadcasting handoff ${message.id} to the fresh worker`);
77
127
  handoffChannel.postMessage(message);
78
128
  }
@@ -98,6 +148,8 @@ function handoff(request, handoffURL) {
98
148
  log(`broadcasting handoff request for cache ${cachename}`, message);
99
149
  handoffChannel.postMessage(message);
100
150
  const timeout = setTimeout(() => {
151
+ lifecycle("warn", `asset handoff ${id} stranded: no reply from the automerge worker after ` +
152
+ `${HANDOFF_TIMEOUT_MS}ms (${handoffURL.href})`);
101
153
  resolvers.reject(new Error(`no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms`));
102
154
  }, HANDOFF_TIMEOUT_MS);
103
155
  return resolvers.promise.finally(() => {
@@ -153,7 +205,7 @@ self.addEventListener("fetch", (fetchEvent) => {
153
205
  // response in our cache
154
206
  const cached = await cache.match(request);
155
207
  if (!cached) {
156
- return new Response(`the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`, { status: 500 });
208
+ return new Response(`the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`, { status: 555 });
157
209
  }
158
210
  log(`serving ${handoffURL} from cache ${cachename} after handoff`);
159
211
  return withSpecialHeaders(cached);
@@ -163,7 +215,9 @@ self.addEventListener("fetch", (fetchEvent) => {
163
215
  if (response) {
164
216
  if (cacheableStatuses.includes(response.status) &&
165
217
  response.url.match(/^https?\:/)) {
166
- await cache.put(request, response.clone());
218
+ await cache.put(request, response.clone()).catch((error) => {
219
+ log(`error caching ${request.url} in ${cachename}`, error);
220
+ });
167
221
  }
168
222
  else {
169
223
  log(`skipping uncacheable response code from cache: ${response.status} for ${response.url}`);
@@ -183,7 +237,7 @@ self.addEventListener("fetch", (fetchEvent) => {
183
237
  if (match)
184
238
  return match;
185
239
  return new Response(message, {
186
- status: 500,
240
+ status: 556,
187
241
  headers: { "content-type": "text/plain" },
188
242
  });
189
243
  }
package/dist/setup.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { SetupServiceWorkerOptions, SetupServiceWorkerResult } from "./types.js";
2
+ export declare function lifecycleLoggingEnabled(): boolean;
2
3
  export declare function bumpServiceWorkerCache(sw?: ServiceWorker | null): void;
4
+ export declare function getAutomergeWorker(): SharedWorker;
3
5
  export declare function connectClassicSync(server?: string): Promise<void>;
4
6
  export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<SetupServiceWorkerResult>;