@inkandswitch/patchwork 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # @inkandswitch/patchwork
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5be751b: Add `static` and `buildInfo` options to the vite plugin.
8
+
9
+ `static` mounts file trees into the site — a package of Patchwork modules, a
10
+ sibling repo's build output, a hand-written `modules.json`. Each source is
11
+ served by the dev server and copied into the site at build:
12
+
13
+ ```js
14
+ patchwork({
15
+ static: [
16
+ { from: "modules.json" },
17
+ "@inkandswitch/patchwork-pkg-base",
18
+ {
19
+ from: "../notebook/dist",
20
+ to: "/packages/notebook",
21
+ watch: ".watch-ready",
22
+ },
23
+ ],
24
+ });
25
+ ```
26
+
27
+ A source is either a package specifier or a path relative to the site root, and
28
+ either a file or a directory. `to` mounts it somewhere other than the site root.
29
+ `watch` names a file inside the source that another build touches when it
30
+ finishes; writing to it full-reloads the dev page, which is how a sibling repo
31
+ in watch mode drives the site's dev server.
32
+
33
+ Sources never overwrite the site's own files — not the build's output, not
34
+ `public/`, not an earlier source in the list — so precedence is list order. A
35
+ site that wants its own `modules.json` lists it before the package it takes the
36
+ rest of its modules from. Every file a source didn't get to write is logged at
37
+ the end of the build, so a collision you didn't mean to have is visible.
38
+
39
+ A package can say which of its directories is the static tree with a
40
+ `"patchwork": {"static": "static-dist"}` field in its package.json. Without one,
41
+ the package root is mounted (minus its manifest, `node_modules` and `.git`).
42
+
43
+ `buildInfo` writes `build-info.json`: the site's git revision, the version and
44
+ revision of the patchwork that built it, and every `static` source. Pass an
45
+ object to merge extra fields into it.
46
+
47
+ Both options are off unless set, so existing sites are unaffected.
48
+
3
49
  ## 0.6.1
4
50
 
5
51
  ### Patch Changes
@@ -0,0 +1,8 @@
1
+ import type { Plugin } from "vite";
2
+ import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
3
+ /**
4
+ * Writes build-info.json: what this site was built from. The site's own
5
+ * revision, the version of patchwork that built it, and every `static` source,
6
+ * plus whatever extra fields the site passes as the option value.
7
+ */
8
+ export declare function buildInfoPlugin(options?: PatchworkVitePluginOptions): Plugin | null;
@@ -0,0 +1,63 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { writeFile } from "node:fs/promises";
4
+ import { dirname, join, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { resolveStatic } from "./static-plugin.js";
7
+ function revision(directory) {
8
+ try {
9
+ return execFileSync("git", ["rev-parse", "HEAD"], {
10
+ cwd: directory,
11
+ encoding: "utf8",
12
+ stdio: ["ignore", "pipe", "ignore"],
13
+ }).trim();
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ }
19
+ /**
20
+ * What a package directory is: its name and version, plus the revision it was
21
+ * built from when it isn't an installed copy. A workspace link or a checkout
22
+ * resolves outside node_modules, and its version alone doesn't say which build
23
+ * of it this was.
24
+ */
25
+ function describe(directory) {
26
+ const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
27
+ return {
28
+ name: manifest.name,
29
+ version: manifest.version,
30
+ revision: directory.split(sep).includes("node_modules")
31
+ ? undefined
32
+ : revision(directory),
33
+ };
34
+ }
35
+ /**
36
+ * Writes build-info.json: what this site was built from. The site's own
37
+ * revision, the version of patchwork that built it, and every `static` source,
38
+ * plus whatever extra fields the site passes as the option value.
39
+ */
40
+ export function buildInfoPlugin(options = {}) {
41
+ if (!options.buildInfo)
42
+ return null;
43
+ let root;
44
+ let outDir;
45
+ return {
46
+ name: "@patchwork/build-info",
47
+ configResolved(config) {
48
+ root = config.root;
49
+ outDir = join(config.root, config.build.outDir);
50
+ },
51
+ async closeBundle() {
52
+ const sources = resolveStatic(options, root).map((source) => source.packageDirectory
53
+ ? { from: source.from, ...describe(source.packageDirectory) }
54
+ : { from: source.from, revision: revision(source.path) });
55
+ await writeFile(join(outDir, "build-info.json"), `${JSON.stringify({
56
+ site: { revision: revision(root) },
57
+ patchwork: describe(join(dirname(fileURLToPath(import.meta.url)), "..", "..")),
58
+ static: sources.length ? sources : undefined,
59
+ ...(options.buildInfo === true ? {} : options.buildInfo),
60
+ }, null, 2)}\n`);
61
+ },
62
+ };
63
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Plugin, ServerOptions, PreviewOptions, BuildOptions } from "vite";
2
+ import { type PatchworkStaticSource } from "./static-plugin.js";
2
3
  import type { PatchworkSiteOptions } from "../site-kit/options.js";
3
4
  /**
4
5
  * The patchwork vite plugin. A site's vite.config.ts can shrink down to
@@ -31,9 +32,23 @@ export type ImportMap = {
31
32
  [scope: string]: Imports;
32
33
  };
33
34
  };
35
+ export type { PatchworkStaticSource } from "./static-plugin.js";
34
36
  export type { PatchworkSiteOptions, PatchworkIconsOptions, PatchworkHtmlOptions, PatchworkNetlifyOptions, PatchworkKeyhiveSyncServer, PatchworkSyncServersOptions, } from "../site-kit/options.js";
35
37
  export interface PatchworkVitePluginOptions extends PatchworkSiteOptions {
36
38
  importmap?: ImportMap;
39
+ /**
40
+ * File trees to mount into the site, in order of precedence — a package of
41
+ * Patchwork modules, a sibling repo's build output, a hand-written
42
+ * modules.json. Served in dev, copied into the site at build, and never
43
+ * overwriting the site's own files. A bare string is `{from: string}`.
44
+ */
45
+ static?: (string | PatchworkStaticSource)[];
46
+ /**
47
+ * Write build-info.json: this site's revision, the patchwork version that
48
+ * built it, and every `static` source. An object is merged into it, for
49
+ * whatever else the site wants recorded.
50
+ */
51
+ buildInfo?: boolean | Record<string, unknown>;
37
52
  server?: false | ServerOptions;
38
53
  preview?: false | PreviewOptions;
39
54
  worker?: false | {
@@ -6,6 +6,8 @@ import { iconsPlugin } from "./icons.js";
6
6
  import { htmlPlugin } from "./html-plugin.js";
7
7
  import { manifestPlugin } from "./manifest-plugin.js";
8
8
  import { netlifyPlugin } from "./netlify-plugin.js";
9
+ import { staticPlugin } from "./static-plugin.js";
10
+ import { buildInfoPlugin } from "./build-info-plugin.js";
9
11
  /**
10
12
  * The patchwork vite plugin. A site's vite.config.ts can shrink down to
11
13
  * `plugins: [patchwork({...})]` plus a single source icon file — this plugin
@@ -38,5 +40,7 @@ export default function patchwork(options) {
38
40
  importmap(options),
39
41
  serviceworker(),
40
42
  devPlugin(options),
43
+ staticPlugin(options),
44
+ buildInfoPlugin(options),
41
45
  ].filter((plugin) => plugin != null);
42
46
  }
@@ -0,0 +1,46 @@
1
+ import type { Plugin } from "vite";
2
+ import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
3
+ /**
4
+ * A tree of files to mount into the site — a package of Patchwork modules, a
5
+ * sibling repo's build output, a single hand-written file.
6
+ *
7
+ * `from` is either a package specifier or a path relative to the site root. A
8
+ * package says where its static tree lives with a `"patchwork": {"static":
9
+ * "static-dist"}` field in its own package.json; without one, the whole
10
+ * package directory is mounted.
11
+ */
12
+ export interface PatchworkStaticSource {
13
+ from: string;
14
+ /**
15
+ * URL path to mount `from` at. Defaults to `/`. For a file source a trailing
16
+ * slash means "this directory, under the file's own name", so `{from:
17
+ * "build/modules.json"}` lands at `/modules.json`.
18
+ */
19
+ to?: string;
20
+ /**
21
+ * Path, within `from`, of a file another build touches when it finishes.
22
+ * Writing to it full-reloads the dev page, and it is never copied into the
23
+ * site. This is how a sibling repo in watch mode drives the site's dev
24
+ * server.
25
+ */
26
+ watch?: string;
27
+ }
28
+ export interface ResolvedStaticSource extends PatchworkStaticSource {
29
+ /** absolute path of the file or directory `from` resolved to */
30
+ path: string;
31
+ /** normalised mount point: "" for the site root, else "/packages/x" */
32
+ mount: string;
33
+ file: boolean;
34
+ /** set when `from` was a package specifier rather than a path */
35
+ packageDirectory?: string;
36
+ }
37
+ export declare function resolveStatic(options: PatchworkVitePluginOptions, root: string): ResolvedStaticSource[];
38
+ /**
39
+ * Mounts `static` sources: served in dev, copied into the site at build.
40
+ *
41
+ * Sources never overwrite the site's own output — neither the build's, nor
42
+ * `public/`, nor an earlier source in the list. So precedence is just list
43
+ * order, and a site keeps its own `modules.json` by listing it before the
44
+ * package it borrows the rest of the packages from.
45
+ */
46
+ export declare function staticPlugin(options?: PatchworkVitePluginOptions): Plugin | null;
@@ -0,0 +1,207 @@
1
+ import { constants, existsSync, readFileSync, statSync } from "node:fs";
2
+ import { copyFile, mkdir, readFile, readdir } from "node:fs/promises";
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, } from "node:path";
4
+ import { createRequire } from "node:module";
5
+ const CONTENT_TYPES = {
6
+ ".css": "text/css",
7
+ ".html": "text/html",
8
+ ".js": "text/javascript",
9
+ ".json": "application/json",
10
+ ".map": "application/json",
11
+ ".png": "image/png",
12
+ ".svg": "image/svg+xml",
13
+ ".wasm": "application/wasm",
14
+ };
15
+ /** Never site content, whatever a source directory happens to contain. */
16
+ const SKIP = new Set(["node_modules", ".git"]);
17
+ /**
18
+ * Resolved from the site, not from here: the site is what depends on these
19
+ * packages, and under pnpm this plugin lives somewhere that can't see them.
20
+ */
21
+ function packageDirectory(name, root) {
22
+ const require = createRequire(join(root, "package.json"));
23
+ try {
24
+ return dirname(require.resolve(`${name}/package.json`));
25
+ }
26
+ catch {
27
+ // an "exports" map can hide both the manifest and the package's own main,
28
+ // so fall back to looking for it where node would have found it
29
+ for (const directory of require.resolve.paths(name) ?? []) {
30
+ if (existsSync(join(directory, name, "package.json"))) {
31
+ return join(directory, name);
32
+ }
33
+ }
34
+ throw new Error(`[patchwork] can't find the package "${name}" — a static source has to be a dependency of the site`);
35
+ }
36
+ }
37
+ export function resolveStatic(options, root) {
38
+ return (options.static ?? []).map((source) => {
39
+ const entry = typeof source === "string" ? { from: source } : source;
40
+ const isPath = entry.from.startsWith(".") || isAbsolute(entry.from);
41
+ const directory = isPath ? undefined : packageDirectory(entry.from, root);
42
+ const path = directory
43
+ ? join(directory, JSON.parse(readFileSync(join(directory, "package.json"), "utf8"))
44
+ .patchwork?.static ?? ".")
45
+ : resolve(root, entry.from);
46
+ if (!existsSync(path)) {
47
+ throw new Error(`[patchwork] static source not found: ${entry.from}`);
48
+ }
49
+ const file = statSync(path).isFile();
50
+ const to = entry.to ?? "/";
51
+ return {
52
+ ...entry,
53
+ path,
54
+ file,
55
+ packageDirectory: directory,
56
+ mount: (file && to.endsWith("/") ? join(to, basename(path)) : to).replace(/\/+$/, ""),
57
+ };
58
+ });
59
+ }
60
+ function locate(source, pathname) {
61
+ if (source.file)
62
+ return pathname === source.mount ? source.path : undefined;
63
+ if (pathname !== source.mount && !pathname.startsWith(`${source.mount}/`)) {
64
+ return undefined;
65
+ }
66
+ const path = resolve(source.path, `.${pathname.slice(source.mount.length)}`);
67
+ const within = relative(source.path, path);
68
+ if (within.startsWith(".."))
69
+ return undefined;
70
+ if (within.split(sep).some((part) => SKIP.has(part)))
71
+ return undefined;
72
+ if (within === source.watch)
73
+ return undefined;
74
+ if (within === "package.json" && source.packageDirectory)
75
+ return undefined;
76
+ return path;
77
+ }
78
+ /** How a source is named in messages: a specifier, or the shorter of the paths. */
79
+ function label(source, root) {
80
+ if (source.packageDirectory)
81
+ return source.from;
82
+ const path = relative(root, source.path);
83
+ return path.split(sep).filter((part) => part === "..").length > 1
84
+ ? source.path
85
+ : path;
86
+ }
87
+ async function files(source, directory = source) {
88
+ const paths = [];
89
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
90
+ if (SKIP.has(entry.name))
91
+ continue;
92
+ const path = join(directory, entry.name);
93
+ if (entry.isDirectory())
94
+ paths.push(...(await files(source, path)));
95
+ if (entry.isFile())
96
+ paths.push(relative(source, path));
97
+ }
98
+ return paths;
99
+ }
100
+ /**
101
+ * Mounts `static` sources: served in dev, copied into the site at build.
102
+ *
103
+ * Sources never overwrite the site's own output — neither the build's, nor
104
+ * `public/`, nor an earlier source in the list. So precedence is just list
105
+ * order, and a site keeps its own `modules.json` by listing it before the
106
+ * package it borrows the rest of the packages from.
107
+ */
108
+ export function staticPlugin(options = {}) {
109
+ if (!options.static?.length)
110
+ return null;
111
+ let sources = [];
112
+ let root;
113
+ let outDir;
114
+ let base = "/";
115
+ let logger;
116
+ return {
117
+ name: "@patchwork/static",
118
+ configResolved(config) {
119
+ sources = resolveStatic(options, config.root);
120
+ root = config.root;
121
+ outDir = resolve(config.root, config.build.outDir);
122
+ base = config.base;
123
+ logger = config.logger;
124
+ },
125
+ async closeBundle() {
126
+ for (const source of sources) {
127
+ const paths = source.file ? [""] : await files(source.path);
128
+ const kept = [];
129
+ for (const path of paths) {
130
+ if (path === source.watch)
131
+ continue;
132
+ // a package that doesn't declare a static directory mounts its whole
133
+ // root, and its manifest isn't part of the site
134
+ if (path === "package.json" && source.packageDirectory)
135
+ continue;
136
+ const to = join(outDir, `.${source.mount}`, path);
137
+ await mkdir(dirname(to), { recursive: true });
138
+ try {
139
+ // EXCL rather than a check, so the build's own output and public/
140
+ // win a collision no matter what order the copies happen in.
141
+ await copyFile(join(source.path, path), to, constants.COPYFILE_EXCL);
142
+ }
143
+ catch (error) {
144
+ if (error.code !== "EEXIST")
145
+ throw error;
146
+ kept.push(join(source.mount, "/", path));
147
+ }
148
+ }
149
+ if (kept.length) {
150
+ logger.info(`[patchwork] ${label(source, root)}: ${kept.length} ${kept.length === 1 ? "file was" : "files were"} already in the site and not copied over — ${kept
151
+ .slice(0, 5)
152
+ .join(", ")}${kept.length > 5 ? `, and ${kept.length - 5} more` : ""}`, { timestamp: true });
153
+ }
154
+ }
155
+ },
156
+ configureServer(server) {
157
+ const watched = new Map(sources
158
+ .filter((source) => source.watch)
159
+ .map((source) => [join(source.path, source.watch), source]));
160
+ for (const path of watched.keys())
161
+ server.watcher.add(path);
162
+ server.watcher.on("change", (path) => {
163
+ if (watched.has(path))
164
+ server.ws.send({ type: "full-reload" });
165
+ });
166
+ // Returned, so this runs after vite's own public/static middlewares and
167
+ // dev matches the build: sources fill in what the site doesn't have.
168
+ // By then the SPA fallback has rewritten req.url to /index.html, so the
169
+ // path being asked for is the one connect saved on the way in.
170
+ return () => {
171
+ server.middlewares.use(async (request, response, next) => {
172
+ const url = request
173
+ .originalUrl ??
174
+ request.url ??
175
+ "/";
176
+ let pathname;
177
+ try {
178
+ pathname = decodeURIComponent(new URL(url, "http://localhost").pathname);
179
+ }
180
+ catch {
181
+ return next();
182
+ }
183
+ if (base !== "/" && pathname.startsWith(base)) {
184
+ pathname = pathname.slice(base.length - 1);
185
+ }
186
+ for (const source of sources) {
187
+ const path = locate(source, pathname);
188
+ if (!path)
189
+ continue;
190
+ try {
191
+ if (!statSync(path).isFile())
192
+ continue;
193
+ response.setHeader("Cache-Control", "no-cache");
194
+ response.setHeader("Content-Type", CONTENT_TYPES[extname(path)] ?? "application/octet-stream");
195
+ response.end(await readFile(path));
196
+ return;
197
+ }
198
+ catch {
199
+ continue;
200
+ }
201
+ }
202
+ next();
203
+ });
204
+ };
205
+ },
206
+ };
207
+ }
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.6.1",
8
+ "version": "0.7.0",
9
9
  "author": "Ink & Switch",
10
10
  "type": "module",
11
11
  "license": "MIT",
@@ -44,10 +44,10 @@
44
44
  "sharp": "^0.35.3",
45
45
  "vite-plugin-wasm": "^3.6.0",
46
46
  "@inkandswitch/patchwork-bootloader": "^0.6.2",
47
- "@inkandswitch/patchwork-plugins": "^1.2.0",
48
47
  "@inkandswitch/patchwork-filesystem": "^0.2.5",
49
- "@inkandswitch/patchwork-elements": "^6.0.0",
50
- "@inkandswitch/patchwork-providers": "^0.5.0"
48
+ "@inkandswitch/patchwork-plugins": "^1.2.0",
49
+ "@inkandswitch/patchwork-providers": "^0.5.0",
50
+ "@inkandswitch/patchwork-elements": "^6.0.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "rollup": "^4.61.1",
@@ -0,0 +1,81 @@
1
+ import type { Plugin } from "vite";
2
+ import { execFileSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+ import { writeFile } from "node:fs/promises";
5
+ import { dirname, join, sep } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
8
+ import { resolveStatic } from "./static-plugin.js";
9
+
10
+ function revision(directory: string) {
11
+ try {
12
+ return execFileSync("git", ["rev-parse", "HEAD"], {
13
+ cwd: directory,
14
+ encoding: "utf8",
15
+ stdio: ["ignore", "pipe", "ignore"],
16
+ }).trim();
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * What a package directory is: its name and version, plus the revision it was
24
+ * built from when it isn't an installed copy. A workspace link or a checkout
25
+ * resolves outside node_modules, and its version alone doesn't say which build
26
+ * of it this was.
27
+ */
28
+ function describe(directory: string) {
29
+ const manifest = JSON.parse(
30
+ readFileSync(join(directory, "package.json"), "utf8")
31
+ );
32
+ return {
33
+ name: manifest.name,
34
+ version: manifest.version,
35
+ revision: directory.split(sep).includes("node_modules")
36
+ ? undefined
37
+ : revision(directory),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Writes build-info.json: what this site was built from. The site's own
43
+ * revision, the version of patchwork that built it, and every `static` source,
44
+ * plus whatever extra fields the site passes as the option value.
45
+ */
46
+ export function buildInfoPlugin(
47
+ options: PatchworkVitePluginOptions = {}
48
+ ): Plugin | null {
49
+ if (!options.buildInfo) return null;
50
+ let root: string;
51
+ let outDir: string;
52
+ return {
53
+ name: "@patchwork/build-info",
54
+ configResolved(config) {
55
+ root = config.root;
56
+ outDir = join(config.root, config.build.outDir);
57
+ },
58
+ async closeBundle() {
59
+ const sources = resolveStatic(options, root).map((source) =>
60
+ source.packageDirectory
61
+ ? { from: source.from, ...describe(source.packageDirectory) }
62
+ : { from: source.from, revision: revision(source.path) }
63
+ );
64
+ await writeFile(
65
+ join(outDir, "build-info.json"),
66
+ `${JSON.stringify(
67
+ {
68
+ site: { revision: revision(root) },
69
+ patchwork: describe(
70
+ join(dirname(fileURLToPath(import.meta.url)), "..", "..")
71
+ ),
72
+ static: sources.length ? sources : undefined,
73
+ ...(options.buildInfo === true ? {} : options.buildInfo),
74
+ },
75
+ null,
76
+ 2
77
+ )}\n`
78
+ );
79
+ },
80
+ };
81
+ }
@@ -8,6 +8,8 @@ import { iconsPlugin } from "./icons.js";
8
8
  import { htmlPlugin } from "./html-plugin.js";
9
9
  import { manifestPlugin } from "./manifest-plugin.js";
10
10
  import { netlifyPlugin } from "./netlify-plugin.js";
11
+ import { staticPlugin, type PatchworkStaticSource } from "./static-plugin.js";
12
+ import { buildInfoPlugin } from "./build-info-plugin.js";
11
13
  import type { PatchworkSiteOptions } from "../site-kit/options.js";
12
14
 
13
15
  /**
@@ -42,6 +44,8 @@ export default function patchwork(options?: PatchworkVitePluginOptions) {
42
44
  importmap(options),
43
45
  serviceworker(),
44
46
  devPlugin(options),
47
+ staticPlugin(options),
48
+ buildInfoPlugin(options),
45
49
  ].filter((plugin): plugin is Plugin => plugin != null);
46
50
  }
47
51
 
@@ -51,6 +55,8 @@ export type ImportMap = {
51
55
  scopes?: { [scope: string]: Imports };
52
56
  };
53
57
 
58
+ export type { PatchworkStaticSource } from "./static-plugin.js";
59
+
54
60
  export type {
55
61
  PatchworkSiteOptions,
56
62
  PatchworkIconsOptions,
@@ -63,6 +69,20 @@ export type {
63
69
  export interface PatchworkVitePluginOptions extends PatchworkSiteOptions {
64
70
  importmap?: ImportMap;
65
71
 
72
+ /**
73
+ * File trees to mount into the site, in order of precedence — a package of
74
+ * Patchwork modules, a sibling repo's build output, a hand-written
75
+ * modules.json. Served in dev, copied into the site at build, and never
76
+ * overwriting the site's own files. A bare string is `{from: string}`.
77
+ */
78
+ static?: (string | PatchworkStaticSource)[];
79
+ /**
80
+ * Write build-info.json: this site's revision, the patchwork version that
81
+ * built it, and every `static` source. An object is merged into it, for
82
+ * whatever else the site wants recorded.
83
+ */
84
+ buildInfo?: boolean | Record<string, unknown>;
85
+
66
86
  server?: false | ServerOptions;
67
87
  preview?: false | PreviewOptions;
68
88
  worker?: false | { format?: "es" | "iife" };
@@ -0,0 +1,275 @@
1
+ import type { Logger, Plugin } from "vite";
2
+ import type { IncomingMessage } from "node:http";
3
+ import { constants, existsSync, readFileSync, statSync } from "node:fs";
4
+ import { copyFile, mkdir, readFile, readdir } from "node:fs/promises";
5
+ import {
6
+ basename,
7
+ dirname,
8
+ extname,
9
+ isAbsolute,
10
+ join,
11
+ relative,
12
+ resolve,
13
+ sep,
14
+ } from "node:path";
15
+ import { createRequire } from "node:module";
16
+ import type { PatchworkVitePluginOptions } from "./patchwork-plugin.js";
17
+
18
+ /**
19
+ * A tree of files to mount into the site — a package of Patchwork modules, a
20
+ * sibling repo's build output, a single hand-written file.
21
+ *
22
+ * `from` is either a package specifier or a path relative to the site root. A
23
+ * package says where its static tree lives with a `"patchwork": {"static":
24
+ * "static-dist"}` field in its own package.json; without one, the whole
25
+ * package directory is mounted.
26
+ */
27
+ export interface PatchworkStaticSource {
28
+ from: string;
29
+ /**
30
+ * URL path to mount `from` at. Defaults to `/`. For a file source a trailing
31
+ * slash means "this directory, under the file's own name", so `{from:
32
+ * "build/modules.json"}` lands at `/modules.json`.
33
+ */
34
+ to?: string;
35
+ /**
36
+ * Path, within `from`, of a file another build touches when it finishes.
37
+ * Writing to it full-reloads the dev page, and it is never copied into the
38
+ * site. This is how a sibling repo in watch mode drives the site's dev
39
+ * server.
40
+ */
41
+ watch?: string;
42
+ }
43
+
44
+ const CONTENT_TYPES: Record<string, string> = {
45
+ ".css": "text/css",
46
+ ".html": "text/html",
47
+ ".js": "text/javascript",
48
+ ".json": "application/json",
49
+ ".map": "application/json",
50
+ ".png": "image/png",
51
+ ".svg": "image/svg+xml",
52
+ ".wasm": "application/wasm",
53
+ };
54
+
55
+ /** Never site content, whatever a source directory happens to contain. */
56
+ const SKIP = new Set(["node_modules", ".git"]);
57
+
58
+ export interface ResolvedStaticSource extends PatchworkStaticSource {
59
+ /** absolute path of the file or directory `from` resolved to */
60
+ path: string;
61
+ /** normalised mount point: "" for the site root, else "/packages/x" */
62
+ mount: string;
63
+ file: boolean;
64
+ /** set when `from` was a package specifier rather than a path */
65
+ packageDirectory?: string;
66
+ }
67
+
68
+ /**
69
+ * Resolved from the site, not from here: the site is what depends on these
70
+ * packages, and under pnpm this plugin lives somewhere that can't see them.
71
+ */
72
+ function packageDirectory(name: string, root: string) {
73
+ const require = createRequire(join(root, "package.json"));
74
+ try {
75
+ return dirname(require.resolve(`${name}/package.json`));
76
+ } catch {
77
+ // an "exports" map can hide both the manifest and the package's own main,
78
+ // so fall back to looking for it where node would have found it
79
+ for (const directory of require.resolve.paths(name) ?? []) {
80
+ if (existsSync(join(directory, name, "package.json"))) {
81
+ return join(directory, name);
82
+ }
83
+ }
84
+ throw new Error(
85
+ `[patchwork] can't find the package "${name}" — a static source has to be a dependency of the site`
86
+ );
87
+ }
88
+ }
89
+
90
+ export function resolveStatic(
91
+ options: PatchworkVitePluginOptions,
92
+ root: string
93
+ ): ResolvedStaticSource[] {
94
+ return (options.static ?? []).map((source) => {
95
+ const entry = typeof source === "string" ? { from: source } : source;
96
+ const isPath = entry.from.startsWith(".") || isAbsolute(entry.from);
97
+ const directory = isPath ? undefined : packageDirectory(entry.from, root);
98
+ const path = directory
99
+ ? join(
100
+ directory,
101
+ JSON.parse(readFileSync(join(directory, "package.json"), "utf8"))
102
+ .patchwork?.static ?? "."
103
+ )
104
+ : resolve(root, entry.from);
105
+ if (!existsSync(path)) {
106
+ throw new Error(`[patchwork] static source not found: ${entry.from}`);
107
+ }
108
+ const file = statSync(path).isFile();
109
+ const to = entry.to ?? "/";
110
+ return {
111
+ ...entry,
112
+ path,
113
+ file,
114
+ packageDirectory: directory,
115
+ mount: (file && to.endsWith("/") ? join(to, basename(path)) : to).replace(
116
+ /\/+$/,
117
+ ""
118
+ ),
119
+ };
120
+ });
121
+ }
122
+
123
+ function locate(source: ResolvedStaticSource, pathname: string) {
124
+ if (source.file) return pathname === source.mount ? source.path : undefined;
125
+ if (pathname !== source.mount && !pathname.startsWith(`${source.mount}/`)) {
126
+ return undefined;
127
+ }
128
+ const path = resolve(source.path, `.${pathname.slice(source.mount.length)}`);
129
+ const within = relative(source.path, path);
130
+ if (within.startsWith("..")) return undefined;
131
+ if (within.split(sep).some((part) => SKIP.has(part))) return undefined;
132
+ if (within === source.watch) return undefined;
133
+ if (within === "package.json" && source.packageDirectory) return undefined;
134
+ return path;
135
+ }
136
+
137
+ /** How a source is named in messages: a specifier, or the shorter of the paths. */
138
+ function label(source: ResolvedStaticSource, root: string) {
139
+ if (source.packageDirectory) return source.from;
140
+ const path = relative(root, source.path);
141
+ return path.split(sep).filter((part) => part === "..").length > 1
142
+ ? source.path
143
+ : path;
144
+ }
145
+
146
+ async function files(source: string, directory = source): Promise<string[]> {
147
+ const paths: string[] = [];
148
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
149
+ if (SKIP.has(entry.name)) continue;
150
+ const path = join(directory, entry.name);
151
+ if (entry.isDirectory()) paths.push(...(await files(source, path)));
152
+ if (entry.isFile()) paths.push(relative(source, path));
153
+ }
154
+ return paths;
155
+ }
156
+
157
+ /**
158
+ * Mounts `static` sources: served in dev, copied into the site at build.
159
+ *
160
+ * Sources never overwrite the site's own output — neither the build's, nor
161
+ * `public/`, nor an earlier source in the list. So precedence is just list
162
+ * order, and a site keeps its own `modules.json` by listing it before the
163
+ * package it borrows the rest of the packages from.
164
+ */
165
+ export function staticPlugin(
166
+ options: PatchworkVitePluginOptions = {}
167
+ ): Plugin | null {
168
+ if (!options.static?.length) return null;
169
+ let sources: ResolvedStaticSource[] = [];
170
+ let root: string;
171
+ let outDir: string;
172
+ let base = "/";
173
+ let logger: Logger;
174
+ return {
175
+ name: "@patchwork/static",
176
+ configResolved(config) {
177
+ sources = resolveStatic(options, config.root);
178
+ root = config.root;
179
+ outDir = resolve(config.root, config.build.outDir);
180
+ base = config.base;
181
+ logger = config.logger;
182
+ },
183
+ async closeBundle() {
184
+ for (const source of sources) {
185
+ const paths = source.file ? [""] : await files(source.path);
186
+ const kept: string[] = [];
187
+ for (const path of paths) {
188
+ if (path === source.watch) continue;
189
+ // a package that doesn't declare a static directory mounts its whole
190
+ // root, and its manifest isn't part of the site
191
+ if (path === "package.json" && source.packageDirectory) continue;
192
+ const to = join(outDir, `.${source.mount}`, path);
193
+ await mkdir(dirname(to), { recursive: true });
194
+ try {
195
+ // EXCL rather than a check, so the build's own output and public/
196
+ // win a collision no matter what order the copies happen in.
197
+ await copyFile(
198
+ join(source.path, path),
199
+ to,
200
+ constants.COPYFILE_EXCL
201
+ );
202
+ } catch (error) {
203
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
204
+ kept.push(join(source.mount, "/", path));
205
+ }
206
+ }
207
+ if (kept.length) {
208
+ logger.info(
209
+ `[patchwork] ${label(source, root)}: ${kept.length} ${
210
+ kept.length === 1 ? "file was" : "files were"
211
+ } already in the site and not copied over — ${kept
212
+ .slice(0, 5)
213
+ .join(
214
+ ", "
215
+ )}${kept.length > 5 ? `, and ${kept.length - 5} more` : ""}`,
216
+ { timestamp: true }
217
+ );
218
+ }
219
+ }
220
+ },
221
+ configureServer(server) {
222
+ const watched = new Map(
223
+ sources
224
+ .filter((source) => source.watch)
225
+ .map((source) => [join(source.path, source.watch!), source])
226
+ );
227
+ for (const path of watched.keys()) server.watcher.add(path);
228
+ server.watcher.on("change", (path) => {
229
+ if (watched.has(path)) server.ws.send({ type: "full-reload" });
230
+ });
231
+
232
+ // Returned, so this runs after vite's own public/static middlewares and
233
+ // dev matches the build: sources fill in what the site doesn't have.
234
+ // By then the SPA fallback has rewritten req.url to /index.html, so the
235
+ // path being asked for is the one connect saved on the way in.
236
+ return () => {
237
+ server.middlewares.use(async (request, response, next) => {
238
+ const url =
239
+ (request as IncomingMessage & { originalUrl?: string })
240
+ .originalUrl ??
241
+ request.url ??
242
+ "/";
243
+ let pathname: string;
244
+ try {
245
+ pathname = decodeURIComponent(
246
+ new URL(url, "http://localhost").pathname
247
+ );
248
+ } catch {
249
+ return next();
250
+ }
251
+ if (base !== "/" && pathname.startsWith(base)) {
252
+ pathname = pathname.slice(base.length - 1);
253
+ }
254
+ for (const source of sources) {
255
+ const path = locate(source, pathname);
256
+ if (!path) continue;
257
+ try {
258
+ if (!statSync(path).isFile()) continue;
259
+ response.setHeader("Cache-Control", "no-cache");
260
+ response.setHeader(
261
+ "Content-Type",
262
+ CONTENT_TYPES[extname(path)] ?? "application/octet-stream"
263
+ );
264
+ response.end(await readFile(path));
265
+ return;
266
+ } catch {
267
+ continue;
268
+ }
269
+ }
270
+ next();
271
+ });
272
+ };
273
+ },
274
+ };
275
+ }