@inkandswitch/patchwork-bootloader 0.4.2 → 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/site.js CHANGED
@@ -20,7 +20,7 @@ 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
26
  import { importAutomergePackageViaWorker } from "./module-loader.js";
@@ -28,7 +28,7 @@ import { openDocument, registerPatchworkViewElement, } from "@inkandswitch/patch
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;
@@ -272,8 +326,8 @@ function installLifecycleLogging() {
272
326
  document.addEventListener("visibilitychange", () => note(`visibilitychange → ${document.visibilityState}`), opts);
273
327
  document.addEventListener("freeze", () => note("freeze (tab suspended)"), opts);
274
328
  document.addEventListener("resume", () => note("resume (tab unsuspended)"), opts);
275
- window.addEventListener("pageshow", e => note("pageshow", { persisted: e.persisted }), opts);
276
- 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);
277
331
  window.addEventListener("online", () => note("online"), opts);
278
332
  window.addEventListener("offline", () => note("offline"), opts);
279
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
  },
package/package.json CHANGED
@@ -1,11 +1,10 @@
1
1
  {
2
2
  "name": "@inkandswitch/patchwork-bootloader",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "author": "chee",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "devDependencies": {
8
- "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
9
8
  "esbuild": "^0.23.1",
10
9
  "rollup": "^4.61.1"
11
10
  },
@@ -46,27 +45,47 @@
46
45
  "dependencies": {
47
46
  "@automerge/automerge": "3.3.2",
48
47
  "@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",
48
+ "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
49
+ "@automerge/automerge-repo-network-messagechannel": "2.6.0-subduction.46",
50
+ "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.46",
51
+ "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.46",
52
52
  "@automerge/automerge-subduction": "0.16.0",
53
- "@automerge/vanillajs": "2.6.0-subduction.45",
53
+ "@automerge/vanillajs": "2.6.0-subduction.46",
54
+ "@codemirror/commands": "^6.10.4",
55
+ "@codemirror/language": "^6.12.4",
56
+ "@codemirror/state": "^6.7.1",
57
+ "@codemirror/view": "^6.43.6",
54
58
  "@keyhive/keyhive": "0.1.0-alpha.5",
55
59
  "@types/debug": "^4.1.13",
56
60
  "debug": "^4.4.3",
57
61
  "resolve.exports": "^2.0.3",
58
62
  "service-worker-types": "npm:@types/serviceworker@^0.0.153",
63
+ "solid-js": "^1.9.13",
59
64
  "tinyargs": "^0.1.4",
60
65
  "@inkandswitch/patchwork-elements": "^4.0.0",
61
- "@inkandswitch/patchwork-filesystem": "^0.2.0",
62
- "@inkandswitch/patchwork-plugins": "^1.0.0",
63
- "@inkandswitch/patchwork-providers": "^0.4.0"
66
+ "@inkandswitch/patchwork-filesystem": "^0.2.1",
67
+ "@inkandswitch/patchwork-plugins": "^1.0.1",
68
+ "@inkandswitch/patchwork-providers": "^0.4.1"
64
69
  },
65
70
  "peerDependencies": {
66
71
  "@automerge/automerge": "3.3.2",
67
72
  "@automerge/automerge-repo": "2.6.0-subduction.46",
68
73
  "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
69
- "@automerge/vanillajs": "2.6.0-subduction.45"
74
+ "@automerge/vanillajs": "2.6.0-subduction.46"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "@automerge/automerge": {
78
+ "optional": true
79
+ },
80
+ "@automerge/automerge-repo": {
81
+ "optional": true
82
+ },
83
+ "@automerge/automerge-repo-keyhive": {
84
+ "optional": true
85
+ },
86
+ "@automerge/vanillajs": {
87
+ "optional": true
88
+ }
70
89
  },
71
90
  "scripts": {
72
91
  "build": "tsc",
@@ -49,6 +49,7 @@ import {
49
49
  type HandoffCachedMessage,
50
50
  type HandoffOnlineMessage,
51
51
  type HandoffRequest,
52
+ type HandoffAbortMessage,
52
53
  type HandoffRequestMessage,
53
54
  type HandoffResponseMessage,
54
55
  type SyncStateBroadcast,
@@ -219,11 +220,9 @@ setInterval(() => {
219
220
  watchdogLast = now;
220
221
  if (gap > WATCHDOG_TICK_MS * WATCHDOG_GAP_FACTOR) {
221
222
  console.warn(
222
- `[lifecycle] worker resumed after ~${Math.round(gap / 1000)}s gap ` +
223
- `(timer expected every ${WATCHDOG_TICK_MS / 1000}s) likely ` +
224
- `suspended/frozen/throttled; WebSocket keepalive pongs were not sent ` +
225
- `during this window, so the sync server may have reaped us. at ` +
226
- `${new Date(now).toISOString()}`
223
+ `[lifecycle] ${new Date(now).toISOString()} watchdog timer gap ` +
224
+ `~${Math.round(gap / 1000)}s (expected every ` +
225
+ `${WATCHDOG_TICK_MS / 1000}s)`
227
226
  );
228
227
  }
229
228
  }, WATCHDOG_TICK_MS);
@@ -283,9 +282,7 @@ function getSubductionEndpoints(): (WorkerWebSocketEndpoint | string)[] {
283
282
  : [
284
283
  new WorkerWebSocketEndpoint(SUBDUCTION_SYNC_URL, {
285
284
  worker: subductionPortProvider.source,
286
- ...(WS_WINDOW_FRAMES
287
- ? { windowFrames: WS_WINDOW_FRAMES }
288
- : {}),
285
+ ...(WS_WINDOW_FRAMES ? { windowFrames: WS_WINDOW_FRAMES } : {}),
289
286
  }),
290
287
  ];
291
288
  }
@@ -339,7 +336,7 @@ async function connectClassicSyncNetwork(server: string): Promise<void> {
339
336
  }
340
337
 
341
338
  const siteName =
342
- typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
339
+ typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "patchwork.inkandswitch.com";
343
340
 
344
341
  const cacheableStatuses = [200, 203, 204];
345
342
 
@@ -365,10 +362,9 @@ function getRepoHive() {
365
362
  if (!repoHivePromise) {
366
363
  repoHivePromise = (async () => {
367
364
  log("getRepo: starting");
368
-
369
365
  log("fetching wasm modules");
370
366
  const [amWasmBuf, sdnWasmBuf] = await Promise.all([
371
- fetch("/automerge.wasm?worker").then((r) => r.arrayBuffer()),
367
+ fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
372
368
  fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
373
369
  ]);
374
370
  initSubductionSync(new Uint8Array(sdnWasmBuf));
@@ -646,8 +642,9 @@ function setupSyncStateBroadcast(
646
642
  >();
647
643
  // Inspectable from the SharedWorker console as `self.patchworkResync` to see
648
644
  // whether/how often a doc is being re-synced and against which server heads.
649
- const resyncDiag: { fires: number; byDoc: Record<string, unknown> } =
650
- ((self as any).patchworkResync ??= { fires: 0, byDoc: {} });
645
+ const resyncDiag: { fires: number; byDoc: Record<string, unknown> } = ((
646
+ self as any
647
+ ).patchworkResync ??= { fires: 0, byDoc: {} });
651
648
  const reviewResync = (documentId: string) => {
652
649
  if (!identity || !connected) {
653
650
  resyncState.delete(documentId);
@@ -732,8 +729,12 @@ function setupSyncStateBroadcast(
732
729
  byStorage.set(storageId, { heads: headsCopy, timestamp });
733
730
  postHeads(documentId, storageId, headsCopy, timestamp);
734
731
  // A doc the server reported is one we hold — make sure we're advertising
735
- // our own heads for it too.
736
- scanOwnHandles();
732
+ // our own heads for it too. Track just this doc: a full scanOwnHandles()
733
+ // per event is O(all handles) and goes quadratic during sync bursts,
734
+ // starving the thread that's doing the syncing. The 3s tick still covers
735
+ // general discovery.
736
+ const handle = repo.handles[documentId as DocumentId];
737
+ if (handle) trackOwnHandle(handle as never);
737
738
  reviewResync(documentId);
738
739
  }
739
740
  );
@@ -870,6 +871,16 @@ function handleControlMessage(
870
871
  connection: Connection
871
872
  ) {
872
873
  const data = event.data;
874
+ // Tally of control messages received, readable from the SharedWorker console
875
+ // as `self.patchworkControl`. Not using log(): that's gated on `debugging`,
876
+ // which is only enabled by a {type:"debug"} message arriving over this same
877
+ // channel.
878
+ const stats = ((self as any).patchworkControl ??= {
879
+ connects: 0,
880
+ byType: {} as Record<string, number>,
881
+ });
882
+ stats.byType[String(data?.type ?? "<untyped>")] =
883
+ (stats.byType[String(data?.type ?? "<untyped>")] ?? 0) + 1;
873
884
  if (data?.type === "port") {
874
885
  log("received repo channel");
875
886
  const [repoPort] = event.ports;
@@ -930,6 +941,7 @@ function handleControlMessage(
930
941
  self.addEventListener("connect", (event) => {
931
942
  const controlPort = (event as MessageEvent).ports[0];
932
943
  const connection: Connection = { channels: new Set() };
944
+ ((self as any).patchworkControl ??= { connects: 0, byType: {} }).connects++;
933
945
 
934
946
  controlPort.addEventListener("message", (messageEvent) => {
935
947
  handleControlMessage(messageEvent as MessageEvent, controlPort, connection);
@@ -1010,6 +1022,13 @@ function waitForHeads(
1010
1022
  });
1011
1023
  }
1012
1024
 
1025
+ /**
1026
+ * Thrown instead of returning a Response when the request should fail as a
1027
+ * network error rather than resolve to something the caller can memoize.
1028
+ * See {@link HandoffAbortMessage}.
1029
+ */
1030
+ class AbortHandoff extends Error {}
1031
+
1013
1032
  async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
1014
1033
  const { repo } = await getRepoHive();
1015
1034
  const href = automergeURL.href;
@@ -1043,7 +1062,12 @@ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
1043
1062
  // The heads may not have synced to us yet — give them the rest of the
1044
1063
  // resolve window to arrive before giving up.
1045
1064
  if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
1046
- return new Response("heads not found", { status: 404 });
1065
+ // Not a 404: the heads may still be on their way, and this exact URL will
1066
+ // be requested again once they land. Fail it as a network error so the
1067
+ // caller doesn't memoize the miss.
1068
+ throw new AbortHandoff(
1069
+ `heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`
1070
+ );
1047
1071
  }
1048
1072
  const rootHandle = baseHandle.view(heads);
1049
1073
 
@@ -1137,6 +1161,14 @@ async function handleHandoffRequest(message: HandoffRequestMessage) {
1137
1161
  ),
1138
1162
  ]);
1139
1163
  } catch (error) {
1164
+ if (error instanceof AbortHandoff) {
1165
+ handoffChannel.postMessage({
1166
+ id,
1167
+ type: "abort",
1168
+ reason: error.message,
1169
+ } satisfies HandoffAbortMessage);
1170
+ return;
1171
+ }
1140
1172
  const body =
1141
1173
  error instanceof Error
1142
1174
  ? `${error.message}\n\n${error.stack}`
@@ -184,6 +184,9 @@ handoffChannel.addEventListener("message", (event) => {
184
184
  }
185
185
  });
186
186
 
187
+ /** Signals that respondWith should reject; see {@link HandoffAbortMessage}. */
188
+ class HandoffAborted extends Error {}
189
+
187
190
  function handoff(
188
191
  request: Request,
189
192
  handoffURL: URL
@@ -259,10 +262,34 @@ async function cachePage(
259
262
  response: Response
260
263
  ) {
261
264
  const indexRequest = indexRequestFor(request);
262
- if (indexRequest) await cache.put(indexRequest, response.clone());
263
265
  const rootRequest = rootRequestFor(request);
264
- if (rootRequest) await cache.put(rootRequest, response.clone());
265
- await cache.put(request, response);
266
+ await Promise.all([
267
+ indexRequest && cache.put(indexRequest, response.clone()),
268
+ rootRequest && cache.put(rootRequest, response.clone()),
269
+ cache.put(request, response),
270
+ ]);
271
+ }
272
+
273
+ // Write to the cache without blocking the response: cache.put only resolves
274
+ // once the whole body has been consumed and persisted, so awaiting it before
275
+ // returning would turn time-to-first-byte into time-to-last-byte-plus-disk
276
+ // for every proxied asset. waitUntil keeps the worker alive for the write.
277
+ function cacheInBackground(
278
+ fetchEvent: FetchEvent,
279
+ cache: Cache,
280
+ request: Request,
281
+ response: Response
282
+ ) {
283
+ fetchEvent.waitUntil(
284
+ (request.mode === "navigate" || request.destination === "document"
285
+ ? cachePage(cache, request, response)
286
+ : cache.put(request, response)
287
+ ).catch((error) => {
288
+ // Always loud (not gated on debugging): a QuotaExceededError here is
289
+ // the first sign the origin is under storage pressure.
290
+ console.warn(`error caching ${request.url} in ${cachename}`, error);
291
+ })
292
+ );
266
293
  }
267
294
 
268
295
  // ── Fetch handler ──────────────────────────────────────────────────────
@@ -270,7 +297,9 @@ async function cachePage(
270
297
  self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
271
298
  log("fetch event", fetchEvent.request.url);
272
299
  const request = fetchEvent.request;
273
- if (request.method !== "GET") return fetchEvent.respondWith(fetch(request));
300
+ // Not calling respondWith at all lets the browser handle non-GETs natively
301
+ // instead of proxying their bodies through this worker.
302
+ if (request.method !== "GET") return;
274
303
  const url = new URL(fetchEvent.request.url);
275
304
 
276
305
  let handoffURL: URL | undefined;
@@ -303,6 +332,14 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
303
332
  fetchEvent.waitUntil(replyPromise.catch(() => {}));
304
333
  const reply = await replyPromise;
305
334
 
335
+ if (reply.type === "abort") {
336
+ // Rejecting respondWith gives the caller a network error rather
337
+ // than a response it can memoize. Rethrown past the catch below,
338
+ // which would otherwise turn this into a 556.
339
+ log(`aborting ${handoffURL}: ${reply.reason}`);
340
+ throw new HandoffAborted(reply.reason);
341
+ }
342
+
306
343
  if (reply.type === "response") {
307
344
  // errors, redirects and other things that shouldn't be cached
308
345
  log(`serving handed-off response for ${handoffURL}`, reply);
@@ -341,15 +378,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
341
378
  cacheableStatuses.includes(response.status)) &&
342
379
  /^https?:/.test(request.url)
343
380
  ) {
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) => {
351
- log(`error caching ${request.url} in ${cachename}`, error);
352
- });
381
+ cacheInBackground(fetchEvent, cache, request, response.clone());
353
382
  } else {
354
383
  log(
355
384
  `skipping uncacheable response code from cache: ${response.status} for ${request.url}`
@@ -366,6 +395,8 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
366
395
  );
367
396
  }
368
397
  } catch (error) {
398
+ // Deliberate: fail the request as a network error, no response.
399
+ if (error instanceof HandoffAborted) throw error;
369
400
  const message =
370
401
  error instanceof Error
371
402
  ? `${error.message}\n\n${error.stack}`