@uniflowed/vite 0.0.0-alpha.34 → 0.0.0-alpha.37

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
@@ -42,26 +42,42 @@
42
42
  import { randomUUID } from "node:crypto";
43
43
  import { createServer as createHttpServer } from "node:http";
44
44
  import { builtinModules, register } from "node:module";
45
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
45
+ import { installFlowHooks } from "@uniflowed/host/internal/sync-hooks.js";
46
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
46
47
  import path from "node:path";
47
48
  import { pathToFileURL } from "node:url";
48
49
 
49
50
  import { COMPILE_ASSETS_ID, compileAssetsPlugin } from "./internal/compile-assets.js";
51
+ import {
52
+ survivingImports,
53
+ unavailableOnWorkers,
54
+ workerBuiltinWarnings,
55
+ } from "./internal/worker-builtins.js";
50
56
  import { emit, errorEvent, eventLogger } from "./internal/events.js";
51
57
  import { loadUfConfig, projectConfig } from "./internal/config.js";
52
- import { send, toRequest } from "./internal/http.js";
58
+ import { send, toAddressRequest, toRequest } from "./internal/http.js";
53
59
  import { createOpenApiDocument } from "./internal/openapi.js";
54
60
  import { withProjectConfig } from "./merge.js";
55
- import { VIRTUAL, resolveRouteTarget, scanRoutes } from "./internal/routes.js";
61
+ import { FLIGHT_VIRTUAL, RSC_ENVIRONMENT } from "./internal/flight.js";
62
+ import { MODULE_GRAPH_FILE, createModuleGraphCollector } from "./internal/module-graph.js";
63
+ import { VIRTUAL, resolveRouteTarget, routingRulesOf, scanRoutes } from "./internal/routes.js";
56
64
  import {
57
65
  BUILD_ID_FILE,
66
+ DOCUMENT_ASSETS_FILE,
67
+ REGENERATED_DIRECTORY,
68
+ REGENERATION_FILE,
69
+ answerRouting,
70
+ answersInFrontOfFiles,
58
71
  assetsFromManifest,
59
72
  buildIdentity,
60
73
  createPrerenderGate,
61
74
  createServeHandler,
75
+ documentAssetsFor,
76
+ forViteBase,
62
77
  loadBuild,
63
78
  nodeListener,
64
79
  providerSpecifier,
80
+ readRegeneration,
65
81
  withRequest,
66
82
  } from "./internal/serve.js";
67
83
 
@@ -94,11 +110,23 @@ process.env.UF_PROJECT_ROOT = root;
94
110
  // hooks for that; Bun is started with `--preload` on the same package's
95
111
  // preload instead, and has no `register`.
96
112
  //
113
+ // Deno has no `register` either, and it gets its hooks *here* rather than from
114
+ // a preload, for a reason that is about order. While a Deno `load` hook is
115
+ // registered, `require()` of a native addon fails — and Vite requires one,
116
+ // Rolldown's binding. The static imports above have already loaded Vite by
117
+ // this line, binding included, so hooks installed now see only what is
118
+ // imported after them: the config, and the `@uniflowed/*` modules this driver
119
+ // reaches dynamically. They are the in-thread hooks a new enough Node takes
120
+ // through `@uniflowed/host/register` too; see
121
+ // `@uniflowed/host/internal/sync-hooks.js`.
122
+ //
97
123
  // The hooks live in `@uniflowed/host` rather than here: they are how Flow runs
98
124
  // on a Capability JS Host, and nothing in them is Vite's. `uf test` reaches for
99
125
  // the same package, which is what stopped a test run from depending on a
100
126
  // bundler it never loads.
101
- if (typeof Bun === "undefined" && typeof Deno === "undefined") {
127
+ if (typeof Deno !== "undefined") {
128
+ installFlowHooks(root);
129
+ } else if (typeof Bun === "undefined") {
102
130
  register("@uniflowed/host/internal/node-hooks.js", import.meta.url, { data: { root } });
103
131
  }
104
132
 
@@ -152,6 +180,15 @@ async function loadConfig() {
152
180
  return config;
153
181
  }
154
182
 
183
+ /**
184
+ * `app.router.basePath` as the driver uses it: `""` at the root, `"/docs"`
185
+ * otherwise. Read where the build writes asset URLs and file names, because a
186
+ * prerendered document is written outside Vite's HTML transform.
187
+ */
188
+ function basePathOf(config) {
189
+ return routingRulesOf(config.app?.router).basePath;
190
+ }
191
+
155
192
  /** The Vite inline config a uf config describes. */
156
193
  async function viteConfig(config, mode) {
157
194
  const { default: uniflowed } = await import("./index.js");
@@ -191,6 +228,9 @@ async function viteConfig(config, mode) {
191
228
  configFile: false,
192
229
  envDir: false,
193
230
  mode,
231
+ // `app.router.basePath`: where Vite serves the modules in development, and
232
+ // what it puts in front of every asset URL a build writes.
233
+ base: basePathOf(config) === "" ? "/" : `${basePathOf(config)}/`,
194
234
  clearScreen: false,
195
235
  customLogger: eventLogger(argument("--log-level") ?? "info"),
196
236
  plugins: [uniflowed({ root, config, target: routeTarget })],
@@ -437,6 +477,37 @@ async function preview() {
437
477
  const draftFirst = {
438
478
  name: "uf:draft-before-files",
439
479
  configurePreviewServer(previewServer) {
480
+ // `app.router.headers` and `redirects`, first of all: a redirect answers
481
+ // before a file is looked for, and a header is pinned on the response so
482
+ // it survives the `writeHead` Vite's file middleware writes its own
483
+ // with. `uf start` and every adapter put the same two in front of their
484
+ // static half; the application's own answers get them from
485
+ // `createServeHandler` behind. See `internal/serve.js`'s `answerRouting`.
486
+ const routing = build?.entry?.routing;
487
+ if (answersInFrontOfFiles(routing)) {
488
+ previewServer.middlewares.use((request, response, next) => {
489
+ answerRouting(routing, toAddressRequest(request), response)
490
+ .then((answered) => {
491
+ if (answered) return;
492
+ // The bare base path is the root, which Vite only knows as
493
+ // `/docs/`; see `forViteBase`.
494
+ forViteBase(routing, request);
495
+ next();
496
+ })
497
+ .catch(next);
498
+ });
499
+ }
500
+ // A prerendered payload is a file whose extension Vite's static middleware
501
+ // knows no type for, and the router hands bytes to React only when they
502
+ // are answered as a payload — so a navigation on a preview would silently
503
+ // become a document load. Set here, in front of the file server, which
504
+ // keeps a type it did not choose.
505
+ previewServer.middlewares.use((request, response, next) => {
506
+ if ((request.url ?? "").split("?")[0].endsWith("/__uf.flight")) {
507
+ response.setHeader("content-type", "text/x-component");
508
+ }
509
+ next();
510
+ });
440
511
  if (handle == null) return;
441
512
  const run = answer(previewServer);
442
513
  previewServer.middlewares.use((request, response, next) => {
@@ -589,17 +660,60 @@ async function build() {
589
660
  const staticBuild = flag("--static-build");
590
661
  const because = argument("--because") ?? "this build prerenders every route";
591
662
 
663
+ // 0. The rsc graph, for an application React Server Components render: the
664
+ // route table and every server component, resolved under `react-server`.
665
+ // First, because it is what finds the client modules the next pass has
666
+ // to build; `./internal/flight.js` has the order and the reason for it.
667
+ const flight = flightStateOf(inline);
668
+ // `uf build --analyze`: the graph every bundle below is built from, for uf
669
+ // to attribute to routes; see `./internal/module-graph.js`. A client module
670
+ // is an entry the browser loads because a server component names it, not on
671
+ // every page, so it is not one of the client bundle's shared entries.
672
+ const graph = flag("--analyze")
673
+ ? createModuleGraphCollector(root, {
674
+ isReference: (file) => flight?.clientModules.has(file) ?? false,
675
+ })
676
+ : null;
677
+ if (graph != null) {
678
+ inline.plugins = [...(inline.plugins ?? []), graph.plugin];
679
+ }
680
+ const rscDir = path.join(root, ".uf", "build", "rsc");
681
+ if (flight != null) {
682
+ emit("phase", { name: "rsc" });
683
+ await buildRscGraph(vite, inline, flight, { outDir: rscDir, conditions: null });
684
+ }
685
+
592
686
  // 1. The client: everything the browser loads, with a manifest so the
593
- // server render knows which script and stylesheet tags to write.
687
+ // server render knows which script and stylesheet tags to write. Under
688
+ // React Server Components that is the entry and one entry per client
689
+ // module, each keeping its export names, because a payload asks for a
690
+ // chunk by its URL and for a component by its export.
594
691
  emit("phase", { name: "client" });
692
+ const references = flight == null ? [] : [...flight.clientModules].sort();
693
+ const input = { client: VIRTUAL.client };
694
+ references.forEach((file, index) => {
695
+ input[`client-reference-${index}`] = file;
696
+ });
595
697
  await vite.build({
596
698
  ...inline,
597
699
  build: {
598
700
  ...inline.build,
599
- rollupOptions: { input: { client: VIRTUAL.client } },
701
+ rollupOptions:
702
+ flight == null ? { input } : { input, preserveEntrySignatures: "exports-only" },
600
703
  },
601
704
  });
602
705
  const manifest = readManifest(outDir);
706
+ if (flight != null) {
707
+ recordClientChunks(flight, manifest, references, rscDir, basePathOf(config));
708
+ // What the summary's "pages in the client bundle" reads. None: a browser
709
+ // that hydrates a payload imports no page, whichever route it is on.
710
+ emit("rsc-split", {
711
+ pages: 0,
712
+ routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app"), {
713
+ target: resolveRouteTarget(config, argument("--target")),
714
+ }).routes.length,
715
+ });
716
+ }
603
717
 
604
718
  // 2. The server entry, bundled for the host, outside `dist/` so it is never
605
719
  // deployed by accident.
@@ -627,6 +741,9 @@ async function build() {
627
741
  // `internal/serve.js`'s `buildIdentity`, which is what reads this.
628
742
  writeFileSync(path.join(serverDir, BUILD_ID_FILE), `${mintBuildId()}\n`);
629
743
 
744
+ // Every bundle is built by here, and the prerender below builds none.
745
+ graph?.write(path.join(root, ".uf", "build", "meta", MODULE_GRAPH_FILE));
746
+
630
747
  // 3. Which routes this build renders when, and every route it renders now.
631
748
  //
632
749
  // The decision comes from `uf.config.js` and is made in Rust — see
@@ -635,7 +752,14 @@ async function build() {
635
752
  // arrives here as one word, and this is where it meets the route table.
636
753
  emit("phase", { name: "prerender" });
637
754
  const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
638
- const assets = assetsFromManifest(manifest);
755
+ const assets =
756
+ flight == null
757
+ ? assetsFromManifest(manifest, basePathOf(config))
758
+ : flightAssets(manifest, references, rscDir, outDir, basePathOf(config));
759
+ // Recorded beside the server bundle, because whatever serves this build
760
+ // later cannot recompute them from the client manifest alone; see
761
+ // `documentAssetsFor`.
762
+ writeFileSync(path.join(serverDir, DOCUMENT_ASSETS_FILE), `${JSON.stringify(assets, null, 2)}\n`);
639
763
  const openapi = await createOpenApiDocument(server.handlers);
640
764
  const openapiFile = path.join(root, ".uf", "build", "meta", "openapi.json");
641
765
  mkdirSync(path.dirname(openapiFile), { recursive: true });
@@ -686,28 +810,86 @@ async function build() {
686
810
  failures.push(url);
687
811
  emit("page-failed", { url, ...errorEvent(error) });
688
812
  };
813
+ //
814
+ // Each prerender runs inside a fill that stores nothing, so what a page states
815
+ // with `cacheLife`, `cacheTag` and `noStore` is something this loop can read
816
+ // rather than a call that throws for want of a scope.
817
+ //
818
+ // A page is written for regeneration when `uf` passed `--regenerate` — `isr`
819
+ // allowed in `app.rendering.modes`, `rendering.cache.route` on, and a server
820
+ // deployed to regenerate it — and the page stated a lifetime, called no
821
+ // `noStore`, and answered 200. Its document goes under
822
+ // `REGENERATED_DIRECTORY` rather than at its own URL, so no static half
823
+ // answers the page: the server does, starting from this document, until the
824
+ // lifetime has passed. What it stated goes into `REGENERATION_FILE` beside the
825
+ // server bundle. Every other page is the document it has always been.
826
+ //
827
+ // A tag without a lifetime is not enough, for the route cache's own reason:
828
+ // an entry with no end is one another process could serve from its memory
829
+ // for ever after `revalidateTag` took it out of the shared store.
830
+ const { collectCacheDeclarations } = await import("@uniflowed/server/cache");
831
+ const regenerate = flag("--regenerate");
832
+ const regenerated = {};
689
833
  for (const url of pages) {
690
- let result;
834
+ let declared;
835
+ const renderedAt = Date.now();
691
836
  try {
692
- result = await server.prerender(url, assets);
837
+ declared = await collectCacheDeclarations(() => server.prerender(url, assets));
693
838
  } catch (error) {
694
839
  failed(url, error);
695
840
  continue;
696
841
  }
842
+ const result = declared.value;
697
843
  if (result.error != null) {
698
844
  failed(url, result.error);
699
845
  continue;
700
846
  }
701
- const file = htmlPathFor(outDir, url);
847
+ const lifetime = declared.lifetime;
848
+ const regenerates =
849
+ regenerate && result.status === 200 && declared.denied == null && lifetime != null;
850
+ // The trailing-slash policy decides a served page's file name; a page the
851
+ // build regenerates keeps the one layout the regeneration reads.
852
+ const file = regenerates
853
+ ? htmlPathFor(path.join(outDir, REGENERATED_DIRECTORY), url)
854
+ : htmlPathFor(outDir, url, routingRulesOf(config.app?.router).trailingSlash);
702
855
  mkdirSync(path.dirname(file), { recursive: true });
703
856
  writeFileSync(file, result.html);
857
+ // The payload the document was rendered from, beside it: what a browser
858
+ // navigating to this route fetches, from whatever serves the files. Beside
859
+ // the document wherever the document went, so a page written for
860
+ // regeneration leaves no file at its route's own payload URL answering with
861
+ // the build's copy for ever; the server answers that URL instead.
862
+ if (result.payload != null) {
863
+ // Beside the route's directory whatever the document is called:
864
+ // `guide/index.html` and `guide.html` both put it at `guide/__uf.flight`.
865
+ const payloadDirectory =
866
+ path.basename(file) === "index.html" ? path.dirname(file) : file.slice(0, -".html".length);
867
+ mkdirSync(payloadDirectory, { recursive: true });
868
+ writeFileSync(path.join(payloadDirectory, "__uf.flight"), result.payload);
869
+ }
870
+ if (regenerates) {
871
+ regenerated[url] = {
872
+ document: regeneratedDocumentUrl(url),
873
+ renderedAt,
874
+ revalidate: lifetime.revalidate,
875
+ expire: lifetime.expire ?? null,
876
+ tags: declared.tags,
877
+ };
878
+ }
704
879
  emit("page", {
705
880
  url,
706
881
  file: path.relative(root, file),
707
882
  status: result.status,
708
883
  bytes: Buffer.byteLength(result.html),
884
+ regenerates,
709
885
  });
710
886
  }
887
+ if (Object.keys(regenerated).length > 0) {
888
+ writeFileSync(
889
+ path.join(serverDir, REGENERATION_FILE),
890
+ `${JSON.stringify({ pages: regenerated }, null, 2)}\n`,
891
+ );
892
+ }
711
893
  // One `404.html`, from the boundary at the router root: a static host serves
712
894
  // a single error document for the whole site, so the nested boundaries a
713
895
  // project declares are the server's and the client's to render, not
@@ -1039,9 +1221,16 @@ async function compile() {
1039
1221
  mkdirSync(bundleDir, { recursive: true });
1040
1222
  writeFileSync(
1041
1223
  entry,
1042
- entrySource(path.relative(root, assets), assetsFromManifest(readManifest(outDir))),
1224
+ entrySource(
1225
+ path.relative(root, assets),
1226
+ await documentAssetsFor(path.join(root, ".uf", "build", "server"), readManifest(outDir)),
1227
+ ),
1043
1228
  );
1044
1229
 
1230
+ // The rsc graph `uf build` built, and the client chunks its references name.
1231
+ const flight = flightStateOf(inline);
1232
+ if (flight != null) loadFlightBuild(flight, path.join(root, ".uf", "build", "rsc"));
1233
+
1045
1234
  await vite.build({
1046
1235
  ...inline,
1047
1236
  customLogger: eventLogger("warn"),
@@ -1081,13 +1270,21 @@ async function compile() {
1081
1270
  * `static` is deliberately absent; `uf_config`'s
1082
1271
  * `DeployAdapter::is_implemented` is the other half of that fact and
1083
1272
  * `docs/app/reference/cli/$page.mdx` says why.
1273
+ *
1274
+ * `regenerationStore` is where a target keeps the pages a build regenerates
1275
+ * when `rendering.cache.store` names nothing: a disk for a process with one,
1276
+ * Workers KV for a Worker, and `null` for a Lambda, which keeps nothing between
1277
+ * invocations and is refused by name instead. A regenerated page kept only in
1278
+ * memory would go back to the build's copy on every restart, which is a page
1279
+ * that travels back in time. See [`deploy`].
1084
1280
  */
1085
1281
  const ADAPTERS = {
1086
1282
  node: {
1087
- entries: (document, cache, build, schedules) => ({
1088
- handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build),
1283
+ entries: (document, cache, build, schedules, regeneration) => ({
1284
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build, regeneration),
1089
1285
  server: nodeEntrySource("./handler.js", schedules),
1090
1286
  }),
1287
+ regenerationStore: "filesystem",
1091
1288
  },
1092
1289
  // The same two files as `node`, with `@uniflowed/server/bun` in place of
1093
1290
  // `@uniflowed/server/node`. That module is `./internal/static.js` for every
@@ -1104,32 +1301,40 @@ const ADAPTERS = {
1104
1301
  // nobody here. `edge` pays that price because it must: there is no
1105
1302
  // `node:stream` in a Worker.
1106
1303
  bun: {
1107
- entries: (document, cache, build, schedules) => ({
1108
- handler: handlerEntrySource(document, cache, BUN_CAPABILITIES, build),
1304
+ entries: (document, cache, build, schedules, regeneration) => ({
1305
+ handler: handlerEntrySource(document, cache, BUN_CAPABILITIES, build, regeneration),
1109
1306
  server: bunEntrySource("./handler.js", schedules),
1110
1307
  }),
1308
+ regenerationStore: "filesystem",
1111
1309
  },
1112
1310
  deno: {
1113
- entries: (document, cache, build, schedules) => ({
1114
- handler: handlerEntrySource(document, cache, DENO_CAPABILITIES, build),
1311
+ entries: (document, cache, build, schedules, regeneration) => ({
1312
+ handler: handlerEntrySource(document, cache, DENO_CAPABILITIES, build, regeneration),
1115
1313
  server: denoEntrySource("./handler.js", schedules),
1116
1314
  }),
1315
+ regenerationStore: "filesystem",
1117
1316
  },
1118
1317
  // The same two files. What `--adapter container` adds is a `Dockerfile` and
1119
1318
  // a `.dockerignore`, and both are plain text that `uf` writes beside this
1120
1319
  // output rather than anything the bundler produces — see `uf_cli`'s
1121
1320
  // `commands::deploy`.
1122
1321
  container: {
1123
- entries: (document, cache, build, schedules) => ({
1124
- handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build),
1322
+ entries: (document, cache, build, schedules, regeneration) => ({
1323
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build, regeneration),
1125
1324
  server: nodeEntrySource("./handler.js", schedules),
1126
1325
  }),
1326
+ regenerationStore: "filesystem",
1127
1327
  },
1128
1328
  edge: {
1129
- entries: (document, cache, build, schedules) => ({
1130
- handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES, build),
1329
+ entries: (document, cache, build, schedules, regeneration) => ({
1330
+ handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES, build, regeneration),
1131
1331
  worker: workerEntrySource("./handler.js", schedules),
1132
1332
  }),
1333
+ // Workers KV, through the same module seam a project's own provider goes
1334
+ // through. `packages/server/cache-kv.js` argues KV over the Cache API: the
1335
+ // seam has to find every entry under a tag, and the Cache API cannot list
1336
+ // what it holds.
1337
+ regenerationStore: "@uniflowed/server/cache/kv",
1133
1338
  // `workerd` first, so React resolves to the build that has
1134
1339
  // `renderToReadableStream` and no `node:stream`. `browser` and `module`
1135
1340
  // after it are Vite's own SSR defaults, kept so a dependency with no
@@ -1141,12 +1346,19 @@ const ADAPTERS = {
1141
1346
  // target which cannot provide a durable store says so, and this is the one
1142
1347
  // target that cannot.
1143
1348
  filesystem: false,
1349
+ // And the Node built-ins it has only as stubs, which the link reports by
1350
+ // module and importer. See `./internal/worker-builtins.js`.
1351
+ workerBuiltins: true,
1144
1352
  },
1145
1353
  serverless: {
1146
- entries: (document, cache, build) => ({
1147
- handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES, build),
1354
+ entries: (document, cache, build, _schedules, regeneration) => ({
1355
+ handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES, build, regeneration),
1148
1356
  lambda: lambdaEntrySource("./handler.js"),
1149
1357
  }),
1358
+ // A Lambda's memory lasts one instance and its disk is that instance's
1359
+ // `/tmp`, so neither is somewhere a regenerated page survives. A build that
1360
+ // regenerates pages has to name a provider module; see [`deploy`].
1361
+ regenerationStore: null,
1150
1362
  },
1151
1363
  };
1152
1364
 
@@ -1267,15 +1479,18 @@ async function deploy() {
1267
1479
  // file is the version a person can open when a deployed directory
1268
1480
  // misbehaves.
1269
1481
  mkdirSync(work, { recursive: true });
1270
- const document = assetsFromManifest(readManifest(outDir));
1482
+ const document = await documentAssetsFor(
1483
+ path.join(root, ".uf", "build", "server"),
1484
+ readManifest(outDir),
1485
+ );
1271
1486
  // Whatever `build` above minted, so a durable cache in the deployed artefact
1272
1487
  // is keyed by the build that produced it and not by the moment it was
1273
1488
  // packaged. Read rather than minted again for exactly that reason: a second
1274
1489
  // `randomUUID()` here would key the adapter's copy differently from the one
1275
1490
  // `uf start` serves out of `.uf/build/`, which is two caches for one build.
1276
1491
  const buildId = await buildIdentity(root, path.join(".uf", "build", "server"));
1277
- const cacheConfig = config.app?.rendering?.cache;
1278
- if (shape.filesystem === false && cacheConfig?.store === "filesystem") {
1492
+ const declaredCache = config.app?.rendering?.cache;
1493
+ if (shape.filesystem === false && declaredCache?.store === "filesystem") {
1279
1494
  throw new Error(
1280
1495
  `uf: rendering.cache.store is "filesystem" and \`--adapter ${adapter}\` has no ` +
1281
1496
  "filesystem. Name a module exporting `createCacheProvider` instead — a KV " +
@@ -1283,13 +1498,49 @@ async function deploy() {
1283
1498
  "docs/app/guide/cache.",
1284
1499
  );
1285
1500
  }
1286
- const entries = shape.entries(document, cacheConfig, buildId, schedules);
1501
+ // The pages this build regenerates, and where this target keeps what it
1502
+ // regenerates when the project named no store: the adapter's
1503
+ // `regenerationStore`. A target with nowhere refuses by name and lists the
1504
+ // pages, rather than deploying pages that every cold start takes back to the
1505
+ // build's copy.
1506
+ const regeneration = await readRegeneration(path.join(root, ".uf", "build", "server"));
1507
+ let cacheConfig = declaredCache;
1508
+ if (regeneration != null && declaredCache?.store == null) {
1509
+ if (shape.regenerationStore == null) {
1510
+ const pages = Object.keys(regeneration.pages);
1511
+ throw new Error(
1512
+ `uf: this build regenerates ${pages.length} ${plural(pages.length, "page")} ` +
1513
+ `(${pages.join(", ")}), and \`--adapter ${adapter}\` has nowhere of its own to keep ` +
1514
+ "a regenerated page: an instance's memory and its /tmp both go with the instance. " +
1515
+ "Name a module exporting `createCacheProvider` in rendering.cache.store, or leave " +
1516
+ "`isr` out of app.rendering.modes to prerender those pages as documents that do not " +
1517
+ "change. See docs/app/guide/rendering.",
1518
+ );
1519
+ }
1520
+ cacheConfig = { ...declaredCache, store: shape.regenerationStore };
1521
+ }
1522
+ const entries = shape.entries(document, cacheConfig, buildId, schedules, regeneration);
1287
1523
  const input = {};
1288
1524
  for (const name of Object.keys(entries)) {
1289
1525
  writeFileSync(path.join(work, `${name}.js`), entries[name]);
1290
1526
  input[name] = path.join(work, `${name}.js`);
1291
1527
  }
1292
1528
 
1529
+ // An application React Server Components render bundles the rsc graph into
1530
+ // its server, and a target with export conditions of its own needs that graph
1531
+ // resolved under them as well: React's Flight server has a Node build and a
1532
+ // worker build, exactly as its HTML renderer does.
1533
+ const flight = flightStateOf(inline);
1534
+ if (flight != null) {
1535
+ loadFlightBuild(flight, path.join(root, ".uf", "build", "rsc"));
1536
+ if (shape.conditions != null) {
1537
+ await buildRscGraph(vite, inline, flight, {
1538
+ outDir: path.join(work, "rsc"),
1539
+ conditions: shape.conditions,
1540
+ });
1541
+ }
1542
+ }
1543
+
1293
1544
  const ssr = { ...(inline.ssr ?? {}), noExternal: true };
1294
1545
  if (shape.conditions != null) {
1295
1546
  // Which build of a dependency this target gets, and it is the difference
@@ -1304,7 +1555,24 @@ async function deploy() {
1304
1555
  await vite.build({
1305
1556
  ...inline,
1306
1557
  customLogger: eventLogger("warn"),
1307
- plugins: [...inline.plugins, nativeAddonGuard()],
1558
+ plugins: [
1559
+ ...inline.plugins,
1560
+ nativeAddonGuard(),
1561
+ ...(shape.workerBuiltins === true ? [workerBuiltinGuard()] : []),
1562
+ ],
1563
+ // Fixed, because this bundle inlines every dependency and so both of each
1564
+ // React package's builds, and a runtime lookup of `NODE_ENV` in a worker
1565
+ // finds nothing and picks the development one. React's Flight client's
1566
+ // development build constructs a `WeakRef` for every response, which
1567
+ // workerd does not have: every document the edge artefact rendered was a
1568
+ // `ReferenceError`. The production build has none, and is the one a
1569
+ // deployment means.
1570
+ define: {
1571
+ ...(inline.define ?? {}),
1572
+ "process.env.NODE_ENV": JSON.stringify(
1573
+ inline.mode === "development" ? "development" : "production",
1574
+ ),
1575
+ },
1308
1576
  ssr,
1309
1577
  build: {
1310
1578
  ...inline.build,
@@ -1388,7 +1656,7 @@ async function deploy() {
1388
1656
  * `buildIdentity` is where it came from and
1389
1657
  * `packages/server/internal/cache-key.js` is why it exists.
1390
1658
  */
1391
- function handlerEntrySource(document, cache, capabilities, build) {
1659
+ function handlerEntrySource(document, cache, capabilities, build, regeneration) {
1392
1660
  const route = cache?.route === true;
1393
1661
  const fetchCache = cache?.fetch === true;
1394
1662
  // Nothing at all when both switches are off, so a default project's
@@ -1402,6 +1670,10 @@ function handlerEntrySource(document, cache, capabilities, build) {
1402
1670
  `document: ${JSON.stringify(document)}`,
1403
1671
  ...(store ? ["cache"] : []),
1404
1672
  "capabilities",
1673
+ // The pages this build regenerates, baked in beside the document and for
1674
+ // the same reason: the manifest exists on the machine doing the build, and
1675
+ // the deployed directory has only what this file carries.
1676
+ ...(store && regeneration != null ? [`regeneration: ${JSON.stringify(regeneration)}`] : []),
1405
1677
  ].join(", ");
1406
1678
  const cacheImport = store ? 'import { createCacheStore } from "@uniflowed/server/cache";\n' : "";
1407
1679
  const providerImport = durable == null ? "" : `${durable.import}\n`;
@@ -1440,8 +1712,11 @@ const capabilities = ${capabilities.name}();
1440
1712
 
1441
1713
  export const fetch = createFetchHandler({ ${options} });
1442
1714
  export const beginRequest = app.beginRequest;
1715
+ // \`app.router\`'s redirects and headers, for the entry beside this file to put
1716
+ // in front of its static half. Rewrites are \`fetch\`'s own.
1717
+ export const routing = app.routing;
1443
1718
 
1444
- export default { fetch, beginRequest };
1719
+ export default { fetch, beginRequest, routing };
1445
1720
  `;
1446
1721
  }
1447
1722
 
@@ -1549,7 +1824,7 @@ ${cron.imports}
1549
1824
  // \`@uniflowed/server/node\` above, because the request has to be established in
1550
1825
  // the storage the *application* reads, which is the copy bundled into
1551
1826
  // \`handler.js\`. See ubugeeei-prod/uf#389.
1552
- import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1827
+ import { beginRequest, fetch, routing } from ${JSON.stringify(handlerSpecifier)};
1553
1828
 
1554
1829
  // Resolved from this file and not from the working directory: a process
1555
1830
  // manager, a container entrypoint and a person in a shell each start a server
@@ -1562,7 +1837,7 @@ ${cron.declarations}
1562
1837
  // (ubugeeei-prod/uf#204) and this entry is a module, so it would work; \`.catch\`
1563
1838
  // is the better spelling regardless — a server that cannot take its port should
1564
1839
  // say so and exit non-zero, rather than die as an unhandled rejection.
1565
- serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1840
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
1566
1841
  process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1567
1842
  process.exit(1);
1568
1843
  });
@@ -1589,7 +1864,7 @@ ${cron.imports}
1589
1864
  // \`@uniflowed/server/bun\` above, because the request has to be established in
1590
1865
  // the storage the *application* reads, which is the copy bundled into
1591
1866
  // \`handler.js\`. See ubugeeei-prod/uf#389.
1592
- import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1867
+ import { beginRequest, fetch, routing } from ${JSON.stringify(handlerSpecifier)};
1593
1868
 
1594
1869
  // Resolved from this file and not from the working directory: a process
1595
1870
  // manager, a container entrypoint and a person in a shell each start a server
@@ -1598,7 +1873,7 @@ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1598
1873
  // trap in it.
1599
1874
  const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1600
1875
  ${cron.declarations}
1601
- serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1876
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
1602
1877
  process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1603
1878
  process.exit(1);
1604
1879
  });
@@ -1636,7 +1911,7 @@ globalThis.Buffer ??= {
1636
1911
  };
1637
1912
  globalThis.setImmediate ??= (callback, ...args) => setTimeout(callback, 0, ...args);
1638
1913
  globalThis.clearImmediate ??= (handle) => clearTimeout(handle);
1639
- const { beginRequest, fetch } = await import(${JSON.stringify(handlerSpecifier)});
1914
+ const { beginRequest, fetch, routing } = await import(${JSON.stringify(handlerSpecifier)});
1640
1915
 
1641
1916
  // Resolved from this file and not from the working directory: a process
1642
1917
  // manager and a person in a shell each start a server from wherever they
@@ -1644,7 +1919,7 @@ const { beginRequest, fetch } = await import(${JSON.stringify(handlerSpecifier)}
1644
1919
  // started from inside itself would be a deployment with a trap in it.
1645
1920
  const staticDir = decodeURIComponent(new URL("./static", import.meta.url).pathname);
1646
1921
  ${cron.declarations}
1647
- serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1922
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
1648
1923
  console.error(\`uf: \${error?.message ?? String(error)}\`);
1649
1924
  Deno.exit(1);
1650
1925
  });
@@ -1671,11 +1946,15 @@ function workerEntrySource(handlerSpecifier, schedules) {
1671
1946
  // would not be there.
1672
1947
  if (declared.length === 0) {
1673
1948
  return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1674
- import { createWorkerFetch } from "@uniflowed/server/edge";
1949
+ import { createWorkerFetch, installWorkerLogger } from "@uniflowed/server/edge";
1950
+
1951
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
1675
1952
 
1676
- import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1953
+ // After the imports, so a logger the application installed while it loaded is
1954
+ // the one that stays; see \`installWorkerLogger\`.
1955
+ installWorkerLogger();
1677
1956
 
1678
- export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1957
+ export default { fetch: createWorkerFetch({ handle, beginRequest, routing }) };
1679
1958
  `;
1680
1959
  }
1681
1960
 
@@ -1684,16 +1963,20 @@ export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1684
1963
  // `uf` writes both from one list, so the two cannot disagree.
1685
1964
  const routes = Object.fromEntries(declared.map((schedule) => [schedule.cron, schedule.path]));
1686
1965
  return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1687
- import { createWorkerFetch, createWorkerScheduled } from "@uniflowed/server/edge";
1966
+ import { createWorkerFetch, createWorkerScheduled, installWorkerLogger } from "@uniflowed/server/edge";
1688
1967
 
1689
- import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1968
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
1969
+
1970
+ // After the imports, so a logger the application installed while it loaded is
1971
+ // the one that stays; see \`installWorkerLogger\`.
1972
+ installWorkerLogger();
1690
1973
 
1691
1974
  // \`triggers.crons\` in the wrangler.json beside this file names these same
1692
1975
  // expressions. See ubugeeei-prod/uf#531.
1693
1976
  const routes = ${JSON.stringify(routes, null, 2)};
1694
1977
 
1695
1978
  export default {
1696
- fetch: createWorkerFetch({ handle, beginRequest }),
1979
+ fetch: createWorkerFetch({ handle, beginRequest, routing }),
1697
1980
  scheduled: createWorkerScheduled({ handle, beginRequest, routes }),
1698
1981
  };
1699
1982
  `;
@@ -1719,7 +2002,7 @@ import { fileURLToPath } from "node:url";
1719
2002
 
1720
2003
  import { createLambdaHandler } from "@uniflowed/server/lambda";
1721
2004
 
1722
- import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
2005
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
1723
2006
 
1724
2007
  // Resolved from this file and not from the working directory: Lambda sets the
1725
2008
  // working directory to the task root today and is under no obligation to keep
@@ -1727,7 +2010,7 @@ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)
1727
2010
  // deployment with a trap in it.
1728
2011
  const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1729
2012
 
1730
- export const handler = createLambdaHandler({ handle, beginRequest, staticDir });
2013
+ export const handler = createLambdaHandler({ handle, beginRequest, staticDir, routing });
1731
2014
  `;
1732
2015
  }
1733
2016
 
@@ -1803,6 +2086,38 @@ function nativeAddonGuard() {
1803
2086
  };
1804
2087
  }
1805
2088
 
2089
+ /**
2090
+ * Say which Node built-ins the Worker being linked reaches and does not have.
2091
+ *
2092
+ * `--adapter edge` only. Every import the bundler resolves passes through here,
2093
+ * and one naming a module `./internal/worker-builtins.js` measured as a stub at
2094
+ * the compatibility date uf writes is kept with the file that imported it. Only
2095
+ * the ones still in the output are reported — see `survivingImports` for the
2096
+ * fixture that showed why — and as warnings rather than a refusal, for the
2097
+ * reason that module gives: a deployment at a newer date may have the module,
2098
+ * and uf cannot see that deployment. What it can do is say, before anything is
2099
+ * uploaded, which request is going to answer 500 and why.
2100
+ */
2101
+ function workerBuiltinGuard() {
2102
+ const reached = [];
2103
+ return {
2104
+ name: "uf:worker-builtins",
2105
+ enforce: "pre",
2106
+ resolveId(source, importer) {
2107
+ if (importer != null && unavailableOnWorkers(source) != null) {
2108
+ reached.push({ specifier: source, importer });
2109
+ }
2110
+ return null;
2111
+ },
2112
+ generateBundle(_options, bundle) {
2113
+ const chunks = Object.values(bundle).filter((output) => output.type === "chunk");
2114
+ for (const warning of workerBuiltinWarnings(survivingImports(reached, chunks), root)) {
2115
+ this.warn(warning);
2116
+ }
2117
+ },
2118
+ };
2119
+ }
2120
+
1806
2121
  /** `word`, pluralised for `count`. */
1807
2122
  function plural(count, word) {
1808
2123
  return count === 1 ? word : `${word}s`;
@@ -1814,6 +2129,138 @@ async function printConfig() {
1814
2129
  process.exit(0);
1815
2130
  }
1816
2131
 
2132
+ /**
2133
+ * The React Server Components state `@uniflowed/vite` shares with this driver,
2134
+ * or `null` for an application rendered from its modules.
2135
+ *
2136
+ * Read off the plugin rather than decided again here: `rendersFlight` in
2137
+ * `./internal/flight.js` decides from the same `uf.config.js`, and a second
2138
+ * reading of it would be the one that drifts.
2139
+ */
2140
+ function flightStateOf(inline) {
2141
+ const plugins = (inline.plugins ?? []).flat(Number.POSITIVE_INFINITY);
2142
+ return plugins.find((plugin) => plugin?.name === "uf:flow")?.api?.flight ?? null;
2143
+ }
2144
+
2145
+ /** What a later command needs of the rsc build, written beside its output. */
2146
+ const FLIGHT_BUILD_FILE = "uf-flight.json";
2147
+
2148
+ /**
2149
+ * Build the rsc graph into `outDir`.
2150
+ *
2151
+ * `conditions` are a deploy target's own, added to `react-server`; `null` is
2152
+ * the Node server `uf build` writes. Vite's builder rather than `vite.build`,
2153
+ * because the rsc graph is an environment of its own and `vite.build` builds
2154
+ * the two Vite always has.
2155
+ */
2156
+ async function buildRscGraph(vite, inline, state, { outDir, conditions }) {
2157
+ const environment = {
2158
+ build: {
2159
+ outDir,
2160
+ emptyOutDir: true,
2161
+ rollupOptions: {
2162
+ input: { index: FLIGHT_VIRTUAL.entry },
2163
+ output: { entryFileNames: "[name].js", format: "es" },
2164
+ },
2165
+ },
2166
+ };
2167
+ if (conditions != null) {
2168
+ environment.resolve = {
2169
+ conditions: ["react-server", ...conditions],
2170
+ externalConditions: ["react-server", ...conditions],
2171
+ };
2172
+ }
2173
+ const builder = await vite.createBuilder({
2174
+ ...inline,
2175
+ customLogger: eventLogger("warn"),
2176
+ environments: { [RSC_ENVIRONMENT]: environment },
2177
+ });
2178
+ await builder.build(builder.environments[RSC_ENVIRONMENT]);
2179
+ state.rscOutput = path.join(outDir, "index.js");
2180
+ }
2181
+
2182
+ /**
2183
+ * Record the chunk each client module was built into, and write it down.
2184
+ *
2185
+ * Written down because `uf build --adapter` and `uf build --compile` bundle
2186
+ * the server again in a process of their own, and a reference in the rsc
2187
+ * output names its module by path, which only this build's manifest turns
2188
+ * into a URL.
2189
+ */
2190
+ function recordClientChunks(state, manifest, references, rscDir, base = "") {
2191
+ for (const file of references) {
2192
+ const key = path.relative(root, file).split(path.sep).join("/");
2193
+ const chunk = manifest[key];
2194
+ if (chunk == null) {
2195
+ throw new Error(
2196
+ `uf: the client build wrote no chunk for ${key}, which a server component renders as a ` +
2197
+ "client component",
2198
+ );
2199
+ }
2200
+ state.chunkUrls.set(file, `${base}/${chunk.file}`);
2201
+ }
2202
+ writeFileSync(
2203
+ path.join(rscDir, FLIGHT_BUILD_FILE),
2204
+ `${JSON.stringify({ chunkUrls: [...state.chunkUrls] }, null, 2)}\n`,
2205
+ );
2206
+ }
2207
+
2208
+ /** What `recordClientChunks` wrote, for a command that runs after `uf build`. */
2209
+ function loadFlightBuild(state, rscDir) {
2210
+ const file = path.join(rscDir, FLIGHT_BUILD_FILE);
2211
+ if (!existsSync(file)) {
2212
+ throw new Error(
2213
+ `uf: ${path.relative(root, file)} is missing, so there is no rsc graph to render routes ` +
2214
+ "with; run `uf build` first",
2215
+ );
2216
+ }
2217
+ state.chunkUrls = new Map(JSON.parse(readFileSync(file, "utf8")).chunkUrls);
2218
+ state.rscOutput = path.join(rscDir, "index.js");
2219
+ }
2220
+
2221
+ /**
2222
+ * The tags a document React Server Components render needs.
2223
+ *
2224
+ * `assetsFromManifest`'s, with stylesheets from three places in the order they
2225
+ * cascade: the rsc graph's first — every layout's and every server component's
2226
+ * — then the client entry's, then each client module's own, which the client
2227
+ * build emits beside that module's chunk and no import from the entry reaches.
2228
+ *
2229
+ * The rsc build's emitted files are copied under `dist/` so those URLs resolve,
2230
+ * and only its assets: a server bundle's JavaScript is never a deployable file.
2231
+ */
2232
+ function flightAssets(manifest, references, rscDir, outDir, base = "") {
2233
+ const assets = assetsFromManifest(manifest, base);
2234
+ const styles = new Set();
2235
+ const rscManifest = path.join(rscDir, ".vite", "manifest.json");
2236
+ if (existsSync(rscManifest)) {
2237
+ const rscStyles = assetsFromManifest(
2238
+ JSON.parse(readFileSync(rscManifest, "utf8")),
2239
+ base,
2240
+ ).styles;
2241
+ for (const href of rscStyles) styles.add(href);
2242
+ }
2243
+ const rscAssets = path.join(rscDir, "assets");
2244
+ if (existsSync(rscAssets)) {
2245
+ cpSync(rscAssets, path.join(outDir, "assets"), {
2246
+ recursive: true,
2247
+ filter: (from) => !/\.(?:[cm]?js|map)$/.test(from),
2248
+ });
2249
+ }
2250
+ for (const href of assets.styles) styles.add(href);
2251
+ const seen = new Set();
2252
+ const visit = (key) => {
2253
+ if (seen.has(key)) return;
2254
+ seen.add(key);
2255
+ const chunk = manifest[key];
2256
+ if (chunk == null) return;
2257
+ for (const css of chunk.css ?? []) styles.add(`${base}/${css}`);
2258
+ for (const imported of chunk.imports ?? []) visit(imported);
2259
+ };
2260
+ for (const file of references) visit(path.relative(root, file).split(path.sep).join("/"));
2261
+ return { ...assets, styles: [...styles] };
2262
+ }
2263
+
1817
2264
  function readManifest(outDir) {
1818
2265
  const file = path.join(outDir, ".vite", "manifest.json");
1819
2266
  if (!existsSync(file)) throw new Error(`uf: the client build wrote no manifest at ${file}`);
@@ -1949,6 +2396,21 @@ async function renderingPlan(server, prerender) {
1949
2396
  why: "a middleware guards it, and a middleware runs once per request",
1950
2397
  });
1951
2398
  }
2399
+ // And `app.router`'s three lists, by the source each rule matches: a file
2400
+ // can be neither a redirect nor a rewrite, and a header a file is served
2401
+ // with is the host's to add rather than the file's.
2402
+ for (const [key, what] of [
2403
+ ["redirects", "a redirect"],
2404
+ ["rewrites", "a rewrite"],
2405
+ ["headers", "a response header"],
2406
+ ]) {
2407
+ for (const rule of server.routing?.[key] ?? []) {
2408
+ perRequest.push({
2409
+ path: rule.source,
2410
+ why: `\`app.router.${key}\` names it, and ${what} is answered when a request arrives`,
2411
+ });
2412
+ }
2413
+ }
1952
2414
 
1953
2415
  return { urls, perRequest };
1954
2416
  }
@@ -1970,9 +2432,36 @@ function fillParams(routePath, params) {
1970
2432
  .join("/");
1971
2433
  }
1972
2434
 
1973
- function htmlPathFor(outDir, url) {
1974
- const pathname = url.split("?")[0].replace(/^\/+/, "");
1975
- return pathname === ""
1976
- ? path.join(outDir, "index.html")
2435
+ /**
2436
+ * The file a prerendered page is written to.
2437
+ *
2438
+ * `guide/index.html`, which every static host serves at `/guide/` and most at
2439
+ * `/guide`, unless `app.router.trailingSlash` is `"never"` — then `guide.html`,
2440
+ * which the same hosts serve at `/guide` without a redirect to the slash.
2441
+ * Next.js's static export makes the same choice from the same setting.
2442
+ */
2443
+ function htmlPathFor(outDir, url, trailingSlash = "ignore") {
2444
+ const pathname = url.split("?")[0].replace(/^\/+/, "").replace(/\/+$/, "");
2445
+ if (pathname === "") return path.join(outDir, "index.html");
2446
+ return trailingSlash === "never"
2447
+ ? path.join(outDir, `${pathname}.html`)
1977
2448
  : path.join(outDir, pathname, "index.html");
1978
2449
  }
2450
+
2451
+ /**
2452
+ * The URL path at which a static half answers the document `htmlPathFor`
2453
+ * wrote for `url` under `REGENERATED_DIRECTORY`.
2454
+ *
2455
+ * Ending in a slash, so every host answers it with that directory's
2456
+ * `index.html` in the same way: a Node static half tries `index.html` for such
2457
+ * a path, and a Worker's assets binding serves it without the redirect it
2458
+ * answers `…/index.html` with.
2459
+ */
2460
+ function regeneratedDocumentUrl(url) {
2461
+ const pathname = url.split("?")[0].replace(/^\/+/, "").replace(/\/+$/, "");
2462
+ const encoded = pathname
2463
+ .split("/")
2464
+ .map((segment) => encodeURIComponent(segment))
2465
+ .join("/");
2466
+ return pathname === "" ? `/${REGENERATED_DIRECTORY}/` : `/${REGENERATED_DIRECTORY}/${encoded}/`;
2467
+ }