@inkandswitch/patchwork-bootloader 0.2.6 → 0.2.7

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.
@@ -1,82 +1,27 @@
1
1
  /// <reference types="service-worker-types" />
2
2
 
3
- import { SwLogger } from "./sw-logger.js";
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).
4
9
 
5
- // Heavy imports — marked external by the service-worker vite plugin,
6
- // resolved to /packages/... URLs at build time. The SW is registered with
7
- // type:"module" so the browser fetches these as regular network requests.
8
- // Uses /slim to avoid top-level await (disallowed in service workers).
9
- // Wasm is fetched from /automerge.wasm (emitted by the vite plugin) instead
10
- // of bundling the ~3MB base64 string.
11
- import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
12
- // eslint-disable-next-line
13
- // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
14
- import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
15
- import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
16
-
17
- import {
18
- Repo,
19
- isValidAutomergeUrl,
20
- parseAutomergeUrl,
21
- stringifyAutomergeUrl,
22
- type AutomergeUrl,
23
- } from "@automerge/automerge-repo/slim";
24
- import { resolvePath } from "@inkandswitch/patchwork-filesystem";
25
-
26
- // Small adapters — bundled directly into the SW
27
- import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
28
- import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
29
- import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
30
10
  import {
31
- initializeAutomergeRepoKeyhiveRust,
32
- initKeyhiveWasm,
33
- type AutomergeRepoKeyhiveRust,
34
- } from "@automerge/automerge-repo-keyhive";
35
-
36
- declare const __SITE_NAME__: string;
37
- declare const __KEYHIVE__: boolean;
38
-
39
- // TEMPORARY: enable debug npm module in SW context (no localStorage available)
11
+ HANDOFF_CHANNEL,
12
+ type HandoffReplyMessage,
13
+ type HandoffRequestMessage,
14
+ } from "./types.js";
40
15
 
41
16
  let cachename = "default";
42
17
  let debugging = false;
43
- const workerInstanceId = crypto.randomUUID();
44
-
45
- const SUBDUCTION_ENDPOINTS = ["wss://subduction.sync.inkandswitch.com"];
46
- const RESOLVE_TIMEOUT_MS = 30_000;
47
-
48
- // ── Persistent logger ───────────────────────────────────────────────────
49
- // Initialized eagerly so it's available for the entire SW lifetime.
50
- // Access from the SW inspector console via self.printLogs(), self.tailLogs(),
51
- // self.exportLogs(), self.clearLogs().
52
- const slog = SwLogger.open().then((logger) => {
53
- (self as any).slog = logger;
54
-
55
- (self as any).printLogs = async (n = 200) => {
56
- const entries = await logger.tail(n);
57
- for (const e of entries) {
58
- const prefix = `[${e.ts}] [${e.level}]`;
59
- if (e.data !== undefined) {
60
- console.log(prefix, e.msg, e.data);
61
- } else {
62
- console.log(prefix, e.msg);
63
- }
64
- }
65
- console.log(`--- ${entries.length} entries ---`);
66
- };
67
-
68
- (self as any).tailLogs = (n = 200) => logger.tail(n);
69
- (self as any).exportLogs = () => logger.exportAll();
70
- (self as any).clearLogs = () => logger.clear();
71
-
72
- logger.info("sw-logger initialized");
73
- return logger;
74
- });
75
-
76
- const siteName = typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
77
18
 
78
19
  const cacheableStatuses = [200, 203, 204, 206];
79
20
 
21
+ // The automerge worker times its own resolution out after 30s and replies
22
+ // with an error, so this only fires when nobody is listening at all.
23
+ const HANDOFF_TIMEOUT_MS = 35_000;
24
+
80
25
  function log(...args: any[]) {
81
26
  if (!debugging) return;
82
27
  console.log.call(
@@ -88,7 +33,12 @@ function log(...args: any[]) {
88
33
  );
89
34
  }
90
35
 
91
- self.addEventListener("install", () => self.skipWaiting());
36
+ self.addEventListener("install", (event) => {
37
+ // waitUntil keeps the worker alive until skipWaiting resolves, so a freshly
38
+ // installed SW reliably jumps the "waiting" queue instead of stalling until
39
+ // every old tab closes.
40
+ (event as ExtendableEvent).waitUntil(self.skipWaiting());
41
+ });
92
42
 
93
43
  async function clearOldCaches() {
94
44
  const cacheWhitelist = [cachename];
@@ -101,201 +51,20 @@ async function clearOldCaches() {
101
51
  await Promise.all(deletePromises);
102
52
  }
103
53
 
104
- self.addEventListener("activate", async () => {
105
- await clearOldCaches();
106
- clients.claim();
54
+ self.addEventListener("activate", (event) => {
55
+ // Without waitUntil the activate event settles immediately and clients.claim()
56
+ // runs detached — the new worker can be killed before it takes control, so
57
+ // existing tabs keep talking to the old SW. Extend the event instead.
58
+ (event as ExtendableEvent).waitUntil(
59
+ (async () => {
60
+ await clearOldCaches();
61
+ await self.clients.claim();
62
+ })()
63
+ );
107
64
  });
108
65
 
109
- let repoHivePromise: Promise<{
110
- repo: Repo;
111
- hive?: AutomergeRepoKeyhiveRust;
112
- }> | null = null;
113
-
114
- const useKeyhive = typeof __KEYHIVE__ !== "undefined" && __KEYHIVE__;
115
-
116
- function getRepoHive() {
117
- if (!repoHivePromise) {
118
- repoHivePromise = (async () => {
119
- const logger = await slog;
120
- logger.info("getRepo: starting");
121
-
122
- logger.info("fetching wasm modules");
123
- const [amWasmBuf, sdnWasmBuf] = await Promise.all([
124
- fetch("/automerge.wasm?sw").then((r) => r.arrayBuffer()),
125
- fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
126
- ]);
127
- initSubductionSync(new Uint8Array(sdnWasmBuf));
128
- await initializeWasm(new Uint8Array(amWasmBuf));
129
- logger.info("wasm initialized");
130
-
131
- if (!useKeyhive) {
132
- const signer = await WebCryptoSigner.setup();
133
-
134
- const repo = new Repo({
135
- storage: new IndexedDBStorageAdapter(),
136
- signer,
137
- peerId: ("service-worker-" +
138
- Math.random().toString(36).slice(2)) as import("@automerge/automerge-repo/slim").PeerId,
139
- async sharePolicy(peerId) {
140
- return peerId.includes("storage-server");
141
- },
142
- enableRemoteHeadsGossiping: true,
143
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
144
- });
145
-
146
- (self as any).repo = repo;
147
- logger.info("repo constructed (no keyhive), waiting for network subsystem");
148
-
149
- repo.networkSubsystem.whenReady().then(() => {
150
- logger.info("repo network subsystem ready");
151
- });
152
-
153
- return { repo };
154
- }
155
-
156
- initKeyhiveWasm();
157
- const keyhiveStorage = new IndexedDBStorageAdapter(
158
- `${siteName}-keyhive`
159
- );
160
-
161
- // Keyhive bootstrap needs to run before Repo creation but
162
- // the adapter needs the subduction instance from the Repo.
163
- // A deferred promise breaks the cycle.
164
- let resolveRepoSubduction!: (s: any) => void;
165
- const repoSubductionPromise = new Promise((resolve) => {
166
- resolveRepoSubduction = resolve;
167
- });
168
-
169
- // We use the Rust variant of Keyhive initialization to talk
170
- // to the Rust keyhive-enabled subduction sync server.
171
- const hive = await initializeAutomergeRepoKeyhiveRust({
172
- storage: keyhiveStorage,
173
- peerIdSuffix:
174
- `${siteName}-worker` + Math.random().toString(36).slice(2),
175
- subduction: repoSubductionPromise as any,
176
- automaticArchiveIngestion: true,
177
- cachingMode: "periodic",
178
- });
179
-
180
- const signer = await hive.constructSubductionSigner();
181
-
182
- const repo = new Repo({
183
- storage: new IndexedDBStorageAdapter(),
184
- signer,
185
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
186
- peerId: hive.peerId,
187
- enableRemoteHeadsGossiping: true,
188
- idFactory: hive.idFactory,
189
- //network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
190
- });
191
-
192
- repo.subduction.then(resolveRepoSubduction);
193
-
194
- hive.linkRepo(repo);
195
-
196
- (self as any).repo = repo;
197
- (self as any).hive = hive;
198
- logger.info("repo constructed, waiting for network subsystem");
199
-
200
- // Don't block getRepoHive() on whenReady() — the network subsystem starts
201
- // with only the subduction adapter, and the MessageChannel adapter is
202
- // added later via connectPort (which awaits getRepoHive). Blocking here
203
- // would deadlock that path and starve the fetch handler.
204
- repo.networkSubsystem.whenReady().then(() => {
205
- logger.info("repo network subsystem ready");
206
- });
207
-
208
- hive.networkAdapter.whenReady().then(() => {
209
- (hive.networkAdapter as any).syncKeyhive();
210
- });
211
-
212
- return { hive, repo };
213
- })();
214
- // If construction fails (e.g. wasm fetch errors out because the SW was
215
- // terminated mid-flight), don't permanently cache the rejection — clear
216
- // the slot so the next caller can retry from scratch.
217
- repoHivePromise.catch(() => {
218
- repoHivePromise = null;
219
- });
220
- }
221
- return repoHivePromise;
222
- }
223
-
224
- // Connect client MessagePorts to the repo for sync
225
- async function connectPort(port: MessagePort) {
226
- const { hive, repo } = await getRepoHive();
227
- const networkAdapter = new MessageChannelNetworkAdapter(port, { useWeakRef: true });
228
-
229
- if (!hive) {
230
- repo.networkSubsystem.addNetworkAdapter(networkAdapter);
231
- return;
232
- }
233
-
234
- const onlyShareWithHardcodedServerPeerId = false;
235
- const periodicallyRequestKeyhiveSync = false;
236
- const keyhiveNetworkAdapter = hive.createKeyhiveNetworkAdapter(networkAdapter, onlyShareWithHardcodedServerPeerId, periodicallyRequestKeyhiveSync, 2000);
237
-
238
- keyhiveNetworkAdapter.on("message", async (msg: any) => {
239
- if ((msg.type === "sync" || msg.type === "request") && msg.documentId) {
240
- const handle = repo.handles[msg.documentId];
241
- if (!handle || handle.state === "unavailable") {
242
- const url = `automerge:${msg.documentId}` as AutomergeUrl;
243
- repo.findWithProgress(url);
244
- repo.shareConfigChanged();
245
- }
246
- }
247
- });
248
-
249
- (keyhiveNetworkAdapter as any).on("ingest-remote", () => {
250
- (hive.networkAdapter as any).syncKeyhive?.();
251
- repo.shareConfigChanged();
252
- });
253
-
254
- repo.networkSubsystem.addNetworkAdapter(keyhiveNetworkAdapter);
255
- }
256
-
257
66
  self.addEventListener("message", async (event) => {
258
- if (event.data.type == "ping") {
259
- // Keepalive — Chromium idles out service workers after ~30s of inactivity.
260
- // Reply via the provided port if any; the message event itself also resets
261
- // the idle timer.
262
- const [pongPort] = event.ports;
263
- log("ping");
264
- if (pongPort) {
265
- pongPort.postMessage({ type: "pong", workerInstanceId });
266
- log("pong");
267
- pongPort.close();
268
- } else if (event.source) {
269
- (event.source as unknown as Client).postMessage({
270
- type: "pong",
271
- workerInstanceId,
272
- });
273
- log("pong");
274
- }
275
- } else if (event.data.type == "port") {
276
- log("received messagechannel");
277
- const [port] = event.ports;
278
- const source = event.source as Client | null;
279
- const id = event.data.id;
280
- // event.waitUntil keeps the SW alive until the work completes. Without
281
- // it, the browser can terminate the SW the moment this synchronous block
282
- // returns, killing the in-flight wasm fetch.
283
- (event as unknown as FetchEvent).waitUntil(
284
- connectPort(port).then(
285
- () => source?.postMessage({ type: "port-ready", id, workerInstanceId }),
286
- (err) => {
287
- console.error("connectPort failed", err);
288
- // Tell the client we failed so it doesn't hang forever.
289
- source?.postMessage({
290
- type: "port-failed",
291
- id,
292
- error: String(err),
293
- workerInstanceId,
294
- });
295
- }
296
- )
297
- );
298
- } else if (event.data.type == "cachename") {
67
+ if (event.data.type == "cachename") {
299
68
  const nextCachename = event.data.cachename;
300
69
  if (cachename == nextCachename) {
301
70
  return;
@@ -311,65 +80,82 @@ self.addEventListener("message", async (event) => {
311
80
  }
312
81
  });
313
82
 
314
- // ── Automerge URL resolution ───────────────────────────────────────────
83
+ // ── Handoff to the automerge worker ────────────────────────────────────
315
84
 
316
- async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
317
- const { repo } = await getRepoHive();
318
- const href = automergeURL.href;
319
- const [maybeAutomergeUrl, ...path] = href.split("/");
85
+ const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
320
86
 
321
- if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
322
- return new Response("invalid automerge url", { status: 400 });
323
- }
87
+ type PendingHandoff = {
88
+ message: HandoffRequestMessage;
89
+ resolvers: PromiseWithResolvers<HandoffReplyMessage>;
90
+ };
324
91
 
325
- // Trim trailing empty path segment
326
- if (path.length && !path[path.length - 1]) path.pop();
92
+ const pendingHandoffs = new Map<string, PendingHandoff>();
327
93
 
328
- const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
329
- const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
330
-
331
- if (!heads) {
332
- const folder = await repo.find(maybeAutomergeUrl, { signal });
333
- const latestHeads = folder.heads();
334
- const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
335
- let location = `/${encodeURIComponent(url)}`;
336
- if (path.length) location += `/${path.join("/")}`;
337
- return Response.redirect(location, 307);
338
- }
339
-
340
- // Load by documentId only so we can verify the requested heads are actually
341
- // in our local history. repo.find with a heads-bearing URL returns a view
342
- // at those heads, which silently materializes garbage if we never synced them.
343
- const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
344
- signal,
345
- });
346
- if (!hasHeads(baseHandle.doc(), hexHeads ?? [])) {
347
- return new Response("heads not found", { status: 404 });
94
+ handoffChannel.addEventListener("message", (event) => {
95
+ const data = event.data;
96
+ if (data?.type === "cached" || data?.type === "response") {
97
+ const pending = pendingHandoffs.get(data.id);
98
+ if (!pending) {
99
+ return log(`no pending handoff for id ${data.id}`);
100
+ }
101
+ pending.resolvers.resolve(data as HandoffReplyMessage);
102
+ } else if (data?.type === "online") {
103
+ // The automerge worker (re)started — re-broadcast anything still in
104
+ // flight so requests that raced its boot aren't stranded.
105
+ for (const { message } of pendingHandoffs.values()) {
106
+ log(`re-broadcasting handoff ${message.id} to the fresh worker`);
107
+ handoffChannel.postMessage(message);
108
+ }
348
109
  }
349
- const rootHandle = baseHandle.view(heads);
350
-
351
- const resolved = await resolvePath(
352
- repo,
353
- rootHandle,
354
- path.map(decodeURIComponent)
355
- );
110
+ });
356
111
 
357
- if (!resolved) {
358
- throw new Error(
359
- `couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`
112
+ function handoff(
113
+ request: Request,
114
+ handoffURL: URL
115
+ ): Promise<HandoffReplyMessage> {
116
+ const id = crypto.randomUUID();
117
+ const resolvers = Promise.withResolvers<HandoffReplyMessage>();
118
+ const message: HandoffRequestMessage = {
119
+ id,
120
+ type: "request",
121
+ cachename,
122
+ request: {
123
+ url: request.url,
124
+ handoffURL: handoffURL.href,
125
+ headers: Object.fromEntries(request.headers.entries()),
126
+ method: request.method,
127
+ destination: request.destination,
128
+ referrer: request.referrer,
129
+ },
130
+ };
131
+ pendingHandoffs.set(id, { message, resolvers });
132
+ log(`broadcasting handoff request for cache ${cachename}`, message);
133
+ handoffChannel.postMessage(message);
134
+ const timeout = setTimeout(() => {
135
+ resolvers.reject(
136
+ new Error(
137
+ `no reply from the automerge worker after ${HANDOFF_TIMEOUT_MS}ms`
138
+ )
360
139
  );
361
- }
362
-
363
- const body: BodyInit =
364
- resolved.content instanceof Uint8Array
365
- ? (new Uint8Array(resolved.content) as BlobPart)
366
- : resolved.content;
140
+ }, HANDOFF_TIMEOUT_MS);
141
+ return resolvers.promise.finally(() => {
142
+ clearTimeout(timeout);
143
+ pendingHandoffs.delete(id);
144
+ });
145
+ }
367
146
 
368
- const headers = new Headers({ "content-type": resolved.type });
147
+ function withSpecialHeaders(response: {
148
+ body?: BodyInit | ReadableStream<Uint8Array> | null;
149
+ status?: number;
150
+ headers?: HeadersInit;
151
+ }): Response {
152
+ const headers = new Headers(response.headers);
369
153
  headers.set("cross-origin-embedder-policy", "credentialless");
370
154
  headers.set("cross-origin-resource-policy", "cross-origin");
371
-
372
- return new Response(body, { status: 200, headers });
155
+ return new Response(response.body ?? null, {
156
+ status: response.status ?? 200,
157
+ headers,
158
+ });
373
159
  }
374
160
 
375
161
  // ── Fetch handler ──────────────────────────────────────────────────────
@@ -380,7 +166,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
380
166
  if (request.method !== "GET") return fetchEvent.respondWith(fetch(request));
381
167
  const url = new URL(fetchEvent.request.url);
382
168
 
383
- let specialURL: URL | undefined;
169
+ let handoffURL: URL | undefined;
384
170
 
385
171
  if (
386
172
  url.hostname == self.location.hostname &&
@@ -388,8 +174,8 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
388
174
  url.protocol == self.location.protocol
389
175
  ) {
390
176
  try {
391
- specialURL = new URL(decodeURIComponent(url.pathname.slice(1)));
392
- log(`received special request ${specialURL}`);
177
+ handoffURL = new URL(decodeURIComponent(url.pathname.slice(1)));
178
+ log(`received special request ${handoffURL}`);
393
179
  } catch {}
394
180
  }
395
181
 
@@ -399,41 +185,34 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
399
185
  const match = await cache.match(request);
400
186
 
401
187
  try {
402
- if (specialURL) {
188
+ if (handoffURL) {
403
189
  if (match) {
404
- log(`serving ${specialURL} from cache ${cachename}`);
405
- const headers = new Headers(match.headers);
406
- headers.set("cross-origin-embedder-policy", "credentialless");
407
- headers.set("cross-origin-resource-policy", "cross-origin");
408
- return new Response(match.body, {
409
- status: match.status,
410
- headers,
411
- });
190
+ log(`serving ${handoffURL} from cache ${cachename}`);
191
+ return withSpecialHeaders(match);
412
192
  }
413
193
 
414
- const response = await Promise.race([
415
- resolveAutomergeUrl(specialURL),
416
- new Promise<never>((_, reject) =>
417
- setTimeout(
418
- () =>
419
- reject(
420
- new Error(`resolve timeout after ${RESOLVE_TIMEOUT_MS}ms`)
421
- ),
422
- RESOLVE_TIMEOUT_MS
423
- )
424
- ),
425
- ]);
194
+ log(`handing ${handoffURL} off to the automerge worker`);
195
+ const replyPromise = handoff(request, handoffURL);
196
+ fetchEvent.waitUntil(replyPromise.catch(() => {}));
197
+ const reply = await replyPromise;
426
198
 
427
- if (response.status === 307) {
428
- return response;
199
+ if (reply.type === "response") {
200
+ // errors, redirects and other things that shouldn't be cached
201
+ log(`serving handed-off response for ${handoffURL}`, reply);
202
+ return withSpecialHeaders(reply.response);
429
203
  }
430
204
 
431
- if (cacheableStatuses.includes(response.status)) {
432
- log(`caching ${specialURL}`);
433
- await cache.put(request, response.clone());
205
+ // reply.type === "cached": the automerge worker has put the
206
+ // response in our cache
207
+ const cached = await cache.match(request);
208
+ if (!cached) {
209
+ return new Response(
210
+ `the automerge worker reported ${handoffURL} cached, but it has no match in ${cachename}`,
211
+ { status: 500 }
212
+ );
434
213
  }
435
-
436
- return response;
214
+ log(`serving ${handoffURL} from cache ${cachename} after handoff`);
215
+ return withSpecialHeaders(cached);
437
216
  } else {
438
217
  const response = await fetch(request).catch(() => null);
439
218
  if (response) {
@@ -457,13 +236,9 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
457
236
  error instanceof Error
458
237
  ? `${error.message}\n\n${error.stack}`
459
238
  : String(error);
460
- const logger = await slog;
461
- logger.error(
462
- `service worker error resolving ${request.url}${specialURL ? ` (for: ${specialURL})` : ""}`,
463
- {
464
- message: error instanceof Error ? error.message : String(error),
465
- stack: error instanceof Error ? error.stack : undefined,
466
- }
239
+ console.error(
240
+ `service worker error resolving ${request.url}${handoffURL ? ` (for: ${handoffURL})` : ""}`,
241
+ error
467
242
  );
468
243
  if (match) return match;
469
244