@inkandswitch/patchwork-bootloader 0.2.6 → 0.2.8

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