@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.
@@ -2,11 +2,6 @@ type Descriptor = Record<string, unknown> & {
2
2
  id?: string;
3
3
  type?: string;
4
4
  };
5
- /**
6
- * ModuleWatcher `importAutomergePackage` 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
5
  export declare function importAutomergePackageViaWorker(urlAtHeads: string): Promise<{
11
6
  plugins: Descriptor[];
12
7
  }>;
@@ -41,7 +41,6 @@ function getWorker() {
41
41
  });
42
42
  return worker;
43
43
  }
44
- /** Ask the worker which plugins the package at `urlAtHeads` exports. */
45
44
  function discoverDescriptors(urlAtHeads) {
46
45
  const id = nextRequestId++;
47
46
  return new Promise((resolve, reject) => {
@@ -49,11 +48,6 @@ function discoverDescriptors(urlAtHeads) {
49
48
  getWorker().postMessage({ type: "discover", id, url: urlAtHeads });
50
49
  });
51
50
  }
52
- /**
53
- * ModuleWatcher `importAutomergePackage` 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
51
  export async function importAutomergePackageViaWorker(urlAtHeads) {
58
52
  const url = urlAtHeads;
59
53
  const descriptors = await discoverDescriptors(url);
@@ -1,29 +1,20 @@
1
1
  /// <reference types="service-worker-types" />
2
- // The service worker holds no automerge repo — that lives in the automerge
3
- // SharedWorker (automerge-worker.ts). This worker manages the cache. When a
4
- // special URL misses the cache it broadcasts a handoff request; the
5
- // automerge worker resolves it, puts the response in our cache, and replies
6
- // "cached" (or "response" for errors and other things that shouldn't be
7
- // cached).
8
2
  import { HANDOFF_CHANNEL, } from "./types.js";
9
3
  const DEFAULT_CACHE_NAME = "patchwork";
10
4
  let cachename = DEFAULT_CACHE_NAME;
11
5
  let debugging = false;
12
- // 0 is an opaque response, that also needs cached
13
- const cacheableStatuses = [0, 200, 203, 204];
14
- // The automerge worker times its own resolution out after 30s and replies
15
- // with an error, so this only fires when nobody is listening at all.
6
+ // 0 is an opaque cross-origin response, which is cacheable and replays to the
7
+ // same no-cors consumer. these are big, so unfortunate.
8
+ const CACHEABLE_STATUSES = [0, 200, 203, 204];
9
+ // The automerge worker times its own resolution out after 30s and replies with
10
+ // an error, so this only fires when nobody is listening at all.
16
11
  const HANDOFF_TIMEOUT_MS = 35_000;
17
12
  function log(...args) {
18
- if (!debugging)
19
- return;
20
- console.log.call(console, `%cpatchwork:serviceworker%c\n`, `color: #00ffcc; font-weight: bold`, "color: inherit", ...args);
13
+ if (debugging)
14
+ console.log("[service-worker]", ...args);
21
15
  }
22
- // ── Lifecycle diagnostics ──────────────────────────────────────────────
23
- // [lifecycle] markers for SW (re)boots, install/activate, crashes, and stranded
24
- // handoffs. The SW can't read localStorage, so it always emits and forwards to
25
- // the tab, which gates rendering on the live toggle. The SW holds no sync
26
- // socket — observability only.
16
+ // A service worker has no localStorage and so can't read the debug config: it
17
+ // always emits these and forwards them to the tab, which does the filtering.
27
18
  async function postToClients(message) {
28
19
  const clients = await self.clients.matchAll({
29
20
  type: "window",
@@ -33,9 +24,9 @@ async function postToClients(message) {
33
24
  client.postMessage(message);
34
25
  }
35
26
  function lifecycle(level, text) {
36
- const msg = `[lifecycle] ${new Date().toISOString()} ${text}`;
27
+ const msg = `${new Date().toISOString()} ${text}`;
37
28
  console[level](msg);
38
- void postToClients({ type: "sw-lifecycle", level, msg });
29
+ postToClients({ type: "sw-lifecycle", level, msg });
39
30
  }
40
31
  lifecycle("info", `booted (scope ${self.registration?.scope ?? "?"})`);
41
32
  self.addEventListener("error", (event) => {
@@ -47,95 +38,95 @@ self.addEventListener("unhandledrejection", (event) => {
47
38
  const reason = event.reason;
48
39
  lifecycle("warn", `unhandled rejection: ${reason instanceof Error ? reason.stack || reason.message : String(reason)}`);
49
40
  });
41
+ // ── Lifecycle ──────────────────────────────────────────────────────────
50
42
  self.addEventListener("install", (event) => {
51
43
  lifecycle("info", "install (skipWaiting)");
52
44
  // waitUntil keeps the worker alive until skipWaiting resolves, so a freshly
53
- // installed SW reliably jumps the "waiting" queue instead of stalling until
45
+ // installed worker reliably jumps the waiting queue instead of stalling until
54
46
  // every old tab closes.
55
47
  event.waitUntil(self.skipWaiting());
56
48
  });
57
49
  async function clearOtherCaches() {
58
- await Promise.all((await caches.keys()).map((cacheName) => {
59
- if (cacheName !== cachename)
60
- return caches.delete(cacheName);
61
- }));
50
+ const names = await caches.keys();
51
+ await Promise.all(names
52
+ .filter((name) => name !== cachename)
53
+ .map((name) => caches.delete(name)));
62
54
  }
63
55
  self.addEventListener("activate", (event) => {
64
56
  lifecycle("info", "activate (claiming clients)");
65
57
  event.waitUntil((async () => {
66
58
  await clearOtherCaches();
67
59
  await self.clients.claim();
68
- // Pre-cache pages of already-open clients so they survive going offline
69
- // before the next navigation.
70
- const allClients = await self.clients.matchAll({ type: "window" });
60
+ // Pre-cache the pages of already-open clients so they survive going
61
+ // offline before the next navigation.
62
+ const clients = await self.clients.matchAll({ type: "window" });
71
63
  const cache = await caches.open(cachename);
72
- await Promise.all(allClients.map(async (client) => {
64
+ await Promise.all(clients.map(async (client) => {
73
65
  try {
74
- const existing = await cache.match(client.url);
75
- if (!existing) {
76
- const response = await fetch(client.url);
77
- if (cacheableStatuses.includes(response.status)) {
78
- await cachePage(cache, client.url, response);
79
- }
66
+ if (await cache.match(client.url))
67
+ return;
68
+ const response = await fetch(client.url);
69
+ if (CACHEABLE_STATUSES.includes(response.status)) {
70
+ await cachePage(cache, client.url, response);
80
71
  }
81
72
  }
82
73
  catch {
83
- // Network may be unavailable during activation
74
+ // Network may be unavailable during activation.
84
75
  }
85
76
  }));
86
77
  })());
87
78
  });
88
79
  self.addEventListener("message", async (event) => {
89
- if (event.data.type == "cachename") {
90
- const nextCachename = event.data.cachename;
91
- if (cachename == nextCachename) {
92
- return;
93
- }
94
- console.info(`moving from cache ${cachename} to ${nextCachename}`);
95
- if (cachename === DEFAULT_CACHE_NAME) {
96
- const defaultCache = await caches.open(cachename);
97
- const nextCache = await caches.open(nextCachename);
98
- await Promise.all((await defaultCache.keys()).map(async (request) => {
99
- const response = await defaultCache.match(request);
100
- if (response)
101
- await nextCache.put(request, response);
102
- }));
103
- }
104
- cachename = nextCachename;
105
- await clearOtherCaches();
106
- }
107
- else if (event.data.type == "debug") {
108
- debugging = event.data.debug;
80
+ const data = event.data;
81
+ if (data?.type === "debug") {
82
+ debugging = data.debug;
109
83
  log("serviceworker debugging enabled");
84
+ return;
110
85
  }
86
+ if (data?.type !== "cachename" || cachename === data.cachename)
87
+ return;
88
+ console.info(`moving from cache ${cachename} to ${data.cachename}`);
89
+ const previous = cachename;
90
+ // Switch before copying: fetches landing mid-copy must write into the new
91
+ // cache, or their entries get deleted along with the old one below.
92
+ cachename = data.cachename;
93
+ if (previous === DEFAULT_CACHE_NAME) {
94
+ const from = await caches.open(previous);
95
+ const to = await caches.open(cachename);
96
+ await Promise.all((await from.keys()).map(async (request) => {
97
+ const response = await from.match(request);
98
+ if (response)
99
+ await to.put(request, response);
100
+ }));
101
+ }
102
+ await clearOtherCaches();
111
103
  });
112
- // ── Handoff to the automerge worker ────────────────────────────────────
113
104
  const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
114
105
  const pendingHandoffs = new Map();
115
106
  handoffChannel.addEventListener("message", (event) => {
116
107
  const data = event.data;
117
108
  if (data?.type === "cached" || data?.type === "response") {
118
109
  const pending = pendingHandoffs.get(data.id);
119
- if (!pending) {
110
+ if (!pending)
120
111
  return log(`no pending handoff for id ${data.id}`);
121
- }
122
112
  pending.resolvers.resolve(data);
113
+ return;
123
114
  }
124
- else if (data?.type === "online") {
125
- // The automerge worker (re)started — re-broadcast anything still in
126
- // flight so requests that raced its boot aren't stranded.
127
- const stranded = [...pendingHandoffs.values()];
128
- if (stranded.length > 0) {
129
- lifecycle("info", `automerge worker (re)started; re-broadcasting ${stranded.length} ` +
130
- `in-flight asset handoff(s)`);
131
- }
132
- for (const { message } of stranded) {
133
- log(`re-broadcasting handoff ${message.id} to the fresh worker`);
134
- handoffChannel.postMessage(message);
135
- }
136
- }
115
+ if (data?.type !== "online")
116
+ return;
117
+ // The automerge worker (re)started re-broadcast anything still in flight so
118
+ // requests that raced its boot aren't stranded.
119
+ const stranded = [...pendingHandoffs.values()];
120
+ if (stranded.length === 0)
121
+ return;
122
+ lifecycle("info", `automerge worker (re)started; re-broadcasting ${stranded.length} in-flight asset handoff(s)`);
123
+ for (const { message } of stranded)
124
+ handoffChannel.postMessage(message);
137
125
  });
138
- function handoff(request, handoffURL) {
126
+ /** Signals that respondWith should reject; see {@link HandoffAbortMessage}. */
127
+ class HandoffAborted extends Error {
128
+ }
129
+ async function handoff(request, handoffURL) {
139
130
  const id = crypto.randomUUID();
140
131
  const resolvers = Promise.withResolvers();
141
132
  const message = {
@@ -155,8 +146,7 @@ function handoff(request, handoffURL) {
155
146
  log(`broadcasting handoff request for cache ${cachename}`, message);
156
147
  handoffChannel.postMessage(message);
157
148
  const timeout = setTimeout(() => {
158
- lifecycle("warn", `asset handoff ${id} stranded: no reply from the automerge worker after ` +
159
- `${HANDOFF_TIMEOUT_MS}ms (${handoffURL.href})`);
149
+ lifecycle("warn", `asset handoff ${id} stranded: no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms (${handoffURL.href})`);
160
150
  resolvers.reject(new Error(`no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms`));
161
151
  }, HANDOFF_TIMEOUT_MS);
162
152
  return resolvers.promise.finally(() => {
@@ -164,129 +154,130 @@ function handoff(request, handoffURL) {
164
154
  pendingHandoffs.delete(id);
165
155
  });
166
156
  }
167
- function makeResponse(response) {
168
- return new Response(response.body ?? null, {
169
- status: response.status ?? 200,
170
- headers: response.headers,
171
- });
172
- }
173
- function indexRequestFor(request) {
174
- const url = new URL(typeof request === "string" ? request : request.url);
157
+ // ── Caching ────────────────────────────────────────────────────────────
158
+ /** A page is cached under its own url plus /index.html and / on this origin. */
159
+ function pageCacheKeys(request) {
160
+ const original = typeof request === "string" ? new Request(request) : request;
161
+ const url = new URL(original.url);
175
162
  if (url.origin !== self.location.origin)
176
- return undefined;
177
- url.pathname = "/index.html";
178
- url.search = "";
179
- url.hash = "";
180
- return new Request(url.href);
163
+ return [original];
164
+ return [
165
+ original,
166
+ ...["/index.html", "/"].map((pathname) => {
167
+ const variant = new URL(url.href);
168
+ variant.pathname = pathname;
169
+ variant.search = "";
170
+ variant.hash = "";
171
+ return new Request(variant.href);
172
+ }),
173
+ ];
181
174
  }
182
- function rootRequestFor(request) {
183
- const url = new URL(typeof request === "string" ? request : request.url);
184
- if (url.origin !== self.location.origin)
175
+ async function cachePage(cache, request, response) {
176
+ const keys = pageCacheKeys(request);
177
+ await Promise.all(keys.map((key, i) => cache.put(key, i === keys.length - 1 ? response : response.clone())));
178
+ }
179
+ // cache.put only resolves once the whole body has been consumed and persisted,
180
+ // so awaiting it before returning would turn time-to-first-byte into
181
+ // time-to-last-byte-plus-disk for every proxied asset. waitUntil keeps the
182
+ // worker alive for the write.
183
+ function cacheInBackground(fetchEvent, cache, request, response) {
184
+ const isPage = request.mode === "navigate" || request.destination === "document";
185
+ fetchEvent.waitUntil((isPage
186
+ ? cachePage(cache, request, response)
187
+ : cache.put(request, response)).catch((error) => {
188
+ // Always loud: a QuotaExceededError here is the first sign the origin is
189
+ // under storage pressure.
190
+ console.warn(`error caching ${request.url} in ${cachename}`, error);
191
+ }));
192
+ }
193
+ /** A same-origin path that is itself an encoded URL, e.g. /automerge%3Aabc/x. */
194
+ function specialURLFor(request) {
195
+ const url = new URL(request.url);
196
+ if (url.hostname !== self.location.hostname ||
197
+ url.port !== self.location.port ||
198
+ url.protocol !== self.location.protocol) {
185
199
  return undefined;
186
- url.pathname = "/";
187
- url.search = "";
188
- url.hash = "";
189
- return new Request(url.href);
200
+ }
201
+ try {
202
+ return new URL(decodeURIComponent(url.pathname.slice(1)));
203
+ }
204
+ catch {
205
+ return undefined;
206
+ }
190
207
  }
191
- async function cachePage(cache, request, response) {
192
- const indexRequest = indexRequestFor(request);
193
- if (indexRequest)
194
- await cache.put(indexRequest, response.clone());
195
- const rootRequest = rootRequestFor(request);
196
- if (rootRequest)
197
- await cache.put(rootRequest, response.clone());
198
- await cache.put(request, response);
208
+ async function serveHandoff(fetchEvent, cache, handoffURL, cached) {
209
+ if (cached) {
210
+ log(`serving ${handoffURL} from cache ${cachename}`);
211
+ return cached;
212
+ }
213
+ log(`handing ${handoffURL} off to the automerge worker`);
214
+ const replyPromise = handoff(fetchEvent.request, handoffURL);
215
+ fetchEvent.waitUntil(replyPromise.catch(() => { }));
216
+ const reply = await replyPromise;
217
+ if (reply.type === "abort") {
218
+ // Rejecting respondWith gives the caller a network error rather than a
219
+ // response it can memoize.
220
+ log(`aborting ${handoffURL}: ${reply.reason}`);
221
+ throw new HandoffAborted(reply.reason);
222
+ }
223
+ if (reply.type === "response") {
224
+ log(`serving handed-off response for ${handoffURL}`, reply);
225
+ return new Response(reply.response.body ?? null, {
226
+ status: reply.response.status ?? 200,
227
+ headers: reply.response.headers,
228
+ });
229
+ }
230
+ const stored = await cache.match(fetchEvent.request);
231
+ if (!stored) {
232
+ return new Response(`the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`, { status: 555 });
233
+ }
234
+ log(`serving ${handoffURL} from cache ${cachename} after handoff`);
235
+ return stored;
199
236
  }
200
- // ── Fetch handler ──────────────────────────────────────────────────────
201
- self.addEventListener("fetch", (fetchEvent) => {
202
- log("fetch event", fetchEvent.request.url);
237
+ async function servePassthrough(fetchEvent, cache, cached) {
203
238
  const request = fetchEvent.request;
204
- if (request.method !== "GET")
205
- return fetchEvent.respondWith(fetch(request));
206
- const url = new URL(fetchEvent.request.url);
207
- let handoffURL;
208
- if (url.hostname == self.location.hostname &&
209
- url.port == self.location.port &&
210
- url.protocol == self.location.protocol) {
211
- try {
212
- handoffURL = new URL(decodeURIComponent(url.pathname.slice(1)));
213
- log(`received special request ${handoffURL}`);
214
- }
215
- catch { }
216
- }
217
- fetchEvent.respondWith((async () => {
218
- const cache = await caches.open(cachename);
219
- const match = await cache.match(request);
220
- try {
221
- if (handoffURL) {
222
- if (match) {
223
- log(`serving ${handoffURL} from cache ${cachename}`);
224
- return match;
225
- }
226
- log(`handing ${handoffURL} off to the automerge worker`);
227
- const replyPromise = handoff(request, handoffURL);
228
- fetchEvent.waitUntil(replyPromise.catch(() => { }));
229
- const reply = await replyPromise;
230
- if (reply.type === "response") {
231
- // errors, redirects and other things that shouldn't be cached
232
- log(`serving handed-off response for ${handoffURL}`, reply);
233
- return makeResponse(reply.response);
234
- }
235
- // reply.type === "cached": the automerge worker has put the
236
- // response in our cache
237
- const cached = await cache.match(request);
238
- if (!cached) {
239
- return new Response(`the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`, { status: 555 });
240
- }
241
- log(`serving ${handoffURL} from cache ${cachename} after handoff`);
242
- return cached;
243
- }
244
- else {
245
- // fetch() rejects on network error / abort rather than resolving;
246
- // keep the error so we can surface it in the 503 body below.
247
- const result = await fetch(request).catch((error) => error instanceof Error ? error : new Error(String(error)));
248
- if (result instanceof Response) {
249
- const response = result;
250
- // Tool subresources (<link>/<script>) are requested from srcdoc
251
- // frames whose origin is "null", so they come back as opaque
252
- // cross-origin `no-cors` responses: status 0 and an empty url. They
253
- // render fine while online but were being excluded from the cache,
254
- // so e.g. a theme stylesheet vanished on an offline refresh. Opaque
255
- // responses are cacheable and replay to the same no-cors consumer,
256
- // so treat status 0 as cacheable and gate the scheme on request.url
257
- // (an opaque response's own url is "").
258
- if ((response.status === 0 ||
259
- cacheableStatuses.includes(response.status)) &&
260
- /^https?:/.test(request.url)) {
261
- const cachedResponse = response.clone();
262
- await (request.mode === "navigate" ||
263
- request.destination === "document"
264
- ? cachePage(cache, request, cachedResponse)
265
- : cache.put(request, cachedResponse)).catch((error) => {
266
- log(`error caching ${request.url} in ${cachename}`, error);
267
- });
268
- }
269
- else {
270
- log(`skipping uncacheable response code from cache: ${response.status} for ${request.url}`);
271
- }
272
- return response;
273
- }
274
- if (match)
275
- return match;
276
- return new Response(`couldnt fetch ${request.url} and no stale copy in ${cachename}\n\n${result.stack ?? result.message}`, { status: 503, headers: { "content-type": "text/plain" } });
277
- }
239
+ // fetch() rejects on network error rather than resolving, so keep the error
240
+ // to surface in the 503 body below.
241
+ const result = await fetch(request).catch((error) => error instanceof Error ? error : new Error(String(error)));
242
+ if (result instanceof Response) {
243
+ if (CACHEABLE_STATUSES.includes(result.status) &&
244
+ /^https?:/.test(request.url)) {
245
+ cacheInBackground(fetchEvent, cache, request, result.clone());
278
246
  }
279
- catch (error) {
280
- const message = error instanceof Error
281
- ? `${error.message}\n\n${error.stack}`
282
- : String(error);
283
- console.error(`service worker error resolving ${request.url}${handoffURL ? ` (for: ${handoffURL})` : ""}`, error);
284
- if (match)
285
- return match;
286
- return new Response(message, {
287
- status: 556,
288
- headers: { "content-type": "text/plain" },
289
- });
247
+ else {
248
+ log(`not caching status ${result.status} for ${request.url}`);
290
249
  }
291
- })());
250
+ return result;
251
+ }
252
+ if (cached)
253
+ return cached;
254
+ return new Response(`couldnt fetch ${request.url} and no stale copy in ${cachename}\n\n${result.stack ?? result.message}`, { status: 503, headers: { "content-type": "text/plain" } });
255
+ }
256
+ async function respond(fetchEvent, handoffURL) {
257
+ const cache = await caches.open(cachename);
258
+ const cached = await cache.match(fetchEvent.request);
259
+ try {
260
+ return handoffURL
261
+ ? await serveHandoff(fetchEvent, cache, handoffURL, cached)
262
+ : await servePassthrough(fetchEvent, cache, cached);
263
+ }
264
+ catch (error) {
265
+ // Deliberate: fail the request as a network error, with no response.
266
+ if (error instanceof HandoffAborted)
267
+ throw error;
268
+ console.error(`service worker error resolving ${fetchEvent.request.url}` +
269
+ (handoffURL ? ` (for: ${handoffURL})` : ""), error);
270
+ if (cached)
271
+ return cached;
272
+ return new Response(error instanceof Error
273
+ ? `${error.message}\n\n${error.stack}`
274
+ : String(error), { status: 556, headers: { "content-type": "text/plain" } });
275
+ }
276
+ }
277
+ self.addEventListener("fetch", (fetchEvent) => {
278
+ const request = fetchEvent.request;
279
+ log("fetch event", request.url);
280
+ if (request.method !== "GET")
281
+ return;
282
+ fetchEvent.respondWith(respond(fetchEvent, specialURLFor(request)));
292
283
  });
package/dist/setup.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { SetupServiceWorkerOptions, SetupServiceWorkerResult, SyncStateDocMessage } from "./types.js";
2
- export declare function lifecycleLoggingEnabled(): boolean;
2
+ import debug from "debug";
3
+ export declare const lifecycleLog: debug.Debugger;
3
4
  export declare function bumpServiceWorkerCache(sw?: ServiceWorker | null): void;
4
5
  export declare function getAutomergeWorker(): SharedWorker;
5
6
  type SyncStateListener = (update: SyncStateDocMessage) => void;