@uniflowed/vite 0.0.0-alpha.35 → 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,6 +42,7 @@
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 { installFlowHooks } from "@uniflowed/host/internal/sync-hooks.js";
45
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";
@@ -54,22 +55,29 @@ import {
54
55
  } from "./internal/worker-builtins.js";
55
56
  import { emit, errorEvent, eventLogger } from "./internal/events.js";
56
57
  import { loadUfConfig, projectConfig } from "./internal/config.js";
57
- import { send, toRequest } from "./internal/http.js";
58
+ import { send, toAddressRequest, toRequest } from "./internal/http.js";
58
59
  import { createOpenApiDocument } from "./internal/openapi.js";
59
60
  import { withProjectConfig } from "./merge.js";
60
61
  import { FLIGHT_VIRTUAL, RSC_ENVIRONMENT } from "./internal/flight.js";
61
- import { VIRTUAL, resolveRouteTarget, scanRoutes } from "./internal/routes.js";
62
+ import { MODULE_GRAPH_FILE, createModuleGraphCollector } from "./internal/module-graph.js";
63
+ import { VIRTUAL, resolveRouteTarget, routingRulesOf, scanRoutes } from "./internal/routes.js";
62
64
  import {
63
65
  BUILD_ID_FILE,
64
66
  DOCUMENT_ASSETS_FILE,
67
+ REGENERATED_DIRECTORY,
68
+ REGENERATION_FILE,
69
+ answerRouting,
70
+ answersInFrontOfFiles,
65
71
  assetsFromManifest,
66
72
  buildIdentity,
67
73
  createPrerenderGate,
68
74
  createServeHandler,
69
75
  documentAssetsFor,
76
+ forViteBase,
70
77
  loadBuild,
71
78
  nodeListener,
72
79
  providerSpecifier,
80
+ readRegeneration,
73
81
  withRequest,
74
82
  } from "./internal/serve.js";
75
83
 
@@ -102,11 +110,23 @@ process.env.UF_PROJECT_ROOT = root;
102
110
  // hooks for that; Bun is started with `--preload` on the same package's
103
111
  // preload instead, and has no `register`.
104
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
+ //
105
123
  // The hooks live in `@uniflowed/host` rather than here: they are how Flow runs
106
124
  // on a Capability JS Host, and nothing in them is Vite's. `uf test` reaches for
107
125
  // the same package, which is what stopped a test run from depending on a
108
126
  // bundler it never loads.
109
- if (typeof Bun === "undefined" && typeof Deno === "undefined") {
127
+ if (typeof Deno !== "undefined") {
128
+ installFlowHooks(root);
129
+ } else if (typeof Bun === "undefined") {
110
130
  register("@uniflowed/host/internal/node-hooks.js", import.meta.url, { data: { root } });
111
131
  }
112
132
 
@@ -160,6 +180,15 @@ async function loadConfig() {
160
180
  return config;
161
181
  }
162
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
+
163
192
  /** The Vite inline config a uf config describes. */
164
193
  async function viteConfig(config, mode) {
165
194
  const { default: uniflowed } = await import("./index.js");
@@ -199,6 +228,9 @@ async function viteConfig(config, mode) {
199
228
  configFile: false,
200
229
  envDir: false,
201
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)}/`,
202
234
  clearScreen: false,
203
235
  customLogger: eventLogger(argument("--log-level") ?? "info"),
204
236
  plugins: [uniflowed({ root, config, target: routeTarget })],
@@ -445,6 +477,26 @@ async function preview() {
445
477
  const draftFirst = {
446
478
  name: "uf:draft-before-files",
447
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
+ }
448
500
  // A prerendered payload is a file whose extension Vite's static middleware
449
501
  // knows no type for, and the router hands bytes to React only when they
450
502
  // are answered as a payload — so a navigation on a preview would silently
@@ -613,6 +665,18 @@ async function build() {
613
665
  // First, because it is what finds the client modules the next pass has
614
666
  // to build; `./internal/flight.js` has the order and the reason for it.
615
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
+ }
616
680
  const rscDir = path.join(root, ".uf", "build", "rsc");
617
681
  if (flight != null) {
618
682
  emit("phase", { name: "rsc" });
@@ -640,7 +704,7 @@ async function build() {
640
704
  });
641
705
  const manifest = readManifest(outDir);
642
706
  if (flight != null) {
643
- recordClientChunks(flight, manifest, references, rscDir);
707
+ recordClientChunks(flight, manifest, references, rscDir, basePathOf(config));
644
708
  // What the summary's "pages in the client bundle" reads. None: a browser
645
709
  // that hydrates a payload imports no page, whichever route it is on.
646
710
  emit("rsc-split", {
@@ -677,6 +741,9 @@ async function build() {
677
741
  // `internal/serve.js`'s `buildIdentity`, which is what reads this.
678
742
  writeFileSync(path.join(serverDir, BUILD_ID_FILE), `${mintBuildId()}\n`);
679
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
+
680
747
  // 3. Which routes this build renders when, and every route it renders now.
681
748
  //
682
749
  // The decision comes from `uf.config.js` and is made in Rust — see
@@ -687,8 +754,8 @@ async function build() {
687
754
  const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
688
755
  const assets =
689
756
  flight == null
690
- ? assetsFromManifest(manifest)
691
- : flightAssets(manifest, references, rscDir, outDir);
757
+ ? assetsFromManifest(manifest, basePathOf(config))
758
+ : flightAssets(manifest, references, rscDir, outDir, basePathOf(config));
692
759
  // Recorded beside the server bundle, because whatever serves this build
693
760
  // later cannot recompute them from the client manifest alone; see
694
761
  // `documentAssetsFor`.
@@ -743,33 +810,86 @@ async function build() {
743
810
  failures.push(url);
744
811
  emit("page-failed", { url, ...errorEvent(error) });
745
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 = {};
746
833
  for (const url of pages) {
747
- let result;
834
+ let declared;
835
+ const renderedAt = Date.now();
748
836
  try {
749
- result = await server.prerender(url, assets);
837
+ declared = await collectCacheDeclarations(() => server.prerender(url, assets));
750
838
  } catch (error) {
751
839
  failed(url, error);
752
840
  continue;
753
841
  }
842
+ const result = declared.value;
754
843
  if (result.error != null) {
755
844
  failed(url, result.error);
756
845
  continue;
757
846
  }
758
- 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);
759
855
  mkdirSync(path.dirname(file), { recursive: true });
760
856
  writeFileSync(file, result.html);
761
857
  // The payload the document was rendered from, beside it: what a browser
762
- // navigating to this route fetches, from whatever serves the files.
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.
763
862
  if (result.payload != null) {
764
- writeFileSync(path.join(path.dirname(file), "__uf.flight"), result.payload);
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
+ };
765
878
  }
766
879
  emit("page", {
767
880
  url,
768
881
  file: path.relative(root, file),
769
882
  status: result.status,
770
883
  bytes: Buffer.byteLength(result.html),
884
+ regenerates,
771
885
  });
772
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
+ }
773
893
  // One `404.html`, from the boundary at the router root: a static host serves
774
894
  // a single error document for the whole site, so the nested boundaries a
775
895
  // project declares are the server's and the client's to render, not
@@ -1150,13 +1270,21 @@ async function compile() {
1150
1270
  * `static` is deliberately absent; `uf_config`'s
1151
1271
  * `DeployAdapter::is_implemented` is the other half of that fact and
1152
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`].
1153
1280
  */
1154
1281
  const ADAPTERS = {
1155
1282
  node: {
1156
- entries: (document, cache, build, schedules) => ({
1157
- handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build),
1283
+ entries: (document, cache, build, schedules, regeneration) => ({
1284
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build, regeneration),
1158
1285
  server: nodeEntrySource("./handler.js", schedules),
1159
1286
  }),
1287
+ regenerationStore: "filesystem",
1160
1288
  },
1161
1289
  // The same two files as `node`, with `@uniflowed/server/bun` in place of
1162
1290
  // `@uniflowed/server/node`. That module is `./internal/static.js` for every
@@ -1173,32 +1301,40 @@ const ADAPTERS = {
1173
1301
  // nobody here. `edge` pays that price because it must: there is no
1174
1302
  // `node:stream` in a Worker.
1175
1303
  bun: {
1176
- entries: (document, cache, build, schedules) => ({
1177
- handler: handlerEntrySource(document, cache, BUN_CAPABILITIES, build),
1304
+ entries: (document, cache, build, schedules, regeneration) => ({
1305
+ handler: handlerEntrySource(document, cache, BUN_CAPABILITIES, build, regeneration),
1178
1306
  server: bunEntrySource("./handler.js", schedules),
1179
1307
  }),
1308
+ regenerationStore: "filesystem",
1180
1309
  },
1181
1310
  deno: {
1182
- entries: (document, cache, build, schedules) => ({
1183
- handler: handlerEntrySource(document, cache, DENO_CAPABILITIES, build),
1311
+ entries: (document, cache, build, schedules, regeneration) => ({
1312
+ handler: handlerEntrySource(document, cache, DENO_CAPABILITIES, build, regeneration),
1184
1313
  server: denoEntrySource("./handler.js", schedules),
1185
1314
  }),
1315
+ regenerationStore: "filesystem",
1186
1316
  },
1187
1317
  // The same two files. What `--adapter container` adds is a `Dockerfile` and
1188
1318
  // a `.dockerignore`, and both are plain text that `uf` writes beside this
1189
1319
  // output rather than anything the bundler produces — see `uf_cli`'s
1190
1320
  // `commands::deploy`.
1191
1321
  container: {
1192
- entries: (document, cache, build, schedules) => ({
1193
- handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build),
1322
+ entries: (document, cache, build, schedules, regeneration) => ({
1323
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build, regeneration),
1194
1324
  server: nodeEntrySource("./handler.js", schedules),
1195
1325
  }),
1326
+ regenerationStore: "filesystem",
1196
1327
  },
1197
1328
  edge: {
1198
- entries: (document, cache, build, schedules) => ({
1199
- handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES, build),
1329
+ entries: (document, cache, build, schedules, regeneration) => ({
1330
+ handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES, build, regeneration),
1200
1331
  worker: workerEntrySource("./handler.js", schedules),
1201
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",
1202
1338
  // `workerd` first, so React resolves to the build that has
1203
1339
  // `renderToReadableStream` and no `node:stream`. `browser` and `module`
1204
1340
  // after it are Vite's own SSR defaults, kept so a dependency with no
@@ -1215,10 +1351,14 @@ const ADAPTERS = {
1215
1351
  workerBuiltins: true,
1216
1352
  },
1217
1353
  serverless: {
1218
- entries: (document, cache, build) => ({
1219
- handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES, build),
1354
+ entries: (document, cache, build, _schedules, regeneration) => ({
1355
+ handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES, build, regeneration),
1220
1356
  lambda: lambdaEntrySource("./handler.js"),
1221
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,
1222
1362
  },
1223
1363
  };
1224
1364
 
@@ -1349,8 +1489,8 @@ async function deploy() {
1349
1489
  // `randomUUID()` here would key the adapter's copy differently from the one
1350
1490
  // `uf start` serves out of `.uf/build/`, which is two caches for one build.
1351
1491
  const buildId = await buildIdentity(root, path.join(".uf", "build", "server"));
1352
- const cacheConfig = config.app?.rendering?.cache;
1353
- if (shape.filesystem === false && cacheConfig?.store === "filesystem") {
1492
+ const declaredCache = config.app?.rendering?.cache;
1493
+ if (shape.filesystem === false && declaredCache?.store === "filesystem") {
1354
1494
  throw new Error(
1355
1495
  `uf: rendering.cache.store is "filesystem" and \`--adapter ${adapter}\` has no ` +
1356
1496
  "filesystem. Name a module exporting `createCacheProvider` instead — a KV " +
@@ -1358,7 +1498,28 @@ async function deploy() {
1358
1498
  "docs/app/guide/cache.",
1359
1499
  );
1360
1500
  }
1361
- 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);
1362
1523
  const input = {};
1363
1524
  for (const name of Object.keys(entries)) {
1364
1525
  writeFileSync(path.join(work, `${name}.js`), entries[name]);
@@ -1495,7 +1656,7 @@ async function deploy() {
1495
1656
  * `buildIdentity` is where it came from and
1496
1657
  * `packages/server/internal/cache-key.js` is why it exists.
1497
1658
  */
1498
- function handlerEntrySource(document, cache, capabilities, build) {
1659
+ function handlerEntrySource(document, cache, capabilities, build, regeneration) {
1499
1660
  const route = cache?.route === true;
1500
1661
  const fetchCache = cache?.fetch === true;
1501
1662
  // Nothing at all when both switches are off, so a default project's
@@ -1509,6 +1670,10 @@ function handlerEntrySource(document, cache, capabilities, build) {
1509
1670
  `document: ${JSON.stringify(document)}`,
1510
1671
  ...(store ? ["cache"] : []),
1511
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)}`] : []),
1512
1677
  ].join(", ");
1513
1678
  const cacheImport = store ? 'import { createCacheStore } from "@uniflowed/server/cache";\n' : "";
1514
1679
  const providerImport = durable == null ? "" : `${durable.import}\n`;
@@ -1547,8 +1712,11 @@ const capabilities = ${capabilities.name}();
1547
1712
 
1548
1713
  export const fetch = createFetchHandler({ ${options} });
1549
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;
1550
1718
 
1551
- export default { fetch, beginRequest };
1719
+ export default { fetch, beginRequest, routing };
1552
1720
  `;
1553
1721
  }
1554
1722
 
@@ -1656,7 +1824,7 @@ ${cron.imports}
1656
1824
  // \`@uniflowed/server/node\` above, because the request has to be established in
1657
1825
  // the storage the *application* reads, which is the copy bundled into
1658
1826
  // \`handler.js\`. See ubugeeei-prod/uf#389.
1659
- import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1827
+ import { beginRequest, fetch, routing } from ${JSON.stringify(handlerSpecifier)};
1660
1828
 
1661
1829
  // Resolved from this file and not from the working directory: a process
1662
1830
  // manager, a container entrypoint and a person in a shell each start a server
@@ -1669,7 +1837,7 @@ ${cron.declarations}
1669
1837
  // (ubugeeei-prod/uf#204) and this entry is a module, so it would work; \`.catch\`
1670
1838
  // is the better spelling regardless — a server that cannot take its port should
1671
1839
  // say so and exit non-zero, rather than die as an unhandled rejection.
1672
- serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1840
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
1673
1841
  process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1674
1842
  process.exit(1);
1675
1843
  });
@@ -1696,7 +1864,7 @@ ${cron.imports}
1696
1864
  // \`@uniflowed/server/bun\` above, because the request has to be established in
1697
1865
  // the storage the *application* reads, which is the copy bundled into
1698
1866
  // \`handler.js\`. See ubugeeei-prod/uf#389.
1699
- import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1867
+ import { beginRequest, fetch, routing } from ${JSON.stringify(handlerSpecifier)};
1700
1868
 
1701
1869
  // Resolved from this file and not from the working directory: a process
1702
1870
  // manager, a container entrypoint and a person in a shell each start a server
@@ -1705,7 +1873,7 @@ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1705
1873
  // trap in it.
1706
1874
  const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1707
1875
  ${cron.declarations}
1708
- serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1876
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
1709
1877
  process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1710
1878
  process.exit(1);
1711
1879
  });
@@ -1743,7 +1911,7 @@ globalThis.Buffer ??= {
1743
1911
  };
1744
1912
  globalThis.setImmediate ??= (callback, ...args) => setTimeout(callback, 0, ...args);
1745
1913
  globalThis.clearImmediate ??= (handle) => clearTimeout(handle);
1746
- const { beginRequest, fetch } = await import(${JSON.stringify(handlerSpecifier)});
1914
+ const { beginRequest, fetch, routing } = await import(${JSON.stringify(handlerSpecifier)});
1747
1915
 
1748
1916
  // Resolved from this file and not from the working directory: a process
1749
1917
  // manager and a person in a shell each start a server from wherever they
@@ -1751,7 +1919,7 @@ const { beginRequest, fetch } = await import(${JSON.stringify(handlerSpecifier)}
1751
1919
  // started from inside itself would be a deployment with a trap in it.
1752
1920
  const staticDir = decodeURIComponent(new URL("./static", import.meta.url).pathname);
1753
1921
  ${cron.declarations}
1754
- serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1922
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
1755
1923
  console.error(\`uf: \${error?.message ?? String(error)}\`);
1756
1924
  Deno.exit(1);
1757
1925
  });
@@ -1780,13 +1948,13 @@ function workerEntrySource(handlerSpecifier, schedules) {
1780
1948
  return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1781
1949
  import { createWorkerFetch, installWorkerLogger } from "@uniflowed/server/edge";
1782
1950
 
1783
- import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1951
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
1784
1952
 
1785
1953
  // After the imports, so a logger the application installed while it loaded is
1786
1954
  // the one that stays; see \`installWorkerLogger\`.
1787
1955
  installWorkerLogger();
1788
1956
 
1789
- export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1957
+ export default { fetch: createWorkerFetch({ handle, beginRequest, routing }) };
1790
1958
  `;
1791
1959
  }
1792
1960
 
@@ -1797,7 +1965,7 @@ export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1797
1965
  return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1798
1966
  import { createWorkerFetch, createWorkerScheduled, installWorkerLogger } from "@uniflowed/server/edge";
1799
1967
 
1800
- import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1968
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
1801
1969
 
1802
1970
  // After the imports, so a logger the application installed while it loaded is
1803
1971
  // the one that stays; see \`installWorkerLogger\`.
@@ -1808,7 +1976,7 @@ installWorkerLogger();
1808
1976
  const routes = ${JSON.stringify(routes, null, 2)};
1809
1977
 
1810
1978
  export default {
1811
- fetch: createWorkerFetch({ handle, beginRequest }),
1979
+ fetch: createWorkerFetch({ handle, beginRequest, routing }),
1812
1980
  scheduled: createWorkerScheduled({ handle, beginRequest, routes }),
1813
1981
  };
1814
1982
  `;
@@ -1834,7 +2002,7 @@ import { fileURLToPath } from "node:url";
1834
2002
 
1835
2003
  import { createLambdaHandler } from "@uniflowed/server/lambda";
1836
2004
 
1837
- import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
2005
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
1838
2006
 
1839
2007
  // Resolved from this file and not from the working directory: Lambda sets the
1840
2008
  // working directory to the task root today and is under no obligation to keep
@@ -1842,7 +2010,7 @@ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)
1842
2010
  // deployment with a trap in it.
1843
2011
  const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1844
2012
 
1845
- export const handler = createLambdaHandler({ handle, beginRequest, staticDir });
2013
+ export const handler = createLambdaHandler({ handle, beginRequest, staticDir, routing });
1846
2014
  `;
1847
2015
  }
1848
2016
 
@@ -2019,7 +2187,7 @@ async function buildRscGraph(vite, inline, state, { outDir, conditions }) {
2019
2187
  * output names its module by path, which only this build's manifest turns
2020
2188
  * into a URL.
2021
2189
  */
2022
- function recordClientChunks(state, manifest, references, rscDir) {
2190
+ function recordClientChunks(state, manifest, references, rscDir, base = "") {
2023
2191
  for (const file of references) {
2024
2192
  const key = path.relative(root, file).split(path.sep).join("/");
2025
2193
  const chunk = manifest[key];
@@ -2029,7 +2197,7 @@ function recordClientChunks(state, manifest, references, rscDir) {
2029
2197
  "client component",
2030
2198
  );
2031
2199
  }
2032
- state.chunkUrls.set(file, `/${chunk.file}`);
2200
+ state.chunkUrls.set(file, `${base}/${chunk.file}`);
2033
2201
  }
2034
2202
  writeFileSync(
2035
2203
  path.join(rscDir, FLIGHT_BUILD_FILE),
@@ -2061,12 +2229,15 @@ function loadFlightBuild(state, rscDir) {
2061
2229
  * The rsc build's emitted files are copied under `dist/` so those URLs resolve,
2062
2230
  * and only its assets: a server bundle's JavaScript is never a deployable file.
2063
2231
  */
2064
- function flightAssets(manifest, references, rscDir, outDir) {
2065
- const assets = assetsFromManifest(manifest);
2232
+ function flightAssets(manifest, references, rscDir, outDir, base = "") {
2233
+ const assets = assetsFromManifest(manifest, base);
2066
2234
  const styles = new Set();
2067
2235
  const rscManifest = path.join(rscDir, ".vite", "manifest.json");
2068
2236
  if (existsSync(rscManifest)) {
2069
- const rscStyles = assetsFromManifest(JSON.parse(readFileSync(rscManifest, "utf8"))).styles;
2237
+ const rscStyles = assetsFromManifest(
2238
+ JSON.parse(readFileSync(rscManifest, "utf8")),
2239
+ base,
2240
+ ).styles;
2070
2241
  for (const href of rscStyles) styles.add(href);
2071
2242
  }
2072
2243
  const rscAssets = path.join(rscDir, "assets");
@@ -2083,7 +2254,7 @@ function flightAssets(manifest, references, rscDir, outDir) {
2083
2254
  seen.add(key);
2084
2255
  const chunk = manifest[key];
2085
2256
  if (chunk == null) return;
2086
- for (const css of chunk.css ?? []) styles.add(`/${css}`);
2257
+ for (const css of chunk.css ?? []) styles.add(`${base}/${css}`);
2087
2258
  for (const imported of chunk.imports ?? []) visit(imported);
2088
2259
  };
2089
2260
  for (const file of references) visit(path.relative(root, file).split(path.sep).join("/"));
@@ -2225,6 +2396,21 @@ async function renderingPlan(server, prerender) {
2225
2396
  why: "a middleware guards it, and a middleware runs once per request",
2226
2397
  });
2227
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
+ }
2228
2414
 
2229
2415
  return { urls, perRequest };
2230
2416
  }
@@ -2246,9 +2432,36 @@ function fillParams(routePath, params) {
2246
2432
  .join("/");
2247
2433
  }
2248
2434
 
2249
- function htmlPathFor(outDir, url) {
2250
- const pathname = url.split("?")[0].replace(/^\/+/, "");
2251
- return pathname === ""
2252
- ? 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`)
2253
2448
  : path.join(outDir, pathname, "index.html");
2254
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
+ }