@uniflowed/vite 0.0.0-alpha.13 → 0.0.0-alpha.15

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,12 @@
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>]
13
+ // <host> driver.js library --root <dir> [--mode <m>] [--out-dir <dir>]
14
+ // --entry <file>... --format <es|cjs>... [--external <name>]...
10
15
  // <host> driver.js compile --root <dir> [--mode <m>] [--out-dir <dir>] --assets <file> --bundle <dir>
11
16
  // <host> driver.js deploy --root <dir> [--mode <m>] [--out-dir <dir>] --adapter <name> --work <dir> --output <dir>
12
17
  // <host> driver.js preview --root <dir> [--mode <m>] [--out-dir <dir>] [--host <h>] [--port <n>]
@@ -19,6 +24,12 @@
19
24
  // environment — see `viteConfig` below and `crates/uf_config/src/env_files.rs`.
20
25
  // `start` has no Vite in it and therefore no mode.
21
26
  //
27
+ // `--uf-env-file` names those files, one flag each, so `dev` can watch them and
28
+ // say when one moved; nothing here reads their contents. The prefix is load
29
+ // bearing: node claims `--env-file` for itself and honours it wherever it
30
+ // appears on the command line, script arguments included, so a driver argument
31
+ // by that name is an argument node eats and then exits 9 over.
32
+ //
22
33
  // `uf` in Rust owns the terminal; this process owns Vite. They talk over
23
34
  // stdout, one JSON event per line (see `./internal/events.js`), and the driver
24
35
  // exits when its stdin closes so it cannot outlive the command that started
@@ -29,12 +40,12 @@
29
40
  // one host that can evaluate the file evaluates it.
30
41
 
31
42
  import { createServer as createHttpServer } from "node:http";
32
- import { register } from "node:module";
43
+ import { builtinModules, register } from "node:module";
33
44
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
34
45
  import path from "node:path";
35
46
  import { pathToFileURL } from "node:url";
36
47
 
37
- import { emit, errorEvent, eventLogger, reportRenderError } from "./internal/events.js";
48
+ import { emit, errorEvent, eventLogger } from "./internal/events.js";
38
49
  import { loadUfConfig, projectConfig } from "./internal/config.js";
39
50
  import { send, toRequest } from "./internal/http.js";
40
51
  import { withProjectConfig } from "./merge.js";
@@ -52,6 +63,16 @@ function argument(name) {
52
63
  return at === -1 ? null : process.argv[at + 1];
53
64
  }
54
65
 
66
+ /** Every value of a repeated argument, in the order they were given. */
67
+ function argumentAll(name) {
68
+ const values = [];
69
+ for (let at = 0; at < process.argv.length; at += 1) {
70
+ if (process.argv[at] === name && process.argv[at + 1] != null)
71
+ values.push(process.argv[at + 1]);
72
+ }
73
+ return values;
74
+ }
75
+
55
76
  function flag(name) {
56
77
  return process.argv.includes(name);
57
78
  }
@@ -78,7 +99,7 @@ process.stdin.on("end", () => process.exit(0));
78
99
  process.stdin.on("error", () => process.exit(0));
79
100
  process.stdin.resume();
80
101
 
81
- const commands = { dev, build, compile, deploy, preview, start, config: printConfig };
102
+ const commands = { dev, build, library, compile, deploy, preview, start, config: printConfig };
82
103
  const run = commands[command];
83
104
  if (run == null) {
84
105
  emit("error", { message: `unknown driver command ${JSON.stringify(command)}` });
@@ -182,26 +203,21 @@ async function viteConfig(config, mode) {
182
203
  * Vite in middleware mode serves nothing on its own: with no `index.html` at
183
204
  * the project root it answers every navigation with "Cannot GET /", which is
184
205
  * 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:
206
+ * `index.html` — the document comes from a layout — so the server has to
207
+ * render it.
187
208
  *
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.
209
+ * That rendering is **not** here. It is one middleware, in `./index.js`'s
210
+ * `configureServer`, and this function installs none of its own. It used to
211
+ * install a second one, and two middlewares rendering the same request is how
212
+ * `uf dev` came to answer a route handler with a page and a redirect without
213
+ * its `Location`: `configureServer`'s post hook runs inside `createServer`,
214
+ * and anything added here runs after it returns, so of the two the plugin's
215
+ * was always the one that decided. See ubugeeei-prod/uf#349 and #338, and the
216
+ * comment above that middleware for what it now has to do.
195
217
  *
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.
202
- *
203
- * Anything Vite already serves — a module, a public file — never reaches this,
204
- * because the middleware runs after Vite's own.
218
+ * What is left here is the half that is genuinely the driver's: the Vite
219
+ * config, the socket, the event channel back to `uf`, and the two watchers
220
+ * below.
205
221
  */
206
222
  async function dev() {
207
223
  const { createServer } = await import("vite");
@@ -212,100 +228,6 @@ async function dev() {
212
228
  const inline = await viteConfig(config, argument("--mode") ?? "development");
213
229
  const server = await createServer({ ...inline, appType: "custom" });
214
230
 
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
- // A server action next, below the guard and above the handlers. It
250
- // declines every request that carries no action id, so this costs a
251
- // page request one header lookup; and it answers every request that
252
- // carries one, refusals included, so an action can never fall through
253
- // to a route handler that happens to sit at the URL it was posted to.
254
- const acted = await entry.callAction(asRequest);
255
- if (acted != null) {
256
- await send(response, acted);
257
- return true;
258
- }
259
-
260
- // Route handlers next, and for every method: a handler is the only
261
- // thing that answers a POST, and it may also answer a GET for a path
262
- // that has no page.
263
- const handled = await entry.dispatch(asRequest);
264
- if (handled != null) {
265
- await send(response, handled);
266
- return true;
267
- }
268
-
269
- // Only a navigation reaches the renderer. A page cannot answer a POST,
270
- // and letting one try would turn a missing handler into a rendered page
271
- // with a 200 rather than a 404.
272
- if (request.method !== "GET" && request.method !== "HEAD") {
273
- return false;
274
- }
275
-
276
- const result = await entry.render(url, assets, {
277
- // A boundary that threw after the shell went out. `result.error` cannot
278
- // carry it — the caller already has the result by then — so the
279
- // terminal hears about it here or not at all.
280
- onError: (error) => reportRenderError(server, url, error),
281
- });
282
- if (result.error != null) reportRenderError(server, url, result.error);
283
- const html = await server.transformIndexHtml(url, await result.text());
284
- response.statusCode = result.status ?? 200;
285
- response.setHeader("content-type", "text/html; charset=utf-8");
286
- response.end(html);
287
- return true;
288
- });
289
-
290
- if (!answered) {
291
- // The one path where uf is not the one writing the response: a
292
- // non-navigation nothing claimed goes back to Vite's chain. The guard
293
- // has still run and may have deferred work, so `close` — the socket
294
- // saying the response is over, however it ended — is the only honest
295
- // signal left that the bytes are out.
296
- response.once("close", lifecycle.settle);
297
- next();
298
- return;
299
- }
300
- await lifecycle.settle();
301
- } catch (error) {
302
- if (lifecycle != null) await lifecycle.settle();
303
- // Map the stack back onto the Flow source before it reaches the overlay.
304
- if (error instanceof Error) server.ssrFixStacktrace(error);
305
- next(error);
306
- }
307
- });
308
-
309
231
  await server.listen();
310
232
  const urls = server.resolvedUrls ?? { local: [], network: [] };
311
233
  emit("listening", {
@@ -316,6 +238,7 @@ async function dev() {
316
238
  ),
317
239
  });
318
240
  watchSources(server);
241
+ watchEnvFiles(server);
319
242
 
320
243
  const shutdown = async () => {
321
244
  await server.close();
@@ -362,6 +285,45 @@ function watchSources(server) {
362
285
  }
363
286
  }
364
287
 
288
+ /**
289
+ * Restart the server when one of the `.env` files uf read changes.
290
+ *
291
+ * uf reads the `.env` cascade itself, in Rust, before this process starts —
292
+ * one parser, one precedence, one answer for every command (see `viteConfig`
293
+ * above and `crates/uf_config/src/env_files.rs`) — and `envDir: false` turns
294
+ * Vite's own file loading off so there cannot be two answers. The cost of that
295
+ * was that nothing watched them: a value edited while `uf dev` ran changed
296
+ * nothing until somebody restarted the command by hand, and the guide had to
297
+ * document it as a limitation. See ubugeeei-prod/uf#428.
298
+ *
299
+ * `uf` passes the files it would consult with `--uf-env-file`, one per file, in
300
+ * cascade order, whether or not each exists today — a `.env.local` *created*
301
+ * while the server runs changes the answer exactly as much as an edit to one
302
+ * that was already there, and watching only what was read would have missed
303
+ * it. They are added to Vite's watcher explicitly because they are in no
304
+ * module graph, which is the same reason the RSC manifest is added in
305
+ * `index.js`.
306
+ *
307
+ * What is emitted is "these values are stale", and the Rust side restarts this
308
+ * process with the files re-read. A restart rather than a hot update is the
309
+ * honest granularity: a prefixed value reaches the browser by substitution
310
+ * into the bundle, so a new value has to be substituted again, and every
311
+ * module that read one has to be re-evaluated. Vite's watcher is still the
312
+ * only watcher — a second one over the same tree, in Rust, would be a second
313
+ * answer to "did this file change".
314
+ */
315
+ function watchEnvFiles(server) {
316
+ const files = argumentAll("--uf-env-file").map((file) => path.resolve(root, file));
317
+ if (files.length === 0) return;
318
+ const watched = new Set(files);
319
+ server.watcher.add(files);
320
+ for (const event of ["add", "change", "unlink"]) {
321
+ server.watcher.on(event, (file) => {
322
+ if (watched.has(path.resolve(file))) emit("env-changed", { file, change: event });
323
+ });
324
+ }
325
+ }
326
+
365
327
  /**
366
328
  * The preview server: the build, as Vite serves it.
367
329
  *
@@ -386,37 +348,57 @@ async function preview() {
386
348
  const { preview: startPreview } = await import("vite");
387
349
  const config = await loadConfig();
388
350
  const inline = await viteConfig(config, argument("--mode") ?? "production");
389
- const build = await loadBuild({
390
- root,
391
- outDir: inline.build.outDir,
392
- serverDir: path.join(".uf", "build", "server"),
393
- });
351
+ // A build that declared it emits no server has none to mount. `uf` refuses
352
+ // `uf start` for such a project and lets this one through, because a preview
353
+ // of files *is* the deployment: what a static host does with `dist/` is
354
+ // exactly what Vite's preview server does with it, and mounting a request
355
+ // handler behind it would make this preview right about a deployment that is
356
+ // not the one happening. See `uf_cli`'s `commands::serve`.
357
+ const staticBuild = flag("--static-build");
358
+ const build = staticBuild
359
+ ? null
360
+ : await loadBuild({
361
+ root,
362
+ outDir: inline.build.outDir,
363
+ serverDir: path.join(".uf", "build", "server"),
364
+ });
394
365
 
395
366
  const server = await startPreview({ ...inline, appType: "custom" });
396
- const handle = createServeHandler({ ...build, cache: config.app?.rendering?.cache });
397
- server.middlewares.use(async (request, response, next) => {
398
- try {
399
- const asRequest = await toRequest(request, server.config);
400
- // The same lifecycle `uf start` gets from `nodeListener`, spelled out
401
- // because this door is Vite's connect chain rather than a bare
402
- // `node:http` server: the whole request runs inside it, and it settles
403
- // once `send` has returned. A preview whose `after()` fired at a
404
- // different moment from the production server's would be a preview that
405
- // is checked and believed and wrong.
406
- await withRequest(build.entry, asRequest, async () => {
407
- await send(response, await handle(asRequest));
408
- });
409
- } catch (error) {
410
- next(error);
411
- }
412
- });
367
+ if (build != null) {
368
+ const handle = createServeHandler({ ...build, cache: config.app?.rendering?.cache });
369
+ server.middlewares.use(async (request, response, next) => {
370
+ try {
371
+ const asRequest = await toRequest(request, server.config);
372
+ // The same lifecycle `uf start` gets from `nodeListener`, spelled out
373
+ // because this door is Vite's connect chain rather than a bare
374
+ // `node:http` server: the whole request runs inside it, and it settles
375
+ // once `send` has returned. A preview whose `after()` fired at a
376
+ // different moment from the production server's would be a preview that
377
+ // is checked and believed and wrong.
378
+ await withRequest(build.entry, asRequest, async () => {
379
+ await send(response, await handle(asRequest));
380
+ });
381
+ } catch (error) {
382
+ next(error);
383
+ }
384
+ });
385
+ }
413
386
 
414
387
  const urls = server.resolvedUrls ?? { local: [], network: [] };
415
388
  emit("listening", {
416
389
  local: urls.local,
417
390
  network: urls.network,
418
- routes: build.entry.routes.map((route) => route.path),
419
- handlers: build.entry.handlers.map((handler) => handler.path),
391
+ // From the filesystem when there is no bundle to ask, which is the same
392
+ // scan `dev` reports from. The count is what a reader checks the build
393
+ // against, so answering "0 routes" for a static site that has thirty would
394
+ // be the report being wrong about the thing it exists to report.
395
+ routes:
396
+ build == null
397
+ ? scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map(
398
+ (route) => route.path,
399
+ )
400
+ : build.entry.routes.map((route) => route.path),
401
+ handlers: build == null ? [] : build.entry.handlers.map((handler) => handler.path),
420
402
  });
421
403
 
422
404
  const shutdown = async () => {
@@ -498,6 +480,15 @@ async function build() {
498
480
  const inline = await viteConfig(config, mode);
499
481
  const outDir = path.resolve(root, inline.build.outDir);
500
482
  const serverDir = path.join(root, ".uf", "build", "server");
483
+ // How much of the route table to prerender, and whether the server bundle
484
+ // survives the build. Both are `uf`'s answer rather than this file's: they
485
+ // come from two settings in `uf.config.js` that only mean something read
486
+ // together, and `uf_config`'s `RenderingPlan` is where they are. A driver
487
+ // started by hand gets the behaviour every uf build had before either
488
+ // setting was read.
489
+ const prerender = argument("--prerender") ?? "possible";
490
+ const staticBuild = flag("--static-build");
491
+ const because = argument("--because") ?? "this build prerenders every route";
501
492
 
502
493
  // 1. The client: everything the browser loads, with a manifest so the
503
494
  // server render knows which script and stylesheet tags to write.
@@ -530,11 +521,39 @@ async function build() {
530
521
  },
531
522
  });
532
523
 
533
- // 3. Every static route, rendered to an HTML document.
524
+ // 3. Which routes this build renders when, and every route it renders now.
525
+ //
526
+ // The decision comes from `uf.config.js` and is made in Rust — see
527
+ // `uf_config`'s `RenderingPlan` — because `app.rendering.modes` and
528
+ // `build.staticBuild` are two settings that have to be read together. It
529
+ // arrives here as one word, and this is where it meets the route table.
534
530
  emit("phase", { name: "prerender" });
535
531
  const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
536
532
  const assets = assetsFromManifest(manifest);
537
- const pages = await staticPaths(server.routes);
533
+ const plan = await renderingPlan(server, prerender);
534
+ emit("rendering", {
535
+ prerender,
536
+ prerendered: plan.urls.length,
537
+ perRequest: plan.perRequest.map((route) => route.path),
538
+ });
539
+ // A build that has to prerender everything, and a route it cannot: the
540
+ // refusal ubugeeei-prod/uf#336 and ubugeeei-prod/uf#385 are both about.
541
+ // Before the loop below, so no document is written for a build that is not
542
+ // going to be one, and with the whole list rather than the first item — a
543
+ // project that has just narrowed `rendering.modes` wants to see every route
544
+ // the narrowing costs it, not one per rebuild.
545
+ if (prerender === "everything" && plan.perRequest.length > 0) {
546
+ const listed = plan.perRequest.map((entry) => ` ${entry.path} — ${entry.why}`).join("\n");
547
+ emit("error", {
548
+ message:
549
+ `${plan.perRequest.length} ${plural(plan.perRequest.length, "route")} in this project ` +
550
+ `can only be answered by a server, and ${because}\n${listed}\n\n` +
551
+ "Give each page a `generateStaticParams` and take out the handlers and middleware, or " +
552
+ 'allow `"ssr"` in `app.rendering.modes` and deploy a server.',
553
+ });
554
+ process.exit(1);
555
+ }
556
+ const pages = plan.urls;
538
557
 
539
558
  // A route that throws fails *that route*, and the rest of the build still
540
559
  // happens. This loop had no `try`: the first page to throw rejected out of
@@ -598,7 +617,11 @@ async function build() {
598
617
  // host would then serve uf's error page to every visitor who mistyped a URL,
599
618
  // and nothing between the throw and the deploy would have mentioned it.
600
619
  let attempted = pages.length;
601
- if (server.notFound.some((boundary) => boundary.path === "/")) {
620
+ // Not for a build that prerenders nothing. `404.html` is a file a static
621
+ // host serves for every path it has no file for, and a project whose
622
+ // `rendering.modes` allows only `ssr` has no such host: its not-found
623
+ // boundary is rendered per request, by the server, with the right status.
624
+ if (prerender !== "nothing" && server.notFound.some((boundary) => boundary.path === "/")) {
602
625
  attempted += 1;
603
626
  // `/404` rather than `/__uf_not_found__`: the internal path is how the
604
627
  // router is asked, and the file the reader is looking for is `404.html`.
@@ -645,10 +668,161 @@ async function build() {
645
668
  process.exit(1);
646
669
  }
647
670
 
671
+ // `build.staticBuild` is "prerender everything and emit no server bundle",
672
+ // and this is the second half of it. The bundle is still *built*: the
673
+ // prerender renders through it, so a build with no server bundle at any
674
+ // point would be a build with no documents either. What the declaration is
675
+ // about is what is left behind — so it goes once the last document is
676
+ // written, and `uf start`, `uf preview` and every server adapter then find
677
+ // nothing to serve, which is the honest outcome for a project that said it
678
+ // deploys files.
679
+ if (staticBuild) rmSync(serverDir, { recursive: true, force: true });
680
+
648
681
  emit("done", { outDir: path.relative(root, outDir), pages: pages.length });
649
682
  process.exit(0);
650
683
  }
651
684
 
685
+ /**
686
+ * The library build, for a project whose `app.router.enabled` is false.
687
+ *
688
+ * `build` above is an application build and has no other mode: it links
689
+ * `virtual:uf/client`, which imports the router and the project's `app.js`.
690
+ * A library has neither, so `uf build` in a project `uf create lib`
691
+ * scaffolded failed at the first pass with `Could not resolve '<root>/app.js'`
692
+ * — a file a library does not have and never had. See ubugeeei-prod/uf#268.
693
+ *
694
+ * This is the fourth thing the driver does, beside `dev`, `build` and
695
+ * `compile`, and it is one pass per format over one input list. Which of the
696
+ * two builds runs is **not decided here**: `uf` resolves it from the config
697
+ * (`uf_config`'s `LibraryPlan`) and spawns this subcommand, the same way
698
+ * `--prerender` arrives as one word rather than as two settings for this file
699
+ * to read together.
700
+ *
701
+ * # The three ways it differs from the application build
702
+ *
703
+ * * **Every dependency stays an import.** `--external` names them, and
704
+ * `uf` computes the list from the project's own manifest —
705
+ * `dependencies`, `peerDependencies`, `optionalDependencies` — so a
706
+ * library ships its own modules and nobody else's. That is the opposite
707
+ * of the application build, which inlines what it can because an
708
+ * application is the end of the line and a library is not: a bundled copy
709
+ * of React inside a library is a second React in every application that
710
+ * installs it.
711
+ * * **One output per entry, named after the entry.** `index.js` becomes
712
+ * `dist/index.js`; `internal/parse.js` becomes `dist/internal/parse.js`.
713
+ * The path rather than the basename, so two entries cannot collide at the
714
+ * moment one would overwrite the other.
715
+ * * **No manifest, no prerender, no server bundle.** There is no document to
716
+ * write and no route table to write it from.
717
+ *
718
+ * Vite's own `build.lib` does the work. uf owns *that* a library is a
719
+ * different build and what goes into it; how this builder performs one is the
720
+ * builder's, which is the same line `build` draws around `rollupOptions`.
721
+ */
722
+ async function library() {
723
+ const vite = await import("vite");
724
+ const config = await loadConfig();
725
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
726
+ const outDir = path.resolve(root, inline.build.outDir);
727
+ const entries = argumentAll("--entry");
728
+ const formats = argumentAll("--format");
729
+ const external = argumentAll("--external");
730
+ if (entries.length === 0) {
731
+ throw new Error("uf: `driver.js library` needs at least one --entry");
732
+ }
733
+ if (formats.length === 0) {
734
+ throw new Error("uf: `driver.js library` needs at least one --format");
735
+ }
736
+
737
+ // Keyed by the entry's path without its extension, which is what Vite's lib
738
+ // mode turns into the output file name.
739
+ const input = {};
740
+ for (const entry of entries) {
741
+ input[entryName(entry)] = path.resolve(root, entry);
742
+ }
743
+
744
+ const isExternal = externalTest(external);
745
+ // One pass per format rather than one build with several outputs: Vite's
746
+ // lib mode writes a whole `outDir` per format, and the second pass must not
747
+ // empty what the first wrote. So `emptyOutDir` is true exactly once, on the
748
+ // first, which is also what makes a build that dropped an entry leave no
749
+ // stale copy of it behind.
750
+ let first = true;
751
+ for (const format of formats) {
752
+ emit("phase", { name: `library (${format})` });
753
+ await vite.build({
754
+ ...inline,
755
+ build: {
756
+ ...inline.build,
757
+ // Vite's `manifest` maps source modules to hashed browser assets. A
758
+ // library has neither — its file names are its API — and writing one
759
+ // would put a `.vite/` directory into a published tarball.
760
+ manifest: false,
761
+ outDir,
762
+ emptyOutDir: first,
763
+ lib: {
764
+ entry: input,
765
+ formats: [format],
766
+ fileName: (_format, name) => `${name}.${format === "cjs" ? "cjs" : "js"}`,
767
+ },
768
+ rollupOptions: { external: isExternal },
769
+ },
770
+ });
771
+ first = false;
772
+ }
773
+
774
+ emit("done", { outDir: path.relative(root, outDir), pages: 0 });
775
+ process.exit(0);
776
+ }
777
+
778
+ /**
779
+ * The output name for one entry: its path, without the extension.
780
+ *
781
+ * Not the basename. `index.js` and `internal/index.js` are two entries a
782
+ * library can reasonably have, and under a basename they are one file written
783
+ * twice — the second silently winning, which is a published package whose
784
+ * subpath export is somebody else's module.
785
+ */
786
+ function entryName(entry) {
787
+ const normalised = entry.replace(/\\/g, "/").replace(/^\.\//, "");
788
+ const dot = normalised.lastIndexOf(".");
789
+ const slash = normalised.lastIndexOf("/");
790
+ return dot > slash ? normalised.slice(0, dot) : normalised;
791
+ }
792
+
793
+ /**
794
+ * Whether an import is somebody else's module.
795
+ *
796
+ * Three checks, and only the one over `names` is a policy uf decided. `names`
797
+ * is what `uf` read out of the project's manifest and passed as `--external`,
798
+ * and a subpath of one of those names — `@scope/pkg/deep` for `@scope/pkg` —
799
+ * is the same package. The other two are the host's built-in modules, and they
800
+ * are a fact rather than a decision: `node:fs` has no bytes to inline.
801
+ *
802
+ * A bare relative or absolute id is never external, which is the rule that
803
+ * makes this a library build at all: what the author wrote is bundled, and
804
+ * what they installed is imported.
805
+ */
806
+ function externalTest(names) {
807
+ const declared = new Set(names);
808
+ // The host's built-in module names, unprefixed. `node:`-prefixed ids are
809
+ // caught by the first check whatever the host is; this set is for the bare
810
+ // spellings — `fs`, `path`, `stream` — which a dependency written before the
811
+ // prefix existed still uses. Read from the running host rather than written
812
+ // down, because the list grows and a stale copy of it here would be a
813
+ // bundled `node:worker_threads` that cannot be bundled.
814
+ const builtins = new Set(builtinModules ?? []);
815
+ return (id) => {
816
+ if (id.startsWith("node:")) return true;
817
+ if (builtins.has(id)) return true;
818
+ if (declared.has(id)) return true;
819
+ for (const name of declared) {
820
+ if (id.startsWith(`${name}/`)) return true;
821
+ }
822
+ return false;
823
+ };
824
+ }
825
+
652
826
  /**
653
827
  * Link the whole application into one JavaScript file, for `uf build --compile`.
654
828
  *
@@ -856,6 +1030,16 @@ const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lam
856
1030
  * copying every file in it is bulk work over the whole build, which belongs in
857
1031
  * Rust rather than in the host process — the same division `--compile` makes
858
1032
  * with its embedded assets.
1033
+ *
1034
+ * # `--adapter static` never reaches this function
1035
+ *
1036
+ * It is the one implemented target with no application to link: a static host
1037
+ * returns files, and `uf build` has already written them. So `uf` copies the
1038
+ * output directory itself and never spawns this driver for it, which is why
1039
+ * [`ADAPTERS`] has four rows and not five. What that target does instead of
1040
+ * linking is refuse a project whose route handlers, middleware, unprerendered
1041
+ * routes or server actions a static host cannot answer — in Rust, because the
1042
+ * facts it needs are the route table and what the prerender reported.
859
1043
  */
860
1044
  async function deploy() {
861
1045
  const vite = await import("vite");
@@ -1204,24 +1388,127 @@ function readManifest(outDir) {
1204
1388
  }
1205
1389
 
1206
1390
  /**
1207
- * The URLs to prerender: every route without parameters, plus every set of
1208
- * parameters a page's `generateStaticParams` returns.
1391
+ * What this build renders now, and what it leaves for a server.
1392
+ *
1393
+ * The rendering decision, per route, and it has three answers rather than the
1394
+ * two `staticPaths` used to have:
1395
+ *
1396
+ * * **prerender it** — a route with no parameters, or a route whose page
1397
+ * exports `generateStaticParams`, once per set of parameters it returns;
1398
+ * * **leave it to the server** — a route with parameters and no
1399
+ * `generateStaticParams`, or a page that has said `export const dynamic =
1400
+ * "force-dynamic"`;
1401
+ * * **refuse** — which is not decided here. This function reports what it
1402
+ * found and the caller, which knows whether the project allows a server,
1403
+ * is the one that turns "there is a route here a static host cannot
1404
+ * answer" into an error.
1405
+ *
1406
+ * `dynamic` is the spelling ubugeeei-prod/uf#336 asked for: a route with *no*
1407
+ * parameters whose content depends on the request had no way to say so, and
1408
+ * `generateStaticParams` cannot say it — there are no parameters to generate.
1409
+ * It is Next.js's name for the same declaration, because a person arriving
1410
+ * from `app/` should not have to learn a second word for a decision they have
1411
+ * already made once.
1412
+ *
1413
+ * Two of Next's four values are missing and are not silently accepted:
1414
+ * `"force-static"` and `"error"` are refused by name, because each is a
1415
+ * *constraint* on a page that uf does not yet check, and accepting one would
1416
+ * be reading a declaration and ignoring it — the failure the two issues behind
1417
+ * this function are about.
1418
+ *
1419
+ * Handlers and middleware are in the same list, and they belong there: this is
1420
+ * the list of things that need a process, and a `_uf.route.js` needs one more
1421
+ * obviously than any page does. They carry no per-route render — the build has
1422
+ * never written a file for either — so they appear only when the answer might
1423
+ * be a refusal.
1424
+ *
1425
+ * @param {{routes: Route[], handlers: Handler[], middleware: Middleware[]}} server
1426
+ * @param {"everything" | "possible" | "nothing"} prerender
1209
1427
  */
1210
- async function staticPaths(routes) {
1428
+ async function renderingPlan(server, prerender) {
1211
1429
  const urls = [];
1212
- for (const route of routes) {
1430
+ const perRequest = [];
1431
+
1432
+ // Nothing is prerendered and nothing is refused, so no page module is
1433
+ // loaded: a project that renders everything per request should not pay for
1434
+ // a `generateStaticParams` this build will not call.
1435
+ if (prerender === "nothing") {
1436
+ return {
1437
+ urls,
1438
+ perRequest: server.routes.map((route) => ({
1439
+ path: route.path,
1440
+ why: "this build prerenders nothing",
1441
+ })),
1442
+ };
1443
+ }
1444
+
1445
+ for (const route of server.routes) {
1446
+ // Every page module, and not only the parameterised ones: `dynamic` is a
1447
+ // declaration any page can make. A module that cannot be imported at all
1448
+ // is a failure of *that route*, so a route with no parameters goes into
1449
+ // the prerender anyway and the loop below reports it the way it has always
1450
+ // reported a page that throws — named, with the rest of the build still
1451
+ // happening. A parameterised one still rejects out of the build, which is
1452
+ // what it did before there was anything else to load a page module for.
1453
+ let module;
1454
+ try {
1455
+ module = await route.page();
1456
+ } catch (error) {
1457
+ if (route.params.length > 0) throw error;
1458
+ urls.push(route.path);
1459
+ continue;
1460
+ }
1461
+ const declared = module.dynamic ?? "auto";
1462
+ if (declared !== "auto" && declared !== "force-dynamic") {
1463
+ throw new Error(
1464
+ `uf: ${route.file} exports \`dynamic = ${JSON.stringify(declared)}\`, and uf reads ` +
1465
+ '`"auto"` and `"force-dynamic"`. `"force-static"` and `"error"` are Next.js values ' +
1466
+ "for constraints uf does not check yet, and accepting one would be reading a " +
1467
+ "declaration and ignoring it.",
1468
+ );
1469
+ }
1470
+ if (declared === "force-dynamic") {
1471
+ perRequest.push({
1472
+ path: route.path,
1473
+ why: 'its page exports `dynamic = "force-dynamic"`',
1474
+ });
1475
+ continue;
1476
+ }
1213
1477
  if (route.params.length === 0) {
1214
1478
  urls.push(route.path);
1215
1479
  continue;
1216
1480
  }
1217
- const module = await route.page();
1218
1481
  const generate = module.generateStaticParams;
1219
- if (typeof generate !== "function") continue;
1482
+ if (typeof generate !== "function") {
1483
+ perRequest.push({
1484
+ path: route.path,
1485
+ why: "it has parameters and its page exports no `generateStaticParams`",
1486
+ });
1487
+ continue;
1488
+ }
1220
1489
  for (const params of await generate()) {
1221
1490
  urls.push(fillParams(route.path, params));
1222
1491
  }
1223
1492
  }
1224
- return urls;
1493
+
1494
+ for (const handler of server.handlers ?? []) {
1495
+ perRequest.push({
1496
+ path: handler.path,
1497
+ why: "it is a route handler, and a handler answers a request rather than producing a file",
1498
+ });
1499
+ }
1500
+ for (const entry of server.middleware ?? []) {
1501
+ // A middleware is reported by the path it guards rather than by the route
1502
+ // it guards, which is why it cannot be folded into the loop above: it runs
1503
+ // for a page, for a handler, and for a path under it that is neither, so
1504
+ // "which route is this" has no single answer.
1505
+ perRequest.push({
1506
+ path: `${entry.path === "/" ? "" : entry.path}/*`,
1507
+ why: "a middleware guards it, and a middleware runs once per request",
1508
+ });
1509
+ }
1510
+
1511
+ return { urls, perRequest };
1225
1512
  }
1226
1513
 
1227
1514
  function fillParams(routePath, params) {