@uniflowed/vite 0.0.0-alpha.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/bun-preload.js ADDED
@@ -0,0 +1,22 @@
1
+ // Plain JavaScript: this file registers the loader, so it cannot need one.
2
+ //
3
+ // `bun --preload @uniflowed/vite/bun-preload app.js` runs a Flow project on
4
+ // Bun without a build step, through Bun's own plugin API: every module uf is
5
+ // responsible for is transformed by `uf transform` as Bun loads it. It is the
6
+ // Bun counterpart of `./register.js`, and the policy of which files count is
7
+ // the same `isFlowModule`.
8
+
9
+ import { isFlowModule, transformFlow } from "./transform.js";
10
+
11
+ Bun.plugin({
12
+ name: "uniflowed-flow",
13
+ setup(build) {
14
+ build.onLoad({ filter: /\.(js|jsx|mjs)$/ }, async (args) => {
15
+ if (!isFlowModule(args.path)) return undefined;
16
+ const source = await Bun.file(args.path).text();
17
+ const out = await transformFlow(source, args.path, { development: true, sourceMap: false });
18
+ if (out == null) return undefined;
19
+ return { contents: out.code, loader: "js" };
20
+ });
21
+ },
22
+ });
package/driver.js ADDED
@@ -0,0 +1,360 @@
1
+ // Plain JavaScript: the host runs this file directly.
2
+ //
3
+ // The driver `uf dev`, `uf build` and `uf preview` spawn.
4
+ //
5
+ // <host> driver.js dev --root <dir> [--host <h>] [--port <n>] [--strict-port]
6
+ // <host> driver.js build --root <dir> [--out-dir <dir>] [--mode <m>]
7
+ // <host> driver.js preview --root <dir> [--host <h>] [--port <n>]
8
+ // <host> driver.js config --root <dir>
9
+ //
10
+ // `uf` in Rust owns the terminal; this process owns Vite. They talk over
11
+ // stdout, one JSON event per line (see `./internal/events.js`), and the driver
12
+ // exits when its stdin closes so it cannot outlive the command that started
13
+ // it.
14
+ //
15
+ // `config` loads `uf.config.js` and prints its JSON projection. It is how the
16
+ // Rust side reads a config that may hold functions and plugin instances: the
17
+ // one host that can evaluate the file evaluates it.
18
+
19
+ import { register } from "node:module";
20
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
+ import path from "node:path";
22
+ import { pathToFileURL } from "node:url";
23
+
24
+ import { emit, errorEvent, eventLogger } from "./internal/events.js";
25
+ import { loadUfConfig, projectConfig } from "./internal/config.js";
26
+ import { VIRTUAL, scanRoutes } from "./internal/routes.js";
27
+
28
+ function argument(name) {
29
+ const at = process.argv.indexOf(name);
30
+ return at === -1 ? null : process.argv[at + 1];
31
+ }
32
+
33
+ function flag(name) {
34
+ return process.argv.includes(name);
35
+ }
36
+
37
+ const command = process.argv[2];
38
+ const root = path.resolve(argument("--root") ?? process.cwd());
39
+ // Every transform in this process — the loader hooks, the config loader, the
40
+ // Vite plugin — talks to one `uf transform` started at the project root.
41
+ process.env.UF_PROJECT_ROOT = root;
42
+
43
+ // The config imports `@uniflowed/config`, which is Flow. Node needs the loader
44
+ // hooks for that; Bun is started with `--preload ./bun-preload.js` instead
45
+ // and has no `register`.
46
+ if (typeof Bun === "undefined" && typeof Deno === "undefined") {
47
+ register("./internal/node-hooks.js", import.meta.url, { data: { root } });
48
+ }
49
+
50
+ process.stdin.on("end", () => process.exit(0));
51
+ process.stdin.on("error", () => process.exit(0));
52
+ process.stdin.resume();
53
+
54
+ const commands = { dev, build, preview, config: printConfig };
55
+ const run = commands[command];
56
+ if (run == null) {
57
+ emit("error", { message: `unknown driver command ${JSON.stringify(command)}` });
58
+ process.exit(2);
59
+ }
60
+
61
+ run().catch((error) => {
62
+ emit("error", errorEvent(error));
63
+ process.exit(1);
64
+ });
65
+
66
+ /** Load `uf.config.js`, reporting where it was found. */
67
+ async function loadConfig() {
68
+ const { config, file } = await loadUfConfig(root);
69
+ emit("config-loaded", { file });
70
+ return config;
71
+ }
72
+
73
+ /** The Vite inline config a uf config describes. */
74
+ async function viteConfig(config, mode) {
75
+ const { default: uniflowed } = await import("./index.js");
76
+ const dev = config.dev ?? {};
77
+ const build = config.build ?? {};
78
+ const userPlugins = Array.isArray(config.plugins) ? config.plugins : [];
79
+ const host = argument("--host") ?? dev.host ?? "127.0.0.1";
80
+ const port = Number(argument("--port") ?? dev.port ?? 5173);
81
+ const allowedHosts = Array.isArray(dev.allowedHosts) && dev.allowedHosts.length > 0 ? dev.allowedHosts : undefined;
82
+
83
+ return {
84
+ root,
85
+ configFile: false,
86
+ envFile: false,
87
+ mode,
88
+ clearScreen: false,
89
+ customLogger: eventLogger(argument("--log-level") ?? "info"),
90
+ plugins: [uniflowed({ root, config }), ...userPlugins],
91
+ server: {
92
+ host,
93
+ port,
94
+ strictPort: flag("--strict-port") || dev.strictPort === true,
95
+ allowedHosts,
96
+ fs: {
97
+ allow: dev.fs?.allow,
98
+ deny: dev.fs?.deny,
99
+ },
100
+ },
101
+ preview: { host, port },
102
+ build: {
103
+ outDir: argument("--out-dir") ?? build.outDir ?? "dist",
104
+ sourcemap: build.sourcemap ?? true,
105
+ manifest: true,
106
+ emptyOutDir: true,
107
+ },
108
+ };
109
+ }
110
+
111
+ /**
112
+ * The dev server.
113
+ *
114
+ * Vite in middleware mode serves nothing on its own: with no `index.html` at
115
+ * the project root it answers every navigation with "Cannot GET /", which is
116
+ * what `uf dev` used to do for every project it started. A uf project has no
117
+ * `index.html` — the document comes from a layout — so the server has to render
118
+ * it, which is what this middleware does:
119
+ *
120
+ * 1. load the server entry through `ssrLoadModule`, so it is transformed the
121
+ * same way the browser's copy is and picks up edits without a restart;
122
+ * 2. render the URL, pointing the client script at the dev entry rather than
123
+ * at a built asset;
124
+ * 3. hand the HTML to `transformIndexHtml`, which is what injects the HMR
125
+ * client and lets any Vite plugin see the document.
126
+ *
127
+ * Anything Vite already serves — a module, a public file — never reaches this,
128
+ * because the middleware runs after Vite's own.
129
+ */
130
+ async function dev() {
131
+ const { createServer } = await import("vite");
132
+ const config = await loadConfig();
133
+ const inline = await viteConfig(config, "development");
134
+ const server = await createServer({ ...inline, appType: "custom" });
135
+
136
+ // In dev the browser loads the client entry from Vite, not from a manifest;
137
+ // its stylesheets arrive through that module rather than as <link> tags.
138
+ const assets = { scripts: [`/@id/${VIRTUAL.client}`], styles: [], preloads: [] };
139
+
140
+ server.middlewares.use(async (request, response, next) => {
141
+ if (request.method !== "GET" && request.method !== "HEAD") {
142
+ next();
143
+ return;
144
+ }
145
+ const url = request.originalUrl ?? request.url ?? "/";
146
+ try {
147
+ const entry = await server.ssrLoadModule(VIRTUAL.server);
148
+ const result = await entry.render(url, assets);
149
+ const html = await server.transformIndexHtml(url, result.html);
150
+ response.statusCode = result.status ?? 200;
151
+ response.setHeader("content-type", "text/html; charset=utf-8");
152
+ response.end(html);
153
+ } catch (error) {
154
+ // Map the stack back onto the Flow source before it reaches the overlay.
155
+ if (error instanceof Error) server.ssrFixStacktrace(error);
156
+ next(error);
157
+ }
158
+ });
159
+
160
+ await server.listen();
161
+ const urls = server.resolvedUrls ?? { local: [], network: [] };
162
+ emit("listening", {
163
+ local: urls.local,
164
+ network: urls.network,
165
+ routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map((route) => route.path),
166
+ });
167
+
168
+ const shutdown = async () => {
169
+ await server.close();
170
+ process.exit(0);
171
+ };
172
+ process.on("SIGINT", shutdown);
173
+ process.on("SIGTERM", shutdown);
174
+ }
175
+
176
+ async function preview() {
177
+ const { preview: startPreview } = await import("vite");
178
+ const config = await loadConfig();
179
+ const server = await startPreview(await viteConfig(config, "production"));
180
+ const urls = server.resolvedUrls ?? { local: [], network: [] };
181
+ emit("listening", { local: urls.local, network: urls.network, routes: [] });
182
+ const shutdown = async () => {
183
+ await server.close();
184
+ process.exit(0);
185
+ };
186
+ process.on("SIGINT", shutdown);
187
+ process.on("SIGTERM", shutdown);
188
+ }
189
+
190
+ async function build() {
191
+ const vite = await import("vite");
192
+ const config = await loadConfig();
193
+ const mode = argument("--mode") ?? "production";
194
+ const inline = await viteConfig(config, mode);
195
+ const outDir = path.resolve(root, inline.build.outDir);
196
+ const serverDir = path.join(root, ".uf", "build", "server");
197
+
198
+ // 1. The client: everything the browser loads, with a manifest so the
199
+ // server render knows which script and stylesheet tags to write.
200
+ emit("phase", { name: "client" });
201
+ await vite.build({
202
+ ...inline,
203
+ build: {
204
+ ...inline.build,
205
+ rollupOptions: { input: { client: VIRTUAL.client } },
206
+ },
207
+ });
208
+ const manifest = readManifest(outDir);
209
+
210
+ // 2. The server entry, bundled for the host, outside `dist/` so it is never
211
+ // deployed by accident.
212
+ emit("phase", { name: "server" });
213
+ rmSync(serverDir, { recursive: true, force: true });
214
+ await vite.build({
215
+ ...inline,
216
+ customLogger: eventLogger("warn"),
217
+ build: {
218
+ ...inline.build,
219
+ manifest: false,
220
+ ssr: true,
221
+ outDir: serverDir,
222
+ rollupOptions: {
223
+ input: { server: VIRTUAL.server },
224
+ output: { entryFileNames: "server.js", format: "es" },
225
+ },
226
+ },
227
+ });
228
+
229
+ // 3. Every static route, rendered to an HTML document.
230
+ emit("phase", { name: "prerender" });
231
+ const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
232
+ const assets = assetsFromManifest(manifest);
233
+ const pages = await staticPaths(server.routes);
234
+ for (const url of pages) {
235
+ const result = await server.render(url, assets);
236
+ const file = htmlPathFor(outDir, url);
237
+ mkdirSync(path.dirname(file), { recursive: true });
238
+ writeFileSync(file, result.html);
239
+ emit("page", { url, file: path.relative(root, file), status: result.status, bytes: Buffer.byteLength(result.html) });
240
+ }
241
+ if (server.notFound != null) {
242
+ const result = await server.render("/__uf_not_found__", assets);
243
+ const file = path.join(outDir, "404.html");
244
+ writeFileSync(file, result.html);
245
+ emit("page", { url: "/404", file: path.relative(root, file), status: 404, bytes: Buffer.byteLength(result.html) });
246
+ }
247
+
248
+ emit("done", { outDir: path.relative(root, outDir), pages: pages.length });
249
+ process.exit(0);
250
+ }
251
+
252
+ async function printConfig() {
253
+ const config = await loadConfig();
254
+ emit("config", { config: projectConfig(config) });
255
+ process.exit(0);
256
+ }
257
+
258
+ function readManifest(outDir) {
259
+ const file = path.join(outDir, ".vite", "manifest.json");
260
+ if (!existsSync(file)) throw new Error(`uf: the client build wrote no manifest at ${file}`);
261
+ return JSON.parse(readFileSync(file, "utf8"));
262
+ }
263
+
264
+ /**
265
+ * Script, stylesheet and preload URLs for the client entry chunk.
266
+ *
267
+ * The entry is found by its `isEntry` flag rather than by key, because a
268
+ * virtual module's manifest key is an implementation detail of the bundler.
269
+ */
270
+ /**
271
+ * The tags a prerendered document needs.
272
+ *
273
+ * Two walks over the manifest, because the two answers are different. A
274
+ * `modulepreload` is worth emitting only for a chunk this document will
275
+ * certainly load, which is the entry's *static* imports. A stylesheet has to
276
+ * be emitted for anything the page might render, and the router loads every
277
+ * route module dynamically — so a stylesheet imported by a layout is reached
278
+ * through `dynamicImports` and through nothing else. Following only the static
279
+ * graph, as this did, meant a layout could import a stylesheet and the built
280
+ * HTML would silently ship without it.
281
+ *
282
+ * The cost is that a project with per-route stylesheets links all of them on
283
+ * every page. Narrowing that needs the route table to say which chunk each
284
+ * route came from, which the manifest alone cannot tell us.
285
+ */
286
+ function assetsFromManifest(manifest) {
287
+ const entry = Object.values(manifest).find((chunk) => chunk.isEntry);
288
+ if (entry == null) throw new Error("uf: the client manifest has no entry chunk");
289
+
290
+ const styles = new Set(entry.css ?? []);
291
+ const seen = new Set();
292
+ const collectStyles = (chunk) => {
293
+ for (const imported of [...(chunk.imports ?? []), ...(chunk.dynamicImports ?? [])]) {
294
+ if (seen.has(imported)) continue;
295
+ seen.add(imported);
296
+ const dependency = manifest[imported];
297
+ if (dependency == null) continue;
298
+ for (const css of dependency.css ?? []) styles.add(css);
299
+ collectStyles(dependency);
300
+ }
301
+ };
302
+ collectStyles(entry);
303
+
304
+ const preloads = new Set();
305
+ const collectPreloads = (chunk) => {
306
+ for (const imported of chunk.imports ?? []) {
307
+ const dependency = manifest[imported];
308
+ if (dependency == null || preloads.has(dependency.file)) continue;
309
+ preloads.add(dependency.file);
310
+ collectPreloads(dependency);
311
+ }
312
+ };
313
+ collectPreloads(entry);
314
+
315
+ return {
316
+ scripts: [`/${entry.file}`],
317
+ styles: [...styles].map((file) => `/${file}`),
318
+ preloads: [...preloads].map((file) => `/${file}`),
319
+ };
320
+ }
321
+
322
+ /**
323
+ * The URLs to prerender: every route without parameters, plus every set of
324
+ * parameters a page's `generateStaticParams` returns.
325
+ */
326
+ async function staticPaths(routes) {
327
+ const urls = [];
328
+ for (const route of routes) {
329
+ if (route.params.length === 0) {
330
+ urls.push(route.path);
331
+ continue;
332
+ }
333
+ const module = await route.page();
334
+ const generate = module.generateStaticParams;
335
+ if (typeof generate !== "function") continue;
336
+ for (const params of await generate()) {
337
+ urls.push(fillParams(route.path, params));
338
+ }
339
+ }
340
+ return urls;
341
+ }
342
+
343
+ function fillParams(routePath, params) {
344
+ return routePath
345
+ .split("/")
346
+ .map((segment) => {
347
+ if (segment.endsWith("*")) {
348
+ const value = params[segment.slice(1, -1)];
349
+ return Array.isArray(value) ? value.map(encodeURIComponent).join("/") : encodeURIComponent(String(value ?? ""));
350
+ }
351
+ if (segment.startsWith(":")) return encodeURIComponent(String(params[segment.slice(1)] ?? ""));
352
+ return segment;
353
+ })
354
+ .join("/");
355
+ }
356
+
357
+ function htmlPathFor(outDir, url) {
358
+ const pathname = url.split("?")[0].replace(/^\/+/, "");
359
+ return pathname === "" ? path.join(outDir, "index.html") : path.join(outDir, pathname, "index.html");
360
+ }
package/index.js ADDED
@@ -0,0 +1,316 @@
1
+ // Plain JavaScript: Vite imports this module directly, before any transform.
2
+ //
3
+ // `@uniflowed/vite` — uf, as Vite plugins.
4
+ //
5
+ // Vite is the dev server, the module graph, hot module replacement, the
6
+ // bundler and the plugin system; uf contributes what is specific to a Flow
7
+ // React application and nothing that Vite already does:
8
+ //
9
+ // * `uf:flow` — every Flow module goes through `uf transform` (the official
10
+ // Flow parser, Flow's own lowering rules, the official React
11
+ // Compiler, oxc), plus the React Fast Refresh wiring in
12
+ // development and the virtual modules that make a directory
13
+ // of pages an application: the route table, the client entry
14
+ // that hydrates it, and the server entry that renders it. In
15
+ // development it also renders every HTML request on the
16
+ // server, so `uf dev` serves the same markup `uf build` writes.
17
+ // * `uf:mdx` — `@mdx-js/rollup`, configured for React with GitHub-flavoured
18
+ // markdown, front matter and heading ids, so `.mdx` works with
19
+ // no configuration.
20
+ //
21
+ // `uniflowed(options)` returns the array; a project that wants to add a plugin
22
+ // declares it in `uf.config.js` and the driver appends it after these.
23
+
24
+ import { readdirSync } from "node:fs";
25
+ import path from "node:path";
26
+
27
+ import mdx from "@mdx-js/rollup";
28
+ import rehypeSlug from "rehype-slug";
29
+ import remarkFrontmatter from "remark-frontmatter";
30
+ import remarkGfm from "remark-gfm";
31
+ import remarkMdxFrontmatter from "remark-mdx-frontmatter";
32
+
33
+ import {
34
+ RUNTIME_PUBLIC_PATH,
35
+ RUNTIME_RESOLVED_ID,
36
+ addRefreshWrapper,
37
+ preambleCode,
38
+ refreshRuntimeSource,
39
+ } from "./internal/refresh.js";
40
+ import {
41
+ VIRTUAL,
42
+ clientModuleSource,
43
+ routesModuleSource,
44
+ scanRoutes,
45
+ serverModuleSource,
46
+ } from "./internal/routes.js";
47
+ import { TransformService, isFlowModule } from "./transform.js";
48
+
49
+ /** A resolved virtual id: Vite's convention is a leading NUL byte. */
50
+ const resolved = (id) => `\0${id}`;
51
+ const VIRTUAL_IDS = new Set(Object.values(VIRTUAL));
52
+
53
+ /** The URL a NUL-prefixed module is served at in development. */
54
+ export function devUrlFor(id) {
55
+ return `/@id/__x00__${id}`;
56
+ }
57
+
58
+ /**
59
+ * Options for the plugin set.
60
+ *
61
+ * @typedef {object} UniflowedOptions
62
+ * @property {string} [root] absolute project root; Vite's root by default
63
+ * @property {object} [config] the loaded `uf.config.js` object
64
+ * @property {string} [command] the `uf` binary to transform through
65
+ */
66
+
67
+ /**
68
+ * uf's Vite plugins.
69
+ *
70
+ * @param {UniflowedOptions} [options]
71
+ */
72
+ export default function uniflowed(options = {}) {
73
+ const ufConfig = options.config ?? {};
74
+ const app = ufConfig.app ?? {};
75
+ const routerRoot = app.router?.root ?? "app";
76
+ const appEntry = app.router?.entry ?? ufConfig.build?.entries?.[0] ?? "app.js";
77
+ const markdown = app.builtins?.markdown ?? {};
78
+
79
+ return [flowPlugin({ routerRoot, appEntry, command: options.command }), mdxPlugin(markdown)];
80
+ }
81
+
82
+ function flowPlugin({ routerRoot, appEntry, command }) {
83
+ let root = process.cwd();
84
+ let isProduction = false;
85
+ let base = "/";
86
+ let appRoot = "";
87
+ let entryPath = "";
88
+ /** @type {import("vite").ViteDevServer | null} */
89
+ let server = null;
90
+ /** @type {TransformService | null} */
91
+ let service = null;
92
+
93
+ const ensureService = () => {
94
+ service ??= new TransformService({ command, root });
95
+ return service;
96
+ };
97
+
98
+ return {
99
+ name: "uf:flow",
100
+ enforce: "pre",
101
+
102
+ config(userConfig, env) {
103
+ const projectRoot = path.resolve(userConfig.root ?? process.cwd());
104
+ isProduction = env.mode === "production" || env.command === "build";
105
+ return {
106
+ // uf serves HTML itself; there is no index.html to fall back to.
107
+ appType: "custom",
108
+ resolve: {
109
+ dedupe: ["react", "react-dom"],
110
+ },
111
+ optimizeDeps: {
112
+ include: [
113
+ "react",
114
+ "react/jsx-runtime",
115
+ "react/jsx-dev-runtime",
116
+ "react/compiler-runtime",
117
+ "react-dom",
118
+ "react-dom/client",
119
+ ],
120
+ // uf's packages ship Flow. The dependency optimiser pre-bundles
121
+ // with a JavaScript parser and would reject every one of them.
122
+ exclude: uniflowedPackages(projectRoot),
123
+ },
124
+ ssr: {
125
+ // Same reason on the server: Node cannot import Flow, so these go
126
+ // through the plugin like project code rather than being
127
+ // externalised.
128
+ noExternal: [/^@uniflowed\//],
129
+ },
130
+ };
131
+ },
132
+
133
+ configResolved(config) {
134
+ root = config.root;
135
+ base = config.base;
136
+ appRoot = path.resolve(root, routerRoot);
137
+ entryPath = path.resolve(root, appEntry);
138
+ },
139
+
140
+ buildStart() {
141
+ ensureService();
142
+ },
143
+
144
+ resolveId(id) {
145
+ if (id === RUNTIME_PUBLIC_PATH) return RUNTIME_RESOLVED_ID;
146
+ if (VIRTUAL_IDS.has(id)) return resolved(id);
147
+ return null;
148
+ },
149
+
150
+ load(id) {
151
+ if (id === RUNTIME_RESOLVED_ID) return refreshRuntimeSource();
152
+ if (id === resolved(VIRTUAL.routes)) return routesModuleSource(scanRoutes(appRoot));
153
+ if (id === resolved(VIRTUAL.client)) return clientModuleSource(entryPath);
154
+ if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
155
+ return null;
156
+ },
157
+
158
+ async transform(code, id, transformOptions) {
159
+ if (!isFlowModule(id)) return null;
160
+ const ssr = transformOptions?.ssr === true || this.environment?.name === "ssr";
161
+ const refresh = !isProduction && !ssr && server != null;
162
+ const out = await ensureService().transform(cleanId(id), code, {
163
+ development: !isProduction,
164
+ refresh,
165
+ sourceMap: true,
166
+ });
167
+ if (out == null) return null;
168
+ for (const diagnostic of out.diagnostics) {
169
+ this.warn?.(`${diagnostic.function ?? "a function"}: ${diagnostic.message}`);
170
+ }
171
+ const map = out.map == null ? null : JSON.parse(out.map);
172
+ if (!refresh) return { code: out.code, map };
173
+ const relative = path.relative(root, cleanId(id)).split(path.sep).join("/");
174
+ return addRefreshWrapper(out.code, map, relative);
175
+ },
176
+
177
+ buildEnd() {
178
+ // A dev server keeps its service for the whole session; a build is
179
+ // done with it here.
180
+ if (server == null) {
181
+ service?.close();
182
+ service = null;
183
+ }
184
+ },
185
+
186
+ transformIndexHtml() {
187
+ if (isProduction) return [];
188
+ return [
189
+ {
190
+ tag: "script",
191
+ attrs: { type: "module" },
192
+ children: preambleCode(base),
193
+ injectTo: "head-prepend",
194
+ },
195
+ ];
196
+ },
197
+
198
+ configureServer(devServer) {
199
+ server = devServer;
200
+ devServer.httpServer?.once("close", () => {
201
+ service?.close();
202
+ service = null;
203
+ });
204
+
205
+ // A page or layout appearing or disappearing changes the route table,
206
+ // which lives in a virtual module the watcher knows nothing about.
207
+ const reserved = /\/_uf\.(page|layout|middleware|not-found)(\.[a-z]+)?\.(js|jsx|mdx)$/;
208
+ const onRouteFile = (file) => {
209
+ if (!reserved.test(file) || !file.startsWith(appRoot)) return;
210
+ const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
211
+ if (routes) devServer.moduleGraph.invalidateModule(routes);
212
+ devServer.ws.send({ type: "full-reload", path: "*" });
213
+ };
214
+ devServer.watcher.on("add", onRouteFile);
215
+ devServer.watcher.on("unlink", onRouteFile);
216
+
217
+ // After Vite's own middlewares, so `/@vite/client`, `/@id/...` and
218
+ // static files are served first and only a document request reaches
219
+ // the renderer.
220
+ return () => {
221
+ devServer.middlewares.use(async (request, response, next) => {
222
+ if (!wantsDocument(request)) return next();
223
+ try {
224
+ const url = request.url ?? "/";
225
+ const { render } = await importServerEntry(devServer);
226
+ const result = await render(url, {
227
+ scripts: [devUrlFor(VIRTUAL.client)],
228
+ styles: [],
229
+ preloads: [],
230
+ });
231
+ const html = await devServer.transformIndexHtml(url, result.html);
232
+ response.statusCode = result.status;
233
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
234
+ for (const [name, value] of Object.entries(result.headers ?? {})) {
235
+ response.setHeader(name, value);
236
+ }
237
+ response.end(html);
238
+ } catch (error) {
239
+ devServer.ssrFixStacktrace(error);
240
+ next(error);
241
+ }
242
+ });
243
+ };
244
+ },
245
+ };
246
+ }
247
+
248
+ function mdxPlugin(markdown) {
249
+ const mdxConfig = markdown.mdx ?? {};
250
+ if (mdxConfig.enabled === false) return { name: "uf:mdx" };
251
+ return {
252
+ enforce: "pre",
253
+ ...mdx({
254
+ jsxImportSource: "react",
255
+ remarkPlugins: [remarkGfm, remarkFrontmatter, [remarkMdxFrontmatter, { name: "frontmatter" }]],
256
+ rehypePlugins: [rehypeSlug],
257
+ }),
258
+ name: "uf:mdx",
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Import `virtual:uf/server` through the dev server's module runner.
264
+ *
265
+ * Vite 6 introduced the environment API and its module runner; `ssrLoadModule`
266
+ * is the older path and is kept as the fallback.
267
+ */
268
+ async function importServerEntry(devServer) {
269
+ const ssr = devServer.environments?.ssr;
270
+ if (ssr != null) {
271
+ if (ssr.runner == null) {
272
+ const { createServerModuleRunner } = await import("vite");
273
+ ssr.runner = createServerModuleRunner(ssr, { hmr: { logger: false } });
274
+ }
275
+ return ssr.runner.import(VIRTUAL.server);
276
+ }
277
+ return devServer.ssrLoadModule(VIRTUAL.server);
278
+ }
279
+
280
+ function wantsDocument(request) {
281
+ if (request.method !== "GET" && request.method !== "HEAD") return false;
282
+ const url = request.url ?? "/";
283
+ if (url.startsWith("/@") || url.startsWith("/node_modules/")) return false;
284
+ const accept = request.headers.accept ?? "";
285
+ if (!accept.includes("text/html")) return false;
286
+ const pathname = url.split("?")[0];
287
+ // A request for a file — `/favicon.svg`, `/assets/x.js` — that no static
288
+ // middleware answered is a 404, not a page.
289
+ return !/\.[a-z0-9]+$/i.test(pathname);
290
+ }
291
+
292
+ function cleanId(id) {
293
+ const at = id.indexOf("?");
294
+ return at === -1 ? id : id.slice(0, at);
295
+ }
296
+
297
+ /**
298
+ * Every `@uniflowed/*` package the project can resolve, for
299
+ * `optimizeDeps.exclude`, which takes names rather than patterns.
300
+ */
301
+ function uniflowedPackages(root) {
302
+ const names = new Set();
303
+ let directory = root;
304
+ for (let depth = 0; depth < 16; depth += 1) {
305
+ const scope = path.join(directory, "node_modules", "@uniflowed");
306
+ try {
307
+ for (const entry of readdirSync(scope)) names.add(`@uniflowed/${entry}`);
308
+ } catch {
309
+ // no packages at this level
310
+ }
311
+ const parent = path.dirname(directory);
312
+ if (parent === directory) break;
313
+ directory = parent;
314
+ }
315
+ return [...names].sort();
316
+ }