@uniflowed/vite 0.0.0-alpha.15 → 0.0.0-alpha.17

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
@@ -45,6 +45,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
45
45
  import path from "node:path";
46
46
  import { pathToFileURL } from "node:url";
47
47
 
48
+ import { COMPILE_ASSETS_ID, compileAssetsPlugin } from "./internal/compile-assets.js";
48
49
  import { emit, errorEvent, eventLogger } from "./internal/events.js";
49
50
  import { loadUfConfig, projectConfig } from "./internal/config.js";
50
51
  import { send, toRequest } from "./internal/http.js";
@@ -52,6 +53,7 @@ import { withProjectConfig } from "./merge.js";
52
53
  import { VIRTUAL, scanRoutes } from "./internal/routes.js";
53
54
  import {
54
55
  assetsFromManifest,
56
+ createPrerenderGate,
55
57
  createServeHandler,
56
58
  loadBuild,
57
59
  nodeListener,
@@ -343,6 +345,16 @@ function watchEnvFiles(server) {
343
345
  * The static middleware still runs first, and that is deliberate rather than
344
346
  * incidental; see `internal/serve.js` for why `uf start` orders itself the
345
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.
346
358
  */
347
359
  async function preview() {
348
360
  const { preview: startPreview } = await import("vite");
@@ -363,25 +375,53 @@ async function preview() {
363
375
  serverDir: path.join(".uf", "build", "server"),
364
376
  });
365
377
 
366
- const server = await startPreview({ ...inline, appType: "custom" });
367
- if (build != null) {
368
- const handle = createServeHandler({ ...build, cache: config.app?.rendering?.cache });
369
- server.middlewares.use(async (request, response, next) => {
370
- try {
371
- const asRequest = await toRequest(request, server.config);
372
- // The same lifecycle `uf start` gets from `nodeListener`, spelled out
373
- // because this door is Vite's connect chain rather than a bare
374
- // `node:http` server: the whole request runs inside it, and it settles
375
- // once `send` has returned. A preview whose `after()` fired at a
376
- // different moment from the production server's would be a preview that
377
- // is checked and believed and wrong.
378
- await withRequest(build.entry, asRequest, async () => {
379
- await send(response, await handle(asRequest));
380
- });
381
- } catch (error) {
382
- next(error);
383
- }
384
- });
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));
385
425
  }
386
426
 
387
427
  const urls = server.resolvedUrls ?? { local: [], network: [] };
@@ -884,17 +924,19 @@ async function compile() {
884
924
  emit("phase", { name: "standalone" });
885
925
 
886
926
  // The entry is written to disk rather than served as another virtual module:
887
- // 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
888
928
  // file is the version a person can open when a compiled binary misbehaves.
889
929
  const entry = path.join(bundleDir, "entry.js");
890
930
  mkdirSync(bundleDir, { recursive: true });
891
- const specifier = `./${path.relative(bundleDir, assets)}`;
892
- writeFileSync(entry, entrySource(specifier, assetsFromManifest(readManifest(outDir))));
931
+ writeFileSync(
932
+ entry,
933
+ entrySource(path.relative(root, assets), assetsFromManifest(readManifest(outDir))),
934
+ );
893
935
 
894
936
  await vite.build({
895
937
  ...inline,
896
938
  customLogger: eventLogger("warn"),
897
- plugins: [...inline.plugins, nativeAddonGuard()],
939
+ plugins: [...inline.plugins, nativeAddonGuard(), compileAssetsPlugin(assets)],
898
940
  ssr: { ...(inline.ssr ?? {}), noExternal: true },
899
941
  build: {
900
942
  ...inline.build,
@@ -1315,8 +1357,14 @@ export const handler = createLambdaHandler({ handle, beginRequest, staticDir });
1315
1357
  * bytes of `dist/`. The document's script and stylesheet URLs are baked in
1316
1358
  * here because they come from the client manifest, which exists at this moment
1317
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.
1318
1366
  */
1319
- function entrySource(assetsSpecifier, document) {
1367
+ function entrySource(assetsFile, document) {
1320
1368
  // Not `await serve(...)` at the top level. uf parses that now
1321
1369
  // (ubugeeei-prod/uf#204) and this entry is a module, so it would work;
1322
1370
  // `.catch` is the better spelling regardless: a binary that cannot take its
@@ -1324,7 +1372,11 @@ function entrySource(assetsSpecifier, document) {
1324
1372
  // unhandled rejection.
1325
1373
  return `// Generated by \`uf build --compile\`. Not checked in, not edited.
1326
1374
  import { serve } from "@uniflowed/server/standalone";
1327
- 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)};
1328
1380
  import * as app from ${JSON.stringify(VIRTUAL.server)};
1329
1381
 
1330
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";
@@ -129,8 +136,16 @@ export default function uniflowed(options = {}) {
129
136
  // client entry; see `flowPlugin`'s `load`. ubugeeei-prod/uf#516.
130
137
  const strictMode = app.react?.strictMode !== false;
131
138
 
139
+ const accessibility = ufConfig.accessibility ?? {};
140
+
132
141
  return [
133
- flowPlugin({ routerRoot, appEntry, strictMode, command: options.command }),
142
+ flowPlugin({
143
+ routerRoot,
144
+ appEntry,
145
+ strictMode,
146
+ command: options.command,
147
+ accessibility,
148
+ }),
134
149
  mdxPlugin(markdown),
135
150
  assetPlugin({
136
151
  images: builtins.images ?? {},
@@ -142,9 +157,18 @@ export default function uniflowed(options = {}) {
142
157
  ];
143
158
  }
144
159
 
145
- function flowPlugin({ routerRoot, appEntry, strictMode, command }) {
160
+ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }) {
146
161
  let root = process.cwd();
147
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;
148
172
  let base = "/";
149
173
  let appRoot = "";
150
174
  let entryPath = "";
@@ -254,6 +278,10 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command }) {
254
278
  config(userConfig, env) {
255
279
  const projectRoot = path.resolve(userConfig.root ?? process.cwd());
256
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);
257
285
  return {
258
286
  // uf serves HTML itself; there is no index.html to fall back to.
259
287
  appType: "custom",
@@ -268,6 +296,16 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command }) {
268
296
  "react/compiler-runtime",
269
297
  "react-dom",
270
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"] : []),
271
309
  ],
272
310
  // uf's packages ship Flow. The dependency optimiser pre-bundles
273
311
  // with a JavaScript parser and would reject every one of them.
@@ -295,6 +333,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command }) {
295
333
 
296
334
  resolveId(id) {
297
335
  if (id === RUNTIME_PUBLIC_PATH) return RUNTIME_RESOLVED_ID;
336
+ if (id === AUDIT_PUBLIC_PATH) return AUDIT_RESOLVED_ID;
298
337
  if (VIRTUAL_IDS.has(id)) return resolved(id);
299
338
  // A module's own stylesheet, which `transform` below asked for by
300
339
  // importing this id. Returning it unchanged marks it resolved without
@@ -305,6 +344,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command }) {
305
344
 
306
345
  load(id, loadOptions) {
307
346
  if (id === RUNTIME_RESOLVED_ID) return refreshRuntimeSource();
347
+ if (id === AUDIT_RESOLVED_ID) return auditRuntimeSource(accessibility?.axe);
308
348
  if (id === resolved(VIRTUAL.routes)) {
309
349
  const table = scanRoutes(appRoot);
310
350
  // The server renders every route, so the server's table is the whole
@@ -431,7 +471,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command }) {
431
471
  // argument, and the three conditions DevTools needs.
432
472
  transformIndexHtml() {
433
473
  if (isProduction) return [];
434
- return [
474
+ const tags = [
435
475
  {
436
476
  tag: "script",
437
477
  children: devtoolsPreamble(),
@@ -444,6 +484,13 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command }) {
444
484
  injectTo: "head-prepend",
445
485
  },
446
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;
447
494
  },
448
495
 
449
496
  configureServer(devServer) {
@@ -620,7 +667,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command }) {
620
667
  // `POST`, and letting one try would turn a missing handler into
621
668
  // a rendered page with a 200 rather than a 404.
622
669
  //
623
- // `wantsDocument` is stricter than the production handler, which
670
+ // `notADocumentBecause` is stricter than the production handler, which
624
671
  // renders anything a static file did not answer, and the
625
672
  // difference is Vite's chain: `/@id/…`, `/node_modules/…` and
626
673
  // any path with an extension belong to the module server, and a
@@ -630,7 +677,15 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command }) {
630
677
  // and the project's own not-found *page* under `uf preview` and
631
678
  // `uf start` — a difference in the body of a 404 for a path that
632
679
  // is an asset request in the first place.
633
- 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;
634
689
 
635
690
  const result = await entry.render(
636
691
  url,
@@ -730,16 +785,62 @@ async function importServerEntry(devServer) {
730
785
  return devServer.ssrLoadModule(VIRTUAL.server);
731
786
  }
732
787
 
733
- function wantsDocument(request) {
734
- 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";
735
804
  const url = request.url ?? "/";
736
- if (url.startsWith("/@") || url.startsWith("/node_modules/")) return false;
737
- const accept = request.headers.accept ?? "";
738
- if (!accept.includes("text/html")) return false;
805
+ if (url.startsWith("/@") || url.startsWith("/node_modules/")) return "module-server";
739
806
  const pathname = url.split("?")[0];
740
807
  // A request for a file — `/favicon.svg`, `/assets/x.js` — that no static
741
808
  // middleware answered is a 404, not a page.
742
- 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);
743
844
  }
744
845
 
745
846
  /** The name of the environment variable that turns every finding back on. */
@@ -0,0 +1,234 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: this module is served to the browser as text, by
4
+ // `./a11y.js`'s virtual module, before any transform has run on it.
5
+ //
6
+ // The accessibility audit `uf dev` runs against the page on screen.
7
+ //
8
+ // The second half of ubugeeei-prod/uf#511, and the half that is worth more: a
9
+ // violation found while the component is being written is a violation somebody
10
+ // can fix in the same minute, and one found in CI is a pull request that goes
11
+ // red an hour later. Same engine and same rule set as
12
+ // `expect(el).toHaveNoAxeViolations()` — `uf.config.js`'s `accessibility.axe`
13
+ // is read once and given to both — so the two cannot disagree about what
14
+ // "accessible" means.
15
+ //
16
+ // # It reports to the terminal rather than drawing an overlay
17
+ //
18
+ // `internal/diagnostics.js` records the decision and the reason, from #583: an
19
+ // overlay is a rectangle drawn over the page, and a rectangle drawn over the
20
+ // page is the last thing an *accessibility* report should be. It would cover
21
+ // the markup it is complaining about, it would change the layout the audit
22
+ // just measured, and it would need to be accessible itself. So a finding goes
23
+ // down the channel that already exists, `POST /__uf/diagnostic`, and comes out
24
+ // in the terminal beside every other thing `uf dev` has to say.
25
+ //
26
+ // # When it runs
27
+ //
28
+ // Once when the page has settled, and again after the DOM stops changing.
29
+ // Not on every mutation: a React render is hundreds of them, and axe walks the
30
+ // whole subtree each time. The debounce is what makes it a background job
31
+ // rather than a stutter, and the audit is skipped outright while the tab is
32
+ // hidden — a page nobody is looking at has nothing to report about.
33
+ //
34
+ // Development only. `./a11y.js` injects this module from `transformIndexHtml`,
35
+ // which is guarded on `isProduction`, and the module is not reachable from any
36
+ // entry a build follows.
37
+
38
+ import axe from "axe-core";
39
+
40
+ /**
41
+ * Whether the engine is mid-run, for every audit in the process.
42
+ *
43
+ * Module scope because `axe` is a singleton and holds a single-run lock of its
44
+ * own: asking it for a second run while one is going answers "Axe is already
45
+ * running" rather than a result, and a per-page flag would not see the other
46
+ * page's run. One engine, one lock.
47
+ */
48
+ let engineBusy = false;
49
+
50
+ /** Most violations one report names; the rest are counted. */
51
+ const MAX_VIOLATIONS_REPORTED = 8;
52
+
53
+ /** Longest excerpt of an element's markup a line quotes. */
54
+ const MAX_NODE_CHARS = 100;
55
+
56
+ /**
57
+ * Start auditing.
58
+ *
59
+ * `options` is generated by `./a11y.js` and carries the project's rule set,
60
+ * the endpoint to report to, and nothing a page could have chosen: everything
61
+ * here is decided by `uf.config.js` on the server before the module exists.
62
+ */
63
+ export function start(options) {
64
+ const endpoint = options.endpoint;
65
+ // How long the DOM has to stop moving before an audit runs. Decided by
66
+ // `./a11y.js` and written into this module's last line rather than kept here
67
+ // as a constant, so there is one number rather than two — and so a test can
68
+ // drive the loop without waiting three quarters of a second per assertion.
69
+ const settleMs = options.settleMs;
70
+ const runOptions = axeOptions(options.axe ?? {});
71
+ const floor = options.axe?.minImpact ?? null;
72
+ let timer = null;
73
+ // Whether `stop` has been called. A run already in flight cannot be
74
+ // cancelled, but nothing after it should act as though the page is still
75
+ // being watched.
76
+ let stopped = false;
77
+ // What the last report said, so a re-render that changes nothing the audit
78
+ // cares about does not print the same block again. A page under active
79
+ // editing re-renders constantly; a terminal that repeats itself every time
80
+ // is a terminal nobody reads.
81
+ let reported = "";
82
+
83
+ const audit = async () => {
84
+ if (stopped || document.hidden) return;
85
+ if (engineBusy) {
86
+ // Not a run to skip — a run to come back for. The timer that brought us
87
+ // here cleared itself before calling, so returning without scheduling
88
+ // again loses the mutation for good: nothing is left pending, and the
89
+ // DOM it settled into never gets audited. Rescheduling rather than
90
+ // queueing a flag also means the wait is another settle, which is right
91
+ // — the tree may still be moving — and it is the same answer whether the
92
+ // engine is busy for this page or for another one.
93
+ schedule();
94
+ return;
95
+ }
96
+ engineBusy = true;
97
+ try {
98
+ const results = await axe.run(document, runOptions);
99
+ // The engine takes as long as it takes, and `stop` can land in the
100
+ // middle of it. A caller that has said it is done is not expecting one
101
+ // more report a second later.
102
+ if (stopped) return;
103
+ const violations = (results?.violations ?? []).filter((violation) =>
104
+ atOrAbove(violation.impact, floor),
105
+ );
106
+ const summary = violations.map((violation) => violation.id).join(",");
107
+ if (summary === reported) return;
108
+ reported = summary;
109
+ if (violations.length > 0) report(endpoint, violations);
110
+ } catch (error) {
111
+ // An audit that throws is a bug in this file or in the engine, and it
112
+ // must not take the page with it: `uf dev` is for developing the
113
+ // application, not this. Reported once, at `info`, and then the run is
114
+ // over — `reported` is left as it was so the next settle tries again.
115
+ report(endpoint, null, error);
116
+ } finally {
117
+ engineBusy = false;
118
+ }
119
+ };
120
+
121
+ const schedule = () => {
122
+ if (timer != null) clearTimeout(timer);
123
+ timer = setTimeout(() => {
124
+ timer = null;
125
+ void audit();
126
+ }, settleMs);
127
+ };
128
+
129
+ const observer = new MutationObserver(schedule);
130
+ observer.observe(document.documentElement, {
131
+ subtree: true,
132
+ childList: true,
133
+ attributes: true,
134
+ });
135
+ document.addEventListener("visibilitychange", () => {
136
+ if (!document.hidden) schedule();
137
+ });
138
+ schedule();
139
+
140
+ // Returned so a caller that owns the page's lifetime can end the loop. The
141
+ // generated call in `./a11y.js` ignores it: a dev server's page is over when
142
+ // the document is, and nothing outlives that.
143
+ return () => {
144
+ stopped = true;
145
+ if (timer != null) clearTimeout(timer);
146
+ observer.disconnect();
147
+ };
148
+ }
149
+
150
+ /** The project's rule set in axe's own vocabulary. */
151
+ function axeOptions(settings) {
152
+ const options = {};
153
+ const tags = settings.tags ?? [];
154
+ if (tags.length > 0) options.runOnly = { type: "tag", values: [...tags] };
155
+ const disabled = settings.disabledRules ?? [];
156
+ if (disabled.length > 0) {
157
+ const rules = {};
158
+ for (const rule of disabled) rules[rule] = { enabled: false };
159
+ options.rules = rules;
160
+ }
161
+ return options;
162
+ }
163
+
164
+ /** Impacts weakest first, so a floor is a comparison rather than a match. */
165
+ const IMPACTS = ["minor", "moderate", "serious", "critical"];
166
+
167
+ /**
168
+ * Whether a violation clears the configured floor.
169
+ *
170
+ * A violation axe could not rate clears every floor: "we do not know how bad
171
+ * this is" is not a reason to hide it.
172
+ */
173
+ function atOrAbove(impact, floor) {
174
+ if (floor == null || impact == null) return true;
175
+ const at = IMPACTS.indexOf(impact);
176
+ return at === -1 || at >= IMPACTS.indexOf(floor);
177
+ }
178
+
179
+ /** One element, short enough to be one line of a terminal. */
180
+ function excerpt(html) {
181
+ const line = String(html ?? "")
182
+ .split("\n")[0]
183
+ .trim();
184
+ return line.length > MAX_NODE_CHARS ? `${line.slice(0, MAX_NODE_CHARS)}…` : line;
185
+ }
186
+
187
+ /**
188
+ * Send one report down the diagnostic channel.
189
+ *
190
+ * `keepalive` and a swallowed failure, because this is telemetry about the
191
+ * page rather than part of it: a dev server that has gone away must not turn
192
+ * into an unhandled rejection in the application being developed.
193
+ */
194
+ function report(endpoint, violations, error) {
195
+ const body =
196
+ violations == null
197
+ ? {
198
+ severity: "info",
199
+ message: `the accessibility audit could not run: ${String(error)}`,
200
+ }
201
+ : {
202
+ severity: "warn",
203
+ message: `${violations.length} accessibility ${
204
+ violations.length === 1 ? "violation" : "violations"
205
+ } on this page`,
206
+ detail: detailLines(violations),
207
+ };
208
+ void fetch(endpoint, {
209
+ method: "POST",
210
+ keepalive: true,
211
+ headers: { "content-type": "application/json" },
212
+ body: JSON.stringify({ ...body, url: window.location.href }),
213
+ }).then(
214
+ () => {},
215
+ () => {},
216
+ );
217
+ }
218
+
219
+ /** One line per rule, then the elements that broke it. */
220
+ function detailLines(violations) {
221
+ const lines = [];
222
+ for (const violation of violations.slice(0, MAX_VIOLATIONS_REPORTED)) {
223
+ const impact = violation.impact == null ? "" : ` (${violation.impact})`;
224
+ lines.push(`${violation.id}${impact} — ${violation.help}`);
225
+ for (const node of (violation.nodes ?? []).slice(0, 2)) {
226
+ lines.push(` ${excerpt(node.html)}`);
227
+ }
228
+ if (violation.helpUrl) lines.push(` ${violation.helpUrl}`);
229
+ }
230
+ if (violations.length > MAX_VIOLATIONS_REPORTED) {
231
+ lines.push(`…and ${violations.length - MAX_VIOLATIONS_REPORTED} more rules`);
232
+ }
233
+ return lines;
234
+ }
@@ -0,0 +1,100 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // Serving `./a11y-runtime.js` to the page `uf dev` renders.
6
+ //
7
+ // The same shape as `./refresh.js`, and deliberately so: a virtual module
8
+ // whose source is a file read from disk, and a tag added to every development
9
+ // document that imports it. Two mechanisms for "a module uf injects into the
10
+ // page" would be two things to keep working.
11
+ //
12
+ // What is different is the gate. Fast Refresh is always there; this is not.
13
+ // axe-core is an *optional* dependency — uf does not make a project that never
14
+ // audits carry a megabyte of rule definitions — so the injection is decided by
15
+ // asking the project's own `node_modules` whether the engine is installed. The
16
+ // question is answered on the server, once, before anything is injected, which
17
+ // is why a project without the engine gets no script, no failed import and no
18
+ // error in a console it did not open.
19
+
20
+ import { createRequire } from "node:module";
21
+ import path from "node:path";
22
+ import { readFileSync } from "node:fs";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ import { DIAGNOSTIC_ENDPOINT } from "./diagnostics.js";
26
+
27
+ /** Public URL the audit runtime is served from. */
28
+ export const AUDIT_PUBLIC_PATH = "/@uf-a11y";
29
+
30
+ /** The resolved id Vite hands back for it. */
31
+ export const AUDIT_RESOLVED_ID = "\0uf:a11y-audit";
32
+
33
+ const RUNTIME_SOURCE_PATH = fileURLToPath(new URL("./a11y-runtime.js", import.meta.url));
34
+
35
+ /**
36
+ * How long the DOM has to stop moving before an audit runs, in milliseconds.
37
+ *
38
+ * A React render is hundreds of mutations and axe walks the whole subtree, so
39
+ * without a window this would be a stutter rather than a background job. Long
40
+ * enough to sit out a render and short enough that somebody who has just saved
41
+ * a file hears about it while they are still looking at the page.
42
+ */
43
+ export const SETTLE_MS = 750;
44
+
45
+ /**
46
+ * Whether the project has axe-core.
47
+ *
48
+ * Resolved from the project root rather than from this package, because the
49
+ * dependency is the *project's* — `@uniflowed/vite` declares it as an optional
50
+ * peer, so it is installed beside the application and not beside the plugin.
51
+ *
52
+ * A failure to resolve is the ordinary answer, not an error: "no engine" is
53
+ * what most projects will say, and it is the reason this function exists.
54
+ */
55
+ export function auditAvailable(root) {
56
+ try {
57
+ createRequire(path.join(root, "package.json")).resolve("axe-core");
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The audit module's source, with the project's settings written into its
66
+ * last line.
67
+ *
68
+ * Generated rather than passed at runtime because the module is a *module*:
69
+ * there is nowhere for a caller to hand it arguments, and a global would be a
70
+ * second name to agree on. `JSON.stringify` of a value uf built from its own
71
+ * config — never from anything a page said — is what goes in.
72
+ */
73
+ export function auditRuntimeSource(settings) {
74
+ const options = {
75
+ endpoint: DIAGNOSTIC_ENDPOINT,
76
+ settleMs: SETTLE_MS,
77
+ axe: {
78
+ tags: settings?.tags ?? [],
79
+ disabledRules: settings?.disabledRules ?? [],
80
+ minImpact: settings?.minImpact ?? null,
81
+ },
82
+ };
83
+ return `${readFileSync(RUNTIME_SOURCE_PATH, "utf8")}\nstart(${JSON.stringify(options)});\n`;
84
+ }
85
+
86
+ /**
87
+ * The tag that loads it, or `null` when this project has no engine.
88
+ *
89
+ * `injectTo: "body"` rather than the head: the audit reads the rendered tree,
90
+ * so there is nothing for it to do until there is one, and a script in the
91
+ * head would only sit through the same wait with the parser stopped behind it.
92
+ */
93
+ export function auditTag(base, available) {
94
+ if (!available) return null;
95
+ return {
96
+ tag: "script",
97
+ attrs: { type: "module", src: `${base}${AUDIT_PUBLIC_PATH.slice(1)}` },
98
+ injectTo: "body",
99
+ };
100
+ }
@@ -0,0 +1,67 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // The embedded copy of `dist/`, handed to the bundler as a virtual module.
6
+ //
7
+ // `uf build --compile` puts everything `uf build` wrote inside the binary, and
8
+ // the way it travels there is one generated module: `uf_bundle::embed` writes
9
+ // `export const assets = JSON.parse("…")` into `.uf/build/compile/assets.js`,
10
+ // where the string is base64 of every file in the output directory. For the
11
+ // docs site that is eight megabytes; for a project with images it is tens.
12
+ //
13
+ // # Why it does not enter the graph as the file it is
14
+ //
15
+ // Because it is uf's own data, and the module graph is where uf's *source*
16
+ // transform is. `crates/uf_transform` refuses a source over
17
+ // `MAX_SOURCE_BYTES` — 8 MiB, a ceiling that bounds the parser against a file
18
+ // nobody meant to compile — and `.uf/build/compile/assets.js` is a file
19
+ // nobody wrote at all: its size is the size of the *build output*, which has
20
+ // no relationship to the size of any source file and grows every time a page
21
+ // is added. A site that crossed the line failed with
22
+ //
23
+ // TransformError: source is 8442536 bytes, over the 8388608 byte ceiling
24
+ // [plugin uf:flow] docs/.uf/build/compile/assets.js
25
+ //
26
+ // which names a file the project does not own, for a limit it did not break,
27
+ // at the end of a build that had already succeeded.
28
+ //
29
+ // So the payload enters the graph the way a build tool's own modules do,
30
+ // under a NUL-prefixed id. `@uniflowed/host/transform`'s `isFlowModule`
31
+ // declines those by name — "a build tool synthesises modules of its own", the
32
+ // first sentence of its contract — so the bytes go from disk to the bundler
33
+ // without being parsed as Flow on the way. Raising the ceiling instead would
34
+ // have weakened the one thing it is for, on every real source file, to make
35
+ // room for a file that is not source.
36
+ //
37
+ // The file on disk is still written and still what is served, so a person
38
+ // debugging a compiled binary can open the thing that went into it. What
39
+ // changed is only which name the bundler reaches it by.
40
+
41
+ import { readFileSync } from "node:fs";
42
+
43
+ /** What the generated entry imports. */
44
+ export const COMPILE_ASSETS_ID = "virtual:uf/compile-assets";
45
+
46
+ /** The resolved id Vite hands back for it. */
47
+ export const COMPILE_ASSETS_RESOLVED_ID = `\0${COMPILE_ASSETS_ID}`;
48
+
49
+ /**
50
+ * Serve `file` — `uf_bundle::embed`'s output — as [`COMPILE_ASSETS_ID`].
51
+ *
52
+ * Read at `load` rather than when the plugin is made, so a build that fails
53
+ * before it reaches the entry never pays for eight megabytes of string, and
54
+ * so the bytes are the ones on disk at the moment the bundler asked for them
55
+ * rather than at the moment the plugin list was assembled.
56
+ */
57
+ export function compileAssetsPlugin(file) {
58
+ return {
59
+ name: "uf:compile-assets",
60
+ resolveId(id) {
61
+ return id === COMPILE_ASSETS_ID ? COMPILE_ASSETS_RESOLVED_ID : null;
62
+ },
63
+ load(id) {
64
+ return id === COMPILE_ASSETS_RESOLVED_ID ? readFileSync(file, "utf8") : null;
65
+ },
66
+ };
67
+ }
package/internal/serve.js CHANGED
@@ -362,6 +362,36 @@ export function createServeHandler({ entry, assets, distDir, cache }) {
362
362
  };
363
363
  }
364
364
 
365
+ /**
366
+ * Whether a file server may answer this request, or uf has to go first.
367
+ *
368
+ * `@uniflowed/server`'s `prerenderedMayAnswer`, reached the same way
369
+ * `createStaticHandler` is. It exists out here because of the one request
370
+ * `uf preview` may not leave to Vite: `createServeHandler` above is mounted
371
+ * *behind* Vite's static middleware, which is fine for everything except a
372
+ * request carrying the draft cookie. `createStaticHandler` declines a
373
+ * prerendered document for such a request and, under `uf preview`, never sees
374
+ * it — so draft mode appeared to be off there while it worked under `uf dev`
375
+ * and `uf start`. That is ubugeeei-prod/uf#620.
376
+ *
377
+ * `driver.js` asks this in a middleware mounted in *front* of Vite's, and
378
+ * mounts `createServeHandler` behind it for the answer, so a draft request
379
+ * goes through the same handler `uf start` uses and gets the same answer —
380
+ * including its stylesheets and chunks, which that handler still serves off
381
+ * disk. Every other request is untouched and Vite's file middleware runs as
382
+ * before.
383
+ *
384
+ * A gate rather than a second handler, because the caller has a request
385
+ * lifecycle to open and must not open one for a request it is about to hand
386
+ * on.
387
+ */
388
+ export function createPrerenderGate() {
389
+ const ready = deployment().then(({ prerenderedMayAnswer }) => prerenderedMayAnswer);
390
+ return async function prerenderedMayAnswer(cookieHeader) {
391
+ return (await ready)(cookieHeader ?? null);
392
+ };
393
+ }
394
+
365
395
  /**
366
396
  * A `Request`/`Response` handler as a Node request listener.
367
397
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.0.0-alpha.15",
3
+ "version": "0.0.0-alpha.17",
4
4
  "description": "Vite, driven by uf.config.js: every Flow module through `uf transform`, MDX, the file-system router and static rendering as Vite plugins.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,8 +33,8 @@
33
33
  "dependencies": {
34
34
  "@mdx-js/rollup": "^3.1.1",
35
35
  "@shikijs/rehype": "^3.23.0",
36
- "@uniflowed/host": "0.0.0-alpha.15",
37
- "@uniflowed/server": "0.0.0-alpha.15",
36
+ "@uniflowed/host": "0.0.0-alpha.17",
37
+ "@uniflowed/server": "0.0.0-alpha.17",
38
38
  "rehype-slug": "^6.0.0",
39
39
  "remark-frontmatter": "^5.0.0",
40
40
  "remark-gfm": "^4.0.1",
@@ -44,6 +44,12 @@
44
44
  },
45
45
  "peerDependencies": {
46
46
  "react": ">=19",
47
- "react-dom": ">=19"
47
+ "react-dom": ">=19",
48
+ "axe-core": ">=4"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "axe-core": {
52
+ "optional": true
53
+ }
48
54
  }
49
55
  }