@inkandswitch/patchwork-bootloader 0.4.2 → 0.4.4

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.
@@ -52,7 +52,6 @@ function getWorker(): Worker {
52
52
  return worker;
53
53
  }
54
54
 
55
- /** Ask the worker which plugins the package at `urlAtHeads` exports. */
56
55
  function discoverDescriptors(urlAtHeads: AutomergeUrl): Promise<Descriptor[]> {
57
56
  const id = nextRequestId++;
58
57
  return new Promise<Descriptor[]>((resolve, reject) => {
@@ -61,11 +60,6 @@ function discoverDescriptors(urlAtHeads: AutomergeUrl): Promise<Descriptor[]> {
61
60
  });
62
61
  }
63
62
 
64
- /**
65
- * ModuleWatcher `importAutomergePackage` 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
63
  export async function importAutomergePackageViaWorker(
70
64
  urlAtHeads: string
71
65
  ): Promise<{ plugins: Descriptor[] }> {
@@ -1,12 +1,5 @@
1
1
  /// <reference types="service-worker-types" />
2
2
 
3
- // The service worker holds no automerge repo — that lives in the automerge
4
- // SharedWorker (automerge-worker.ts). This worker manages the cache. When a
5
- // special URL misses the cache it broadcasts a handoff request; the
6
- // automerge worker resolves it, puts the response in our cache, and replies
7
- // "cached" (or "response" for errors and other things that shouldn't be
8
- // cached).
9
-
10
3
  import {
11
4
  HANDOFF_CHANNEL,
12
5
  type HandoffReplyMessage,
@@ -18,30 +11,20 @@ const DEFAULT_CACHE_NAME = "patchwork";
18
11
  let cachename = DEFAULT_CACHE_NAME;
19
12
  let debugging = false;
20
13
 
21
- // 0 is an opaque response, that also needs cached
22
- const cacheableStatuses = [0, 200, 203, 204];
14
+ // 0 is an opaque cross-origin response, which is cacheable and replays to the
15
+ // same no-cors consumer. these are big, so unfortunate.
16
+ const CACHEABLE_STATUSES = [0, 200, 203, 204];
23
17
 
24
- // The automerge worker times its own resolution out after 30s and replies
25
- // with an error, so this only fires when nobody is listening at all.
18
+ // The automerge worker times its own resolution out after 30s and replies with
19
+ // an error, so this only fires when nobody is listening at all.
26
20
  const HANDOFF_TIMEOUT_MS = 35_000;
27
21
 
28
22
  function log(...args: any[]) {
29
- if (!debugging) return;
30
- console.log.call(
31
- console,
32
- `%cpatchwork:serviceworker%c\n`,
33
- `color: #00ffcc; font-weight: bold`,
34
- "color: inherit",
35
- ...args
36
- );
23
+ if (debugging) console.log("[service-worker]", ...args);
37
24
  }
38
25
 
39
- // ── Lifecycle diagnostics ──────────────────────────────────────────────
40
- // [lifecycle] markers for SW (re)boots, install/activate, crashes, and stranded
41
- // handoffs. The SW can't read localStorage, so it always emits and forwards to
42
- // the tab, which gates rendering on the live toggle. The SW holds no sync
43
- // socket — observability only.
44
-
26
+ // A service worker has no localStorage and so can't read the debug config: it
27
+ // always emits these and forwards them to the tab, which does the filtering.
45
28
  async function postToClients(message: unknown) {
46
29
  const clients = await self.clients.matchAll({
47
30
  type: "window",
@@ -51,9 +34,9 @@ async function postToClients(message: unknown) {
51
34
  }
52
35
 
53
36
  function lifecycle(level: "info" | "warn", text: string) {
54
- const msg = `[lifecycle] ${new Date().toISOString()} ${text}`;
37
+ const msg = `${new Date().toISOString()} ${text}`;
55
38
  console[level](msg);
56
- void postToClients({ type: "sw-lifecycle", level, msg });
39
+ postToClients({ type: "sw-lifecycle", level, msg });
57
40
  }
58
41
 
59
42
  lifecycle("info", `booted (scope ${self.registration?.scope ?? "?"})`);
@@ -77,19 +60,22 @@ self.addEventListener("unhandledrejection", (event) => {
77
60
  );
78
61
  });
79
62
 
63
+ // ── Lifecycle ──────────────────────────────────────────────────────────
64
+
80
65
  self.addEventListener("install", (event) => {
81
66
  lifecycle("info", "install (skipWaiting)");
82
67
  // waitUntil keeps the worker alive until skipWaiting resolves, so a freshly
83
- // installed SW reliably jumps the "waiting" queue instead of stalling until
68
+ // installed worker reliably jumps the waiting queue instead of stalling until
84
69
  // every old tab closes.
85
70
  (event as ExtendableEvent).waitUntil(self.skipWaiting());
86
71
  });
87
72
 
88
73
  async function clearOtherCaches() {
74
+ const names = await caches.keys();
89
75
  await Promise.all(
90
- (await caches.keys()).map((cacheName) => {
91
- if (cacheName !== cachename) return caches.delete(cacheName);
92
- })
76
+ names
77
+ .filter((name) => name !== cachename)
78
+ .map((name) => caches.delete(name))
93
79
  );
94
80
  }
95
81
 
@@ -99,22 +85,20 @@ self.addEventListener("activate", (event) => {
99
85
  (async () => {
100
86
  await clearOtherCaches();
101
87
  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" });
88
+ // Pre-cache the pages of already-open clients so they survive going
89
+ // offline before the next navigation.
90
+ const clients = await self.clients.matchAll({ type: "window" });
105
91
  const cache = await caches.open(cachename);
106
92
  await Promise.all(
107
- allClients.map(async (client) => {
93
+ clients.map(async (client) => {
108
94
  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 cachePage(cache, client.url, response);
114
- }
95
+ if (await cache.match(client.url)) return;
96
+ const response = await fetch(client.url);
97
+ if (CACHEABLE_STATUSES.includes(response.status)) {
98
+ await cachePage(cache, client.url, response);
115
99
  }
116
100
  } catch {
117
- // Network may be unavailable during activation
101
+ // Network may be unavailable during activation.
118
102
  }
119
103
  })
120
104
  );
@@ -123,31 +107,33 @@ self.addEventListener("activate", (event) => {
123
107
  });
124
108
 
125
109
  self.addEventListener("message", async (event) => {
126
- if (event.data.type == "cachename") {
127
- const nextCachename = event.data.cachename;
128
- if (cachename == nextCachename) {
129
- return;
130
- }
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
- }
142
- cachename = nextCachename;
143
- await clearOtherCaches();
144
- } else if (event.data.type == "debug") {
145
- debugging = event.data.debug;
110
+ const data = event.data;
111
+
112
+ if (data?.type === "debug") {
113
+ debugging = data.debug;
146
114
  log("serviceworker debugging enabled");
115
+ return;
147
116
  }
148
- });
149
117
 
150
- // ── Handoff to the automerge worker ────────────────────────────────────
118
+ if (data?.type !== "cachename" || cachename === data.cachename) return;
119
+
120
+ console.info(`moving from cache ${cachename} to ${data.cachename}`);
121
+ const previous = cachename;
122
+ // Switch before copying: fetches landing mid-copy must write into the new
123
+ // cache, or their entries get deleted along with the old one below.
124
+ cachename = data.cachename;
125
+ if (previous === DEFAULT_CACHE_NAME) {
126
+ const from = await caches.open(previous);
127
+ const to = await caches.open(cachename);
128
+ await Promise.all(
129
+ (await from.keys()).map(async (request) => {
130
+ const response = await from.match(request);
131
+ if (response) await to.put(request, response);
132
+ })
133
+ );
134
+ }
135
+ await clearOtherCaches();
136
+ });
151
137
 
152
138
  const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
153
139
 
@@ -160,31 +146,30 @@ const pendingHandoffs = new Map<string, PendingHandoff>();
160
146
 
161
147
  handoffChannel.addEventListener("message", (event) => {
162
148
  const data = event.data;
149
+
163
150
  if (data?.type === "cached" || data?.type === "response") {
164
151
  const pending = pendingHandoffs.get(data.id);
165
- if (!pending) {
166
- return log(`no pending handoff for id ${data.id}`);
167
- }
152
+ if (!pending) return log(`no pending handoff for id ${data.id}`);
168
153
  pending.resolvers.resolve(data as HandoffReplyMessage);
169
- } else if (data?.type === "online") {
170
- // The automerge worker (re)started — re-broadcast anything still in
171
- // flight so requests that raced its boot aren't stranded.
172
- const stranded = [...pendingHandoffs.values()];
173
- if (stranded.length > 0) {
174
- lifecycle(
175
- "info",
176
- `automerge worker (re)started; re-broadcasting ${stranded.length} ` +
177
- `in-flight asset handoff(s)`
178
- );
179
- }
180
- for (const { message } of stranded) {
181
- log(`re-broadcasting handoff ${message.id} to the fresh worker`);
182
- handoffChannel.postMessage(message);
183
- }
154
+ return;
184
155
  }
156
+
157
+ if (data?.type !== "online") return;
158
+ // The automerge worker (re)started — re-broadcast anything still in flight so
159
+ // requests that raced its boot aren't stranded.
160
+ const stranded = [...pendingHandoffs.values()];
161
+ if (stranded.length === 0) return;
162
+ lifecycle(
163
+ "info",
164
+ `automerge worker (re)started; re-broadcasting ${stranded.length} in-flight asset handoff(s)`
165
+ );
166
+ for (const { message } of stranded) handoffChannel.postMessage(message);
185
167
  });
186
168
 
187
- function handoff(
169
+ /** Signals that respondWith should reject; see {@link HandoffAbortMessage}. */
170
+ class HandoffAborted extends Error {}
171
+
172
+ async function handoff(
188
173
  request: Request,
189
174
  handoffURL: URL
190
175
  ): Promise<HandoffReplyMessage> {
@@ -203,14 +188,15 @@ function handoff(
203
188
  referrer: request.referrer,
204
189
  },
205
190
  };
191
+
206
192
  pendingHandoffs.set(id, { message, resolvers });
207
193
  log(`broadcasting handoff request for cache ${cachename}`, message);
208
194
  handoffChannel.postMessage(message);
195
+
209
196
  const timeout = setTimeout(() => {
210
197
  lifecycle(
211
198
  "warn",
212
- `asset handoff ${id} stranded: no reply from the automerge worker after ` +
213
- `${HANDOFF_TIMEOUT_MS}ms (${handoffURL.href})`
199
+ `asset handoff ${id} stranded: no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms (${handoffURL.href})`
214
200
  );
215
201
  resolvers.reject(
216
202
  new Error(
@@ -218,39 +204,31 @@ function handoff(
218
204
  )
219
205
  );
220
206
  }, HANDOFF_TIMEOUT_MS);
207
+
221
208
  return resolvers.promise.finally(() => {
222
209
  clearTimeout(timeout);
223
210
  pendingHandoffs.delete(id);
224
211
  });
225
212
  }
226
213
 
227
- function makeResponse(response: {
228
- body?: BodyInit | ReadableStream<Uint8Array> | null;
229
- status?: number;
230
- headers?: HeadersInit;
231
- }): Response {
232
- return new Response(response.body ?? null, {
233
- status: response.status ?? 200,
234
- headers: response.headers,
235
- });
236
- }
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);
214
+ // ── Caching ────────────────────────────────────────────────────────────
215
+
216
+ /** A page is cached under its own url plus /index.html and / on this origin. */
217
+ function pageCacheKeys(request: Request | string): Request[] {
218
+ const original = typeof request === "string" ? new Request(request) : request;
219
+ const url = new URL(original.url);
220
+ if (url.origin !== self.location.origin) return [original];
221
+
222
+ return [
223
+ original,
224
+ ...["/index.html", "/"].map((pathname) => {
225
+ const variant = new URL(url.href);
226
+ variant.pathname = pathname;
227
+ variant.search = "";
228
+ variant.hash = "";
229
+ return new Request(variant.href);
230
+ }),
231
+ ];
254
232
  }
255
233
 
256
234
  async function cachePage(
@@ -258,129 +236,163 @@ async function cachePage(
258
236
  request: Request | string,
259
237
  response: Response
260
238
  ) {
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);
239
+ const keys = pageCacheKeys(request);
240
+ await Promise.all(
241
+ keys.map((key, i) =>
242
+ cache.put(key, i === keys.length - 1 ? response : response.clone())
243
+ )
244
+ );
266
245
  }
267
246
 
268
- // ── Fetch handler ──────────────────────────────────────────────────────
269
-
270
- self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
271
- log("fetch event", fetchEvent.request.url);
272
- const request = fetchEvent.request;
273
- if (request.method !== "GET") return fetchEvent.respondWith(fetch(request));
274
- const url = new URL(fetchEvent.request.url);
275
-
276
- let handoffURL: URL | undefined;
247
+ // cache.put only resolves once the whole body has been consumed and persisted,
248
+ // so awaiting it before returning would turn time-to-first-byte into
249
+ // time-to-last-byte-plus-disk for every proxied asset. waitUntil keeps the
250
+ // worker alive for the write.
251
+ function cacheInBackground(
252
+ fetchEvent: FetchEvent,
253
+ cache: Cache,
254
+ request: Request,
255
+ response: Response
256
+ ) {
257
+ const isPage =
258
+ request.mode === "navigate" || request.destination === "document";
259
+ fetchEvent.waitUntil(
260
+ (isPage
261
+ ? cachePage(cache, request, response)
262
+ : cache.put(request, response)
263
+ ).catch((error) => {
264
+ // Always loud: a QuotaExceededError here is the first sign the origin is
265
+ // under storage pressure.
266
+ console.warn(`error caching ${request.url} in ${cachename}`, error);
267
+ })
268
+ );
269
+ }
277
270
 
271
+ /** A same-origin path that is itself an encoded URL, e.g. /automerge%3Aabc/x. */
272
+ function specialURLFor(request: Request): URL | undefined {
273
+ const url = new URL(request.url);
278
274
  if (
279
- url.hostname == self.location.hostname &&
280
- url.port == self.location.port &&
281
- url.protocol == self.location.protocol
275
+ url.hostname !== self.location.hostname ||
276
+ url.port !== self.location.port ||
277
+ url.protocol !== self.location.protocol
282
278
  ) {
283
- try {
284
- handoffURL = new URL(decodeURIComponent(url.pathname.slice(1)));
285
- log(`received special request ${handoffURL}`);
286
- } catch {}
279
+ return undefined;
280
+ }
281
+ try {
282
+ return new URL(decodeURIComponent(url.pathname.slice(1)));
283
+ } catch {
284
+ return undefined;
287
285
  }
286
+ }
288
287
 
289
- fetchEvent.respondWith(
290
- (async () => {
291
- const cache = await caches.open(cachename);
292
- const match = await cache.match(request);
288
+ async function serveHandoff(
289
+ fetchEvent: FetchEvent,
290
+ cache: Cache,
291
+ handoffURL: URL,
292
+ cached: Response | undefined
293
+ ): Promise<Response> {
294
+ if (cached) {
295
+ log(`serving ${handoffURL} from cache ${cachename}`);
296
+ return cached;
297
+ }
293
298
 
294
- try {
295
- if (handoffURL) {
296
- if (match) {
297
- log(`serving ${handoffURL} from cache ${cachename}`);
298
- return match;
299
- }
299
+ log(`handing ${handoffURL} off to the automerge worker`);
300
+ const replyPromise = handoff(fetchEvent.request, handoffURL);
301
+ fetchEvent.waitUntil(replyPromise.catch(() => {}));
302
+ const reply = await replyPromise;
300
303
 
301
- log(`handing ${handoffURL} off to the automerge worker`);
302
- const replyPromise = handoff(request, handoffURL);
303
- fetchEvent.waitUntil(replyPromise.catch(() => {}));
304
- const reply = await replyPromise;
304
+ if (reply.type === "abort") {
305
+ // Rejecting respondWith gives the caller a network error rather than a
306
+ // response it can memoize.
307
+ log(`aborting ${handoffURL}: ${reply.reason}`);
308
+ throw new HandoffAborted(reply.reason);
309
+ }
305
310
 
306
- if (reply.type === "response") {
307
- // errors, redirects and other things that shouldn't be cached
308
- log(`serving handed-off response for ${handoffURL}`, reply);
309
- return makeResponse(reply.response);
310
- }
311
+ if (reply.type === "response") {
312
+ log(`serving handed-off response for ${handoffURL}`, reply);
313
+ return new Response(reply.response.body ?? null, {
314
+ status: reply.response.status ?? 200,
315
+ headers: reply.response.headers,
316
+ });
317
+ }
311
318
 
312
- // reply.type === "cached": the automerge worker has put the
313
- // response in our cache
314
- const cached = await cache.match(request);
315
- if (!cached) {
316
- return new Response(
317
- `the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`,
318
- { status: 555 }
319
- );
320
- }
321
- log(`serving ${handoffURL} from cache ${cachename} after handoff`);
322
- return cached;
323
- } else {
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 "").
339
- if (
340
- (response.status === 0 ||
341
- cacheableStatuses.includes(response.status)) &&
342
- /^https?:/.test(request.url)
343
- ) {
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
- });
353
- } else {
354
- log(
355
- `skipping uncacheable response code from cache: ${response.status} for ${request.url}`
356
- );
357
- }
358
- return response;
359
- }
360
- if (match) return match;
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
- );
367
- }
368
- } catch (error) {
369
- const message =
370
- error instanceof Error
371
- ? `${error.message}\n\n${error.stack}`
372
- : String(error);
373
- console.error(
374
- `service worker error resolving ${request.url}${handoffURL ? ` (for: ${handoffURL})` : ""}`,
375
- error
376
- );
377
- if (match) return match;
378
-
379
- return new Response(message, {
380
- status: 556,
381
- headers: { "content-type": "text/plain" },
382
- });
383
- }
384
- })()
319
+ const stored = await cache.match(fetchEvent.request);
320
+ if (!stored) {
321
+ return new Response(
322
+ `the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`,
323
+ { status: 555 }
324
+ );
325
+ }
326
+ log(`serving ${handoffURL} from cache ${cachename} after handoff`);
327
+ return stored;
328
+ }
329
+
330
+ async function servePassthrough(
331
+ fetchEvent: FetchEvent,
332
+ cache: Cache,
333
+ cached: Response | undefined
334
+ ): Promise<Response> {
335
+ const request = fetchEvent.request;
336
+ // fetch() rejects on network error rather than resolving, so keep the error
337
+ // to surface in the 503 body below.
338
+ const result = await fetch(request).catch((error: unknown) =>
339
+ error instanceof Error ? error : new Error(String(error))
340
+ );
341
+
342
+ if (result instanceof Response) {
343
+ if (
344
+ CACHEABLE_STATUSES.includes(result.status) &&
345
+ /^https?:/.test(request.url)
346
+ ) {
347
+ cacheInBackground(fetchEvent, cache, request, result.clone());
348
+ } else {
349
+ log(`not caching status ${result.status} for ${request.url}`);
350
+ }
351
+ return result;
352
+ }
353
+
354
+ if (cached) return cached;
355
+ return new Response(
356
+ `couldnt fetch ${request.url} and no stale copy in ${cachename}\n\n${
357
+ result.stack ?? result.message
358
+ }`,
359
+ { status: 503, headers: { "content-type": "text/plain" } }
385
360
  );
361
+ }
362
+
363
+ async function respond(
364
+ fetchEvent: FetchEvent,
365
+ handoffURL: URL | undefined
366
+ ): Promise<Response> {
367
+ const cache = await caches.open(cachename);
368
+ const cached = await cache.match(fetchEvent.request);
369
+
370
+ try {
371
+ return handoffURL
372
+ ? await serveHandoff(fetchEvent, cache, handoffURL, cached)
373
+ : await servePassthrough(fetchEvent, cache, cached);
374
+ } catch (error) {
375
+ // Deliberate: fail the request as a network error, with no response.
376
+ if (error instanceof HandoffAborted) throw error;
377
+
378
+ console.error(
379
+ `service worker error resolving ${fetchEvent.request.url}` +
380
+ (handoffURL ? ` (for: ${handoffURL})` : ""),
381
+ error
382
+ );
383
+ if (cached) return cached;
384
+ return new Response(
385
+ error instanceof Error
386
+ ? `${error.message}\n\n${error.stack}`
387
+ : String(error),
388
+ { status: 556, headers: { "content-type": "text/plain" } }
389
+ );
390
+ }
391
+ }
392
+
393
+ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
394
+ const request = fetchEvent.request;
395
+ log("fetch event", request.url);
396
+ if (request.method !== "GET") return;
397
+ fetchEvent.respondWith(respond(fetchEvent, specialURLFor(request)));
386
398
  });