@inkandswitch/patchwork-bootloader 0.4.3 → 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,139 +17,193 @@ 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 normally lives as long as any tab
93
- // does — but browsers do reap SharedWorkers under memory pressure, so we
94
- // heartbeat it and rebuild everything if it dies (see
95
- // recoverAutomergeWorker). Repo sync ports are passed to it over its
96
- // connect 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.
97
71
 
98
72
  let automergeWorkerPath = "/automerge-worker.js";
99
73
  let automergeWorker: SharedWorker | undefined;
100
- // Bumped whenever a new SharedWorker is constructed. A repo port opened
101
- // against instance N is stale once instance N+1 exists (its channel ends in
102
- // a dead worker), so deliveries are guarded on the generation they started in.
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.
103
76
  let workerGeneration = 0;
104
- // Tears down the current worker's heartbeat when it's replaced.
105
77
  let disposeWorkerDeathDetection: (() => void) | undefined;
106
- // Every subscribeToRepoChannel listener, kept so a recovered worker can hand
107
- // each subscriber a fresh repo port.
108
78
  const repoChannelListeners = new Set<ServiceWorkerRepoChannelListener>();
109
79
  let recoveringWorker = false;
110
80
  let lastWorkerRecoveryAt = 0;
111
- // Below this spacing, skip: if the fresh worker is dead too, its own
112
- // heartbeat re-triggers recovery later rather than spinning in a tight loop.
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.
113
83
  const RECOVERY_MIN_INTERVAL_MS = 15_000;
84
+ let nextRepoChannelId = 0;
85
+
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 port — outlives the donor tab.
90
+ const SUBDUCTION_IO_WORKER_URL =
91
+ "/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
92
+
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;
121
+ }
122
+
123
+ function handleWorkerMessage(event: MessageEvent): void {
124
+ const data = event.data;
125
+
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);
155
+ }
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;
175
+ }
114
176
 
115
177
  /**
116
- * The automerge SharedWorker died (browser reaped it, or it crashed): build a
117
- * replacement and re-wire everything a live tab holds against it — console
118
- * forwarding and the io-proxy port donation (both re-done by
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
119
180
  * getAutomergeWorker), the per-doc sync-state subscriptions, and every
120
- * subscriber's repo sync port. The new instance boots with cold state; its
121
- * repo is reconstructed on the first port we send.
181
+ * subscriber's repo port. The new instance boots with cold state.
122
182
  */
123
183
  async function recoverAutomergeWorker(
124
184
  reason: string,
125
185
  deadWorker: SharedWorker
126
186
  ): Promise<void> {
127
- if (deadWorker !== automergeWorker) return; // already replaced
187
+ if (deadWorker !== automergeWorker) return;
128
188
  if (recoveringWorker) return;
129
189
  const now = Date.now();
130
190
  if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS) return;
131
191
  recoveringWorker = true;
132
192
  lastWorkerRecoveryAt = now;
133
- console.warn(
134
- `[lifecycle] ${new Date().toISOString()} recreating the automerge ` +
135
- `SharedWorker (${reason})`
136
- );
193
+ lifecycleLog("recreating the automerge SharedWorker (%s)", reason);
194
+
137
195
  try {
138
196
  disposeWorkerDeathDetection?.();
139
197
  disposeWorkerDeathDetection = undefined;
140
198
  automergeWorker = undefined;
141
199
  try {
142
200
  deadWorker.port.close();
143
- } catch {
144
- // Port already dead.
145
- }
201
+ } catch {}
202
+
146
203
  const fresh = getAutomergeWorker();
147
- // The fresh instance knows nothing — replay every doc subscription.
148
204
  for (const documentId of syncStateListeners.keys()) {
149
205
  fresh.port.postMessage({ type: "sync-sub", documentId });
150
206
  }
151
- // Hand every repo-channel subscriber a fresh port so their repos sync
152
- // again (the old adapters sit on dead MessagePorts).
153
207
  for (const listener of repoChannelListeners) {
154
208
  try {
155
209
  const generation = workerGeneration;
@@ -169,225 +223,83 @@ async function recoverAutomergeWorker(
169
223
  }
170
224
  }
171
225
 
172
- // SharedWorker proxy entry that owns the subduction WebSocket. Chrome can't
173
- // spawn workers from inside a SharedWorker, so each tab offers this proxy's
174
- // port to the automerge worker (which requests one via its port provider).
175
- // Being a SharedWorker itself, the proxy and the donated worker↔worker
176
- // port outlives the donor tab. Emitted at /packages/... via externals.ts.
177
- const SUBDUCTION_IO_WORKER_URL =
178
- "/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js";
179
-
180
- // Bench toggles for the subduction socket (see getSubductionEndpoints in
181
- // automerge-worker.ts), passed as query params because SharedWorker scope
182
- // has no localStorage which also gives each configuration its own worker
183
- // instance, so bench arms can't share state.
184
- // localStorage["patchwork:ws-mode"] = "inline" → socket on worker thread
185
- // localStorage["patchwork:ws-window"] = "16" → WorkerWebSocketEndpoint
186
- // windowFrames override
187
- function workerBenchParams(): string {
188
- const params = new URLSearchParams();
189
- try {
190
- for (const [key, param] of [
191
- ["patchwork:ws-mode", "ws-mode"],
192
- ["patchwork:ws-window", "ws-window"],
193
- ] as const) {
194
- const value = globalThis.localStorage?.getItem(key);
195
- if (value) params.set(param, value);
196
- }
197
- } catch {
198
- // No localStorage (shouldn't happen in a tab) — use defaults.
199
- }
200
- const qs = params.toString();
201
- return qs ? `?${qs}` : "";
202
- }
203
-
204
- function automergeWorkerUrl(): string {
205
- return `${automergeWorkerPath}${workerBenchParams()}`;
206
- }
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;
207
242
 
208
- export function getAutomergeWorker(): SharedWorker {
209
- if (!automergeWorker) {
210
- workerGeneration++;
211
- automergeWorker = new SharedWorker(automergeWorkerUrl(), {
212
- name: "patchwork-automerge",
213
- type: "module",
214
- });
215
- // Fired when a message arrives that can't be structured-deserialized —
216
- // e.g. a transfer list that named something unclonable. Silent otherwise:
217
- // the message is simply dropped, which looks identical to a worker that
218
- // never replied. Always loud, not gated on the lifecycle toggle.
219
- automergeWorker.port.addEventListener("messageerror", (event) => {
220
- console.error(
221
- "[automerge-worker] undeserializable message from worker:",
222
- event
223
- );
224
- });
225
- // Control replies (port-ready &c) come back on this port, so it needs
226
- // start() — we listen with addEventListener, not onmessage.
227
- automergeWorker.port.start();
228
- // Surface the SharedWorker's console output and uncaught errors in this
229
- // tab's console (it has its own console that's awkward to find otherwise).
230
- automergeWorker.port.addEventListener("message", (event: MessageEvent) => {
231
- if (event.data?.type === "sync-state") {
232
- dispatchSyncState(event.data as SyncStateDocMessage);
233
- return;
234
- }
235
- if (isWorkerErrorMessage(event.data)) {
236
- // Crash/skew reports relayed from the subduction io proxy (e.g.
237
- // protocol-mismatch from a stale SW-cached worker chunk). Surface
238
- // loudly — these otherwise only exist in chrome://inspect.
239
- console.error("[subduction-io]", event.data);
240
- return;
241
- }
242
- if (event.data?.type === "drift-samples") {
243
- // Keepalive-drift samples from the worker's bench probe. Kept on a
244
- // bounded window global for the Playwright bench to harvest.
245
- const sink = ((window as any).__driftSamples ??= []) as number[];
246
- sink.push(...event.data.samples);
247
- if (sink.length > 10_000) sink.splice(0, sink.length - 10_000);
248
- return;
249
- }
250
- if (event.data?.type !== "console") return;
251
- const { level, args } = event.data;
252
- // Gate forwarded [lifecycle] logs on the toggle too.
253
- if (
254
- !lifecycleLoggingEnabled() &&
255
- typeof args?.[0] === "string" &&
256
- args[0].includes("[lifecycle]")
257
- ) {
258
- return;
259
- }
260
- const fn = (console as any)[level] ?? console.log;
261
- // The worker's logs (debug library, the worker's own log()) carry %c
262
- // format directives in args[0] with CSS in the following args. Prefix
263
- // the tag into the format string rather than as a separate positional,
264
- // or the %c would no longer be in arg 0 and the CSS would print raw.
265
- if (typeof args[0] === "string") {
266
- fn(`[automerge-worker] ${args[0]}`, ...args.slice(1));
267
- } else {
268
- fn("[automerge-worker]", ...args);
269
- }
270
- });
271
- automergeWorker.port.postMessage({ type: "debug", debug: workerDebugging });
272
-
273
- // Offer the subduction io proxy's port; the worker's port provider pulls
274
- // it when (re)constructing its WorkerWebSocketEndpoint.
275
- donatePort(automergeWorker.port, () => {
276
- const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, {
277
- type: "module",
278
- name: "subduction-websocket",
279
- });
280
- // This worker carries the websocket to the sync server, so if it fails
281
- // to load, sync silently stops with no other symptom.
282
- io.addEventListener("error", (event) => {
283
- const error = event as ErrorEvent;
284
- console.error(
285
- `[subduction-io] failed to load/run ${SUBDUCTION_IO_WORKER_URL}:`,
286
- error.message || event,
287
- error.filename ? `(${error.filename}:${error.lineno})` : ""
288
- );
289
- });
290
- io.port.addEventListener("messageerror", (event) => {
291
- console.error("[subduction-io] undeserializable message:", event);
292
- });
293
- return io.port;
294
- });
295
-
296
- disposeWorkerDeathDetection = installWorkerDeathDetection(automergeWorker);
297
- }
298
- return automergeWorker;
299
- }
300
-
301
- /**
302
- * Detect when the automerge SharedWorker dies or its control port goes deaf.
303
- *
304
- * Silence alone is NOT proof of death: the worker may still be evaluating its
305
- * (large) module graph on a cold boot, or its single thread may be busy with
306
- * wasm/sync work — in both cases every queued message (including the repo
307
- * ports the network adapters ride on) is delivered fine once it catches up,
308
- * and tearing the port down would *lose* them. So silence only starts a
309
- * non-destructive PROBE: a second SharedWorker connection to the same
310
- * instance. Only when the probe gets a `hello` while this port stays silent do
311
- * we know the instance is alive-and-responsive but our port is stranded (a
312
- * failure mode observed in the wild) — or was replaced — and recovery is
313
- * warranted. A `close` event (where supported) is a definitive death signal
314
- * and recovers immediately. [lifecycle]-tagged. Returns a dispose that stops
315
- * the heartbeat and any outstanding probe (called when this worker is
316
- * replaced).
317
- */
318
243
  function installWorkerDeathDetection(worker: SharedWorker): () => void {
319
- const stamp = () => new Date().toISOString();
320
- const warn = (msg: string) => {
321
- if (lifecycleLoggingEnabled()) console.warn(`[lifecycle] ${stamp()} ${msg}`);
322
- };
323
- const info = (msg: string) => {
324
- if (lifecycleLoggingEnabled()) console.info(`[lifecycle] ${stamp()} ${msg}`);
325
- };
326
-
327
244
  let instanceId: string | undefined;
328
245
  let lastHeardAt = Date.now();
329
246
  let warnedUnresponsive = false;
247
+ let warnedSendFailed = false;
330
248
  let disposed = false;
331
249
  let probe: SharedWorker | undefined;
250
+ let seq = 0;
332
251
 
333
252
  const closeProbe = () => {
334
253
  if (!probe) return;
335
254
  try {
336
255
  probe.port.close();
337
- } catch {
338
- // Already closed.
339
- }
256
+ } catch {}
340
257
  probe = undefined;
341
258
  };
342
- let warnedSendFailed = false;
343
- let pingsSent = 0;
344
259
 
345
260
  worker.port.addEventListener("message", (event: MessageEvent) => {
346
261
  const data = event.data;
347
262
  if (data?.type !== "hello" && data?.type !== "pong") return;
348
263
  lastHeardAt = Date.now();
349
264
  warnedUnresponsive = false;
350
- // The port spoke — any outstanding probe is moot.
351
265
  closeProbe();
352
266
  if (instanceId === undefined) {
353
267
  instanceId = data.instanceId;
354
- 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
+ );
355
273
  } else if (data.instanceId && data.instanceId !== instanceId) {
356
- warn(
357
- `automerge SharedWorker instance changed (instance ${data.instanceId}, ` +
358
- `was ${instanceId})`
274
+ lifecycleLog(
275
+ "automerge SharedWorker instance changed (instance %s, was %s)",
276
+ data.instanceId,
277
+ instanceId
359
278
  );
360
279
  instanceId = data.instanceId;
361
280
  }
362
281
  });
363
282
 
364
- // Fires when the SharedWorker is destroyed (where supported).
365
283
  worker.port.addEventListener("close", () => {
366
284
  if (disposed) return;
367
- warn("automerge SharedWorker control port closed");
285
+ lifecycleLog("automerge SharedWorker control port closed");
368
286
  void recoverAutomergeWorker("control port closed", worker);
369
287
  });
370
288
 
371
- // Not gated on the lifecycle toggle: a worker that fails to load never
372
- // replies to anything, and this is the only signal that says so.
373
- worker.addEventListener("error", event => {
374
- const error = event as ErrorEvent;
375
- console.error(
376
- `[lifecycle] ${stamp()} automerge SharedWorker error:`,
377
- error.message || event,
378
- error.filename ? `(${error.filename}:${error.lineno})` : ""
379
- );
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));
380
293
  });
381
294
 
382
- // On probe hello, give the suspect port this long to also speak before
383
- // concluding it's stranded: after a slow worker boot both connections hello
384
- // at roughly the same moment and cross-port delivery order isn't guaranteed.
385
- const PROBE_GRACE_MS = 500;
386
295
  const startProbe = (reason: string) => {
387
296
  if (probe || disposed) return;
388
- warn(`automerge SharedWorker ${reason}; probing with a second connection`);
297
+ lifecycleLog(
298
+ "automerge SharedWorker %s; probing with a second connection",
299
+ reason
300
+ );
389
301
  const startedAt = Date.now();
390
- const p = new SharedWorker(automergeWorkerUrl(), {
302
+ const p = new SharedWorker(automergeWorkerPath, {
391
303
  name: "patchwork-automerge",
392
304
  type: "module",
393
305
  });
@@ -396,10 +308,10 @@ function installWorkerDeathDetection(worker: SharedWorker): () => void {
396
308
  p.port.addEventListener("message", (event: MessageEvent) => {
397
309
  if (event.data?.type !== "hello") return;
398
310
  setTimeout(() => {
399
- if (disposed || probe !== p) return; // superseded or torn down
311
+ if (disposed || probe !== p) return;
400
312
  closeProbe();
401
- // The suspect spoke while (or just after) the probe ran: it was
402
- // merely slow/busy, and everything queued on it has been delivered.
313
+ // The suspect spoke while the probe ran: it was merely busy, and
314
+ // everything queued on it has been delivered.
403
315
  if (lastHeardAt >= startedAt) return;
404
316
  void recoverAutomergeWorker(
405
317
  `port unresponsive on a live worker (${reason}; probe confirmed)`,
@@ -407,56 +319,45 @@ function installWorkerDeathDetection(worker: SharedWorker): () => void {
407
319
  );
408
320
  }, PROBE_GRACE_MS);
409
321
  });
410
- // No hello on the probe means the instance is loading or busy (the probe
411
- // waits indefinitely its hello triggers the check above whenever it
412
- // lands) — never tear anything down on a timer.
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.
413
324
  };
414
325
 
415
- // A silent-too-long port while the tab is visible starts a probe (a miss
416
- // while hidden is more likely suspension). Before first contact the budget
417
- // is tighter: an idle worker hellos within milliseconds of connecting, so
418
- // probing early costs nothing and rescues genuinely stranded boots fast.
419
- const HEARTBEAT_MS = 5_000;
420
- const HEARTBEAT_TIMEOUT_MS = 25_000;
421
- const FIRST_CONTACT_TIMEOUT_MS = 4_000;
422
- let seq = 0;
423
326
  const heartbeat = setInterval(() => {
424
327
  try {
425
328
  worker.port.postMessage({ type: "ping", id: ++seq });
426
- pingsSent++;
427
329
  } catch (error) {
428
- // Swallowing this makes a failed send indistinguishable from a dead
429
- // worker in the "no pong" warning below. Once, not every heartbeat.
330
+ // Without this a failed send is indistinguishable from a dead worker.
430
331
  if (!warnedSendFailed) {
431
332
  warnedSendFailed = true;
432
- console.error(
433
- `[lifecycle] ${stamp()} automerge SharedWorker ping send threw ` +
434
- `after ${pingsSent} sent:`,
435
- error
436
- );
333
+ console.error("automerge SharedWorker ping send threw", error);
437
334
  }
438
335
  }
336
+
439
337
  const neverHeard = instanceId === undefined;
440
338
  const silentMs = Date.now() - lastHeardAt;
441
339
  const timeoutMs = neverHeard
442
340
  ? FIRST_CONTACT_TIMEOUT_MS
443
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.
444
348
  const visible =
445
349
  typeof document === "undefined" || document.visibilityState === "visible";
446
- // First contact probes regardless of visibility: SharedWorkers don't
447
- // suspend with tab visibility, boots in background tabs must still get
448
- // rescued, and the probe destroys nothing. Post-contact silence defers to
449
- // visibility, since a hidden page's own throttling can fake it.
450
- if (silentMs > timeoutMs && (neverHeard || visible)) {
451
- const reason = neverHeard
452
- ? `no hello ~${Math.round(silentMs / 1000)}s after connecting`
453
- : `no pong for ~${Math.round(silentMs / 1000)}s`;
454
- if (!warnedUnresponsive) {
455
- warnedUnresponsive = true;
456
- warn(`automerge SharedWorker ${reason} (tab visible)`);
457
- }
458
- startProbe(reason);
459
- }
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) {
357
+ warnedUnresponsive = true;
358
+ lifecycleLog("automerge SharedWorker %s (tab visible)", reason);
359
+ }
360
+ startProbe(reason);
460
361
  }, HEARTBEAT_MS);
461
362
 
462
363
  return () => {
@@ -466,18 +367,13 @@ function installWorkerDeathDetection(worker: SharedWorker): () => void {
466
367
  };
467
368
  }
468
369
 
469
- // ── Sync-state subscriptions ────────────────────────────────────────────
470
- // The automerge worker pushes per-document heads only to the tabs that ask for
471
- // them (see SyncStateDocMessage). We ref-count locally so several callers in
472
- // this tab can watch the same doc with a single worker subscription, and tear
473
- // 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.
474
372
  type SyncStateListener = (update: SyncStateDocMessage) => void;
475
373
  const syncStateListeners = new Map<string, Set<SyncStateListener>>();
476
374
 
477
375
  function dispatchSyncState(update: SyncStateDocMessage): void {
478
- const listeners = syncStateListeners.get(update.documentId);
479
- if (!listeners) return;
480
- for (const listener of listeners) {
376
+ for (const listener of syncStateListeners.get(update.documentId) ?? []) {
481
377
  try {
482
378
  listener(update);
483
379
  } catch (err) {
@@ -494,25 +390,22 @@ export function subscribeSyncState(
494
390
  let listeners = syncStateListeners.get(documentId);
495
391
  if (!listeners) {
496
392
  syncStateListeners.set(documentId, (listeners = new Set()));
497
- // First local watcher for this doc — ask the worker to start pushing it.
498
393
  worker.port.postMessage({ type: "sync-sub", documentId });
499
394
  }
500
395
  listeners.add(listener);
501
396
 
502
397
  let active = true;
503
398
  return () => {
504
- if (!active) return; // idempotent
399
+ if (!active) return;
505
400
  active = false;
506
401
  const set = syncStateListeners.get(documentId);
507
402
  if (!set) return;
508
403
  set.delete(listener);
509
- if (set.size === 0) {
510
- syncStateListeners.delete(documentId);
511
- // The worker may have been replaced since we subscribed (recovery
512
- // replays subscriptions onto the new instance) unsubscribe from
513
- // whichever instance is current, not the one captured above.
514
- automergeWorker?.port.postMessage({ type: "sync-unsub", documentId });
515
- }
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 });
516
409
  };
517
410
  }
518
411
 
@@ -536,11 +429,9 @@ export function connectClassicSync(
536
429
  port1.onmessage = (event) => {
537
430
  clearTimeout(timeout);
538
431
  port1.close();
539
- if (event.data?.type === "connect-classic-sync-ready") {
540
- resolve();
541
- } else {
432
+ if (event.data?.type === "connect-classic-sync-ready") resolve();
433
+ else
542
434
  reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
543
- }
544
435
  };
545
436
  worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
546
437
  port2,
@@ -548,110 +439,113 @@ export function connectClassicSync(
548
439
  });
549
440
  }
550
441
 
551
- /** Wait for a registration to have an active worker */
552
- function waitForActive(reg: ServiceWorkerRegistration): Promise<ServiceWorker> {
553
- if (reg.active) return Promise.resolve(reg.active);
554
- const worker = reg.installing || reg.waiting;
555
- if (!worker)
556
- return Promise.reject(new Error("no service worker in registration"));
557
- return new Promise((resolve) => {
558
- worker.addEventListener("statechange", () => {
559
- if (worker.state === "activated") resolve(worker);
560
- });
561
- });
442
+ function sendRepoPort(id: number): MessagePort {
443
+ const { port1, port2 } = new MessageChannel();
444
+ getAutomergeWorker().port.postMessage({ type: "port", id }, [port2]);
445
+ return port1;
562
446
  }
563
447
 
564
- async function openRepoChannel(): Promise<MessagePort> {
565
- const worker = getAutomergeWorker();
566
-
567
- // Send a MessagePort so the worker's repo can sync with this tab, and wait
568
- // for the worker to confirm its repo is constructed before returning. The
569
- // MessageChannel adapter's whenReady() force-resolves after 100ms regardless
570
- // of the other end's state, so it can't be used as a real readiness signal
571
- // on first boot (when the worker still has to fetch wasm and build its repo).
572
- const id = ++nextRepoChannelId;
573
- const { port1, port2 } = new MessageChannel();
574
- const workerReady = new Promise<void>((resolve, reject) => {
575
- 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) => {
576
456
  const cleanup = () => {
577
457
  clearTimeout(timeout);
578
- worker.port.removeEventListener("message", listener);
458
+ control.removeEventListener("message", listener);
579
459
  };
580
460
  const listener = (event: MessageEvent) => {
581
461
  if (event.data?.id !== id) return;
582
- if (event.data?.type === "port-ready") {
462
+ if (event.data.type === "port-ready") {
583
463
  cleanup();
584
464
  resolve();
585
- } else if (event.data?.type === "port-failed") {
465
+ } else if (event.data.type === "port-failed") {
586
466
  cleanup();
587
467
  reject(new Error(`automerge worker init failed: ${event.data.error}`));
588
468
  }
589
469
  };
590
- worker.port.addEventListener("message", listener);
591
- // Failsafe: don't block boot forever if the worker never replies. Surface
592
- // the issue and let the rest of the site come up rather than hanging on a
593
- // blank page.
594
- timeout = setTimeout(() => {
470
+ control.addEventListener("message", listener);
471
+ const timeout = setTimeout(() => {
595
472
  cleanup();
596
473
  reject(new Error("automerge worker port-ready timeout"));
597
474
  }, 30_000);
598
475
  });
599
- 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);
600
482
  try {
601
- await workerReady;
483
+ await ready;
602
484
  } catch (err) {
485
+ // Surface the problem and let the rest of the site come up rather than
486
+ // hanging on a blank page.
603
487
  console.warn(
604
488
  "proceeding without worker ready ack:",
605
489
  err instanceof Error ? err.message : err
606
490
  );
607
491
  }
608
- return port1;
492
+ return port;
609
493
  }
610
494
 
611
495
  /** Open a fresh repo sync port to the automerge worker (dev console). */
612
496
  function getRepoChannel(): MessagePort {
613
- const worker = getAutomergeWorker();
614
- const { port1, port2 } = new MessageChannel();
615
- worker.port.postMessage({ type: "port", id: ++nextRepoChannelId }, [port2]);
616
- 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
+ });
617
515
  }
618
516
 
619
517
  export default async function setupServiceWorker(
620
518
  options?: SetupServiceWorkerOptions
621
519
  ): Promise<SetupServiceWorkerResult> {
622
- // Attach the SW→tab [lifecycle] log bridge as early as possible so boot /
623
- // 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.
624
522
  installServiceWorkerLogForwarding();
625
- localStorage.removeItem(key);
523
+ localStorage.removeItem(CACHE_VERSION_KEY);
626
524
 
627
- // Ask for persistent storage so cache growth can't trip origin-wide
628
- // eviction, which would take the Automerge IndexedDB — the user's documents
629
- // — with it. Chrome/Safari decide silently from site engagement; Firefox may
630
- // show a one-time prompt. Best-effort: denial just means default eviction.
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.
631
529
  void navigator.storage?.persist?.().catch(() => {});
632
530
 
633
531
  if (options?.workerPath) automergeWorkerPath = options.workerPath;
634
532
 
635
- // 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
636
534
  // service worker installs.
637
535
  const shared = getAutomergeWorker();
638
- // todo delete
639
-
640
- const path = options?.path ?? "/service-worker.js";
641
- // No controller at this point means the page loaded without a service
642
- // worker — i.e. this is a first-time install (or a hard reload). Wait for
643
- // activation so the app boots with the SW in control of generated fetches.
644
- const reg = await navigator.serviceWorker.register(path, { type: "module" });
645
-
646
- // If there's an update waiting or installing, wait for it to activate
647
- let active = reg.active;
648
- if (reg.installing || reg.waiting) {
649
- active = await waitForActive(reg);
650
- }
651
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;
652
544
  configureServiceWorker(active);
653
545
 
654
- // 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.
655
549
  if (!navigator.serviceWorker.controller) {
656
550
  await new Promise<void>((resolve) => {
657
551
  navigator.serviceWorker.addEventListener(
@@ -662,8 +556,8 @@ export default async function setupServiceWorker(
662
556
  });
663
557
  }
664
558
 
665
- // A replacement service worker boots with the default cache name re-send
666
- // 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.
667
561
  navigator.serviceWorker.addEventListener("controllerchange", () => {
668
562
  configureServiceWorker(navigator.serviceWorker.controller);
669
563
  });
@@ -673,30 +567,20 @@ export default async function setupServiceWorker(
673
567
  "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px"
674
568
  );
675
569
 
676
- // todon't
677
- (window as any).killsw = () => {
678
- if (automergeWorker) {
679
- automergeWorker.port.close();
680
- automergeWorker = undefined;
681
- }
682
- };
683
-
684
570
  return {
685
571
  shared,
686
572
  connectClassicSync,
687
573
  getRepoChannel,
688
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".
689
578
  async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
690
- // Called once with the boot port. If the automerge worker later dies
691
- // and is recreated (recoverAutomergeWorker), the listener is called
692
- // again with a fresh port — treat every call as "(re)wire your repo's
693
- // sync onto this port".
694
579
  repoChannelListeners.add(listener);
695
580
  const generation = workerGeneration;
696
581
  const port = await openRepoChannel();
697
- // If the worker was replaced while this channel was opening (e.g. the
698
- // port-ready wait timed out against a stranded connection and recovery
699
- // already delivered a good port to this listener), drop the stale one
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
700
584
  // rather than wiring the repo to a dead channel.
701
585
  if (generation === workerGeneration) await listener(port);
702
586
  return () => {
@@ -705,3 +589,5 @@ export default async function setupServiceWorker(
705
589
  },
706
590
  };
707
591
  }
592
+
593
+ (window as any).bumpServiceWorkerCache = bumpServiceWorkerCache;