@inkandswitch/patchwork-bootloader 0.4.3 → 0.5.0

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