@uniflowed/vite 0.0.0-alpha.9 → 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 +1854 -190
- package/index.js +931 -121
- package/internal/a11y-runtime.js +234 -0
- package/internal/a11y.js +102 -0
- package/internal/assets.js +366 -18
- package/internal/barrel-imports.js +459 -0
- package/internal/compile-assets.js +67 -0
- package/internal/config.js +30 -3
- package/internal/dev-state.js +161 -0
- package/internal/devtools.js +117 -0
- package/internal/diagnostics.js +369 -0
- package/internal/events.js +8 -5
- package/internal/flight.js +803 -0
- package/internal/flow-keywords.js +1 -1
- package/internal/frontmatter.js +33 -0
- package/internal/http.js +21 -2
- package/internal/image-endpoint.js +118 -0
- package/internal/instrumentation.js +23 -0
- package/internal/module-graph.js +134 -0
- package/internal/native-web.js +26 -0
- package/internal/openapi.js +218 -0
- package/internal/relay.js +74 -0
- package/internal/routes.js +1294 -81
- package/internal/rsc.js +335 -11
- package/internal/serve.js +583 -29
- package/internal/server-components.js +109 -0
- package/internal/worker-builtins.js +170 -0
- package/package.json +28 -6
package/driver.js
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
// `uf start` spawn.
|
|
7
7
|
//
|
|
8
8
|
// <host> driver.js dev --root <dir> [--mode <m>] [--host <h>] [--port <n>] [--strict-port]
|
|
9
|
-
//
|
|
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>]...
|
|
10
15
|
// <host> driver.js compile --root <dir> [--mode <m>] [--out-dir <dir>] --assets <file> --bundle <dir>
|
|
11
16
|
// <host> driver.js deploy --root <dir> [--mode <m>] [--out-dir <dir>] --adapter <name> --work <dir> --output <dir>
|
|
12
17
|
// <host> driver.js preview --root <dir> [--mode <m>] [--out-dir <dir>] [--host <h>] [--port <n>]
|
|
@@ -19,6 +24,12 @@
|
|
|
19
24
|
// environment — see `viteConfig` below and `crates/uf_config/src/env_files.rs`.
|
|
20
25
|
// `start` has no Vite in it and therefore no mode.
|
|
21
26
|
//
|
|
27
|
+
// `--uf-env-file` names those files, one flag each, so `dev` can watch them and
|
|
28
|
+
// say when one moved; nothing here reads their contents. The prefix is load
|
|
29
|
+
// bearing: node claims `--env-file` for itself and honours it wherever it
|
|
30
|
+
// appears on the command line, script arguments included, so a driver argument
|
|
31
|
+
// by that name is an argument node eats and then exits 9 over.
|
|
32
|
+
//
|
|
22
33
|
// `uf` in Rust owns the terminal; this process owns Vite. They talk over
|
|
23
34
|
// stdout, one JSON event per line (see `./internal/events.js`), and the driver
|
|
24
35
|
// exits when its stdin closes so it cannot outlive the command that started
|
|
@@ -28,22 +39,50 @@
|
|
|
28
39
|
// Rust side reads a config that may hold functions and plugin instances: the
|
|
29
40
|
// one host that can evaluate the file evaluates it.
|
|
30
41
|
|
|
42
|
+
import { randomUUID } from "node:crypto";
|
|
31
43
|
import { createServer as createHttpServer } from "node:http";
|
|
32
|
-
import { register } from "node:module";
|
|
33
|
-
import {
|
|
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";
|
|
34
48
|
import path from "node:path";
|
|
35
49
|
import { pathToFileURL } from "node:url";
|
|
36
50
|
|
|
37
|
-
import {
|
|
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";
|
|
38
59
|
import { loadUfConfig, projectConfig } from "./internal/config.js";
|
|
39
|
-
import { send, toRequest } from "./internal/http.js";
|
|
60
|
+
import { send, toAddressRequest, toRequest } from "./internal/http.js";
|
|
61
|
+
import { createOpenApiDocument } from "./internal/openapi.js";
|
|
40
62
|
import { withProjectConfig } from "./merge.js";
|
|
41
|
-
import {
|
|
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";
|
|
42
66
|
import {
|
|
67
|
+
BUILD_ID_FILE,
|
|
68
|
+
DOCUMENT_ASSETS_FILE,
|
|
69
|
+
PARTIAL_PRERENDER_FILE,
|
|
70
|
+
REGENERATED_DIRECTORY,
|
|
71
|
+
REGENERATION_FILE,
|
|
72
|
+
answerRouting,
|
|
73
|
+
answersInFrontOfFiles,
|
|
43
74
|
assetsFromManifest,
|
|
75
|
+
buildIdentity,
|
|
76
|
+
createPrerenderGate,
|
|
44
77
|
createServeHandler,
|
|
78
|
+
deploymentIdFor,
|
|
79
|
+
documentAssetsFor,
|
|
80
|
+
forViteBase,
|
|
45
81
|
loadBuild,
|
|
46
82
|
nodeListener,
|
|
83
|
+
providerSpecifier,
|
|
84
|
+
readPartialPrerenders,
|
|
85
|
+
readRegeneration,
|
|
47
86
|
withRequest,
|
|
48
87
|
} from "./internal/serve.js";
|
|
49
88
|
|
|
@@ -52,6 +91,16 @@ function argument(name) {
|
|
|
52
91
|
return at === -1 ? null : process.argv[at + 1];
|
|
53
92
|
}
|
|
54
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
|
+
|
|
55
104
|
function flag(name) {
|
|
56
105
|
return process.argv.includes(name);
|
|
57
106
|
}
|
|
@@ -66,11 +115,23 @@ process.env.UF_PROJECT_ROOT = root;
|
|
|
66
115
|
// hooks for that; Bun is started with `--preload` on the same package's
|
|
67
116
|
// preload instead, and has no `register`.
|
|
68
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
|
+
//
|
|
69
128
|
// The hooks live in `@uniflowed/host` rather than here: they are how Flow runs
|
|
70
129
|
// on a Capability JS Host, and nothing in them is Vite's. `uf test` reaches for
|
|
71
130
|
// the same package, which is what stopped a test run from depending on a
|
|
72
131
|
// bundler it never loads.
|
|
73
|
-
if (typeof
|
|
132
|
+
if (typeof Deno !== "undefined") {
|
|
133
|
+
installFlowHooks(root);
|
|
134
|
+
} else if (typeof Bun === "undefined") {
|
|
74
135
|
register("@uniflowed/host/internal/node-hooks.js", import.meta.url, { data: { root } });
|
|
75
136
|
}
|
|
76
137
|
|
|
@@ -78,7 +139,7 @@ process.stdin.on("end", () => process.exit(0));
|
|
|
78
139
|
process.stdin.on("error", () => process.exit(0));
|
|
79
140
|
process.stdin.resume();
|
|
80
141
|
|
|
81
|
-
const commands = { dev, build, compile, deploy, preview, start, config: printConfig };
|
|
142
|
+
const commands = { dev, build, library, compile, deploy, preview, start, config: printConfig };
|
|
82
143
|
const run = commands[command];
|
|
83
144
|
if (run == null) {
|
|
84
145
|
emit("error", { message: `unknown driver command ${JSON.stringify(command)}` });
|
|
@@ -90,6 +151,33 @@ run().catch((error) => {
|
|
|
90
151
|
process.exit(1);
|
|
91
152
|
});
|
|
92
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
|
+
|
|
93
181
|
/** Load `uf.config.js`, reporting where it was found. */
|
|
94
182
|
async function loadConfig() {
|
|
95
183
|
const { config, file } = await loadUfConfig(root);
|
|
@@ -97,6 +185,15 @@ async function loadConfig() {
|
|
|
97
185
|
return config;
|
|
98
186
|
}
|
|
99
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
|
+
|
|
100
197
|
/** The Vite inline config a uf config describes. */
|
|
101
198
|
async function viteConfig(config, mode) {
|
|
102
199
|
const { default: uniflowed } = await import("./index.js");
|
|
@@ -107,6 +204,7 @@ async function viteConfig(config, mode) {
|
|
|
107
204
|
const port = Number(argument("--port") ?? dev.port ?? 5173);
|
|
108
205
|
const allowedHosts =
|
|
109
206
|
Array.isArray(dev.allowedHosts) && dev.allowedHosts.length > 0 ? dev.allowedHosts : undefined;
|
|
207
|
+
const routeTarget = resolveRouteTarget(config, argument("--target"));
|
|
110
208
|
|
|
111
209
|
// What uf generates from the semantics it owns: where the project is, which
|
|
112
210
|
// plugins make Flow compile, and the few settings uf enforces rather than
|
|
@@ -135,9 +233,30 @@ async function viteConfig(config, mode) {
|
|
|
135
233
|
configFile: false,
|
|
136
234
|
envDir: false,
|
|
137
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)}/`,
|
|
138
257
|
clearScreen: false,
|
|
139
258
|
customLogger: eventLogger(argument("--log-level") ?? "info"),
|
|
140
|
-
plugins: [uniflowed({ root, config })],
|
|
259
|
+
plugins: [uniflowed({ root, config, target: routeTarget })],
|
|
141
260
|
server: {
|
|
142
261
|
host,
|
|
143
262
|
port,
|
|
@@ -182,129 +301,43 @@ async function viteConfig(config, mode) {
|
|
|
182
301
|
* Vite in middleware mode serves nothing on its own: with no `index.html` at
|
|
183
302
|
* the project root it answers every navigation with "Cannot GET /", which is
|
|
184
303
|
* what `uf dev` used to do for every project it started. A uf project has no
|
|
185
|
-
* `index.html` — the document comes from a layout — so the server has to
|
|
186
|
-
* it
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
* being clear that this is a property of the development server rather than of
|
|
201
|
-
* the renderer. Streaming through the transform is ubugeeei-prod/uf#374.
|
|
202
|
-
*
|
|
203
|
-
* Anything Vite already serves — a module, a public file — never reaches this,
|
|
204
|
-
* because the middleware runs after Vite's own.
|
|
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.
|
|
205
319
|
*/
|
|
206
320
|
async function dev() {
|
|
207
321
|
const { createServer } = await import("vite");
|
|
208
322
|
const config = await loadConfig();
|
|
323
|
+
const routeTarget = resolveRouteTarget(config, argument("--target"));
|
|
209
324
|
// The mode is uf's to decide, not this file's: `uf dev` resolves `--mode`,
|
|
210
325
|
// the profile `uf env use` wrote and `env.active` before it starts anything,
|
|
211
326
|
// and always passes the answer. The fallback is for a driver started by hand.
|
|
212
327
|
const inline = await viteConfig(config, argument("--mode") ?? "development");
|
|
213
328
|
const server = await createServer({ ...inline, appType: "custom" });
|
|
214
329
|
|
|
215
|
-
// In dev the browser loads the client entry from Vite, not from a manifest;
|
|
216
|
-
// its stylesheets arrive through that module rather than as <link> tags.
|
|
217
|
-
const assets = { scripts: [`/@id/${VIRTUAL.client}`], styles: [], preloads: [] };
|
|
218
|
-
|
|
219
|
-
server.middlewares.use(async (request, response, next) => {
|
|
220
|
-
const url = request.originalUrl ?? request.url ?? "/";
|
|
221
|
-
// Declared out here so the catch below can still settle: a request that
|
|
222
|
-
// failed is a request that happened, and a middleware that logged its
|
|
223
|
-
// arrival is owed its callback either way.
|
|
224
|
-
let lifecycle = null;
|
|
225
|
-
try {
|
|
226
|
-
const entry = await server.ssrLoadModule(VIRTUAL.server);
|
|
227
|
-
const asRequest = await toRequest(request, server.config);
|
|
228
|
-
|
|
229
|
-
// The request begins here and ends when the document has been written,
|
|
230
|
-
// which is what `after()` promises and what `uf preview`, `uf start` and
|
|
231
|
-
// a compiled binary all do too — a middleware that logs a response's
|
|
232
|
-
// status has to mean the same thing in development as in production.
|
|
233
|
-
// `entry.beginRequest` rather than an import: the storage that holds the
|
|
234
|
-
// request belongs to the application's own copy of `@uniflowed/server`.
|
|
235
|
-
// See `internal/serve.js` and ubugeeei-prod/uf#389.
|
|
236
|
-
lifecycle = entry.beginRequest(asRequest);
|
|
237
|
-
const answered = await lifecycle.run(async () => {
|
|
238
|
-
// Middleware first, above everything: it guards a subtree, so it has to
|
|
239
|
-
// run for a page, for a route handler, and for a path under it that
|
|
240
|
-
// matches neither. Running it inside the dispatcher and again inside the
|
|
241
|
-
// renderer would have left `/dashboard/typo` unguarded and run it twice
|
|
242
|
-
// for a path that is both.
|
|
243
|
-
const guarded = await entry.runMiddleware(asRequest);
|
|
244
|
-
if (guarded != null) {
|
|
245
|
-
await send(response, guarded);
|
|
246
|
-
return true;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Route handlers next, and for every method: a handler is the only
|
|
250
|
-
// thing that answers a POST, and it may also answer a GET for a path
|
|
251
|
-
// that has no page.
|
|
252
|
-
const handled = await entry.dispatch(asRequest);
|
|
253
|
-
if (handled != null) {
|
|
254
|
-
await send(response, handled);
|
|
255
|
-
return true;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Only a navigation reaches the renderer. A page cannot answer a POST,
|
|
259
|
-
// and letting one try would turn a missing handler into a rendered page
|
|
260
|
-
// with a 200 rather than a 404.
|
|
261
|
-
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const result = await entry.render(url, assets, {
|
|
266
|
-
// A boundary that threw after the shell went out. `result.error` cannot
|
|
267
|
-
// carry it — the caller already has the result by then — so the
|
|
268
|
-
// terminal hears about it here or not at all.
|
|
269
|
-
onError: (error) => reportRenderError(server, url, error),
|
|
270
|
-
});
|
|
271
|
-
if (result.error != null) reportRenderError(server, url, result.error);
|
|
272
|
-
const html = await server.transformIndexHtml(url, await result.text());
|
|
273
|
-
response.statusCode = result.status ?? 200;
|
|
274
|
-
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
275
|
-
response.end(html);
|
|
276
|
-
return true;
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
if (!answered) {
|
|
280
|
-
// The one path where uf is not the one writing the response: a
|
|
281
|
-
// non-navigation nothing claimed goes back to Vite's chain. The guard
|
|
282
|
-
// has still run and may have deferred work, so `close` — the socket
|
|
283
|
-
// saying the response is over, however it ended — is the only honest
|
|
284
|
-
// signal left that the bytes are out.
|
|
285
|
-
response.once("close", lifecycle.settle);
|
|
286
|
-
next();
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
await lifecycle.settle();
|
|
290
|
-
} catch (error) {
|
|
291
|
-
if (lifecycle != null) await lifecycle.settle();
|
|
292
|
-
// Map the stack back onto the Flow source before it reaches the overlay.
|
|
293
|
-
if (error instanceof Error) server.ssrFixStacktrace(error);
|
|
294
|
-
next(error);
|
|
295
|
-
}
|
|
296
|
-
});
|
|
297
|
-
|
|
298
330
|
await server.listen();
|
|
299
331
|
const urls = server.resolvedUrls ?? { local: [], network: [] };
|
|
300
332
|
emit("listening", {
|
|
301
333
|
local: urls.local,
|
|
302
334
|
network: urls.network,
|
|
303
|
-
routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")
|
|
304
|
-
|
|
305
|
-
),
|
|
335
|
+
routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app"), {
|
|
336
|
+
target: routeTarget,
|
|
337
|
+
}).routes.map((route) => route.path),
|
|
306
338
|
});
|
|
307
339
|
watchSources(server);
|
|
340
|
+
watchEnvFiles(server);
|
|
308
341
|
|
|
309
342
|
const shutdown = async () => {
|
|
310
343
|
await server.close();
|
|
@@ -351,6 +384,45 @@ function watchSources(server) {
|
|
|
351
384
|
}
|
|
352
385
|
}
|
|
353
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
|
+
|
|
354
426
|
/**
|
|
355
427
|
* The preview server: the build, as Vite serves it.
|
|
356
428
|
*
|
|
@@ -370,22 +442,52 @@ function watchSources(server) {
|
|
|
370
442
|
* The static middleware still runs first, and that is deliberate rather than
|
|
371
443
|
* incidental; see `internal/serve.js` for why `uf start` orders itself the
|
|
372
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.
|
|
373
455
|
*/
|
|
374
456
|
async function preview() {
|
|
375
457
|
const { preview: startPreview } = await import("vite");
|
|
376
458
|
const config = await loadConfig();
|
|
459
|
+
const routeTarget = resolveRouteTarget(config, argument("--target"));
|
|
377
460
|
const inline = await viteConfig(config, argument("--mode") ?? "production");
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
+
});
|
|
383
475
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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) => {
|
|
387
489
|
try {
|
|
388
|
-
const asRequest = await toRequest(request,
|
|
490
|
+
const asRequest = await toRequest(request, previewServer.config);
|
|
389
491
|
// The same lifecycle `uf start` gets from `nodeListener`, spelled out
|
|
390
492
|
// because this door is Vite's connect chain rather than a bare
|
|
391
493
|
// `node:http` server: the whole request runs inside it, and it settles
|
|
@@ -398,14 +500,107 @@ async function preview() {
|
|
|
398
500
|
} catch (error) {
|
|
399
501
|
next(error);
|
|
400
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],
|
|
401
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
|
+
}
|
|
402
588
|
|
|
403
589
|
const urls = server.resolvedUrls ?? { local: [], network: [] };
|
|
404
590
|
emit("listening", {
|
|
405
591
|
local: urls.local,
|
|
406
592
|
network: urls.network,
|
|
407
|
-
|
|
408
|
-
|
|
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),
|
|
409
604
|
});
|
|
410
605
|
|
|
411
606
|
const shutdown = async () => {
|
|
@@ -447,7 +642,16 @@ async function start() {
|
|
|
447
642
|
|
|
448
643
|
const host = argument("--host") ?? process.env.HOST ?? "0.0.0.0";
|
|
449
644
|
const port = Number(argument("--port") ?? process.env.PORT ?? 3000);
|
|
450
|
-
const server = createHttpServer(
|
|
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
|
+
);
|
|
451
655
|
|
|
452
656
|
await new Promise((resolve, reject) => {
|
|
453
657
|
server.once("error", reject);
|
|
@@ -482,18 +686,76 @@ async function build() {
|
|
|
482
686
|
const inline = await viteConfig(config, mode);
|
|
483
687
|
const outDir = path.resolve(root, inline.build.outDir);
|
|
484
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
|
+
}
|
|
485
727
|
|
|
486
728
|
// 1. The client: everything the browser loads, with a manifest so the
|
|
487
|
-
// 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.
|
|
488
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
|
+
});
|
|
489
739
|
await vite.build({
|
|
490
740
|
...inline,
|
|
491
741
|
build: {
|
|
492
742
|
...inline.build,
|
|
493
|
-
rollupOptions:
|
|
743
|
+
rollupOptions:
|
|
744
|
+
flight == null ? { input } : { input, preserveEntrySignatures: "exports-only" },
|
|
494
745
|
},
|
|
495
746
|
});
|
|
496
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
|
+
}
|
|
497
759
|
|
|
498
760
|
// 2. The server entry, bundled for the host, outside `dist/` so it is never
|
|
499
761
|
// deployed by accident.
|
|
@@ -513,12 +775,66 @@ async function build() {
|
|
|
513
775
|
},
|
|
514
776
|
},
|
|
515
777
|
});
|
|
516
|
-
|
|
517
|
-
//
|
|
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.
|
|
518
795
|
emit("phase", { name: "prerender" });
|
|
519
796
|
const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
|
|
520
|
-
const assets =
|
|
521
|
-
|
|
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;
|
|
522
838
|
|
|
523
839
|
// A route that throws fails *that route*, and the rest of the build still
|
|
524
840
|
// happens. This loop had no `try`: the first page to throw rejected out of
|
|
@@ -541,28 +857,120 @@ async function build() {
|
|
|
541
857
|
failures.push(url);
|
|
542
858
|
emit("page-failed", { url, ...errorEvent(error) });
|
|
543
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 = {};
|
|
544
894
|
for (const url of pages) {
|
|
545
|
-
let
|
|
895
|
+
let declared;
|
|
896
|
+
const renderedAt = Date.now();
|
|
546
897
|
try {
|
|
547
|
-
|
|
898
|
+
declared = await collectCacheDeclarations(() =>
|
|
899
|
+
server.prerender(url, assets, partialPrerender ? { partial: true } : undefined),
|
|
900
|
+
);
|
|
548
901
|
} catch (error) {
|
|
549
902
|
failed(url, error);
|
|
550
903
|
continue;
|
|
551
904
|
}
|
|
905
|
+
const result = declared.value;
|
|
552
906
|
if (result.error != null) {
|
|
553
907
|
failed(url, result.error);
|
|
554
908
|
continue;
|
|
555
909
|
}
|
|
556
|
-
|
|
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);
|
|
557
930
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
558
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
|
+
}
|
|
559
954
|
emit("page", {
|
|
560
955
|
url,
|
|
561
956
|
file: path.relative(root, file),
|
|
562
957
|
status: result.status,
|
|
563
958
|
bytes: Buffer.byteLength(result.html),
|
|
959
|
+
regenerates,
|
|
564
960
|
});
|
|
565
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
|
+
}
|
|
566
974
|
// One `404.html`, from the boundary at the router root: a static host serves
|
|
567
975
|
// a single error document for the whole site, so the nested boundaries a
|
|
568
976
|
// project declares are the server's and the client's to render, not
|
|
@@ -570,7 +978,7 @@ async function build() {
|
|
|
570
978
|
//
|
|
571
979
|
// The condition is "there is a root boundary", not "there is any boundary",
|
|
572
980
|
// because `/__uf_not_found__` is a path at the root: a project whose only
|
|
573
|
-
//
|
|
981
|
+
// `$not-found.js` is in `app/guide/` would otherwise get a `404.html`
|
|
574
982
|
// rendered from the framework's bare default, which is worse than the file
|
|
575
983
|
// it used to write, which was none.
|
|
576
984
|
//
|
|
@@ -582,7 +990,50 @@ async function build() {
|
|
|
582
990
|
// host would then serve uf's error page to every visitor who mistyped a URL,
|
|
583
991
|
// and nothing between the throw and the deploy would have mentioned it.
|
|
584
992
|
let attempted = pages.length;
|
|
585
|
-
|
|
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 === "/")) {
|
|
586
1037
|
attempted += 1;
|
|
587
1038
|
// `/404` rather than `/__uf_not_found__`: the internal path is how the
|
|
588
1039
|
// router is asked, and the file the reader is looking for is `404.html`.
|
|
@@ -629,10 +1080,161 @@ async function build() {
|
|
|
629
1080
|
process.exit(1);
|
|
630
1081
|
}
|
|
631
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
|
+
|
|
632
1093
|
emit("done", { outDir: path.relative(root, outDir), pages: pages.length });
|
|
633
1094
|
process.exit(0);
|
|
634
1095
|
}
|
|
635
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
|
+
|
|
636
1238
|
/**
|
|
637
1239
|
* Link the whole application into one JavaScript file, for `uf build --compile`.
|
|
638
1240
|
*
|
|
@@ -690,21 +1292,42 @@ async function compile() {
|
|
|
690
1292
|
}
|
|
691
1293
|
const assets = path.resolve(root, assetsArgument);
|
|
692
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
|
+
}
|
|
693
1307
|
|
|
694
1308
|
emit("phase", { name: "standalone" });
|
|
695
1309
|
|
|
696
1310
|
// The entry is written to disk rather than served as another virtual module:
|
|
697
|
-
// it is generated per build (it
|
|
1311
|
+
// it is generated per build (it bakes in this build's document), and a real
|
|
698
1312
|
// file is the version a person can open when a compiled binary misbehaves.
|
|
699
1313
|
const entry = path.join(bundleDir, "entry.js");
|
|
700
1314
|
mkdirSync(bundleDir, { recursive: true });
|
|
701
|
-
|
|
702
|
-
|
|
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"));
|
|
703
1326
|
|
|
704
1327
|
await vite.build({
|
|
705
1328
|
...inline,
|
|
706
1329
|
customLogger: eventLogger("warn"),
|
|
707
|
-
plugins: [...inline.plugins, nativeAddonGuard()],
|
|
1330
|
+
plugins: [...inline.plugins, nativeAddonGuard(), compileAssetsPlugin(assets)],
|
|
708
1331
|
ssr: { ...(inline.ssr ?? {}), noExternal: true },
|
|
709
1332
|
build: {
|
|
710
1333
|
...inline.build,
|
|
@@ -737,46 +1360,182 @@ async function compile() {
|
|
|
737
1360
|
* of what an adapter is, and keeping the differences in one object is what
|
|
738
1361
|
* stops a second one from quietly becoming a second application.
|
|
739
1362
|
*
|
|
740
|
-
* `
|
|
1363
|
+
* `static` is deliberately absent; `uf_config`'s
|
|
741
1364
|
* `DeployAdapter::is_implemented` is the other half of that fact and
|
|
742
|
-
* `docs/app/reference/cli
|
|
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.
|
|
743
1378
|
*/
|
|
744
1379
|
const ADAPTERS = {
|
|
745
1380
|
node: {
|
|
746
|
-
entries: (document) => ({
|
|
747
|
-
handler: handlerEntrySource(
|
|
748
|
-
|
|
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),
|
|
749
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",
|
|
750
1438
|
},
|
|
751
1439
|
// The same two files. What `--adapter container` adds is a `Dockerfile` and
|
|
752
1440
|
// a `.dockerignore`, and both are plain text that `uf` writes beside this
|
|
753
1441
|
// output rather than anything the bundler produces — see `uf_cli`'s
|
|
754
1442
|
// `commands::deploy`.
|
|
755
1443
|
container: {
|
|
756
|
-
entries: (document) => ({
|
|
757
|
-
handler: handlerEntrySource(
|
|
758
|
-
|
|
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),
|
|
759
1455
|
}),
|
|
1456
|
+
regenerationStore: "filesystem",
|
|
760
1457
|
},
|
|
761
1458
|
edge: {
|
|
762
|
-
entries: (document) => ({
|
|
763
|
-
handler: handlerEntrySource(
|
|
764
|
-
|
|
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),
|
|
765
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",
|
|
766
1476
|
// `workerd` first, so React resolves to the build that has
|
|
767
1477
|
// `renderToReadableStream` and no `node:stream`. `browser` and `module`
|
|
768
1478
|
// after it are Vite's own SSR defaults, kept so a dependency with no
|
|
769
1479
|
// worker condition still resolves the way it does for every other target.
|
|
770
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,
|
|
771
1490
|
},
|
|
772
1491
|
serverless: {
|
|
773
|
-
entries: (document) => ({
|
|
774
|
-
handler: handlerEntrySource(
|
|
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
|
+
),
|
|
775
1502
|
lambda: lambdaEntrySource("./handler.js"),
|
|
776
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,
|
|
777
1513
|
},
|
|
778
1514
|
};
|
|
779
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
|
+
|
|
780
1539
|
/**
|
|
781
1540
|
* Link the application into a directory that can be copied, for
|
|
782
1541
|
* `uf build --adapter`.
|
|
@@ -819,6 +1578,16 @@ const ADAPTERS = {
|
|
|
819
1578
|
* copying every file in it is bulk work over the whole build, which belongs in
|
|
820
1579
|
* Rust rather than in the host process — the same division `--compile` makes
|
|
821
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.
|
|
822
1591
|
*/
|
|
823
1592
|
async function deploy() {
|
|
824
1593
|
const vite = await import("vite");
|
|
@@ -831,6 +1600,12 @@ async function deploy() {
|
|
|
831
1600
|
if (adapter == null || workArgument == null || outputArgument == null) {
|
|
832
1601
|
throw new Error("uf: `driver.js deploy` needs --adapter, --work and --output");
|
|
833
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") ?? "[]");
|
|
834
1609
|
// The Rust side has already refused every adapter it has no implementation
|
|
835
1610
|
// for, by name and with the issue that tracks it. This is the second half of
|
|
836
1611
|
// that fact rather than a duplicate of it: the driver may be spawned by a
|
|
@@ -855,14 +1630,97 @@ async function deploy() {
|
|
|
855
1630
|
// file is the version a person can open when a deployed directory
|
|
856
1631
|
// misbehaves.
|
|
857
1632
|
mkdirSync(work, { recursive: true });
|
|
858
|
-
const document =
|
|
859
|
-
|
|
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
|
+
);
|
|
860
1700
|
const input = {};
|
|
861
1701
|
for (const name of Object.keys(entries)) {
|
|
862
1702
|
writeFileSync(path.join(work, `${name}.js`), entries[name]);
|
|
863
1703
|
input[name] = path.join(work, `${name}.js`);
|
|
864
1704
|
}
|
|
865
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
|
+
|
|
866
1724
|
const ssr = { ...(inline.ssr ?? {}), noExternal: true };
|
|
867
1725
|
if (shape.conditions != null) {
|
|
868
1726
|
// Which build of a dependency this target gets, and it is the difference
|
|
@@ -877,7 +1735,29 @@ async function deploy() {
|
|
|
877
1735
|
await vite.build({
|
|
878
1736
|
...inline,
|
|
879
1737
|
customLogger: eventLogger("warn"),
|
|
880
|
-
plugins: [
|
|
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
|
+
},
|
|
881
1761
|
ssr,
|
|
882
1762
|
build: {
|
|
883
1763
|
...inline.build,
|
|
@@ -894,6 +1774,10 @@ async function deploy() {
|
|
|
894
1774
|
emptyOutDir: false,
|
|
895
1775
|
rollupOptions: {
|
|
896
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" } : {}),
|
|
897
1781
|
output: {
|
|
898
1782
|
entryFileNames: "[name].js",
|
|
899
1783
|
// Route modules are lazy `import()`s, so the server bundle splits
|
|
@@ -929,24 +1813,299 @@ async function deploy() {
|
|
|
929
1813
|
* out — `server.js` below does exactly that through
|
|
930
1814
|
* `@uniflowed/server/node`, and a worker hands `settle` to `ctx.waitUntil`.
|
|
931
1815
|
* It comes from the bundle rather than from the host's own
|
|
932
|
-
* `@uniflowed/server`, because the request
|
|
933
|
-
*
|
|
934
|
-
*
|
|
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.
|
|
935
1819
|
*
|
|
936
1820
|
* The document's script and stylesheet URLs are baked in here because they
|
|
937
1821
|
* come from the client manifest, which exists at this moment and not in the
|
|
938
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.
|
|
939
1847
|
*/
|
|
940
|
-
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};`;
|
|
941
1886
|
return `// Generated by \`uf build --adapter\`. Not checked in, not edited.
|
|
942
1887
|
import { createFetchHandler } from "@uniflowed/server/fetch";
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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} });
|
|
946
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
|
+
}
|
|
1981
|
+
|
|
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
|
+
}
|
|
947
2036
|
|
|
948
|
-
|
|
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
|
+
});
|
|
949
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
|
+
};
|
|
950
2109
|
}
|
|
951
2110
|
|
|
952
2111
|
/**
|
|
@@ -958,18 +2117,19 @@ export default { fetch, beginRequest };
|
|
|
958
2117
|
* request answered by `uf start` go through one implementation, not two that
|
|
959
2118
|
* agree today.
|
|
960
2119
|
*/
|
|
961
|
-
function nodeEntrySource(handlerSpecifier) {
|
|
2120
|
+
function nodeEntrySource(handlerSpecifier, schedules) {
|
|
2121
|
+
const cron = scheduleLines("@uniflowed/server/schedule", schedules);
|
|
962
2122
|
return `// Generated by \`uf build --adapter node\`. Not checked in, not edited.
|
|
963
2123
|
import path from "node:path";
|
|
964
2124
|
import { fileURLToPath } from "node:url";
|
|
965
2125
|
|
|
966
2126
|
import { serve } from "@uniflowed/server/node";
|
|
967
|
-
|
|
2127
|
+
${cron.imports}
|
|
968
2128
|
// \`beginRequest\` comes from the handler beside this file rather than from
|
|
969
2129
|
// \`@uniflowed/server/node\` above, because the request has to be established in
|
|
970
2130
|
// the storage the *application* reads, which is the copy bundled into
|
|
971
2131
|
// \`handler.js\`. See ubugeeei-prod/uf#389.
|
|
972
|
-
import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
|
|
2132
|
+
import { beginRequest, fetch, routing } from ${JSON.stringify(handlerSpecifier)};
|
|
973
2133
|
|
|
974
2134
|
// Resolved from this file and not from the working directory: a process
|
|
975
2135
|
// manager, a container entrypoint and a person in a shell each start a server
|
|
@@ -977,18 +2137,160 @@ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
|
|
|
977
2137
|
// assets when it was started from inside itself would be a deployment with a
|
|
978
2138
|
// trap in it.
|
|
979
2139
|
const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
|
|
980
|
-
|
|
2140
|
+
${cron.declarations}
|
|
981
2141
|
// Not \`await serve(...)\` at the top level. uf parses that now
|
|
982
2142
|
// (ubugeeei-prod/uf#204) and this entry is a module, so it would work; \`.catch\`
|
|
983
2143
|
// is the better spelling regardless — a server that cannot take its port should
|
|
984
2144
|
// say so and exit non-zero, rather than die as an unhandled rejection.
|
|
985
|
-
serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
|
|
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)});
|
|
2233
|
+
|
|
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) => {
|
|
986
2242
|
process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
|
|
987
2243
|
process.exit(1);
|
|
988
2244
|
});
|
|
989
2245
|
`;
|
|
990
2246
|
}
|
|
991
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
|
+
|
|
992
2294
|
/**
|
|
993
2295
|
* The source of `worker.js`: the Cloudflare Workers entry around that handler.
|
|
994
2296
|
*
|
|
@@ -1001,13 +2303,47 @@ serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
|
|
|
1001
2303
|
* `nodeEntrySource` gives: the request has to be established in the storage the
|
|
1002
2304
|
* *application* reads. See ubugeeei-prod/uf#389.
|
|
1003
2305
|
*/
|
|
1004
|
-
function workerEntrySource(handlerSpecifier) {
|
|
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]));
|
|
1005
2330
|
return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
|
|
1006
|
-
import { createWorkerFetch } from "@uniflowed/server/edge";
|
|
2331
|
+
import { createWorkerFetch, createWorkerScheduled, installWorkerLogger } from "@uniflowed/server/edge";
|
|
1007
2332
|
|
|
1008
|
-
import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
|
|
2333
|
+
import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
|
|
1009
2334
|
|
|
1010
|
-
|
|
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
|
+
};
|
|
1011
2347
|
`;
|
|
1012
2348
|
}
|
|
1013
2349
|
|
|
@@ -1031,7 +2367,7 @@ import { fileURLToPath } from "node:url";
|
|
|
1031
2367
|
|
|
1032
2368
|
import { createLambdaHandler } from "@uniflowed/server/lambda";
|
|
1033
2369
|
|
|
1034
|
-
import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
|
|
2370
|
+
import { beginRequest, fetch as handle, routing } from ${JSON.stringify(handlerSpecifier)};
|
|
1035
2371
|
|
|
1036
2372
|
// Resolved from this file and not from the working directory: Lambda sets the
|
|
1037
2373
|
// working directory to the task root today and is under no obligation to keep
|
|
@@ -1039,7 +2375,7 @@ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)
|
|
|
1039
2375
|
// deployment with a trap in it.
|
|
1040
2376
|
const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
|
|
1041
2377
|
|
|
1042
|
-
export const handler = createLambdaHandler({ handle, beginRequest, staticDir });
|
|
2378
|
+
export const handler = createLambdaHandler({ handle, beginRequest, staticDir, routing });
|
|
1043
2379
|
`;
|
|
1044
2380
|
}
|
|
1045
2381
|
|
|
@@ -1050,8 +2386,14 @@ export const handler = createLambdaHandler({ handle, beginRequest, staticDir });
|
|
|
1050
2386
|
* bytes of `dist/`. The document's script and stylesheet URLs are baked in
|
|
1051
2387
|
* here because they come from the client manifest, which exists at this moment
|
|
1052
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.
|
|
1053
2395
|
*/
|
|
1054
|
-
function entrySource(
|
|
2396
|
+
function entrySource(assetsFile, document) {
|
|
1055
2397
|
// Not `await serve(...)` at the top level. uf parses that now
|
|
1056
2398
|
// (ubugeeei-prod/uf#204) and this entry is a module, so it would work;
|
|
1057
2399
|
// `.catch` is the better spelling regardless: a binary that cannot take its
|
|
@@ -1059,7 +2401,11 @@ function entrySource(assetsSpecifier, document) {
|
|
|
1059
2401
|
// unhandled rejection.
|
|
1060
2402
|
return `// Generated by \`uf build --compile\`. Not checked in, not edited.
|
|
1061
2403
|
import { serve } from "@uniflowed/server/standalone";
|
|
1062
|
-
|
|
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)};
|
|
1063
2409
|
import * as app from ${JSON.stringify(VIRTUAL.server)};
|
|
1064
2410
|
|
|
1065
2411
|
serve({ app, assets, document: ${JSON.stringify(document)} }).catch((error) => {
|
|
@@ -1105,6 +2451,38 @@ function nativeAddonGuard() {
|
|
|
1105
2451
|
};
|
|
1106
2452
|
}
|
|
1107
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
|
+
|
|
1108
2486
|
/** `word`, pluralised for `count`. */
|
|
1109
2487
|
function plural(count, word) {
|
|
1110
2488
|
return count === 1 ? word : `${word}s`;
|
|
@@ -1116,6 +2494,138 @@ async function printConfig() {
|
|
|
1116
2494
|
process.exit(0);
|
|
1117
2495
|
}
|
|
1118
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
|
+
|
|
1119
2629
|
function readManifest(outDir) {
|
|
1120
2630
|
const file = path.join(outDir, ".vite", "manifest.json");
|
|
1121
2631
|
if (!existsSync(file)) throw new Error(`uf: the client build wrote no manifest at ${file}`);
|
|
@@ -1123,24 +2633,151 @@ function readManifest(outDir) {
|
|
|
1123
2633
|
}
|
|
1124
2634
|
|
|
1125
2635
|
/**
|
|
1126
|
-
*
|
|
1127
|
-
*
|
|
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
|
|
1128
2672
|
*/
|
|
1129
|
-
async function
|
|
2673
|
+
async function renderingPlan(server, prerender) {
|
|
1130
2674
|
const urls = [];
|
|
1131
|
-
|
|
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
|
+
}
|
|
1132
2731
|
if (route.params.length === 0) {
|
|
1133
2732
|
urls.push(route.path);
|
|
1134
2733
|
continue;
|
|
1135
2734
|
}
|
|
1136
|
-
const module = await route.page();
|
|
1137
2735
|
const generate = module.generateStaticParams;
|
|
1138
|
-
if (typeof generate !== "function")
|
|
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
|
+
}
|
|
1139
2743
|
for (const params of await generate()) {
|
|
1140
2744
|
urls.push(fillParams(route.path, params));
|
|
1141
2745
|
}
|
|
1142
2746
|
}
|
|
1143
|
-
|
|
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 };
|
|
1144
2781
|
}
|
|
1145
2782
|
|
|
1146
2783
|
function fillParams(routePath, params) {
|
|
@@ -1160,9 +2797,36 @@ function fillParams(routePath, params) {
|
|
|
1160
2797
|
.join("/");
|
|
1161
2798
|
}
|
|
1162
2799
|
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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`)
|
|
1167
2813
|
: path.join(outDir, pathname, "index.html");
|
|
1168
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
|
+
}
|