@inkandswitch/patchwork-bootloader 0.6.3 → 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/dist/setup.js CHANGED
@@ -1,16 +1,9 @@
1
1
  import { readClassicSyncServer, DEFAULT_CLASSIC_SYNC_SERVER, } from "./sync-config.js";
2
2
  import debug from "debug";
3
- import { donatePort, isWorkerErrorMessage, } from "@automerge/automerge-repo/worker-port";
3
+ import { forwardWorkerConsole, lifecycleLog, sharedWorkerHandle, } from "./shared-worker-lifecycle.js";
4
+ export { lifecycleLog };
4
5
  const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker");
5
6
  const workerDebugging = debug.enabled("patchwork:automergeworker");
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
7
  // The version is cleared on every boot, so the steady state is
15
8
  // DEFAULT_CACHE_NAME. bumpServiceWorkerCache is a dev escape hatch: it moves
16
9
  // the worker to a throwaway cache now, and the next boot both reverts the name
@@ -47,315 +40,26 @@ function installServiceWorkerLogForwarding() {
47
40
  lifecycleLog("[service-worker] %s", event.data.msg);
48
41
  });
49
42
  }
50
- // The automerge repo lives in a SharedWorker. one instance serves every
51
- // tab. Browsers might kill a SharedWorker under memory pressure, so we
52
- // heartbeat it and rebuild everything if it dies.
53
- let automergeWorkerPath = "/automerge-worker.js";
54
- let automergeWorker;
55
- // A repo port opened against instance N is stale once instance N+1 exists — its
56
- // channel ends in a dead worker — so deliveries are guarded on generation.
57
- let workerGeneration = 0;
58
- let disposeWorkerDeathDetection;
59
- const repoChannelListeners = new Set();
60
- let recoveringWorker = false;
61
- let lastWorkerRecoveryAt = 0;
62
- // Below this spacing, skip: if the fresh worker is dead too, its own heartbeat
63
- // re-triggers recovery later rather than spinning in a tight loop.
64
- const RECOVERY_MIN_INTERVAL_MS = 15_000;
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
- }
141
- /**
142
- * Build a replacement worker and re-wire everything a live tab holds against
143
- * it: console forwarding and port donation (both re-done by
144
- * getAutomergeWorker), the per-doc sync-state subscriptions, and every
145
- * subscriber's repo port. The new instance boots with cold state.
146
- */
147
- async function recoverAutomergeWorker(reason, deadWorker) {
148
- if (deadWorker !== automergeWorker)
149
- return;
150
- if (recoveringWorker)
151
- return;
152
- const now = Date.now();
153
- if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS)
154
- return;
155
- recoveringWorker = true;
156
- lastWorkerRecoveryAt = now;
157
- lifecycleLog("recreating the automerge SharedWorker (%s)", reason);
158
- try {
159
- disposeWorkerDeathDetection?.();
160
- disposeWorkerDeathDetection = undefined;
161
- automergeWorker = undefined;
162
- try {
163
- deadWorker.port.close();
164
- }
165
- catch { }
166
- const fresh = getAutomergeWorker();
167
- for (const documentId of syncStateListeners.keys()) {
168
- fresh.port.postMessage({ type: "sync-sub", documentId });
169
- }
170
- for (const listener of repoChannelListeners) {
171
- try {
172
- const generation = workerGeneration;
173
- const port = await openRepoChannel();
174
- // Replaced again while we waited — the newer recovery re-delivers.
175
- if (generation !== workerGeneration)
176
- break;
177
- await listener(port);
178
- }
179
- catch (err) {
180
- console.error("failed to re-wire a repo channel after worker recovery", err);
181
- }
182
- }
183
- }
184
- finally {
185
- recoveringWorker = false;
186
- }
187
- }
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;
204
- function installWorkerDeathDetection(worker) {
205
- let instanceId;
206
- let lastHeardAt = Date.now();
207
- let warnedUnresponsive = false;
208
- let warnedSendFailed = false;
209
- let disposed = false;
210
- let probe;
211
- let seq = 0;
212
- const closeProbe = () => {
213
- if (!probe)
214
- return;
215
- try {
216
- probe.port.close();
217
- }
218
- catch { }
219
- probe = undefined;
220
- };
221
- worker.port.addEventListener("message", (event) => {
222
- const data = event.data;
223
- if (data?.type !== "hello" && data?.type !== "pong")
224
- return;
225
- lastHeardAt = Date.now();
226
- warnedUnresponsive = false;
227
- closeProbe();
228
- if (instanceId === undefined) {
229
- instanceId = data.instanceId;
230
- lifecycleLog("automerge SharedWorker instance %s (via %s)", data.instanceId, data.type);
231
- }
232
- else if (data.instanceId && data.instanceId !== instanceId) {
233
- lifecycleLog("automerge SharedWorker instance changed (instance %s, was %s)", data.instanceId, instanceId);
234
- instanceId = data.instanceId;
235
- }
236
- });
237
- worker.port.addEventListener("close", () => {
238
- if (disposed)
239
- return;
240
- lifecycleLog("automerge SharedWorker control port closed");
241
- void recoverAutomergeWorker("control port closed", worker);
242
- });
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));
247
- });
248
- const startProbe = (reason) => {
249
- if (probe || disposed)
250
- return;
251
- lifecycleLog("automerge SharedWorker %s; probing with a second connection", reason);
252
- const startedAt = Date.now();
253
- const p = new SharedWorker(automergeWorkerPath, {
254
- name: "patchwork-automerge",
255
- type: "module",
256
- });
257
- probe = p;
258
- p.port.start();
259
- p.port.addEventListener("message", (event) => {
260
- if (event.data?.type !== "hello")
261
- return;
262
- setTimeout(() => {
263
- if (disposed || probe !== p)
264
- return;
265
- closeProbe();
266
- // The suspect spoke while the probe ran: it was merely busy, and
267
- // everything queued on it has been delivered.
268
- if (lastHeardAt >= startedAt)
269
- return;
270
- void recoverAutomergeWorker(`port unresponsive on a live worker (${reason}; probe confirmed)`, worker);
271
- }, PROBE_GRACE_MS);
272
- });
273
- // No hello on the probe means the instance is loading or busy. The probe
274
- // waits indefinitely rather than tearing anything down on a timer.
275
- };
276
- const heartbeat = setInterval(() => {
277
- try {
278
- worker.port.postMessage({ type: "ping", id: ++seq });
279
- }
280
- catch (error) {
281
- // Without this a failed send is indistinguishable from a dead worker.
282
- if (!warnedSendFailed) {
283
- warnedSendFailed = true;
284
- console.error("automerge SharedWorker ping send threw", error);
285
- }
286
- }
287
- const neverHeard = instanceId === undefined;
288
- const silentMs = Date.now() - lastHeardAt;
289
- const timeoutMs = neverHeard
290
- ? FIRST_CONTACT_TIMEOUT_MS
291
- : HEARTBEAT_TIMEOUT_MS;
292
- if (silentMs <= timeoutMs)
293
- return;
294
- // First contact probes regardless of visibility: SharedWorkers don't
295
- // suspend with the tab, and the probe destroys nothing. Post-contact
296
- // silence defers to visibility, since a hidden page's throttling can fake
297
- // it.
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);
308
- }
309
- startProbe(reason);
310
- }, HEARTBEAT_MS);
311
- return () => {
312
- disposed = true;
313
- clearInterval(heartbeat);
314
- closeProbe();
315
- };
316
- }
317
- const syncStateListeners = new Map();
318
- function dispatchSyncState(update) {
319
- for (const listener of syncStateListeners.get(update.documentId) ?? []) {
320
- try {
321
- listener(update);
322
- }
323
- catch (err) {
324
- console.error("sync-state listener threw", err);
325
- }
326
- }
327
- }
328
- export function subscribeSyncState(documentId, listener) {
329
- const worker = getAutomergeWorker();
330
- let listeners = syncStateListeners.get(documentId);
331
- if (!listeners) {
332
- syncStateListeners.set(documentId, (listeners = new Set()));
333
- worker.port.postMessage({ type: "sync-sub", documentId });
334
- }
335
- listeners.add(listener);
336
- let active = true;
337
- return () => {
338
- if (!active)
339
- return;
340
- active = false;
341
- const set = syncStateListeners.get(documentId);
342
- if (!set)
343
- return;
344
- set.delete(listener);
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 });
351
- };
43
+ // ── The automerge worker ───────────────────────────────────────────────
44
+ // A SharedWorker holding the Repo that resolves `automerge:` URLs for the
45
+ // service worker. Tabs don't sync through it — each tab is its own node — but
46
+ // each tab keeps it alive and heartbeats it, so it's here rather than in the
47
+ // service worker, which can't own one.
48
+ let automergeProtocolHandlerWorkerPath = "/automerge-protocol-handler-worker.js";
49
+ const automergeProtocolHandlerWorker = sharedWorkerHandle("patchwork-automerge-protocol-handler", () => automergeProtocolHandlerWorkerPath, {
50
+ debugging: workerDebugging,
51
+ onMessage(event) {
52
+ forwardWorkerConsole("automerge-protocol-handler-worker", event.data);
53
+ },
54
+ });
55
+ export function getAutomergeProtocolHandlerWorker() {
56
+ return automergeProtocolHandlerWorker.get();
352
57
  }
353
58
  export function connectClassicSync(server = readClassicSyncServer()) {
354
59
  const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
355
60
  if (!/^wss?:\/\//.test(url)) {
356
61
  return Promise.reject(new Error(`invalid classic sync server URL: ${server}`));
357
62
  }
358
- const worker = getAutomergeWorker();
359
63
  const { port1, port2 } = new MessageChannel();
360
64
  return new Promise((resolve, reject) => {
361
65
  const timeout = setTimeout(() => {
@@ -370,65 +74,10 @@ export function connectClassicSync(server = readClassicSyncServer()) {
370
74
  else
371
75
  reject(new Error(event.data?.error ?? "connect-classic-sync failed"));
372
76
  };
373
- worker.port.postMessage({ type: "connect-classic-sync", server: url }, [
374
- port2,
375
- ]);
77
+ automergeProtocolHandlerWorker.post({ type: "connect-classic-sync", server: url }, [port2]);
376
78
  });
377
79
  }
378
- function sendRepoPort(id) {
379
- const { port1, port2 } = new MessageChannel();
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) => {
391
- const cleanup = () => {
392
- clearTimeout(timeout);
393
- control.removeEventListener("message", listener);
394
- };
395
- const listener = (event) => {
396
- if (event.data?.id !== id)
397
- return;
398
- if (event.data.type === "port-ready") {
399
- cleanup();
400
- resolve();
401
- }
402
- else if (event.data.type === "port-failed") {
403
- cleanup();
404
- reject(new Error(`automerge worker init failed: ${event.data.error}`));
405
- }
406
- };
407
- control.addEventListener("message", listener);
408
- const timeout = setTimeout(() => {
409
- cleanup();
410
- reject(new Error("automerge worker port-ready timeout"));
411
- }, 30_000);
412
- });
413
- }
414
- async function openRepoChannel() {
415
- const id = ++nextRepoChannelId;
416
- const ready = awaitPortReady(getAutomergeWorker().port, id);
417
- const port = sendRepoPort(id);
418
- try {
419
- await ready;
420
- }
421
- catch (err) {
422
- // Surface the problem and let the rest of the site come up rather than
423
- // hanging on a blank page.
424
- console.warn("proceeding without worker ready ack:", err instanceof Error ? err.message : err);
425
- }
426
- return port;
427
- }
428
- /** Open a fresh repo sync port to the automerge worker (dev console). */
429
- function getRepoChannel() {
430
- return sendRepoPort(++nextRepoChannelId);
431
- }
80
+ // ── Boot ───────────────────────────────────────────────────────────────
432
81
  function waitForActive(reg) {
433
82
  if (reg.active)
434
83
  return Promise.resolve(reg.active);
@@ -458,10 +107,9 @@ export default async function setupServiceWorker(options) {
458
107
  // default eviction.
459
108
  void navigator.storage?.persist?.().catch(() => { });
460
109
  if (options?.workerPath)
461
- automergeWorkerPath = options.workerPath;
462
- // Start the automerge worker now so it boots wasm and its repo while the
463
- // service worker installs.
464
- const shared = getAutomergeWorker();
110
+ automergeProtocolHandlerWorkerPath = options.workerPath;
111
+ // Start it now so it boots wasm while the service worker installs.
112
+ const shared = automergeProtocolHandlerWorker.get();
465
113
  const reg = await navigator.serviceWorker.register(options?.path ?? "/service-worker.js", { type: "module" });
466
114
  const active = reg.installing || reg.waiting ? await waitForActive(reg) : reg.active;
467
115
  configureServiceWorker(active);
@@ -479,27 +127,6 @@ export default async function setupServiceWorker(options) {
479
127
  configureServiceWorker(navigator.serviceWorker.controller);
480
128
  });
481
129
  console.log("service worker alive, loading %c patchwork system ", "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px");
482
- return {
483
- shared,
484
- connectClassicSync,
485
- getRepoChannel,
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".
490
- async subscribeToRepoChannel(listener) {
491
- repoChannelListeners.add(listener);
492
- const generation = workerGeneration;
493
- const port = await openRepoChannel();
494
- // If the worker was replaced while this channel was opening, recovery has
495
- // already delivered a good port to this listener — drop the stale one
496
- // rather than wiring the repo to a dead channel.
497
- if (generation === workerGeneration)
498
- await listener(port);
499
- return () => {
500
- repoChannelListeners.delete(listener);
501
- };
502
- },
503
- };
130
+ return { shared, connectClassicSync };
504
131
  }
505
132
  window.bumpServiceWorkerCache = bumpServiceWorkerCache;
@@ -0,0 +1,30 @@
1
+ import debug from "debug";
2
+ export declare const lifecycleLog: debug.Debugger;
3
+ export type SharedWorkerHandle = {
4
+ readonly name: string;
5
+ /** The current instance, spawning one if there isn't a live one. */
6
+ get(): SharedWorker;
7
+ /** Send on the current instance's control port. */
8
+ post(message: unknown, transfer?: Transferable[]): void;
9
+ /**
10
+ * The worker died and was replaced. Anything held against the old instance —
11
+ * a port, a subscription — is stranded; the new one boots with cold state.
12
+ */
13
+ onRecreated(listener: () => void): () => void;
14
+ };
15
+ /**
16
+ * A SharedWorker a tab keeps alive: spawned on demand, heartbeated, and rebuilt
17
+ * if the browser kills it (which it may, under memory pressure).
18
+ */
19
+ export declare function sharedWorkerHandle(name: string,
20
+ /** Read on every spawn, so a site can set the path after this is built. */
21
+ path: () => string, { debugging, onMessage, onSpawn, }: {
22
+ debugging: boolean;
23
+ onMessage: (event: MessageEvent) => void;
24
+ onSpawn?: (worker: SharedWorker) => void;
25
+ }): SharedWorkerHandle;
26
+ /**
27
+ * Mirror a worker's forwarded console output into this tab's console, since a
28
+ * SharedWorker's own console is only visible in chrome://inspect.
29
+ */
30
+ export declare function forwardWorkerConsole(name: string, data: any): boolean;