@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.
@@ -1,811 +0,0 @@
1
- // The automerge repo for a patchwork site, in a SharedWorker: one instance
2
- // serves every tab and lives as long as any tab does.
3
- //
4
- // The service worker holds no repo. When it misses the cache for a request that
5
- // looks like a URL encoded URL, it broadcasts a HandoffRequestMessage on
6
- // HANDOFF_CHANNEL; we resolve the automerge URL, write the response into the
7
- // service worker's cache (keyed by a Request reconstructed to match the one
8
- // it's holding), and reply on the same channel.
9
- import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
10
- // eslint-disable-next-line
11
- // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
12
- import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
13
- import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
14
- import { makePortProvider } from "@automerge/automerge-repo/worker-port";
15
- import { Repo, WorkerWebSocketEndpoint, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
16
- import { resolvePath } from "@inkandswitch/patchwork-filesystem";
17
- import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
18
- import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
19
- import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
20
- import { initializeAutomergeRepoKeyhiveRustWithRepo, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
21
- import { DEFAULT_CLASSIC_SYNC_SERVER } from "./sync-config.js";
22
- import { keyhiveStorageName, storagePrefix } from "./storage.js";
23
- import { HANDOFF_CHANNEL, SYNCSTATE_CHANNEL, } from "./types.js";
24
- const syncServer = typeof __SYNC_SERVER__ !== "undefined"
25
- ? __SYNC_SERVER__
26
- : { url: "wss://subduction.sync.inkandswitch.com" };
27
- const RESOLVE_TIMEOUT_MS = 30_000;
28
- const CACHEABLE_STATUSES = [200, 203, 204];
29
- // A fresh instance means a new repo peerId and cold in-memory state, so a tab
30
- // seeing a changed id knows to re-subscribe. Sent in `hello` and every `pong`.
31
- const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2);
32
- const WORKER_BOOT_TIME = Date.now();
33
- // `debug` reads localStorage, which a SharedWorker doesn't have, so debugging is
34
- // toggled by a control message from a tab instead.
35
- let debugging = false;
36
- function log(...args) {
37
- if (debugging)
38
- console.log("[automerge-worker]", ...args);
39
- }
40
- // ── Console forwarding ─────────────────────────────────────────────────
41
- // The SharedWorker's own console is buried in chrome://inspect, so mirror
42
- // everything over each connected tab's control port.
43
- const controlPorts = new Set();
44
- // Logs emitted before any tab connects (wasm boot) would otherwise be lost.
45
- const preConnectBuffer = [];
46
- const MAX_BUFFER = 200;
47
- function serializeArg(arg) {
48
- if (typeof arg === "string")
49
- return arg;
50
- if (arg instanceof Error)
51
- return arg.stack || `${arg.name}: ${arg.message}`;
52
- try {
53
- return JSON.stringify(arg);
54
- }
55
- catch {
56
- return String(arg);
57
- }
58
- }
59
- function postToPort(port, message) {
60
- try {
61
- port.postMessage(message);
62
- }
63
- catch (error) {
64
- console.warn(`sending failed`, error);
65
- }
66
- }
67
- function forwardToMainThread(level, rawArgs) {
68
- const args = rawArgs.map(serializeArg);
69
- if (!controlPorts.size) {
70
- if (preConnectBuffer.length < MAX_BUFFER)
71
- preConnectBuffer.push({ level, args });
72
- return;
73
- }
74
- for (const port of controlPorts) {
75
- postToPort(port, { type: "console", level, args });
76
- }
77
- }
78
- for (const level of ["log", "info", "warn", "error", "debug"]) {
79
- const original = console[level].bind(console);
80
- console[level] = (...args) => {
81
- original(...args);
82
- forwardToMainThread(level, args);
83
- };
84
- }
85
- self.addEventListener("error", (event) => {
86
- const e = event;
87
- forwardToMainThread("error", [
88
- `uncaught error: ${e.message}`,
89
- e.error instanceof Error ? e.error.stack : undefined,
90
- ]);
91
- });
92
- self.addEventListener("unhandledrejection", (event) => {
93
- const reason = event.reason;
94
- forwardToMainThread("error", [
95
- "unhandled rejection:",
96
- reason instanceof Error ? reason.stack || reason.message : reason,
97
- ]);
98
- });
99
- console.warn(`[lifecycle] automerge SharedWorker started (instance ${WORKER_INSTANCE_ID})`);
100
- const WATCHDOG_TICK_MS = 5_000;
101
- let watchdogLast = Date.now();
102
- setInterval(() => {
103
- const now = Date.now();
104
- const gap = now - watchdogLast;
105
- watchdogLast = now;
106
- if (gap > WATCHDOG_TICK_MS * 2) {
107
- console.warn(`[lifecycle] watchdog timer gap ~${Math.round(gap / 1000)}s ` +
108
- `(expected every ${WATCHDOG_TICK_MS / 1000}s)`);
109
- }
110
- }, WATCHDOG_TICK_MS);
111
- // ── Per-tab sync-state subscriptions ───────────────────────────────────
112
- // A tab's control port subscribes to the documents it cares about and we push
113
- // only those docs' heads down that port, so tab A never sees tab B's docs. A
114
- // port's whole subscription set is dropped when it closes, so there's nothing
115
- // to reference-count or time out.
116
- const syncWatchers = new Map();
117
- // Set once the repo's snapshot exists, so a `sync-sub` arriving during boot can
118
- // be replayed the doc's current heads as soon as it does.
119
- let replaySyncForPort = null;
120
- function syncSubscribe(port, documentId) {
121
- let docs = syncWatchers.get(port);
122
- if (!docs)
123
- syncWatchers.set(port, (docs = new Set()));
124
- if (docs.has(documentId))
125
- return;
126
- docs.add(documentId);
127
- replaySyncForPort?.(documentId, port);
128
- }
129
- function syncUnsubscribe(port, documentId) {
130
- syncWatchers.get(port)?.delete(documentId);
131
- }
132
- function pushSyncState(message) {
133
- for (const [port, docs] of syncWatchers) {
134
- if (docs.has(message.documentId))
135
- postToPort(port, message);
136
- }
137
- }
138
- const subductionPortProvider = makePortProvider();
139
- // Memoized so a construction retry reuses the endpoint instead of leaking one
140
- // per attempt.
141
- let subductionEndpoints = null;
142
- function getSubductionEndpoints() {
143
- return (subductionEndpoints ??= [
144
- new WorkerWebSocketEndpoint(syncServer.url, {
145
- worker: subductionPortProvider.source,
146
- }),
147
- ]);
148
- }
149
- let repoHivePromise = null;
150
- function getRepoHive() {
151
- if (!repoHivePromise) {
152
- repoHivePromise = setUpRepoHive();
153
- // Don't permanently cache a rejection (e.g. the wasm fetch failed) — clear
154
- // the slot so the next caller retries from scratch.
155
- repoHivePromise.catch(() => {
156
- repoHivePromise = null;
157
- });
158
- }
159
- return repoHivePromise;
160
- }
161
- async function setUpRepoHive() {
162
- log("fetching wasm");
163
- const [automergeWasm, subductionWasm] = await Promise.all([
164
- fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
165
- fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
166
- ]);
167
- initSubductionSync(new Uint8Array(subductionWasm));
168
- await initializeWasm(new Uint8Array(automergeWasm));
169
- log("wasm initialized");
170
- const built = syncServer.keyhive
171
- ? await buildKeyhiveRepo(syncServer.keyhive)
172
- : await buildPlainRepo();
173
- self.repo = built.repo;
174
- if (built.hive)
175
- self.hive = built.hive;
176
- if (built.identity)
177
- self.syncIdentity = built.identity;
178
- setUpSyncStateBroadcast(built.repo, built.identity);
179
- // Deliberately not awaited: the network subsystem starts with only the
180
- // subduction adapter, and the MessageChannel adapter is added later by
181
- // connectPort, which itself awaits getRepoHive. Blocking here would deadlock
182
- // that path and starve the handoff handler.
183
- built.repo.networkSubsystem
184
- .whenReady()
185
- .then(() => log("repo network subsystem ready"));
186
- return { repo: built.repo, hive: built.hive };
187
- }
188
- async function buildPlainRepo() {
189
- const signer = await WebCryptoSigner.setup();
190
- const identity = {
191
- peerId: signer.peerId().toString(),
192
- verifyingKey: signer.verifyingKey().toHex(),
193
- };
194
- const repo = new Repo({
195
- storage: new IndexedDBWorkerStorageAdapter(),
196
- signer,
197
- peerId: `automerge-worker-${Math.random().toString(36).slice(2)}`,
198
- async sharePolicy(peerId) {
199
- return peerId.includes("storage-server");
200
- },
201
- enableRemoteHeadsGossiping: true,
202
- subductionWebsocketEndpoints: getSubductionEndpoints(),
203
- });
204
- console.log("[patchwork] shared-worker subduction identity:", identity);
205
- return { repo, identity };
206
- }
207
- async function buildKeyhiveRepo(keyhiveSyncServer) {
208
- initKeyhiveWasm();
209
- const { hive, repo } = await initializeAutomergeRepoKeyhiveRustWithRepo({
210
- createRepo: (config) => new Repo(config),
211
- storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName),
212
- peerIdSuffix: `${storagePrefix}-worker` + Math.random().toString(36).slice(2),
213
- automaticArchiveIngestion: true,
214
- cachingMode: "periodic",
215
- // ARK selects the relay via `syncServer`, which pairs the contact card with
216
- // the matching peer id. Omitting it defaults to "subduction".
217
- syncServer: keyhiveSyncServer,
218
- repo: {
219
- storage: new IndexedDBWorkerStorageAdapter(),
220
- subductionWebsocketEndpoints: getSubductionEndpoints(),
221
- enableRemoteHeadsGossiping: true,
222
- },
223
- });
224
- hive.networkAdapter.whenReady().then(() => {
225
- hive.networkAdapter.syncKeyhive();
226
- });
227
- return { repo, hive };
228
- }
229
- // ── Classic sync ───────────────────────────────────────────────────────
230
- let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
231
- let classicSyncAdapter = null;
232
- let classicSyncConnect = null;
233
- function connectClassicSyncNetwork(server) {
234
- const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
235
- if (classicSyncConnect && classicSyncServer === url)
236
- return classicSyncConnect;
237
- if (classicSyncAdapter && classicSyncServer !== url) {
238
- classicSyncAdapter.disconnect();
239
- classicSyncAdapter = null;
240
- }
241
- classicSyncServer = url;
242
- const connecting = (async () => {
243
- const { repo } = await getRepoHive();
244
- if (!classicSyncAdapter) {
245
- classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
246
- repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
247
- }
248
- await classicSyncAdapter.whenReady();
249
- log("classic sync connected", url);
250
- })();
251
- // Clear the memo on failure so a later attempt can retry, and swallow the
252
- // rejection on this copy so it isn't reported as unhandled — callers get it
253
- // from the promise we return.
254
- classicSyncConnect = connecting;
255
- connecting.catch(() => {
256
- if (classicSyncConnect === connecting)
257
- classicSyncConnect = null;
258
- });
259
- return connecting;
260
- }
261
- // ── Sync-state broadcast ───────────────────────────────────────────────
262
- // Only this worker is connected to the sync server, so it's the only place that
263
- // learns the server's heads ("subduction-remote-heads", keyed by each Subduction
264
- // peer's verifying-key storageId) and whether the link is up
265
- // ("subduction-connection"). Global signals go out on SYNCSTATE_CHANNEL so any
266
- // tab can render a sync indicator; per-document heads are addressed to
267
- // subscribers instead (see pushSyncState).
268
- const RESYNC_GRACE_MS = 8_000; // must be stably diverged this long first
269
- const RESYNC_INITIAL_DELAY_MS = 5_000;
270
- const RESYNC_MAX_DELAY_MS = 60_000;
271
- const RESYNC_REVIEW_INTERVAL_MS = 5_000;
272
- const OWN_HANDLE_SCAN_INTERVAL_MS = 3_000;
273
- let syncStateWired = false;
274
- function setUpSyncStateBroadcast(repo, identity) {
275
- if (syncStateWired)
276
- return;
277
- syncStateWired = true;
278
- const state = {
279
- repo,
280
- channel: new BroadcastChannel(SYNCSTATE_CHANNEL),
281
- identity,
282
- snapshot: new Map(),
283
- connected: repo.isSubductionConnected(),
284
- serverPeerIds: [],
285
- tracked: new Set(),
286
- resync: new Map(),
287
- };
288
- postWhoAmI(state);
289
- replaySyncForPort = (documentId, port) => replayDoc(state, documentId, port);
290
- for (const [port, docs] of syncWatchers) {
291
- for (const documentId of docs)
292
- replayDoc(state, documentId, port);
293
- }
294
- repo.on("subduction-remote-heads", ({ documentId, storageId, heads, timestamp }) => {
295
- recordHeads(state, documentId, storageId, [...heads], timestamp);
296
- // A doc the server reported is one we hold, so advertise our heads for it
297
- // too. Only this doc: a full scan per event is O(all handles) and goes
298
- // quadratic during sync bursts. The tick covers general discovery.
299
- const handle = repo.handles[documentId];
300
- if (handle)
301
- trackOwnHandle(state, handle);
302
- reviewResync(state, documentId);
303
- });
304
- repo.on("subduction-connection", ({ connected }) => {
305
- state.connected = connected;
306
- postConnection(state);
307
- if (connected)
308
- void refreshServerPeers(state);
309
- });
310
- // A BroadcastChannel never receives its own posts, so this only sees tabs'
311
- // requests. Only the global signals are replayed; a late tab gets per-doc
312
- // heads by subscribing.
313
- state.channel.addEventListener("message", (event) => {
314
- if (event.data?.type !== "request")
315
- return;
316
- postWhoAmI(state);
317
- postConnection(state);
318
- });
319
- void refreshServerPeers(state);
320
- scanOwnHandles(state);
321
- if (!identity)
322
- return;
323
- // Subduction-pushed docs don't surface via the "document" event, so discover
324
- // them by re-scanning repo.handles on a tick.
325
- setInterval(() => scanOwnHandles(state), OWN_HANDLE_SCAN_INTERVAL_MS);
326
- // The "stuck" case is precisely when no head events are firing, so the
327
- // grace/backoff timers can only advance on a tick.
328
- setInterval(() => reviewAllResync(state), RESYNC_REVIEW_INTERVAL_MS);
329
- }
330
- function postWhoAmI(state) {
331
- if (!state.identity)
332
- return;
333
- state.channel.postMessage({
334
- type: "whoami",
335
- peerId: state.identity.peerId,
336
- verifyingKey: state.identity.verifyingKey,
337
- });
338
- }
339
- function postConnection(state) {
340
- state.channel.postMessage({
341
- type: "connection",
342
- connected: state.connected,
343
- serverPeerIds: state.serverPeerIds,
344
- });
345
- }
346
- function recordHeads(state, documentId, storageId, heads, timestamp) {
347
- let byStorage = state.snapshot.get(documentId);
348
- if (!byStorage)
349
- state.snapshot.set(documentId, (byStorage = new Map()));
350
- byStorage.set(storageId, { heads, timestamp });
351
- pushSyncState({
352
- type: "sync-state",
353
- documentId,
354
- storageId,
355
- heads,
356
- timestamp,
357
- });
358
- }
359
- function replayDoc(state, documentId, port) {
360
- const byStorage = state.snapshot.get(documentId);
361
- if (!byStorage)
362
- return;
363
- for (const [storageId, { heads, timestamp }] of byStorage) {
364
- postToPort(port, {
365
- type: "sync-state",
366
- documentId,
367
- storageId,
368
- heads,
369
- timestamp,
370
- });
371
- }
372
- }
373
- /** The peer list is empty until the handshake finishes, so retry briefly. */
374
- async function refreshServerPeers(state) {
375
- for (let attempt = 0; attempt < 6; attempt++) {
376
- try {
377
- const ids = await state.repo.connectedSubductionPeerIds();
378
- if (ids.length > 0) {
379
- state.serverPeerIds = ids;
380
- postConnection(state);
381
- return;
382
- }
383
- }
384
- catch {
385
- // No subduction source yet.
386
- }
387
- await new Promise((r) => setTimeout(r, 500));
388
- }
389
- }
390
- // Advertise this worker's own heads for every doc it holds, so the worker hop is
391
- // visible on every document. No-op on the keyhive path, which has no identity.
392
- function broadcastOwnHeads(state, handle) {
393
- if (!state.identity)
394
- return;
395
- let heads;
396
- try {
397
- heads = [...handle.heads()];
398
- }
399
- catch {
400
- return; // handle not ready
401
- }
402
- recordHeads(state, handle.documentId, state.identity.peerId, heads, Date.now());
403
- reviewResync(state, handle.documentId);
404
- }
405
- function trackOwnHandle(state, handle) {
406
- if (!state.identity || state.tracked.has(handle.documentId))
407
- return;
408
- state.tracked.add(handle.documentId);
409
- handle.on("heads-changed", () => broadcastOwnHeads(state, handle));
410
- broadcastOwnHeads(state, handle);
411
- }
412
- function scanOwnHandles(state) {
413
- if (!state.identity)
414
- return;
415
- for (const handle of Object.values(state.repo.handles)) {
416
- trackOwnHandle(state, handle);
417
- }
418
- }
419
- function serverHeadsFor(state, documentId) {
420
- const byStorage = state.snapshot.get(documentId);
421
- if (!byStorage)
422
- return [];
423
- const heads = new Set();
424
- for (const [storageId, entry] of byStorage) {
425
- if (state.serverPeerIds.includes(storageId)) {
426
- for (const head of entry.heads)
427
- heads.add(head);
428
- }
429
- }
430
- return [...heads];
431
- }
432
- function reviewResync(state, documentId) {
433
- if (!state.identity || !state.connected) {
434
- state.resync.delete(documentId);
435
- return;
436
- }
437
- const handle = state.repo.handles[documentId];
438
- if (!handle)
439
- return;
440
- const serverHeads = serverHeadsFor(state, documentId);
441
- if (serverHeads.length === 0) {
442
- state.resync.delete(documentId); // nothing to compare against
443
- return;
444
- }
445
- // The server advertises subduction sedimentree heads (loose-commit and
446
- // fragment-boundary commit ids), which are NOT the Automerge frontier, so
447
- // never compare them to handle.heads() for equality. Ask instead whether we
448
- // already hold every commit the server advertises.
449
- let haveAll;
450
- try {
451
- haveAll = handle.containsHeads(serverHeads);
452
- }
453
- catch {
454
- return; // doc not ready, or an undecodable head
455
- }
456
- if (haveAll) {
457
- state.resync.delete(documentId);
458
- return;
459
- }
460
- // Behind. Key the grace timer on the server heads alone, so your own edits
461
- // churning don't keep resetting it.
462
- const serverSig = [...serverHeads].sort().join(",");
463
- const now = Date.now();
464
- const prev = state.resync.get(documentId);
465
- if (!prev || prev.serverSig !== serverSig) {
466
- // First sighting, or the server made progress: restart the clock.
467
- state.resync.set(documentId, {
468
- serverSig,
469
- since: now,
470
- delay: RESYNC_INITIAL_DELAY_MS,
471
- lastResyncAt: 0,
472
- });
473
- return;
474
- }
475
- if (now - prev.since < RESYNC_GRACE_MS)
476
- return;
477
- if (now - prev.lastResyncAt < prev.delay)
478
- return;
479
- log("re-syncing behind doc", documentId);
480
- try {
481
- state.repo.resyncSubduction(documentId);
482
- }
483
- catch (e) {
484
- log("resyncSubduction failed", e);
485
- }
486
- prev.lastResyncAt = now;
487
- prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
488
- }
489
- function reviewAllResync(state) {
490
- if (!state.identity)
491
- return;
492
- for (const documentId of state.snapshot.keys())
493
- reviewResync(state, documentId);
494
- for (const id of [...state.resync.keys()]) {
495
- if (!state.snapshot.has(id))
496
- state.resync.delete(id);
497
- }
498
- }
499
- function dropRepoChannel(repo, channel) {
500
- // removeNetworkAdapter pulls the adapter out of networkSubsystem.adapters and
501
- // calls disconnect(), which for the MessageChannel adapter emits the
502
- // close/peer-disconnected events that clear #adaptersByPeer.
503
- try {
504
- repo.networkSubsystem.removeNetworkAdapter(channel.adapter);
505
- }
506
- catch (err) {
507
- console.error("removeNetworkAdapter failed", err);
508
- }
509
- // On the keyhive path the registered adapter is a wrapper, so make sure the
510
- // underlying port is disconnected and closed too.
511
- try {
512
- channel.mcAdapter.disconnect();
513
- }
514
- catch { }
515
- try {
516
- channel.port.close();
517
- }
518
- catch { }
519
- }
520
- async function dropConnection(connection) {
521
- if (!connection.channels.size || !repoHivePromise)
522
- return;
523
- const { repo } = await getRepoHive();
524
- log(`tab gone — removing ${connection.channels.size} network adapter(s)`);
525
- for (const channel of connection.channels)
526
- dropRepoChannel(repo, channel);
527
- connection.channels.clear();
528
- }
529
- async function connectPort(port, connection) {
530
- const { hive, repo } = await getRepoHive();
531
- const mcAdapter = new MessageChannelNetworkAdapter(port, {
532
- useWeakRef: true,
533
- });
534
- if (!hive) {
535
- repo.networkSubsystem.addNetworkAdapter(mcAdapter);
536
- connection.channels.add({ adapter: mcAdapter, mcAdapter, port });
537
- return;
538
- }
539
- const onlyShareWithHardcodedServerPeerId = false;
540
- const periodicallyRequestKeyhiveSync = false;
541
- const adapter = hive.createKeyhiveNetworkAdapter(mcAdapter, onlyShareWithHardcodedServerPeerId, periodicallyRequestKeyhiveSync, 2000);
542
- adapter.on("message", (msg) => {
543
- if (msg.type !== "sync" && msg.type !== "request")
544
- return;
545
- if (!msg.documentId)
546
- return;
547
- const handle = repo.handles[msg.documentId];
548
- if (handle && handle.state !== "unavailable")
549
- return;
550
- repo.findWithProgress(`automerge:${msg.documentId}`);
551
- repo.shareConfigChanged();
552
- });
553
- adapter.on("ingest-remote", () => {
554
- hive.notifySameAgentKeyhiveChange();
555
- hive.networkAdapter.syncKeyhive?.();
556
- repo.shareConfigChanged();
557
- });
558
- repo.networkSubsystem.addNetworkAdapter(adapter);
559
- connection.channels.add({ adapter, mcAdapter, port });
560
- }
561
- function handleControlMessage(event, controlPort, connection) {
562
- const data = event.data;
563
- switch (data?.type) {
564
- case "port": {
565
- log("received repo channel");
566
- const [repoPort] = event.ports;
567
- connectPort(repoPort, connection).then(() => controlPort.postMessage({ type: "port-ready", id: data.id }), (err) => {
568
- console.error("connectPort failed", err);
569
- // Tell the tab so it doesn't hang until its timeout.
570
- controlPort.postMessage({
571
- type: "port-failed",
572
- id: data.id,
573
- error: String(err),
574
- });
575
- });
576
- return;
577
- }
578
- case "sync-sub":
579
- if (typeof data.documentId === "string") {
580
- syncSubscribe(controlPort, data.documentId);
581
- }
582
- return;
583
- case "sync-unsub":
584
- if (typeof data.documentId === "string") {
585
- syncUnsubscribe(controlPort, data.documentId);
586
- }
587
- return;
588
- case "debug":
589
- debugging = data.debug;
590
- log("automerge worker debugging enabled");
591
- return;
592
- case "connect-classic-sync": {
593
- const [replyPort] = event.ports;
594
- const server = typeof data.server === "string"
595
- ? data.server
596
- : DEFAULT_CLASSIC_SYNC_SERVER;
597
- connectClassicSyncNetwork(server).then(() => {
598
- replyPort?.postMessage({ type: "connect-classic-sync-ready" });
599
- replyPort?.close();
600
- }, (err) => {
601
- console.error("connectClassicSyncNetwork failed", err);
602
- replyPort?.postMessage({
603
- type: "connect-classic-sync-failed",
604
- error: String(err),
605
- });
606
- replyPort?.close();
607
- });
608
- return;
609
- }
610
- case "ping":
611
- controlPort.postMessage({
612
- type: "pong",
613
- id: data.id,
614
- instanceId: WORKER_INSTANCE_ID,
615
- });
616
- return;
617
- }
618
- }
619
- self.addEventListener("connect", (event) => {
620
- const controlPort = event.ports[0];
621
- const connection = { channels: new Set() };
622
- controlPort.addEventListener("message", (messageEvent) => {
623
- handleControlMessage(messageEvent, controlPort, connection);
624
- });
625
- // The tab side runs donatePort; the messages are channel-tagged so they
626
- // coexist with the control protocol above.
627
- subductionPortProvider.attachClient(controlPort);
628
- // Fires when the owning page is destroyed. Browsers without the close event
629
- // fall back to the adapters' lazy useWeakRef cleanup.
630
- controlPort.addEventListener("close", () => {
631
- controlPorts.delete(controlPort);
632
- syncWatchers.delete(controlPort);
633
- void dropConnection(connection);
634
- });
635
- controlPort.start();
636
- controlPort.postMessage({
637
- type: "hello",
638
- instanceId: WORKER_INSTANCE_ID,
639
- bootTime: WORKER_BOOT_TIME,
640
- });
641
- controlPorts.add(controlPort);
642
- for (const { level, args } of preConnectBuffer.splice(0)) {
643
- postToPort(controlPort, { type: "console", level, args });
644
- }
645
- });
646
- function waitForHeads(handle, hexHeads, signal) {
647
- if (hasHeads(handle.doc(), hexHeads))
648
- return Promise.resolve(true);
649
- if (signal.aborted)
650
- return Promise.resolve(false);
651
- return new Promise((resolve) => {
652
- const cleanup = () => {
653
- handle.off("heads-changed", check);
654
- signal.removeEventListener("abort", onAbort);
655
- };
656
- const check = () => {
657
- if (!hasHeads(handle.doc(), hexHeads))
658
- return;
659
- cleanup();
660
- resolve(true);
661
- };
662
- const onAbort = () => {
663
- cleanup();
664
- resolve(false);
665
- };
666
- handle.on("heads-changed", check);
667
- signal.addEventListener("abort", onAbort);
668
- // The heads may have landed between the check above and subscribing.
669
- check();
670
- });
671
- }
672
- /**
673
- * Thrown instead of returning a Response when the request should fail as a
674
- * network error rather than resolve to something the caller can memoize.
675
- * See {@link HandoffAbortMessage}.
676
- */
677
- class AbortHandoff extends Error {
678
- }
679
- async function resolveAutomergeUrl(automergeURL, signal) {
680
- const { repo } = await getRepoHive();
681
- const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/");
682
- if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
683
- return new Response("invalid automerge url", { status: 400 });
684
- }
685
- if (path.length && !path[path.length - 1])
686
- path.pop();
687
- const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
688
- // todo, maybe a bad idea? maybe we should throw instead of es-module-caching
689
- // the headless req
690
- if (!heads) {
691
- const folder = await repo.find(maybeAutomergeUrl, { signal });
692
- const url = stringifyAutomergeUrl({ documentId, heads: folder.heads() });
693
- const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`;
694
- return Response.redirect(location, 307);
695
- }
696
- const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
697
- signal,
698
- });
699
- if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
700
- throw new AbortHandoff(`heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`);
701
- }
702
- const resolved = await resolvePath(repo, baseHandle.view(heads), path.map(decodeURIComponent));
703
- if (!resolved) {
704
- throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
705
- }
706
- const body = resolved.content instanceof Uint8Array
707
- ? new Uint8Array(resolved.content)
708
- : resolved.content;
709
- return new Response(body, {
710
- status: 200,
711
- headers: { "content-type": resolved.type },
712
- });
713
- }
714
- const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
715
- function replyToHandoff(id, status, body) {
716
- handoffChannel.postMessage({
717
- id,
718
- type: "response",
719
- response: { status, body, headers: { "content-type": "text/plain" } },
720
- });
721
- }
722
- function impatience(limit) {
723
- return new Promise((_, reject) => setTimeout(() => reject(new Error(`resolve timeout after ${limit}ms`)), limit));
724
- }
725
- async function handleHandoffRequest(message) {
726
- const { id, cachename, request } = message;
727
- let handoff;
728
- try {
729
- handoff = new URL(request.handoffURL);
730
- }
731
- catch {
732
- console.error("couldn't parse handoff url", request);
733
- replyToHandoff(id, 400, `couldn't parse a special url out of ${request.url}`);
734
- return;
735
- }
736
- // Other handlers may be listening on the channel for other schemes, so stay
737
- // quiet rather than clobbering their reply with an error.
738
- if (handoff.protocol !== "automerge:") {
739
- log(`ignoring handoff ${id} for non-automerge url ${handoff}. not my circus, not my monkeys`);
740
- return;
741
- }
742
- let response;
743
- try {
744
- log(`resolving handoff ${id} for ${handoff}`);
745
- const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
746
- response = await Promise.race([
747
- resolveAutomergeUrl(handoff, signal),
748
- impatience(RESOLVE_TIMEOUT_MS),
749
- ]);
750
- }
751
- catch (error) {
752
- if (error instanceof AbortHandoff) {
753
- handoffChannel.postMessage({
754
- id,
755
- type: "abort",
756
- reason: error.message,
757
- });
758
- return;
759
- }
760
- console.error(`error resolving ${request.url}`, error);
761
- replyToHandoff(id, 557, error instanceof Error
762
- ? `${error.message}\n\n${error.stack}`
763
- : String(error));
764
- return;
765
- }
766
- try {
767
- if (!CACHEABLE_STATUSES.includes(response.status)) {
768
- // Errors, redirects and the like go back inline for the service worker to
769
- // serve directly, so they aren't cached forever (still in esmodulecache,
770
- // cleared after a refresh)
771
- log(`responding inline to ${request.url} with ${response.status}`);
772
- handoffChannel.postMessage({
773
- id,
774
- type: "response",
775
- response: {
776
- status: response.status,
777
- headers: Object.fromEntries(response.headers.entries()),
778
- body: response.body ? await response.text() : undefined,
779
- },
780
- });
781
- return;
782
- }
783
- // Reconstruct the request the service worker is holding so the entry matches
784
- // its cache.match. `destination` isn't constructible but doesn't participate
785
- // in cache matching.
786
- const cacheKey = new Request(request.url, {
787
- method: request.method,
788
- headers: request.headers,
789
- referrer: request.referrer,
790
- });
791
- const cache = await caches.open(cachename);
792
- await cache.put(cacheKey, response);
793
- log(`cached ${cacheKey.url} in ${cachename}`);
794
- handoffChannel.postMessage({
795
- id,
796
- type: "cached",
797
- });
798
- }
799
- catch (error) {
800
- console.error(`failed to reply for ${request.url}`, error);
801
- replyToHandoff(id, 558, String(error));
802
- }
803
- }
804
- handoffChannel.addEventListener("message", (event) => {
805
- if (event.data?.type === "request") {
806
- void handleHandoffRequest(event.data);
807
- }
808
- });
809
- // Announce ourselves so the service worker can re-broadcast handoff requests
810
- // sent while we were booting.
811
- handoffChannel.postMessage({ type: "online" });