@uniflowed/vite 0.0.0-alpha.4 → 0.0.0-alpha.40

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/index.js CHANGED
@@ -14,12 +14,32 @@
14
14
  // development and the virtual modules that make a directory
15
15
  // of pages an application: the route table, the client entry
16
16
  // that hydrates it, and the server entry that renders it. In
17
- // development it also renders every HTML request on the
18
- // server, so `uf dev` serves the same markup `uf build` writes.
17
+ // development it also answers every request the way a
18
+ // deployment does — the guard, a server action, a route
19
+ // handler, then the renderer — so `uf dev` serves what
20
+ // `uf start` serves rather than an approximation of it, and it
21
+ // serves the two `/__uf/` paths a browser reports to (see
22
+ // `internal/diagnostics.js`).
23
+ // The client's copy of the route table is not the server's:
24
+ // `internal/rsc.js` reads the RSC analysis and leaves out the
25
+ // page of every route no client boundary reaches, so that
26
+ // route's modules never enter the browser bundle.
19
27
  // * `uf:mdx` — `@mdx-js/rollup`, configured for React with GitHub-flavoured
20
28
  // markdown, front matter, heading ids and build-time syntax
21
29
  // highlighting, so `.mdx` works with
22
30
  // no configuration.
31
+ // * `uf:barrel-imports` — a named import from `@uniflowed/ui` becomes an import
32
+ // from the file that defines the name, in every environment,
33
+ // so a page using one component loads that component's module
34
+ // rather than every module the barrel re-exports. See
35
+ // `internal/barrel-imports.js`.
36
+ // * `uf:asset` — an imported image is decoded, resized to the widths the
37
+ // project declares and re-encoded by `uf assets`, and an
38
+ // imported font is self-hosted with the `@font-face` and the
39
+ // metric-matched fallback that stop the swap moving the page.
40
+ // The import evaluates to what `Image` and `Font` need — the
41
+ // intrinsic size, every emitted variant, the placeholder —
42
+ // rather than to a URL string. See `internal/assets.js`.
23
43
  //
24
44
  // `uniflowed(options)` returns the array; a project that wants to add a plugin
25
45
  // declares it in `uf.config.js` and the driver appends it after these.
@@ -30,10 +50,21 @@ import path from "node:path";
30
50
  import mdx from "@mdx-js/rollup";
31
51
  import rehypeSlug from "rehype-slug";
32
52
 
53
+ import {
54
+ AUDIT_PUBLIC_PATH,
55
+ AUDIT_RESOLVED_ID,
56
+ auditAvailable,
57
+ auditRuntimeSource,
58
+ auditTag,
59
+ } from "./internal/a11y.js";
60
+ import { assetPlugin } from "./internal/assets.js";
61
+ import { barrelImportsPlugin, namespaceViewOf } from "./internal/barrel-imports.js";
62
+ import { emit, reportRenderError } from "./internal/events.js";
63
+ import remarkFrontmatterExport from "./internal/frontmatter.js";
33
64
  import { highlightPlugin } from "./internal/highlight.js";
65
+ import { moduleId } from "./internal/module-graph.js";
34
66
  import remarkFrontmatter from "remark-frontmatter";
35
67
  import remarkGfm from "remark-gfm";
36
- import remarkMdxFrontmatter from "remark-mdx-frontmatter";
37
68
 
38
69
  import {
39
70
  RUNTIME_PUBLIC_PATH,
@@ -43,17 +74,64 @@ import {
43
74
  refreshRuntimeSource,
44
75
  } from "./internal/refresh.js";
45
76
  import {
77
+ RSC_MANIFEST_ENV,
78
+ actionReferenceSource,
79
+ actionsModuleSource,
80
+ clientRouteFilter,
81
+ readRscManifest,
82
+ rscManifestKey,
83
+ serverActionModules,
84
+ serverActionTable,
85
+ } from "./internal/rsc.js";
86
+ import {
87
+ RESERVED,
46
88
  VIRTUAL,
47
89
  clientModuleSource,
90
+ resolveRouteTarget,
48
91
  routesModuleSource,
92
+ routingRulesOf,
49
93
  scanRoutes,
50
94
  serverModuleSource,
51
95
  } from "./internal/routes.js";
52
96
  import { TransformService, isFlowModule } from "@uniflowed/host/transform";
97
+ import {
98
+ DEV_RSC_HOOK,
99
+ FLIGHT_BROWSER_DEPENDENCIES,
100
+ FLIGHT_VIRTUAL,
101
+ RSC_ENVIRONMENT,
102
+ builtBridgeSource,
103
+ builtReferencesSource,
104
+ clientManifestSource,
105
+ clientModuleUrlPlugin,
106
+ clientReferencePlugin,
107
+ compilerRuntimeSource,
108
+ createFlightState,
109
+ devBridgeSource,
110
+ devReferencesSource,
111
+ devStylesheets,
112
+ flightClientSource,
113
+ flightDocumentPath,
114
+ flightServerSource,
115
+ linkStylesheets,
116
+ rendersFlight,
117
+ rscEntrySource,
118
+ rscEnvironment,
119
+ } from "./internal/flight.js";
120
+ import { createChannelMiddleware } from "./internal/diagnostics.js";
121
+ import { devtoolsPreamble } from "./internal/devtools.js";
122
+ import { send, toAddressRequest, toRequest } from "./internal/http.js";
123
+ import {
124
+ answerRouting,
125
+ answersInFrontOfFiles,
126
+ beginRequest,
127
+ forViteBase,
128
+ rewriteRouting,
129
+ } from "./internal/serve.js";
130
+ import { serverComponentsProblem } from "./internal/server-components.js";
53
131
 
54
132
  /** A resolved virtual id: Vite's convention is a leading NUL byte. */
55
133
  const resolved = (id) => `\0${id}`;
56
- const VIRTUAL_IDS = new Set(Object.values(VIRTUAL));
134
+ const VIRTUAL_IDS = new Set([...Object.values(VIRTUAL), ...Object.values(FLIGHT_VIRTUAL)]);
57
135
 
58
136
  /**
59
137
  * Prefix of the virtual module that carries one source module's StyleX rules.
@@ -76,6 +154,7 @@ export function devUrlFor(id) {
76
154
  * @typedef {object} UniflowedOptions
77
155
  * @property {string} [root] absolute project root; Vite's root by default
78
156
  * @property {object} [config] the loaded `uf.config.js` object
157
+ * @property {"web" | "native" | "ios" | "android"} [target] app target
79
158
  * @property {string} [command] the `uf` binary to transform through
80
159
  */
81
160
 
@@ -87,16 +166,109 @@ export function devUrlFor(id) {
87
166
  export default function uniflowed(options = {}) {
88
167
  const ufConfig = options.config ?? {};
89
168
  const app = ufConfig.app ?? {};
169
+ const routeTarget = resolveRouteTarget(ufConfig, options.target);
90
170
  const routerRoot = app.router?.root ?? "app";
91
171
  const appEntry = app.router?.entry ?? ufConfig.build?.entries?.[0] ?? "app.js";
92
172
  const markdown = app.builtins?.markdown ?? {};
173
+ const builtins = app.builtins ?? {};
174
+ // On unless the project says otherwise, and read as `!== false` rather than
175
+ // `=== true` because that is what "on by default" means for a field almost
176
+ // no `uf.config.js` will mention. It only ever reaches the *development*
177
+ // client entry; see `flowPlugin`'s `load`. ubugeeei-prod/uf#516.
178
+ const strictMode = app.react?.strictMode !== false;
179
+ // What the browser does with a link, read here for the reason Strict Mode is
180
+ // and honoured in every command rather than in the build alone: it is the
181
+ // one setting whose whole effect is what happens on a click, so a dev server
182
+ // that disagreed with the deployment would be the wrong application to look
183
+ // at. Anything but `"document"` is the client router, which is what every
184
+ // project that has not heard of the key has.
185
+ const navigation = app.rendering?.navigation === "document" ? "document" : "client";
186
+ // How long, in seconds, the client router shows a route it already fetched
187
+ // without asking the server again, written into the client entry beside
188
+ // `navigation` and honoured by every command for the same reason. Anything
189
+ // but a positive number is `0`, which keeps nothing.
190
+ const staleTime =
191
+ typeof app.rendering?.staleTime === "number" &&
192
+ Number.isFinite(app.rendering.staleTime) &&
193
+ app.rendering.staleTime > 0
194
+ ? app.rendering.staleTime
195
+ : 0;
196
+ // Whether this application starts by attaching to markup or by rendering
197
+ // into an empty root. `["csr"]` is the only list that means the second, and
198
+ // `uf` refuses that value beside any other while the config is read — so the
199
+ // question here is "is it in the list", not "is it the only thing in it",
200
+ // and a driver started by hand on a config `uf` never validated gets the same
201
+ // answer for the same reason a project would want.
202
+ const mount = (app.rendering?.modes ?? []).includes("csr") ? "render" : "hydrate";
203
+ // Whether routes render as React Server Components, which is the default: a
204
+ // second module graph resolved under `react-server`, a document carrying the
205
+ // Flight payload it was rendered from, and a browser that hydrates that
206
+ // payload rather than importing routes. `app.rsc: false` is the application
207
+ // rendered from its modules, as every uf application was before
208
+ // ubugeeei-prod/uf#519. See `./internal/flight.js`.
209
+ const flightState = rendersFlight(app, { mount, routeTarget })
210
+ ? createFlightState({ root: options.root ?? process.cwd() })
211
+ : null;
212
+
213
+ const accessibility = ufConfig.accessibility ?? {};
214
+ // `app.router.redirects`, `rewrites` and `headers`: written into the server
215
+ // bundle for every host that serves a build, and asked by `uf dev` itself
216
+ // from the same object. See `@uniflowed/server`'s `internal/routing.js`.
217
+ const routing = routingRulesOf(app.router);
93
218
 
94
- return [flowPlugin({ routerRoot, appEntry, command: options.command }), mdxPlugin(markdown)];
219
+ return [
220
+ flowPlugin({
221
+ routerRoot,
222
+ appEntry,
223
+ routeTarget,
224
+ strictMode,
225
+ navigation,
226
+ staleTime,
227
+ mount,
228
+ flightState,
229
+ routing,
230
+ command: options.command,
231
+ accessibility,
232
+ }),
233
+ ...(flightState == null ? [] : [clientReferencePlugin(flightState), clientModuleUrlPlugin()]),
234
+ // After the references, so a client module the rsc graph has already
235
+ // replaced is not read for imports it no longer has; see the file.
236
+ barrelImportsPlugin(),
237
+ mdxPlugin(markdown),
238
+ assetPlugin({
239
+ images: builtins.images ?? {},
240
+ fonts: builtins.fonts ?? {},
241
+ icons: builtins.icons ?? {},
242
+ og: builtins.og ?? {},
243
+ command: options.command,
244
+ }),
245
+ ];
95
246
  }
96
247
 
97
- function flowPlugin({ routerRoot, appEntry, command }) {
248
+ function flowPlugin({
249
+ routerRoot,
250
+ appEntry,
251
+ routeTarget,
252
+ strictMode,
253
+ navigation,
254
+ staleTime,
255
+ mount,
256
+ flightState,
257
+ routing,
258
+ command,
259
+ accessibility,
260
+ }) {
98
261
  let root = process.cwd();
99
262
  let isProduction = false;
263
+ /**
264
+ * Whether this project has axe-core, so the audit has an engine.
265
+ *
266
+ * Answered once in `configResolved` rather than per document: it is a
267
+ * question about `node_modules`, the answer cannot change while the server
268
+ * runs without a restart anyway, and asking it per render would put a module
269
+ * resolution on the path of every page.
270
+ */
271
+ let auditsPage = false;
100
272
  let base = "/";
101
273
  let appRoot = "";
102
274
  let entryPath = "";
@@ -113,19 +285,130 @@ function flowPlugin({ routerRoot, appEntry, command }) {
113
285
  * hand, which is the part that goes wrong.
114
286
  */
115
287
  const styles = new Map();
288
+ /**
289
+ * The React Compiler findings already reported, so each is said once.
290
+ *
291
+ * `uf build` runs Vite twice — once for the browser bundle and once for the
292
+ * server one — over the same modules, so every finding was made twice and
293
+ * printed twice. An entry records which environment reported a module's
294
+ * findings first: a re-transform in *that* environment (a dev server, after
295
+ * an edit) clears it and reports again, and the other environment's pass over
296
+ * the same module stays quiet. Keying on the environment rather than on a
297
+ * flag is what keeps the second half true without making the first half
298
+ * false.
299
+ *
300
+ * @type {Map<string, { environment: string, signatures: Set<string> }>}
301
+ */
302
+ const reported = new Map();
303
+ /** Findings held back as a dependency's, waiting to be counted out loud. */
304
+ let suppressed = [];
116
305
 
117
306
  const ensureService = () => {
118
307
  service ??= new TransformService({ command, root });
119
308
  return service;
120
309
  };
121
310
 
311
+ /**
312
+ * The browser's copy of the route table.
313
+ *
314
+ * The manifest is read here rather than once at start-up because `uf dev`
315
+ * rewrites it whenever the graph moves, and this hook runs again when it
316
+ * does — a table built from a manifest read at start-up would be the answer
317
+ * for the project as it was when the server started.
318
+ *
319
+ * The count is emitted rather than computed on the Rust side, and that is
320
+ * the point of it: `uf build` prints what the table it just generated
321
+ * contains, not what a second implementation of this decision predicted it
322
+ * would. Only for a build — a dev server has no summary to be true in.
323
+ */
324
+ const clientRoutesModule = (table) => {
325
+ const shipsPage = clientRouteFilter(
326
+ readRscManifest(process.env[RSC_MANIFEST_ENV]),
327
+ root,
328
+ table,
329
+ );
330
+ const kept = new Set(table.routes.filter(shipsPage));
331
+ if (server == null) {
332
+ emit("rsc-split", { pages: kept.size, routes: table.routes.length });
333
+ }
334
+ // `relativeTo` only here, and never for the server's copy below. Every
335
+ // `import()` in this table becomes a chunk URL, but `file` is a string and
336
+ // survives the build — so the browser's table was shipping the absolute
337
+ // path of every page on the machine that built the site, to every visitor.
338
+ // The server's table is read where those files are and keeps them.
339
+ // HTTP handlers are exclusively server capabilities. Even an unused
340
+ // dynamic import makes Vite traverse the handler's database/auth imports.
341
+ return routesModuleSource(
342
+ { ...table, handlers: [] },
343
+ {
344
+ shipsPage: (route) => kept.has(route),
345
+ relativeTo: root,
346
+ },
347
+ );
348
+ };
349
+
350
+ /**
351
+ * The manifest's two action tables, re-read only when the file changes.
352
+ *
353
+ * `load` runs for every module in the graph and has to ask "is this a
354
+ * `"use server"` module?" about each one, so parsing the manifest per call
355
+ * would put a JSON parse of the whole graph between Vite and every file it
356
+ * opens. Size and modification time are the identity — the same pair
357
+ * `.uf/cache/transform` keys on — and `uf dev` drops the memo outright when
358
+ * its watcher sees the file change, so a rewrite inside one millisecond is
359
+ * still seen.
360
+ */
361
+ let actionMemo = null;
362
+ const forgetActions = () => {
363
+ actionMemo = null;
364
+ };
365
+ const invalidateActionModules = (moduleGraph, modules) => {
366
+ for (const id of modules.keys()) {
367
+ const module = moduleGraph.getModuleById(id);
368
+ if (module) moduleGraph.invalidateModule(module);
369
+ }
370
+ };
371
+ const actionTables = () => {
372
+ const file = process.env[RSC_MANIFEST_ENV];
373
+ const key = rscManifestKey(file);
374
+ if (actionMemo == null || actionMemo.key !== key) {
375
+ const manifest = readRscManifest(file);
376
+ actionMemo = {
377
+ key,
378
+ modules: serverActionModules(manifest, root),
379
+ table: serverActionTable(manifest, root),
380
+ };
381
+ }
382
+ return actionMemo;
383
+ };
384
+
122
385
  return {
123
386
  name: "uf:flow",
124
387
  enforce: "pre",
388
+ // What `driver.js` runs the three builds with; `null` for an application
389
+ // rendered from its modules. See `./internal/flight.js`.
390
+ api: { flight: flightState },
125
391
 
126
392
  config(userConfig, env) {
127
393
  const projectRoot = path.resolve(userConfig.root ?? process.cwd());
394
+ // Before anything is resolved. Routes that render as React Server
395
+ // Components need `react-server-dom-parcel` and React 19.3, and the router
396
+ // installs without either (ubugeeei-prod/uf#992). Said here, once, rather
397
+ // than as an unresolved import deep in a build or a failure inside a render.
398
+ if (flightState != null) {
399
+ const problem = serverComponentsProblem(projectRoot);
400
+ if (problem != null) {
401
+ throw new Error(problem);
402
+ }
403
+ }
128
404
  isProduction = env.mode === "production" || env.command === "build";
405
+ // A reference names the client manifest only in a build, where a client
406
+ // build writes the chunks it names; a dev server names the URL it serves.
407
+ if (flightState != null) flightState.production = env.command === "build";
408
+ // Decided here rather than in `configResolved`, because the answer has to
409
+ // reach `optimizeDeps.include` below and that is written in this hook.
410
+ auditsPage =
411
+ !isProduction && (accessibility?.devAudit ?? true) && auditAvailable(projectRoot);
129
412
  return {
130
413
  // uf serves HTML itself; there is no index.html to fall back to.
131
414
  appType: "custom",
@@ -140,6 +423,19 @@ function flowPlugin({ routerRoot, appEntry, command }) {
140
423
  "react/compiler-runtime",
141
424
  "react-dom",
142
425
  "react-dom/client",
426
+ // React's Flight client, for an application whose routes render as
427
+ // Server Components; `FLIGHT_BROWSER_DEPENDENCIES` says why.
428
+ ...(flightState == null ? [] : FLIGHT_BROWSER_DEPENDENCIES),
429
+ // The accessibility audit's engine, when this project has one.
430
+ //
431
+ // Named up front rather than left to be discovered. axe-core is
432
+ // CommonJS, and the only thing that imports it is a *virtual*
433
+ // module reached from the document — so Vite would meet it for the
434
+ // first time after a page had already loaded, optimise it then, and
435
+ // reload the page it had just served. A full reload in the middle
436
+ // of the first render of every `uf dev` session is a high price for
437
+ // a feature whose whole manner is to be quiet.
438
+ ...(auditsPage ? ["axe-core"] : []),
143
439
  ],
144
440
  // uf's packages ship Flow. The dependency optimiser pre-bundles
145
441
  // with a JavaScript parser and would reject every one of them.
@@ -151,12 +447,29 @@ function flowPlugin({ routerRoot, appEntry, command }) {
151
447
  // externalised.
152
448
  noExternal: [/^@uniflowed\//],
153
449
  },
450
+ // The graph React Server Components render in, beside the two Vite
451
+ // always has — declared only for an application that renders them, so
452
+ // `app.rsc: false` is a Vite configuration with nothing added.
453
+ ...(flightState == null
454
+ ? {}
455
+ : {
456
+ environments: {
457
+ [RSC_ENVIRONMENT]: rscEnvironment({
458
+ production: env.command === "build",
459
+ exclude: uniflowedPackages(projectRoot),
460
+ }),
461
+ },
462
+ }),
154
463
  };
155
464
  },
156
465
 
157
466
  configResolved(config) {
158
467
  root = config.root;
159
468
  base = config.base;
469
+ if (flightState != null) {
470
+ flightState.root = config.root;
471
+ flightState.base = config.base;
472
+ }
160
473
  appRoot = path.resolve(root, routerRoot);
161
474
  entryPath = path.resolve(root, appEntry);
162
475
  },
@@ -165,8 +478,43 @@ function flowPlugin({ routerRoot, appEntry, command }) {
165
478
  ensureService();
166
479
  },
167
480
 
168
- resolveId(id) {
481
+ resolveId(id, importer, resolveOptions) {
482
+ // The client's graph, and the rsc graph too. `@uniflowed/react` is
483
+ // `export * from "react"`, and the rsc graph pre-bundles React's CommonJS
484
+ // under `react-server`: a star re-export of that namespace names nothing
485
+ // Vite's module runner can forward, so under `uf dev` every hook a server
486
+ // component imported from `@uniflowed/react` was `undefined` — `use` first.
487
+ // Importing `react` itself is the module those names are on. The ssr graph
488
+ // keeps its own resolution, because React is external there and Node's
489
+ // interop forwards the names.
490
+ if (
491
+ id === "@uniflowed/react" &&
492
+ (this.environment?.name === RSC_ENVIRONMENT || !isSsr(this, resolveOptions))
493
+ ) {
494
+ return this.resolve("react", importer, { ...resolveOptions, skipSelf: true });
495
+ }
169
496
  if (id === RUNTIME_PUBLIC_PATH) return RUNTIME_RESOLVED_ID;
497
+ if (id === AUDIT_PUBLIC_PATH) return AUDIT_RESOLVED_ID;
498
+ // The rsc build cannot know a client chunk's URL, because the client
499
+ // build that writes the chunks runs after it. It leaves the manifest as an
500
+ // import, and the ssr build — which bundles the rsc output in — resolves it.
501
+ if (
502
+ id === FLIGHT_VIRTUAL.manifest &&
503
+ flightState?.production === true &&
504
+ this.environment?.name === RSC_ENVIRONMENT
505
+ ) {
506
+ return { id, external: true };
507
+ }
508
+ // The React Compiler's runtime, which reads the client's internals and so
509
+ // cannot run where `react` resolved under `react-server`; see
510
+ // `compilerRuntimeSource`.
511
+ if (
512
+ id === "react/compiler-runtime" &&
513
+ flightState != null &&
514
+ this.environment?.name === RSC_ENVIRONMENT
515
+ ) {
516
+ return resolved(FLIGHT_VIRTUAL.compilerRuntime);
517
+ }
170
518
  if (VIRTUAL_IDS.has(id)) return resolved(id);
171
519
  // A module's own stylesheet, which `transform` below asked for by
172
520
  // importing this id. Returning it unchanged marks it resolved without
@@ -175,18 +523,125 @@ function flowPlugin({ routerRoot, appEntry, command }) {
175
523
  return null;
176
524
  },
177
525
 
178
- load(id) {
526
+ load(id, loadOptions) {
179
527
  if (id === RUNTIME_RESOLVED_ID) return refreshRuntimeSource();
180
- if (id === resolved(VIRTUAL.routes)) return routesModuleSource(scanRoutes(appRoot));
181
- if (id === resolved(VIRTUAL.client)) return clientModuleSource(entryPath);
182
- if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
528
+ if (id === AUDIT_RESOLVED_ID) return auditRuntimeSource(accessibility?.axe);
529
+ if (id === resolved(VIRTUAL.routes)) {
530
+ const table = scanRoutes(appRoot, { target: routeTarget });
531
+ // Under React Server Components the table is split by graph rather than
532
+ // filtered. The rsc graph renders routes, so it gets every route and
533
+ // boundary and no handler or middleware: those answer a request, and
534
+ // importing one here would resolve its dependencies under
535
+ // `react-server` for nothing. The ssr graph gets exactly those two,
536
+ // because every route it renders reaches it as a payload.
537
+ if (flightState != null && this.environment?.name === RSC_ENVIRONMENT) {
538
+ return routesModuleSource({ ...table, handlers: [], middleware: [] });
539
+ }
540
+ if (flightState != null && isSsr(this, loadOptions)) {
541
+ return routesModuleSource({ ...table, routes: [], notFound: [], errors: [] });
542
+ }
543
+ // The server renders every route, so the server's table is the whole
544
+ // one and is generated with no filter at all. Only the browser's copy
545
+ // is split.
546
+ if (isSsr(this, loadOptions)) return routesModuleSource(table);
547
+ return clientRoutesModule(table);
548
+ }
549
+ // Strict Mode belongs to the client entry and to development only: a
550
+ // build passes `false`, so the generated module is the one that existed
551
+ // before #516 and a visitor's browser renders once.
552
+ //
553
+ // `navigation` is in the same entry and has no `isProduction` beside it,
554
+ // deliberately: it is what a link does, and a dev server whose links
555
+ // behave differently from the deployment is the wrong thing to be
556
+ // looking at. See `clientModuleSource`.
557
+ if (id === resolved(VIRTUAL.client) && flightState != null) {
558
+ return flightClientSource(entryPath, {
559
+ strictMode: strictMode && !isProduction,
560
+ navigation,
561
+ staleTime,
562
+ routing,
563
+ });
564
+ }
565
+ if (id === resolved(VIRTUAL.client)) {
566
+ return clientModuleSource(entryPath, {
567
+ strictMode: strictMode && !isProduction,
568
+ navigation,
569
+ staleTime,
570
+ mount,
571
+ routing,
572
+ });
573
+ }
574
+ if (id === resolved(VIRTUAL.server)) {
575
+ return flightState == null
576
+ ? serverModuleSource(entryPath, routing)
577
+ : flightServerSource(entryPath, VIRTUAL.routes, VIRTUAL.actions, routing);
578
+ }
579
+ if (flightState != null) {
580
+ if (id === resolved(FLIGHT_VIRTUAL.entry)) return rscEntrySource(VIRTUAL.routes, routing);
581
+ if (id === resolved(FLIGHT_VIRTUAL.compilerRuntime)) return compilerRuntimeSource();
582
+ if (id === resolved(FLIGHT_VIRTUAL.bridge)) {
583
+ if (server != null) return devBridgeSource();
584
+ if (flightState.rscOutput == null) {
585
+ throw new Error(
586
+ "uf: the server bundle was asked to bundle the rsc graph before it was built. " +
587
+ "`uf build` builds `virtual:uf/rsc` first; a build started some other way has to as well.",
588
+ );
589
+ }
590
+ return builtBridgeSource(flightState.rscOutput);
591
+ }
592
+ if (id === resolved(FLIGHT_VIRTUAL.manifest)) {
593
+ return clientManifestSource(flightState.chunkUrls);
594
+ }
595
+ if (id === resolved(FLIGHT_VIRTUAL.references)) {
596
+ return server != null
597
+ ? devReferencesSource(root, base)
598
+ : builtReferencesSource(flightState.chunkUrls);
599
+ }
600
+ }
601
+ // Only `virtual:uf/server` imports this, so it is only ever asked for in
602
+ // the server environment — but the table it carries is every callable
603
+ // endpoint of the build, so it is worth saying that a browser asking for
604
+ // it gets nothing rather than getting the list.
605
+ if (id === resolved(VIRTUAL.actions)) {
606
+ if (!isSsr(this, loadOptions))
607
+ return "export const actions = [];\nexport default actions;\n";
608
+ return actionsModuleSource(actionTables().table);
609
+ }
183
610
  if (id.startsWith(STYLE_PREFIX)) return styles.get(id) ?? "";
611
+
612
+ // A `"use server"` module, in the browser's graph only: what the client
613
+ // gets is one `createServerReference` per callable export, and never the
614
+ // file. This is where the second half of the RSC split actually happens
615
+ // — the route filter above decides which *pages* the browser is given,
616
+ // and this decides that an action module's body, its imports and
617
+ // everything only they reached are not the browser's business at all.
618
+ //
619
+ // Substituting the source rather than rewriting it: a transform that
620
+ // stripped the body would have to be right about every way a module can
621
+ // name something, and being wrong once means shipping a database handle.
622
+ // The exports the reference module declares come from the manifest, so
623
+ // they are exactly the exports `uf_rsc` decided are callable endpoints
624
+ // and an import of anything else is a build error rather than a silent
625
+ // `undefined`. `crates/uf_rsc/src/graph/build.rs` colours these modules
626
+ // server for the same reason, so the analysis and the bundle agree.
627
+ //
628
+ // Nor in the rsc graph: a server component that calls an action is calling
629
+ // a function on the server, and it gets the function.
630
+ if (!isSsr(this, loadOptions) && this.environment?.name !== RSC_ENVIRONMENT) {
631
+ const references = actionTables().modules.get(cleanId(id));
632
+ if (references != null) return actionReferenceSource(references);
633
+ }
184
634
  return null;
185
635
  },
186
636
 
187
637
  async transform(code, id, transformOptions) {
188
- if (!isFlowModule(id)) return null;
189
- const ssr = transformOptions?.ssr === true || this.environment?.name === "ssr";
638
+ // A view of a barrel's namespace has the barrel's path and none of its
639
+ // source: `uf:barrel-imports` generates it as JavaScript.
640
+ if (!isFlowModule(id) || namespaceViewOf(id) != null) return null;
641
+ // Both server graphs: neither gets a refresh wrapper, and the rsc graph's
642
+ // findings are reported as that graph's.
643
+ const rsc = this.environment?.name === RSC_ENVIRONMENT;
644
+ const ssr = transformOptions?.ssr === true || this.environment?.name === "ssr" || rsc;
190
645
  const refresh = !isProduction && !ssr && server != null;
191
646
  const out = await ensureService().transform(cleanId(id), code, {
192
647
  development: !isProduction,
@@ -194,9 +649,14 @@ function flowPlugin({ routerRoot, appEntry, command }) {
194
649
  sourceMap: true,
195
650
  });
196
651
  if (out == null) return null;
197
- for (const diagnostic of out.diagnostics) {
198
- this.warn?.(`${diagnostic.function ?? "a function"}: ${diagnostic.message}`);
199
- }
652
+ reportDiagnostics(this, {
653
+ id: cleanId(id),
654
+ root,
655
+ diagnostics: out.diagnostics,
656
+ environment: rsc ? RSC_ENVIRONMENT : ssr ? "ssr" : "client",
657
+ reported,
658
+ suppressed,
659
+ });
200
660
  const map = out.map == null ? null : JSON.parse(out.map);
201
661
  // StyleX. `uf transform` compiled the module's `stylex.create` calls into
202
662
  // class names and handed back the rules they declared; the rules become a
@@ -207,18 +667,41 @@ function flowPlugin({ routerRoot, appEntry, command }) {
207
667
  // business: Vite already injects a stylesheet in dev, extracts it in a
208
668
  // build, code-splits it per chunk, and replaces it over HMR. A module
209
669
  // whose styles are gone stops importing it, and Vite notices.
670
+ //
671
+ // The stylesheet is named by the module's path from the project root, the
672
+ // spelling `moduleId` gives the module graph report, and not by its
673
+ // absolute path. In a client chunk the stylesheet is one of the chunk's
674
+ // sources, so its name is written into the source map a site publishes,
675
+ // and an absolute name would publish where the machine that built it
676
+ // keeps its files. A module outside the root climbs out with `../`.
677
+ const styled = out.css != null && out.css !== "";
210
678
  let output = out.code;
211
- if (out.css != null && out.css !== "") {
212
- const styleId = `${STYLE_PREFIX}${cleanId(id)}.css`;
679
+ if (styled) {
680
+ const styleId = `${STYLE_PREFIX}${moduleId(root, cleanId(id))}.css`;
213
681
  styles.set(styleId, out.css);
214
682
  output = `import ${JSON.stringify(styleId)};\n${output}`;
215
683
  }
216
- if (!refresh) return { code: output, map };
684
+ // A module that compiled a stylesheet has a side effect, whatever its
685
+ // package says. `@uniflowed/stylex` declares `sideEffects: false` and is
686
+ // right about its source: `tokens.stylex.js` only exports a token set.
687
+ // What it exports after this transform is a token set *and* a `:root`
688
+ // block, and the page that imports `ufTokens` no longer names it at
689
+ // runtime — the compiler turned every read into the `var(--…)` it minted.
690
+ // So the import was unused, a side-effect-free module with no used
691
+ // exports was dropped, and the custom properties every one of those
692
+ // `var()`s resolves against went with it: rules that referred to nothing.
693
+ // Declaring the side effect here rather than editing the package is
694
+ // deliberate — the side effect is one this plugin added, so it is this
695
+ // plugin's to admit to. See ubugeeei-prod/uf#306.
696
+ const moduleSideEffects = styled ? true : undefined;
697
+ if (!refresh) return { code: output, map, moduleSideEffects };
217
698
  const relative = path.relative(root, cleanId(id)).split(path.sep).join("/");
218
- return addRefreshWrapper(output, map, relative);
699
+ return { ...addRefreshWrapper(output, map, relative), moduleSideEffects };
219
700
  },
220
701
 
221
702
  buildEnd() {
703
+ summariseSuppressed(this, suppressed);
704
+ suppressed = [];
222
705
  // A dev server keeps its service for the whole session; a build is
223
706
  // done with it here.
224
707
  if (server == null) {
@@ -227,28 +710,117 @@ function flowPlugin({ routerRoot, appEntry, command }) {
227
710
  }
228
711
  },
229
712
 
713
+ // The two scripts a development document loads before its own, and
714
+ // nothing at all in a build — which is the whole of "the hook is out of a
715
+ // production build" (ubugeeei-prod/uf#503) and of "production does not run
716
+ // under Strict Mode" (#516, whose flag is generated into
717
+ // `virtual:uf/client` rather than injected here).
718
+ //
719
+ // DevTools first, and as a *classic* script rather than a module: React
720
+ // registers itself with `__REACT_DEVTOOLS_GLOBAL_HOOK__` while `react-dom`
721
+ // is evaluated and never again, so the hook has to exist before any module
722
+ // runs. A classic inline script runs while the parser is on it; a module
723
+ // waits for the document. `internal/devtools.js` has the rest of the
724
+ // argument, and the three conditions DevTools needs.
230
725
  transformIndexHtml() {
231
726
  if (isProduction) return [];
232
- return [
727
+ const tags = [
728
+ {
729
+ tag: "script",
730
+ attrs: { "data-uf-dev-head-preamble": "react-devtools" },
731
+ children: devtoolsPreamble(),
732
+ injectTo: "head-prepend",
733
+ },
233
734
  {
234
735
  tag: "script",
235
- attrs: { type: "module" },
736
+ attrs: { type: "module", "data-uf-dev-head-preamble": "react-refresh" },
236
737
  children: preambleCode(base),
237
738
  injectTo: "head-prepend",
238
739
  },
239
740
  ];
741
+ // Only when the project has the engine. A tag pointing at a module that
742
+ // cannot resolve `axe-core` would be a red console on every page of a
743
+ // project that never asked for an audit, which is a worse default than
744
+ // no audit.
745
+ const audit = auditTag(base, auditsPage);
746
+ if (audit != null) tags.push(audit);
747
+ return tags;
748
+ },
749
+
750
+ // An edit to a server component changes what the rsc graph renders and no
751
+ // module the browser holds, so Vite has nothing to tell the browser. It is
752
+ // reloaded, which renders the edit; an edit to a client module — which the
753
+ // rsc graph only holds references to — is left to Fast Refresh.
754
+ hotUpdate({ modules }) {
755
+ if (flightState == null || server == null) return;
756
+ if (this.environment?.name !== RSC_ENVIRONMENT) return;
757
+ const serverSide = modules.some((module) => {
758
+ const file = module.file ?? cleanId(module.id ?? "");
759
+ return file !== "" && !flightState.clientModules.has(file);
760
+ });
761
+ if (serverSide) server.environments.client.hot.send({ type: "full-reload", path: "*" });
240
762
  },
241
763
 
242
764
  configureServer(devServer) {
243
765
  server = devServer;
766
+ // The ssr graph's way into the rsc graph; see `devBridgeSource`.
767
+ if (flightState != null) {
768
+ globalThis[Symbol.for(DEV_RSC_HOOK)] = () =>
769
+ devServer.environments[RSC_ENVIRONMENT].runner.import(FLIGHT_VIRTUAL.entry);
770
+ }
244
771
  devServer.httpServer?.once("close", () => {
245
772
  service?.close();
246
773
  service = null;
247
774
  });
248
775
 
249
- // A page or layout appearing or disappearing changes the route table,
776
+ // `app.router.headers` and `redirects`, in front of Vite's own middleware
777
+ // — the hook's body runs before those are installed — so a redirect
778
+ // answers before `public/` is looked in and a header reaches a file Vite
779
+ // serves, as both do in front of the static half of every other door.
780
+ // Vite's module server is left alone: `/@vite/client` and `/@fs/…` are
781
+ // development plumbing no deployment has, and `/__uf/` is the browser's
782
+ // channel back. Not mounted at all for a project with neither list.
783
+ if (answersInFrontOfFiles(routing)) {
784
+ devServer.middlewares.use((request, response, next) => {
785
+ const url = request.url ?? "/";
786
+ // Vite serves its own paths under the base path too, so they are
787
+ // recognised once it is taken off.
788
+ const under =
789
+ routing.basePath !== "" && url.startsWith(`${routing.basePath}/`)
790
+ ? url.slice(routing.basePath.length)
791
+ : url;
792
+ if (
793
+ under.startsWith("/@") ||
794
+ under.startsWith("/node_modules/") ||
795
+ under.startsWith("/__uf/")
796
+ ) {
797
+ next();
798
+ return;
799
+ }
800
+ answerRouting(routing, toAddressRequest(request), response)
801
+ .then((answered) => {
802
+ if (answered) return;
803
+ // The bare base path is the root, which Vite only knows as
804
+ // `/docs/`; see `forViteBase`.
805
+ forViteBase(routing, request);
806
+ next();
807
+ })
808
+ .catch(next);
809
+ });
810
+ }
811
+
812
+ // A reserved file appearing or disappearing changes the route table,
250
813
  // which lives in a virtual module the watcher knows nothing about.
251
- const reserved = /\/_uf\.(page|layout|middleware|not-found)(\.[a-z]+)?\.(js|jsx|mdx)$/;
814
+ //
815
+ // Built from `RESERVED` rather than written out. It used to be the
816
+ // literal `(page|layout|middleware|not-found)`, which is a fourth
817
+ // spelling of a grammar that already has three, and it was already
818
+ // missing `route` — so adding a route handler to a running dev server
819
+ // did not rebuild the table and the handler stayed invisible until a
820
+ // restart. A list that has to match another list has to be that list.
821
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
822
+ const stems = Object.values(RESERVED).map(escapeRegExp).join("|");
823
+ const reserved = new RegExp(`/(${stems})(\\.[a-z]+)?\\.(js|jsx|mdx)$`);
252
824
  const onRouteFile = (file) => {
253
825
  if (!reserved.test(file) || !file.startsWith(appRoot)) return;
254
826
  const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
@@ -258,29 +830,324 @@ function flowPlugin({ routerRoot, appEntry, command }) {
258
830
  devServer.watcher.on("add", onRouteFile);
259
831
  devServer.watcher.on("unlink", onRouteFile);
260
832
 
833
+ // The same problem one level up. Adding `"use client"` to a module, or
834
+ // deleting the import that reached it, changes which routes the browser
835
+ // is given a page for — and touches no reserved file name, so nothing
836
+ // above notices. `uf dev` rewrites the RSC manifest when the analysis
837
+ // moves and only then, so this fires when the answer changed rather than
838
+ // on every keystroke. Watched explicitly because the file is uf's own
839
+ // artefact and is in no module graph.
840
+ const manifestFile = process.env[RSC_MANIFEST_ENV];
841
+ if (manifestFile != null && manifestFile !== "") {
842
+ const manifestPath = path.resolve(manifestFile);
843
+ devServer.watcher.add(manifestPath);
844
+ const onManifest = (file) => {
845
+ if (path.resolve(file) !== manifestPath) return;
846
+ const previousActions = actionTables().modules;
847
+ // The action tables are read from the same file and are memoised on
848
+ // its size and modification time, which is a pair two writes inside
849
+ // one millisecond can share. This is the answer that does not
850
+ // depend on a clock.
851
+ forgetActions();
852
+ const nextActions = actionTables().modules;
853
+ invalidateActionModules(devServer.moduleGraph, previousActions);
854
+ invalidateActionModules(devServer.moduleGraph, nextActions);
855
+ const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
856
+ if (routes) devServer.moduleGraph.invalidateModule(routes);
857
+ const actions = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.actions));
858
+ if (actions) devServer.moduleGraph.invalidateModule(actions);
859
+ devServer.ws.send({ type: "full-reload", path: "*" });
860
+ };
861
+ devServer.watcher.on("add", onManifest);
862
+ devServer.watcher.on("change", onManifest);
863
+ }
864
+
261
865
  // After Vite's own middlewares, so `/@vite/client`, `/@id/...` and
262
- // static files are served first and only a document request reaches
263
- // the renderer.
866
+ // static files are served first and only what Vite declined reaches uf.
867
+ //
868
+ // # One renderer
869
+ //
870
+ // This is the only middleware that renders a document under `uf dev`,
871
+ // and that is worth stating because there were two. `driver.js` added a
872
+ // second one after `createServer` had returned, which put it *later* in
873
+ // the connect stack than this one — so for every request this one
874
+ // claimed, this one decided, and the other was reached only for what
875
+ // this one declined. Route-handler dispatch was in the other. A
876
+ // `GET /feed` from a browser is `Accept: text/html` with no extension,
877
+ // so it looked like a document, so `app/feed/$route.js` was never
878
+ // asked and the reader got the route table's page — or the not-found
879
+ // page — for a path that had a handler. See ubugeeei-prod/uf#349.
880
+ //
881
+ // Two renderers is also how the two came to disagree about the render
882
+ // result's `headers`: this one wrote them and the driver's did not, so a
883
+ // loader calling `redirect()` answered a browser with a `307` carrying
884
+ // no `Location` and a meta-refresh body — a redirect that works when you
885
+ // deploy it and not while you are writing it. See
886
+ // ubugeeei-prod/uf#338.
887
+ //
888
+ // So the driver's middleware is gone and everything it did is here: it
889
+ // claims every request, runs the guard, the action endpoint and the
890
+ // dispatcher for every method, and hands back to Vite's chain what none
891
+ // of them answered.
892
+ //
893
+ // # What is still not `createFetchHandler`
894
+ //
895
+ // One middleware rather than two, and still not the function every
896
+ // deployment runs. It cannot be: `transformIndexHtml` takes a whole
897
+ // document, so the render has to be collected here rather than streamed
898
+ // (ubugeeei-prod/uf#374), and a request nothing claimed has to go back
899
+ // to Vite's chain rather than become a 404 — neither of which a handler
900
+ // that always answers with a `Response` can do.
901
+ //
902
+ // What that still costs, written down so the next reader does not have
903
+ // to find it: `createFetchHandler` renders inside a cache scope even
904
+ // with no cache configured, so a component calling `cacheLife` states a
905
+ // lifetime nobody honours; here there is no scope, so the same component
906
+ // throws under `uf dev` and renders under `uf start`. Closing that means
907
+ // the generated server entry handing the host a scope the way it already
908
+ // hands it `beginRequest` — see `serverModuleSource` in
909
+ // `./internal/routes.js` — and it is the next thing to remove from this
910
+ // list rather than something this middleware can decide on its own.
264
911
  return () => {
912
+ // The browser's own reporting channel, mounted above the application
913
+ // so that a report never reaches a project's `$middleware.js` or
914
+ // its route table. See `internal/diagnostics.js`.
915
+ devServer.middlewares.use(
916
+ createChannelMiddleware((diagnostic) => emit("diagnostic", diagnostic)),
917
+ );
918
+
265
919
  devServer.middlewares.use(async (request, response, next) => {
266
- if (!wantsDocument(request)) return next();
920
+ // `request.url` and not `originalUrl`, which is the URL Vite's base
921
+ // middleware has already stripped the base from — and the route
922
+ // table's paths have no base in them either.
923
+ //
924
+ // `let`, because a rewrite moves it: from here on it is the address
925
+ // the application answers for, which is what the render, the payload
926
+ // and the document test below all have to agree on.
927
+ let url = request.url ?? "/";
928
+ // Declared out here so the catch below can still settle: a request
929
+ // that failed is a request that happened, and a middleware that
930
+ // logged its arrival is owed its callback either way.
931
+ let lifecycle = null;
267
932
  try {
268
- const url = request.url ?? "/";
269
- const { render } = await importServerEntry(devServer);
270
- const result = await render(url, {
271
- scripts: [devUrlFor(VIRTUAL.client)],
272
- styles: [],
273
- preloads: [],
933
+ const entry = await importServerEntry(devServer);
934
+ // At `request.url`, which Vite's base middleware has already taken
935
+ // `app.router.basePath` off: the application path, as every other
936
+ // front door hands the application once it has admitted a request.
937
+ const arrived = await toRequest(request, devServer.config, url);
938
+ // `app.router.rewrites`, where `createFetchHandler` applies them for
939
+ // every other door: after the files Vite already served, before the
940
+ // guard — so the guard that runs is the destination's.
941
+ let asRequest = (await rewriteRouting(entry.routing, arrived)) ?? arrived;
942
+ if (asRequest !== arrived) url = addressOf(asRequest);
943
+
944
+ // The request begins here and ends when the response has been
945
+ // written, which is what `after()` promises and what `uf preview`,
946
+ // `uf start` and a compiled binary all do too — a middleware that
947
+ // logs a response's status has to mean the same thing in
948
+ // development as in production. See `internal/serve.js` and
949
+ // ubugeeei-prod/uf#389.
950
+ lifecycle = await beginRequest(entry, arrived);
951
+ const answered = await lifecycle.run(async () => {
952
+ // Before anything answers: a middleware guards a subtree, so it
953
+ // has to run for a page, for a route handler, and for a path
954
+ // under it that matches neither. Running it inside the
955
+ // dispatcher and again inside the renderer would have left
956
+ // `/dashboard/typo` unguarded and run it twice for a path that
957
+ // is both. See ubugeeei-prod/uf#260.
958
+ //
959
+ // A `Request` back is a middleware's `rewrite()`, already past
960
+ // the destination's own middleware.
961
+ const guarded = await entry.runMiddleware(asRequest);
962
+ if (guarded instanceof Request) {
963
+ asRequest = guarded;
964
+ url = addressOf(guarded);
965
+ } else if (guarded != null) {
966
+ await send(response, guarded);
967
+ return true;
968
+ }
969
+
970
+ // Then a server action, below the guard and above the handlers.
971
+ // It declines every request that carries no action id, so this
972
+ // costs an ordinary request one `headers.get`; and it answers
973
+ // every request that carries one, refusals included, so an
974
+ // action can never fall through to a route handler that happens
975
+ // to sit at the URL it was posted to. The same two lines are in
976
+ // `@uniflowed/server`'s `fetch.js`, which is what every
977
+ // deployment runs.
978
+ const acted = await entry.callAction(asRequest);
979
+ if (acted != null) {
980
+ await send(response, acted);
981
+ return true;
982
+ }
983
+
984
+ // Then the route handlers, above the renderer and for every
985
+ // method: a path that answers a request is not a document,
986
+ // whatever the client said it would accept. `curl /api/thing`
987
+ // and a `<form action>` navigation both send `Accept:
988
+ // text/html`, and both want the handler's answer — which is the
989
+ // whole of ubugeeei-prod/uf#349.
990
+ // A browser that is navigating, asking for the next route's
991
+ // payload: after the guard and before the handlers, for the
992
+ // reasons `@uniflowed/server`'s `internal/flight.js` gives.
993
+ const payloadFor =
994
+ entry.flight == null ? null : flightDocumentPath(url.split("?")[0]);
995
+ if (payloadFor != null && (request.method === "GET" || request.method === "HEAD")) {
996
+ const query = url.includes("?") ? url.slice(url.indexOf("?")) : "";
997
+ const target = payloadFor + query;
998
+ const answered = await entry.flight(target, {
999
+ onError: (error) => reportRenderError(devServer, target, error),
1000
+ });
1001
+ if (answered.error != null) reportRenderError(devServer, target, answered.error);
1002
+ if (request.method === "HEAD") await answered.stream?.cancel();
1003
+ await send(
1004
+ response,
1005
+ new Response(request.method === "HEAD" ? null : answered.stream, {
1006
+ status: answered.status,
1007
+ headers: answered.headers,
1008
+ }),
1009
+ );
1010
+ return true;
1011
+ }
1012
+
1013
+ const handled = await entry.dispatch(asRequest);
1014
+ if (handled != null) {
1015
+ await send(response, handled);
1016
+ return true;
1017
+ }
1018
+
1019
+ // Only a navigation reaches the renderer. A page cannot answer a
1020
+ // `POST`, and letting one try would turn a missing handler into
1021
+ // a rendered page with a 200 rather than a 404.
1022
+ //
1023
+ // `notADocumentBecause` is stricter than the production handler, which
1024
+ // renders anything a static file did not answer, and the
1025
+ // difference is Vite's chain: `/@id/…`, `/node_modules/…` and
1026
+ // any path with an extension belong to the module server, and a
1027
+ // request one of those declined has to go back to it rather than
1028
+ // become a rendered 404 page. What it costs is that
1029
+ // `/favicon.svg` on a project that has none is a bare 404 here
1030
+ // and the project's own not-found *page* under `uf preview` and
1031
+ // `uf start` — a difference in the body of a 404 for a path that
1032
+ // is an asset request in the first place.
1033
+ const notDocument = notADocumentBecause(request, url);
1034
+ if (notDocument === "accept") {
1035
+ // Everything about this is a navigation except the header, and
1036
+ // the path is one uf renders. Say so, rather than letting the
1037
+ // chain below answer `Cannot GET /` in somebody else's words.
1038
+ documentNeedsHtml(request, response);
1039
+ return true;
1040
+ }
1041
+ if (notDocument != null) return false;
1042
+
1043
+ // A single-page project's deployment answers every navigation
1044
+ // with the same empty shell, so this does too. Rendering the
1045
+ // route here instead would have been the better-looking dev
1046
+ // server and the wrong one: a page that only works because the
1047
+ // server rendered it would work all through development and be
1048
+ // blank the day it shipped. It is the same argument
1049
+ // `app.rendering.navigation` makes about a link, one level up.
1050
+ //
1051
+ // The three steps above still ran — the guard, the action, the
1052
+ // handler — and each of them is something `uf build` refuses in
1053
+ // a `["csr"]` project by name. A dev server that skipped them
1054
+ // would hide the very thing the build is going to stop.
1055
+ if (mount === "render") {
1056
+ response.statusCode = 200;
1057
+ response.setHeader("content-type", "text/html; charset=utf-8");
1058
+ const shell = entry.shellDocument({
1059
+ scripts: [devUrlFor(VIRTUAL.client)],
1060
+ styles: [],
1061
+ preloads: [],
1062
+ });
1063
+ response.end(await devServer.transformIndexHtml(url, shell));
1064
+ return true;
1065
+ }
1066
+
1067
+ const result = await entry.render(
1068
+ url,
1069
+ { scripts: [devUrlFor(VIRTUAL.client)], styles: [], preloads: [] },
1070
+ {
1071
+ // A boundary that threw after the shell went out.
1072
+ // `result.error` cannot carry it — the caller already has the
1073
+ // result by then — so the terminal hears about it here or not
1074
+ // at all.
1075
+ onError: (error) => reportRenderError(devServer, url, error),
1076
+ // Vite sees the head and only the head. That is what lets the
1077
+ // development server stream like every other host — see below.
1078
+ //
1079
+ // Under React Server Components it is also where a server
1080
+ // component's stylesheets are linked in development: they are
1081
+ // in the rsc graph, which the browser never loads, and they
1082
+ // enter it when the route's modules are imported — after this
1083
+ // call and before the head is written, which is when this
1084
+ // runs. See `devStylesheets`.
1085
+ transformHead: (head) =>
1086
+ devServer.transformIndexHtml(
1087
+ url,
1088
+ flightState == null ? head : linkStylesheets(head, devStylesheets(devServer)),
1089
+ ),
1090
+ // And what the streaming actually did, when it changed. The
1091
+ // router has already decided there is something worth saying
1092
+ // and written the words — see its `internal/inspector.js`,
1093
+ // which cannot be imported from here because this file is
1094
+ // plain JavaScript that Vite loads before any Flow transform
1095
+ // exists. `info`, because a page that streamed is a
1096
+ // measurement and not a problem; `origin`, because a report
1097
+ // that does not say which page produced it is one somebody
1098
+ // has to reproduce before they can act on it.
1099
+ onStream: (diagnostic) =>
1100
+ emit("diagnostic", {
1101
+ severity: "info",
1102
+ origin: url,
1103
+ message: diagnostic.message,
1104
+ detail: diagnostic.detail,
1105
+ }),
1106
+ },
1107
+ );
1108
+ if (result.error != null) reportRenderError(devServer, url, result.error);
1109
+ // Piped, like every other host. This used to collect the whole
1110
+ // document and transform it at the end, because
1111
+ // `transformIndexHtml` is a *whole document* hook — which made the
1112
+ // one place a developer would notice streaming the one place it
1113
+ // did not happen: a slow page showed nothing until it was finished
1114
+ // and `$loading.js` looked broken.
1115
+ //
1116
+ // `transformHead` above is the seam. `internal/stream.js` already
1117
+ // held the opening chunk back until the head was complete and
1118
+ // forwarded everything after it untouched, so the hook only ever
1119
+ // sees the head — and Vite's dev hook handles a document that ends
1120
+ // mid-`<body>` without complaint, which was the open question on
1121
+ // ubugeeei-prod/uf#374.
1122
+ response.statusCode = result.status ?? 200;
1123
+ // The render's own headers, then the content type over the top:
1124
+ // exactly the order `@uniflowed/server`'s `fetch.js` writes them
1125
+ // in, so a `Location` from `redirect()` survives here and a
1126
+ // render cannot claim to be something other than a document.
1127
+ for (const [name, value] of Object.entries(result.headers ?? {})) {
1128
+ response.setHeader(name, value);
1129
+ }
1130
+ response.setHeader("content-type", "text/html; charset=utf-8");
1131
+ await result.pipe(response);
1132
+ return true;
274
1133
  });
275
- const html = await devServer.transformIndexHtml(url, result.html);
276
- response.statusCode = result.status;
277
- response.setHeader("Content-Type", "text/html; charset=utf-8");
278
- for (const [name, value] of Object.entries(result.headers ?? {})) {
279
- response.setHeader(name, value);
1134
+
1135
+ if (!answered) {
1136
+ // The one path where uf is not the one writing the response: a
1137
+ // request nothing claimed goes back to Vite's chain. The guard
1138
+ // has still run and may have deferred work, so `close` — the
1139
+ // socket saying the response is over, however it ended — is the
1140
+ // only honest signal left that the bytes are out.
1141
+ response.once("close", lifecycle.settle);
1142
+ next();
1143
+ return;
280
1144
  }
281
- response.end(html);
1145
+ await lifecycle.settle();
282
1146
  } catch (error) {
283
- devServer.ssrFixStacktrace(error);
1147
+ if (lifecycle != null) await lifecycle.settle();
1148
+ // Map the stack back onto the Flow source before it reaches the
1149
+ // overlay.
1150
+ if (error instanceof Error) devServer.ssrFixStacktrace(error);
284
1151
  next(error);
285
1152
  }
286
1153
  });
@@ -303,11 +1170,7 @@ function mdxPlugin(markdown) {
303
1170
  enforce: "pre",
304
1171
  ...mdx({
305
1172
  jsxImportSource: "react",
306
- remarkPlugins: [
307
- remarkGfm,
308
- remarkFrontmatter,
309
- [remarkMdxFrontmatter, { name: "frontmatter" }],
310
- ],
1173
+ remarkPlugins: [remarkGfm, remarkFrontmatter, remarkFrontmatterExport],
311
1174
  rehypePlugins,
312
1175
  }),
313
1176
  name: "uf:mdx",
@@ -332,16 +1195,225 @@ async function importServerEntry(devServer) {
332
1195
  return devServer.ssrLoadModule(VIRTUAL.server);
333
1196
  }
334
1197
 
335
- function wantsDocument(request) {
336
- if (request.method !== "GET" && request.method !== "HEAD") return false;
337
- const url = request.url ?? "/";
338
- if (url.startsWith("/@") || url.startsWith("/node_modules/")) return false;
339
- const accept = request.headers.accept ?? "";
340
- if (!accept.includes("text/html")) return false;
1198
+ /**
1199
+ * The path and query a `Request` is for, which is what the dev server's
1200
+ * renderer and payload test are keyed on.
1201
+ *
1202
+ * @param {Request} request
1203
+ */
1204
+ function addressOf(request) {
1205
+ const url = new URL(request.url);
1206
+ return url.pathname + url.search;
1207
+ }
1208
+
1209
+ /**
1210
+ * Why `request` is not a document request, or `null` when it is one.
1211
+ *
1212
+ * `url` is the address the application answers for, which a rewrite may have
1213
+ * moved away from the one the request line named.
1214
+ *
1215
+ * A reason rather than a boolean because one of the four is worth saying out
1216
+ * loud. Three of them mean the request belongs to somebody else — Vite's module
1217
+ * server, a static file, or a method a page cannot answer — and handing it back
1218
+ * is the whole point. The fourth means the client asked for a page uf would
1219
+ * have rendered and did not say it accepts HTML, and `curl` is the client that
1220
+ * does that. See ubugeeei-prod/uf#675.
1221
+ *
1222
+ * The extension test moved above the `accept` test so the two cannot be
1223
+ * confused: `/favicon.svg` with `Accept: *\/*` is an asset, not a navigation
1224
+ * with the wrong header, and still goes back to Vite's chain untouched.
1225
+ */
1226
+ function notADocumentBecause(request, url = request.url ?? "/") {
1227
+ if (request.method !== "GET" && request.method !== "HEAD") return "method";
1228
+ if (url.startsWith("/@") || url.startsWith("/node_modules/")) return "module-server";
341
1229
  const pathname = url.split("?")[0];
342
1230
  // A request for a file — `/favicon.svg`, `/assets/x.js` — that no static
343
1231
  // middleware answered is a 404, not a page.
344
- return !/\.[a-z0-9]+$/i.test(pathname);
1232
+ if (/\.[a-z0-9]+$/i.test(pathname)) return "asset";
1233
+ const accept = request.headers.accept ?? "";
1234
+ if (!accept.includes("text/html")) return "accept";
1235
+ return null;
1236
+ }
1237
+
1238
+ /**
1239
+ * The 404 for a navigation that did not ask for HTML.
1240
+ *
1241
+ * uf's own words rather than the framework underneath saying `Cannot GET /` in
1242
+ * its: the path is one uf would have rendered, and the only thing that stopped
1243
+ * it is a header the reader cannot see from the terminal. Development only —
1244
+ * this middleware is the dev server — and `uf preview` and `uf start` answer a
1245
+ * 404 with nothing in it, as `docs/security.md` requires.
1246
+ *
1247
+ * The client's `Accept` is quoted back, so it is treated the way every other
1248
+ * piece of foreign text here is treated: control characters removed and the
1249
+ * length capped. A header is not a thing a terminal should be asked to run.
1250
+ * See ubugeeei-prod/uf#649.
1251
+ */
1252
+ function documentNeedsHtml(request, response) {
1253
+ const accept = String(request.headers.accept ?? "")
1254
+ // eslint-disable-next-line no-control-regex
1255
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
1256
+ .slice(0, 80);
1257
+ const path = String(request.url ?? "/").slice(0, 200);
1258
+ const body =
1259
+ `404 ${request.method} ${path}\n\n` +
1260
+ "This path is a page, and a page is rendered for a request that accepts\n" +
1261
+ `HTML. This request sent \`Accept: ${accept || "(none)"}\`.\n\n` +
1262
+ ` curl -H 'Accept: text/html' http://${request.headers.host ?? "localhost"}${path}\n\n` +
1263
+ "A browser sends it; `curl` does not. Nothing is wrong with the route.\n";
1264
+ response.statusCode = 404;
1265
+ response.setHeader("content-type", "text/plain; charset=utf-8");
1266
+ response.end(body);
1267
+ }
1268
+
1269
+ /** The name of the environment variable that turns every finding back on. */
1270
+ const ALL_DIAGNOSTICS = "UF_REACT_COMPILER_DIAGNOSTICS";
1271
+
1272
+ /**
1273
+ * Report what the React Compiler said about one module.
1274
+ *
1275
+ * Every finding used to be printed as `a function: <message>` — no file, no
1276
+ * line, no column, and the fallback string doing all the work, because
1277
+ * `diagnostic.function` was read out of a field only a *success* event carries
1278
+ * and so was null for every finding there has ever been (#371), not because
1279
+ * the compiler was reporting on anonymous inner functions. The transform hook
1280
+ * knows the module and the compiler gives a position for most findings, so
1281
+ * both go into the message: Vite prints a plugin warning's `message` and
1282
+ * nothing else, so a location that is not in the string is a location the
1283
+ * reader never sees. `id` and `loc` go along for anything reading the log
1284
+ * object rather than the line. See ubugeeei-prod/uf#307.
1285
+ *
1286
+ * `(in Form)` is real now: `uf transform` recovers the name from the tree it
1287
+ * compiled, and leaves it off the diagnostic when the function genuinely has
1288
+ * no name to give.
1289
+ *
1290
+ * The position is real too, and is the expression the compiler objected to
1291
+ * rather than the function containing it, so the two halves of the line say
1292
+ * different things — `packages/hooks/dom.js:195:7: This value cannot be
1293
+ * modified (in useLongPress)` locates the write and names the hook to look in.
1294
+ *
1295
+ * A dependency's findings are held back. A React Compiler bailout inside
1296
+ * `@uniflowed/form` is not something the person running the build can fix, and
1297
+ * a channel carrying forty of them on every build is a channel people stop
1298
+ * reading — which costs them the one finding that was theirs. They are counted
1299
+ * and said once instead, and `UF_REACT_COMPILER_DIAGNOSTICS=all` prints every
1300
+ * one for whoever is fixing the dependency.
1301
+ */
1302
+ function reportDiagnostics(context, { id, root, diagnostics, environment, reported, suppressed }) {
1303
+ if (diagnostics.length === 0) return;
1304
+ // A module transformed again by the environment that first reported it has
1305
+ // been edited; anything else is the second bundle passing over the same file.
1306
+ const previous = reported.get(id);
1307
+ const ledger =
1308
+ previous != null && previous.environment !== environment
1309
+ ? previous
1310
+ : { environment, signatures: new Set() };
1311
+ reported.set(id, ledger);
1312
+
1313
+ const file = relativeId(root, id);
1314
+ const mine = isProjectModule(root, id) || process.env[ALL_DIAGNOSTICS] === "all";
1315
+ for (const diagnostic of diagnostics) {
1316
+ // Everything a reader would be shown, so two findings that would print as
1317
+ // the same line collapse into one.
1318
+ //
1319
+ // This used to collapse far more than that, and the note here used to say
1320
+ // the compiler reports "Cannot access refs during render" once per pass
1321
+ // that noticed it — three times for one `ref`. That was the wrong reading
1322
+ // of the evidence. The position in a finding was the position of the
1323
+ // *function* it was found in, so every finding in one function shared a
1324
+ // line and a column and any two with the same message were, to this
1325
+ // signature, the same finding. They were not: over this repository's own
1326
+ // packages it collapsed 173 findings into 119 lines, and the five that
1327
+ // became one line in `DatePickerInput` are five different reads of a ref
1328
+ // on five different lines. `uf transform` now reports where the compiler
1329
+ // actually objected, so the signature separates them, and what it still
1330
+ // collapses is a genuine repeat of one site.
1331
+ const signature = `${diagnostic.kind}\0${diagnostic.line}\0${diagnostic.column}\0${diagnostic.message}`;
1332
+ if (ledger.signatures.has(signature)) continue;
1333
+ ledger.signatures.add(signature);
1334
+ if (!mine) {
1335
+ suppressed.push(file);
1336
+ continue;
1337
+ }
1338
+ // Two conventions, both honoured. uf's own frames count columns from one
1339
+ // (`uf_term::diagnostic`) and so does every editor a reader will paste
1340
+ // `file:line:column` into; Rollup's `loc.column` counts from zero, which is
1341
+ // what the compiler already gave us. The string gets the reader's number
1342
+ // and the log object gets Rollup's.
1343
+ const at = diagnostic.line == null ? "" : `:${diagnostic.line}:${(diagnostic.column ?? 0) + 1}`;
1344
+ const who = diagnostic.function == null ? "" : ` (in ${diagnostic.function})`;
1345
+ context.warn?.({
1346
+ message: `${file}${at}: ${diagnostic.message}${who}`,
1347
+ id,
1348
+ loc:
1349
+ diagnostic.line == null
1350
+ ? undefined
1351
+ : { file: id, line: diagnostic.line, column: diagnostic.column ?? 0 },
1352
+ });
1353
+ }
1354
+ }
1355
+
1356
+ /**
1357
+ * Say how many findings were a dependency's, and whose.
1358
+ *
1359
+ * Held back is not the same as hidden: a build that quietly drops forty
1360
+ * findings is a build that has decided for the reader that uf has no bugs. One
1361
+ * line names the packages and how to see the rest.
1362
+ */
1363
+ function summariseSuppressed(context, suppressed) {
1364
+ if (suppressed.length === 0) return;
1365
+ const packages = [...new Set(suppressed.map(packageOf))].sort();
1366
+ const count = suppressed.length;
1367
+ context.warn?.(
1368
+ `${count} React Compiler ${count === 1 ? "finding" : "findings"} in ` +
1369
+ `${packages.join(", ")} — not this application's to fix; ` +
1370
+ `set ${ALL_DIAGNOSTICS}=all to see them`,
1371
+ );
1372
+ }
1373
+
1374
+ /**
1375
+ * Whether a finding about this module is the application author's to act on.
1376
+ *
1377
+ * Inside the project root *and* outside `node_modules`, rather than
1378
+ * `node_modules` alone: uf's own packages reach an application through a
1379
+ * workspace link in this repository and through `node_modules` everywhere
1380
+ * else, and they are no more the reader's code in one case than the other.
1381
+ */
1382
+ function isProjectModule(root, id) {
1383
+ const relative = path.relative(root, id);
1384
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
1385
+ return !relative.split(path.sep).includes("node_modules");
1386
+ }
1387
+
1388
+ /** A module's path as a reader would write it: relative, with forward slashes. */
1389
+ function relativeId(root, id) {
1390
+ const relative = path.relative(root, id);
1391
+ return relative === "" ? id : relative.split(path.sep).join("/");
1392
+ }
1393
+
1394
+ /** The package a module belongs to, for the one line that names them. */
1395
+ function packageOf(file) {
1396
+ const parts = file.split("/");
1397
+ const at = parts.lastIndexOf("node_modules");
1398
+ if (at !== -1) {
1399
+ const scoped = parts[at + 1]?.startsWith("@");
1400
+ return parts.slice(at + 1, at + (scoped ? 3 : 2)).join("/");
1401
+ }
1402
+ // No `node_modules` in the path: a workspace link, resolved to a checkout.
1403
+ // The directory the module hangs off is the closest thing to a package name
1404
+ // that is true without reading its `package.json` from a warning path.
1405
+ const up = parts.lastIndexOf("packages");
1406
+ return up === -1 ? parts.slice(0, -1).join("/") || file : parts.slice(up, up + 2).join("/");
1407
+ }
1408
+
1409
+ /**
1410
+ * Whether a hook is running for the server environment.
1411
+ *
1412
+ * Both spellings, for the reason `transform` above checks both: Vite 6 moved
1413
+ * the answer onto the plugin context and the `ssr` option is the older one.
1414
+ */
1415
+ function isSsr(context, options) {
1416
+ return options?.ssr === true || context?.environment?.name === "ssr";
345
1417
  }
346
1418
 
347
1419
  function cleanId(id) {