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

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
@@ -56,7 +60,6 @@ import {
56
60
  refreshRuntimeSource,
57
61
  } from "./internal/refresh.js";
58
62
  import {
59
- ACTION_HEADER,
60
63
  RSC_MANIFEST_ENV,
61
64
  actionReferenceSource,
62
65
  actionsModuleSource,
@@ -75,8 +78,10 @@ import {
75
78
  serverModuleSource,
76
79
  } from "./internal/routes.js";
77
80
  import { TransformService, isFlowModule } from "@uniflowed/host/transform";
81
+ import { createChannelMiddleware } from "./internal/diagnostics.js";
82
+ import { devtoolsPreamble } from "./internal/devtools.js";
78
83
  import { send, toRequest } from "./internal/http.js";
79
- import { withRequest } from "./internal/serve.js";
84
+ import { beginRequest } from "./internal/serve.js";
80
85
 
81
86
  /** A resolved virtual id: Vite's convention is a leading NUL byte. */
82
87
  const resolved = (id) => `\0${id}`;
@@ -118,19 +123,26 @@ export default function uniflowed(options = {}) {
118
123
  const appEntry = app.router?.entry ?? ufConfig.build?.entries?.[0] ?? "app.js";
119
124
  const markdown = app.builtins?.markdown ?? {};
120
125
  const builtins = app.builtins ?? {};
126
+ // On unless the project says otherwise, and read as `!== false` rather than
127
+ // `=== true` because that is what "on by default" means for a field almost
128
+ // no `uf.config.js` will mention. It only ever reaches the *development*
129
+ // client entry; see `flowPlugin`'s `load`. ubugeeei-prod/uf#516.
130
+ const strictMode = app.react?.strictMode !== false;
121
131
 
122
132
  return [
123
- flowPlugin({ routerRoot, appEntry, command: options.command }),
133
+ flowPlugin({ routerRoot, appEntry, strictMode, command: options.command }),
124
134
  mdxPlugin(markdown),
125
135
  assetPlugin({
126
136
  images: builtins.images ?? {},
127
137
  fonts: builtins.fonts ?? {},
138
+ icons: builtins.icons ?? {},
139
+ og: builtins.og ?? {},
128
140
  command: options.command,
129
141
  }),
130
142
  ];
131
143
  }
132
144
 
133
- function flowPlugin({ routerRoot, appEntry, command }) {
145
+ function flowPlugin({ routerRoot, appEntry, strictMode, command }) {
134
146
  let root = process.cwd();
135
147
  let isProduction = false;
136
148
  let base = "/";
@@ -195,7 +207,15 @@ function flowPlugin({ routerRoot, appEntry, command }) {
195
207
  if (server == null) {
196
208
  emit("rsc-split", { pages: kept.size, routes: table.routes.length });
197
209
  }
198
- return routesModuleSource(table, { shipsPage: (route) => kept.has(route) });
210
+ // `relativeTo` only here, and never for the server's copy below. Every
211
+ // `import()` in this table becomes a chunk URL, but `file` is a string and
212
+ // survives the build — so the browser's table was shipping the absolute
213
+ // path of every page on the machine that built the site, to every visitor.
214
+ // The server's table is read where those files are and keeps them.
215
+ return routesModuleSource(table, {
216
+ shipsPage: (route) => kept.has(route),
217
+ relativeTo: root,
218
+ });
199
219
  };
200
220
 
201
221
  /**
@@ -293,7 +313,12 @@ function flowPlugin({ routerRoot, appEntry, command }) {
293
313
  if (isSsr(this, loadOptions)) return routesModuleSource(table);
294
314
  return clientRoutesModule(table);
295
315
  }
296
- if (id === resolved(VIRTUAL.client)) return clientModuleSource(entryPath);
316
+ // Strict Mode belongs to the client entry and to development only: a
317
+ // build passes `false`, so the generated module is the one that existed
318
+ // before #516 and a visitor's browser renders once.
319
+ if (id === resolved(VIRTUAL.client)) {
320
+ return clientModuleSource(entryPath, { strictMode: strictMode && !isProduction });
321
+ }
297
322
  if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
298
323
  // Only `virtual:uf/server` imports this, so it is only ever asked for in
299
324
  // the server environment — but the table it carries is every callable
@@ -392,9 +417,26 @@ function flowPlugin({ routerRoot, appEntry, command }) {
392
417
  }
393
418
  },
394
419
 
420
+ // The two scripts a development document loads before its own, and
421
+ // nothing at all in a build — which is the whole of "the hook is out of a
422
+ // production build" (ubugeeei-prod/uf#503) and of "production does not run
423
+ // under Strict Mode" (#516, whose flag is generated into
424
+ // `virtual:uf/client` rather than injected here).
425
+ //
426
+ // DevTools first, and as a *classic* script rather than a module: React
427
+ // registers itself with `__REACT_DEVTOOLS_GLOBAL_HOOK__` while `react-dom`
428
+ // is evaluated and never again, so the hook has to exist before any module
429
+ // runs. A classic inline script runs while the parser is on it; a module
430
+ // waits for the document. `internal/devtools.js` has the rest of the
431
+ // argument, and the three conditions DevTools needs.
395
432
  transformIndexHtml() {
396
433
  if (isProduction) return [];
397
434
  return [
435
+ {
436
+ tag: "script",
437
+ children: devtoolsPreamble(),
438
+ injectTo: "head-prepend",
439
+ },
398
440
  {
399
441
  tag: "script",
400
442
  attrs: { type: "module" },
@@ -462,107 +504,181 @@ function flowPlugin({ routerRoot, appEntry, command }) {
462
504
  }
463
505
 
464
506
  // After Vite's own middlewares, so `/@vite/client`, `/@id/...` and
465
- // static files are served first and only a document request reaches
466
- // the renderer.
507
+ // static files are served first and only what Vite declined reaches uf.
508
+ //
509
+ // # One renderer
510
+ //
511
+ // This is the only middleware that renders a document under `uf dev`,
512
+ // and that is worth stating because there were two. `driver.js` added a
513
+ // second one after `createServer` had returned, which put it *later* in
514
+ // the connect stack than this one — so for every request this one
515
+ // claimed, this one decided, and the other was reached only for what
516
+ // this one declined. Route-handler dispatch was in the other. A
517
+ // `GET /feed` from a browser is `Accept: text/html` with no extension,
518
+ // so it looked like a document, so `app/feed/_uf.route.js` was never
519
+ // asked and the reader got the route table's page — or the not-found
520
+ // page — for a path that had a handler. See ubugeeei-prod/uf#349.
521
+ //
522
+ // Two renderers is also how the two came to disagree about the render
523
+ // result's `headers`: this one wrote them and the driver's did not, so a
524
+ // loader calling `redirect()` answered a browser with a `307` carrying
525
+ // no `Location` and a meta-refresh body — a redirect that works when you
526
+ // deploy it and not while you are writing it. See
527
+ // ubugeeei-prod/uf#338.
528
+ //
529
+ // So the driver's middleware is gone and everything it did is here: it
530
+ // claims every request, runs the guard, the action endpoint and the
531
+ // dispatcher for every method, and hands back to Vite's chain what none
532
+ // of them answered.
533
+ //
534
+ // # What is still not `createFetchHandler`
535
+ //
536
+ // One middleware rather than two, and still not the function every
537
+ // deployment runs. It cannot be: `transformIndexHtml` takes a whole
538
+ // document, so the render has to be collected here rather than streamed
539
+ // (ubugeeei-prod/uf#374), and a request nothing claimed has to go back
540
+ // to Vite's chain rather than become a 404 — neither of which a handler
541
+ // that always answers with a `Response` can do.
542
+ //
543
+ // What that still costs, written down so the next reader does not have
544
+ // to find it: `createFetchHandler` renders inside a cache scope even
545
+ // with no cache configured, so a component calling `cacheLife` states a
546
+ // lifetime nobody honours; here there is no scope, so the same component
547
+ // throws under `uf dev` and renders under `uf start`. Closing that means
548
+ // the generated server entry handing the host a scope the way it already
549
+ // hands it `beginRequest` — see `serverModuleSource` in
550
+ // `./internal/routes.js` — and it is the next thing to remove from this
551
+ // list rather than something this middleware can decide on its own.
467
552
  return () => {
553
+ // The browser's own reporting channel, mounted above the application
554
+ // so that a report never reaches a project's `_uf.middleware.js` or
555
+ // its route table. See `internal/diagnostics.js`.
556
+ devServer.middlewares.use(
557
+ createChannelMiddleware((diagnostic) => emit("diagnostic", diagnostic)),
558
+ );
559
+
468
560
  devServer.middlewares.use(async (request, response, next) => {
469
- // Two kinds of request reach uf here, and the second one is why this
470
- // is not `wantsDocument` alone: a server action is a `POST` carrying
471
- // `uf-action`, which every gate below the renderer would refuse.
472
- // `driver.js` claims every request and can afford to decide later;
473
- // this middleware is mounted behind Vite's own and has to say up
474
- // front which ones are uf's.
475
- const document = wantsDocument(request);
476
- if (!document && !isActionCall(request)) return next();
561
+ // `request.url` and not `originalUrl`, which is the URL Vite's base
562
+ // middleware has already stripped the base from — and the route
563
+ // table's paths have no base in them either.
564
+ const url = request.url ?? "/";
565
+ // Declared out here so the catch below can still settle: a request
566
+ // that failed is a request that happened, and a middleware that
567
+ // logged its arrival is owed its callback either way.
568
+ let lifecycle = null;
477
569
  try {
478
- const url = request.url ?? "/";
479
570
  const entry = await importServerEntry(devServer);
480
571
  const asRequest = await toRequest(request, devServer.config);
481
572
 
482
- // One request, owned here and settled once the document has been
483
- // written — the same lifecycle `driver.js` gives `uf dev` and
484
- // `internal/serve.js` gives `uf preview` and `uf start`. A project
485
- // driving Vite itself must not get a different answer about when
486
- // `after()` runs than the same project run through `uf dev`; see
487
- // `internal/serve.js` and ubugeeei-prod/uf#389.
488
- //
489
- // Only requests that look like a document reach here, so unlike
490
- // `driver.js` there is no path where uf hands the response back to
491
- // Vite's chain: what is below either writes it or throws.
492
- await withRequest(entry, asRequest, async () => {
493
- // Before anything answers: a middleware guards a subtree, and a
494
- // page rendered while the guard on it had not run is the whole of
495
- // ubugeeei-prod/uf#260. `driver.js` makes the same call, for
496
- // every method.
573
+ // The request begins here and ends when the response has been
574
+ // written, which is what `after()` promises and what `uf preview`,
575
+ // `uf start` and a compiled binary all do too — a middleware that
576
+ // logs a response's status has to mean the same thing in
577
+ // development as in production. See `internal/serve.js` and
578
+ // ubugeeei-prod/uf#389.
579
+ lifecycle = await beginRequest(entry, asRequest);
580
+ const answered = await lifecycle.run(async () => {
581
+ // Before anything answers: a middleware guards a subtree, so it
582
+ // has to run for a page, for a route handler, and for a path
583
+ // under it that matches neither. Running it inside the
584
+ // dispatcher and again inside the renderer would have left
585
+ // `/dashboard/typo` unguarded and run it twice for a path that
586
+ // is both. See ubugeeei-prod/uf#260.
497
587
  const guarded = await entry.runMiddleware(asRequest);
498
588
  if (guarded != null) {
499
589
  await send(response, guarded);
500
- return;
590
+ return true;
501
591
  }
502
592
 
503
593
  // Then a server action, below the guard and above the handlers.
504
- // It declines anything that carries no action id, so the two
505
- // lines cost a document request one `headers.get`; and it never
506
- // declines one that does, so an action call cannot reach a route
507
- // handler that happens to share the URL it was posted to. The
508
- // same two lines are in `driver.js`, in `fetch.js` for every
509
- // deploy adapter, and in `standalone.js`.
594
+ // It declines every request that carries no action id, so this
595
+ // costs an ordinary request one `headers.get`; and it answers
596
+ // every request that carries one, refusals included, so an
597
+ // action can never fall through to a route handler that happens
598
+ // to sit at the URL it was posted to. The same two lines are in
599
+ // `@uniflowed/server`'s `fetch.js`, which is what every
600
+ // deployment runs.
510
601
  const acted = await entry.callAction(asRequest);
511
602
  if (acted != null) {
512
603
  await send(response, acted);
513
- return;
604
+ return true;
514
605
  }
515
606
 
516
- // Then the route handlers, above the renderer and for the same
517
- // reason `driver.js` puts them there: a path that answers a
518
- // request is not a document, whatever the client said it would
519
- // accept. `curl /api/thing` and a `<form action>` navigation both
520
- // send `Accept: text/html`, and both want the handler's answer.
521
- //
522
- // This step is not a duplicate of the dispatcher in `driver.js`,
523
- // it is the only one that can run: this middleware is mounted by
524
- // `configureServer`, which Vite calls while it is building the
525
- // server, and `uf dev` adds its own after `createServer` has
526
- // returned — so for every request this one claims, it is the one
527
- // that decides. Without it a route handler under `uf dev` was
528
- // reachable only by a client that asked for something other than
529
- // HTML, and answered the 404 page to everyone else.
607
+ // Then the route handlers, above the renderer and for every
608
+ // method: a path that answers a request is not a document,
609
+ // whatever the client said it would accept. `curl /api/thing`
610
+ // and a `<form action>` navigation both send `Accept:
611
+ // text/html`, and both want the handler's answer — which is the
612
+ // whole of ubugeeei-prod/uf#349.
530
613
  const handled = await entry.dispatch(asRequest);
531
614
  if (handled != null) {
532
615
  await send(response, handled);
533
- return;
616
+ return true;
534
617
  }
535
618
 
536
- // A `POST` this middleware claimed because it named an action,
537
- // that the endpoint then declined and no handler answered. It
538
- // cannot happen — the endpoint answers every request carrying an
539
- // id, including every refusal — and a page cannot answer a
540
- // `POST` anyway, so the honest end is a 404 rather than a
541
- // rendered document with a 200.
542
- if (!document) {
543
- response.statusCode = 404;
544
- response.end();
545
- return;
546
- }
619
+ // Only a navigation reaches the renderer. A page cannot answer a
620
+ // `POST`, and letting one try would turn a missing handler into
621
+ // a rendered page with a 200 rather than a 404.
622
+ //
623
+ // `wantsDocument` is stricter than the production handler, which
624
+ // renders anything a static file did not answer, and the
625
+ // difference is Vite's chain: `/@id/…`, `/node_modules/…` and
626
+ // any path with an extension belong to the module server, and a
627
+ // request one of those declined has to go back to it rather than
628
+ // become a rendered 404 page. What it costs is that
629
+ // `/favicon.svg` on a project that has none is a bare 404 here
630
+ // and the project's own not-found *page* under `uf preview` and
631
+ // `uf start` — a difference in the body of a 404 for a path that
632
+ // is an asset request in the first place.
633
+ if (!wantsDocument(request)) return false;
547
634
 
548
635
  const result = await entry.render(
549
636
  url,
550
637
  { scripts: [devUrlFor(VIRTUAL.client)], styles: [], preloads: [] },
551
- { onError: (error) => reportRenderError(devServer, url, error) },
638
+ {
639
+ // A boundary that threw after the shell went out.
640
+ // `result.error` cannot carry it — the caller already has the
641
+ // result by then — so the terminal hears about it here or not
642
+ // at all.
643
+ onError: (error) => reportRenderError(devServer, url, error),
644
+ },
552
645
  );
553
646
  if (result.error != null) reportRenderError(devServer, url, result.error);
554
- // Collected rather than piped, for the reason `driver.js` gives at
555
- // step 4: `transformIndexHtml` is a whole-document hook.
647
+ // Collected rather than piped: `transformIndexHtml` is a
648
+ // whole-document hook, so there is no first byte to send until it
649
+ // has run. `uf start` and `uf preview` stream — see
650
+ // `internal/serve.js` — and that is a property of the development
651
+ // server rather than of the renderer. ubugeeei-prod/uf#374.
556
652
  const html = await devServer.transformIndexHtml(url, await result.text());
557
- response.statusCode = result.status;
558
- response.setHeader("Content-Type", "text/html; charset=utf-8");
653
+ response.statusCode = result.status ?? 200;
654
+ // The render's own headers, then the content type over the top:
655
+ // exactly the order `@uniflowed/server`'s `fetch.js` writes them
656
+ // in, so a `Location` from `redirect()` survives here and a
657
+ // render cannot claim to be something other than a document.
559
658
  for (const [name, value] of Object.entries(result.headers ?? {})) {
560
659
  response.setHeader(name, value);
561
660
  }
661
+ response.setHeader("content-type", "text/html; charset=utf-8");
562
662
  response.end(html);
663
+ return true;
563
664
  });
665
+
666
+ if (!answered) {
667
+ // The one path where uf is not the one writing the response: a
668
+ // request nothing claimed goes back to Vite's chain. The guard
669
+ // has still run and may have deferred work, so `close` — the
670
+ // socket saying the response is over, however it ended — is the
671
+ // only honest signal left that the bytes are out.
672
+ response.once("close", lifecycle.settle);
673
+ next();
674
+ return;
675
+ }
676
+ await lifecycle.settle();
564
677
  } catch (error) {
565
- devServer.ssrFixStacktrace(error);
678
+ if (lifecycle != null) await lifecycle.settle();
679
+ // Map the stack back onto the Flow source before it reaches the
680
+ // overlay.
681
+ if (error instanceof Error) devServer.ssrFixStacktrace(error);
566
682
  next(error);
567
683
  }
568
684
  });
@@ -614,20 +730,6 @@ async function importServerEntry(devServer) {
614
730
  return devServer.ssrLoadModule(VIRTUAL.server);
615
731
  }
616
732
 
617
- /**
618
- * Whether this request is a server action call.
619
- *
620
- * The header alone, and never the path: an action is posted to the page's own
621
- * URL, so there is nothing about the URL to recognise. Deliberately *not* the
622
- * whole set of checks the endpoint makes — the origin, the content type, the
623
- * body — because those decide whether the call is *allowed*, and a call that
624
- * is not allowed must be refused by the endpoint rather than handed on to
625
- * Vite's chain as though nobody had claimed it.
626
- */
627
- function isActionCall(request) {
628
- return request.method === "POST" && request.headers[ACTION_HEADER] != null;
629
- }
630
-
631
733
  function wantsDocument(request) {
632
734
  if (request.method !== "GET" && request.method !== "HEAD") return false;
633
735
  const url = request.url ?? "/";
@@ -647,14 +749,25 @@ const ALL_DIAGNOSTICS = "UF_REACT_COMPILER_DIAGNOSTICS";
647
749
  * Report what the React Compiler said about one module.
648
750
  *
649
751
  * Every finding used to be printed as `a function: <message>` — no file, no
650
- * line, no column, and the fallback string doing all the work because the
651
- * compiler names an inner function about as often as not. The transform hook
752
+ * line, no column, and the fallback string doing all the work, because
753
+ * `diagnostic.function` was read out of a field only a *success* event carries
754
+ * and so was null for every finding there has ever been (#371), not because
755
+ * the compiler was reporting on anonymous inner functions. The transform hook
652
756
  * knows the module and the compiler gives a position for most findings, so
653
757
  * both go into the message: Vite prints a plugin warning's `message` and
654
758
  * nothing else, so a location that is not in the string is a location the
655
759
  * reader never sees. `id` and `loc` go along for anything reading the log
656
760
  * object rather than the line. See ubugeeei-prod/uf#307.
657
761
  *
762
+ * `(in Form)` is real now: `uf transform` recovers the name from the tree it
763
+ * compiled, and leaves it off the diagnostic when the function genuinely has
764
+ * no name to give.
765
+ *
766
+ * The position is real too, and is the expression the compiler objected to
767
+ * rather than the function containing it, so the two halves of the line say
768
+ * different things — `packages/hooks/dom.js:195:7: This value cannot be
769
+ * modified (in useLongPress)` locates the write and names the hook to look in.
770
+ *
658
771
  * A dependency's findings are held back. A React Compiler bailout inside
659
772
  * `@uniflowed/form` is not something the person running the build can fix, and
660
773
  * a channel carrying forty of them on every build is a channel people stop
@@ -677,9 +790,20 @@ function reportDiagnostics(context, { id, root, diagnostics, environment, report
677
790
  const mine = isProjectModule(root, id) || process.env[ALL_DIAGNOSTICS] === "all";
678
791
  for (const diagnostic of diagnostics) {
679
792
  // Everything a reader would be shown, so two findings that would print as
680
- // the same line collapse into one. The compiler reports "Cannot access refs
681
- // during render" once per pass that noticed it — three times for one `ref`
682
- // — and three identical lines are not three things to fix.
793
+ // the same line collapse into one.
794
+ //
795
+ // This used to collapse far more than that, and the note here used to say
796
+ // the compiler reports "Cannot access refs during render" once per pass
797
+ // that noticed it — three times for one `ref`. That was the wrong reading
798
+ // of the evidence. The position in a finding was the position of the
799
+ // *function* it was found in, so every finding in one function shared a
800
+ // line and a column and any two with the same message were, to this
801
+ // signature, the same finding. They were not: over this repository's own
802
+ // packages it collapsed 173 findings into 119 lines, and the five that
803
+ // became one line in `DatePickerInput` are five different reads of a ref
804
+ // on five different lines. `uf transform` now reports where the compiler
805
+ // actually objected, so the signature separates them, and what it still
806
+ // collapses is a genuine repeat of one site.
683
807
  const signature = `${diagnostic.kind}\0${diagnostic.line}\0${diagnostic.column}\0${diagnostic.message}`;
684
808
  if (ledger.signatures.has(signature)) continue;
685
809
  ledger.signatures.add(signature);