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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/driver.js CHANGED
@@ -6,7 +6,10 @@
6
6
  // `uf start` spawn.
7
7
  //
8
8
  // <host> driver.js dev --root <dir> [--mode <m>] [--host <h>] [--port <n>] [--strict-port]
9
+ // [--uf-env-file <file>]...
9
10
  // <host> driver.js build --root <dir> [--mode <m>] [--out-dir <dir>]
11
+ // [--prerender everything|possible|nothing]
12
+ // [--static-build] [--because <sentence>]
10
13
  // <host> driver.js compile --root <dir> [--mode <m>] [--out-dir <dir>] --assets <file> --bundle <dir>
11
14
  // <host> driver.js deploy --root <dir> [--mode <m>] [--out-dir <dir>] --adapter <name> --work <dir> --output <dir>
12
15
  // <host> driver.js preview --root <dir> [--mode <m>] [--out-dir <dir>] [--host <h>] [--port <n>]
@@ -19,6 +22,12 @@
19
22
  // environment — see `viteConfig` below and `crates/uf_config/src/env_files.rs`.
20
23
  // `start` has no Vite in it and therefore no mode.
21
24
  //
25
+ // `--uf-env-file` names those files, one flag each, so `dev` can watch them and
26
+ // say when one moved; nothing here reads their contents. The prefix is load
27
+ // bearing: node claims `--env-file` for itself and honours it wherever it
28
+ // appears on the command line, script arguments included, so a driver argument
29
+ // by that name is an argument node eats and then exits 9 over.
30
+ //
22
31
  // `uf` in Rust owns the terminal; this process owns Vite. They talk over
23
32
  // stdout, one JSON event per line (see `./internal/events.js`), and the driver
24
33
  // exits when its stdin closes so it cannot outlive the command that started
@@ -34,7 +43,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
34
43
  import path from "node:path";
35
44
  import { pathToFileURL } from "node:url";
36
45
 
37
- import { emit, errorEvent, eventLogger, reportRenderError } from "./internal/events.js";
46
+ import { emit, errorEvent, eventLogger } from "./internal/events.js";
38
47
  import { loadUfConfig, projectConfig } from "./internal/config.js";
39
48
  import { send, toRequest } from "./internal/http.js";
40
49
  import { withProjectConfig } from "./merge.js";
@@ -52,6 +61,16 @@ function argument(name) {
52
61
  return at === -1 ? null : process.argv[at + 1];
53
62
  }
54
63
 
64
+ /** Every value of a repeated argument, in the order they were given. */
65
+ function argumentAll(name) {
66
+ const values = [];
67
+ for (let at = 0; at < process.argv.length; at += 1) {
68
+ if (process.argv[at] === name && process.argv[at + 1] != null)
69
+ values.push(process.argv[at + 1]);
70
+ }
71
+ return values;
72
+ }
73
+
55
74
  function flag(name) {
56
75
  return process.argv.includes(name);
57
76
  }
@@ -182,26 +201,21 @@ async function viteConfig(config, mode) {
182
201
  * Vite in middleware mode serves nothing on its own: with no `index.html` at
183
202
  * the project root it answers every navigation with "Cannot GET /", which is
184
203
  * what `uf dev` used to do for every project it started. A uf project has no
185
- * `index.html` — the document comes from a layout — so the server has to render
186
- * it, which is what this middleware does:
187
- *
188
- * 1. load the server entry through `ssrLoadModule`, so it is transformed the
189
- * same way the browser's copy is and picks up edits without a restart;
190
- * 2. run the middleware guarding this path, which may answer instead;
191
- * 3. render the URL, pointing the client script at the dev entry rather than
192
- * at a built asset;
193
- * 4. hand the HTML to `transformIndexHtml`, which is what injects the HMR
194
- * client and lets any Vite plugin see the document.
204
+ * `index.html` — the document comes from a layout — so the server has to
205
+ * render it.
195
206
  *
196
- * Step 4 is why `uf dev` collects the stream instead of piping it: Vite's HTML
197
- * hook takes a whole document and any plugin may rewrite any part of it, so
198
- * there is no first byte to send until it has run. `uf start` and `uf preview`
199
- * have no such hook and stream — see `internal/serve.js` — and it is worth
200
- * being clear that this is a property of the development server rather than of
201
- * the renderer. Streaming through the transform is ubugeeei-prod/uf#374.
207
+ * That rendering is **not** here. It is one middleware, in `./index.js`'s
208
+ * `configureServer`, and this function installs none of its own. It used to
209
+ * install a second one, and two middlewares rendering the same request is how
210
+ * `uf dev` came to answer a route handler with a page and a redirect without
211
+ * its `Location`: `configureServer`'s post hook runs inside `createServer`,
212
+ * and anything added here runs after it returns, so of the two the plugin's
213
+ * was always the one that decided. See ubugeeei-prod/uf#349 and #338, and the
214
+ * comment above that middleware for what it now has to do.
202
215
  *
203
- * Anything Vite already serves — a module, a public file — never reaches this,
204
- * because the middleware runs after Vite's own.
216
+ * What is left here is the half that is genuinely the driver's: the Vite
217
+ * config, the socket, the event channel back to `uf`, and the two watchers
218
+ * below.
205
219
  */
206
220
  async function dev() {
207
221
  const { createServer } = await import("vite");
@@ -212,89 +226,6 @@ async function dev() {
212
226
  const inline = await viteConfig(config, argument("--mode") ?? "development");
213
227
  const server = await createServer({ ...inline, appType: "custom" });
214
228
 
215
- // In dev the browser loads the client entry from Vite, not from a manifest;
216
- // its stylesheets arrive through that module rather than as <link> tags.
217
- const assets = { scripts: [`/@id/${VIRTUAL.client}`], styles: [], preloads: [] };
218
-
219
- server.middlewares.use(async (request, response, next) => {
220
- const url = request.originalUrl ?? request.url ?? "/";
221
- // Declared out here so the catch below can still settle: a request that
222
- // failed is a request that happened, and a middleware that logged its
223
- // arrival is owed its callback either way.
224
- let lifecycle = null;
225
- try {
226
- const entry = await server.ssrLoadModule(VIRTUAL.server);
227
- const asRequest = await toRequest(request, server.config);
228
-
229
- // The request begins here and ends when the document has been written,
230
- // which is what `after()` promises and what `uf preview`, `uf start` and
231
- // a compiled binary all do too — a middleware that logs a response's
232
- // status has to mean the same thing in development as in production.
233
- // `entry.beginRequest` rather than an import: the storage that holds the
234
- // request belongs to the application's own copy of `@uniflowed/server`.
235
- // See `internal/serve.js` and ubugeeei-prod/uf#389.
236
- lifecycle = entry.beginRequest(asRequest);
237
- const answered = await lifecycle.run(async () => {
238
- // Middleware first, above everything: it guards a subtree, so it has to
239
- // run for a page, for a route handler, and for a path under it that
240
- // matches neither. Running it inside the dispatcher and again inside the
241
- // renderer would have left `/dashboard/typo` unguarded and run it twice
242
- // for a path that is both.
243
- const guarded = await entry.runMiddleware(asRequest);
244
- if (guarded != null) {
245
- await send(response, guarded);
246
- return true;
247
- }
248
-
249
- // Route handlers next, and for every method: a handler is the only
250
- // thing that answers a POST, and it may also answer a GET for a path
251
- // that has no page.
252
- const handled = await entry.dispatch(asRequest);
253
- if (handled != null) {
254
- await send(response, handled);
255
- return true;
256
- }
257
-
258
- // Only a navigation reaches the renderer. A page cannot answer a POST,
259
- // and letting one try would turn a missing handler into a rendered page
260
- // with a 200 rather than a 404.
261
- if (request.method !== "GET" && request.method !== "HEAD") {
262
- return false;
263
- }
264
-
265
- const result = await entry.render(url, assets, {
266
- // A boundary that threw after the shell went out. `result.error` cannot
267
- // carry it — the caller already has the result by then — so the
268
- // terminal hears about it here or not at all.
269
- onError: (error) => reportRenderError(server, url, error),
270
- });
271
- if (result.error != null) reportRenderError(server, url, result.error);
272
- const html = await server.transformIndexHtml(url, await result.text());
273
- response.statusCode = result.status ?? 200;
274
- response.setHeader("content-type", "text/html; charset=utf-8");
275
- response.end(html);
276
- return true;
277
- });
278
-
279
- if (!answered) {
280
- // The one path where uf is not the one writing the response: a
281
- // non-navigation nothing claimed goes back to Vite's chain. The guard
282
- // has still run and may have deferred work, so `close` — the socket
283
- // saying the response is over, however it ended — is the only honest
284
- // signal left that the bytes are out.
285
- response.once("close", lifecycle.settle);
286
- next();
287
- return;
288
- }
289
- await lifecycle.settle();
290
- } catch (error) {
291
- if (lifecycle != null) await lifecycle.settle();
292
- // Map the stack back onto the Flow source before it reaches the overlay.
293
- if (error instanceof Error) server.ssrFixStacktrace(error);
294
- next(error);
295
- }
296
- });
297
-
298
229
  await server.listen();
299
230
  const urls = server.resolvedUrls ?? { local: [], network: [] };
300
231
  emit("listening", {
@@ -305,6 +236,7 @@ async function dev() {
305
236
  ),
306
237
  });
307
238
  watchSources(server);
239
+ watchEnvFiles(server);
308
240
 
309
241
  const shutdown = async () => {
310
242
  await server.close();
@@ -351,6 +283,45 @@ function watchSources(server) {
351
283
  }
352
284
  }
353
285
 
286
+ /**
287
+ * Restart the server when one of the `.env` files uf read changes.
288
+ *
289
+ * uf reads the `.env` cascade itself, in Rust, before this process starts —
290
+ * one parser, one precedence, one answer for every command (see `viteConfig`
291
+ * above and `crates/uf_config/src/env_files.rs`) — and `envDir: false` turns
292
+ * Vite's own file loading off so there cannot be two answers. The cost of that
293
+ * was that nothing watched them: a value edited while `uf dev` ran changed
294
+ * nothing until somebody restarted the command by hand, and the guide had to
295
+ * document it as a limitation. See ubugeeei-prod/uf#428.
296
+ *
297
+ * `uf` passes the files it would consult with `--uf-env-file`, one per file, in
298
+ * cascade order, whether or not each exists today — a `.env.local` *created*
299
+ * while the server runs changes the answer exactly as much as an edit to one
300
+ * that was already there, and watching only what was read would have missed
301
+ * it. They are added to Vite's watcher explicitly because they are in no
302
+ * module graph, which is the same reason the RSC manifest is added in
303
+ * `index.js`.
304
+ *
305
+ * What is emitted is "these values are stale", and the Rust side restarts this
306
+ * process with the files re-read. A restart rather than a hot update is the
307
+ * honest granularity: a prefixed value reaches the browser by substitution
308
+ * into the bundle, so a new value has to be substituted again, and every
309
+ * module that read one has to be re-evaluated. Vite's watcher is still the
310
+ * only watcher — a second one over the same tree, in Rust, would be a second
311
+ * answer to "did this file change".
312
+ */
313
+ function watchEnvFiles(server) {
314
+ const files = argumentAll("--uf-env-file").map((file) => path.resolve(root, file));
315
+ if (files.length === 0) return;
316
+ const watched = new Set(files);
317
+ server.watcher.add(files);
318
+ for (const event of ["add", "change", "unlink"]) {
319
+ server.watcher.on(event, (file) => {
320
+ if (watched.has(path.resolve(file))) emit("env-changed", { file, change: event });
321
+ });
322
+ }
323
+ }
324
+
354
325
  /**
355
326
  * The preview server: the build, as Vite serves it.
356
327
  *
@@ -375,37 +346,57 @@ async function preview() {
375
346
  const { preview: startPreview } = await import("vite");
376
347
  const config = await loadConfig();
377
348
  const inline = await viteConfig(config, argument("--mode") ?? "production");
378
- const build = await loadBuild({
379
- root,
380
- outDir: inline.build.outDir,
381
- serverDir: path.join(".uf", "build", "server"),
382
- });
349
+ // A build that declared it emits no server has none to mount. `uf` refuses
350
+ // `uf start` for such a project and lets this one through, because a preview
351
+ // of files *is* the deployment: what a static host does with `dist/` is
352
+ // exactly what Vite's preview server does with it, and mounting a request
353
+ // handler behind it would make this preview right about a deployment that is
354
+ // not the one happening. See `uf_cli`'s `commands::serve`.
355
+ const staticBuild = flag("--static-build");
356
+ const build = staticBuild
357
+ ? null
358
+ : await loadBuild({
359
+ root,
360
+ outDir: inline.build.outDir,
361
+ serverDir: path.join(".uf", "build", "server"),
362
+ });
383
363
 
384
364
  const server = await startPreview({ ...inline, appType: "custom" });
385
- const handle = createServeHandler({ ...build, cache: config.app?.rendering?.cache });
386
- server.middlewares.use(async (request, response, next) => {
387
- try {
388
- const asRequest = await toRequest(request, server.config);
389
- // The same lifecycle `uf start` gets from `nodeListener`, spelled out
390
- // because this door is Vite's connect chain rather than a bare
391
- // `node:http` server: the whole request runs inside it, and it settles
392
- // once `send` has returned. A preview whose `after()` fired at a
393
- // different moment from the production server's would be a preview that
394
- // is checked and believed and wrong.
395
- await withRequest(build.entry, asRequest, async () => {
396
- await send(response, await handle(asRequest));
397
- });
398
- } catch (error) {
399
- next(error);
400
- }
401
- });
365
+ if (build != null) {
366
+ const handle = createServeHandler({ ...build, cache: config.app?.rendering?.cache });
367
+ server.middlewares.use(async (request, response, next) => {
368
+ try {
369
+ const asRequest = await toRequest(request, server.config);
370
+ // The same lifecycle `uf start` gets from `nodeListener`, spelled out
371
+ // because this door is Vite's connect chain rather than a bare
372
+ // `node:http` server: the whole request runs inside it, and it settles
373
+ // once `send` has returned. A preview whose `after()` fired at a
374
+ // different moment from the production server's would be a preview that
375
+ // is checked and believed and wrong.
376
+ await withRequest(build.entry, asRequest, async () => {
377
+ await send(response, await handle(asRequest));
378
+ });
379
+ } catch (error) {
380
+ next(error);
381
+ }
382
+ });
383
+ }
402
384
 
403
385
  const urls = server.resolvedUrls ?? { local: [], network: [] };
404
386
  emit("listening", {
405
387
  local: urls.local,
406
388
  network: urls.network,
407
- routes: build.entry.routes.map((route) => route.path),
408
- handlers: build.entry.handlers.map((handler) => handler.path),
389
+ // From the filesystem when there is no bundle to ask, which is the same
390
+ // scan `dev` reports from. The count is what a reader checks the build
391
+ // against, so answering "0 routes" for a static site that has thirty would
392
+ // be the report being wrong about the thing it exists to report.
393
+ routes:
394
+ build == null
395
+ ? scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map(
396
+ (route) => route.path,
397
+ )
398
+ : build.entry.routes.map((route) => route.path),
399
+ handlers: build == null ? [] : build.entry.handlers.map((handler) => handler.path),
409
400
  });
410
401
 
411
402
  const shutdown = async () => {
@@ -487,6 +478,15 @@ async function build() {
487
478
  const inline = await viteConfig(config, mode);
488
479
  const outDir = path.resolve(root, inline.build.outDir);
489
480
  const serverDir = path.join(root, ".uf", "build", "server");
481
+ // How much of the route table to prerender, and whether the server bundle
482
+ // survives the build. Both are `uf`'s answer rather than this file's: they
483
+ // come from two settings in `uf.config.js` that only mean something read
484
+ // together, and `uf_config`'s `RenderingPlan` is where they are. A driver
485
+ // started by hand gets the behaviour every uf build had before either
486
+ // setting was read.
487
+ const prerender = argument("--prerender") ?? "possible";
488
+ const staticBuild = flag("--static-build");
489
+ const because = argument("--because") ?? "this build prerenders every route";
490
490
 
491
491
  // 1. The client: everything the browser loads, with a manifest so the
492
492
  // server render knows which script and stylesheet tags to write.
@@ -519,11 +519,39 @@ async function build() {
519
519
  },
520
520
  });
521
521
 
522
- // 3. Every static route, rendered to an HTML document.
522
+ // 3. Which routes this build renders when, and every route it renders now.
523
+ //
524
+ // The decision comes from `uf.config.js` and is made in Rust — see
525
+ // `uf_config`'s `RenderingPlan` — because `app.rendering.modes` and
526
+ // `build.staticBuild` are two settings that have to be read together. It
527
+ // arrives here as one word, and this is where it meets the route table.
523
528
  emit("phase", { name: "prerender" });
524
529
  const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
525
530
  const assets = assetsFromManifest(manifest);
526
- const pages = await staticPaths(server.routes);
531
+ const plan = await renderingPlan(server, prerender);
532
+ emit("rendering", {
533
+ prerender,
534
+ prerendered: plan.urls.length,
535
+ perRequest: plan.perRequest.map((route) => route.path),
536
+ });
537
+ // A build that has to prerender everything, and a route it cannot: the
538
+ // refusal ubugeeei-prod/uf#336 and ubugeeei-prod/uf#385 are both about.
539
+ // Before the loop below, so no document is written for a build that is not
540
+ // going to be one, and with the whole list rather than the first item — a
541
+ // project that has just narrowed `rendering.modes` wants to see every route
542
+ // the narrowing costs it, not one per rebuild.
543
+ if (prerender === "everything" && plan.perRequest.length > 0) {
544
+ const listed = plan.perRequest.map((entry) => ` ${entry.path} — ${entry.why}`).join("\n");
545
+ emit("error", {
546
+ message:
547
+ `${plan.perRequest.length} ${plural(plan.perRequest.length, "route")} in this project ` +
548
+ `can only be answered by a server, and ${because}\n${listed}\n\n` +
549
+ "Give each page a `generateStaticParams` and take out the handlers and middleware, or " +
550
+ 'allow `"ssr"` in `app.rendering.modes` and deploy a server.',
551
+ });
552
+ process.exit(1);
553
+ }
554
+ const pages = plan.urls;
527
555
 
528
556
  // A route that throws fails *that route*, and the rest of the build still
529
557
  // happens. This loop had no `try`: the first page to throw rejected out of
@@ -587,7 +615,11 @@ async function build() {
587
615
  // host would then serve uf's error page to every visitor who mistyped a URL,
588
616
  // and nothing between the throw and the deploy would have mentioned it.
589
617
  let attempted = pages.length;
590
- if (server.notFound.some((boundary) => boundary.path === "/")) {
618
+ // Not for a build that prerenders nothing. `404.html` is a file a static
619
+ // host serves for every path it has no file for, and a project whose
620
+ // `rendering.modes` allows only `ssr` has no such host: its not-found
621
+ // boundary is rendered per request, by the server, with the right status.
622
+ if (prerender !== "nothing" && server.notFound.some((boundary) => boundary.path === "/")) {
591
623
  attempted += 1;
592
624
  // `/404` rather than `/__uf_not_found__`: the internal path is how the
593
625
  // router is asked, and the file the reader is looking for is `404.html`.
@@ -634,6 +666,16 @@ async function build() {
634
666
  process.exit(1);
635
667
  }
636
668
 
669
+ // `build.staticBuild` is "prerender everything and emit no server bundle",
670
+ // and this is the second half of it. The bundle is still *built*: the
671
+ // prerender renders through it, so a build with no server bundle at any
672
+ // point would be a build with no documents either. What the declaration is
673
+ // about is what is left behind — so it goes once the last document is
674
+ // written, and `uf start`, `uf preview` and every server adapter then find
675
+ // nothing to serve, which is the honest outcome for a project that said it
676
+ // deploys files.
677
+ if (staticBuild) rmSync(serverDir, { recursive: true, force: true });
678
+
637
679
  emit("done", { outDir: path.relative(root, outDir), pages: pages.length });
638
680
  process.exit(0);
639
681
  }
@@ -749,7 +791,7 @@ async function compile() {
749
791
  const ADAPTERS = {
750
792
  node: {
751
793
  entries: (document, cache) => ({
752
- handler: handlerEntrySource(document, cache),
794
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES),
753
795
  server: nodeEntrySource("./handler.js"),
754
796
  }),
755
797
  },
@@ -759,13 +801,13 @@ const ADAPTERS = {
759
801
  // `commands::deploy`.
760
802
  container: {
761
803
  entries: (document, cache) => ({
762
- handler: handlerEntrySource(document, cache),
804
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES),
763
805
  server: nodeEntrySource("./handler.js"),
764
806
  }),
765
807
  },
766
808
  edge: {
767
809
  entries: (document, cache) => ({
768
- handler: handlerEntrySource(document, cache),
810
+ handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES),
769
811
  worker: workerEntrySource("./handler.js"),
770
812
  }),
771
813
  // `workerd` first, so React resolves to the build that has
@@ -776,12 +818,33 @@ const ADAPTERS = {
776
818
  },
777
819
  serverless: {
778
820
  entries: (document, cache) => ({
779
- handler: handlerEntrySource(document, cache),
821
+ handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES),
780
822
  lambda: lambdaEntrySource("./handler.js"),
781
823
  }),
782
824
  },
783
825
  };
784
826
 
827
+ /**
828
+ * How each target says what it can do: the module, and the name to call.
829
+ *
830
+ * A pair of strings rather than a value, because this is the module that
831
+ * *writes* `handler.js` and never imports what it writes: the capabilities
832
+ * belong to the deployed application's copy of `@uniflowed/server`, not to the
833
+ * driver's. It is the same reason `beginRequest` is re-exported from the
834
+ * generated file rather than reached for here — see `handlerEntrySource`.
835
+ *
836
+ * Nothing is passed for `websocket` or `queue`, and that is not an oversight:
837
+ * uf defines both and implements neither, so a generated file that invented
838
+ * one would be inventing an upgrade for a runtime it cannot see. What the call
839
+ * does supply is the target's name and its two facts, which is what turns "an
840
+ * upgrade is not available" into "the serverless host cannot hold a socket
841
+ * open" and what lets `@uniflowed/server/lambda` refuse a queue that would be
842
+ * dropped.
843
+ */
844
+ const NODE_CAPABILITIES = { module: "@uniflowed/server/node", name: "nodeCapabilities" };
845
+ const EDGE_CAPABILITIES = { module: "@uniflowed/server/edge", name: "edgeCapabilities" };
846
+ const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lambdaCapabilities" };
847
+
785
848
  /**
786
849
  * Link the application into a directory that can be copied, for
787
850
  * `uf build --adapter`.
@@ -952,7 +1015,7 @@ async function deploy() {
952
1015
  * package a bundler happened to give it, and the copy that matters is the one
953
1016
  * the application resolved. See ubugeeei-prod/uf#277 and #389.
954
1017
  */
955
- function handlerEntrySource(document, cache) {
1018
+ function handlerEntrySource(document, cache, capabilities) {
956
1019
  const route = cache?.route === true;
957
1020
  const fetchCache = cache?.fetch === true;
958
1021
  // Nothing at all when both switches are off, so a default project's
@@ -960,9 +1023,19 @@ function handlerEntrySource(document, cache) {
960
1023
  // generated output nobody asked for is the second half of the complaint
961
1024
  // #277 makes about the first half.
962
1025
  const store = route || fetchCache;
1026
+ const options = [
1027
+ "app",
1028
+ `document: ${JSON.stringify(document)}`,
1029
+ ...(store ? ["cache"] : []),
1030
+ "capabilities",
1031
+ ].join(", ");
1032
+ const cacheImport = store ? 'import { createCacheStore } from "@uniflowed/server/cache";\n' : "";
1033
+ const from = JSON.stringify(capabilities.module);
1034
+ const capabilityImport = `import { ${capabilities.name} } from ${from};`;
963
1035
  return `// Generated by \`uf build --adapter\`. Not checked in, not edited.
964
1036
  import { createFetchHandler } from "@uniflowed/server/fetch";
965
- ${store ? 'import { createCacheStore } from "@uniflowed/server/cache";\n' : ""}import * as app from ${JSON.stringify(VIRTUAL.server)};
1037
+ ${cacheImport}${capabilityImport}
1038
+ import * as app from ${JSON.stringify(VIRTUAL.server)};
966
1039
 
967
1040
  ${
968
1041
  store
@@ -971,9 +1044,17 @@ ${
971
1044
  // application. See ubugeeei-prod/uf#277.
972
1045
  const cache = { store: createCacheStore(), route: ${String(route)}, fetch: ${String(fetchCache)} };
973
1046
 
974
- export const fetch = createFetchHandler({ app, document: ${JSON.stringify(document)}, cache });`
975
- : `export const fetch = createFetchHandler({ app, document: ${JSON.stringify(document)} });`
976
- }
1047
+ `
1048
+ : ""
1049
+ }// What this target can do, and it is not the same for all four: whether a
1050
+ // response body reaches the client as it is produced, and whether the process
1051
+ // is still there once it has. A route handler that streams events or takes a
1052
+ // socket asks through this rather than finding out in production. Nothing is
1053
+ // passed for the upgrade or the queue — uf defines both and implements
1054
+ // neither. See \`@uniflowed/server/socket\` and \`@uniflowed/server/queue\`.
1055
+ const capabilities = ${capabilities.name}();
1056
+
1057
+ export const fetch = createFetchHandler({ ${options} });
977
1058
  export const beginRequest = app.beginRequest;
978
1059
 
979
1060
  export default { fetch, beginRequest };
@@ -1154,24 +1235,127 @@ function readManifest(outDir) {
1154
1235
  }
1155
1236
 
1156
1237
  /**
1157
- * The URLs to prerender: every route without parameters, plus every set of
1158
- * parameters a page's `generateStaticParams` returns.
1238
+ * What this build renders now, and what it leaves for a server.
1239
+ *
1240
+ * The rendering decision, per route, and it has three answers rather than the
1241
+ * two `staticPaths` used to have:
1242
+ *
1243
+ * * **prerender it** — a route with no parameters, or a route whose page
1244
+ * exports `generateStaticParams`, once per set of parameters it returns;
1245
+ * * **leave it to the server** — a route with parameters and no
1246
+ * `generateStaticParams`, or a page that has said `export const dynamic =
1247
+ * "force-dynamic"`;
1248
+ * * **refuse** — which is not decided here. This function reports what it
1249
+ * found and the caller, which knows whether the project allows a server,
1250
+ * is the one that turns "there is a route here a static host cannot
1251
+ * answer" into an error.
1252
+ *
1253
+ * `dynamic` is the spelling ubugeeei-prod/uf#336 asked for: a route with *no*
1254
+ * parameters whose content depends on the request had no way to say so, and
1255
+ * `generateStaticParams` cannot say it — there are no parameters to generate.
1256
+ * It is Next.js's name for the same declaration, because a person arriving
1257
+ * from `app/` should not have to learn a second word for a decision they have
1258
+ * already made once.
1259
+ *
1260
+ * Two of Next's four values are missing and are not silently accepted:
1261
+ * `"force-static"` and `"error"` are refused by name, because each is a
1262
+ * *constraint* on a page that uf does not yet check, and accepting one would
1263
+ * be reading a declaration and ignoring it — the failure the two issues behind
1264
+ * this function are about.
1265
+ *
1266
+ * Handlers and middleware are in the same list, and they belong there: this is
1267
+ * the list of things that need a process, and a `_uf.route.js` needs one more
1268
+ * obviously than any page does. They carry no per-route render — the build has
1269
+ * never written a file for either — so they appear only when the answer might
1270
+ * be a refusal.
1271
+ *
1272
+ * @param {{routes: Route[], handlers: Handler[], middleware: Middleware[]}} server
1273
+ * @param {"everything" | "possible" | "nothing"} prerender
1159
1274
  */
1160
- async function staticPaths(routes) {
1275
+ async function renderingPlan(server, prerender) {
1161
1276
  const urls = [];
1162
- for (const route of routes) {
1277
+ const perRequest = [];
1278
+
1279
+ // Nothing is prerendered and nothing is refused, so no page module is
1280
+ // loaded: a project that renders everything per request should not pay for
1281
+ // a `generateStaticParams` this build will not call.
1282
+ if (prerender === "nothing") {
1283
+ return {
1284
+ urls,
1285
+ perRequest: server.routes.map((route) => ({
1286
+ path: route.path,
1287
+ why: "this build prerenders nothing",
1288
+ })),
1289
+ };
1290
+ }
1291
+
1292
+ for (const route of server.routes) {
1293
+ // Every page module, and not only the parameterised ones: `dynamic` is a
1294
+ // declaration any page can make. A module that cannot be imported at all
1295
+ // is a failure of *that route*, so a route with no parameters goes into
1296
+ // the prerender anyway and the loop below reports it the way it has always
1297
+ // reported a page that throws — named, with the rest of the build still
1298
+ // happening. A parameterised one still rejects out of the build, which is
1299
+ // what it did before there was anything else to load a page module for.
1300
+ let module;
1301
+ try {
1302
+ module = await route.page();
1303
+ } catch (error) {
1304
+ if (route.params.length > 0) throw error;
1305
+ urls.push(route.path);
1306
+ continue;
1307
+ }
1308
+ const declared = module.dynamic ?? "auto";
1309
+ if (declared !== "auto" && declared !== "force-dynamic") {
1310
+ throw new Error(
1311
+ `uf: ${route.file} exports \`dynamic = ${JSON.stringify(declared)}\`, and uf reads ` +
1312
+ '`"auto"` and `"force-dynamic"`. `"force-static"` and `"error"` are Next.js values ' +
1313
+ "for constraints uf does not check yet, and accepting one would be reading a " +
1314
+ "declaration and ignoring it.",
1315
+ );
1316
+ }
1317
+ if (declared === "force-dynamic") {
1318
+ perRequest.push({
1319
+ path: route.path,
1320
+ why: 'its page exports `dynamic = "force-dynamic"`',
1321
+ });
1322
+ continue;
1323
+ }
1163
1324
  if (route.params.length === 0) {
1164
1325
  urls.push(route.path);
1165
1326
  continue;
1166
1327
  }
1167
- const module = await route.page();
1168
1328
  const generate = module.generateStaticParams;
1169
- if (typeof generate !== "function") continue;
1329
+ if (typeof generate !== "function") {
1330
+ perRequest.push({
1331
+ path: route.path,
1332
+ why: "it has parameters and its page exports no `generateStaticParams`",
1333
+ });
1334
+ continue;
1335
+ }
1170
1336
  for (const params of await generate()) {
1171
1337
  urls.push(fillParams(route.path, params));
1172
1338
  }
1173
1339
  }
1174
- return urls;
1340
+
1341
+ for (const handler of server.handlers ?? []) {
1342
+ perRequest.push({
1343
+ path: handler.path,
1344
+ why: "it is a route handler, and a handler answers a request rather than producing a file",
1345
+ });
1346
+ }
1347
+ for (const entry of server.middleware ?? []) {
1348
+ // A middleware is reported by the path it guards rather than by the route
1349
+ // it guards, which is why it cannot be folded into the loop above: it runs
1350
+ // for a page, for a handler, and for a path under it that is neither, so
1351
+ // "which route is this" has no single answer.
1352
+ perRequest.push({
1353
+ path: `${entry.path === "/" ? "" : entry.path}/*`,
1354
+ why: "a middleware guards it, and a middleware runs once per request",
1355
+ });
1356
+ }
1357
+
1358
+ return { urls, perRequest };
1175
1359
  }
1176
1360
 
1177
1361
  function fillParams(routePath, params) {