@inkandswitch/patchwork 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @inkandswitch/patchwork
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2e71745: Every Repo on the origin is its own Subduction node. A tab holds this origin's IndexedDB, keeps its own WebSocket to the sync server, and meets the other tabs over a BroadcastChannel carrying Subduction (`siblingAdapters()` in `@inkandswitch/patchwork-bootloader/siblings`, passed to `new Repo({ subductionAdapters })`). The automerge SharedWorker no longer sits between tabs and storage — it is one more such node, kept only to resolve `automerge:` URLs for the service worker, which can't own a Repo itself. The websocket proxy worker is gone with it.
8
+
9
+ Benchmarked against the shared-worker arrangement (`sites/bench`): boot and memory are a wash or better, cross-tab propagation matches, and two shared-worker failures go away — a `find()` racing a sibling's `create()` settled as unavailable, and edits made just before a tab closed were lost, since a storageless tab had nothing to flush to. Each tab flushing its own IndexedDB closes both.
10
+
11
+ Keyhive sites use the subduction-backed hive in both the tab and the worker, each talking to the sync server directly.
12
+
13
+ `patchwork.sw.subscribeSyncState(documentId, listener)` stays, now a filter over the tab's own Repo's `subduction-remote-heads` event, replaying the server's current heads from `handle.getSyncInfo()` on subscribe; `SyncStateDocMessage` is exported from `@inkandswitch/patchwork`. Removed from `setupServiceWorker()`'s result: `subscribeToRepoChannel`, `getRepoChannel`, `subscribeSyncState`. The `@patchwork/syncstate` BroadcastChannel and the other `SyncState*` message types are gone too; a tab's own Repo has what they carried — `repo.isSubductionConnected()` and the `subduction-connection` event for the link, `repo.connectedSubductionPeerIds()` for which peers are the server, and `patchwork.signerIdentity` for this tab's peer id. `createRepo` in `@inkandswitch/patchwork` takes no arguments.
14
+
15
+ `@inkandswitch/patchwork-bootloader` depends on `@automerge/automerge-repo-network-broadcastchannel`, which is also on the importmap.
16
+
17
+ That worker is renamed for what it now does: `@inkandswitch/patchwork-bootloader/automerge-worker` is now `@inkandswitch/patchwork-bootloader/automerge-protocol-handler-worker`, emitted as `automerge-protocol-handler-worker.js` (the `workerPath` option on `setupServiceWorker` still overrides it), and `getAutomergeWorker()` is now `getAutomergeProtocolHandlerWorker()`.
18
+
19
+ Inter-tab sync is Subduction, not classic automerge sync. `connectSiblings(repo, hive)` is replaced by `siblingAdapters()`, which returns the `subductionAdapters` entries for a Repo rather than mutating one after the fact, so it is passed to the `Repo` constructor. The frames on the siblings BroadcastChannel are Subduction transport frames authenticated by each node's own signer; the keyhive network adapter no longer wraps that channel, since keyhive material reaches siblings the same way it reaches the sync server. The one remaining classic-sync path is the opt-in classic sync server the protocol handler worker connects to on request.
20
+
21
+ Subduction's handshake has an initiator and a responder, and a BroadcastChannel is a mesh, so the siblings adapter is passed with `role: "mesh"`, added to the automerge-repo fork's `subductionAdapters` by this repo's pnpm patch: for each pair of peers on the adapter, the one whose peer id sorts lower initiates the handshake and the other accepts.
22
+
23
+ ### Patch Changes
24
+
25
+ - 47bc4cf: `@automerge/automerge` goes to `3.4.1`, and `@automerge/automerge-repo-network-broadcastchannel` joins the automerge-repo family at `2.6.0-subduction.48`.
26
+ - Updated dependencies [47bc4cf]
27
+ - Updated dependencies [2e71745]
28
+ - @inkandswitch/patchwork-bootloader@0.7.0
29
+ - @inkandswitch/patchwork-filesystem@0.2.9
30
+ - @inkandswitch/patchwork-plugins@1.2.4
31
+
3
32
  ## 0.7.4
4
33
 
5
34
  ### Patch Changes
package/dist/head.js CHANGED
@@ -6,11 +6,7 @@
6
6
  "@automerge/automerge/slim",
7
7
  "@automerge/automerge-repo",
8
8
  "@automerge/automerge-repo/slim",
9
- // Port-donation plumbing for WorkerWebSocketEndpoint: tabs spawn the shared
10
- // proxy entry and donate its port to the automerge worker (Chrome can't
11
- // spawn workers from inside a SharedWorker). See setup.ts/automerge-worker.ts.
12
- "@automerge/automerge-repo/worker-port",
13
- "@automerge/automerge-repo/subduction-websocket-worker-shared",
9
+ "@automerge/automerge-repo-network-broadcastchannel",
14
10
  "@automerge/automerge-repo-network-messagechannel",
15
11
  "@automerge/automerge-repo-network-websocket",
16
12
  "@automerge/automerge-repo-storage-indexeddb",
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * One import for a Patchwork site.
3
3
  *
4
- * `setup(options)` constructs the Repo, wires up the automerge-worker port,
5
- * loads plugins via the ModuleWatcher, resolves the user's account document,
4
+ * `setup(options)` constructs the Repo, wires up the
5
+ * automerge-protocol-handler-worker port, loads plugins via the
6
+ * ModuleWatcher, resolves the user's account document,
6
7
  * installs the router, and resolves with the site's runtime API — `repo`,
7
8
  * `create`, `open`, `find`, `packages`, `plugins`, `sw` — which is what a
8
9
  * site assigns to `window.patchwork`.
@@ -15,7 +16,7 @@
15
16
  * Pulls in DOM- and plugin-layer dependencies, so it is for a browser site's
16
17
  * `main.ts` only. Non-UI consumers should import
17
18
  * `@inkandswitch/patchwork-bootloader` directly, which does SW registration
18
- * and the automerge-worker handoff and nothing else.
19
+ * and the automerge-protocol-handler-worker handoff and nothing else.
19
20
  */
20
21
  import { Repo } from "@automerge/vanillajs/slim";
21
22
  import type { AutomergeRepoKeyhiveBase as AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
@@ -34,4 +35,4 @@ export default setup;
34
35
  export { createRepo, initWasm } from "./repo.js";
35
36
  export { createRouter } from "./router.js";
36
37
  export { showLoadingAnimation, hideLoadingAnimation, showErrorScreen, } from "./loading.js";
37
- export type { OpenOptions, Patchwork, PatchworkOptions, ServiceWorkerApi, SignerIdentity, } from "./types.js";
38
+ export type { OpenOptions, Patchwork, PatchworkOptions, ServiceWorkerApi, SignerIdentity, SyncStateDocMessage, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,23 +1,3 @@
1
- /**
2
- * One import for a Patchwork site.
3
- *
4
- * `setup(options)` constructs the Repo, wires up the automerge-worker port,
5
- * loads plugins via the ModuleWatcher, resolves the user's account document,
6
- * installs the router, and resolves with the site's runtime API — `repo`,
7
- * `create`, `open`, `find`, `packages`, `plugins`, `sw` — which is what a
8
- * site assigns to `window.patchwork`.
9
- *
10
- * Setup owns page-wide state — the `window.repo`/`window.Automerge`/
11
- * `window.AutomergeRepo`/`window.hive` globals, custom-element registration,
12
- * document listeners — so it may run only once per page; a second call
13
- * throws.
14
- *
15
- * Pulls in DOM- and plugin-layer dependencies, so it is for a browser site's
16
- * `main.ts` only. Non-UI consumers should import
17
- * `@inkandswitch/patchwork-bootloader` directly, which does SW registration
18
- * and the automerge-worker handoff and nothing else.
19
- */
20
- import { MessageChannelNetworkAdapter, } from "@automerge/vanillajs/slim";
21
1
  import * as Automerge from "@automerge/automerge/slim";
22
2
  import * as AutomergeRepo from "@automerge/automerge-repo/slim";
23
3
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
@@ -28,7 +8,7 @@ import { createDocOfDatatype2, getRegistry, registerPlugins, resolveAccountHandl
28
8
  import * as plugins from "@inkandswitch/patchwork-plugins";
29
9
  import setupServiceWorker, { lifecycleLog, } from "@inkandswitch/patchwork-bootloader";
30
10
  import debug from "debug";
31
- import { createRepo, firstRepoPort, initWasm, removeAdapterFor, } from "./repo.js";
11
+ import { createRepo, initWasm } from "./repo.js";
32
12
  import { createRouter } from "./router.js";
33
13
  import { createDefaultAccount } from "./createAccount.js";
34
14
  const log = debug("patchwork:setup");
@@ -68,43 +48,13 @@ async function doSetup(options) {
68
48
  let hive;
69
49
  let repo;
70
50
  let signerIdentity;
71
- // Called with a fresh port when the automerge worker dies and is recreated.
72
- // Assigned once the repo exists.
73
- let onWorkerPortRenewed;
74
51
  if (options.repo) {
75
52
  log("using provided Repo");
76
53
  repo = options.repo;
77
54
  hive = options.hive;
78
55
  }
79
56
  else {
80
- const workerPort = await firstRepoPort(sw, (port) => {
81
- if (onWorkerPortRenewed)
82
- onWorkerPortRenewed(port);
83
- else {
84
- console.warn("automerge worker port renewed before the repo existed; dropping it");
85
- }
86
- });
87
- let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
88
- ({ repo, hive, signerIdentity } = await createRepo(workerAdapter));
89
- // The worker was recreated with cold state: wire the repo onto the fresh
90
- // port and drop the adapter stranded on the dead one.
91
- const bootHive = hive;
92
- onWorkerPortRenewed = (port) => {
93
- const fresh = new MessageChannelNetworkAdapter(port);
94
- // Mirror the boot wiring: a keyhive repo talks to the worker through a
95
- // keyhive adapter wrapped around the message channel.
96
- const registered = bootHive
97
- ? bootHive.createKeyhiveNetworkAdapter(fresh, {
98
- onlyShareWithSyncServer: false,
99
- periodicallyRequestSync: false,
100
- syncRequestInterval: 2000,
101
- })
102
- : fresh;
103
- repo.networkSubsystem.addNetworkAdapter(registered);
104
- removeAdapterFor(repo, workerAdapter, registered);
105
- workerAdapter = fresh;
106
- lifecycleLog("repo re-wired to the recreated automerge worker");
107
- };
57
+ ({ repo, hive, signerIdentity } = await createRepo());
108
58
  }
109
59
  // Dev-console / tool-runtime globals (e2e and loaded tools read these). The
110
60
  // `window.patchwork` handle is deliberately not set here — the caller does
@@ -115,8 +65,6 @@ async function doSetup(options) {
115
65
  AutomergeRepo;
116
66
  if (hive)
117
67
  window.hive = hive;
118
- await repo.networkSubsystem.whenReady();
119
- log("networkSubsystem ready");
120
68
  hive?.networkAdapter?.syncKeyhive?.();
121
69
  registerRepoProviderElement(repo);
122
70
  const rootElementId = options.rootElementId ?? "root";
@@ -172,8 +120,7 @@ async function doSetup(options) {
172
120
  plugins,
173
121
  sw: {
174
122
  connectClassicSync: sw.connectClassicSync,
175
- subscribeToRepoChannel: sw.subscribeToRepoChannel,
176
- subscribeSyncState: sw.subscribeSyncState,
123
+ subscribeSyncState: (documentId, listener) => subscribeSyncState(repo, documentId, listener),
177
124
  },
178
125
  async create(type, init) {
179
126
  const datatype = await getRegistry("patchwork:datatype").load(type);
@@ -283,6 +230,40 @@ function installLifecycleLogging() {
283
230
  window.addEventListener("offline", () => lifecycleLog("offline"), opts);
284
231
  lifecycleLog("logging installed (visibilityState=%s, hasFocus=%s)", document.visibilityState, document.hasFocus());
285
232
  }
233
+ // The tab's own Repo hears the server's heads directly, so this is a filter
234
+ // over its `subduction-remote-heads` event. The current value is replayed
235
+ // from the handle's sync info, when the server has reported any.
236
+ function subscribeSyncState(repo, documentId, listener) {
237
+ const onHeads = (payload) => {
238
+ if (payload.documentId !== documentId)
239
+ return;
240
+ listener({
241
+ type: "sync-state",
242
+ documentId,
243
+ storageId: payload.storageId,
244
+ heads: [...payload.heads],
245
+ timestamp: payload.timestamp,
246
+ });
247
+ };
248
+ repo.on("subduction-remote-heads", onHeads);
249
+ void (async () => {
250
+ const handle = repo.handles[documentId];
251
+ if (!handle)
252
+ return;
253
+ for (const storageId of await repo.connectedSubductionPeerIds()) {
254
+ const info = handle.getSyncInfo(storageId);
255
+ if (info) {
256
+ onHeads({
257
+ documentId,
258
+ storageId,
259
+ heads: info.lastHeads,
260
+ timestamp: info.lastSyncTimestamp,
261
+ });
262
+ }
263
+ }
264
+ })();
265
+ return () => repo.off("subduction-remote-heads", onHeads);
266
+ }
286
267
  // ── Named exports ────────────────────────────────────────────────────────
287
268
  export { createRepo, initWasm } from "./repo.js";
288
269
  export { createRouter } from "./router.js";
package/dist/repo.d.ts CHANGED
@@ -1,23 +1,15 @@
1
- import { MessageChannelNetworkAdapter, Repo } from "@automerge/vanillajs/slim";
2
- import { type AutomergeRepoKeyhiveBase } from "@automerge/automerge-repo-keyhive";
3
- import setupServiceWorker from "@inkandswitch/patchwork-bootloader";
1
+ import { Repo } from "@automerge/vanillajs/slim";
2
+ import { type AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
4
3
  import type { SignerIdentity } from "./types.js";
5
4
  export declare function initWasm(): Promise<void>;
6
- export declare function createRepo(workerAdapter: MessageChannelNetworkAdapter): Promise<{
5
+ export type TabRepo = {
7
6
  repo: Repo;
8
- hive?: AutomergeRepoKeyhiveBase;
7
+ hive?: AutomergeRepoKeyhive;
9
8
  signerIdentity?: SignerIdentity;
10
- }>;
9
+ };
11
10
  /**
12
- * Resolve with the first repo port the worker delivers, calling `onRenewed` for
13
- * every later one.
14
- *
15
- * subscribeToRepoChannel is deliberately not awaited: it resolves only after
16
- * the boot channel's port-ready handshake, which can take its full 30s timeout
17
- * against a stranded worker connection. Boot blocks on the first *delivered*
18
- * port instead — if the boot channel stalls, worker recovery hands the listener
19
- * a good port long before that timeout.
11
+ * The tab's own node: this origin's IndexedDB, a socket to the sync server,
12
+ * and the siblings channel to every other Repo on the origin. Nothing is
13
+ * shared with other tabs except the database underneath.
20
14
  */
21
- export declare function firstRepoPort(sw: Awaited<ReturnType<typeof setupServiceWorker>>, onRenewed: (port: MessagePort) => void): Promise<MessagePort>;
22
- /** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
23
- export declare function removeAdapterFor(repo: Repo, stale: MessageChannelNetworkAdapter, keep: unknown): void;
15
+ export declare function createRepo(): Promise<TabRepo>;
package/dist/repo.js CHANGED
@@ -1,6 +1,7 @@
1
- import { initializeWasm, Repo, } from "@automerge/vanillajs/slim";
1
+ import { initializeWasm, Repo } from "@automerge/vanillajs/slim";
2
2
  import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
3
- import { initKeyhiveWasm, initializeLegacyAutomergeRepoKeyhive, } from "@automerge/automerge-repo-keyhive";
3
+ import { siblingAdapters } from "@inkandswitch/patchwork-bootloader/siblings";
4
+ import { initKeyhiveWasm, initializeAutomergeRepoKeyhive, } from "@automerge/automerge-repo-keyhive";
4
5
  // eslint-disable-next-line
5
6
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
6
7
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
@@ -28,41 +29,43 @@ export function initWasm() {
28
29
  }
29
30
  return wasmReady;
30
31
  }
31
- export async function createRepo(workerAdapter) {
32
+ /**
33
+ * The tab's own node: this origin's IndexedDB, a socket to the sync server,
34
+ * and the siblings channel to every other Repo on the origin. Nothing is
35
+ * shared with other tabs except the database underneath.
36
+ */
37
+ export async function createRepo() {
32
38
  if (syncServer.keyhive) {
33
39
  log("setting up keyhive");
34
40
  initKeyhiveWasm();
35
- const { hive, repo } = await initializeLegacyAutomergeRepoKeyhive({
41
+ const { hive, repo } = await initializeAutomergeRepoKeyhive({
36
42
  createRepo: (repoConfig) => new Repo(repoConfig),
37
43
  storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName),
38
44
  peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2),
39
- networkAdapter: workerAdapter,
40
45
  automaticArchiveIngestion: true,
41
46
  cachingMode: "periodic",
42
- onlyShareWithSyncServer: false,
43
47
  // ARK selects the relay via `syncServer`, defaulting to "subduction".
44
48
  syncServer: syncServer.keyhive,
45
49
  repo: {
46
50
  storage: new IndexedDBWorkerStorageAdapter(),
51
+ subductionWebsocketEndpoints: [syncServer.url],
52
+ subductionAdapters: siblingAdapters(),
47
53
  enableRemoteHeadsGossiping: true,
48
54
  },
49
55
  });
50
56
  log("keyhive setup complete");
51
57
  return { repo, hive };
52
58
  }
53
- // An explicit signer, rather than the Repo's internal default, so the tab's
54
- // identity can be exposed on window.patchwork. The tab never connects via
55
- // Subduction, so this id never goes on the wire.
59
+ // The signer is explicit rather than the Repo's internal default so the
60
+ // identity the tab presents to the server can be shown on window.patchwork.
56
61
  const signer = new MemorySigner();
57
62
  const repo = new Repo({
58
- network: [workerAdapter],
59
- storage: new IndexedDBWorkerStorageAdapter(),
60
63
  signer,
61
- async sharePolicy(peerId) {
62
- return peerId.includes("automerge-worker");
63
- },
64
- enableRemoteHeadsGossiping: true,
64
+ storage: new IndexedDBWorkerStorageAdapter(),
65
65
  peerId: `${storagePrefix}-tab-${crypto.randomUUID()}`,
66
+ subductionWebsocketEndpoints: [syncServer.url],
67
+ subductionAdapters: siblingAdapters(),
68
+ enableRemoteHeadsGossiping: true,
66
69
  });
67
70
  const signerIdentity = {
68
71
  peerId: signer.peerId().toString(),
@@ -71,41 +74,3 @@ export async function createRepo(workerAdapter) {
71
74
  log("repo created, tab subduction identity:", signerIdentity);
72
75
  return { repo, signerIdentity };
73
76
  }
74
- /**
75
- * Resolve with the first repo port the worker delivers, calling `onRenewed` for
76
- * every later one.
77
- *
78
- * subscribeToRepoChannel is deliberately not awaited: it resolves only after
79
- * the boot channel's port-ready handshake, which can take its full 30s timeout
80
- * against a stranded worker connection. Boot blocks on the first *delivered*
81
- * port instead — if the boot channel stalls, worker recovery hands the listener
82
- * a good port long before that timeout.
83
- */
84
- export function firstRepoPort(sw, onRenewed) {
85
- return new Promise((resolve) => {
86
- let seen = false;
87
- void sw.subscribeToRepoChannel((port) => {
88
- if (seen)
89
- return onRenewed(port);
90
- seen = true;
91
- resolve(port);
92
- });
93
- });
94
- }
95
- /** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
96
- export function removeAdapterFor(repo, stale, keep) {
97
- for (const adapter of [...repo.networkSubsystem.adapters]) {
98
- if (adapter === keep)
99
- continue;
100
- // The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
101
- const base = adapter.networkAdapter ?? adapter;
102
- if (base !== stale)
103
- continue;
104
- try {
105
- repo.networkSubsystem.removeNetworkAdapter(adapter);
106
- }
107
- catch (err) {
108
- console.error("failed to remove stale worker network adapter", err);
109
- }
110
- }
111
- }
@@ -1,7 +1,8 @@
1
1
  // Mirrors core/bootloader/src/sync-config.ts's DEFAULT_CLASSIC_SYNC_SERVER
2
- // and automerge-worker.ts's SUBDUCTION_SYNC_URL selection — kept here as
3
- // plain constants (rather than importing those runtime modules) since this
4
- // only needs the hostnames, not the browser-only logic that reads them.
2
+ // and automerge-protocol-handler-worker.ts's SUBDUCTION_SYNC_URL selection —
3
+ // kept here as plain constants (rather than importing those runtime modules)
4
+ // since this only needs the hostnames, not the browser-only logic that reads
5
+ // them.
5
6
  export const DEFAULT_SYNC_SERVERS = {
6
7
  classic: "wss://sync3.automerge.org",
7
8
  subduction: "wss://subduction.sync.inkandswitch.com",
package/dist/types.d.ts CHANGED
@@ -2,16 +2,26 @@ import type { AutomergeUrl, DocHandle, Repo } from "@automerge/vanillajs/slim";
2
2
  import type { AutomergeRepoKeyhiveBase as AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
3
3
  import type { ModuleWatcher, HasPatchworkMetadata } from "@inkandswitch/patchwork-filesystem";
4
4
  import type { AccountCreator, AccountDoc } from "@inkandswitch/patchwork-plugins";
5
- import type { ServiceWorkerRepoChannelListener, SyncStateDocMessage } from "@inkandswitch/patchwork-bootloader/types";
6
5
  import type * as pluginsNS from "@inkandswitch/patchwork-plugins";
7
6
  export type PluginsApi = typeof pluginsNS;
8
7
  export type SignerIdentity = {
9
8
  peerId: string;
10
9
  verifyingKey: string;
11
10
  };
11
+ /** The sync server's last-known heads for one document, as it reports them. */
12
+ export interface SyncStateDocMessage {
13
+ type: "sync-state";
14
+ documentId: string;
15
+ storageId: string;
16
+ heads: string[];
17
+ timestamp: number;
18
+ }
12
19
  export interface ServiceWorkerApi {
13
20
  connectClassicSync: (server?: string) => Promise<void>;
14
- subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
21
+ /**
22
+ * Watch one document's heads at the sync server. Calls `listener` with
23
+ * what the server holds now, then on every update. Returns unsubscribe.
24
+ */
15
25
  subscribeSyncState: (documentId: string, listener: (update: SyncStateDocMessage) => void) => () => void;
16
26
  }
17
27
  export interface OpenOptions {
@@ -70,7 +80,8 @@ export interface PatchworkOptions {
70
80
  /**
71
81
  * Bring your own Repo. When provided, setup skips wasm initialization and
72
82
  * repo creation entirely — you are responsible for having initialized
73
- * automerge/subduction and wired the automerge-worker port yourself.
83
+ * automerge/subduction and wired the automerge-protocol-handler-worker
84
+ * port yourself.
74
85
  */
75
86
  repo?: Repo;
76
87
  /** The keyhive instance accompanying a provided {@link PatchworkOptions.repo}. */
@@ -69,11 +69,11 @@ export function config(options = {}) {
69
69
  },
70
70
  };
71
71
  },
72
- // The shared automerge-worker's chunk imports bypass the page's service
73
- // worker, so offline boot needs the browser's HTTP cache to serve them
74
- // without revalidating. Content hashes make that safe; a new build gets
75
- // new URLs. Production gets this from the generated _headers file — vite
76
- // preview doesn't read that, so mirror it here.
72
+ // The shared automerge-protocol-handler-worker's chunk imports bypass the
73
+ // page's service worker, so offline boot needs the browser's HTTP cache to
74
+ // serve them without revalidating. Content hashes make that safe; a new
75
+ // build gets new URLs. Production gets this from the generated _headers
76
+ // file — vite preview doesn't read that, so mirror it here.
77
77
  configurePreviewServer(server) {
78
78
  if (options.netlify === false ||
79
79
  options.netlify?.immutableAssets === false) {
@@ -16,8 +16,8 @@ export const workers = [
16
16
  fileName: "service-worker.js",
17
17
  },
18
18
  {
19
- specifier: "@inkandswitch/patchwork-bootloader/automerge-worker",
20
- fileName: "automerge-worker.js",
19
+ specifier: "@inkandswitch/patchwork-bootloader/automerge-protocol-handler-worker",
20
+ fileName: "automerge-protocol-handler-worker.js",
21
21
  },
22
22
  {
23
23
  specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker",
package/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "url": "git+https://github.com/inkandswitch/patchwork-system.git",
6
6
  "directory": "core/patchwork"
7
7
  },
8
- "version": "0.7.4",
8
+ "version": "0.8.0",
9
9
  "author": "Ink & Switch",
10
10
  "type": "module",
11
11
  "license": "MIT",
@@ -72,7 +72,7 @@
72
72
  "./global.css": "./dist/global.css"
73
73
  },
74
74
  "dependencies": {
75
- "@automerge/automerge": "3.3.2",
75
+ "@automerge/automerge": "3.4.1",
76
76
  "@automerge/automerge-repo": "2.6.0-subduction.48",
77
77
  "@automerge/automerge-repo-keyhive": "0.5.0-alpha.7",
78
78
  "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.48",
@@ -83,11 +83,11 @@
83
83
  "esbuild": "^0.23.1",
84
84
  "sharp": "^0.35.3",
85
85
  "vite-plugin-wasm": "^3.6.0",
86
- "@inkandswitch/patchwork-bootloader": "^0.6.3",
87
- "@inkandswitch/patchwork-elements": "^6.0.2",
88
- "@inkandswitch/patchwork-filesystem": "^0.2.8",
89
- "@inkandswitch/patchwork-plugins": "^1.2.3",
90
- "@inkandswitch/patchwork-providers": "^0.5.2"
86
+ "@inkandswitch/patchwork-bootloader": "^0.7.0",
87
+ "@inkandswitch/patchwork-filesystem": "^0.2.9",
88
+ "@inkandswitch/patchwork-plugins": "^1.2.4",
89
+ "@inkandswitch/patchwork-providers": "^0.5.2",
90
+ "@inkandswitch/patchwork-elements": "^6.0.2"
91
91
  },
92
92
  "devDependencies": {
93
93
  "rollup": "^4.61.1",
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * One import for a Patchwork site.
3
3
  *
4
- * `setup(options)` constructs the Repo, wires up the automerge-worker port,
5
- * loads plugins via the ModuleWatcher, resolves the user's account document,
4
+ * `setup(options)` constructs the Repo, wires up the
5
+ * automerge-protocol-handler-worker port, loads plugins via the
6
+ * ModuleWatcher, resolves the user's account document,
6
7
  * installs the router, and resolves with the site's runtime API — `repo`,
7
8
  * `create`, `open`, `find`, `packages`, `plugins`, `sw` — which is what a
8
9
  * site assigns to `window.patchwork`.
@@ -15,12 +16,13 @@
15
16
  * Pulls in DOM- and plugin-layer dependencies, so it is for a browser site's
16
17
  * `main.ts` only. Non-UI consumers should import
17
18
  * `@inkandswitch/patchwork-bootloader` directly, which does SW registration
18
- * and the automerge-worker handoff and nothing else.
19
+ * and the automerge-protocol-handler-worker handoff and nothing else.
19
20
  */
20
21
  import {
21
22
  type AutomergeUrl,
22
23
  type DocHandle,
23
- MessageChannelNetworkAdapter,
24
+ type DocumentId,
25
+ type StorageId,
24
26
  Repo,
25
27
  } from "@automerge/vanillajs/slim";
26
28
  import * as Automerge from "@automerge/automerge/slim";
@@ -52,13 +54,9 @@ import type {
52
54
  Patchwork,
53
55
  PatchworkOptions,
54
56
  SignerIdentity,
57
+ SyncStateDocMessage,
55
58
  } from "./types.js";
56
- import {
57
- createRepo,
58
- firstRepoPort,
59
- initWasm,
60
- removeAdapterFor,
61
- } from "./repo.js";
59
+ import { createRepo, initWasm } from "./repo.js";
62
60
  import { createRouter, type Router } from "./router.js";
63
61
  import { createDefaultAccount } from "./createAccount.js";
64
62
 
@@ -130,46 +128,13 @@ async function doSetup(options: PatchworkOptions): Promise<Patchwork> {
130
128
  let hive: AutomergeRepoKeyhive | undefined;
131
129
  let repo: Repo;
132
130
  let signerIdentity: SignerIdentity | undefined;
133
- // Called with a fresh port when the automerge worker dies and is recreated.
134
- // Assigned once the repo exists.
135
- let onWorkerPortRenewed: ((port: MessagePort) => void) | undefined;
136
131
 
137
132
  if (options.repo) {
138
133
  log("using provided Repo");
139
134
  repo = options.repo;
140
135
  hive = options.hive;
141
136
  } else {
142
- const workerPort = await firstRepoPort(sw, (port) => {
143
- if (onWorkerPortRenewed) onWorkerPortRenewed(port);
144
- else {
145
- console.warn(
146
- "automerge worker port renewed before the repo existed; dropping it"
147
- );
148
- }
149
- });
150
-
151
- let workerAdapter = new MessageChannelNetworkAdapter(workerPort);
152
- ({ repo, hive, signerIdentity } = await createRepo(workerAdapter));
153
-
154
- // The worker was recreated with cold state: wire the repo onto the fresh
155
- // port and drop the adapter stranded on the dead one.
156
- const bootHive = hive;
157
- onWorkerPortRenewed = (port) => {
158
- const fresh = new MessageChannelNetworkAdapter(port);
159
- // Mirror the boot wiring: a keyhive repo talks to the worker through a
160
- // keyhive adapter wrapped around the message channel.
161
- const registered = bootHive
162
- ? bootHive.createKeyhiveNetworkAdapter(fresh, {
163
- onlyShareWithSyncServer: false,
164
- periodicallyRequestSync: false,
165
- syncRequestInterval: 2000,
166
- })
167
- : fresh;
168
- repo.networkSubsystem.addNetworkAdapter(registered as any);
169
- removeAdapterFor(repo, workerAdapter, registered);
170
- workerAdapter = fresh;
171
- lifecycleLog("repo re-wired to the recreated automerge worker");
172
- };
137
+ ({ repo, hive, signerIdentity } = await createRepo());
173
138
  }
174
139
 
175
140
  // Dev-console / tool-runtime globals (e2e and loaded tools read these). The
@@ -181,8 +146,6 @@ async function doSetup(options: PatchworkOptions): Promise<Patchwork> {
181
146
  AutomergeRepo as typeof import("@automerge/automerge-repo");
182
147
  if (hive) window.hive = hive;
183
148
 
184
- await repo.networkSubsystem.whenReady();
185
- log("networkSubsystem ready");
186
149
  (hive?.networkAdapter as any)?.syncKeyhive?.();
187
150
 
188
151
  registerRepoProviderElement(repo as any);
@@ -261,8 +224,8 @@ async function doSetup(options: PatchworkOptions): Promise<Patchwork> {
261
224
  plugins,
262
225
  sw: {
263
226
  connectClassicSync: sw.connectClassicSync,
264
- subscribeToRepoChannel: sw.subscribeToRepoChannel,
265
- subscribeSyncState: sw.subscribeSyncState,
227
+ subscribeSyncState: (documentId, listener) =>
228
+ subscribeSyncState(repo, documentId, listener),
266
229
  },
267
230
 
268
231
  async create<D>(type: string, init?: (doc: D) => void) {
@@ -433,6 +396,50 @@ function installLifecycleLogging(): void {
433
396
  );
434
397
  }
435
398
 
399
+ // The tab's own Repo hears the server's heads directly, so this is a filter
400
+ // over its `subduction-remote-heads` event. The current value is replayed
401
+ // from the handle's sync info, when the server has reported any.
402
+ function subscribeSyncState(
403
+ repo: Repo,
404
+ documentId: string,
405
+ listener: (update: SyncStateDocMessage) => void
406
+ ): () => void {
407
+ const onHeads = (payload: {
408
+ documentId: string;
409
+ storageId: string;
410
+ heads: readonly string[];
411
+ timestamp: number;
412
+ }) => {
413
+ if (payload.documentId !== documentId) return;
414
+ listener({
415
+ type: "sync-state",
416
+ documentId,
417
+ storageId: payload.storageId,
418
+ heads: [...payload.heads],
419
+ timestamp: payload.timestamp,
420
+ });
421
+ };
422
+ repo.on("subduction-remote-heads", onHeads);
423
+
424
+ void (async () => {
425
+ const handle = repo.handles[documentId as DocumentId];
426
+ if (!handle) return;
427
+ for (const storageId of await repo.connectedSubductionPeerIds()) {
428
+ const info = handle.getSyncInfo(storageId as StorageId);
429
+ if (info) {
430
+ onHeads({
431
+ documentId,
432
+ storageId,
433
+ heads: info.lastHeads,
434
+ timestamp: info.lastSyncTimestamp,
435
+ });
436
+ }
437
+ }
438
+ })();
439
+
440
+ return () => repo.off("subduction-remote-heads", onHeads);
441
+ }
442
+
436
443
  // ── Named exports ────────────────────────────────────────────────────────
437
444
 
438
445
  export { createRepo, initWasm } from "./repo.js";
@@ -448,4 +455,5 @@ export type {
448
455
  PatchworkOptions,
449
456
  ServiceWorkerApi,
450
457
  SignerIdentity,
458
+ SyncStateDocMessage,
451
459
  } from "./types.js";
package/src/repo.ts CHANGED
@@ -1,22 +1,17 @@
1
- import {
2
- initializeWasm,
3
- MessageChannelNetworkAdapter,
4
- Repo,
5
- type AutomergeUrl,
6
- } from "@automerge/vanillajs/slim";
1
+ import { initializeWasm, Repo } from "@automerge/vanillajs/slim";
7
2
  import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
3
+ import { siblingAdapters } from "@inkandswitch/patchwork-bootloader/siblings";
8
4
  import * as AutomergeRepo from "@automerge/automerge-repo/slim";
9
5
  import {
10
6
  initKeyhiveWasm,
11
- initializeLegacyAutomergeRepoKeyhive,
12
- type AutomergeRepoKeyhiveBase,
7
+ initializeAutomergeRepoKeyhive,
8
+ type AutomergeRepoKeyhive,
13
9
  type SyncServerSelection,
14
10
  } from "@automerge/automerge-repo-keyhive";
15
11
  // eslint-disable-next-line
16
12
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
17
13
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
18
14
  import { MemorySigner } from "@automerge/automerge-subduction/slim";
19
- import setupServiceWorker from "@inkandswitch/patchwork-bootloader";
20
15
  import {
21
16
  keyhiveStorageName,
22
17
  storagePrefix,
@@ -53,28 +48,33 @@ export function initWasm(): Promise<void> {
53
48
  return wasmReady;
54
49
  }
55
50
 
56
- export async function createRepo(
57
- workerAdapter: MessageChannelNetworkAdapter
58
- ): Promise<{
51
+ export type TabRepo = {
59
52
  repo: Repo;
60
- hive?: AutomergeRepoKeyhiveBase;
53
+ hive?: AutomergeRepoKeyhive;
61
54
  signerIdentity?: SignerIdentity;
62
- }> {
55
+ };
56
+
57
+ /**
58
+ * The tab's own node: this origin's IndexedDB, a socket to the sync server,
59
+ * and the siblings channel to every other Repo on the origin. Nothing is
60
+ * shared with other tabs except the database underneath.
61
+ */
62
+ export async function createRepo(): Promise<TabRepo> {
63
63
  if (syncServer.keyhive) {
64
64
  log("setting up keyhive");
65
65
  initKeyhiveWasm();
66
- const { hive, repo } = await initializeLegacyAutomergeRepoKeyhive({
66
+ const { hive, repo } = await initializeAutomergeRepoKeyhive({
67
67
  createRepo: (repoConfig) => new Repo(repoConfig),
68
68
  storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName),
69
69
  peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2),
70
- networkAdapter: workerAdapter,
71
70
  automaticArchiveIngestion: true,
72
71
  cachingMode: "periodic",
73
- onlyShareWithSyncServer: false,
74
72
  // ARK selects the relay via `syncServer`, defaulting to "subduction".
75
73
  syncServer: syncServer.keyhive,
76
74
  repo: {
77
75
  storage: new IndexedDBWorkerStorageAdapter(),
76
+ subductionWebsocketEndpoints: [syncServer.url],
77
+ subductionAdapters: siblingAdapters(),
78
78
  enableRemoteHeadsGossiping: true,
79
79
  },
80
80
  });
@@ -82,20 +82,17 @@ export async function createRepo(
82
82
  return { repo, hive };
83
83
  }
84
84
 
85
- // An explicit signer, rather than the Repo's internal default, so the tab's
86
- // identity can be exposed on window.patchwork. The tab never connects via
87
- // Subduction, so this id never goes on the wire.
85
+ // The signer is explicit rather than the Repo's internal default so the
86
+ // identity the tab presents to the server can be shown on window.patchwork.
88
87
  const signer = new MemorySigner();
89
88
  const repo = new Repo({
90
- network: [workerAdapter],
91
- storage: new IndexedDBWorkerStorageAdapter(),
92
89
  signer,
93
- async sharePolicy(peerId) {
94
- return peerId.includes("automerge-worker");
95
- },
96
- enableRemoteHeadsGossiping: true,
90
+ storage: new IndexedDBWorkerStorageAdapter(),
97
91
  peerId:
98
92
  `${storagePrefix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
93
+ subductionWebsocketEndpoints: [syncServer.url],
94
+ subductionAdapters: siblingAdapters(),
95
+ enableRemoteHeadsGossiping: true,
99
96
  });
100
97
  const signerIdentity = {
101
98
  peerId: signer.peerId().toString(),
@@ -108,46 +105,3 @@ export async function createRepo(
108
105
  log("repo created, tab subduction identity:", signerIdentity);
109
106
  return { repo, signerIdentity };
110
107
  }
111
-
112
- /**
113
- * Resolve with the first repo port the worker delivers, calling `onRenewed` for
114
- * every later one.
115
- *
116
- * subscribeToRepoChannel is deliberately not awaited: it resolves only after
117
- * the boot channel's port-ready handshake, which can take its full 30s timeout
118
- * against a stranded worker connection. Boot blocks on the first *delivered*
119
- * port instead — if the boot channel stalls, worker recovery hands the listener
120
- * a good port long before that timeout.
121
- */
122
- export function firstRepoPort(
123
- sw: Awaited<ReturnType<typeof setupServiceWorker>>,
124
- onRenewed: (port: MessagePort) => void
125
- ): Promise<MessagePort> {
126
- return new Promise<MessagePort>((resolve) => {
127
- let seen = false;
128
- void sw.subscribeToRepoChannel((port) => {
129
- if (seen) return onRenewed(port);
130
- seen = true;
131
- resolve(port);
132
- });
133
- });
134
- }
135
-
136
- /** Drop the adapter sitting on the dead worker port, leaving `keep` in place. */
137
- export function removeAdapterFor(
138
- repo: Repo,
139
- stale: MessageChannelNetworkAdapter,
140
- keep: unknown
141
- ): void {
142
- for (const adapter of [...repo.networkSubsystem.adapters]) {
143
- if (adapter === keep) continue;
144
- // The keyhive wrapper keeps the wrapped adapter on `.networkAdapter`.
145
- const base = (adapter as any).networkAdapter ?? adapter;
146
- if (base !== stale) continue;
147
- try {
148
- repo.networkSubsystem.removeNetworkAdapter(adapter as any);
149
- } catch (err) {
150
- console.error("failed to remove stale worker network adapter", err);
151
- }
152
- }
153
- }
@@ -2,9 +2,10 @@ import type { PatchworkSiteOptions } from "./options.js";
2
2
  import type { SyncServerSelection } from "@automerge/automerge-repo-keyhive";
3
3
 
4
4
  // Mirrors core/bootloader/src/sync-config.ts's DEFAULT_CLASSIC_SYNC_SERVER
5
- // and automerge-worker.ts's SUBDUCTION_SYNC_URL selection — kept here as
6
- // plain constants (rather than importing those runtime modules) since this
7
- // only needs the hostnames, not the browser-only logic that reads them.
5
+ // and automerge-protocol-handler-worker.ts's SUBDUCTION_SYNC_URL selection —
6
+ // kept here as plain constants (rather than importing those runtime modules)
7
+ // since this only needs the hostnames, not the browser-only logic that reads
8
+ // them.
8
9
  export const DEFAULT_SYNC_SERVERS = {
9
10
  classic: "wss://sync3.automerge.org",
10
11
  subduction: "wss://subduction.sync.inkandswitch.com",
package/src/types.ts CHANGED
@@ -8,21 +8,27 @@ import type {
8
8
  AccountCreator,
9
9
  AccountDoc,
10
10
  } from "@inkandswitch/patchwork-plugins";
11
- import type {
12
- ServiceWorkerRepoChannelListener,
13
- SyncStateDocMessage,
14
- } from "@inkandswitch/patchwork-bootloader/types";
15
11
  import type * as pluginsNS from "@inkandswitch/patchwork-plugins";
16
12
 
17
13
  export type PluginsApi = typeof pluginsNS;
18
14
 
19
15
  export type SignerIdentity = { peerId: string; verifyingKey: string };
20
16
 
17
+ /** The sync server's last-known heads for one document, as it reports them. */
18
+ export interface SyncStateDocMessage {
19
+ type: "sync-state";
20
+ documentId: string;
21
+ storageId: string;
22
+ heads: string[];
23
+ timestamp: number;
24
+ }
25
+
21
26
  export interface ServiceWorkerApi {
22
27
  connectClassicSync: (server?: string) => Promise<void>;
23
- subscribeToRepoChannel: (
24
- listener: ServiceWorkerRepoChannelListener
25
- ) => Promise<() => void>;
28
+ /**
29
+ * Watch one document's heads at the sync server. Calls `listener` with
30
+ * what the server holds now, then on every update. Returns unsubscribe.
31
+ */
26
32
  subscribeSyncState: (
27
33
  documentId: string,
28
34
  listener: (update: SyncStateDocMessage) => void
@@ -91,7 +97,8 @@ export interface PatchworkOptions {
91
97
  /**
92
98
  * Bring your own Repo. When provided, setup skips wasm initialization and
93
99
  * repo creation entirely — you are responsible for having initialized
94
- * automerge/subduction and wired the automerge-worker port yourself.
100
+ * automerge/subduction and wired the automerge-protocol-handler-worker
101
+ * port yourself.
95
102
  */
96
103
  repo?: Repo;
97
104
 
@@ -40,9 +40,7 @@ export function buildDefines(
40
40
  * used to hand-write in its own vite.config.ts. Each is switched off
41
41
  * individually via the matching `false` option.
42
42
  */
43
- export function config(
44
- options: PatchworkVitePluginOptions = {}
45
- ): Plugin {
43
+ export function config(options: PatchworkVitePluginOptions = {}): Plugin {
46
44
  return {
47
45
  name: "@patchwork/config",
48
46
  config() {
@@ -87,11 +85,11 @@ export function config(
87
85
  },
88
86
  };
89
87
  },
90
- // The shared automerge-worker's chunk imports bypass the page's service
91
- // worker, so offline boot needs the browser's HTTP cache to serve them
92
- // without revalidating. Content hashes make that safe; a new build gets
93
- // new URLs. Production gets this from the generated _headers file — vite
94
- // preview doesn't read that, so mirror it here.
88
+ // The shared automerge-protocol-handler-worker's chunk imports bypass the
89
+ // page's service worker, so offline boot needs the browser's HTTP cache to
90
+ // serve them without revalidating. Content hashes make that safe; a new
91
+ // build gets new URLs. Production gets this from the generated _headers
92
+ // file — vite preview doesn't read that, so mirror it here.
95
93
  configurePreviewServer(server) {
96
94
  if (
97
95
  options.netlify === false ||
@@ -101,10 +99,7 @@ export function config(
101
99
  }
102
100
  server.middlewares.use((req, res, next) => {
103
101
  if (req.url?.startsWith("/assets/")) {
104
- res.setHeader(
105
- "Cache-Control",
106
- "public, max-age=31536000, immutable"
107
- );
102
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
108
103
  }
109
104
  next();
110
105
  });
@@ -19,8 +19,9 @@ export const workers = [
19
19
  fileName: "service-worker.js",
20
20
  },
21
21
  {
22
- specifier: "@inkandswitch/patchwork-bootloader/automerge-worker",
23
- fileName: "automerge-worker.js",
22
+ specifier:
23
+ "@inkandswitch/patchwork-bootloader/automerge-protocol-handler-worker",
24
+ fileName: "automerge-protocol-handler-worker.js",
24
25
  },
25
26
  {
26
27
  specifier: "@inkandswitch/patchwork-bootloader/module-loader-worker",