@inkandswitch/patchwork 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Source patches to @automerge/automerge-repo, applied as it passes through
5
+ * the bundler.
6
+ *
7
+ * The same edits live in this repo's `patches/` as a pnpm patch, which is what
8
+ * makes our own typecheck see the widened `role` type. A pnpm patch only
9
+ * exists in this repo's node_modules, though: a site that installs
10
+ * @inkandswitch/patchwork resolves automerge-repo out of its own tree and
11
+ * would bundle it unpatched. These run there too.
12
+ *
13
+ * Both edits are upstream-shaped and meant to be deleted once subduction takes
14
+ * them. Until then they are pinned to one automerge-repo version and every
15
+ * anchor has to match, so a dependency bump fails the build instead of quietly
16
+ * un-patching it.
17
+ */
18
+ const AUTOMERGE_REPO = "@automerge/automerge-repo";
19
+ const VERSION = "2.6.0-subduction.48";
20
+ const PATCHES = {
21
+ "dist/subduction/AdapterConnections.js": [
22
+ {
23
+ find: ` if (role === "accept") {
24
+ await subduction.acceptTransport(transport, serviceName);
25
+ }
26
+ else {
27
+ await subduction.connectTransport(transport, serviceName);
28
+ }`,
29
+ replace: ` const initiate = role === "mesh" ? this.#localPeerId < peerId : role !== "accept";
30
+ if (initiate) {
31
+ await subduction.connectTransport(transport, serviceName);
32
+ }
33
+ else {
34
+ await subduction.acceptTransport(transport, serviceName);
35
+ }`,
36
+ },
37
+ ],
38
+ "dist/subduction/SubductionConnections.js": [
39
+ {
40
+ find: ` if (state === "connecting")
41
+ return true;`,
42
+ replace: ` // "awaiting-reconnect" counts: the loop is between attempts, not
43
+ // given up, so a query should wait rather than report unavailable.
44
+ if (state === "connecting" || state === "awaiting-reconnect")
45
+ return true;`,
46
+ },
47
+ ],
48
+ };
49
+ function match(id) {
50
+ const path = id.replace(/\\/g, "/").split("?")[0];
51
+ for (const file of Object.keys(PATCHES)) {
52
+ const suffix = `/${AUTOMERGE_REPO}/${file}`;
53
+ if (path.endsWith(suffix)) {
54
+ return {
55
+ file,
56
+ root: path.slice(0, -suffix.length) + `/${AUTOMERGE_REPO}`,
57
+ };
58
+ }
59
+ }
60
+ }
61
+ const versions = new Map();
62
+ async function assertVersion(root, file) {
63
+ let version = versions.get(root);
64
+ if (!version) {
65
+ version = readFile(join(root, "package.json"), "utf8").then((json) => JSON.parse(json).version, (error) => {
66
+ throw new Error(`@inkandswitch/patchwork: couldn't read ${root}/package.json to ` +
67
+ `check the version the source patches are written against. ` +
68
+ `Has the package's layout changed? (${error})`);
69
+ });
70
+ versions.set(root, version);
71
+ }
72
+ if ((await version) !== VERSION) {
73
+ throw new Error(`@inkandswitch/patchwork: ${AUTOMERGE_REPO} is ${await version}, and the ` +
74
+ `source patches in patches-plugin.ts are written against ${VERSION}. ` +
75
+ `Re-check them against the new version (${file} is one of the files ` +
76
+ `they edit), then bump VERSION.`);
77
+ }
78
+ }
79
+ function apply(code, file) {
80
+ return PATCHES[file].reduce((code, { find, replace }) => {
81
+ if (code.includes(replace))
82
+ return code;
83
+ const matches = code.split(find).length - 1;
84
+ if (matches !== 1) {
85
+ throw new Error(`@inkandswitch/patchwork: the source patch for ${AUTOMERGE_REPO}'s ` +
86
+ `${file} matched ${matches} times, expected 1. The file is the ` +
87
+ `version it says it is, so the patch needs rewriting against it.`);
88
+ }
89
+ return code.replace(find, replace);
90
+ }, code);
91
+ }
92
+ async function patchFile(id) {
93
+ const found = match(id);
94
+ if (!found)
95
+ return;
96
+ await assertVersion(found.root, found.file);
97
+ return apply(await readFile(id, "utf8"), found.file);
98
+ }
99
+ export function patches() {
100
+ const seen = new Set();
101
+ let serve = false;
102
+ return {
103
+ name: "@patchwork/patches",
104
+ enforce: "pre",
105
+ // Dep pre-bundling runs esbuild directly, outside the plugin pipeline that
106
+ // applies `transform`. Without this the dev server would serve an
107
+ // unpatched automerge-repo while the build patched it.
108
+ config() {
109
+ return {
110
+ optimizeDeps: {
111
+ esbuildOptions: {
112
+ plugins: [
113
+ {
114
+ name: "patchwork-automerge-repo-patches",
115
+ setup(build) {
116
+ build.onLoad({ filter: /automerge-repo[\\/]dist[\\/]subduction[\\/]/ }, async ({ path }) => {
117
+ const contents = await patchFile(path);
118
+ return contents ? { contents, loader: "js" } : undefined;
119
+ });
120
+ },
121
+ },
122
+ ],
123
+ },
124
+ },
125
+ };
126
+ },
127
+ configResolved(config) {
128
+ serve = config.command === "serve";
129
+ },
130
+ async transform(code, id) {
131
+ const found = match(id);
132
+ if (!found)
133
+ return;
134
+ await assertVersion(found.root, found.file);
135
+ seen.add(found.file);
136
+ return apply(code, found.file);
137
+ },
138
+ buildEnd() {
139
+ if (serve)
140
+ return;
141
+ const missing = Object.keys(PATCHES).filter((file) => !seen.has(file));
142
+ if (missing.length) {
143
+ throw new Error(`@inkandswitch/patchwork: ${AUTOMERGE_REPO}'s ${missing.join(", ")} ` +
144
+ `never reached the bundler, so the source patches for them didn't ` +
145
+ `apply. Has the package's layout changed?`);
146
+ }
147
+ },
148
+ };
149
+ }
@@ -24,6 +24,7 @@ import type { PatchworkSiteOptions } from "../site-kit/options.js";
24
24
  */
25
25
  export default function patchwork(options?: PatchworkVitePluginOptions): Plugin<any>[];
26
26
  export { importmap, builtins, devDependencyId } from "./importmap-plugin.js";
27
+ export { patches } from "./patches-plugin.js";
27
28
  export { serviceworker, workers } from "./service-worker-plugin.js";
28
29
  export { config, buildDefines, wasm } from "./config-plugin.js";
29
30
  export { dev } from "./dev-plugin.js";
@@ -43,7 +44,7 @@ export type ImportMap = {
43
44
  };
44
45
  };
45
46
  export type { PatchworkStaticSource } from "./static-plugin.js";
46
- export type { PatchworkSiteOptions, PatchworkIconsOptions, PatchworkHtmlOptions, PatchworkNetlifyOptions, PatchworkKeyhiveSyncServer, PatchworkSyncServersOptions, } from "../site-kit/options.js";
47
+ export type { PatchworkSiteOptions, PatchworkIconsOptions, PatchworkHtmlOptions, PatchworkNetlifyOptions, PatchworkKeyhiveSyncServer, PatchworkKeyhiveOptions, PatchworkSyncServersOptions, } from "../site-kit/options.js";
47
48
  export interface PatchworkVitePluginOptions extends PatchworkSiteOptions {
48
49
  importmap?: ImportMap;
49
50
  /**
@@ -1,4 +1,5 @@
1
1
  import { importmap } from "./importmap-plugin.js";
2
+ import { patches } from "./patches-plugin.js";
2
3
  import { serviceworker } from "./service-worker-plugin.js";
3
4
  import { config, wasm } from "./config-plugin.js";
4
5
  import { dev } from "./dev-plugin.js";
@@ -38,6 +39,7 @@ export default function patchwork(options) {
38
39
  manifest(options),
39
40
  netlify(options),
40
41
  importmap(options),
42
+ patches(),
41
43
  serviceworker(),
42
44
  dev(options),
43
45
  statics(options),
@@ -45,6 +47,7 @@ export default function patchwork(options) {
45
47
  ].filter((plugin) => plugin != null);
46
48
  }
47
49
  export { importmap, builtins, devDependencyId } from "./importmap-plugin.js";
50
+ export { patches } from "./patches-plugin.js";
48
51
  export { serviceworker, workers } from "./service-worker-plugin.js";
49
52
  export { config, buildDefines, wasm } from "./config-plugin.js";
50
53
  export { dev } from "./dev-plugin.js";
@@ -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.1",
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,10 +83,10 @@
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",
86
+ "@inkandswitch/patchwork-bootloader": "^0.7.1",
87
87
  "@inkandswitch/patchwork-elements": "^6.0.2",
88
- "@inkandswitch/patchwork-filesystem": "^0.2.8",
89
- "@inkandswitch/patchwork-plugins": "^1.2.3",
88
+ "@inkandswitch/patchwork-filesystem": "^0.2.9",
89
+ "@inkandswitch/patchwork-plugins": "^1.2.4",
90
90
  "@inkandswitch/patchwork-providers": "^0.5.2"
91
91
  },
92
92
  "devDependencies": {
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";
4
+ import { loadOrCreateSigner } from "@inkandswitch/patchwork-bootloader/signer";
8
5
  import * as AutomergeRepo from "@automerge/automerge-repo/slim";
9
6
  import {
10
7
  initKeyhiveWasm,
11
- initializeLegacyAutomergeRepoKeyhive,
12
- type AutomergeRepoKeyhiveBase,
8
+ initializeAutomergeRepoKeyhive,
9
+ type AutomergeRepoKeyhive,
13
10
  type SyncServerSelection,
14
11
  } from "@automerge/automerge-repo-keyhive";
15
12
  // eslint-disable-next-line
16
13
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
17
14
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
18
- import { MemorySigner } from "@automerge/automerge-subduction/slim";
19
- import setupServiceWorker from "@inkandswitch/patchwork-bootloader";
20
15
  import {
21
16
  keyhiveStorageName,
22
17
  storagePrefix,
@@ -29,6 +24,7 @@ const log = debug("patchwork:setup:repo");
29
24
  declare const __SYNC_SERVER__: {
30
25
  url: string;
31
26
  keyhive?: SyncServerSelection;
27
+ useIdFactory?: boolean;
32
28
  };
33
29
  const syncServer =
34
30
  typeof __SYNC_SERVER__ !== "undefined"
@@ -53,28 +49,40 @@ export function initWasm(): Promise<void> {
53
49
  return wasmReady;
54
50
  }
55
51
 
56
- export async function createRepo(
57
- workerAdapter: MessageChannelNetworkAdapter
58
- ): Promise<{
52
+ export type TabRepo = {
59
53
  repo: Repo;
60
- hive?: AutomergeRepoKeyhiveBase;
54
+ hive?: AutomergeRepoKeyhive;
61
55
  signerIdentity?: SignerIdentity;
62
- }> {
56
+ };
57
+
58
+ /**
59
+ * The tab's own node: this origin's IndexedDB, a socket to the sync server,
60
+ * and the siblings channel to every other Repo on the origin. Nothing is
61
+ * shared with other tabs except the database underneath.
62
+ */
63
+ export async function createRepo(): Promise<TabRepo> {
63
64
  if (syncServer.keyhive) {
64
65
  log("setting up keyhive");
65
66
  initKeyhiveWasm();
66
- const { hive, repo } = await initializeLegacyAutomergeRepoKeyhive({
67
- createRepo: (repoConfig) => new Repo(repoConfig),
67
+ const { hive, repo } = await initializeAutomergeRepoKeyhive({
68
+ // ARK injects an `idFactory` deriving document ids from keyhive. A site
69
+ // can opt out of it with `keyhive: { useIdFactory: false }`.
70
+ createRepo: ({ idFactory, ...repoConfig }) =>
71
+ new Repo(
72
+ syncServer.useIdFactory === false
73
+ ? repoConfig
74
+ : { ...repoConfig, idFactory }
75
+ ),
68
76
  storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName),
69
77
  peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2),
70
- networkAdapter: workerAdapter,
71
78
  automaticArchiveIngestion: true,
72
79
  cachingMode: "periodic",
73
- onlyShareWithSyncServer: false,
74
80
  // ARK selects the relay via `syncServer`, defaulting to "subduction".
75
81
  syncServer: syncServer.keyhive,
76
82
  repo: {
77
83
  storage: new IndexedDBWorkerStorageAdapter(),
84
+ subductionWebsocketEndpoints: [syncServer.url],
85
+ subductionAdapters: siblingAdapters(),
78
86
  enableRemoteHeadsGossiping: true,
79
87
  },
80
88
  });
@@ -82,20 +90,19 @@ export async function createRepo(
82
90
  return { repo, hive };
83
91
  }
84
92
 
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.
88
- const signer = new MemorySigner();
93
+ // The signer is explicit rather than the Repo's internal default so that
94
+ // every tab on this origin signs as the same peer, and so the identity the
95
+ // tab presents to the server can be shown on window.patchwork.
96
+ const storage = new IndexedDBWorkerStorageAdapter();
97
+ const signer = await loadOrCreateSigner(storage);
89
98
  const repo = new Repo({
90
- network: [workerAdapter],
91
- storage: new IndexedDBWorkerStorageAdapter(),
92
99
  signer,
93
- async sharePolicy(peerId) {
94
- return peerId.includes("automerge-worker");
95
- },
96
- enableRemoteHeadsGossiping: true,
100
+ storage,
97
101
  peerId:
98
102
  `${storagePrefix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
103
+ subductionWebsocketEndpoints: [syncServer.url],
104
+ subductionAdapters: siblingAdapters(),
105
+ enableRemoteHeadsGossiping: true,
99
106
  });
100
107
  const signerIdentity = {
101
108
  peerId: signer.peerId().toString(),
@@ -108,46 +115,3 @@ export async function createRepo(
108
115
  log("repo created, tab subduction identity:", signerIdentity);
109
116
  return { repo, signerIdentity };
110
117
  }
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
- }
@@ -13,6 +13,7 @@ export type {
13
13
  PatchworkHtmlOptions,
14
14
  PatchworkNetlifyOptions,
15
15
  PatchworkKeyhiveSyncServer,
16
+ PatchworkKeyhiveOptions,
16
17
  PatchworkSyncServersOptions,
17
18
  } from "./options.js";
18
19
 
@@ -36,19 +36,31 @@ export interface PatchworkNetlifyOptions {
36
36
  immutableAssets?: boolean;
37
37
  }
38
38
 
39
- type PatchworkPrimarySyncServerOptions =
40
- | { subduction?: string; keyhive?: never }
41
- | { subduction?: never; keyhive: PatchworkKeyhiveSyncServer };
42
-
43
39
  export type PatchworkKeyhiveSyncServer =
44
40
  | "keyhive"
45
41
  | "subduction"
46
42
  | ({ url: string } & SyncServerIdentity);
47
43
 
44
+ export interface PatchworkKeyhiveOptions {
45
+ /**
46
+ * Which relay ARK registers and grants access to. Default `"subduction"`.
47
+ * A custom identity also carries the WebSocket URL to reach it on.
48
+ */
49
+ syncServer?: PatchworkKeyhiveSyncServer;
50
+ /**
51
+ * Use the `idFactory` ARK injects into the repo config, which derives
52
+ * document ids from keyhive. Default true. `false` drops it and lets the
53
+ * Repo generate ids its own way.
54
+ */
55
+ useIdFactory?: boolean;
56
+ }
57
+
48
58
  export type PatchworkSyncServersOptions = {
49
59
  /** wss:// URL for the legacy automerge-repo sync-server channel (connected on demand via connectClassicSync). Default: wss://sync3.automerge.org. Pass false to skip its preconnect hint. */
50
60
  classic?: string | false;
51
- } & PatchworkPrimarySyncServerOptions;
61
+ /** wss:// URL for the subduction channel. Default: wss://subduction.sync.inkandswitch.com. Overrides the URL a named `keyhive.syncServer` would otherwise imply. */
62
+ subduction?: string;
63
+ };
52
64
 
53
65
  export const DEFAULT_TITLE = "Patchwork";
54
66
 
@@ -81,12 +93,15 @@ export interface PatchworkSiteOptions {
81
93
  backgroundColor?: string;
82
94
 
83
95
  /**
84
- * Sync-server configuration for this build. Providing `keyhive` enables
85
- * keyhive and selects the relay identity ARK grants access to. Custom
86
- * identities also require their WebSocket URL. `subduction` and `keyhive`
87
- * are mutually exclusive. The live server and `classic` are also emitted as
88
- * connection hints. Pass `false` to keep the default servers but skip those
89
- * hints.
96
+ * Enables keyhive for this build. `true` takes every default; an object
97
+ * picks the relay and turns individual behaviour off. Omitted or `false`
98
+ * builds a plain subduction repo.
99
+ */
100
+ keyhive?: boolean | PatchworkKeyhiveOptions;
101
+
102
+ /**
103
+ * The sync-server URLs for this build. Also emitted as connection hints;
104
+ * pass `false` to keep the URLs but skip the hints.
90
105
  */
91
106
  syncServers?: false | PatchworkSyncServersOptions;
92
107