@inkandswitch/patchwork-bootloader 0.3.2 → 0.4.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/dist/site.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * site's `main.ts`. Non-UI consumers should import the package default (which
12
12
  * only does SW registration and the automerge-worker handoff).
13
13
  */
14
- import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, parseAutomergeUrl, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
14
+ import { initializeWasm, isValidAutomergeUrl, isValidDocumentId, MessageChannelNetworkAdapter, Repo, stringifyAutomergeUrl, } from "@automerge/vanillajs/slim";
15
15
  import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
16
16
  import * as Automerge from "@automerge/automerge/slim";
17
17
  import * as AutomergeRepo from "@automerge/automerge-repo/slim";
@@ -31,8 +31,11 @@ import * as plugins from "@inkandswitch/patchwork-plugins";
31
31
  import setupServiceWorker, { lifecycleLoggingEnabled, } from "./setup.js";
32
32
  import debug from "debug";
33
33
  const log = debug("patchwork:bootloader:site");
34
- // Legacy big-patchwork hash shape: `slug--<documentId>[?=type]`.
35
- const BIG_PATCHWORK_HASH_REGEX = /(?<title>[A-Za-z0-9-]+)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)(?<type>\?=[^&?]+)?/;
34
+ // Legacy big-patchwork hash shape: `<slug>--<documentId>[?…]`. The slug can
35
+ // contain characters we don't otherwise permit (e.g. `drawing-(branch-1)`), so
36
+ // we anchor on the `--` before the base58 document id and allow any non-query
37
+ // characters ahead of it rather than a strict slug charset.
38
+ const BIG_PATCHWORK_HASH_REGEX = /^(?<title>[^=&?/#]*)--(?<docId>[1-9A-HJ-NP-Za-km-z]+)/;
36
39
  const [automergeWasm, subductionWasm] = await Promise.all([
37
40
  fetch("/automerge.wasm?main").then((r) => r.bytes()),
38
41
  fetch("/subduction.wasm").then((r) => r.bytes()),
@@ -182,7 +185,6 @@ export async function bootPatchworkSite(config) {
182
185
  rootElement,
183
186
  repo,
184
187
  accountDocHandle,
185
- moduleWatcher,
186
188
  titleSuffix: config.titleSuffix,
187
189
  });
188
190
  return { repo, moduleWatcher, accountDocHandle };
@@ -397,12 +399,12 @@ async function uncache(match) {
397
399
  }
398
400
  }
399
401
  }
400
- // Keys whose values are automerge URLs — we keep the `:` (and any `#`/`|`
401
- // heads) literal rather than percent-encoding them so links stay readable.
402
- const RAW_HASH_KEYS = new Set(["doc", "package"]);
403
- // Emit hash params in a stable order so re-serializing the same logical
404
- // params yields a byte-identical string (avoids spurious `hashchange`).
405
- const HASH_KEY_ORDER = ["doc", "package", "tool", "type", "title", "frame"];
402
+ // The `doc=` value is an automerge URL — we keep its `:` (and any `#`/`|`
403
+ // heads) literal rather than percent-encoding it so links stay readable.
404
+ const RAW_HASH_KEYS = new Set(["doc"]);
405
+ // Emit hash params in a stable order so re-serializing the same logical params
406
+ // yields a byte-identical string (avoids spurious `hashchange` round-trips).
407
+ const HASH_KEY_ORDER = ["doc", "tool", "type", "title", "frame"];
406
408
  function serializeHashParams(params) {
407
409
  const emitted = new Set();
408
410
  const parts = [];
@@ -423,14 +425,15 @@ function serializeHashParams(params) {
423
425
  }
424
426
  /**
425
427
  * Coerce a `doc=` hash param to a full automerge URL. Accepts a full URL
426
- * (`automerge:<id>[#heads]`) or a bare document id for backwards
427
- * compatibility with older links.
428
+ * (`automerge:<id>[#heads]`) or a bare document id for backwards compatibility
429
+ * with older links.
428
430
  */
429
431
  function docParamToUrl(docParam) {
430
432
  if (!docParam)
431
433
  return undefined;
432
- if (isValidAutomergeUrl(docParam))
434
+ if (isValidAutomergeUrl(docParam)) {
433
435
  return docParam;
436
+ }
434
437
  const documentId = docParam.replace(/^automerge:/, "");
435
438
  if (isValidDocumentId(documentId)) {
436
439
  return stringifyAutomergeUrl({ documentId: documentId });
@@ -438,12 +441,12 @@ function docParamToUrl(docParam) {
438
441
  return undefined;
439
442
  }
440
443
  function installHashRouting(params) {
441
- const { rootElement, repo, accountDocHandle, moduleWatcher, titleSuffix } = params;
444
+ const { rootElement, repo, accountDocHandle, titleSuffix } = params;
442
445
  rootElement.addEventListener("patchwork:open-document", async (event) => {
443
446
  const params = new URLSearchParams(window.location.hash.slice(1));
444
447
  const { url, toolId, type, title } = event.detail;
445
- // `doc` is now the full automerge URL (heads, if any, live in it). The
446
- // in-use package is recorded separately in `package=` when a tool mounts.
448
+ // `doc` is now the full automerge URL heads, if any, live inside it, so
449
+ // the separate `heads=` param is gone.
447
450
  params.delete("heads");
448
451
  params.set("doc", url);
449
452
  if (toolId)
@@ -478,29 +481,6 @@ function installHashRouting(params) {
478
481
  console.error("Failed to update document title", e);
479
482
  }
480
483
  });
481
- // When a tool mounts we know the package actually rendering the top-level
482
- // document, so record its (heads-pinned) importUrl in `package=`.
483
- const recordInUsePackage = (event) => {
484
- const detail = event.detail;
485
- if (!detail || !("url" in detail) || !detail.importUrl)
486
- return;
487
- const params = new URLSearchParams(window.location.hash.slice(1));
488
- const docUrl = docParamToUrl(params.get("doc"));
489
- if (!docUrl)
490
- return;
491
- // Ignore nested branch / side-by-side views: only the top-level doc's
492
- // package belongs in the hash.
493
- if (parseAutomergeUrl(docUrl).documentId !==
494
- parseAutomergeUrl(detail.url).documentId) {
495
- return;
496
- }
497
- if (params.get("package") === detail.importUrl)
498
- return;
499
- params.set("package", detail.importUrl);
500
- // Replace rather than push: recording the in-use package shouldn't add a
501
- // back-button entry (and replaceState avoids a `hashchange` round-trip).
502
- history.replaceState(null, "", `#${serializeHashParams(params)}`);
503
- };
504
484
  let firstMount = true;
505
485
  const reveal = () => {
506
486
  if (!firstMount)
@@ -510,7 +490,6 @@ function installHashRouting(params) {
510
490
  hideLoadingAnimation();
511
491
  };
512
492
  rootElement.addEventListener("patchwork:mounted", (event) => {
513
- recordInUsePackage(event);
514
493
  handleHashChange();
515
494
  if (event.target !== rootElement)
516
495
  return;
@@ -525,12 +504,14 @@ function installHashRouting(params) {
525
504
  setTimeout(reveal, 12_000);
526
505
  const handleHashChange = async () => {
527
506
  const hash = window.location.hash.slice(1);
528
- const legacy = BIG_PATCHWORK_HASH_REGEX.exec(hash);
529
- if (legacy) {
530
- const documentId = legacy.groups?.docId;
531
- if (isValidDocumentId(documentId)) {
532
- openDocument(rootElement, stringifyAutomergeUrl({ documentId }));
533
- }
507
+ // Legacy big-patchwork link (`<slug>--<docId>?…`): if the hash carries a
508
+ // `--` followed by a valid document id, normalize it to the canonical
509
+ // `#doc=automerge:<docId>` form and let routing re-run on the hashchange.
510
+ const legacyDocId = BIG_PATCHWORK_HASH_REGEX.exec(hash)?.groups?.docId;
511
+ if (legacyDocId && isValidDocumentId(legacyDocId)) {
512
+ window.location.hash = serializeHashParams(new URLSearchParams({
513
+ doc: stringifyAutomergeUrl({ documentId: legacyDocId }),
514
+ }));
534
515
  return;
535
516
  }
536
517
  // Bare automerge URL in hash: /#automerge:<documentId>
@@ -542,16 +523,10 @@ function installHashRouting(params) {
542
523
  }
543
524
  const params = new URLSearchParams(hash);
544
525
  const docUrl = docParamToUrl(params.get("doc"));
545
- const packageUrl = params.get("package");
546
526
  const toolId = params.get("tool");
547
527
  const title = params.get("title");
548
528
  const type = params.get("type");
549
529
  const frame = params.get("frame");
550
- // Load the package that produced this document so its tool is available
551
- // even when it isn't in the user's module settings.
552
- if (packageUrl) {
553
- void moduleWatcher.loadModules([packageUrl]);
554
- }
555
530
  if (frame) {
556
531
  const frameDocUrl = docUrl ?? accountDocHandle.url;
557
532
  if (rootElement.getAttribute("tool-id") !== frame ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inkandswitch/patchwork-bootloader",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "author": "chee",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,29 +44,29 @@
44
44
  }
45
45
  },
46
46
  "dependencies": {
47
- "@automerge/automerge": "3.3.0-fragments.1",
48
- "@automerge/automerge-repo": "2.6.0-subduction.39",
49
- "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.39",
50
- "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.39",
51
- "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.39",
47
+ "@automerge/automerge": "3.3.2",
48
+ "@automerge/automerge-repo": "2.6.0-subduction.46",
49
+ "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.45",
50
+ "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.45",
51
+ "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.45",
52
52
  "@automerge/automerge-subduction": "0.16.0",
53
- "@automerge/vanillajs": "2.6.0-subduction.39",
53
+ "@automerge/vanillajs": "2.6.0-subduction.45",
54
54
  "@keyhive/keyhive": "0.1.0-alpha.5",
55
55
  "@types/debug": "^4.1.13",
56
56
  "debug": "^4.4.3",
57
57
  "resolve.exports": "^2.0.3",
58
58
  "service-worker-types": "npm:@types/serviceworker@^0.0.153",
59
59
  "tinyargs": "^0.1.4",
60
- "@inkandswitch/patchwork-elements": "^2.0.0",
61
- "@inkandswitch/patchwork-filesystem": "^0.1.1",
62
- "@inkandswitch/patchwork-plugins": "^0.0.11",
63
- "@inkandswitch/patchwork-providers": "^0.3.0"
60
+ "@inkandswitch/patchwork-elements": "^3.0.0",
61
+ "@inkandswitch/patchwork-filesystem": "^0.1.3",
62
+ "@inkandswitch/patchwork-providers": "^0.4.0",
63
+ "@inkandswitch/patchwork-plugins": "^0.0.11"
64
64
  },
65
65
  "peerDependencies": {
66
- "@automerge/automerge": "3.3.0-fragments.1",
67
- "@automerge/automerge-repo": "2.6.0-subduction.39",
66
+ "@automerge/automerge": "3.3.2",
67
+ "@automerge/automerge-repo": "2.6.0-subduction.46",
68
68
  "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
69
- "@automerge/vanillajs": "2.6.0-subduction.39"
69
+ "@automerge/vanillajs": "2.6.0-subduction.45"
70
70
  },
71
71
  "scripts": {
72
72
  "build": "tsc",
@@ -18,9 +18,11 @@ import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
18
18
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
19
19
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
20
20
  import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
21
+ import { makePortProvider } from "@automerge/automerge-repo/worker-port";
21
22
 
22
23
  import {
23
24
  Repo,
25
+ WorkerWebSocketEndpoint,
24
26
  isValidAutomergeUrl,
25
27
  parseAutomergeUrl,
26
28
  stringifyAutomergeUrl,
@@ -73,6 +75,35 @@ const WORKER_BOOT_TIME = Date.now();
73
75
 
74
76
  const controlPorts = new Set<MessagePort>();
75
77
 
78
+ // ── Keepalive-drift probe (bench instrumentation) ───────────────────────
79
+ // Measures how late a 1s timer fires on this thread — i.e. how late an
80
+ // in-thread keepalive would be under sync/wasm load. Cheap (one Date.now()
81
+ // per second); samples are batched to every connected tab as drift-samples
82
+ // messages, which setup.ts accumulates on window.__driftSamples for the
83
+ // Playwright bench (e2e/tests/bench-ws.spec.ts).
84
+ const DRIFT_INTERVAL_MS = 1_000;
85
+ const DRIFT_BATCH_SIZE = 5;
86
+ {
87
+ let expected = Date.now() + DRIFT_INTERVAL_MS;
88
+ let batch: number[] = [];
89
+ setInterval(() => {
90
+ const now = Date.now();
91
+ batch.push(Math.max(0, now - expected));
92
+ expected = now + DRIFT_INTERVAL_MS;
93
+ if (batch.length >= DRIFT_BATCH_SIZE) {
94
+ const samples = batch;
95
+ batch = [];
96
+ for (const port of controlPorts) {
97
+ try {
98
+ port.postMessage({ type: "drift-samples", samples });
99
+ } catch {
100
+ // Port torn down mid-iteration — its close handler cleans up.
101
+ }
102
+ }
103
+ }
104
+ }, DRIFT_INTERVAL_MS);
105
+ }
106
+
76
107
  // ── Per-tab sync-state subscriptions ────────────────────────────────────
77
108
  // Each tab's control port subscribes to the documents it cares about; we push
78
109
  // only those docs' heads back down that port (addressed — tab A never sees tab
@@ -211,11 +242,55 @@ if (useKeyhiveSyncServer) {
211
242
  };
212
243
  }
213
244
 
214
- const SUBDUCTION_ENDPOINTS = [
215
- useKeyhiveSyncServer
216
- ? "wss://keyhive.sync.automerge.org"
217
- : "wss://subduction.sync.inkandswitch.com",
218
- ];
245
+ const SUBDUCTION_SYNC_URL = useKeyhiveSyncServer
246
+ ? "wss://keyhive.sync.automerge.org"
247
+ : "wss://subduction.sync.inkandswitch.com";
248
+
249
+ // The subduction WebSocket lives in its own worker so socket I/O (and
250
+ // keepalive pongs) keep flowing even when this SharedWorker's thread is busy
251
+ // syncing. We can't spawn that worker ourselves — Chrome doesn't expose the
252
+ // Worker constructor inside SharedWorkerGlobalScope — so tabs spawn the
253
+ // shipped SharedWorker proxy entry and donate its port to us (donatePort in
254
+ // setup.ts). The provider hands WorkerWebSocketEndpoint whichever port is
255
+ // current, healing across late arrival and proxy-worker restarts.
256
+ const subductionPortProvider = makePortProvider();
257
+
258
+ // A/B bench toggle: the tab appends ?ws-mode=inline to our URL (SharedWorker
259
+ // scope has no localStorage; see getAutomergeWorker in setup.ts). "inline"
260
+ // passes the bare URL string so the socket lives on this thread — the
261
+ // pre-worker behaviour — as the control arm for benchmarking the
262
+ // worker-based endpoint. Default: "worker".
263
+ const WS_MODE =
264
+ new URL(self.location.href).searchParams.get("ws-mode") === "inline"
265
+ ? "inline"
266
+ : "worker";
267
+
268
+ // Optional windowFrames override (bench knob — max un-acked frames the io
269
+ // proxy delivers before pausing; endpoint default is 128).
270
+ const WS_WINDOW_FRAMES =
271
+ Number(new URL(self.location.href).searchParams.get("ws-window")) ||
272
+ undefined;
273
+
274
+ // Memoized so a repo-construction retry (getRepoHive clears its promise on
275
+ // failure) reuses the same endpoint instead of leaking one per attempt.
276
+ let subductionEndpoints: (WorkerWebSocketEndpoint | string)[] | null = null;
277
+ function getSubductionEndpoints(): (WorkerWebSocketEndpoint | string)[] {
278
+ if (!subductionEndpoints) {
279
+ log(`subduction websocket mode: ${WS_MODE}`);
280
+ subductionEndpoints =
281
+ WS_MODE === "inline"
282
+ ? [SUBDUCTION_SYNC_URL]
283
+ : [
284
+ new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
285
+ worker: subductionPortProvider.source,
286
+ ...(WS_WINDOW_FRAMES
287
+ ? { windowFrames: WS_WINDOW_FRAMES }
288
+ : {}),
289
+ }),
290
+ ];
291
+ }
292
+ return subductionEndpoints;
293
+ }
219
294
  const RESOLVE_TIMEOUT_MS = 30_000;
220
295
 
221
296
  // Backoff re-sync of stuck/diverged docs. Only this worker is connected to the
@@ -310,8 +385,6 @@ function getRepoHive() {
310
385
  }
311
386
  ).toHex(),
312
387
  };
313
- console.log("[patchwork] shared-worker subduction identity:", identity);
314
-
315
388
  const repo = new Repo({
316
389
  storage: new IndexedDBWorkerStorageAdapter(),
317
390
  signer,
@@ -323,9 +396,16 @@ function getRepoHive() {
323
396
  return peerId.includes("storage-server");
324
397
  },
325
398
  enableRemoteHeadsGossiping: true,
326
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
399
+ subductionWebsocketEndpoints: getSubductionEndpoints(),
327
400
  });
328
401
 
402
+ console.log(
403
+ "[patchwork] shared-worker subduction identity:",
404
+ identity,
405
+ "networkSubsystem.adapters:",
406
+ repo.networkSubsystem.adapters.length
407
+ );
408
+
329
409
  (self as any).repo = repo;
330
410
  (self as any).syncIdentity = identity;
331
411
  setupSyncStateBroadcast(repo, identity);
@@ -354,7 +434,7 @@ function getRepoHive() {
354
434
  ...(useKeyhiveSyncServer ? { syncServer: "keyhive" as const } : {}),
355
435
  repo: {
356
436
  storage: new IndexedDBWorkerStorageAdapter(),
357
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
437
+ subductionWebsocketEndpoints: getSubductionEndpoints(),
358
438
  enableRemoteHeadsGossiping: true,
359
439
  },
360
440
  });
@@ -717,10 +797,14 @@ function dropRepoChannel(repo: Repo, channel: RepoChannel) {
717
797
  // wrapper: make sure the underlying port is disconnected and closed too.
718
798
  try {
719
799
  channel.mcAdapter.disconnect();
720
- } catch {}
800
+ } catch {
801
+ // Already disconnected by removeNetworkAdapter above.
802
+ }
721
803
  try {
722
804
  channel.port.close();
723
- } catch {}
805
+ } catch {
806
+ // Port already closed by the departing tab.
807
+ }
724
808
  }
725
809
 
726
810
  async function dropConnection(connection: Connection) {
@@ -851,6 +935,11 @@ self.addEventListener("connect", (event) => {
851
935
  handleControlMessage(messageEvent as MessageEvent, controlPort, connection);
852
936
  });
853
937
 
938
+ // Let the subduction port provider negotiate over this tab's control port
939
+ // (the tab side runs donatePort; the messages are channel-tagged so they
940
+ // coexist with our control protocol above).
941
+ subductionPortProvider.attachClient(controlPort);
942
+
854
943
  // Fires when the owning page is destroyed. Browsers without the close
855
944
  // event fall back to the adapters' lazy useWeakRef cleanup.
856
945
  controlPort.addEventListener("close", () => {
@@ -975,11 +1064,10 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
975
1064
  ? (new Uint8Array(resolved.content) as BlobPart)
976
1065
  : resolved.content;
977
1066
 
978
- const headers = new Headers({ "content-type": resolved.type });
979
- headers.set("cross-origin-embedder-policy", "credentialless");
980
- headers.set("cross-origin-resource-policy", "cross-origin");
981
-
982
- return new Response(body, { status: 200, headers });
1067
+ return new Response(body, {
1068
+ status: 200,
1069
+ headers: { "content-type": resolved.type },
1070
+ });
983
1071
  }
984
1072
 
985
1073
  // ── Handoff: resolve special URLs for the service worker ──────────────
package/src/externals.ts CHANGED
@@ -6,6 +6,11 @@ const externals = [
6
6
  "@automerge/automerge/slim",
7
7
  "@automerge/automerge-repo",
8
8
  "@automerge/automerge-repo/slim",
9
+ // Port-donation plumbing for WorkerWebSocketEndpoint: tabs spawn the shared
10
+ // proxy entry and donate its port to the automerge worker (Chrome can't
11
+ // spawn workers from inside a SharedWorker). See setup.ts/automerge-worker.ts.
12
+ "@automerge/automerge-repo/worker-port",
13
+ "@automerge/automerge-repo/subduction-websocket-worker-shared",
9
14
  "@automerge/automerge-repo-network-messagechannel",
10
15
  "@automerge/automerge-repo-network-websocket",
11
16
  "@automerge/automerge-repo-storage-indexeddb",
@@ -24,6 +29,7 @@ const externals = [
24
29
  "@codemirror/state",
25
30
  "@codemirror/view",
26
31
  "@codemirror/language",
32
+ "@codemirror/commands",
27
33
 
28
34
  // rip
29
35
  "solid-js",
@@ -13,10 +13,13 @@ import {
13
13
  type HandoffRequestMessage,
14
14
  } from "./types.js";
15
15
 
16
- let cachename = "default";
16
+ const DEFAULT_CACHE_NAME = "patchwork";
17
+
18
+ let cachename = DEFAULT_CACHE_NAME;
17
19
  let debugging = false;
18
20
 
19
- const cacheableStatuses = [200, 203, 204];
21
+ // 0 is an opaque response, that also needs cached
22
+ const cacheableStatuses = [0, 200, 203, 204];
20
23
 
21
24
  // The automerge worker times its own resolution out after 30s and replies
22
25
  // with an error, so this only fires when nobody is listening at all.
@@ -82,22 +85,19 @@ self.addEventListener("install", (event) => {
82
85
  (event as ExtendableEvent).waitUntil(self.skipWaiting());
83
86
  });
84
87
 
85
- async function clearOldCaches() {
86
- const cacheWhitelist = [cachename];
87
- const cacheNames = await caches.keys();
88
- const deletePromises = cacheNames.map((cacheName) => {
89
- if (!cacheWhitelist.includes(cacheName)) {
90
- return caches.delete(cacheName);
91
- }
92
- });
93
- await Promise.all(deletePromises);
88
+ async function clearOtherCaches() {
89
+ await Promise.all(
90
+ (await caches.keys()).map((cacheName) => {
91
+ if (cacheName !== cachename) return caches.delete(cacheName);
92
+ })
93
+ );
94
94
  }
95
95
 
96
96
  self.addEventListener("activate", (event) => {
97
97
  lifecycle("info", "activate (claiming clients)");
98
98
  (event as ExtendableEvent).waitUntil(
99
99
  (async () => {
100
- await clearOldCaches();
100
+ await clearOtherCaches();
101
101
  await self.clients.claim();
102
102
  // Pre-cache pages of already-open clients so they survive going offline
103
103
  // before the next navigation.
@@ -110,7 +110,7 @@ self.addEventListener("activate", (event) => {
110
110
  if (!existing) {
111
111
  const response = await fetch(client.url);
112
112
  if (cacheableStatuses.includes(response.status)) {
113
- await cache.put(client.url, response);
113
+ await cachePage(cache, client.url, response);
114
114
  }
115
115
  }
116
116
  } catch {
@@ -128,11 +128,19 @@ self.addEventListener("message", async (event) => {
128
128
  if (cachename == nextCachename) {
129
129
  return;
130
130
  }
131
- console.info(
132
- `deleting ${cachename} and setting cache name to ${nextCachename}`
133
- );
134
- caches.delete(cachename);
131
+ console.info(`moving from cache ${cachename} to ${nextCachename}`);
132
+ if (cachename === DEFAULT_CACHE_NAME) {
133
+ const defaultCache = await caches.open(cachename);
134
+ const nextCache = await caches.open(nextCachename);
135
+ await Promise.all(
136
+ (await defaultCache.keys()).map(async (request) => {
137
+ const response = await defaultCache.match(request);
138
+ if (response) await nextCache.put(request, response);
139
+ })
140
+ );
141
+ }
135
142
  cachename = nextCachename;
143
+ await clearOtherCaches();
136
144
  } else if (event.data.type == "debug") {
137
145
  debugging = event.data.debug;
138
146
  log("serviceworker debugging enabled");
@@ -216,20 +224,47 @@ function handoff(
216
224
  });
217
225
  }
218
226
 
219
- function withSpecialHeaders(response: {
227
+ function makeResponse(response: {
220
228
  body?: BodyInit | ReadableStream<Uint8Array> | null;
221
229
  status?: number;
222
230
  headers?: HeadersInit;
223
231
  }): Response {
224
- const headers = new Headers(response.headers);
225
- headers.set("cross-origin-embedder-policy", "credentialless");
226
- headers.set("cross-origin-resource-policy", "cross-origin");
227
232
  return new Response(response.body ?? null, {
228
233
  status: response.status ?? 200,
229
- headers,
234
+ headers: response.headers,
230
235
  });
231
236
  }
232
237
 
238
+ function indexRequestFor(request: Request | string): Request | undefined {
239
+ const url = new URL(typeof request === "string" ? request : request.url);
240
+ if (url.origin !== self.location.origin) return undefined;
241
+ url.pathname = "/index.html";
242
+ url.search = "";
243
+ url.hash = "";
244
+ return new Request(url.href);
245
+ }
246
+
247
+ function rootRequestFor(request: Request | string): Request | undefined {
248
+ const url = new URL(typeof request === "string" ? request : request.url);
249
+ if (url.origin !== self.location.origin) return undefined;
250
+ url.pathname = "/";
251
+ url.search = "";
252
+ url.hash = "";
253
+ return new Request(url.href);
254
+ }
255
+
256
+ async function cachePage(
257
+ cache: Cache,
258
+ request: Request | string,
259
+ response: Response
260
+ ) {
261
+ const indexRequest = indexRequestFor(request);
262
+ if (indexRequest) await cache.put(indexRequest, response.clone());
263
+ const rootRequest = rootRequestFor(request);
264
+ if (rootRequest) await cache.put(rootRequest, response.clone());
265
+ await cache.put(request, response);
266
+ }
267
+
233
268
  // ── Fetch handler ──────────────────────────────────────────────────────
234
269
 
235
270
  self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
@@ -260,7 +295,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
260
295
  if (handoffURL) {
261
296
  if (match) {
262
297
  log(`serving ${handoffURL} from cache ${cachename}`);
263
- return withSpecialHeaders(match);
298
+ return match;
264
299
  }
265
300
 
266
301
  log(`handing ${handoffURL} off to the automerge worker`);
@@ -271,7 +306,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
271
306
  if (reply.type === "response") {
272
307
  // errors, redirects and other things that shouldn't be cached
273
308
  log(`serving handed-off response for ${handoffURL}`, reply);
274
- return withSpecialHeaders(reply.response);
309
+ return makeResponse(reply.response);
275
310
  }
276
311
 
277
312
  // reply.type === "cached": the automerge worker has put the
@@ -284,26 +319,51 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
284
319
  );
285
320
  }
286
321
  log(`serving ${handoffURL} from cache ${cachename} after handoff`);
287
- return withSpecialHeaders(cached);
322
+ return cached;
288
323
  } else {
289
- const response = await fetch(request).catch(() => null);
290
- if (response) {
324
+ // fetch() rejects on network error / abort rather than resolving;
325
+ // keep the error so we can surface it in the 503 body below.
326
+ const result = await fetch(request).catch((error: unknown) =>
327
+ error instanceof Error ? error : new Error(String(error))
328
+ );
329
+ if (result instanceof Response) {
330
+ const response = result;
331
+ // Tool subresources (<link>/<script>) are requested from srcdoc
332
+ // frames whose origin is "null", so they come back as opaque
333
+ // cross-origin `no-cors` responses: status 0 and an empty url. They
334
+ // render fine while online but were being excluded from the cache,
335
+ // so e.g. a theme stylesheet vanished on an offline refresh. Opaque
336
+ // responses are cacheable and replay to the same no-cors consumer,
337
+ // so treat status 0 as cacheable and gate the scheme on request.url
338
+ // (an opaque response's own url is "").
291
339
  if (
292
- cacheableStatuses.includes(response.status) &&
293
- response.url.match(/^https?\:/)
340
+ (response.status === 0 ||
341
+ cacheableStatuses.includes(response.status)) &&
342
+ /^https?:/.test(request.url)
294
343
  ) {
295
- await cache.put(request, response.clone()).catch((error) => {
344
+ const cachedResponse = response.clone();
345
+ await (
346
+ request.mode === "navigate" ||
347
+ request.destination === "document"
348
+ ? cachePage(cache, request, cachedResponse)
349
+ : cache.put(request, cachedResponse)
350
+ ).catch((error) => {
296
351
  log(`error caching ${request.url} in ${cachename}`, error);
297
352
  });
298
353
  } else {
299
354
  log(
300
- `skipping uncacheable response code from cache: ${response.status} for ${response.url}`
355
+ `skipping uncacheable response code from cache: ${response.status} for ${request.url}`
301
356
  );
302
357
  }
303
358
  return response;
304
359
  }
305
360
  if (match) return match;
306
- return new Response("couldnt fetch and no stale", { status: 503 });
361
+ return new Response(
362
+ `couldnt fetch ${request.url} and no stale copy in ${cachename}\n\n${
363
+ result.stack ?? result.message
364
+ }`,
365
+ { status: 503, headers: { "content-type": "text/plain" } }
366
+ );
307
367
  }
308
368
  } catch (error) {
309
369
  const message =