@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,60 +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 { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
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
11
  const cacheableStatuses = [200, 203, 204, 206];
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;
52
15
  function log(...args) {
53
16
  if (!debugging)
54
17
  return;
55
18
  console.log.call(console, `%cpatchwork:serviceworker%c\n`, `color: #00ffcc; font-weight: bold`, "color: inherit", ...args);
56
19
  }
57
- 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
+ });
58
26
  async function clearOldCaches() {
59
27
  const cacheWhitelist = [cachename];
60
28
  const cacheNames = await caches.keys();
@@ -65,104 +33,17 @@ async function clearOldCaches() {
65
33
  });
66
34
  await Promise.all(deletePromises);
67
35
  }
68
- self.addEventListener("activate", async () => {
69
- await clearOldCaches();
70
- 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
+ })());
71
44
  });
72
- let repoPromise = null;
73
- function getRepo() {
74
- if (!repoPromise) {
75
- const p = (async () => {
76
- const logger = await slog;
77
- logger.info("getRepo: starting");
78
- logger.info("fetching wasm modules");
79
- const [amWasmBuf, sdnWasmBuf] = await Promise.all([
80
- fetch("/automerge.wasm?sw").then((r) => r.arrayBuffer()),
81
- fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
82
- ]);
83
- initSubductionSync(new Uint8Array(sdnWasmBuf));
84
- await initializeWasm(new Uint8Array(amWasmBuf));
85
- logger.info("wasm initialized");
86
- const signer = await WebCryptoSigner.setup();
87
- const repo = new Repo({
88
- storage: new IndexedDBStorageAdapter(),
89
- signer,
90
- peerId: ("service-worker-" +
91
- (Math.random() * 10000).toString(36).slice(2)),
92
- async sharePolicy(peerId) {
93
- return peerId.includes("storage-server");
94
- },
95
- enableRemoteHeadsGossiping: true,
96
- subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
97
- network: [new WebSocketClientAdapter("wss://sync3.automerge.org")],
98
- });
99
- self.repo = repo;
100
- logger.info("repo constructed, waiting for network subsystem");
101
- // Don't block getRepo() on whenReady() — the network subsystem starts
102
- // with only the subduction adapter, and the MessageChannel adapter is
103
- // added later via connectPort (which awaits getRepo). Blocking here
104
- // would deadlock that path and starve the fetch handler.
105
- repo.networkSubsystem.whenReady().then(() => {
106
- logger.info("repo network subsystem ready");
107
- });
108
- return repo;
109
- })();
110
- // If construction fails (e.g. wasm fetch errors out because the SW was
111
- // terminated mid-flight), don't permanently cache the rejection — clear
112
- // the slot so the next caller can retry from scratch.
113
- p.catch(() => {
114
- if (repoPromise === p)
115
- repoPromise = null;
116
- });
117
- repoPromise = p;
118
- }
119
- return repoPromise;
120
- }
121
- // Connect client MessagePorts to the repo for sync
122
- async function connectPort(port) {
123
- const repo = await getRepo();
124
- repo.networkSubsystem.addNetworkAdapter(new MessageChannelNetworkAdapter(port, { useWeakRef: true }));
125
- }
126
45
  self.addEventListener("message", async (event) => {
127
- if (event.data.type == "ping") {
128
- // Keepalive — Chromium idles out service workers after ~30s of inactivity.
129
- // Reply via the provided port if any; the message event itself also resets
130
- // the idle timer.
131
- const [pongPort] = event.ports;
132
- log("ping");
133
- if (pongPort) {
134
- pongPort.postMessage({ type: "pong", workerInstanceId });
135
- log("pong");
136
- pongPort.close();
137
- }
138
- else if (event.source) {
139
- event.source.postMessage({
140
- type: "pong",
141
- workerInstanceId,
142
- });
143
- log("pong");
144
- }
145
- }
146
- else if (event.data.type == "port") {
147
- log("received messagechannel");
148
- const [port] = event.ports;
149
- const source = event.source;
150
- const id = event.data.id;
151
- // event.waitUntil keeps the SW alive until the work completes. Without
152
- // it, the browser can terminate the SW the moment this synchronous block
153
- // returns, killing the in-flight wasm fetch.
154
- event.waitUntil(connectPort(port).then(() => source?.postMessage({ type: "port-ready", id, workerInstanceId }), (err) => {
155
- console.error("connectPort failed", err);
156
- // Tell the client we failed so it doesn't hang forever.
157
- source?.postMessage({
158
- type: "port-failed",
159
- id,
160
- error: String(err),
161
- workerInstanceId,
162
- });
163
- }));
164
- }
165
- else if (event.data.type == "cachename") {
46
+ if (event.data.type == "cachename") {
166
47
  const nextCachename = event.data.cachename;
167
48
  if (cachename == nextCachename) {
168
49
  return;
@@ -176,49 +57,62 @@ self.addEventListener("message", async (event) => {
176
57
  log("serviceworker debugging enabled");
177
58
  }
178
59
  });
179
- // ── Automerge URL resolution ───────────────────────────────────────────
180
- async function resolveAutomergeUrl(automergeURL) {
181
- const repo = await getRepo();
182
- const href = automergeURL.href;
183
- const [maybeAutomergeUrl, ...path] = href.split("/");
184
- if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
185
- 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);
186
71
  }
187
- // Trim trailing empty path segment
188
- if (path.length && !path[path.length - 1])
189
- path.pop();
190
- const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
191
- const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
192
- if (!heads) {
193
- const folder = await repo.find(maybeAutomergeUrl, { signal });
194
- const latestHeads = folder.heads();
195
- const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
196
- let location = `/${encodeURIComponent(url)}`;
197
- if (path.length)
198
- location += `/${path.join("/")}`;
199
- 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
+ }
200
79
  }
201
- // Load by documentId only so we can verify the requested heads are actually
202
- // in our local history. repo.find with a heads-bearing URL returns a view
203
- // at those heads, which silently materializes garbage if we never synced them.
204
- const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
205
- 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);
206
106
  });
207
- if (!hasHeads(baseHandle.doc(), hexHeads ?? [])) {
208
- return new Response("heads not found", { status: 404 });
209
- }
210
- const rootHandle = baseHandle.view(heads);
211
- const resolved = await resolvePath(repo, rootHandle, path.map(decodeURIComponent));
212
- if (!resolved) {
213
- throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
214
- }
215
- const body = resolved.content instanceof Uint8Array
216
- ? new Uint8Array(resolved.content)
217
- : resolved.content;
218
- const headers = new Headers({ "content-type": resolved.type });
107
+ }
108
+ function withSpecialHeaders(response) {
109
+ const headers = new Headers(response.headers);
219
110
  headers.set("cross-origin-embedder-policy", "credentialless");
220
111
  headers.set("cross-origin-resource-policy", "cross-origin");
221
- return new Response(body, { status: 200, headers });
112
+ return new Response(response.body ?? null, {
113
+ status: response.status ?? 200,
114
+ headers,
115
+ });
222
116
  }
223
117
  // ── Fetch handler ──────────────────────────────────────────────────────
224
118
  self.addEventListener("fetch", (fetchEvent) => {
@@ -227,13 +121,13 @@ self.addEventListener("fetch", (fetchEvent) => {
227
121
  if (request.method !== "GET")
228
122
  return fetchEvent.respondWith(fetch(request));
229
123
  const url = new URL(fetchEvent.request.url);
230
- let specialURL;
124
+ let handoffURL;
231
125
  if (url.hostname == self.location.hostname &&
232
126
  url.port == self.location.port &&
233
127
  url.protocol == self.location.protocol) {
234
128
  try {
235
- specialURL = new URL(decodeURIComponent(url.pathname.slice(1)));
236
- log(`received special request ${specialURL}`);
129
+ handoffURL = new URL(decodeURIComponent(url.pathname.slice(1)));
130
+ log(`received special request ${handoffURL}`);
237
131
  }
238
132
  catch { }
239
133
  }
@@ -241,29 +135,28 @@ self.addEventListener("fetch", (fetchEvent) => {
241
135
  const cache = await caches.open(cachename);
242
136
  const match = await cache.match(request);
243
137
  try {
244
- if (specialURL) {
138
+ if (handoffURL) {
245
139
  if (match) {
246
- log(`serving ${specialURL} from cache ${cachename}`);
247
- const headers = new Headers(match.headers);
248
- headers.set("cross-origin-embedder-policy", "credentialless");
249
- headers.set("cross-origin-resource-policy", "cross-origin");
250
- return new Response(match.body, {
251
- status: match.status,
252
- headers,
253
- });
140
+ log(`serving ${handoffURL} from cache ${cachename}`);
141
+ return withSpecialHeaders(match);
254
142
  }
255
- const response = await Promise.race([
256
- resolveAutomergeUrl(specialURL),
257
- new Promise((_, reject) => setTimeout(() => reject(new Error(`resolve timeout after ${RESOLVE_TIMEOUT_MS}ms`)), RESOLVE_TIMEOUT_MS)),
258
- ]);
259
- if (response.status === 307) {
260
- 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);
261
151
  }
262
- if (cacheableStatuses.includes(response.status)) {
263
- log(`caching ${specialURL}`);
264
- 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 });
265
157
  }
266
- return response;
158
+ log(`serving ${handoffURL} from cache ${cachename} after handoff`);
159
+ return withSpecialHeaders(cached);
267
160
  }
268
161
  else {
269
162
  const response = await fetch(request).catch(() => null);
@@ -286,7 +179,7 @@ self.addEventListener("fetch", (fetchEvent) => {
286
179
  const message = error instanceof Error
287
180
  ? `${error.message}\n\n${error.stack}`
288
181
  : String(error);
289
- console.error(`service worker error resolving ${request.url}${specialURL ? ` (for: ${specialURL})` : ""}.\n${message}`);
182
+ console.error(`service worker error resolving ${request.url}${handoffURL ? ` (for: ${handoffURL})` : ""}`, error);
290
183
  if (match)
291
184
  return match;
292
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>;
package/dist/setup.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { readClassicSyncServer, DEFAULT_CLASSIC_SYNC_SERVER, } from "./sync-config.js";
1
2
  import debug from "debug";
2
- const debugging = debug.enabled("patchwork:serviceworker");
3
+ const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
4
+ const workerDebugging = debug.enabled("patchwork:automergeworker");
3
5
  const key = "patchworkServiceWorkerCacheVersion";
4
6
  let nextRepoChannelId = 0;
5
- let serviceWorkerInstanceId;
6
7
  function bumpServiceWorkerCacheVersion() {
7
8
  const version = new Date().valueOf().toString(36);
8
9
  localStorage.setItem(key, version);
@@ -34,17 +35,58 @@ window.bumpServiceWorkerCache = bumpServiceWorkerCache;
34
35
  function configureServiceWorker(sw) {
35
36
  if (!sw)
36
37
  return;
37
- sw.postMessage({ type: "debug", debug: debugging });
38
+ sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
38
39
  const cachename = getServiceWorkerCacheVersion();
39
40
  if (cachename)
40
41
  sw.postMessage({ type: "cachename", cachename });
41
42
  }
42
- function updateServiceWorkerInstanceId(next) {
43
- if (typeof next !== "string")
44
- return false;
45
- const changed = serviceWorkerInstanceId != null && serviceWorkerInstanceId !== next;
46
- serviceWorkerInstanceId = next;
47
- return changed;
43
+ // ── The automerge worker ───────────────────────────────────────────────
44
+ // The automerge repo lives in a SharedWorker (not the service worker). One
45
+ // instance is shared by every tab and lives exactly as long as any tab
46
+ // does, so there's no keepalive ping and no restart detection: if we're
47
+ // alive, it's alive. Repo sync ports are passed to it over its connect
48
+ // port; it talks to the service worker over a BroadcastChannel.
49
+ let automergeWorkerPath = "/automerge-worker.js";
50
+ let automergeWorker;
51
+ function getAutomergeWorker() {
52
+ if (!automergeWorker) {
53
+ automergeWorker = new SharedWorker(automergeWorkerPath, {
54
+ name: "patchwork-automerge",
55
+ type: "module",
56
+ });
57
+ // Control replies (port-ready &c) come back on this port, so it needs
58
+ // start() — we listen with addEventListener, not onmessage.
59
+ automergeWorker.port.start();
60
+ automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
61
+ }
62
+ return automergeWorker;
63
+ }
64
+ export function connectClassicSync(server = readClassicSyncServer()) {
65
+ const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
66
+ if (!/^wss?:\/\//.test(url)) {
67
+ return Promise.reject(new Error(`invalid classic sync server URL: ${server}`));
68
+ }
69
+ const worker = getAutomergeWorker();
70
+ const { port1, port2 } = new MessageChannel();
71
+ return new Promise((resolve, reject) => {
72
+ const timeout = setTimeout(() => {
73
+ port1.close();
74
+ reject(new Error("connect-classic-sync timeout"));
75
+ }, 30_000);
76
+ port1.onmessage = (event) => {
77
+ clearTimeout(timeout);
78
+ port1.close();
79
+ if (event.data?.type === "connect-classic-sync-ready") {
80
+ resolve();
81
+ }
82
+ else {
83
+ reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
84
+ }
85
+ };
86
+ worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
87
+ port2,
88
+ ]);
89
+ });
48
90
  }
49
91
  /** Wait for a registration to have an active worker */
50
92
  function waitForActive(reg) {
@@ -61,106 +103,63 @@ function waitForActive(reg) {
61
103
  });
62
104
  }
63
105
  async function openRepoChannel() {
64
- const controller = navigator.serviceWorker.controller;
65
- if (!controller) {
66
- throw new Error("no service worker controller");
67
- }
68
- // Send a MessagePort so the SW's repo can sync with clients, and wait for
69
- // the SW to confirm its repo is constructed before returning. The
106
+ const worker = getAutomergeWorker();
107
+ // Send a MessagePort so the worker's repo can sync with this tab, and wait
108
+ // for the worker to confirm its repo is constructed before returning. The
70
109
  // MessageChannel adapter's whenReady() force-resolves after 100ms regardless
71
110
  // of the other end's state, so it can't be used as a real readiness signal
72
- // on first install (when the SW still has to fetch wasm and build its repo).
111
+ // on first boot (when the worker still has to fetch wasm and build its repo).
73
112
  const id = ++nextRepoChannelId;
74
- let workerInstanceChanged = false;
75
113
  const { port1, port2 } = new MessageChannel();
76
- const swReady = new Promise((resolve, reject) => {
114
+ const workerReady = new Promise((resolve, reject) => {
77
115
  let timeout;
78
116
  const cleanup = () => {
79
117
  clearTimeout(timeout);
80
- navigator.serviceWorker.removeEventListener("message", listener);
118
+ worker.port.removeEventListener("message", listener);
81
119
  };
82
120
  const listener = (event) => {
83
- if (event.data?.id != null && event.data.id !== id)
121
+ if (event.data?.id !== id)
84
122
  return;
85
123
  if (event.data?.type === "port-ready") {
86
- workerInstanceChanged = updateServiceWorkerInstanceId(event.data.workerInstanceId);
87
124
  cleanup();
88
125
  resolve();
89
126
  }
90
127
  else if (event.data?.type === "port-failed") {
91
- workerInstanceChanged = updateServiceWorkerInstanceId(event.data.workerInstanceId);
92
128
  cleanup();
93
- reject(new Error(`service worker init failed: ${event.data.error}`));
129
+ reject(new Error(`automerge worker init failed: ${event.data.error}`));
94
130
  }
95
131
  };
96
- navigator.serviceWorker.addEventListener("message", listener);
97
- // Failsafe: don't block boot forever if the SW never replies. Surface the
98
- // issue and let the rest of the site come up rather than hanging on a
132
+ worker.port.addEventListener("message", listener);
133
+ // Failsafe: don't block boot forever if the worker never replies. Surface
134
+ // the issue and let the rest of the site come up rather than hanging on a
99
135
  // blank page.
100
136
  timeout = setTimeout(() => {
101
137
  cleanup();
102
- reject(new Error("service worker port-ready timeout"));
138
+ reject(new Error("automerge worker port-ready timeout"));
103
139
  }, 30_000);
104
140
  });
105
- controller.postMessage({ type: "port", id }, [port2]);
141
+ worker.port.postMessage({ type: "port", id }, [port2]);
106
142
  try {
107
- await swReady;
143
+ await workerReady;
108
144
  }
109
145
  catch (err) {
110
- console.warn("proceeding without SW ready ack:", err instanceof Error ? err.message : err);
146
+ console.warn("proceeding without worker ready ack:", err instanceof Error ? err.message : err);
111
147
  }
112
- return { port: port1, workerInstanceChanged };
148
+ return port1;
149
+ }
150
+ /** Open a fresh repo sync port to the automerge worker (dev console). */
151
+ function getRepoChannel() {
152
+ const worker = getAutomergeWorker();
153
+ const { port1, port2 } = new MessageChannel();
154
+ worker.port.postMessage({ type: "port", id: ++nextRepoChannelId }, [port2]);
155
+ return port1;
113
156
  }
114
157
  export default async function setupServiceWorker(options) {
115
- const repoChannelListeners = new Set();
116
- let reconnectPromise = null;
117
- const reconnectRepoChannels = (reason) => {
118
- if (reconnectPromise)
119
- return reconnectPromise;
120
- reconnectPromise = (async () => {
121
- console.info(`%cservice worker ${reason}, reconnecting repo channels...`, "color: pink; font-weight: bold");
122
- configureServiceWorker(navigator.serviceWorker.controller);
123
- for (const listener of repoChannelListeners) {
124
- try {
125
- const { port } = await openRepoChannel();
126
- await listener(port);
127
- }
128
- catch (err) {
129
- console.error("service worker repo channel listener failed", err);
130
- }
131
- }
132
- })().finally(() => {
133
- reconnectPromise = null;
134
- });
135
- return reconnectPromise;
136
- };
137
- const pingServiceWorker = async () => {
138
- const controller = navigator.serviceWorker.controller;
139
- if (!controller)
140
- return;
141
- const { port1, port2 } = new MessageChannel();
142
- const pong = new Promise((resolve, reject) => {
143
- const timeout = setTimeout(() => {
144
- port1.close();
145
- reject(new Error("service worker pong timeout"));
146
- }, 5_000);
147
- port1.onmessage = (event) => {
148
- clearTimeout(timeout);
149
- port1.close();
150
- resolve(event.data?.workerInstanceId);
151
- };
152
- });
153
- controller.postMessage({ type: "ping" }, [port2]);
154
- try {
155
- const restarted = updateServiceWorkerInstanceId(await pong);
156
- if (restarted) {
157
- await reconnectRepoChannels("restarted");
158
- }
159
- }
160
- catch (err) {
161
- console.warn("service worker ping failed:", err instanceof Error ? err.message : err);
162
- }
163
- };
158
+ if (options?.workerPath)
159
+ automergeWorkerPath = options.workerPath;
160
+ // Start the automerge worker right away so it boots (wasm, repo) while the
161
+ // service worker installs.
162
+ getAutomergeWorker();
164
163
  const path = options?.path ?? "/service-worker.js";
165
164
  // No controller at this point means the page loaded without a service
166
165
  // worker — i.e. this is a first-time install (or a hard reload). Wait for
@@ -178,36 +177,20 @@ export default async function setupServiceWorker(options) {
178
177
  navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true });
179
178
  });
180
179
  }
181
- // Keepalive Chromium idles out service workers after ~30s of inactivity,
182
- // which tears down the in-memory Repo and forces a cold restart on the next
183
- // fetch. Ping through a MessageChannel so we can detect when a restarted SW
184
- // has a new in-memory Repo and reconnect all repo channels.
185
- setInterval(() => {
186
- void pingServiceWorker();
187
- }, 20_000);
188
- // Reconnect on future SW updates (added after setup so the initial
189
- // activation doesn't notify before callers subscribe).
190
- navigator.serviceWorker.addEventListener("controllerchange", function () {
191
- void reconnectRepoChannels("took control").catch((err) => {
192
- console.error("service worker reconnect failed", err);
193
- });
180
+ // A replacement service worker boots with the default cache name — re-send
181
+ // its configuration whenever a new one takes control.
182
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
183
+ configureServiceWorker(navigator.serviceWorker.controller);
194
184
  });
195
185
  console.log("service worker alive, loading %c patchwork system ", "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px");
196
186
  return {
187
+ connectClassicSync,
188
+ getRepoChannel,
197
189
  async subscribeToRepoChannel(listener) {
198
- const { port, workerInstanceChanged } = await openRepoChannel();
199
- if (workerInstanceChanged) {
200
- await reconnectRepoChannels("restarted");
201
- }
202
- repoChannelListeners.add(listener);
203
- try {
204
- await listener(port);
205
- }
206
- catch (err) {
207
- repoChannelListeners.delete(listener);
208
- throw err;
209
- }
210
- return () => repoChannelListeners.delete(listener);
190
+ // The automerge worker outlives the page, so unlike the old in-service-
191
+ // worker repo there's nothing to reconnect: one port, handed over once.
192
+ await listener(await openRepoChannel());
193
+ return () => { };
211
194
  },
212
195
  };
213
196
  }