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