@inkandswitch/patchwork-bootloader 0.6.3 → 0.7.1

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,428 @@
1
+ // The Repo that resolves `automerge:` URLs for the service worker, in a
2
+ // SharedWorker: one instance serves every tab and lives as long as any tab
3
+ // does.
4
+ //
5
+ // It is a node like any tab's: the same IndexedDB, its own sync-server socket,
6
+ // and the siblings channel to the tabs. Resolving requests is its whole job.
7
+ // When the service worker misses the cache for a request that looks like a URL
8
+ // encoded URL, it broadcasts a HandoffRequestMessage on HANDOFF_CHANNEL; we
9
+ // resolve the automerge URL, write the response into the service worker's
10
+ // cache (keyed by a Request reconstructed to match the one it's holding), and
11
+ // reply on the same channel.
12
+ import { initializeWasm, hasHeads } from "@automerge/automerge/slim";
13
+ // eslint-disable-next-line
14
+ // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
15
+ import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
16
+
17
+ import {
18
+ Repo,
19
+ isValidAutomergeUrl,
20
+ parseAutomergeUrl,
21
+ stringifyAutomergeUrl,
22
+ type AutomergeUrl,
23
+ type DocHandle,
24
+ type PeerId,
25
+ } from "@automerge/automerge-repo/slim";
26
+ import { resolvePath } from "@inkandswitch/patchwork-filesystem";
27
+
28
+ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
29
+ import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
30
+ import {
31
+ initializeAutomergeRepoKeyhive,
32
+ initKeyhiveWasm,
33
+ type AutomergeRepoKeyhive,
34
+ type SyncServerSelection,
35
+ } from "@automerge/automerge-repo-keyhive";
36
+
37
+ import { DEFAULT_CLASSIC_SYNC_SERVER } from "./sync-config.js";
38
+ import { siblingAdapters } from "./siblings.js";
39
+ import { loadOrCreateSigner } from "./signer.js";
40
+ import { keyhiveStorageName, storagePrefix } from "./storage.js";
41
+ import { startWorkerControl } from "./worker-control.js";
42
+ import {
43
+ HANDOFF_CHANNEL,
44
+ type HandoffCachedMessage,
45
+ type HandoffOnlineMessage,
46
+ type HandoffAbortMessage,
47
+ type HandoffRequestMessage,
48
+ type HandoffResponseMessage,
49
+ } from "./types.js";
50
+
51
+ declare const __SYNC_SERVER__: {
52
+ url: string;
53
+ keyhive?: SyncServerSelection;
54
+ useIdFactory?: boolean;
55
+ };
56
+
57
+ const syncServer =
58
+ typeof __SYNC_SERVER__ !== "undefined"
59
+ ? __SYNC_SERVER__
60
+ : { url: "wss://subduction.sync.inkandswitch.com" };
61
+
62
+ const RESOLVE_TIMEOUT_MS = 30_000;
63
+
64
+ const CACHEABLE_STATUSES = [200, 203, 204];
65
+
66
+ const control = startWorkerControl("automerge-protocol-handler-worker", {
67
+ onMessage: handleControlMessage,
68
+ });
69
+ const log = control.log;
70
+
71
+ // ── The repo ───────────────────────────────────────────────────────────
72
+
73
+ let repoPromise: Promise<Repo> | null = null;
74
+
75
+ function getRepo(): Promise<Repo> {
76
+ if (!repoPromise) {
77
+ repoPromise = buildRepo();
78
+ // Don't cache a rejection (e.g. the wasm fetch failed): clear the slot so
79
+ // the next caller retries from scratch.
80
+ repoPromise.catch(() => {
81
+ repoPromise = null;
82
+ });
83
+ }
84
+ return repoPromise;
85
+ }
86
+
87
+ async function buildRepo(): Promise<Repo> {
88
+ log("fetching wasm");
89
+ const [automergeWasm, subductionWasm] = await Promise.all([
90
+ fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
91
+ fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
92
+ ]);
93
+ initSubductionSync(new Uint8Array(subductionWasm));
94
+ await initializeWasm(new Uint8Array(automergeWasm));
95
+ log("wasm initialized");
96
+
97
+ const { repo, hive } = syncServer.keyhive
98
+ ? await buildKeyhiveRepo(syncServer.keyhive)
99
+ : { repo: await buildPlainRepo() };
100
+
101
+ (self as any).repo = repo;
102
+ if (hive) (self as any).hive = hive;
103
+ return repo;
104
+ }
105
+
106
+ async function buildPlainRepo(): Promise<Repo> {
107
+ const storage = new IndexedDBWorkerStorageAdapter();
108
+ return new Repo({
109
+ signer: await loadOrCreateSigner(storage),
110
+ storage,
111
+ peerId:
112
+ `${storagePrefix}-resolver-${Math.random().toString(36).slice(2)}` as PeerId,
113
+ subductionWebsocketEndpoints: [syncServer.url],
114
+ subductionAdapters: siblingAdapters(),
115
+ enableRemoteHeadsGossiping: true,
116
+ });
117
+ }
118
+
119
+ async function buildKeyhiveRepo(
120
+ keyhiveSyncServer: SyncServerSelection
121
+ ): Promise<{ repo: Repo; hive: AutomergeRepoKeyhive }> {
122
+ initKeyhiveWasm();
123
+ const { hive, repo } = await initializeAutomergeRepoKeyhive({
124
+ // ARK injects an `idFactory` deriving document ids from keyhive. A site
125
+ // can opt out of it with `keyhive: { useIdFactory: false }`.
126
+ createRepo: ({ idFactory, ...config }) =>
127
+ new Repo(
128
+ syncServer.useIdFactory === false ? config : { ...config, idFactory }
129
+ ),
130
+ storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName),
131
+ peerIdSuffix:
132
+ `${storagePrefix}-resolver` + Math.random().toString(36).slice(2),
133
+ automaticArchiveIngestion: true,
134
+ cachingMode: "periodic",
135
+ // ARK selects the relay via `syncServer`, which pairs the contact card with
136
+ // the matching peer id. Omitting it defaults to "subduction".
137
+ syncServer: keyhiveSyncServer,
138
+ repo: {
139
+ storage: new IndexedDBWorkerStorageAdapter(),
140
+ subductionWebsocketEndpoints: [syncServer.url],
141
+ subductionAdapters: siblingAdapters(),
142
+ enableRemoteHeadsGossiping: true,
143
+ },
144
+ });
145
+
146
+ hive.networkAdapter.whenReady().then(() => {
147
+ (hive.networkAdapter as any).syncKeyhive();
148
+ });
149
+
150
+ return { repo, hive };
151
+ }
152
+
153
+ // ── Classic sync ───────────────────────────────────────────────────────
154
+
155
+ let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
156
+ let classicSyncAdapter: WebSocketWorkerClientAdapter | null = null;
157
+ let classicSyncConnect: Promise<void> | null = null;
158
+
159
+ function connectClassicSyncNetwork(server: string): Promise<void> {
160
+ const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
161
+ if (classicSyncConnect && classicSyncServer === url)
162
+ return classicSyncConnect;
163
+
164
+ if (classicSyncAdapter && classicSyncServer !== url) {
165
+ classicSyncAdapter.disconnect();
166
+ classicSyncAdapter = null;
167
+ }
168
+
169
+ classicSyncServer = url;
170
+ const connecting = (async () => {
171
+ const repo = await getRepo();
172
+ if (!classicSyncAdapter) {
173
+ classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
174
+ repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
175
+ }
176
+ await classicSyncAdapter.whenReady();
177
+ log("classic sync connected", url);
178
+ })();
179
+
180
+ // Clear the memo on failure so a later attempt can retry, and swallow the
181
+ // rejection on this copy so it isn't reported as unhandled — callers get it
182
+ // from the promise we return.
183
+ classicSyncConnect = connecting;
184
+ connecting.catch(() => {
185
+ if (classicSyncConnect === connecting) classicSyncConnect = null;
186
+ });
187
+ return connecting;
188
+ }
189
+
190
+ // ── Control protocol ───────────────────────────────────────────────────
191
+
192
+ function handleControlMessage(
193
+ data: any,
194
+ controlPort: MessagePort,
195
+ event: MessageEvent
196
+ ): void {
197
+ if (data?.type !== "connect-classic-sync") return;
198
+ const [replyPort] = event.ports;
199
+ const server =
200
+ typeof data.server === "string" ? data.server : DEFAULT_CLASSIC_SYNC_SERVER;
201
+ connectClassicSyncNetwork(server).then(
202
+ () => {
203
+ replyPort?.postMessage({ type: "connect-classic-sync-ready" });
204
+ replyPort?.close();
205
+ },
206
+ (err) => {
207
+ console.error("connectClassicSyncNetwork failed", err);
208
+ replyPort?.postMessage({
209
+ type: "connect-classic-sync-failed",
210
+ error: String(err),
211
+ });
212
+ replyPort?.close();
213
+ }
214
+ );
215
+ }
216
+
217
+ // ── Resolving ──────────────────────────────────────────────────────────
218
+
219
+ function waitForHeads(
220
+ handle: DocHandle<unknown>,
221
+ hexHeads: string[],
222
+ signal: AbortSignal
223
+ ): Promise<boolean> {
224
+ if (hasHeads(handle.doc(), hexHeads)) return Promise.resolve(true);
225
+ if (signal.aborted) return Promise.resolve(false);
226
+ return new Promise((resolve) => {
227
+ const cleanup = () => {
228
+ handle.off("heads-changed", check);
229
+ signal.removeEventListener("abort", onAbort);
230
+ };
231
+ const check = () => {
232
+ if (!hasHeads(handle.doc(), hexHeads)) return;
233
+ cleanup();
234
+ resolve(true);
235
+ };
236
+ const onAbort = () => {
237
+ cleanup();
238
+ resolve(false);
239
+ };
240
+ handle.on("heads-changed", check);
241
+ signal.addEventListener("abort", onAbort);
242
+ // The heads may have landed between the check above and subscribing.
243
+ check();
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Thrown instead of returning a Response when the request should fail as a
249
+ * network error rather than resolve to something the caller can memoize.
250
+ * See {@link HandoffAbortMessage}.
251
+ */
252
+ class AbortHandoff extends Error {}
253
+
254
+ async function resolveAutomergeUrl(
255
+ automergeURL: URL,
256
+ signal: AbortSignal
257
+ ): Promise<Response> {
258
+ const repo = await getRepo();
259
+ const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/");
260
+
261
+ if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
262
+ return new Response("invalid automerge url", { status: 400 });
263
+ }
264
+
265
+ if (path.length && !path[path.length - 1]) path.pop();
266
+
267
+ const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
268
+
269
+ // todo, maybe a bad idea? maybe we should throw instead of es-module-caching
270
+ // the headless req
271
+ if (!heads) {
272
+ const folder = await repo.find(maybeAutomergeUrl, { signal });
273
+ const url = stringifyAutomergeUrl({ documentId, heads: folder.heads() });
274
+ const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`;
275
+ return Response.redirect(location, 307);
276
+ }
277
+
278
+ const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
279
+ signal,
280
+ });
281
+ if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
282
+ throw new AbortHandoff(
283
+ `heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`
284
+ );
285
+ }
286
+
287
+ const resolved = await resolvePath(
288
+ repo,
289
+ baseHandle.view(heads),
290
+ path.map(decodeURIComponent)
291
+ );
292
+ if (!resolved) {
293
+ throw new Error(
294
+ `couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`
295
+ );
296
+ }
297
+
298
+ const body: BodyInit =
299
+ resolved.content instanceof Uint8Array
300
+ ? (new Uint8Array(resolved.content) as BlobPart)
301
+ : resolved.content;
302
+
303
+ return new Response(body, {
304
+ status: 200,
305
+ headers: { "content-type": resolved.type },
306
+ });
307
+ }
308
+
309
+ const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
310
+
311
+ function replyToHandoff(id: string, status: number, body: string): void {
312
+ handoffChannel.postMessage({
313
+ id,
314
+ type: "response",
315
+ response: { status, body, headers: { "content-type": "text/plain" } },
316
+ } satisfies HandoffResponseMessage);
317
+ }
318
+
319
+ function impatience(limit: number) {
320
+ return new Promise<never>((_, reject) =>
321
+ setTimeout(
322
+ () => reject(new Error(`resolve timeout after ${limit}ms`)),
323
+ limit
324
+ )
325
+ );
326
+ }
327
+
328
+ async function handleHandoffRequest(message: HandoffRequestMessage) {
329
+ const { id, cachename, request } = message;
330
+
331
+ let handoff: URL;
332
+ try {
333
+ handoff = new URL(request.handoffURL);
334
+ } catch {
335
+ console.error("couldn't parse handoff url", request);
336
+ replyToHandoff(
337
+ id,
338
+ 400,
339
+ `couldn't parse a special url out of ${request.url}`
340
+ );
341
+ return;
342
+ }
343
+
344
+ // Other handlers may be listening on the channel for other schemes, so stay
345
+ // quiet rather than clobbering their reply with an error.
346
+ if (handoff.protocol !== "automerge:") {
347
+ log(
348
+ `ignoring handoff ${id} for non-automerge url ${handoff}. not my circus, not my monkeys`
349
+ );
350
+ return;
351
+ }
352
+
353
+ let response: Response;
354
+ try {
355
+ log(`resolving handoff ${id} for ${handoff}`);
356
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
357
+ response = await Promise.race([
358
+ resolveAutomergeUrl(handoff, signal),
359
+ impatience(RESOLVE_TIMEOUT_MS),
360
+ ]);
361
+ } catch (error) {
362
+ if (error instanceof AbortHandoff) {
363
+ handoffChannel.postMessage({
364
+ id,
365
+ type: "abort",
366
+ reason: error.message,
367
+ } satisfies HandoffAbortMessage);
368
+ return;
369
+ }
370
+ console.error(`error resolving ${request.url}`, error);
371
+ replyToHandoff(
372
+ id,
373
+ 557,
374
+ error instanceof Error
375
+ ? `${error.message}\n\n${error.stack}`
376
+ : String(error)
377
+ );
378
+ return;
379
+ }
380
+
381
+ try {
382
+ if (!CACHEABLE_STATUSES.includes(response.status)) {
383
+ // Errors, redirects and the like go back inline for the service worker to
384
+ // serve directly, so they aren't cached forever (still in esmodulecache,
385
+ // cleared after a refresh)
386
+ log(`responding inline to ${request.url} with ${response.status}`);
387
+ handoffChannel.postMessage({
388
+ id,
389
+ type: "response",
390
+ response: {
391
+ status: response.status,
392
+ headers: Object.fromEntries(response.headers.entries()),
393
+ body: response.body ? await response.text() : undefined,
394
+ },
395
+ } satisfies HandoffResponseMessage);
396
+ return;
397
+ }
398
+
399
+ // Reconstruct the request the service worker is holding so the entry matches
400
+ // its cache.match. `destination` isn't constructible but doesn't participate
401
+ // in cache matching.
402
+ const cacheKey = new Request(request.url, {
403
+ method: request.method,
404
+ headers: request.headers,
405
+ referrer: request.referrer,
406
+ });
407
+ const cache = await caches.open(cachename);
408
+ await cache.put(cacheKey, response);
409
+ log(`cached ${cacheKey.url} in ${cachename}`);
410
+ handoffChannel.postMessage({
411
+ id,
412
+ type: "cached",
413
+ } satisfies HandoffCachedMessage);
414
+ } catch (error) {
415
+ console.error(`failed to reply for ${request.url}`, error);
416
+ replyToHandoff(id, 558, String(error));
417
+ }
418
+ }
419
+
420
+ handoffChannel.addEventListener("message", (event) => {
421
+ if (event.data?.type === "request") {
422
+ void handleHandoffRequest(event.data as HandoffRequestMessage);
423
+ }
424
+ });
425
+
426
+ // Announce ourselves so the service worker can re-broadcast handoff requests
427
+ // sent while we were booting.
428
+ handoffChannel.postMessage({ type: "online" } satisfies HandoffOnlineMessage);
@@ -6,11 +6,7 @@ const externals = [
6
6
  "@automerge/automerge/slim",
7
7
  "@automerge/automerge-repo",
8
8
  "@automerge/automerge-repo/slim",
9
- // Port-donation plumbing for WorkerWebSocketEndpoint: tabs spawn the shared
10
- // proxy entry and donate its port to the automerge worker (Chrome can't
11
- // spawn workers from inside a SharedWorker). See setup.ts/automerge-worker.ts.
12
- "@automerge/automerge-repo/worker-port",
13
- "@automerge/automerge-repo/subduction-websocket-worker-shared",
9
+ "@automerge/automerge-repo-network-broadcastchannel",
14
10
  "@automerge/automerge-repo-network-messagechannel",
15
11
  "@automerge/automerge-repo-network-websocket",
16
12
  "@automerge/automerge-repo-storage-indexeddb",