@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # @inkandswitch/patchwork-bootloader
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2e71745: Every Repo on the origin is its own Subduction node. A tab holds this origin's IndexedDB, keeps its own WebSocket to the sync server, and meets the other tabs over a BroadcastChannel carrying Subduction (`siblingAdapters()` in `@inkandswitch/patchwork-bootloader/siblings`, passed to `new Repo({ subductionAdapters })`). The automerge SharedWorker no longer sits between tabs and storage — it is one more such node, kept only to resolve `automerge:` URLs for the service worker, which can't own a Repo itself. The websocket proxy worker is gone with it.
8
+
9
+ Benchmarked against the shared-worker arrangement (`sites/bench`): boot and memory are a wash or better, cross-tab propagation matches, and two shared-worker failures go away — a `find()` racing a sibling's `create()` settled as unavailable, and edits made just before a tab closed were lost, since a storageless tab had nothing to flush to. Each tab flushing its own IndexedDB closes both.
10
+
11
+ Keyhive sites use the subduction-backed hive in both the tab and the worker, each talking to the sync server directly.
12
+
13
+ `patchwork.sw.subscribeSyncState(documentId, listener)` stays, now a filter over the tab's own Repo's `subduction-remote-heads` event, replaying the server's current heads from `handle.getSyncInfo()` on subscribe; `SyncStateDocMessage` is exported from `@inkandswitch/patchwork`. Removed from `setupServiceWorker()`'s result: `subscribeToRepoChannel`, `getRepoChannel`, `subscribeSyncState`. The `@patchwork/syncstate` BroadcastChannel and the other `SyncState*` message types are gone too; a tab's own Repo has what they carried — `repo.isSubductionConnected()` and the `subduction-connection` event for the link, `repo.connectedSubductionPeerIds()` for which peers are the server, and `patchwork.signerIdentity` for this tab's peer id. `createRepo` in `@inkandswitch/patchwork` takes no arguments.
14
+
15
+ `@inkandswitch/patchwork-bootloader` depends on `@automerge/automerge-repo-network-broadcastchannel`, which is also on the importmap.
16
+
17
+ That worker is renamed for what it now does: `@inkandswitch/patchwork-bootloader/automerge-worker` is now `@inkandswitch/patchwork-bootloader/automerge-protocol-handler-worker`, emitted as `automerge-protocol-handler-worker.js` (the `workerPath` option on `setupServiceWorker` still overrides it), and `getAutomergeWorker()` is now `getAutomergeProtocolHandlerWorker()`.
18
+
19
+ Inter-tab sync is Subduction, not classic automerge sync. `connectSiblings(repo, hive)` is replaced by `siblingAdapters()`, which returns the `subductionAdapters` entries for a Repo rather than mutating one after the fact, so it is passed to the `Repo` constructor. The frames on the siblings BroadcastChannel are Subduction transport frames authenticated by each node's own signer; the keyhive network adapter no longer wraps that channel, since keyhive material reaches siblings the same way it reaches the sync server. The one remaining classic-sync path is the opt-in classic sync server the protocol handler worker connects to on request.
20
+
21
+ Subduction's handshake has an initiator and a responder, and a BroadcastChannel is a mesh, so the siblings adapter is passed with `role: "mesh"`, added to the automerge-repo fork's `subductionAdapters` by this repo's pnpm patch: for each pair of peers on the adapter, the one whose peer id sorts lower initiates the handshake and the other accepts.
22
+
23
+ ### Patch Changes
24
+
25
+ - 47bc4cf: `@automerge/automerge` goes to `3.4.1`, and `@automerge/automerge-repo-network-broadcastchannel` joins the automerge-repo family at `2.6.0-subduction.48`.
26
+ - Updated dependencies [47bc4cf]
27
+ - @inkandswitch/patchwork-filesystem@0.2.9
28
+ - @inkandswitch/patchwork-plugins@1.2.4
29
+
30
+ ## 0.6.3
31
+
32
+ ### Patch Changes
33
+
34
+ - ebca53b: Move the automerge-repo subduction fork to 2.6.0-subduction.48, `@automerge/automerge-repo-keyhive` to 0.5.0-alpha.7, and `@keyhive/keyhive` to 0.1.0-alpha.8. These three are bumped together because the keyhive package pins its automerge-repo version exactly.
35
+
36
+ keyhive 0.5 renames the two hive flavours. The network-adapter hive is now `LegacyAutomergeRepoKeyhive`, built by `initializeLegacyAutomergeRepoKeyhive`; the subduction hive keeps the name `AutomergeRepoKeyhive` and is built by `initializeAutomergeRepoKeyhive`. Both extend `AutomergeRepoKeyhiveBase`, which is what Patchwork's `hive` fields are typed as, so a tool that only reads membership works against either.
37
+
38
+ `createKeyhiveNetworkAdapter` takes an options object instead of positional arguments, and `onlyShareWithHardcodedServerPeerId` is now `onlyShareWithSyncServer`.
39
+
40
+ - Updated dependencies [ebca53b]
41
+ - Updated dependencies [882eacd]
42
+ - @inkandswitch/patchwork-elements@6.0.2
43
+ - @inkandswitch/patchwork-filesystem@0.2.8
44
+ - @inkandswitch/patchwork-plugins@1.2.3
45
+ - @inkandswitch/patchwork-providers@0.5.2
46
+
3
47
  ## 0.6.2
4
48
 
5
49
  ### Patch Changes
@@ -0,0 +1,315 @@
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
+ import { MemorySigner } from "@automerge/automerge-subduction/slim";
17
+ import { Repo, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, } from "@automerge/automerge-repo/slim";
18
+ import { resolvePath } from "@inkandswitch/patchwork-filesystem";
19
+ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
20
+ import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
21
+ import { initializeAutomergeRepoKeyhive, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
22
+ import { DEFAULT_CLASSIC_SYNC_SERVER } from "./sync-config.js";
23
+ import { siblingAdapters } from "./siblings.js";
24
+ import { keyhiveStorageName, storagePrefix } from "./storage.js";
25
+ import { startWorkerControl } from "./worker-control.js";
26
+ import { HANDOFF_CHANNEL, } from "./types.js";
27
+ const syncServer = typeof __SYNC_SERVER__ !== "undefined"
28
+ ? __SYNC_SERVER__
29
+ : { url: "wss://subduction.sync.inkandswitch.com" };
30
+ const RESOLVE_TIMEOUT_MS = 30_000;
31
+ const CACHEABLE_STATUSES = [200, 203, 204];
32
+ const control = startWorkerControl("automerge-protocol-handler-worker", {
33
+ onMessage: handleControlMessage,
34
+ });
35
+ const log = control.log;
36
+ // ── The repo ───────────────────────────────────────────────────────────
37
+ let repoPromise = null;
38
+ function getRepo() {
39
+ if (!repoPromise) {
40
+ repoPromise = buildRepo();
41
+ // Don't cache a rejection (e.g. the wasm fetch failed): clear the slot so
42
+ // the next caller retries from scratch.
43
+ repoPromise.catch(() => {
44
+ repoPromise = null;
45
+ });
46
+ }
47
+ return repoPromise;
48
+ }
49
+ async function buildRepo() {
50
+ log("fetching wasm");
51
+ const [automergeWasm, subductionWasm] = await Promise.all([
52
+ fetch("/automerge.wasm").then((r) => r.arrayBuffer()),
53
+ fetch("/subduction.wasm").then((r) => r.arrayBuffer()),
54
+ ]);
55
+ initSubductionSync(new Uint8Array(subductionWasm));
56
+ await initializeWasm(new Uint8Array(automergeWasm));
57
+ log("wasm initialized");
58
+ const { repo, hive } = syncServer.keyhive
59
+ ? await buildKeyhiveRepo(syncServer.keyhive)
60
+ : { repo: buildPlainRepo() };
61
+ self.repo = repo;
62
+ if (hive)
63
+ self.hive = hive;
64
+ return repo;
65
+ }
66
+ function buildPlainRepo() {
67
+ return new Repo({
68
+ signer: new MemorySigner(),
69
+ storage: new IndexedDBWorkerStorageAdapter(),
70
+ peerId: `${storagePrefix}-resolver-${Math.random().toString(36).slice(2)}`,
71
+ subductionWebsocketEndpoints: [syncServer.url],
72
+ subductionAdapters: siblingAdapters(),
73
+ enableRemoteHeadsGossiping: true,
74
+ });
75
+ }
76
+ async function buildKeyhiveRepo(keyhiveSyncServer) {
77
+ initKeyhiveWasm();
78
+ const { hive, repo } = await initializeAutomergeRepoKeyhive({
79
+ createRepo: (config) => new Repo(config),
80
+ storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName),
81
+ peerIdSuffix: `${storagePrefix}-resolver` + Math.random().toString(36).slice(2),
82
+ automaticArchiveIngestion: true,
83
+ cachingMode: "periodic",
84
+ // ARK selects the relay via `syncServer`, which pairs the contact card with
85
+ // the matching peer id. Omitting it defaults to "subduction".
86
+ syncServer: keyhiveSyncServer,
87
+ repo: {
88
+ storage: new IndexedDBWorkerStorageAdapter(),
89
+ subductionWebsocketEndpoints: [syncServer.url],
90
+ subductionAdapters: siblingAdapters(),
91
+ enableRemoteHeadsGossiping: true,
92
+ },
93
+ });
94
+ hive.networkAdapter.whenReady().then(() => {
95
+ hive.networkAdapter.syncKeyhive();
96
+ });
97
+ return { repo, hive };
98
+ }
99
+ // ── Classic sync ───────────────────────────────────────────────────────
100
+ let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
101
+ let classicSyncAdapter = null;
102
+ let classicSyncConnect = null;
103
+ function connectClassicSyncNetwork(server) {
104
+ const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
105
+ if (classicSyncConnect && classicSyncServer === url)
106
+ return classicSyncConnect;
107
+ if (classicSyncAdapter && classicSyncServer !== url) {
108
+ classicSyncAdapter.disconnect();
109
+ classicSyncAdapter = null;
110
+ }
111
+ classicSyncServer = url;
112
+ const connecting = (async () => {
113
+ const repo = await getRepo();
114
+ if (!classicSyncAdapter) {
115
+ classicSyncAdapter = new WebSocketWorkerClientAdapter(url);
116
+ repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter);
117
+ }
118
+ await classicSyncAdapter.whenReady();
119
+ log("classic sync connected", url);
120
+ })();
121
+ // Clear the memo on failure so a later attempt can retry, and swallow the
122
+ // rejection on this copy so it isn't reported as unhandled — callers get it
123
+ // from the promise we return.
124
+ classicSyncConnect = connecting;
125
+ connecting.catch(() => {
126
+ if (classicSyncConnect === connecting)
127
+ classicSyncConnect = null;
128
+ });
129
+ return connecting;
130
+ }
131
+ // ── Control protocol ───────────────────────────────────────────────────
132
+ function handleControlMessage(data, controlPort, event) {
133
+ if (data?.type !== "connect-classic-sync")
134
+ return;
135
+ const [replyPort] = event.ports;
136
+ const server = typeof data.server === "string" ? data.server : DEFAULT_CLASSIC_SYNC_SERVER;
137
+ connectClassicSyncNetwork(server).then(() => {
138
+ replyPort?.postMessage({ type: "connect-classic-sync-ready" });
139
+ replyPort?.close();
140
+ }, (err) => {
141
+ console.error("connectClassicSyncNetwork failed", err);
142
+ replyPort?.postMessage({
143
+ type: "connect-classic-sync-failed",
144
+ error: String(err),
145
+ });
146
+ replyPort?.close();
147
+ });
148
+ }
149
+ // ── Resolving ──────────────────────────────────────────────────────────
150
+ function waitForHeads(handle, hexHeads, signal) {
151
+ if (hasHeads(handle.doc(), hexHeads))
152
+ return Promise.resolve(true);
153
+ if (signal.aborted)
154
+ return Promise.resolve(false);
155
+ return new Promise((resolve) => {
156
+ const cleanup = () => {
157
+ handle.off("heads-changed", check);
158
+ signal.removeEventListener("abort", onAbort);
159
+ };
160
+ const check = () => {
161
+ if (!hasHeads(handle.doc(), hexHeads))
162
+ return;
163
+ cleanup();
164
+ resolve(true);
165
+ };
166
+ const onAbort = () => {
167
+ cleanup();
168
+ resolve(false);
169
+ };
170
+ handle.on("heads-changed", check);
171
+ signal.addEventListener("abort", onAbort);
172
+ // The heads may have landed between the check above and subscribing.
173
+ check();
174
+ });
175
+ }
176
+ /**
177
+ * Thrown instead of returning a Response when the request should fail as a
178
+ * network error rather than resolve to something the caller can memoize.
179
+ * See {@link HandoffAbortMessage}.
180
+ */
181
+ class AbortHandoff extends Error {
182
+ }
183
+ async function resolveAutomergeUrl(automergeURL, signal) {
184
+ const repo = await getRepo();
185
+ const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/");
186
+ if (!isValidAutomergeUrl(maybeAutomergeUrl)) {
187
+ return new Response("invalid automerge url", { status: 400 });
188
+ }
189
+ if (path.length && !path[path.length - 1])
190
+ path.pop();
191
+ const { heads, hexHeads, documentId } = parseAutomergeUrl(maybeAutomergeUrl);
192
+ // todo, maybe a bad idea? maybe we should throw instead of es-module-caching
193
+ // the headless req
194
+ if (!heads) {
195
+ const folder = await repo.find(maybeAutomergeUrl, { signal });
196
+ const url = stringifyAutomergeUrl({ documentId, heads: folder.heads() });
197
+ const location = `/${encodeURIComponent(url)}${path.length ? `/${path.join("/")}` : ""}`;
198
+ return Response.redirect(location, 307);
199
+ }
200
+ const baseHandle = await repo.find(stringifyAutomergeUrl({ documentId }), {
201
+ signal,
202
+ });
203
+ if (!(await waitForHeads(baseHandle, hexHeads ?? [], signal))) {
204
+ throw new AbortHandoff(`heads not found for ${maybeAutomergeUrl} within ${RESOLVE_TIMEOUT_MS}ms`);
205
+ }
206
+ const resolved = await resolvePath(repo, baseHandle.view(heads), path.map(decodeURIComponent));
207
+ if (!resolved) {
208
+ throw new Error(`couldn't resolve ${path.join("/")} in folder at ${maybeAutomergeUrl}`);
209
+ }
210
+ const body = resolved.content instanceof Uint8Array
211
+ ? new Uint8Array(resolved.content)
212
+ : resolved.content;
213
+ return new Response(body, {
214
+ status: 200,
215
+ headers: { "content-type": resolved.type },
216
+ });
217
+ }
218
+ const handoffChannel = new BroadcastChannel(HANDOFF_CHANNEL);
219
+ function replyToHandoff(id, status, body) {
220
+ handoffChannel.postMessage({
221
+ id,
222
+ type: "response",
223
+ response: { status, body, headers: { "content-type": "text/plain" } },
224
+ });
225
+ }
226
+ function impatience(limit) {
227
+ return new Promise((_, reject) => setTimeout(() => reject(new Error(`resolve timeout after ${limit}ms`)), limit));
228
+ }
229
+ async function handleHandoffRequest(message) {
230
+ const { id, cachename, request } = message;
231
+ let handoff;
232
+ try {
233
+ handoff = new URL(request.handoffURL);
234
+ }
235
+ catch {
236
+ console.error("couldn't parse handoff url", request);
237
+ replyToHandoff(id, 400, `couldn't parse a special url out of ${request.url}`);
238
+ return;
239
+ }
240
+ // Other handlers may be listening on the channel for other schemes, so stay
241
+ // quiet rather than clobbering their reply with an error.
242
+ if (handoff.protocol !== "automerge:") {
243
+ log(`ignoring handoff ${id} for non-automerge url ${handoff}. not my circus, not my monkeys`);
244
+ return;
245
+ }
246
+ let response;
247
+ try {
248
+ log(`resolving handoff ${id} for ${handoff}`);
249
+ const signal = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
250
+ response = await Promise.race([
251
+ resolveAutomergeUrl(handoff, signal),
252
+ impatience(RESOLVE_TIMEOUT_MS),
253
+ ]);
254
+ }
255
+ catch (error) {
256
+ if (error instanceof AbortHandoff) {
257
+ handoffChannel.postMessage({
258
+ id,
259
+ type: "abort",
260
+ reason: error.message,
261
+ });
262
+ return;
263
+ }
264
+ console.error(`error resolving ${request.url}`, error);
265
+ replyToHandoff(id, 557, error instanceof Error
266
+ ? `${error.message}\n\n${error.stack}`
267
+ : String(error));
268
+ return;
269
+ }
270
+ try {
271
+ if (!CACHEABLE_STATUSES.includes(response.status)) {
272
+ // Errors, redirects and the like go back inline for the service worker to
273
+ // serve directly, so they aren't cached forever (still in esmodulecache,
274
+ // cleared after a refresh)
275
+ log(`responding inline to ${request.url} with ${response.status}`);
276
+ handoffChannel.postMessage({
277
+ id,
278
+ type: "response",
279
+ response: {
280
+ status: response.status,
281
+ headers: Object.fromEntries(response.headers.entries()),
282
+ body: response.body ? await response.text() : undefined,
283
+ },
284
+ });
285
+ return;
286
+ }
287
+ // Reconstruct the request the service worker is holding so the entry matches
288
+ // its cache.match. `destination` isn't constructible but doesn't participate
289
+ // in cache matching.
290
+ const cacheKey = new Request(request.url, {
291
+ method: request.method,
292
+ headers: request.headers,
293
+ referrer: request.referrer,
294
+ });
295
+ const cache = await caches.open(cachename);
296
+ await cache.put(cacheKey, response);
297
+ log(`cached ${cacheKey.url} in ${cachename}`);
298
+ handoffChannel.postMessage({
299
+ id,
300
+ type: "cached",
301
+ });
302
+ }
303
+ catch (error) {
304
+ console.error(`failed to reply for ${request.url}`, error);
305
+ replyToHandoff(id, 558, String(error));
306
+ }
307
+ }
308
+ handoffChannel.addEventListener("message", (event) => {
309
+ if (event.data?.type === "request") {
310
+ void handleHandoffRequest(event.data);
311
+ }
312
+ });
313
+ // Announce ourselves so the service worker can re-broadcast handoff requests
314
+ // sent while we were booting.
315
+ handoffChannel.postMessage({ type: "online" });
@@ -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",
package/dist/setup.d.ts CHANGED
@@ -1,10 +1,7 @@
1
- import type { SetupServiceWorkerOptions, SetupServiceWorkerResult, SyncStateDocMessage } from "./types.js";
2
- import debug from "debug";
3
- export declare const lifecycleLog: debug.Debugger;
1
+ import type { SetupServiceWorkerOptions, SetupServiceWorkerResult } from "./types.js";
2
+ import { lifecycleLog } from "./shared-worker-lifecycle.js";
3
+ export { lifecycleLog };
4
4
  export declare function bumpServiceWorkerCache(sw?: ServiceWorker | null): void;
5
- export declare function getAutomergeWorker(): SharedWorker;
6
- type SyncStateListener = (update: SyncStateDocMessage) => void;
7
- export declare function subscribeSyncState(documentId: string, listener: SyncStateListener): () => void;
5
+ export declare function getAutomergeProtocolHandlerWorker(): SharedWorker;
8
6
  export declare function connectClassicSync(server?: string): Promise<void>;
9
7
  export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<SetupServiceWorkerResult>;
10
- export {};