@inkandswitch/patchwork-bootloader 0.2.5 → 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,71 +1,27 @@
1
1
  /// <reference types="service-worker-types" />
2
2
 
3
- import { SwLogger } from "./sw-logger.js";
4
-
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";
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).
16
9
 
17
10
  import {
18
- Repo,
19
- isValidAutomergeUrl,
20
- parseAutomergeUrl,
21
- stringifyAutomergeUrl,
22
- type PeerId,
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";
11
+ HANDOFF_CHANNEL,
12
+ type HandoffReplyMessage,
13
+ type HandoffRequestMessage,
14
+ } from "./types.js";
30
15
 
31
- // TEMPORARY: enable debug npm module in SW context (no localStorage available)
32
16
  let cachename = "default";
33
17
  let debugging = false;
34
- const workerInstanceId = crypto.randomUUID();
35
-
36
- const SUBDUCTION_ENDPOINTS = ["wss://subduction.sync.inkandswitch.com"];
37
- const RESOLVE_TIMEOUT_MS = 30_000;
38
-
39
- // ── Persistent logger ───────────────────────────────────────────────────
40
- // Initialized eagerly so it's available for the entire SW lifetime.
41
- // Access from the SW inspector console via self.printLogs(), self.tailLogs(),
42
- // self.exportLogs(), self.clearLogs().
43
- const slog = SwLogger.open().then((logger) => {
44
- (self as any).slog = logger;
45
-
46
- (self as any).printLogs = async (n = 200) => {
47
- const entries = await logger.tail(n);
48
- for (const e of entries) {
49
- const prefix = `[${e.ts}] [${e.level}]`;
50
- if (e.data !== undefined) {
51
- console.log(prefix, e.msg, e.data);
52
- } else {
53
- console.log(prefix, e.msg);
54
- }
55
- }
56
- console.log(`--- ${entries.length} entries ---`);
57
- };
58
-
59
- (self as any).tailLogs = (n = 200) => logger.tail(n);
60
- (self as any).exportLogs = () => logger.exportAll();
61
- (self as any).clearLogs = () => logger.clear();
62
-
63
- logger.info("sw-logger initialized");
64
- return logger;
65
- });
66
18
 
67
19
  const cacheableStatuses = [200, 203, 204, 206];
68
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
+
69
25
  function log(...args: any[]) {
70
26
  if (!debugging) return;
71
27
  console.log.call(
@@ -77,7 +33,12 @@ function log(...args: any[]) {
77
33
  );
78
34
  }
79
35
 
80
- 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
+ });
81
42
 
82
43
  async function clearOldCaches() {
83
44
  const cacheWhitelist = [cachename];
@@ -90,117 +51,20 @@ async function clearOldCaches() {
90
51
  await Promise.all(deletePromises);
91
52
  }
92
53
 
93
- self.addEventListener("activate", async () => {
94
- await clearOldCaches();
95
- clients.claim();
96
- });
97
-
98
- let repoPromise: Promise<Repo> | null = null;
99
-
100
- function getRepo() {
101
- if (!repoPromise) {
102
- const p: Promise<Repo> = (async () => {
103
- const logger = await slog;
104
- logger.info("getRepo: starting");
105
-
106
- logger.info("fetching wasm modules");
107
- const [amWasmBuf, sdnWasmBuf] = await Promise.all([
108
- fetch("/automerge.wasm?sw").then((r) => r.arrayBuffer()),
109
- fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
110
- ]);
111
- initSubductionSync(new Uint8Array(sdnWasmBuf));
112
- await initializeWasm(new Uint8Array(amWasmBuf));
113
- logger.info("wasm initialized");
114
-
115
- const signer = await WebCryptoSigner.setup();
116
-
117
- const repo = new Repo({
118
- storage: new IndexedDBStorageAdapter(),
119
- signer,
120
- peerId: ("service-worker-" +
121
- (Math.random() * 10000).toString(36).slice(2)) as PeerId,
122
- async sharePolicy(peerId) {
123
- return peerId.includes("storage-server");
124
- },
125
- enableRemoteHeadsGossiping: true,
126
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
127
- network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
128
- });
129
-
130
- (self as any).repo = repo;
131
- logger.info("repo constructed, waiting for network subsystem");
132
-
133
- // Don't block getRepo() on whenReady() — the network subsystem starts
134
- // with only the subduction adapter, and the MessageChannel adapter is
135
- // added later via connectPort (which awaits getRepo). Blocking here
136
- // would deadlock that path and starve the fetch handler.
137
- repo.networkSubsystem.whenReady().then(() => {
138
- logger.info("repo network subsystem ready");
139
- });
140
-
141
- return repo;
142
- })();
143
- // If construction fails (e.g. wasm fetch errors out because the SW was
144
- // terminated mid-flight), don't permanently cache the rejection — clear
145
- // the slot so the next caller can retry from scratch.
146
- p.catch(() => {
147
- if (repoPromise === p) repoPromise = null;
148
- });
149
- repoPromise = p;
150
- }
151
- return repoPromise;
152
- }
153
-
154
- // Connect client MessagePorts to the repo for sync
155
- async function connectPort(port: MessagePort) {
156
- const repo = await getRepo();
157
- repo.networkSubsystem.addNetworkAdapter(
158
- new MessageChannelNetworkAdapter(port, { useWeakRef: true })
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
+ })()
159
63
  );
160
- }
64
+ });
161
65
 
162
66
  self.addEventListener("message", async (event) => {
163
- if (event.data.type == "ping") {
164
- // Keepalive — Chromium idles out service workers after ~30s of inactivity.
165
- // Reply via the provided port if any; the message event itself also resets
166
- // the idle timer.
167
- const [pongPort] = event.ports;
168
- log("ping");
169
- if (pongPort) {
170
- pongPort.postMessage({ type: "pong", workerInstanceId });
171
- log("pong");
172
- pongPort.close();
173
- } else if (event.source) {
174
- (event.source as unknown as Client).postMessage({
175
- type: "pong",
176
- workerInstanceId,
177
- });
178
- log("pong");
179
- }
180
- } else if (event.data.type == "port") {
181
- log("received messagechannel");
182
- const [port] = event.ports;
183
- const source = event.source as Client | null;
184
- const id = event.data.id;
185
- // event.waitUntil keeps the SW alive until the work completes. Without
186
- // it, the browser can terminate the SW the moment this synchronous block
187
- // returns, killing the in-flight wasm fetch.
188
- (event as unknown as FetchEvent).waitUntil(
189
- connectPort(port).then(
190
- () => source?.postMessage({ type: "port-ready", id, workerInstanceId }),
191
- (err) => {
192
- console.error("connectPort failed", err);
193
- // Tell the client we failed so it doesn't hang forever.
194
- source?.postMessage({
195
- type: "port-failed",
196
- id,
197
- error: String(err),
198
- workerInstanceId,
199
- });
200
- }
201
- )
202
- );
203
- } else if (event.data.type == "cachename") {
67
+ if (event.data.type == "cachename") {
204
68
  const nextCachename = event.data.cachename;
205
69
  if (cachename == nextCachename) {
206
70
  return;
@@ -216,65 +80,82 @@ self.addEventListener("message", async (event) => {
216
80
  }
217
81
  });
218
82
 
219
- // ── Automerge URL resolution ───────────────────────────────────────────
83
+ // ── Handoff to the automerge worker ────────────────────────────────────
220
84
 
221
- async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
222
- const repo = await getRepo();
223
- const href = automergeURL.href;
224
- const [maybeAutomergeUrl, ...path] = href.split("/");
85
+ const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
225
86
 
226
- if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
227
- return new Response("invalid automerge url", { status: 400 });
228
- }
87
+ type PendingHandoff = {
88
+ message: HandoffRequestMessage;
89
+ resolvers: PromiseWithResolvers<HandoffReplyMessage>;
90
+ };
229
91
 
230
- // Trim trailing empty path segment
231
- if (path.length && !path[path.length - 1]) path.pop();
92
+ const pendingHandoffs = new Map<string, PendingHandoff>();
232
93
 
233
- const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
234
- const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
235
-
236
- if (!heads) {
237
- const folder = await repo.find(maybeAutomergeUrl, { signal });
238
- const latestHeads = folder.heads();
239
- const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
240
- let location = `/${encodeURIComponent(url)}`;
241
- if (path.length) location += `/${path.join("/")}`;
242
- return Response.redirect(location, 307);
243
- }
244
-
245
- // Load by documentId only so we can verify the requested heads are actually
246
- // in our local history. repo.find with a heads-bearing URL returns a view
247
- // at those heads, which silently materializes garbage if we never synced them.
248
- const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
249
- signal,
250
- });
251
- if (!hasHeads(baseHandle.doc(), hexHeads ?? [])) {
252
- 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
+ }
253
109
  }
254
- const rootHandle = baseHandle.view(heads);
255
-
256
- const resolved = await resolvePath(
257
- repo,
258
- rootHandle,
259
- path.map(decodeURIComponent)
260
- );
110
+ });
261
111
 
262
- if (!resolved) {
263
- throw new Error(
264
- `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
+ )
265
139
  );
266
- }
267
-
268
- const body: BodyInit =
269
- resolved.content instanceof Uint8Array
270
- ? (new Uint8Array(resolved.content) as BlobPart)
271
- : resolved.content;
140
+ }, HANDOFF_TIMEOUT_MS);
141
+ return resolvers.promise.finally(() => {
142
+ clearTimeout(timeout);
143
+ pendingHandoffs.delete(id);
144
+ });
145
+ }
272
146
 
273
- 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);
274
153
  headers.set("cross-origin-embedder-policy", "credentialless");
275
154
  headers.set("cross-origin-resource-policy", "cross-origin");
276
-
277
- return new Response(body, { status: 200, headers });
155
+ return new Response(response.body ?? null, {
156
+ status: response.status ?? 200,
157
+ headers,
158
+ });
278
159
  }
279
160
 
280
161
  // ── Fetch handler ──────────────────────────────────────────────────────
@@ -285,7 +166,7 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
285
166
  if (request.method !== "GET") return fetchEvent.respondWith(fetch(request));
286
167
  const url = new URL(fetchEvent.request.url);
287
168
 
288
- let specialURL: URL | undefined;
169
+ let handoffURL: URL | undefined;
289
170
 
290
171
  if (
291
172
  url.hostname == self.location.hostname &&
@@ -293,8 +174,8 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
293
174
  url.protocol == self.location.protocol
294
175
  ) {
295
176
  try {
296
- specialURL = new URL(decodeURIComponent(url.pathname.slice(1)));
297
- log(`received special request ${specialURL}`);
177
+ handoffURL = new URL(decodeURIComponent(url.pathname.slice(1)));
178
+ log(`received special request ${handoffURL}`);
298
179
  } catch {}
299
180
  }
300
181
 
@@ -304,41 +185,34 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
304
185
  const match = await cache.match(request);
305
186
 
306
187
  try {
307
- if (specialURL) {
188
+ if (handoffURL) {
308
189
  if (match) {
309
- log(`serving ${specialURL} from cache ${cachename}`);
310
- const headers = new Headers(match.headers);
311
- headers.set("cross-origin-embedder-policy", "credentialless");
312
- headers.set("cross-origin-resource-policy", "cross-origin");
313
- return new Response(match.body, {
314
- status: match.status,
315
- headers,
316
- });
190
+ log(`serving ${handoffURL} from cache ${cachename}`);
191
+ return withSpecialHeaders(match);
317
192
  }
318
193
 
319
- const response = await Promise.race([
320
- resolveAutomergeUrl(specialURL),
321
- new Promise<never>((_, reject) =>
322
- setTimeout(
323
- () =>
324
- reject(
325
- new Error(`resolve timeout after ${RESOLVE_TIMEOUT_MS}ms`)
326
- ),
327
- RESOLVE_TIMEOUT_MS
328
- )
329
- ),
330
- ]);
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;
331
198
 
332
- if (response.status === 307) {
333
- 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);
334
203
  }
335
204
 
336
- if (cacheableStatuses.includes(response.status)) {
337
- log(`caching ${specialURL}`);
338
- 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
+ );
339
213
  }
340
-
341
- return response;
214
+ log(`serving ${handoffURL} from cache ${cachename} after handoff`);
215
+ return withSpecialHeaders(cached);
342
216
  } else {
343
217
  const response = await fetch(request).catch(() => null);
344
218
  if (response) {
@@ -363,7 +237,8 @@ self.addEventListener("fetch", (fetchEvent: FetchEvent) => {
363
237
  ? `${error.message}\n\n${error.stack}`
364
238
  : String(error);
365
239
  console.error(
366
- `service worker error resolving ${request.url}${specialURL ? ` (for: ${specialURL})` : ""}.\n${message}`
240
+ `service worker error resolving ${request.url}${handoffURL ? ` (for: ${handoffURL})` : ""}`,
241
+ error
367
242
  );
368
243
  if (match) return match;
369
244