@inkandswitch/patchwork 0.5.0 → 0.6.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,50 @@
1
1
  # @inkandswitch/patchwork
2
2
 
3
+ ## 0.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - f37bb8e: Bump `@automerge/automerge-subduction` to 0.16.1.
8
+ - Updated dependencies [f37bb8e]
9
+ - @inkandswitch/patchwork-bootloader@0.6.2
10
+
11
+ ## 0.6.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 61a152a: Add a `frameToolId` option to `setup`, and stop seeding a frame tool into new accounts.
16
+
17
+ `createDefaultAccount` wrote `frameToolId: "threepane"` into every account it created — a tool id belonging to one particular tool bundle, hardcoded in core. A site whose `packageListURL` didn't ship `threepane` gave every new user an account pointing at a tool that never registers, so the root view never mounted.
18
+
19
+ New accounts now leave `frameToolId` unset, and the router resolves the frame each boot:
20
+
21
+ ```
22
+ #frame= → the account's frameToolId → setup({frameToolId}) → first tool tagged frame-tool
23
+ ```
24
+
25
+ The field is still written to the account when a user picks a frame, so it stays a user preference; it is no longer decided for them at signup. Existing accounts already have it set and are unaffected.
26
+
27
+ Sites relying on the old seeded default should pass `frameToolId: "threepane"` to `setup`.
28
+
29
+ - 61a152a: Make `vite dev` work without a site hand-rolling the dev server.
30
+
31
+ The build emits the service worker, the automerge shared worker, the module-loader worker, and three wasm binaries. None of them existed in serve mode, so every one 404'd and dev only worked if a site served a previous production build's `dist/` behind vite. The plugin now serves all of them itself:
32
+
33
+ - The three worker entries are bundled on demand with esbuild and rebuilt per request, so they always reflect the source on disk. Their heavy imports resolve to the dev server's optimized-dep URLs, mirroring how the build rewrites them to `/packages/...` — import maps don't apply to `type: "module"` workers, so the URLs have to be real either way.
34
+ - `automerge.wasm`, `keyhive_wasm.wasm`, and `subduction.wasm` are served from the bootloader's own node_modules. `@inkandswitch/patchwork-bootloader/externals` gains `wasmAssets()`, which `emitWasmAssets` now uses too.
35
+ - `global.css` 404'd in dev: the generated `index.html` links it by bare specifier, which only the build resolves. Both patchwork's and the bootloader's stylesheets are now served under root-absolute paths, and the link points at them.
36
+ - `@patchwork/service-worker` called `emitFile` from `buildStart` unconditionally, which throws in serve mode — it logged "This plugin is likely not vite-compatible" three times on every dev-server start. It now skips emission when serving.
37
+ - Dep pre-bundling runs esbuild outside the plugin pipeline that applies `define`, so in dev the page fell back to the default storage prefix while the workers used the configured one — the two would have opened different IndexedDB databases. The defines are now passed to the optimizer as well.
38
+
39
+ Sites no longer need to filter `@patchwork/service-worker` out of the plugin list, serve stylesheets themselves, or keep a built `dist/` around for `vite dev`.
40
+
41
+ ### Patch Changes
42
+
43
+ - 846cfac: Update the pinned `@automerge/*` versions to `2.6.0-subduction.47`. These are exact pins in `dependencies` and `peerDependencies`, so both packages need to ship the new version together — installing a `.46` and a `.47` package side by side loads two copies of automerge-repo, and document handles from one are not recognised by the other.
44
+ - Updated dependencies [846cfac]
45
+ - Updated dependencies [61a152a]
46
+ - @inkandswitch/patchwork-bootloader@0.6.1
47
+
3
48
  ## 0.5.0
4
49
 
5
50
  ### Minor Changes
@@ -20,7 +20,6 @@ export const createDefaultAccount = async (accountHandle, repo) => {
20
20
  }),
21
21
  ]);
22
22
  accountHandle.change((doc) => {
23
- doc.frameToolId = "threepane";
24
23
  doc.rootFolderUrl = rootFolder.url;
25
24
  doc.moduleSettingsUrl = moduleSettings.url;
26
25
  doc.contactUrl = contact.url;
package/dist/index.js CHANGED
@@ -155,6 +155,7 @@ async function doSetup(options) {
155
155
  repo,
156
156
  accountDocHandle,
157
157
  siteTitle,
158
+ frameToolId: options.frameToolId,
158
159
  });
159
160
  }
160
161
  installReveal(rootElement, router, toolsLoaded);
package/dist/router.d.ts CHANGED
@@ -10,9 +10,10 @@ export interface RouterParams {
10
10
  repo: Repo;
11
11
  accountDocHandle: DocHandle<AccountDoc>;
12
12
  siteTitle: string;
13
+ frameToolId?: string;
13
14
  }
14
15
  export interface Router {
15
16
  /** Apply `location.hash` to the view. */
16
17
  route(): Promise<void>;
17
18
  }
18
- export declare function createRouter({ rootElement, repo, accountDocHandle, siteTitle, }: RouterParams): Router;
19
+ export declare function createRouter({ rootElement, repo, accountDocHandle, siteTitle, frameToolId, }: RouterParams): Router;
package/dist/router.js CHANGED
@@ -47,14 +47,17 @@ function registeredFrameToolId() {
47
47
  .filter((tool) => !!tool.tags?.includes("frame-tool") && !tool.unlisted)
48
48
  .at(0)?.id;
49
49
  }
50
- export function createRouter({ rootElement, repo, accountDocHandle, siteTitle, }) {
50
+ export function createRouter({ rootElement, repo, accountDocHandle, siteTitle, frameToolId, }) {
51
51
  const route = async () => {
52
52
  // The first call seeds the root view's tool/doc so it can mount; later
53
53
  // calls reconcile the mounted view with the hash.
54
54
  if (!rootElement.hasAttribute("tool-id")) {
55
55
  const params = new URLSearchParams(location.hash.slice(1));
56
56
  const frame = params.get("frame");
57
- const toolId = frame ?? accountDocHandle.doc().frameToolId ?? registeredFrameToolId();
57
+ const toolId = frame ??
58
+ accountDocHandle.doc().frameToolId ??
59
+ frameToolId ??
60
+ registeredFrameToolId();
58
61
  if (!toolId) {
59
62
  console.error("patchwork: no frame tool registered, nothing to mount");
60
63
  return;
package/dist/types.d.ts CHANGED
@@ -55,6 +55,16 @@ export interface PatchworkOptions {
55
55
  * then `"Patchwork"`.
56
56
  */
57
57
  title?: string;
58
+ /**
59
+ * Id of the tool that frames this site — what mounts in the root view when
60
+ * the user hasn't chosen one on their account and the URL doesn't name one
61
+ * via `#frame=`. Must be a tool one of the {@link
62
+ * PatchworkOptions.packageListURL} sources registers.
63
+ *
64
+ * Falls back to the first registered tool tagged `frame-tool`, which with
65
+ * more than one of them depends on module load order.
66
+ */
67
+ frameToolId?: string;
58
68
  /** DOM id of the `<patchwork-view>` hosting the root tool. Defaults to "root". */
59
69
  rootElementId?: string;
60
70
  /**
@@ -1,6 +1,12 @@
1
1
  import type { Plugin } from "vite";
2
2
  import wasm from "vite-plugin-wasm";
3
3
  import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
4
+ /**
5
+ * The build-time constants both the page bundle and the workers are compiled
6
+ * against. Values are already JSON — the shape vite's `define` and esbuild's
7
+ * `define` both take.
8
+ */
9
+ export declare function buildDefines(options?: PatchworkVitePluginOptions): Record<string, string>;
4
10
  /**
5
11
  * Owns envPrefix, define (__SITE_TITLE__/__STORAGE_PREFIX__/sync-server
6
12
  * configuration),
@@ -3,6 +3,22 @@ import { DEFAULT_STORAGE_PREFIX } from "@inkandswitch/patchwork-bootloader/stora
3
3
  import { DEFAULT_TITLE } from "../site-kit/options.js";
4
4
  import { DEFAULT_SYNC_SERVERS, resolvePrimarySyncServer, } from "../site-kit/sync-servers.js";
5
5
  const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
6
+ /**
7
+ * The build-time constants both the page bundle and the workers are compiled
8
+ * against. Values are already JSON — the shape vite's `define` and esbuild's
9
+ * `define` both take.
10
+ */
11
+ export function buildDefines(options = {}) {
12
+ const classicSyncServer = options.syncServers && typeof options.syncServers.classic === "string"
13
+ ? options.syncServers.classic
14
+ : DEFAULT_SYNC_SERVERS.classic;
15
+ return {
16
+ __SYNC_SERVER__: JSON.stringify(resolvePrimarySyncServer(options)),
17
+ __CLASSIC_SYNC_SERVER__: JSON.stringify(classicSyncServer),
18
+ __SITE_TITLE__: JSON.stringify(options.title ?? DEFAULT_TITLE),
19
+ __STORAGE_PREFIX__: JSON.stringify(options.storagePrefix ?? DEFAULT_STORAGE_PREFIX),
20
+ };
21
+ }
6
22
  /**
7
23
  * Owns envPrefix, define (__SITE_TITLE__/__STORAGE_PREFIX__/sync-server
8
24
  * configuration),
@@ -15,21 +31,17 @@ export function configPlugin(options = {}) {
15
31
  return {
16
32
  name: "@patchwork/config",
17
33
  config() {
18
- const primarySyncServer = resolvePrimarySyncServer(options);
19
- const classicSyncServer = options.syncServers && typeof options.syncServers.classic === "string"
20
- ? options.syncServers.classic
21
- : DEFAULT_SYNC_SERVERS.classic;
22
- // __STORAGE_PREFIX__ is defined unconditionally: the tab and the shared
23
- // automerge worker resolve it separately, and only a define reaches both.
24
- const define = {
25
- __SYNC_SERVER__: JSON.stringify(primarySyncServer),
26
- __CLASSIC_SYNC_SERVER__: JSON.stringify(classicSyncServer),
27
- __SITE_TITLE__: JSON.stringify(options.title ?? DEFAULT_TITLE),
28
- __STORAGE_PREFIX__: JSON.stringify(options.storagePrefix ?? DEFAULT_STORAGE_PREFIX),
29
- };
30
34
  return {
31
35
  envPrefix: ["VITE_", "PATCHWORK_"],
32
- define,
36
+ define: buildDefines(options),
37
+ optimizeDeps: {
38
+ exclude: ["@automerge/automerge-repo-storage-indexeddb"],
39
+ // Dep pre-bundling runs esbuild directly, outside the plugin
40
+ // pipeline that applies `define`. Without this the page would fall
41
+ // back to the built-in storage prefix while the workers used the
42
+ // configured one, and they have to open the same databases.
43
+ esbuildOptions: { define: buildDefines(options) },
44
+ },
33
45
  server: options.server === false
34
46
  ? undefined
35
47
  : {
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "vite";
2
+ import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
3
+ export declare function devPlugin(options?: PatchworkVitePluginOptions): Plugin;
@@ -0,0 +1,129 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import * as esbuild from "esbuild";
4
+ import { wasmAssets } from "@inkandswitch/patchwork-bootloader/externals";
5
+ import { buildDefines } from "./config-plugin.js";
6
+ import { builtins, devDependencyId } from "./importmap-plugin.js";
7
+ import { workers } from "./service-worker-plugin.js";
8
+ const PATCHWORK_CSS = "/@inkandswitch/patchwork/global.css";
9
+ const BOOTLOADER_CSS = "/@inkandswitch/patchwork-bootloader/global.css";
10
+ // The generated index.html links the stylesheet by bare specifier, which the
11
+ // build resolves and the dev server does not. Serving the two files under
12
+ // these paths, and pointing the link at the first one, covers the gap.
13
+ const stylesheets = {
14
+ [PATCHWORK_CSS]: fileURLToPath(import.meta.resolve("@inkandswitch/patchwork/global.css")),
15
+ [BOOTLOADER_CSS]: fileURLToPath(import.meta.resolve("@inkandswitch/patchwork-bootloader/global.css")),
16
+ };
17
+ /**
18
+ * Workers are `type: "module"` scripts the browser fetches directly, so import
19
+ * maps don't apply to them and their heavy imports have to resolve to real
20
+ * URLs. The build rewrites those to the `/packages/...` chunks it emits; here
21
+ * they become the dev server's own optimized-dep URLs.
22
+ */
23
+ function externalBuiltins() {
24
+ return {
25
+ name: "patchwork-dev-externals",
26
+ setup(build) {
27
+ build.onResolve({ filter: /.*/ }, (args) => {
28
+ if (!(args.path in builtins))
29
+ return null;
30
+ return {
31
+ path: `/@id/${devDependencyId(args.path)}`,
32
+ external: true,
33
+ };
34
+ });
35
+ },
36
+ };
37
+ }
38
+ function workerContexts(options) {
39
+ const contexts = new Map();
40
+ for (const { specifier, fileName } of workers) {
41
+ contexts.set(`/${fileName}`, esbuild.context({
42
+ entryPoints: [fileURLToPath(import.meta.resolve(specifier))],
43
+ bundle: true,
44
+ write: false,
45
+ format: "esm",
46
+ platform: "browser",
47
+ target: "firefox115",
48
+ sourcemap: "inline",
49
+ define: buildDefines(options),
50
+ plugins: [externalBuiltins()],
51
+ }));
52
+ }
53
+ return contexts;
54
+ }
55
+ export function devPlugin(options = {}) {
56
+ let serve = false;
57
+ let contexts;
58
+ const wasm = new Map(wasmAssets().map(({ fileName, path }) => [`/${fileName}`, path]));
59
+ return {
60
+ name: "@patchwork/dev",
61
+ configResolved(config) {
62
+ serve = config.command === "serve";
63
+ },
64
+ transformIndexHtml(html) {
65
+ if (!serve)
66
+ return html;
67
+ return html.replace(`href="@inkandswitch/patchwork/global.css"`, `href="${PATCHWORK_CSS}"`);
68
+ },
69
+ async buildEnd() {
70
+ if (!contexts)
71
+ return;
72
+ for (const context of contexts.values())
73
+ (await context).dispose();
74
+ contexts = undefined;
75
+ },
76
+ configureServer(server) {
77
+ contexts = workerContexts(options);
78
+ server.middlewares.use(async (request, response, next) => {
79
+ const pathname = request.url?.split("?")[0] ?? "";
80
+ const stylesheet = stylesheets[pathname];
81
+ if (stylesheet) {
82
+ try {
83
+ const css = await readFile(stylesheet, "utf8");
84
+ response.setHeader("Content-Type", "text/css");
85
+ response.setHeader("Cache-Control", "no-cache");
86
+ response.end(pathname === PATCHWORK_CSS
87
+ ? css.replace(`"@inkandswitch/patchwork-bootloader/global.css"`, `"${BOOTLOADER_CSS}"`)
88
+ : css);
89
+ }
90
+ catch {
91
+ next();
92
+ }
93
+ return;
94
+ }
95
+ const binary = wasm.get(pathname);
96
+ if (binary) {
97
+ try {
98
+ response.setHeader("Content-Type", "application/wasm");
99
+ response.setHeader("Cache-Control", "no-cache");
100
+ response.end(await readFile(binary));
101
+ }
102
+ catch {
103
+ next();
104
+ }
105
+ return;
106
+ }
107
+ const context = contexts?.get(pathname);
108
+ if (!context)
109
+ return next();
110
+ try {
111
+ // Rebuilt per request rather than watched: a worker is fetched once
112
+ // when the browser starts it, and esbuild's incremental rebuild is
113
+ // cheaper than keeping a watcher per entry.
114
+ const result = await (await context).rebuild();
115
+ response.setHeader("Content-Type", "text/javascript");
116
+ response.setHeader("Cache-Control", "no-cache");
117
+ response.end(result.outputFiles[0].text);
118
+ }
119
+ catch (error) {
120
+ // A worker that fails to build is otherwise a silent 500 in a
121
+ // context with no console of its own.
122
+ server.config.logger.error(`[patchwork] failed to bundle ${pathname}: ${error}`);
123
+ response.statusCode = 500;
124
+ response.end(`console.error(${JSON.stringify(String(error))})`);
125
+ }
126
+ });
127
+ },
128
+ };
129
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Plugin } from "vite";
2
2
  import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
3
3
  export declare const builtins: Record<string, string>;
4
+ export declare function devDependencyId(id: string): string;
4
5
  export declare function importmap(options?: PatchworkVitePluginOptions): Plugin;
@@ -37,7 +37,7 @@ function createImportMap(options) {
37
37
  Object.assign(importmap.imports, builtins);
38
38
  return { importmap, builtins };
39
39
  }
40
- function devDependencyId(id) {
40
+ export function devDependencyId(id) {
41
41
  if (id === "@inkandswitch/patchwork")
42
42
  return id;
43
43
  if (id === "@inkandswitch/patchwork-bootloader") {
@@ -1,6 +1,7 @@
1
1
  import { importmap } from "./importmap-plugin.js";
2
2
  import { serviceworker } from "./service-worker-plugin.js";
3
3
  import { configPlugin, wasm } from "./config-plugin.js";
4
+ import { devPlugin } from "./dev-plugin.js";
4
5
  import { iconsPlugin } from "./icons.js";
5
6
  import { htmlPlugin } from "./html-plugin.js";
6
7
  import { manifestPlugin } from "./manifest-plugin.js";
@@ -36,5 +37,6 @@ export default function patchwork(options) {
36
37
  netlifyPlugin(options),
37
38
  importmap(options),
38
39
  serviceworker(),
40
+ devPlugin(options),
39
41
  ].filter((plugin) => plugin != null);
40
42
  }
@@ -1,2 +1,6 @@
1
1
  import type { Plugin } from "vite";
2
+ export declare const workers: {
3
+ specifier: string;
4
+ fileName: string;
5
+ }[];
2
6
  export declare function serviceworker(): Plugin;
@@ -10,7 +10,7 @@ const self = fileURLToPath(import.meta.url);
10
10
  // own chunks. Their heavy imports are marked external and resolved to
11
11
  // /packages/... URLs (both workers are created with type:"module", so the
12
12
  // browser fetches those as regular network requests).
13
- const workers = [
13
+ export const workers = [
14
14
  {
15
15
  specifier: "@inkandswitch/patchwork-bootloader/service-worker",
16
16
  fileName: "service-worker.js",
@@ -26,10 +26,17 @@ const workers = [
26
26
  ];
27
27
  export function serviceworker() {
28
28
  const entryIds = new Set();
29
+ let serve = false;
29
30
  return {
30
31
  name: "@patchwork/service-worker",
31
32
  enforce: "pre",
33
+ configResolved(config) {
34
+ serve = config.command === "serve";
35
+ },
32
36
  async buildStart() {
37
+ // emitFile throws in serve mode.
38
+ if (serve)
39
+ return;
33
40
  for (const { specifier, fileName } of workers) {
34
41
  const resolved = await this.resolve(specifier, self);
35
42
  entryIds.add(resolved.id);
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.5.0",
8
+ "version": "0.6.1",
9
9
  "author": "Ink & Switch",
10
10
  "type": "module",
11
11
  "license": "MIT",
@@ -33,23 +33,23 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@automerge/automerge": "3.3.2",
36
- "@automerge/automerge-repo": "2.6.0-subduction.46",
36
+ "@automerge/automerge-repo": "2.6.0-subduction.47",
37
37
  "@automerge/automerge-repo-keyhive": "0.3.0-alpha.sub.8b",
38
- "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.46",
39
- "@automerge/automerge-subduction": "0.16.0",
40
- "@automerge/vanillajs": "2.6.0-subduction.46",
38
+ "@automerge/automerge-repo-storage-indexeddb": "2.6.0-subduction.47",
39
+ "@automerge/automerge-subduction": "0.16.1",
40
+ "@automerge/vanillajs": "2.6.0-subduction.47",
41
41
  "@types/debug": "^4.1.13",
42
42
  "debug": "^4.4.3",
43
+ "esbuild": "^0.23.1",
43
44
  "sharp": "^0.35.3",
44
45
  "vite-plugin-wasm": "^3.6.0",
45
- "@inkandswitch/patchwork-elements": "^6.0.0",
46
+ "@inkandswitch/patchwork-bootloader": "^0.6.2",
46
47
  "@inkandswitch/patchwork-plugins": "^1.2.0",
47
- "@inkandswitch/patchwork-bootloader": "^0.6.0",
48
48
  "@inkandswitch/patchwork-filesystem": "^0.2.5",
49
+ "@inkandswitch/patchwork-elements": "^6.0.0",
49
50
  "@inkandswitch/patchwork-providers": "^0.5.0"
50
51
  },
51
52
  "devDependencies": {
52
- "esbuild": "^0.23.1",
53
53
  "rollup": "^4.61.1",
54
54
  "vite": "^7.3.5"
55
55
  },
@@ -40,7 +40,6 @@ export const createDefaultAccount: AccountCreator<AccountDoc> = async (
40
40
  ]);
41
41
 
42
42
  accountHandle.change((doc) => {
43
- doc.frameToolId = "threepane";
44
43
  doc.rootFolderUrl = rootFolder.url;
45
44
  doc.moduleSettingsUrl = moduleSettings.url;
46
45
  doc.contactUrl = contact.url;
package/src/index.ts CHANGED
@@ -244,6 +244,7 @@ async function doSetup(options: PatchworkOptions): Promise<Patchwork> {
244
244
  repo,
245
245
  accountDocHandle,
246
246
  siteTitle,
247
+ frameToolId: options.frameToolId,
247
248
  });
248
249
  }
249
250
 
package/src/router.ts CHANGED
@@ -66,6 +66,7 @@ export interface RouterParams {
66
66
  repo: Repo;
67
67
  accountDocHandle: DocHandle<AccountDoc>;
68
68
  siteTitle: string;
69
+ frameToolId?: string;
69
70
  }
70
71
 
71
72
  export interface Router {
@@ -84,6 +85,7 @@ export function createRouter({
84
85
  repo,
85
86
  accountDocHandle,
86
87
  siteTitle,
88
+ frameToolId,
87
89
  }: RouterParams): Router {
88
90
  const route = async () => {
89
91
  // The first call seeds the root view's tool/doc so it can mount; later
@@ -92,7 +94,10 @@ export function createRouter({
92
94
  const params = new URLSearchParams(location.hash.slice(1));
93
95
  const frame = params.get("frame");
94
96
  const toolId =
95
- frame ?? accountDocHandle.doc().frameToolId ?? registeredFrameToolId();
97
+ frame ??
98
+ accountDocHandle.doc().frameToolId ??
99
+ frameToolId ??
100
+ registeredFrameToolId();
96
101
  if (!toolId) {
97
102
  console.error("patchwork: no frame tool registered, nothing to mount");
98
103
  return;
package/src/types.ts CHANGED
@@ -74,6 +74,17 @@ export interface PatchworkOptions {
74
74
  */
75
75
  title?: string;
76
76
 
77
+ /**
78
+ * Id of the tool that frames this site — what mounts in the root view when
79
+ * the user hasn't chosen one on their account and the URL doesn't name one
80
+ * via `#frame=`. Must be a tool one of the {@link
81
+ * PatchworkOptions.packageListURL} sources registers.
82
+ *
83
+ * Falls back to the first registered tool tagged `frame-tool`, which with
84
+ * more than one of them depends on module load order.
85
+ */
86
+ frameToolId?: string;
87
+
77
88
  /** DOM id of the `<patchwork-view>` hosting the root tool. Defaults to "root". */
78
89
  rootElementId?: string;
79
90
 
@@ -10,6 +10,28 @@ import {
10
10
 
11
11
  const CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
12
12
 
13
+ /**
14
+ * The build-time constants both the page bundle and the workers are compiled
15
+ * against. Values are already JSON — the shape vite's `define` and esbuild's
16
+ * `define` both take.
17
+ */
18
+ export function buildDefines(
19
+ options: PatchworkVitePluginOptions = {}
20
+ ): Record<string, string> {
21
+ const classicSyncServer =
22
+ options.syncServers && typeof options.syncServers.classic === "string"
23
+ ? options.syncServers.classic
24
+ : DEFAULT_SYNC_SERVERS.classic;
25
+ return {
26
+ __SYNC_SERVER__: JSON.stringify(resolvePrimarySyncServer(options)),
27
+ __CLASSIC_SYNC_SERVER__: JSON.stringify(classicSyncServer),
28
+ __SITE_TITLE__: JSON.stringify(options.title ?? DEFAULT_TITLE),
29
+ __STORAGE_PREFIX__: JSON.stringify(
30
+ options.storagePrefix ?? DEFAULT_STORAGE_PREFIX
31
+ ),
32
+ };
33
+ }
34
+
13
35
  /**
14
36
  * Owns envPrefix, define (__SITE_TITLE__/__STORAGE_PREFIX__/sync-server
15
37
  * configuration),
@@ -24,25 +46,17 @@ export function configPlugin(
24
46
  return {
25
47
  name: "@patchwork/config",
26
48
  config() {
27
- const primarySyncServer = resolvePrimarySyncServer(options);
28
- const classicSyncServer =
29
- options.syncServers && typeof options.syncServers.classic === "string"
30
- ? options.syncServers.classic
31
- : DEFAULT_SYNC_SERVERS.classic;
32
- // __STORAGE_PREFIX__ is defined unconditionally: the tab and the shared
33
- // automerge worker resolve it separately, and only a define reaches both.
34
- const define: Record<string, string> = {
35
- __SYNC_SERVER__: JSON.stringify(primarySyncServer),
36
- __CLASSIC_SYNC_SERVER__: JSON.stringify(classicSyncServer),
37
- __SITE_TITLE__: JSON.stringify(options.title ?? DEFAULT_TITLE),
38
- __STORAGE_PREFIX__: JSON.stringify(
39
- options.storagePrefix ?? DEFAULT_STORAGE_PREFIX
40
- ),
41
- };
42
-
43
49
  return {
44
50
  envPrefix: ["VITE_", "PATCHWORK_"],
45
- define,
51
+ define: buildDefines(options),
52
+ optimizeDeps: {
53
+ exclude: ["@automerge/automerge-repo-storage-indexeddb"],
54
+ // Dep pre-bundling runs esbuild directly, outside the plugin
55
+ // pipeline that applies `define`. Without this the page would fall
56
+ // back to the built-in storage prefix while the workers used the
57
+ // configured one, and they have to open the same databases.
58
+ esbuildOptions: { define: buildDefines(options) },
59
+ },
46
60
  server:
47
61
  options.server === false
48
62
  ? undefined
@@ -0,0 +1,154 @@
1
+ import type { Plugin } from "vite";
2
+ import { readFile } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+ import * as esbuild from "esbuild";
5
+ import { wasmAssets } from "@inkandswitch/patchwork-bootloader/externals";
6
+ import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
7
+ import { buildDefines } from "./config-plugin.js";
8
+ import { builtins, devDependencyId } from "./importmap-plugin.js";
9
+ import { workers } from "./service-worker-plugin.js";
10
+
11
+ const PATCHWORK_CSS = "/@inkandswitch/patchwork/global.css";
12
+ const BOOTLOADER_CSS = "/@inkandswitch/patchwork-bootloader/global.css";
13
+
14
+ // The generated index.html links the stylesheet by bare specifier, which the
15
+ // build resolves and the dev server does not. Serving the two files under
16
+ // these paths, and pointing the link at the first one, covers the gap.
17
+ const stylesheets: Record<string, string> = {
18
+ [PATCHWORK_CSS]: fileURLToPath(
19
+ import.meta.resolve("@inkandswitch/patchwork/global.css")
20
+ ),
21
+ [BOOTLOADER_CSS]: fileURLToPath(
22
+ import.meta.resolve("@inkandswitch/patchwork-bootloader/global.css")
23
+ ),
24
+ };
25
+
26
+ /**
27
+ * Workers are `type: "module"` scripts the browser fetches directly, so import
28
+ * maps don't apply to them and their heavy imports have to resolve to real
29
+ * URLs. The build rewrites those to the `/packages/...` chunks it emits; here
30
+ * they become the dev server's own optimized-dep URLs.
31
+ */
32
+ function externalBuiltins(): esbuild.Plugin {
33
+ return {
34
+ name: "patchwork-dev-externals",
35
+ setup(build) {
36
+ build.onResolve({ filter: /.*/ }, (args) => {
37
+ if (!(args.path in builtins)) return null;
38
+ return {
39
+ path: `/@id/${devDependencyId(args.path)}`,
40
+ external: true,
41
+ };
42
+ });
43
+ },
44
+ };
45
+ }
46
+
47
+ function workerContexts(
48
+ options: PatchworkVitePluginOptions
49
+ ): Map<string, Promise<esbuild.BuildContext>> {
50
+ const contexts = new Map<string, Promise<esbuild.BuildContext>>();
51
+ for (const { specifier, fileName } of workers) {
52
+ contexts.set(
53
+ `/${fileName}`,
54
+ esbuild.context({
55
+ entryPoints: [fileURLToPath(import.meta.resolve(specifier))],
56
+ bundle: true,
57
+ write: false,
58
+ format: "esm",
59
+ platform: "browser",
60
+ target: "firefox115",
61
+ sourcemap: "inline",
62
+ define: buildDefines(options),
63
+ plugins: [externalBuiltins()],
64
+ })
65
+ );
66
+ }
67
+ return contexts;
68
+ }
69
+
70
+ export function devPlugin(options: PatchworkVitePluginOptions = {}): Plugin {
71
+ let serve = false;
72
+ let contexts: Map<string, Promise<esbuild.BuildContext>> | undefined;
73
+ const wasm = new Map(
74
+ wasmAssets().map(({ fileName, path }) => [`/${fileName}`, path])
75
+ );
76
+
77
+ return {
78
+ name: "@patchwork/dev",
79
+ configResolved(config) {
80
+ serve = config.command === "serve";
81
+ },
82
+ transformIndexHtml(html) {
83
+ if (!serve) return html;
84
+ return html.replace(
85
+ `href="@inkandswitch/patchwork/global.css"`,
86
+ `href="${PATCHWORK_CSS}"`
87
+ );
88
+ },
89
+ async buildEnd() {
90
+ if (!contexts) return;
91
+ for (const context of contexts.values()) (await context).dispose();
92
+ contexts = undefined;
93
+ },
94
+ configureServer(server) {
95
+ contexts = workerContexts(options);
96
+
97
+ server.middlewares.use(async (request, response, next) => {
98
+ const pathname = request.url?.split("?")[0] ?? "";
99
+
100
+ const stylesheet = stylesheets[pathname];
101
+ if (stylesheet) {
102
+ try {
103
+ const css = await readFile(stylesheet, "utf8");
104
+ response.setHeader("Content-Type", "text/css");
105
+ response.setHeader("Cache-Control", "no-cache");
106
+ response.end(
107
+ pathname === PATCHWORK_CSS
108
+ ? css.replace(
109
+ `"@inkandswitch/patchwork-bootloader/global.css"`,
110
+ `"${BOOTLOADER_CSS}"`
111
+ )
112
+ : css
113
+ );
114
+ } catch {
115
+ next();
116
+ }
117
+ return;
118
+ }
119
+
120
+ const binary = wasm.get(pathname);
121
+ if (binary) {
122
+ try {
123
+ response.setHeader("Content-Type", "application/wasm");
124
+ response.setHeader("Cache-Control", "no-cache");
125
+ response.end(await readFile(binary));
126
+ } catch {
127
+ next();
128
+ }
129
+ return;
130
+ }
131
+
132
+ const context = contexts?.get(pathname);
133
+ if (!context) return next();
134
+ try {
135
+ // Rebuilt per request rather than watched: a worker is fetched once
136
+ // when the browser starts it, and esbuild's incremental rebuild is
137
+ // cheaper than keeping a watcher per entry.
138
+ const result = await (await context).rebuild();
139
+ response.setHeader("Content-Type", "text/javascript");
140
+ response.setHeader("Cache-Control", "no-cache");
141
+ response.end(result.outputFiles![0]!.text);
142
+ } catch (error) {
143
+ // A worker that fails to build is otherwise a silent 500 in a
144
+ // context with no console of its own.
145
+ server.config.logger.error(
146
+ `[patchwork] failed to bundle ${pathname}: ${error}`
147
+ );
148
+ response.statusCode = 500;
149
+ response.end(`console.error(${JSON.stringify(String(error))})`);
150
+ }
151
+ });
152
+ },
153
+ };
154
+ }
@@ -65,7 +65,7 @@ function createImportMap(options?: PatchworkVitePluginOptions) {
65
65
  return { importmap, builtins };
66
66
  }
67
67
 
68
- function devDependencyId(id: string): string {
68
+ export function devDependencyId(id: string): string {
69
69
  if (id === "@inkandswitch/patchwork") return id;
70
70
  if (id === "@inkandswitch/patchwork-bootloader") {
71
71
  return `@inkandswitch/patchwork > ${id}`;
@@ -3,6 +3,7 @@ import type { Plugin, ServerOptions, PreviewOptions, BuildOptions } from "vite";
3
3
  import { importmap } from "./importmap-plugin.js";
4
4
  import { serviceworker } from "./service-worker-plugin.js";
5
5
  import { configPlugin, wasm } from "./config-plugin.js";
6
+ import { devPlugin } from "./dev-plugin.js";
6
7
  import { iconsPlugin } from "./icons.js";
7
8
  import { htmlPlugin } from "./html-plugin.js";
8
9
  import { manifestPlugin } from "./manifest-plugin.js";
@@ -40,6 +41,7 @@ export default function patchwork(options?: PatchworkVitePluginOptions) {
40
41
  netlifyPlugin(options),
41
42
  importmap(options),
42
43
  serviceworker(),
44
+ devPlugin(options),
43
45
  ].filter((plugin): plugin is Plugin => plugin != null);
44
46
  }
45
47
 
@@ -13,7 +13,7 @@ const self = fileURLToPath(import.meta.url);
13
13
  // own chunks. Their heavy imports are marked external and resolved to
14
14
  // /packages/... URLs (both workers are created with type:"module", so the
15
15
  // browser fetches those as regular network requests).
16
- const workers = [
16
+ export const workers = [
17
17
  {
18
18
  specifier: "@inkandswitch/patchwork-bootloader/service-worker",
19
19
  fileName: "service-worker.js",
@@ -30,11 +30,17 @@ const workers = [
30
30
 
31
31
  export function serviceworker(): Plugin {
32
32
  const entryIds = new Set<string>();
33
+ let serve = false;
33
34
 
34
35
  return {
35
36
  name: "@patchwork/service-worker",
36
37
  enforce: "pre",
38
+ configResolved(config) {
39
+ serve = config.command === "serve";
40
+ },
37
41
  async buildStart() {
42
+ // emitFile throws in serve mode.
43
+ if (serve) return;
38
44
  for (const { specifier, fileName } of workers) {
39
45
  const resolved = await this.resolve(specifier, self);
40
46
  entryIds.add(resolved!.id);