@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.
@@ -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",
@@ -14,24 +15,26 @@ export const DEFAULT_SYNC_SERVERS = {
14
15
  export function resolvePrimarySyncServer(options: PatchworkSiteOptions): {
15
16
  url: string;
16
17
  keyhive?: SyncServerSelection;
18
+ useIdFactory?: boolean;
17
19
  } {
18
- const servers = options.syncServers || undefined;
19
- if (servers?.keyhive) {
20
- if (typeof servers.keyhive === "string") {
21
- return {
22
- keyhive: servers.keyhive,
23
- url: DEFAULT_SYNC_SERVERS[servers.keyhive],
24
- };
25
- }
26
- 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") {
27
28
  return {
28
- keyhive: identity,
29
- url,
29
+ keyhive: syncServer,
30
+ url: subduction ?? DEFAULT_SYNC_SERVERS[syncServer],
31
+ useIdFactory,
30
32
  };
31
33
  }
32
- return {
33
- url: servers?.subduction ?? DEFAULT_SYNC_SERVERS.subduction,
34
- };
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 };
35
38
  }
36
39
 
37
40
  function wsToHttpOrigin(wsUrl: string): string {
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
  });
@@ -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
 
@@ -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",