@uniflowed/vite 0.0.0-alpha.35 → 0.0.0-alpha.39

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,27 @@ async function viteConfig(config, mode) {
199
228
  configFile: false,
200
229
  envDir: false,
201
230
  mode,
231
+ // Vite's dependency cache, kept in the project whatever is above it.
232
+ // Vite's own default is the nearest `package.json`'s `node_modules/.vite`,
233
+ // and a uf project needs no `package.json`. One inside another repository
234
+ // pre-bundled into that repository's `node_modules`, in one directory
235
+ // shared with every other such project there and emptied by each dev
236
+ // server that started — which is how two `uf dev` servers came to answer
237
+ // each other's pre-bundled files with `504 Outdated Optimize Dep`
238
+ // (ubugeeei-prod/uf#1141).
239
+ //
240
+ // This directory rather than `.vite` or `.uf/cache/vite`, because it is
241
+ // where Vite already puts the cache for a project with its own
242
+ // `package.json`, so nothing moves for one: the URLs stay
243
+ // `/node_modules/.vite/deps/…`, and the files stay under `node_modules`,
244
+ // which git ignores, Vite's watcher skips and Vite's sourcemap ignore list
245
+ // treats as somebody else's code. `cacheDir` is shared config, so every
246
+ // environment's optimizer follows it — `deps`, `deps_ssr`, `deps_rsc` —
247
+ // and a project's own `vite.cacheDir` is merged over it like any option.
248
+ cacheDir: path.join(root, "node_modules", ".vite"),
249
+ // `app.router.basePath`: where Vite serves the modules in development, and
250
+ // what it puts in front of every asset URL a build writes.
251
+ base: basePathOf(config) === "" ? "/" : `${basePathOf(config)}/`,
202
252
  clearScreen: false,
203
253
  customLogger: eventLogger(argument("--log-level") ?? "info"),
204
254
  plugins: [uniflowed({ root, config, target: routeTarget })],
@@ -445,6 +495,26 @@ async function preview() {
445
495
  const draftFirst = {
446
496
  name: "uf:draft-before-files",
447
497
  configurePreviewServer(previewServer) {
498
+ // `app.router.headers` and `redirects`, first of all: a redirect answers
499
+ // before a file is looked for, and a header is pinned on the response so
500
+ // it survives the `writeHead` Vite's file middleware writes its own
501
+ // with. `uf start` and every adapter put the same two in front of their
502
+ // static half; the application's own answers get them from
503
+ // `createServeHandler` behind. See `internal/serve.js`'s `answerRouting`.
504
+ const routing = build?.entry?.routing;
505
+ if (answersInFrontOfFiles(routing)) {
506
+ previewServer.middlewares.use((request, response, next) => {
507
+ answerRouting(routing, toAddressRequest(request), response)
508
+ .then((answered) => {
509
+ if (answered) return;
510
+ // The bare base path is the root, which Vite only knows as
511
+ // `/docs/`; see `forViteBase`.
512
+ forViteBase(routing, request);
513
+ next();
514
+ })
515
+ .catch(next);
516
+ });
517
+ }
448
518
  // A prerendered payload is a file whose extension Vite's static middleware
449
519
  // knows no type for, and the router hands bytes to React only when they
450
520
  // are answered as a payload — so a navigation on a preview would silently
@@ -613,6 +683,18 @@ async function build() {
613
683
  // First, because it is what finds the client modules the next pass has
614
684
  // to build; `./internal/flight.js` has the order and the reason for it.
615
685
  const flight = flightStateOf(inline);
686
+ // `uf build --analyze`: the graph every bundle below is built from, for uf
687
+ // to attribute to routes; see `./internal/module-graph.js`. A client module
688
+ // is an entry the browser loads because a server component names it, not on
689
+ // every page, so it is not one of the client bundle's shared entries.
690
+ const graph = flag("--analyze")
691
+ ? createModuleGraphCollector(root, {
692
+ isReference: (file) => flight?.clientModules.has(file) ?? false,
693
+ })
694
+ : null;
695
+ if (graph != null) {
696
+ inline.plugins = [...(inline.plugins ?? []), graph.plugin];
697
+ }
616
698
  const rscDir = path.join(root, ".uf", "build", "rsc");
617
699
  if (flight != null) {
618
700
  emit("phase", { name: "rsc" });
@@ -640,7 +722,7 @@ async function build() {
640
722
  });
641
723
  const manifest = readManifest(outDir);
642
724
  if (flight != null) {
643
- recordClientChunks(flight, manifest, references, rscDir);
725
+ recordClientChunks(flight, manifest, references, rscDir, basePathOf(config));
644
726
  // What the summary's "pages in the client bundle" reads. None: a browser
645
727
  // that hydrates a payload imports no page, whichever route it is on.
646
728
  emit("rsc-split", {
@@ -677,6 +759,9 @@ async function build() {
677
759
  // `internal/serve.js`'s `buildIdentity`, which is what reads this.
678
760
  writeFileSync(path.join(serverDir, BUILD_ID_FILE), `${mintBuildId()}\n`);
679
761
 
762
+ // Every bundle is built by here, and the prerender below builds none.
763
+ graph?.write(path.join(root, ".uf", "build", "meta", MODULE_GRAPH_FILE));
764
+
680
765
  // 3. Which routes this build renders when, and every route it renders now.
681
766
  //
682
767
  // The decision comes from `uf.config.js` and is made in Rust — see
@@ -687,8 +772,8 @@ async function build() {
687
772
  const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
688
773
  const assets =
689
774
  flight == null
690
- ? assetsFromManifest(manifest)
691
- : flightAssets(manifest, references, rscDir, outDir);
775
+ ? assetsFromManifest(manifest, basePathOf(config))
776
+ : flightAssets(manifest, references, rscDir, outDir, basePathOf(config));
692
777
  // Recorded beside the server bundle, because whatever serves this build
693
778
  // later cannot recompute them from the client manifest alone; see
694
779
  // `documentAssetsFor`.
@@ -743,33 +828,86 @@ async function build() {
743
828
  failures.push(url);
744
829
  emit("page-failed", { url, ...errorEvent(error) });
745
830
  };
831
+ //
832
+ // Each prerender runs inside a fill that stores nothing, so what a page states
833
+ // with `cacheLife`, `cacheTag` and `noStore` is something this loop can read
834
+ // rather than a call that throws for want of a scope.
835
+ //
836
+ // A page is written for regeneration when `uf` passed `--regenerate` — `isr`
837
+ // allowed in `app.rendering.modes`, `rendering.cache.route` on, and a server
838
+ // deployed to regenerate it — and the page stated a lifetime, called no
839
+ // `noStore`, and answered 200. Its document goes under
840
+ // `REGENERATED_DIRECTORY` rather than at its own URL, so no static half
841
+ // answers the page: the server does, starting from this document, until the
842
+ // lifetime has passed. What it stated goes into `REGENERATION_FILE` beside the
843
+ // server bundle. Every other page is the document it has always been.
844
+ //
845
+ // A tag without a lifetime is not enough, for the route cache's own reason:
846
+ // an entry with no end is one another process could serve from its memory
847
+ // for ever after `revalidateTag` took it out of the shared store.
848
+ const { collectCacheDeclarations } = await import("@uniflowed/server/cache");
849
+ const regenerate = flag("--regenerate");
850
+ const regenerated = {};
746
851
  for (const url of pages) {
747
- let result;
852
+ let declared;
853
+ const renderedAt = Date.now();
748
854
  try {
749
- result = await server.prerender(url, assets);
855
+ declared = await collectCacheDeclarations(() => server.prerender(url, assets));
750
856
  } catch (error) {
751
857
  failed(url, error);
752
858
  continue;
753
859
  }
860
+ const result = declared.value;
754
861
  if (result.error != null) {
755
862
  failed(url, result.error);
756
863
  continue;
757
864
  }
758
- const file = htmlPathFor(outDir, url);
865
+ const lifetime = declared.lifetime;
866
+ const regenerates =
867
+ regenerate && result.status === 200 && declared.denied == null && lifetime != null;
868
+ // The trailing-slash policy decides a served page's file name; a page the
869
+ // build regenerates keeps the one layout the regeneration reads.
870
+ const file = regenerates
871
+ ? htmlPathFor(path.join(outDir, REGENERATED_DIRECTORY), url)
872
+ : htmlPathFor(outDir, url, routingRulesOf(config.app?.router).trailingSlash);
759
873
  mkdirSync(path.dirname(file), { recursive: true });
760
874
  writeFileSync(file, result.html);
761
875
  // The payload the document was rendered from, beside it: what a browser
762
- // navigating to this route fetches, from whatever serves the files.
876
+ // navigating to this route fetches, from whatever serves the files. Beside
877
+ // the document wherever the document went, so a page written for
878
+ // regeneration leaves no file at its route's own payload URL answering with
879
+ // the build's copy for ever; the server answers that URL instead.
763
880
  if (result.payload != null) {
764
- writeFileSync(path.join(path.dirname(file), "__uf.flight"), result.payload);
881
+ // Beside the route's directory whatever the document is called:
882
+ // `guide/index.html` and `guide.html` both put it at `guide/__uf.flight`.
883
+ const payloadDirectory =
884
+ path.basename(file) === "index.html" ? path.dirname(file) : file.slice(0, -".html".length);
885
+ mkdirSync(payloadDirectory, { recursive: true });
886
+ writeFileSync(path.join(payloadDirectory, "__uf.flight"), result.payload);
887
+ }
888
+ if (regenerates) {
889
+ regenerated[url] = {
890
+ document: regeneratedDocumentUrl(url),
891
+ renderedAt,
892
+ revalidate: lifetime.revalidate,
893
+ expire: lifetime.expire ?? null,
894
+ tags: declared.tags,
895
+ };
765
896
  }
766
897
  emit("page", {
767
898
  url,
768
899
  file: path.relative(root, file),
769
900
  status: result.status,
770
901
  bytes: Buffer.byteLength(result.html),
902
+ regenerates,
771
903
  });
772
904
  }
905
+ if (Object.keys(regenerated).length > 0) {
906
+ writeFileSync(
907
+ path.join(serverDir, REGENERATION_FILE),
908
+ `${JSON.stringify({ pages: regenerated }, null, 2)}\n`,
909
+ );
910
+ }
773
911
  // One `404.html`, from the boundary at the router root: a static host serves
774
912
  // a single error document for the whole site, so the nested boundaries a
775
913
  // project declares are the server's and the client's to render, not
@@ -1150,13 +1288,21 @@ async function compile() {
1150
1288
  * `static` is deliberately absent; `uf_config`'s
1151
1289
  * `DeployAdapter::is_implemented` is the other half of that fact and
1152
1290
  * `docs/app/reference/cli/$page.mdx` says why.
1291
+ *
1292
+ * `regenerationStore` is where a target keeps the pages a build regenerates
1293
+ * when `rendering.cache.store` names nothing: a disk for a process with one,
1294
+ * Workers KV for a Worker, and `null` for a Lambda, which keeps nothing between
1295
+ * invocations and is refused by name instead. A regenerated page kept only in
1296
+ * memory would go back to the build's copy on every restart, which is a page
1297
+ * that travels back in time. See [`deploy`].
1153
1298
  */
1154
1299
  const ADAPTERS = {
1155
1300
  node: {
1156
- entries: (document, cache, build, schedules) => ({
1157
- handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build),
1301
+ entries: (document, cache, build, schedules, regeneration) => ({
1302
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build, regeneration),
1158
1303
  server: nodeEntrySource("./handler.js", schedules),
1159
1304
  }),
1305
+ regenerationStore: "filesystem",
1160
1306
  },
1161
1307
  // The same two files as `node`, with `@uniflowed/server/bun` in place of
1162
1308
  // `@uniflowed/server/node`. That module is `./internal/static.js` for every
@@ -1173,32 +1319,40 @@ const ADAPTERS = {
1173
1319
  // nobody here. `edge` pays that price because it must: there is no
1174
1320
  // `node:stream` in a Worker.
1175
1321
  bun: {
1176
- entries: (document, cache, build, schedules) => ({
1177
- handler: handlerEntrySource(document, cache, BUN_CAPABILITIES, build),
1322
+ entries: (document, cache, build, schedules, regeneration) => ({
1323
+ handler: handlerEntrySource(document, cache, BUN_CAPABILITIES, build, regeneration),
1178
1324
  server: bunEntrySource("./handler.js", schedules),
1179
1325
  }),
1326
+ regenerationStore: "filesystem",
1180
1327
  },
1181
1328
  deno: {
1182
- entries: (document, cache, build, schedules) => ({
1183
- handler: handlerEntrySource(document, cache, DENO_CAPABILITIES, build),
1329
+ entries: (document, cache, build, schedules, regeneration) => ({
1330
+ handler: handlerEntrySource(document, cache, DENO_CAPABILITIES, build, regeneration),
1184
1331
  server: denoEntrySource("./handler.js", schedules),
1185
1332
  }),
1333
+ regenerationStore: "filesystem",
1186
1334
  },
1187
1335
  // The same two files. What `--adapter container` adds is a `Dockerfile` and
1188
1336
  // a `.dockerignore`, and both are plain text that `uf` writes beside this
1189
1337
  // output rather than anything the bundler produces — see `uf_cli`'s
1190
1338
  // `commands::deploy`.
1191
1339
  container: {
1192
- entries: (document, cache, build, schedules) => ({
1193
- handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build),
1340
+ entries: (document, cache, build, schedules, regeneration) => ({
1341
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build, regeneration),
1194
1342
  server: nodeEntrySource("./handler.js", schedules),
1195
1343
  }),
1344
+ regenerationStore: "filesystem",
1196
1345
  },
1197
1346
  edge: {
1198
- entries: (document, cache, build, schedules) => ({
1199
- handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES, build),
1347
+ entries: (document, cache, build, schedules, regeneration) => ({
1348
+ handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES, build, regeneration),
1200
1349
  worker: workerEntrySource("./handler.js", schedules),
1201
1350
  }),
1351
+ // Workers KV, through the same module seam a project's own provider goes
1352
+ // through. `packages/server/cache-kv.js` argues KV over the Cache API: the
1353
+ // seam has to find every entry under a tag, and the Cache API cannot list
1354
+ // what it holds.
1355
+ regenerationStore: "@uniflowed/server/cache/kv",
1202
1356
  // `workerd` first, so React resolves to the build that has
1203
1357
  // `renderToReadableStream` and no `node:stream`. `browser` and `module`
1204
1358
  // after it are Vite's own SSR defaults, kept so a dependency with no
@@ -1215,10 +1369,14 @@ const ADAPTERS = {
1215
1369
  workerBuiltins: true,
1216
1370
  },
1217
1371
  serverless: {
1218
- entries: (document, cache, build) => ({
1219
- handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES, build),
1372
+ entries: (document, cache, build, _schedules, regeneration) => ({
1373
+ handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES, build, regeneration),
1220
1374
  lambda: lambdaEntrySource("./handler.js"),
1221
1375
  }),
1376
+ // A Lambda's memory lasts one instance and its disk is that instance's
1377
+ // `/tmp`, so neither is somewhere a regenerated page survives. A build that
1378
+ // regenerates pages has to name a provider module; see [`deploy`].
1379
+ regenerationStore: null,
1222
1380
  },
1223
1381
  };
1224
1382
 
@@ -1349,8 +1507,8 @@ async function deploy() {
1349
1507
  // `randomUUID()` here would key the adapter's copy differently from the one
1350
1508
  // `uf start` serves out of `.uf/build/`, which is two caches for one build.
1351
1509
  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") {
1510
+ const declaredCache = config.app?.rendering?.cache;
1511
+ if (shape.filesystem === false && declaredCache?.store === "filesystem") {
1354
1512
  throw new Error(
1355
1513
  `uf: rendering.cache.store is "filesystem" and \`--adapter ${adapter}\` has no ` +
1356
1514
  "filesystem. Name a module exporting `createCacheProvider` instead — a KV " +
@@ -1358,7 +1516,28 @@ async function deploy() {
1358
1516
  "docs/app/guide/cache.",
1359
1517
  );
1360
1518
  }
1361
- const entries = shape.entries(document, cacheConfig, buildId, schedules);
1519
+ // The pages this build regenerates, and where this target keeps what it
1520
+ // regenerates when the project named no store: the adapter's
1521
+ // `regenerationStore`. A target with nowhere refuses by name and lists the
1522
+ // pages, rather than deploying pages that every cold start takes back to the
1523
+ // build's copy.
1524
+ const regeneration = await readRegeneration(path.join(root, ".uf", "build", "server"));
1525
+ let cacheConfig = declaredCache;
1526
+ if (regeneration != null && declaredCache?.store == null) {
1527
+ if (shape.regenerationStore == null) {
1528
+ const pages = Object.keys(regeneration.pages);
1529
+ throw new Error(
1530
+ `uf: this build regenerates ${pages.length} ${plural(pages.length, "page")} ` +
1531
+ `(${pages.join(", ")}), and \`--adapter ${adapter}\` has nowhere of its own to keep ` +
1532
+ "a regenerated page: an instance's memory and its /tmp both go with the instance. " +
1533
+ "Name a module exporting `createCacheProvider` in rendering.cache.store, or leave " +
1534
+ "`isr` out of app.rendering.modes to prerender those pages as documents that do not " +
1535
+ "change. See docs/app/guide/rendering.",
1536
+ );
1537
+ }
1538
+ cacheConfig = { ...declaredCache, store: shape.regenerationStore };
1539
+ }
1540
+ const entries = shape.entries(document, cacheConfig, buildId, schedules, regeneration);
1362
1541
  const input = {};
1363
1542
  for (const name of Object.keys(entries)) {
1364
1543
  writeFileSync(path.join(work, `${name}.js`), entries[name]);
@@ -1495,7 +1674,7 @@ async function deploy() {
1495
1674
  * `buildIdentity` is where it came from and
1496
1675
  * `packages/server/internal/cache-key.js` is why it exists.
1497
1676
  */
1498
- function handlerEntrySource(document, cache, capabilities, build) {
1677
+ function handlerEntrySource(document, cache, capabilities, build, regeneration) {
1499
1678
  const route = cache?.route === true;
1500
1679
  const fetchCache = cache?.fetch === true;
1501
1680
  // Nothing at all when both switches are off, so a default project's
@@ -1509,6 +1688,10 @@ function handlerEntrySource(document, cache, capabilities, build) {
1509
1688
  `document: ${JSON.stringify(document)}`,
1510
1689
  ...(store ? ["cache"] : []),
1511
1690
  "capabilities",
1691
+ // The pages this build regenerates, baked in beside the document and for
1692
+ // the same reason: the manifest exists on the machine doing the build, and
1693
+ // the deployed directory has only what this file carries.
1694
+ ...(store && regeneration != null ? [`regeneration: ${JSON.stringify(regeneration)}`] : []),
1512
1695
  ].join(", ");
1513
1696
  const cacheImport = store ? 'import { createCacheStore } from "@uniflowed/server/cache";\n' : "";
1514
1697
  const providerImport = durable == null ? "" : `${durable.import}\n`;
@@ -1547,8 +1730,11 @@ const capabilities = ${capabilities.name}();
1547
1730
 
1548
1731
  export const fetch = createFetchHandler({ ${options} });
1549
1732
  export const beginRequest = app.beginRequest;
1733
+ // \`app.router\`'s redirects and headers, for the entry beside this file to put
1734
+ // in front of its static half. Rewrites are \`fetch\`'s own.
1735
+ export const routing = app.routing;
1550
1736
 
1551
- export default { fetch, beginRequest };
1737
+ export default { fetch, beginRequest, routing };
1552
1738
  `;
1553
1739
  }
1554
1740
 
@@ -1656,7 +1842,7 @@ ${cron.imports}
1656
1842
  // \`@uniflowed/server/node\` above, because the request has to be established in
1657
1843
  // the storage the *application* reads, which is the copy bundled into
1658
1844
  // \`handler.js\`. See ubugeeei-prod/uf#389.
1659
- import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1845
+ import { beginRequest, fetch, routing } from ${JSON.stringify(handlerSpecifier)};
1660
1846
 
1661
1847
  // Resolved from this file and not from the working directory: a process
1662
1848
  // manager, a container entrypoint and a person in a shell each start a server
@@ -1669,7 +1855,7 @@ ${cron.declarations}
1669
1855
  // (ubugeeei-prod/uf#204) and this entry is a module, so it would work; \`.catch\`
1670
1856
  // is the better spelling regardless — a server that cannot take its port should
1671
1857
  // say so and exit non-zero, rather than die as an unhandled rejection.
1672
- serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1858
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
1673
1859
  process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1674
1860
  process.exit(1);
1675
1861
  });
@@ -1696,7 +1882,7 @@ ${cron.imports}
1696
1882
  // \`@uniflowed/server/bun\` above, because the request has to be established in
1697
1883
  // the storage the *application* reads, which is the copy bundled into
1698
1884
  // \`handler.js\`. See ubugeeei-prod/uf#389.
1699
- import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1885
+ import { beginRequest, fetch, routing } from ${JSON.stringify(handlerSpecifier)};
1700
1886
 
1701
1887
  // Resolved from this file and not from the working directory: a process
1702
1888
  // manager, a container entrypoint and a person in a shell each start a server
@@ -1705,7 +1891,7 @@ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1705
1891
  // trap in it.
1706
1892
  const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1707
1893
  ${cron.declarations}
1708
- serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1894
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
1709
1895
  process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1710
1896
  process.exit(1);
1711
1897
  });
@@ -1743,7 +1929,7 @@ globalThis.Buffer ??= {
1743
1929
  };
1744
1930
  globalThis.setImmediate ??= (callback, ...args) => setTimeout(callback, 0, ...args);
1745
1931
  globalThis.clearImmediate ??= (handle) => clearTimeout(handle);
1746
- const { beginRequest, fetch } = await import(${JSON.stringify(handlerSpecifier)});
1932
+ const { beginRequest, fetch, routing } = await import(${JSON.stringify(handlerSpecifier)});
1747
1933
 
1748
1934
  // Resolved from this file and not from the working directory: a process
1749
1935
  // manager and a person in a shell each start a server from wherever they
@@ -1751,7 +1937,7 @@ const { beginRequest, fetch } = await import(${JSON.stringify(handlerSpecifier)}
1751
1937
  // started from inside itself would be a deployment with a trap in it.
1752
1938
  const staticDir = decodeURIComponent(new URL("./static", import.meta.url).pathname);
1753
1939
  ${cron.declarations}
1754
- serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1940
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
1755
1941
  console.error(\`uf: \${error?.message ?? String(error)}\`);
1756
1942
  Deno.exit(1);
1757
1943
  });
@@ -1780,13 +1966,13 @@ function workerEntrySource(handlerSpecifier, schedules) {
1780
1966
  return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1781
1967
  import { createWorkerFetch, installWorkerLogger } from "@uniflowed/server/edge";
1782
1968
 
1783
- import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1969
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
1784
1970
 
1785
1971
  // After the imports, so a logger the application installed while it loaded is
1786
1972
  // the one that stays; see \`installWorkerLogger\`.
1787
1973
  installWorkerLogger();
1788
1974
 
1789
- export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1975
+ export default { fetch: createWorkerFetch({ handle, beginRequest, routing }) };
1790
1976
  `;
1791
1977
  }
1792
1978
 
@@ -1797,7 +1983,7 @@ export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1797
1983
  return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1798
1984
  import { createWorkerFetch, createWorkerScheduled, installWorkerLogger } from "@uniflowed/server/edge";
1799
1985
 
1800
- import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1986
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
1801
1987
 
1802
1988
  // After the imports, so a logger the application installed while it loaded is
1803
1989
  // the one that stays; see \`installWorkerLogger\`.
@@ -1808,7 +1994,7 @@ installWorkerLogger();
1808
1994
  const routes = ${JSON.stringify(routes, null, 2)};
1809
1995
 
1810
1996
  export default {
1811
- fetch: createWorkerFetch({ handle, beginRequest }),
1997
+ fetch: createWorkerFetch({ handle, beginRequest, routing }),
1812
1998
  scheduled: createWorkerScheduled({ handle, beginRequest, routes }),
1813
1999
  };
1814
2000
  `;
@@ -1834,7 +2020,7 @@ import { fileURLToPath } from "node:url";
1834
2020
 
1835
2021
  import { createLambdaHandler } from "@uniflowed/server/lambda";
1836
2022
 
1837
- import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
2023
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
1838
2024
 
1839
2025
  // Resolved from this file and not from the working directory: Lambda sets the
1840
2026
  // working directory to the task root today and is under no obligation to keep
@@ -1842,7 +2028,7 @@ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)
1842
2028
  // deployment with a trap in it.
1843
2029
  const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1844
2030
 
1845
- export const handler = createLambdaHandler({ handle, beginRequest, staticDir });
2031
+ export const handler = createLambdaHandler({ handle, beginRequest, staticDir, routing });
1846
2032
  `;
1847
2033
  }
1848
2034
 
@@ -2019,7 +2205,7 @@ async function buildRscGraph(vite, inline, state, { outDir, conditions }) {
2019
2205
  * output names its module by path, which only this build's manifest turns
2020
2206
  * into a URL.
2021
2207
  */
2022
- function recordClientChunks(state, manifest, references, rscDir) {
2208
+ function recordClientChunks(state, manifest, references, rscDir, base = "") {
2023
2209
  for (const file of references) {
2024
2210
  const key = path.relative(root, file).split(path.sep).join("/");
2025
2211
  const chunk = manifest[key];
@@ -2029,7 +2215,7 @@ function recordClientChunks(state, manifest, references, rscDir) {
2029
2215
  "client component",
2030
2216
  );
2031
2217
  }
2032
- state.chunkUrls.set(file, `/${chunk.file}`);
2218
+ state.chunkUrls.set(file, `${base}/${chunk.file}`);
2033
2219
  }
2034
2220
  writeFileSync(
2035
2221
  path.join(rscDir, FLIGHT_BUILD_FILE),
@@ -2061,12 +2247,15 @@ function loadFlightBuild(state, rscDir) {
2061
2247
  * The rsc build's emitted files are copied under `dist/` so those URLs resolve,
2062
2248
  * and only its assets: a server bundle's JavaScript is never a deployable file.
2063
2249
  */
2064
- function flightAssets(manifest, references, rscDir, outDir) {
2065
- const assets = assetsFromManifest(manifest);
2250
+ function flightAssets(manifest, references, rscDir, outDir, base = "") {
2251
+ const assets = assetsFromManifest(manifest, base);
2066
2252
  const styles = new Set();
2067
2253
  const rscManifest = path.join(rscDir, ".vite", "manifest.json");
2068
2254
  if (existsSync(rscManifest)) {
2069
- const rscStyles = assetsFromManifest(JSON.parse(readFileSync(rscManifest, "utf8"))).styles;
2255
+ const rscStyles = assetsFromManifest(
2256
+ JSON.parse(readFileSync(rscManifest, "utf8")),
2257
+ base,
2258
+ ).styles;
2070
2259
  for (const href of rscStyles) styles.add(href);
2071
2260
  }
2072
2261
  const rscAssets = path.join(rscDir, "assets");
@@ -2083,7 +2272,7 @@ function flightAssets(manifest, references, rscDir, outDir) {
2083
2272
  seen.add(key);
2084
2273
  const chunk = manifest[key];
2085
2274
  if (chunk == null) return;
2086
- for (const css of chunk.css ?? []) styles.add(`/${css}`);
2275
+ for (const css of chunk.css ?? []) styles.add(`${base}/${css}`);
2087
2276
  for (const imported of chunk.imports ?? []) visit(imported);
2088
2277
  };
2089
2278
  for (const file of references) visit(path.relative(root, file).split(path.sep).join("/"));
@@ -2225,6 +2414,21 @@ async function renderingPlan(server, prerender) {
2225
2414
  why: "a middleware guards it, and a middleware runs once per request",
2226
2415
  });
2227
2416
  }
2417
+ // And `app.router`'s three lists, by the source each rule matches: a file
2418
+ // can be neither a redirect nor a rewrite, and a header a file is served
2419
+ // with is the host's to add rather than the file's.
2420
+ for (const [key, what] of [
2421
+ ["redirects", "a redirect"],
2422
+ ["rewrites", "a rewrite"],
2423
+ ["headers", "a response header"],
2424
+ ]) {
2425
+ for (const rule of server.routing?.[key] ?? []) {
2426
+ perRequest.push({
2427
+ path: rule.source,
2428
+ why: `\`app.router.${key}\` names it, and ${what} is answered when a request arrives`,
2429
+ });
2430
+ }
2431
+ }
2228
2432
 
2229
2433
  return { urls, perRequest };
2230
2434
  }
@@ -2246,9 +2450,36 @@ function fillParams(routePath, params) {
2246
2450
  .join("/");
2247
2451
  }
2248
2452
 
2249
- function htmlPathFor(outDir, url) {
2250
- const pathname = url.split("?")[0].replace(/^\/+/, "");
2251
- return pathname === ""
2252
- ? path.join(outDir, "index.html")
2453
+ /**
2454
+ * The file a prerendered page is written to.
2455
+ *
2456
+ * `guide/index.html`, which every static host serves at `/guide/` and most at
2457
+ * `/guide`, unless `app.router.trailingSlash` is `"never"` — then `guide.html`,
2458
+ * which the same hosts serve at `/guide` without a redirect to the slash.
2459
+ * Next.js's static export makes the same choice from the same setting.
2460
+ */
2461
+ function htmlPathFor(outDir, url, trailingSlash = "ignore") {
2462
+ const pathname = url.split("?")[0].replace(/^\/+/, "").replace(/\/+$/, "");
2463
+ if (pathname === "") return path.join(outDir, "index.html");
2464
+ return trailingSlash === "never"
2465
+ ? path.join(outDir, `${pathname}.html`)
2253
2466
  : path.join(outDir, pathname, "index.html");
2254
2467
  }
2468
+
2469
+ /**
2470
+ * The URL path at which a static half answers the document `htmlPathFor`
2471
+ * wrote for `url` under `REGENERATED_DIRECTORY`.
2472
+ *
2473
+ * Ending in a slash, so every host answers it with that directory's
2474
+ * `index.html` in the same way: a Node static half tries `index.html` for such
2475
+ * a path, and a Worker's assets binding serves it without the redirect it
2476
+ * answers `…/index.html` with.
2477
+ */
2478
+ function regeneratedDocumentUrl(url) {
2479
+ const pathname = url.split("?")[0].replace(/^\/+/, "").replace(/\/+$/, "");
2480
+ const encoded = pathname
2481
+ .split("/")
2482
+ .map((segment) => encodeURIComponent(segment))
2483
+ .join("/");
2484
+ return pathname === "" ? `/${REGENERATED_DIRECTORY}/` : `/${REGENERATED_DIRECTORY}/${encoded}/`;
2485
+ }