@uniflowed/vite 0.0.0-alpha.34 → 0.0.0-alpha.37

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.
@@ -0,0 +1,33 @@
1
+ // @noflow
2
+ //
3
+ // A document's YAML front matter, as `export const frontmatter`.
4
+ //
5
+ // Plain JavaScript, for the reason `index.js` gives: Vite imports this before
6
+ // any transform runs.
7
+ //
8
+ // This is `remark-mdx-frontmatter` for the one format uf reads, and it replaces
9
+ // that package for one reason. The package imported `toml` 3.0.0 at the top of
10
+ // its module whether or not a document held any TOML, so every project uf
11
+ // scaffolded installed a parser with two high advisories and failed its first
12
+ // `uf audit` (#1009). The TOML half was never reachable from here either:
13
+ // `remark-frontmatter` is given its default, which recognises YAML and nothing
14
+ // else, so a `+++` block was never a front-matter node to parse.
15
+ //
16
+ // For YAML the output is the package's, through the same two helpers it used:
17
+ // the first `yaml` node is parsed and defined as `frontmatter`, and a document
18
+ // with none exports `undefined`.
19
+
20
+ import { valueToEstree } from "estree-util-value-to-estree";
21
+ import { define } from "unist-util-mdx-define";
22
+ import { parse } from "yaml";
23
+
24
+ /** The remark plugin: `export const frontmatter` from a document's YAML. */
25
+ export default function remarkFrontmatterExport() {
26
+ return (tree, file) => {
27
+ const node = tree.children.find((child) => child.type === "yaml");
28
+ const data = node == null ? undefined : parse(node.value);
29
+ define(tree, file, {
30
+ frontmatter: valueToEstree(data, { preserveReferences: true }),
31
+ });
32
+ };
33
+ }
package/internal/http.js CHANGED
@@ -25,13 +25,18 @@
25
25
  * that accepts an upload should not need the whole thing buffered before it
26
26
  * starts.
27
27
  *
28
+ * `path` names the path and query to build it at, when that is not the one the
29
+ * request line carried — `uf dev` passes the one Vite's base middleware has
30
+ * already taken `app.router.basePath` off.
31
+ *
28
32
  * @param {import("node:http").IncomingMessage} incoming
29
33
  * @param {{server?: {https?: unknown}} | undefined} config the resolved Vite config
34
+ * @param {string} [path]
30
35
  */
31
- export async function toRequest(incoming, config) {
36
+ export async function toRequest(incoming, config, path) {
32
37
  const host = incoming.headers.host ?? "localhost";
33
38
  const protocol = config?.server?.https == null ? "http" : "https";
34
- const url = new URL(incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
39
+ const url = new URL(path ?? incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
35
40
 
36
41
  const headers = new Headers();
37
42
  for (const [name, value] of Object.entries(incoming.headers)) {
@@ -52,6 +57,20 @@ export async function toRequest(incoming, config) {
52
57
  return new Request(url, init);
53
58
  }
54
59
 
60
+ /**
61
+ * A Node request as a `Request` that carries its address and nothing else.
62
+ *
63
+ * For the questions asked about a URL alone — `app.router`'s redirects and
64
+ * headers, in front of Vite's own middleware — which must not wrap the body a
65
+ * later middleware turns into the `Request` the application reads.
66
+ *
67
+ * @param {import("node:http").IncomingMessage} incoming
68
+ */
69
+ export function toAddressRequest(incoming) {
70
+ const host = incoming.headers.host ?? "localhost";
71
+ return new Request(new URL(incoming.originalUrl ?? incoming.url ?? "/", `http://${host}`));
72
+ }
73
+
55
74
  /**
56
75
  * Write a `Response` to a Node response.
57
76
  *
@@ -0,0 +1,134 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: the driver imports it, and the driver runs before any Flow
4
+ // loader exists.
5
+ //
6
+ // The module graph `uf build --analyze` reads: every module each bundle was
7
+ // built from, what it imports, and which chunk its rendered code went into.
8
+ //
9
+ // What the graph means — which route a module belongs to, the chain of imports
10
+ // behind it, what it weighs — is uf's, in `crates/uf_bundle/src/analysis.rs`.
11
+ // This file writes down only what the bundler knows and uf cannot, and that
12
+ // split is the reason the graph is a file at all: a builder other than this
13
+ // one that writes the same file gets the same analysis.
14
+ //
15
+ // {
16
+ // "version": 1,
17
+ // "builds": [{
18
+ // "environment": "client",
19
+ // "entries": ["virtual:uf/client"],
20
+ // "modules": [{ "id": "app/$page.js", "imports": ["app/Counter.js"], "dynamicImports": [] }],
21
+ // "chunks": [{
22
+ // "file": "assets/index-3f2a.js",
23
+ // "facade": "virtual:uf/client",
24
+ // "modules": [{ "id": "virtual:uf/client", "code": "…" }]
25
+ // }]
26
+ // }]
27
+ // }
28
+
29
+ import { mkdirSync, writeFileSync } from "node:fs";
30
+ import path from "node:path";
31
+
32
+ /** Where the driver writes the graph, in the build's metadata directory. */
33
+ export const MODULE_GRAPH_FILE = "uf-module-graph.json";
34
+
35
+ const VERSION = 1;
36
+
37
+ /**
38
+ * A plugin that records the graph of every bundle it takes part in, and the
39
+ * means to write what it recorded.
40
+ *
41
+ * `isReference(file)` says whether an absolute file is a client reference: an
42
+ * entry the browser loads because a server component named it, rather than on
43
+ * every page. Its chunk still carries it as the facade, and it is left out of
44
+ * `entries`, which is the list of what every page of a bundle loads.
45
+ */
46
+ export function createModuleGraphCollector(root, { isReference = () => false } = {}) {
47
+ const builds = [];
48
+ const graph = () => ({ version: VERSION, builds });
49
+ return {
50
+ plugin: {
51
+ name: "uf:module-graph",
52
+ // Last, so the bundle it reads is one every other plugin has finished.
53
+ enforce: "post",
54
+ generateBundle(_options, bundle) {
55
+ builds.push(describeBundle(this, root, bundle, isReference));
56
+ },
57
+ },
58
+ graph,
59
+ write(file) {
60
+ mkdirSync(path.dirname(file), { recursive: true });
61
+ writeFileSync(file, `${JSON.stringify(graph())}\n`);
62
+ },
63
+ };
64
+ }
65
+
66
+ /** One bundle, as `generateBundle` sees it. */
67
+ function describeBundle(context, root, bundle, isReference) {
68
+ const ids = typeof context.getModuleIds === "function" ? [...context.getModuleIds()] : [];
69
+ const modules = ids
70
+ .map((id) => {
71
+ const info = context.getModuleInfo(id);
72
+ return {
73
+ id: moduleId(root, id),
74
+ imports: (info?.importedIds ?? []).map((imported) => moduleId(root, imported)),
75
+ dynamicImports: (info?.dynamicallyImportedIds ?? []).map((imported) =>
76
+ moduleId(root, imported),
77
+ ),
78
+ };
79
+ })
80
+ .sort(byKey("id"));
81
+ const entries = new Set();
82
+ const chunks = [];
83
+ for (const output of Object.values(bundle)) {
84
+ if (output.type !== "chunk") {
85
+ continue;
86
+ }
87
+ const facade = output.isEntry ? (output.facadeModuleId ?? null) : null;
88
+ if (facade != null && !isReference(facade.split("?")[0])) {
89
+ entries.add(moduleId(root, facade));
90
+ }
91
+ chunks.push({
92
+ file: output.fileName,
93
+ facade: facade == null ? null : moduleId(root, facade),
94
+ // A module the bundler left no code for was still walked through to
95
+ // reach what it imports, so it stays in `modules` above; it has nothing
96
+ // to weigh, so it is not in the chunk.
97
+ modules: Object.entries(output.modules ?? {})
98
+ .map(([id, rendered]) => ({ id: moduleId(root, id), code: rendered?.code ?? "" }))
99
+ .filter((module) => module.code !== ""),
100
+ });
101
+ }
102
+ return {
103
+ environment: context.environment?.name ?? "client",
104
+ entries: [...entries].sort(),
105
+ modules,
106
+ chunks: chunks.sort(byKey("file")),
107
+ };
108
+ }
109
+
110
+ /**
111
+ * A module id as the analysis spells it: relative to the project root with
112
+ * `/`, without the `\0` a resolved virtual module carries, and with its query.
113
+ *
114
+ * Relative even when it climbs out of the root, as a workspace package does,
115
+ * because an absolute path would make two machines' reports of the same build
116
+ * disagree.
117
+ */
118
+ export function moduleId(root, id) {
119
+ const bare = id.startsWith("\0") ? id.slice(1) : id;
120
+ const at = bare.indexOf("?");
121
+ const file = at === -1 ? bare : bare.slice(0, at);
122
+ if (!path.isAbsolute(file)) {
123
+ return bare;
124
+ }
125
+ const relative = path.relative(root, file);
126
+ if (path.isAbsolute(relative)) {
127
+ return bare;
128
+ }
129
+ return `${relative.split(path.sep).join("/")}${at === -1 ? "" : bare.slice(at)}`;
130
+ }
131
+
132
+ function byKey(key) {
133
+ return (a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0);
134
+ }
@@ -1653,10 +1653,11 @@ export function clientModuleSource(appEntry, options = {}) {
1653
1653
  const strictMode = options.strictMode === true ? ", strictMode: true" : "";
1654
1654
  const navigation = options.navigation === "document" ? ', navigation: "document"' : "";
1655
1655
  const mount = options.mount === "render" ? "render" : "hydrate";
1656
+ const routing = routingArgumentSource(options.routing);
1656
1657
  return `import { ${mount} } from "@uniflowed/router/client";
1657
1658
  import { routes, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
1658
1659
  import App from ${JSON.stringify(appEntry)};
1659
- ${mount}({ App, routes, notFound, errors${strictMode}${navigation} });
1660
+ ${mount}({ App, routes, notFound, errors${strictMode}${navigation}${routing} });
1660
1661
  `;
1661
1662
  }
1662
1663
 
@@ -1714,16 +1715,18 @@ ${mount}({ App, routes, notFound, errors${strictMode}${navigation} });
1714
1715
  * shorter proof of the paragraph above: the copy the router dispatches and
1715
1716
  * renders with is by construction the copy the host is handed.
1716
1717
  */
1717
- export function serverModuleSource(appEntry) {
1718
+ export function serverModuleSource(appEntry, routing = routingRulesOf({})) {
1718
1719
  return `import {
1719
1720
  createActionDispatcher,
1720
1721
  createDispatcher,
1721
1722
  createMiddlewareRunner,
1722
1723
  createRenderer,
1724
+ installRouting,
1723
1725
  } from "@uniflowed/router/server";
1724
1726
  import { routes, handlers, middleware, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
1725
1727
  import { actions } from ${JSON.stringify(VIRTUAL.actions)};
1726
1728
  import App from ${JSON.stringify(appEntry)};
1729
+ ${routingExportSource(routing)}installRouting(routing);
1727
1730
  export { routes, handlers, middleware, notFound, errors };
1728
1731
  export { beginRequest } from "@uniflowed/router/server";
1729
1732
  const renderer = createRenderer({ App, routes, notFound, errors });
@@ -1735,3 +1738,53 @@ export const callAction = createActionDispatcher({ actions });
1735
1738
  export const runMiddleware = createMiddlewareRunner({ middleware });
1736
1739
  `;
1737
1740
  }
1741
+
1742
+ /**
1743
+ * `app.router.redirects`, `rewrites` and `headers`, as the bundle carries them.
1744
+ *
1745
+ * Three lists and nothing else, each present, so a host reads `routing` the one
1746
+ * way whatever the project wrote. Validation is not here: `uf_config` refuses a
1747
+ * rule it cannot read when the file is loaded, with a sentence per spelling,
1748
+ * and `@uniflowed/server`'s `internal/routing.js` is what interprets one.
1749
+ *
1750
+ * @param {{redirects?: unknown[], rewrites?: unknown[], headers?: unknown[]} | undefined} router
1751
+ */
1752
+ export function routingRulesOf(router) {
1753
+ const policy = router?.trailingSlash;
1754
+ return {
1755
+ redirects: Array.isArray(router?.redirects) ? router.redirects : [],
1756
+ rewrites: Array.isArray(router?.rewrites) ? router.rewrites : [],
1757
+ headers: Array.isArray(router?.headers) ? router.headers : [],
1758
+ basePath: typeof router?.basePath === "string" ? router.basePath.replace(/\/+$/, "") : "",
1759
+ trailingSlash: policy === "never" || policy === "always" ? policy : "ignore",
1760
+ };
1761
+ }
1762
+
1763
+ /**
1764
+ * `basePath` and `trailingSlash` as arguments to a client entry's call, or
1765
+ * nothing for a project at the root with the default policy — so a default
1766
+ * project's entry is the module it has always been.
1767
+ *
1768
+ * @param {{basePath?: string, trailingSlash?: string} | undefined} routing
1769
+ */
1770
+ export function routingArgumentSource(routing) {
1771
+ const basePath = routing?.basePath ?? "";
1772
+ const trailingSlash = routing?.trailingSlash ?? "ignore";
1773
+ let source = "";
1774
+ if (basePath !== "") source += `, basePath: ${JSON.stringify(basePath)}`;
1775
+ if (trailingSlash !== "ignore") source += `, trailingSlash: ${JSON.stringify(trailingSlash)}`;
1776
+ return source;
1777
+ }
1778
+
1779
+ /**
1780
+ * The `routing` export of `virtual:uf/server`.
1781
+ *
1782
+ * On the bundle rather than read from `uf.config.js` where a host starts, so a
1783
+ * served build answers with the rules it was built with — `uf start` of last
1784
+ * week's build, and every `--adapter` artefact, carry their own.
1785
+ *
1786
+ * @param {ReturnType<typeof routingRulesOf>} routing
1787
+ */
1788
+ export function routingExportSource(routing) {
1789
+ return `export const routing = ${JSON.stringify(routing)};\n`;
1790
+ }
package/internal/serve.js CHANGED
@@ -156,8 +156,13 @@ async function cacheFor(declared, createCacheStore, where) {
156
156
  * @param {{store?: string, storeDir?: string} | undefined} declared
157
157
  * @param {{root: string, build: string | null}} where
158
158
  */
159
- async function providerFor(declared, { root, build }) {
160
- const named = declared?.store ?? "memory";
159
+ async function providerFor(declared, { root, build, regenerates }) {
160
+ // A build that regenerates pages keeps what it regenerated on disk unless the
161
+ // project named a store. A restart that took every regenerated page back to
162
+ // the build's copy would be a server whose pages went back in time, which is
163
+ // the one thing regeneration is for not doing. `"memory"`, said out loud, is
164
+ // still memory.
165
+ const named = declared?.store ?? (regenerates === true ? "filesystem" : "memory");
161
166
  if (named === "memory") return null;
162
167
  if (build == null) {
163
168
  throw new Error(
@@ -213,7 +218,81 @@ export async function loadBuild({ root, outDir, serverDir }) {
213
218
  const entry = await import(pathToFileURL(entryFile).href);
214
219
  await deployment();
215
220
  const build = await buildIdentity(root, serverDir);
216
- return { entry, assets: assetsFromManifest(manifest), distDir, root, build };
221
+ const regeneration = await readRegeneration(path.resolve(root, serverDir));
222
+ return {
223
+ entry,
224
+ assets: await documentAssetsFor(path.resolve(root, serverDir), manifest),
225
+ distDir,
226
+ root,
227
+ build,
228
+ regeneration,
229
+ };
230
+ }
231
+
232
+ /** What `uf build` records a document's tags in, beside the server bundle. */
233
+ export const DOCUMENT_ASSETS_FILE = "uf-document-assets.json";
234
+
235
+ /**
236
+ * The tags a served document needs: the ones `uf build` recorded, or — for a
237
+ * build from before it recorded them — the client manifest's.
238
+ *
239
+ * Recorded rather than recomputed, because the client manifest is no longer the
240
+ * whole answer. An application React Server Components render links the
241
+ * stylesheets its rsc graph emitted and the ones each client module's chunk
242
+ * carries, and neither is reachable from the client entry the manifest is walked
243
+ * from — so a server that recomputed the tags rendered every page without the
244
+ * stylesheets its prerendered pages had. `uf start`, `uf preview`, `--adapter`
245
+ * and `--compile` all read them from here.
246
+ *
247
+ * @param {string} serverDir absolute path of the server bundle's directory
248
+ * @param {object} manifest the client build's Vite manifest
249
+ */
250
+ export async function documentAssetsFor(serverDir, manifest) {
251
+ const file = path.join(serverDir, DOCUMENT_ASSETS_FILE);
252
+ let recorded;
253
+ try {
254
+ recorded = await readFile(file, "utf8");
255
+ } catch {
256
+ return assetsFromManifest(manifest);
257
+ }
258
+ try {
259
+ return JSON.parse(recorded);
260
+ } catch {
261
+ throw new Error(`uf: ${file} is not the JSON \`uf build\` writes; run \`uf build\` again`);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * The file beside the server bundle that names the pages a build regenerates.
267
+ *
268
+ * Written by `driver.js`'s build, and only for a build that has such a page.
269
+ */
270
+ export const REGENERATION_FILE = "regenerate.json";
271
+
272
+ /**
273
+ * Where a regenerated page's document goes, under the build's output directory.
274
+ *
275
+ * Somewhere no static half answers the page's own URL, which is the point: a
276
+ * file at `dist/posts/a/index.html` would be served by every host before the
277
+ * server saw the request, forever, whatever the page's lifetime said.
278
+ */
279
+ export const REGENERATED_DIRECTORY = "__uf/regenerate";
280
+
281
+ /**
282
+ * The pages this build regenerates, or `undefined` for a build that has none.
283
+ *
284
+ * `undefined` rather than an empty manifest, so a build with nothing to
285
+ * regenerate serves exactly as every build did before regeneration existed.
286
+ */
287
+ export async function readRegeneration(serverDir) {
288
+ let text;
289
+ try {
290
+ text = await readFile(path.join(serverDir, REGENERATION_FILE), "utf8");
291
+ } catch (error) {
292
+ if (error?.code === "ENOENT") return undefined;
293
+ throw error;
294
+ }
295
+ return JSON.parse(text);
217
296
  }
218
297
 
219
298
  /**
@@ -303,8 +382,15 @@ async function readable(file, message) {
303
382
  * the rest moved to `@uniflowed/server`. `uf build --adapter` calls it too,
304
383
  * at build time, and bakes the answer into what it emits.
305
384
  */
306
- export function assetsFromManifest(manifest) {
307
- const entry = Object.values(manifest).find((chunk) => chunk.isEntry);
385
+ export function assetsFromManifest(manifest, base = "") {
386
+ // `client` by name first. An application React Server Components render
387
+ // gives the client build one entry per client module as well, and the
388
+ // document's script is the application's entry, not whichever of those the
389
+ // manifest happens to list first.
390
+ const chunks = Object.values(manifest);
391
+ const entry =
392
+ chunks.find((chunk) => chunk.isEntry && chunk.name === "client") ??
393
+ chunks.find((chunk) => chunk.isEntry);
308
394
  if (entry == null) throw new Error("uf: the client manifest has no entry chunk");
309
395
 
310
396
  const styles = new Set(entry.css ?? []);
@@ -332,10 +418,12 @@ export function assetsFromManifest(manifest) {
332
418
  };
333
419
  collectPreloads(entry);
334
420
 
421
+ // Under `app.router.basePath` when there is one: a prerendered document is
422
+ // written outside Vite's HTML transform, so nothing else would put it there.
335
423
  return {
336
- scripts: [`/${entry.file}`],
337
- styles: [...styles].map((file) => `/${file}`),
338
- preloads: [...preloads].map((file) => `/${file}`),
424
+ scripts: [`${base}/${entry.file}`],
425
+ styles: [...styles].map((file) => `${base}/${file}`),
426
+ preloads: [...preloads].map((file) => `${base}/${file}`),
339
427
  };
340
428
  }
341
429
 
@@ -437,7 +525,7 @@ export async function beginRequest(entry, request) {
437
525
  *
438
526
  * @param {{entry: object, assets: object, cache?: object, root?: string, build?: string | null}} build
439
527
  */
440
- export function createApplicationHandler({ entry, assets, cache, root, build }) {
528
+ export function createApplicationHandler({ entry, assets, cache, root, build, regeneration }) {
441
529
  const ready = deployment().then(
442
530
  async ({ createFetchHandler, createCacheStore, nodeCapabilities }) =>
443
531
  createFetchHandler({
@@ -446,7 +534,12 @@ export function createApplicationHandler({ entry, assets, cache, root, build })
446
534
  cache: await cacheFor(cache, createCacheStore, {
447
535
  root: root ?? process.cwd(),
448
536
  build: build ?? null,
537
+ regenerates: regeneration != null,
449
538
  }),
539
+ // The pages this build regenerates, from the manifest beside the server
540
+ // bundle. Absent for a build with none, which then serves exactly as it
541
+ // did before regeneration existed.
542
+ ...(regeneration == null ? {} : { regeneration }),
450
543
  // `uf preview` and `uf start` are a Node process with a socket, which is
451
544
  // what a deployed `--adapter node` build is too — so a route handler
452
545
  // that streams events answers the same way in the preview it is checked
@@ -482,16 +575,115 @@ export function createStaticHandler({ root }) {
482
575
  * project whose handler path collides with a file in `public/` behaves one way
483
576
  * when it is checked and the other way when it is deployed.
484
577
  *
485
- * @param {{entry: object, assets: object, distDir: string, cache?: object, root?: string, build?: string | null}} build
578
+ * It is `@uniflowed/server/node`'s own `createServeHandler`, the one a deployed
579
+ * `server.js` runs, rather than the two halves composed a second time here.
580
+ * That one also hands the application the build's files when the static half
581
+ * has nothing, which is how a regenerated page starts from the document the
582
+ * build wrote; a composition of its own here would be a `uf start` whose
583
+ * regenerated pages rendered on their first request while a deployment's did
584
+ * not.
585
+ *
586
+ * It is handed the bundle's `routing` too, so `app.router`'s redirects answer
587
+ * before the files and its headers go on whatever answers, here exactly as in
588
+ * a deployment. `uf preview` puts the same two in front of Vite's file
589
+ * middleware as well; see [`answerRouting`].
590
+ *
591
+ * @param {{entry: object, assets: object, distDir: string, cache?: object, root?: string, build?: string | null, regeneration?: object}} build
486
592
  */
487
- export function createServeHandler({ entry, assets, distDir, cache, root, build }) {
488
- const serveStatic = createStaticHandler({ root: distDir });
489
- const application = createApplicationHandler({ entry, assets, cache, root, build });
593
+ export function createServeHandler({ entry, assets, distDir, cache, root, build, regeneration }) {
594
+ const application = createApplicationHandler({ entry, assets, cache, root, build, regeneration });
595
+ const ready = deployment().then(({ createServeHandler: create }) =>
596
+ create({ staticDir: distDir, handle: application, routing: entry.routing }),
597
+ );
490
598
  return async function handle(request) {
491
- return (await serveStatic(request)) ?? (await application(request));
599
+ return (await ready)(request);
492
600
  };
493
601
  }
494
602
 
603
+ /**
604
+ * Whether `routing` has anything to say in front of a file server.
605
+ *
606
+ * Redirects and headers are the two that do; a rewrite is the application's.
607
+ * Asked before a middleware is mounted at all, so a project with no rules pays
608
+ * nothing per request under `uf dev` or `uf preview`.
609
+ *
610
+ * @param {{redirects?: unknown[], headers?: unknown[]} | undefined} routing
611
+ */
612
+ export function answersInFrontOfFiles(routing) {
613
+ return (
614
+ (routing?.redirects?.length ?? 0) > 0 ||
615
+ (routing?.headers?.length ?? 0) > 0 ||
616
+ (routing?.basePath ?? "") !== "" ||
617
+ (routing?.trailingSlash ?? "ignore") !== "ignore"
618
+ );
619
+ }
620
+
621
+ /**
622
+ * `app.router`'s headers and redirects, for a door whose files Vite serves.
623
+ *
624
+ * `uf dev` and `uf preview` mount this in front of Vite's own middleware:
625
+ * the headers are pinned on the Node response, so they survive the
626
+ * `writeHead` Vite's file server writes its own with, and a redirect is
627
+ * answered before any file is looked for. `true` when it answered.
628
+ *
629
+ * @param {object} routing the bundle's `routing`
630
+ * @param {Request} request the address alone; see `toAddressRequest`
631
+ * @param {import("node:http").ServerResponse} response
632
+ */
633
+ export async function answerRouting(routing, request, response) {
634
+ const { admit, headersFor, pinHeaders, send: write } = await deployment();
635
+ pinHeaders(response, headersFor(routing, request));
636
+ // A request outside the base path, the other spelling of a path, or a
637
+ // redirect rule: answered here, before Vite's own base middleware would
638
+ // answer the first in its words rather than uf's.
639
+ const admitted = admit(routing, request);
640
+ if (admitted.kind !== "answer") return false;
641
+ await write(response, admitted.response);
642
+ return true;
643
+ }
644
+
645
+ /**
646
+ * A request [`answerRouting`] let through, spelled so Vite's own middleware
647
+ * recognises it.
648
+ *
649
+ * Vite serves under its `base` with the trailing slash, `/docs/`, and its base
650
+ * middleware answers every other path with a 404 of its own, the bare `/docs`
651
+ * included. But `/docs` is the application's root under `app.router.basePath`,
652
+ * and the only spelling of it unless the trailing-slash policy is `"always"`,
653
+ * which has already answered `/docs` with a `308` by the time this is asked.
654
+ * So a request for exactly the base goes on to Vite as `/docs/`, which Vite
655
+ * takes the base off and hands on as the root. The application is handed the
656
+ * root either way.
657
+ *
658
+ * @param {{basePath?: string} | undefined} routing the bundle's `routing`
659
+ * @param {import("node:http").IncomingMessage} request
660
+ */
661
+ export function forViteBase(routing, request) {
662
+ const base = routing?.basePath ?? "";
663
+ const url = request.url ?? "/";
664
+ if (base === "") return;
665
+ const queryAt = url.indexOf("?");
666
+ const pathname = queryAt === -1 ? url : url.slice(0, queryAt);
667
+ if (pathname === base) {
668
+ request.url = `${base}/${queryAt === -1 ? "" : url.slice(queryAt)}`;
669
+ }
670
+ }
671
+
672
+ /**
673
+ * `app.router.rewrites` for this request, for `uf dev`: the rewritten request,
674
+ * or `null`.
675
+ *
676
+ * `@uniflowed/server`'s `rewriteFor`, which `createFetchHandler` asks for every
677
+ * other front door at the same point — after the files, before the guard.
678
+ *
679
+ * @param {object | undefined} routing the bundle's `routing`
680
+ * @param {Request} request
681
+ */
682
+ export async function rewriteRouting(routing, request) {
683
+ const { rewriteFor } = await deployment();
684
+ return rewriteFor(routing, request);
685
+ }
686
+
495
687
  /**
496
688
  * Whether a file server may answer this request, or uf has to go first.
497
689
  *
@@ -0,0 +1,109 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // What a project needs before its routes can render as React Server Components.
6
+ //
7
+ // `@uniflowed/router` installs beside React 19.2.3, the React that Expo SDK 57
8
+ // and React Native 0.87 ship, and lists `react-server-dom-parcel` as an optional
9
+ // peer (ubugeeei-prod/uf#992). An application rendered from its modules
10
+ // (`app.rsc: false`) needs nothing more. One whose routes render as Server
11
+ // Components, which is the default, needs that package and the React it was
12
+ // released with. Without the package a build fails on an unresolved import
13
+ // somewhere inside the router, and on too old a React it fails later, inside a
14
+ // render, in terms of React's internals. So `uf:flow` asks here once, while Vite
15
+ // reads its configuration, and stops with one sentence that says what the
16
+ // project has and what to change.
17
+ //
18
+ // The router's own Server Components entries refuse the same React at run time
19
+ // (`packages/router/internal/react-version.js`). This is the earlier half of the
20
+ // same rule, for a project that builds with uf.
21
+
22
+ import fs from "node:fs";
23
+ import { createRequire } from "node:module";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ /** The oldest React, and `react-server-dom-parcel`, that Server Components render on. */
28
+ export const SERVER_COMPONENTS_REACT = "19.3.0";
29
+
30
+ /**
31
+ * What stops the project at `root` from rendering React Server Components, as a
32
+ * sentence, or `null` when nothing does.
33
+ *
34
+ * Both packages are resolved the way the router's own imports resolve them: from
35
+ * the `@uniflowed/router` the project resolves, or from the one this package
36
+ * depends on when the project names none. A prerelease tag is ignored, so a 19.3
37
+ * canary counts as 19.3.
38
+ *
39
+ * @param {string} root
40
+ * @returns {string | null}
41
+ */
42
+ export function serverComponentsProblem(root) {
43
+ const router =
44
+ resolveFrom(path.join(root, "package.json"), "@uniflowed/router/package.json") ??
45
+ resolveFrom(fileURLToPath(import.meta.url), "@uniflowed/router/package.json") ??
46
+ path.join(root, "package.json");
47
+ const react = versionOf(resolveFrom(router, "react/package.json"));
48
+ const flight = versionOf(resolveFrom(router, "react-server-dom-parcel/package.json"));
49
+
50
+ const found = [];
51
+ if (react == null) {
52
+ found.push("no react");
53
+ } else if (!isRecentEnough(react)) {
54
+ found.push(`React ${react}`);
55
+ }
56
+ if (flight == null) {
57
+ found.push("no react-server-dom-parcel");
58
+ } else if (!isRecentEnough(flight)) {
59
+ found.push(`react-server-dom-parcel ${flight}`);
60
+ }
61
+ if (found.length === 0) {
62
+ return null;
63
+ }
64
+ return (
65
+ "uf: routes render as React Server Components unless `app.rsc` is false, and that needs " +
66
+ `react-server-dom-parcel and React ${SERVER_COMPONENTS_REACT} or newer. This project has ` +
67
+ `${found.join(" and ")}. Install react, react-dom and react-server-dom-parcel at ^19.3.0, ` +
68
+ "or set `app.rsc: false` in uf.config.js to render routes from their modules, which the " +
69
+ "router supports from React 19.2.3."
70
+ );
71
+ }
72
+
73
+ /** Where `specifier` resolves from the file at `from`, or `null`. */
74
+ function resolveFrom(from, specifier) {
75
+ try {
76
+ return createRequire(from).resolve(specifier);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** The `version` in the manifest at `file`, or `null`. */
83
+ function versionOf(file) {
84
+ if (file == null) {
85
+ return null;
86
+ }
87
+ try {
88
+ const { version } = JSON.parse(fs.readFileSync(file, "utf8"));
89
+ return typeof version === "string" ? version : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /** Whether `version` is at least `SERVER_COMPONENTS_REACT`, ignoring a prerelease tag. */
96
+ function isRecentEnough(version) {
97
+ const found = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
98
+ if (found == null) {
99
+ return false;
100
+ }
101
+ const needed = SERVER_COMPONENTS_REACT.split(".").map(Number);
102
+ for (let index = 0; index < 3; index += 1) {
103
+ const part = Number(found[index + 1]);
104
+ if (part !== needed[index]) {
105
+ return part > needed[index];
106
+ }
107
+ }
108
+ return true;
109
+ }