@inkandswitch/patchwork-bootloader 0.2.6 → 0.2.7

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.
@@ -0,0 +1,647 @@
1
+ // The automerge repo for a patchwork site. This runs in a SharedWorker, so
2
+ // one instance serves every tab and lives exactly as long as any tab does —
3
+ // no keepalive pings, no idle teardown.
4
+ //
5
+ // The service worker holds no repo. When it misses the cache for a special
6
+ // URL it broadcasts a HandoffRequestMessage on HANDOFF_CHANNEL; we resolve
7
+ // the automerge URL, write the response into the service worker's cache
8
+ // (keyed by a Request reconstructed to match the one it's holding), and
9
+ // reply on the same channel.
10
+
11
+ // Heavy imports — marked external by the service-worker vite plugin,
12
+ // resolved to /packages/... URLs at build time. The worker is created with
13
+ // type:"module" so the browser fetches these as regular network requests.
14
+ // Uses /slim so wasm is fetched from /automerge.wasm (emitted by the vite
15
+ // plugin) instead of bundling the ~3MB base64 string.
16
+ import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
17
+ // eslint-disable-next-line
18
+ // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
19
+ import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
20
+ import { WebCryptoSigner } from "@automerge/automerge-subduction/slim";
21
+
22
+ import {
23
+ Repo,
24
+ isValidAutomergeUrl,
25
+ parseAutomergeUrl,
26
+ stringifyAutomergeUrl,
27
+ type AutomergeUrl,
28
+ type DocHandle,
29
+ } from "@automerge/automerge-repo/slim";
30
+ import { resolvePath } from "@inkandswitch/patchwork-filesystem";
31
+
32
+ // Small adapters — bundled directly into the worker
33
+ import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
34
+ import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
35
+ import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
36
+ import {
37
+ initializeAutomergeRepoKeyhiveRust,
38
+ initKeyhiveWasm,
39
+ type AutomergeRepoKeyhiveRust,
40
+ } from "@automerge/automerge-repo-keyhive";
41
+
42
+ import {
43
+ HANDOFF_CHANNEL,
44
+ type HandoffCachedMessage,
45
+ type HandoffOnlineMessage,
46
+ type HandoffRequest,
47
+ type HandoffRequestMessage,
48
+ type HandoffResponseMessage,
49
+ } from "./types.js";
50
+
51
+ declare const __SITE_NAME__: string;
52
+ declare const __KEYHIVE__: boolean;
53
+ declare const __KEYHIVE_SYNC_SERVER__: boolean;
54
+
55
+ let debugging = false;
56
+
57
+ // Sync server selection. Sub is the default. Build with KEYHIVE_SYNC_SERVER=true
58
+ // to target keyhive.sync.automerge.org.
59
+ const useKeyhiveSyncServer =
60
+ typeof __KEYHIVE_SYNC_SERVER__ !== "undefined" && __KEYHIVE_SYNC_SERVER__;
61
+
62
+ // Set the correct env var for automerge_repo_keyhive if need be.
63
+ if (useKeyhiveSyncServer) {
64
+ (globalThis as any).process = (globalThis as any).process ?? {};
65
+ (globalThis as any).process.env = {
66
+ ...((globalThis as any).process.env ?? {}),
67
+ KEYHIVE_SERVER_IDENTITY: "keyhive-sync",
68
+ };
69
+ }
70
+
71
+ // keyhive.sync.automerge.org's keyhive identity (issuer d7f41e6f…).
72
+ const KEYHIVE_SYNC_SERVER_PEER_ID =
73
+ "1/Qebw9O69oH8T/ejYMhFup0tNBh69I3ytGqsmIl358=";
74
+ const KEYHIVE_SYNC_SERVER_CONTACT_CARD_JSON =
75
+ '{"Rotate":{"payload":{"old":[73,163,230,244,111,233,153,119,133,211,134,237,111,36,52,131,22,50,54,144,150,45,227,235,128,36,33,217,190,198,55,75],"new":[109,115,204,144,178,114,182,238,113,124,4,139,249,76,220,44,128,104,194,68,187,184,82,241,94,145,104,198,159,122,186,43]},"issuer":[215,244,30,111,15,78,235,218,7,241,63,222,141,131,33,22,234,116,180,208,97,235,210,55,202,209,170,178,98,37,223,159],"signature":[178,64,85,76,51,199,196,151,129,14,191,53,127,191,34,223,97,238,95,109,118,179,152,17,205,188,204,177,116,166,147,231,192,201,48,137,19,214,180,45,108,104,34,8,14,63,115,139,215,142,4,179,233,89,150,218,174,168,107,23,8,109,228,6]}}';
76
+
77
+ const SUBDUCTION_ENDPOINTS = [
78
+ useKeyhiveSyncServer
79
+ ? "wss://keyhive.sync.automerge.org"
80
+ : "wss://subduction.sync.inkandswitch.com",
81
+ ];
82
+ const RESOLVE_TIMEOUT_MS = 30_000;
83
+
84
+ const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
85
+
86
+ let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
87
+ let classicSyncAdapter: WebSocketClientAdapter | null = null;
88
+ let classicSyncConnectPromise: Promise<void> | null = null;
89
+
90
+ async function connectClassicSyncNetwork(server: string): Promise<void> {
91
+ const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
92
+ if (classicSyncConnectPromise && classicSyncServer === url) {
93
+ return classicSyncConnectPromise;
94
+ }
95
+
96
+ if (classicSyncAdapter && classicSyncServer !== url) {
97
+ classicSyncAdapter.disconnect();
98
+ classicSyncAdapter = null;
99
+ classicSyncConnectPromise = null;
100
+ }
101
+
102
+ classicSyncServer = url;
103
+ classicSyncConnectPromise = (async () => {
104
+ const { repo } = await getRepoHive();
105
+ if (!classicSyncAdapter) {
106
+ classicSyncAdapter = new WebSocketClientAdapter(url);
107
+ repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
108
+ }
109
+ await classicSyncAdapter.whenReady();
110
+ log("classic sync connected", { server: url });
111
+ })();
112
+
113
+ try {
114
+ await classicSyncConnectPromise;
115
+ } catch (err) {
116
+ classicSyncConnectPromise = null;
117
+ throw err;
118
+ }
119
+ }
120
+
121
+ const siteName =
122
+ typeof __SITE_NAME__ !== "undefined" ? __SITE_NAME__ : "tiny-patchwork";
123
+
124
+ const cacheableStatuses = [200, 203, 204, 206];
125
+
126
+ function log(...args: any[]) {
127
+ if (!debugging) return;
128
+ console.log.call(
129
+ console,
130
+ `%cpatchwork:automergeworker%c\n`,
131
+ `color: #ffaa00; font-weight: bold`,
132
+ "color: inherit",
133
+ ...args
134
+ );
135
+ }
136
+
137
+ let repoHivePromise: Promise<{
138
+ repo: Repo;
139
+ hive?: AutomergeRepoKeyhiveRust;
140
+ }> | null = null;
141
+
142
+ const useKeyhive = typeof __KEYHIVE__ !== "undefined" && __KEYHIVE__;
143
+
144
+ function getRepoHive() {
145
+ if (!repoHivePromise) {
146
+ repoHivePromise = (async () => {
147
+ log("getRepo: starting");
148
+
149
+ log("fetching wasm modules");
150
+ const [amWasmBuf, sdnWasmBuf] = await Promise.all([
151
+ fetch("/automerge.wasm?worker").then((r) => r.arrayBuffer()),
152
+ fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
153
+ ]);
154
+ initSubductionSync(new Uint8Array(sdnWasmBuf));
155
+ await initializeWasm(new Uint8Array(amWasmBuf));
156
+ log("wasm initialized");
157
+
158
+ if (!useKeyhive) {
159
+ const signer = await WebCryptoSigner.setup();
160
+
161
+ const repo = new Repo({
162
+ storage: new IndexedDBStorageAdapter(),
163
+ signer,
164
+ peerId: ("automerge-worker-" +
165
+ Math.random()
166
+ .toString(36)
167
+ .slice(2)) as import("@automerge/automerge-repo/slim").PeerId,
168
+ async sharePolicy(peerId) {
169
+ return peerId.includes("storage-server");
170
+ },
171
+ enableRemoteHeadsGossiping: true,
172
+ subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
173
+ });
174
+
175
+ (self as any).repo = repo;
176
+ log("repo constructed (no keyhive), waiting for network subsystem");
177
+
178
+ repo.networkSubsystem.whenReady().then(() => {
179
+ log("repo network subsystem ready");
180
+ });
181
+
182
+ return { repo };
183
+ }
184
+
185
+ initKeyhiveWasm();
186
+ const keyhiveStorage = new IndexedDBStorageAdapter(`${siteName}-keyhive`);
187
+
188
+ // Keyhive bootstrap needs to run before Repo creation but
189
+ // the adapter needs the subduction instance from the Repo.
190
+ // A deferred promise breaks the cycle.
191
+ let resolveRepoSubduction!: (s: any) => void;
192
+ const repoSubductionPromise = new Promise((resolve) => {
193
+ resolveRepoSubduction = resolve;
194
+ });
195
+
196
+ // We use the Rust variant of Keyhive initialization to talk
197
+ // to the Rust keyhive-enabled subduction sync server.
198
+ const hive = await initializeAutomergeRepoKeyhiveRust({
199
+ storage: keyhiveStorage,
200
+ peerIdSuffix:
201
+ `${siteName}-worker` + Math.random().toString(36).slice(2),
202
+ subduction: repoSubductionPromise as any,
203
+ automaticArchiveIngestion: true,
204
+ cachingMode: "periodic",
205
+ ...(useKeyhiveSyncServer
206
+ ? {
207
+ serverPeerId: KEYHIVE_SYNC_SERVER_PEER_ID as any,
208
+ serverContactCardJson: KEYHIVE_SYNC_SERVER_CONTACT_CARD_JSON,
209
+ }
210
+ : {}),
211
+ });
212
+
213
+ const signer = await hive.constructSubductionSigner();
214
+
215
+ const repo = new Repo({
216
+ storage: new IndexedDBStorageAdapter(),
217
+ signer,
218
+ subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
219
+ peerId: hive.peerId,
220
+ enableRemoteHeadsGossiping: true,
221
+ idFactory: hive.idFactory,
222
+ });
223
+
224
+ repo.subduction.then(resolveRepoSubduction);
225
+
226
+ hive.linkRepo(repo);
227
+
228
+ (self as any).repo = repo;
229
+ (self as any).hive = hive;
230
+ log("repo constructed, waiting for network subsystem");
231
+
232
+ // Don't block getRepoHive() on whenReady() — the network subsystem starts
233
+ // with only the subduction adapter, and the MessageChannel adapter is
234
+ // added later via connectPort (which awaits getRepoHive). Blocking here
235
+ // would deadlock that path and starve the handoff handler.
236
+ repo.networkSubsystem.whenReady().then(() => {
237
+ log("repo network subsystem ready");
238
+ });
239
+
240
+ hive.networkAdapter.whenReady().then(() => {
241
+ (hive.networkAdapter as any).syncKeyhive();
242
+ });
243
+
244
+ return { hive, repo };
245
+ })();
246
+ // If construction fails (e.g. wasm fetch errors out), don't permanently
247
+ // cache the rejection — clear the slot so the next caller can retry from
248
+ // scratch.
249
+ repoHivePromise.catch(() => {
250
+ repoHivePromise = null;
251
+ });
252
+ }
253
+ return repoHivePromise;
254
+ }
255
+
256
+ // ── Tab connections ────────────────────────────────────────────────────
257
+
258
+ // Each tab connects with a control port (the SharedWorker connect port) and
259
+ // opens repo MessageChannel ports through it. `adapter` is what was
260
+ // registered with the network subsystem (the MessageChannelNetworkAdapter,
261
+ // or the keyhive wrapper around it); `mcAdapter` is always the underlying
262
+ // MessageChannel adapter so we can disconnect the port itself.
263
+ type RepoChannel = {
264
+ adapter: { disconnect(): void };
265
+ mcAdapter: MessageChannelNetworkAdapter;
266
+ port: MessagePort;
267
+ };
268
+ type Connection = {
269
+ channels: Set<RepoChannel>;
270
+ };
271
+
272
+ function dropRepoChannel(repo: Repo, channel: RepoChannel) {
273
+ // removeNetworkAdapter pulls the adapter out of networkSubsystem.adapters and
274
+ // calls adapter.disconnect(), which (for the MessageChannel adapter) emits the
275
+ // "close"/"peer-disconnected" events that also clear #adaptersByPeer.
276
+ try {
277
+ repo.networkSubsystem.removeNetworkAdapter(channel.adapter as any);
278
+ } catch (err) {
279
+ console.error("removeNetworkAdapter failed", err);
280
+ }
281
+ // Belt and braces for the keyhive path, where the registered adapter is a
282
+ // wrapper: make sure the underlying port is disconnected and closed too.
283
+ try {
284
+ channel.mcAdapter.disconnect();
285
+ } catch {}
286
+ try {
287
+ channel.port.close();
288
+ } catch {}
289
+ }
290
+
291
+ async function dropConnection(connection: Connection) {
292
+ if (!connection.channels.size || !repoHivePromise) return;
293
+ const { repo } = await getRepoHive();
294
+ log(`tab gone — removing ${connection.channels.size} network adapter(s)`);
295
+ for (const channel of connection.channels) {
296
+ dropRepoChannel(repo, channel);
297
+ }
298
+ connection.channels.clear();
299
+ }
300
+
301
+ // Connect client MessagePorts to the repo for sync
302
+ async function connectPort(port: MessagePort, connection: Connection) {
303
+ const { hive, repo } = await getRepoHive();
304
+ const networkAdapter = new MessageChannelNetworkAdapter(port, {
305
+ useWeakRef: true,
306
+ });
307
+
308
+ const track = (adapter: { disconnect(): void }) => {
309
+ connection.channels.add({ adapter, mcAdapter: networkAdapter, port });
310
+ };
311
+
312
+ if (!hive) {
313
+ repo.networkSubsystem.addNetworkAdapter(networkAdapter);
314
+ track(networkAdapter);
315
+ return;
316
+ }
317
+
318
+ const onlyShareWithHardcodedServerPeerId = false;
319
+ const periodicallyRequestKeyhiveSync = false;
320
+ const keyhiveNetworkAdapter = hive.createKeyhiveNetworkAdapter(
321
+ networkAdapter,
322
+ onlyShareWithHardcodedServerPeerId,
323
+ periodicallyRequestKeyhiveSync,
324
+ 2000
325
+ );
326
+
327
+ keyhiveNetworkAdapter.on("message", async (msg: any) => {
328
+ if ((msg.type === "sync" || msg.type === "request") && msg.documentId) {
329
+ const handle = repo.handles[msg.documentId];
330
+ if (!handle || handle.state === "unavailable") {
331
+ const url = `automerge:${msg.documentId}` as AutomergeUrl;
332
+ repo.findWithProgress(url);
333
+ repo.shareConfigChanged();
334
+ }
335
+ }
336
+ });
337
+
338
+ (keyhiveNetworkAdapter as any).on("ingest-remote", () => {
339
+ (hive.networkAdapter as any).syncKeyhive?.();
340
+ repo.shareConfigChanged();
341
+ });
342
+
343
+ repo.networkSubsystem.addNetworkAdapter(keyhiveNetworkAdapter);
344
+ track(keyhiveNetworkAdapter);
345
+ }
346
+
347
+ function handleControlMessage(
348
+ event: MessageEvent,
349
+ controlPort: MessagePort,
350
+ connection: Connection
351
+ ) {
352
+ const data = event.data;
353
+ if (data?.type === "port") {
354
+ log("received repo channel");
355
+ const [repoPort] = event.ports;
356
+ const id = data.id;
357
+ connectPort(repoPort, connection).then(
358
+ () => controlPort.postMessage({ type: "port-ready", id }),
359
+ (err) => {
360
+ console.error("connectPort failed", err);
361
+ // Tell the client we failed so it doesn't hang forever.
362
+ controlPort.postMessage({
363
+ type: "port-failed",
364
+ id,
365
+ error: String(err),
366
+ });
367
+ }
368
+ );
369
+ } else if (data?.type === "debug") {
370
+ debugging = data.debug;
371
+ log("automerge worker debugging enabled");
372
+ } else if (data?.type === "connect-classic-sync") {
373
+ const [replyPort] = event.ports;
374
+ const server =
375
+ typeof data.server === "string"
376
+ ? data.server
377
+ : DEFAULT_CLASSIC_SYNC_SERVER;
378
+ connectClassicSyncNetwork(server)
379
+ .then(() => {
380
+ replyPort?.postMessage({ type: "connect-classic-sync-ready" });
381
+ replyPort?.close();
382
+ log("classic sync connected on demand", { server });
383
+ })
384
+ .catch((err) => {
385
+ console.error("connectClassicSyncNetwork failed", err);
386
+ replyPort?.postMessage({
387
+ type: "connect-classic-sync-failed",
388
+ error: String(err),
389
+ });
390
+ replyPort?.close();
391
+ });
392
+ }
393
+ }
394
+
395
+ self.addEventListener("connect", (event) => {
396
+ const controlPort = (event as MessageEvent).ports[0];
397
+ const connection: Connection = { channels: new Set() };
398
+
399
+ controlPort.addEventListener("message", (messageEvent) => {
400
+ handleControlMessage(messageEvent as MessageEvent, controlPort, connection);
401
+ });
402
+
403
+ // Fires when the owning page is destroyed. Browsers without the close
404
+ // event fall back to the adapters' lazy useWeakRef cleanup.
405
+ controlPort.addEventListener("close", () => {
406
+ void dropConnection(connection);
407
+ });
408
+
409
+ controlPort.start();
410
+ });
411
+
412
+ // ── Automerge URL resolution ───────────────────────────────────────────
413
+
414
+ /**
415
+ * Wait for the requested heads to appear in the handle's local history —
416
+ * they may still be syncing toward us when the request lands. Resolves
417
+ * false if the signal aborts before they arrive.
418
+ */
419
+ function waitForHeads(
420
+ handle: DocHandle<unknown>,
421
+ hexHeads: string[],
422
+ signal: AbortSignal
423
+ ): Promise<boolean> {
424
+ if (hasHeads(handle.doc(), hexHeads)) return Promise.resolve(true);
425
+ if (signal.aborted) return Promise.resolve(false);
426
+ return new Promise((resolve) => {
427
+ const check = () => {
428
+ if (!hasHeads(handle.doc(), hexHeads)) return;
429
+ cleanup();
430
+ resolve(true);
431
+ };
432
+ const onAbort = () => {
433
+ cleanup();
434
+ resolve(false);
435
+ };
436
+ const cleanup = () => {
437
+ handle.off("heads-changed", check);
438
+ signal.removeEventListener("abort", onAbort);
439
+ };
440
+ handle.on("heads-changed", check);
441
+ signal.addEventListener("abort", onAbort);
442
+ // The heads may have landed between the synchronous check above and
443
+ // subscribing.
444
+ check();
445
+ });
446
+ }
447
+
448
+ async function resolveAutomergeUrl(automergeURL: URL): Promise<Response> {
449
+ const { repo } = await getRepoHive();
450
+ const href = automergeURL.href;
451
+ const [maybeAutomergeUrl, ...path] = href.split("/");
452
+
453
+ if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
454
+ return new Response("invalid automerge url", { status: 400 });
455
+ }
456
+
457
+ // Trim trailing empty path segment
458
+ if (path.length && !path[path.length - 1]) path.pop();
459
+
460
+ const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
461
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
462
+
463
+ if (!heads) {
464
+ const folder = await repo.find(maybeAutomergeUrl, { signal });
465
+ const latestHeads = folder.heads();
466
+ const url = stringifyAutomergeUrl({ documentId, heads: latestHeads });
467
+ let location = `/${encodeURIComponent(url)}`;
468
+ if (path.length) location += `/${path.join("/")}`;
469
+ return Response.redirect(location, 307);
470
+ }
471
+
472
+ // Load by documentId only so we can verify the requested heads are actually
473
+ // in our local history. repo.find with a heads-bearing URL returns a view
474
+ // at those heads, which silently materializes garbage if we never synced them.
475
+ const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
476
+ signal,
477
+ });
478
+ // The heads may not have synced to us yet — give them the rest of the
479
+ // resolve window to arrive before giving up.
480
+ if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
481
+ return new Response("heads not found", { status: 404 });
482
+ }
483
+ const rootHandle = baseHandle.view(heads);
484
+
485
+ const resolved = await resolvePath(
486
+ repo,
487
+ rootHandle,
488
+ path.map(decodeURIComponent)
489
+ );
490
+
491
+ if (!resolved) {
492
+ throw new Error(
493
+ `couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`
494
+ );
495
+ }
496
+
497
+ const body: BodyInit =
498
+ resolved.content instanceof Uint8Array
499
+ ? (new Uint8Array(resolved.content) as BlobPart)
500
+ : resolved.content;
501
+
502
+ const headers = new Headers({ "content-type": resolved.type });
503
+ headers.set("cross-origin-embedder-policy", "credentialless");
504
+ headers.set("cross-origin-resource-policy", "cross-origin");
505
+
506
+ return new Response(body, { status: 200, headers });
507
+ }
508
+
509
+ // ── Handoff: resolve special URLs for the service worker ──────────────
510
+
511
+ const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
512
+
513
+ /**
514
+ * Pull the special URL out of a handoff request, whichever generation of
515
+ * service worker sent it. Returns null rather than throwing — a stale
516
+ * worker on the other end of the channel can send us anything.
517
+ */
518
+ function parseHandoffhandoffURL(request: HandoffRequest): URL | null {
519
+ try {
520
+ // The service worker already decoded the special URL out of the
521
+ // request it's holding and sends it alongside.
522
+ if (request.handoffURL) return new URL(request.handoffURL);
523
+ // TODO(backcompat): a briefly-deployed shape sent the special URL in
524
+ // request.url and the http URL in cacheKey.
525
+ if (request.cacheKey) return new URL(request.url);
526
+ // TODO(backcompat): older service workers send only the http URL,
527
+ // special URL still URI-encoded in its pathname.
528
+ return new URL(decodeURIComponent(new URL(request.url).pathname.slice(1)));
529
+ } catch {
530
+ return null;
531
+ }
532
+ }
533
+
534
+ async function handleHandoffRequest(message: HandoffRequestMessage) {
535
+ const { id, cachename, request } = message;
536
+
537
+ const handoffURL = parseHandoffhandoffURL(request);
538
+ if (!handoffURL) {
539
+ console.error(
540
+ `automerge worker couldn't parse a special url out of handoff request`,
541
+ request
542
+ );
543
+ handoffChannel.postMessage({
544
+ id,
545
+ type: "response",
546
+ response: {
547
+ status: 400,
548
+ body: `couldn't parse a special url out of ${request.url}`,
549
+ headers: { "content-type": "text/plain" },
550
+ },
551
+ } satisfies HandoffResponseMessage);
552
+ return;
553
+ }
554
+
555
+ if (handoffURL.protocol != "automerge:") {
556
+ // This worker only resolves automerge: URLs. Other handlers may be
557
+ // listening on the channel for other schemes — stay quiet rather than
558
+ // clobbering their reply with an error.
559
+ return log(`ignoring handoff ${id} for non-automerge url ${handoffURL}`);
560
+ }
561
+
562
+ let response: Response;
563
+ try {
564
+ log(`resolving handoff ${id} for ${handoffURL}`);
565
+ response = await Promise.race([
566
+ resolveAutomergeUrl(handoffURL),
567
+ new Promise<never>((_, reject) =>
568
+ setTimeout(
569
+ () =>
570
+ reject(new Error(`resolve timeout after ${RESOLVE_TIMEOUT_MS}ms`)),
571
+ RESOLVE_TIMEOUT_MS
572
+ )
573
+ ),
574
+ ]);
575
+ } catch (error) {
576
+ const body =
577
+ error instanceof Error
578
+ ? `${error.message}\n\n${error.stack}`
579
+ : String(error);
580
+ console.error(`automerge worker error resolving ${request.url}`, error);
581
+ handoffChannel.postMessage({
582
+ id,
583
+ type: "response",
584
+ response: {
585
+ status: 500,
586
+ body,
587
+ headers: { "content-type": "text/plain" },
588
+ },
589
+ } satisfies HandoffResponseMessage);
590
+ return;
591
+ }
592
+
593
+ try {
594
+ if (cacheableStatuses.includes(response.status)) {
595
+ // Reconstruct the request the service worker is holding so the entry
596
+ // matches on its cache.match. (destination isn't constructible, but it
597
+ // doesn't participate in cache matching.) request.url is the http URL
598
+ // the SW is holding except in the briefly-deployed cacheKey shape.
599
+ const cacheKey = new Request(request.cacheKey ?? request.url, {
600
+ method: request.method,
601
+ headers: request.headers,
602
+ referrer: request.referrer,
603
+ });
604
+ const cache = await caches.open(cachename);
605
+ await cache.put(cacheKey, response);
606
+ log(`cached ${cacheKey.url} in ${cachename}`);
607
+ handoffChannel.postMessage({
608
+ id,
609
+ type: "cached",
610
+ } satisfies HandoffCachedMessage);
611
+ } else {
612
+ // Errors, redirects &c — things that shouldn't be cached — go back
613
+ // inline for the service worker to serve directly.
614
+ log(`responding inline to ${request.url} with ${response.status}`);
615
+ handoffChannel.postMessage({
616
+ id,
617
+ type: "response",
618
+ response: {
619
+ status: response.status,
620
+ headers: Object.fromEntries(response.headers.entries()),
621
+ body: response.body ? await response.text() : undefined,
622
+ },
623
+ } satisfies HandoffResponseMessage);
624
+ }
625
+ } catch (error) {
626
+ console.error(`automerge worker failed to reply for ${request.url}`, error);
627
+ handoffChannel.postMessage({
628
+ id,
629
+ type: "response",
630
+ response: {
631
+ status: 500,
632
+ body: String(error),
633
+ headers: { "content-type": "text/plain" },
634
+ },
635
+ } satisfies HandoffResponseMessage);
636
+ }
637
+ }
638
+
639
+ handoffChannel.addEventListener("message", (event) => {
640
+ if (event.data?.type === "request") {
641
+ void handleHandoffRequest(event.data as HandoffRequestMessage);
642
+ }
643
+ });
644
+
645
+ // Announce ourselves so the service worker can re-broadcast any handoff
646
+ // requests that were sent while we were still booting.
647
+ handoffChannel.postMessage({ type: "online" } satisfies HandoffOnlineMessage);
package/src/externals.ts CHANGED
@@ -9,7 +9,6 @@ const externals = [
9
9
  "@automerge/automerge-repo-network-messagechannel",
10
10
  "@automerge/automerge-repo-storage-indexeddb",
11
11
  "@automerge/automerge-repo-keyhive",
12
- "@automerge/automerge-repo-react-hooks",
13
12
  "@automerge/automerge-repo-network-messagechannel",
14
13
  "@automerge/automerge-repo-storage-indexeddb",
15
14
  "@automerge/automerge-subduction",