@uniflowed/vite 0.0.0-alpha.14 → 0.0.0-alpha.16

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/driver.js CHANGED
@@ -10,6 +10,8 @@
10
10
  // <host> driver.js build --root <dir> [--mode <m>] [--out-dir <dir>]
11
11
  // [--prerender everything|possible|nothing]
12
12
  // [--static-build] [--because <sentence>]
13
+ // <host> driver.js library --root <dir> [--mode <m>] [--out-dir <dir>]
14
+ // --entry <file>... --format <es|cjs>... [--external <name>]...
13
15
  // <host> driver.js compile --root <dir> [--mode <m>] [--out-dir <dir>] --assets <file> --bundle <dir>
14
16
  // <host> driver.js deploy --root <dir> [--mode <m>] [--out-dir <dir>] --adapter <name> --work <dir> --output <dir>
15
17
  // <host> driver.js preview --root <dir> [--mode <m>] [--out-dir <dir>] [--host <h>] [--port <n>]
@@ -38,11 +40,12 @@
38
40
  // one host that can evaluate the file evaluates it.
39
41
 
40
42
  import { createServer as createHttpServer } from "node:http";
41
- import { register } from "node:module";
43
+ import { builtinModules, register } from "node:module";
42
44
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
43
45
  import path from "node:path";
44
46
  import { pathToFileURL } from "node:url";
45
47
 
48
+ import { COMPILE_ASSETS_ID, compileAssetsPlugin } from "./internal/compile-assets.js";
46
49
  import { emit, errorEvent, eventLogger } from "./internal/events.js";
47
50
  import { loadUfConfig, projectConfig } from "./internal/config.js";
48
51
  import { send, toRequest } from "./internal/http.js";
@@ -50,6 +53,7 @@ import { withProjectConfig } from "./merge.js";
50
53
  import { VIRTUAL, scanRoutes } from "./internal/routes.js";
51
54
  import {
52
55
  assetsFromManifest,
56
+ createPrerenderGate,
53
57
  createServeHandler,
54
58
  loadBuild,
55
59
  nodeListener,
@@ -97,7 +101,7 @@ process.stdin.on("end", () => process.exit(0));
97
101
  process.stdin.on("error", () => process.exit(0));
98
102
  process.stdin.resume();
99
103
 
100
- const commands = { dev, build, compile, deploy, preview, start, config: printConfig };
104
+ const commands = { dev, build, library, compile, deploy, preview, start, config: printConfig };
101
105
  const run = commands[command];
102
106
  if (run == null) {
103
107
  emit("error", { message: `unknown driver command ${JSON.stringify(command)}` });
@@ -341,6 +345,16 @@ function watchEnvFiles(server) {
341
345
  * The static middleware still runs first, and that is deliberate rather than
342
346
  * incidental; see `internal/serve.js` for why `uf start` orders itself the
343
347
  * same way.
348
+ *
349
+ * One request is the exception, and it is the only thing uf mounts in *front*
350
+ * of Vite here: a request carrying the draft cookie, which no front door may
351
+ * answer from a prerendered document. `createStaticHandler` applies that rule
352
+ * and cannot reach a request Vite's file middleware answered first, so under
353
+ * `uf preview` draft mode appeared to be off while it worked under `uf dev`
354
+ * and `uf start` — ubugeeei-prod/uf#620. `configurePreviewServer` is where a
355
+ * middleware goes ahead of Vite's own; the hook's *body* runs before they are
356
+ * installed and a function it returns runs after, which is why this is a body
357
+ * and the handler below is a `use` on the started server.
344
358
  */
345
359
  async function preview() {
346
360
  const { preview: startPreview } = await import("vite");
@@ -361,25 +375,53 @@ async function preview() {
361
375
  serverDir: path.join(".uf", "build", "server"),
362
376
  });
363
377
 
364
- const server = await startPreview({ ...inline, appType: "custom" });
365
- if (build != null) {
366
- const handle = createServeHandler({ ...build, cache: config.app?.rendering?.cache });
367
- server.middlewares.use(async (request, response, next) => {
368
- try {
369
- const asRequest = await toRequest(request, server.config);
370
- // The same lifecycle `uf start` gets from `nodeListener`, spelled out
371
- // because this door is Vite's connect chain rather than a bare
372
- // `node:http` server: the whole request runs inside it, and it settles
373
- // once `send` has returned. A preview whose `after()` fired at a
374
- // different moment from the production server's would be a preview that
375
- // is checked and believed and wrong.
376
- await withRequest(build.entry, asRequest, async () => {
377
- await send(response, await handle(asRequest));
378
- });
379
- } catch (error) {
380
- next(error);
381
- }
382
- });
378
+ // One handler for both positions in the chain. A draft request meets it in
379
+ // front of Vite's file middleware and every other request meets it behind,
380
+ // and because it is the same handler the two give the same answer — which is
381
+ // the whole reason `uf preview` exists.
382
+ const handle =
383
+ build == null ? null : createServeHandler({ ...build, cache: config.app?.rendering?.cache });
384
+ const answer = (previewServer) => async (request, response, next) => {
385
+ try {
386
+ const asRequest = await toRequest(request, previewServer.config);
387
+ // The same lifecycle `uf start` gets from `nodeListener`, spelled out
388
+ // because this door is Vite's connect chain rather than a bare
389
+ // `node:http` server: the whole request runs inside it, and it settles
390
+ // once `send` has returned. A preview whose `after()` fired at a
391
+ // different moment from the production server's would be a preview that
392
+ // is checked and believed and wrong.
393
+ await withRequest(build.entry, asRequest, async () => {
394
+ await send(response, await handle(asRequest));
395
+ });
396
+ } catch (error) {
397
+ next(error);
398
+ }
399
+ };
400
+
401
+ const mayAnswerFromPrerender = createPrerenderGate();
402
+ const draftFirst = {
403
+ name: "uf:draft-before-files",
404
+ configurePreviewServer(previewServer) {
405
+ if (handle == null) return;
406
+ const run = answer(previewServer);
407
+ previewServer.middlewares.use((request, response, next) => {
408
+ // Not `await`ed by connect, which takes no promise: the gate is
409
+ // resolved inside and `next()` is called from there. A rejection is a
410
+ // `next(error)` for the same reason.
411
+ mayAnswerFromPrerender(request.headers.cookie ?? null)
412
+ .then((mayAnswer) => (mayAnswer ? next() : run(request, response, next)))
413
+ .catch(next);
414
+ });
415
+ },
416
+ };
417
+
418
+ const server = await startPreview({
419
+ ...inline,
420
+ appType: "custom",
421
+ plugins: [...(inline.plugins ?? []), draftFirst],
422
+ });
423
+ if (handle != null) {
424
+ server.middlewares.use(answer(server));
383
425
  }
384
426
 
385
427
  const urls = server.resolvedUrls ?? { local: [], network: [] };
@@ -680,6 +722,147 @@ async function build() {
680
722
  process.exit(0);
681
723
  }
682
724
 
725
+ /**
726
+ * The library build, for a project whose `app.router.enabled` is false.
727
+ *
728
+ * `build` above is an application build and has no other mode: it links
729
+ * `virtual:uf/client`, which imports the router and the project's `app.js`.
730
+ * A library has neither, so `uf build` in a project `uf create lib`
731
+ * scaffolded failed at the first pass with `Could not resolve '<root>/app.js'`
732
+ * — a file a library does not have and never had. See ubugeeei-prod/uf#268.
733
+ *
734
+ * This is the fourth thing the driver does, beside `dev`, `build` and
735
+ * `compile`, and it is one pass per format over one input list. Which of the
736
+ * two builds runs is **not decided here**: `uf` resolves it from the config
737
+ * (`uf_config`'s `LibraryPlan`) and spawns this subcommand, the same way
738
+ * `--prerender` arrives as one word rather than as two settings for this file
739
+ * to read together.
740
+ *
741
+ * # The three ways it differs from the application build
742
+ *
743
+ * * **Every dependency stays an import.** `--external` names them, and
744
+ * `uf` computes the list from the project's own manifest —
745
+ * `dependencies`, `peerDependencies`, `optionalDependencies` — so a
746
+ * library ships its own modules and nobody else's. That is the opposite
747
+ * of the application build, which inlines what it can because an
748
+ * application is the end of the line and a library is not: a bundled copy
749
+ * of React inside a library is a second React in every application that
750
+ * installs it.
751
+ * * **One output per entry, named after the entry.** `index.js` becomes
752
+ * `dist/index.js`; `internal/parse.js` becomes `dist/internal/parse.js`.
753
+ * The path rather than the basename, so two entries cannot collide at the
754
+ * moment one would overwrite the other.
755
+ * * **No manifest, no prerender, no server bundle.** There is no document to
756
+ * write and no route table to write it from.
757
+ *
758
+ * Vite's own `build.lib` does the work. uf owns *that* a library is a
759
+ * different build and what goes into it; how this builder performs one is the
760
+ * builder's, which is the same line `build` draws around `rollupOptions`.
761
+ */
762
+ async function library() {
763
+ const vite = await import("vite");
764
+ const config = await loadConfig();
765
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
766
+ const outDir = path.resolve(root, inline.build.outDir);
767
+ const entries = argumentAll("--entry");
768
+ const formats = argumentAll("--format");
769
+ const external = argumentAll("--external");
770
+ if (entries.length === 0) {
771
+ throw new Error("uf: `driver.js library` needs at least one --entry");
772
+ }
773
+ if (formats.length === 0) {
774
+ throw new Error("uf: `driver.js library` needs at least one --format");
775
+ }
776
+
777
+ // Keyed by the entry's path without its extension, which is what Vite's lib
778
+ // mode turns into the output file name.
779
+ const input = {};
780
+ for (const entry of entries) {
781
+ input[entryName(entry)] = path.resolve(root, entry);
782
+ }
783
+
784
+ const isExternal = externalTest(external);
785
+ // One pass per format rather than one build with several outputs: Vite's
786
+ // lib mode writes a whole `outDir` per format, and the second pass must not
787
+ // empty what the first wrote. So `emptyOutDir` is true exactly once, on the
788
+ // first, which is also what makes a build that dropped an entry leave no
789
+ // stale copy of it behind.
790
+ let first = true;
791
+ for (const format of formats) {
792
+ emit("phase", { name: `library (${format})` });
793
+ await vite.build({
794
+ ...inline,
795
+ build: {
796
+ ...inline.build,
797
+ // Vite's `manifest` maps source modules to hashed browser assets. A
798
+ // library has neither — its file names are its API — and writing one
799
+ // would put a `.vite/` directory into a published tarball.
800
+ manifest: false,
801
+ outDir,
802
+ emptyOutDir: first,
803
+ lib: {
804
+ entry: input,
805
+ formats: [format],
806
+ fileName: (_format, name) => `${name}.${format === "cjs" ? "cjs" : "js"}`,
807
+ },
808
+ rollupOptions: { external: isExternal },
809
+ },
810
+ });
811
+ first = false;
812
+ }
813
+
814
+ emit("done", { outDir: path.relative(root, outDir), pages: 0 });
815
+ process.exit(0);
816
+ }
817
+
818
+ /**
819
+ * The output name for one entry: its path, without the extension.
820
+ *
821
+ * Not the basename. `index.js` and `internal/index.js` are two entries a
822
+ * library can reasonably have, and under a basename they are one file written
823
+ * twice — the second silently winning, which is a published package whose
824
+ * subpath export is somebody else's module.
825
+ */
826
+ function entryName(entry) {
827
+ const normalised = entry.replace(/\\/g, "/").replace(/^\.\//, "");
828
+ const dot = normalised.lastIndexOf(".");
829
+ const slash = normalised.lastIndexOf("/");
830
+ return dot > slash ? normalised.slice(0, dot) : normalised;
831
+ }
832
+
833
+ /**
834
+ * Whether an import is somebody else's module.
835
+ *
836
+ * Three checks, and only the one over `names` is a policy uf decided. `names`
837
+ * is what `uf` read out of the project's manifest and passed as `--external`,
838
+ * and a subpath of one of those names — `@scope/pkg/deep` for `@scope/pkg` —
839
+ * is the same package. The other two are the host's built-in modules, and they
840
+ * are a fact rather than a decision: `node:fs` has no bytes to inline.
841
+ *
842
+ * A bare relative or absolute id is never external, which is the rule that
843
+ * makes this a library build at all: what the author wrote is bundled, and
844
+ * what they installed is imported.
845
+ */
846
+ function externalTest(names) {
847
+ const declared = new Set(names);
848
+ // The host's built-in module names, unprefixed. `node:`-prefixed ids are
849
+ // caught by the first check whatever the host is; this set is for the bare
850
+ // spellings — `fs`, `path`, `stream` — which a dependency written before the
851
+ // prefix existed still uses. Read from the running host rather than written
852
+ // down, because the list grows and a stale copy of it here would be a
853
+ // bundled `node:worker_threads` that cannot be bundled.
854
+ const builtins = new Set(builtinModules ?? []);
855
+ return (id) => {
856
+ if (id.startsWith("node:")) return true;
857
+ if (builtins.has(id)) return true;
858
+ if (declared.has(id)) return true;
859
+ for (const name of declared) {
860
+ if (id.startsWith(`${name}/`)) return true;
861
+ }
862
+ return false;
863
+ };
864
+ }
865
+
683
866
  /**
684
867
  * Link the whole application into one JavaScript file, for `uf build --compile`.
685
868
  *
@@ -741,17 +924,19 @@ async function compile() {
741
924
  emit("phase", { name: "standalone" });
742
925
 
743
926
  // The entry is written to disk rather than served as another virtual module:
744
- // it is generated per build (it names this build's asset file), and a real
927
+ // it is generated per build (it bakes in this build's document), and a real
745
928
  // file is the version a person can open when a compiled binary misbehaves.
746
929
  const entry = path.join(bundleDir, "entry.js");
747
930
  mkdirSync(bundleDir, { recursive: true });
748
- const specifier = `./${path.relative(bundleDir, assets)}`;
749
- writeFileSync(entry, entrySource(specifier, assetsFromManifest(readManifest(outDir))));
931
+ writeFileSync(
932
+ entry,
933
+ entrySource(path.relative(root, assets), assetsFromManifest(readManifest(outDir))),
934
+ );
750
935
 
751
936
  await vite.build({
752
937
  ...inline,
753
938
  customLogger: eventLogger("warn"),
754
- plugins: [...inline.plugins, nativeAddonGuard()],
939
+ plugins: [...inline.plugins, nativeAddonGuard(), compileAssetsPlugin(assets)],
755
940
  ssr: { ...(inline.ssr ?? {}), noExternal: true },
756
941
  build: {
757
942
  ...inline.build,
@@ -887,6 +1072,16 @@ const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lam
887
1072
  * copying every file in it is bulk work over the whole build, which belongs in
888
1073
  * Rust rather than in the host process — the same division `--compile` makes
889
1074
  * with its embedded assets.
1075
+ *
1076
+ * # `--adapter static` never reaches this function
1077
+ *
1078
+ * It is the one implemented target with no application to link: a static host
1079
+ * returns files, and `uf build` has already written them. So `uf` copies the
1080
+ * output directory itself and never spawns this driver for it, which is why
1081
+ * [`ADAPTERS`] has four rows and not five. What that target does instead of
1082
+ * linking is refuse a project whose route handlers, middleware, unprerendered
1083
+ * routes or server actions a static host cannot answer — in Rust, because the
1084
+ * facts it needs are the route table and what the prerender reported.
890
1085
  */
891
1086
  async function deploy() {
892
1087
  const vite = await import("vite");
@@ -1162,8 +1357,14 @@ export const handler = createLambdaHandler({ handle, beginRequest, staticDir });
1162
1357
  * bytes of `dist/`. The document's script and stylesheet URLs are baked in
1163
1358
  * here because they come from the client manifest, which exists at this moment
1164
1359
  * and not inside the binary.
1360
+ *
1361
+ * `assetsFile` is the generated payload's path, and it appears in a comment
1362
+ * rather than in the import: the module enters the graph under a virtual id so
1363
+ * that uf's Flow transform never meets eight megabytes of base64. The reason
1364
+ * that matters is `./internal/compile-assets.js`; naming the file here is what
1365
+ * keeps a reader of the generated entry able to find the bytes it carries.
1165
1366
  */
1166
- function entrySource(assetsSpecifier, document) {
1367
+ function entrySource(assetsFile, document) {
1167
1368
  // Not `await serve(...)` at the top level. uf parses that now
1168
1369
  // (ubugeeei-prod/uf#204) and this entry is a module, so it would work;
1169
1370
  // `.catch` is the better spelling regardless: a binary that cannot take its
@@ -1171,7 +1372,11 @@ function entrySource(assetsSpecifier, document) {
1171
1372
  // unhandled rejection.
1172
1373
  return `// Generated by \`uf build --compile\`. Not checked in, not edited.
1173
1374
  import { serve } from "@uniflowed/server/standalone";
1174
- import { assets } from ${JSON.stringify(assetsSpecifier)};
1375
+ // Every file \`uf build\` wrote, base64 in one string. It is on disk at
1376
+ // ${assetsFile}, and it is imported under a virtual id so that uf's
1377
+ // Flow transform is never asked to parse it — see \`@uniflowed/vite\`'s
1378
+ // \`internal/compile-assets.js\` for why that matters.
1379
+ import { assets } from ${JSON.stringify(COMPILE_ASSETS_ID)};
1175
1380
  import * as app from ${JSON.stringify(VIRTUAL.server)};
1176
1381
 
1177
1382
  serve({ app, assets, document: ${JSON.stringify(document)} }).catch((error) => {
package/index.js CHANGED
@@ -45,6 +45,13 @@ import path from "node:path";
45
45
  import mdx from "@mdx-js/rollup";
46
46
  import rehypeSlug from "rehype-slug";
47
47
 
48
+ import {
49
+ AUDIT_PUBLIC_PATH,
50
+ AUDIT_RESOLVED_ID,
51
+ auditAvailable,
52
+ auditRuntimeSource,
53
+ auditTag,
54
+ } from "./internal/a11y.js";
48
55
  import { assetPlugin } from "./internal/assets.js";
49
56
  import { emit, reportRenderError } from "./internal/events.js";
50
57
  import { highlightPlugin } from "./internal/highlight.js";
@@ -79,6 +86,7 @@ import {
79
86
  } from "./internal/routes.js";
80
87
  import { TransformService, isFlowModule } from "@uniflowed/host/transform";
81
88
  import { createChannelMiddleware } from "./internal/diagnostics.js";
89
+ import { devtoolsPreamble } from "./internal/devtools.js";
82
90
  import { send, toRequest } from "./internal/http.js";
83
91
  import { beginRequest } from "./internal/serve.js";
84
92
 
@@ -122,21 +130,45 @@ export default function uniflowed(options = {}) {
122
130
  const appEntry = app.router?.entry ?? ufConfig.build?.entries?.[0] ?? "app.js";
123
131
  const markdown = app.builtins?.markdown ?? {};
124
132
  const builtins = app.builtins ?? {};
133
+ // On unless the project says otherwise, and read as `!== false` rather than
134
+ // `=== true` because that is what "on by default" means for a field almost
135
+ // no `uf.config.js` will mention. It only ever reaches the *development*
136
+ // client entry; see `flowPlugin`'s `load`. ubugeeei-prod/uf#516.
137
+ const strictMode = app.react?.strictMode !== false;
138
+
139
+ const accessibility = ufConfig.accessibility ?? {};
125
140
 
126
141
  return [
127
- flowPlugin({ routerRoot, appEntry, command: options.command }),
142
+ flowPlugin({
143
+ routerRoot,
144
+ appEntry,
145
+ strictMode,
146
+ command: options.command,
147
+ accessibility,
148
+ }),
128
149
  mdxPlugin(markdown),
129
150
  assetPlugin({
130
151
  images: builtins.images ?? {},
131
152
  fonts: builtins.fonts ?? {},
153
+ icons: builtins.icons ?? {},
154
+ og: builtins.og ?? {},
132
155
  command: options.command,
133
156
  }),
134
157
  ];
135
158
  }
136
159
 
137
- function flowPlugin({ routerRoot, appEntry, command }) {
160
+ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }) {
138
161
  let root = process.cwd();
139
162
  let isProduction = false;
163
+ /**
164
+ * Whether this project has axe-core, so the audit has an engine.
165
+ *
166
+ * Answered once in `configResolved` rather than per document: it is a
167
+ * question about `node_modules`, the answer cannot change while the server
168
+ * runs without a restart anyway, and asking it per render would put a module
169
+ * resolution on the path of every page.
170
+ */
171
+ let auditsPage = false;
140
172
  let base = "/";
141
173
  let appRoot = "";
142
174
  let entryPath = "";
@@ -246,6 +278,10 @@ function flowPlugin({ routerRoot, appEntry, command }) {
246
278
  config(userConfig, env) {
247
279
  const projectRoot = path.resolve(userConfig.root ?? process.cwd());
248
280
  isProduction = env.mode === "production" || env.command === "build";
281
+ // Decided here rather than in `configResolved`, because the answer has to
282
+ // reach `optimizeDeps.include` below and that is written in this hook.
283
+ auditsPage =
284
+ !isProduction && (accessibility?.devAudit ?? true) && auditAvailable(projectRoot);
249
285
  return {
250
286
  // uf serves HTML itself; there is no index.html to fall back to.
251
287
  appType: "custom",
@@ -260,6 +296,16 @@ function flowPlugin({ routerRoot, appEntry, command }) {
260
296
  "react/compiler-runtime",
261
297
  "react-dom",
262
298
  "react-dom/client",
299
+ // The accessibility audit's engine, when this project has one.
300
+ //
301
+ // Named up front rather than left to be discovered. axe-core is
302
+ // CommonJS, and the only thing that imports it is a *virtual*
303
+ // module reached from the document — so Vite would meet it for the
304
+ // first time after a page had already loaded, optimise it then, and
305
+ // reload the page it had just served. A full reload in the middle
306
+ // of the first render of every `uf dev` session is a high price for
307
+ // a feature whose whole manner is to be quiet.
308
+ ...(auditsPage ? ["axe-core"] : []),
263
309
  ],
264
310
  // uf's packages ship Flow. The dependency optimiser pre-bundles
265
311
  // with a JavaScript parser and would reject every one of them.
@@ -287,6 +333,7 @@ function flowPlugin({ routerRoot, appEntry, command }) {
287
333
 
288
334
  resolveId(id) {
289
335
  if (id === RUNTIME_PUBLIC_PATH) return RUNTIME_RESOLVED_ID;
336
+ if (id === AUDIT_PUBLIC_PATH) return AUDIT_RESOLVED_ID;
290
337
  if (VIRTUAL_IDS.has(id)) return resolved(id);
291
338
  // A module's own stylesheet, which `transform` below asked for by
292
339
  // importing this id. Returning it unchanged marks it resolved without
@@ -297,6 +344,7 @@ function flowPlugin({ routerRoot, appEntry, command }) {
297
344
 
298
345
  load(id, loadOptions) {
299
346
  if (id === RUNTIME_RESOLVED_ID) return refreshRuntimeSource();
347
+ if (id === AUDIT_RESOLVED_ID) return auditRuntimeSource(accessibility?.axe);
300
348
  if (id === resolved(VIRTUAL.routes)) {
301
349
  const table = scanRoutes(appRoot);
302
350
  // The server renders every route, so the server's table is the whole
@@ -305,7 +353,12 @@ function flowPlugin({ routerRoot, appEntry, command }) {
305
353
  if (isSsr(this, loadOptions)) return routesModuleSource(table);
306
354
  return clientRoutesModule(table);
307
355
  }
308
- if (id === resolved(VIRTUAL.client)) return clientModuleSource(entryPath);
356
+ // Strict Mode belongs to the client entry and to development only: a
357
+ // build passes `false`, so the generated module is the one that existed
358
+ // before #516 and a visitor's browser renders once.
359
+ if (id === resolved(VIRTUAL.client)) {
360
+ return clientModuleSource(entryPath, { strictMode: strictMode && !isProduction });
361
+ }
309
362
  if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
310
363
  // Only `virtual:uf/server` imports this, so it is only ever asked for in
311
364
  // the server environment — but the table it carries is every callable
@@ -404,9 +457,26 @@ function flowPlugin({ routerRoot, appEntry, command }) {
404
457
  }
405
458
  },
406
459
 
460
+ // The two scripts a development document loads before its own, and
461
+ // nothing at all in a build — which is the whole of "the hook is out of a
462
+ // production build" (ubugeeei-prod/uf#503) and of "production does not run
463
+ // under Strict Mode" (#516, whose flag is generated into
464
+ // `virtual:uf/client` rather than injected here).
465
+ //
466
+ // DevTools first, and as a *classic* script rather than a module: React
467
+ // registers itself with `__REACT_DEVTOOLS_GLOBAL_HOOK__` while `react-dom`
468
+ // is evaluated and never again, so the hook has to exist before any module
469
+ // runs. A classic inline script runs while the parser is on it; a module
470
+ // waits for the document. `internal/devtools.js` has the rest of the
471
+ // argument, and the three conditions DevTools needs.
407
472
  transformIndexHtml() {
408
473
  if (isProduction) return [];
409
- return [
474
+ const tags = [
475
+ {
476
+ tag: "script",
477
+ children: devtoolsPreamble(),
478
+ injectTo: "head-prepend",
479
+ },
410
480
  {
411
481
  tag: "script",
412
482
  attrs: { type: "module" },
@@ -414,6 +484,13 @@ function flowPlugin({ routerRoot, appEntry, command }) {
414
484
  injectTo: "head-prepend",
415
485
  },
416
486
  ];
487
+ // Only when the project has the engine. A tag pointing at a module that
488
+ // cannot resolve `axe-core` would be a red console on every page of a
489
+ // project that never asked for an audit, which is a worse default than
490
+ // no audit.
491
+ const audit = auditTag(base, auditsPage);
492
+ if (audit != null) tags.push(audit);
493
+ return tags;
417
494
  },
418
495
 
419
496
  configureServer(devServer) {
@@ -590,7 +667,7 @@ function flowPlugin({ routerRoot, appEntry, command }) {
590
667
  // `POST`, and letting one try would turn a missing handler into
591
668
  // a rendered page with a 200 rather than a 404.
592
669
  //
593
- // `wantsDocument` is stricter than the production handler, which
670
+ // `notADocumentBecause` is stricter than the production handler, which
594
671
  // renders anything a static file did not answer, and the
595
672
  // difference is Vite's chain: `/@id/…`, `/node_modules/…` and
596
673
  // any path with an extension belong to the module server, and a
@@ -600,7 +677,15 @@ function flowPlugin({ routerRoot, appEntry, command }) {
600
677
  // and the project's own not-found *page* under `uf preview` and
601
678
  // `uf start` — a difference in the body of a 404 for a path that
602
679
  // is an asset request in the first place.
603
- if (!wantsDocument(request)) return false;
680
+ const notDocument = notADocumentBecause(request);
681
+ if (notDocument === "accept") {
682
+ // Everything about this is a navigation except the header, and
683
+ // the path is one uf renders. Say so, rather than letting the
684
+ // chain below answer `Cannot GET /` in somebody else's words.
685
+ documentNeedsHtml(request, response);
686
+ return true;
687
+ }
688
+ if (notDocument != null) return false;
604
689
 
605
690
  const result = await entry.render(
606
691
  url,
@@ -700,16 +785,62 @@ async function importServerEntry(devServer) {
700
785
  return devServer.ssrLoadModule(VIRTUAL.server);
701
786
  }
702
787
 
703
- function wantsDocument(request) {
704
- if (request.method !== "GET" && request.method !== "HEAD") return false;
788
+ /**
789
+ * Why `request` is not a document request, or `null` when it is one.
790
+ *
791
+ * A reason rather than a boolean because one of the four is worth saying out
792
+ * loud. Three of them mean the request belongs to somebody else — Vite's module
793
+ * server, a static file, or a method a page cannot answer — and handing it back
794
+ * is the whole point. The fourth means the client asked for a page uf would
795
+ * have rendered and did not say it accepts HTML, and `curl` is the client that
796
+ * does that. See ubugeeei-prod/uf#675.
797
+ *
798
+ * The extension test moved above the `accept` test so the two cannot be
799
+ * confused: `/favicon.svg` with `Accept: *\/*` is an asset, not a navigation
800
+ * with the wrong header, and still goes back to Vite's chain untouched.
801
+ */
802
+ function notADocumentBecause(request) {
803
+ if (request.method !== "GET" && request.method !== "HEAD") return "method";
705
804
  const url = request.url ?? "/";
706
- if (url.startsWith("/@") || url.startsWith("/node_modules/")) return false;
707
- const accept = request.headers.accept ?? "";
708
- if (!accept.includes("text/html")) return false;
805
+ if (url.startsWith("/@") || url.startsWith("/node_modules/")) return "module-server";
709
806
  const pathname = url.split("?")[0];
710
807
  // A request for a file — `/favicon.svg`, `/assets/x.js` — that no static
711
808
  // middleware answered is a 404, not a page.
712
- return !/\.[a-z0-9]+$/i.test(pathname);
809
+ if (/\.[a-z0-9]+$/i.test(pathname)) return "asset";
810
+ const accept = request.headers.accept ?? "";
811
+ if (!accept.includes("text/html")) return "accept";
812
+ return null;
813
+ }
814
+
815
+ /**
816
+ * The 404 for a navigation that did not ask for HTML.
817
+ *
818
+ * uf's own words rather than the framework underneath saying `Cannot GET /` in
819
+ * its: the path is one uf would have rendered, and the only thing that stopped
820
+ * it is a header the reader cannot see from the terminal. Development only —
821
+ * this middleware is the dev server — and `uf preview` and `uf start` answer a
822
+ * 404 with nothing in it, as `docs/security.md` requires.
823
+ *
824
+ * The client's `Accept` is quoted back, so it is treated the way every other
825
+ * piece of foreign text here is treated: control characters removed and the
826
+ * length capped. A header is not a thing a terminal should be asked to run.
827
+ * See ubugeeei-prod/uf#649.
828
+ */
829
+ function documentNeedsHtml(request, response) {
830
+ const accept = String(request.headers.accept ?? "")
831
+ // eslint-disable-next-line no-control-regex
832
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
833
+ .slice(0, 80);
834
+ const path = String(request.url ?? "/").slice(0, 200);
835
+ const body =
836
+ `404 ${request.method} ${path}\n\n` +
837
+ "This path is a page, and a page is rendered for a request that accepts\n" +
838
+ `HTML. This request sent \`Accept: ${accept || "(none)"}\`.\n\n` +
839
+ ` curl -H 'Accept: text/html' http://${request.headers.host ?? "localhost"}${path}\n\n` +
840
+ "A browser sends it; `curl` does not. Nothing is wrong with the route.\n";
841
+ response.statusCode = 404;
842
+ response.setHeader("content-type", "text/plain; charset=utf-8");
843
+ response.end(body);
713
844
  }
714
845
 
715
846
  /** The name of the environment variable that turns every finding back on. */