@uniflowed/vite 0.0.0-alpha.8 → 0.1.0

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