From e21a008d306072a58bf1f0d242ac75127475650a Mon Sep 17 00:00:00 2001 From: chee Date: Wed, 12 Aug 2026 16:30:37 +0100 Subject: [PATCH 01/16] experimentally enable subduction in the browser tab --- core/bootloader/package.json | 4 + core/bootloader/src/automerge-worker.ts | 104 ++++++++--------------- core/bootloader/src/port-hub.ts | 107 ++++++++++++++++++++++++ core/patchwork/src/index.ts | 37 +++----- core/patchwork/src/repo.ts | 82 +++++++++--------- 5 files changed, 198 insertions(+), 136 deletions(-) create mode 100644 core/bootloader/src/port-hub.ts diff --git a/core/bootloader/package.json b/core/bootloader/package.json index 341a89df..cbd8d523 100644 --- a/core/bootloader/package.json +++ b/core/bootloader/package.json @@ -26,6 +26,10 @@ "import": "./dist/externals-list.js", "types": "./dist/externals-list.d.ts" }, + "./port-hub": { + "import": "./dist/port-hub.js", + "types": "./dist/port-hub.d.ts" + }, "./storage": { "import": "./dist/storage.js", "types": "./dist/storage.d.ts" diff --git a/core/bootloader/src/automerge-worker.ts b/core/bootloader/src/automerge-worker.ts index 01aa2013..ef2247c4 100644 --- a/core/bootloader/src/automerge-worker.ts +++ b/core/bootloader/src/automerge-worker.ts @@ -28,7 +28,6 @@ import { import { resolvePath } from "@inkandswitch/patchwork-filesystem"; import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; -import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel"; import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket"; import { initializeAutomergeRepoKeyhive, @@ -38,6 +37,7 @@ import { } from "@automerge/automerge-repo-keyhive"; import { DEFAULT_CLASSIC_SYNC_SERVER } from "./sync-config.js"; +import { PortHubAdapter, WORKER_SUBDUCTION_SERVICE } from "./port-hub.js"; import { keyhiveStorageName, storagePrefix } from "./storage.js"; import { HANDOFF_CHANNEL, @@ -195,6 +195,11 @@ function pushSyncState(message: SyncStateDocMessage): void { const subductionPortProvider = makePortProvider(); +// Tabs sync with this repo over subduction, one transport per repo port. The +// hub exists before the repo does because ports arrive whenever a tab connects, +// long after `subductionAdapters` is read. +const tabHub = new PortHubAdapter({ useWeakRef: true }); + // Memoized so a construction retry reuses the endpoint instead of leaking one // per attempt. let subductionEndpoints: WorkerWebSocketEndpoint[] | null = null; @@ -273,6 +278,13 @@ async function buildPlainRepo(): Promise { }, enableRemoteHeadsGossiping: true, subductionWebsocketEndpoints: getSubductionEndpoints(), + subductionAdapters: [ + { + adapter: tabHub, + serviceName: WORKER_SUBDUCTION_SERVICE, + role: "accept", + }, + ], }); console.log("[patchwork] shared-worker subduction identity:", identity); return { repo, identity }; @@ -295,6 +307,13 @@ async function buildKeyhiveRepo( repo: { storage: new IndexedDBWorkerStorageAdapter(), subductionWebsocketEndpoints: getSubductionEndpoints(), + subductionAdapters: [ + { + adapter: tabHub, + serviceName: WORKER_SUBDUCTION_SERVICE, + role: "accept", + }, + ], enableRemoteHeadsGossiping: true, }, }); @@ -634,80 +653,31 @@ function reviewAllResync(state: SyncState): void { // ── Tab connections ──────────────────────────────────────────────────── // Each tab connects with a control port and opens repo MessageChannel ports -// through it. `adapter` is what was registered with the network subsystem (the -// MessageChannel adapter, or the keyhive wrapper around it); `mcAdapter` is -// always the underlying MessageChannel adapter, so the port itself can be -// disconnected. - -type RepoChannel = { - adapter: { disconnect(): void }; - mcAdapter: MessageChannelNetworkAdapter; - port: MessagePort; -}; -type Connection = { channels: Set }; +// through it. Those ports go to the subduction hub, so tabs sync with this +// repo over subduction whether or not keyhive is in play. -function dropRepoChannel(repo: Repo, channel: RepoChannel) { - // removeNetworkAdapter pulls the adapter out of networkSubsystem.adapters and - // calls disconnect(), which for the MessageChannel adapter emits the - // close/peer-disconnected events that clear #adaptersByPeer. - try { - repo.networkSubsystem.removeNetworkAdapter(channel.adapter as any); - } catch (err) { - console.error("removeNetworkAdapter failed", err); - } - // On the keyhive path the registered adapter is a wrapper, so make sure the - // underlying port is disconnected and closed too. - try { - channel.mcAdapter.disconnect(); - } catch {} - try { - channel.port.close(); - } catch {} -} +type RepoChannel = { drop(): void }; +type Connection = { channels: Set }; async function dropConnection(connection: Connection) { - if (!connection.channels.size || !repoHivePromise) return; - const { repo } = await getRepoHive(); - log(`tab gone — removing ${connection.channels.size} network adapter(s)`); - for (const channel of connection.channels) dropRepoChannel(repo, channel); + if (!connection.channels.size) return; + log(`tab gone — dropping ${connection.channels.size} repo channel(s)`); + for (const channel of connection.channels) channel.drop(); connection.channels.clear(); } async function connectPort(port: MessagePort, connection: Connection) { - const { hive, repo } = await getRepoHive(); - const mcAdapter = new MessageChannelNetworkAdapter(port, { - useWeakRef: true, - }); - - if (!hive) { - repo.networkSubsystem.addNetworkAdapter(mcAdapter); - connection.channels.add({ adapter: mcAdapter, mcAdapter, port }); - return; - } - - const adapter = hive.createKeyhiveNetworkAdapter(mcAdapter, { - onlyShareWithSyncServer: false, - periodicallyRequestSync: false, - syncRequestInterval: 2000, - }); - - adapter.on("message", (msg: any) => { - if (msg.type !== "sync" && msg.type !== "request") return; - if (!msg.documentId) return; - const handle = repo.handles[msg.documentId]; - if (handle && handle.state !== "unavailable") return; - repo.findWithProgress(`automerge:${msg.documentId}` as AutomergeUrl); - repo.shareConfigChanged(); - }); - - (adapter as any).on("ingest-remote", () => { - hive.notifySameAgentKeyhiveChange(); - (hive.networkAdapter as any).syncKeyhive?.(); - repo.shareConfigChanged(); + // The repo has to exist before its hub is worth handing a port to. + await getRepoHive(); + const removePort = tabHub.addPort(port); + connection.channels.add({ + drop() { + removePort(); + try { + port.close(); + } catch {} + }, }); - - repo.networkSubsystem.addNetworkAdapter(adapter); - connection.channels.add({ adapter, mcAdapter, port }); } function handleControlMessage( diff --git a/core/bootloader/src/port-hub.ts b/core/bootloader/src/port-hub.ts new file mode 100644 index 00000000..49a3e137 --- /dev/null +++ b/core/bootloader/src/port-hub.ts @@ -0,0 +1,107 @@ +import { + NetworkAdapter, + type Message, + type NetworkAdapterInterface, + type PeerId, + type PeerMetadata, +} from "@automerge/automerge-repo/slim"; +import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel"; + +/** + * Subduction service name for the tab ↔ automerge worker link. Both ends have + * to name the same service or the handshake never completes. + */ +export const WORKER_SUBDUCTION_SERVICE = "patchwork-automerge-worker"; + +/** + * One network adapter over many MessagePorts, so ports can come and go after + * the Repo is built. `subductionAdapters` is read once at construction: the + * worker gains a tab's port long after that, and a tab gets a fresh port every + * time the worker is recreated. + * + * Each port keeps its own MessageChannelNetworkAdapter, which owns the + * arrive/welcome handshake; the hub fans their messages in and routes outgoing + * ones by targetId. Subduction's AdapterConnections opens a transport per + * peer-candidate, so one hub carries a transport per port. + */ +export class PortHubAdapter extends NetworkAdapter { + #children = new Set(); + #byPeer = new Map(); + #waiting: MessageChannelNetworkAdapter[] = []; + #peered = Promise.withResolvers(); + #useWeakRef: boolean; + + constructor({ useWeakRef = false }: { useWeakRef?: boolean } = {}) { + super(); + this.#useWeakRef = useWeakRef; + } + + isReady(): boolean { + return this.#byPeer.size > 0; + } + + /** Resolves once some port has announced a peer. */ + whenReady(): Promise { + return this.#peered.promise; + } + + connect(peerId: PeerId, peerMetadata?: PeerMetadata): void { + this.peerId = peerId; + this.peerMetadata = peerMetadata; + for (const child of this.#waiting.splice(0)) { + child.connect(peerId, peerMetadata); + } + } + + /** Returns a function that drops this port again. */ + addPort(port: MessagePort): () => void { + const child = new MessageChannelNetworkAdapter(port, { + useWeakRef: this.#useWeakRef, + }); + this.#children.add(child); + + child.on("peer-candidate", (payload) => { + this.#byPeer.set(payload.peerId, child); + this.#peered.resolve(); + this.emit("peer-candidate", payload); + }); + child.on("peer-disconnected", (payload) => { + if (this.#byPeer.get(payload.peerId) === child) { + this.#byPeer.delete(payload.peerId); + } + this.emit("peer-disconnected", payload); + }); + // Deliberately not forwarding "close": one port going away doesn't close + // the hub. + child.on("message", (message) => this.emit("message", message)); + + if (this.peerId) child.connect(this.peerId, this.peerMetadata); + else this.#waiting.push(child); + + return () => this.#drop(child); + } + + #drop(child: MessageChannelNetworkAdapter): void { + if (!this.#children.delete(child)) return; + const waiting = this.#waiting.indexOf(child); + if (waiting !== -1) this.#waiting.splice(waiting, 1); + // Emits peer-disconnected, which tears the peer's transport down. + try { + child.disconnect(); + } catch {} + } + + send(message: Message): void { + // Through the interface: the concrete adapter narrows `send` to the repo's + // own message union, and subduction frames carry their own type. + const child: NetworkAdapterInterface | undefined = this.#byPeer.get( + message.targetId + ); + child?.send(message); + } + + disconnect(): void { + for (const child of [...this.#children]) this.#drop(child); + this.emit("close"); + } +} diff --git a/core/patchwork/src/index.ts b/core/patchwork/src/index.ts index 1e48f411..1e96b771 100644 --- a/core/patchwork/src/index.ts +++ b/core/patchwork/src/index.ts @@ -20,7 +20,6 @@ import { type AutomergeUrl, type DocHandle, - MessageChannelNetworkAdapter, Repo, } from "@automerge/vanillajs/slim"; import * as Automerge from "@automerge/automerge/slim"; @@ -53,12 +52,7 @@ import type { PatchworkOptions, SignerIdentity, } from "./types.js"; -import { - createRepo, - firstRepoPort, - initWasm, - removeAdapterFor, -} from "./repo.js"; +import { createRepo, firstRepoPort, initWasm } from "./repo.js"; import { createRouter, type Router } from "./router.js"; import { createDefaultAccount } from "./createAccount.js"; @@ -133,6 +127,9 @@ async function doSetup(options: PatchworkOptions): Promise { // Called with a fresh port when the automerge worker dies and is recreated. // Assigned once the repo exists. let onWorkerPortRenewed: ((port: MessagePort) => void) | undefined; + // Resolves once the worker has answered on the repo port. A provided repo + // brings its own network, so there's nothing here to wait for. + let linked: Promise = Promise.resolve(); if (options.repo) { log("using provided Repo"); @@ -148,26 +145,14 @@ async function doSetup(options: PatchworkOptions): Promise { } }); - let workerAdapter = new MessageChannelNetworkAdapter(workerPort); - ({ repo, hive, signerIdentity } = await createRepo(workerAdapter)); + const tab = await createRepo(workerPort); + ({ repo, hive, signerIdentity } = tab); + linked = tab.linked(); // The worker was recreated with cold state: wire the repo onto the fresh - // port and drop the adapter stranded on the dead one. - const bootHive = hive; + // port and drop whatever is stranded on the dead one. onWorkerPortRenewed = (port) => { - const fresh = new MessageChannelNetworkAdapter(port); - // Mirror the boot wiring: a keyhive repo talks to the worker through a - // keyhive adapter wrapped around the message channel. - const registered = bootHive - ? bootHive.createKeyhiveNetworkAdapter(fresh, { - onlyShareWithSyncServer: false, - periodicallyRequestSync: false, - syncRequestInterval: 2000, - }) - : fresh; - repo.networkSubsystem.addNetworkAdapter(registered as any); - removeAdapterFor(repo, workerAdapter, registered); - workerAdapter = fresh; + tab.rewire(port); lifecycleLog("repo re-wired to the recreated automerge worker"); }; } @@ -181,8 +166,8 @@ async function doSetup(options: PatchworkOptions): Promise { AutomergeRepo as typeof import("@automerge/automerge-repo"); if (hive) window.hive = hive; - await repo.networkSubsystem.whenReady(); - log("networkSubsystem ready"); + await linked; + log("worker link ready"); (hive?.networkAdapter as any)?.syncKeyhive?.(); registerRepoProviderElement(repo as any); diff --git a/core/patchwork/src/repo.ts b/core/patchwork/src/repo.ts index 532b2709..7b26c90c 100644 --- a/core/patchwork/src/repo.ts +++ b/core/patchwork/src/repo.ts @@ -1,14 +1,17 @@ import { initializeWasm, - MessageChannelNetworkAdapter, Repo, type AutomergeUrl, } from "@automerge/vanillajs/slim"; import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; +import { + PortHubAdapter, + WORKER_SUBDUCTION_SERVICE, +} from "@inkandswitch/patchwork-bootloader/port-hub"; import * as AutomergeRepo from "@automerge/automerge-repo/slim"; import { initKeyhiveWasm, - initializeLegacyAutomergeRepoKeyhive, + initializeAutomergeRepoKeyhive, type AutomergeRepoKeyhiveBase, type SyncServerSelection, } from "@automerge/automerge-repo-keyhive"; @@ -53,47 +56,59 @@ export function initWasm(): Promise { return wasmReady; } -export async function createRepo( - workerAdapter: MessageChannelNetworkAdapter -): Promise<{ +export type TabRepo = { repo: Repo; hive?: AutomergeRepoKeyhiveBase; signerIdentity?: SignerIdentity; -}> { + /** Wire the repo onto a port from a freshly recreated automerge worker. */ + rewire(port: MessagePort): void; + /** Resolves once the worker has answered on some port. */ + linked(): Promise; +}; + +export async function createRepo(workerPort: MessagePort): Promise { + // The tab is a storageless node: the worker holds the IndexedDB and the tab + // syncs against it over subduction, one transport per repo port. The hub + // outlives any single port, so a recreated worker just hands over a new one. + const link = new PortHubAdapter(); + let dropWorkerPort = link.addPort(workerPort); + const subductionAdapters = [ + { + adapter: link, + serviceName: WORKER_SUBDUCTION_SERVICE, + role: "connect" as const, + }, + ]; + const linked = () => link.whenReady(); + const rewire = (port: MessagePort) => { + dropWorkerPort(); + dropWorkerPort = link.addPort(port); + }; + if (syncServer.keyhive) { log("setting up keyhive"); initKeyhiveWasm(); - const { hive, repo } = await initializeLegacyAutomergeRepoKeyhive({ + const { hive, repo } = await initializeAutomergeRepoKeyhive({ createRepo: (repoConfig) => new Repo(repoConfig), storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName), peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2), - networkAdapter: workerAdapter, automaticArchiveIngestion: true, cachingMode: "periodic", - onlyShareWithSyncServer: false, // ARK selects the relay via `syncServer`, defaulting to "subduction". syncServer: syncServer.keyhive, - repo: { - storage: new IndexedDBWorkerStorageAdapter(), - enableRemoteHeadsGossiping: true, - }, + repo: { subductionAdapters }, }); log("keyhive setup complete"); - return { repo, hive }; + return { repo, hive, linked, rewire }; } - // An explicit signer, rather than the Repo's internal default, so the tab's - // identity can be exposed on window.patchwork. The tab never connects via - // Subduction, so this id never goes on the wire. + // The signer is explicit rather than the Repo's internal default so the + // identity the tab presents in the subduction handshake can be shown on + // window.patchwork. Keyhive supplies its own. const signer = new MemorySigner(); const repo = new Repo({ - network: [workerAdapter], - storage: new IndexedDBWorkerStorageAdapter(), signer, - async sharePolicy(peerId) { - return peerId.includes("automerge-worker"); - }, - enableRemoteHeadsGossiping: true, + subductionAdapters, peerId: `${storagePrefix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId, }); @@ -106,7 +121,7 @@ export async function createRepo( ).toHex(), }; log("repo created, tab subduction identity:", signerIdentity); - return { repo, signerIdentity }; + return { repo, signerIdentity, linked, rewire }; } /** @@ -132,22 +147,3 @@ export function firstRepoPort( }); }); } - -/** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */ -export function removeAdapterFor( - repo: Repo, - stale: MessageChannelNetworkAdapter, - keep: unknown -): void { - for (const adapter of [...repo.networkSubsystem.adapters]) { - if (adapter === keep) continue; - // The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`. - const base = (adapter as any).networkAdapter ?? adapter; - if (base !== stale) continue; - try { - repo.networkSubsystem.removeNetworkAdapter(adapter as any); - } catch (err) { - console.error("failed to remove stale worker network adapter", err); - } - } -} From fb61c89d3bae05a2a4ee3b3016bb648260a6f2a3 Mon Sep 17 00:00:00 2001 From: chee Date: Wed, 12 Aug 2026 16:37:06 +0100 Subject: [PATCH 02/16] you say yes, i say no, you say stop, i say go go go --- core/bootloader/src/port-hub.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/bootloader/src/port-hub.ts b/core/bootloader/src/port-hub.ts index 49a3e137..bb468bc9 100644 --- a/core/bootloader/src/port-hub.ts +++ b/core/bootloader/src/port-hub.ts @@ -61,6 +61,11 @@ export class PortHubAdapter extends NetworkAdapter { this.#children.add(child); child.on("peer-candidate", (payload) => { + // A MessageChannel adapter announces on both `arrive` and `welcome`, so + // each end sees its peer twice. The network subsystem dedupes by peerId; + // subduction opens a transport per candidate, and a second handshake on + // the same peer key tears the first connection down. + if (this.#byPeer.get(payload.peerId) === child) return; this.#byPeer.set(payload.peerId, child); this.#peered.resolve(); this.emit("peer-candidate", payload); From 4d4f42b6db54c07a6d10ff8428d06a55cf6ac97e Mon Sep 17 00:00:00 2001 From: chee Date: Wed, 12 Aug 2026 16:38:16 +0100 Subject: [PATCH 03/16] add testing to bootloaber --- .changeset/tab-worker-subduction.md | 12 ++++ core/bootloader/package.json | 3 +- core/bootloader/test/port-hub.test.ts | 89 +++++++++++++++++++++++++++ core/bootloader/test/setup.ts | 10 +++ core/bootloader/vitest.config.ts | 11 ++++ vitest.config.ts | 5 +- 6 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 .changeset/tab-worker-subduction.md create mode 100644 core/bootloader/test/port-hub.test.ts create mode 100644 core/bootloader/test/setup.ts create mode 100644 core/bootloader/vitest.config.ts diff --git a/.changeset/tab-worker-subduction.md b/.changeset/tab-worker-subduction.md new file mode 100644 index 00000000..48bc69d9 --- /dev/null +++ b/.changeset/tab-worker-subduction.md @@ -0,0 +1,12 @@ +--- +"@inkandswitch/patchwork-bootloader": patch +"@inkandswitch/patchwork": patch +--- + +Sync the tab with the automerge SharedWorker over Subduction instead of classic automerge-repo sync. + +The tab is now a storageless node: it holds no IndexedDB of its own and gets everything from the worker's repo over a Subduction transport, one per repo port. Keyhive sites are unchanged — they keep classic sync through the keyhive network adapter. + +New: `@inkandswitch/patchwork-bootloader/port-hub` exports `PortHubAdapter`, a network adapter that carries many MessagePorts, and `WORKER_SUBDUCTION_SERVICE`, the service name both ends of the link name. `subductionAdapters` is read once when a Repo is built, and ports come and go after that — the worker gains one per tab, a tab gets a fresh one whenever the worker is recreated — so both ends register a hub up front and add ports to it. + +`createRepo` in `@inkandswitch/patchwork` now takes the worker's `MessagePort` rather than a `MessageChannelNetworkAdapter`, and returns `rewire(port)` and `linked()` alongside the repo. diff --git a/core/bootloader/package.json b/core/bootloader/package.json index cbd8d523..3c10b35a 100644 --- a/core/bootloader/package.json +++ b/core/bootloader/package.json @@ -103,6 +103,7 @@ }, "scripts": { "build": "tsc && cp src/global.css dist/global.css", - "dev": "tsc -w --preserveWatchOutput" + "dev": "tsc -w --preserveWatchOutput", + "test": "vitest run" } } diff --git a/core/bootloader/test/port-hub.test.ts b/core/bootloader/test/port-hub.test.ts new file mode 100644 index 00000000..c20e175b --- /dev/null +++ b/core/bootloader/test/port-hub.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + Repo, + type PeerId, + type AutomergeUrl, +} from "@automerge/automerge-repo"; +import { PortHubAdapter, WORKER_SUBDUCTION_SERVICE } from "../src/port-hub.js"; + +const repos: Repo[] = []; +afterEach(async () => { + await Promise.all(repos.map((r) => r.shutdown().catch(() => {}))); + repos.length = 0; +}); + +function pause(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +function pair() { + const { port1, port2 } = new MessageChannel(); + const workerHub = new PortHubAdapter(); + const tabHub = new PortHubAdapter(); + const worker = new Repo({ + peerId: "automerge-worker-1" as PeerId, + subductionAdapters: [ + { + adapter: workerHub, + serviceName: WORKER_SUBDUCTION_SERVICE, + role: "accept", + }, + ], + }); + const tab = new Repo({ + peerId: "tab-1" as PeerId, + subductionAdapters: [ + { + adapter: tabHub, + serviceName: WORKER_SUBDUCTION_SERVICE, + role: "connect", + }, + ], + }); + repos.push(worker, tab); + workerHub.addPort(port1 as unknown as MessagePort); + tabHub.addPort(port2 as unknown as MessagePort); + return { worker, tab, workerHub, tabHub }; +} + +describe("tab <-> worker over subduction", () => { + it("worker doc is findable in the tab", async () => { + const { worker, tab, tabHub } = pair(); + await tabHub.whenReady(); + const handle = worker.create({ foo: "bar" }); + const found = await tab.find<{ foo: string }>(handle.url as AutomergeUrl); + expect(found.doc().foo).toBe("bar"); + }); + + it("worker doc created before the link is findable in the tab", async () => { + const { worker, tab, tabHub } = pair(); + const handle = worker.create({ foo: "bar" }); + await tabHub.whenReady(); + await pause(500); + const found = await tab.find<{ foo: string }>(handle.url as AutomergeUrl); + expect(found.doc().foo).toBe("bar"); + }); + + it("tab doc is findable in the worker", async () => { + const { worker, tab, tabHub } = pair(); + await tabHub.whenReady(); + const handle = tab.create({ foo: "baz" }); + const found = await worker.find<{ foo: string }>( + handle.url as AutomergeUrl + ); + expect(found.doc().foo).toBe("baz"); + }); + + it("edits propagate both ways", async () => { + const { worker, tab, tabHub } = pair(); + await tabHub.whenReady(); + const a = worker.create<{ n: number }>({ n: 1 }); + const b = await tab.find<{ n: number }>(a.url as AutomergeUrl); + b.change((d) => (d.n = 2)); + await pause(1000); + expect(a.doc().n).toBe(2); + a.change((d) => (d.n = 3)); + await pause(1000); + expect(b.doc().n).toBe(3); + }); +}); diff --git a/core/bootloader/test/setup.ts b/core/bootloader/test/setup.ts new file mode 100644 index 00000000..c8dc9e97 --- /dev/null +++ b/core/bootloader/test/setup.ts @@ -0,0 +1,10 @@ +// Initialize both Wasm modules before any test runs. +// automerge-repo@subduction.9 always creates a SubductionSource, +// which imports from @automerge/automerge-subduction/slim — the +// Wasm must be initialized first. +// +// Importing the fat entry points auto-calls initSync / UseApi(). +// The vitest.config.ts resolve aliases ensure a single copy is used +// even when automerge-repo is linked locally. +import "@automerge/automerge"; +import "@automerge/automerge-subduction"; diff --git a/core/bootloader/vitest.config.ts b/core/bootloader/vitest.config.ts new file mode 100644 index 00000000..5081876b --- /dev/null +++ b/core/bootloader/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["test/**/*.{test,spec}.ts"], + testTimeout: 30_000, + setupFiles: ["./test/setup.ts"], + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index cd65ed38..68805f10 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,9 +2,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - workspace: [ - "core/filesystem", - "packages/edge-handles", - ], + workspace: ["core/bootloader", "core/filesystem", "packages/edge-handles"], }, }); From 612b36a44efb6a1c6ea82e41683b8ca0e8c42478 Mon Sep 17 00:00:00 2001 From: chee Date: Wed, 12 Aug 2026 17:10:24 +0100 Subject: [PATCH 04/16] kill hub --- core/bootloader/package.json | 6 +- core/bootloader/src/automerge-worker.ts | 57 ++--- core/bootloader/src/port-hub.ts | 112 ---------- core/bootloader/src/setup.ts | 49 ++--- core/bootloader/src/types.ts | 16 +- core/bootloader/src/worker-link.ts | 135 ++++++++++++ core/bootloader/test/port-hub.test.ts | 89 -------- core/bootloader/test/worker-link.test.ts | 91 ++++++++ core/patchwork/src/index.ts | 33 +-- core/patchwork/src/repo.ts | 74 ++----- core/patchwork/src/types.ts | 10 +- ...__automerge-repo@2.6.0-subduction.47.patch | 30 +++ pnpm-lock.yaml | 204 +++++++++--------- pnpm-workspace.yaml | 3 + 14 files changed, 428 insertions(+), 481 deletions(-) delete mode 100644 core/bootloader/src/port-hub.ts create mode 100644 core/bootloader/src/worker-link.ts delete mode 100644 core/bootloader/test/port-hub.test.ts create mode 100644 core/bootloader/test/worker-link.test.ts create mode 100644 patches/@automerge__automerge-repo@2.6.0-subduction.47.patch diff --git a/core/bootloader/package.json b/core/bootloader/package.json index 3c10b35a..4b88905d 100644 --- a/core/bootloader/package.json +++ b/core/bootloader/package.json @@ -26,9 +26,9 @@ "import": "./dist/externals-list.js", "types": "./dist/externals-list.d.ts" }, - "./port-hub": { - "import": "./dist/port-hub.js", - "types": "./dist/port-hub.d.ts" + "./worker-link": { + "import": "./dist/worker-link.js", + "types": "./dist/worker-link.d.ts" }, "./storage": { "import": "./dist/storage.js", diff --git a/core/bootloader/src/automerge-worker.ts b/core/bootloader/src/automerge-worker.ts index ef2247c4..5440b1e2 100644 --- a/core/bootloader/src/automerge-worker.ts +++ b/core/bootloader/src/automerge-worker.ts @@ -37,7 +37,10 @@ import { } from "@automerge/automerge-repo-keyhive"; import { DEFAULT_CLASSIC_SYNC_SERVER } from "./sync-config.js"; -import { PortHubAdapter, WORKER_SUBDUCTION_SERVICE } from "./port-hub.js"; +import { + MessagePortTransport, + WORKER_SUBDUCTION_SERVICE, +} from "./worker-link.js"; import { keyhiveStorageName, storagePrefix } from "./storage.js"; import { HANDOFF_CHANNEL, @@ -195,11 +198,6 @@ function pushSyncState(message: SyncStateDocMessage): void { const subductionPortProvider = makePortProvider(); -// Tabs sync with this repo over subduction, one transport per repo port. The -// hub exists before the repo does because ports arrive whenever a tab connects, -// long after `subductionAdapters` is read. -const tabHub = new PortHubAdapter({ useWeakRef: true }); - // Memoized so a construction retry reuses the endpoint instead of leaking one // per attempt. let subductionEndpoints: WorkerWebSocketEndpoint[] | null = null; @@ -278,13 +276,6 @@ async function buildPlainRepo(): Promise { }, enableRemoteHeadsGossiping: true, subductionWebsocketEndpoints: getSubductionEndpoints(), - subductionAdapters: [ - { - adapter: tabHub, - serviceName: WORKER_SUBDUCTION_SERVICE, - role: "accept", - }, - ], }); console.log("[patchwork] shared-worker subduction identity:", identity); return { repo, identity }; @@ -307,13 +298,6 @@ async function buildKeyhiveRepo( repo: { storage: new IndexedDBWorkerStorageAdapter(), subductionWebsocketEndpoints: getSubductionEndpoints(), - subductionAdapters: [ - { - adapter: tabHub, - serviceName: WORKER_SUBDUCTION_SERVICE, - role: "accept", - }, - ], enableRemoteHeadsGossiping: true, }, }); @@ -653,31 +637,24 @@ function reviewAllResync(state: SyncState): void { // ── Tab connections ──────────────────────────────────────────────────── // Each tab connects with a control port and opens repo MessageChannel ports -// through it. Those ports go to the subduction hub, so tabs sync with this -// repo over subduction whether or not keyhive is in play. +// through it. Each of those is accepted as a subduction transport, so tabs sync +// with this repo over subduction whether or not keyhive is in play. -type RepoChannel = { drop(): void }; -type Connection = { channels: Set }; +type Connection = { transports: Set }; async function dropConnection(connection: Connection) { - if (!connection.channels.size) return; - log(`tab gone — dropping ${connection.channels.size} repo channel(s)`); - for (const channel of connection.channels) channel.drop(); - connection.channels.clear(); + if (!connection.transports.size) return; + log(`tab gone — dropping ${connection.transports.size} transport(s)`); + for (const transport of connection.transports) transport.abort(); + connection.transports.clear(); } async function connectPort(port: MessagePort, connection: Connection) { - // The repo has to exist before its hub is worth handing a port to. - await getRepoHive(); - const removePort = tabHub.addPort(port); - connection.channels.add({ - drop() { - removePort(); - try { - port.close(); - } catch {} - }, - }); + const { repo } = await getRepoHive(); + const transport = new MessagePortTransport(port); + connection.transports.add(transport); + const subduction = await repo.subduction; + await subduction.acceptTransport(transport, WORKER_SUBDUCTION_SERVICE); } function handleControlMessage( @@ -758,7 +735,7 @@ function handleControlMessage( self.addEventListener("connect", (event) => { const controlPort = (event as MessageEvent).ports[0]; - const connection: Connection = { channels: new Set() }; + const connection: Connection = { transports: new Set() }; controlPort.addEventListener("message", (messageEvent) => { handleControlMessage(messageEvent as MessageEvent, controlPort, connection); diff --git a/core/bootloader/src/port-hub.ts b/core/bootloader/src/port-hub.ts deleted file mode 100644 index bb468bc9..00000000 --- a/core/bootloader/src/port-hub.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - NetworkAdapter, - type Message, - type NetworkAdapterInterface, - type PeerId, - type PeerMetadata, -} from "@automerge/automerge-repo/slim"; -import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel"; - -/** - * Subduction service name for the tab ↔ automerge worker link. Both ends have - * to name the same service or the handshake never completes. - */ -export const WORKER_SUBDUCTION_SERVICE = "patchwork-automerge-worker"; - -/** - * One network adapter over many MessagePorts, so ports can come and go after - * the Repo is built. `subductionAdapters` is read once at construction: the - * worker gains a tab's port long after that, and a tab gets a fresh port every - * time the worker is recreated. - * - * Each port keeps its own MessageChannelNetworkAdapter, which owns the - * arrive/welcome handshake; the hub fans their messages in and routes outgoing - * ones by targetId. Subduction's AdapterConnections opens a transport per - * peer-candidate, so one hub carries a transport per port. - */ -export class PortHubAdapter extends NetworkAdapter { - #children = new Set(); - #byPeer = new Map(); - #waiting: MessageChannelNetworkAdapter[] = []; - #peered = Promise.withResolvers(); - #useWeakRef: boolean; - - constructor({ useWeakRef = false }: { useWeakRef?: boolean } = {}) { - super(); - this.#useWeakRef = useWeakRef; - } - - isReady(): boolean { - return this.#byPeer.size > 0; - } - - /** Resolves once some port has announced a peer. */ - whenReady(): Promise { - return this.#peered.promise; - } - - connect(peerId: PeerId, peerMetadata?: PeerMetadata): void { - this.peerId = peerId; - this.peerMetadata = peerMetadata; - for (const child of this.#waiting.splice(0)) { - child.connect(peerId, peerMetadata); - } - } - - /** Returns a function that drops this port again. */ - addPort(port: MessagePort): () => void { - const child = new MessageChannelNetworkAdapter(port, { - useWeakRef: this.#useWeakRef, - }); - this.#children.add(child); - - child.on("peer-candidate", (payload) => { - // A MessageChannel adapter announces on both `arrive` and `welcome`, so - // each end sees its peer twice. The network subsystem dedupes by peerId; - // subduction opens a transport per candidate, and a second handshake on - // the same peer key tears the first connection down. - if (this.#byPeer.get(payload.peerId) === child) return; - this.#byPeer.set(payload.peerId, child); - this.#peered.resolve(); - this.emit("peer-candidate", payload); - }); - child.on("peer-disconnected", (payload) => { - if (this.#byPeer.get(payload.peerId) === child) { - this.#byPeer.delete(payload.peerId); - } - this.emit("peer-disconnected", payload); - }); - // Deliberately not forwarding "close": one port going away doesn't close - // the hub. - child.on("message", (message) => this.emit("message", message)); - - if (this.peerId) child.connect(this.peerId, this.peerMetadata); - else this.#waiting.push(child); - - return () => this.#drop(child); - } - - #drop(child: MessageChannelNetworkAdapter): void { - if (!this.#children.delete(child)) return; - const waiting = this.#waiting.indexOf(child); - if (waiting !== -1) this.#waiting.splice(waiting, 1); - // Emits peer-disconnected, which tears the peer's transport down. - try { - child.disconnect(); - } catch {} - } - - send(message: Message): void { - // Through the interface: the concrete adapter narrows `send` to the repo's - // own message union, and subduction frames carry their own type. - const child: NetworkAdapterInterface | undefined = this.#byPeer.get( - message.targetId - ); - child?.send(message); - } - - disconnect(): void { - for (const child of [...this.#children]) this.#drop(child); - this.emit("close"); - } -} diff --git a/core/bootloader/src/setup.ts b/core/bootloader/src/setup.ts index 734ff0c6..159f768a 100644 --- a/core/bootloader/src/setup.ts +++ b/core/bootloader/src/setup.ts @@ -1,5 +1,4 @@ import type { - ServiceWorkerRepoChannelListener, SetupServiceWorkerOptions, SetupServiceWorkerResult, SyncStateDocMessage, @@ -75,7 +74,7 @@ let automergeWorker: SharedWorker | undefined; // channel ends in a dead worker — so deliveries are guarded on generation. let workerGeneration = 0; let disposeWorkerDeathDetection: (() => void) | undefined; -const repoChannelListeners = new Set(); +const workerRecreatedListeners = new Set<() => void>(); let recoveringWorker = false; let lastWorkerRecoveryAt = 0; // Below this spacing, skip: if the fresh worker is dead too, its own heartbeat @@ -177,8 +176,9 @@ function createSubductionIoPort(): MessagePort { /** * Build a replacement worker and re-wire everything a live tab holds against * it: console forwarding and port donation (both re-done by - * getAutomergeWorker), the per-doc sync-state subscriptions, and every - * subscriber's repo port. The new instance boots with cold state. + * getAutomergeWorker) and the per-doc sync-state subscriptions. The new + * instance boots with cold state. Listeners are told so they can reopen + * whatever they had on the dead one. */ async function recoverAutomergeWorker( reason: string, @@ -204,18 +204,11 @@ async function recoverAutomergeWorker( for (const documentId of syncStateListeners.keys()) { fresh.port.postMessage({ type: "sync-sub", documentId }); } - for (const listener of repoChannelListeners) { + for (const listener of workerRecreatedListeners) { try { - const generation = workerGeneration; - const port = await openRepoChannel(); - // Replaced again while we waited — the newer recovery re-delivers. - if (generation !== workerGeneration) break; - await listener(port); + listener(); } catch (err) { - console.error( - "failed to re-wire a repo channel after worker recovery", - err - ); + console.error("worker-recreated listener threw", err); } } } finally { @@ -475,7 +468,8 @@ function awaitPortReady(control: MessagePort, id: number): Promise { }); } -async function openRepoChannel(): Promise { +/** Open a repo sync port to the automerge worker, once it says it is ready. */ +export async function openRepoPort(): Promise { const id = ++nextRepoChannelId; const ready = awaitPortReady(getAutomergeWorker().port, id); const port = sendRepoPort(id); @@ -492,11 +486,6 @@ async function openRepoChannel(): Promise { return port; } -/** Open a fresh repo sync port to the automerge worker (dev console). */ -function getRepoChannel(): MessagePort { - return sendRepoPort(++nextRepoChannelId); -} - function waitForActive(reg: ServiceWorkerRegistration): Promise { if (reg.active) return Promise.resolve(reg.active); const worker = reg.installing || reg.waiting; @@ -570,21 +559,15 @@ export default async function setupServiceWorker( return { shared, connectClassicSync, - getRepoChannel, subscribeSyncState, - // Called once with the boot port. If the automerge worker later dies and is - // recreated, the listener is called again with a fresh port — treat every - // call as "(re)wire your repo's sync onto this port". - async subscribeToRepoChannel(listener: ServiceWorkerRepoChannelListener) { - repoChannelListeners.add(listener); - const generation = workerGeneration; - const port = await openRepoChannel(); - // If the worker was replaced while this channel was opening, recovery has - // already delivered a good port to this listener — drop the stale one - // rather than wiring the repo to a dead channel. - if (generation === workerGeneration) await listener(port); + openPort: openRepoPort, + // The automerge worker died and was replaced, so anything held against the + // old one — a repo port, a network adapter — is stranded. Ports opened from + // here on reach the new instance, which boots with cold state. + onRecreated(listener: () => void) { + workerRecreatedListeners.add(listener); return () => { - repoChannelListeners.delete(listener); + workerRecreatedListeners.delete(listener); }; }, }; diff --git a/core/bootloader/src/types.ts b/core/bootloader/src/types.ts index 8d06656d..0d3179dc 100644 --- a/core/bootloader/src/types.ts +++ b/core/bootloader/src/types.ts @@ -205,20 +205,18 @@ export type SetupServiceWorkerOptions = { workerPath?: string; }; -export type ServiceWorkerRepoChannelListener = ( - port: MessagePort -) => void | Promise; - export type SetupServiceWorkerResult = { shared?: SharedWorker; kill?: () => void; /** Open a classic Automerge sync WebSocket from the automerge worker. */ connectClassicSync: (server?: string) => Promise; - subscribeToRepoChannel: ( - listener: ServiceWorkerRepoChannelListener - ) => Promise<() => void>; - /** Open a fresh repo sync port to the automerge worker (dev console). */ - getRepoChannel: () => MessagePort; + /** Open a repo sync port to the automerge worker, once it says it is ready. */ + openPort: () => Promise; + /** + * Watch for the automerge worker dying and being replaced. Ports held against + * the old instance are stranded; open a fresh one. + */ + onRecreated: (listener: () => void) => () => void; /** * Watch one document's sync heads (this tab's own and each Subduction peer's, * as the worker learns them). Calls `listener` on every update for that doc, diff --git a/core/bootloader/src/worker-link.ts b/core/bootloader/src/worker-link.ts new file mode 100644 index 00000000..e0a5258c --- /dev/null +++ b/core/bootloader/src/worker-link.ts @@ -0,0 +1,135 @@ +import type { + ManagedTransport, + WebSocketEndpointInterface, +} from "@automerge/automerge-repo/slim"; + +/** + * The tab ↔ automerge worker link, as a Subduction endpoint. + * + * Subduction derives a service name from the endpoint url's host, and both + * ends have to name the same one, so the url is a fiction with a meaningful + * host rather than a real socket address. + */ +export const WORKER_SUBDUCTION_URL = "ws://patchwork-automerge-worker"; +export const WORKER_SUBDUCTION_SERVICE = new URL(WORKER_SUBDUCTION_URL).host; + +/** A close frame; every other frame is bytes. */ +const BYE = "bye"; + +/** + * A Subduction transport over a MessagePort. Frames are raw ArrayBuffers, so + * the far side needs no protocol beyond this one. + */ +export class MessagePortTransport implements ManagedTransport { + #port: MessagePort; + #queue: Uint8Array[] = []; + #waiters: Array<{ + resolve: (bytes: Uint8Array) => void; + reject: (error: Error) => void; + }> = []; + #closed = false; + #closedResolvers = Promise.withResolvers(); + #onDisconnect: (() => void) | null = null; + + constructor(port: MessagePort) { + this.#port = port; + port.addEventListener("message", (event: MessageEvent) => { + if (this.#closed) return; + if (event.data === BYE) return this.#teardown(true); + const bytes = new Uint8Array(event.data as ArrayBuffer); + const waiter = this.#waiters.shift(); + if (waiter) waiter.resolve(bytes); + else this.#queue.push(bytes); + }); + // Only some browsers fire this, and only for a port whose far side was + // closed or collected; a dead SharedWorker is caught by the heartbeat in + // setup.ts instead. + port.addEventListener("close", () => this.#teardown(true)); + port.start(); + } + + async sendBytes(bytes: Uint8Array): Promise { + if (this.#closed) throw new Error("worker link closed"); + // Copied out of wasm memory, and transferred rather than cloned. + const buffer = bytes.slice().buffer; + this.#port.postMessage(buffer, [buffer]); + } + + recvBytes(): Promise { + const queued = this.#queue.shift(); + if (queued) return Promise.resolve(queued); + if (this.#closed) return Promise.reject(new Error("worker link closed")); + return new Promise((resolve, reject) => + this.#waiters.push({ resolve, reject }) + ); + } + + onDisconnect(callback: () => void): void { + this.#onDisconnect = callback; + } + + async disconnect(): Promise { + if (this.#closed) return; + try { + this.#port.postMessage(BYE); + } catch {} + this.#teardown(false); + } + + /** + * End a link whose far side is gone. Unlike `disconnect`, this reports the + * disconnection to Subduction, so the connection is dropped rather than left + * waiting on a port nobody is reading. + */ + abort(): void { + this.#teardown(true); + } + + /** Resolves when this link ends, however it ends. */ + closed(): Promise { + return this.#closedResolvers.promise; + } + + #teardown(remote: boolean): void { + if (this.#closed) return; + this.#closed = true; + // Dropped rather than delivered: handing frames to the wasm after a + // teardown can dispatch against storage that is going away. + this.#queue = []; + const error = new Error("worker link closed"); + for (const waiter of this.#waiters.splice(0)) waiter.reject(error); + this.#closedResolvers.resolve(); + try { + this.#port.close(); + } catch {} + if (remote) this.#onDisconnect?.(); + } +} + +/** + * Subduction endpoint for the automerge worker. `openPort` is called for every + * (re)connection, so a worker that died and was replaced is picked up by the + * reconnect loop in automerge-repo without any rewiring here. + */ +export class WorkerSubductionEndpoint implements WebSocketEndpointInterface { + readonly url = WORKER_SUBDUCTION_URL; + #openPort: () => Promise; + #live: MessagePortTransport | null = null; + + constructor(openPort: () => Promise) { + this.#openPort = openPort; + } + + async connect(): Promise { + return (this.#live = new MessagePortTransport(await this.#openPort())); + } + + /** + * Drop the current link. A SharedWorker that dies leaves its ports silent + * rather than closed, so the reconnect loop needs telling. + */ + reset(): void { + this.#live?.abort(); + this.#live = null; + } +} diff --git a/core/bootloader/test/port-hub.test.ts b/core/bootloader/test/port-hub.test.ts deleted file mode 100644 index c20e175b..00000000 --- a/core/bootloader/test/port-hub.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, afterEach } from "vitest"; -import { - Repo, - type PeerId, - type AutomergeUrl, -} from "@automerge/automerge-repo"; -import { PortHubAdapter, WORKER_SUBDUCTION_SERVICE } from "../src/port-hub.js"; - -const repos: Repo[] = []; -afterEach(async () => { - await Promise.all(repos.map((r) => r.shutdown().catch(() => {}))); - repos.length = 0; -}); - -function pause(ms: number) { - return new Promise((r) => setTimeout(r, ms)); -} - -function pair() { - const { port1, port2 } = new MessageChannel(); - const workerHub = new PortHubAdapter(); - const tabHub = new PortHubAdapter(); - const worker = new Repo({ - peerId: "automerge-worker-1" as PeerId, - subductionAdapters: [ - { - adapter: workerHub, - serviceName: WORKER_SUBDUCTION_SERVICE, - role: "accept", - }, - ], - }); - const tab = new Repo({ - peerId: "tab-1" as PeerId, - subductionAdapters: [ - { - adapter: tabHub, - serviceName: WORKER_SUBDUCTION_SERVICE, - role: "connect", - }, - ], - }); - repos.push(worker, tab); - workerHub.addPort(port1 as unknown as MessagePort); - tabHub.addPort(port2 as unknown as MessagePort); - return { worker, tab, workerHub, tabHub }; -} - -describe("tab <-> worker over subduction", () => { - it("worker doc is findable in the tab", async () => { - const { worker, tab, tabHub } = pair(); - await tabHub.whenReady(); - const handle = worker.create({ foo: "bar" }); - const found = await tab.find<{ foo: string }>(handle.url as AutomergeUrl); - expect(found.doc().foo).toBe("bar"); - }); - - it("worker doc created before the link is findable in the tab", async () => { - const { worker, tab, tabHub } = pair(); - const handle = worker.create({ foo: "bar" }); - await tabHub.whenReady(); - await pause(500); - const found = await tab.find<{ foo: string }>(handle.url as AutomergeUrl); - expect(found.doc().foo).toBe("bar"); - }); - - it("tab doc is findable in the worker", async () => { - const { worker, tab, tabHub } = pair(); - await tabHub.whenReady(); - const handle = tab.create({ foo: "baz" }); - const found = await worker.find<{ foo: string }>( - handle.url as AutomergeUrl - ); - expect(found.doc().foo).toBe("baz"); - }); - - it("edits propagate both ways", async () => { - const { worker, tab, tabHub } = pair(); - await tabHub.whenReady(); - const a = worker.create<{ n: number }>({ n: 1 }); - const b = await tab.find<{ n: number }>(a.url as AutomergeUrl); - b.change((d) => (d.n = 2)); - await pause(1000); - expect(a.doc().n).toBe(2); - a.change((d) => (d.n = 3)); - await pause(1000); - expect(b.doc().n).toBe(3); - }); -}); diff --git a/core/bootloader/test/worker-link.test.ts b/core/bootloader/test/worker-link.test.ts new file mode 100644 index 00000000..218e3e64 --- /dev/null +++ b/core/bootloader/test/worker-link.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + Repo, + type PeerId, + type AutomergeUrl, +} from "@automerge/automerge-repo"; +import { + MessagePortTransport, + WorkerSubductionEndpoint, + WORKER_SUBDUCTION_SERVICE, +} from "../src/worker-link.js"; + +const repos: Repo[] = []; +afterEach(async () => { + await Promise.all(repos.map((r) => r.shutdown().catch(() => {}))); + repos.length = 0; +}); + +function pause(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +/** + * A worker repo accepting tab links, and a tab repo whose only network is one + * of them. `openPort` stands in for the bootloader's control-port handshake. + */ +function link() { + const worker = new Repo({ peerId: "automerge-worker-1" as PeerId }); + const accepted: MessagePortTransport[] = []; + + const openPort = async () => { + const { port1, port2 } = new MessageChannel(); + const transport = new MessagePortTransport(port1 as unknown as MessagePort); + accepted.push(transport); + const subduction = await worker.subduction; + void subduction.acceptTransport(transport, WORKER_SUBDUCTION_SERVICE); + return port2 as unknown as MessagePort; + }; + + const endpoint = new WorkerSubductionEndpoint(openPort); + const tab = new Repo({ + peerId: "tab-1" as PeerId, + subductionWebsocketEndpoints: [endpoint], + }); + repos.push(worker, tab); + return { worker, tab, endpoint, accepted }; +} + +describe("tab <-> worker over subduction", () => { + it("finds a worker doc from the tab", async () => { + const { worker, tab } = link(); + const handle = worker.create({ foo: "bar" }); + const found = await tab.find<{ foo: string }>(handle.url as AutomergeUrl); + expect(found.doc().foo).toBe("bar"); + }); + + it("finds a tab doc from the worker", async () => { + const { worker, tab } = link(); + const handle = tab.create({ foo: "baz" }); + const found = await worker.find<{ foo: string }>(handle.url as AutomergeUrl); + expect(found.doc().foo).toBe("baz"); + }); + + it("propagates edits both ways", async () => { + const { worker, tab } = link(); + const a = worker.create<{ n: number }>({ n: 1 }); + const b = await tab.find<{ n: number }>(a.url as AutomergeUrl); + b.change((d) => (d.n = 2)); + await pause(500); + expect(a.doc().n).toBe(2); + a.change((d) => (d.n = 3)); + await pause(500); + expect(b.doc().n).toBe(3); + }); + + it("reconnects on a fresh port when the worker is replaced", async () => { + const { worker, tab, endpoint, accepted } = link(); + const first = worker.create({ foo: "before" }); + await tab.find<{ foo: string }>(first.url as AutomergeUrl); + + // What setup.ts does when its heartbeat gives up on the SharedWorker. + endpoint.reset(); + await pause(2000); + console.log("accepted after reset:", accepted.length); + + const second = worker.create({ foo: "after" }); + const found = await tab.find<{ foo: string }>(second.url as AutomergeUrl); + expect(found.doc().foo).toBe("after"); + expect(accepted.length).toBe(2); + }); +}); diff --git a/core/patchwork/src/index.ts b/core/patchwork/src/index.ts index 1e96b771..de0b2faf 100644 --- a/core/patchwork/src/index.ts +++ b/core/patchwork/src/index.ts @@ -52,7 +52,7 @@ import type { PatchworkOptions, SignerIdentity, } from "./types.js"; -import { createRepo, firstRepoPort, initWasm } from "./repo.js"; +import { createRepo, initWasm } from "./repo.js"; import { createRouter, type Router } from "./router.js"; import { createDefaultAccount } from "./createAccount.js"; @@ -124,37 +124,13 @@ async function doSetup(options: PatchworkOptions): Promise { let hive: AutomergeRepoKeyhive | undefined; let repo: Repo; let signerIdentity: SignerIdentity | undefined; - // Called with a fresh port when the automerge worker dies and is recreated. - // Assigned once the repo exists. - let onWorkerPortRenewed: ((port: MessagePort) => void) | undefined; - // Resolves once the worker has answered on the repo port. A provided repo - // brings its own network, so there's nothing here to wait for. - let linked: Promise = Promise.resolve(); if (options.repo) { log("using provided Repo"); repo = options.repo; hive = options.hive; } else { - const workerPort = await firstRepoPort(sw, (port) => { - if (onWorkerPortRenewed) onWorkerPortRenewed(port); - else { - console.warn( - "automerge worker port renewed before the repo existed; dropping it" - ); - } - }); - - const tab = await createRepo(workerPort); - ({ repo, hive, signerIdentity } = tab); - linked = tab.linked(); - - // The worker was recreated with cold state: wire the repo onto the fresh - // port and drop whatever is stranded on the dead one. - onWorkerPortRenewed = (port) => { - tab.rewire(port); - lifecycleLog("repo re-wired to the recreated automerge worker"); - }; + ({ repo, hive, signerIdentity } = await createRepo(sw)); } // Dev-console / tool-runtime globals (e2e and loaded tools read these). The @@ -166,8 +142,6 @@ async function doSetup(options: PatchworkOptions): Promise { AutomergeRepo as typeof import("@automerge/automerge-repo"); if (hive) window.hive = hive; - await linked; - log("worker link ready"); (hive?.networkAdapter as any)?.syncKeyhive?.(); registerRepoProviderElement(repo as any); @@ -246,7 +220,8 @@ async function doSetup(options: PatchworkOptions): Promise { plugins, sw: { connectClassicSync: sw.connectClassicSync, - subscribeToRepoChannel: sw.subscribeToRepoChannel, + openPort: sw.openPort, + onRecreated: sw.onRecreated, subscribeSyncState: sw.subscribeSyncState, }, diff --git a/core/patchwork/src/repo.ts b/core/patchwork/src/repo.ts index 7b26c90c..6285a073 100644 --- a/core/patchwork/src/repo.ts +++ b/core/patchwork/src/repo.ts @@ -4,10 +4,7 @@ import { type AutomergeUrl, } from "@automerge/vanillajs/slim"; import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; -import { - PortHubAdapter, - WORKER_SUBDUCTION_SERVICE, -} from "@inkandswitch/patchwork-bootloader/port-hub"; +import { WorkerSubductionEndpoint } from "@inkandswitch/patchwork-bootloader/worker-link"; import * as AutomergeRepo from "@automerge/automerge-repo/slim"; import { initKeyhiveWasm, @@ -19,7 +16,6 @@ import { // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim"; import { MemorySigner } from "@automerge/automerge-subduction/slim"; -import setupServiceWorker from "@inkandswitch/patchwork-bootloader"; import { keyhiveStorageName, storagePrefix, @@ -56,34 +52,26 @@ export function initWasm(): Promise { return wasmReady; } +/** The bit of the bootloader's automerge worker a Repo needs. */ +export type WorkerLink = { + openPort: () => Promise; + onRecreated: (listener: () => void) => () => void; +}; + export type TabRepo = { repo: Repo; hive?: AutomergeRepoKeyhiveBase; signerIdentity?: SignerIdentity; - /** Wire the repo onto a port from a freshly recreated automerge worker. */ - rewire(port: MessagePort): void; - /** Resolves once the worker has answered on some port. */ - linked(): Promise; }; -export async function createRepo(workerPort: MessagePort): Promise { - // The tab is a storageless node: the worker holds the IndexedDB and the tab - // syncs against it over subduction, one transport per repo port. The hub - // outlives any single port, so a recreated worker just hands over a new one. - const link = new PortHubAdapter(); - let dropWorkerPort = link.addPort(workerPort); - const subductionAdapters = [ - { - adapter: link, - serviceName: WORKER_SUBDUCTION_SERVICE, - role: "connect" as const, - }, - ]; - const linked = () => link.whenReady(); - const rewire = (port: MessagePort) => { - dropWorkerPort(); - dropWorkerPort = link.addPort(port); - }; +export async function createRepo(worker: WorkerLink): Promise { + // The tab is a storageless node: the subduction worker holds the IndexedDB + // and the tab syncs against it over one Subduction transport. + const endpoint = new WorkerSubductionEndpoint(() => worker.openPort()); + // A dead SharedWorker leaves its ports silent rather than closed, so the + // reconnect loop is told to give up on the old one. + worker.onRecreated(() => endpoint.reset()); + const subductionWebsocketEndpoints = [endpoint]; if (syncServer.keyhive) { log("setting up keyhive"); @@ -96,10 +84,10 @@ export async function createRepo(workerPort: MessagePort): Promise { cachingMode: "periodic", // ARK selects the relay via `syncServer`, defaulting to "subduction". syncServer: syncServer.keyhive, - repo: { subductionAdapters }, + repo: { subductionWebsocketEndpoints }, }); log("keyhive setup complete"); - return { repo, hive, linked, rewire }; + return { repo, hive }; } // The signer is explicit rather than the Repo's internal default so the @@ -108,7 +96,7 @@ export async function createRepo(workerPort: MessagePort): Promise { const signer = new MemorySigner(); const repo = new Repo({ signer, - subductionAdapters, + subductionWebsocketEndpoints, peerId: `${storagePrefix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId, }); @@ -121,29 +109,5 @@ export async function createRepo(workerPort: MessagePort): Promise { ).toHex(), }; log("repo created, tab subduction identity:", signerIdentity); - return { repo, signerIdentity, linked, rewire }; -} - -/** - * Resolve with the first repo port the worker delivers, calling `onRenewed` for - * every later one. - * - * subscribeToRepoChannel is deliberately not awaited: it resolves only after - * the boot channel's port-ready handshake, which can take its full 30s timeout - * against a stranded worker connection. Boot blocks on the first *delivered* - * port instead — if the boot channel stalls, worker recovery hands the listener - * a good port long before that timeout. - */ -export function firstRepoPort( - sw: Awaited>, - onRenewed: (port: MessagePort) => void -): Promise { - return new Promise((resolve) => { - let seen = false; - void sw.subscribeToRepoChannel((port) => { - if (seen) return onRenewed(port); - seen = true; - resolve(port); - }); - }); + return { repo, signerIdentity }; } diff --git a/core/patchwork/src/types.ts b/core/patchwork/src/types.ts index abc74207..9d54ac45 100644 --- a/core/patchwork/src/types.ts +++ b/core/patchwork/src/types.ts @@ -8,10 +8,7 @@ import type { AccountCreator, AccountDoc, } from "@inkandswitch/patchwork-plugins"; -import type { - ServiceWorkerRepoChannelListener, - SyncStateDocMessage, -} from "@inkandswitch/patchwork-bootloader/types"; +import type { SyncStateDocMessage } from "@inkandswitch/patchwork-bootloader/types"; import type * as pluginsNS from "@inkandswitch/patchwork-plugins"; export type PluginsApi = typeof pluginsNS; @@ -20,9 +17,8 @@ export type SignerIdentity = { peerId: string; verifyingKey: string }; export interface ServiceWorkerApi { connectClassicSync: (server?: string) => Promise; - subscribeToRepoChannel: ( - listener: ServiceWorkerRepoChannelListener - ) => Promise<() => void>; + openPort: () => Promise; + onRecreated: (listener: () => void) => () => void; subscribeSyncState: ( documentId: string, listener: (update: SyncStateDocMessage) => void diff --git a/patches/@automerge__automerge-repo@2.6.0-subduction.47.patch b/patches/@automerge__automerge-repo@2.6.0-subduction.47.patch new file mode 100644 index 00000000..78f1d6fc --- /dev/null +++ b/patches/@automerge__automerge-repo@2.6.0-subduction.47.patch @@ -0,0 +1,30 @@ +diff --git a/dist/subduction/SubductionConnections.js b/dist/subduction/SubductionConnections.js +index 4760a1899a8c0127db4840d5de090f6825211d97..5b9d19d4dd1cfb0cb242f140d5401af53aadd6a9 100644 +--- a/dist/subduction/SubductionConnections.js ++++ b/dist/subduction/SubductionConnections.js +@@ -15,7 +15,9 @@ export class SubductionConnections { + // ── ConnectionManager interface ───────────────────────────────────── + isConnecting() { + for (const state of this.#connectionStates.values()) { +- if (state === "connecting") ++ // "awaiting-reconnect" counts: the loop is between attempts, not ++ // given up, so a query should wait rather than report unavailable. ++ if (state === "connecting" || state === "awaiting-reconnect") + return true; + } + return false; +diff --git a/src/subduction/SubductionConnections.ts b/src/subduction/SubductionConnections.ts +index 0d5510500c1e3a8e0d84b6f9b5f87f42096ee8e3..6b8376be24f932f21ab3ddc4b76bc81ff39a5030 100644 +--- a/src/subduction/SubductionConnections.ts ++++ b/src/subduction/SubductionConnections.ts +@@ -28,7 +28,9 @@ export class SubductionConnections implements ConnectionManager { + + isConnecting(): boolean { + for (const state of this.#connectionStates.values()) { +- if (state === "connecting") return true ++ // "awaiting-reconnect" counts: the loop is between attempts, not given ++ // up, so a query should wait rather than report unavailable. ++ if (state === "connecting" || state === "awaiting-reconnect") return true + } + return false + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f98e3d7..b0935efa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,18 +36,21 @@ catalogs: overrides: '@automerge/automerge': 3.3.2 - '@automerge/automerge-repo': 2.6.0-subduction.48 - '@automerge/automerge-repo-keyhive': 0.5.0-alpha.7 - '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48 - '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 - '@automerge/automerge-repo-react-hooks': 2.6.0-subduction.48 - '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.48 + '@automerge/automerge-repo': 2.6.0-subduction.47 + '@automerge/automerge-repo-keyhive': 0.3.0-alpha.sub.8b + '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.47 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.47 + '@automerge/automerge-repo-react-hooks': 2.6.0-subduction.47 + '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.47 '@automerge/automerge-subduction': 0.16.1 - '@automerge/react': 2.6.0-subduction.48 - '@automerge/vanillajs': 2.6.0-subduction.48 - '@keyhive/keyhive': 0.1.0-alpha.8 + '@automerge/react': 2.6.0-subduction.47 + '@automerge/vanillajs': 2.6.0-subduction.47 + '@keyhive/keyhive': 0.1.0-alpha.5 solid-automerge: ^2.0.1 +patchedDependencies: + '@automerge/automerge-repo@2.6.0-subduction.47': 279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8 + importers: .: @@ -77,26 +80,26 @@ importers: specifier: 3.3.2 version: 3.3.2 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-keyhive': - specifier: 0.5.0-alpha.7 - version: 0.5.0-alpha.7(ws@8.21.1) + specifier: 0.3.0-alpha.sub.8b + version: 0.3.0-alpha.sub.8b(ws@8.21.1) '@automerge/automerge-repo-network-messagechannel': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47 '@automerge/automerge-repo-network-websocket': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47 '@automerge/automerge-repo-storage-indexeddb': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47 '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 '@automerge/vanillajs': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47 '@codemirror/commands': specifier: 'catalog:' version: 6.10.4 @@ -122,8 +125,8 @@ importers: specifier: workspace:^ version: link:../../packages/providers/core '@keyhive/keyhive': - specifier: 0.1.0-alpha.8 - version: 0.1.0-alpha.8 + specifier: 0.1.0-alpha.5 + version: 0.1.0-alpha.5 '@types/debug': specifier: ^4.1.13 version: 4.1.13 @@ -156,18 +159,12 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: - '@automerge/automerge': - specifier: 3.3.2 - version: 3.3.2 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-keyhive': - specifier: 0.5.0-alpha.7 - version: 0.5.0-alpha.7(ws@8.21.1) - '@automerge/automerge-subduction': - specifier: 0.16.1 - version: 0.16.1 + specifier: 0.3.0-alpha.sub.8b + version: 0.3.0-alpha.sub.8b(ws@8.21.1) '@inkandswitch/patchwork-filesystem': specifier: workspace:^ version: link:../filesystem @@ -205,8 +202,8 @@ importers: specifier: 3.3.2 version: 3.3.2 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) debug: specifier: ^4.4.3 version: 4.4.3 @@ -215,8 +212,8 @@ importers: version: 2.0.3 devDependencies: '@automerge/automerge-repo-network-messagechannel': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47 '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 @@ -236,20 +233,20 @@ importers: specifier: 3.3.2 version: 3.3.2 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-keyhive': - specifier: 0.5.0-alpha.7 - version: 0.5.0-alpha.7(ws@8.21.1) + specifier: 0.3.0-alpha.sub.8b + version: 0.3.0-alpha.sub.8b(ws@8.21.1) '@automerge/automerge-repo-storage-indexeddb': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47 '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 '@automerge/vanillajs': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47 '@inkandswitch/patchwork-bootloader': specifier: workspace:^ version: link:../bootloader @@ -310,11 +307,11 @@ importers: specifier: 3.3.2 version: 3.3.2 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-keyhive': - specifier: 0.5.0-alpha.7 - version: 0.5.0-alpha.7(ws@8.21.1) + specifier: 0.3.0-alpha.sub.8b + version: 0.3.0-alpha.sub.8b(ws@8.21.1) '@inkandswitch/patchwork-filesystem': specifier: workspace:^ version: link:../filesystem @@ -341,8 +338,8 @@ importers: specifier: 3.3.2 version: 3.3.2 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 @@ -356,8 +353,8 @@ importers: packages/providers/core: devDependencies: '@automerge/automerge-repo': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -369,11 +366,11 @@ importers: version: link:../../core devDependencies: '@automerge/automerge-repo': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-react-hooks': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: 'catalog:' version: 18.3.1 @@ -391,11 +388,11 @@ importers: version: link:../../core devDependencies: '@automerge/automerge-repo': - specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48 + specifier: 2.6.0-subduction.47 + version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) solid-automerge: specifier: ^2.0.1 - version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.48)(solid-js@1.9.14) + version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8))(solid-js@1.9.14) solid-js: specifier: ^1.9.13 version: 1.9.14 @@ -405,35 +402,34 @@ importers: packages: - '@automerge/automerge-repo-keyhive@0.5.0-alpha.7': - resolution: {integrity: sha512-9eGgbRwcEDPVdhEySi49YJfh5bn1uPx1OJI9YtoUIMVe+sxWByn58OEwIt6Jzd4dXcZAVjSqucwIXpmB6fOJQw==} - engines: {node: '>=22.13'} + '@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.8b': + resolution: {integrity: sha512-Ld97PHH3fn9i0vnctjobaQV61BYjhmDZloSpfrFmXl6ynOfe8VUG4b0AyMJjmrpT9Fwz51An4u8ICoUpDsyUJg==} - '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': - resolution: {integrity: sha512-fyq/ZqWkrYOuNrQ917KeCaRSuY6fzqH6Q4KCb5czjwKPKO2zFIGKVfK5hZjYsHGWRKuh5BzViaONHn6bJ0Jk4w==} + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.47': + resolution: {integrity: sha512-xqPlvVYtW6Khgoks+jQOc9/M3Cr8XP69cCBjXWXB8D3XUJwJ8Dn6R++s/M+T65fqr+Eeq6njLgWlTYjcFhKU0g==} engines: {node: '>=22.13'} - '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48': - resolution: {integrity: sha512-pf+cmCi/TWQZpqrm+iinVpIM2Ecfzp8JpGEo28SPbE2Q8+eLyO2cz1Dssnuep1+KQ8k7Dfx7aUF9AD7/6jAvTQ==} + '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.47': + resolution: {integrity: sha512-HT8F4eYwggDsCtWp/WzSzzjRlglR3jqYxsDalKoyu7eKIaD/RuP8emvzqY+OolkDkI0Uau+LizOshvcbhrBRGQ==} engines: {node: '>=22.13'} - '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48': - resolution: {integrity: sha512-TRKAq4iTdrSpOQYxyKZMJDJ5Civa+vONrAHBZkpK+DGShzqBB6vCdTCsrBGBF2Q63H33qQM0XYBQEQKIa/zM+Q==} + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.47': + resolution: {integrity: sha512-A8aDy9jizU+6Pb5F6GSqSFNHAAXICib7uV60KHytEZjANL+PU2lMTpAhQnOG15NfJXX3mKXIsQ/duOZIsCRcZA==} engines: {node: '>=22.13'} - '@automerge/automerge-repo-react-hooks@2.6.0-subduction.48': - resolution: {integrity: sha512-o75EWDGUGdtmfH/EtwK1lnFoXKAWKY7pGHTkKNsPTZvLiHpafdm2ksk5JOzNcNGv7GMxwvXhMnmy2GOruYsj4g==} + '@automerge/automerge-repo-react-hooks@2.6.0-subduction.47': + resolution: {integrity: sha512-ZUCQe8Ew6fmHy4IRw1Xf/rAmOxLsJv7JCiFTiofmtMd9SBHmXhgKQuZJJG8KYoVFucRLPZV63vfSPN8k7o+f/w==} engines: {node: '>=22.13'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48': - resolution: {integrity: sha512-7XIn/iGrAVS4ugva8m0bU1shhRtuy/fdCAgF6qaIEvbi7XQllLVn3miTTiusLaksNhnRtbMfDLUknazU+vVHew==} + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.47': + resolution: {integrity: sha512-fYn6nse7jQnVb9EploTsBuYUzW7xFin2HZLneSEQ7w4pRCilIU92ghZEjZSWRKEPunSN0MYEvfJJJdlLyYTdkQ==} engines: {node: '>=22.13'} - '@automerge/automerge-repo@2.6.0-subduction.48': - resolution: {integrity: sha512-HNS1YsD0XmQ0vtwIincF7NvEBrQuOMnk+mjCVluRiIpUFvcaVeNAFXCCyBVeXubmg88AHe+LkIQ96v/lyHZ7Wg==} + '@automerge/automerge-repo@2.6.0-subduction.47': + resolution: {integrity: sha512-NGBQUjGH67Kyrc8a6zoafvDKT1+pnFi2/yiMgULS7l+apWQT2QkgQZ3vQGms/ngdrmM6ye1fhcwHwBn/DGJ6tA==} engines: {node: '>=22.13'} '@automerge/automerge-subduction@0.16.1': @@ -442,8 +438,8 @@ packages: '@automerge/automerge@3.3.2': resolution: {integrity: sha512-9vCdCL7pdQwUra66SBxPVHr+/t9epXKni9KDeak2rNBFMzABVh2u6gSpcwxi3jkR5qr047jVqZv9DlrOfVxLFw==} - '@automerge/vanillajs@2.6.0-subduction.48': - resolution: {integrity: sha512-pmgwTtukitG5kY60cJ7x527IQgeG0NSnoSU35Is1xmswS+05QYpZ1vVdeyKNlGcIz2Kttdvn4vzm/45h0eicYQ==} + '@automerge/vanillajs@2.6.0-subduction.47': + resolution: {integrity: sha512-OR0OIfxQzD9lOyagpMxQIHngC8J/NBBVi/Fxps3NXx752Vwrxn2RYEcb/8gg1NuYB8bkuVL3Jt477AZn2uhykw==} engines: {node: '>=22.13'} '@babel/runtime@7.29.7': @@ -1027,8 +1023,8 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@keyhive/keyhive@0.1.0-alpha.8': - resolution: {integrity: sha512-juyDs15N3xyKl9mtGHoghdxiCGiXVReS4GufjUMATE3TsgASA/fznISCic+sKc9mMg+aAOXWFAbNELfBe3GyvA==} + '@keyhive/keyhive@0.1.0-alpha.5': + resolution: {integrity: sha512-RoFimLwO91OR4/794pVT0SyTA4Gn3Jk9KL64ONrLe1UYLBdIPhrqeeQgY6i0FBcS1/zSeIFeK3AJZ8GzCRTRmA==} '@lezer/common@1.5.2': resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} @@ -1786,7 +1782,7 @@ packages: solid-automerge@2.0.1: resolution: {integrity: sha512-GhYw6/KGYH5q2a44UMnGOFdJ91YW8TGa4S5UFFRw/IC2Q6B0lfLGizUNitqYu39zt1citmILxqFrGhHThqNlOQ==} peerDependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo': 2.6.0-subduction.47 solid-js: ^1.9.13 solid-js@1.9.14: @@ -1994,12 +1990,12 @@ packages: snapshots: - '@automerge/automerge-repo-keyhive@0.5.0-alpha.7(ws@8.21.1)': + '@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.8b(ws@8.21.1)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48 - '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.47 '@automerge/automerge-subduction': 0.16.1 - '@keyhive/keyhive': 0.1.0-alpha.8 + '@keyhive/keyhive': 0.1.0-alpha.5 '@noble/hashes': 2.2.0 cbor-x: 1.6.4 eventemitter3: 5.0.4 @@ -2010,26 +2006,26 @@ snapshots: - utf-8-validate - ws - '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.47': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48': + '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.47': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) eventemitter3: 5.0.4 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48': + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.47': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) cbor-x: 1.6.4 debug: 4.4.3 eventemitter3: 5.0.4 @@ -2039,10 +2035,10 @@ snapshots: - supports-color - utf-8-validate - '@automerge/automerge-repo-react-hooks@2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@automerge/automerge-repo-react-hooks@2.6.0-subduction.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@automerge/automerge': 3.3.2 - '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) eventemitter3: 5.0.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -2051,15 +2047,15 @@ snapshots: - supports-color - utf-8-validate - '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48': + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.47': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.48': + '@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8)': dependencies: '@automerge/automerge': 3.3.2 '@automerge/automerge-subduction': 0.16.1 @@ -2081,13 +2077,13 @@ snapshots: '@automerge/automerge@3.3.2': {} - '@automerge/vanillajs@2.6.0-subduction.48': + '@automerge/vanillajs@2.6.0-subduction.47': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48 - '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.48 - '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48 - '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 - '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.48 + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.47 + '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.47 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.47 + '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.47 transitivePeerDependencies: - bufferutil - supports-color @@ -2555,7 +2551,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@keyhive/keyhive@0.1.0-alpha.8': {} + '@keyhive/keyhive@0.1.0-alpha.5': {} '@lezer/common@1.5.2': {} @@ -3307,9 +3303,9 @@ snapshots: slash@3.0.0: {} - solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.48)(solid-js@1.9.14): + solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8))(solid-js@1.9.14): dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) cabbages: 0.2.10 solid-js: 1.9.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 46376bb1..20e3672d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,3 +54,6 @@ catalog: allowBuilds: cbor-extract: true esbuild: true + +patchedDependencies: + '@automerge/automerge-repo@2.6.0-subduction.47': patches/@automerge__automerge-repo@2.6.0-subduction.47.patch From 14ffbf00075e05f07089a0d9a2a75d31d737c16a Mon Sep 17 00:00:00 2001 From: chee Date: Wed, 12 Aug 2026 17:13:16 +0100 Subject: [PATCH 05/16] splonk --- .changeset/tab-worker-subduction.md | 6 +++--- core/bootloader/src/types.ts | 7 ++----- core/bootloader/test/worker-link.test.ts | 12 +++--------- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/.changeset/tab-worker-subduction.md b/.changeset/tab-worker-subduction.md index 48bc69d9..9624eb64 100644 --- a/.changeset/tab-worker-subduction.md +++ b/.changeset/tab-worker-subduction.md @@ -5,8 +5,8 @@ Sync the tab with the automerge SharedWorker over Subduction instead of classic automerge-repo sync. -The tab is now a storageless node: it holds no IndexedDB of its own and gets everything from the worker's repo over a Subduction transport, one per repo port. Keyhive sites are unchanged — they keep classic sync through the keyhive network adapter. +The tab is now a storageless node: it holds no IndexedDB of its own and gets everything from the worker over a Subduction transport on the repo port. Keyhive sites are unchanged — they keep classic sync through the keyhive network adapter. -New: `@inkandswitch/patchwork-bootloader/port-hub` exports `PortHubAdapter`, a network adapter that carries many MessagePorts, and `WORKER_SUBDUCTION_SERVICE`, the service name both ends of the link name. `subductionAdapters` is read once when a Repo is built, and ports come and go after that — the worker gains one per tab, a tab gets a fresh one whenever the worker is recreated — so both ends register a hub up front and add ports to it. +New: `@inkandswitch/patchwork-bootloader/worker-link` exports `MessagePortTransport`, a Subduction transport over a MessagePort, and `WorkerSubductionEndpoint`, which opens one per connection. The tab passes the endpoint as a `subductionWebsocketEndpoint`, so automerge-repo's own reconnect loop replaces the port re-wiring the tab used to do by hand. -`createRepo` in `@inkandswitch/patchwork` now takes the worker's `MessagePort` rather than a `MessageChannelNetworkAdapter`, and returns `rewire(port)` and `linked()` alongside the repo. +The worker handoff on `patchwork.sw` changed with it: `subscribeToRepoChannel(listener)` and `getRepoChannel()` are gone, replaced by `openPort(): Promise` and `onRecreated(listener)`. `createRepo` in `@inkandswitch/patchwork` takes those two rather than a network adapter. diff --git a/core/bootloader/src/types.ts b/core/bootloader/src/types.ts index 0d3179dc..81fa9e0f 100644 --- a/core/bootloader/src/types.ts +++ b/core/bootloader/src/types.ts @@ -40,8 +40,7 @@ export interface SyncStateWhoAmIMessage { // now. Per-document heads are addressed to subscribers over the control port // instead (see SyncStateDocMessage) rather than fanned out to every tab. export type SyncStateBroadcast = - | SyncStateConnectionMessage - | SyncStateWhoAmIMessage; + SyncStateConnectionMessage | SyncStateWhoAmIMessage; /** * Tab → worker: please replay the current global sync signals (whoami + @@ -180,9 +179,7 @@ export interface HandoffAbortMessage { } export type HandoffReplyMessage = - | HandoffCachedMessage - | HandoffResponseMessage - | HandoffAbortMessage; + HandoffCachedMessage | HandoffResponseMessage | HandoffAbortMessage; /** * Automerge worker → world: broadcast once on startup so the service worker diff --git a/core/bootloader/test/worker-link.test.ts b/core/bootloader/test/worker-link.test.ts index 218e3e64..fd70e2ea 100644 --- a/core/bootloader/test/worker-link.test.ts +++ b/core/bootloader/test/worker-link.test.ts @@ -46,6 +46,9 @@ function link() { return { worker, tab, endpoint, accepted }; } +// The worker end only accepts links; it has no connection manager of its own +// here, so it never queries across them. Tab-to-worker data flow is covered by +// the edit test below. describe("tab <-> worker over subduction", () => { it("finds a worker doc from the tab", async () => { const { worker, tab } = link(); @@ -54,13 +57,6 @@ describe("tab <-> worker over subduction", () => { expect(found.doc().foo).toBe("bar"); }); - it("finds a tab doc from the worker", async () => { - const { worker, tab } = link(); - const handle = tab.create({ foo: "baz" }); - const found = await worker.find<{ foo: string }>(handle.url as AutomergeUrl); - expect(found.doc().foo).toBe("baz"); - }); - it("propagates edits both ways", async () => { const { worker, tab } = link(); const a = worker.create<{ n: number }>({ n: 1 }); @@ -80,8 +76,6 @@ describe("tab <-> worker over subduction", () => { // What setup.ts does when its heartbeat gives up on the SharedWorker. endpoint.reset(); - await pause(2000); - console.log("accepted after reset:", accepted.length); const second = worker.create({ foo: "after" }); const found = await tab.find<{ foo: string }>(second.url as AutomergeUrl); From 82c12e9e7cbb888fcb0aa84b5ae7b42519082695 Mon Sep 17 00:00:00 2001 From: chee Date: Wed, 12 Aug 2026 18:09:59 +0100 Subject: [PATCH 06/16] patch subduction --- .changeset/subduction-worker.md | 14 + core/bootloader/package.json | 4 + core/bootloader/src/automerge-worker.ts | 741 ++---------------- core/bootloader/src/externals-list.ts | 7 +- core/bootloader/src/setup.ts | 485 +++--------- core/bootloader/src/shared-worker.ts | 289 +++++++ core/bootloader/src/subduction-worker.ts | 366 +++++++++ core/bootloader/src/types.ts | 5 + core/bootloader/src/worker-control.ts | 140 ++++ core/bootloader/test/worker-link.test.ts | 108 ++- core/patchwork/src/repo.ts | 8 +- .../src/vite/service-worker-plugin.ts | 7 +- ...__automerge-repo@2.6.0-subduction.47.patch | 39 + pnpm-lock.yaml | 42 +- 14 files changed, 1160 insertions(+), 1095 deletions(-) create mode 100644 .changeset/subduction-worker.md create mode 100644 core/bootloader/src/shared-worker.ts create mode 100644 core/bootloader/src/subduction-worker.ts create mode 100644 core/bootloader/src/worker-control.ts diff --git a/.changeset/subduction-worker.md b/.changeset/subduction-worker.md new file mode 100644 index 00000000..459a6069 --- /dev/null +++ b/.changeset/subduction-worker.md @@ -0,0 +1,14 @@ +--- +"@inkandswitch/patchwork-bootloader": patch +"@inkandswitch/patchwork": patch +--- + +Split the shared worker in two: a Subduction node that owns storage and the sync-server link, and an automerge worker that only resolves `automerge:` URLs. + +automerge-repo now runs only where documents are read: in the tab, and in the automerge worker on the service worker's behalf. Both are storageless nodes hanging off the new subduction worker, which holds this origin's IndexedDB, keeps the WebSocket to the sync server (in-thread now — the websocket proxy worker is gone), and relays documents, edits and ephemeral messages between everything connected to it. + +A SharedWorker can neither spawn nor connect to another SharedWorker, so a tab brokers the link between the two: it opens a port on the subduction worker and donates it to the automerge worker with `donatePort`. + +Sites get a new emitted worker, `subduction-worker.js`; `setupServiceWorker` takes `subductionWorkerPath` alongside `workerPath`. Sync-state subscriptions now come from the subduction worker, which compares its own sedimentree heads against the server's rather than a document's Automerge frontier. + +Keyhive sites are not covered by this split yet. diff --git a/core/bootloader/package.json b/core/bootloader/package.json index 4b88905d..faba77d9 100644 --- a/core/bootloader/package.json +++ b/core/bootloader/package.json @@ -42,6 +42,10 @@ "import": "./dist/automerge-worker.js", "types": "./dist/automerge-worker.d.ts" }, + "./subduction-worker": { + "import": "./dist/subduction-worker.js", + "types": "./dist/subduction-worker.d.ts" + }, "./module-loader": { "import": "./dist/module-loader.js", "types": "./dist/module-loader.d.ts" diff --git a/core/bootloader/src/automerge-worker.ts b/core/bootloader/src/automerge-worker.ts index 5440b1e2..92381b62 100644 --- a/core/bootloader/src/automerge-worker.ts +++ b/core/bootloader/src/automerge-worker.ts @@ -1,232 +1,82 @@ -// The automerge repo for a patchwork site, in a SharedWorker: one instance -// serves every tab and lives as long as any tab does. +// The Repo that resolves `automerge:` URLs for the service worker, in a +// SharedWorker: one instance serves every tab and lives as long as any tab +// does. // -// The service worker holds no repo. When it misses the cache for a request that -// looks like a URL encoded URL, it broadcasts a HandoffRequestMessage on -// HANDOFF_CHANNEL; we resolve the automerge URL, write the response into the -// service worker's cache (keyed by a Request reconstructed to match the one -// it's holding), and reply on the same channel. +// It holds no storage of its own — it is a storageless node hanging off the +// subduction worker, and resolving requests is its whole job. When the service +// worker misses the cache for a request that looks like a URL encoded URL, it +// broadcasts a HandoffRequestMessage on HANDOFF_CHANNEL; we resolve the +// automerge URL, write the response into the service worker's cache (keyed by +// a Request reconstructed to match the one it's holding), and reply on the same +// channel. import { initializeWasm, hasHeads } from "@automerge/automerge/slim"; // eslint-disable-next-line // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim"; -import { WebCryptoSigner } from "@automerge/automerge-subduction/slim"; +import { MemorySigner } from "@automerge/automerge-subduction/slim"; import { makePortProvider } from "@automerge/automerge-repo/worker-port"; import { Repo, - WorkerWebSocketEndpoint, isValidAutomergeUrl, parseAutomergeUrl, stringifyAutomergeUrl, type AutomergeUrl, type DocHandle, - type DocumentId, type PeerId, - type UrlHeads, } from "@automerge/automerge-repo/slim"; import { resolvePath } from "@inkandswitch/patchwork-filesystem"; -import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket"; -import { - initializeAutomergeRepoKeyhive, - initKeyhiveWasm, - type AutomergeRepoKeyhive, - type SyncServerSelection, -} from "@automerge/automerge-repo-keyhive"; import { DEFAULT_CLASSIC_SYNC_SERVER } from "./sync-config.js"; -import { - MessagePortTransport, - WORKER_SUBDUCTION_SERVICE, -} from "./worker-link.js"; -import { keyhiveStorageName, storagePrefix } from "./storage.js"; +import { WorkerSubductionEndpoint } from "./worker-link.js"; +import { startWorkerControl, postToPort } from "./worker-control.js"; import { HANDOFF_CHANNEL, - SYNCSTATE_CHANNEL, type HandoffCachedMessage, type HandoffOnlineMessage, - type HandoffRequest, type HandoffAbortMessage, type HandoffRequestMessage, type HandoffResponseMessage, - type SyncStateBroadcast, - type SyncStateDocMessage, - type SyncStateRequestMessage, } from "./types.js"; -declare const __SYNC_SERVER__: { - url: string; - keyhive?: SyncServerSelection; -}; - -const syncServer = - typeof __SYNC_SERVER__ !== "undefined" - ? __SYNC_SERVER__ - : { url: "wss://subduction.sync.inkandswitch.com" }; - const RESOLVE_TIMEOUT_MS = 30_000; const CACHEABLE_STATUSES = [200, 203, 204]; -// A fresh instance means a new repo peerId and cold in-memory state, so a tab -// seeing a changed id knows to re-subscribe. Sent in `hello` and every `pong`. -const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2); -const WORKER_BOOT_TIME = Date.now(); - -type Identity = { peerId: string; verifyingKey: string }; - -// `debug` reads localStorage, which a SharedWorker doesn't have, so debugging is -// toggled by a control message from a tab instead. -let debugging = false; -function log(...args: any[]) { - if (debugging) console.log("[automerge-worker]", ...args); -} - -// ── Console forwarding ───────────────────────────────────────────────── -// The SharedWorker's own console is buried in chrome://inspect, so mirror -// everything over each connected tab's control port. - -const controlPorts = new Set(); -// Logs emitted before any tab connects (wasm boot) would otherwise be lost. -const preConnectBuffer: Array<{ level: string; args: string[] }> = []; -const MAX_BUFFER = 200; - -function serializeArg(arg: any): string { - if (typeof arg === "string") return arg; - if (arg instanceof Error) return arg.stack || `${arg.name}: ${arg.message}`; - try { - return JSON.stringify(arg); - } catch { - return String(arg); - } -} - -function postToPort(port: MessagePort, message: unknown): void { - try { - port.postMessage(message); - } catch (error) { - console.warn(`sending failed`, error); - } -} - -function forwardToMainThread(level: string, rawArgs: any[]) { - const args = rawArgs.map(serializeArg); - if (!controlPorts.size) { - if (preConnectBuffer.length < MAX_BUFFER) - preConnectBuffer.push({ level, args }); - return; - } - for (const port of controlPorts) { - postToPort(port, { type: "console", level, args }); - } -} - -for (const level of ["log", "info", "warn", "error", "debug"] as const) { - const original = console[level].bind(console); - console[level] = (...args: any[]) => { - original(...args); - forwardToMainThread(level, args); - }; -} +let link: WorkerSubductionEndpoint | undefined; -self.addEventListener("error", (event) => { - const e = event as ErrorEvent; - forwardToMainThread("error", [ - `uncaught error: ${e.message}`, - e.error instanceof Error ? e.error.stack : undefined, - ]); -}); - -self.addEventListener("unhandledrejection", (event) => { - const reason = (event as PromiseRejectionEvent).reason; - forwardToMainThread("error", [ - "unhandled rejection:", - reason instanceof Error ? reason.stack || reason.message : reason, - ]); +const control = startWorkerControl("automerge-worker", { + // The tab side runs donatePort; the messages are channel-tagged so they + // coexist with the control protocol. + onConnect: (port) => linkPortProvider.attachClient(port), + onMessage: handleControlMessage, }); +const log = control.log; -console.warn( - `[lifecycle] automerge SharedWorker started (instance ${WORKER_INSTANCE_ID})` -); - -const WATCHDOG_TICK_MS = 5_000; -let watchdogLast = Date.now(); -setInterval(() => { - const now = Date.now(); - const gap = now - watchdogLast; - watchdogLast = now; - if (gap > WATCHDOG_TICK_MS * 2) { - console.warn( - `[lifecycle] watchdog timer gap ~${Math.round(gap / 1000)}s ` + - `(expected every ${WATCHDOG_TICK_MS / 1000}s)` - ); - } -}, WATCHDOG_TICK_MS); - -// ── Per-tab sync-state subscriptions ─────────────────────────────────── -// A tab's control port subscribes to the documents it cares about and we push -// only those docs' heads down that port, so tab A never sees tab B's docs. A -// port's whole subscription set is dropped when it closes, so there's nothing -// to reference-count or time out. - -const syncWatchers = new Map>(); - -// Set once the repo's snapshot exists, so a `sync-sub` arriving during boot can -// be replayed the doc's current heads as soon as it does. -let replaySyncForPort: - ((documentId: string, port: MessagePort) => void) | null = null; - -function syncSubscribe(port: MessagePort, documentId: string): void { - let docs = syncWatchers.get(port); - if (!docs) syncWatchers.set(port, (docs = new Set())); - if (docs.has(documentId)) return; - docs.add(documentId); - replaySyncForPort?.(documentId, port); -} - -function syncUnsubscribe(port: MessagePort, documentId: string): void { - syncWatchers.get(port)?.delete(documentId); -} +// A SharedWorker can neither spawn nor connect to another SharedWorker, so a +// tab brokers this worker's link to the subduction worker: it asks for a port +// and donates one. +const linkPortProvider = makePortProvider({ target: "subduction-link" }); -function pushSyncState(message: SyncStateDocMessage): void { - for (const [port, docs] of syncWatchers) { - if (docs.has(message.documentId)) postToPort(port, message); - } -} +// ── The repo ─────────────────────────────────────────────────────────── -const subductionPortProvider = makePortProvider(); +let repoPromise: Promise | null = null; -// Memoized so a construction retry reuses the endpoint instead of leaking one -// per attempt. -let subductionEndpoints: WorkerWebSocketEndpoint[] | null = null; -function getSubductionEndpoints(): WorkerWebSocketEndpoint[] { - return (subductionEndpoints ??= [ - new WorkerWebSocketEndpoint(syncServer.url, { - worker: subductionPortProvider.source, - }), - ]); -} - -type RepoHive = { repo: Repo; hive?: AutomergeRepoKeyhive }; -type BuiltRepo = RepoHive & { identity?: Identity }; - -let repoHivePromise: Promise | null = null; - -function getRepoHive(): Promise { - if (!repoHivePromise) { - repoHivePromise = setUpRepoHive(); - // Don't permanently cache a rejection (e.g. the wasm fetch failed) — clear - // the slot so the next caller retries from scratch. - repoHivePromise.catch(() => { - repoHivePromise = null; +function getRepo(): Promise { + if (!repoPromise) { + repoPromise = buildRepo(); + // Don't cache a rejection (e.g. the wasm fetch failed): clear the slot so + // the next caller retries from scratch. + repoPromise.catch(() => { + repoPromise = null; }); } - return repoHivePromise; + return repoPromise; } -async function setUpRepoHive(): Promise { +async function buildRepo(): Promise { log("fetching wasm"); const [automergeWasm, subductionWasm] = await Promise.all([ fetch("/automerge.wasm").then((r) => r.arrayBuffer()), @@ -236,77 +86,18 @@ async function setUpRepoHive(): Promise { await initializeWasm(new Uint8Array(automergeWasm)); log("wasm initialized"); - const built: BuiltRepo = syncServer.keyhive - ? await buildKeyhiveRepo(syncServer.keyhive) - : await buildPlainRepo(); - - (self as any).repo = built.repo; - if (built.hive) (self as any).hive = built.hive; - if (built.identity) (self as any).syncIdentity = built.identity; - - setUpSyncStateBroadcast(built.repo, built.identity); - - // Deliberately not awaited: the network subsystem starts with only the - // subduction adapter, and the MessageChannel adapter is added later by - // connectPort, which itself awaits getRepoHive. Blocking here would deadlock - // that path and starve the handoff handler. - built.repo.networkSubsystem - .whenReady() - .then(() => log("repo network subsystem ready")); - - return { repo: built.repo, hive: built.hive }; -} - -async function buildPlainRepo(): Promise { - const signer = await WebCryptoSigner.setup(); - const identity = { - peerId: signer.peerId().toString(), - verifyingKey: ( - signer.verifyingKey() as Uint8Array & { - toHex(): string; - } - ).toHex(), - }; const repo = new Repo({ - storage: new IndexedDBWorkerStorageAdapter(), - signer, - peerId: `automerge-worker-${Math.random().toString(36).slice(2)}` as PeerId, - async sharePolicy(peerId) { - return peerId.includes("storage-server"); - }, - enableRemoteHeadsGossiping: true, - subductionWebsocketEndpoints: getSubductionEndpoints(), - }); - console.log("[patchwork] shared-worker subduction identity:", identity); - return { repo, identity }; -} - -async function buildKeyhiveRepo( - keyhiveSyncServer: SyncServerSelection -): Promise { - initKeyhiveWasm(); - const { hive, repo } = await initializeAutomergeRepoKeyhive({ - createRepo: (config) => new Repo(config), - storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName), - peerIdSuffix: - `${storagePrefix}-worker` + Math.random().toString(36).slice(2), - automaticArchiveIngestion: true, - cachingMode: "periodic", - // ARK selects the relay via `syncServer`, which pairs the contact card with - // the matching peer id. Omitting it defaults to "subduction". - syncServer: keyhiveSyncServer, - repo: { - storage: new IndexedDBWorkerStorageAdapter(), - subductionWebsocketEndpoints: getSubductionEndpoints(), - enableRemoteHeadsGossiping: true, - }, - }); - - hive.networkAdapter.whenReady().then(() => { - (hive.networkAdapter as any).syncKeyhive(); + signer: new MemorySigner(), + peerId: `resolver-${Math.random().toString(36).slice(2)}` as PeerId, + subductionWebsocketEndpoints: [ + (link = new WorkerSubductionEndpoint( + () => linkPortProvider.source() as Promise + )), + ], }); - return { repo, hive }; + (self as never as { repo: Repo }).repo = repo; + return repo; } // ── Classic sync ─────────────────────────────────────────────────────── @@ -327,7 +118,7 @@ function connectClassicSyncNetwork(server: string): Promise { classicSyncServer = url; const connecting = (async () => { - const { repo } = await getRepoHive(); + const repo = await getRepo(); if (!classicSyncAdapter) { classicSyncAdapter = new WebSocketWorkerClientAdapter(url); repo.networkSubsystem.addNetworkAdapter(classicSyncAdapter); @@ -346,426 +137,42 @@ function connectClassicSyncNetwork(server: string): Promise { return connecting; } -// ── Sync-state broadcast ─────────────────────────────────────────────── -// Only this worker is connected to the sync server, so it's the only place that -// learns the server's heads ("subduction-remote-heads", keyed by each Subduction -// peer's verifying-key storageId) and whether the link is up -// ("subduction-connection"). Global signals go out on SYNCSTATE_CHANNEL so any -// tab can render a sync indicator; per-document heads are addressed to -// subscribers instead (see pushSyncState). - -const RESYNC_GRACE_MS = 8_000; // must be stably diverged this long first -const RESYNC_INITIAL_DELAY_MS = 5_000; -const RESYNC_MAX_DELAY_MS = 60_000; -const RESYNC_REVIEW_INTERVAL_MS = 5_000; -const OWN_HANDLE_SCAN_INTERVAL_MS = 3_000; - -type PeerHeads = { heads: string[]; timestamp: number }; -type ResyncEntry = { - serverSig: string; - since: number; - delay: number; - lastResyncAt: number; -}; - -type SyncState = { - repo: Repo; - channel: BroadcastChannel; - identity?: Identity; - /** documentId -> storageId (verifying key) -> last-known heads */ - snapshot: Map>; - connected: boolean; - /** Directly-connected sync-server peer ids, used to judge "synced". */ - serverPeerIds: string[]; - tracked: Set; - resync: Map; -}; - -type OwnHandle = { - documentId: string; - heads: () => string[]; - on: (ev: "heads-changed", cb: () => void) => void; -}; - -let syncStateWired = false; - -function setUpSyncStateBroadcast(repo: Repo, identity?: Identity): void { - if (syncStateWired) return; - syncStateWired = true; - - const state: SyncState = { - repo, - channel: new BroadcastChannel(SYNCSTATE_CHANNEL), - identity, - snapshot: new Map(), - connected: repo.isSubductionConnected(), - serverPeerIds: [], - tracked: new Set(), - resync: new Map(), - }; - - postWhoAmI(state); - - replaySyncForPort = (documentId, port) => replayDoc(state, documentId, port); - for (const [port, docs] of syncWatchers) { - for (const documentId of docs) replayDoc(state, documentId, port); - } +// ── Control protocol ─────────────────────────────────────────────────── - repo.on( - "subduction-remote-heads", - ({ documentId, storageId, heads, timestamp }) => { - recordHeads(state, documentId, storageId, [...heads], timestamp); - // A doc the server reported is one we hold, so advertise our heads for it - // too. Only this doc: a full scan per event is O(all handles) and goes - // quadratic during sync bursts. The tick covers general discovery. - const handle = repo.handles[documentId as DocumentId]; - if (handle) trackOwnHandle(state, handle as never); - reviewResync(state, documentId); - } - ); - - repo.on("subduction-connection", ({ connected }) => { - state.connected = connected; - postConnection(state); - if (connected) void refreshServerPeers(state); - }); - - // A BroadcastChannel never receives its own posts, so this only sees tabs' - // requests. Only the global signals are replayed; a late tab gets per-doc - // heads by subscribing. - state.channel.addEventListener("message", (event: MessageEvent) => { - if ((event.data as SyncStateRequestMessage)?.type !== "request") return; - postWhoAmI(state); - postConnection(state); - }); - - void refreshServerPeers(state); - scanOwnHandles(state); - - if (!identity) return; - // Subduction-pushed docs don't surface via the "document" event, so discover - // them by re-scanning repo.handles on a tick. - setInterval(() => scanOwnHandles(state), OWN_HANDLE_SCAN_INTERVAL_MS); - // The "stuck" case is precisely when no head events are firing, so the - // grace/backoff timers can only advance on a tick. - setInterval(() => reviewAllResync(state), RESYNC_REVIEW_INTERVAL_MS); -} - -function postWhoAmI(state: SyncState): void { - if (!state.identity) return; - state.channel.postMessage({ - type: "whoami", - peerId: state.identity.peerId, - verifyingKey: state.identity.verifyingKey, - } satisfies SyncStateBroadcast); -} - -function postConnection(state: SyncState): void { - state.channel.postMessage({ - type: "connection", - connected: state.connected, - serverPeerIds: state.serverPeerIds, - } satisfies SyncStateBroadcast); -} - -function recordHeads( - state: SyncState, - documentId: string, - storageId: string, - heads: string[], - timestamp: number -): void { - let byStorage = state.snapshot.get(documentId); - if (!byStorage) state.snapshot.set(documentId, (byStorage = new Map())); - byStorage.set(storageId, { heads, timestamp }); - pushSyncState({ - type: "sync-state", - documentId, - storageId, - heads, - timestamp, - }); -} - -function replayDoc( - state: SyncState, - documentId: string, - port: MessagePort +function handleControlMessage( + data: any, + controlPort: MessagePort, + event: MessageEvent ): void { - const byStorage = state.snapshot.get(documentId); - if (!byStorage) return; - for (const [storageId, { heads, timestamp }] of byStorage) { - postToPort(port, { - type: "sync-state", - documentId, - storageId, - heads, - timestamp, - } satisfies SyncStateDocMessage); - } -} - -/** The peer list is empty until the handshake finishes, so retry briefly. */ -async function refreshServerPeers(state: SyncState): Promise { - for (let attempt = 0; attempt < 6; attempt++) { - try { - const ids = await state.repo.connectedSubductionPeerIds(); - if (ids.length > 0) { - state.serverPeerIds = ids; - postConnection(state); - return; - } - } catch { - // No subduction source yet. - } - await new Promise((r) => setTimeout(r, 500)); - } -} - -// Advertise this worker's own heads for every doc it holds, so the worker hop is -// visible on every document. No-op on the keyhive path, which has no identity. - -function broadcastOwnHeads(state: SyncState, handle: OwnHandle): void { - if (!state.identity) return; - let heads: string[]; - try { - heads = [...handle.heads()]; - } catch { - return; // handle not ready - } - recordHeads( - state, - handle.documentId, - state.identity.peerId, - heads, - Date.now() - ); - reviewResync(state, handle.documentId); -} - -function trackOwnHandle(state: SyncState, handle: OwnHandle): void { - if (!state.identity || state.tracked.has(handle.documentId)) return; - state.tracked.add(handle.documentId); - handle.on("heads-changed", () => broadcastOwnHeads(state, handle)); - broadcastOwnHeads(state, handle); -} - -function scanOwnHandles(state: SyncState): void { - if (!state.identity) return; - for (const handle of Object.values(state.repo.handles)) { - trackOwnHandle(state, handle as never); - } -} - -function serverHeadsFor(state: SyncState, documentId: string): UrlHeads { - const byStorage = state.snapshot.get(documentId); - if (!byStorage) return [] as unknown as UrlHeads; - const heads = new Set(); - for (const [storageId, entry] of byStorage) { - if (state.serverPeerIds.includes(storageId)) { - for (const head of entry.heads) heads.add(head); - } - } - return [...heads] as UrlHeads; -} - -function reviewResync(state: SyncState, documentId: string): void { - if (!state.identity || !state.connected) { - state.resync.delete(documentId); - return; - } - const handle = state.repo.handles[documentId as DocumentId]; - if (!handle) return; - - const serverHeads = serverHeadsFor(state, documentId); - if (serverHeads.length === 0) { - state.resync.delete(documentId); // nothing to compare against - return; - } - - // The server advertises subduction sedimentree heads (loose-commit and - // fragment-boundary commit ids), which are NOT the Automerge frontier, so - // never compare them to handle.heads() for equality. Ask instead whether we - // already hold every commit the server advertises. - let haveAll: boolean; - try { - haveAll = handle.containsHeads(serverHeads); - } catch { - return; // doc not ready, or an undecodable head - } - if (haveAll) { - state.resync.delete(documentId); - return; - } - - // Behind. Key the grace timer on the server heads alone, so your own edits - // churning don't keep resetting it. - const serverSig = [...serverHeads].sort().join(","); - const now = Date.now(); - const prev = state.resync.get(documentId); - if (!prev || prev.serverSig !== serverSig) { - // First sighting, or the server made progress: restart the clock. - state.resync.set(documentId, { - serverSig, - since: now, - delay: RESYNC_INITIAL_DELAY_MS, - lastResyncAt: 0, - }); + // The subduction worker died and was replaced: the donated port ends in a + // worker that no longer exists, so drop it and ask for another. + if (data?.type === "link-lost") { + linkPortProvider.invalidate(); + link?.reset(); return; } - if (now - prev.since < RESYNC_GRACE_MS) return; - if (now - prev.lastResyncAt < prev.delay) return; - log("re-syncing behind doc", documentId); - try { - state.repo.resyncSubduction(documentId as DocumentId); - } catch (e) { - log("resyncSubduction failed", e); - } - prev.lastResyncAt = now; - prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS); -} - -function reviewAllResync(state: SyncState): void { - if (!state.identity) return; - for (const documentId of state.snapshot.keys()) - reviewResync(state, documentId); - for (const id of [...state.resync.keys()]) { - if (!state.snapshot.has(id)) state.resync.delete(id); - } -} - -// ── Tab connections ──────────────────────────────────────────────────── -// Each tab connects with a control port and opens repo MessageChannel ports -// through it. Each of those is accepted as a subduction transport, so tabs sync -// with this repo over subduction whether or not keyhive is in play. - -type Connection = { transports: Set }; - -async function dropConnection(connection: Connection) { - if (!connection.transports.size) return; - log(`tab gone — dropping ${connection.transports.size} transport(s)`); - for (const transport of connection.transports) transport.abort(); - connection.transports.clear(); -} - -async function connectPort(port: MessagePort, connection: Connection) { - const { repo } = await getRepoHive(); - const transport = new MessagePortTransport(port); - connection.transports.add(transport); - const subduction = await repo.subduction; - await subduction.acceptTransport(transport, WORKER_SUBDUCTION_SERVICE); -} - -function handleControlMessage( - event: MessageEvent, - controlPort: MessagePort, - connection: Connection -) { - const data = event.data; - - switch (data?.type) { - case "port": { - log("received repo channel"); - const [repoPort] = event.ports; - connectPort(repoPort, connection).then( - () => controlPort.postMessage({ type: "port-ready", id: data.id }), - (err) => { - console.error("connectPort failed", err); - // Tell the tab so it doesn't hang until its timeout. - controlPort.postMessage({ - type: "port-failed", - id: data.id, - error: String(err), - }); - } - ); - return; - } - - case "sync-sub": - if (typeof data.documentId === "string") { - syncSubscribe(controlPort, data.documentId); - } - return; - - case "sync-unsub": - if (typeof data.documentId === "string") { - syncUnsubscribe(controlPort, data.documentId); - } - return; - - case "debug": - debugging = data.debug; - log("automerge worker debugging enabled"); - return; - - case "connect-classic-sync": { - const [replyPort] = event.ports; - const server = - typeof data.server === "string" - ? data.server - : DEFAULT_CLASSIC_SYNC_SERVER; - connectClassicSyncNetwork(server).then( - () => { - replyPort?.postMessage({ type: "connect-classic-sync-ready" }); - replyPort?.close(); - }, - (err) => { - console.error("connectClassicSyncNetwork failed", err); - replyPort?.postMessage({ - type: "connect-classic-sync-failed", - error: String(err), - }); - replyPort?.close(); - } - ); - return; - } - - case "ping": - controlPort.postMessage({ - type: "pong", - id: data.id, - instanceId: WORKER_INSTANCE_ID, + if (data?.type !== "connect-classic-sync") return; + const [replyPort] = event.ports; + const server = + typeof data.server === "string" ? data.server : DEFAULT_CLASSIC_SYNC_SERVER; + connectClassicSyncNetwork(server).then( + () => { + replyPort?.postMessage({ type: "connect-classic-sync-ready" }); + replyPort?.close(); + }, + (err) => { + console.error("connectClassicSyncNetwork failed", err); + replyPort?.postMessage({ + type: "connect-classic-sync-failed", + error: String(err), }); - return; - } + replyPort?.close(); + } + ); } -self.addEventListener("connect", (event) => { - const controlPort = (event as MessageEvent).ports[0]; - const connection: Connection = { transports: new Set() }; - - controlPort.addEventListener("message", (messageEvent) => { - handleControlMessage(messageEvent as MessageEvent, controlPort, connection); - }); - - // The tab side runs donatePort; the messages are channel-tagged so they - // coexist with the control protocol above. - subductionPortProvider.attachClient(controlPort); - - // Fires when the owning page is destroyed. Browsers without the close event - // fall back to the adapters' lazy useWeakRef cleanup. - controlPort.addEventListener("close", () => { - controlPorts.delete(controlPort); - syncWatchers.delete(controlPort); - void dropConnection(connection); - }); - - controlPort.start(); - - controlPort.postMessage({ - type: "hello", - instanceId: WORKER_INSTANCE_ID, - bootTime: WORKER_BOOT_TIME, - }); - - controlPorts.add(controlPort); - for (const { level, args } of preConnectBuffer.splice(0)) { - postToPort(controlPort, { type: "console", level, args }); - } -}); +// ── Resolving ────────────────────────────────────────────────────────── function waitForHeads( handle: DocHandle, @@ -806,7 +213,7 @@ async function resolveAutomergeUrl( automergeURL: URL, signal: AbortSignal ): Promise { - const { repo } = await getRepoHive(); + const repo = await getRepo(); const [maybeAutomergeUrl, ...path] = automergeURL.href.split("/"); if (!isValidAutomergeUrl(maybeAutomergeUrl)) { diff --git a/core/bootloader/src/externals-list.ts b/core/bootloader/src/externals-list.ts index 38e5bec9..ab823218 100644 --- a/core/bootloader/src/externals-list.ts +++ b/core/bootloader/src/externals-list.ts @@ -6,11 +6,10 @@ const externals = [ "@automerge/automerge/slim", "@automerge/automerge-repo", "@automerge/automerge-repo/slim", - // Port-donation plumbing for WorkerWebSocketEndpoint: tabs spawn the shared - // proxy entry and donate its port to the automerge worker (Chrome can't - // spawn workers from inside a SharedWorker). See setup.ts/automerge-worker.ts. + // Port-donation plumbing: a tab opens a port on the subduction worker and + // donates it to the automerge worker, since a SharedWorker can neither spawn + // nor connect to another one. See setup.ts/automerge-worker.ts. "@automerge/automerge-repo/worker-port", - "@automerge/automerge-repo/subduction-websocket-worker-shared", "@automerge/automerge-repo-network-messagechannel", "@automerge/automerge-repo-network-websocket", "@automerge/automerge-repo-storage-indexeddb", diff --git a/core/bootloader/src/setup.ts b/core/bootloader/src/setup.ts index 159f768a..f0d53f87 100644 --- a/core/bootloader/src/setup.ts +++ b/core/bootloader/src/setup.ts @@ -12,20 +12,17 @@ import { donatePort, isWorkerErrorMessage, } from "@automerge/automerge-repo/worker-port"; +import { + forwardWorkerConsole, + lifecycleLog, + sharedWorkerHandle, +} from "./shared-worker.js"; + +export { lifecycleLog }; const serviceWorkerDebugging = debug.enabled("patchwork:serviceworker"); const workerDebugging = debug.enabled("patchwork:automergeworker"); -export const lifecycleLog = debug("patchwork:lifecycle"); - -function describeErrorEvent(event: Event): string { - const error = event as ErrorEvent; - const where = error.filename - ? ` (${error.filename}:${error.lineno}:${error.colno})` - : ""; - return `${error.message || String(event)}${where}`; -} - // The version is cleared on every boot, so the steady state is // DEFAULT_CACHE_NAME. bumpServiceWorkerCache is a dev escape hatch: it moves // the worker to a throwaway cache now, and the next boot both reverts the name @@ -64,304 +61,131 @@ function installServiceWorkerLogForwarding(): void { }); } -// The automerge repo lives in a SharedWorker. one instance serves every -// tab. Browsers might kill a SharedWorker under memory pressure, so we -// heartbeat it and rebuild everything if it dies. +// ── The two shared workers ───────────────────────────────────────────── +// +// The subduction worker owns this origin's storage and the link to the sync +// server; tabs are peers of it. The automerge worker is a storageless Repo +// whose only job is resolving `automerge:` URLs for the service worker. A +// SharedWorker can neither spawn nor connect to another SharedWorker, so this +// tab brokers the link between them: it opens a port on the subduction worker +// and donates it. +let subductionWorkerPath = "/subduction-worker.js"; let automergeWorkerPath = "/automerge-worker.js"; -let automergeWorker: SharedWorker | undefined; -// A repo port opened against instance N is stale once instance N+1 exists — its -// channel ends in a dead worker — so deliveries are guarded on generation. -let workerGeneration = 0; -let disposeWorkerDeathDetection: (() => void) | undefined; -const workerRecreatedListeners = new Set<() => void>(); -let recoveringWorker = false; -let lastWorkerRecoveryAt = 0; -// Below this spacing, skip: if the fresh worker is dead too, its own heartbeat -// re-triggers recovery later rather than spinning in a tight loop. -const RECOVERY_MIN_INTERVAL_MS = 15_000; -let nextRepoChannelId = 0; - -// Chrome can't spawn workers inside a SharedWorker, so each tab offers this -// proxy's port to the automerge worker, which requests one via its port -// provider. Being a SharedWorker itself, the proxy — and the donated -// worker↔worker port — outlives the donor tab. -const SUBDUCTION_IO_WORKER_URL = - "/packages/@automerge/automerge-repo/subduction-websocket-worker-shared.js"; - -export function getAutomergeWorker(): SharedWorker { - if (automergeWorker) return automergeWorker; - - workerGeneration++; - const worker = new SharedWorker(automergeWorkerPath, { - name: "patchwork-automerge", - type: "module", - }); - automergeWorker = worker; - - // Fires when a message can't be structured-deserialized. Silent otherwise: - // the message is dropped, which looks identical to a worker that never - // replied. - worker.port.addEventListener("messageerror", (event) => { - console.error( - "[automerge-worker] undeserializable message from worker:", - event - ); - }); - // Control replies come back on this port, and we listen with - // addEventListener rather than onmessage, so it needs start(). - worker.port.start(); - worker.port.addEventListener("message", handleWorkerMessage); - worker.port.postMessage({ type: "debug", debug: workerDebugging }); - - donatePort(worker.port, createSubductionIoPort); - disposeWorkerDeathDetection = installWorkerDeathDetection(worker); - return worker; -} - -function handleWorkerMessage(event: MessageEvent): void { - const data = event.data; - - if (data?.type === "sync-state") { - dispatchSyncState(data as SyncStateDocMessage); - return; +let nextPortId = 0; + +const subductionWorker = sharedWorkerHandle( + "patchwork-subduction", + () => subductionWorkerPath, + { + debugging: workerDebugging, + onMessage(event) { + const data = event.data; + if (data?.type === "sync-state") { + dispatchSyncState(data as SyncStateDocMessage); + return; + } + forwardWorkerConsole("subduction-worker", data); + }, } - - // Crash/skew reports relayed from the subduction io proxy (e.g. a protocol - // mismatch from a stale SW-cached worker chunk). These otherwise only exist - // in chrome://inspect. - if (isWorkerErrorMessage(data)) { - console.error("[subduction-io]", data); - return; +); + +const automergeWorker = sharedWorkerHandle( + "patchwork-automerge", + () => automergeWorkerPath, + { + debugging: workerDebugging, + onMessage(event) { + const data = event.data; + // Crash/skew reports relayed over the port-provision protocol (e.g. a + // mismatch from a stale SW-cached worker chunk). These otherwise only + // exist in chrome://inspect. + if (isWorkerErrorMessage(data)) { + console.error("[automerge-worker]", data); + return; + } + forwardWorkerConsole("automerge-worker", data); + }, + onSpawn(worker) { + // Its Repo asks for this link on first use; `eager` would open a port + // before the worker had booted its wasm. + donatePort(worker.port, () => openPort(), { + target: "subduction-link", + eager: false, + }); + }, } +); - if (data?.type !== "console") return; - const { level, args } = data; - if ( - !lifecycleLog.enabled && - typeof args?.[0] === "string" && - args[0].includes("[lifecycle]") - ) { - return; - } - const write = (console as any)[level] ?? console.log; - // The worker's logs carry %c directives in args[0] with CSS in the following - // args, so the tag has to go inside the format string or the CSS prints raw. - if (typeof args[0] === "string") { - write(`[automerge-worker] ${args[0]}`, ...args.slice(1)); - } else { - write("[automerge-worker]", ...args); +// The resolver's link ends in a worker that no longer exists, and a dead +// SharedWorker leaves its ports silent rather than closed, so it needs telling. +subductionWorker.onRecreated(() => { + for (const documentId of syncStateListeners.keys()) { + subductionWorker.post({ type: "sync-sub", documentId }); } + automergeWorker.post({ type: "link-lost" }); +}); + +export function getAutomergeWorker(): SharedWorker { + return automergeWorker.get(); } -function createSubductionIoPort(): MessagePort { - const io = new SharedWorker(SUBDUCTION_IO_WORKER_URL, { - type: "module", - name: "subduction-websocket", - }); - // This worker carries the websocket to the sync server, so a load failure - // stops sync with no other symptom. - io.addEventListener("error", (event) => { - console.error( - `[subduction-io] failed to load/run ${SUBDUCTION_IO_WORKER_URL}:`, - describeErrorEvent(event) - ); - }); - io.port.addEventListener("messageerror", (event) => { - console.error("[subduction-io] undeserializable message:", event); - }); - return io.port; +export function getSubductionWorker(): SharedWorker { + return subductionWorker.get(); } /** - * Build a replacement worker and re-wire everything a live tab holds against - * it: console forwarding and port donation (both re-done by - * getAutomergeWorker) and the per-doc sync-state subscriptions. The new - * instance boots with cold state. Listeners are told so they can reopen - * whatever they had on the dead one. + * Wait for the worker to confirm it has accepted the port. Nothing on the port + * itself says so: the far side has to fetch wasm and build its node first. */ -async function recoverAutomergeWorker( - reason: string, - deadWorker: SharedWorker -): Promise { - if (deadWorker !== automergeWorker) return; - if (recoveringWorker) return; - const now = Date.now(); - if (now - lastWorkerRecoveryAt < RECOVERY_MIN_INTERVAL_MS) return; - recoveringWorker = true; - lastWorkerRecoveryAt = now; - lifecycleLog("recreating the automerge SharedWorker (%s)", reason); - - try { - disposeWorkerDeathDetection?.(); - disposeWorkerDeathDetection = undefined; - automergeWorker = undefined; - try { - deadWorker.port.close(); - } catch {} - - const fresh = getAutomergeWorker(); - for (const documentId of syncStateListeners.keys()) { - fresh.port.postMessage({ type: "sync-sub", documentId }); - } - for (const listener of workerRecreatedListeners) { - try { - listener(); - } catch (err) { - console.error("worker-recreated listener threw", err); +function awaitPortReady(control: MessagePort, id: number): Promise { + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timeout); + control.removeEventListener("message", listener); + }; + const listener = (event: MessageEvent) => { + if (event.data?.id !== id) return; + if (event.data.type === "port-ready") { + cleanup(); + resolve(); + } else if (event.data.type === "port-failed") { + cleanup(); + reject(new Error(`subduction worker init failed: ${event.data.error}`)); } - } - } finally { - recoveringWorker = false; - } -} - -// A silent port is not proof of death: the worker may still be evaluating its -// module graph, or be busy with wasm/sync work. In both cases every queued -// message — including the repo ports the network adapters ride on — is -// delivered once it catches up, and tearing the port down would lose them. So -// silence only starts a non-destructive probe: a second connection to the same -// instance. Only if the probe gets a `hello` while this port stays silent do we -// know the instance is alive but our port is stranded, and recover. -const HEARTBEAT_MS = 5_000; -const HEARTBEAT_TIMEOUT_MS = 25_000; -// An idle worker hellos within milliseconds of connecting, so before first -// contact the budget is tighter — probing early rescues stranded boots fast. -const FIRST_CONTACT_TIMEOUT_MS = 4_000; -// After a slow boot both connections hello at roughly the same moment and -// cross-port delivery order isn't guaranteed, so give the suspect this long to -// also speak before concluding it's stranded. -const PROBE_GRACE_MS = 500; - -function installWorkerDeathDetection(worker: SharedWorker): () => void { - let instanceId: string | undefined; - let lastHeardAt = Date.now(); - let warnedUnresponsive = false; - let warnedSendFailed = false; - let disposed = false; - let probe: SharedWorker | undefined; - let seq = 0; - - const closeProbe = () => { - if (!probe) return; - try { - probe.port.close(); - } catch {} - probe = undefined; - }; - - worker.port.addEventListener("message", (event: MessageEvent) => { - const data = event.data; - if (data?.type !== "hello" && data?.type !== "pong") return; - lastHeardAt = Date.now(); - warnedUnresponsive = false; - closeProbe(); - if (instanceId === undefined) { - instanceId = data.instanceId; - lifecycleLog( - "automerge SharedWorker instance %s (via %s)", - data.instanceId, - data.type - ); - } else if (data.instanceId && data.instanceId !== instanceId) { - lifecycleLog( - "automerge SharedWorker instance changed (instance %s, was %s)", - data.instanceId, - instanceId - ); - instanceId = data.instanceId; - } - }); - - worker.port.addEventListener("close", () => { - if (disposed) return; - lifecycleLog("automerge SharedWorker control port closed"); - void recoverAutomergeWorker("control port closed", worker); - }); - - // Not gated on the debug namespace: a worker that fails to load never replies - // to anything, and this is the only signal that says so. - worker.addEventListener("error", (event) => { - console.error("automerge SharedWorker error:", describeErrorEvent(event)); + }; + control.addEventListener("message", listener); + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("subduction worker port-ready timeout")); + }, 30_000); }); +} - const startProbe = (reason: string) => { - if (probe || disposed) return; - lifecycleLog( - "automerge SharedWorker %s; probing with a second connection", - reason +/** Open a Subduction port to the subduction worker, once it says it is ready. */ +export async function openPort(): Promise { + const id = ++nextPortId; + const worker = subductionWorker.get(); + const ready = awaitPortReady(worker.port, id); + const { port1, port2 } = new MessageChannel(); + worker.port.postMessage({ type: "port", id }, [port2]); + try { + await ready; + } catch (err) { + // Surface the problem and let the rest of the site come up rather than + // hanging on a blank page. + console.warn( + "proceeding without worker ready ack:", + err instanceof Error ? err.message : err ); - const startedAt = Date.now(); - const p = new SharedWorker(automergeWorkerPath, { - name: "patchwork-automerge", - type: "module", - }); - probe = p; - p.port.start(); - p.port.addEventListener("message", (event: MessageEvent) => { - if (event.data?.type !== "hello") return; - setTimeout(() => { - if (disposed || probe !== p) return; - closeProbe(); - // The suspect spoke while the probe ran: it was merely busy, and - // everything queued on it has been delivered. - if (lastHeardAt >= startedAt) return; - void recoverAutomergeWorker( - `port unresponsive on a live worker (${reason}; probe confirmed)`, - worker - ); - }, PROBE_GRACE_MS); - }); - // No hello on the probe means the instance is loading or busy. The probe - // waits indefinitely rather than tearing anything down on a timer. - }; - - const heartbeat = setInterval(() => { - try { - worker.port.postMessage({ type: "ping", id: ++seq }); - } catch (error) { - // Without this a failed send is indistinguishable from a dead worker. - if (!warnedSendFailed) { - warnedSendFailed = true; - console.error("automerge SharedWorker ping send threw", error); - } - } - - const neverHeard = instanceId === undefined; - const silentMs = Date.now() - lastHeardAt; - const timeoutMs = neverHeard - ? FIRST_CONTACT_TIMEOUT_MS - : HEARTBEAT_TIMEOUT_MS; - if (silentMs <= timeoutMs) return; - - // First contact probes regardless of visibility: SharedWorkers don't - // suspend with the tab, and the probe destroys nothing. Post-contact - // silence defers to visibility, since a hidden page's throttling can fake - // it. - const visible = - typeof document === "undefined" || document.visibilityState === "visible"; - if (!neverHeard && !visible) return; - - const seconds = Math.round(silentMs / 1000); - const reason = neverHeard - ? `no hello ~${seconds}s after connecting` - : `no pong for ~${seconds}s`; - if (!warnedUnresponsive) { - warnedUnresponsive = true; - lifecycleLog("automerge SharedWorker %s (tab visible)", reason); - } - startProbe(reason); - }, HEARTBEAT_MS); - - return () => { - disposed = true; - clearInterval(heartbeat); - closeProbe(); - }; + } + return port1; } +// ── Sync state ───────────────────────────────────────────────────────── // Ref-counted locally so several callers in this tab can watch the same doc // with a single worker subscription. + type SyncStateListener = (update: SyncStateDocMessage) => void; const syncStateListeners = new Map>(); @@ -379,11 +203,10 @@ export function subscribeSyncState( documentId: string, listener: SyncStateListener ): () => void { - const worker = getAutomergeWorker(); let listeners = syncStateListeners.get(documentId); if (!listeners) { syncStateListeners.set(documentId, (listeners = new Set())); - worker.port.postMessage({ type: "sync-sub", documentId }); + subductionWorker.post({ type: "sync-sub", documentId }); } listeners.add(listener); @@ -396,9 +219,7 @@ export function subscribeSyncState( set.delete(listener); if (set.size > 0) return; syncStateListeners.delete(documentId); - // Unsubscribe from whichever instance is current: recovery replays - // subscriptions onto a new worker, so it may not be the one captured above. - automergeWorker?.port.postMessage({ type: "sync-unsub", documentId }); + subductionWorker.post({ type: "sync-unsub", documentId }); }; } @@ -412,7 +233,6 @@ export function connectClassicSync( ); } - const worker = getAutomergeWorker(); const { port1, port2 } = new MessageChannel(); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -426,65 +246,13 @@ export function connectClassicSync( else reject(new Error(event.data?.error ?? "connect-classic-sync failed")); }; - worker.port.postMessage({ type: "connect-classic-sync", server: url }, [ + automergeWorker.post({ type: "connect-classic-sync", server: url }, [ port2, ]); }); } -function sendRepoPort(id: number): MessagePort { - const { port1, port2 } = new MessageChannel(); - getAutomergeWorker().port.postMessage({ type: "port", id }, [port2]); - return port1; -} - -/** - * Wait for the worker to confirm its repo is constructed. The MessageChannel - * adapter's whenReady() force-resolves after 100ms regardless of the other - * end's state, so it can't serve as a readiness signal on first boot, when the - * worker still has to fetch wasm and build its repo. - */ -function awaitPortReady(control: MessagePort, id: number): Promise { - return new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timeout); - control.removeEventListener("message", listener); - }; - const listener = (event: MessageEvent) => { - if (event.data?.id !== id) return; - if (event.data.type === "port-ready") { - cleanup(); - resolve(); - } else if (event.data.type === "port-failed") { - cleanup(); - reject(new Error(`automerge worker init failed: ${event.data.error}`)); - } - }; - control.addEventListener("message", listener); - const timeout = setTimeout(() => { - cleanup(); - reject(new Error("automerge worker port-ready timeout")); - }, 30_000); - }); -} - -/** Open a repo sync port to the automerge worker, once it says it is ready. */ -export async function openRepoPort(): Promise { - const id = ++nextRepoChannelId; - const ready = awaitPortReady(getAutomergeWorker().port, id); - const port = sendRepoPort(id); - try { - await ready; - } catch (err) { - // Surface the problem and let the rest of the site come up rather than - // hanging on a blank page. - console.warn( - "proceeding without worker ready ack:", - err instanceof Error ? err.message : err - ); - } - return port; -} +// ── Boot ─────────────────────────────────────────────────────────────── function waitForActive(reg: ServiceWorkerRegistration): Promise { if (reg.active) return Promise.resolve(reg.active); @@ -518,10 +286,13 @@ export default async function setupServiceWorker( void navigator.storage?.persist?.().catch(() => {}); if (options?.workerPath) automergeWorkerPath = options.workerPath; + if (options?.subductionWorkerPath) { + subductionWorkerPath = options.subductionWorkerPath; + } - // Start the automerge worker now so it boots wasm and its repo while the - // service worker installs. - const shared = getAutomergeWorker(); + // Start both now so they boot wasm while the service worker installs. + const shared = subductionWorker.get(); + automergeWorker.get(); const reg = await navigator.serviceWorker.register( options?.path ?? "/service-worker.js", @@ -560,16 +331,8 @@ export default async function setupServiceWorker( shared, connectClassicSync, subscribeSyncState, - openPort: openRepoPort, - // The automerge worker died and was replaced, so anything held against the - // old one — a repo port, a network adapter — is stranded. Ports opened from - // here on reach the new instance, which boots with cold state. - onRecreated(listener: () => void) { - workerRecreatedListeners.add(listener); - return () => { - workerRecreatedListeners.delete(listener); - }; - }, + openPort, + onRecreated: subductionWorker.onRecreated, }; } diff --git a/core/bootloader/src/shared-worker.ts b/core/bootloader/src/shared-worker.ts new file mode 100644 index 00000000..ae1982bb --- /dev/null +++ b/core/bootloader/src/shared-worker.ts @@ -0,0 +1,289 @@ +import debug from "debug"; + +export const lifecycleLog = debug("patchwork:lifecycle"); + +function describeErrorEvent(event: Event): string { + const error = event as ErrorEvent; + const where = error.filename + ? ` (${error.filename}:${error.lineno}:${error.colno})` + : ""; + return `${error.message || String(event)}${where}`; +} + +// A silent port is not proof of death: the worker may still be evaluating its +// module graph, or be busy with wasm/sync work. In both cases every queued +// message is delivered once it catches up, and tearing the port down would lose +// them. So silence only starts a non-destructive probe: a second connection to +// the same instance. Only if the probe gets a `hello` while this port stays +// silent do we know the instance is alive but our port is stranded, and +// recover. +const HEARTBEAT_MS = 5_000; +const HEARTBEAT_TIMEOUT_MS = 25_000; +// An idle worker hellos within milliseconds of connecting, so before first +// contact the budget is tighter — probing early rescues stranded boots fast. +const FIRST_CONTACT_TIMEOUT_MS = 4_000; +// After a slow boot both connections hello at roughly the same moment and +// cross-port delivery order isn't guaranteed, so give the suspect this long to +// also speak before concluding it's stranded. +const PROBE_GRACE_MS = 500; +// Below this spacing, skip: if the fresh worker is dead too, its own heartbeat +// re-triggers recovery later rather than spinning in a tight loop. +const RECOVERY_MIN_INTERVAL_MS = 15_000; + +export type SharedWorkerHandle = { + readonly name: string; + /** The current instance, spawning one if there isn't a live one. */ + get(): SharedWorker; + /** Send on the current instance's control port. */ + post(message: unknown, transfer?: Transferable[]): void; + /** + * The worker died and was replaced. Anything held against the old instance — + * a port, a subscription — is stranded; the new one boots with cold state. + */ + onRecreated(listener: () => void): () => void; +}; + +/** + * A SharedWorker a tab keeps alive: spawned on demand, heartbeated, and rebuilt + * if the browser kills it (which it may, under memory pressure). + */ +export function sharedWorkerHandle( + name: string, + /** Read on every spawn, so a site can set the path after this is built. */ + path: () => string, + { + debugging, + onMessage, + onSpawn, + }: { + debugging: boolean; + onMessage: (event: MessageEvent) => void; + onSpawn?: (worker: SharedWorker) => void; + } +): SharedWorkerHandle { + let current: SharedWorker | undefined; + let disposeDeathDetection: (() => void) | undefined; + let recovering = false; + let lastRecoveryAt = 0; + const recreatedListeners = new Set<() => void>(); + + const get = (): SharedWorker => { + if (current) return current; + + const worker = new SharedWorker(path(), { name, type: "module" }); + current = worker; + + // Fires when a message can't be structured-deserialized. Silent otherwise: + // the message is dropped, which looks identical to a worker that never + // replied. + worker.port.addEventListener("messageerror", (event) => { + console.error(`[${name}] undeserializable message from worker:`, event); + }); + // Replies come back on this port, and we listen with addEventListener + // rather than onmessage, so it needs start(). + worker.port.start(); + worker.port.addEventListener("message", onMessage); + worker.port.postMessage({ type: "debug", debug: debugging }); + + onSpawn?.(worker); + disposeDeathDetection = installDeathDetection(worker); + return worker; + }; + + async function recover(reason: string, dead: SharedWorker): Promise { + if (dead !== current) return; + if (recovering) return; + const now = Date.now(); + if (now - lastRecoveryAt < RECOVERY_MIN_INTERVAL_MS) return; + recovering = true; + lastRecoveryAt = now; + lifecycleLog("recreating the %s SharedWorker (%s)", name, reason); + + try { + disposeDeathDetection?.(); + disposeDeathDetection = undefined; + current = undefined; + try { + dead.port.close(); + } catch {} + + get(); + for (const listener of recreatedListeners) { + try { + listener(); + } catch (error) { + console.error(`[${name}] recreated listener threw`, error); + } + } + } finally { + recovering = false; + } + } + + function installDeathDetection(worker: SharedWorker): () => void { + let instanceId: string | undefined; + let lastHeardAt = Date.now(); + let warnedUnresponsive = false; + let warnedSendFailed = false; + let disposed = false; + let probe: SharedWorker | undefined; + let seq = 0; + + const closeProbe = () => { + if (!probe) return; + try { + probe.port.close(); + } catch {} + probe = undefined; + }; + + worker.port.addEventListener("message", (event: MessageEvent) => { + const data = event.data; + if (data?.type !== "hello" && data?.type !== "pong") return; + lastHeardAt = Date.now(); + warnedUnresponsive = false; + closeProbe(); + if (instanceId === undefined) { + instanceId = data.instanceId; + lifecycleLog( + "%s SharedWorker instance %s (via %s)", + name, + data.instanceId, + data.type + ); + } else if (data.instanceId && data.instanceId !== instanceId) { + lifecycleLog( + "%s SharedWorker instance changed (instance %s, was %s)", + name, + data.instanceId, + instanceId + ); + instanceId = data.instanceId; + } + }); + + worker.port.addEventListener("close", () => { + if (disposed) return; + lifecycleLog("%s SharedWorker control port closed", name); + void recover("control port closed", worker); + }); + + // Not gated on the debug namespace: a worker that fails to load never + // replies to anything, and this is the only signal that says so. + worker.addEventListener("error", (event) => { + console.error(`${name} SharedWorker error:`, describeErrorEvent(event)); + }); + + const startProbe = (reason: string) => { + if (probe || disposed) return; + lifecycleLog( + "%s SharedWorker %s; probing with a second connection", + name, + reason + ); + const startedAt = Date.now(); + const p = new SharedWorker(path(), { name, type: "module" }); + probe = p; + p.port.start(); + p.port.addEventListener("message", (event: MessageEvent) => { + if (event.data?.type !== "hello") return; + setTimeout(() => { + if (disposed || probe !== p) return; + closeProbe(); + // The suspect spoke while the probe ran: it was merely busy, and + // everything queued on it has been delivered. + if (lastHeardAt >= startedAt) return; + void recover( + `port unresponsive on a live worker (${reason}; probe confirmed)`, + worker + ); + }, PROBE_GRACE_MS); + }); + // No hello on the probe means the instance is loading or busy. The probe + // waits indefinitely rather than tearing anything down on a timer. + }; + + const heartbeat = setInterval(() => { + try { + worker.port.postMessage({ type: "ping", id: ++seq }); + } catch (error) { + // Without this a failed send is indistinguishable from a dead worker. + if (!warnedSendFailed) { + warnedSendFailed = true; + console.error(`${name} SharedWorker ping send threw`, error); + } + } + + const neverHeard = instanceId === undefined; + const silentMs = Date.now() - lastHeardAt; + const timeoutMs = neverHeard + ? FIRST_CONTACT_TIMEOUT_MS + : HEARTBEAT_TIMEOUT_MS; + if (silentMs <= timeoutMs) return; + + // First contact probes regardless of visibility: SharedWorkers don't + // suspend with the tab, and the probe destroys nothing. Post-contact + // silence defers to visibility, since a hidden page's throttling can fake + // it. + const visible = + typeof document === "undefined" || + document.visibilityState === "visible"; + if (!neverHeard && !visible) return; + + const seconds = Math.round(silentMs / 1000); + const reason = neverHeard + ? `no hello ~${seconds}s after connecting` + : `no pong for ~${seconds}s`; + if (!warnedUnresponsive) { + warnedUnresponsive = true; + lifecycleLog("%s SharedWorker %s (tab visible)", name, reason); + } + startProbe(reason); + }, HEARTBEAT_MS); + + return () => { + disposed = true; + clearInterval(heartbeat); + closeProbe(); + }; + } + + return { + name, + get, + post(message, transfer) { + get().port.postMessage(message, transfer ?? []); + }, + onRecreated(listener) { + recreatedListeners.add(listener); + return () => { + recreatedListeners.delete(listener); + }; + }, + }; +} + +/** + * Mirror a worker's forwarded console output into this tab's console, since a + * SharedWorker's own console is only visible in chrome://inspect. + */ +export function forwardWorkerConsole(name: string, data: any): boolean { + if (data?.type !== "console") return false; + const { level, args } = data; + if ( + !lifecycleLog.enabled && + typeof args?.[0] === "string" && + args[0].includes("[lifecycle]") + ) { + return true; + } + const write = (console as any)[level] ?? console.log; + // The worker's logs carry %c directives in args[0] with CSS in the following + // args, so the tag has to go inside the format string or the CSS prints raw. + if (typeof args[0] === "string") { + write(`[${name}] ${args[0]}`, ...args.slice(1)); + } else { + write(`[${name}]`, ...args); + } + return true; +} diff --git a/core/bootloader/src/subduction-worker.ts b/core/bootloader/src/subduction-worker.ts new file mode 100644 index 00000000..0ff43775 --- /dev/null +++ b/core/bootloader/src/subduction-worker.ts @@ -0,0 +1,366 @@ +// The Subduction node for a patchwork site, in a SharedWorker: one instance +// serves every tab and lives as long as any tab does. +// +// It holds this origin's storage and the link to the sync server, and nothing +// else — no Repo, no automerge. Tabs and the automerge worker are peers that +// connect over a MessagePort; a bare Subduction node relays their documents, +// edits and ephemeral messages both to each other and to the server. + +// eslint-disable-next-line +// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts +import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim"; +import { + Subduction, + WebCryptoSigner, +} from "@automerge/automerge-subduction/slim"; +import { + SubductionStorageBridge, + WebSocketTransport, + encodeHeads, + toDocumentId, +} from "@automerge/automerge-repo/slim"; +import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; +import type { SyncServerSelection } from "@automerge/automerge-repo-keyhive"; + +import { + MessagePortTransport, + WORKER_SUBDUCTION_SERVICE, +} from "./worker-link.js"; +import { startWorkerControl, postToPort } from "./worker-control.js"; +import { + SYNCSTATE_CHANNEL, + type SyncStateBroadcast, + type SyncStateDocMessage, + type SyncStateRequestMessage, +} from "./types.js"; + +declare const __SYNC_SERVER__: { + url: string; + keyhive?: SyncServerSelection; +}; + +const syncServer = + typeof __SYNC_SERVER__ !== "undefined" + ? __SYNC_SERVER__ + : { url: "wss://subduction.sync.inkandswitch.com" }; + +const RECONNECT_BASE_MS = 1_000; +const RECONNECT_MAX_MS = 30_000; +const HEADS_SCAN_INTERVAL_MS = 3_000; +const RESYNC_REVIEW_INTERVAL_MS = 5_000; +const RESYNC_GRACE_MS = 8_000; +const RESYNC_INITIAL_DELAY_MS = 5_000; +const RESYNC_MAX_DELAY_MS = 60_000; + +const control = startWorkerControl("subduction-worker", { + onMessage: handleControlMessage, + onClose: (port) => syncWatchers.delete(port), +}); +const log = control.log; + +type Identity = { peerId: string; verifyingKey: string }; + +let identity: Identity | undefined; +let serverPeerIds: string[] = []; +let connected = false; + +// ── The node ─────────────────────────────────────────────────────────── + +let nodePromise: Promise | null = null; + +function getSubduction(): Promise { + if (!nodePromise) { + nodePromise = start(); + // Don't cache a rejection (e.g. the wasm fetch failed): clear the slot so + // the next caller retries from scratch. + nodePromise.catch(() => { + nodePromise = null; + }); + } + return nodePromise; +} + +async function start(): Promise { + log("fetching wasm"); + const wasm = await fetch("/subduction.wasm").then((r) => r.arrayBuffer()); + initSubductionSync(new Uint8Array(wasm)); + log("wasm initialized"); + + const signer = await WebCryptoSigner.setup(); + identity = { + peerId: signer.peerId().toString(), + verifyingKey: ( + signer.verifyingKey() as Uint8Array & { + toHex(): string; + } + ).toHex(), + }; + + const subduction = new Subduction({ + signer: signer as never, + storage: new SubductionStorageBridge( + new IndexedDBWorkerStorageAdapter() + ) as never, + onRemoteHeads: ( + sedimentreeId: { toString(): string; toBytes(): Uint8Array }, + remotePeerId: { toString(): string }, + heads: Array<{ toHexString(): string }> + ) => { + recordHeads( + toDocumentId(sedimentreeId as never), + remotePeerId.toString(), + // bs58check-encoded to match automerge-repo's UrlHeads format, which + // is what a tab compares against its own heads. + [...encodeHeads(heads.map((head) => head.toHexString()) as never)], + Date.now() + ); + }, + }); + + (self as any).subduction = subduction; + (self as any).syncIdentity = identity; + console.log("[patchwork] subduction identity:", identity); + + postWhoAmI(); + void serverLoop(subduction); + setInterval(() => void scanOwnHeads(subduction), HEADS_SCAN_INTERVAL_MS); + setInterval(() => void reviewResync(subduction), RESYNC_REVIEW_INTERVAL_MS); + + return subduction; +} + +/** Reconnect loop for the sync server. */ +async function serverLoop(subduction: Subduction): Promise { + const service = new URL(syncServer.url).host; + let backoff = RECONNECT_BASE_MS; + + for (;;) { + let transport: WebSocketTransport | null = null; + try { + transport = await WebSocketTransport.connect(syncServer.url); + const peerId = await subduction.connectTransport(transport, service); + serverPeerIds = [peerId.toString()]; + connected = true; + postConnection(); + log("connected to", syncServer.url); + backoff = RECONNECT_BASE_MS; + await transport.closed(); + log("disconnected from", syncServer.url); + } catch (error) { + console.warn(`[subduction-worker] ${syncServer.url} failed:`, error); + void transport?.disconnect().catch(() => {}); + } + connected = false; + postConnection(); + await new Promise((resolve) => setTimeout(resolve, backoff)); + backoff = Math.min(backoff * 2, RECONNECT_MAX_MS); + } +} + +// ── Tab and worker links ─────────────────────────────────────────────── + +async function acceptPort(port: MessagePort): Promise { + const subduction = await getSubduction(); + await subduction.acceptTransport( + new MessagePortTransport(port), + WORKER_SUBDUCTION_SERVICE + ); + log("accepted a peer"); +} + +function handleControlMessage( + data: any, + controlPort: MessagePort, + event: MessageEvent +): void { + switch (data?.type) { + case "port": { + const [port] = event.ports; + acceptPort(port).then( + () => postToPort(controlPort, { type: "port-ready", id: data.id }), + (error) => { + console.error("accepting a peer failed", error); + // Tell the tab so it doesn't hang until its timeout. + postToPort(controlPort, { + type: "port-failed", + id: data.id, + error: String(error), + }); + } + ); + return; + } + + case "sync-sub": + if (typeof data.documentId === "string") { + syncSubscribe(controlPort, data.documentId); + } + return; + + case "sync-unsub": + if (typeof data.documentId === "string") { + syncWatchers.get(controlPort)?.delete(data.documentId); + } + return; + } +} + +// ── Sync state ───────────────────────────────────────────────────────── +// Only this worker talks to the sync server, so it is the only place that +// learns the server's heads and whether the link is up. Global signals go out +// on SYNCSTATE_CHANNEL so any tab can render an indicator; per-document heads +// are addressed to the tabs that asked for that document. + +type PeerHeads = { heads: string[]; timestamp: number }; +/** documentId -> peer (storageId) -> last-known heads */ +const snapshot = new Map>(); +const syncWatchers = new Map>(); +const channel = new BroadcastChannel(SYNCSTATE_CHANNEL); + +type ResyncEntry = { + serverSig: string; + since: number; + delay: number; + lastResyncAt: number; +}; +const resyncing = new Map(); + +function syncSubscribe(port: MessagePort, documentId: string): void { + let docs = syncWatchers.get(port); + if (!docs) syncWatchers.set(port, (docs = new Set())); + if (docs.has(documentId)) return; + docs.add(documentId); + for (const [storageId, { heads, timestamp }] of snapshot.get(documentId) ?? + []) { + postToPort(port, { + type: "sync-state", + documentId, + storageId, + heads, + timestamp, + } satisfies SyncStateDocMessage); + } +} + +function recordHeads( + documentId: string, + storageId: string, + heads: string[], + timestamp: number +): void { + let byStorage = snapshot.get(documentId); + if (!byStorage) snapshot.set(documentId, (byStorage = new Map())); + byStorage.set(storageId, { heads, timestamp }); + const message: SyncStateDocMessage = { + type: "sync-state", + documentId, + storageId, + heads, + timestamp, + }; + for (const [port, docs] of syncWatchers) { + if (docs.has(documentId)) postToPort(port, message); + } +} + +function postWhoAmI(): void { + if (!identity) return; + channel.postMessage({ + type: "whoami", + peerId: identity.peerId, + verifyingKey: identity.verifyingKey, + } satisfies SyncStateBroadcast); +} + +function postConnection(): void { + channel.postMessage({ + type: "connection", + connected, + serverPeerIds, + } satisfies SyncStateBroadcast); +} + +// A BroadcastChannel never receives its own posts, so this only sees tabs' +// requests. Only the global signals are replayed; a tab gets per-doc heads by +// subscribing. +channel.addEventListener("message", (event: MessageEvent) => { + if ((event.data as SyncStateRequestMessage)?.type !== "request") return; + postWhoAmI(); + postConnection(); +}); + +/** Advertise our own heads for every document we hold. */ +async function scanOwnHeads(subduction: Subduction): Promise { + if (!identity) return; + const now = Date.now(); + for (const entry of await subduction.getAllHeads()) { + recordHeads( + toDocumentId(entry.id as never), + identity.peerId, + [...encodeHeads(entry.heads.map((head) => head.toHexString()) as never)], + now + ); + } +} + +/** + * Nudge documents the server has moved past. Both sides' heads here are + * sedimentree commit ids, so they compare directly — unlike a document's + * Automerge frontier, which is a different thing entirely. + */ +async function reviewResync(subduction: Subduction): Promise { + if (!identity || !connected) { + resyncing.clear(); + return; + } + const now = Date.now(); + + for (const [documentId, byStorage] of snapshot) { + const ours = new Set(byStorage.get(identity.peerId)?.heads ?? []); + const serverHeads = new Set(); + for (const [storageId, entry] of byStorage) { + if (serverPeerIds.includes(storageId)) { + for (const head of entry.heads) serverHeads.add(head); + } + } + if (serverHeads.size === 0) { + resyncing.delete(documentId); + continue; + } + if ([...serverHeads].every((head) => ours.has(head))) { + resyncing.delete(documentId); + continue; + } + + // Behind. Key the grace timer on the server's heads alone, so our own + // edits churning don't keep resetting it. + const serverSig = [...serverHeads].sort().join(","); + const previous = resyncing.get(documentId); + if (!previous || previous.serverSig !== serverSig) { + resyncing.set(documentId, { + serverSig, + since: now, + delay: RESYNC_INITIAL_DELAY_MS, + lastResyncAt: 0, + }); + continue; + } + if (now - previous.since < RESYNC_GRACE_MS) continue; + if (now - previous.lastResyncAt < previous.delay) continue; + + log("re-syncing behind doc", documentId); + previous.lastResyncAt = now; + previous.delay = Math.min(previous.delay * 2, RESYNC_MAX_DELAY_MS); + for (const peerId of serverPeerIds) { + try { + await subduction.fullSyncWithPeer(peerId as never, true); + } catch (error) { + log("fullSyncWithPeer failed", error); + } + } + } +} + +// Start booting now rather than on the first port: wasm and storage hydration +// are the slow part, and a tab connects within milliseconds of spawning us. +void getSubduction(); diff --git a/core/bootloader/src/types.ts b/core/bootloader/src/types.ts index 81fa9e0f..76ac2a6a 100644 --- a/core/bootloader/src/types.ts +++ b/core/bootloader/src/types.ts @@ -200,6 +200,11 @@ export type SetupServiceWorkerOptions = { * Defaults to `/automerge-worker.js` */ workerPath?: string; + /** + * The public path to the subduction shared worker file. + * Defaults to `/subduction-worker.js` + */ + subductionWorkerPath?: string; }; export type SetupServiceWorkerResult = { diff --git a/core/bootloader/src/worker-control.ts b/core/bootloader/src/worker-control.ts new file mode 100644 index 00000000..fa35124f --- /dev/null +++ b/core/bootloader/src/worker-control.ts @@ -0,0 +1,140 @@ +// The control protocol every patchwork SharedWorker speaks with its tabs: +// console forwarding, a `hello` on connect, ping/pong for the tab's death +// detection, and a debug toggle. Everything else is the worker's own business +// and arrives through `onMessage`. + +/** A fresh instance means cold in-memory state, so tabs watch this. */ +export const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2); +export const WORKER_BOOT_TIME = Date.now(); + +const MAX_BUFFER = 200; + +export type WorkerControl = { + log: (...args: unknown[]) => void; + debugging: () => boolean; + post: (port: MessagePort, message: unknown) => void; + ports: Set; +}; + +export function postToPort(port: MessagePort, message: unknown): void { + try { + port.postMessage(message); + } catch (error) { + console.warn("sending failed", error); + } +} + +function serializeArg(arg: unknown): string { + if (typeof arg === "string") return arg; + if (arg instanceof Error) return arg.stack || `${arg.name}: ${arg.message}`; + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } +} + +export function startWorkerControl( + name: string, + handlers: { + onConnect?: (port: MessagePort) => void; + onMessage?: (data: any, port: MessagePort, event: MessageEvent) => void; + onClose?: (port: MessagePort) => void; + } = {} +): WorkerControl { + const ports = new Set(); + // Logs emitted before any tab connects (wasm boot) would otherwise be lost. + const preConnect: Array<{ level: string; args: string[] }> = []; + // `debug` reads localStorage, which a SharedWorker doesn't have, so this is + // toggled by a control message from a tab instead. + let debugging = false; + + // A SharedWorker's own console is buried in chrome://inspect, so mirror + // everything over each connected tab's control port. + const forward = (level: string, rawArgs: unknown[]) => { + const args = rawArgs.map(serializeArg); + if (!ports.size) { + if (preConnect.length < MAX_BUFFER) preConnect.push({ level, args }); + return; + } + for (const port of ports) + postToPort(port, { type: "console", level, args }); + }; + + for (const level of ["log", "info", "warn", "error", "debug"] as const) { + const original = console[level].bind(console); + console[level] = (...args: unknown[]) => { + original(...args); + forward(level, args); + }; + } + + self.addEventListener("error", (event) => { + const e = event as ErrorEvent; + forward("error", [ + `uncaught error: ${e.message}`, + e.error instanceof Error ? e.error.stack : undefined, + ]); + }); + + self.addEventListener("unhandledrejection", (event) => { + const reason = (event as PromiseRejectionEvent).reason; + forward("error", [ + "unhandled rejection:", + reason instanceof Error ? reason.stack || reason.message : reason, + ]); + }); + + self.addEventListener("connect", (event) => { + const port = (event as MessageEvent).ports[0]; + handlers.onConnect?.(port); + + port.addEventListener("message", (messageEvent) => { + const data = (messageEvent as MessageEvent).data; + if (data?.type === "ping") { + postToPort(port, { + type: "pong", + id: data.id, + instanceId: WORKER_INSTANCE_ID, + }); + return; + } + if (data?.type === "debug") { + debugging = data.debug; + return; + } + handlers.onMessage?.(data, port, messageEvent as MessageEvent); + }); + + // Fires when the owning page is destroyed. + port.addEventListener("close", () => { + ports.delete(port); + handlers.onClose?.(port); + }); + + port.start(); + postToPort(port, { + type: "hello", + instanceId: WORKER_INSTANCE_ID, + bootTime: WORKER_BOOT_TIME, + }); + + ports.add(port); + for (const { level, args } of preConnect.splice(0)) { + postToPort(port, { type: "console", level, args }); + } + }); + + console.warn( + `[lifecycle] ${name} SharedWorker started (instance ${WORKER_INSTANCE_ID})` + ); + + return { + ports, + post: postToPort, + debugging: () => debugging, + log: (...args: unknown[]) => { + if (debugging) console.log(`[${name}]`, ...args); + }, + }; +} diff --git a/core/bootloader/test/worker-link.test.ts b/core/bootloader/test/worker-link.test.ts index fd70e2ea..8c21ed2b 100644 --- a/core/bootloader/test/worker-link.test.ts +++ b/core/bootloader/test/worker-link.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, afterEach } from "vitest"; import { Repo, + SubductionStorageBridge, type PeerId, type AutomergeUrl, } from "@automerge/automerge-repo"; +import { Subduction, MemorySigner } from "@automerge/automerge-subduction"; +import { DummyStorageAdapter } from "@automerge/automerge-repo/helpers/DummyStorageAdapter.js"; import { MessagePortTransport, WorkerSubductionEndpoint, @@ -21,65 +24,102 @@ function pause(ms: number) { } /** - * A worker repo accepting tab links, and a tab repo whose only network is one - * of them. `openPort` stands in for the bootloader's control-port handshake. + * The subduction worker — a bare Subduction node, no Repo — and the Repos that + * hang off it: tabs, and the automerge worker that resolves URLs for the + * service worker. `openPort` stands in for the bootloader's control-port + * handshake. */ -function link() { - const worker = new Repo({ peerId: "automerge-worker-1" as PeerId }); +function site() { + const subduction = new Subduction({ + signer: new MemorySigner(), + storage: new SubductionStorageBridge(new DummyStorageAdapter()) as never, + }); const accepted: MessagePortTransport[] = []; const openPort = async () => { const { port1, port2 } = new MessageChannel(); const transport = new MessagePortTransport(port1 as unknown as MessagePort); accepted.push(transport); - const subduction = await worker.subduction; void subduction.acceptTransport(transport, WORKER_SUBDUCTION_SERVICE); return port2 as unknown as MessagePort; }; - const endpoint = new WorkerSubductionEndpoint(openPort); - const tab = new Repo({ - peerId: "tab-1" as PeerId, - subductionWebsocketEndpoints: [endpoint], - }); - repos.push(worker, tab); - return { worker, tab, endpoint, accepted }; + return { + accepted, + node(peerId: string) { + const endpoint = new WorkerSubductionEndpoint(openPort); + const repo = new Repo({ + peerId: peerId as PeerId, + subductionWebsocketEndpoints: [endpoint], + }); + repos.push(repo); + return { repo, endpoint }; + }, + }; } -// The worker end only accepts links; it has no connection manager of its own -// here, so it never queries across them. Tab-to-worker data flow is covered by -// the edit test below. -describe("tab <-> worker over subduction", () => { - it("finds a worker doc from the tab", async () => { - const { worker, tab } = link(); - const handle = worker.create({ foo: "bar" }); - const found = await tab.find<{ foo: string }>(handle.url as AutomergeUrl); +describe("nodes linked through the subduction worker", () => { + it("finds another node's document", async () => { + const { node } = site(); + const tab = node("tab-1").repo; + const resolver = node("resolver").repo; + const created = tab.create({ foo: "bar" }); + await pause(500); + const found = await resolver.find<{ foo: string }>( + created.url as AutomergeUrl + ); expect(found.doc().foo).toBe("bar"); }); it("propagates edits both ways", async () => { - const { worker, tab } = link(); - const a = worker.create<{ n: number }>({ n: 1 }); - const b = await tab.find<{ n: number }>(a.url as AutomergeUrl); - b.change((d) => (d.n = 2)); + const { node } = site(); + const a = node("tab-1").repo; + const b = node("tab-2").repo; + const here = a.create<{ n: number }>({ n: 1 }); + await pause(500); + const there = await b.find<{ n: number }>(here.url as AutomergeUrl); + there.change((d) => (d.n = 2)); await pause(500); - expect(a.doc().n).toBe(2); - a.change((d) => (d.n = 3)); + expect(here.doc().n).toBe(2); + here.change((d) => (d.n = 3)); await pause(500); - expect(b.doc().n).toBe(3); + expect(there.doc().n).toBe(3); + }); + + it("relays ephemeral messages", async () => { + const { node } = site(); + const a = node("tab-1").repo; + const b = node("tab-2").repo; + const here = a.create<{ n: number }>({ n: 1 }); + await pause(500); + const there = await b.find<{ n: number }>(here.url as AutomergeUrl); + + const seen: unknown[] = []; + there.on("ephemeral-message", ({ message }: { message: unknown }) => + seen.push(message) + ); + await pause(200); + here.broadcast({ hello: "there" }); + await pause(1000); + expect(seen).toEqual([{ hello: "there" }]); }); it("reconnects on a fresh port when the worker is replaced", async () => { - const { worker, tab, endpoint, accepted } = link(); - const first = worker.create({ foo: "before" }); - await tab.find<{ foo: string }>(first.url as AutomergeUrl); + const { node, accepted } = site(); + const tab = node("tab-1"); + const other = node("tab-2").repo; + const before = other.create({ foo: "before" }); + await pause(500); + await tab.repo.find<{ foo: string }>(before.url as AutomergeUrl); // What setup.ts does when its heartbeat gives up on the SharedWorker. - endpoint.reset(); + tab.endpoint.reset(); - const second = worker.create({ foo: "after" }); - const found = await tab.find<{ foo: string }>(second.url as AutomergeUrl); + const after = other.create({ foo: "after" }); + const found = await tab.repo.find<{ foo: string }>( + after.url as AutomergeUrl + ); expect(found.doc().foo).toBe("after"); - expect(accepted.length).toBe(2); + expect(accepted.length).toBe(3); }); }); diff --git a/core/patchwork/src/repo.ts b/core/patchwork/src/repo.ts index 6285a073..641dbc6b 100644 --- a/core/patchwork/src/repo.ts +++ b/core/patchwork/src/repo.ts @@ -1,8 +1,4 @@ -import { - initializeWasm, - Repo, - type AutomergeUrl, -} from "@automerge/vanillajs/slim"; +import { initializeWasm, Repo } from "@automerge/vanillajs/slim"; import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; import { WorkerSubductionEndpoint } from "@inkandswitch/patchwork-bootloader/worker-link"; import * as AutomergeRepo from "@automerge/automerge-repo/slim"; @@ -52,7 +48,7 @@ export function initWasm(): Promise { return wasmReady; } -/** The bit of the bootloader's automerge worker a Repo needs. */ +/** The bit of the bootloader's subduction worker a Repo needs. */ export type WorkerLink = { openPort: () => Promise; onRecreated: (listener: () => void) => () => void; diff --git a/core/patchwork/src/vite/service-worker-plugin.ts b/core/patchwork/src/vite/service-worker-plugin.ts index 1dbc16fd..0a74a1ab 100644 --- a/core/patchwork/src/vite/service-worker-plugin.ts +++ b/core/patchwork/src/vite/service-worker-plugin.ts @@ -9,8 +9,7 @@ import { builtins } from "./importmap-plugin.js"; // own node_modules by bare specifier. const self = fileURLToPath(import.meta.url); -// The service worker and the automerge shared worker are emitted as their -// own chunks. Their heavy imports are marked external and resolved to +// The service worker and the shared workers are emitted as their own chunks. Their heavy imports are marked external and resolved to // /packages/... URLs (both workers are created with type:"module", so the // browser fetches those as regular network requests). export const workers = [ @@ -22,6 +21,10 @@ export const workers = [ specifier: "@inkandswitch/patchwork-bootloader/automerge-worker", fileName: "automerge-worker.js", }, + { + specifier: "@inkandswitch/patchwork-bootloader/subduction-worker", + fileName: "subduction-worker.js", + }, { specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker", fileName: "module-loader-worker.js", diff --git a/patches/@automerge__automerge-repo@2.6.0-subduction.47.patch b/patches/@automerge__automerge-repo@2.6.0-subduction.47.patch index 78f1d6fc..c63d6a32 100644 --- a/patches/@automerge__automerge-repo@2.6.0-subduction.47.patch +++ b/patches/@automerge__automerge-repo@2.6.0-subduction.47.patch @@ -1,3 +1,29 @@ +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 1ca836a0b386a69c63e0fa2f655b9f78d5dfe508..20159edec16bc7d67e79ec41359bfd396399757f 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -30,6 +30,8 @@ export { isValidAutomergeUrl, isValidDocumentId, parseAutomergeUrl, stringifyAut + export type { ParsedAutomergeUrl, UrlOptions } from "./AutomergeUrl.js"; + export { Repo } from "./Repo.js"; + export { initSubduction } from "./initSubduction.js"; ++export { SubductionStorageBridge } from "./subduction/storage.js"; ++export { toDocumentId, toSedimentreeId } from "./subduction/helpers.js"; + export { makeLogger, resetLoggerFactory, setLoggerFactory } from "./Logger.js"; + export type { Logger, LoggerFactory } from "./Logger.js"; + export { Presence } from "./presence/Presence.js"; +diff --git a/dist/index.js b/dist/index.js +index 07c1a138fa618dd1f0538a490519ad5b2cf85f11..e12d141b953938917a191cabb7deccd31450d2c8 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -29,6 +29,8 @@ export { DocHandle } from "./DocHandle.js"; + export { isValidAutomergeUrl, isValidDocumentId, parseAutomergeUrl, stringifyAutomergeUrl, interpretAsDocumentId, documentIdToBinary, generateAutomergeUrl, encodeHeads, decodeHeads, } from "./AutomergeUrl.js"; + export { Repo } from "./Repo.js"; + export { initSubduction } from "./initSubduction.js"; ++export { SubductionStorageBridge } from "./subduction/storage.js"; ++export { toDocumentId, toSedimentreeId } from "./subduction/helpers.js"; + export { makeLogger, resetLoggerFactory, setLoggerFactory } from "./Logger.js"; + export { Presence } from "./presence/Presence.js"; + export { PeerStateView } from "./presence/PeerStateView.js"; diff --git a/dist/subduction/SubductionConnections.js b/dist/subduction/SubductionConnections.js index 4760a1899a8c0127db4840d5de090f6825211d97..5b9d19d4dd1cfb0cb242f140d5401af53aadd6a9 100644 --- a/dist/subduction/SubductionConnections.js @@ -13,6 +39,19 @@ index 4760a1899a8c0127db4840d5de090f6825211d97..5b9d19d4dd1cfb0cb242f140d5401af5 return true; } return false; +diff --git a/src/index.ts b/src/index.ts +index b13005dab37c119a016a8edc03969d3f6abefe1f..e9e1efae6d4c99c4caff55fce7e29dc667a595d3 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -41,6 +41,8 @@ export { + export type { ParsedAutomergeUrl, UrlOptions } from "./AutomergeUrl.js" + export { Repo } from "./Repo.js" + export { initSubduction } from "./initSubduction.js" ++export { SubductionStorageBridge } from "./subduction/storage.js" ++export { toDocumentId, toSedimentreeId } from "./subduction/helpers.js" + export { makeLogger, resetLoggerFactory, setLoggerFactory } from "./Logger.js" + export type { Logger, LoggerFactory } from "./Logger.js" + export { Presence } from "./presence/Presence.js" diff --git a/src/subduction/SubductionConnections.ts b/src/subduction/SubductionConnections.ts index 0d5510500c1e3a8e0d84b6f9b5f87f42096ee8e3..6b8376be24f932f21ab3ddc4b76bc81ff39a5030 100644 --- a/src/subduction/SubductionConnections.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0935efa..e81e5a45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ overrides: solid-automerge: ^2.0.1 patchedDependencies: - '@automerge/automerge-repo@2.6.0-subduction.47': 279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8 + '@automerge/automerge-repo@2.6.0-subduction.47': d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97 importers: @@ -81,7 +81,7 @@ importers: version: 3.3.2 '@automerge/automerge-repo': specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-keyhive': specifier: 0.3.0-alpha.sub.8b version: 0.3.0-alpha.sub.8b(ws@8.21.1) @@ -161,7 +161,7 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-keyhive': specifier: 0.3.0-alpha.sub.8b version: 0.3.0-alpha.sub.8b(ws@8.21.1) @@ -203,7 +203,7 @@ importers: version: 3.3.2 '@automerge/automerge-repo': specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) debug: specifier: ^4.4.3 version: 4.4.3 @@ -234,7 +234,7 @@ importers: version: 3.3.2 '@automerge/automerge-repo': specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-keyhive': specifier: 0.3.0-alpha.sub.8b version: 0.3.0-alpha.sub.8b(ws@8.21.1) @@ -308,7 +308,7 @@ importers: version: 3.3.2 '@automerge/automerge-repo': specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-keyhive': specifier: 0.3.0-alpha.sub.8b version: 0.3.0-alpha.sub.8b(ws@8.21.1) @@ -339,7 +339,7 @@ importers: version: 3.3.2 '@automerge/automerge-repo': specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 @@ -354,7 +354,7 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -367,7 +367,7 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-react-hooks': specifier: 2.6.0-subduction.47 version: 2.6.0-subduction.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -389,10 +389,10 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) solid-automerge: specifier: ^2.0.1 - version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8))(solid-js@1.9.14) + version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97))(solid-js@1.9.14) solid-js: specifier: ^1.9.13 version: 1.9.14 @@ -1992,7 +1992,7 @@ snapshots: '@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.8b(ws@8.21.1)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.47 '@automerge/automerge-subduction': 0.16.1 '@keyhive/keyhive': 0.1.0-alpha.5 @@ -2008,7 +2008,7 @@ snapshots: '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.47': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) transitivePeerDependencies: - bufferutil - supports-color @@ -2016,7 +2016,7 @@ snapshots: '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.47': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) eventemitter3: 5.0.4 transitivePeerDependencies: - bufferutil @@ -2025,7 +2025,7 @@ snapshots: '@automerge/automerge-repo-network-websocket@2.6.0-subduction.47': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) cbor-x: 1.6.4 debug: 4.4.3 eventemitter3: 5.0.4 @@ -2038,7 +2038,7 @@ snapshots: '@automerge/automerge-repo-react-hooks@2.6.0-subduction.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@automerge/automerge': 3.3.2 - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) eventemitter3: 5.0.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -2049,13 +2049,13 @@ snapshots: '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.47': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8)': + '@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97)': dependencies: '@automerge/automerge': 3.3.2 '@automerge/automerge-subduction': 0.16.1 @@ -2079,7 +2079,7 @@ snapshots: '@automerge/vanillajs@2.6.0-subduction.47': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.47 '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.47 '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.47 @@ -3303,9 +3303,9 @@ snapshots: slash@3.0.0: {} - solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8))(solid-js@1.9.14): + solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97))(solid-js@1.9.14): dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) cabbages: 0.2.10 solid-js: 1.9.14 From 0ea57fdda5f98e0f0233b89bf666ab9b68d5394d Mon Sep 17 00:00:00 2001 From: chee Date: Wed, 12 Aug 2026 18:42:25 +0100 Subject: [PATCH 07/16] dont be sily --- core/bootloader/src/subduction-worker.ts | 17 ++++++++++++----- core/bootloader/test/worker-link.test.ts | 2 ++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/core/bootloader/src/subduction-worker.ts b/core/bootloader/src/subduction-worker.ts index 0ff43775..9ab5cfb0 100644 --- a/core/bootloader/src/subduction-worker.ts +++ b/core/bootloader/src/subduction-worker.ts @@ -159,13 +159,20 @@ async function serverLoop(subduction: Subduction): Promise { // ── Tab and worker links ─────────────────────────────────────────────── +/** + * Resolves once this port is being read, which is all the ack means and all + * the far side can wait for: `acceptTransport` is the responder half of the + * handshake, so it doesn't settle until the other end initiates — and the + * other end doesn't initiate until it has the ack. + */ async function acceptPort(port: MessagePort): Promise { const subduction = await getSubduction(); - await subduction.acceptTransport( - new MessagePortTransport(port), - WORKER_SUBDUCTION_SERVICE - ); - log("accepted a peer"); + void subduction + .acceptTransport(new MessagePortTransport(port), WORKER_SUBDUCTION_SERVICE) + .then( + () => log("accepted a peer"), + (error) => console.error("accepting a peer failed", error) + ); } function handleControlMessage( diff --git a/core/bootloader/test/worker-link.test.ts b/core/bootloader/test/worker-link.test.ts index 8c21ed2b..e10b4a10 100644 --- a/core/bootloader/test/worker-link.test.ts +++ b/core/bootloader/test/worker-link.test.ts @@ -40,6 +40,8 @@ function site() { const { port1, port2 } = new MessageChannel(); const transport = new MessagePortTransport(port1 as unknown as MessagePort); accepted.push(transport); + // Not awaited, as in the worker: acceptTransport is the responder half of + // the handshake and only settles once this port's far side initiates. void subduction.acceptTransport(transport, WORKER_SUBDUCTION_SERVICE); return port2 as unknown as MessagePort; }; From f2e1fb1e8e33bccf55e2b52b37bc395ecd6b9f22 Mon Sep 17 00:00:00 2001 From: chee Date: Wed, 9 Sep 2026 17:32:58 +0100 Subject: [PATCH 08/16] update to latest sub + keyhive deps --- .changeset/subduction-forty-eight.md | 8 + ..._automerge-repo@2.6.0-subduction.48.patch} | 0 pnpm-lock.yaml | 235 +++++++++--------- pnpm-workspace.yaml | 4 +- 4 files changed, 131 insertions(+), 116 deletions(-) create mode 100644 .changeset/subduction-forty-eight.md rename patches/{@automerge__automerge-repo@2.6.0-subduction.47.patch => @automerge__automerge-repo@2.6.0-subduction.48.patch} (100%) diff --git a/.changeset/subduction-forty-eight.md b/.changeset/subduction-forty-eight.md new file mode 100644 index 00000000..c5f3d20c --- /dev/null +++ b/.changeset/subduction-forty-eight.md @@ -0,0 +1,8 @@ +--- +"@inkandswitch/patchwork-bootloader": patch +"@inkandswitch/patchwork-filesystem": patch +"@inkandswitch/patchwork": patch +"@inkandswitch/patchwork-plugins": patch +--- + +`@automerge/automerge` goes to `3.4.1`. diff --git a/patches/@automerge__automerge-repo@2.6.0-subduction.47.patch b/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch similarity index 100% rename from patches/@automerge__automerge-repo@2.6.0-subduction.47.patch rename to patches/@automerge__automerge-repo@2.6.0-subduction.48.patch diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e81e5a45..d56ee47c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,21 +35,21 @@ catalogs: version: 5.9.3 overrides: - '@automerge/automerge': 3.3.2 - '@automerge/automerge-repo': 2.6.0-subduction.47 - '@automerge/automerge-repo-keyhive': 0.3.0-alpha.sub.8b - '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.47 - '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.47 - '@automerge/automerge-repo-react-hooks': 2.6.0-subduction.47 - '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.47 + '@automerge/automerge': 3.4.1 + '@automerge/automerge-repo': 2.6.0-subduction.48 + '@automerge/automerge-repo-keyhive': 0.5.0-alpha.7 + '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 + '@automerge/automerge-repo-react-hooks': 2.6.0-subduction.48 + '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.48 '@automerge/automerge-subduction': 0.16.1 - '@automerge/react': 2.6.0-subduction.47 - '@automerge/vanillajs': 2.6.0-subduction.47 - '@keyhive/keyhive': 0.1.0-alpha.5 + '@automerge/react': 2.6.0-subduction.48 + '@automerge/vanillajs': 2.6.0-subduction.48 + '@keyhive/keyhive': 0.1.0-alpha.8 solid-automerge: ^2.0.1 patchedDependencies: - '@automerge/automerge-repo@2.6.0-subduction.47': d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97 + '@automerge/automerge-repo@2.6.0-subduction.48': d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97 importers: @@ -77,29 +77,29 @@ importers: core/bootloader: dependencies: '@automerge/automerge': - specifier: 3.3.2 - version: 3.3.2 + specifier: 3.4.1 + version: 3.4.1 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-keyhive': - specifier: 0.3.0-alpha.sub.8b - version: 0.3.0-alpha.sub.8b(ws@8.21.1) + specifier: 0.5.0-alpha.7 + version: 0.5.0-alpha.7(ws@8.21.1) '@automerge/automerge-repo-network-messagechannel': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47 + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48 '@automerge/automerge-repo-network-websocket': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47 + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48 '@automerge/automerge-repo-storage-indexeddb': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47 + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48 '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 '@automerge/vanillajs': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47 + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48 '@codemirror/commands': specifier: 'catalog:' version: 6.10.4 @@ -125,8 +125,8 @@ importers: specifier: workspace:^ version: link:../../packages/providers/core '@keyhive/keyhive': - specifier: 0.1.0-alpha.5 - version: 0.1.0-alpha.5 + specifier: 0.1.0-alpha.8 + version: 0.1.0-alpha.8 '@types/debug': specifier: ^4.1.13 version: 4.1.13 @@ -159,12 +159,18 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@automerge/automerge': + specifier: 3.4.1 + version: 3.4.1 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-keyhive': - specifier: 0.3.0-alpha.sub.8b - version: 0.3.0-alpha.sub.8b(ws@8.21.1) + specifier: 0.5.0-alpha.7 + version: 0.5.0-alpha.7(ws@8.21.1) + '@automerge/automerge-subduction': + specifier: 0.16.1 + version: 0.16.1 '@inkandswitch/patchwork-filesystem': specifier: workspace:^ version: link:../filesystem @@ -199,11 +205,11 @@ importers: core/filesystem: dependencies: '@automerge/automerge': - specifier: 3.3.2 - version: 3.3.2 + specifier: 3.4.1 + version: 3.4.1 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) debug: specifier: ^4.4.3 version: 4.4.3 @@ -212,8 +218,8 @@ importers: version: 2.0.3 devDependencies: '@automerge/automerge-repo-network-messagechannel': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47 + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48 '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 @@ -230,23 +236,23 @@ importers: core/patchwork: dependencies: '@automerge/automerge': - specifier: 3.3.2 - version: 3.3.2 + specifier: 3.4.1 + version: 3.4.1 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-keyhive': - specifier: 0.3.0-alpha.sub.8b - version: 0.3.0-alpha.sub.8b(ws@8.21.1) + specifier: 0.5.0-alpha.7 + version: 0.5.0-alpha.7(ws@8.21.1) '@automerge/automerge-repo-storage-indexeddb': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47 + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48 '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 '@automerge/vanillajs': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47 + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48 '@inkandswitch/patchwork-bootloader': specifier: workspace:^ version: link:../bootloader @@ -304,14 +310,14 @@ importers: version: 2.0.3 devDependencies: '@automerge/automerge': - specifier: 3.3.2 - version: 3.3.2 + specifier: 3.4.1 + version: 3.4.1 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-keyhive': - specifier: 0.3.0-alpha.sub.8b - version: 0.3.0-alpha.sub.8b(ws@8.21.1) + specifier: 0.5.0-alpha.7 + version: 0.5.0-alpha.7(ws@8.21.1) '@inkandswitch/patchwork-filesystem': specifier: workspace:^ version: link:../filesystem @@ -335,11 +341,11 @@ importers: packages/edge-handles: devDependencies: '@automerge/automerge': - specifier: 3.3.2 - version: 3.3.2 + specifier: 3.4.1 + version: 3.4.1 '@automerge/automerge-repo': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 @@ -353,8 +359,8 @@ importers: packages/providers/core: devDependencies: '@automerge/automerge-repo': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -366,11 +372,11 @@ importers: version: link:../../core devDependencies: '@automerge/automerge-repo': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@automerge/automerge-repo-react-hooks': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: 'catalog:' version: 18.3.1 @@ -388,11 +394,11 @@ importers: version: link:../../core devDependencies: '@automerge/automerge-repo': - specifier: 2.6.0-subduction.47 - version: 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) solid-automerge: specifier: ^2.0.1 - version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97))(solid-js@1.9.14) + version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97))(solid-js@1.9.14) solid-js: specifier: ^1.9.13 version: 1.9.14 @@ -402,44 +408,45 @@ importers: packages: - '@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.8b': - resolution: {integrity: sha512-Ld97PHH3fn9i0vnctjobaQV61BYjhmDZloSpfrFmXl6ynOfe8VUG4b0AyMJjmrpT9Fwz51An4u8ICoUpDsyUJg==} + '@automerge/automerge-repo-keyhive@0.5.0-alpha.7': + resolution: {integrity: sha512-9eGgbRwcEDPVdhEySi49YJfh5bn1uPx1OJI9YtoUIMVe+sxWByn58OEwIt6Jzd4dXcZAVjSqucwIXpmB6fOJQw==} + engines: {node: '>=22.13'} - '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.47': - resolution: {integrity: sha512-xqPlvVYtW6Khgoks+jQOc9/M3Cr8XP69cCBjXWXB8D3XUJwJ8Dn6R++s/M+T65fqr+Eeq6njLgWlTYjcFhKU0g==} + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': + resolution: {integrity: sha512-fyq/ZqWkrYOuNrQ917KeCaRSuY6fzqH6Q4KCb5czjwKPKO2zFIGKVfK5hZjYsHGWRKuh5BzViaONHn6bJ0Jk4w==} engines: {node: '>=22.13'} - '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.47': - resolution: {integrity: sha512-HT8F4eYwggDsCtWp/WzSzzjRlglR3jqYxsDalKoyu7eKIaD/RuP8emvzqY+OolkDkI0Uau+LizOshvcbhrBRGQ==} + '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48': + resolution: {integrity: sha512-pf+cmCi/TWQZpqrm+iinVpIM2Ecfzp8JpGEo28SPbE2Q8+eLyO2cz1Dssnuep1+KQ8k7Dfx7aUF9AD7/6jAvTQ==} engines: {node: '>=22.13'} - '@automerge/automerge-repo-network-websocket@2.6.0-subduction.47': - resolution: {integrity: sha512-A8aDy9jizU+6Pb5F6GSqSFNHAAXICib7uV60KHytEZjANL+PU2lMTpAhQnOG15NfJXX3mKXIsQ/duOZIsCRcZA==} + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48': + resolution: {integrity: sha512-TRKAq4iTdrSpOQYxyKZMJDJ5Civa+vONrAHBZkpK+DGShzqBB6vCdTCsrBGBF2Q63H33qQM0XYBQEQKIa/zM+Q==} engines: {node: '>=22.13'} - '@automerge/automerge-repo-react-hooks@2.6.0-subduction.47': - resolution: {integrity: sha512-ZUCQe8Ew6fmHy4IRw1Xf/rAmOxLsJv7JCiFTiofmtMd9SBHmXhgKQuZJJG8KYoVFucRLPZV63vfSPN8k7o+f/w==} + '@automerge/automerge-repo-react-hooks@2.6.0-subduction.48': + resolution: {integrity: sha512-o75EWDGUGdtmfH/EtwK1lnFoXKAWKY7pGHTkKNsPTZvLiHpafdm2ksk5JOzNcNGv7GMxwvXhMnmy2GOruYsj4g==} engines: {node: '>=22.13'} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.47': - resolution: {integrity: sha512-fYn6nse7jQnVb9EploTsBuYUzW7xFin2HZLneSEQ7w4pRCilIU92ghZEjZSWRKEPunSN0MYEvfJJJdlLyYTdkQ==} + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48': + resolution: {integrity: sha512-7XIn/iGrAVS4ugva8m0bU1shhRtuy/fdCAgF6qaIEvbi7XQllLVn3miTTiusLaksNhnRtbMfDLUknazU+vVHew==} engines: {node: '>=22.13'} - '@automerge/automerge-repo@2.6.0-subduction.47': - resolution: {integrity: sha512-NGBQUjGH67Kyrc8a6zoafvDKT1+pnFi2/yiMgULS7l+apWQT2QkgQZ3vQGms/ngdrmM6ye1fhcwHwBn/DGJ6tA==} + '@automerge/automerge-repo@2.6.0-subduction.48': + resolution: {integrity: sha512-HNS1YsD0XmQ0vtwIincF7NvEBrQuOMnk+mjCVluRiIpUFvcaVeNAFXCCyBVeXubmg88AHe+LkIQ96v/lyHZ7Wg==} engines: {node: '>=22.13'} '@automerge/automerge-subduction@0.16.1': resolution: {integrity: sha512-alH7U4eYn0O7sT6hNLv7CJhNODJi+QD6qfRQTNfZCxL3Q5JZdJ8lFpdvXUkBf+I3bu0GLvVlTaYPWGrBihjpIw==} - '@automerge/automerge@3.3.2': - resolution: {integrity: sha512-9vCdCL7pdQwUra66SBxPVHr+/t9epXKni9KDeak2rNBFMzABVh2u6gSpcwxi3jkR5qr047jVqZv9DlrOfVxLFw==} + '@automerge/automerge@3.4.1': + resolution: {integrity: sha512-zsZpbs/iDPvp+ZojIYd+gxmbcPVz2Xbkcx778G8zrt3E0zS+6saHJOm666lOuZyNRlTV4wHw9qzGTKueedeCsQ==} - '@automerge/vanillajs@2.6.0-subduction.47': - resolution: {integrity: sha512-OR0OIfxQzD9lOyagpMxQIHngC8J/NBBVi/Fxps3NXx752Vwrxn2RYEcb/8gg1NuYB8bkuVL3Jt477AZn2uhykw==} + '@automerge/vanillajs@2.6.0-subduction.48': + resolution: {integrity: sha512-pmgwTtukitG5kY60cJ7x527IQgeG0NSnoSU35Is1xmswS+05QYpZ1vVdeyKNlGcIz2Kttdvn4vzm/45h0eicYQ==} engines: {node: '>=22.13'} '@babel/runtime@7.29.7': @@ -1023,8 +1030,8 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@keyhive/keyhive@0.1.0-alpha.5': - resolution: {integrity: sha512-RoFimLwO91OR4/794pVT0SyTA4Gn3Jk9KL64ONrLe1UYLBdIPhrqeeQgY6i0FBcS1/zSeIFeK3AJZ8GzCRTRmA==} + '@keyhive/keyhive@0.1.0-alpha.8': + resolution: {integrity: sha512-juyDs15N3xyKl9mtGHoghdxiCGiXVReS4GufjUMATE3TsgASA/fznISCic+sKc9mMg+aAOXWFAbNELfBe3GyvA==} '@lezer/common@1.5.2': resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} @@ -1782,7 +1789,7 @@ packages: solid-automerge@2.0.1: resolution: {integrity: sha512-GhYw6/KGYH5q2a44UMnGOFdJ91YW8TGa4S5UFFRw/IC2Q6B0lfLGizUNitqYu39zt1citmILxqFrGhHThqNlOQ==} peerDependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47 + '@automerge/automerge-repo': 2.6.0-subduction.48 solid-js: ^1.9.13 solid-js@1.9.14: @@ -1990,12 +1997,12 @@ packages: snapshots: - '@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.8b(ws@8.21.1)': + '@automerge/automerge-repo-keyhive@0.5.0-alpha.7(ws@8.21.1)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) - '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.47 + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 '@automerge/automerge-subduction': 0.16.1 - '@keyhive/keyhive': 0.1.0-alpha.5 + '@keyhive/keyhive': 0.1.0-alpha.8 '@noble/hashes': 2.2.0 cbor-x: 1.6.4 eventemitter3: 5.0.4 @@ -2006,26 +2013,26 @@ snapshots: - utf-8-validate - ws - '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.47': + '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.47': + '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) eventemitter3: 5.0.4 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo-network-websocket@2.6.0-subduction.47': + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) cbor-x: 1.6.4 debug: 4.4.3 eventemitter3: 5.0.4 @@ -2035,10 +2042,10 @@ snapshots: - supports-color - utf-8-validate - '@automerge/automerge-repo-react-hooks@2.6.0-subduction.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@automerge/automerge-repo-react-hooks@2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@automerge/automerge': 3.3.2 - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge': 3.4.1 + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) eventemitter3: 5.0.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -2047,17 +2054,17 @@ snapshots: - supports-color - utf-8-validate - '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.47': + '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97)': + '@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97)': dependencies: - '@automerge/automerge': 3.3.2 + '@automerge/automerge': 3.4.1 '@automerge/automerge-subduction': 0.16.1 bs58check: 4.0.0 cbor-x: 1.6.4 @@ -2075,15 +2082,15 @@ snapshots: '@automerge/automerge-subduction@0.16.1': {} - '@automerge/automerge@3.3.2': {} + '@automerge/automerge@3.4.1': {} - '@automerge/vanillajs@2.6.0-subduction.47': + '@automerge/vanillajs@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) - '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.47 - '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.47 - '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.47 - '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.47 + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.48 + '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48 + '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 + '@automerge/automerge-repo-storage-indexeddb': 2.6.0-subduction.48 transitivePeerDependencies: - bufferutil - supports-color @@ -2551,7 +2558,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@keyhive/keyhive@0.1.0-alpha.5': {} + '@keyhive/keyhive@0.1.0-alpha.8': {} '@lezer/common@1.5.2': {} @@ -3303,9 +3310,9 @@ snapshots: slash@3.0.0: {} - solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97))(solid-js@1.9.14): + solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97))(solid-js@1.9.14): dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.47(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) cabbages: 0.2.10 solid-js: 1.9.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 20e3672d..4e7475b0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -26,7 +26,7 @@ minimumReleaseAgeExclude: - solid-automerge catalog: - "@automerge/automerge": 3.3.2 + "@automerge/automerge": 3.4.1 "@automerge/automerge-repo": 2.6.0-subduction.48 "@automerge/automerge-repo-keyhive": 0.5.0-alpha.7 "@automerge/automerge-repo-network-messagechannel": 2.6.0-subduction.48 @@ -56,4 +56,4 @@ allowBuilds: esbuild: true patchedDependencies: - '@automerge/automerge-repo@2.6.0-subduction.47': patches/@automerge__automerge-repo@2.6.0-subduction.47.patch + '@automerge/automerge-repo@2.6.0-subduction.48': patches/@automerge__automerge-repo@2.6.0-subduction.48.patch From ea6cf1087e1839b483d14e6655734edabf612e2e Mon Sep 17 00:00:00 2001 From: chee Date: Mon, 14 Sep 2026 19:53:58 +0100 Subject: [PATCH 09/16] keyhive over subduction in the tab The tab builds its hive with the subduction-backed initializer and points it at the subduction worker, which relays keyhive frames to and from the sync server. Drops the classic-sync keyhive path and the hub adapter that came with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SVGX4ASe8nJiNXMhccTzDR --- .changeset/subduction-worker.md | 2 +- .changeset/tab-worker-subduction.md | 2 +- core/bootloader/src/setup.ts | 18 +++++++++++ core/bootloader/src/subduction-worker.ts | 39 ++++++++++++++++++++++++ core/bootloader/src/types.ts | 21 ++++++++----- core/bootloader/src/worker-control.ts | 16 ++-------- core/patchwork/src/index.ts | 1 + core/patchwork/src/repo.ts | 13 +++++--- core/patchwork/src/types.ts | 6 +++- 9 files changed, 89 insertions(+), 29 deletions(-) diff --git a/.changeset/subduction-worker.md b/.changeset/subduction-worker.md index 459a6069..c2fdcfcf 100644 --- a/.changeset/subduction-worker.md +++ b/.changeset/subduction-worker.md @@ -11,4 +11,4 @@ A SharedWorker can neither spawn nor connect to another SharedWorker, so a tab b Sites get a new emitted worker, `subduction-worker.js`; `setupServiceWorker` takes `subductionWorkerPath` alongside `workerPath`. Sync-state subscriptions now come from the subduction worker, which compares its own sedimentree heads against the server's rather than a document's Automerge frontier. -Keyhive sites are not covered by this split yet. +On a keyhive site the tab's hive addresses the subduction worker rather than the sync server: keyhive frames are point-to-point, so the worker relays them — a tab's to the server, the server's to every tab — without a hive of its own. `setupServiceWorker` returns `identity()` so a tab can find the worker's peer id. The automerge worker has no hive either, so it can't resolve `automerge:` URLs to keyhive-protected documents. diff --git a/.changeset/tab-worker-subduction.md b/.changeset/tab-worker-subduction.md index 9624eb64..33a44b7f 100644 --- a/.changeset/tab-worker-subduction.md +++ b/.changeset/tab-worker-subduction.md @@ -5,7 +5,7 @@ Sync the tab with the automerge SharedWorker over Subduction instead of classic automerge-repo sync. -The tab is now a storageless node: it holds no IndexedDB of its own and gets everything from the worker over a Subduction transport on the repo port. Keyhive sites are unchanged — they keep classic sync through the keyhive network adapter. +The tab is now a storageless node: it holds no IndexedDB of its own and gets everything from the worker over a Subduction transport on the repo port. Keyhive sites take the same path: the tab builds its hive with `initializeAutomergeRepoKeyhive`, the subduction-backed one, and syncs keyhive state over the same transport instead of wrapping a classic adapter. New: `@inkandswitch/patchwork-bootloader/worker-link` exports `MessagePortTransport`, a Subduction transport over a MessagePort, and `WorkerSubductionEndpoint`, which opens one per connection. The tab passes the endpoint as a `subductionWebsocketEndpoint`, so automerge-repo's own reconnect loop replaces the port re-wiring the tab used to do by hand. diff --git a/core/bootloader/src/setup.ts b/core/bootloader/src/setup.ts index f0d53f87..5f713294 100644 --- a/core/bootloader/src/setup.ts +++ b/core/bootloader/src/setup.ts @@ -2,6 +2,8 @@ import type { SetupServiceWorkerOptions, SetupServiceWorkerResult, SyncStateDocMessage, + SyncStateWhoAmIMessage, + WorkerIdentity, } from "./types.js"; import { readClassicSyncServer, @@ -182,6 +184,21 @@ export async function openPort(): Promise { return port1; } +/** The subduction worker's identity. Stable across restarts: its key is kept in IndexedDB. */ +export function identity(): Promise { + const control = subductionWorker.get().port; + return new Promise((resolve) => { + const listener = (event: MessageEvent) => { + const data = event.data as SyncStateWhoAmIMessage; + if (data?.type !== "whoami") return; + control.removeEventListener("message", listener); + resolve({ peerId: data.peerId, verifyingKey: data.verifyingKey }); + }; + control.addEventListener("message", listener); + control.postMessage({ type: "whoami" }); + }); +} + // ── Sync state ───────────────────────────────────────────────────────── // Ref-counted locally so several callers in this tab can watch the same doc // with a single worker subscription. @@ -332,6 +349,7 @@ export default async function setupServiceWorker( connectClassicSync, subscribeSyncState, openPort, + identity, onRecreated: subductionWorker.onRecreated, }; } diff --git a/core/bootloader/src/subduction-worker.ts b/core/bootloader/src/subduction-worker.ts index 9ab5cfb0..9900d56b 100644 --- a/core/bootloader/src/subduction-worker.ts +++ b/core/bootloader/src/subduction-worker.ts @@ -32,6 +32,7 @@ import { type SyncStateBroadcast, type SyncStateDocMessage, type SyncStateRequestMessage, + type SyncStateWhoAmIMessage, } from "./types.js"; declare const __SYNC_SERVER__: { @@ -122,6 +123,7 @@ async function start(): Promise { console.log("[patchwork] subduction identity:", identity); postWhoAmI(); + if (syncServer.keyhive) relayKeyhiveFrames(subduction); void serverLoop(subduction); setInterval(() => void scanOwnHeads(subduction), HEADS_SCAN_INTERVAL_MS); setInterval(() => void reviewResync(subduction), RESYNC_REVIEW_INTERVAL_MS); @@ -157,6 +159,33 @@ async function serverLoop(subduction: Subduction): Promise { } } +/** + * Keyhive frames are point-to-point and a bare node doesn't forward them, so + * a tab's hive addresses this worker and the worker passes frames along: a + * tab's go to the server, the server's go to every tab. The hive on each end + * checks signatures and sender ids itself; nothing here reads the payload. + */ +function relayKeyhiveFrames(subduction: Subduction): void { + const isServer = (peerId: { toString(): string }) => + serverPeerIds.includes(peerId.toString()); + void subduction.registerFrameHandler({ + onMessage(payload, from) { + void (async () => { + const fromServer = isServer(from); + for (const peer of await subduction.getConnectedPeerIds()) { + if (isServer(peer) === fromServer) continue; + try { + await subduction.sendKeyhiveMessage(payload, peer); + } catch (error) { + log("relaying a keyhive frame failed", error); + } + } + })(); + }, + onPeerDisconnect() {}, + }); +} + // ── Tab and worker links ─────────────────────────────────────────────── /** @@ -209,6 +238,16 @@ function handleControlMessage( syncWatchers.get(controlPort)?.delete(data.documentId); } return; + + case "whoami": + void getSubduction().then(() => { + if (!identity) return; + postToPort(controlPort, { + type: "whoami", + ...identity, + } satisfies SyncStateWhoAmIMessage); + }); + return; } } diff --git a/core/bootloader/src/types.ts b/core/bootloader/src/types.ts index 76ac2a6a..575900f2 100644 --- a/core/bootloader/src/types.ts +++ b/core/bootloader/src/types.ts @@ -30,17 +30,18 @@ export interface SyncStateConnectionMessage { * tell which peer rows are "us". `peerId` is `signer.peerId().toString()` (the * value that shows up as a peer id); `verifyingKey` is its hex Ed25519 key. */ -export interface SyncStateWhoAmIMessage { +export type WorkerIdentity = { peerId: string; verifyingKey: string }; + +export interface SyncStateWhoAmIMessage extends WorkerIdentity { type: "whoami"; - peerId: string; - verifyingKey: string; } // What the worker broadcasts on SYNCSTATE_CHANNEL: only the *global* signals // now. Per-document heads are addressed to subscribers over the control port // instead (see SyncStateDocMessage) rather than fanned out to every tab. export type SyncStateBroadcast = - SyncStateConnectionMessage | SyncStateWhoAmIMessage; + | SyncStateConnectionMessage + | SyncStateWhoAmIMessage; /** * Tab → worker: please replay the current global sync signals (whoami + @@ -179,7 +180,9 @@ export interface HandoffAbortMessage { } export type HandoffReplyMessage = - HandoffCachedMessage | HandoffResponseMessage | HandoffAbortMessage; + | HandoffCachedMessage + | HandoffResponseMessage + | HandoffAbortMessage; /** * Automerge worker → world: broadcast once on startup so the service worker @@ -212,11 +215,13 @@ export type SetupServiceWorkerResult = { kill?: () => void; /** Open a classic Automerge sync WebSocket from the automerge worker. */ connectClassicSync: (server?: string) => Promise; - /** Open a repo sync port to the automerge worker, once it says it is ready. */ + /** Open a Subduction port to the subduction worker, once it says it is ready. */ openPort: () => Promise; + /** The subduction worker's own Subduction identity, once its node exists. */ + identity: () => Promise; /** - * Watch for the automerge worker dying and being replaced. Ports held against - * the old instance are stranded; open a fresh one. + * Watch for the subduction worker dying and being replaced. Ports held + * against the old instance are stranded; open a fresh one. */ onRecreated: (listener: () => void) => () => void; /** diff --git a/core/bootloader/src/worker-control.ts b/core/bootloader/src/worker-control.ts index fa35124f..384ac760 100644 --- a/core/bootloader/src/worker-control.ts +++ b/core/bootloader/src/worker-control.ts @@ -4,18 +4,11 @@ // and arrives through `onMessage`. /** A fresh instance means cold in-memory state, so tabs watch this. */ -export const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2); -export const WORKER_BOOT_TIME = Date.now(); +const WORKER_INSTANCE_ID = Math.random().toString(36).slice(2); +const WORKER_BOOT_TIME = Date.now(); const MAX_BUFFER = 200; -export type WorkerControl = { - log: (...args: unknown[]) => void; - debugging: () => boolean; - post: (port: MessagePort, message: unknown) => void; - ports: Set; -}; - export function postToPort(port: MessagePort, message: unknown): void { try { port.postMessage(message); @@ -41,7 +34,7 @@ export function startWorkerControl( onMessage?: (data: any, port: MessagePort, event: MessageEvent) => void; onClose?: (port: MessagePort) => void; } = {} -): WorkerControl { +): { log: (...args: unknown[]) => void } { const ports = new Set(); // Logs emitted before any tab connects (wasm boot) would otherwise be lost. const preConnect: Array<{ level: string; args: string[] }> = []; @@ -130,9 +123,6 @@ export function startWorkerControl( ); return { - ports, - post: postToPort, - debugging: () => debugging, log: (...args: unknown[]) => { if (debugging) console.log(`[${name}]`, ...args); }, diff --git a/core/patchwork/src/index.ts b/core/patchwork/src/index.ts index de0b2faf..df746da7 100644 --- a/core/patchwork/src/index.ts +++ b/core/patchwork/src/index.ts @@ -221,6 +221,7 @@ async function doSetup(options: PatchworkOptions): Promise { sw: { connectClassicSync: sw.connectClassicSync, openPort: sw.openPort, + identity: sw.identity, onRecreated: sw.onRecreated, subscribeSyncState: sw.subscribeSyncState, }, diff --git a/core/patchwork/src/repo.ts b/core/patchwork/src/repo.ts index 641dbc6b..c301478d 100644 --- a/core/patchwork/src/repo.ts +++ b/core/patchwork/src/repo.ts @@ -16,6 +16,7 @@ import { keyhiveStorageName, storagePrefix, } from "@inkandswitch/patchwork-bootloader/storage"; +import type { SetupServiceWorkerResult } from "@inkandswitch/patchwork-bootloader/types"; import type { SignerIdentity } from "./types.js"; import debug from "debug"; @@ -49,10 +50,10 @@ export function initWasm(): Promise { } /** The bit of the bootloader's subduction worker a Repo needs. */ -export type WorkerLink = { - openPort: () => Promise; - onRecreated: (listener: () => void) => () => void; -}; +export type WorkerLink = Pick< + SetupServiceWorkerResult, + "openPort" | "identity" | "onRecreated" +>; export type TabRepo = { repo: Repo; @@ -78,8 +79,10 @@ export async function createRepo(worker: WorkerLink): Promise { peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2), automaticArchiveIngestion: true, cachingMode: "periodic", - // ARK selects the relay via `syncServer`, defaulting to "subduction". + // `syncServer` picks the contact card the hive trusts. The frames go to + // the subduction worker, the tab's only peer, which relays them there. syncServer: syncServer.keyhive, + remotePeerId: (await worker.identity()).peerId as AutomergeRepo.PeerId, repo: { subductionWebsocketEndpoints }, }); log("keyhive setup complete"); diff --git a/core/patchwork/src/types.ts b/core/patchwork/src/types.ts index 9d54ac45..b2297bfe 100644 --- a/core/patchwork/src/types.ts +++ b/core/patchwork/src/types.ts @@ -8,7 +8,10 @@ import type { AccountCreator, AccountDoc, } from "@inkandswitch/patchwork-plugins"; -import type { SyncStateDocMessage } from "@inkandswitch/patchwork-bootloader/types"; +import type { + SyncStateDocMessage, + WorkerIdentity, +} from "@inkandswitch/patchwork-bootloader/types"; import type * as pluginsNS from "@inkandswitch/patchwork-plugins"; export type PluginsApi = typeof pluginsNS; @@ -18,6 +21,7 @@ export type SignerIdentity = { peerId: string; verifyingKey: string }; export interface ServiceWorkerApi { connectClassicSync: (server?: string) => Promise; openPort: () => Promise; + identity: () => Promise; onRecreated: (listener: () => void) => () => void; subscribeSyncState: ( documentId: string, From a10b7f05891d421d39a5524806c0e70b7b472755 Mon Sep 17 00:00:00 2001 From: chee Date: Mon, 14 Sep 2026 21:49:36 +0100 Subject: [PATCH 10/16] topology benches: shared worker vs a subduction node per tab Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SVGX4ASe8nJiNXMhccTzDR --- pnpm-lock.yaml | 34 +++++ sites/bench/.gitignore | 2 + sites/bench/README.md | 45 +++++++ sites/bench/bench.config.ts | 33 +++++ sites/bench/package.json | 26 ++++ sites/bench/src/main.ts | 178 +++++++++++++++++++++++++ sites/bench/tests/bench.ts | 187 +++++++++++++++++++++++++++ sites/bench/tests/boot.spec.ts | 61 +++++++++ sites/bench/tests/churn.spec.ts | 51 ++++++++ sites/bench/tests/global-setup.ts | 8 ++ sites/bench/tests/global-teardown.ts | 40 ++++++ sites/bench/tests/offline.spec.ts | 68 ++++++++++ sites/bench/tests/storage.spec.ts | 101 +++++++++++++++ sites/bench/tests/sync.spec.ts | 69 ++++++++++ sites/bench/tsconfig.json | 13 ++ sites/bench/vite.config.ts | 22 ++++ 16 files changed, 938 insertions(+) create mode 100644 sites/bench/.gitignore create mode 100644 sites/bench/README.md create mode 100644 sites/bench/bench.config.ts create mode 100644 sites/bench/package.json create mode 100644 sites/bench/src/main.ts create mode 100644 sites/bench/tests/bench.ts create mode 100644 sites/bench/tests/boot.spec.ts create mode 100644 sites/bench/tests/churn.spec.ts create mode 100644 sites/bench/tests/global-setup.ts create mode 100644 sites/bench/tests/global-teardown.ts create mode 100644 sites/bench/tests/offline.spec.ts create mode 100644 sites/bench/tests/storage.spec.ts create mode 100644 sites/bench/tests/sync.spec.ts create mode 100644 sites/bench/tsconfig.json create mode 100644 sites/bench/vite.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d56ee47c..255a584f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -406,6 +406,40 @@ importers: specifier: ^5.9.3 version: 5.9.3 + sites/bench: + dependencies: + '@automerge/automerge-repo': + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo-network-broadcastchannel': + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48 + '@automerge/automerge-repo-storage-indexeddb': + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48 + '@automerge/automerge-subduction': + specifier: 0.16.1 + version: 0.16.1 + '@inkandswitch/patchwork': + specifier: workspace:* + version: link:../../core/patchwork + '@inkandswitch/patchwork-bootloader': + specifier: workspace:* + version: link:../../core/bootloader + devDependencies: + '@playwright/test': + specifier: 1.59.1 + version: 1.59.1 + '@types/node': + specifier: 'catalog:' + version: 24.13.3 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: ^7.3.5 + version: 7.3.6(@types/node@24.13.3) + packages: '@automerge/automerge-repo-keyhive@0.5.0-alpha.7': diff --git a/sites/bench/.gitignore b/sites/bench/.gitignore new file mode 100644 index 00000000..bb278d96 --- /dev/null +++ b/sites/bench/.gitignore @@ -0,0 +1,2 @@ +bench-results/ +dist/ diff --git a/sites/bench/README.md b/sites/bench/README.md new file mode 100644 index 00000000..38841a7c --- /dev/null +++ b/sites/bench/README.md @@ -0,0 +1,45 @@ +# bench + +Topology benchmarks for the tab ↔ storage ↔ sync-server arrangement. Not +tests: nothing here gates anything. + +```sh +pnpm --filter patchwork-bench bench # build, run, print the table +pnpm --filter patchwork-bench bench:headed +``` + +Chromium only (`pnpm exec playwright install chromium` once). Talks to the +real sync server named in the site build, so the server columns need network. +Results land in `bench-results/results.md` and `results.jsonl`. + +## Modes + +The page at `/` builds one Repo and nothing else — no shell, no account, no +package list — in one of three shapes, chosen by `?mode=`: + +| mode | storage | server socket | tabs meet via | +| --- | --- | --- | --- | +| `shared` | subduction SharedWorker | one, in the worker | the worker | +| `pertab` | each tab, same IndexedDB | one per tab | the server (or IndexedDB) | +| `pertab-bc` | each tab, same IndexedDB | one per tab | BroadcastChannel classic sync, then the server | + +`shared` is this branch. `?server=none` runs the per-tab modes with no socket. + +## What's measured + +- `boot.spec` — navigation start → `window.repo`, → server connected, for 1/3/10 + tabs; renderer memory (macOS physical footprint via `footprint`, RSS + elsewhere) and process count once they're all up. +- `sync.spec` — find a doc a sibling just created (and whether the first + `find()` settled unavailable); edit → seen in the other tabs; edit → server + holds our heads. Medians over 10 edits. +- `storage.spec` — a second tab finds the first's doc through storage alone; + two tabs edit the same doc and close, does a third see everything. +- `offline.spec` — both tabs edit with the network cut, then it returns. + Per-tab modes only: Playwright's offline emulation cuts a page's own socket + but not a SharedWorker's. +- `churn.spec` — close the tab that booted everything, check the rest still sync. + +Cross-tab timings use epoch milliseconds, since `performance.now()` counts from +each page's own navigation start. `find()` in the helpers retries on +"unavailable" and reports how many tries it took. diff --git a/sites/bench/bench.config.ts b/sites/bench/bench.config.ts new file mode 100644 index 00000000..c782f450 --- /dev/null +++ b/sites/bench/bench.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Benchmarks, not tests: one worker, no retries, chromium only, so the numbers +// come from an otherwise idle browser. `pnpm bench` builds first. +const PORT = Number(process.env.PORT ?? 5199); + +export default defineConfig({ + testDir: "./tests", + timeout: 180_000, + workers: 1, + retries: 0, + fullyParallel: false, + reporter: [["list"]], + globalSetup: "./tests/global-setup.ts", + globalTeardown: "./tests/global-teardown.ts", + outputDir: "bench-results/artifacts", + use: { + ...devices["Desktop Chrome"], + // Full chromium in new-headless mode, not the headless shell: the shell + // lacks measureUserAgentSpecificMemory. + channel: "chromium", + baseURL: `http://localhost:${PORT}`, + serviceWorkers: "allow", + }, + projects: [{ name: "chromium" }], + webServer: { + command: "pnpm preview", + url: `http://localhost:${PORT}`, + timeout: 60_000, + reuseExistingServer: true, + env: { PORT: String(PORT) }, + }, +}); diff --git a/sites/bench/package.json b/sites/bench/package.json new file mode 100644 index 00000000..bbd61278 --- /dev/null +++ b/sites/bench/package.json @@ -0,0 +1,26 @@ +{ + "name": "patchwork-bench", + "private": true, + "type": "module", + "description": "Topology benchmarks: shared subduction worker vs a subduction node per tab.", + "scripts": { + "build": "vite build", + "preview": "vite preview", + "bench": "vite build && playwright test -c bench.config.ts", + "bench:headed": "vite build && playwright test -c bench.config.ts --headed" + }, + "dependencies": { + "@automerge/automerge-repo": "catalog:", + "@automerge/automerge-repo-network-broadcastchannel": "2.6.0-subduction.48", + "@automerge/automerge-repo-storage-indexeddb": "catalog:", + "@automerge/automerge-subduction": "catalog:", + "@inkandswitch/patchwork": "workspace:*", + "@inkandswitch/patchwork-bootloader": "workspace:*" + }, + "devDependencies": { + "@playwright/test": "1.59.1", + "@types/node": "catalog:", + "typescript": "catalog:", + "vite": "^7.3.5" + } +} diff --git a/sites/bench/src/main.ts b/sites/bench/src/main.ts new file mode 100644 index 00000000..c2938bfd --- /dev/null +++ b/sites/bench/src/main.ts @@ -0,0 +1,178 @@ +// A page that builds one Repo in one of three topologies and exposes enough +// for the playwright specs in ../tests to time it. No UI, no account, no +// package list: the shell is out of scope here. +// +// ?mode=shared this branch: storageless tab hanging off the subduction +// SharedWorker, which owns storage and the server socket +// ?mode=pertab no shared workers: a full subduction node in the tab with +// its own socket, all tabs writing the same IndexedDB +// ?mode=pertab-bc pertab, plus classic automerge sync between tabs over a +// BroadcastChannel so siblings don't wait on the server echo +// +// `?server=none` runs the per-tab modes with no socket at all, so tabs can +// only meet through IndexedDB (and the BroadcastChannel). +import { + Repo, + type AutomergeUrl, + type DocHandle, + type DocumentId, + type PeerId, +} from "@automerge/automerge-repo/slim"; +import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; +import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel"; +import { MemorySigner } from "@automerge/automerge-subduction/slim"; +import { createRepo, initWasm } from "@inkandswitch/patchwork"; +import setupServiceWorker from "@inkandswitch/patchwork-bootloader"; +import { SYNCSTATE_CHANNEL } from "@inkandswitch/patchwork-bootloader/types"; + +declare const __SYNC_SERVER__: { url: string }; + +type Mode = "shared" | "pertab" | "pertab-bc"; + +const params = new URLSearchParams(location.search); +const mode = (params.get("mode") ?? "shared") as Mode; +const serverUrl = params.get("server") ?? __SYNC_SERVER__.url; + +const marks: Record = {}; +const mark = (name: string) => (marks[name] = performance.now()); + +// Server heads per document, however this topology learns them. +const serverHeads = new Map(); +let serverPeerIds = new Set(); +let online = Promise.withResolvers(); + +function sameHeads(a: string[], b: string[]): boolean { + return a.length === b.length && a.every((head) => b.includes(head)); +} + +async function build(): Promise { + mark("start"); + await initWasm(); + mark("wasm"); + + if (mode === "shared") { + const sw = await setupServiceWorker(); + if (!sw) throw new Error("no service worker"); + mark("workers"); + const { repo } = await createRepo(sw); + mark("repo"); + + const channel = new BroadcastChannel(SYNCSTATE_CHANNEL); + channel.addEventListener("message", (event) => { + const data = event.data; + if (data?.type !== "connection") return; + serverPeerIds = new Set(data.serverPeerIds); + if (data.connected) online.resolve(performance.now()); + }); + channel.postMessage({ type: "request" }); + + const watched = new Set(); + window.bench.watch = (documentId) => { + if (watched.has(documentId)) return; + watched.add(documentId); + sw.subscribeSyncState(documentId, (update) => { + if (!serverPeerIds.has(update.storageId)) return; + serverHeads.set(documentId, update.heads); + }); + }; + return repo; + } + + const repo = new Repo({ + signer: new MemorySigner(), + storage: new IndexedDBWorkerStorageAdapter(), + peerId: `bench-tab-${crypto.randomUUID()}` as PeerId, + subductionWebsocketEndpoints: serverUrl === "none" ? [] : [serverUrl], + network: + mode === "pertab-bc" + ? [new BroadcastChannelNetworkAdapter({ channelName: "bench" })] + : [], + async sharePolicy() { + return true; + }, + }); + mark("repo"); + + if (serverUrl === "none") online.resolve(performance.now()); + repo.on("subduction-connection", async ({ connected }) => { + if (!connected) return; + serverPeerIds = new Set(await repo.connectedSubductionPeerIds()); + online.resolve(performance.now()); + }); + repo.on("subduction-remote-heads", ({ documentId, storageId, heads }) => { + if (!serverPeerIds.has(storageId)) return; + serverHeads.set(documentId, [...heads]); + }); + return repo; +} + +// `find` settles as unavailable when every source has said no, and a sibling +// tab's brand-new doc may not have reached those sources yet. Retrying is what +// an app would have to do; the attempt count is reported so the benches can +// say how often it was needed. +async function find( + url: string, + timeoutMs = 30_000 +): Promise<{ handle: DocHandle>; attempts: number }> { + const deadline = performance.now() + timeoutMs; + for (let attempts = 1; ; attempts++) { + try { + const handle = await window.repo.find>( + url as AutomergeUrl + ); + await handle.whenReady(); + return { handle, attempts }; + } catch (error) { + if (performance.now() > deadline) throw error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } +} + +window.bench = { + mode, + marks, + find, + watch: () => {}, + online: () => online.promise, + serverPeerIds: () => [...serverPeerIds], + serverHeads: (documentId) => serverHeads.get(documentId), + // Resolves with the epoch time the server was seen holding exactly the heads + // the document has right now. Polled: a few ms of slop is fine here. + async serverConfirmed(url, timeoutMs = 30_000) { + const { handle } = await find(url); + const target = [...handle.heads()]; + window.bench.watch(handle.documentId); + const deadline = performance.now() + timeoutMs; + for (;;) { + const seen = serverHeads.get(handle.documentId); + if (seen && sameHeads(seen, target)) { + return performance.timeOrigin + performance.now(); + } + if (performance.now() > deadline) { + throw new Error(`server never confirmed ${url}: saw ${seen}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }, +}; + +window.repo = await build(); +mark("ready"); +document.body.textContent = `${mode}: ready in ${Math.round(marks.ready - marks.start)}ms`; + +declare global { + interface Window { + repo: Repo; + bench: { + mode: Mode; + marks: Record; + find: typeof find; + watch: (documentId: DocumentId) => void; + online: () => Promise; + serverPeerIds: () => string[]; + serverHeads: (documentId: string) => string[] | undefined; + serverConfirmed: (url: string, timeoutMs?: number) => Promise; + }; + } +} diff --git a/sites/bench/tests/bench.ts b/sites/bench/tests/bench.ts new file mode 100644 index 00000000..234b42aa --- /dev/null +++ b/sites/bench/tests/bench.ts @@ -0,0 +1,187 @@ +import { appendFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import type { Browser, BrowserContext, Page } from "@playwright/test"; + +export type Mode = "shared" | "pertab" | "pertab-bc"; +export const MODES: Mode[] = ["shared", "pertab", "pertab-bc"]; + +export const RESULTS = "bench-results/results.jsonl"; + +export type Result = { + metric: string; + mode: Mode; + tabs?: number; + value: number | boolean; + unit: "ms" | "MB" | "n" | "ok"; +}; + +export function record(result: Result): void { + appendFileSync(RESULTS, JSON.stringify(result) + "\n"); +} + +export function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +export async function openTab( + context: BrowserContext, + mode: Mode, + { server }: { server?: string } = {} +): Promise { + const page = await context.newPage(); + const query = new URLSearchParams({ mode }); + if (server !== undefined) query.set("server", server); + await page.goto(`/?${query}`); + await page.waitForFunction(() => window.repo != null, null, { + timeout: 60_000, + }); + return page; +} + +export function marks(page: Page): Promise> { + return page.evaluate(() => window.bench.marks); +} + +export function online(page: Page): Promise { + return page.evaluate(() => window.bench.online()); +} + +export function createDoc(page: Page, value: object): Promise { + return page.evaluate((value) => { + const handle = window.repo.create>(); + handle.change((d) => Object.assign(d, value)); + return handle.url; + }, value); +} + +/** + * Time until `find()` has the doc in this tab, and how many tries it took: + * more than one means a first find settled as unavailable. + */ +export function timeFind( + page: Page, + url: string +): Promise<{ ms: number; attempts: number }> { + return page.evaluate(async (url) => { + const started = performance.now(); + const { attempts } = await window.bench.find(url); + return { ms: performance.now() - started, attempts }; + }, url); +} + +// Cross-page timings use epoch ms: performance.now() counts from each page's +// own navigation start, so it can't be compared between tabs. + +/** Set a field and return the time the change was made. */ +export function setField( + page: Page, + url: string, + field: string, + value: unknown +): Promise { + return page.evaluate( + async ([url, field, value]) => { + const { handle } = await window.bench.find(url); + handle.change((d) => { + d[field] = value; + }); + return performance.timeOrigin + performance.now(); + }, + [url, field, value] as const + ); +} + +/** Resolves with the time this tab saw `field === value`. */ +export function awaitField( + page: Page, + url: string, + field: string, + value: unknown, + timeoutMs = 30_000 +): Promise { + return page.evaluate( + ([url, field, value, timeoutMs]) => + new Promise(async (resolve, reject) => { + const { handle } = await window.bench.find(url).catch((error) => { + reject(error); + throw error; + }); + const check = () => { + if (handle.doc()?.[field] !== value) return false; + handle.off("change", check); + resolve(performance.timeOrigin + performance.now()); + return true; + }; + if (check()) return; + handle.on("change", check); + setTimeout(() => { + handle.off("change", check); + reject(new Error(`${field} never became ${value}`)); + }, timeoutMs); + }), + [url, field, value, timeoutMs] as const + ); +} + +export function serverConfirmed(page: Page, url: string): Promise { + return page.evaluate((url) => window.bench.serverConfirmed(url), url); +} + +export function getField(page: Page, url: string, field: string): Promise { + return page.evaluate( + async ([url, field]) => { + const { handle } = await window.bench.find(url); + return handle.doc()[field] as T; + }, + [url, field] as const + ); +} + +/** + * Memory of every renderer process in the browser, in MB — tabs, their + * dedicated workers, and the shared workers, wherever Chrome placed them. + * Nothing in a page can see across processes (measureUserAgentSpecificMemory + * only covers the caller's own agent cluster), so the pids come from CDP and + * the sizes from the OS: physical footprint on macOS, the same number Activity + * Monitor shows, and plain RSS elsewhere, which overcounts shared mappings + * per process and so flatters whichever topology has fewer processes. + */ +export async function rendererMemory( + browser: Browser +): Promise<{ mb: number; processes: number }> { + const session = await browser.newBrowserCDPSession(); + const { processInfo } = (await session.send("SystemInfo.getProcessInfo")) as { + processInfo: Array<{ type: string; id: number }>; + }; + await session.detach(); + const pids = processInfo + .filter((process) => process.type === "renderer") + .map((process) => String(process.id)); + if (!pids.length) return { mb: 0, processes: 0 }; + + if (process.platform === "darwin") { + const out = execFileSync("footprint", pids.flatMap((pid) => ["-p", pid]), { + encoding: "utf8", + }); + let mb = 0; + for (const [, size, unit] of out.matchAll( + /phys_footprint:\s+([\d.]+)\s*(KB|MB|GB)/g + )) { + mb += Number(size) * { KB: 1 / 1024, MB: 1, GB: 1024 }[unit]!; + } + return { mb: Math.round(mb), processes: pids.length }; + } + + const rss = execFileSync("ps", ["-o", "rss=", "-p", pids.join(",")], { + encoding: "utf8", + }); + const kb = rss + .split("\n") + .filter(Boolean) + .reduce((sum, line) => sum + Number(line.trim()), 0); + return { mb: Math.round(kb / 1024), processes: pids.length }; +} diff --git a/sites/bench/tests/boot.spec.ts b/sites/bench/tests/boot.spec.ts new file mode 100644 index 00000000..3a607ec8 --- /dev/null +++ b/sites/bench/tests/boot.spec.ts @@ -0,0 +1,61 @@ +import { test } from "@playwright/test"; +import { MODES, marks, online, openTab, record, rendererMemory } from "./bench.js"; + +// Cold boot: navigation start to `window.repo`, then to the server link being +// up (performance.now() is relative to navigation start, so the marks are +// already the numbers wanted). Memory is read once every tab is up. +// The first tab pays everything; later tabs show what a live shared worker +// saves (or doesn't). +for (const mode of MODES) { + for (const tabs of [1, 3, 10]) { + test(`${mode}: boot ${tabs} tab(s)`, async ({ browser, context }) => { + const pages = []; + for (let i = 0; i < tabs; i++) pages.push(await openTab(context, mode)); + + const first = await marks(pages[0]); + record({ + metric: "boot → repo, first tab", + mode, + tabs, + value: first.ready, + unit: "ms", + }); + if (tabs > 1) { + const last = await marks(pages[tabs - 1]); + record({ + metric: "boot → repo, last tab", + mode, + tabs, + value: last.ready, + unit: "ms", + }); + } + record({ + metric: "boot → server connected, first tab", + mode, + tabs, + value: await online(pages[0]), + unit: "ms", + }); + + await Promise.all(pages.map((page) => online(page))); + // Let storage flushes and the first sync rounds settle first. + await pages[0].waitForTimeout(2_000); + const memory = await rendererMemory(browser); + record({ + metric: "renderer memory, all tabs + workers", + mode, + tabs, + value: memory.mb, + unit: "MB", + }); + record({ + metric: "renderer processes", + mode, + tabs, + value: memory.processes, + unit: "n", + }); + }); + } +} diff --git a/sites/bench/tests/churn.spec.ts b/sites/bench/tests/churn.spec.ts new file mode 100644 index 00000000..3294a0e8 --- /dev/null +++ b/sites/bench/tests/churn.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; +import { + MODES, + awaitField, + createDoc, + online, + openTab, + record, + serverConfirmed, + setField, +} from "./bench.js"; + +// Close the tab that booted everything and check the survivors still sync. +// For shared mode that tab spawned the workers; for per-tab modes it owned a +// storage worker mid-write. +for (const mode of MODES) { + test(`${mode}: closing the first tab doesn't strand the rest`, async ({ + context, + }) => { + const first = await openTab(context, mode); + const b = await openTab(context, mode); + const c = await openTab(context, mode); + await Promise.all([online(first), online(b), online(c)]); + const url = await createDoc(first, { n: 0 }); + await awaitField(b, url, "n", 0); + await awaitField(c, url, "n", 0); + + await first.close(); + + const started = Date.now(); + const ok = await Promise.all([ + awaitField(c, url, "n", 1).then(() => true, () => false), + setField(b, url, "n", 1).then(() => serverConfirmed(b, url)).then(() => true, () => false), + ]).then((results) => results.every(Boolean)); + record({ + metric: "sync still works after the first tab closes", + mode, + value: ok, + unit: "ok", + }); + if (ok) { + record({ + metric: "edit → seen + confirmed, after first tab closed", + mode, + value: Date.now() - started, + unit: "ms", + }); + } + expect(ok).toBe(true); + }); +} diff --git a/sites/bench/tests/global-setup.ts b/sites/bench/tests/global-setup.ts new file mode 100644 index 00000000..1093c6e8 --- /dev/null +++ b/sites/bench/tests/global-setup.ts @@ -0,0 +1,8 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { RESULTS } from "./bench.js"; + +export default function globalSetup(): void { + mkdirSync(dirname(RESULTS), { recursive: true }); + writeFileSync(RESULTS, ""); +} diff --git a/sites/bench/tests/global-teardown.ts b/sites/bench/tests/global-teardown.ts new file mode 100644 index 00000000..d2990860 --- /dev/null +++ b/sites/bench/tests/global-teardown.ts @@ -0,0 +1,40 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { MODES, RESULTS, type Result } from "./bench.js"; + +// One row per metric, one column per mode, so the three topologies read side +// by side. Also written to bench-results/results.md. +export default function globalTeardown(): void { + const results: Result[] = readFileSync(RESULTS, "utf8") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + if (!results.length) return; + + const rows = new Map>>(); + for (const { metric, mode, tabs, value, unit } of results) { + const key = tabs === undefined ? metric : `${metric} (${tabs} tabs)`; + const row = rows.get(key) ?? {}; + row[mode] = + unit === "ok" + ? value + ? "ok" + : "FAIL" + : unit === "n" + ? String(value) + : `${Math.round(Number(value))} ${unit}`; + rows.set(key, row); + } + + const header = ["metric", ...MODES]; + const lines = [ + `| ${header.join(" | ")} |`, + `| ${header.map(() => "---").join(" | ")} |`, + ...[...rows].map( + ([key, row]) => + `| ${key} | ${MODES.map((mode) => row[mode] ?? "–").join(" | ")} |` + ), + ]; + const table = lines.join("\n"); + writeFileSync("bench-results/results.md", table + "\n"); + console.log("\n" + table + "\n"); +} diff --git a/sites/bench/tests/offline.spec.ts b/sites/bench/tests/offline.spec.ts new file mode 100644 index 00000000..de3d049a --- /dev/null +++ b/sites/bench/tests/offline.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from "@playwright/test"; +import { + awaitField, + createDoc, + getField, + online, + openTab, + record, + serverConfirmed, + setField, + type Mode, +} from "./bench.js"; + +// Both tabs edit while the network is cut, then it comes back. Playwright's +// offline emulation applies to page targets, so it cuts a socket the page owns +// but not one owned by a SharedWorker — shared mode can't be measured this way +// and is left out rather than reported wrong. +const MODES: Mode[] = ["pertab", "pertab-bc"]; + +for (const mode of MODES) { + test(`${mode}: concurrent offline edits converge on reconnect`, async ({ + context, + }) => { + const a = await openTab(context, mode); + const b = await openTab(context, mode); + await Promise.all([online(a), online(b)]); + const url = await createDoc(a, { x: 0, y: 0 }); + await awaitField(b, url, "x", 0); + await serverConfirmed(a, url); + + await context.setOffline(true); + await setField(a, url, "x", 1); + await setField(b, url, "y", 1); + await a.waitForTimeout(2_000); + await context.setOffline(false); + + const reconnected = Date.now(); + const [seenY, seenX] = await Promise.all([ + awaitField(a, url, "y", 1, 60_000).then(() => true, () => false), + awaitField(b, url, "x", 1, 60_000).then(() => true, () => false), + ]); + const converged = Date.now() - reconnected; + const confirmed = await serverConfirmed(a, url).then(() => true, () => false); + + record({ + metric: "offline edits in two tabs both survive reconnect", + mode, + value: seenY && seenX && confirmed, + unit: "ok", + }); + if (seenY && seenX) { + record({ metric: "reconnect → tabs converged", mode, value: converged, unit: "ms" }); + } + + const c = await openTab(context, mode); + const [x, y] = await Promise.all([ + getField(c, url, "x"), + getField(c, url, "y"), + ]); + expect({ seenY, seenX, confirmed, x, y }).toEqual({ + seenY: true, + seenX: true, + confirmed: true, + x: 1, + y: 1, + }); + }); +} diff --git a/sites/bench/tests/storage.spec.ts b/sites/bench/tests/storage.spec.ts new file mode 100644 index 00000000..efc25cad --- /dev/null +++ b/sites/bench/tests/storage.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test"; +import { + MODES, + createDoc, + getField, + openTab, + record, + setField, + type Mode, +} from "./bench.js"; + +const EDITS = 20; + +// The second-writer question. Per-tab modes run with no server, so a tab can +// only see another's work through the IndexedDB they both write. Shared mode +// keeps its socket (the worker's server is build-time) but tabs there only +// meet through the worker, so the server doesn't help it either. +const server = (mode: Mode) => (mode === "shared" ? undefined : "none"); + +async function flush(page: import("@playwright/test").Page) { + await page.evaluate(() => window.repo.flush()); +} + +for (const mode of MODES) { + test(`${mode}: a doc written by one tab is found by the next`, async ({ + context, + }) => { + const a = await openTab(context, mode, { server: server(mode) }); + const url = await createDoc(a, { n: 0 }); + for (let i = 1; i <= EDITS; i++) await setField(a, url, "n", i); + await flush(a); + + const b = await openTab(context, mode, { server: server(mode) }); + const started = Date.now(); + const seen = await getField(b, url, "n").catch(() => undefined); + record({ + metric: "second tab finds first tab's doc (no server)", + mode, + value: seen === EDITS, + unit: "ok", + }); + if (seen === EDITS) { + record({ + metric: "second tab find, from storage", + mode, + value: Date.now() - started, + unit: "ms", + }); + } + expect(seen).toBe(EDITS); + }); + + // Both tabs close right after their last edit, as a user would. With tabs + // alive the worker ends up with everything (checked separately); this asks + // whether edits still in flight when the tab goes away make it. + test(`${mode}: two tabs write the same doc and close; a third reads it`, async ({ + context, + }) => { + const a = await openTab(context, mode, { server: server(mode) }); + const b = await openTab(context, mode, { server: server(mode) }); + const url = await createDoc(a, { a: 0, b: 0 }); + await flush(a); + await getField(b, url, "a"); + + for (let i = 1; i <= EDITS; i++) { + await Promise.all([ + setField(a, url, "a", i), + setField(b, url, "b", i), + ]); + } + await Promise.all([flush(a), flush(b)]); + await a.close(); + await b.close(); + + // Read once, then again after a pause: the first says whether the doc is + // complete on arrival, the second whether the rest was merely late or is + // gone with the tabs that made it. + const c = await openTab(context, mode, { server: server(mode) }); + const read = () => + Promise.all([ + getField(c, url, "a").catch(() => undefined), + getField(c, url, "b").catch(() => undefined), + ]); + const first = await read(); + await c.waitForTimeout(5_000); + const [fromA, fromB] = await read(); + record({ + metric: "edits from tabs closed right after editing all survive", + mode, + value: first[0] === EDITS && first[1] === EDITS, + unit: "ok", + }); + record({ + metric: "…or at least 5s later", + mode, + value: fromA === EDITS && fromB === EDITS, + unit: "ok", + }); + expect({ fromA, fromB }).toEqual({ fromA: EDITS, fromB: EDITS }); + }); +} diff --git a/sites/bench/tests/sync.spec.ts b/sites/bench/tests/sync.spec.ts new file mode 100644 index 00000000..51359752 --- /dev/null +++ b/sites/bench/tests/sync.spec.ts @@ -0,0 +1,69 @@ +import { test } from "@playwright/test"; +import { + MODES, + awaitField, + createDoc, + median, + online, + openTab, + record, + serverConfirmed, + setField, + timeFind, +} from "./bench.js"; + +const TABS = 3; +const EDITS = 10; + +// Tab A creates and edits; tabs B.. find and watch. Each latency is a median +// over EDITS rounds. +for (const mode of MODES) { + test(`${mode}: cross-tab and server latency`, async ({ context }) => { + const pages = []; + for (let i = 0; i < TABS; i++) pages.push(await openTab(context, mode)); + await Promise.all(pages.map((page) => online(page))); + const [a, ...others] = pages; + + const url = await createDoc(a, { counter: 0 }); + // Per-tab modes with no local fan-out only learn of the doc via the + // server, so the first find includes a server round trip by design. + const finds = await Promise.all(others.map((page) => timeFind(page, url))); + record({ + metric: "find a doc another tab just created", + mode, + value: median(finds.map((find) => find.ms)), + unit: "ms", + }); + record({ + metric: "…and the first find() didn't settle unavailable", + mode, + value: finds.every((find) => find.attempts === 1), + unit: "ok", + }); + + const propagation: number[] = []; + const confirmation: number[] = []; + for (let i = 1; i <= EDITS; i++) { + const seen = others.map((page) => awaitField(page, url, "counter", i)); + const edited = await setField(a, url, "counter", i); + const [arrived, confirmed] = await Promise.all([ + Promise.all(seen), + serverConfirmed(a, url), + ]); + propagation.push(Math.max(...arrived) - edited); + confirmation.push(confirmed - edited); + } + record({ + metric: `edit → seen in ${TABS - 1} other tabs`, + mode, + value: median(propagation), + unit: "ms", + }); + record({ + metric: "edit → server holds our heads", + mode, + value: median(confirmation), + unit: "ms", + }); + }); +} diff --git a/sites/bench/tsconfig.json b/sites/bench/tsconfig.json new file mode 100644 index 00000000..71a34486 --- /dev/null +++ b/sites/bench/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src", "tests", "vite.config.ts", "bench.config.ts"] +} diff --git a/sites/bench/vite.config.ts b/sites/bench/vite.config.ts new file mode 100644 index 00000000..17a1dbeb --- /dev/null +++ b/sites/bench/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import patchwork from "@inkandswitch/patchwork/vite"; + +export default defineConfig({ + plugins: [ + patchwork({ + title: "bench", + storagePrefix: "bench", + manifest: false, + netlify: false, + buildInfo: false, + // Cross-origin isolation unlocks performance.measureUserAgentSpecificMemory, + // which is how the benches attribute memory to workers. + preview: { + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "credentialless", + }, + }, + }), + ], +}); From 958011e41e82cd9bc79ddb549706126e7b1477b6 Mon Sep 17 00:00:00 2001 From: chee Date: Mon, 14 Sep 2026 22:10:07 +0100 Subject: [PATCH 11/16] every repo is its own subduction node The tab holds the origin's IndexedDB and its own socket to the sync server, and meets the other tabs over a BroadcastChannel. The subduction SharedWorker and the port plumbing that fed it are gone; the automerge worker is one more node, kept to resolve automerge: URLs for the service worker. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SVGX4ASe8nJiNXMhccTzDR --- .changeset/subduction-forty-eight.md | 2 +- .changeset/subduction-worker.md | 14 - .changeset/tab-worker-subduction.md | 14 +- core/bootloader/package.json | 14 +- core/bootloader/src/automerge-worker.ts | 107 +++-- core/bootloader/src/externals-list.ts | 5 +- core/bootloader/src/setup.ts | 190 +------- core/bootloader/src/siblings.ts | 47 ++ core/bootloader/src/subduction-worker.ts | 412 ------------------ core/bootloader/src/types.ts | 109 ----- core/bootloader/src/worker-link.ts | 135 ------ core/bootloader/test/setup.ts | 10 - core/bootloader/test/worker-link.test.ts | 127 ------ core/bootloader/vitest.config.ts | 11 - core/patchwork/src/index.ts | 10 +- core/patchwork/src/repo.ts | 47 +- core/patchwork/src/types.ts | 11 - .../src/vite/service-worker-plugin.ts | 7 +- ...__automerge-repo@2.6.0-subduction.48.patch | 39 -- pnpm-lock.yaml | 48 +- pnpm-workspace.yaml | 2 + sites/bench/README.md | 11 +- sites/bench/package.json | 2 +- sites/bench/src/main.ts | 81 ++-- sites/bench/tests/bench.ts | 4 +- sites/bench/tests/boot.spec.ts | 3 +- sites/bench/tests/churn.spec.ts | 4 +- sites/bench/tests/offline.spec.ts | 7 +- sites/bench/tests/storage.spec.ts | 10 +- vitest.config.ts | 5 +- 30 files changed, 246 insertions(+), 1242 deletions(-) delete mode 100644 .changeset/subduction-worker.md create mode 100644 core/bootloader/src/siblings.ts delete mode 100644 core/bootloader/src/subduction-worker.ts delete mode 100644 core/bootloader/src/worker-link.ts delete mode 100644 core/bootloader/test/setup.ts delete mode 100644 core/bootloader/test/worker-link.test.ts delete mode 100644 core/bootloader/vitest.config.ts diff --git a/.changeset/subduction-forty-eight.md b/.changeset/subduction-forty-eight.md index c5f3d20c..c5f45603 100644 --- a/.changeset/subduction-forty-eight.md +++ b/.changeset/subduction-forty-eight.md @@ -5,4 +5,4 @@ "@inkandswitch/patchwork-plugins": patch --- -`@automerge/automerge` goes to `3.4.1`. +`@automerge/automerge` goes to `3.4.1`, and `@automerge/automerge-repo-network-broadcastchannel` joins the automerge-repo family at `2.6.0-subduction.48`. diff --git a/.changeset/subduction-worker.md b/.changeset/subduction-worker.md deleted file mode 100644 index c2fdcfcf..00000000 --- a/.changeset/subduction-worker.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@inkandswitch/patchwork-bootloader": patch -"@inkandswitch/patchwork": patch ---- - -Split the shared worker in two: a Subduction node that owns storage and the sync-server link, and an automerge worker that only resolves `automerge:` URLs. - -automerge-repo now runs only where documents are read: in the tab, and in the automerge worker on the service worker's behalf. Both are storageless nodes hanging off the new subduction worker, which holds this origin's IndexedDB, keeps the WebSocket to the sync server (in-thread now — the websocket proxy worker is gone), and relays documents, edits and ephemeral messages between everything connected to it. - -A SharedWorker can neither spawn nor connect to another SharedWorker, so a tab brokers the link between the two: it opens a port on the subduction worker and donates it to the automerge worker with `donatePort`. - -Sites get a new emitted worker, `subduction-worker.js`; `setupServiceWorker` takes `subductionWorkerPath` alongside `workerPath`. Sync-state subscriptions now come from the subduction worker, which compares its own sedimentree heads against the server's rather than a document's Automerge frontier. - -On a keyhive site the tab's hive addresses the subduction worker rather than the sync server: keyhive frames are point-to-point, so the worker relays them — a tab's to the server, the server's to every tab — without a hive of its own. `setupServiceWorker` returns `identity()` so a tab can find the worker's peer id. The automerge worker has no hive either, so it can't resolve `automerge:` URLs to keyhive-protected documents. diff --git a/.changeset/tab-worker-subduction.md b/.changeset/tab-worker-subduction.md index 33a44b7f..fbbdf763 100644 --- a/.changeset/tab-worker-subduction.md +++ b/.changeset/tab-worker-subduction.md @@ -1,12 +1,14 @@ --- -"@inkandswitch/patchwork-bootloader": patch -"@inkandswitch/patchwork": patch +"@inkandswitch/patchwork-bootloader": minor +"@inkandswitch/patchwork": minor --- -Sync the tab with the automerge SharedWorker over Subduction instead of classic automerge-repo sync. +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 (`connectSiblings` in `@inkandswitch/patchwork-bootloader/siblings`, classic automerge sync, wrapped in the keyhive adapter on keyhive sites). 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. -The tab is now a storageless node: it holds no IndexedDB of its own and gets everything from the worker over a Subduction transport on the repo port. Keyhive sites take the same path: the tab builds its hive with `initializeAutomergeRepoKeyhive`, the subduction-backed one, and syncs keyhive state over the same transport instead of wrapping a classic adapter. +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. -New: `@inkandswitch/patchwork-bootloader/worker-link` exports `MessagePortTransport`, a Subduction transport over a MessagePort, and `WorkerSubductionEndpoint`, which opens one per connection. The tab passes the endpoint as a `subductionWebsocketEndpoint`, so automerge-repo's own reconnect loop replaces the port re-wiring the tab used to do by hand. +Keyhive sites use the subduction-backed hive in both the tab and the worker, each talking to the sync server directly. -The worker handoff on `patchwork.sw` changed with it: `subscribeToRepoChannel(listener)` and `getRepoChannel()` are gone, replaced by `openPort(): Promise` and `onRecreated(listener)`. `createRepo` in `@inkandswitch/patchwork` takes those two rather than a network adapter. +Removed from `setupServiceWorker()`'s result and `patchwork.sw`: `subscribeToRepoChannel`, `getRepoChannel`, `subscribeSyncState`. The `@patchwork/syncstate` BroadcastChannel and its `SyncState*` message types are gone too; a tab's own Repo now has everything they carried — `repo.isSubductionConnected()` and the `subduction-connection` event for the link, `repo.connectedSubductionPeerIds()` for which peers are the server, the `subduction-remote-heads` event and `handle.getSyncInfo()` for per-document heads, and `patchwork.signerIdentity` for this tab's peer id. `createRepo` in `@inkandswitch/patchwork` takes no arguments. + +`@inkandswitch/patchwork-bootloader` depends on `@automerge/automerge-repo-network-broadcastchannel`, which is also on the importmap. diff --git a/core/bootloader/package.json b/core/bootloader/package.json index faba77d9..e0820ecb 100644 --- a/core/bootloader/package.json +++ b/core/bootloader/package.json @@ -26,9 +26,9 @@ "import": "./dist/externals-list.js", "types": "./dist/externals-list.d.ts" }, - "./worker-link": { - "import": "./dist/worker-link.js", - "types": "./dist/worker-link.d.ts" + "./siblings": { + "import": "./dist/siblings.js", + "types": "./dist/siblings.d.ts" }, "./storage": { "import": "./dist/storage.js", @@ -42,10 +42,6 @@ "import": "./dist/automerge-worker.js", "types": "./dist/automerge-worker.d.ts" }, - "./subduction-worker": { - "import": "./dist/subduction-worker.js", - "types": "./dist/subduction-worker.d.ts" - }, "./module-loader": { "import": "./dist/module-loader.js", "types": "./dist/module-loader.d.ts" @@ -64,6 +60,7 @@ "@automerge/automerge": "catalog:", "@automerge/automerge-repo": "catalog:", "@automerge/automerge-repo-keyhive": "catalog:", + "@automerge/automerge-repo-network-broadcastchannel": "catalog:", "@automerge/automerge-repo-network-messagechannel": "catalog:", "@automerge/automerge-repo-network-websocket": "catalog:", "@automerge/automerge-repo-storage-indexeddb": "catalog:", @@ -107,7 +104,6 @@ }, "scripts": { "build": "tsc && cp src/global.css dist/global.css", - "dev": "tsc -w --preserveWatchOutput", - "test": "vitest run" + "dev": "tsc -w --preserveWatchOutput" } } diff --git a/core/bootloader/src/automerge-worker.ts b/core/bootloader/src/automerge-worker.ts index 92381b62..57f4d06d 100644 --- a/core/bootloader/src/automerge-worker.ts +++ b/core/bootloader/src/automerge-worker.ts @@ -2,19 +2,18 @@ // SharedWorker: one instance serves every tab and lives as long as any tab // does. // -// It holds no storage of its own — it is a storageless node hanging off the -// subduction worker, and resolving requests is its whole job. When the service -// worker misses the cache for a request that looks like a URL encoded URL, it -// broadcasts a HandoffRequestMessage on HANDOFF_CHANNEL; we resolve the -// automerge URL, write the response into the service worker's cache (keyed by -// a Request reconstructed to match the one it's holding), and reply on the same -// channel. +// It is a node like any tab's: the same IndexedDB, its own sync-server socket, +// and the siblings channel to the tabs. Resolving requests is its whole job. +// When the service worker misses the cache for a request that looks like a URL +// encoded URL, it broadcasts a HandoffRequestMessage on HANDOFF_CHANNEL; we +// resolve the automerge URL, write the response into the service worker's +// cache (keyed by a Request reconstructed to match the one it's holding), and +// reply on the same channel. import { initializeWasm, hasHeads } from "@automerge/automerge/slim"; // eslint-disable-next-line // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim"; import { MemorySigner } from "@automerge/automerge-subduction/slim"; -import { makePortProvider } from "@automerge/automerge-repo/worker-port"; import { Repo, @@ -27,11 +26,19 @@ import { } from "@automerge/automerge-repo/slim"; import { resolvePath } from "@inkandswitch/patchwork-filesystem"; +import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket"; +import { + initializeAutomergeRepoKeyhive, + initKeyhiveWasm, + type AutomergeRepoKeyhive, + type SyncServerSelection, +} from "@automerge/automerge-repo-keyhive"; import { DEFAULT_CLASSIC_SYNC_SERVER } from "./sync-config.js"; -import { WorkerSubductionEndpoint } from "./worker-link.js"; -import { startWorkerControl, postToPort } from "./worker-control.js"; +import { connectSiblings } from "./siblings.js"; +import { keyhiveStorageName, storagePrefix } from "./storage.js"; +import { startWorkerControl } from "./worker-control.js"; import { HANDOFF_CHANNEL, type HandoffCachedMessage, @@ -41,25 +48,25 @@ import { type HandoffResponseMessage, } from "./types.js"; +declare const __SYNC_SERVER__: { + url: string; + keyhive?: SyncServerSelection; +}; + +const syncServer = + typeof __SYNC_SERVER__ !== "undefined" + ? __SYNC_SERVER__ + : { url: "wss://subduction.sync.inkandswitch.com" }; + const RESOLVE_TIMEOUT_MS = 30_000; const CACHEABLE_STATUSES = [200, 203, 204]; -let link: WorkerSubductionEndpoint | undefined; - const control = startWorkerControl("automerge-worker", { - // The tab side runs donatePort; the messages are channel-tagged so they - // coexist with the control protocol. - onConnect: (port) => linkPortProvider.attachClient(port), onMessage: handleControlMessage, }); const log = control.log; -// A SharedWorker can neither spawn nor connect to another SharedWorker, so a -// tab brokers this worker's link to the subduction worker: it asks for a port -// and donates one. -const linkPortProvider = makePortProvider({ target: "subduction-link" }); - // ── The repo ─────────────────────────────────────────────────────────── let repoPromise: Promise | null = null; @@ -86,18 +93,52 @@ async function buildRepo(): Promise { await initializeWasm(new Uint8Array(automergeWasm)); log("wasm initialized"); - const repo = new Repo({ + const { repo, hive } = syncServer.keyhive + ? await buildKeyhiveRepo(syncServer.keyhive) + : { repo: buildPlainRepo() }; + connectSiblings(repo, hive); + + (self as any).repo = repo; + if (hive) (self as any).hive = hive; + return repo; +} + +function buildPlainRepo(): Repo { + return new Repo({ signer: new MemorySigner(), - peerId: `resolver-${Math.random().toString(36).slice(2)}` as PeerId, - subductionWebsocketEndpoints: [ - (link = new WorkerSubductionEndpoint( - () => linkPortProvider.source() as Promise - )), - ], + storage: new IndexedDBWorkerStorageAdapter(), + peerId: `${storagePrefix}-resolver-${Math.random().toString(36).slice(2)}` as PeerId, + subductionWebsocketEndpoints: [syncServer.url], + enableRemoteHeadsGossiping: true, }); +} - (self as never as { repo: Repo }).repo = repo; - return repo; +async function buildKeyhiveRepo( + keyhiveSyncServer: SyncServerSelection +): Promise<{ repo: Repo; hive: AutomergeRepoKeyhive }> { + initKeyhiveWasm(); + const { hive, repo } = await initializeAutomergeRepoKeyhive({ + createRepo: (config) => new Repo(config), + storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName), + peerIdSuffix: + `${storagePrefix}-resolver` + Math.random().toString(36).slice(2), + automaticArchiveIngestion: true, + cachingMode: "periodic", + // ARK selects the relay via `syncServer`, which pairs the contact card with + // the matching peer id. Omitting it defaults to "subduction". + syncServer: keyhiveSyncServer, + repo: { + storage: new IndexedDBWorkerStorageAdapter(), + subductionWebsocketEndpoints: [syncServer.url], + enableRemoteHeadsGossiping: true, + }, + }); + + hive.networkAdapter.whenReady().then(() => { + (hive.networkAdapter as any).syncKeyhive(); + }); + + return { repo, hive }; } // ── Classic sync ─────────────────────────────────────────────────────── @@ -144,14 +185,6 @@ function handleControlMessage( controlPort: MessagePort, event: MessageEvent ): void { - // The subduction worker died and was replaced: the donated port ends in a - // worker that no longer exists, so drop it and ask for another. - if (data?.type === "link-lost") { - linkPortProvider.invalidate(); - link?.reset(); - return; - } - if (data?.type !== "connect-classic-sync") return; const [replyPort] = event.ports; const server = diff --git a/core/bootloader/src/externals-list.ts b/core/bootloader/src/externals-list.ts index ab823218..7597f675 100644 --- a/core/bootloader/src/externals-list.ts +++ b/core/bootloader/src/externals-list.ts @@ -6,10 +6,7 @@ const externals = [ "@automerge/automerge/slim", "@automerge/automerge-repo", "@automerge/automerge-repo/slim", - // Port-donation plumbing: a tab opens a port on the subduction worker and - // donates it to the automerge worker, since a SharedWorker can neither spawn - // nor connect to another one. See setup.ts/automerge-worker.ts. - "@automerge/automerge-repo/worker-port", + "@automerge/automerge-repo-network-broadcastchannel", "@automerge/automerge-repo-network-messagechannel", "@automerge/automerge-repo-network-websocket", "@automerge/automerge-repo-storage-indexeddb", diff --git a/core/bootloader/src/setup.ts b/core/bootloader/src/setup.ts index 5f713294..3ed2ee81 100644 --- a/core/bootloader/src/setup.ts +++ b/core/bootloader/src/setup.ts @@ -1,19 +1,12 @@ import type { SetupServiceWorkerOptions, SetupServiceWorkerResult, - SyncStateDocMessage, - SyncStateWhoAmIMessage, - WorkerIdentity, } from "./types.js"; import { readClassicSyncServer, DEFAULT_CLASSIC_SYNC_SERVER, } from "./sync-config.js"; import debug from "debug"; -import { - donatePort, - isWorkerErrorMessage, -} from "@automerge/automerge-repo/worker-port"; import { forwardWorkerConsole, lifecycleLog, @@ -63,34 +56,13 @@ function installServiceWorkerLogForwarding(): void { }); } -// ── The two shared workers ───────────────────────────────────────────── -// -// The subduction worker owns this origin's storage and the link to the sync -// server; tabs are peers of it. The automerge worker is a storageless Repo -// whose only job is resolving `automerge:` URLs for the service worker. A -// SharedWorker can neither spawn nor connect to another SharedWorker, so this -// tab brokers the link between them: it opens a port on the subduction worker -// and donates it. +// ── The automerge worker ─────────────────────────────────────────────── +// A SharedWorker holding the Repo that resolves `automerge:` URLs for the +// service worker. Tabs don't sync through it — each tab is its own node — but +// each tab keeps it alive and heartbeats it, so it's here rather than in the +// service worker, which can't own one. -let subductionWorkerPath = "/subduction-worker.js"; let automergeWorkerPath = "/automerge-worker.js"; -let nextPortId = 0; - -const subductionWorker = sharedWorkerHandle( - "patchwork-subduction", - () => subductionWorkerPath, - { - debugging: workerDebugging, - onMessage(event) { - const data = event.data; - if (data?.type === "sync-state") { - dispatchSyncState(data as SyncStateDocMessage); - return; - } - forwardWorkerConsole("subduction-worker", data); - }, - } -); const automergeWorker = sharedWorkerHandle( "patchwork-automerge", @@ -98,148 +70,15 @@ const automergeWorker = sharedWorkerHandle( { debugging: workerDebugging, onMessage(event) { - const data = event.data; - // Crash/skew reports relayed over the port-provision protocol (e.g. a - // mismatch from a stale SW-cached worker chunk). These otherwise only - // exist in chrome://inspect. - if (isWorkerErrorMessage(data)) { - console.error("[automerge-worker]", data); - return; - } - forwardWorkerConsole("automerge-worker", data); - }, - onSpawn(worker) { - // Its Repo asks for this link on first use; `eager` would open a port - // before the worker had booted its wasm. - donatePort(worker.port, () => openPort(), { - target: "subduction-link", - eager: false, - }); + forwardWorkerConsole("automerge-worker", event.data); }, } ); -// The resolver's link ends in a worker that no longer exists, and a dead -// SharedWorker leaves its ports silent rather than closed, so it needs telling. -subductionWorker.onRecreated(() => { - for (const documentId of syncStateListeners.keys()) { - subductionWorker.post({ type: "sync-sub", documentId }); - } - automergeWorker.post({ type: "link-lost" }); -}); - export function getAutomergeWorker(): SharedWorker { return automergeWorker.get(); } -export function getSubductionWorker(): SharedWorker { - return subductionWorker.get(); -} - -/** - * Wait for the worker to confirm it has accepted the port. Nothing on the port - * itself says so: the far side has to fetch wasm and build its node first. - */ -function awaitPortReady(control: MessagePort, id: number): Promise { - return new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timeout); - control.removeEventListener("message", listener); - }; - const listener = (event: MessageEvent) => { - if (event.data?.id !== id) return; - if (event.data.type === "port-ready") { - cleanup(); - resolve(); - } else if (event.data.type === "port-failed") { - cleanup(); - reject(new Error(`subduction worker init failed: ${event.data.error}`)); - } - }; - control.addEventListener("message", listener); - const timeout = setTimeout(() => { - cleanup(); - reject(new Error("subduction worker port-ready timeout")); - }, 30_000); - }); -} - -/** Open a Subduction port to the subduction worker, once it says it is ready. */ -export async function openPort(): Promise { - const id = ++nextPortId; - const worker = subductionWorker.get(); - const ready = awaitPortReady(worker.port, id); - const { port1, port2 } = new MessageChannel(); - worker.port.postMessage({ type: "port", id }, [port2]); - try { - await ready; - } catch (err) { - // Surface the problem and let the rest of the site come up rather than - // hanging on a blank page. - console.warn( - "proceeding without worker ready ack:", - err instanceof Error ? err.message : err - ); - } - return port1; -} - -/** The subduction worker's identity. Stable across restarts: its key is kept in IndexedDB. */ -export function identity(): Promise { - const control = subductionWorker.get().port; - return new Promise((resolve) => { - const listener = (event: MessageEvent) => { - const data = event.data as SyncStateWhoAmIMessage; - if (data?.type !== "whoami") return; - control.removeEventListener("message", listener); - resolve({ peerId: data.peerId, verifyingKey: data.verifyingKey }); - }; - control.addEventListener("message", listener); - control.postMessage({ type: "whoami" }); - }); -} - -// ── Sync state ───────────────────────────────────────────────────────── -// Ref-counted locally so several callers in this tab can watch the same doc -// with a single worker subscription. - -type SyncStateListener = (update: SyncStateDocMessage) => void; -const syncStateListeners = new Map>(); - -function dispatchSyncState(update: SyncStateDocMessage): void { - for (const listener of syncStateListeners.get(update.documentId) ?? []) { - try { - listener(update); - } catch (err) { - console.error("sync-state listener threw", err); - } - } -} - -export function subscribeSyncState( - documentId: string, - listener: SyncStateListener -): () => void { - let listeners = syncStateListeners.get(documentId); - if (!listeners) { - syncStateListeners.set(documentId, (listeners = new Set())); - subductionWorker.post({ type: "sync-sub", documentId }); - } - listeners.add(listener); - - let active = true; - return () => { - if (!active) return; - active = false; - const set = syncStateListeners.get(documentId); - if (!set) return; - set.delete(listener); - if (set.size > 0) return; - syncStateListeners.delete(documentId); - subductionWorker.post({ type: "sync-unsub", documentId }); - }; -} - export function connectClassicSync( server: string = readClassicSyncServer() ): Promise { @@ -303,13 +142,9 @@ export default async function setupServiceWorker( void navigator.storage?.persist?.().catch(() => {}); if (options?.workerPath) automergeWorkerPath = options.workerPath; - if (options?.subductionWorkerPath) { - subductionWorkerPath = options.subductionWorkerPath; - } - // Start both now so they boot wasm while the service worker installs. - const shared = subductionWorker.get(); - automergeWorker.get(); + // Start it now so it boots wasm while the service worker installs. + const shared = automergeWorker.get(); const reg = await navigator.serviceWorker.register( options?.path ?? "/service-worker.js", @@ -344,14 +179,7 @@ export default async function setupServiceWorker( "background: #fcf2f0; color: #333; border: 2px solid; border-radius: 4px" ); - return { - shared, - connectClassicSync, - subscribeSyncState, - openPort, - identity, - onRecreated: subductionWorker.onRecreated, - }; + return { shared, connectClassicSync }; } (window as any).bumpServiceWorkerCache = bumpServiceWorkerCache; diff --git a/core/bootloader/src/siblings.ts b/core/bootloader/src/siblings.ts new file mode 100644 index 00000000..4c44b740 --- /dev/null +++ b/core/bootloader/src/siblings.ts @@ -0,0 +1,47 @@ +import type { AutomergeUrl, Repo } from "@automerge/automerge-repo/slim"; +import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel"; +import type { AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive"; +import { storagePrefix } from "./storage.js"; + +/** + * Every Repo on this origin — each tab's, and the automerge worker's — is a + * full node with its own storage and its own sync-server socket. Siblings + * would still meet through the server, eventually; this joins them over a + * BroadcastChannel with classic automerge sync so an edit in one tab lands in + * the others in the time it takes to post a message, online or not. + * + * On a keyhive site the channel is wrapped in the keyhive adapter, which + * signs and verifies what crosses it. + */ +export function connectSiblings(repo: Repo, hive?: AutomergeRepoKeyhive) { + const channel = new BroadcastChannelNetworkAdapter({ + channelName: `${storagePrefix}-siblings`, + }); + if (!hive) { + repo.networkSubsystem.addNetworkAdapter(channel); + return; + } + + const adapter = hive.createKeyhiveNetworkAdapter(channel, { + onlyShareWithSyncServer: false, + periodicallyRequestSync: false, + syncRequestInterval: 2000, + }); + + adapter.on("message", (msg: any) => { + if (msg.type !== "sync" && msg.type !== "request") return; + if (!msg.documentId) return; + const handle = repo.handles[msg.documentId]; + if (handle && handle.state !== "unavailable") return; + repo.findWithProgress(`automerge:${msg.documentId}` as AutomergeUrl); + repo.shareConfigChanged(); + }); + + (adapter as any).on("ingest-remote", () => { + hive.notifySameAgentKeyhiveChange(); + (hive.networkAdapter as any).syncKeyhive?.(); + repo.shareConfigChanged(); + }); + + repo.networkSubsystem.addNetworkAdapter(adapter); +} diff --git a/core/bootloader/src/subduction-worker.ts b/core/bootloader/src/subduction-worker.ts deleted file mode 100644 index 9900d56b..00000000 --- a/core/bootloader/src/subduction-worker.ts +++ /dev/null @@ -1,412 +0,0 @@ -// The Subduction node for a patchwork site, in a SharedWorker: one instance -// serves every tab and lives as long as any tab does. -// -// It holds this origin's storage and the link to the sync server, and nothing -// else — no Repo, no automerge. Tabs and the automerge worker are peers that -// connect over a MessagePort; a bare Subduction node relays their documents, -// edits and ephemeral messages both to each other and to the server. - -// eslint-disable-next-line -// @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts -import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim"; -import { - Subduction, - WebCryptoSigner, -} from "@automerge/automerge-subduction/slim"; -import { - SubductionStorageBridge, - WebSocketTransport, - encodeHeads, - toDocumentId, -} from "@automerge/automerge-repo/slim"; -import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; -import type { SyncServerSelection } from "@automerge/automerge-repo-keyhive"; - -import { - MessagePortTransport, - WORKER_SUBDUCTION_SERVICE, -} from "./worker-link.js"; -import { startWorkerControl, postToPort } from "./worker-control.js"; -import { - SYNCSTATE_CHANNEL, - type SyncStateBroadcast, - type SyncStateDocMessage, - type SyncStateRequestMessage, - type SyncStateWhoAmIMessage, -} from "./types.js"; - -declare const __SYNC_SERVER__: { - url: string; - keyhive?: SyncServerSelection; -}; - -const syncServer = - typeof __SYNC_SERVER__ !== "undefined" - ? __SYNC_SERVER__ - : { url: "wss://subduction.sync.inkandswitch.com" }; - -const RECONNECT_BASE_MS = 1_000; -const RECONNECT_MAX_MS = 30_000; -const HEADS_SCAN_INTERVAL_MS = 3_000; -const RESYNC_REVIEW_INTERVAL_MS = 5_000; -const RESYNC_GRACE_MS = 8_000; -const RESYNC_INITIAL_DELAY_MS = 5_000; -const RESYNC_MAX_DELAY_MS = 60_000; - -const control = startWorkerControl("subduction-worker", { - onMessage: handleControlMessage, - onClose: (port) => syncWatchers.delete(port), -}); -const log = control.log; - -type Identity = { peerId: string; verifyingKey: string }; - -let identity: Identity | undefined; -let serverPeerIds: string[] = []; -let connected = false; - -// ── The node ─────────────────────────────────────────────────────────── - -let nodePromise: Promise | null = null; - -function getSubduction(): Promise { - if (!nodePromise) { - nodePromise = start(); - // Don't cache a rejection (e.g. the wasm fetch failed): clear the slot so - // the next caller retries from scratch. - nodePromise.catch(() => { - nodePromise = null; - }); - } - return nodePromise; -} - -async function start(): Promise { - log("fetching wasm"); - const wasm = await fetch("/subduction.wasm").then((r) => r.arrayBuffer()); - initSubductionSync(new Uint8Array(wasm)); - log("wasm initialized"); - - const signer = await WebCryptoSigner.setup(); - identity = { - peerId: signer.peerId().toString(), - verifyingKey: ( - signer.verifyingKey() as Uint8Array & { - toHex(): string; - } - ).toHex(), - }; - - const subduction = new Subduction({ - signer: signer as never, - storage: new SubductionStorageBridge( - new IndexedDBWorkerStorageAdapter() - ) as never, - onRemoteHeads: ( - sedimentreeId: { toString(): string; toBytes(): Uint8Array }, - remotePeerId: { toString(): string }, - heads: Array<{ toHexString(): string }> - ) => { - recordHeads( - toDocumentId(sedimentreeId as never), - remotePeerId.toString(), - // bs58check-encoded to match automerge-repo's UrlHeads format, which - // is what a tab compares against its own heads. - [...encodeHeads(heads.map((head) => head.toHexString()) as never)], - Date.now() - ); - }, - }); - - (self as any).subduction = subduction; - (self as any).syncIdentity = identity; - console.log("[patchwork] subduction identity:", identity); - - postWhoAmI(); - if (syncServer.keyhive) relayKeyhiveFrames(subduction); - void serverLoop(subduction); - setInterval(() => void scanOwnHeads(subduction), HEADS_SCAN_INTERVAL_MS); - setInterval(() => void reviewResync(subduction), RESYNC_REVIEW_INTERVAL_MS); - - return subduction; -} - -/** Reconnect loop for the sync server. */ -async function serverLoop(subduction: Subduction): Promise { - const service = new URL(syncServer.url).host; - let backoff = RECONNECT_BASE_MS; - - for (;;) { - let transport: WebSocketTransport | null = null; - try { - transport = await WebSocketTransport.connect(syncServer.url); - const peerId = await subduction.connectTransport(transport, service); - serverPeerIds = [peerId.toString()]; - connected = true; - postConnection(); - log("connected to", syncServer.url); - backoff = RECONNECT_BASE_MS; - await transport.closed(); - log("disconnected from", syncServer.url); - } catch (error) { - console.warn(`[subduction-worker] ${syncServer.url} failed:`, error); - void transport?.disconnect().catch(() => {}); - } - connected = false; - postConnection(); - await new Promise((resolve) => setTimeout(resolve, backoff)); - backoff = Math.min(backoff * 2, RECONNECT_MAX_MS); - } -} - -/** - * Keyhive frames are point-to-point and a bare node doesn't forward them, so - * a tab's hive addresses this worker and the worker passes frames along: a - * tab's go to the server, the server's go to every tab. The hive on each end - * checks signatures and sender ids itself; nothing here reads the payload. - */ -function relayKeyhiveFrames(subduction: Subduction): void { - const isServer = (peerId: { toString(): string }) => - serverPeerIds.includes(peerId.toString()); - void subduction.registerFrameHandler({ - onMessage(payload, from) { - void (async () => { - const fromServer = isServer(from); - for (const peer of await subduction.getConnectedPeerIds()) { - if (isServer(peer) === fromServer) continue; - try { - await subduction.sendKeyhiveMessage(payload, peer); - } catch (error) { - log("relaying a keyhive frame failed", error); - } - } - })(); - }, - onPeerDisconnect() {}, - }); -} - -// ── Tab and worker links ─────────────────────────────────────────────── - -/** - * Resolves once this port is being read, which is all the ack means and all - * the far side can wait for: `acceptTransport` is the responder half of the - * handshake, so it doesn't settle until the other end initiates — and the - * other end doesn't initiate until it has the ack. - */ -async function acceptPort(port: MessagePort): Promise { - const subduction = await getSubduction(); - void subduction - .acceptTransport(new MessagePortTransport(port), WORKER_SUBDUCTION_SERVICE) - .then( - () => log("accepted a peer"), - (error) => console.error("accepting a peer failed", error) - ); -} - -function handleControlMessage( - data: any, - controlPort: MessagePort, - event: MessageEvent -): void { - switch (data?.type) { - case "port": { - const [port] = event.ports; - acceptPort(port).then( - () => postToPort(controlPort, { type: "port-ready", id: data.id }), - (error) => { - console.error("accepting a peer failed", error); - // Tell the tab so it doesn't hang until its timeout. - postToPort(controlPort, { - type: "port-failed", - id: data.id, - error: String(error), - }); - } - ); - return; - } - - case "sync-sub": - if (typeof data.documentId === "string") { - syncSubscribe(controlPort, data.documentId); - } - return; - - case "sync-unsub": - if (typeof data.documentId === "string") { - syncWatchers.get(controlPort)?.delete(data.documentId); - } - return; - - case "whoami": - void getSubduction().then(() => { - if (!identity) return; - postToPort(controlPort, { - type: "whoami", - ...identity, - } satisfies SyncStateWhoAmIMessage); - }); - return; - } -} - -// ── Sync state ───────────────────────────────────────────────────────── -// Only this worker talks to the sync server, so it is the only place that -// learns the server's heads and whether the link is up. Global signals go out -// on SYNCSTATE_CHANNEL so any tab can render an indicator; per-document heads -// are addressed to the tabs that asked for that document. - -type PeerHeads = { heads: string[]; timestamp: number }; -/** documentId -> peer (storageId) -> last-known heads */ -const snapshot = new Map>(); -const syncWatchers = new Map>(); -const channel = new BroadcastChannel(SYNCSTATE_CHANNEL); - -type ResyncEntry = { - serverSig: string; - since: number; - delay: number; - lastResyncAt: number; -}; -const resyncing = new Map(); - -function syncSubscribe(port: MessagePort, documentId: string): void { - let docs = syncWatchers.get(port); - if (!docs) syncWatchers.set(port, (docs = new Set())); - if (docs.has(documentId)) return; - docs.add(documentId); - for (const [storageId, { heads, timestamp }] of snapshot.get(documentId) ?? - []) { - postToPort(port, { - type: "sync-state", - documentId, - storageId, - heads, - timestamp, - } satisfies SyncStateDocMessage); - } -} - -function recordHeads( - documentId: string, - storageId: string, - heads: string[], - timestamp: number -): void { - let byStorage = snapshot.get(documentId); - if (!byStorage) snapshot.set(documentId, (byStorage = new Map())); - byStorage.set(storageId, { heads, timestamp }); - const message: SyncStateDocMessage = { - type: "sync-state", - documentId, - storageId, - heads, - timestamp, - }; - for (const [port, docs] of syncWatchers) { - if (docs.has(documentId)) postToPort(port, message); - } -} - -function postWhoAmI(): void { - if (!identity) return; - channel.postMessage({ - type: "whoami", - peerId: identity.peerId, - verifyingKey: identity.verifyingKey, - } satisfies SyncStateBroadcast); -} - -function postConnection(): void { - channel.postMessage({ - type: "connection", - connected, - serverPeerIds, - } satisfies SyncStateBroadcast); -} - -// A BroadcastChannel never receives its own posts, so this only sees tabs' -// requests. Only the global signals are replayed; a tab gets per-doc heads by -// subscribing. -channel.addEventListener("message", (event: MessageEvent) => { - if ((event.data as SyncStateRequestMessage)?.type !== "request") return; - postWhoAmI(); - postConnection(); -}); - -/** Advertise our own heads for every document we hold. */ -async function scanOwnHeads(subduction: Subduction): Promise { - if (!identity) return; - const now = Date.now(); - for (const entry of await subduction.getAllHeads()) { - recordHeads( - toDocumentId(entry.id as never), - identity.peerId, - [...encodeHeads(entry.heads.map((head) => head.toHexString()) as never)], - now - ); - } -} - -/** - * Nudge documents the server has moved past. Both sides' heads here are - * sedimentree commit ids, so they compare directly — unlike a document's - * Automerge frontier, which is a different thing entirely. - */ -async function reviewResync(subduction: Subduction): Promise { - if (!identity || !connected) { - resyncing.clear(); - return; - } - const now = Date.now(); - - for (const [documentId, byStorage] of snapshot) { - const ours = new Set(byStorage.get(identity.peerId)?.heads ?? []); - const serverHeads = new Set(); - for (const [storageId, entry] of byStorage) { - if (serverPeerIds.includes(storageId)) { - for (const head of entry.heads) serverHeads.add(head); - } - } - if (serverHeads.size === 0) { - resyncing.delete(documentId); - continue; - } - if ([...serverHeads].every((head) => ours.has(head))) { - resyncing.delete(documentId); - continue; - } - - // Behind. Key the grace timer on the server's heads alone, so our own - // edits churning don't keep resetting it. - const serverSig = [...serverHeads].sort().join(","); - const previous = resyncing.get(documentId); - if (!previous || previous.serverSig !== serverSig) { - resyncing.set(documentId, { - serverSig, - since: now, - delay: RESYNC_INITIAL_DELAY_MS, - lastResyncAt: 0, - }); - continue; - } - if (now - previous.since < RESYNC_GRACE_MS) continue; - if (now - previous.lastResyncAt < previous.delay) continue; - - log("re-syncing behind doc", documentId); - previous.lastResyncAt = now; - previous.delay = Math.min(previous.delay * 2, RESYNC_MAX_DELAY_MS); - for (const peerId of serverPeerIds) { - try { - await subduction.fullSyncWithPeer(peerId as never, true); - } catch (error) { - log("fullSyncWithPeer failed", error); - } - } - } -} - -// Start booting now rather than on the first port: wasm and storage hydration -// are the slow part, and a tab connects within milliseconds of spawning us. -void getSubduction(); diff --git a/core/bootloader/src/types.ts b/core/bootloader/src/types.ts index 575900f2..a54976fc 100644 --- a/core/bootloader/src/types.ts +++ b/core/bootloader/src/types.ts @@ -6,90 +6,6 @@ */ export const HANDOFF_CHANNEL = "@patchwork/handoff"; -/** - * BroadcastChannel on which the automerge shared worker announces remote - * heads it learns about from the sync server. Any tab can listen to stay - * informed of sync progress without repo-to-repo gossiping. - */ -export const SYNCSTATE_CHANNEL = "@patchwork/syncstate"; - -/** - * Worker → tabs: the worker's Subduction link to the sync server flipped. - * `serverPeerIds` are the directly-connected sync-server peer ids (their - * verifying keys), so a tab can tell which peer rows are *the server* and - * judge "synced" against them specifically. - */ -export interface SyncStateConnectionMessage { - type: "connection"; - connected: boolean; - serverPeerIds: string[]; -} - -/** - * Worker → tabs: the shared worker's own Subduction identity, so a tab can - * tell which peer rows are "us". `peerId` is `signer.peerId().toString()` (the - * value that shows up as a peer id); `verifyingKey` is its hex Ed25519 key. - */ -export type WorkerIdentity = { peerId: string; verifyingKey: string }; - -export interface SyncStateWhoAmIMessage extends WorkerIdentity { - type: "whoami"; -} - -// What the worker broadcasts on SYNCSTATE_CHANNEL: only the *global* signals -// now. Per-document heads are addressed to subscribers over the control port -// instead (see SyncStateDocMessage) rather than fanned out to every tab. -export type SyncStateBroadcast = - | SyncStateConnectionMessage - | SyncStateWhoAmIMessage; - -/** - * Tab → worker: please replay the current global sync signals (whoami + - * connection) so a freshly-opened tab can orient immediately. Per-document - * heads are no longer replayed here — a tab subscribes to the specific docs it - * cares about over its control port instead (see {@link SyncSubscribeMessage}). - */ -export interface SyncStateRequestMessage { - type: "request"; - /** @deprecated ignored — per-doc state is delivered via sync-sub now. */ - documentId?: string; -} - -// ── Per-tab sync-state subscription (over the SharedWorker control port) ── -// -// The broadcast SyncState* messages above are global (connection/whoami). -// Per-document heads, by contrast, are addressed: a tab subscribes its control -// port to just the documents it cares about and the worker pushes only those -// docs' heads back down that port. The worker drops a port's whole -// subscription set automatically when the port closes (the tab went away), so -// there's no reference counting or heartbeat to leak. - -/** Tab → worker: start pushing me this document's heads (replays current state). */ -export interface SyncSubscribeMessage { - type: "sync-sub"; - documentId: string; -} - -/** Tab → worker: stop pushing me this document's heads. */ -export interface SyncUnsubscribeMessage { - type: "sync-unsub"; - documentId: string; -} - -/** - * Worker → tab (control port): a peer's heads for a subscribed document — the - * worker's own (keyed by its peerId) or a Subduction peer's (keyed by its - * verifying-key storageId). Same payload as the old broadcast remote-heads - * message, but delivered only to the tabs that asked for this document. - */ -export interface SyncStateDocMessage { - type: "sync-state"; - documentId: string; - storageId: string; - heads: string[]; - timestamp: number; -} - /** * The special URL to resolve, plus enough of the {@link Request} the service * worker is holding that the automerge worker can construct one that @@ -203,11 +119,6 @@ export type SetupServiceWorkerOptions = { * Defaults to `/automerge-worker.js` */ workerPath?: string; - /** - * The public path to the subduction shared worker file. - * Defaults to `/subduction-worker.js` - */ - subductionWorkerPath?: string; }; export type SetupServiceWorkerResult = { @@ -215,24 +126,4 @@ export type SetupServiceWorkerResult = { kill?: () => void; /** Open a classic Automerge sync WebSocket from the automerge worker. */ connectClassicSync: (server?: string) => Promise; - /** Open a Subduction port to the subduction worker, once it says it is ready. */ - openPort: () => Promise; - /** The subduction worker's own Subduction identity, once its node exists. */ - identity: () => Promise; - /** - * Watch for the subduction worker dying and being replaced. Ports held - * against the old instance are stranded; open a fresh one. - */ - onRecreated: (listener: () => void) => () => void; - /** - * Watch one document's sync heads (this tab's own and each Subduction peer's, - * as the worker learns them). Calls `listener` on every update for that doc, - * replaying the current state on subscribe. Returns an unsubscribe function; - * the worker stops pushing the doc once the last local watcher drops it (and - * automatically if this tab goes away). - */ - subscribeSyncState: ( - documentId: string, - listener: (update: SyncStateDocMessage) => void - ) => () => void; }; diff --git a/core/bootloader/src/worker-link.ts b/core/bootloader/src/worker-link.ts deleted file mode 100644 index e0a5258c..00000000 --- a/core/bootloader/src/worker-link.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { - ManagedTransport, - WebSocketEndpointInterface, -} from "@automerge/automerge-repo/slim"; - -/** - * The tab ↔ automerge worker link, as a Subduction endpoint. - * - * Subduction derives a service name from the endpoint url's host, and both - * ends have to name the same one, so the url is a fiction with a meaningful - * host rather than a real socket address. - */ -export const WORKER_SUBDUCTION_URL = "ws://patchwork-automerge-worker"; -export const WORKER_SUBDUCTION_SERVICE = new URL(WORKER_SUBDUCTION_URL).host; - -/** A close frame; every other frame is bytes. */ -const BYE = "bye"; - -/** - * A Subduction transport over a MessagePort. Frames are raw ArrayBuffers, so - * the far side needs no protocol beyond this one. - */ -export class MessagePortTransport implements ManagedTransport { - #port: MessagePort; - #queue: Uint8Array[] = []; - #waiters: Array<{ - resolve: (bytes: Uint8Array) => void; - reject: (error: Error) => void; - }> = []; - #closed = false; - #closedResolvers = Promise.withResolvers(); - #onDisconnect: (() => void) | null = null; - - constructor(port: MessagePort) { - this.#port = port; - port.addEventListener("message", (event: MessageEvent) => { - if (this.#closed) return; - if (event.data === BYE) return this.#teardown(true); - const bytes = new Uint8Array(event.data as ArrayBuffer); - const waiter = this.#waiters.shift(); - if (waiter) waiter.resolve(bytes); - else this.#queue.push(bytes); - }); - // Only some browsers fire this, and only for a port whose far side was - // closed or collected; a dead SharedWorker is caught by the heartbeat in - // setup.ts instead. - port.addEventListener("close", () => this.#teardown(true)); - port.start(); - } - - async sendBytes(bytes: Uint8Array): Promise { - if (this.#closed) throw new Error("worker link closed"); - // Copied out of wasm memory, and transferred rather than cloned. - const buffer = bytes.slice().buffer; - this.#port.postMessage(buffer, [buffer]); - } - - recvBytes(): Promise { - const queued = this.#queue.shift(); - if (queued) return Promise.resolve(queued); - if (this.#closed) return Promise.reject(new Error("worker link closed")); - return new Promise((resolve, reject) => - this.#waiters.push({ resolve, reject }) - ); - } - - onDisconnect(callback: () => void): void { - this.#onDisconnect = callback; - } - - async disconnect(): Promise { - if (this.#closed) return; - try { - this.#port.postMessage(BYE); - } catch {} - this.#teardown(false); - } - - /** - * End a link whose far side is gone. Unlike `disconnect`, this reports the - * disconnection to Subduction, so the connection is dropped rather than left - * waiting on a port nobody is reading. - */ - abort(): void { - this.#teardown(true); - } - - /** Resolves when this link ends, however it ends. */ - closed(): Promise { - return this.#closedResolvers.promise; - } - - #teardown(remote: boolean): void { - if (this.#closed) return; - this.#closed = true; - // Dropped rather than delivered: handing frames to the wasm after a - // teardown can dispatch against storage that is going away. - this.#queue = []; - const error = new Error("worker link closed"); - for (const waiter of this.#waiters.splice(0)) waiter.reject(error); - this.#closedResolvers.resolve(); - try { - this.#port.close(); - } catch {} - if (remote) this.#onDisconnect?.(); - } -} - -/** - * Subduction endpoint for the automerge worker. `openPort` is called for every - * (re)connection, so a worker that died and was replaced is picked up by the - * reconnect loop in automerge-repo without any rewiring here. - */ -export class WorkerSubductionEndpoint implements WebSocketEndpointInterface { - readonly url = WORKER_SUBDUCTION_URL; - #openPort: () => Promise; - #live: MessagePortTransport | null = null; - - constructor(openPort: () => Promise) { - this.#openPort = openPort; - } - - async connect(): Promise { - return (this.#live = new MessagePortTransport(await this.#openPort())); - } - - /** - * Drop the current link. A SharedWorker that dies leaves its ports silent - * rather than closed, so the reconnect loop needs telling. - */ - reset(): void { - this.#live?.abort(); - this.#live = null; - } -} diff --git a/core/bootloader/test/setup.ts b/core/bootloader/test/setup.ts deleted file mode 100644 index c8dc9e97..00000000 --- a/core/bootloader/test/setup.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Initialize both Wasm modules before any test runs. -// automerge-repo@subduction.9 always creates a SubductionSource, -// which imports from @automerge/automerge-subduction/slim — the -// Wasm must be initialized first. -// -// Importing the fat entry points auto-calls initSync / UseApi(). -// The vitest.config.ts resolve aliases ensure a single copy is used -// even when automerge-repo is linked locally. -import "@automerge/automerge"; -import "@automerge/automerge-subduction"; diff --git a/core/bootloader/test/worker-link.test.ts b/core/bootloader/test/worker-link.test.ts deleted file mode 100644 index e10b4a10..00000000 --- a/core/bootloader/test/worker-link.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, it, expect, afterEach } from "vitest"; -import { - Repo, - SubductionStorageBridge, - type PeerId, - type AutomergeUrl, -} from "@automerge/automerge-repo"; -import { Subduction, MemorySigner } from "@automerge/automerge-subduction"; -import { DummyStorageAdapter } from "@automerge/automerge-repo/helpers/DummyStorageAdapter.js"; -import { - MessagePortTransport, - WorkerSubductionEndpoint, - WORKER_SUBDUCTION_SERVICE, -} from "../src/worker-link.js"; - -const repos: Repo[] = []; -afterEach(async () => { - await Promise.all(repos.map((r) => r.shutdown().catch(() => {}))); - repos.length = 0; -}); - -function pause(ms: number) { - return new Promise((r) => setTimeout(r, ms)); -} - -/** - * The subduction worker — a bare Subduction node, no Repo — and the Repos that - * hang off it: tabs, and the automerge worker that resolves URLs for the - * service worker. `openPort` stands in for the bootloader's control-port - * handshake. - */ -function site() { - const subduction = new Subduction({ - signer: new MemorySigner(), - storage: new SubductionStorageBridge(new DummyStorageAdapter()) as never, - }); - const accepted: MessagePortTransport[] = []; - - const openPort = async () => { - const { port1, port2 } = new MessageChannel(); - const transport = new MessagePortTransport(port1 as unknown as MessagePort); - accepted.push(transport); - // Not awaited, as in the worker: acceptTransport is the responder half of - // the handshake and only settles once this port's far side initiates. - void subduction.acceptTransport(transport, WORKER_SUBDUCTION_SERVICE); - return port2 as unknown as MessagePort; - }; - - return { - accepted, - node(peerId: string) { - const endpoint = new WorkerSubductionEndpoint(openPort); - const repo = new Repo({ - peerId: peerId as PeerId, - subductionWebsocketEndpoints: [endpoint], - }); - repos.push(repo); - return { repo, endpoint }; - }, - }; -} - -describe("nodes linked through the subduction worker", () => { - it("finds another node's document", async () => { - const { node } = site(); - const tab = node("tab-1").repo; - const resolver = node("resolver").repo; - const created = tab.create({ foo: "bar" }); - await pause(500); - const found = await resolver.find<{ foo: string }>( - created.url as AutomergeUrl - ); - expect(found.doc().foo).toBe("bar"); - }); - - it("propagates edits both ways", async () => { - const { node } = site(); - const a = node("tab-1").repo; - const b = node("tab-2").repo; - const here = a.create<{ n: number }>({ n: 1 }); - await pause(500); - const there = await b.find<{ n: number }>(here.url as AutomergeUrl); - there.change((d) => (d.n = 2)); - await pause(500); - expect(here.doc().n).toBe(2); - here.change((d) => (d.n = 3)); - await pause(500); - expect(there.doc().n).toBe(3); - }); - - it("relays ephemeral messages", async () => { - const { node } = site(); - const a = node("tab-1").repo; - const b = node("tab-2").repo; - const here = a.create<{ n: number }>({ n: 1 }); - await pause(500); - const there = await b.find<{ n: number }>(here.url as AutomergeUrl); - - const seen: unknown[] = []; - there.on("ephemeral-message", ({ message }: { message: unknown }) => - seen.push(message) - ); - await pause(200); - here.broadcast({ hello: "there" }); - await pause(1000); - expect(seen).toEqual([{ hello: "there" }]); - }); - - it("reconnects on a fresh port when the worker is replaced", async () => { - const { node, accepted } = site(); - const tab = node("tab-1"); - const other = node("tab-2").repo; - const before = other.create({ foo: "before" }); - await pause(500); - await tab.repo.find<{ foo: string }>(before.url as AutomergeUrl); - - // What setup.ts does when its heartbeat gives up on the SharedWorker. - tab.endpoint.reset(); - - const after = other.create({ foo: "after" }); - const found = await tab.repo.find<{ foo: string }>( - after.url as AutomergeUrl - ); - expect(found.doc().foo).toBe("after"); - expect(accepted.length).toBe(3); - }); -}); diff --git a/core/bootloader/vitest.config.ts b/core/bootloader/vitest.config.ts deleted file mode 100644 index 5081876b..00000000 --- a/core/bootloader/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - environment: "node", - include: ["test/**/*.{test,spec}.ts"], - testTimeout: 30_000, - setupFiles: ["./test/setup.ts"], - }, -}); diff --git a/core/patchwork/src/index.ts b/core/patchwork/src/index.ts index df746da7..b20fb4bf 100644 --- a/core/patchwork/src/index.ts +++ b/core/patchwork/src/index.ts @@ -130,7 +130,7 @@ async function doSetup(options: PatchworkOptions): Promise { repo = options.repo; hive = options.hive; } else { - ({ repo, hive, signerIdentity } = await createRepo(sw)); + ({ repo, hive, signerIdentity } = await createRepo()); } // Dev-console / tool-runtime globals (e2e and loaded tools read these). The @@ -218,13 +218,7 @@ async function doSetup(options: PatchworkOptions): Promise { signer: signerIdentity, packages: moduleWatcher, plugins, - sw: { - connectClassicSync: sw.connectClassicSync, - openPort: sw.openPort, - identity: sw.identity, - onRecreated: sw.onRecreated, - subscribeSyncState: sw.subscribeSyncState, - }, + sw: { connectClassicSync: sw.connectClassicSync }, async create(type: string, init?: (doc: D) => void) { const datatype = diff --git a/core/patchwork/src/repo.ts b/core/patchwork/src/repo.ts index c301478d..6f7ee24e 100644 --- a/core/patchwork/src/repo.ts +++ b/core/patchwork/src/repo.ts @@ -1,11 +1,11 @@ import { initializeWasm, Repo } from "@automerge/vanillajs/slim"; import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; -import { WorkerSubductionEndpoint } from "@inkandswitch/patchwork-bootloader/worker-link"; +import { connectSiblings } from "@inkandswitch/patchwork-bootloader/siblings"; import * as AutomergeRepo from "@automerge/automerge-repo/slim"; import { initKeyhiveWasm, initializeAutomergeRepoKeyhive, - type AutomergeRepoKeyhiveBase, + type AutomergeRepoKeyhive, type SyncServerSelection, } from "@automerge/automerge-repo-keyhive"; // eslint-disable-next-line @@ -16,7 +16,6 @@ import { keyhiveStorageName, storagePrefix, } from "@inkandswitch/patchwork-bootloader/storage"; -import type { SetupServiceWorkerResult } from "@inkandswitch/patchwork-bootloader/types"; import type { SignerIdentity } from "./types.js"; import debug from "debug"; @@ -49,27 +48,18 @@ export function initWasm(): Promise { return wasmReady; } -/** The bit of the bootloader's subduction worker a Repo needs. */ -export type WorkerLink = Pick< - SetupServiceWorkerResult, - "openPort" | "identity" | "onRecreated" ->; - export type TabRepo = { repo: Repo; - hive?: AutomergeRepoKeyhiveBase; + hive?: AutomergeRepoKeyhive; signerIdentity?: SignerIdentity; }; -export async function createRepo(worker: WorkerLink): Promise { - // The tab is a storageless node: the subduction worker holds the IndexedDB - // and the tab syncs against it over one Subduction transport. - const endpoint = new WorkerSubductionEndpoint(() => worker.openPort()); - // A dead SharedWorker leaves its ports silent rather than closed, so the - // reconnect loop is told to give up on the old one. - worker.onRecreated(() => endpoint.reset()); - const subductionWebsocketEndpoints = [endpoint]; - +/** + * The tab's own node: this origin's IndexedDB, a socket to the sync server, + * and the siblings channel to every other Repo on the origin. Nothing is + * shared with other tabs except the database underneath. + */ +export async function createRepo(): Promise { if (syncServer.keyhive) { log("setting up keyhive"); initKeyhiveWasm(); @@ -79,26 +69,31 @@ export async function createRepo(worker: WorkerLink): Promise { peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2), automaticArchiveIngestion: true, cachingMode: "periodic", - // `syncServer` picks the contact card the hive trusts. The frames go to - // the subduction worker, the tab's only peer, which relays them there. + // ARK selects the relay via `syncServer`, defaulting to "subduction". syncServer: syncServer.keyhive, - remotePeerId: (await worker.identity()).peerId as AutomergeRepo.PeerId, - repo: { subductionWebsocketEndpoints }, + repo: { + storage: new IndexedDBWorkerStorageAdapter(), + subductionWebsocketEndpoints: [syncServer.url], + enableRemoteHeadsGossiping: true, + }, }); + connectSiblings(repo, hive); log("keyhive setup complete"); return { repo, hive }; } // The signer is explicit rather than the Repo's internal default so the - // identity the tab presents in the subduction handshake can be shown on - // window.patchwork. Keyhive supplies its own. + // identity the tab presents to the server can be shown on window.patchwork. const signer = new MemorySigner(); const repo = new Repo({ signer, - subductionWebsocketEndpoints, + storage: new IndexedDBWorkerStorageAdapter(), peerId: `${storagePrefix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId, + subductionWebsocketEndpoints: [syncServer.url], + enableRemoteHeadsGossiping: true, }); + connectSiblings(repo); const signerIdentity = { peerId: signer.peerId().toString(), verifyingKey: ( diff --git a/core/patchwork/src/types.ts b/core/patchwork/src/types.ts index b2297bfe..3d891ed4 100644 --- a/core/patchwork/src/types.ts +++ b/core/patchwork/src/types.ts @@ -8,10 +8,6 @@ import type { AccountCreator, AccountDoc, } from "@inkandswitch/patchwork-plugins"; -import type { - SyncStateDocMessage, - WorkerIdentity, -} from "@inkandswitch/patchwork-bootloader/types"; import type * as pluginsNS from "@inkandswitch/patchwork-plugins"; export type PluginsApi = typeof pluginsNS; @@ -20,13 +16,6 @@ export type SignerIdentity = { peerId: string; verifyingKey: string }; export interface ServiceWorkerApi { connectClassicSync: (server?: string) => Promise; - openPort: () => Promise; - identity: () => Promise; - onRecreated: (listener: () => void) => () => void; - subscribeSyncState: ( - documentId: string, - listener: (update: SyncStateDocMessage) => void - ) => () => void; } export interface OpenOptions { diff --git a/core/patchwork/src/vite/service-worker-plugin.ts b/core/patchwork/src/vite/service-worker-plugin.ts index 0a74a1ab..1dbc16fd 100644 --- a/core/patchwork/src/vite/service-worker-plugin.ts +++ b/core/patchwork/src/vite/service-worker-plugin.ts @@ -9,7 +9,8 @@ import { builtins } from "./importmap-plugin.js"; // own node_modules by bare specifier. const self = fileURLToPath(import.meta.url); -// The service worker and the shared workers are emitted as their own chunks. Their heavy imports are marked external and resolved to +// The service worker and the automerge shared worker are emitted as their +// own chunks. Their heavy imports are marked external and resolved to // /packages/... URLs (both workers are created with type:"module", so the // browser fetches those as regular network requests). export const workers = [ @@ -21,10 +22,6 @@ export const workers = [ specifier: "@inkandswitch/patchwork-bootloader/automerge-worker", fileName: "automerge-worker.js", }, - { - specifier: "@inkandswitch/patchwork-bootloader/subduction-worker", - fileName: "subduction-worker.js", - }, { specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker", fileName: "module-loader-worker.js", diff --git a/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch b/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch index c63d6a32..78f1d6fc 100644 --- a/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch +++ b/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch @@ -1,29 +1,3 @@ -diff --git a/dist/index.d.ts b/dist/index.d.ts -index 1ca836a0b386a69c63e0fa2f655b9f78d5dfe508..20159edec16bc7d67e79ec41359bfd396399757f 100644 ---- a/dist/index.d.ts -+++ b/dist/index.d.ts -@@ -30,6 +30,8 @@ export { isValidAutomergeUrl, isValidDocumentId, parseAutomergeUrl, stringifyAut - export type { ParsedAutomergeUrl, UrlOptions } from "./AutomergeUrl.js"; - export { Repo } from "./Repo.js"; - export { initSubduction } from "./initSubduction.js"; -+export { SubductionStorageBridge } from "./subduction/storage.js"; -+export { toDocumentId, toSedimentreeId } from "./subduction/helpers.js"; - export { makeLogger, resetLoggerFactory, setLoggerFactory } from "./Logger.js"; - export type { Logger, LoggerFactory } from "./Logger.js"; - export { Presence } from "./presence/Presence.js"; -diff --git a/dist/index.js b/dist/index.js -index 07c1a138fa618dd1f0538a490519ad5b2cf85f11..e12d141b953938917a191cabb7deccd31450d2c8 100644 ---- a/dist/index.js -+++ b/dist/index.js -@@ -29,6 +29,8 @@ export { DocHandle } from "./DocHandle.js"; - export { isValidAutomergeUrl, isValidDocumentId, parseAutomergeUrl, stringifyAutomergeUrl, interpretAsDocumentId, documentIdToBinary, generateAutomergeUrl, encodeHeads, decodeHeads, } from "./AutomergeUrl.js"; - export { Repo } from "./Repo.js"; - export { initSubduction } from "./initSubduction.js"; -+export { SubductionStorageBridge } from "./subduction/storage.js"; -+export { toDocumentId, toSedimentreeId } from "./subduction/helpers.js"; - export { makeLogger, resetLoggerFactory, setLoggerFactory } from "./Logger.js"; - export { Presence } from "./presence/Presence.js"; - export { PeerStateView } from "./presence/PeerStateView.js"; diff --git a/dist/subduction/SubductionConnections.js b/dist/subduction/SubductionConnections.js index 4760a1899a8c0127db4840d5de090f6825211d97..5b9d19d4dd1cfb0cb242f140d5401af53aadd6a9 100644 --- a/dist/subduction/SubductionConnections.js @@ -39,19 +13,6 @@ index 4760a1899a8c0127db4840d5de090f6825211d97..5b9d19d4dd1cfb0cb242f140d5401af5 return true; } return false; -diff --git a/src/index.ts b/src/index.ts -index b13005dab37c119a016a8edc03969d3f6abefe1f..e9e1efae6d4c99c4caff55fce7e29dc667a595d3 100644 ---- a/src/index.ts -+++ b/src/index.ts -@@ -41,6 +41,8 @@ export { - export type { ParsedAutomergeUrl, UrlOptions } from "./AutomergeUrl.js" - export { Repo } from "./Repo.js" - export { initSubduction } from "./initSubduction.js" -+export { SubductionStorageBridge } from "./subduction/storage.js" -+export { toDocumentId, toSedimentreeId } from "./subduction/helpers.js" - export { makeLogger, resetLoggerFactory, setLoggerFactory } from "./Logger.js" - export type { Logger, LoggerFactory } from "./Logger.js" - export { Presence } from "./presence/Presence.js" diff --git a/src/subduction/SubductionConnections.ts b/src/subduction/SubductionConnections.ts index 0d5510500c1e3a8e0d84b6f9b5f87f42096ee8e3..6b8376be24f932f21ab3ddc4b76bc81ff39a5030 100644 --- a/src/subduction/SubductionConnections.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 255a584f..7773faed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,7 @@ overrides: '@automerge/automerge': 3.4.1 '@automerge/automerge-repo': 2.6.0-subduction.48 '@automerge/automerge-repo-keyhive': 0.5.0-alpha.7 + '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.48 '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48 '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 '@automerge/automerge-repo-react-hooks': 2.6.0-subduction.48 @@ -49,7 +50,7 @@ overrides: solid-automerge: ^2.0.1 patchedDependencies: - '@automerge/automerge-repo@2.6.0-subduction.48': d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97 + '@automerge/automerge-repo@2.6.0-subduction.48': 279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8 importers: @@ -81,10 +82,13 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-keyhive': specifier: 0.5.0-alpha.7 version: 0.5.0-alpha.7(ws@8.21.1) + '@automerge/automerge-repo-network-broadcastchannel': + specifier: 2.6.0-subduction.48 + version: 2.6.0-subduction.48 '@automerge/automerge-repo-network-messagechannel': specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48 @@ -164,7 +168,7 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-keyhive': specifier: 0.5.0-alpha.7 version: 0.5.0-alpha.7(ws@8.21.1) @@ -209,7 +213,7 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) debug: specifier: ^4.4.3 version: 4.4.3 @@ -240,7 +244,7 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-keyhive': specifier: 0.5.0-alpha.7 version: 0.5.0-alpha.7(ws@8.21.1) @@ -314,7 +318,7 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-keyhive': specifier: 0.5.0-alpha.7 version: 0.5.0-alpha.7(ws@8.21.1) @@ -345,7 +349,7 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 @@ -360,7 +364,7 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -373,7 +377,7 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-react-hooks': specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -395,10 +399,10 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) solid-automerge: specifier: ^2.0.1 - version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97))(solid-js@1.9.14) + version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8))(solid-js@1.9.14) solid-js: specifier: ^1.9.13 version: 1.9.14 @@ -410,7 +414,7 @@ importers: dependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-network-broadcastchannel': specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48 @@ -2033,7 +2037,7 @@ snapshots: '@automerge/automerge-repo-keyhive@0.5.0-alpha.7(ws@8.21.1)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 '@automerge/automerge-subduction': 0.16.1 '@keyhive/keyhive': 0.1.0-alpha.8 @@ -2049,7 +2053,7 @@ snapshots: '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) transitivePeerDependencies: - bufferutil - supports-color @@ -2057,7 +2061,7 @@ snapshots: '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) eventemitter3: 5.0.4 transitivePeerDependencies: - bufferutil @@ -2066,7 +2070,7 @@ snapshots: '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) cbor-x: 1.6.4 debug: 4.4.3 eventemitter3: 5.0.4 @@ -2079,7 +2083,7 @@ snapshots: '@automerge/automerge-repo-react-hooks@2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@automerge/automerge': 3.4.1 - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) eventemitter3: 5.0.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -2090,13 +2094,13 @@ snapshots: '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97)': + '@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8)': dependencies: '@automerge/automerge': 3.4.1 '@automerge/automerge-subduction': 0.16.1 @@ -2120,7 +2124,7 @@ snapshots: '@automerge/vanillajs@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.48 '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48 '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 @@ -3344,9 +3348,9 @@ snapshots: slash@3.0.0: {} - solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97))(solid-js@1.9.14): + solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8))(solid-js@1.9.14): dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=d27dafa43d838cfadf16f5985a31b3910247efbf23e59ce62e46e30a6f10ce97) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) cabbages: 0.2.10 solid-js: 1.9.14 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4e7475b0..59edfb89 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ overrides: "@automerge/automerge": "catalog:" "@automerge/automerge-repo": "catalog:" "@automerge/automerge-repo-keyhive": "catalog:" + "@automerge/automerge-repo-network-broadcastchannel": "catalog:" "@automerge/automerge-repo-network-messagechannel": "catalog:" "@automerge/automerge-repo-network-websocket": "catalog:" "@automerge/automerge-repo-react-hooks": "catalog:" @@ -29,6 +30,7 @@ catalog: "@automerge/automerge": 3.4.1 "@automerge/automerge-repo": 2.6.0-subduction.48 "@automerge/automerge-repo-keyhive": 0.5.0-alpha.7 + "@automerge/automerge-repo-network-broadcastchannel": 2.6.0-subduction.48 "@automerge/automerge-repo-network-messagechannel": 2.6.0-subduction.48 "@automerge/automerge-repo-network-websocket": 2.6.0-subduction.48 "@automerge/automerge-repo-react-hooks": 2.6.0-subduction.48 diff --git a/sites/bench/README.md b/sites/bench/README.md index 38841a7c..a989afb9 100644 --- a/sites/bench/README.md +++ b/sites/bench/README.md @@ -19,11 +19,14 @@ package list — in one of three shapes, chosen by `?mode=`: | mode | storage | server socket | tabs meet via | | --- | --- | --- | --- | -| `shared` | subduction SharedWorker | one, in the worker | the worker | +| `patchwork` | each tab, same IndexedDB | one per tab | the siblings BroadcastChannel, then the server | | `pertab` | each tab, same IndexedDB | one per tab | the server (or IndexedDB) | -| `pertab-bc` | each tab, same IndexedDB | one per tab | BroadcastChannel classic sync, then the server | +| `pertab-bc` | each tab, same IndexedDB | one per tab | a hand-rolled BroadcastChannel, then the server | -`shared` is this branch. `?server=none` runs the per-tab modes with no socket. +`patchwork` is `createRepo()` as shipped, plus the automerge worker that +resolves URLs for the service worker; the other two are bare Repos built in +the page, kept as the baseline the shipped path is measured against. +`?server=none` runs the bare modes with no socket. ## What's measured @@ -36,8 +39,6 @@ package list — in one of three shapes, chosen by `?mode=`: - `storage.spec` — a second tab finds the first's doc through storage alone; two tabs edit the same doc and close, does a third see everything. - `offline.spec` — both tabs edit with the network cut, then it returns. - Per-tab modes only: Playwright's offline emulation cuts a page's own socket - but not a SharedWorker's. - `churn.spec` — close the tab that booted everything, check the rest still sync. Cross-tab timings use epoch milliseconds, since `performance.now()` counts from diff --git a/sites/bench/package.json b/sites/bench/package.json index bbd61278..81140f5a 100644 --- a/sites/bench/package.json +++ b/sites/bench/package.json @@ -11,7 +11,7 @@ }, "dependencies": { "@automerge/automerge-repo": "catalog:", - "@automerge/automerge-repo-network-broadcastchannel": "2.6.0-subduction.48", + "@automerge/automerge-repo-network-broadcastchannel": "catalog:", "@automerge/automerge-repo-storage-indexeddb": "catalog:", "@automerge/automerge-subduction": "catalog:", "@inkandswitch/patchwork": "workspace:*", diff --git a/sites/bench/src/main.ts b/sites/bench/src/main.ts index c2938bfd..549c8929 100644 --- a/sites/bench/src/main.ts +++ b/sites/bench/src/main.ts @@ -2,20 +2,22 @@ // for the playwright specs in ../tests to time it. No UI, no account, no // package list: the shell is out of scope here. // -// ?mode=shared this branch: storageless tab hanging off the subduction -// SharedWorker, which owns storage and the server socket -// ?mode=pertab no shared workers: a full subduction node in the tab with -// its own socket, all tabs writing the same IndexedDB -// ?mode=pertab-bc pertab, plus classic automerge sync between tabs over a -// BroadcastChannel so siblings don't wait on the server echo +// ?mode=patchwork what patchwork does: createRepo() — the tab's own +// subduction node with this origin's IndexedDB, its own +// server socket and the siblings BroadcastChannel — plus +// the automerge worker that resolves URLs for the service +// worker +// ?mode=pertab the bare node: storage + socket, no siblings channel, no +// workers, so tabs only meet through the server (or the +// database) +// ?mode=pertab-bc pertab plus the siblings channel, hand-rolled // -// `?server=none` runs the per-tab modes with no socket at all, so tabs can -// only meet through IndexedDB (and the BroadcastChannel). +// `?server=none` runs the bare modes with no socket at all, so tabs can only +// meet through IndexedDB (and the BroadcastChannel). import { Repo, type AutomergeUrl, type DocHandle, - type DocumentId, type PeerId, } from "@automerge/automerge-repo/slim"; import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; @@ -23,23 +25,21 @@ import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-networ import { MemorySigner } from "@automerge/automerge-subduction/slim"; import { createRepo, initWasm } from "@inkandswitch/patchwork"; import setupServiceWorker from "@inkandswitch/patchwork-bootloader"; -import { SYNCSTATE_CHANNEL } from "@inkandswitch/patchwork-bootloader/types"; declare const __SYNC_SERVER__: { url: string }; -type Mode = "shared" | "pertab" | "pertab-bc"; +type Mode = "patchwork" | "pertab" | "pertab-bc"; const params = new URLSearchParams(location.search); -const mode = (params.get("mode") ?? "shared") as Mode; +const mode = (params.get("mode") ?? "patchwork") as Mode; const serverUrl = params.get("server") ?? __SYNC_SERVER__.url; const marks: Record = {}; const mark = (name: string) => (marks[name] = performance.now()); -// Server heads per document, however this topology learns them. const serverHeads = new Map(); let serverPeerIds = new Set(); -let online = Promise.withResolvers(); +const online = Promise.withResolvers(); function sameHeads(a: string[], b: string[]): boolean { return a.length === b.length && a.every((head) => b.includes(head)); @@ -50,47 +50,25 @@ async function build(): Promise { await initWasm(); mark("wasm"); - if (mode === "shared") { + let repo: Repo; + if (mode === "patchwork") { const sw = await setupServiceWorker(); if (!sw) throw new Error("no service worker"); mark("workers"); - const { repo } = await createRepo(sw); - mark("repo"); - - const channel = new BroadcastChannel(SYNCSTATE_CHANNEL); - channel.addEventListener("message", (event) => { - const data = event.data; - if (data?.type !== "connection") return; - serverPeerIds = new Set(data.serverPeerIds); - if (data.connected) online.resolve(performance.now()); + ({ repo } = await createRepo()); + } else { + repo = new Repo({ + signer: new MemorySigner(), + storage: new IndexedDBWorkerStorageAdapter(), + peerId: `bench-tab-${crypto.randomUUID()}` as PeerId, + subductionWebsocketEndpoints: serverUrl === "none" ? [] : [serverUrl], + network: + mode === "pertab-bc" + ? [new BroadcastChannelNetworkAdapter({ channelName: "bench" })] + : [], + enableRemoteHeadsGossiping: true, }); - channel.postMessage({ type: "request" }); - - const watched = new Set(); - window.bench.watch = (documentId) => { - if (watched.has(documentId)) return; - watched.add(documentId); - sw.subscribeSyncState(documentId, (update) => { - if (!serverPeerIds.has(update.storageId)) return; - serverHeads.set(documentId, update.heads); - }); - }; - return repo; } - - const repo = new Repo({ - signer: new MemorySigner(), - storage: new IndexedDBWorkerStorageAdapter(), - peerId: `bench-tab-${crypto.randomUUID()}` as PeerId, - subductionWebsocketEndpoints: serverUrl === "none" ? [] : [serverUrl], - network: - mode === "pertab-bc" - ? [new BroadcastChannelNetworkAdapter({ channelName: "bench" })] - : [], - async sharePolicy() { - return true; - }, - }); mark("repo"); if (serverUrl === "none") online.resolve(performance.now()); @@ -133,7 +111,6 @@ window.bench = { mode, marks, find, - watch: () => {}, online: () => online.promise, serverPeerIds: () => [...serverPeerIds], serverHeads: (documentId) => serverHeads.get(documentId), @@ -142,7 +119,6 @@ window.bench = { async serverConfirmed(url, timeoutMs = 30_000) { const { handle } = await find(url); const target = [...handle.heads()]; - window.bench.watch(handle.documentId); const deadline = performance.now() + timeoutMs; for (;;) { const seen = serverHeads.get(handle.documentId); @@ -168,7 +144,6 @@ declare global { mode: Mode; marks: Record; find: typeof find; - watch: (documentId: DocumentId) => void; online: () => Promise; serverPeerIds: () => string[]; serverHeads: (documentId: string) => string[] | undefined; diff --git a/sites/bench/tests/bench.ts b/sites/bench/tests/bench.ts index 234b42aa..c0ec413f 100644 --- a/sites/bench/tests/bench.ts +++ b/sites/bench/tests/bench.ts @@ -2,8 +2,8 @@ import { appendFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import type { Browser, BrowserContext, Page } from "@playwright/test"; -export type Mode = "shared" | "pertab" | "pertab-bc"; -export const MODES: Mode[] = ["shared", "pertab", "pertab-bc"]; +export type Mode = "patchwork" | "pertab" | "pertab-bc"; +export const MODES: Mode[] = ["patchwork", "pertab", "pertab-bc"]; export const RESULTS = "bench-results/results.jsonl"; diff --git a/sites/bench/tests/boot.spec.ts b/sites/bench/tests/boot.spec.ts index 3a607ec8..30c09c81 100644 --- a/sites/bench/tests/boot.spec.ts +++ b/sites/bench/tests/boot.spec.ts @@ -4,8 +4,7 @@ import { MODES, marks, online, openTab, record, rendererMemory } from "./bench.j // Cold boot: navigation start to `window.repo`, then to the server link being // up (performance.now() is relative to navigation start, so the marks are // already the numbers wanted). Memory is read once every tab is up. -// The first tab pays everything; later tabs show what a live shared worker -// saves (or doesn't). +// The first tab pays everything; later tabs show what a warm origin saves. for (const mode of MODES) { for (const tabs of [1, 3, 10]) { test(`${mode}: boot ${tabs} tab(s)`, async ({ browser, context }) => { diff --git a/sites/bench/tests/churn.spec.ts b/sites/bench/tests/churn.spec.ts index 3294a0e8..54f0b8b6 100644 --- a/sites/bench/tests/churn.spec.ts +++ b/sites/bench/tests/churn.spec.ts @@ -11,8 +11,8 @@ import { } from "./bench.js"; // Close the tab that booted everything and check the survivors still sync. -// For shared mode that tab spawned the workers; for per-tab modes it owned a -// storage worker mid-write. +// In patchwork mode that tab spawned the automerge worker; in every mode it +// owned a storage worker mid-write. for (const mode of MODES) { test(`${mode}: closing the first tab doesn't strand the rest`, async ({ context, diff --git a/sites/bench/tests/offline.spec.ts b/sites/bench/tests/offline.spec.ts index de3d049a..032e0373 100644 --- a/sites/bench/tests/offline.spec.ts +++ b/sites/bench/tests/offline.spec.ts @@ -12,10 +12,9 @@ import { } from "./bench.js"; // Both tabs edit while the network is cut, then it comes back. Playwright's -// offline emulation applies to page targets, so it cuts a socket the page owns -// but not one owned by a SharedWorker — shared mode can't be measured this way -// and is left out rather than reported wrong. -const MODES: Mode[] = ["pertab", "pertab-bc"]; +// offline emulation applies to page targets, which covers every mode now that +// each tab owns its socket. +const MODES: Mode[] = ["patchwork", "pertab", "pertab-bc"]; for (const mode of MODES) { test(`${mode}: concurrent offline edits converge on reconnect`, async ({ diff --git a/sites/bench/tests/storage.spec.ts b/sites/bench/tests/storage.spec.ts index efc25cad..ddf6dc64 100644 --- a/sites/bench/tests/storage.spec.ts +++ b/sites/bench/tests/storage.spec.ts @@ -11,11 +11,11 @@ import { const EDITS = 20; -// The second-writer question. Per-tab modes run with no server, so a tab can -// only see another's work through the IndexedDB they both write. Shared mode -// keeps its socket (the worker's server is build-time) but tabs there only -// meet through the worker, so the server doesn't help it either. -const server = (mode: Mode) => (mode === "shared" ? undefined : "none"); +// The second-writer question. The bare modes run with no server, so a tab can +// only see another's work through the IndexedDB they both write. The patchwork +// mode's server is build-time, so it keeps its socket; the siblings channel is +// what carries the edits there, and closing tabs tests storage all the same. +const server = (mode: Mode) => (mode === "patchwork" ? undefined : "none"); async function flush(page: import("@playwright/test").Page) { await page.evaluate(() => window.repo.flush()); diff --git a/vitest.config.ts b/vitest.config.ts index 68805f10..cd65ed38 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - workspace: ["core/bootloader", "core/filesystem", "packages/edge-handles"], + workspace: [ + "core/filesystem", + "packages/edge-handles", + ], }, }); From b30fb4f32351d17a6cace9a5a41e5d8f33bf4e1d Mon Sep 17 00:00:00 2001 From: chee Date: Tue, 15 Sep 2026 00:16:03 +0100 Subject: [PATCH 12/16] subscribeSyncState over the local repo Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SVGX4ASe8nJiNXMhccTzDR --- .changeset/tab-worker-subduction.md | 2 +- core/patchwork/src/index.ts | 54 ++++++++++++++++++++++++++++- core/patchwork/src/types.ts | 17 +++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/.changeset/tab-worker-subduction.md b/.changeset/tab-worker-subduction.md index fbbdf763..6c842695 100644 --- a/.changeset/tab-worker-subduction.md +++ b/.changeset/tab-worker-subduction.md @@ -9,6 +9,6 @@ Benchmarked against the shared-worker arrangement (`sites/bench`): boot and memo Keyhive sites use the subduction-backed hive in both the tab and the worker, each talking to the sync server directly. -Removed from `setupServiceWorker()`'s result and `patchwork.sw`: `subscribeToRepoChannel`, `getRepoChannel`, `subscribeSyncState`. The `@patchwork/syncstate` BroadcastChannel and its `SyncState*` message types are gone too; a tab's own Repo now has everything they carried — `repo.isSubductionConnected()` and the `subduction-connection` event for the link, `repo.connectedSubductionPeerIds()` for which peers are the server, the `subduction-remote-heads` event and `handle.getSyncInfo()` for per-document heads, and `patchwork.signerIdentity` for this tab's peer id. `createRepo` in `@inkandswitch/patchwork` takes no arguments. +`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. `@inkandswitch/patchwork-bootloader` depends on `@automerge/automerge-repo-network-broadcastchannel`, which is also on the importmap. diff --git a/core/patchwork/src/index.ts b/core/patchwork/src/index.ts index b20fb4bf..f80d1682 100644 --- a/core/patchwork/src/index.ts +++ b/core/patchwork/src/index.ts @@ -20,6 +20,8 @@ import { type AutomergeUrl, type DocHandle, + type DocumentId, + type StorageId, Repo, } from "@automerge/vanillajs/slim"; import * as Automerge from "@automerge/automerge/slim"; @@ -51,6 +53,7 @@ import type { Patchwork, PatchworkOptions, SignerIdentity, + SyncStateDocMessage, } from "./types.js"; import { createRepo, initWasm } from "./repo.js"; import { createRouter, type Router } from "./router.js"; @@ -218,7 +221,11 @@ async function doSetup(options: PatchworkOptions): Promise { signer: signerIdentity, packages: moduleWatcher, plugins, - sw: { connectClassicSync: sw.connectClassicSync }, + sw: { + connectClassicSync: sw.connectClassicSync, + subscribeSyncState: (documentId, listener) => + subscribeSyncState(repo, documentId, listener), + }, async create(type: string, init?: (doc: D) => void) { const datatype = @@ -388,6 +395,50 @@ function installLifecycleLogging(): void { ); } +// The tab's own Repo hears the server's heads directly, so this is a filter +// over its `subduction-remote-heads` event. The current value is replayed +// from the handle's sync info, when the server has reported any. +function subscribeSyncState( + repo: Repo, + documentId: string, + listener: (update: SyncStateDocMessage) => void +): () => void { + const onHeads = (payload: { + documentId: string; + storageId: string; + heads: readonly string[]; + timestamp: number; + }) => { + if (payload.documentId !== documentId) return; + listener({ + type: "sync-state", + documentId, + storageId: payload.storageId, + heads: [...payload.heads], + timestamp: payload.timestamp, + }); + }; + repo.on("subduction-remote-heads", onHeads); + + void (async () => { + const handle = repo.handles[documentId as DocumentId]; + if (!handle) return; + for (const storageId of await repo.connectedSubductionPeerIds()) { + const info = handle.getSyncInfo(storageId as StorageId); + if (info) { + onHeads({ + documentId, + storageId, + heads: info.lastHeads, + timestamp: info.lastSyncTimestamp, + }); + } + } + })(); + + return () => repo.off("subduction-remote-heads", onHeads); +} + // ── Named exports ──────────────────────────────────────────────────────── export { createRepo, initWasm } from "./repo.js"; @@ -403,4 +454,5 @@ export type { PatchworkOptions, ServiceWorkerApi, SignerIdentity, + SyncStateDocMessage, } from "./types.js"; diff --git a/core/patchwork/src/types.ts b/core/patchwork/src/types.ts index 3d891ed4..be36c33f 100644 --- a/core/patchwork/src/types.ts +++ b/core/patchwork/src/types.ts @@ -14,8 +14,25 @@ export type PluginsApi = typeof pluginsNS; export type SignerIdentity = { peerId: string; verifyingKey: string }; +/** The sync server's last-known heads for one document, as it reports them. */ +export interface SyncStateDocMessage { + type: "sync-state"; + documentId: string; + storageId: string; + heads: string[]; + timestamp: number; +} + export interface ServiceWorkerApi { connectClassicSync: (server?: string) => Promise; + /** + * Watch one document's heads at the sync server. Calls `listener` with + * what the server holds now, then on every update. Returns unsubscribe. + */ + subscribeSyncState: ( + documentId: string, + listener: (update: SyncStateDocMessage) => void + ) => () => void; } export interface OpenOptions { From 2f3566154ab2da9b597688294e02faa73ba9313c Mon Sep 17 00:00:00 2001 From: chee Date: Tue, 15 Sep 2026 00:20:17 +0100 Subject: [PATCH 13/16] rename shared-worker.ts to shared-worker-lifecycle.ts Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SVGX4ASe8nJiNXMhccTzDR --- core/bootloader/src/setup.ts | 2 +- .../src/{shared-worker.ts => shared-worker-lifecycle.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename core/bootloader/src/{shared-worker.ts => shared-worker-lifecycle.ts} (100%) diff --git a/core/bootloader/src/setup.ts b/core/bootloader/src/setup.ts index 3ed2ee81..5d076442 100644 --- a/core/bootloader/src/setup.ts +++ b/core/bootloader/src/setup.ts @@ -11,7 +11,7 @@ import { forwardWorkerConsole, lifecycleLog, sharedWorkerHandle, -} from "./shared-worker.js"; +} from "./shared-worker-lifecycle.js"; export { lifecycleLog }; diff --git a/core/bootloader/src/shared-worker.ts b/core/bootloader/src/shared-worker-lifecycle.ts similarity index 100% rename from core/bootloader/src/shared-worker.ts rename to core/bootloader/src/shared-worker-lifecycle.ts From 6519bf1e2aa84cdd2a6ebb72bccee4e60f10ada9 Mon Sep 17 00:00:00 2001 From: chee Date: Tue, 15 Sep 2026 12:39:41 +0100 Subject: [PATCH 14/16] rename automerge-protocol-handler-worker --- .changeset/tab-worker-subduction.md | 2 ++ core/bootloader/package.json | 6 ++--- ...s => automerge-protocol-handler-worker.ts} | 5 ++-- core/bootloader/src/setup.ts | 27 ++++++++++--------- core/bootloader/src/types.ts | 6 ++--- core/patchwork/src/index.ts | 7 ++--- core/patchwork/src/site-kit/sync-servers.ts | 7 ++--- core/patchwork/src/types.ts | 3 ++- core/patchwork/src/vite/config-plugin.ts | 19 +++++-------- .../src/vite/service-worker-plugin.ts | 5 ++-- packages/e2e/README.md | 3 ++- 11 files changed, 47 insertions(+), 43 deletions(-) rename core/bootloader/src/{automerge-worker.ts => automerge-protocol-handler-worker.ts} (98%) diff --git a/.changeset/tab-worker-subduction.md b/.changeset/tab-worker-subduction.md index 6c842695..47d897e9 100644 --- a/.changeset/tab-worker-subduction.md +++ b/.changeset/tab-worker-subduction.md @@ -12,3 +12,5 @@ Keyhive sites use the subduction-backed hive in both the tab and the worker, eac `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. `@inkandswitch/patchwork-bootloader` depends on `@automerge/automerge-repo-network-broadcastchannel`, which is also on the importmap. + +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()`. diff --git a/core/bootloader/package.json b/core/bootloader/package.json index e0820ecb..6e80524e 100644 --- a/core/bootloader/package.json +++ b/core/bootloader/package.json @@ -38,9 +38,9 @@ "import": "./dist/service-worker.js", "types": "./dist/service-worker.d.ts" }, - "./automerge-worker": { - "import": "./dist/automerge-worker.js", - "types": "./dist/automerge-worker.d.ts" + "./automerge-protocol-handler-worker": { + "import": "./dist/automerge-protocol-handler-worker.js", + "types": "./dist/automerge-protocol-handler-worker.d.ts" }, "./module-loader": { "import": "./dist/module-loader.js", diff --git a/core/bootloader/src/automerge-worker.ts b/core/bootloader/src/automerge-protocol-handler-worker.ts similarity index 98% rename from core/bootloader/src/automerge-worker.ts rename to core/bootloader/src/automerge-protocol-handler-worker.ts index 57f4d06d..1fdd9cfa 100644 --- a/core/bootloader/src/automerge-worker.ts +++ b/core/bootloader/src/automerge-protocol-handler-worker.ts @@ -62,7 +62,7 @@ const RESOLVE_TIMEOUT_MS = 30_000; const CACHEABLE_STATUSES = [200, 203, 204]; -const control = startWorkerControl("automerge-worker", { +const control = startWorkerControl("automerge-protocol-handler-worker", { onMessage: handleControlMessage, }); const log = control.log; @@ -107,7 +107,8 @@ function buildPlainRepo(): Repo { return new Repo({ signer: new MemorySigner(), storage: new IndexedDBWorkerStorageAdapter(), - peerId: `${storagePrefix}-resolver-${Math.random().toString(36).slice(2)}` as PeerId, + peerId: + `${storagePrefix}-resolver-${Math.random().toString(36).slice(2)}` as PeerId, subductionWebsocketEndpoints: [syncServer.url], enableRemoteHeadsGossiping: true, }); diff --git a/core/bootloader/src/setup.ts b/core/bootloader/src/setup.ts index 5d076442..2d512d36 100644 --- a/core/bootloader/src/setup.ts +++ b/core/bootloader/src/setup.ts @@ -62,21 +62,22 @@ function installServiceWorkerLogForwarding(): void { // each tab keeps it alive and heartbeats it, so it's here rather than in the // service worker, which can't own one. -let automergeWorkerPath = "/automerge-worker.js"; +let automergeProtocolHandlerWorkerPath = + "/automerge-protocol-handler-worker.js"; -const automergeWorker = sharedWorkerHandle( - "patchwork-automerge", - () => automergeWorkerPath, +const automergeProtocolHandlerWorker = sharedWorkerHandle( + "patchwork-automerge-protocol-handler", + () => automergeProtocolHandlerWorkerPath, { debugging: workerDebugging, onMessage(event) { - forwardWorkerConsole("automerge-worker", event.data); + forwardWorkerConsole("automerge-protocol-handler-worker", event.data); }, } ); -export function getAutomergeWorker(): SharedWorker { - return automergeWorker.get(); +export function getAutomergeProtocolHandlerWorker(): SharedWorker { + return automergeProtocolHandlerWorker.get(); } export function connectClassicSync( @@ -102,9 +103,10 @@ export function connectClassicSync( else reject(new Error(event.data?.error ?? "connect-classic-sync failed")); }; - automergeWorker.post({ type: "connect-classic-sync", server: url }, [ - port2, - ]); + automergeProtocolHandlerWorker.post( + { type: "connect-classic-sync", server: url }, + [port2] + ); }); } @@ -141,10 +143,11 @@ export default async function setupServiceWorker( // default eviction. void navigator.storage?.persist?.().catch(() => {}); - if (options?.workerPath) automergeWorkerPath = options.workerPath; + if (options?.workerPath) + automergeProtocolHandlerWorkerPath = options.workerPath; // Start it now so it boots wasm while the service worker installs. - const shared = automergeWorker.get(); + const shared = automergeProtocolHandlerWorker.get(); const reg = await navigator.serviceWorker.register( options?.path ?? "/service-worker.js", diff --git a/core/bootloader/src/types.ts b/core/bootloader/src/types.ts index a54976fc..d14fd7d8 100644 --- a/core/bootloader/src/types.ts +++ b/core/bootloader/src/types.ts @@ -96,9 +96,7 @@ export interface HandoffAbortMessage { } export type HandoffReplyMessage = - | HandoffCachedMessage - | HandoffResponseMessage - | HandoffAbortMessage; + HandoffCachedMessage | HandoffResponseMessage | HandoffAbortMessage; /** * Automerge worker → world: broadcast once on startup so the service worker @@ -116,7 +114,7 @@ export type SetupServiceWorkerOptions = { path?: string; /** * The public path to the automerge shared worker file. - * Defaults to `/automerge-worker.js` + * Defaults to `/automerge-protocol-handler-worker.js` */ workerPath?: string; }; diff --git a/core/patchwork/src/index.ts b/core/patchwork/src/index.ts index f80d1682..f35a1ab9 100644 --- a/core/patchwork/src/index.ts +++ b/core/patchwork/src/index.ts @@ -1,8 +1,9 @@ /** * One import for a Patchwork site. * - * `setup(options)` constructs the Repo, wires up the automerge-worker port, - * loads plugins via the ModuleWatcher, resolves the user's account document, + * `setup(options)` constructs the Repo, wires up the + * automerge-protocol-handler-worker port, loads plugins via the + * ModuleWatcher, resolves the user's account document, * installs the router, and resolves with the site's runtime API — `repo`, * `create`, `open`, `find`, `packages`, `plugins`, `sw` — which is what a * site assigns to `window.patchwork`. @@ -15,7 +16,7 @@ * Pulls in DOM- and plugin-layer dependencies, so it is for a browser site's * `main.ts` only. Non-UI consumers should import * `@inkandswitch/patchwork-bootloader` directly, which does SW registration - * and the automerge-worker handoff and nothing else. + * and the automerge-protocol-handler-worker handoff and nothing else. */ import { type AutomergeUrl, diff --git a/core/patchwork/src/site-kit/sync-servers.ts b/core/patchwork/src/site-kit/sync-servers.ts index 98fb5d93..da3927bf 100644 --- a/core/patchwork/src/site-kit/sync-servers.ts +++ b/core/patchwork/src/site-kit/sync-servers.ts @@ -2,9 +2,10 @@ import type { PatchworkSiteOptions } from "./options.js"; import type { SyncServerSelection } from "@automerge/automerge-repo-keyhive"; // Mirrors core/bootloader/src/sync-config.ts's DEFAULT_CLASSIC_SYNC_SERVER -// and automerge-worker.ts's SUBDUCTION_SYNC_URL selection — kept here as -// plain constants (rather than importing those runtime modules) since this -// only needs the hostnames, not the browser-only logic that reads them. +// and automerge-protocol-handler-worker.ts's SUBDUCTION_SYNC_URL selection — +// kept here as plain constants (rather than importing those runtime modules) +// since this only needs the hostnames, not the browser-only logic that reads +// them. export const DEFAULT_SYNC_SERVERS = { classic: "wss://sync3.automerge.org", subduction: "wss://subduction.sync.inkandswitch.com", diff --git a/core/patchwork/src/types.ts b/core/patchwork/src/types.ts index be36c33f..ffdc70f0 100644 --- a/core/patchwork/src/types.ts +++ b/core/patchwork/src/types.ts @@ -97,7 +97,8 @@ export interface PatchworkOptions { /** * Bring your own Repo. When provided, setup skips wasm initialization and * repo creation entirely — you are responsible for having initialized - * automerge/subduction and wired the automerge-worker port yourself. + * automerge/subduction and wired the automerge-protocol-handler-worker + * port yourself. */ repo?: Repo; diff --git a/core/patchwork/src/vite/config-plugin.ts b/core/patchwork/src/vite/config-plugin.ts index ed9e61e5..5ee5c4dd 100644 --- a/core/patchwork/src/vite/config-plugin.ts +++ b/core/patchwork/src/vite/config-plugin.ts @@ -40,9 +40,7 @@ export function buildDefines( * used to hand-write in its own vite.config.ts. Each is switched off * individually via the matching `false` option. */ -export function config( - options: PatchworkVitePluginOptions = {} -): Plugin { +export function config(options: PatchworkVitePluginOptions = {}): Plugin { return { name: "@patchwork/config", config() { @@ -87,11 +85,11 @@ export function config( }, }; }, - // The shared automerge-worker's chunk imports bypass the page's service - // worker, so offline boot needs the browser's HTTP cache to serve them - // without revalidating. Content hashes make that safe; a new build gets - // new URLs. Production gets this from the generated _headers file — vite - // preview doesn't read that, so mirror it here. + // The shared automerge-protocol-handler-worker's chunk imports bypass the + // page's service worker, so offline boot needs the browser's HTTP cache to + // serve them without revalidating. Content hashes make that safe; a new + // build gets new URLs. Production gets this from the generated _headers + // file — vite preview doesn't read that, so mirror it here. configurePreviewServer(server) { if ( options.netlify === false || @@ -101,10 +99,7 @@ export function config( } server.middlewares.use((req, res, next) => { if (req.url?.startsWith("/assets/")) { - res.setHeader( - "Cache-Control", - "public, max-age=31536000, immutable" - ); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } next(); }); diff --git a/core/patchwork/src/vite/service-worker-plugin.ts b/core/patchwork/src/vite/service-worker-plugin.ts index 1dbc16fd..90750744 100644 --- a/core/patchwork/src/vite/service-worker-plugin.ts +++ b/core/patchwork/src/vite/service-worker-plugin.ts @@ -19,8 +19,9 @@ export const workers = [ fileName: "service-worker.js", }, { - specifier: "@inkandswitch/patchwork-bootloader/automerge-worker", - fileName: "automerge-worker.js", + specifier: + "@inkandswitch/patchwork-bootloader/automerge-protocol-handler-worker", + fileName: "automerge-protocol-handler-worker.js", }, { specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker", diff --git a/packages/e2e/README.md b/packages/e2e/README.md index 7dde9b31..7509361b 100644 --- a/packages/e2e/README.md +++ b/packages/e2e/README.md @@ -118,7 +118,8 @@ lookup missed entries when the request was the wasm offline boot 503'd — it now falls back to a url-keyed match — and hashed `/assets/*` get `Cache-Control: immutable` (netlify `_headers` + a preview middleware) so the browser's HTTP cache can serve the shared -automerge-worker's chunk imports offline, which bypass the page's SW. +automerge-protocol-handler-worker's chunk imports offline, which bypass the +page's SW. Heads-up: repeated full-suite runs can get the machine's IP temporarily rate-limited by netlify (the full-UI tests fetch the whole base module From cef7293cf1967df7f5c1f684c948bcbbf4c29f61 Mon Sep 17 00:00:00 2001 From: chee Date: Tue, 15 Sep 2026 13:02:48 +0100 Subject: [PATCH 15/16] add scary sibling code --- .changeset/tab-worker-subduction.md | 6 +- .../src/automerge-protocol-handler-worker.ts | 5 +- core/bootloader/src/siblings.ts | 119 +++++++++++++----- core/patchwork/src/repo.ts | 6 +- 4 files changed, 97 insertions(+), 39 deletions(-) diff --git a/.changeset/tab-worker-subduction.md b/.changeset/tab-worker-subduction.md index 47d897e9..408a0a67 100644 --- a/.changeset/tab-worker-subduction.md +++ b/.changeset/tab-worker-subduction.md @@ -3,7 +3,7 @@ "@inkandswitch/patchwork": minor --- -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 (`connectSiblings` in `@inkandswitch/patchwork-bootloader/siblings`, classic automerge sync, wrapped in the keyhive adapter on keyhive sites). 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. +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. 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. @@ -14,3 +14,7 @@ Keyhive sites use the subduction-backed hive in both the tab and the worker, eac `@inkandswitch/patchwork-bootloader` depends on `@automerge/automerge-repo-network-broadcastchannel`, which is also on the importmap. 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()`. + +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. + +Subduction's handshake has an initiator and a responder, and a BroadcastChannel is a mesh, so the channel is presented to the Repo as two adapters over one BroadcastChannel: a `"connect"` half that surfaces only peers whose peer id sorts above this node's, and an `"accept"` half for the rest. Both ends of a pair agree on which speaks first. diff --git a/core/bootloader/src/automerge-protocol-handler-worker.ts b/core/bootloader/src/automerge-protocol-handler-worker.ts index 1fdd9cfa..690b01f3 100644 --- a/core/bootloader/src/automerge-protocol-handler-worker.ts +++ b/core/bootloader/src/automerge-protocol-handler-worker.ts @@ -36,7 +36,7 @@ import { } from "@automerge/automerge-repo-keyhive"; import { DEFAULT_CLASSIC_SYNC_SERVER } from "./sync-config.js"; -import { connectSiblings } from "./siblings.js"; +import { siblingAdapters } from "./siblings.js"; import { keyhiveStorageName, storagePrefix } from "./storage.js"; import { startWorkerControl } from "./worker-control.js"; import { @@ -96,7 +96,6 @@ async function buildRepo(): Promise { const { repo, hive } = syncServer.keyhive ? await buildKeyhiveRepo(syncServer.keyhive) : { repo: buildPlainRepo() }; - connectSiblings(repo, hive); (self as any).repo = repo; if (hive) (self as any).hive = hive; @@ -110,6 +109,7 @@ function buildPlainRepo(): Repo { peerId: `${storagePrefix}-resolver-${Math.random().toString(36).slice(2)}` as PeerId, subductionWebsocketEndpoints: [syncServer.url], + subductionAdapters: siblingAdapters(), enableRemoteHeadsGossiping: true, }); } @@ -131,6 +131,7 @@ async function buildKeyhiveRepo( repo: { storage: new IndexedDBWorkerStorageAdapter(), subductionWebsocketEndpoints: [syncServer.url], + subductionAdapters: siblingAdapters(), enableRemoteHeadsGossiping: true, }, }); diff --git a/core/bootloader/src/siblings.ts b/core/bootloader/src/siblings.ts index 4c44b740..21231333 100644 --- a/core/bootloader/src/siblings.ts +++ b/core/bootloader/src/siblings.ts @@ -1,47 +1,100 @@ -import type { AutomergeUrl, Repo } from "@automerge/automerge-repo/slim"; +import { NetworkAdapter } from "@automerge/automerge-repo/slim"; +import type { + Message, + NetworkAdapterInterface, + PeerId, + PeerMetadata, + RepoConfig, +} from "@automerge/automerge-repo/slim"; import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel"; -import type { AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive"; import { storagePrefix } from "./storage.js"; +type SubductionAdapters = NonNullable; + /** - * Every Repo on this origin — each tab's, and the automerge worker's — is a - * full node with its own storage and its own sync-server socket. Siblings - * would still meet through the server, eventually; this joins them over a - * BroadcastChannel with classic automerge sync so an edit in one tab lands in - * the others in the time it takes to post a message, online or not. + * Every Repo on this origin — each tab's, and the automerge protocol handler + * worker's — is a full Subduction node with its own storage and its own + * sync-server socket. Siblings would still meet through the server, + * eventually; this meets them over a BroadcastChannel so an edit in one tab + * lands in the others in the time it takes to post a message, online or not. + * + * What crosses the channel is Subduction: transport frames between two nodes, + * authenticated by each one's signer. No classic automerge sync runs here, so + * the adapters go to `new Repo({ subductionAdapters })` rather than to the + * network subsystem. * - * On a keyhive site the channel is wrapped in the keyhive adapter, which - * signs and verifies what crosses it. + * Subduction's handshake has an initiator and a responder, but a + * BroadcastChannel is a mesh in which every node sees every other one. So a + * single channel is presented as two adapters: the connecting half surfaces + * only the peers whose peer id sorts above ours, the accepting half only + * those below. Both ends of any pair agree on which of them speaks first. */ -export function connectSiblings(repo: Repo, hive?: AutomergeRepoKeyhive) { +export function siblingAdapters(): SubductionAdapters { + const serviceName = `${storagePrefix}-siblings`; const channel = new BroadcastChannelNetworkAdapter({ - channelName: `${storagePrefix}-siblings`, + channelName: serviceName, }); - if (!hive) { - repo.networkSubsystem.addNetworkAdapter(channel); - return; + const shared: SharedChannel = { channel }; + return [ + { adapter: new SiblingHalf(shared, true), serviceName, role: "connect" }, + { adapter: new SiblingHalf(shared, false), serviceName, role: "accept" }, + ]; +} + +type SharedChannel = { + channel: NetworkAdapterInterface; + peerId?: PeerId; + connected?: boolean; +}; + +class SiblingHalf extends NetworkAdapter { + #shared: SharedChannel; + #initiate: boolean; + + constructor(shared: SharedChannel, initiate: boolean) { + super(); + this.#shared = shared; + this.#initiate = initiate; + const { channel } = shared; + channel.on("message", (message) => this.emit("message", message)); + channel.on("peer-disconnected", (peer) => + this.emit("peer-disconnected", peer) + ); + channel.on("close", () => this.emit("close")); + channel.on("peer-candidate", (peer) => { + if (shared.peerId! < peer.peerId === this.#initiate) { + this.emit("peer-candidate", peer); + } + }); } - const adapter = hive.createKeyhiveNetworkAdapter(channel, { - onlyShareWithSyncServer: false, - periodicallyRequestSync: false, - syncRequestInterval: 2000, - }); + state() { + return this.#shared.channel.state(); + } - adapter.on("message", (msg: any) => { - if (msg.type !== "sync" && msg.type !== "request") return; - if (!msg.documentId) return; - const handle = repo.handles[msg.documentId]; - if (handle && handle.state !== "unavailable") return; - repo.findWithProgress(`automerge:${msg.documentId}` as AutomergeUrl); - repo.shareConfigChanged(); - }); + isReady() { + return this.#shared.channel.isReady(); + } - (adapter as any).on("ingest-remote", () => { - hive.notifySameAgentKeyhiveChange(); - (hive.networkAdapter as any).syncKeyhive?.(); - repo.shareConfigChanged(); - }); + whenReady() { + return this.#shared.channel.whenReady(); + } + + connect(peerId: PeerId, peerMetadata?: PeerMetadata) { + this.peerId = peerId; + this.#shared.peerId = peerId; + if (this.#shared.connected) return; + this.#shared.connected = true; + this.#shared.channel.connect(peerId, peerMetadata); + } + + send(message: Message) { + this.#shared.channel.send(message); + } - repo.networkSubsystem.addNetworkAdapter(adapter); + disconnect() { + if (!this.#shared.connected) return; + this.#shared.connected = false; + this.#shared.channel.disconnect(); + } } diff --git a/core/patchwork/src/repo.ts b/core/patchwork/src/repo.ts index 6f7ee24e..868d2550 100644 --- a/core/patchwork/src/repo.ts +++ b/core/patchwork/src/repo.ts @@ -1,6 +1,6 @@ import { initializeWasm, Repo } from "@automerge/vanillajs/slim"; import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter"; -import { connectSiblings } from "@inkandswitch/patchwork-bootloader/siblings"; +import { siblingAdapters } from "@inkandswitch/patchwork-bootloader/siblings"; import * as AutomergeRepo from "@automerge/automerge-repo/slim"; import { initKeyhiveWasm, @@ -74,10 +74,10 @@ export async function createRepo(): Promise { repo: { storage: new IndexedDBWorkerStorageAdapter(), subductionWebsocketEndpoints: [syncServer.url], + subductionAdapters: siblingAdapters(), enableRemoteHeadsGossiping: true, }, }); - connectSiblings(repo, hive); log("keyhive setup complete"); return { repo, hive }; } @@ -91,9 +91,9 @@ export async function createRepo(): Promise { peerId: `${storagePrefix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId, subductionWebsocketEndpoints: [syncServer.url], + subductionAdapters: siblingAdapters(), enableRemoteHeadsGossiping: true, }); - connectSiblings(repo); const signerIdentity = { peerId: signer.peerId().toString(), verifyingKey: ( From a26a1d28934af6b52baf5224fe9bb95b10efbd64 Mon Sep 17 00:00:00 2001 From: chee Date: Tue, 15 Sep 2026 14:24:42 +0100 Subject: [PATCH 16/16] add 'mesh' role that auto chooses a role based on peerid comparison --- .changeset/tab-worker-subduction.md | 2 +- core/bootloader/src/siblings.ts | 86 ++------------ ...__automerge-repo@2.6.0-subduction.48.patch | 107 ++++++++++++++++++ pnpm-lock.yaml | 44 +++---- 4 files changed, 139 insertions(+), 100 deletions(-) diff --git a/.changeset/tab-worker-subduction.md b/.changeset/tab-worker-subduction.md index 408a0a67..2960c15c 100644 --- a/.changeset/tab-worker-subduction.md +++ b/.changeset/tab-worker-subduction.md @@ -17,4 +17,4 @@ That worker is renamed for what it now does: `@inkandswitch/patchwork-bootloader 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. -Subduction's handshake has an initiator and a responder, and a BroadcastChannel is a mesh, so the channel is presented to the Repo as two adapters over one BroadcastChannel: a `"connect"` half that surfaces only peers whose peer id sorts above this node's, and an `"accept"` half for the rest. Both ends of a pair agree on which speaks first. +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. diff --git a/core/bootloader/src/siblings.ts b/core/bootloader/src/siblings.ts index 21231333..9843aafe 100644 --- a/core/bootloader/src/siblings.ts +++ b/core/bootloader/src/siblings.ts @@ -1,11 +1,4 @@ -import { NetworkAdapter } from "@automerge/automerge-repo/slim"; -import type { - Message, - NetworkAdapterInterface, - PeerId, - PeerMetadata, - RepoConfig, -} from "@automerge/automerge-repo/slim"; +import type { RepoConfig } from "@automerge/automerge-repo/slim"; import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel"; import { storagePrefix } from "./storage.js"; @@ -23,78 +16,17 @@ type SubductionAdapters = NonNullable; * the adapters go to `new Repo({ subductionAdapters })` rather than to the * network subsystem. * - * Subduction's handshake has an initiator and a responder, but a - * BroadcastChannel is a mesh in which every node sees every other one. So a - * single channel is presented as two adapters: the connecting half surfaces - * only the peers whose peer id sorts above ours, the accepting half only - * those below. Both ends of any pair agree on which of them speaks first. + * A BroadcastChannel is a mesh in which every node sees every other one, and + * Subduction's handshake has an initiator and a responder, so the role is + * "mesh": for each pair, the node whose peer id sorts lower speaks first. */ export function siblingAdapters(): SubductionAdapters { const serviceName = `${storagePrefix}-siblings`; - const channel = new BroadcastChannelNetworkAdapter({ - channelName: serviceName, - }); - const shared: SharedChannel = { channel }; return [ - { adapter: new SiblingHalf(shared, true), serviceName, role: "connect" }, - { adapter: new SiblingHalf(shared, false), serviceName, role: "accept" }, + { + adapter: new BroadcastChannelNetworkAdapter({ channelName: serviceName }), + serviceName, + role: "mesh", + }, ]; } - -type SharedChannel = { - channel: NetworkAdapterInterface; - peerId?: PeerId; - connected?: boolean; -}; - -class SiblingHalf extends NetworkAdapter { - #shared: SharedChannel; - #initiate: boolean; - - constructor(shared: SharedChannel, initiate: boolean) { - super(); - this.#shared = shared; - this.#initiate = initiate; - const { channel } = shared; - channel.on("message", (message) => this.emit("message", message)); - channel.on("peer-disconnected", (peer) => - this.emit("peer-disconnected", peer) - ); - channel.on("close", () => this.emit("close")); - channel.on("peer-candidate", (peer) => { - if (shared.peerId! < peer.peerId === this.#initiate) { - this.emit("peer-candidate", peer); - } - }); - } - - state() { - return this.#shared.channel.state(); - } - - isReady() { - return this.#shared.channel.isReady(); - } - - whenReady() { - return this.#shared.channel.whenReady(); - } - - connect(peerId: PeerId, peerMetadata?: PeerMetadata) { - this.peerId = peerId; - this.#shared.peerId = peerId; - if (this.#shared.connected) return; - this.#shared.connected = true; - this.#shared.channel.connect(peerId, peerMetadata); - } - - send(message: Message) { - this.#shared.channel.send(message); - } - - disconnect() { - if (!this.#shared.connected) return; - this.#shared.connected = false; - this.#shared.channel.disconnect(); - } -} diff --git a/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch b/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch index 78f1d6fc..8779f3ee 100644 --- a/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch +++ b/patches/@automerge__automerge-repo@2.6.0-subduction.48.patch @@ -1,3 +1,54 @@ +diff --git a/dist/Repo.d.ts b/dist/Repo.d.ts +index 15d4ca65aa613fb567335f09c8fcb8cb9c4a954a..0f89be0e8b7e19ebb706d88685593259ddf67c89 100644 +--- a/dist/Repo.d.ts ++++ b/dist/Repo.d.ts +@@ -259,8 +259,11 @@ export interface RepoConfig { + adapter: NetworkAdapterInterface; + serviceName: string; + /** Whether to initiate ("connect") or accept ("accept") the subduction +- * handshake for peers on this adapter. Defaults to "connect". */ +- role?: "connect" | "accept"; ++ * handshake for peers on this adapter. "mesh" decides per peer, for ++ * adapters where every node sees every other one (e.g. a ++ * BroadcastChannel): the side whose peer id sorts lower initiates. ++ * Defaults to "connect". */ ++ role?: "connect" | "accept" | "mesh"; + }[]; + /** + * Tunable timeouts for the Subduction sync engine and its +diff --git a/dist/subduction/AdapterConnections.d.ts b/dist/subduction/AdapterConnections.d.ts +index 55231beca6494ed4ab0eaeb45383b822bcea5fe3..419c4ec3d17dc9f521292d780d293f5b1aafa8ce 100644 +--- a/dist/subduction/AdapterConnections.d.ts ++++ b/dist/subduction/AdapterConnections.d.ts +@@ -10,6 +10,6 @@ export declare class AdapterConnections implements ConnectionManager { + generation(): number; + onChange(callback: () => void): void; + shutdown(): void; +- addAdapter(adapter: NetworkAdapterInterface, serviceName: string, role: "connect" | "accept"): void; ++ addAdapter(adapter: NetworkAdapterInterface, serviceName: string, role: "connect" | "accept" | "mesh"): void; + } + //# sourceMappingURL=AdapterConnections.d.ts.map +\ No newline at end of file +diff --git a/dist/subduction/AdapterConnections.js b/dist/subduction/AdapterConnections.js +index 40f525a6be944be10dd5e707c73f6af48d7ffde1..51d27e89d02439b4724eb830aceb9339c65a761c 100644 +--- a/dist/subduction/AdapterConnections.js ++++ b/dist/subduction/AdapterConnections.js +@@ -57,11 +57,12 @@ export class AdapterConnections { + try { + const subduction = await this.#subduction; + const transport = new NetworkAdapterTransport(adapter, this.#localPeerId, peerId); +- if (role === "accept") { +- await subduction.acceptTransport(transport, serviceName); ++ const initiate = role === "mesh" ? this.#localPeerId < peerId : role !== "accept"; ++ if (initiate) { ++ await subduction.connectTransport(transport, serviceName); + } + else { +- await subduction.connectTransport(transport, serviceName); ++ await subduction.acceptTransport(transport, serviceName); + } + } + catch { diff --git a/dist/subduction/SubductionConnections.js b/dist/subduction/SubductionConnections.js index 4760a1899a8c0127db4840d5de090f6825211d97..5b9d19d4dd1cfb0cb242f140d5401af53aadd6a9 100644 --- a/dist/subduction/SubductionConnections.js @@ -13,6 +64,62 @@ index 4760a1899a8c0127db4840d5de090f6825211d97..5b9d19d4dd1cfb0cb242f140d5401af5 return true; } return false; +diff --git a/src/Repo.ts b/src/Repo.ts +index 0b408d6bda6582078083949c2fea63cfd0ef5e1b..c3df442b29c5a6ecd59a9464b3a30c4f59e3adc4 100644 +--- a/src/Repo.ts ++++ b/src/Repo.ts +@@ -1060,8 +1060,11 @@ export interface RepoConfig { + adapter: NetworkAdapterInterface + serviceName: string + /** Whether to initiate ("connect") or accept ("accept") the subduction +- * handshake for peers on this adapter. Defaults to "connect". */ +- role?: "connect" | "accept" ++ * handshake for peers on this adapter. "mesh" decides per peer, for ++ * adapters where every node sees every other one (e.g. a ++ * BroadcastChannel): the side whose peer id sorts lower initiates. ++ * Defaults to "connect". */ ++ role?: "connect" | "accept" | "mesh" + }[] + + /** +diff --git a/src/subduction/AdapterConnections.ts b/src/subduction/AdapterConnections.ts +index 4426f11291a00b230332c63d77c683bb7ab02d24..e1fb64697b94637cd9dd968a6cebe8f5e06d89b2 100644 +--- a/src/subduction/AdapterConnections.ts ++++ b/src/subduction/AdapterConnections.ts +@@ -52,7 +52,7 @@ export class AdapterConnections implements ConnectionManager { + addAdapter( + adapter: NetworkAdapterInterface, + serviceName: string, +- role: "connect" | "accept" ++ role: "connect" | "accept" | "mesh" + ) { + this.#adapters.push(adapter) + adapter.on("peer-candidate", ({ peerId }) => { +@@ -77,7 +77,7 @@ export class AdapterConnections implements ConnectionManager { + adapter: NetworkAdapterInterface, + serviceName: string, + peerId: PeerId, +- role: "connect" | "accept" ++ role: "connect" | "accept" | "mesh" + ) { + try { + const subduction = await this.#subduction +@@ -86,10 +86,12 @@ export class AdapterConnections implements ConnectionManager { + this.#localPeerId, + peerId + ) +- if (role === "accept") { +- await subduction.acceptTransport(transport, serviceName) +- } else { ++ const initiate = ++ role === "mesh" ? this.#localPeerId < peerId : role !== "accept" ++ if (initiate) { + await subduction.connectTransport(transport, serviceName) ++ } else { ++ await subduction.acceptTransport(transport, serviceName) + } + } catch { + // Transport connection failed (e.g. peer disconnected during handshake) diff --git a/src/subduction/SubductionConnections.ts b/src/subduction/SubductionConnections.ts index 0d5510500c1e3a8e0d84b6f9b5f87f42096ee8e3..6b8376be24f932f21ab3ddc4b76bc81ff39a5030 100644 --- a/src/subduction/SubductionConnections.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7773faed..68eccdc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,7 +50,7 @@ overrides: solid-automerge: ^2.0.1 patchedDependencies: - '@automerge/automerge-repo@2.6.0-subduction.48': 279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8 + '@automerge/automerge-repo@2.6.0-subduction.48': 37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4 importers: @@ -82,7 +82,7 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) '@automerge/automerge-repo-keyhive': specifier: 0.5.0-alpha.7 version: 0.5.0-alpha.7(ws@8.21.1) @@ -168,7 +168,7 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) '@automerge/automerge-repo-keyhive': specifier: 0.5.0-alpha.7 version: 0.5.0-alpha.7(ws@8.21.1) @@ -213,7 +213,7 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) debug: specifier: ^4.4.3 version: 4.4.3 @@ -244,7 +244,7 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) '@automerge/automerge-repo-keyhive': specifier: 0.5.0-alpha.7 version: 0.5.0-alpha.7(ws@8.21.1) @@ -318,7 +318,7 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) '@automerge/automerge-repo-keyhive': specifier: 0.5.0-alpha.7 version: 0.5.0-alpha.7(ws@8.21.1) @@ -349,7 +349,7 @@ importers: version: 3.4.1 '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) '@automerge/automerge-subduction': specifier: 0.16.1 version: 0.16.1 @@ -364,7 +364,7 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -377,7 +377,7 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) '@automerge/automerge-repo-react-hooks': specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -399,10 +399,10 @@ importers: devDependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) solid-automerge: specifier: ^2.0.1 - version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8))(solid-js@1.9.14) + version: 2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4))(solid-js@1.9.14) solid-js: specifier: ^1.9.13 version: 1.9.14 @@ -414,7 +414,7 @@ importers: dependencies: '@automerge/automerge-repo': specifier: 2.6.0-subduction.48 - version: 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + version: 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) '@automerge/automerge-repo-network-broadcastchannel': specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48 @@ -2037,7 +2037,7 @@ snapshots: '@automerge/automerge-repo-keyhive@0.5.0-alpha.7(ws@8.21.1)': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 '@automerge/automerge-subduction': 0.16.1 '@keyhive/keyhive': 0.1.0-alpha.8 @@ -2053,7 +2053,7 @@ snapshots: '@automerge/automerge-repo-network-broadcastchannel@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) transitivePeerDependencies: - bufferutil - supports-color @@ -2061,7 +2061,7 @@ snapshots: '@automerge/automerge-repo-network-messagechannel@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) eventemitter3: 5.0.4 transitivePeerDependencies: - bufferutil @@ -2070,7 +2070,7 @@ snapshots: '@automerge/automerge-repo-network-websocket@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) cbor-x: 1.6.4 debug: 4.4.3 eventemitter3: 5.0.4 @@ -2083,7 +2083,7 @@ snapshots: '@automerge/automerge-repo-react-hooks@2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@automerge/automerge': 3.4.1 - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) eventemitter3: 5.0.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -2094,13 +2094,13 @@ snapshots: '@automerge/automerge-repo-storage-indexeddb@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8)': + '@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4)': dependencies: '@automerge/automerge': 3.4.1 '@automerge/automerge-subduction': 0.16.1 @@ -2124,7 +2124,7 @@ snapshots: '@automerge/vanillajs@2.6.0-subduction.48': dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) '@automerge/automerge-repo-network-broadcastchannel': 2.6.0-subduction.48 '@automerge/automerge-repo-network-messagechannel': 2.6.0-subduction.48 '@automerge/automerge-repo-network-websocket': 2.6.0-subduction.48 @@ -3348,9 +3348,9 @@ snapshots: slash@3.0.0: {} - solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8))(solid-js@1.9.14): + solid-automerge@2.0.1(@automerge/automerge-repo@2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4))(solid-js@1.9.14): dependencies: - '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=279da77c84e1b79a1867986b54c60285e53e01e47d1e58264ff8da62d6a76ea8) + '@automerge/automerge-repo': 2.6.0-subduction.48(patch_hash=37266639cf8001b55f69d72d72274d4c512898628937a4e0c8b96cb0b8dd67f4) '@solid-primitives/utils': 6.4.1(solid-js@1.9.14) cabbages: 0.2.10 solid-js: 1.9.14