@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 +22 -0
- package/driver.js +360 -0
- package/index.js +316 -0
- package/internal/config.js +141 -0
- package/internal/events.js +68 -0
- package/internal/node-hooks.js +111 -0
- package/internal/refresh-runtime.js +670 -0
- package/internal/refresh.js +110 -0
- package/internal/routes.js +243 -0
- package/package.json +41 -0
- package/register.js +11 -0
- package/transform.js +187 -0
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
|
+
}
|