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