@inkandswitch/patchwork-bootloader 0.4.2 → 0.4.4

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.
package/dist/setup.js CHANGED
@@ -3,248 +3,320 @@ import debug from "debug";
3
3
  import { donatePort, isWorkerErrorMessage, } from "@automerge/automerge-repo/worker-port";
4
4
  const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
5
5
  const workerDebugging = debug.enabled("patchwork:automergeworker");
6
- // Diagnostic [lifecycle] logging, on by default. Disable via
7
- // localStorage["patchwork:lifecycle-logs"] = "off". Read live at log time.
8
- const LIFECYCLE_LOG_KEY = "patchwork:lifecycle-logs";
9
- export function lifecycleLoggingEnabled() {
10
- try {
11
- const v = globalThis.localStorage?.getItem(LIFECYCLE_LOG_KEY);
12
- return v !== "off" && v !== "false" && v !== "0" && v !== "no";
13
- }
14
- catch {
15
- return true;
16
- }
6
+ export const lifecycleLog = debug("patchwork:lifecycle");
7
+ function describeErrorEvent(event) {
8
+ const error = event;
9
+ const where = error.filename
10
+ ? ` (${error.filename}:${error.lineno}:${error.colno})`
11
+ : "";
12
+ return `${error.message || String(event)}${where}`;
13
+ }
14
+ // The version is cleared on every boot, so the steady state is
15
+ // DEFAULT_CACHE_NAME. bumpServiceWorkerCache is a dev escape hatch: it moves
16
+ // the worker to a throwaway cache now, and the next boot both reverts the name
17
+ // and (via the worker's activate handler) deletes the throwaway.
18
+ const CACHE_VERSION_KEY = "patchworkServiceWorkerCacheVersion";
19
+ const DEFAULT_CACHE_NAME = "patchwork";
20
+ function currentCacheName() {
21
+ return localStorage.getItem(CACHE_VERSION_KEY) ?? DEFAULT_CACHE_NAME;
22
+ }
23
+ function configureServiceWorker(sw) {
24
+ if (!sw)
25
+ return;
26
+ sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
27
+ sw.postMessage({ type: "cachename", cachename: currentCacheName() });
17
28
  }
18
- // The SW can't read localStorage, so it always emits [lifecycle] markers and
19
- // forwards them as `sw-lifecycle`; gate rendering here on the live toggle.
20
- let swLifecycleListenerInstalled = false;
29
+ export function bumpServiceWorkerCache(sw = navigator.serviceWorker.controller) {
30
+ if (!sw)
31
+ throw new Error("no service worker!");
32
+ localStorage.setItem(CACHE_VERSION_KEY, Date.now().toString(36));
33
+ sw.postMessage({ type: "cachename", cachename: currentCacheName() });
34
+ }
35
+ // The service worker has no localStorage, so it can't read the debug config —
36
+ // it always emits lifecycle markers and forwards them here to be filtered.
37
+ let logForwardingInstalled = false;
21
38
  function installServiceWorkerLogForwarding() {
22
- if (swLifecycleListenerInstalled)
39
+ if (logForwardingInstalled)
23
40
  return;
24
41
  if (typeof navigator === "undefined" || !navigator.serviceWorker)
25
42
  return;
26
- swLifecycleListenerInstalled = true;
43
+ logForwardingInstalled = true;
27
44
  navigator.serviceWorker.addEventListener("message", (event) => {
28
- const data = event.data;
29
- if (data?.type !== "sw-lifecycle")
30
- return;
31
- if (!lifecycleLoggingEnabled())
45
+ if (event.data?.type !== "sw-lifecycle")
32
46
  return;
33
- const fn = console[data.level] ?? console.log;
34
- fn(`[service-worker] ${data.msg}`);
47
+ lifecycleLog("[service-worker] %s", event.data.msg);
35
48
  });
36
49
  }
37
- const key = "patchworkServiceWorkerCacheVersion";
38
- const defaultServiceWorkerCacheName = "patchwork";
50
+ // The automerge repo lives in a SharedWorker. one instance serves every
51
+ // tab. Browsers might kill a SharedWorker under memory pressure, so we
52
+ // heartbeat it and rebuild everything if it dies.
53
+ let automergeWorkerPath = "/automerge-worker.js";
54
+ let automergeWorker;
55
+ // A repo port opened against instance N is stale once instance N+1 exists — its
56
+ // channel ends in a dead worker — so deliveries are guarded on generation.
57
+ let workerGeneration = 0;
58
+ let disposeWorkerDeathDetection;
59
+ const repoChannelListeners = new Set();
60
+ let recoveringWorker = false;
61
+ let lastWorkerRecoveryAt = 0;
62
+ // Below this spacing, skip: if the fresh worker is dead too, its own heartbeat
63
+ // re-triggers recovery later rather than spinning in a tight loop.
64
+ const RECOVERY_MIN_INTERVAL_MS = 15_000;
39
65
  let nextRepoChannelId = 0;
40
- function bumpServiceWorkerCacheVersion() {
41
- const version = new Date().valueOf().toString(36);
42
- localStorage.setItem(key, version);
43
- return getServiceWorkerCacheVersion();
44
- }
45
- function getServiceWorkerCacheVersion() {
46
- return localStorage.getItem(key);
47
- }
48
- function setServiceWorkerCacheName(sw) {
49
- if (!sw) {
50
- throw new Error("no service worker!");
51
- }
52
- sw.postMessage({
53
- type: "cachename",
54
- cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
66
+ // Chrome can't spawn workers inside a SharedWorker, so each tab offers this
67
+ // proxy's port to the automerge worker, which requests one via its port
68
+ // provider. Being a SharedWorker itself, the proxy — and the donated
69
+ // worker↔worker port — outlives the donor tab.
70
+ const SUBDUCTION_IO_WORKER_URL = "/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
71
+ export function getAutomergeWorker() {
72
+ if (automergeWorker)
73
+ return automergeWorker;
74
+ workerGeneration++;
75
+ const worker = new SharedWorker(automergeWorkerPath, {
76
+ name: "patchwork-automerge",
77
+ type: "module",
55
78
  });
79
+ automergeWorker = worker;
80
+ // Fires when a message can't be structured-deserialized. Silent otherwise:
81
+ // the message is dropped, which looks identical to a worker that never
82
+ // replied.
83
+ worker.port.addEventListener("messageerror", (event) => {
84
+ console.error("[automerge-worker] undeserializable message from worker:", event);
85
+ });
86
+ // Control replies come back on this port, and we listen with
87
+ // addEventListener rather than onmessage, so it needs start().
88
+ worker.port.start();
89
+ worker.port.addEventListener("message", handleWorkerMessage);
90
+ worker.port.postMessage({ type: "debug", debug: workerDebugging });
91
+ donatePort(worker.port, createSubductionIoPort);
92
+ disposeWorkerDeathDetection = installWorkerDeathDetection(worker);
93
+ return worker;
56
94
  }
57
- export function bumpServiceWorkerCache(sw = navigator.serviceWorker.controller) {
58
- bumpServiceWorkerCacheVersion();
59
- setServiceWorkerCacheName(sw);
60
- }
61
- window.bumpServiceWorkerCache = bumpServiceWorkerCache;
62
- function configureServiceWorker(sw) {
63
- if (!sw)
95
+ function handleWorkerMessage(event) {
96
+ const data = event.data;
97
+ if (data?.type === "sync-state") {
98
+ dispatchSyncState(data);
64
99
  return;
65
- sw.postMessage({ type: "debug", debug: serviceWorkerDebugging });
66
- sw.postMessage({
67
- type: "cachename",
68
- cachename: getServiceWorkerCacheVersion() ?? defaultServiceWorkerCacheName,
100
+ }
101
+ // Crash/skew reports relayed from the subduction io proxy (e.g. a protocol
102
+ // mismatch from a stale SW-cached worker chunk). These otherwise only exist
103
+ // in chrome://inspect.
104
+ if (isWorkerErrorMessage(data)) {
105
+ console.error("[subduction-io]", data);
106
+ return;
107
+ }
108
+ if (data?.type !== "console")
109
+ return;
110
+ const { level, args } = data;
111
+ if (!lifecycleLog.enabled &&
112
+ typeof args?.[0] === "string" &&
113
+ args[0].includes("[lifecycle]")) {
114
+ return;
115
+ }
116
+ const write = console[level] ?? console.log;
117
+ // The worker's logs carry %c directives in args[0] with CSS in the following
118
+ // args, so the tag has to go inside the format string or the CSS prints raw.
119
+ if (typeof args[0] === "string") {
120
+ write(`[automerge-worker] ${args[0]}`, ...args.slice(1));
121
+ }
122
+ else {
123
+ write("[automerge-worker]", ...args);
124
+ }
125
+ }
126
+ function createSubductionIoPort() {
127
+ const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
128
+ type: "module",
129
+ name: "subduction-websocket",
130
+ });
131
+ // This worker carries the websocket to the sync server, so a load failure
132
+ // stops sync with no other symptom.
133
+ io.addEventListener("error", (event) => {
134
+ console.error(`[subduction-io] failed to load/run ${SUBDUCTION_IO_WORKER_URL}:`, describeErrorEvent(event));
69
135
  });
136
+ io.port.addEventListener("messageerror", (event) => {
137
+ console.error("[subduction-io] undeserializable message:", event);
138
+ });
139
+ return io.port;
70
140
  }
71
- // ── The automerge worker ───────────────────────────────────────────────
72
- // The automerge repo lives in a SharedWorker (not the service worker). One
73
- // instance is shared by every tab and lives exactly as long as any tab
74
- // does, so there's no keepalive ping and no restart detection: if we're
75
- // alive, it's alive. Repo sync ports are passed to it over its connect
76
- // port; it talks to the service worker over a BroadcastChannel.
77
- let automergeWorkerPath = "/automerge-worker.js";
78
- let automergeWorker;
79
- // SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
80
- // spawn workers from inside a SharedWorker, so each tab offers this proxy's
81
- // port to the automerge worker (which requests one via its port provider).
82
- // Being a SharedWorker itself, the proxy — and the donated worker↔worker
83
- // port outlives the donor tab. Emitted at /packages/... via externals.ts.
84
- const SUBDUCTION_IO_WORKER_URL = "/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
85
- // Bench toggles for the subduction socket (see getSubductionEndpoints in
86
- // automerge-worker.ts), passed as query params because SharedWorker scope
87
- // has no localStorage which also gives each configuration its own worker
88
- // instance, so bench arms can't share state.
89
- // localStorage["patchwork:ws-mode"] = "inline" → socket on worker thread
90
- // localStorage["patchwork:ws-window"] = "16" → WorkerWebSocketEndpoint
91
- // windowFrames override
92
- function workerBenchParams() {
93
- const params = new URLSearchParams();
141
+ /**
142
+ * Build a replacement worker and re-wire everything a live tab holds against
143
+ * it: console forwarding and port donation (both re-done by
144
+ * getAutomergeWorker), the per-doc sync-state subscriptions, and every
145
+ * subscriber's repo port. The new instance boots with cold state.
146
+ */
147
+ async function recoverAutomergeWorker(reason, deadWorker) {
148
+ if (deadWorker !== automergeWorker)
149
+ return;
150
+ if (recoveringWorker)
151
+ return;
152
+ const now = Date.now();
153
+ if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS)
154
+ return;
155
+ recoveringWorker = true;
156
+ lastWorkerRecoveryAt = now;
157
+ lifecycleLog("recreating the automerge SharedWorker (%s)", reason);
94
158
  try {
95
- for (const [key, param] of [
96
- ["patchwork:ws-mode", "ws-mode"],
97
- ["patchwork:ws-window", "ws-window"],
98
- ]) {
99
- const value = globalThis.localStorage?.getItem(key);
100
- if (value)
101
- params.set(param, value);
159
+ disposeWorkerDeathDetection?.();
160
+ disposeWorkerDeathDetection = undefined;
161
+ automergeWorker = undefined;
162
+ try {
163
+ deadWorker.port.close();
102
164
  }
103
- }
104
- catch {
105
- // No localStorage (shouldn't happen in a tab) — use defaults.
106
- }
107
- const qs = params.toString();
108
- return qs ? `?${qs}` : "";
109
- }
110
- export function getAutomergeWorker() {
111
- if (!automergeWorker) {
112
- const workerUrl = `${automergeWorkerPath}${workerBenchParams()}`;
113
- automergeWorker = new SharedWorker(workerUrl, {
114
- name: "patchwork-automerge",
115
- type: "module",
116
- });
117
- // Control replies (port-ready &c) come back on this port, so it needs
118
- // start() — we listen with addEventListener, not onmessage.
119
- automergeWorker.port.start();
120
- // Surface the SharedWorker's console output and uncaught errors in this
121
- // tab's console (it has its own console that's awkward to find otherwise).
122
- automergeWorker.port.addEventListener("message", (event) => {
123
- if (event.data?.type === "sync-state") {
124
- dispatchSyncState(event.data);
125
- return;
126
- }
127
- if (isWorkerErrorMessage(event.data)) {
128
- // Crash/skew reports relayed from the subduction io proxy (e.g.
129
- // protocol-mismatch from a stale SW-cached worker chunk). Surface
130
- // loudly — these otherwise only exist in chrome://inspect.
131
- console.error("[subduction-io]", event.data);
132
- return;
133
- }
134
- if (event.data?.type === "drift-samples") {
135
- // Keepalive-drift samples from the worker's bench probe. Kept on a
136
- // bounded window global for the Playwright bench to harvest.
137
- const sink = (window.__driftSamples ??= []);
138
- sink.push(...event.data.samples);
139
- if (sink.length > 10_000)
140
- sink.splice(0, sink.length - 10_000);
141
- return;
142
- }
143
- if (event.data?.type !== "console")
144
- return;
145
- const { level, args } = event.data;
146
- // Gate forwarded [lifecycle] logs on the toggle too.
147
- if (!lifecycleLoggingEnabled() &&
148
- typeof args?.[0] === "string" &&
149
- args[0].includes("[lifecycle]")) {
150
- return;
151
- }
152
- const fn = console[level] ?? console.log;
153
- // The worker's logs (debug library, the worker's own log()) carry %c
154
- // format directives in args[0] with CSS in the following args. Prefix
155
- // the tag into the format string rather than as a separate positional,
156
- // or the %c would no longer be in arg 0 and the CSS would print raw.
157
- if (typeof args[0] === "string") {
158
- fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
165
+ catch { }
166
+ const fresh = getAutomergeWorker();
167
+ for (const documentId of syncStateListeners.keys()) {
168
+ fresh.port.postMessage({ type: "sync-sub", documentId });
169
+ }
170
+ for (const listener of repoChannelListeners) {
171
+ try {
172
+ const generation = workerGeneration;
173
+ const port = await openRepoChannel();
174
+ // Replaced again while we waited — the newer recovery re-delivers.
175
+ if (generation !== workerGeneration)
176
+ break;
177
+ await listener(port);
159
178
  }
160
- else {
161
- fn("[automerge-worker]", ...args);
179
+ catch (err) {
180
+ console.error("failed to re-wire a repo channel after worker recovery", err);
162
181
  }
163
- });
164
- automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
165
- // Offer the subduction io proxy's port; the worker's port provider pulls
166
- // it when (re)constructing its WorkerWebSocketEndpoint.
167
- donatePort(automergeWorker.port, () => {
168
- const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
169
- type: "module",
170
- name: "subduction-websocket",
171
- });
172
- return io.port;
173
- });
174
- installWorkerDeathDetection(automergeWorker);
182
+ }
183
+ }
184
+ finally {
185
+ recoveringWorker = false;
175
186
  }
176
- return automergeWorker;
177
187
  }
178
- /**
179
- * Detect when the automerge SharedWorker dies or restarts: control-port close,
180
- * worker error, changed instance id, or an unanswered heartbeat while the tab
181
- * is visible (a miss while hidden is more likely suspension). [lifecycle]-tagged.
182
- */
188
+ // A silent port is not proof of death: the worker may still be evaluating its
189
+ // module graph, or be busy with wasm/sync work. In both cases every queued
190
+ // message including the repo ports the network adapters ride on is
191
+ // delivered once it catches up, and tearing the port down would lose them. So
192
+ // silence only starts a non-destructive probe: a second connection to the same
193
+ // instance. Only if the probe gets a `hello` while this port stays silent do we
194
+ // know the instance is alive but our port is stranded, and recover.
195
+ const HEARTBEAT_MS = 5_000;
196
+ const HEARTBEAT_TIMEOUT_MS = 25_000;
197
+ // An idle worker hellos within milliseconds of connecting, so before first
198
+ // contact the budget is tighter — probing early rescues stranded boots fast.
199
+ const FIRST_CONTACT_TIMEOUT_MS = 4_000;
200
+ // After a slow boot both connections hello at roughly the same moment and
201
+ // cross-port delivery order isn't guaranteed, so give the suspect this long to
202
+ // also speak before concluding it's stranded.
203
+ const PROBE_GRACE_MS = 500;
183
204
  function installWorkerDeathDetection(worker) {
184
- const stamp = () => new Date().toISOString();
185
- const warn = (msg) => {
186
- if (lifecycleLoggingEnabled())
187
- console.warn(`[lifecycle] ${stamp()} ${msg}`);
188
- };
189
- const info = (msg) => {
190
- if (lifecycleLoggingEnabled())
191
- console.info(`[lifecycle] ${stamp()} ${msg}`);
192
- };
193
205
  let instanceId;
194
- let lastPongAt = Date.now();
206
+ let lastHeardAt = Date.now();
195
207
  let warnedUnresponsive = false;
208
+ let warnedSendFailed = false;
209
+ let disposed = false;
210
+ let probe;
211
+ let seq = 0;
212
+ const closeProbe = () => {
213
+ if (!probe)
214
+ return;
215
+ try {
216
+ probe.port.close();
217
+ }
218
+ catch { }
219
+ probe = undefined;
220
+ };
196
221
  worker.port.addEventListener("message", (event) => {
197
222
  const data = event.data;
198
223
  if (data?.type !== "hello" && data?.type !== "pong")
199
224
  return;
200
- if (data.type === "pong") {
201
- lastPongAt = Date.now();
202
- warnedUnresponsive = false;
203
- }
225
+ lastHeardAt = Date.now();
226
+ warnedUnresponsive = false;
227
+ closeProbe();
204
228
  if (instanceId === undefined) {
205
229
  instanceId = data.instanceId;
206
- info(`automerge SharedWorker instance ${data.instanceId} (via ${data.type})`);
230
+ lifecycleLog("automerge SharedWorker instance %s (via %s)", data.instanceId, data.type);
207
231
  }
208
232
  else if (data.instanceId && data.instanceId !== instanceId) {
209
- warn(`automerge SharedWorker RESTARTED (instance ${data.instanceId}, ` +
210
- `was ${instanceId}) — fresh peerId + cold state; docs need re-subscribe`);
233
+ lifecycleLog("automerge SharedWorker instance changed (instance %s, was %s)", data.instanceId, instanceId);
211
234
  instanceId = data.instanceId;
212
235
  }
213
236
  });
214
- // Fires when the SharedWorker is destroyed (where supported).
215
237
  worker.port.addEventListener("close", () => {
216
- warn("automerge SharedWorker control port CLOSED — worker terminated");
238
+ if (disposed)
239
+ return;
240
+ lifecycleLog("automerge SharedWorker control port closed");
241
+ void recoverAutomergeWorker("control port closed", worker);
217
242
  });
218
- worker.addEventListener("error", event => {
219
- warn(`automerge SharedWorker error: ${event.message || event}`);
243
+ // Not gated on the debug namespace: a worker that fails to load never replies
244
+ // to anything, and this is the only signal that says so.
245
+ worker.addEventListener("error", (event) => {
246
+ console.error("automerge SharedWorker error:", describeErrorEvent(event));
220
247
  });
221
- // A missed pong while the tab is visible means the worker likely died (an
222
- // active tab keeps it alive); a miss while hidden is more likely suspension.
223
- const HEARTBEAT_MS = 10_000;
224
- const HEARTBEAT_TIMEOUT_MS = 25_000;
225
- let seq = 0;
226
- setInterval(() => {
248
+ const startProbe = (reason) => {
249
+ if (probe || disposed)
250
+ return;
251
+ lifecycleLog("automerge SharedWorker %s; probing with a second connection", reason);
252
+ const startedAt = Date.now();
253
+ const p = new SharedWorker(automergeWorkerPath, {
254
+ name: "patchwork-automerge",
255
+ type: "module",
256
+ });
257
+ probe = p;
258
+ p.port.start();
259
+ p.port.addEventListener("message", (event) => {
260
+ if (event.data?.type !== "hello")
261
+ return;
262
+ setTimeout(() => {
263
+ if (disposed || probe !== p)
264
+ return;
265
+ closeProbe();
266
+ // The suspect spoke while the probe ran: it was merely busy, and
267
+ // everything queued on it has been delivered.
268
+ if (lastHeardAt >= startedAt)
269
+ return;
270
+ void recoverAutomergeWorker(`port unresponsive on a live worker (${reason}; probe confirmed)`, worker);
271
+ }, PROBE_GRACE_MS);
272
+ });
273
+ // No hello on the probe means the instance is loading or busy. The probe
274
+ // waits indefinitely rather than tearing anything down on a timer.
275
+ };
276
+ const heartbeat = setInterval(() => {
227
277
  try {
228
278
  worker.port.postMessage({ type: "ping", id: ++seq });
229
279
  }
230
- catch {
231
- // Port already torn down the "close" handler covers that case.
280
+ catch (error) {
281
+ // Without this a failed send is indistinguishable from a dead worker.
282
+ if (!warnedSendFailed) {
283
+ warnedSendFailed = true;
284
+ console.error("automerge SharedWorker ping send threw", error);
285
+ }
232
286
  }
233
- const silentMs = Date.now() - lastPongAt;
287
+ const neverHeard = instanceId === undefined;
288
+ const silentMs = Date.now() - lastHeardAt;
289
+ const timeoutMs = neverHeard
290
+ ? FIRST_CONTACT_TIMEOUT_MS
291
+ : HEARTBEAT_TIMEOUT_MS;
292
+ if (silentMs <= timeoutMs)
293
+ return;
294
+ // First contact probes regardless of visibility: SharedWorkers don't
295
+ // suspend with the tab, and the probe destroys nothing. Post-contact
296
+ // silence defers to visibility, since a hidden page's throttling can fake
297
+ // it.
234
298
  const visible = typeof document === "undefined" || document.visibilityState === "visible";
235
- if (silentMs > HEARTBEAT_TIMEOUT_MS && visible && !warnedUnresponsive) {
299
+ if (!neverHeard && !visible)
300
+ return;
301
+ const seconds = Math.round(silentMs / 1000);
302
+ const reason = neverHeard
303
+ ? `no hello ~${seconds}s after connecting`
304
+ : `no pong for ~${seconds}s`;
305
+ if (!warnedUnresponsive) {
236
306
  warnedUnresponsive = true;
237
- warn(`automerge SharedWorker UNRESPONSIVE ~${Math.round(silentMs / 1000)}s ` +
238
- `while tab visible — likely died/crashed`);
307
+ lifecycleLog("automerge SharedWorker %s (tab visible)", reason);
239
308
  }
309
+ startProbe(reason);
240
310
  }, HEARTBEAT_MS);
311
+ return () => {
312
+ disposed = true;
313
+ clearInterval(heartbeat);
314
+ closeProbe();
315
+ };
241
316
  }
242
317
  const syncStateListeners = new Map();
243
318
  function dispatchSyncState(update) {
244
- const listeners = syncStateListeners.get(update.documentId);
245
- if (!listeners)
246
- return;
247
- for (const listener of listeners) {
319
+ for (const listener of syncStateListeners.get(update.documentId) ?? []) {
248
320
  try {
249
321
  listener(update);
250
322
  }
@@ -258,23 +330,24 @@ export function subscribeSyncState(documentId, listener) {
258
330
  let listeners = syncStateListeners.get(documentId);
259
331
  if (!listeners) {
260
332
  syncStateListeners.set(documentId, (listeners = new Set()));
261
- // First local watcher for this doc — ask the worker to start pushing it.
262
333
  worker.port.postMessage({ type: "sync-sub", documentId });
263
334
  }
264
335
  listeners.add(listener);
265
336
  let active = true;
266
337
  return () => {
267
338
  if (!active)
268
- return; // idempotent
339
+ return;
269
340
  active = false;
270
341
  const set = syncStateListeners.get(documentId);
271
342
  if (!set)
272
343
  return;
273
344
  set.delete(listener);
274
- if (set.size === 0) {
275
- syncStateListeners.delete(documentId);
276
- worker.port.postMessage({ type: "sync-unsub", documentId });
277
- }
345
+ if (set.size > 0)
346
+ return;
347
+ syncStateListeners.delete(documentId);
348
+ // Unsubscribe from whichever instance is current: recovery replays
349
+ // subscriptions onto a new worker, so it may not be the one captured above.
350
+ automergeWorker?.port.postMessage({ type: "sync-unsub", documentId });
278
351
  };
279
352
  }
280
353
  export function connectClassicSync(server = readClassicSyncServer()) {
@@ -292,135 +365,141 @@ export function connectClassicSync(server = readClassicSyncServer()) {
292
365
  port1.onmessage = (event) => {
293
366
  clearTimeout(timeout);
294
367
  port1.close();
295
- if (event.data?.type === "connect-classic-sync-ready") {
368
+ if (event.data?.type === "connect-classic-sync-ready")
296
369
  resolve();
297
- }
298
- else {
370
+ else
299
371
  reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
300
- }
301
372
  };
302
373
  worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
303
374
  port2,
304
375
  ]);
305
376
  });
306
377
  }
307
- /** Wait for a registration to have an active worker */
308
- function waitForActive(reg) {
309
- if (reg.active)
310
- return Promise.resolve(reg.active);
311
- const worker = reg.installing || reg.waiting;
312
- if (!worker)
313
- return Promise.reject(new Error("no service worker in registration"));
314
- return new Promise((resolve) => {
315
- worker.addEventListener("statechange", () => {
316
- if (worker.state === "activated")
317
- resolve(worker);
318
- });
319
- });
320
- }
321
- async function openRepoChannel() {
322
- const worker = getAutomergeWorker();
323
- // Send a MessagePort so the worker's repo can sync with this tab, and wait
324
- // for the worker to confirm its repo is constructed before returning. The
325
- // MessageChannel adapter's whenReady() force-resolves after 100ms regardless
326
- // of the other end's state, so it can't be used as a real readiness signal
327
- // on first boot (when the worker still has to fetch wasm and build its repo).
328
- const id = ++nextRepoChannelId;
378
+ function sendRepoPort(id) {
329
379
  const { port1, port2 } = new MessageChannel();
330
- const workerReady = new Promise((resolve, reject) => {
331
- let timeout;
380
+ getAutomergeWorker().port.postMessage({ type: "port", id }, [port2]);
381
+ return port1;
382
+ }
383
+ /**
384
+ * Wait for the worker to confirm its repo is constructed. The MessageChannel
385
+ * adapter's whenReady() force-resolves after 100ms regardless of the other
386
+ * end's state, so it can't serve as a readiness signal on first boot, when the
387
+ * worker still has to fetch wasm and build its repo.
388
+ */
389
+ function awaitPortReady(control, id) {
390
+ return new Promise((resolve, reject) => {
332
391
  const cleanup = () => {
333
392
  clearTimeout(timeout);
334
- worker.port.removeEventListener("message", listener);
393
+ control.removeEventListener("message", listener);
335
394
  };
336
395
  const listener = (event) => {
337
396
  if (event.data?.id !== id)
338
397
  return;
339
- if (event.data?.type === "port-ready") {
398
+ if (event.data.type === "port-ready") {
340
399
  cleanup();
341
400
  resolve();
342
401
  }
343
- else if (event.data?.type === "port-failed") {
402
+ else if (event.data.type === "port-failed") {
344
403
  cleanup();
345
404
  reject(new Error(`automerge worker init failed: ${event.data.error}`));
346
405
  }
347
406
  };
348
- worker.port.addEventListener("message", listener);
349
- // Failsafe: don't block boot forever if the worker never replies. Surface
350
- // the issue and let the rest of the site come up rather than hanging on a
351
- // blank page.
352
- timeout = setTimeout(() => {
407
+ control.addEventListener("message", listener);
408
+ const timeout = setTimeout(() => {
353
409
  cleanup();
354
410
  reject(new Error("automerge worker port-ready timeout"));
355
411
  }, 30_000);
356
412
  });
357
- worker.port.postMessage({ type: "port", id }, [port2]);
413
+ }
414
+ async function openRepoChannel() {
415
+ const id = ++nextRepoChannelId;
416
+ const ready = awaitPortReady(getAutomergeWorker().port, id);
417
+ const port = sendRepoPort(id);
358
418
  try {
359
- await workerReady;
419
+ await ready;
360
420
  }
361
421
  catch (err) {
422
+ // Surface the problem and let the rest of the site come up rather than
423
+ // hanging on a blank page.
362
424
  console.warn("proceeding without worker ready ack:", err instanceof Error ? err.message : err);
363
425
  }
364
- return port1;
426
+ return port;
365
427
  }
366
428
  /** Open a fresh repo sync port to the automerge worker (dev console). */
367
429
  function getRepoChannel() {
368
- const worker = getAutomergeWorker();
369
- const { port1, port2 } = new MessageChannel();
370
- worker.port.postMessage({ type: "port", id: ++nextRepoChannelId }, [port2]);
371
- return port1;
430
+ return sendRepoPort(++nextRepoChannelId);
431
+ }
432
+ function waitForActive(reg) {
433
+ if (reg.active)
434
+ return Promise.resolve(reg.active);
435
+ const worker = reg.installing || reg.waiting;
436
+ if (!worker) {
437
+ return Promise.reject(new Error("no service worker in registration"));
438
+ }
439
+ return new Promise((resolve, reject) => {
440
+ worker.addEventListener("statechange", () => {
441
+ if (worker.state === "activated")
442
+ resolve(worker);
443
+ // Without this the promise never settles when an install fails.
444
+ else if (worker.state === "redundant") {
445
+ reject(new Error("service worker became redundant before activating"));
446
+ }
447
+ });
448
+ });
372
449
  }
373
450
  export default async function setupServiceWorker(options) {
374
- // Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
375
- // install / activate markers from the controlling worker are rendered here.
451
+ // Attach the log bridge first so the controlling worker's boot/install/
452
+ // activate markers are rendered here.
376
453
  installServiceWorkerLogForwarding();
377
- localStorage.removeItem(key);
454
+ localStorage.removeItem(CACHE_VERSION_KEY);
455
+ // Cache growth can otherwise trip origin-wide eviction, which would take the
456
+ // Automerge IndexedDB — the user's documents — with it. Chrome/Safari decide
457
+ // silently from site engagement; Firefox may prompt. Denial just means
458
+ // default eviction.
459
+ void navigator.storage?.persist?.().catch(() => { });
378
460
  if (options?.workerPath)
379
461
  automergeWorkerPath = options.workerPath;
380
- // Start the automerge worker right away so it boots (wasm, repo) while the
462
+ // Start the automerge worker now so it boots wasm and its repo while the
381
463
  // service worker installs.
382
464
  const shared = getAutomergeWorker();
383
- // todo delete
384
- const path = options?.path ?? "/service-worker.js";
385
- // No controller at this point means the page loaded without a service
386
- // worker — i.e. this is a first-time install (or a hard reload). Wait for
387
- // activation so the app boots with the SW in control of generated fetches.
388
- const reg = await navigator.serviceWorker.register(path, { type: "module" });
389
- // If there's an update waiting or installing, wait for it to activate
390
- let active = reg.active;
391
- if (reg.installing || reg.waiting) {
392
- active = await waitForActive(reg);
393
- }
465
+ const reg = await navigator.serviceWorker.register(options?.path ?? "/service-worker.js", { type: "module" });
466
+ const active = reg.installing || reg.waiting ? await waitForActive(reg) : reg.active;
394
467
  configureServiceWorker(active);
395
- // Wait for the controller to be available
468
+ // No controller means the page loaded without a service worker — a first-time
469
+ // install or a hard reload. Wait for it so the app boots with the worker in
470
+ // control of generated fetches.
396
471
  if (!navigator.serviceWorker.controller) {
397
472
  await new Promise((resolve) => {
398
473
  navigator.serviceWorker.addEventListener("controllerchange", () => resolve(), { once: true });
399
474
  });
400
475
  }
401
- // A replacement service worker boots with the default cache name re-send
402
- // its configuration whenever a new one takes control.
476
+ // A replacement worker boots with the default cache name, so reconfigure
477
+ // whenever a new one takes control.
403
478
  navigator.serviceWorker.addEventListener("controllerchange", () => {
404
479
  configureServiceWorker(navigator.serviceWorker.controller);
405
480
  });
406
481
  console.log("service worker alive, loading %c patchwork system ", "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px");
407
- // todon't
408
- window.killsw = () => {
409
- if (automergeWorker) {
410
- automergeWorker.port.close();
411
- automergeWorker = undefined;
412
- }
413
- };
414
482
  return {
415
483
  shared,
416
484
  connectClassicSync,
417
485
  getRepoChannel,
418
486
  subscribeSyncState,
487
+ // Called once with the boot port. If the automerge worker later dies and is
488
+ // recreated, the listener is called again with a fresh port — treat every
489
+ // call as "(re)wire your repo's sync onto this port".
419
490
  async subscribeToRepoChannel(listener) {
420
- // The automerge worker outlives the page, so unlike the old in-service-
421
- // worker repo there's nothing to reconnect: one port, handed over once.
422
- await listener(await openRepoChannel());
423
- return () => { };
491
+ repoChannelListeners.add(listener);
492
+ const generation = workerGeneration;
493
+ const port = await openRepoChannel();
494
+ // If the worker was replaced while this channel was opening, recovery has
495
+ // already delivered a good port to this listener — drop the stale one
496
+ // rather than wiring the repo to a dead channel.
497
+ if (generation === workerGeneration)
498
+ await listener(port);
499
+ return () => {
500
+ repoChannelListeners.delete(listener);
501
+ };
424
502
  },
425
503
  };
426
504
  }
505
+ window.bumpServiceWorkerCache = bumpServiceWorkerCache;