@inkandswitch/patchwork-bootloader 0.3.1 → 0.4.0

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.
@@ -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 =
package/src/setup.ts CHANGED
@@ -2,12 +2,17 @@ import type {
2
2
  ServiceWorkerRepoChannelListener,
3
3
  SetupServiceWorkerOptions,
4
4
  SetupServiceWorkerResult,
5
+ SyncStateDocMessage,
5
6
  } from "./types.js";
6
7
  import {
7
8
  readClassicSyncServer,
8
9
  DEFAULT_CLASSIC_SYNC_SERVER,
9
10
  } from "./sync-config.js";
10
11
  import debug from "debug";
12
+ import {
13
+ donatePort,
14
+ isWorkerErrorMessage,
15
+ } from "@automerge/automerge-repo/worker-port";
11
16
 
12
17
  const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
13
18
  const workerDebugging = debug.enabled("patchwork:automergeworker");
@@ -41,6 +46,7 @@ function installServiceWorkerLogForwarding(): void {
41
46
  }
42
47
 
43
48
  const key = "patchworkServiceWorkerCacheVersion";
49
+ const defaultServiceWorkerCacheName = "patchwork";
44
50
  let nextRepoChannelId = 0;
45
51
 
46
52
  function bumpServiceWorkerCacheVersion() {
@@ -53,19 +59,13 @@ function getServiceWorkerCacheVersion() {
53
59
  return localStorage.getItem(key);
54
60
  }
55
61
 
56
- function getOrCreateServiceWorkerCacheVersion() {
57
- const existing = getServiceWorkerCacheVersion();
58
- if (existing) return existing;
59
- return bumpServiceWorkerCacheVersion();
60
- }
61
-
62
62
  function setServiceWorkerCacheName(sw: ServiceWorker | null) {
63
63
  if (!sw) {
64
64
  throw new Error("no service worker!");
65
65
  }
66
66
  sw.postMessage({
67
67
  type: "cachename",
68
- cachename: getOrCreateServiceWorkerCacheVersion(),
68
+ cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
69
69
  });
70
70
  }
71
71
 
@@ -81,8 +81,10 @@ export function bumpServiceWorkerCache(
81
81
  function configureServiceWorker(sw: ServiceWorker | null) {
82
82
  if (!sw) return;
83
83
  sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
84
- const cachename = getServiceWorkerCacheVersion();
85
- if (cachename) sw.postMessage({ type: "cachename", cachename });
84
+ sw.postMessage({
85
+ type: "cachename",
86
+ cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
87
+ });
86
88
  }
87
89
 
88
90
  // ── The automerge worker ───────────────────────────────────────────────
@@ -95,9 +97,42 @@ function configureServiceWorker(sw: ServiceWorker | null) {
95
97
  let automergeWorkerPath = "/automerge-worker.js";
96
98
  let automergeWorker: SharedWorker | undefined;
97
99
 
100
+ // SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
101
+ // spawn workers from inside a SharedWorker, so each tab offers this proxy's
102
+ // port to the automerge worker (which requests one via its port provider).
103
+ // Being a SharedWorker itself, the proxy — and the donated worker↔worker
104
+ // port — outlives the donor tab. Emitted at /packages/... via externals.ts.
105
+ const SUBDUCTION_IO_WORKER_URL =
106
+ "/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
107
+
108
+ // Bench toggles for the subduction socket (see getSubductionEndpoints in
109
+ // automerge-worker.ts), passed as query params because SharedWorker scope
110
+ // has no localStorage — which also gives each configuration its own worker
111
+ // instance, so bench arms can't share state.
112
+ // localStorage["patchwork:ws-mode"] = "inline" → socket on worker thread
113
+ // localStorage["patchwork:ws-window"] = "16" → WorkerWebSocketEndpoint
114
+ // windowFrames override
115
+ function workerBenchParams(): string {
116
+ const params = new URLSearchParams();
117
+ try {
118
+ for (const [key, param] of [
119
+ ["patchwork:ws-mode", "ws-mode"],
120
+ ["patchwork:ws-window", "ws-window"],
121
+ ] as const) {
122
+ const value = globalThis.localStorage?.getItem(key);
123
+ if (value) params.set(param, value);
124
+ }
125
+ } catch {
126
+ // No localStorage (shouldn't happen in a tab) — use defaults.
127
+ }
128
+ const qs = params.toString();
129
+ return qs ? `?${qs}` : "";
130
+ }
131
+
98
132
  export function getAutomergeWorker(): SharedWorker {
99
133
  if (!automergeWorker) {
100
- automergeWorker = new SharedWorker(automergeWorkerPath, {
134
+ const workerUrl = `${automergeWorkerPath}${workerBenchParams()}`;
135
+ automergeWorker = new SharedWorker(workerUrl, {
101
136
  name: "patchwork-automerge",
102
137
  type: "module",
103
138
  });
@@ -107,6 +142,25 @@ export function getAutomergeWorker(): SharedWorker {
107
142
  // Surface the SharedWorker's console output and uncaught errors in this
108
143
  // tab's console (it has its own console that's awkward to find otherwise).
109
144
  automergeWorker.port.addEventListener("message", (event: MessageEvent) => {
145
+ if (event.data?.type === "sync-state") {
146
+ dispatchSyncState(event.data as SyncStateDocMessage);
147
+ return;
148
+ }
149
+ if (isWorkerErrorMessage(event.data)) {
150
+ // Crash/skew reports relayed from the subduction io proxy (e.g.
151
+ // protocol-mismatch from a stale SW-cached worker chunk). Surface
152
+ // loudly — these otherwise only exist in chrome://inspect.
153
+ console.error("[subduction-io]", event.data);
154
+ return;
155
+ }
156
+ if (event.data?.type === "drift-samples") {
157
+ // Keepalive-drift samples from the worker's bench probe. Kept on a
158
+ // bounded window global for the Playwright bench to harvest.
159
+ const sink = ((window as any).__driftSamples ??= []) as number[];
160
+ sink.push(...event.data.samples);
161
+ if (sink.length > 10_000) sink.splice(0, sink.length - 10_000);
162
+ return;
163
+ }
110
164
  if (event.data?.type !== "console") return;
111
165
  const { level, args } = event.data;
112
166
  // Gate forwarded [lifecycle] logs on the toggle too.
@@ -130,6 +184,16 @@ export function getAutomergeWorker(): SharedWorker {
130
184
  });
131
185
  automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
132
186
 
187
+ // Offer the subduction io proxy's port; the worker's port provider pulls
188
+ // it when (re)constructing its WorkerWebSocketEndpoint.
189
+ donatePort(automergeWorker.port, () => {
190
+ const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
191
+ type: "module",
192
+ name: "subduction-websocket",
193
+ });
194
+ return io.port;
195
+ });
196
+
133
197
  installWorkerDeathDetection(automergeWorker);
134
198
  }
135
199
  return automergeWorker;
@@ -205,6 +269,53 @@ function installWorkerDeathDetection(worker: SharedWorker): void {
205
269
  }, HEARTBEAT_MS);
206
270
  }
207
271
 
272
+ // ── Sync-state subscriptions ────────────────────────────────────────────
273
+ // The automerge worker pushes per-document heads only to the tabs that ask for
274
+ // them (see SyncStateDocMessage). We ref-count locally so several callers in
275
+ // this tab can watch the same doc with a single worker subscription, and tear
276
+ // the worker subscription down when the last local watcher drops.
277
+ type SyncStateListener = (update: SyncStateDocMessage) => void;
278
+ const syncStateListeners = new Map<string, Set<SyncStateListener>>();
279
+
280
+ function dispatchSyncState(update: SyncStateDocMessage): void {
281
+ const listeners = syncStateListeners.get(update.documentId);
282
+ if (!listeners) return;
283
+ for (const listener of listeners) {
284
+ try {
285
+ listener(update);
286
+ } catch (err) {
287
+ console.error("sync-state listener threw", err);
288
+ }
289
+ }
290
+ }
291
+
292
+ export function subscribeSyncState(
293
+ documentId: string,
294
+ listener: SyncStateListener
295
+ ): () => void {
296
+ const worker = getAutomergeWorker();
297
+ let listeners = syncStateListeners.get(documentId);
298
+ if (!listeners) {
299
+ syncStateListeners.set(documentId, (listeners = new Set()));
300
+ // First local watcher for this doc — ask the worker to start pushing it.
301
+ worker.port.postMessage({ type: "sync-sub", documentId });
302
+ }
303
+ listeners.add(listener);
304
+
305
+ let active = true;
306
+ return () => {
307
+ if (!active) return; // idempotent
308
+ active = false;
309
+ const set = syncStateListeners.get(documentId);
310
+ if (!set) return;
311
+ set.delete(listener);
312
+ if (set.size === 0) {
313
+ syncStateListeners.delete(documentId);
314
+ worker.port.postMessage({ type: "sync-unsub", documentId });
315
+ }
316
+ };
317
+ }
318
+
208
319
  export function connectClassicSync(
209
320
  server: string = readClassicSyncServer()
210
321
  ): Promise<void> {
@@ -311,6 +422,7 @@ export default async function setupServiceWorker(
311
422
  // Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
312
423
  // install / activate markers from the controlling worker are rendered here.
313
424
  installServiceWorkerLogForwarding();
425
+ localStorage.removeItem(key);
314
426
 
315
427
  if (options?.workerPath) automergeWorkerPath = options.workerPath;
316
428
 
@@ -367,6 +479,7 @@ export default async function setupServiceWorker(
367
479
  shared,
368
480
  connectClassicSync,
369
481
  getRepoChannel,
482
+ subscribeSyncState,
370
483
  async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
371
484
  // The automerge worker outlives the page, so unlike the old in-service-
372
485
  // worker repo there's nothing to reconnect: one port, handed over once.