@inkandswitch/patchwork-bootloader 0.6.2 → 0.7.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/src/setup.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  import type {
2
- ServiceWorkerRepoChannelListener,
3
2
  SetupServiceWorkerOptions,
4
3
  SetupServiceWorkerResult,
5
- SyncStateDocMessage,
6
4
  } from "./types.js";
7
5
  import {
8
6
  readClassicSyncServer,
@@ -10,23 +8,16 @@ import {
10
8
  } from "./sync-config.js";
11
9
  import debug from "debug";
12
10
  import {
13
- donatePort,
14
- isWorkerErrorMessage,
15
- } from "@automerge/automerge-repo/worker-port";
11
+ forwardWorkerConsole,
12
+ lifecycleLog,
13
+ sharedWorkerHandle,
14
+ } from "./shared-worker-lifecycle.js";
15
+
16
+ export { lifecycleLog };
16
17
 
17
18
  const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
18
19
  const workerDebugging = debug.enabled("patchwork:automergeworker");
19
20
 
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}`;
28
- }
29
-
30
21
  // The version is cleared on every boot, so the steady state is
31
22
  // DEFAULT_CACHE_NAME. bumpServiceWorkerCache is a dev escape hatch: it moves
32
23
  // the worker to a throwaway cache now, and the next boot both reverts the name
@@ -65,348 +56,28 @@ function installServiceWorkerLogForwarding(): void {
65
56
  });
66
57
  }
67
58
 
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.
71
-
72
- let automergeWorkerPath = "/automerge-worker.js";
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;
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
- }
176
-
177
- /**
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.
182
- */
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);
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 {
244
- let instanceId: string | undefined;
245
- let lastHeardAt = Date.now();
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
- };
259
-
260
- worker.port.addEventListener("message", (event: MessageEvent) => {
261
- const data = event.data;
262
- if (data?.type !== "hello" && data?.type !== "pong") return;
263
- lastHeardAt = Date.now();
264
- warnedUnresponsive = false;
265
- closeProbe();
266
- if (instanceId === undefined) {
267
- instanceId = data.instanceId;
268
- lifecycleLog(
269
- "automerge SharedWorker instance %s (via %s)",
270
- data.instanceId,
271
- data.type
272
- );
273
- } else if (data.instanceId && data.instanceId !== instanceId) {
274
- lifecycleLog(
275
- "automerge SharedWorker instance changed (instance %s, was %s)",
276
- data.instanceId,
277
- instanceId
278
- );
279
- instanceId = data.instanceId;
280
- }
281
- });
282
-
283
- worker.port.addEventListener("close", () => {
284
- if (disposed) return;
285
- lifecycleLog("automerge SharedWorker control port closed");
286
- void recoverAutomergeWorker("control port closed", worker);
287
- });
288
-
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));
293
- });
294
-
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(() => {
327
- try {
328
- worker.port.postMessage({ type: "ping", id: ++seq });
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
- }
335
- }
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.
348
- const visible =
349
- typeof document === "undefined" || document.visibilityState === "visible";
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);
361
- }, HEARTBEAT_MS);
362
-
363
- return () => {
364
- disposed = true;
365
- clearInterval(heartbeat);
366
- closeProbe();
367
- };
368
- }
369
-
370
- // Ref-counted locally so several callers in this tab can watch the same doc
371
- // with a single worker subscription.
372
- type SyncStateListener = (update: SyncStateDocMessage) => void;
373
- const syncStateListeners = new Map<string, Set<SyncStateListener>>();
59
+ // ── The automerge worker ───────────────────────────────────────────────
60
+ // A SharedWorker holding the Repo that resolves `automerge:` URLs for the
61
+ // service worker. Tabs don't sync through it — each tab is its own node — but
62
+ // each tab keeps it alive and heartbeats it, so it's here rather than in the
63
+ // service worker, which can't own one.
374
64
 
375
- function dispatchSyncState(update: SyncStateDocMessage): void {
376
- for (const listener of syncStateListeners.get(update.documentId) ?? []) {
377
- try {
378
- listener(update);
379
- } catch (err) {
380
- console.error("sync-state listener threw", err);
381
- }
382
- }
383
- }
65
+ let automergeProtocolHandlerWorkerPath =
66
+ "/automerge-protocol-handler-worker.js";
384
67
 
385
- export function subscribeSyncState(
386
- documentId: string,
387
- listener: SyncStateListener
388
- ): () => void {
389
- const worker = getAutomergeWorker();
390
- let listeners = syncStateListeners.get(documentId);
391
- if (!listeners) {
392
- syncStateListeners.set(documentId, (listeners = new Set()));
393
- worker.port.postMessage({ type: "sync-sub", documentId });
68
+ const automergeProtocolHandlerWorker = sharedWorkerHandle(
69
+ "patchwork-automerge-protocol-handler",
70
+ () => automergeProtocolHandlerWorkerPath,
71
+ {
72
+ debugging: workerDebugging,
73
+ onMessage(event) {
74
+ forwardWorkerConsole("automerge-protocol-handler-worker", event.data);
75
+ },
394
76
  }
395
- listeners.add(listener);
77
+ );
396
78
 
397
- let active = true;
398
- return () => {
399
- if (!active) return;
400
- active = false;
401
- const set = syncStateListeners.get(documentId);
402
- if (!set) return;
403
- set.delete(listener);
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 });
409
- };
79
+ export function getAutomergeProtocolHandlerWorker(): SharedWorker {
80
+ return automergeProtocolHandlerWorker.get();
410
81
  }
411
82
 
412
83
  export function connectClassicSync(
@@ -419,7 +90,6 @@ export function connectClassicSync(
419
90
  );
420
91
  }
421
92
 
422
- const worker = getAutomergeWorker();
423
93
  const { port1, port2 } = new MessageChannel();
424
94
  return new Promise((resolve, reject) => {
425
95
  const timeout = setTimeout(() => {
@@ -433,69 +103,14 @@ export function connectClassicSync(
433
103
  else
434
104
  reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
435
105
  };
436
- worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
437
- port2,
438
- ]);
439
- });
440
- }
441
-
442
- function sendRepoPort(id: number): MessagePort {
443
- const { port1, port2 } = new MessageChannel();
444
- getAutomergeWorker().port.postMessage({ type: "port", id }, [port2]);
445
- return port1;
446
- }
447
-
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) => {
456
- const cleanup = () => {
457
- clearTimeout(timeout);
458
- control.removeEventListener("message", listener);
459
- };
460
- const listener = (event: MessageEvent) => {
461
- if (event.data?.id !== id) return;
462
- if (event.data.type === "port-ready") {
463
- cleanup();
464
- resolve();
465
- } else if (event.data.type === "port-failed") {
466
- cleanup();
467
- reject(new Error(`automerge worker init failed: ${event.data.error}`));
468
- }
469
- };
470
- control.addEventListener("message", listener);
471
- const timeout = setTimeout(() => {
472
- cleanup();
473
- reject(new Error("automerge worker port-ready timeout"));
474
- }, 30_000);
475
- });
476
- }
477
-
478
- async function openRepoChannel(): Promise<MessagePort> {
479
- const id = ++nextRepoChannelId;
480
- const ready = awaitPortReady(getAutomergeWorker().port, id);
481
- const port = sendRepoPort(id);
482
- try {
483
- await ready;
484
- } catch (err) {
485
- // Surface the problem and let the rest of the site come up rather than
486
- // hanging on a blank page.
487
- console.warn(
488
- "proceeding without worker ready ack:",
489
- err instanceof Error ? err.message : err
106
+ automergeProtocolHandlerWorker.post(
107
+ { type: "connect-classic-sync", server: url },
108
+ [port2]
490
109
  );
491
- }
492
- return port;
110
+ });
493
111
  }
494
112
 
495
- /** Open a fresh repo sync port to the automerge worker (dev console). */
496
- function getRepoChannel(): MessagePort {
497
- return sendRepoPort(++nextRepoChannelId);
498
- }
113
+ // ── Boot ───────────────────────────────────────────────────────────────
499
114
 
500
115
  function waitForActive(reg: ServiceWorkerRegistration): Promise<ServiceWorker> {
501
116
  if (reg.active) return Promise.resolve(reg.active);
@@ -528,11 +143,11 @@ export default async function setupServiceWorker(
528
143
  // default eviction.
529
144
  void navigator.storage?.persist?.().catch(() => {});
530
145
 
531
- if (options?.workerPath) automergeWorkerPath = options.workerPath;
146
+ if (options?.workerPath)
147
+ automergeProtocolHandlerWorkerPath = options.workerPath;
532
148
 
533
- // Start the automerge worker now so it boots wasm and its repo while the
534
- // service worker installs.
535
- const shared = getAutomergeWorker();
149
+ // Start it now so it boots wasm while the service worker installs.
150
+ const shared = automergeProtocolHandlerWorker.get();
536
151
 
537
152
  const reg = await navigator.serviceWorker.register(
538
153
  options?.path ?? "/service-worker.js",
@@ -567,27 +182,7 @@ export default async function setupServiceWorker(
567
182
  "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px"
568
183
  );
569
184
 
570
- return {
571
- shared,
572
- connectClassicSync,
573
- getRepoChannel,
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".
578
- async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) {
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
- };
589
- },
590
- };
185
+ return { shared, connectClassicSync };
591
186
  }
592
187
 
593
188
  (window as any).bumpServiceWorkerCache = bumpServiceWorkerCache;