@uniflowed/vite 0.0.0-alpha.4 → 0.0.0-alpha.41

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
@@ -2,13 +2,34 @@
2
2
  //
3
3
  // Plain JavaScript: the host runs this file directly.
4
4
  //
5
- // The driver `uf dev`, `uf build` and `uf preview` spawn.
5
+ // The driver `uf dev`, `uf build`, `uf build --compile`, `uf preview` and
6
+ // `uf start` spawn.
6
7
  //
7
- // <host> driver.js dev --root <dir> [--host <h>] [--port <n>] [--strict-port]
8
- // <host> driver.js build --root <dir> [--out-dir <dir>] [--mode <m>]
9
- // <host> driver.js preview --root <dir> [--host <h>] [--port <n>]
8
+ // <host> driver.js dev --root <dir> [--mode <m>] [--host <h>] [--port <n>] [--strict-port]
9
+ // [--uf-env-file <file>]...
10
+ // <host> driver.js build --root <dir> [--mode <m>] [--out-dir <dir>] [--target <target>]
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>]...
15
+ // <host> driver.js compile --root <dir> [--mode <m>] [--out-dir <dir>] --assets <file> --bundle <dir>
16
+ // <host> driver.js deploy --root <dir> [--mode <m>] [--out-dir <dir>] --adapter <name> --work <dir> --output <dir>
17
+ // <host> driver.js preview --root <dir> [--mode <m>] [--out-dir <dir>] [--host <h>] [--port <n>]
18
+ // <host> driver.js start --root <dir> [--out-dir <dir>] [--host <h>] [--port <n>]
10
19
  // <host> driver.js config --root <dir>
11
20
  //
21
+ // `--mode` is what `uf` resolved from `--mode`, `.uniflowed/profile` and
22
+ // `env.active`; it is Vite's mode, so it is `import.meta.env.MODE`. The `.env`
23
+ // files it selected have already been read, by `uf`, into this process's
24
+ // environment — see `viteConfig` below and `crates/uf_config/src/env_files.rs`.
25
+ // `start` has no Vite in it and therefore no mode.
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
+ //
12
33
  // `uf` in Rust owns the terminal; this process owns Vite. They talk over
13
34
  // stdout, one JSON event per line (see `./internal/events.js`), and the driver
14
35
  // exits when its stdin closes so it cannot outlive the command that started
@@ -18,21 +39,63 @@
18
39
  // Rust side reads a config that may hold functions and plugin instances: the
19
40
  // one host that can evaluate the file evaluates it.
20
41
 
21
- import { register } from "node:module";
22
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
42
+ import { randomUUID } from "node:crypto";
43
+ import { createServer as createHttpServer } from "node:http";
44
+ import { builtinModules, register } from "node:module";
45
+ import { installFlowHooks } from "@uniflowed/host/internal/sync-hooks.js";
46
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
47
  import path from "node:path";
24
48
  import { pathToFileURL } from "node:url";
25
49
 
50
+ import { COMPILE_ASSETS_ID, compileAssetsPlugin } from "./internal/compile-assets.js";
51
+ import {
52
+ survivingImports,
53
+ unavailableOnWorkers,
54
+ workerBuiltinWarnings,
55
+ } from "./internal/worker-builtins.js";
26
56
  import { emit, errorEvent, eventLogger } from "./internal/events.js";
27
57
  import { loadUfConfig, projectConfig } from "./internal/config.js";
58
+ import { send, toAddressRequest, toRequest } from "./internal/http.js";
59
+ import { createOpenApiDocument } from "./internal/openapi.js";
28
60
  import { withProjectConfig } from "./merge.js";
29
- import { VIRTUAL, scanRoutes } from "./internal/routes.js";
61
+ import { FLIGHT_VIRTUAL, RSC_ENVIRONMENT } from "./internal/flight.js";
62
+ import { MODULE_GRAPH_FILE, createModuleGraphCollector } from "./internal/module-graph.js";
63
+ import { VIRTUAL, resolveRouteTarget, routingRulesOf, scanRoutes } from "./internal/routes.js";
64
+ import {
65
+ BUILD_ID_FILE,
66
+ DOCUMENT_ASSETS_FILE,
67
+ REGENERATED_DIRECTORY,
68
+ REGENERATION_FILE,
69
+ answerRouting,
70
+ answersInFrontOfFiles,
71
+ assetsFromManifest,
72
+ buildIdentity,
73
+ createPrerenderGate,
74
+ createServeHandler,
75
+ documentAssetsFor,
76
+ forViteBase,
77
+ loadBuild,
78
+ nodeListener,
79
+ providerSpecifier,
80
+ readRegeneration,
81
+ withRequest,
82
+ } from "./internal/serve.js";
30
83
 
31
84
  function argument(name) {
32
85
  const at = process.argv.indexOf(name);
33
86
  return at === -1 ? null : process.argv[at + 1];
34
87
  }
35
88
 
89
+ /** Every value of a repeated argument, in the order they were given. */
90
+ function argumentAll(name) {
91
+ const values = [];
92
+ for (let at = 0; at < process.argv.length; at += 1) {
93
+ if (process.argv[at] === name && process.argv[at + 1] != null)
94
+ values.push(process.argv[at + 1]);
95
+ }
96
+ return values;
97
+ }
98
+
36
99
  function flag(name) {
37
100
  return process.argv.includes(name);
38
101
  }
@@ -47,11 +110,23 @@ process.env.UF_PROJECT_ROOT = root;
47
110
  // hooks for that; Bun is started with `--preload` on the same package's
48
111
  // preload instead, and has no `register`.
49
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
+ //
50
123
  // The hooks live in `@uniflowed/host` rather than here: they are how Flow runs
51
124
  // on a Capability JS Host, and nothing in them is Vite's. `uf test` reaches for
52
125
  // the same package, which is what stopped a test run from depending on a
53
126
  // bundler it never loads.
54
- if (typeof Bun === "undefined" && typeof Deno === "undefined") {
127
+ if (typeof Deno !== "undefined") {
128
+ installFlowHooks(root);
129
+ } else if (typeof Bun === "undefined") {
55
130
  register("@uniflowed/host/internal/node-hooks.js", import.meta.url, { data: { root } });
56
131
  }
57
132
 
@@ -59,7 +134,7 @@ process.stdin.on("end", () => process.exit(0));
59
134
  process.stdin.on("error", () => process.exit(0));
60
135
  process.stdin.resume();
61
136
 
62
- const commands = { dev, build, preview, config: printConfig };
137
+ const commands = { dev, build, library, compile, deploy, preview, start, config: printConfig };
63
138
  const run = commands[command];
64
139
  if (run == null) {
65
140
  emit("error", { message: `unknown driver command ${JSON.stringify(command)}` });
@@ -71,6 +146,33 @@ run().catch((error) => {
71
146
  process.exit(1);
72
147
  });
73
148
 
149
+ /**
150
+ * What this build is called, for anything that outlives it.
151
+ *
152
+ * `UF_BUILD_ID` when it is set, and a fresh random name otherwise — which is
153
+ * `crates/uf_rsc`'s `BuildId::from_env_or_generate` exactly, reading the same
154
+ * variable, because it is the same question asked by a different half of the
155
+ * toolchain. Two artefacts that have to *be* one build say so with the
156
+ * variable; everything else gets a name no other build has.
157
+ *
158
+ * Minted once per build and written into the build, never per process. Four
159
+ * servers started from one artefact are one build and must share one cache;
160
+ * generating this where the server starts would give them four, which is worse
161
+ * than none at all — four copies of everything written into one directory and
162
+ * none of them read.
163
+ *
164
+ * Not a hash of the output. A content hash would be reproducible, which is
165
+ * appealing and wrong here: two builds with identical client bundles can have
166
+ * different server behaviour — a loader's body moves and no asset hash does —
167
+ * and a cache keyed by one would serve the old loader's documents. A name that
168
+ * changes whenever the build ran is the conservative direction to be wrong in.
169
+ */
170
+ function mintBuildId() {
171
+ const named = process.env.UF_BUILD_ID;
172
+ if (typeof named === "string" && named.trim() !== "") return named.trim();
173
+ return randomUUID().replaceAll("-", "");
174
+ }
175
+
74
176
  /** Load `uf.config.js`, reporting where it was found. */
75
177
  async function loadConfig() {
76
178
  const { config, file } = await loadUfConfig(root);
@@ -78,6 +180,15 @@ async function loadConfig() {
78
180
  return config;
79
181
  }
80
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
+
81
192
  /** The Vite inline config a uf config describes. */
82
193
  async function viteConfig(config, mode) {
83
194
  const { default: uniflowed } = await import("./index.js");
@@ -88,19 +199,59 @@ async function viteConfig(config, mode) {
88
199
  const port = Number(argument("--port") ?? dev.port ?? 5173);
89
200
  const allowedHosts =
90
201
  Array.isArray(dev.allowedHosts) && dev.allowedHosts.length > 0 ? dev.allowedHosts : undefined;
202
+ const routeTarget = resolveRouteTarget(config, argument("--target"));
91
203
 
92
204
  // What uf generates from the semantics it owns: where the project is, which
93
205
  // plugins make Flow compile, and the few settings uf enforces rather than
94
206
  // merely passes on — `allowedHosts` gates binding a routable address, and
95
207
  // `manifest` is how the prerender finds its assets.
208
+ //
209
+ // `envDir: false` turns off Vite's *file* loading, and only that. uf reads
210
+ // the `.env` cascade itself, in Rust, before this process starts — one
211
+ // parser, one precedence, one answer for `uf dev`, `uf build`, `uf start`,
212
+ // `uf test` and `uf run` — and sets what it read in this process's
213
+ // environment. Vite's `loadEnv` still runs with `envDir: false` and still
214
+ // picks every `envPrefix`-matching name out of `process.env`, so the client
215
+ // half is Vite's own, unchanged: the prefixed subset becomes
216
+ // `import.meta.env.*` in the browser bundle and nothing else does. See
217
+ // `crates/uf_config/src/env_files.rs`, `docs/app/guide/env` and #259.
218
+ //
219
+ // A project that would rather Vite read the files can still say
220
+ // `vite: { envDir: "." }` — its own configuration is merged over this one —
221
+ // and then both parsers run, uf's answer still standing. `loadEnv` takes the
222
+ // prefixed names out of the files it read and then copies every prefixed name
223
+ // in `process.env` over the top, and uf put its own there before this process
224
+ // started; so the second parser adds prefixed names uf did not set and
225
+ // changes none that it did.
96
226
  const generated = {
97
227
  root,
98
228
  configFile: false,
99
- envFile: false,
229
+ envDir: false,
100
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)}/`,
101
252
  clearScreen: false,
102
253
  customLogger: eventLogger(argument("--log-level") ?? "info"),
103
- plugins: [uniflowed({ root, config })],
254
+ plugins: [uniflowed({ root, config, target: routeTarget })],
104
255
  server: {
105
256
  host,
106
257
  port,
@@ -111,7 +262,17 @@ async function viteConfig(config, mode) {
111
262
  deny: dev.fs?.deny,
112
263
  },
113
264
  },
114
- preview: { host, port },
265
+ // Not `server`, and not `dev.port` either. Vite's own default for a
266
+ // preview is 4173 rather than 5173, and the reason is the case this
267
+ // command exists for: somebody comparing a build against the dev server
268
+ // they left running. Taking `dev.port` would have made the two collide,
269
+ // and Vite would have moved the preview to the next free port and served
270
+ // it somewhere nobody was looking.
271
+ preview: {
272
+ host: argument("--host") ?? "127.0.0.1",
273
+ port: Number(argument("--port") ?? 4173),
274
+ strictPort: flag("--strict-port"),
275
+ },
115
276
  build: {
116
277
  outDir: argument("--out-dir") ?? build.outDir ?? "dist",
117
278
  sourcemap: build.sourcemap ?? true,
@@ -135,72 +296,43 @@ async function viteConfig(config, mode) {
135
296
  * Vite in middleware mode serves nothing on its own: with no `index.html` at
136
297
  * the project root it answers every navigation with "Cannot GET /", which is
137
298
  * what `uf dev` used to do for every project it started. A uf project has no
138
- * `index.html` — the document comes from a layout — so the server has to render
139
- * it, which is what this middleware does:
299
+ * `index.html` — the document comes from a layout — so the server has to
300
+ * render it.
140
301
  *
141
- * 1. load the server entry through `ssrLoadModule`, so it is transformed the
142
- * same way the browser's copy is and picks up edits without a restart;
143
- * 2. render the URL, pointing the client script at the dev entry rather than
144
- * at a built asset;
145
- * 3. hand the HTML to `transformIndexHtml`, which is what injects the HMR
146
- * client and lets any Vite plugin see the document.
302
+ * That rendering is **not** here. It is one middleware, in `./index.js`'s
303
+ * `configureServer`, and this function installs none of its own. It used to
304
+ * install a second one, and two middlewares rendering the same request is how
305
+ * `uf dev` came to answer a route handler with a page and a redirect without
306
+ * its `Location`: `configureServer`'s post hook runs inside `createServer`,
307
+ * and anything added here runs after it returns, so of the two the plugin's
308
+ * was always the one that decided. See ubugeeei-prod/uf#349 and #338, and the
309
+ * comment above that middleware for what it now has to do.
147
310
  *
148
- * Anything Vite already serves — a module, a public file — never reaches this,
149
- * because the middleware runs after Vite's own.
311
+ * What is left here is the half that is genuinely the driver's: the Vite
312
+ * config, the socket, the event channel back to `uf`, and the two watchers
313
+ * below.
150
314
  */
151
315
  async function dev() {
152
316
  const { createServer } = await import("vite");
153
317
  const config = await loadConfig();
154
- const inline = await viteConfig(config, "development");
318
+ const routeTarget = resolveRouteTarget(config, argument("--target"));
319
+ // The mode is uf's to decide, not this file's: `uf dev` resolves `--mode`,
320
+ // the profile `uf env use` wrote and `env.active` before it starts anything,
321
+ // and always passes the answer. The fallback is for a driver started by hand.
322
+ const inline = await viteConfig(config, argument("--mode") ?? "development");
155
323
  const server = await createServer({ ...inline, appType: "custom" });
156
324
 
157
- // In dev the browser loads the client entry from Vite, not from a manifest;
158
- // its stylesheets arrive through that module rather than as <link> tags.
159
- const assets = { scripts: [`/@id/${VIRTUAL.client}`], styles: [], preloads: [] };
160
-
161
- server.middlewares.use(async (request, response, next) => {
162
- const url = request.originalUrl ?? request.url ?? "/";
163
- try {
164
- const entry = await server.ssrLoadModule(VIRTUAL.server);
165
-
166
- // Route handlers first, and for every method: a handler is the only
167
- // thing that answers a POST, and it may also answer a GET for a path
168
- // that has no page.
169
- const handled = await entry.dispatch(await toRequest(request, server.config));
170
- if (handled != null) {
171
- await send(response, handled);
172
- return;
173
- }
174
-
175
- // Only a navigation reaches the renderer. A page cannot answer a POST,
176
- // and letting one try would turn a missing handler into a rendered page
177
- // with a 200 rather than a 404.
178
- if (request.method !== "GET" && request.method !== "HEAD") {
179
- next();
180
- return;
181
- }
182
-
183
- const result = await entry.render(url, assets);
184
- const html = await server.transformIndexHtml(url, result.html);
185
- response.statusCode = result.status ?? 200;
186
- response.setHeader("content-type", "text/html; charset=utf-8");
187
- response.end(html);
188
- } catch (error) {
189
- // Map the stack back onto the Flow source before it reaches the overlay.
190
- if (error instanceof Error) server.ssrFixStacktrace(error);
191
- next(error);
192
- }
193
- });
194
-
195
325
  await server.listen();
196
326
  const urls = server.resolvedUrls ?? { local: [], network: [] };
197
327
  emit("listening", {
198
328
  local: urls.local,
199
329
  network: urls.network,
200
- routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map(
201
- (route) => route.path,
202
- ),
330
+ routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app"), {
331
+ target: routeTarget,
332
+ }).routes.map((route) => route.path),
203
333
  });
334
+ watchSources(server);
335
+ watchEnvFiles(server);
204
336
 
205
337
  const shutdown = async () => {
206
338
  await server.close();
@@ -211,67 +343,255 @@ async function dev() {
211
343
  }
212
344
 
213
345
  /**
214
- * A Node request as a `Request`.
346
+ * Tell the Rust side when a module under the project root changed.
347
+ *
348
+ * `uf dev` answers questions Vite does not: whether a module is a Server
349
+ * Component, and whether a Server Component reaches for something that only
350
+ * exists in a browser. Those are whole-project answers, so they go stale on
351
+ * any edit and there is no module to recompute them *for* — which is why this
352
+ * event carries no path. What it carries is "ask again".
353
+ *
354
+ * Vite's watcher is the only watcher. A second one over the same tree, in
355
+ * Rust, would be a second answer to "did this file change", and two watchers
356
+ * disagree exactly when an editor writes through a temporary file — which is
357
+ * every editor, and which is not a thing anybody tests.
215
358
  *
216
- * The handler contract is the platform's, so the adapter belongs here rather
217
- * than in every handler. The body is read as a stream where the host supports
218
- * it, because a handler that accepts an upload should not need the whole thing
219
- * buffered before it starts.
359
+ * Debounced, because a `git checkout` is one intention and several hundred
360
+ * `change` events, and unrefed so a pending timer cannot keep this process
361
+ * alive after the server has closed.
220
362
  */
221
- async function toRequest(incoming, config) {
222
- const host = incoming.headers.host ?? "localhost";
223
- const protocol = config?.server?.https == null ? "http" : "https";
224
- const url = new URL(incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
225
-
226
- const headers = new Headers();
227
- for (const [name, value] of Object.entries(incoming.headers)) {
228
- if (value == null) continue;
229
- for (const entry of Array.isArray(value) ? value : [value]) {
230
- headers.append(name, entry);
231
- }
232
- }
233
-
234
- const method = (incoming.method ?? "GET").toUpperCase();
235
- const init = { method, headers };
236
- if (method !== "GET" && method !== "HEAD") {
237
- // `duplex` is required by the specification whenever a body is a stream,
238
- // and Node throws without it.
239
- init.body = incoming;
240
- init.duplex = "half";
363
+ function watchSources(server) {
364
+ let timer = null;
365
+ const changed = () => {
366
+ if (timer != null) clearTimeout(timer);
367
+ timer = setTimeout(() => {
368
+ timer = null;
369
+ emit("source-changed");
370
+ }, 50);
371
+ timer.unref?.();
372
+ };
373
+ const isSource = (file) =>
374
+ (file.endsWith(".js") || file.endsWith(".jsx")) && !file.includes("node_modules");
375
+ for (const event of ["add", "change", "unlink"]) {
376
+ server.watcher.on(event, (file) => {
377
+ if (isSource(file)) changed();
378
+ });
241
379
  }
242
- return new Request(url, init);
243
380
  }
244
381
 
245
- /** Write a `Response` to a Node response. */
246
- async function send(outgoing, result) {
247
- outgoing.statusCode = result.status;
248
- if (result.statusText !== "") {
249
- outgoing.statusMessage = result.statusText;
250
- }
251
- for (const [name, value] of result.headers) {
252
- outgoing.setHeader(name, value);
253
- }
254
- if (result.body == null) {
255
- outgoing.end();
256
- return;
257
- }
258
- // Streamed rather than buffered, so a handler returning a large or
259
- // open-ended body is not read into memory first.
260
- const reader = result.body.getReader();
261
- while (true) {
262
- const { done, value } = await reader.read();
263
- if (done) break;
264
- outgoing.write(value);
382
+ /**
383
+ * Restart the server when one of the `.env` files uf read changes.
384
+ *
385
+ * uf reads the `.env` cascade itself, in Rust, before this process starts —
386
+ * one parser, one precedence, one answer for every command (see `viteConfig`
387
+ * above and `crates/uf_config/src/env_files.rs`) — and `envDir: false` turns
388
+ * Vite's own file loading off so there cannot be two answers. The cost of that
389
+ * was that nothing watched them: a value edited while `uf dev` ran changed
390
+ * nothing until somebody restarted the command by hand, and the guide had to
391
+ * document it as a limitation. See ubugeeei-prod/uf#428.
392
+ *
393
+ * `uf` passes the files it would consult with `--uf-env-file`, one per file, in
394
+ * cascade order, whether or not each exists today — a `.env.local` *created*
395
+ * while the server runs changes the answer exactly as much as an edit to one
396
+ * that was already there, and watching only what was read would have missed
397
+ * it. They are added to Vite's watcher explicitly because they are in no
398
+ * module graph, which is the same reason the RSC manifest is added in
399
+ * `index.js`.
400
+ *
401
+ * What is emitted is "these values are stale", and the Rust side restarts this
402
+ * process with the files re-read. A restart rather than a hot update is the
403
+ * honest granularity: a prefixed value reaches the browser by substitution
404
+ * into the bundle, so a new value has to be substituted again, and every
405
+ * module that read one has to be re-evaluated. Vite's watcher is still the
406
+ * only watcher — a second one over the same tree, in Rust, would be a second
407
+ * answer to "did this file change".
408
+ */
409
+ function watchEnvFiles(server) {
410
+ const files = argumentAll("--uf-env-file").map((file) => path.resolve(root, file));
411
+ if (files.length === 0) return;
412
+ const watched = new Set(files);
413
+ server.watcher.add(files);
414
+ for (const event of ["add", "change", "unlink"]) {
415
+ server.watcher.on(event, (file) => {
416
+ if (watched.has(path.resolve(file))) emit("env-changed", { file, change: event });
417
+ });
265
418
  }
266
- outgoing.end();
267
419
  }
268
420
 
421
+ /**
422
+ * The preview server: the build, as Vite serves it.
423
+ *
424
+ * Vite's `preview()` is a static file server, and a uf build is not only
425
+ * static files — a route handler answers a `POST` and a route with parameters
426
+ * and no `generateStaticParams` was never prerendered. On its own it would
427
+ * therefore 404 every request the interesting half of an application exists to
428
+ * answer, which is worse than having no preview at all, because a preview is
429
+ * checked and believed.
430
+ *
431
+ * So the application handler is mounted behind it, and `appType: "custom"` is
432
+ * what makes that reachable: with Vite's default `spa` it inserts an
433
+ * index.html fallback and a 404 middleware of its own, so every unmatched path
434
+ * would have been answered with the home page — a 200 for a path that does not
435
+ * exist — before anything of uf's ran.
436
+ *
437
+ * The static middleware still runs first, and that is deliberate rather than
438
+ * incidental; see `internal/serve.js` for why `uf start` orders itself the
439
+ * same way.
440
+ *
441
+ * One request is the exception, and it is the only thing uf mounts in *front*
442
+ * of Vite here: a request carrying the draft cookie, which no front door may
443
+ * answer from a prerendered document. `createStaticHandler` applies that rule
444
+ * and cannot reach a request Vite's file middleware answered first, so under
445
+ * `uf preview` draft mode appeared to be off while it worked under `uf dev`
446
+ * and `uf start` — ubugeeei-prod/uf#620. `configurePreviewServer` is where a
447
+ * middleware goes ahead of Vite's own; the hook's *body* runs before they are
448
+ * installed and a function it returns runs after, which is why this is a body
449
+ * and the handler below is a `use` on the started server.
450
+ */
269
451
  async function preview() {
270
452
  const { preview: startPreview } = await import("vite");
271
453
  const config = await loadConfig();
272
- const server = await startPreview(await viteConfig(config, "production"));
454
+ const routeTarget = resolveRouteTarget(config, argument("--target"));
455
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
456
+ // A build that declared it emits no server has none to mount. `uf` refuses
457
+ // `uf start` for such a project and lets this one through, because a preview
458
+ // of files *is* the deployment: what a static host does with `dist/` is
459
+ // exactly what Vite's preview server does with it, and mounting a request
460
+ // handler behind it would make this preview right about a deployment that is
461
+ // not the one happening. See `uf_cli`'s `commands::serve`.
462
+ const staticBuild = flag("--static-build");
463
+ const build = staticBuild
464
+ ? null
465
+ : await loadBuild({
466
+ root,
467
+ outDir: inline.build.outDir,
468
+ serverDir: path.join(".uf", "build", "server"),
469
+ });
470
+
471
+ // One handler for both positions in the chain. A draft request meets it in
472
+ // front of Vite's file middleware and every other request meets it behind,
473
+ // and because it is the same handler the two give the same answer — which is
474
+ // the whole reason `uf preview` exists.
475
+ const handle =
476
+ build == null ? null : createServeHandler({ ...build, cache: config.app?.rendering?.cache });
477
+ const answer = (previewServer) => async (request, response, next) => {
478
+ try {
479
+ const asRequest = await toRequest(request, previewServer.config);
480
+ // The same lifecycle `uf start` gets from `nodeListener`, spelled out
481
+ // because this door is Vite's connect chain rather than a bare
482
+ // `node:http` server: the whole request runs inside it, and it settles
483
+ // once `send` has returned. A preview whose `after()` fired at a
484
+ // different moment from the production server's would be a preview that
485
+ // is checked and believed and wrong.
486
+ await withRequest(build.entry, asRequest, async () => {
487
+ await send(response, await handle(asRequest));
488
+ });
489
+ } catch (error) {
490
+ next(error);
491
+ }
492
+ };
493
+
494
+ const mayAnswerFromPrerender = createPrerenderGate();
495
+ const draftFirst = {
496
+ name: "uf:draft-before-files",
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
+ }
518
+ // A prerendered payload is a file whose extension Vite's static middleware
519
+ // knows no type for, and the router hands bytes to React only when they
520
+ // are answered as a payload — so a navigation on a preview would silently
521
+ // become a document load. Set here, in front of the file server, which
522
+ // keeps a type it did not choose.
523
+ previewServer.middlewares.use((request, response, next) => {
524
+ if ((request.url ?? "").split("?")[0].endsWith("/__uf.flight")) {
525
+ response.setHeader("content-type", "text/x-component");
526
+ }
527
+ if ((request.url ?? "").split("?")[0] === "/.well-known/apple-app-site-association") {
528
+ response.setHeader("content-type", "application/json; charset=utf-8");
529
+ }
530
+ next();
531
+ });
532
+ if (handle == null) return;
533
+ const run = answer(previewServer);
534
+ previewServer.middlewares.use((request, response, next) => {
535
+ // Not `await`ed by connect, which takes no promise: the gate is
536
+ // resolved inside and `next()` is called from there. A rejection is a
537
+ // `next(error)` for the same reason.
538
+ mayAnswerFromPrerender(request.headers.cookie ?? null)
539
+ .then((mayAnswer) => (mayAnswer ? next() : run(request, response, next)))
540
+ .catch(next);
541
+ });
542
+ },
543
+ };
544
+
545
+ const server = await startPreview({
546
+ ...inline,
547
+ appType: "custom",
548
+ plugins: [...(inline.plugins ?? []), draftFirst],
549
+ });
550
+ if (handle != null) {
551
+ server.middlewares.use(answer(server));
552
+ }
553
+ // The rewrite rule a single-page deployment needs, in the one place uf can
554
+ // apply one. A `["csr"]` build writes `index.html` and nothing else that is a
555
+ // page, so a file server answers `/` and 404s every other URL — and this
556
+ // preview exists to be believed about the deployment. A host serving that
557
+ // build has to send unmatched paths to the shell, so a preview that did not
558
+ // would be right about a deployment nobody is doing.
559
+ //
560
+ // Behind the static middleware, which is what makes it a *fallback*: a real
561
+ // file still wins, so `/assets/client.js` is still the chunk and not the
562
+ // shell. And `Accept: text/html` only, so a `fetch` for a missing JSON file
563
+ // gets a 404 rather than a document — the failure mode of a fallback that
564
+ // answers everything is a parse error two layers away from the missing file.
565
+ if (flag("--spa-fallback")) {
566
+ const shell = path.resolve(root, inline.build.outDir, "index.html");
567
+ server.middlewares.use((request, response, next) => {
568
+ if (request.method !== "GET" && request.method !== "HEAD") return next();
569
+ if (!(request.headers.accept ?? "").includes("text/html")) return next();
570
+ if (!existsSync(shell)) return next();
571
+ response.statusCode = 200;
572
+ response.setHeader("content-type", "text/html; charset=utf-8");
573
+ response.end(request.method === "HEAD" ? undefined : readFileSync(shell));
574
+ return undefined;
575
+ });
576
+ }
577
+
273
578
  const urls = server.resolvedUrls ?? { local: [], network: [] };
274
- emit("listening", { local: urls.local, network: urls.network, routes: [] });
579
+ emit("listening", {
580
+ local: urls.local,
581
+ network: urls.network,
582
+ // From the filesystem when there is no bundle to ask, which is the same
583
+ // scan `dev` reports from. The count is what a reader checks the build
584
+ // against, so answering "0 routes" for a static site that has thirty would
585
+ // be the report being wrong about the thing it exists to report.
586
+ routes:
587
+ build == null
588
+ ? scanRoutes(path.resolve(root, config.app?.router?.root ?? "app"), {
589
+ target: routeTarget,
590
+ }).routes.map((route) => route.path)
591
+ : build.entry.routes.map((route) => route.path),
592
+ handlers: build == null ? [] : build.entry.handlers.map((handler) => handler.path),
593
+ });
594
+
275
595
  const shutdown = async () => {
276
596
  await server.close();
277
597
  process.exit(0);
@@ -280,6 +600,70 @@ async function preview() {
280
600
  process.on("SIGTERM", shutdown);
281
601
  }
282
602
 
603
+ /**
604
+ * The production server: the build, with no bundler in the process.
605
+ *
606
+ * `preview` proves the build works through Vite. This is the thing that is
607
+ * actually deployed, and it imports `vite` nowhere — a host running a built
608
+ * application should not need the bundler that produced it, and the moment it
609
+ * does, "portable output" is a claim rather than a property.
610
+ *
611
+ * There is no `--strict-port` here and there is nothing to add: this server
612
+ * binds the port it was given or fails, where Vite's would have quietly moved
613
+ * to the next free one. `PORT` and `HOST` are read from the environment
614
+ * because that is how every process manager and container platform says which
615
+ * socket to take, and a production server that could only be told on the
616
+ * command line would need a wrapper script everywhere it ran.
617
+ *
618
+ * It is not the only thing that can be deployed. `uf build --compile` puts
619
+ * this same application behind this same resolution order inside a single
620
+ * executable, for a host that should not have to have a JavaScript runtime
621
+ * installed at all; see [`compile`] for what that costs and what it shares.
622
+ */
623
+ async function start() {
624
+ const config = await loadConfig();
625
+ const outDir = argument("--out-dir") ?? config.build?.outDir ?? "dist";
626
+ const build = await loadBuild({
627
+ root,
628
+ outDir,
629
+ serverDir: path.join(".uf", "build", "server"),
630
+ });
631
+
632
+ const host = argument("--host") ?? process.env.HOST ?? "0.0.0.0";
633
+ const port = Number(argument("--port") ?? process.env.PORT ?? 3000);
634
+ const server = createHttpServer(
635
+ nodeListener(
636
+ createServeHandler({ ...build, cache: config.app?.rendering?.cache }),
637
+ build.entry,
638
+ ),
639
+ );
640
+
641
+ await new Promise((resolve, reject) => {
642
+ server.once("error", reject);
643
+ server.listen(port, host, resolve);
644
+ });
645
+
646
+ const bound = server.address();
647
+ // `0.0.0.0` is not a URL anybody can open, so the loopback spelling is what
648
+ // is printed as `local` and the bound address is reported as the network
649
+ // one — the same split `uf dev` prints, and for the same reason: one of the
650
+ // two is a link and the other is a fact about the socket.
651
+ const shown = `${bound.address}:${bound.port}`;
652
+ const wildcard = bound.address === "0.0.0.0" || bound.address === "::";
653
+ emit("listening", {
654
+ local: [`http://${wildcard ? `localhost:${bound.port}` : shown}/`],
655
+ network: wildcard ? [`http://${shown}/`] : [],
656
+ routes: build.entry.routes.map((route) => route.path),
657
+ handlers: build.entry.handlers.map((handler) => handler.path),
658
+ });
659
+
660
+ const shutdown = () => {
661
+ server.close(() => process.exit(0));
662
+ };
663
+ process.on("SIGINT", shutdown);
664
+ process.on("SIGTERM", shutdown);
665
+ }
666
+
283
667
  async function build() {
284
668
  const vite = await import("vite");
285
669
  const config = await loadConfig();
@@ -287,18 +671,70 @@ async function build() {
287
671
  const inline = await viteConfig(config, mode);
288
672
  const outDir = path.resolve(root, inline.build.outDir);
289
673
  const serverDir = path.join(root, ".uf", "build", "server");
674
+ // How much of the route table to prerender, and whether the server bundle
675
+ // survives the build. Both are `uf`'s answer rather than this file's: they
676
+ // come from two settings in `uf.config.js` that only mean something read
677
+ // together, and `uf_config`'s `RenderingPlan` is where they are. A driver
678
+ // started by hand gets the behaviour every uf build had before either
679
+ // setting was read.
680
+ const prerender = argument("--prerender") ?? "possible";
681
+ const staticBuild = flag("--static-build");
682
+ const because = argument("--because") ?? "this build prerenders every route";
683
+
684
+ // 0. The rsc graph, for an application React Server Components render: the
685
+ // route table and every server component, resolved under `react-server`.
686
+ // First, because it is what finds the client modules the next pass has
687
+ // to build; `./internal/flight.js` has the order and the reason for it.
688
+ const flight = flightStateOf(inline);
689
+ // `uf build --analyze`: the graph every bundle below is built from, for uf
690
+ // to attribute to routes; see `./internal/module-graph.js`. A client module
691
+ // is an entry the browser loads because a server component names it, not on
692
+ // every page, so it is not one of the client bundle's shared entries.
693
+ const graph = flag("--analyze")
694
+ ? createModuleGraphCollector(root, {
695
+ isReference: (file) => flight?.clientModules.has(file) ?? false,
696
+ })
697
+ : null;
698
+ if (graph != null) {
699
+ inline.plugins = [...(inline.plugins ?? []), graph.plugin];
700
+ }
701
+ const rscDir = path.join(root, ".uf", "build", "rsc");
702
+ if (flight != null) {
703
+ emit("phase", { name: "rsc" });
704
+ await buildRscGraph(vite, inline, flight, { outDir: rscDir, conditions: null });
705
+ }
290
706
 
291
707
  // 1. The client: everything the browser loads, with a manifest so the
292
- // server render knows which script and stylesheet tags to write.
708
+ // server render knows which script and stylesheet tags to write. Under
709
+ // React Server Components that is the entry and one entry per client
710
+ // module, each keeping its export names, because a payload asks for a
711
+ // chunk by its URL and for a component by its export.
293
712
  emit("phase", { name: "client" });
713
+ const references = flight == null ? [] : [...flight.clientModules].sort();
714
+ const input = { client: VIRTUAL.client };
715
+ references.forEach((file, index) => {
716
+ input[`client-reference-${index}`] = file;
717
+ });
294
718
  await vite.build({
295
719
  ...inline,
296
720
  build: {
297
721
  ...inline.build,
298
- rollupOptions: { input: { client: VIRTUAL.client } },
722
+ rollupOptions:
723
+ flight == null ? { input } : { input, preserveEntrySignatures: "exports-only" },
299
724
  },
300
725
  });
301
726
  const manifest = readManifest(outDir);
727
+ if (flight != null) {
728
+ recordClientChunks(flight, manifest, references, rscDir, basePathOf(config));
729
+ // What the summary's "pages in the client bundle" reads. None: a browser
730
+ // that hydrates a payload imports no page, whichever route it is on.
731
+ emit("rsc-split", {
732
+ pages: 0,
733
+ routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app"), {
734
+ target: resolveRouteTarget(config, argument("--target")),
735
+ }).routes.length,
736
+ });
737
+ }
302
738
 
303
739
  // 2. The server entry, bundled for the host, outside `dist/` so it is never
304
740
  // deployed by accident.
@@ -318,129 +754,1747 @@ async function build() {
318
754
  },
319
755
  },
320
756
  });
757
+ // What this build is, beside the bundle it is about. One line, written by
758
+ // every build and read by nothing unless a project turned a durable cache on
759
+ // — at which point it is the thing that stops a deploy answering the new
760
+ // build's URLs with the previous build's documents. See
761
+ // `packages/server/internal/cache-key.js`, which argues the whole of it, and
762
+ // `internal/serve.js`'s `buildIdentity`, which is what reads this.
763
+ writeFileSync(path.join(serverDir, BUILD_ID_FILE), `${mintBuildId()}\n`);
321
764
 
322
- // 3. Every static route, rendered to an HTML document.
765
+ // Every bundle is built by here, and the prerender below builds none.
766
+ graph?.write(path.join(root, ".uf", "build", "meta", MODULE_GRAPH_FILE));
767
+
768
+ // 3. Which routes this build renders when, and every route it renders now.
769
+ //
770
+ // The decision comes from `uf.config.js` and is made in Rust — see
771
+ // `uf_config`'s `RenderingPlan` — because `app.rendering.modes` and
772
+ // `build.staticBuild` are two settings that have to be read together. It
773
+ // arrives here as one word, and this is where it meets the route table.
323
774
  emit("phase", { name: "prerender" });
324
775
  const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
325
- const assets = assetsFromManifest(manifest);
326
- const pages = await staticPaths(server.routes);
776
+ const assets =
777
+ flight == null
778
+ ? assetsFromManifest(manifest, basePathOf(config))
779
+ : flightAssets(manifest, references, rscDir, outDir, basePathOf(config));
780
+ // Recorded beside the server bundle, because whatever serves this build
781
+ // later cannot recompute them from the client manifest alone; see
782
+ // `documentAssetsFor`.
783
+ writeFileSync(path.join(serverDir, DOCUMENT_ASSETS_FILE), `${JSON.stringify(assets, null, 2)}\n`);
784
+ const openapi = await createOpenApiDocument(server.handlers);
785
+ const openapiFile = path.join(root, ".uf", "build", "meta", "openapi.json");
786
+ mkdirSync(path.dirname(openapiFile), { recursive: true });
787
+ writeFileSync(openapiFile, `${JSON.stringify(openapi, null, 2)}\n`);
788
+ const plan = await renderingPlan(server, prerender);
789
+ emit("rendering", {
790
+ prerender,
791
+ prerendered: plan.urls.length,
792
+ perRequest: plan.perRequest.map((route) => route.path),
793
+ });
794
+ // A build that has to prerender everything, and a route it cannot: the
795
+ // refusal ubugeeei-prod/uf#336 and ubugeeei-prod/uf#385 are both about.
796
+ // Before the loop below, so no document is written for a build that is not
797
+ // going to be one, and with the whole list rather than the first item — a
798
+ // project that has just narrowed `rendering.modes` wants to see every route
799
+ // the narrowing costs it, not one per rebuild.
800
+ if (prerender === "everything" && plan.perRequest.length > 0) {
801
+ const listed = plan.perRequest.map((entry) => ` ${entry.path} — ${entry.why}`).join("\n");
802
+ emit("error", {
803
+ message:
804
+ `${plan.perRequest.length} ${plural(plan.perRequest.length, "route")} in this project ` +
805
+ `can only be answered by a server, and ${because}\n${listed}\n\n` +
806
+ "Give each page a `generateStaticParams` and take out the handlers and middleware, or " +
807
+ 'allow `"ssr"` in `app.rendering.modes` and deploy a server.',
808
+ });
809
+ process.exit(1);
810
+ }
811
+ const pages = plan.urls;
812
+
813
+ // A route that throws fails *that route*, and the rest of the build still
814
+ // happens. This loop had no `try`: the first page to throw rejected out of
815
+ // `build()`, `run().catch` reported the exception, and which URL was being
816
+ // rendered was a local variable nobody could see. One broken page was the
817
+ // whole build, and the message named a stack rather than a route.
818
+ //
819
+ // The render itself no longer throws for an ordinary component failure —
820
+ // `createRenderer` renders the error boundary and reports the exception on
821
+ // the result — so both are checked here. Neither writes a file: an error
822
+ // page written into `dist/` is a build that shipped its own failure.
823
+ //
824
+ // `prerender`, not `render`: a build wants the document React produces once
825
+ // every boundary has resolved, with the content where the fallback was. The
826
+ // streaming renderer would write a file whose slow parts are `<template>`
827
+ // elements waiting for a script — correct in a browser, blank to a crawler
828
+ // and to `curl`, which is most of what a static file is for.
829
+ const failures = [];
830
+ const failed = (url, error) => {
831
+ failures.push(url);
832
+ emit("page-failed", { url, ...errorEvent(error) });
833
+ };
834
+ //
835
+ // Each prerender runs inside a fill that stores nothing, so what a page states
836
+ // with `cacheLife`, `cacheTag` and `noStore` is something this loop can read
837
+ // rather than a call that throws for want of a scope.
838
+ //
839
+ // A page is written for regeneration when `uf` passed `--regenerate` — `isr`
840
+ // allowed in `app.rendering.modes`, `rendering.cache.route` on, and a server
841
+ // deployed to regenerate it — and the page stated a lifetime, called no
842
+ // `noStore`, and answered 200. Its document goes under
843
+ // `REGENERATED_DIRECTORY` rather than at its own URL, so no static half
844
+ // answers the page: the server does, starting from this document, until the
845
+ // lifetime has passed. What it stated goes into `REGENERATION_FILE` beside the
846
+ // server bundle. Every other page is the document it has always been.
847
+ //
848
+ // A tag without a lifetime is not enough, for the route cache's own reason:
849
+ // an entry with no end is one another process could serve from its memory
850
+ // for ever after `revalidateTag` took it out of the shared store.
851
+ const { collectCacheDeclarations } = await import("@uniflowed/server/cache");
852
+ const regenerate = flag("--regenerate");
853
+ const regenerated = {};
327
854
  for (const url of pages) {
328
- const result = await server.render(url, assets);
329
- const file = htmlPathFor(outDir, url);
855
+ let declared;
856
+ const renderedAt = Date.now();
857
+ try {
858
+ declared = await collectCacheDeclarations(() => server.prerender(url, assets));
859
+ } catch (error) {
860
+ failed(url, error);
861
+ continue;
862
+ }
863
+ const result = declared.value;
864
+ if (result.error != null) {
865
+ failed(url, result.error);
866
+ continue;
867
+ }
868
+ const lifetime = declared.lifetime;
869
+ const regenerates =
870
+ regenerate && result.status === 200 && declared.denied == null && lifetime != null;
871
+ // The trailing-slash policy decides a served page's file name; a page the
872
+ // build regenerates keeps the one layout the regeneration reads.
873
+ const file = regenerates
874
+ ? htmlPathFor(path.join(outDir, REGENERATED_DIRECTORY), url)
875
+ : htmlPathFor(outDir, url, routingRulesOf(config.app?.router).trailingSlash);
330
876
  mkdirSync(path.dirname(file), { recursive: true });
331
877
  writeFileSync(file, result.html);
878
+ // The payload the document was rendered from, beside it: what a browser
879
+ // navigating to this route fetches, from whatever serves the files. Beside
880
+ // the document wherever the document went, so a page written for
881
+ // regeneration leaves no file at its route's own payload URL answering with
882
+ // the build's copy for ever; the server answers that URL instead.
883
+ if (result.payload != null) {
884
+ // Beside the route's directory whatever the document is called:
885
+ // `guide/index.html` and `guide.html` both put it at `guide/__uf.flight`.
886
+ const payloadDirectory =
887
+ path.basename(file) === "index.html" ? path.dirname(file) : file.slice(0, -".html".length);
888
+ mkdirSync(payloadDirectory, { recursive: true });
889
+ writeFileSync(path.join(payloadDirectory, "__uf.flight"), result.payload);
890
+ }
891
+ if (regenerates) {
892
+ regenerated[url] = {
893
+ document: regeneratedDocumentUrl(url),
894
+ renderedAt,
895
+ revalidate: lifetime.revalidate,
896
+ expire: lifetime.expire ?? null,
897
+ tags: declared.tags,
898
+ };
899
+ }
332
900
  emit("page", {
333
901
  url,
334
902
  file: path.relative(root, file),
335
903
  status: result.status,
336
904
  bytes: Buffer.byteLength(result.html),
905
+ regenerates,
337
906
  });
338
907
  }
339
- if (server.notFound != null) {
340
- const result = await server.render("/__uf_not_found__", assets);
341
- const file = path.join(outDir, "404.html");
342
- writeFileSync(file, result.html);
343
- emit("page", {
344
- url: "/404",
345
- file: path.relative(root, file),
346
- status: 404,
347
- bytes: Buffer.byteLength(result.html),
908
+ if (Object.keys(regenerated).length > 0) {
909
+ writeFileSync(
910
+ path.join(serverDir, REGENERATION_FILE),
911
+ `${JSON.stringify({ pages: regenerated }, null, 2)}\n`,
912
+ );
913
+ }
914
+ // One `404.html`, from the boundary at the router root: a static host serves
915
+ // a single error document for the whole site, so the nested boundaries a
916
+ // project declares are the server's and the client's to render, not
917
+ // something this loop can write a file for.
918
+ //
919
+ // The condition is "there is a root boundary", not "there is any boundary",
920
+ // because `/__uf_not_found__` is a path at the root: a project whose only
921
+ // `$not-found.js` is in `app/guide/` would otherwise get a `404.html`
922
+ // rendered from the framework's bare default, which is worse than the file
923
+ // it used to write, which was none.
924
+ //
925
+ // Through the same two checks as the loop, and for the same reason. A
926
+ // not-found boundary is a component like any other: it can throw, and when it
927
+ // does `prerender` answers with the *error* page's HTML and a non-null
928
+ // `error` rather than rejecting. Writing that HTML and emitting `page` was a
929
+ // build publishing its own failure as `404.html` and exiting 0 — the static
930
+ // host would then serve uf's error page to every visitor who mistyped a URL,
931
+ // and nothing between the throw and the deploy would have mentioned it.
932
+ let attempted = pages.length;
933
+ // The single-page build's whole output, written here because it is the one
934
+ // document this build has and the loop above had no route to write it for.
935
+ //
936
+ // Twice, to two names, and the second is the load-bearing one. `index.html`
937
+ // is what a host serves for `/`; `404.html` is what a static host serves for
938
+ // every path it has no file for, which under this plan is *every other URL
939
+ // in the application*. Without it a deployment of `dist/` answers `/` and
940
+ // 404s `/orders` — a build with a hole in it, found from a 404, which is the
941
+ // failure ubugeeei-prod/uf#336 is about wearing a different hat.
942
+ //
943
+ // It is still the host's rewrite rule that makes this correct, and the two
944
+ // files are what uf can do without one: a host with a proper SPA fallback
945
+ // serves `index.html` and never looks at `404.html`, and a host with only an
946
+ // error document (Pages, Netlify, an S3 bucket) serves `404.html` and gets
947
+ // the same bytes with a 404 status — which is the right status for a URL
948
+ // this application does not have, and the not-found boundary is what the
949
+ // browser then renders into it.
950
+ if (prerender === "shell") {
951
+ const html = server.shellDocument(assets);
952
+ for (const [file, url, status] of [
953
+ ["index.html", "/", 200],
954
+ ["404.html", "/404", 404],
955
+ ]) {
956
+ const target = path.join(outDir, file);
957
+ writeFileSync(target, html);
958
+ attempted += 1;
959
+ emit("page", {
960
+ url,
961
+ file: path.relative(root, target),
962
+ status,
963
+ bytes: Buffer.byteLength(html),
964
+ });
965
+ }
966
+ }
967
+ // Not for a build that prerenders nothing. `404.html` is a file a static
968
+ // host serves for every path it has no file for, and a project whose
969
+ // `rendering.modes` allows only `ssr` has no such host: its not-found
970
+ // boundary is rendered per request, by the server, with the right status.
971
+ //
972
+ // Nor for a shell build, which has just written its own: the boundary this
973
+ // would render is one the *browser* renders in that plan, and a document
974
+ // holding the framework's 404 markup would be served in place of the shell
975
+ // for every URL the host could not match.
976
+ else if (prerender !== "nothing" && server.notFound.some((boundary) => boundary.path === "/")) {
977
+ attempted += 1;
978
+ // `/404` rather than `/__uf_not_found__`: the internal path is how the
979
+ // router is asked, and the file the reader is looking for is `404.html`.
980
+ let result;
981
+ try {
982
+ result = await server.prerender("/__uf_not_found__", assets);
983
+ } catch (error) {
984
+ failed("/404", error);
985
+ result = null;
986
+ }
987
+ if (result != null && result.error != null) {
988
+ failed("/404", result.error);
989
+ result = null;
990
+ }
991
+ if (result != null) {
992
+ const file = path.join(outDir, "404.html");
993
+ writeFileSync(file, result.html);
994
+ emit("page", {
995
+ url: "/404",
996
+ file: path.relative(root, file),
997
+ status: 404,
998
+ bytes: Buffer.byteLength(result.html),
999
+ });
1000
+ }
1001
+ }
1002
+
1003
+ if (failures.length > 0) {
1004
+ // Emitted rather than thrown, so the message is the routes and not the
1005
+ // last exception: each one has already been reported with its own frame.
1006
+ //
1007
+ // The first line stands on its own, because it is the one `uf build` uses
1008
+ // as the headline and the one a CI log's last line will be. It read
1009
+ // `... failed:` with the routes below it, and the headline was then a
1010
+ // sentence ending in a colon and nothing.
1011
+ // `attempted`, not `pages.length`: the root 404 is prerendered too, and
1012
+ // counting a failure of it against a total that excludes it produced
1013
+ // "1 of 12" for a build that rendered thirteen things.
1014
+ emit("error", {
1015
+ message: `${failures.length} of ${attempted} prerendered ${plural(
1016
+ attempted,
1017
+ "route",
1018
+ )} failed\n${failures.map((url) => ` ${url}`).join("\n")}`,
348
1019
  });
1020
+ process.exit(1);
349
1021
  }
350
1022
 
1023
+ // `build.staticBuild` is "prerender everything and emit no server bundle",
1024
+ // and this is the second half of it. The bundle is still *built*: the
1025
+ // prerender renders through it, so a build with no server bundle at any
1026
+ // point would be a build with no documents either. What the declaration is
1027
+ // about is what is left behind — so it goes once the last document is
1028
+ // written, and `uf start`, `uf preview` and every server adapter then find
1029
+ // nothing to serve, which is the honest outcome for a project that said it
1030
+ // deploys files.
1031
+ if (staticBuild) rmSync(serverDir, { recursive: true, force: true });
1032
+
351
1033
  emit("done", { outDir: path.relative(root, outDir), pages: pages.length });
352
1034
  process.exit(0);
353
1035
  }
354
1036
 
355
- async function printConfig() {
1037
+ /**
1038
+ * The library build, for a project whose `app.router.enabled` is false.
1039
+ *
1040
+ * `build` above is an application build and has no other mode: it links
1041
+ * `virtual:uf/client`, which imports the router and the project's `app.js`.
1042
+ * A library has neither, so `uf build` in a project `uf create lib`
1043
+ * scaffolded failed at the first pass with `Could not resolve '<root>/app.js'`
1044
+ * — a file a library does not have and never had. See ubugeeei-prod/uf#268.
1045
+ *
1046
+ * This is the fourth thing the driver does, beside `dev`, `build` and
1047
+ * `compile`, and it is one pass per format over one input list. Which of the
1048
+ * two builds runs is **not decided here**: `uf` resolves it from the config
1049
+ * (`uf_config`'s `LibraryPlan`) and spawns this subcommand, the same way
1050
+ * `--prerender` arrives as one word rather than as two settings for this file
1051
+ * to read together.
1052
+ *
1053
+ * # The three ways it differs from the application build
1054
+ *
1055
+ * * **Every dependency stays an import.** `--external` names them, and
1056
+ * `uf` computes the list from the project's own manifest —
1057
+ * `dependencies`, `peerDependencies`, `optionalDependencies` — so a
1058
+ * library ships its own modules and nobody else's. That is the opposite
1059
+ * of the application build, which inlines what it can because an
1060
+ * application is the end of the line and a library is not: a bundled copy
1061
+ * of React inside a library is a second React in every application that
1062
+ * installs it.
1063
+ * * **One output per entry, named after the entry.** `index.js` becomes
1064
+ * `dist/index.js`; `internal/parse.js` becomes `dist/internal/parse.js`.
1065
+ * The path rather than the basename, so two entries cannot collide at the
1066
+ * moment one would overwrite the other.
1067
+ * * **No manifest, no prerender, no server bundle.** There is no document to
1068
+ * write and no route table to write it from.
1069
+ *
1070
+ * Vite's own `build.lib` does the work. uf owns *that* a library is a
1071
+ * different build and what goes into it; how this builder performs one is the
1072
+ * builder's, which is the same line `build` draws around `rollupOptions`.
1073
+ */
1074
+ async function library() {
1075
+ const vite = await import("vite");
356
1076
  const config = await loadConfig();
357
- emit("config", { config: projectConfig(config) });
1077
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
1078
+ const outDir = path.resolve(root, inline.build.outDir);
1079
+ const entries = argumentAll("--entry");
1080
+ const formats = argumentAll("--format");
1081
+ const external = argumentAll("--external");
1082
+ if (entries.length === 0) {
1083
+ throw new Error("uf: `driver.js library` needs at least one --entry");
1084
+ }
1085
+ if (formats.length === 0) {
1086
+ throw new Error("uf: `driver.js library` needs at least one --format");
1087
+ }
1088
+
1089
+ // Keyed by the entry's path without its extension, which is what Vite's lib
1090
+ // mode turns into the output file name.
1091
+ const input = {};
1092
+ for (const entry of entries) {
1093
+ input[entryName(entry)] = path.resolve(root, entry);
1094
+ }
1095
+
1096
+ const isExternal = externalTest(external);
1097
+ // One pass per format rather than one build with several outputs: Vite's
1098
+ // lib mode writes a whole `outDir` per format, and the second pass must not
1099
+ // empty what the first wrote. So `emptyOutDir` is true exactly once, on the
1100
+ // first, which is also what makes a build that dropped an entry leave no
1101
+ // stale copy of it behind.
1102
+ let first = true;
1103
+ for (const format of formats) {
1104
+ emit("phase", { name: `library (${format})` });
1105
+ await vite.build({
1106
+ ...inline,
1107
+ build: {
1108
+ ...inline.build,
1109
+ // Vite's `manifest` maps source modules to hashed browser assets. A
1110
+ // library has neither — its file names are its API — and writing one
1111
+ // would put a `.vite/` directory into a published tarball.
1112
+ manifest: false,
1113
+ outDir,
1114
+ emptyOutDir: first,
1115
+ lib: {
1116
+ entry: input,
1117
+ formats: [format],
1118
+ fileName: (_format, name) => `${name}.${format === "cjs" ? "cjs" : "js"}`,
1119
+ },
1120
+ rollupOptions: { external: isExternal },
1121
+ },
1122
+ });
1123
+ first = false;
1124
+ }
1125
+
1126
+ emit("done", { outDir: path.relative(root, outDir), pages: 0 });
358
1127
  process.exit(0);
359
1128
  }
360
1129
 
361
- function readManifest(outDir) {
362
- const file = path.join(outDir, ".vite", "manifest.json");
363
- if (!existsSync(file)) throw new Error(`uf: the client build wrote no manifest at ${file}`);
364
- return JSON.parse(readFileSync(file, "utf8"));
1130
+ /**
1131
+ * The output name for one entry: its path, without the extension.
1132
+ *
1133
+ * Not the basename. `index.js` and `internal/index.js` are two entries a
1134
+ * library can reasonably have, and under a basename they are one file written
1135
+ * twice — the second silently winning, which is a published package whose
1136
+ * subpath export is somebody else's module.
1137
+ */
1138
+ function entryName(entry) {
1139
+ const normalised = entry.replace(/\\/g, "/").replace(/^\.\//, "");
1140
+ const dot = normalised.lastIndexOf(".");
1141
+ const slash = normalised.lastIndexOf("/");
1142
+ return dot > slash ? normalised.slice(0, dot) : normalised;
365
1143
  }
366
1144
 
367
1145
  /**
368
- * Script, stylesheet and preload URLs for the client entry chunk.
1146
+ * Whether an import is somebody else's module.
1147
+ *
1148
+ * Three checks, and only the one over `names` is a policy uf decided. `names`
1149
+ * is what `uf` read out of the project's manifest and passed as `--external`,
1150
+ * and a subpath of one of those names — `@scope/pkg/deep` for `@scope/pkg` —
1151
+ * is the same package. The other two are the host's built-in modules, and they
1152
+ * are a fact rather than a decision: `node:fs` has no bytes to inline.
369
1153
  *
370
- * The entry is found by its `isEntry` flag rather than by key, because a
371
- * virtual module's manifest key is an implementation detail of the bundler.
1154
+ * A bare relative or absolute id is never external, which is the rule that
1155
+ * makes this a library build at all: what the author wrote is bundled, and
1156
+ * what they installed is imported.
372
1157
  */
1158
+ function externalTest(names) {
1159
+ const declared = new Set(names);
1160
+ // The host's built-in module names, unprefixed. `node:`-prefixed ids are
1161
+ // caught by the first check whatever the host is; this set is for the bare
1162
+ // spellings — `fs`, `path`, `stream` — which a dependency written before the
1163
+ // prefix existed still uses. Read from the running host rather than written
1164
+ // down, because the list grows and a stale copy of it here would be a
1165
+ // bundled `node:worker_threads` that cannot be bundled.
1166
+ const builtins = new Set(builtinModules ?? []);
1167
+ return (id) => {
1168
+ if (id.startsWith("node:")) return true;
1169
+ if (builtins.has(id)) return true;
1170
+ if (declared.has(id)) return true;
1171
+ for (const name of declared) {
1172
+ if (id.startsWith(`${name}/`)) return true;
1173
+ }
1174
+ return false;
1175
+ };
1176
+ }
1177
+
373
1178
  /**
374
- * The tags a prerendered document needs.
1179
+ * Link the whole application into one JavaScript file, for `uf build --compile`.
1180
+ *
1181
+ * This runs after `build`, on a `dist/` that is already complete, and produces
1182
+ * the module a runtime is wrapped around. It differs from the server build in
1183
+ * `build()` in exactly three ways, and each of them is what "one file" means:
1184
+ *
1185
+ * * `ssr.noExternal: true` — the server build leaves `react`, `react-dom`
1186
+ * and every other dependency as bare imports, because the host it runs on
1187
+ * has `node_modules` beside it. A binary does not, so they come in.
1188
+ * * `codeSplitting: false` — a route is a lazy `import()` so that the browser
1189
+ * can fetch one chunk per page. On the server that split buys nothing and
1190
+ * costs everything: chunks are separate files, and separate files are the
1191
+ * one thing this output may not have.
1192
+ * * the native-addon guard below, which turns "cannot resolve" into a
1193
+ * sentence naming the package that cannot be compiled.
1194
+ *
1195
+ * The embedded copy of `dist/` is *not* built here. `uf` writes it (see
1196
+ * `uf_bundle::embed`) and passes its path in `--assets`, because walking an
1197
+ * output directory and encoding every file in it is bulk work over the whole
1198
+ * build, which belongs in Rust rather than in the host process.
375
1199
  *
376
- * Two walks over the manifest, because the two answers are different. A
377
- * `modulepreload` is worth emitting only for a chunk this document will
378
- * certainly load, which is the entry's *static* imports. A stylesheet has to
379
- * be emitted for anything the page might render, and the router loads every
380
- * route module dynamically — so a stylesheet imported by a layout is reached
381
- * through `dynamicImports` and through nothing else. Following only the static
382
- * graph, as this did, meant a layout could import a stylesheet and the built
383
- * HTML would silently ship without it.
1200
+ * # Three front doors onto one build, and why they are not one function
384
1201
  *
385
- * The cost is that a project with per-route stylesheets links all of them on
386
- * every page. Narrowing that needs the route table to say which chunk each
387
- * route came from, which the manifest alone cannot tell us.
1202
+ * `preview` and `start` above serve `dist/` from disk, and they share a single
1203
+ * handler in `./internal/serve.js` for the express purpose of being unable to
1204
+ * answer differently. What is linked here is a third front door onto the same
1205
+ * build, and it deliberately does *not* import that module. Two reasons, and
1206
+ * either would be enough: `internal/serve.js` answers by opening files under
1207
+ * `dist/`, and a compiled binary has no `dist/` to open — it carries the bytes
1208
+ * — so the half that reads a request would arrive with a half that cannot run;
1209
+ * and it lives in `@uniflowed/vite`, so linking it would put the package named
1210
+ * after the bundler inside the artefact a deployment runs, which is the one
1211
+ * thing `start` exists to avoid.
1212
+ *
1213
+ * What a binary uses instead is `@uniflowed/server/standalone`, and the thing
1214
+ * that is shared between the three is not code but the *answer*: an asset or a
1215
+ * prerendered document first, then a route handler, then a render for whatever
1216
+ * is left. That order is not a preference. `preview` cannot deviate from it —
1217
+ * Vite's preview server runs its own file middleware before anything uf mounts
1218
+ * behind it — so `start` matches Vite, and the binary matches `start`. A
1219
+ * compiled application that resolved a collision the other way would be the
1220
+ * trap `preview` exists to prevent, one deployment further along, and the only
1221
+ * copy nobody can check with `uf preview` first.
388
1222
  */
389
- function assetsFromManifest(manifest) {
390
- const entry = Object.values(manifest).find((chunk) => chunk.isEntry);
391
- if (entry == null) throw new Error("uf: the client manifest has no entry chunk");
1223
+ async function compile() {
1224
+ const vite = await import("vite");
1225
+ const config = await loadConfig();
1226
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
1227
+ const outDir = path.resolve(root, inline.build.outDir);
1228
+ const assetsArgument = argument("--assets");
1229
+ const bundleArgument = argument("--bundle");
1230
+ if (assetsArgument == null || bundleArgument == null) {
1231
+ throw new Error("uf: `driver.js compile` needs both --assets and --bundle");
1232
+ }
1233
+ const assets = path.resolve(root, assetsArgument);
1234
+ const bundleDir = path.resolve(root, bundleArgument);
392
1235
 
393
- const styles = new Set(entry.css ?? []);
394
- const seen = new Set();
395
- const collectStyles = (chunk) => {
396
- for (const imported of [...(chunk.imports ?? []), ...(chunk.dynamicImports ?? [])]) {
397
- if (seen.has(imported)) continue;
398
- seen.add(imported);
399
- const dependency = manifest[imported];
400
- if (dependency == null) continue;
401
- for (const css of dependency.css ?? []) styles.add(css);
402
- collectStyles(dependency);
1236
+ emit("phase", { name: "standalone" });
1237
+
1238
+ // The entry is written to disk rather than served as another virtual module:
1239
+ // it is generated per build (it bakes in this build's document), and a real
1240
+ // file is the version a person can open when a compiled binary misbehaves.
1241
+ const entry = path.join(bundleDir, "entry.js");
1242
+ mkdirSync(bundleDir, { recursive: true });
1243
+ writeFileSync(
1244
+ entry,
1245
+ entrySource(
1246
+ path.relative(root, assets),
1247
+ await documentAssetsFor(path.join(root, ".uf", "build", "server"), readManifest(outDir)),
1248
+ ),
1249
+ );
1250
+
1251
+ // The rsc graph `uf build` built, and the client chunks its references name.
1252
+ const flight = flightStateOf(inline);
1253
+ if (flight != null) loadFlightBuild(flight, path.join(root, ".uf", "build", "rsc"));
1254
+
1255
+ await vite.build({
1256
+ ...inline,
1257
+ customLogger: eventLogger("warn"),
1258
+ plugins: [...inline.plugins, nativeAddonGuard(), compileAssetsPlugin(assets)],
1259
+ ssr: { ...(inline.ssr ?? {}), noExternal: true },
1260
+ build: {
1261
+ ...inline.build,
1262
+ manifest: false,
1263
+ // The map would describe this intermediate bundle rather than the
1264
+ // binary, and nothing downstream reads it. Turning it off is a smaller
1265
+ // `.uf/` and one less file to explain.
1266
+ sourcemap: false,
1267
+ ssr: true,
1268
+ outDir: bundleDir,
1269
+ emptyOutDir: false,
1270
+ rollupOptions: {
1271
+ input: { server: entry },
1272
+ output: { entryFileNames: "server.js", format: "es", codeSplitting: false },
1273
+ },
1274
+ },
1275
+ });
1276
+
1277
+ emit("done", { outDir: path.relative(root, bundleDir), pages: 0 });
1278
+ process.exit(0);
1279
+ }
1280
+
1281
+ /**
1282
+ * What each adapter links, and what it links it against.
1283
+ *
1284
+ * Every entry in this table produces the same `handler.js` — the application
1285
+ * as `Request` → `Response`, from `@uniflowed/server/fetch` — and differs only
1286
+ * in the file wrapped around it and, for a target whose dependencies have a
1287
+ * different build, in the export conditions that pick one. That is the whole
1288
+ * of what an adapter is, and keeping the differences in one object is what
1289
+ * stops a second one from quietly becoming a second application.
1290
+ *
1291
+ * `static` is deliberately absent; `uf_config`'s
1292
+ * `DeployAdapter::is_implemented` is the other half of that fact and
1293
+ * `docs/app/reference/cli/$page.mdx` says why.
1294
+ *
1295
+ * `regenerationStore` is where a target keeps the pages a build regenerates
1296
+ * when `rendering.cache.store` names nothing: a disk for a process with one,
1297
+ * Workers KV for a Worker, and `null` for a Lambda, which keeps nothing between
1298
+ * invocations and is refused by name instead. A regenerated page kept only in
1299
+ * memory would go back to the build's copy on every restart, which is a page
1300
+ * that travels back in time. See [`deploy`].
1301
+ */
1302
+ const ADAPTERS = {
1303
+ node: {
1304
+ entries: (document, cache, build, schedules, regeneration) => ({
1305
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build, regeneration),
1306
+ server: nodeEntrySource("./handler.js", schedules),
1307
+ }),
1308
+ regenerationStore: "filesystem",
1309
+ },
1310
+ // The same two files as `node`, with `@uniflowed/server/bun` in place of
1311
+ // `@uniflowed/server/node`. That module is `./internal/static.js` for every
1312
+ // decision and `Bun.file` for the bytes, which is where the measured win is
1313
+ // — see ubugeeei-prod/uf#391 and the header of `packages/server/bun.js`.
1314
+ //
1315
+ // No `conditions` of its own, unlike `edge`. Bun honours a `bun` export
1316
+ // condition and this deliberately does not ask for it: the measured win is
1317
+ // `Bun.serve` and `Bun.file` and not resolution, so asking would trade a
1318
+ // `handler.js` that is byte-for-byte `node`'s — which is what
1319
+ // `every_adapter_answers_exactly_what_the_node_adapter_answers` checks, and
1320
+ // what makes an adapter one file rather than one application — for whichever
1321
+ // build a dependency happens to ship behind that condition, tested by
1322
+ // nobody here. `edge` pays that price because it must: there is no
1323
+ // `node:stream` in a Worker.
1324
+ bun: {
1325
+ entries: (document, cache, build, schedules, regeneration) => ({
1326
+ handler: handlerEntrySource(document, cache, BUN_CAPABILITIES, build, regeneration),
1327
+ server: bunEntrySource("./handler.js", schedules),
1328
+ }),
1329
+ regenerationStore: "filesystem",
1330
+ },
1331
+ deno: {
1332
+ entries: (document, cache, build, schedules, regeneration) => ({
1333
+ handler: handlerEntrySource(document, cache, DENO_CAPABILITIES, build, regeneration),
1334
+ server: denoEntrySource("./handler.js", schedules),
1335
+ }),
1336
+ regenerationStore: "filesystem",
1337
+ },
1338
+ // The same two files. What `--adapter container` adds is a `Dockerfile` and
1339
+ // a `.dockerignore`, and both are plain text that `uf` writes beside this
1340
+ // output rather than anything the bundler produces — see `uf_cli`'s
1341
+ // `commands::deploy`.
1342
+ container: {
1343
+ entries: (document, cache, build, schedules, regeneration) => ({
1344
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build, regeneration),
1345
+ server: nodeEntrySource("./handler.js", schedules),
1346
+ }),
1347
+ regenerationStore: "filesystem",
1348
+ },
1349
+ edge: {
1350
+ entries: (document, cache, build, schedules, regeneration) => ({
1351
+ handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES, build, regeneration),
1352
+ worker: workerEntrySource("./handler.js", schedules),
1353
+ }),
1354
+ // Workers KV, through the same module seam a project's own provider goes
1355
+ // through. `packages/server/cache-kv.js` argues KV over the Cache API: the
1356
+ // seam has to find every entry under a tag, and the Cache API cannot list
1357
+ // what it holds.
1358
+ regenerationStore: "@uniflowed/server/cache/kv",
1359
+ // `workerd` first, so React resolves to the build that has
1360
+ // `renderToReadableStream` and no `node:stream`. `browser` and `module`
1361
+ // after it are Vite's own SSR defaults, kept so a dependency with no
1362
+ // worker condition still resolves the way it does for every other target.
1363
+ conditions: ["workerd", "worker", "edge-light", "browser", "module", "import", "default"],
1364
+ // A Worker has no filesystem, so uf's built-in durable provider cannot run
1365
+ // here. Refused by name at the build rather than linked into a bundle that
1366
+ // fails on its first `node:fs` import — the deployment rule is that a
1367
+ // target which cannot provide a durable store says so, and this is the one
1368
+ // target that cannot.
1369
+ filesystem: false,
1370
+ // And the Node built-ins it has only as stubs, which the link reports by
1371
+ // module and importer. See `./internal/worker-builtins.js`.
1372
+ workerBuiltins: true,
1373
+ },
1374
+ serverless: {
1375
+ entries: (document, cache, build, _schedules, regeneration) => ({
1376
+ handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES, build, regeneration),
1377
+ lambda: lambdaEntrySource("./handler.js"),
1378
+ }),
1379
+ // A Lambda's memory lasts one instance and its disk is that instance's
1380
+ // `/tmp`, so neither is somewhere a regenerated page survives. A build that
1381
+ // regenerates pages has to name a provider module; see [`deploy`].
1382
+ regenerationStore: null,
1383
+ },
1384
+ };
1385
+
1386
+ /**
1387
+ * How each target says what it can do: the module, and the name to call.
1388
+ *
1389
+ * A pair of strings rather than a value, because this is the module that
1390
+ * *writes* `handler.js` and never imports what it writes: the capabilities
1391
+ * belong to the deployed application's copy of `@uniflowed/server`, not to the
1392
+ * driver's. It is the same reason `beginRequest` is re-exported from the
1393
+ * generated file rather than reached for here — see `handlerEntrySource`.
1394
+ *
1395
+ * Nothing is passed for `websocket` or `queue`, and that is not an oversight:
1396
+ * uf defines both and implements neither, so a generated file that invented
1397
+ * one would be inventing an upgrade for a runtime it cannot see. What the call
1398
+ * does supply is the target's name and its two facts, which is what turns "an
1399
+ * upgrade is not available" into "the serverless host cannot hold a socket
1400
+ * open" and what lets `@uniflowed/server/lambda` refuse a queue that would be
1401
+ * dropped.
1402
+ */
1403
+ const NODE_CAPABILITIES = { module: "@uniflowed/server/node", name: "nodeCapabilities" };
1404
+ const BUN_CAPABILITIES = { module: "@uniflowed/server/bun", name: "bunCapabilities" };
1405
+ const DENO_CAPABILITIES = { module: "@uniflowed/server/deno", name: "denoCapabilities" };
1406
+ const EDGE_CAPABILITIES = { module: "@uniflowed/server/edge", name: "edgeCapabilities" };
1407
+ const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lambdaCapabilities" };
1408
+
1409
+ /**
1410
+ * Link the application into a directory that can be copied, for
1411
+ * `uf build --adapter`.
1412
+ *
1413
+ * `uf start` serves a build and `uf build --compile` puts one inside an
1414
+ * executable, and between them is the shape most hosts actually want: a
1415
+ * directory that carries everything and nothing that is still in the checkout
1416
+ * — no `node_modules`, no source, no `uf`. That is what this writes, for
1417
+ * whichever of [`ADAPTERS`] was asked for.
1418
+ *
1419
+ * It differs from the server build in [`build`] in one way, and that one way
1420
+ * is the whole of the difference between a build artefact and a checkout:
1421
+ * `ssr.noExternal: true`. The ordinary server build leaves `react`,
1422
+ * `react-dom` and every other dependency as bare imports, because the host it
1423
+ * runs on has `node_modules` beside it; a copied directory does not, so they
1424
+ * come in. (`@uniflowed/*` was never external — `index.js` sets
1425
+ * `ssr.noExternal: [/^@uniflowed\//]` because Node cannot import Flow — which
1426
+ * is why serving a build has never needed `uf transform` alive, and why the
1427
+ * blocker ubugeeei-prod/uf#335 records was not one.)
1428
+ *
1429
+ * # Two entries, because an adapter is exactly one of them
1430
+ *
1431
+ * `handler.js` is the application as a Web-standard `fetch` export: a
1432
+ * `Request` in, a `Response` out, no filesystem, no socket, no `node:` import
1433
+ * that a worker does not already have. That is the seam, and it is the same
1434
+ * file for every target in [`ADAPTERS`].
1435
+ *
1436
+ * The second entry is the wrapper for *this* target — `node:http` for `node`
1437
+ * and `container`, `export default { fetch }` for a Worker, `export const
1438
+ * handler` for a Lambda — and each of them is a handful of lines around an
1439
+ * import from `@uniflowed/server`. That is the point: the work is in the
1440
+ * handler, and what a new adapter has to write is the handful of lines, not
1441
+ * the application.
1442
+ *
1443
+ * Both are ordinary entries of one Rolldown build, so the wrapper imports the
1444
+ * emitted `handler.js` rather than a second copy of the application.
1445
+ *
1446
+ * The `static/` directory is *not* written here. `uf` copies it (see
1447
+ * `uf_cli`'s `commands::deploy`), because walking an output directory and
1448
+ * copying every file in it is bulk work over the whole build, which belongs in
1449
+ * Rust rather than in the host process — the same division `--compile` makes
1450
+ * with its embedded assets.
1451
+ *
1452
+ * # `--adapter static` never reaches this function
1453
+ *
1454
+ * It is the one implemented target with no application to link: a static host
1455
+ * returns files, and `uf build` has already written them. So `uf` copies the
1456
+ * output directory itself and never spawns this driver for it, which is why
1457
+ * [`ADAPTERS`] has six rows and not seven. What that target does instead of
1458
+ * linking is refuse a project whose route handlers, middleware, unprerendered
1459
+ * routes or server actions a static host cannot answer — in Rust, because the
1460
+ * facts it needs are the route table and what the prerender reported.
1461
+ */
1462
+ async function deploy() {
1463
+ const vite = await import("vite");
1464
+ const config = await loadConfig();
1465
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
1466
+ const outDir = path.resolve(root, inline.build.outDir);
1467
+ const adapter = argument("--adapter");
1468
+ const workArgument = argument("--work");
1469
+ const outputArgument = argument("--output");
1470
+ if (adapter == null || workArgument == null || outputArgument == null) {
1471
+ throw new Error("uf: `driver.js deploy` needs --adapter, --work and --output");
1472
+ }
1473
+ // What the project declared, read by `uf`'s own walk of the route handlers
1474
+ // and handed over rather than found again here — one reading of a module,
1475
+ // and the same list `wrangler.json`'s `triggers.crons` is written from.
1476
+ // Absent on an older `uf` spawning a newer driver, which is a build with no
1477
+ // schedules rather than an error. See ubugeeei-prod/uf#531.
1478
+ const schedules = JSON.parse(argument("--schedules") ?? "[]");
1479
+ // The Rust side has already refused every adapter it has no implementation
1480
+ // for, by name and with the issue that tracks it. This is the second half of
1481
+ // that fact rather than a duplicate of it: the driver may be spawned by a
1482
+ // future `uf` that knows an adapter this copy does not, and answering "one
1483
+ // moment, here is a directory" for a target nobody wrote would be the silent
1484
+ // wrong answer the whole issue is about.
1485
+ const shape = ADAPTERS[adapter];
1486
+ if (shape == null) {
1487
+ throw new Error(
1488
+ `uf: this driver implements ${Object.keys(ADAPTERS)
1489
+ .map((name) => JSON.stringify(name))
1490
+ .join(", ")} and was asked for ${JSON.stringify(adapter)}`,
1491
+ );
1492
+ }
1493
+ const work = path.resolve(root, workArgument);
1494
+ const output = path.resolve(root, outputArgument);
1495
+
1496
+ emit("phase", { name: adapter });
1497
+
1498
+ // Written to disk rather than served as virtual modules: they are generated
1499
+ // per build — `handler.js` names this build's hashed assets — and a real
1500
+ // file is the version a person can open when a deployed directory
1501
+ // misbehaves.
1502
+ mkdirSync(work, { recursive: true });
1503
+ const document = await documentAssetsFor(
1504
+ path.join(root, ".uf", "build", "server"),
1505
+ readManifest(outDir),
1506
+ );
1507
+ // Whatever `build` above minted, so a durable cache in the deployed artefact
1508
+ // is keyed by the build that produced it and not by the moment it was
1509
+ // packaged. Read rather than minted again for exactly that reason: a second
1510
+ // `randomUUID()` here would key the adapter's copy differently from the one
1511
+ // `uf start` serves out of `.uf/build/`, which is two caches for one build.
1512
+ const buildId = await buildIdentity(root, path.join(".uf", "build", "server"));
1513
+ const declaredCache = config.app?.rendering?.cache;
1514
+ if (shape.filesystem === false && declaredCache?.store === "filesystem") {
1515
+ throw new Error(
1516
+ `uf: rendering.cache.store is "filesystem" and \`--adapter ${adapter}\` has no ` +
1517
+ "filesystem. Name a module exporting `createCacheProvider` instead — a KV " +
1518
+ "namespace or a Redis behind the same seam — or leave the store in memory. See " +
1519
+ "docs/app/guide/cache.",
1520
+ );
1521
+ }
1522
+ // The pages this build regenerates, and where this target keeps what it
1523
+ // regenerates when the project named no store: the adapter's
1524
+ // `regenerationStore`. A target with nowhere refuses by name and lists the
1525
+ // pages, rather than deploying pages that every cold start takes back to the
1526
+ // build's copy.
1527
+ const regeneration = await readRegeneration(path.join(root, ".uf", "build", "server"));
1528
+ let cacheConfig = declaredCache;
1529
+ if (regeneration != null && declaredCache?.store == null) {
1530
+ if (shape.regenerationStore == null) {
1531
+ const pages = Object.keys(regeneration.pages);
1532
+ throw new Error(
1533
+ `uf: this build regenerates ${pages.length} ${plural(pages.length, "page")} ` +
1534
+ `(${pages.join(", ")}), and \`--adapter ${adapter}\` has nowhere of its own to keep ` +
1535
+ "a regenerated page: an instance's memory and its /tmp both go with the instance. " +
1536
+ "Name a module exporting `createCacheProvider` in rendering.cache.store, or leave " +
1537
+ "`isr` out of app.rendering.modes to prerender those pages as documents that do not " +
1538
+ "change. See docs/app/guide/rendering.",
1539
+ );
403
1540
  }
404
- };
405
- collectStyles(entry);
406
-
407
- const preloads = new Set();
408
- const collectPreloads = (chunk) => {
409
- for (const imported of chunk.imports ?? []) {
410
- const dependency = manifest[imported];
411
- if (dependency == null || preloads.has(dependency.file)) continue;
412
- preloads.add(dependency.file);
413
- collectPreloads(dependency);
1541
+ cacheConfig = { ...declaredCache, store: shape.regenerationStore };
1542
+ }
1543
+ const entries = shape.entries(document, cacheConfig, buildId, schedules, regeneration);
1544
+ const input = {};
1545
+ for (const name of Object.keys(entries)) {
1546
+ writeFileSync(path.join(work, `${name}.js`), entries[name]);
1547
+ input[name] = path.join(work, `${name}.js`);
1548
+ }
1549
+
1550
+ // An application React Server Components render bundles the rsc graph into
1551
+ // its server, and a target with export conditions of its own needs that graph
1552
+ // resolved under them as well: React's Flight server has a Node build and a
1553
+ // worker build, exactly as its HTML renderer does.
1554
+ const flight = flightStateOf(inline);
1555
+ if (flight != null) {
1556
+ loadFlightBuild(flight, path.join(root, ".uf", "build", "rsc"));
1557
+ if (shape.conditions != null) {
1558
+ await buildRscGraph(vite, inline, flight, {
1559
+ outDir: path.join(work, "rsc"),
1560
+ conditions: shape.conditions,
1561
+ });
414
1562
  }
1563
+ }
1564
+
1565
+ const ssr = { ...(inline.ssr ?? {}), noExternal: true };
1566
+ if (shape.conditions != null) {
1567
+ // Which build of a dependency this target gets, and it is the difference
1568
+ // between a worker that renders and one that fails to link. React ships
1569
+ // `server.node.js` under the `node` condition and `server.edge.js` under
1570
+ // `workerd`; the first one imports `node:stream`, and the router picks its
1571
+ // renderer by asking whether `renderToPipeableStream` is there — so the
1572
+ // condition list is what decides that, not a flag in the application.
1573
+ ssr.resolve = { ...(inline.ssr?.resolve ?? {}), conditions: shape.conditions };
1574
+ }
1575
+
1576
+ await vite.build({
1577
+ ...inline,
1578
+ customLogger: eventLogger("warn"),
1579
+ plugins: [
1580
+ ...inline.plugins,
1581
+ nativeAddonGuard(),
1582
+ ...(shape.workerBuiltins === true ? [workerBuiltinGuard()] : []),
1583
+ ],
1584
+ // Fixed, because this bundle inlines every dependency and so both of each
1585
+ // React package's builds, and a runtime lookup of `NODE_ENV` in a worker
1586
+ // finds nothing and picks the development one. React's Flight client's
1587
+ // development build constructs a `WeakRef` for every response, which
1588
+ // workerd does not have: every document the edge artefact rendered was a
1589
+ // `ReferenceError`. The production build has none, and is the one a
1590
+ // deployment means.
1591
+ define: {
1592
+ ...(inline.define ?? {}),
1593
+ "process.env.NODE_ENV": JSON.stringify(
1594
+ inline.mode === "development" ? "development" : "production",
1595
+ ),
1596
+ },
1597
+ ssr,
1598
+ build: {
1599
+ ...inline.build,
1600
+ manifest: false,
1601
+ // The map would describe this bundle rather than the source, and nothing
1602
+ // downstream reads it. Off is a smaller directory to copy and one less
1603
+ // file to explain.
1604
+ sourcemap: false,
1605
+ ssr: true,
1606
+ outDir: output,
1607
+ // `uf` has already removed the directory, and `static/` is copied in
1608
+ // after this returns; letting Vite empty it would be Vite deciding when
1609
+ // that happens.
1610
+ emptyOutDir: false,
1611
+ rollupOptions: {
1612
+ input,
1613
+ output: {
1614
+ entryFileNames: "[name].js",
1615
+ // Route modules are lazy `import()`s, so the server bundle splits
1616
+ // whether or not anything asks it to, and the chunks have to land
1617
+ // somewhere. `chunks/` rather than the default `assets/`, because
1618
+ // `static/assets/` beside it is the *client's* — two directories
1619
+ // with one name in a directory whose whole purpose is to be copied
1620
+ // and read by a stranger.
1621
+ chunkFileNames: "chunks/[name]-[hash].js",
1622
+ format: "es",
1623
+ },
1624
+ },
1625
+ },
1626
+ });
1627
+
1628
+ emit("done", { outDir: path.relative(root, output), pages: 0 });
1629
+ process.exit(0);
1630
+ }
1631
+
1632
+ /**
1633
+ * The source of `handler.js`: the application, as one `fetch` export.
1634
+ *
1635
+ * `export default { fetch }` as well as the named export, because those are
1636
+ * the two spellings the hosts this shape exists for actually read — a worker
1637
+ * and Deno Deploy want the default export's `fetch`, and a Node or Bun entry
1638
+ * wants the name. Writing both costs a line and removes the one thing that
1639
+ * would make an otherwise portable file not portable.
1640
+ *
1641
+ * `beginRequest` is exported beside it, and it is not decoration. `fetch`
1642
+ * answers with a `Response`; it does not know when that response reached
1643
+ * anybody, and `after()` promises a callback once it has. So the host owns the
1644
+ * request: begin it, run `fetch` inside `run`, and `settle` when the bytes are
1645
+ * out — `server.js` below does exactly that through
1646
+ * `@uniflowed/server/node`, and a worker hands `settle` to `ctx.waitUntil`.
1647
+ * It comes from the bundle rather than from the host's own
1648
+ * `@uniflowed/server`, because the request store is shared only by copies of
1649
+ * one release of that package and the release the application reads is the one
1650
+ * inlined here. See ubugeeei-prod/uf#389.
1651
+ *
1652
+ * The document's script and stylesheet URLs are baked in here because they
1653
+ * come from the client manifest, which exists at this moment and not in the
1654
+ * directory that gets copied.
1655
+ *
1656
+ * `cache` is `rendering.cache` from `uf.config.js`, and this is where two of
1657
+ * its four switches stop being a field in a JSON file: a build that turned
1658
+ * `route` or `fetch` on constructs a store here and hands it to the handler,
1659
+ * and a build that turned neither on writes the file it always wrote, byte for
1660
+ * byte. The store is constructed in the *generated* module rather than reached
1661
+ * for inside `@uniflowed/server` for the same reason `beginRequest` is
1662
+ * re-exported above — a module-level singleton belongs to whichever copy of the
1663
+ * package a bundler happened to give it, and the copy that matters is the one
1664
+ * the application resolved. See ubugeeei-prod/uf#277 and #389.
1665
+ *
1666
+ * # And where a durable store is named
1667
+ *
1668
+ * `rendering.cache.store` is the fifth key, and it is the one that turns the
1669
+ * store into a shared one: `"filesystem"` links uf's built-in provider, and
1670
+ * anything else is a module specifier the project wrote, imported here by name
1671
+ * so the bundler links it like any other dependency of the application. Neither
1672
+ * appears at all when the key is absent, which is what a default project keeps.
1673
+ *
1674
+ * `build` is baked in beside it, and it is the reason this can be an `import`
1675
+ * at all rather than something read at boot: the build id is a fact about the
1676
+ * artefact being written, known here and nowhere later. `internal/serve.js`'s
1677
+ * `buildIdentity` is where it came from and
1678
+ * `packages/server/internal/cache-key.js` is why it exists.
1679
+ */
1680
+ function handlerEntrySource(document, cache, capabilities, build, regeneration) {
1681
+ const route = cache?.route === true;
1682
+ const fetchCache = cache?.fetch === true;
1683
+ const dataCache = cache?.data === true;
1684
+ // Nothing at all when both switches are off, so a default project's
1685
+ // `handler.js` is the file it has always been. A cache that appears in
1686
+ // generated output nobody asked for is the second half of the complaint
1687
+ // #277 makes about the first half.
1688
+ const store = route || fetchCache || dataCache;
1689
+ const durable = store ? durableStoreSource(root, cache, build) : null;
1690
+ const options = [
1691
+ "app",
1692
+ `document: ${JSON.stringify(document)}`,
1693
+ ...(store ? ["cache"] : []),
1694
+ "capabilities",
1695
+ // The pages this build regenerates, baked in beside the document and for
1696
+ // the same reason: the manifest exists on the machine doing the build, and
1697
+ // the deployed directory has only what this file carries.
1698
+ ...(store && regeneration != null ? [`regeneration: ${JSON.stringify(regeneration)}`] : []),
1699
+ ].join(", ");
1700
+ const cacheImport = store ? 'import { createCacheStore } from "@uniflowed/server/cache";\n' : "";
1701
+ const providerImport = durable == null ? "" : `${durable.import}\n`;
1702
+ const from = JSON.stringify(capabilities.module);
1703
+ const capabilityImport = `import { ${capabilities.name} } from ${from};`;
1704
+ return `// Generated by \`uf build --adapter\`. Not checked in, not edited.
1705
+ import { createFetchHandler } from "@uniflowed/server/fetch";
1706
+ ${cacheImport}${providerImport}${capabilityImport}
1707
+ import * as app from ${JSON.stringify(VIRTUAL.server)};
1708
+
1709
+ ${
1710
+ store
1711
+ ? `// \`rendering.cache\` from uf.config.js. ${
1712
+ durable == null
1713
+ ? `One store per process: it is
1714
+ // emptied by a restart and is not shared with any other instance of this
1715
+ // application.`
1716
+ : `Entries are kept by ${durable.what},
1717
+ // under this build's identity, so a restart finds them where it left them and
1718
+ // every process of this deployment reads one store — and \`revalidateTag\` in
1719
+ // any of them takes an entry out of the store all of them fill from.`
1720
+ } See ubugeeei-prod/uf#277.
1721
+ const cache = { store: createCacheStore(${
1722
+ durable == null ? "" : `{ provider: ${durable.provider}, build: ${JSON.stringify(build)} }`
1723
+ }), route: ${String(route)}, fetch: ${String(fetchCache)}, data: ${String(dataCache)} };
1724
+
1725
+ `
1726
+ : ""
1727
+ }// What this target can do, and it is not the same for all six: whether a
1728
+ // response body reaches the client as it is produced, and whether the process
1729
+ // is still there once it has. A route handler that streams events or takes a
1730
+ // socket asks through this rather than finding out in production. Nothing is
1731
+ // passed for the upgrade or the queue — uf defines both and implements
1732
+ // neither. See \`@uniflowed/server/socket\` and \`@uniflowed/server/queue\`.
1733
+ const capabilities = ${capabilities.name}();
1734
+
1735
+ export const fetch = createFetchHandler({ ${options} });
1736
+ export const beginRequest = app.beginRequest;
1737
+ // \`app.router\`'s redirects and headers, for the entry beside this file to put
1738
+ // in front of its static half. Rewrites are \`fetch\`'s own.
1739
+ export const routing = app.routing;
1740
+
1741
+ export default { fetch, beginRequest, routing };
1742
+ `;
1743
+ }
1744
+
1745
+ /**
1746
+ * The import and the expression that give a generated handler a durable store.
1747
+ *
1748
+ * `null` for `"memory"` and for a project that said nothing, which is every
1749
+ * project until one asks: persistence is a second opt-in on top of `route` and
1750
+ * `fetch`, not something a build decides on a project's behalf.
1751
+ *
1752
+ * The directory is baked in as written rather than resolved here, and that is
1753
+ * deliberate. This function runs on the machine doing the build; the path has
1754
+ * to mean something on the machine doing the *serving*, which may be a
1755
+ * container with one writable mount or a Lambda with only `/tmp`. A relative
1756
+ * one is resolved against the working directory at boot, by the provider, where
1757
+ * the answer is a fact rather than a guess.
1758
+ *
1759
+ * The specifier is resolved against the project for the same reason
1760
+ * `internal/serve.js`'s `providerSpecifier` does it: `"./cache/redis.js"` in
1761
+ * `uf.config.js` is relative to the project, and this file is written into
1762
+ * `.uf/deploy/work/`, where that path means nothing. Absolute is safe here
1763
+ * because the bundler inlines the module rather than emitting the specifier.
1764
+ *
1765
+ * @param {string} root
1766
+ * @param {{store?: string, storeDir?: string}} cache
1767
+ * @param {string | null} build
1768
+ */
1769
+ function durableStoreSource(root, cache, build) {
1770
+ const named = cache?.store ?? "memory";
1771
+ if (named === "memory") return null;
1772
+ if (build == null) {
1773
+ throw new Error(
1774
+ `uf: rendering.cache.store is ${JSON.stringify(named)}, which keeps entries between ` +
1775
+ "restarts, and this build has no identity to key them by. Run `uf build` so one is " +
1776
+ "written, or set UF_BUILD_ID. Without one the deployment would answer this build's " +
1777
+ "URLs with the previous build's documents.",
1778
+ );
1779
+ }
1780
+ const directory = JSON.stringify(cache?.storeDir ?? path.join(".uf", "cache", "route"));
1781
+ if (named === "filesystem") {
1782
+ return {
1783
+ import: 'import { createFilesystemCache } from "@uniflowed/server/cache/filesystem";',
1784
+ provider: `createFilesystemCache({ directory: ${directory} })`,
1785
+ what: "uf's filesystem provider",
1786
+ };
1787
+ }
1788
+ const from = JSON.stringify(providerSpecifier(root, named));
1789
+ return {
1790
+ import: `import { createCacheProvider } from ${from};`,
1791
+ provider: `createCacheProvider({ build: ${JSON.stringify(build)}, directory: ${directory} })`,
1792
+ what: `${named}'s provider`,
415
1793
  };
416
- collectPreloads(entry);
1794
+ }
417
1795
 
1796
+ /**
1797
+ * The lines a process entry needs to run what the project declared.
1798
+ *
1799
+ * Shared by `nodeEntrySource` and `bunEntrySource` because the two differ in
1800
+ * which module they take `serve` from and in nothing else — and a schedule
1801
+ * that behaved differently between them would be the drift the whole seam
1802
+ * exists to prevent. Empty strings when a project declared none, so the entry
1803
+ * a default project gets is the file it has always been.
1804
+ */
1805
+ function scheduleLines(module, schedules) {
1806
+ const declared = schedules ?? [];
1807
+ if (declared.length === 0) {
1808
+ return { imports: "", declarations: "", option: "" };
1809
+ }
1810
+ const built = declared
1811
+ .map(
1812
+ (schedule) =>
1813
+ ` routeSchedule({ handle: fetch, beginRequest, path: ${JSON.stringify(
1814
+ schedule.path,
1815
+ )}, cron: ${JSON.stringify(schedule.cron)} }),`,
1816
+ )
1817
+ .join("\n");
418
1818
  return {
419
- scripts: [`/${entry.file}`],
420
- styles: [...styles].map((file) => `/${file}`),
421
- preloads: [...preloads].map((file) => `/${file}`),
1819
+ imports: `import { routeSchedule } from ${JSON.stringify(module)};\n`,
1820
+ // A schedule runs the route by asking the application for it, so a
1821
+ // scheduled run and a request for the same path are one code path. See
1822
+ // ubugeeei-prod/uf#531.
1823
+ declarations: `\nconst schedules = [\n${built}\n];\n`,
1824
+ option: ", schedules",
422
1825
  };
423
1826
  }
424
1827
 
425
1828
  /**
426
- * The URLs to prerender: every route without parameters, plus every set of
427
- * parameters a page's `generateStaticParams` returns.
1829
+ * The source of `server.js`: the Node socket around that handler.
1830
+ *
1831
+ * Everything host-specific about serving a build is in
1832
+ * `@uniflowed/server/node`, which is the same module `uf start` reaches
1833
+ * through `./internal/serve.js` — so a request answered here and the same
1834
+ * request answered by `uf start` go through one implementation, not two that
1835
+ * agree today.
1836
+ */
1837
+ function nodeEntrySource(handlerSpecifier, schedules) {
1838
+ const cron = scheduleLines("@uniflowed/server/schedule", schedules);
1839
+ return `// Generated by \`uf build --adapter node\`. Not checked in, not edited.
1840
+ import path from "node:path";
1841
+ import { fileURLToPath } from "node:url";
1842
+
1843
+ import { serve } from "@uniflowed/server/node";
1844
+ ${cron.imports}
1845
+ // \`beginRequest\` comes from the handler beside this file rather than from
1846
+ // \`@uniflowed/server/node\` above, because the request has to be established in
1847
+ // the storage the *application* reads, which is the copy bundled into
1848
+ // \`handler.js\`. See ubugeeei-prod/uf#389.
1849
+ import { beginRequest, fetch, routing } from ${JSON.stringify(handlerSpecifier)};
1850
+
1851
+ // Resolved from this file and not from the working directory: a process
1852
+ // manager, a container entrypoint and a person in a shell each start a server
1853
+ // from wherever they happen to be, and a directory that only served its own
1854
+ // assets when it was started from inside itself would be a deployment with a
1855
+ // trap in it.
1856
+ const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1857
+ ${cron.declarations}
1858
+ // Not \`await serve(...)\` at the top level. uf parses that now
1859
+ // (ubugeeei-prod/uf#204) and this entry is a module, so it would work; \`.catch\`
1860
+ // is the better spelling regardless — a server that cannot take its port should
1861
+ // say so and exit non-zero, rather than die as an unhandled rejection.
1862
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
1863
+ process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1864
+ process.exit(1);
1865
+ });
1866
+ `;
1867
+ }
1868
+
1869
+ /**
1870
+ * The oldest Bun that can run what this adapter writes.
1871
+ *
1872
+ * Kept equal to `uf_runtime`'s `BUN_MINIMUM` by
1873
+ * `the_bun_entry_refuses_a_bun_older_than_the_declared_minimum`, which reads
1874
+ * the number out of the artefact rather than out of this file — two places
1875
+ * holding one version is exactly the shape that lets them disagree.
1876
+ *
1877
+ * Why there is a floor at all: React's published server build contains a
1878
+ * labelled statement in `else` position — `else a: if (…)`, in every
1879
+ * `react-dom-server*.production.js` including the `bun` one — and Bun's engine
1880
+ * rejects it with `Cannot find scope for the label 'a'` before this release.
1881
+ * Bun 1.3.13 rejects the built `handler.js`; 1.3.14 imports it and serves it.
1882
+ * It is ordinary ES that Node runs, uf does not emit it, and the `bun` export
1883
+ * condition does not avoid it, so there is nothing for uf to lower. See
1884
+ * ubugeeei-prod/uf#1048.
1885
+ */
1886
+ const MINIMUM_BUN = "1.3.14";
1887
+
1888
+ /**
1889
+ * The source of `server.js`: the Bun socket around that handler.
1890
+ *
1891
+ * `nodeEntrySource`'s twin, and identical but for the module it imports
1892
+ * `serve` from — `@uniflowed/server/bun` exports the same signature on
1893
+ * purpose, so that the two adapters are one contract and a project moving
1894
+ * between them changes a flag and nothing else.
1895
+ *
1896
+ * # Why the handler is imported dynamically
1897
+ *
1898
+ * The one place this is *not* `nodeEntrySource`'s twin. A static
1899
+ * `import … from "./handler.js"` is linked before a single statement of this
1900
+ * file runs, so a version check written above it would never execute: Bun
1901
+ * would fail parsing `handler.js` and the sentence explaining why would never
1902
+ * be reached. `await import()` puts the check first, which is the same move
1903
+ * `denoEntrySource` makes to get its globals in before the handler is
1904
+ * evaluated.
1905
+ */
1906
+ function bunEntrySource(handlerSpecifier, schedules) {
1907
+ const cron = scheduleLines("@uniflowed/server/schedule", schedules);
1908
+ return `// Generated by \`uf build --adapter bun\`. Not checked in, not edited.
1909
+ import path from "node:path";
1910
+ import { fileURLToPath } from "node:url";
1911
+
1912
+ import { serve } from "@uniflowed/server/bun";
1913
+ ${cron.imports}
1914
+ // The Bun this build needs. React's server build carries a labelled statement
1915
+ // in \`else\` position that Bun's engine rejects before this release, so an
1916
+ // older Bun cannot parse \`handler.js\` at all — see ubugeeei-prod/uf#1048.
1917
+ //
1918
+ // This runs *before* the handler is imported, and that ordering is the whole
1919
+ // point: a static import would be linked first and the parse would fail with
1920
+ // Bun's message rather than this one.
1921
+ const MINIMUM_BUN = ${JSON.stringify(MINIMUM_BUN)};
1922
+
1923
+ function ufBunIsOlderThanMinimum(version) {
1924
+ const actual = String(version).split(".");
1925
+ const floor = MINIMUM_BUN.split(".");
1926
+ for (let index = 0; index < floor.length; index += 1) {
1927
+ const mine = Number.parseInt(actual[index], 10);
1928
+ const want = Number.parseInt(floor[index], 10);
1929
+ if (!Number.isFinite(mine)) return true;
1930
+ if (mine !== want) return mine < want;
1931
+ }
1932
+ return false;
1933
+ }
1934
+
1935
+ if (typeof Bun !== "undefined" && ufBunIsOlderThanMinimum(Bun.version)) {
1936
+ process.stderr.write(
1937
+ \`uf: this build needs Bun \${MINIMUM_BUN} or newer, and this is Bun \${Bun.version}. \` +
1938
+ "React's server build uses a labelled statement Bun rejects before " +
1939
+ \`\${MINIMUM_BUN}, so \\\`handler.js\\\` beside this file cannot be parsed here. \` +
1940
+ "Upgrade with \`bun upgrade\`, or build with \`--adapter node\` and run it on Node.\\n",
1941
+ );
1942
+ process.exit(1);
1943
+ }
1944
+
1945
+ // \`beginRequest\` comes from the handler beside this file rather than from
1946
+ // \`@uniflowed/server/bun\` above, because the request has to be established in
1947
+ // the storage the *application* reads, which is the copy bundled into
1948
+ // \`handler.js\`. See ubugeeei-prod/uf#389.
1949
+ const { beginRequest, fetch, routing } = await import(${JSON.stringify(handlerSpecifier)});
1950
+
1951
+ // Resolved from this file and not from the working directory: a process
1952
+ // manager, a container entrypoint and a person in a shell each start a server
1953
+ // from wherever they happen to be, and a directory that only served its own
1954
+ // assets when it was started from inside itself would be a deployment with a
1955
+ // trap in it.
1956
+ const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1957
+ ${cron.declarations}
1958
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
1959
+ process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1960
+ process.exit(1);
1961
+ });
1962
+ `;
1963
+ }
1964
+
1965
+ /**
1966
+ * The source of `server.js`: the Deno socket around that handler.
1967
+ *
1968
+ * The same generated contract as Node and Bun, with `@uniflowed/server/deno`
1969
+ * owning the host-specific pieces. The static directory is resolved with
1970
+ * `import.meta.url` alone so the entry carries no `node:` imports.
428
1971
  */
429
- async function staticPaths(routes) {
1972
+ function denoEntrySource(handlerSpecifier, schedules) {
1973
+ const cron = scheduleLines("@uniflowed/server/schedule", schedules);
1974
+ return `// Generated by \`uf build --adapter deno\`. Not checked in, not edited.
1975
+ import { serve } from "@uniflowed/server/deno";
1976
+ ${cron.imports}
1977
+ // \`beginRequest\` comes from the handler beside this file rather than from
1978
+ // \`@uniflowed/server/deno\` above, because the request has to be established in
1979
+ // the storage the *application* reads, which is the copy bundled into
1980
+ // \`handler.js\`. See ubugeeei-prod/uf#389.
1981
+ //
1982
+ // Deno has no Node globals, but dependency bundles may still carry a CommonJS
1983
+ // production branch that expects a few of them. Establish the small environment
1984
+ // shape before \`handler.js\` is evaluated, which requires a dynamic import here
1985
+ // rather than a static one.
1986
+ globalThis.process ??= { env: {} };
1987
+ globalThis.process.env ??= {};
1988
+ globalThis.process.env.NODE_ENV ??= "production";
1989
+ globalThis.Buffer ??= {
1990
+ byteLength(value) {
1991
+ return new TextEncoder().encode(String(value)).byteLength;
1992
+ },
1993
+ };
1994
+ globalThis.setImmediate ??= (callback, ...args) => setTimeout(callback, 0, ...args);
1995
+ globalThis.clearImmediate ??= (handle) => clearTimeout(handle);
1996
+ const { beginRequest, fetch, routing } = await import(${JSON.stringify(handlerSpecifier)});
1997
+
1998
+ // Resolved from this file and not from the working directory: a process
1999
+ // manager and a person in a shell each start a server from wherever they
2000
+ // happen to be, and a directory that only served its own assets when it was
2001
+ // started from inside itself would be a deployment with a trap in it.
2002
+ const staticDir = decodeURIComponent(new URL("./static", import.meta.url).pathname);
2003
+ ${cron.declarations}
2004
+ serve({ handle: fetch, staticDir, beginRequest, routing${cron.option} }).catch((error) => {
2005
+ console.error(\`uf: \${error?.message ?? String(error)}\`);
2006
+ Deno.exit(1);
2007
+ });
2008
+ `;
2009
+ }
2010
+
2011
+ /**
2012
+ * The source of `worker.js`: the Cloudflare Workers entry around that handler.
2013
+ *
2014
+ * `export default { fetch }`, which is the modules-format Worker Cloudflare
2015
+ * runs, and everything host-specific is in `@uniflowed/server/edge` — the
2016
+ * asset lookup through the `ASSETS` binding `wrangler.json` declares, and the
2017
+ * `ctx.waitUntil` that keeps the isolate alive for `after()`.
2018
+ *
2019
+ * `beginRequest` comes from the handler beside this file for the reason
2020
+ * `nodeEntrySource` gives: the request has to be established in the storage the
2021
+ * *application* reads. See ubugeeei-prod/uf#389.
2022
+ */
2023
+ function workerEntrySource(handlerSpecifier, schedules) {
2024
+ const declared = schedules ?? [];
2025
+ // Nothing at all when the project declared none, so a `worker.js` without
2026
+ // schedules is the file it has always been — and `wrangler.json` carries no
2027
+ // `triggers` for it either, so there is nothing to call the export that
2028
+ // would not be there.
2029
+ if (declared.length === 0) {
2030
+ return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
2031
+ import { createWorkerFetch, installWorkerLogger } from "@uniflowed/server/edge";
2032
+
2033
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
2034
+
2035
+ // After the imports, so a logger the application installed while it loaded is
2036
+ // the one that stays; see \`installWorkerLogger\`.
2037
+ installWorkerLogger();
2038
+
2039
+ export default { fetch: createWorkerFetch({ handle, beginRequest, routing }) };
2040
+ `;
2041
+ }
2042
+
2043
+ // The expression Cloudflare fires, mapped to the route that answers it.
2044
+ // `event.cron` arrives spelled exactly as `wrangler.json` spells it, and
2045
+ // `uf` writes both from one list, so the two cannot disagree.
2046
+ const routes = Object.fromEntries(declared.map((schedule) => [schedule.cron, schedule.path]));
2047
+ return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
2048
+ import { createWorkerFetch, createWorkerScheduled, installWorkerLogger } from "@uniflowed/server/edge";
2049
+
2050
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
2051
+
2052
+ // After the imports, so a logger the application installed while it loaded is
2053
+ // the one that stays; see \`installWorkerLogger\`.
2054
+ installWorkerLogger();
2055
+
2056
+ // \`triggers.crons\` in the wrangler.json beside this file names these same
2057
+ // expressions. See ubugeeei-prod/uf#531.
2058
+ const routes = ${JSON.stringify(routes, null, 2)};
2059
+
2060
+ export default {
2061
+ fetch: createWorkerFetch({ handle, beginRequest, routing }),
2062
+ scheduled: createWorkerScheduled({ handle, beginRequest, routes }),
2063
+ };
2064
+ `;
2065
+ }
2066
+
2067
+ /**
2068
+ * The source of `lambda.js`: the AWS Lambda entry around that handler.
2069
+ *
2070
+ * `export const handler`, so the function's configured handler is
2071
+ * `lambda.handler`. Everything platform-specific — the payload format 2.0
2072
+ * event, the base64 rules, the `cookies` array — is in
2073
+ * `@uniflowed/server/lambda`.
2074
+ *
2075
+ * `staticDir` points at the `static/` copied beside this file, so an uploaded
2076
+ * package answers a prerendered document without any other infrastructure
2077
+ * existing. That is a starting point rather than a destination, and the module
2078
+ * it is passed to says so at length.
2079
+ */
2080
+ function lambdaEntrySource(handlerSpecifier) {
2081
+ return `// Generated by \`uf build --adapter serverless\`. Not checked in, not edited.
2082
+ import path from "node:path";
2083
+ import { fileURLToPath } from "node:url";
2084
+
2085
+ import { createLambdaHandler } from "@uniflowed/server/lambda";
2086
+
2087
+ import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
2088
+
2089
+ // Resolved from this file and not from the working directory: Lambda sets the
2090
+ // working directory to the task root today and is under no obligation to keep
2091
+ // doing so, and a deployment that only found its own assets by accident is a
2092
+ // deployment with a trap in it.
2093
+ const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
2094
+
2095
+ export const handler = createLambdaHandler({ handle, beginRequest, staticDir, routing });
2096
+ `;
2097
+ }
2098
+
2099
+ /**
2100
+ * The source of the module a runtime gets wrapped around.
2101
+ *
2102
+ * Three imports and one call: the shim that serves, the application, and the
2103
+ * bytes of `dist/`. The document's script and stylesheet URLs are baked in
2104
+ * here because they come from the client manifest, which exists at this moment
2105
+ * and not inside the binary.
2106
+ *
2107
+ * `assetsFile` is the generated payload's path, and it appears in a comment
2108
+ * rather than in the import: the module enters the graph under a virtual id so
2109
+ * that uf's Flow transform never meets eight megabytes of base64. The reason
2110
+ * that matters is `./internal/compile-assets.js`; naming the file here is what
2111
+ * keeps a reader of the generated entry able to find the bytes it carries.
2112
+ */
2113
+ function entrySource(assetsFile, document) {
2114
+ // Not `await serve(...)` at the top level. uf parses that now
2115
+ // (ubugeeei-prod/uf#204) and this entry is a module, so it would work;
2116
+ // `.catch` is the better spelling regardless: a binary that cannot take its
2117
+ // port should say which port and exit non-zero, rather than die as an
2118
+ // unhandled rejection.
2119
+ return `// Generated by \`uf build --compile\`. Not checked in, not edited.
2120
+ import { serve } from "@uniflowed/server/standalone";
2121
+ // Every file \`uf build\` wrote, base64 in one string. It is on disk at
2122
+ // ${assetsFile}, and it is imported under a virtual id so that uf's
2123
+ // Flow transform is never asked to parse it — see \`@uniflowed/vite\`'s
2124
+ // \`internal/compile-assets.js\` for why that matters.
2125
+ import { assets } from ${JSON.stringify(COMPILE_ASSETS_ID)};
2126
+ import * as app from ${JSON.stringify(VIRTUAL.server)};
2127
+
2128
+ serve({ app, assets, document: ${JSON.stringify(document)} }).catch((error) => {
2129
+ process.stderr.write(\`uf: \${error?.message ?? String(error)}\n\`);
2130
+ process.exit(1);
2131
+ });
2132
+ `;
2133
+ }
2134
+
2135
+ /**
2136
+ * Refuse a native addon by name instead of by stack trace.
2137
+ *
2138
+ * A `.node` file is a compiled shared object for one platform: it cannot be
2139
+ * inlined into a JavaScript bundle, and a binary that carried one would stop
2140
+ * being a single file. Without this, `ssr.noExternal: true` hands the addon to
2141
+ * Rolldown and the build fails somewhere inside the bundler with a message
2142
+ * about an unexpected character — which is true, and useless. Failing here
2143
+ * with the addon's path and the importer that reached it is the difference
2144
+ * between a feature and a trap.
2145
+ *
2146
+ * It catches what can be caught: a static `import` or `require` that resolves
2147
+ * to a `.node` file. An addon loaded through a runtime string — `process.dlopen`,
2148
+ * or `require(variable)` — is not visible to any bundler, so such a project
2149
+ * still compiles and still fails on the first request that reaches the addon.
2150
+ * That limit is real, it is not fixable from inside a bundler, and it is
2151
+ * written down in the CLI reference rather than papered over.
2152
+ */
2153
+ function nativeAddonGuard() {
2154
+ return {
2155
+ name: "uf:no-native-addons",
2156
+ enforce: "pre",
2157
+ resolveId(source, importer) {
2158
+ if (!source.endsWith(".node")) return null;
2159
+ const from = importer == null ? "the application" : path.relative(root, importer);
2160
+ throw new Error(
2161
+ `${from} loads the native addon ${source}, and \`uf build --compile\` cannot put one ` +
2162
+ "inside a single executable: a `.node` file is a shared object built for one " +
2163
+ "platform, and embedding it would make the output two files rather than one. " +
2164
+ "Build without `--compile` and deploy `dist/` with a runtime, or replace the " +
2165
+ "dependency with one that has no native addon.",
2166
+ );
2167
+ },
2168
+ };
2169
+ }
2170
+
2171
+ /**
2172
+ * Say which Node built-ins the Worker being linked reaches and does not have.
2173
+ *
2174
+ * `--adapter edge` only. Every import the bundler resolves passes through here,
2175
+ * and one naming a module `./internal/worker-builtins.js` measured as a stub at
2176
+ * the compatibility date uf writes is kept with the file that imported it. Only
2177
+ * the ones still in the output are reported — see `survivingImports` for the
2178
+ * fixture that showed why — and as warnings rather than a refusal, for the
2179
+ * reason that module gives: a deployment at a newer date may have the module,
2180
+ * and uf cannot see that deployment. What it can do is say, before anything is
2181
+ * uploaded, which request is going to answer 500 and why.
2182
+ */
2183
+ function workerBuiltinGuard() {
2184
+ const reached = [];
2185
+ return {
2186
+ name: "uf:worker-builtins",
2187
+ enforce: "pre",
2188
+ resolveId(source, importer) {
2189
+ if (importer != null && unavailableOnWorkers(source) != null) {
2190
+ reached.push({ specifier: source, importer });
2191
+ }
2192
+ return null;
2193
+ },
2194
+ generateBundle(_options, bundle) {
2195
+ const chunks = Object.values(bundle).filter((output) => output.type === "chunk");
2196
+ for (const warning of workerBuiltinWarnings(survivingImports(reached, chunks), root)) {
2197
+ this.warn(warning);
2198
+ }
2199
+ },
2200
+ };
2201
+ }
2202
+
2203
+ /** `word`, pluralised for `count`. */
2204
+ function plural(count, word) {
2205
+ return count === 1 ? word : `${word}s`;
2206
+ }
2207
+
2208
+ async function printConfig() {
2209
+ const config = await loadConfig();
2210
+ emit("config", { config: projectConfig(config) });
2211
+ process.exit(0);
2212
+ }
2213
+
2214
+ /**
2215
+ * The React Server Components state `@uniflowed/vite` shares with this driver,
2216
+ * or `null` for an application rendered from its modules.
2217
+ *
2218
+ * Read off the plugin rather than decided again here: `rendersFlight` in
2219
+ * `./internal/flight.js` decides from the same `uf.config.js`, and a second
2220
+ * reading of it would be the one that drifts.
2221
+ */
2222
+ function flightStateOf(inline) {
2223
+ const plugins = (inline.plugins ?? []).flat(Number.POSITIVE_INFINITY);
2224
+ return plugins.find((plugin) => plugin?.name === "uf:flow")?.api?.flight ?? null;
2225
+ }
2226
+
2227
+ /** What a later command needs of the rsc build, written beside its output. */
2228
+ const FLIGHT_BUILD_FILE = "uf-flight.json";
2229
+
2230
+ /**
2231
+ * Build the rsc graph into `outDir`.
2232
+ *
2233
+ * `conditions` are a deploy target's own, added to `react-server`; `null` is
2234
+ * the Node server `uf build` writes. Vite's builder rather than `vite.build`,
2235
+ * because the rsc graph is an environment of its own and `vite.build` builds
2236
+ * the two Vite always has.
2237
+ */
2238
+ async function buildRscGraph(vite, inline, state, { outDir, conditions }) {
2239
+ const environment = {
2240
+ build: {
2241
+ outDir,
2242
+ emptyOutDir: true,
2243
+ rollupOptions: {
2244
+ input: { index: FLIGHT_VIRTUAL.entry },
2245
+ output: { entryFileNames: "[name].js", format: "es" },
2246
+ },
2247
+ },
2248
+ };
2249
+ if (conditions != null) {
2250
+ environment.resolve = {
2251
+ conditions: ["react-server", ...conditions],
2252
+ externalConditions: ["react-server", ...conditions],
2253
+ };
2254
+ }
2255
+ const builder = await vite.createBuilder({
2256
+ ...inline,
2257
+ customLogger: eventLogger("warn"),
2258
+ environments: { [RSC_ENVIRONMENT]: environment },
2259
+ });
2260
+ await builder.build(builder.environments[RSC_ENVIRONMENT]);
2261
+ state.rscOutput = path.join(outDir, "index.js");
2262
+ }
2263
+
2264
+ /**
2265
+ * Record the chunk each client module was built into, and write it down.
2266
+ *
2267
+ * Written down because `uf build --adapter` and `uf build --compile` bundle
2268
+ * the server again in a process of their own, and a reference in the rsc
2269
+ * output names its module by path, which only this build's manifest turns
2270
+ * into a URL.
2271
+ */
2272
+ function recordClientChunks(state, manifest, references, rscDir, base = "") {
2273
+ for (const file of references) {
2274
+ const key = path.relative(root, file).split(path.sep).join("/");
2275
+ const chunk = manifest[key];
2276
+ if (chunk == null) {
2277
+ throw new Error(
2278
+ `uf: the client build wrote no chunk for ${key}, which a server component renders as a ` +
2279
+ "client component",
2280
+ );
2281
+ }
2282
+ state.chunkUrls.set(file, `${base}/${chunk.file}`);
2283
+ }
2284
+ writeFileSync(
2285
+ path.join(rscDir, FLIGHT_BUILD_FILE),
2286
+ `${JSON.stringify({ chunkUrls: [...state.chunkUrls] }, null, 2)}\n`,
2287
+ );
2288
+ }
2289
+
2290
+ /** What `recordClientChunks` wrote, for a command that runs after `uf build`. */
2291
+ function loadFlightBuild(state, rscDir) {
2292
+ const file = path.join(rscDir, FLIGHT_BUILD_FILE);
2293
+ if (!existsSync(file)) {
2294
+ throw new Error(
2295
+ `uf: ${path.relative(root, file)} is missing, so there is no rsc graph to render routes ` +
2296
+ "with; run `uf build` first",
2297
+ );
2298
+ }
2299
+ state.chunkUrls = new Map(JSON.parse(readFileSync(file, "utf8")).chunkUrls);
2300
+ state.rscOutput = path.join(rscDir, "index.js");
2301
+ }
2302
+
2303
+ /**
2304
+ * The tags a document React Server Components render needs.
2305
+ *
2306
+ * `assetsFromManifest`'s, with stylesheets from three places in the order they
2307
+ * cascade: the rsc graph's first — every layout's and every server component's
2308
+ * — then the client entry's, then each client module's own, which the client
2309
+ * build emits beside that module's chunk and no import from the entry reaches.
2310
+ *
2311
+ * The rsc build's emitted files are copied under `dist/` so those URLs resolve,
2312
+ * and only its assets: a server bundle's JavaScript is never a deployable file.
2313
+ */
2314
+ function flightAssets(manifest, references, rscDir, outDir, base = "") {
2315
+ const assets = assetsFromManifest(manifest, base);
2316
+ const styles = new Set();
2317
+ const rscManifest = path.join(rscDir, ".vite", "manifest.json");
2318
+ if (existsSync(rscManifest)) {
2319
+ const rscStyles = assetsFromManifest(
2320
+ JSON.parse(readFileSync(rscManifest, "utf8")),
2321
+ base,
2322
+ ).styles;
2323
+ for (const href of rscStyles) styles.add(href);
2324
+ }
2325
+ const rscAssets = path.join(rscDir, "assets");
2326
+ if (existsSync(rscAssets)) {
2327
+ cpSync(rscAssets, path.join(outDir, "assets"), {
2328
+ recursive: true,
2329
+ filter: (from) => !/\.(?:[cm]?js|map)$/.test(from),
2330
+ });
2331
+ }
2332
+ for (const href of assets.styles) styles.add(href);
2333
+ const seen = new Set();
2334
+ const visit = (key) => {
2335
+ if (seen.has(key)) return;
2336
+ seen.add(key);
2337
+ const chunk = manifest[key];
2338
+ if (chunk == null) return;
2339
+ for (const css of chunk.css ?? []) styles.add(`${base}/${css}`);
2340
+ for (const imported of chunk.imports ?? []) visit(imported);
2341
+ };
2342
+ for (const file of references) visit(path.relative(root, file).split(path.sep).join("/"));
2343
+ return { ...assets, styles: [...styles] };
2344
+ }
2345
+
2346
+ function readManifest(outDir) {
2347
+ const file = path.join(outDir, ".vite", "manifest.json");
2348
+ if (!existsSync(file)) throw new Error(`uf: the client build wrote no manifest at ${file}`);
2349
+ return JSON.parse(readFileSync(file, "utf8"));
2350
+ }
2351
+
2352
+ /**
2353
+ * What this build renders now, and what it leaves for a server.
2354
+ *
2355
+ * The rendering decision, per route, and it has three answers rather than the
2356
+ * two `staticPaths` used to have:
2357
+ *
2358
+ * * **prerender it** — a route with no parameters, or a route whose page
2359
+ * exports `generateStaticParams`, once per set of parameters it returns;
2360
+ * * **leave it to the server** — a route with parameters and no
2361
+ * `generateStaticParams`, or a page that has said `export const dynamic =
2362
+ * "force-dynamic"`;
2363
+ * * **refuse** — which is not decided here. This function reports what it
2364
+ * found and the caller, which knows whether the project allows a server,
2365
+ * is the one that turns "there is a route here a static host cannot
2366
+ * answer" into an error.
2367
+ *
2368
+ * `dynamic` is the spelling ubugeeei-prod/uf#336 asked for: a route with *no*
2369
+ * parameters whose content depends on the request had no way to say so, and
2370
+ * `generateStaticParams` cannot say it — there are no parameters to generate.
2371
+ * It is Next.js's name for the same declaration, because a person arriving
2372
+ * from `app/` should not have to learn a second word for a decision they have
2373
+ * already made once.
2374
+ *
2375
+ * Two of Next's four values are missing and are not silently accepted:
2376
+ * `"force-static"` and `"error"` are refused by name, because each is a
2377
+ * *constraint* on a page that uf does not yet check, and accepting one would
2378
+ * be reading a declaration and ignoring it — the failure the two issues behind
2379
+ * this function are about.
2380
+ *
2381
+ * Handlers and middleware are in the same list, and they belong there: this is
2382
+ * the list of things that need a process, and a `$route.js` needs one more
2383
+ * obviously than any page does. They carry no per-route render — the build has
2384
+ * never written a file for either — so they appear only when the answer might
2385
+ * be a refusal.
2386
+ *
2387
+ * @param {{routes: Route[], handlers: Handler[], middleware: Middleware[]}} server
2388
+ * @param {"everything" | "possible" | "nothing"} prerender
2389
+ */
2390
+ async function renderingPlan(server, prerender) {
430
2391
  const urls = [];
431
- for (const route of routes) {
2392
+ const perRequest = [];
2393
+
2394
+ // One document, and it is no route's, so there is no route to ask anything
2395
+ // about. `perRequest` is empty rather than "every route": nothing here is
2396
+ // left for a server — the browser answers all of it — and listing routes
2397
+ // under a heading that means "these need a process" would be a build
2398
+ // describing itself wrongly to `uf`, which prints that list.
2399
+ if (prerender === "shell") {
2400
+ return { urls: [], perRequest: [] };
2401
+ }
2402
+
2403
+ // Nothing is prerendered and nothing is refused, so no page module is
2404
+ // loaded: a project that renders everything per request should not pay for
2405
+ // a `generateStaticParams` this build will not call.
2406
+ if (prerender === "nothing") {
2407
+ return {
2408
+ urls,
2409
+ perRequest: server.routes.map((route) => ({
2410
+ path: route.path,
2411
+ why: "this build prerenders nothing",
2412
+ })),
2413
+ };
2414
+ }
2415
+
2416
+ for (const route of server.routes) {
2417
+ // Every page module, and not only the parameterised ones: `dynamic` is a
2418
+ // declaration any page can make. A module that cannot be imported at all
2419
+ // is a failure of *that route*, so a route with no parameters goes into
2420
+ // the prerender anyway and the loop below reports it the way it has always
2421
+ // reported a page that throws — named, with the rest of the build still
2422
+ // happening. A parameterised one still rejects out of the build, which is
2423
+ // what it did before there was anything else to load a page module for.
2424
+ let module;
2425
+ try {
2426
+ module = await route.page();
2427
+ } catch (error) {
2428
+ if (route.params.length > 0) throw error;
2429
+ urls.push(route.path);
2430
+ continue;
2431
+ }
2432
+ const declared = module.dynamic ?? "auto";
2433
+ if (declared !== "auto" && declared !== "force-dynamic") {
2434
+ throw new Error(
2435
+ `uf: ${route.file} exports \`dynamic = ${JSON.stringify(declared)}\`, and uf reads ` +
2436
+ '`"auto"` and `"force-dynamic"`. `"force-static"` and `"error"` are Next.js values ' +
2437
+ "for constraints uf does not check yet, and accepting one would be reading a " +
2438
+ "declaration and ignoring it.",
2439
+ );
2440
+ }
2441
+ if (declared === "force-dynamic") {
2442
+ perRequest.push({
2443
+ path: route.path,
2444
+ why: 'its page exports `dynamic = "force-dynamic"`',
2445
+ });
2446
+ continue;
2447
+ }
432
2448
  if (route.params.length === 0) {
433
2449
  urls.push(route.path);
434
2450
  continue;
435
2451
  }
436
- const module = await route.page();
437
2452
  const generate = module.generateStaticParams;
438
- if (typeof generate !== "function") continue;
2453
+ if (typeof generate !== "function") {
2454
+ perRequest.push({
2455
+ path: route.path,
2456
+ why: "it has parameters and its page exports no `generateStaticParams`",
2457
+ });
2458
+ continue;
2459
+ }
439
2460
  for (const params of await generate()) {
440
2461
  urls.push(fillParams(route.path, params));
441
2462
  }
442
2463
  }
443
- return urls;
2464
+
2465
+ for (const handler of server.handlers ?? []) {
2466
+ perRequest.push({
2467
+ path: handler.path,
2468
+ why: "it is a route handler, and a handler answers a request rather than producing a file",
2469
+ });
2470
+ }
2471
+ for (const entry of server.middleware ?? []) {
2472
+ // A middleware is reported by the path it guards rather than by the route
2473
+ // it guards, which is why it cannot be folded into the loop above: it runs
2474
+ // for a page, for a handler, and for a path under it that is neither, so
2475
+ // "which route is this" has no single answer.
2476
+ perRequest.push({
2477
+ path: `${entry.path === "/" ? "" : entry.path}/*`,
2478
+ why: "a middleware guards it, and a middleware runs once per request",
2479
+ });
2480
+ }
2481
+ // And `app.router`'s three lists, by the source each rule matches: a file
2482
+ // can be neither a redirect nor a rewrite, and a header a file is served
2483
+ // with is the host's to add rather than the file's.
2484
+ for (const [key, what] of [
2485
+ ["redirects", "a redirect"],
2486
+ ["rewrites", "a rewrite"],
2487
+ ["headers", "a response header"],
2488
+ ]) {
2489
+ for (const rule of server.routing?.[key] ?? []) {
2490
+ perRequest.push({
2491
+ path: rule.source,
2492
+ why: `\`app.router.${key}\` names it, and ${what} is answered when a request arrives`,
2493
+ });
2494
+ }
2495
+ }
2496
+
2497
+ return { urls, perRequest };
444
2498
  }
445
2499
 
446
2500
  function fillParams(routePath, params) {
@@ -460,9 +2514,36 @@ function fillParams(routePath, params) {
460
2514
  .join("/");
461
2515
  }
462
2516
 
463
- function htmlPathFor(outDir, url) {
464
- const pathname = url.split("?")[0].replace(/^\/+/, "");
465
- return pathname === ""
466
- ? path.join(outDir, "index.html")
2517
+ /**
2518
+ * The file a prerendered page is written to.
2519
+ *
2520
+ * `guide/index.html`, which every static host serves at `/guide/` and most at
2521
+ * `/guide`, unless `app.router.trailingSlash` is `"never"` — then `guide.html`,
2522
+ * which the same hosts serve at `/guide` without a redirect to the slash.
2523
+ * Next.js's static export makes the same choice from the same setting.
2524
+ */
2525
+ function htmlPathFor(outDir, url, trailingSlash = "ignore") {
2526
+ const pathname = url.split("?")[0].replace(/^\/+/, "").replace(/\/+$/, "");
2527
+ if (pathname === "") return path.join(outDir, "index.html");
2528
+ return trailingSlash === "never"
2529
+ ? path.join(outDir, `${pathname}.html`)
467
2530
  : path.join(outDir, pathname, "index.html");
468
2531
  }
2532
+
2533
+ /**
2534
+ * The URL path at which a static half answers the document `htmlPathFor`
2535
+ * wrote for `url` under `REGENERATED_DIRECTORY`.
2536
+ *
2537
+ * Ending in a slash, so every host answers it with that directory's
2538
+ * `index.html` in the same way: a Node static half tries `index.html` for such
2539
+ * a path, and a Worker's assets binding serves it without the redirect it
2540
+ * answers `…/index.html` with.
2541
+ */
2542
+ function regeneratedDocumentUrl(url) {
2543
+ const pathname = url.split("?")[0].replace(/^\/+/, "").replace(/\/+$/, "");
2544
+ const encoded = pathname
2545
+ .split("/")
2546
+ .map((segment) => encodeURIComponent(segment))
2547
+ .join("/");
2548
+ return pathname === "" ? `/${REGENERATED_DIRECTORY}/` : `/${REGENERATED_DIRECTORY}/${encoded}/`;
2549
+ }