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

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
@@ -55,7 +59,16 @@ import {
55
59
  preambleCode,
56
60
  refreshRuntimeSource,
57
61
  } from "./internal/refresh.js";
58
- import { RSC_MANIFEST_ENV, clientRouteFilter, readRscManifest } from "./internal/rsc.js";
62
+ import {
63
+ RSC_MANIFEST_ENV,
64
+ actionReferenceSource,
65
+ actionsModuleSource,
66
+ clientRouteFilter,
67
+ readRscManifest,
68
+ rscManifestKey,
69
+ serverActionModules,
70
+ serverActionTable,
71
+ } from "./internal/rsc.js";
59
72
  import {
60
73
  RESERVED,
61
74
  VIRTUAL,
@@ -65,8 +78,9 @@ import {
65
78
  serverModuleSource,
66
79
  } from "./internal/routes.js";
67
80
  import { TransformService, isFlowModule } from "@uniflowed/host/transform";
81
+ import { createChannelMiddleware } from "./internal/diagnostics.js";
68
82
  import { send, toRequest } from "./internal/http.js";
69
- import { withRequest } from "./internal/serve.js";
83
+ import { beginRequest } from "./internal/serve.js";
70
84
 
71
85
  /** A resolved virtual id: Vite's convention is a leading NUL byte. */
72
86
  const resolved = (id) => `\0${id}`;
@@ -185,7 +199,44 @@ function flowPlugin({ routerRoot, appEntry, command }) {
185
199
  if (server == null) {
186
200
  emit("rsc-split", { pages: kept.size, routes: table.routes.length });
187
201
  }
188
- return routesModuleSource(table, { shipsPage: (route) => kept.has(route) });
202
+ // `relativeTo` only here, and never for the server's copy below. Every
203
+ // `import()` in this table becomes a chunk URL, but `file` is a string and
204
+ // survives the build — so the browser's table was shipping the absolute
205
+ // path of every page on the machine that built the site, to every visitor.
206
+ // The server's table is read where those files are and keeps them.
207
+ return routesModuleSource(table, {
208
+ shipsPage: (route) => kept.has(route),
209
+ relativeTo: root,
210
+ });
211
+ };
212
+
213
+ /**
214
+ * The manifest's two action tables, re-read only when the file changes.
215
+ *
216
+ * `load` runs for every module in the graph and has to ask "is this a
217
+ * `"use server"` module?" about each one, so parsing the manifest per call
218
+ * would put a JSON parse of the whole graph between Vite and every file it
219
+ * opens. Size and modification time are the identity — the same pair
220
+ * `.uf/cache/transform` keys on — and `uf dev` drops the memo outright when
221
+ * its watcher sees the file change, so a rewrite inside one millisecond is
222
+ * still seen.
223
+ */
224
+ let actionMemo = null;
225
+ const forgetActions = () => {
226
+ actionMemo = null;
227
+ };
228
+ const actionTables = () => {
229
+ const file = process.env[RSC_MANIFEST_ENV];
230
+ const key = rscManifestKey(file);
231
+ if (actionMemo == null || actionMemo.key !== key) {
232
+ const manifest = readRscManifest(file);
233
+ actionMemo = {
234
+ key,
235
+ modules: serverActionModules(manifest, root),
236
+ table: serverActionTable(manifest, root),
237
+ };
238
+ }
239
+ return actionMemo;
189
240
  };
190
241
 
191
242
  return {
@@ -256,7 +307,36 @@ function flowPlugin({ routerRoot, appEntry, command }) {
256
307
  }
257
308
  if (id === resolved(VIRTUAL.client)) return clientModuleSource(entryPath);
258
309
  if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
310
+ // Only `virtual:uf/server` imports this, so it is only ever asked for in
311
+ // the server environment — but the table it carries is every callable
312
+ // endpoint of the build, so it is worth saying that a browser asking for
313
+ // it gets nothing rather than getting the list.
314
+ if (id === resolved(VIRTUAL.actions)) {
315
+ if (!isSsr(this, loadOptions))
316
+ return "export const actions = [];\nexport default actions;\n";
317
+ return actionsModuleSource(actionTables().table);
318
+ }
259
319
  if (id.startsWith(STYLE_PREFIX)) return styles.get(id) ?? "";
320
+
321
+ // A `"use server"` module, in the browser's graph only: what the client
322
+ // gets is one `createServerReference` per callable export, and never the
323
+ // file. This is where the second half of the RSC split actually happens
324
+ // — the route filter above decides which *pages* the browser is given,
325
+ // and this decides that an action module's body, its imports and
326
+ // everything only they reached are not the browser's business at all.
327
+ //
328
+ // Substituting the source rather than rewriting it: a transform that
329
+ // stripped the body would have to be right about every way a module can
330
+ // name something, and being wrong once means shipping a database handle.
331
+ // The exports the reference module declares come from the manifest, so
332
+ // they are exactly the exports `uf_rsc` decided are callable endpoints
333
+ // and an import of anything else is a build error rather than a silent
334
+ // `undefined`. `crates/uf_rsc/src/graph/build.rs` colours these modules
335
+ // server for the same reason, so the analysis and the bundle agree.
336
+ if (!isSsr(this, loadOptions)) {
337
+ const references = actionTables().modules.get(cleanId(id));
338
+ if (references != null) return actionReferenceSource(references);
339
+ }
260
340
  return null;
261
341
  },
262
342
 
@@ -378,8 +458,15 @@ function flowPlugin({ routerRoot, appEntry, command }) {
378
458
  devServer.watcher.add(manifestPath);
379
459
  const onManifest = (file) => {
380
460
  if (path.resolve(file) !== manifestPath) return;
461
+ // The action tables are read from the same file and are memoised on
462
+ // its size and modification time, which is a pair two writes inside
463
+ // one millisecond can share. This is the answer that does not
464
+ // depend on a clock.
465
+ forgetActions();
381
466
  const routes = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.routes));
382
467
  if (routes) devServer.moduleGraph.invalidateModule(routes);
468
+ const actions = devServer.moduleGraph.getModuleById(resolved(VIRTUAL.actions));
469
+ if (actions) devServer.moduleGraph.invalidateModule(actions);
383
470
  devServer.ws.send({ type: "full-reload", path: "*" });
384
471
  };
385
472
  devServer.watcher.on("add", onManifest);
@@ -387,75 +474,181 @@ function flowPlugin({ routerRoot, appEntry, command }) {
387
474
  }
388
475
 
389
476
  // After Vite's own middlewares, so `/@vite/client`, `/@id/...` and
390
- // static files are served first and only a document request reaches
391
- // the renderer.
477
+ // static files are served first and only what Vite declined reaches uf.
478
+ //
479
+ // # One renderer
480
+ //
481
+ // This is the only middleware that renders a document under `uf dev`,
482
+ // and that is worth stating because there were two. `driver.js` added a
483
+ // second one after `createServer` had returned, which put it *later* in
484
+ // the connect stack than this one — so for every request this one
485
+ // claimed, this one decided, and the other was reached only for what
486
+ // this one declined. Route-handler dispatch was in the other. A
487
+ // `GET /feed` from a browser is `Accept: text/html` with no extension,
488
+ // so it looked like a document, so `app/feed/_uf.route.js` was never
489
+ // asked and the reader got the route table's page — or the not-found
490
+ // page — for a path that had a handler. See ubugeeei-prod/uf#349.
491
+ //
492
+ // Two renderers is also how the two came to disagree about the render
493
+ // result's `headers`: this one wrote them and the driver's did not, so a
494
+ // loader calling `redirect()` answered a browser with a `307` carrying
495
+ // no `Location` and a meta-refresh body — a redirect that works when you
496
+ // deploy it and not while you are writing it. See
497
+ // ubugeeei-prod/uf#338.
498
+ //
499
+ // So the driver's middleware is gone and everything it did is here: it
500
+ // claims every request, runs the guard, the action endpoint and the
501
+ // dispatcher for every method, and hands back to Vite's chain what none
502
+ // of them answered.
503
+ //
504
+ // # What is still not `createFetchHandler`
505
+ //
506
+ // One middleware rather than two, and still not the function every
507
+ // deployment runs. It cannot be: `transformIndexHtml` takes a whole
508
+ // document, so the render has to be collected here rather than streamed
509
+ // (ubugeeei-prod/uf#374), and a request nothing claimed has to go back
510
+ // to Vite's chain rather than become a 404 — neither of which a handler
511
+ // that always answers with a `Response` can do.
512
+ //
513
+ // What that still costs, written down so the next reader does not have
514
+ // to find it: `createFetchHandler` renders inside a cache scope even
515
+ // with no cache configured, so a component calling `cacheLife` states a
516
+ // lifetime nobody honours; here there is no scope, so the same component
517
+ // throws under `uf dev` and renders under `uf start`. Closing that means
518
+ // the generated server entry handing the host a scope the way it already
519
+ // hands it `beginRequest` — see `serverModuleSource` in
520
+ // `./internal/routes.js` — and it is the next thing to remove from this
521
+ // list rather than something this middleware can decide on its own.
392
522
  return () => {
523
+ // The browser's own reporting channel, mounted above the application
524
+ // so that a report never reaches a project's `_uf.middleware.js` or
525
+ // its route table. See `internal/diagnostics.js`.
526
+ devServer.middlewares.use(
527
+ createChannelMiddleware((diagnostic) => emit("diagnostic", diagnostic)),
528
+ );
529
+
393
530
  devServer.middlewares.use(async (request, response, next) => {
394
- if (!wantsDocument(request)) return next();
531
+ // `request.url` and not `originalUrl`, which is the URL Vite's base
532
+ // middleware has already stripped the base from — and the route
533
+ // table's paths have no base in them either.
534
+ const url = request.url ?? "/";
535
+ // Declared out here so the catch below can still settle: a request
536
+ // that failed is a request that happened, and a middleware that
537
+ // logged its arrival is owed its callback either way.
538
+ let lifecycle = null;
395
539
  try {
396
- const url = request.url ?? "/";
397
540
  const entry = await importServerEntry(devServer);
398
541
  const asRequest = await toRequest(request, devServer.config);
399
542
 
400
- // One request, owned here and settled once the document has been
401
- // written — the same lifecycle `driver.js` gives `uf dev` and
402
- // `internal/serve.js` gives `uf preview` and `uf start`. A project
403
- // driving Vite itself must not get a different answer about when
404
- // `after()` runs than the same project run through `uf dev`; see
405
- // `internal/serve.js` and ubugeeei-prod/uf#389.
406
- //
407
- // Only requests that look like a document reach here, so unlike
408
- // `driver.js` there is no path where uf hands the response back to
409
- // Vite's chain: what is below either writes it or throws.
410
- await withRequest(entry, asRequest, async () => {
411
- // Before anything answers: a middleware guards a subtree, and a
412
- // page rendered while the guard on it had not run is the whole of
413
- // ubugeeei-prod/uf#260. `driver.js` makes the same call, for
414
- // every method.
543
+ // The request begins here and ends when the response has been
544
+ // written, which is what `after()` promises and what `uf preview`,
545
+ // `uf start` and a compiled binary all do too — a middleware that
546
+ // logs a response's status has to mean the same thing in
547
+ // development as in production. See `internal/serve.js` and
548
+ // ubugeeei-prod/uf#389.
549
+ lifecycle = await beginRequest(entry, asRequest);
550
+ const answered = await lifecycle.run(async () => {
551
+ // Before anything answers: a middleware guards a subtree, so it
552
+ // has to run for a page, for a route handler, and for a path
553
+ // under it that matches neither. Running it inside the
554
+ // dispatcher and again inside the renderer would have left
555
+ // `/dashboard/typo` unguarded and run it twice for a path that
556
+ // is both. See ubugeeei-prod/uf#260.
415
557
  const guarded = await entry.runMiddleware(asRequest);
416
558
  if (guarded != null) {
417
559
  await send(response, guarded);
418
- return;
560
+ return true;
419
561
  }
420
562
 
421
- // Then the route handlers, above the renderer and for the same
422
- // reason `driver.js` puts them there: a path that answers a
423
- // request is not a document, whatever the client said it would
424
- // accept. `curl /api/thing` and a `<form action>` navigation both
425
- // send `Accept: text/html`, and both want the handler's answer.
426
- //
427
- // This step is not a duplicate of the dispatcher in `driver.js`,
428
- // it is the only one that can run: this middleware is mounted by
429
- // `configureServer`, which Vite calls while it is building the
430
- // server, and `uf dev` adds its own after `createServer` has
431
- // returned — so for every request this one claims, it is the one
432
- // that decides. Without it a route handler under `uf dev` was
433
- // reachable only by a client that asked for something other than
434
- // HTML, and answered the 404 page to everyone else.
563
+ // Then a server action, below the guard and above the handlers.
564
+ // It declines every request that carries no action id, so this
565
+ // costs an ordinary request one `headers.get`; and it answers
566
+ // every request that carries one, refusals included, so an
567
+ // action can never fall through to a route handler that happens
568
+ // to sit at the URL it was posted to. The same two lines are in
569
+ // `@uniflowed/server`'s `fetch.js`, which is what every
570
+ // deployment runs.
571
+ const acted = await entry.callAction(asRequest);
572
+ if (acted != null) {
573
+ await send(response, acted);
574
+ return true;
575
+ }
576
+
577
+ // Then the route handlers, above the renderer and for every
578
+ // method: a path that answers a request is not a document,
579
+ // whatever the client said it would accept. `curl /api/thing`
580
+ // and a `<form action>` navigation both send `Accept:
581
+ // text/html`, and both want the handler's answer — which is the
582
+ // whole of ubugeeei-prod/uf#349.
435
583
  const handled = await entry.dispatch(asRequest);
436
584
  if (handled != null) {
437
585
  await send(response, handled);
438
- return;
586
+ return true;
439
587
  }
440
588
 
589
+ // Only a navigation reaches the renderer. A page cannot answer a
590
+ // `POST`, and letting one try would turn a missing handler into
591
+ // a rendered page with a 200 rather than a 404.
592
+ //
593
+ // `wantsDocument` is stricter than the production handler, which
594
+ // renders anything a static file did not answer, and the
595
+ // difference is Vite's chain: `/@id/…`, `/node_modules/…` and
596
+ // any path with an extension belong to the module server, and a
597
+ // request one of those declined has to go back to it rather than
598
+ // become a rendered 404 page. What it costs is that
599
+ // `/favicon.svg` on a project that has none is a bare 404 here
600
+ // and the project's own not-found *page* under `uf preview` and
601
+ // `uf start` — a difference in the body of a 404 for a path that
602
+ // is an asset request in the first place.
603
+ if (!wantsDocument(request)) return false;
604
+
441
605
  const result = await entry.render(
442
606
  url,
443
607
  { scripts: [devUrlFor(VIRTUAL.client)], styles: [], preloads: [] },
444
- { onError: (error) => reportRenderError(devServer, url, error) },
608
+ {
609
+ // A boundary that threw after the shell went out.
610
+ // `result.error` cannot carry it — the caller already has the
611
+ // result by then — so the terminal hears about it here or not
612
+ // at all.
613
+ onError: (error) => reportRenderError(devServer, url, error),
614
+ },
445
615
  );
446
616
  if (result.error != null) reportRenderError(devServer, url, result.error);
447
- // Collected rather than piped, for the reason `driver.js` gives at
448
- // step 4: `transformIndexHtml` is a whole-document hook.
617
+ // Collected rather than piped: `transformIndexHtml` is a
618
+ // whole-document hook, so there is no first byte to send until it
619
+ // has run. `uf start` and `uf preview` stream — see
620
+ // `internal/serve.js` — and that is a property of the development
621
+ // server rather than of the renderer. ubugeeei-prod/uf#374.
449
622
  const html = await devServer.transformIndexHtml(url, await result.text());
450
- response.statusCode = result.status;
451
- response.setHeader("Content-Type", "text/html; charset=utf-8");
623
+ response.statusCode = result.status ?? 200;
624
+ // The render's own headers, then the content type over the top:
625
+ // exactly the order `@uniflowed/server`'s `fetch.js` writes them
626
+ // in, so a `Location` from `redirect()` survives here and a
627
+ // render cannot claim to be something other than a document.
452
628
  for (const [name, value] of Object.entries(result.headers ?? {})) {
453
629
  response.setHeader(name, value);
454
630
  }
631
+ response.setHeader("content-type", "text/html; charset=utf-8");
455
632
  response.end(html);
633
+ return true;
456
634
  });
635
+
636
+ if (!answered) {
637
+ // The one path where uf is not the one writing the response: a
638
+ // request nothing claimed goes back to Vite's chain. The guard
639
+ // has still run and may have deferred work, so `close` — the
640
+ // socket saying the response is over, however it ended — is the
641
+ // only honest signal left that the bytes are out.
642
+ response.once("close", lifecycle.settle);
643
+ next();
644
+ return;
645
+ }
646
+ await lifecycle.settle();
457
647
  } catch (error) {
458
- devServer.ssrFixStacktrace(error);
648
+ if (lifecycle != null) await lifecycle.settle();
649
+ // Map the stack back onto the Flow source before it reaches the
650
+ // overlay.
651
+ if (error instanceof Error) devServer.ssrFixStacktrace(error);
459
652
  next(error);
460
653
  }
461
654
  });
@@ -526,14 +719,25 @@ const ALL_DIAGNOSTICS = "UF_REACT_COMPILER_DIAGNOSTICS";
526
719
  * Report what the React Compiler said about one module.
527
720
  *
528
721
  * Every finding used to be printed as `a function: <message>` — no file, no
529
- * line, no column, and the fallback string doing all the work because the
530
- * compiler names an inner function about as often as not. The transform hook
722
+ * line, no column, and the fallback string doing all the work, because
723
+ * `diagnostic.function` was read out of a field only a *success* event carries
724
+ * and so was null for every finding there has ever been (#371), not because
725
+ * the compiler was reporting on anonymous inner functions. The transform hook
531
726
  * knows the module and the compiler gives a position for most findings, so
532
727
  * both go into the message: Vite prints a plugin warning's `message` and
533
728
  * nothing else, so a location that is not in the string is a location the
534
729
  * reader never sees. `id` and `loc` go along for anything reading the log
535
730
  * object rather than the line. See ubugeeei-prod/uf#307.
536
731
  *
732
+ * `(in Form)` is real now: `uf transform` recovers the name from the tree it
733
+ * compiled, and leaves it off the diagnostic when the function genuinely has
734
+ * no name to give.
735
+ *
736
+ * The position is real too, and is the expression the compiler objected to
737
+ * rather than the function containing it, so the two halves of the line say
738
+ * different things — `packages/hooks/dom.js:195:7: This value cannot be
739
+ * modified (in useLongPress)` locates the write and names the hook to look in.
740
+ *
537
741
  * A dependency's findings are held back. A React Compiler bailout inside
538
742
  * `@uniflowed/form` is not something the person running the build can fix, and
539
743
  * a channel carrying forty of them on every build is a channel people stop
@@ -556,9 +760,20 @@ function reportDiagnostics(context, { id, root, diagnostics, environment, report
556
760
  const mine = isProjectModule(root, id) || process.env[ALL_DIAGNOSTICS] === "all";
557
761
  for (const diagnostic of diagnostics) {
558
762
  // Everything a reader would be shown, so two findings that would print as
559
- // the same line collapse into one. The compiler reports "Cannot access refs
560
- // during render" once per pass that noticed it — three times for one `ref`
561
- // — and three identical lines are not three things to fix.
763
+ // the same line collapse into one.
764
+ //
765
+ // This used to collapse far more than that, and the note here used to say
766
+ // the compiler reports "Cannot access refs during render" once per pass
767
+ // that noticed it — three times for one `ref`. That was the wrong reading
768
+ // of the evidence. The position in a finding was the position of the
769
+ // *function* it was found in, so every finding in one function shared a
770
+ // line and a column and any two with the same message were, to this
771
+ // signature, the same finding. They were not: over this repository's own
772
+ // packages it collapsed 173 findings into 119 lines, and the five that
773
+ // became one line in `DatePickerInput` are five different reads of a ref
774
+ // on five different lines. `uf transform` now reports where the compiler
775
+ // actually objected, so the signature separates them, and what it still
776
+ // collapses is a genuine repeat of one site.
562
777
  const signature = `${diagnostic.kind}\0${diagnostic.line}\0${diagnostic.column}\0${diagnostic.message}`;
563
778
  if (ledger.signatures.has(signature)) continue;
564
779
  ledger.signatures.add(signature);