@inkandswitch/patchwork 0.8.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @inkandswitch/patchwork
2
2
 
3
+ ## 0.8.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5462610: Apply Patchwork's automerge-repo source patches at bundle time, so a site that installs `@inkandswitch/patchwork` gets them. They were only a pnpm patch, which exists in this repo's node_modules and nowhere else, so every consumer bundled an automerge-repo without the `mesh` subduction role or the `awaiting-reconnect` fix to `isConnecting()`.
8
+
9
+ The new `patches` plugin is part of `patchwork()` and also exported on its own. It rewrites the two files as they pass through rollup, and registers the same rewrite as an esbuild plugin for dep pre-bundling, which runs outside the plugin pipeline. It is pinned to one automerge-repo version and every anchor has to match, so bumping the dependency fails the build rather than quietly dropping the patches. A copy that already carries the edits — this repo's, via the pnpm patch — is left alone.
10
+
11
+ - 5462610: Move keyhive out of `syncServers` and into its own top-level site option: `keyhive?: boolean | { syncServer?, useIdFactory? }`. `keyhive: true` enables it against the `"subduction"` relay; the object form picks a different relay (or a custom `{url, contactCardJson, peerId}` identity) and turns individual behaviour off. `keyhive.useIdFactory: false` drops the `idFactory` ARK injects into the repo config, so document ids are generated the Repo's own way instead of derived from keyhive. It reaches both the tab repo and the automerge protocol handler worker through the `__SYNC_SERVER__` define.
12
+
13
+ `syncServers` is now just URLs — `subduction` and `classic`, no longer mutually exclusive with anything. Sites passing `syncServers: { keyhive: X }` should pass `keyhive: { syncServer: X }` instead; `syncServers.subduction` still overrides the URL a named relay implies.
14
+
15
+ - 5462610: Persist the Subduction signer so every context on an origin presents the same identity. The seed lives in the shared IndexedDB, so tabs and the automerge protocol handler worker adopt one signer instead of each minting a fresh `MemorySigner` on load. Generating it takes a Web Lock, so a cold profile opening two contexts at once still settles on one seed.
16
+
17
+ `@inkandswitch/patchwork-bootloader/signer` is a new export: `loadOrCreateSigner(storage)` returns the origin's `MemorySigner`, generating and storing a seed the first time.
18
+
19
+ - Updated dependencies [5462610]
20
+ - Updated dependencies [5462610]
21
+ - @inkandswitch/patchwork-bootloader@0.7.1
22
+
3
23
  ## 0.8.0
4
24
 
5
25
  ### Minor Changes
package/dist/repo.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { initializeWasm, Repo } from "@automerge/vanillajs/slim";
2
2
  import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
3
3
  import { siblingAdapters } from "@inkandswitch/patchwork-bootloader/siblings";
4
+ import { loadOrCreateSigner } from "@inkandswitch/patchwork-bootloader/signer";
4
5
  import { initKeyhiveWasm, initializeAutomergeRepoKeyhive, } from "@automerge/automerge-repo-keyhive";
5
6
  // eslint-disable-next-line
6
7
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
7
8
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
8
- import { MemorySigner } from "@automerge/automerge-subduction/slim";
9
9
  import { keyhiveStorageName, storagePrefix, } from "@inkandswitch/patchwork-bootloader/storage";
10
10
  import debug from "debug";
11
11
  const log = debug("patchwork:setup:repo");
@@ -39,7 +39,11 @@ export async function createRepo() {
39
39
  log("setting up keyhive");
40
40
  initKeyhiveWasm();
41
41
  const { hive, repo } = await initializeAutomergeRepoKeyhive({
42
- createRepo: (repoConfig) => new Repo(repoConfig),
42
+ // ARK injects an `idFactory` deriving document ids from keyhive. A site
43
+ // can opt out of it with `keyhive: { useIdFactory: false }`.
44
+ createRepo: ({ idFactory, ...repoConfig }) => new Repo(syncServer.useIdFactory === false
45
+ ? repoConfig
46
+ : { ...repoConfig, idFactory }),
43
47
  storage: new IndexedDBWorkerStorageAdapter(keyhiveStorageName),
44
48
  peerIdSuffix: storagePrefix + Math.random().toString(36).slice(2),
45
49
  automaticArchiveIngestion: true,
@@ -56,12 +60,14 @@ export async function createRepo() {
56
60
  log("keyhive setup complete");
57
61
  return { repo, hive };
58
62
  }
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.
61
- const signer = new MemorySigner();
63
+ // The signer is explicit rather than the Repo's internal default so that
64
+ // every tab on this origin signs as the same peer, and so the identity the
65
+ // tab presents to the server can be shown on window.patchwork.
66
+ const storage = new IndexedDBWorkerStorageAdapter();
67
+ const signer = await loadOrCreateSigner(storage);
62
68
  const repo = new Repo({
63
69
  signer,
64
- storage: new IndexedDBWorkerStorageAdapter(),
70
+ storage,
65
71
  peerId: `${storagePrefix}-tab-${crypto.randomUUID()}`,
66
72
  subductionWebsocketEndpoints: [syncServer.url],
67
73
  subductionAdapters: siblingAdapters(),
@@ -7,7 +7,7 @@
7
7
  * wires these into vite's plugin hooks (dev middleware, emitFile, virtual
8
8
  * modules, config()).
9
9
  */
10
- export type { PatchworkSiteOptions, PatchworkIconsOptions, PatchworkHtmlOptions, PatchworkNetlifyOptions, PatchworkKeyhiveSyncServer, PatchworkSyncServersOptions, } from "./options.js";
10
+ export type { PatchworkSiteOptions, PatchworkIconsOptions, PatchworkHtmlOptions, PatchworkNetlifyOptions, PatchworkKeyhiveSyncServer, PatchworkKeyhiveOptions, PatchworkSyncServersOptions, } from "./options.js";
11
11
  export { resolveSyncServers, PRELOAD_WASM_ASSETS } from "./sync-servers.js";
12
12
  export { getIcons, ICON_SPECS, type IconSpec } from "./icons.js";
13
13
  export { buildHtml, escapeHtml } from "./html.js";
@@ -31,20 +31,28 @@ export interface PatchworkNetlifyOptions {
31
31
  /** Cache-Control: immutable on /assets/*. Default true. */
32
32
  immutableAssets?: boolean;
33
33
  }
34
- type PatchworkPrimarySyncServerOptions = {
35
- subduction?: string;
36
- keyhive?: never;
37
- } | {
38
- subduction?: never;
39
- keyhive: PatchworkKeyhiveSyncServer;
40
- };
41
34
  export type PatchworkKeyhiveSyncServer = "keyhive" | "subduction" | ({
42
35
  url: string;
43
36
  } & SyncServerIdentity);
37
+ export interface PatchworkKeyhiveOptions {
38
+ /**
39
+ * Which relay ARK registers and grants access to. Default `"subduction"`.
40
+ * A custom identity also carries the WebSocket URL to reach it on.
41
+ */
42
+ syncServer?: PatchworkKeyhiveSyncServer;
43
+ /**
44
+ * Use the `idFactory` ARK injects into the repo config, which derives
45
+ * document ids from keyhive. Default true. `false` drops it and lets the
46
+ * Repo generate ids its own way.
47
+ */
48
+ useIdFactory?: boolean;
49
+ }
44
50
  export type PatchworkSyncServersOptions = {
45
51
  /** 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. */
46
52
  classic?: string | false;
47
- } & PatchworkPrimarySyncServerOptions;
53
+ /** wss:// URL for the subduction channel. Default: wss://subduction.sync.inkandswitch.com. Overrides the URL a named `keyhive.syncServer` would otherwise imply. */
54
+ subduction?: string;
55
+ };
48
56
  export declare const DEFAULT_TITLE = "Patchwork";
49
57
  export interface PatchworkSiteOptions {
50
58
  /**
@@ -76,12 +84,14 @@ export interface PatchworkSiteOptions {
76
84
  /** manifest background_color */
77
85
  backgroundColor?: string;
78
86
  /**
79
- * Sync-server configuration for this build. Providing `keyhive` enables
80
- * keyhive and selects the relay identity ARK grants access to. Custom
81
- * identities also require their WebSocket URL. `subduction` and `keyhive`
82
- * are mutually exclusive. The live server and `classic` are also emitted as
83
- * connection hints. Pass `false` to keep the default servers but skip those
84
- * hints.
87
+ * Enables keyhive for this build. `true` takes every default; an object
88
+ * picks the relay and turns individual behaviour off. Omitted or `false`
89
+ * builds a plain subduction repo.
90
+ */
91
+ keyhive?: boolean | PatchworkKeyhiveOptions;
92
+ /**
93
+ * The sync-server URLs for this build. Also emitted as connection hints;
94
+ * pass `false` to keep the URLs but skip the hints.
85
95
  */
86
96
  syncServers?: false | PatchworkSyncServersOptions;
87
97
  icons?: false | PatchworkIconsOptions;
@@ -89,4 +99,3 @@ export interface PatchworkSiteOptions {
89
99
  manifest?: false | Record<string, unknown>;
90
100
  netlify?: false | PatchworkNetlifyOptions;
91
101
  }
92
- export {};
@@ -8,6 +8,7 @@ export declare const DEFAULT_SYNC_SERVERS: {
8
8
  export declare function resolvePrimarySyncServer(options: PatchworkSiteOptions): {
9
9
  url: string;
10
10
  keyhive?: SyncServerSelection;
11
+ useIdFactory?: boolean;
11
12
  };
12
13
  /**
13
14
  * Resolves which sync-server origins are actually live for this build: the
@@ -9,23 +9,24 @@ export const DEFAULT_SYNC_SERVERS = {
9
9
  keyhive: "wss://keyhive.sync.automerge.org",
10
10
  };
11
11
  export function resolvePrimarySyncServer(options) {
12
- const servers = options.syncServers || undefined;
13
- if (servers?.keyhive) {
14
- if (typeof servers.keyhive === "string") {
15
- return {
16
- keyhive: servers.keyhive,
17
- url: DEFAULT_SYNC_SERVERS[servers.keyhive],
18
- };
19
- }
20
- const { url, ...identity } = servers.keyhive;
12
+ const subduction = (options.syncServers || undefined)?.subduction;
13
+ if (!options.keyhive) {
14
+ return { url: subduction ?? DEFAULT_SYNC_SERVERS.subduction };
15
+ }
16
+ const keyhive = options.keyhive === true ? {} : options.keyhive;
17
+ const useIdFactory = keyhive.useIdFactory ?? true;
18
+ const syncServer = keyhive.syncServer ?? "subduction";
19
+ if (typeof syncServer === "string") {
21
20
  return {
22
- keyhive: identity,
23
- url,
21
+ keyhive: syncServer,
22
+ url: subduction ?? DEFAULT_SYNC_SERVERS[syncServer],
23
+ useIdFactory,
24
24
  };
25
25
  }
26
- return {
27
- url: servers?.subduction ?? DEFAULT_SYNC_SERVERS.subduction,
28
- };
26
+ // A custom relay carries the only URL it can be reached on, so it wins over
27
+ // `syncServers.subduction`.
28
+ const { url, ...identity } = syncServer;
29
+ return { keyhive: identity, url, useIdFactory };
29
30
  }
30
31
  function wsToHttpOrigin(wsUrl) {
31
32
  return wsUrl.replace(/^ws/, "http");
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from "vite";
2
+ export declare function patches(): Plugin;
@@ -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";
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.8.0",
8
+ "version": "0.8.1",
9
9
  "author": "Ink & Switch",
10
10
  "type": "module",
11
11
  "license": "MIT",
@@ -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.7.0",
86
+ "@inkandswitch/patchwork-bootloader": "^0.7.1",
87
+ "@inkandswitch/patchwork-elements": "^6.0.2",
87
88
  "@inkandswitch/patchwork-filesystem": "^0.2.9",
88
89
  "@inkandswitch/patchwork-plugins": "^1.2.4",
89
- "@inkandswitch/patchwork-providers": "^0.5.2",
90
- "@inkandswitch/patchwork-elements": "^6.0.2"
90
+ "@inkandswitch/patchwork-providers": "^0.5.2"
91
91
  },
92
92
  "devDependencies": {
93
93
  "rollup": "^4.61.1",
package/src/repo.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { initializeWasm, Repo } from "@automerge/vanillajs/slim";
2
2
  import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb/IndexedDBWorkerStorageAdapter";
3
3
  import { siblingAdapters } from "@inkandswitch/patchwork-bootloader/siblings";
4
+ import { loadOrCreateSigner } from "@inkandswitch/patchwork-bootloader/signer";
4
5
  import * as AutomergeRepo from "@automerge/automerge-repo/slim";
5
6
  import {
6
7
  initKeyhiveWasm,
@@ -11,7 +12,6 @@ import {
11
12
  // eslint-disable-next-line
12
13
  // @ts-ignore — initSync is a wasm-bindgen runtime helper not in the .d.ts
13
14
  import { initSync as initSubductionSync } from "@automerge/automerge-subduction/slim";
14
- import { MemorySigner } from "@automerge/automerge-subduction/slim";
15
15
  import {
16
16
  keyhiveStorageName,
17
17
  storagePrefix,
@@ -24,6 +24,7 @@ const log = debug("patchwork:setup:repo");
24
24
  declare const __SYNC_SERVER__: {
25
25
  url: string;
26
26
  keyhive?: SyncServerSelection;
27
+ useIdFactory?: boolean;
27
28
  };
28
29
  const syncServer =
29
30
  typeof __SYNC_SERVER__ !== "undefined"
@@ -64,7 +65,14 @@ export async function createRepo(): Promise<TabRepo> {
64
65
  log("setting up keyhive");
65
66
  initKeyhiveWasm();
66
67
  const { hive, repo } = await initializeAutomergeRepoKeyhive({
67
- createRepo: (repoConfig) => new Repo(repoConfig),
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
78
  automaticArchiveIngestion: true,
@@ -82,12 +90,14 @@ export async function createRepo(): Promise<TabRepo> {
82
90
  return { repo, hive };
83
91
  }
84
92
 
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.
87
- 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);
88
98
  const repo = new Repo({
89
99
  signer,
90
- storage: new IndexedDBWorkerStorageAdapter(),
100
+ storage,
91
101
  peerId:
92
102
  `${storagePrefix}-tab-${crypto.randomUUID()}` as AutomergeRepo.PeerId,
93
103
  subductionWebsocketEndpoints: [syncServer.url],
@@ -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
 
@@ -15,24 +15,26 @@ export const DEFAULT_SYNC_SERVERS = {
15
15
  export function resolvePrimarySyncServer(options: PatchworkSiteOptions): {
16
16
  url: string;
17
17
  keyhive?: SyncServerSelection;
18
+ useIdFactory?: boolean;
18
19
  } {
19
- const servers = options.syncServers || undefined;
20
- if (servers?.keyhive) {
21
- if (typeof servers.keyhive === "string") {
22
- return {
23
- keyhive: servers.keyhive,
24
- url: DEFAULT_SYNC_SERVERS[servers.keyhive],
25
- };
26
- }
27
- const { url, ...identity } = servers.keyhive;
20
+ const subduction = (options.syncServers || undefined)?.subduction;
21
+ if (!options.keyhive) {
22
+ return { url: subduction ?? DEFAULT_SYNC_SERVERS.subduction };
23
+ }
24
+ const keyhive = options.keyhive === true ? {} : options.keyhive;
25
+ const useIdFactory = keyhive.useIdFactory ?? true;
26
+ const syncServer = keyhive.syncServer ?? "subduction";
27
+ if (typeof syncServer === "string") {
28
28
  return {
29
- keyhive: identity,
30
- url,
29
+ keyhive: syncServer,
30
+ url: subduction ?? DEFAULT_SYNC_SERVERS[syncServer],
31
+ useIdFactory,
31
32
  };
32
33
  }
33
- return {
34
- url: servers?.subduction ?? DEFAULT_SYNC_SERVERS.subduction,
35
- };
34
+ // A custom relay carries the only URL it can be reached on, so it wins over
35
+ // `syncServers.subduction`.
36
+ const { url, ...identity } = syncServer;
37
+ return { keyhive: identity, url, useIdFactory };
36
38
  }
37
39
 
38
40
  function wsToHttpOrigin(wsUrl: string): string {
@@ -0,0 +1,184 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { Plugin } from "vite";
4
+
5
+ /**
6
+ * Source patches to @automerge/automerge-repo, applied as it passes through
7
+ * the bundler.
8
+ *
9
+ * The same edits live in this repo's `patches/` as a pnpm patch, which is what
10
+ * makes our own typecheck see the widened `role` type. A pnpm patch only
11
+ * exists in this repo's node_modules, though: a site that installs
12
+ * @inkandswitch/patchwork resolves automerge-repo out of its own tree and
13
+ * would bundle it unpatched. These run there too.
14
+ *
15
+ * Both edits are upstream-shaped and meant to be deleted once subduction takes
16
+ * them. Until then they are pinned to one automerge-repo version and every
17
+ * anchor has to match, so a dependency bump fails the build instead of quietly
18
+ * un-patching it.
19
+ */
20
+ const AUTOMERGE_REPO = "@automerge/automerge-repo";
21
+ const VERSION = "2.6.0-subduction.48";
22
+
23
+ type Edit = { find: string; replace: string };
24
+
25
+ const PATCHES: Record<string, Edit[]> = {
26
+ "dist/subduction/AdapterConnections.js": [
27
+ {
28
+ find: ` if (role === "accept") {
29
+ await subduction.acceptTransport(transport, serviceName);
30
+ }
31
+ else {
32
+ await subduction.connectTransport(transport, serviceName);
33
+ }`,
34
+ replace: ` const initiate = role === "mesh" ? this.#localPeerId < peerId : role !== "accept";
35
+ if (initiate) {
36
+ await subduction.connectTransport(transport, serviceName);
37
+ }
38
+ else {
39
+ await subduction.acceptTransport(transport, serviceName);
40
+ }`,
41
+ },
42
+ ],
43
+ "dist/subduction/SubductionConnections.js": [
44
+ {
45
+ find: ` if (state === "connecting")
46
+ return true;`,
47
+ replace: ` // "awaiting-reconnect" counts: the loop is between attempts, not
48
+ // given up, so a query should wait rather than report unavailable.
49
+ if (state === "connecting" || state === "awaiting-reconnect")
50
+ return true;`,
51
+ },
52
+ ],
53
+ };
54
+
55
+ function match(id: string): { file: string; root: string } | undefined {
56
+ const path = id.replace(/\\/g, "/").split("?")[0];
57
+ for (const file of Object.keys(PATCHES)) {
58
+ const suffix = `/${AUTOMERGE_REPO}/${file}`;
59
+ if (path.endsWith(suffix)) {
60
+ return {
61
+ file,
62
+ root: path.slice(0, -suffix.length) + `/${AUTOMERGE_REPO}`,
63
+ };
64
+ }
65
+ }
66
+ }
67
+
68
+ const versions = new Map<string, Promise<string>>();
69
+
70
+ async function assertVersion(root: string, file: string): Promise<void> {
71
+ let version = versions.get(root);
72
+ if (!version) {
73
+ version = readFile(join(root, "package.json"), "utf8").then(
74
+ (json) => JSON.parse(json).version,
75
+ (error) => {
76
+ throw new Error(
77
+ `@inkandswitch/patchwork: couldn't read ${root}/package.json to ` +
78
+ `check the version the source patches are written against. ` +
79
+ `Has the package's layout changed? (${error})`
80
+ );
81
+ }
82
+ );
83
+ versions.set(root, version);
84
+ }
85
+ if ((await version) !== VERSION) {
86
+ throw new Error(
87
+ `@inkandswitch/patchwork: ${AUTOMERGE_REPO} is ${await version}, and the ` +
88
+ `source patches in patches-plugin.ts are written against ${VERSION}. ` +
89
+ `Re-check them against the new version (${file} is one of the files ` +
90
+ `they edit), then bump VERSION.`
91
+ );
92
+ }
93
+ }
94
+
95
+ function apply(code: string, file: string): string {
96
+ return PATCHES[file].reduce((code, { find, replace }) => {
97
+ if (code.includes(replace)) return code;
98
+ const matches = code.split(find).length - 1;
99
+ if (matches !== 1) {
100
+ throw new Error(
101
+ `@inkandswitch/patchwork: the source patch for ${AUTOMERGE_REPO}'s ` +
102
+ `${file} matched ${matches} times, expected 1. The file is the ` +
103
+ `version it says it is, so the patch needs rewriting against it.`
104
+ );
105
+ }
106
+ return code.replace(find, replace);
107
+ }, code);
108
+ }
109
+
110
+ async function patchFile(id: string): Promise<string | undefined> {
111
+ const found = match(id);
112
+ if (!found) return;
113
+ await assertVersion(found.root, found.file);
114
+ return apply(await readFile(id, "utf8"), found.file);
115
+ }
116
+
117
+ export function patches(): Plugin {
118
+ const seen = new Set<string>();
119
+ let serve = false;
120
+
121
+ return {
122
+ name: "@patchwork/patches",
123
+ enforce: "pre",
124
+
125
+ // Dep pre-bundling runs esbuild directly, outside the plugin pipeline that
126
+ // applies `transform`. Without this the dev server would serve an
127
+ // unpatched automerge-repo while the build patched it.
128
+ config() {
129
+ return {
130
+ optimizeDeps: {
131
+ esbuildOptions: {
132
+ plugins: [
133
+ {
134
+ name: "patchwork-automerge-repo-patches",
135
+ setup(build: {
136
+ onLoad(
137
+ options: { filter: RegExp },
138
+ callback: (args: {
139
+ path: string;
140
+ }) => Promise<
141
+ { contents: string; loader: "js" } | undefined
142
+ >
143
+ ): void;
144
+ }) {
145
+ build.onLoad(
146
+ { filter: /automerge-repo[\\/]dist[\\/]subduction[\\/]/ },
147
+ async ({ path }) => {
148
+ const contents = await patchFile(path);
149
+ return contents ? { contents, loader: "js" } : undefined;
150
+ }
151
+ );
152
+ },
153
+ },
154
+ ],
155
+ },
156
+ },
157
+ };
158
+ },
159
+
160
+ configResolved(config) {
161
+ serve = config.command === "serve";
162
+ },
163
+
164
+ async transform(code, id) {
165
+ const found = match(id);
166
+ if (!found) return;
167
+ await assertVersion(found.root, found.file);
168
+ seen.add(found.file);
169
+ return apply(code, found.file);
170
+ },
171
+
172
+ buildEnd() {
173
+ if (serve) return;
174
+ const missing = Object.keys(PATCHES).filter((file) => !seen.has(file));
175
+ if (missing.length) {
176
+ throw new Error(
177
+ `@inkandswitch/patchwork: ${AUTOMERGE_REPO}'s ${missing.join(", ")} ` +
178
+ `never reached the bundler, so the source patches for them didn't ` +
179
+ `apply. Has the package's layout changed?`
180
+ );
181
+ }
182
+ },
183
+ };
184
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Plugin, ServerOptions, PreviewOptions, BuildOptions } from "vite";
2
2
 
3
3
  import { importmap } from "./importmap-plugin.js";
4
+ import { patches } from "./patches-plugin.js";
4
5
  import { serviceworker } from "./service-worker-plugin.js";
5
6
  import { config, wasm } from "./config-plugin.js";
6
7
  import { dev } from "./dev-plugin.js";
@@ -42,6 +43,7 @@ export default function patchwork(options?: PatchworkVitePluginOptions) {
42
43
  manifest(options),
43
44
  netlify(options),
44
45
  importmap(options),
46
+ patches(),
45
47
  serviceworker(),
46
48
  dev(options),
47
49
  statics(options),
@@ -50,6 +52,7 @@ export default function patchwork(options?: PatchworkVitePluginOptions) {
50
52
  }
51
53
 
52
54
  export { importmap, builtins, devDependencyId } from "./importmap-plugin.js";
55
+ export { patches } from "./patches-plugin.js";
53
56
  export { serviceworker, workers } from "./service-worker-plugin.js";
54
57
  export { config, buildDefines, wasm } from "./config-plugin.js";
55
58
  export { dev } from "./dev-plugin.js";
@@ -74,6 +77,7 @@ export type {
74
77
  PatchworkHtmlOptions,
75
78
  PatchworkNetlifyOptions,
76
79
  PatchworkKeyhiveSyncServer,
80
+ PatchworkKeyhiveOptions,
77
81
  PatchworkSyncServersOptions,
78
82
  } from "../site-kit/options.js";
79
83