@uniflowed/vite 0.0.0-alpha.9 → 0.2.0

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