@uniflowed/vite 0.0.0-alpha.1 → 0.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/driver.js CHANGED
@@ -1,12 +1,24 @@
1
+ // @noflow
2
+ //
1
3
  // Plain JavaScript: the host runs this file directly.
2
4
  //
3
- // The driver `uf dev`, `uf build` and `uf preview` spawn.
5
+ // The driver `uf dev`, `uf build`, `uf build --compile`, `uf preview` and
6
+ // `uf start` spawn.
4
7
  //
5
- // <host> driver.js dev --root <dir> [--host <h>] [--port <n>] [--strict-port]
6
- // <host> driver.js build --root <dir> [--out-dir <dir>] [--mode <m>]
7
- // <host> driver.js preview --root <dir> [--host <h>] [--port <n>]
8
+ // <host> driver.js dev --root <dir> [--mode <m>] [--host <h>] [--port <n>] [--strict-port]
9
+ // <host> driver.js build --root <dir> [--mode <m>] [--out-dir <dir>]
10
+ // <host> driver.js compile --root <dir> [--mode <m>] [--out-dir <dir>] --assets <file> --bundle <dir>
11
+ // <host> driver.js deploy --root <dir> [--mode <m>] [--out-dir <dir>] --adapter <name> --work <dir> --output <dir>
12
+ // <host> driver.js preview --root <dir> [--mode <m>] [--out-dir <dir>] [--host <h>] [--port <n>]
13
+ // <host> driver.js start --root <dir> [--out-dir <dir>] [--host <h>] [--port <n>]
8
14
  // <host> driver.js config --root <dir>
9
15
  //
16
+ // `--mode` is what `uf` resolved from `--mode`, `.uniflowed/profile` and
17
+ // `env.active`; it is Vite's mode, so it is `import.meta.env.MODE`. The `.env`
18
+ // files it selected have already been read, by `uf`, into this process's
19
+ // environment — see `viteConfig` below and `crates/uf_config/src/env_files.rs`.
20
+ // `start` has no Vite in it and therefore no mode.
21
+ //
10
22
  // `uf` in Rust owns the terminal; this process owns Vite. They talk over
11
23
  // stdout, one JSON event per line (see `./internal/events.js`), and the driver
12
24
  // exits when its stdin closes so it cannot outlive the command that started
@@ -16,14 +28,24 @@
16
28
  // Rust side reads a config that may hold functions and plugin instances: the
17
29
  // one host that can evaluate the file evaluates it.
18
30
 
31
+ import { createServer as createHttpServer } from "node:http";
19
32
  import { register } from "node:module";
20
33
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
34
  import path from "node:path";
22
35
  import { pathToFileURL } from "node:url";
23
36
 
24
- import { emit, errorEvent, eventLogger } from "./internal/events.js";
37
+ import { emit, errorEvent, eventLogger, reportRenderError } from "./internal/events.js";
25
38
  import { loadUfConfig, projectConfig } from "./internal/config.js";
39
+ import { send, toRequest } from "./internal/http.js";
40
+ import { withProjectConfig } from "./merge.js";
26
41
  import { VIRTUAL, scanRoutes } from "./internal/routes.js";
42
+ import {
43
+ assetsFromManifest,
44
+ createServeHandler,
45
+ loadBuild,
46
+ nodeListener,
47
+ withRequest,
48
+ } from "./internal/serve.js";
27
49
 
28
50
  function argument(name) {
29
51
  const at = process.argv.indexOf(name);
@@ -41,17 +63,22 @@ const root = path.resolve(argument("--root") ?? process.cwd());
41
63
  process.env.UF_PROJECT_ROOT = root;
42
64
 
43
65
  // The config imports `@uniflowed/config`, which is Flow. Node needs the loader
44
- // hooks for that; Bun is started with `--preload ./bun-preload.js` instead
45
- // and has no `register`.
66
+ // hooks for that; Bun is started with `--preload` on the same package's
67
+ // preload instead, and has no `register`.
68
+ //
69
+ // The hooks live in `@uniflowed/host` rather than here: they are how Flow runs
70
+ // on a Capability JS Host, and nothing in them is Vite's. `uf test` reaches for
71
+ // the same package, which is what stopped a test run from depending on a
72
+ // bundler it never loads.
46
73
  if (typeof Bun === "undefined" && typeof Deno === "undefined") {
47
- register("./internal/node-hooks.js", import.meta.url, { data: { root } });
74
+ register("@uniflowed/host/internal/node-hooks.js", import.meta.url, { data: { root } });
48
75
  }
49
76
 
50
77
  process.stdin.on("end", () => process.exit(0));
51
78
  process.stdin.on("error", () => process.exit(0));
52
79
  process.stdin.resume();
53
80
 
54
- const commands = { dev, build, preview, config: printConfig };
81
+ const commands = { dev, build, compile, deploy, preview, start, config: printConfig };
55
82
  const run = commands[command];
56
83
  if (run == null) {
57
84
  emit("error", { message: `unknown driver command ${JSON.stringify(command)}` });
@@ -78,16 +105,39 @@ async function viteConfig(config, mode) {
78
105
  const userPlugins = Array.isArray(config.plugins) ? config.plugins : [];
79
106
  const host = argument("--host") ?? dev.host ?? "127.0.0.1";
80
107
  const port = Number(argument("--port") ?? dev.port ?? 5173);
81
- const allowedHosts = Array.isArray(dev.allowedHosts) && dev.allowedHosts.length > 0 ? dev.allowedHosts : undefined;
108
+ const allowedHosts =
109
+ Array.isArray(dev.allowedHosts) && dev.allowedHosts.length > 0 ? dev.allowedHosts : undefined;
82
110
 
83
- return {
111
+ // What uf generates from the semantics it owns: where the project is, which
112
+ // plugins make Flow compile, and the few settings uf enforces rather than
113
+ // merely passes on — `allowedHosts` gates binding a routable address, and
114
+ // `manifest` is how the prerender finds its assets.
115
+ //
116
+ // `envDir: false` turns off Vite's *file* loading, and only that. uf reads
117
+ // the `.env` cascade itself, in Rust, before this process starts — one
118
+ // parser, one precedence, one answer for `uf dev`, `uf build`, `uf start`,
119
+ // `uf test` and `uf run` — and sets what it read in this process's
120
+ // environment. Vite's `loadEnv` still runs with `envDir: false` and still
121
+ // picks every `envPrefix`-matching name out of `process.env`, so the client
122
+ // half is Vite's own, unchanged: the prefixed subset becomes
123
+ // `import.meta.env.*` in the browser bundle and nothing else does. See
124
+ // `crates/uf_config/src/env_files.rs`, `docs/app/guide/env` and #259.
125
+ //
126
+ // A project that would rather Vite read the files can still say
127
+ // `vite: { envDir: "." }` — its own configuration is merged over this one —
128
+ // and then both parsers run, uf's answer still standing. `loadEnv` takes the
129
+ // prefixed names out of the files it read and then copies every prefixed name
130
+ // in `process.env` over the top, and uf put its own there before this process
131
+ // started; so the second parser adds prefixed names uf did not set and
132
+ // changes none that it did.
133
+ const generated = {
84
134
  root,
85
135
  configFile: false,
86
- envFile: false,
136
+ envDir: false,
87
137
  mode,
88
138
  clearScreen: false,
89
139
  customLogger: eventLogger(argument("--log-level") ?? "info"),
90
- plugins: [uniflowed({ root, config }), ...userPlugins],
140
+ plugins: [uniflowed({ root, config })],
91
141
  server: {
92
142
  host,
93
143
  port,
@@ -98,7 +148,17 @@ async function viteConfig(config, mode) {
98
148
  deny: dev.fs?.deny,
99
149
  },
100
150
  },
101
- preview: { host, port },
151
+ // Not `server`, and not `dev.port` either. Vite's own default for a
152
+ // preview is 4173 rather than 5173, and the reason is the case this
153
+ // command exists for: somebody comparing a build against the dev server
154
+ // they left running. Taking `dev.port` would have made the two collide,
155
+ // and Vite would have moved the preview to the next free port and served
156
+ // it somewhere nobody was looking.
157
+ preview: {
158
+ host: argument("--host") ?? "127.0.0.1",
159
+ port: Number(argument("--port") ?? 4173),
160
+ strictPort: flag("--strict-port"),
161
+ },
102
162
  build: {
103
163
  outDir: argument("--out-dir") ?? build.outDir ?? "dist",
104
164
  sourcemap: build.sourcemap ?? true,
@@ -106,6 +166,14 @@ async function viteConfig(config, mode) {
106
166
  emptyOutDir: true,
107
167
  },
108
168
  };
169
+
170
+ // Then the project's own Vite configuration, merged over it. uf does not
171
+ // read this and does not need to: an option added to Vite tomorrow works in
172
+ // a uf project tomorrow, rather than after a uf release that names it.
173
+ return withProjectConfig(generated, {
174
+ ...(config.vite ?? {}),
175
+ plugins: [...(config.vite?.plugins ?? []), ...userPlugins],
176
+ });
109
177
  }
110
178
 
111
179
  /**
@@ -119,18 +187,29 @@ async function viteConfig(config, mode) {
119
187
  *
120
188
  * 1. load the server entry through `ssrLoadModule`, so it is transformed the
121
189
  * same way the browser's copy is and picks up edits without a restart;
122
- * 2. render the URL, pointing the client script at the dev entry rather than
190
+ * 2. run the middleware guarding this path, which may answer instead;
191
+ * 3. render the URL, pointing the client script at the dev entry rather than
123
192
  * at a built asset;
124
- * 3. hand the HTML to `transformIndexHtml`, which is what injects the HMR
193
+ * 4. hand the HTML to `transformIndexHtml`, which is what injects the HMR
125
194
  * client and lets any Vite plugin see the document.
126
195
  *
196
+ * Step 4 is why `uf dev` collects the stream instead of piping it: Vite's HTML
197
+ * hook takes a whole document and any plugin may rewrite any part of it, so
198
+ * there is no first byte to send until it has run. `uf start` and `uf preview`
199
+ * have no such hook and stream — see `internal/serve.js` — and it is worth
200
+ * being clear that this is a property of the development server rather than of
201
+ * the renderer. Streaming through the transform is ubugeeei-prod/uf#374.
202
+ *
127
203
  * Anything Vite already serves — a module, a public file — never reaches this,
128
204
  * because the middleware runs after Vite's own.
129
205
  */
130
206
  async function dev() {
131
207
  const { createServer } = await import("vite");
132
208
  const config = await loadConfig();
133
- const inline = await viteConfig(config, "development");
209
+ // The mode is uf's to decide, not this file's: `uf dev` resolves `--mode`,
210
+ // the profile `uf env use` wrote and `env.active` before it starts anything,
211
+ // and always passes the answer. The fallback is for a driver started by hand.
212
+ const inline = await viteConfig(config, argument("--mode") ?? "development");
134
213
  const server = await createServer({ ...inline, appType: "custom" });
135
214
 
136
215
  // In dev the browser loads the client entry from Vite, not from a manifest;
@@ -138,19 +217,78 @@ async function dev() {
138
217
  const assets = { scripts: [`/@id/${VIRTUAL.client}`], styles: [], preloads: [] };
139
218
 
140
219
  server.middlewares.use(async (request, response, next) => {
141
- if (request.method !== "GET" && request.method !== "HEAD") {
142
- next();
143
- return;
144
- }
145
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;
146
225
  try {
147
226
  const entry = await server.ssrLoadModule(VIRTUAL.server);
148
- const result = await entry.render(url, assets);
149
- const html = await server.transformIndexHtml(url, result.html);
150
- response.statusCode = result.status ?? 200;
151
- response.setHeader("content-type", "text/html; charset=utf-8");
152
- response.end(html);
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();
153
290
  } catch (error) {
291
+ if (lifecycle != null) await lifecycle.settle();
154
292
  // Map the stack back onto the Flow source before it reaches the overlay.
155
293
  if (error instanceof Error) server.ssrFixStacktrace(error);
156
294
  next(error);
@@ -162,8 +300,11 @@ async function dev() {
162
300
  emit("listening", {
163
301
  local: urls.local,
164
302
  network: urls.network,
165
- routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map((route) => route.path),
303
+ routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map(
304
+ (route) => route.path,
305
+ ),
166
306
  });
307
+ watchSources(server);
167
308
 
168
309
  const shutdown = async () => {
169
310
  await server.close();
@@ -173,12 +314,100 @@ async function dev() {
173
314
  process.on("SIGTERM", shutdown);
174
315
  }
175
316
 
317
+ /**
318
+ * Tell the Rust side when a module under the project root changed.
319
+ *
320
+ * `uf dev` answers questions Vite does not: whether a module is a Server
321
+ * Component, and whether a Server Component reaches for something that only
322
+ * exists in a browser. Those are whole-project answers, so they go stale on
323
+ * any edit and there is no module to recompute them *for* — which is why this
324
+ * event carries no path. What it carries is "ask again".
325
+ *
326
+ * Vite's watcher is the only watcher. A second one over the same tree, in
327
+ * Rust, would be a second answer to "did this file change", and two watchers
328
+ * disagree exactly when an editor writes through a temporary file — which is
329
+ * every editor, and which is not a thing anybody tests.
330
+ *
331
+ * Debounced, because a `git checkout` is one intention and several hundred
332
+ * `change` events, and unrefed so a pending timer cannot keep this process
333
+ * alive after the server has closed.
334
+ */
335
+ function watchSources(server) {
336
+ let timer = null;
337
+ const changed = () => {
338
+ if (timer != null) clearTimeout(timer);
339
+ timer = setTimeout(() => {
340
+ timer = null;
341
+ emit("source-changed");
342
+ }, 50);
343
+ timer.unref?.();
344
+ };
345
+ const isSource = (file) =>
346
+ (file.endsWith(".js") || file.endsWith(".jsx")) && !file.includes("node_modules");
347
+ for (const event of ["add", "change", "unlink"]) {
348
+ server.watcher.on(event, (file) => {
349
+ if (isSource(file)) changed();
350
+ });
351
+ }
352
+ }
353
+
354
+ /**
355
+ * The preview server: the build, as Vite serves it.
356
+ *
357
+ * Vite's `preview()` is a static file server, and a uf build is not only
358
+ * static files — a route handler answers a `POST` and a route with parameters
359
+ * and no `generateStaticParams` was never prerendered. On its own it would
360
+ * therefore 404 every request the interesting half of an application exists to
361
+ * answer, which is worse than having no preview at all, because a preview is
362
+ * checked and believed.
363
+ *
364
+ * So the application handler is mounted behind it, and `appType: "custom"` is
365
+ * what makes that reachable: with Vite's default `spa` it inserts an
366
+ * index.html fallback and a 404 middleware of its own, so every unmatched path
367
+ * would have been answered with the home page — a 200 for a path that does not
368
+ * exist — before anything of uf's ran.
369
+ *
370
+ * The static middleware still runs first, and that is deliberate rather than
371
+ * incidental; see `internal/serve.js` for why `uf start` orders itself the
372
+ * same way.
373
+ */
176
374
  async function preview() {
177
375
  const { preview: startPreview } = await import("vite");
178
376
  const config = await loadConfig();
179
- const server = await startPreview(await viteConfig(config, "production"));
377
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
378
+ const build = await loadBuild({
379
+ root,
380
+ outDir: inline.build.outDir,
381
+ serverDir: path.join(".uf", "build", "server"),
382
+ });
383
+
384
+ const server = await startPreview({ ...inline, appType: "custom" });
385
+ const handle = createServeHandler({ ...build, cache: config.app?.rendering?.cache });
386
+ server.middlewares.use(async (request, response, next) => {
387
+ try {
388
+ const asRequest = await toRequest(request, server.config);
389
+ // The same lifecycle `uf start` gets from `nodeListener`, spelled out
390
+ // because this door is Vite's connect chain rather than a bare
391
+ // `node:http` server: the whole request runs inside it, and it settles
392
+ // once `send` has returned. A preview whose `after()` fired at a
393
+ // different moment from the production server's would be a preview that
394
+ // is checked and believed and wrong.
395
+ await withRequest(build.entry, asRequest, async () => {
396
+ await send(response, await handle(asRequest));
397
+ });
398
+ } catch (error) {
399
+ next(error);
400
+ }
401
+ });
402
+
180
403
  const urls = server.resolvedUrls ?? { local: [], network: [] };
181
- emit("listening", { local: urls.local, network: urls.network, routes: [] });
404
+ emit("listening", {
405
+ local: urls.local,
406
+ network: urls.network,
407
+ routes: build.entry.routes.map((route) => route.path),
408
+ handlers: build.entry.handlers.map((handler) => handler.path),
409
+ });
410
+
182
411
  const shutdown = async () => {
183
412
  await server.close();
184
413
  process.exit(0);
@@ -187,6 +416,70 @@ async function preview() {
187
416
  process.on("SIGTERM", shutdown);
188
417
  }
189
418
 
419
+ /**
420
+ * The production server: the build, with no bundler in the process.
421
+ *
422
+ * `preview` proves the build works through Vite. This is the thing that is
423
+ * actually deployed, and it imports `vite` nowhere — a host running a built
424
+ * application should not need the bundler that produced it, and the moment it
425
+ * does, "portable output" is a claim rather than a property.
426
+ *
427
+ * There is no `--strict-port` here and there is nothing to add: this server
428
+ * binds the port it was given or fails, where Vite's would have quietly moved
429
+ * to the next free one. `PORT` and `HOST` are read from the environment
430
+ * because that is how every process manager and container platform says which
431
+ * socket to take, and a production server that could only be told on the
432
+ * command line would need a wrapper script everywhere it ran.
433
+ *
434
+ * It is not the only thing that can be deployed. `uf build --compile` puts
435
+ * this same application behind this same resolution order inside a single
436
+ * executable, for a host that should not have to have a JavaScript runtime
437
+ * installed at all; see [`compile`] for what that costs and what it shares.
438
+ */
439
+ async function start() {
440
+ const config = await loadConfig();
441
+ const outDir = argument("--out-dir") ?? config.build?.outDir ?? "dist";
442
+ const build = await loadBuild({
443
+ root,
444
+ outDir,
445
+ serverDir: path.join(".uf", "build", "server"),
446
+ });
447
+
448
+ const host = argument("--host") ?? process.env.HOST ?? "0.0.0.0";
449
+ const port = Number(argument("--port") ?? process.env.PORT ?? 3000);
450
+ const server = createHttpServer(
451
+ nodeListener(
452
+ createServeHandler({ ...build, cache: config.app?.rendering?.cache }),
453
+ build.entry,
454
+ ),
455
+ );
456
+
457
+ await new Promise((resolve, reject) => {
458
+ server.once("error", reject);
459
+ server.listen(port, host, resolve);
460
+ });
461
+
462
+ const bound = server.address();
463
+ // `0.0.0.0` is not a URL anybody can open, so the loopback spelling is what
464
+ // is printed as `local` and the bound address is reported as the network
465
+ // one — the same split `uf dev` prints, and for the same reason: one of the
466
+ // two is a link and the other is a fact about the socket.
467
+ const shown = `${bound.address}:${bound.port}`;
468
+ const wildcard = bound.address === "0.0.0.0" || bound.address === "::";
469
+ emit("listening", {
470
+ local: [`http://${wildcard ? `localhost:${bound.port}` : shown}/`],
471
+ network: wildcard ? [`http://${shown}/`] : [],
472
+ routes: build.entry.routes.map((route) => route.path),
473
+ handlers: build.entry.handlers.map((handler) => handler.path),
474
+ });
475
+
476
+ const shutdown = () => {
477
+ server.close(() => process.exit(0));
478
+ };
479
+ process.on("SIGINT", shutdown);
480
+ process.on("SIGTERM", shutdown);
481
+ }
482
+
190
483
  async function build() {
191
484
  const vite = await import("vite");
192
485
  const config = await loadConfig();
@@ -231,94 +524,635 @@ async function build() {
231
524
  const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
232
525
  const assets = assetsFromManifest(manifest);
233
526
  const pages = await staticPaths(server.routes);
527
+
528
+ // A route that throws fails *that route*, and the rest of the build still
529
+ // happens. This loop had no `try`: the first page to throw rejected out of
530
+ // `build()`, `run().catch` reported the exception, and which URL was being
531
+ // rendered was a local variable nobody could see. One broken page was the
532
+ // whole build, and the message named a stack rather than a route.
533
+ //
534
+ // The render itself no longer throws for an ordinary component failure —
535
+ // `createRenderer` renders the error boundary and reports the exception on
536
+ // the result — so both are checked here. Neither writes a file: an error
537
+ // page written into `dist/` is a build that shipped its own failure.
538
+ //
539
+ // `prerender`, not `render`: a build wants the document React produces once
540
+ // every boundary has resolved, with the content where the fallback was. The
541
+ // streaming renderer would write a file whose slow parts are `<template>`
542
+ // elements waiting for a script — correct in a browser, blank to a crawler
543
+ // and to `curl`, which is most of what a static file is for.
544
+ const failures = [];
545
+ const failed = (url, error) => {
546
+ failures.push(url);
547
+ emit("page-failed", { url, ...errorEvent(error) });
548
+ };
234
549
  for (const url of pages) {
235
- const result = await server.render(url, assets);
550
+ let result;
551
+ try {
552
+ result = await server.prerender(url, assets);
553
+ } catch (error) {
554
+ failed(url, error);
555
+ continue;
556
+ }
557
+ if (result.error != null) {
558
+ failed(url, result.error);
559
+ continue;
560
+ }
236
561
  const file = htmlPathFor(outDir, url);
237
562
  mkdirSync(path.dirname(file), { recursive: true });
238
563
  writeFileSync(file, result.html);
239
- emit("page", { url, file: path.relative(root, file), status: result.status, bytes: Buffer.byteLength(result.html) });
564
+ emit("page", {
565
+ url,
566
+ file: path.relative(root, file),
567
+ status: result.status,
568
+ bytes: Buffer.byteLength(result.html),
569
+ });
240
570
  }
241
- if (server.notFound != null) {
242
- const result = await server.render("/__uf_not_found__", assets);
243
- const file = path.join(outDir, "404.html");
244
- writeFileSync(file, result.html);
245
- emit("page", { url: "/404", file: path.relative(root, file), status: 404, bytes: Buffer.byteLength(result.html) });
571
+ // One `404.html`, from the boundary at the router root: a static host serves
572
+ // a single error document for the whole site, so the nested boundaries a
573
+ // project declares are the server's and the client's to render, not
574
+ // something this loop can write a file for.
575
+ //
576
+ // The condition is "there is a root boundary", not "there is any boundary",
577
+ // because `/__uf_not_found__` is a path at the root: a project whose only
578
+ // `_uf.not-found.js` is in `app/guide/` would otherwise get a `404.html`
579
+ // rendered from the framework's bare default, which is worse than the file
580
+ // it used to write, which was none.
581
+ //
582
+ // Through the same two checks as the loop, and for the same reason. A
583
+ // not-found boundary is a component like any other: it can throw, and when it
584
+ // does `prerender` answers with the *error* page's HTML and a non-null
585
+ // `error` rather than rejecting. Writing that HTML and emitting `page` was a
586
+ // build publishing its own failure as `404.html` and exiting 0 — the static
587
+ // host would then serve uf's error page to every visitor who mistyped a URL,
588
+ // and nothing between the throw and the deploy would have mentioned it.
589
+ let attempted = pages.length;
590
+ if (server.notFound.some((boundary) => boundary.path === "/")) {
591
+ attempted += 1;
592
+ // `/404` rather than `/__uf_not_found__`: the internal path is how the
593
+ // router is asked, and the file the reader is looking for is `404.html`.
594
+ let result;
595
+ try {
596
+ result = await server.prerender("/__uf_not_found__", assets);
597
+ } catch (error) {
598
+ failed("/404", error);
599
+ result = null;
600
+ }
601
+ if (result != null && result.error != null) {
602
+ failed("/404", result.error);
603
+ result = null;
604
+ }
605
+ if (result != null) {
606
+ const file = path.join(outDir, "404.html");
607
+ writeFileSync(file, result.html);
608
+ emit("page", {
609
+ url: "/404",
610
+ file: path.relative(root, file),
611
+ status: 404,
612
+ bytes: Buffer.byteLength(result.html),
613
+ });
614
+ }
615
+ }
616
+
617
+ if (failures.length > 0) {
618
+ // Emitted rather than thrown, so the message is the routes and not the
619
+ // last exception: each one has already been reported with its own frame.
620
+ //
621
+ // The first line stands on its own, because it is the one `uf build` uses
622
+ // as the headline and the one a CI log's last line will be. It read
623
+ // `... failed:` with the routes below it, and the headline was then a
624
+ // sentence ending in a colon and nothing.
625
+ // `attempted`, not `pages.length`: the root 404 is prerendered too, and
626
+ // counting a failure of it against a total that excludes it produced
627
+ // "1 of 12" for a build that rendered thirteen things.
628
+ emit("error", {
629
+ message: `${failures.length} of ${attempted} prerendered ${plural(
630
+ attempted,
631
+ "route",
632
+ )} failed\n${failures.map((url) => ` ${url}`).join("\n")}`,
633
+ });
634
+ process.exit(1);
246
635
  }
247
636
 
248
637
  emit("done", { outDir: path.relative(root, outDir), pages: pages.length });
249
638
  process.exit(0);
250
639
  }
251
640
 
252
- async function printConfig() {
641
+ /**
642
+ * Link the whole application into one JavaScript file, for `uf build --compile`.
643
+ *
644
+ * This runs after `build`, on a `dist/` that is already complete, and produces
645
+ * the module a runtime is wrapped around. It differs from the server build in
646
+ * `build()` in exactly three ways, and each of them is what "one file" means:
647
+ *
648
+ * * `ssr.noExternal: true` — the server build leaves `react`, `react-dom`
649
+ * and every other dependency as bare imports, because the host it runs on
650
+ * has `node_modules` beside it. A binary does not, so they come in.
651
+ * * `codeSplitting: false` — a route is a lazy `import()` so that the browser
652
+ * can fetch one chunk per page. On the server that split buys nothing and
653
+ * costs everything: chunks are separate files, and separate files are the
654
+ * one thing this output may not have.
655
+ * * the native-addon guard below, which turns "cannot resolve" into a
656
+ * sentence naming the package that cannot be compiled.
657
+ *
658
+ * The embedded copy of `dist/` is *not* built here. `uf` writes it (see
659
+ * `uf_bundle::embed`) and passes its path in `--assets`, because walking an
660
+ * output directory and encoding every file in it is bulk work over the whole
661
+ * build, which belongs in Rust rather than in the host process.
662
+ *
663
+ * # Three front doors onto one build, and why they are not one function
664
+ *
665
+ * `preview` and `start` above serve `dist/` from disk, and they share a single
666
+ * handler in `./internal/serve.js` for the express purpose of being unable to
667
+ * answer differently. What is linked here is a third front door onto the same
668
+ * build, and it deliberately does *not* import that module. Two reasons, and
669
+ * either would be enough: `internal/serve.js` answers by opening files under
670
+ * `dist/`, and a compiled binary has no `dist/` to open — it carries the bytes
671
+ * — so the half that reads a request would arrive with a half that cannot run;
672
+ * and it lives in `@uniflowed/vite`, so linking it would put the package named
673
+ * after the bundler inside the artefact a deployment runs, which is the one
674
+ * thing `start` exists to avoid.
675
+ *
676
+ * What a binary uses instead is `@uniflowed/server/standalone`, and the thing
677
+ * that is shared between the three is not code but the *answer*: an asset or a
678
+ * prerendered document first, then a route handler, then a render for whatever
679
+ * is left. That order is not a preference. `preview` cannot deviate from it —
680
+ * Vite's preview server runs its own file middleware before anything uf mounts
681
+ * behind it — so `start` matches Vite, and the binary matches `start`. A
682
+ * compiled application that resolved a collision the other way would be the
683
+ * trap `preview` exists to prevent, one deployment further along, and the only
684
+ * copy nobody can check with `uf preview` first.
685
+ */
686
+ async function compile() {
687
+ const vite = await import("vite");
253
688
  const config = await loadConfig();
254
- emit("config", { config: projectConfig(config) });
689
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
690
+ const outDir = path.resolve(root, inline.build.outDir);
691
+ const assetsArgument = argument("--assets");
692
+ const bundleArgument = argument("--bundle");
693
+ if (assetsArgument == null || bundleArgument == null) {
694
+ throw new Error("uf: `driver.js compile` needs both --assets and --bundle");
695
+ }
696
+ const assets = path.resolve(root, assetsArgument);
697
+ const bundleDir = path.resolve(root, bundleArgument);
698
+
699
+ emit("phase", { name: "standalone" });
700
+
701
+ // The entry is written to disk rather than served as another virtual module:
702
+ // it is generated per build (it names this build's asset file), and a real
703
+ // file is the version a person can open when a compiled binary misbehaves.
704
+ const entry = path.join(bundleDir, "entry.js");
705
+ mkdirSync(bundleDir, { recursive: true });
706
+ const specifier = `./${path.relative(bundleDir, assets)}`;
707
+ writeFileSync(entry, entrySource(specifier, assetsFromManifest(readManifest(outDir))));
708
+
709
+ await vite.build({
710
+ ...inline,
711
+ customLogger: eventLogger("warn"),
712
+ plugins: [...inline.plugins, nativeAddonGuard()],
713
+ ssr: { ...(inline.ssr ?? {}), noExternal: true },
714
+ build: {
715
+ ...inline.build,
716
+ manifest: false,
717
+ // The map would describe this intermediate bundle rather than the
718
+ // binary, and nothing downstream reads it. Turning it off is a smaller
719
+ // `.uf/` and one less file to explain.
720
+ sourcemap: false,
721
+ ssr: true,
722
+ outDir: bundleDir,
723
+ emptyOutDir: false,
724
+ rollupOptions: {
725
+ input: { server: entry },
726
+ output: { entryFileNames: "server.js", format: "es", codeSplitting: false },
727
+ },
728
+ },
729
+ });
730
+
731
+ emit("done", { outDir: path.relative(root, bundleDir), pages: 0 });
255
732
  process.exit(0);
256
733
  }
257
734
 
258
- function readManifest(outDir) {
259
- const file = path.join(outDir, ".vite", "manifest.json");
260
- if (!existsSync(file)) throw new Error(`uf: the client build wrote no manifest at ${file}`);
261
- return JSON.parse(readFileSync(file, "utf8"));
735
+ /**
736
+ * What each adapter links, and what it links it against.
737
+ *
738
+ * Every entry in this table produces the same `handler.js` — the application
739
+ * as `Request` → `Response`, from `@uniflowed/server/fetch` — and differs only
740
+ * in the file wrapped around it and, for a target whose dependencies have a
741
+ * different build, in the export conditions that pick one. That is the whole
742
+ * of what an adapter is, and keeping the differences in one object is what
743
+ * stops a second one from quietly becoming a second application.
744
+ *
745
+ * `bun`, `deno` and `static` are deliberately absent; `uf_config`'s
746
+ * `DeployAdapter::is_implemented` is the other half of that fact and
747
+ * `docs/app/reference/cli/_uf.page.mdx` says why for each of them.
748
+ */
749
+ const ADAPTERS = {
750
+ node: {
751
+ entries: (document, cache) => ({
752
+ handler: handlerEntrySource(document, cache),
753
+ server: nodeEntrySource("./handler.js"),
754
+ }),
755
+ },
756
+ // The same two files. What `--adapter container` adds is a `Dockerfile` and
757
+ // a `.dockerignore`, and both are plain text that `uf` writes beside this
758
+ // output rather than anything the bundler produces — see `uf_cli`'s
759
+ // `commands::deploy`.
760
+ container: {
761
+ entries: (document, cache) => ({
762
+ handler: handlerEntrySource(document, cache),
763
+ server: nodeEntrySource("./handler.js"),
764
+ }),
765
+ },
766
+ edge: {
767
+ entries: (document, cache) => ({
768
+ handler: handlerEntrySource(document, cache),
769
+ worker: workerEntrySource("./handler.js"),
770
+ }),
771
+ // `workerd` first, so React resolves to the build that has
772
+ // `renderToReadableStream` and no `node:stream`. `browser` and `module`
773
+ // after it are Vite's own SSR defaults, kept so a dependency with no
774
+ // worker condition still resolves the way it does for every other target.
775
+ conditions: ["workerd", "worker", "edge-light", "browser", "module", "import", "default"],
776
+ },
777
+ serverless: {
778
+ entries: (document, cache) => ({
779
+ handler: handlerEntrySource(document, cache),
780
+ lambda: lambdaEntrySource("./handler.js"),
781
+ }),
782
+ },
783
+ };
784
+
785
+ /**
786
+ * Link the application into a directory that can be copied, for
787
+ * `uf build --adapter`.
788
+ *
789
+ * `uf start` serves a build and `uf build --compile` puts one inside an
790
+ * executable, and between them is the shape most hosts actually want: a
791
+ * directory that carries everything and nothing that is still in the checkout
792
+ * — no `node_modules`, no source, no `uf`. That is what this writes, for
793
+ * whichever of [`ADAPTERS`] was asked for.
794
+ *
795
+ * It differs from the server build in [`build`] in one way, and that one way
796
+ * is the whole of the difference between a build artefact and a checkout:
797
+ * `ssr.noExternal: true`. The ordinary server build leaves `react`,
798
+ * `react-dom` and every other dependency as bare imports, because the host it
799
+ * runs on has `node_modules` beside it; a copied directory does not, so they
800
+ * come in. (`@uniflowed/*` was never external — `index.js` sets
801
+ * `ssr.noExternal: [/^@uniflowed\//]` because Node cannot import Flow — which
802
+ * is why serving a build has never needed `uf transform` alive, and why the
803
+ * blocker ubugeeei-prod/uf#335 records was not one.)
804
+ *
805
+ * # Two entries, because an adapter is exactly one of them
806
+ *
807
+ * `handler.js` is the application as a Web-standard `fetch` export: a
808
+ * `Request` in, a `Response` out, no filesystem, no socket, no `node:` import
809
+ * that a worker does not already have. That is the seam, and it is the same
810
+ * file for every target in [`ADAPTERS`].
811
+ *
812
+ * The second entry is the wrapper for *this* target — `node:http` for `node`
813
+ * and `container`, `export default { fetch }` for a Worker, `export const
814
+ * handler` for a Lambda — and each of them is a handful of lines around an
815
+ * import from `@uniflowed/server`. That is the point: the work is in the
816
+ * handler, and what a new adapter has to write is the handful of lines, not
817
+ * the application.
818
+ *
819
+ * Both are ordinary entries of one Rolldown build, so the wrapper imports the
820
+ * emitted `handler.js` rather than a second copy of the application.
821
+ *
822
+ * The `static/` directory is *not* written here. `uf` copies it (see
823
+ * `uf_cli`'s `commands::deploy`), because walking an output directory and
824
+ * copying every file in it is bulk work over the whole build, which belongs in
825
+ * Rust rather than in the host process — the same division `--compile` makes
826
+ * with its embedded assets.
827
+ */
828
+ async function deploy() {
829
+ const vite = await import("vite");
830
+ const config = await loadConfig();
831
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
832
+ const outDir = path.resolve(root, inline.build.outDir);
833
+ const adapter = argument("--adapter");
834
+ const workArgument = argument("--work");
835
+ const outputArgument = argument("--output");
836
+ if (adapter == null || workArgument == null || outputArgument == null) {
837
+ throw new Error("uf: `driver.js deploy` needs --adapter, --work and --output");
838
+ }
839
+ // The Rust side has already refused every adapter it has no implementation
840
+ // for, by name and with the issue that tracks it. This is the second half of
841
+ // that fact rather than a duplicate of it: the driver may be spawned by a
842
+ // future `uf` that knows an adapter this copy does not, and answering "one
843
+ // moment, here is a directory" for a target nobody wrote would be the silent
844
+ // wrong answer the whole issue is about.
845
+ const shape = ADAPTERS[adapter];
846
+ if (shape == null) {
847
+ throw new Error(
848
+ `uf: this driver implements ${Object.keys(ADAPTERS)
849
+ .map((name) => JSON.stringify(name))
850
+ .join(", ")} and was asked for ${JSON.stringify(adapter)}`,
851
+ );
852
+ }
853
+ const work = path.resolve(root, workArgument);
854
+ const output = path.resolve(root, outputArgument);
855
+
856
+ emit("phase", { name: adapter });
857
+
858
+ // Written to disk rather than served as virtual modules: they are generated
859
+ // per build — `handler.js` names this build's hashed assets — and a real
860
+ // file is the version a person can open when a deployed directory
861
+ // misbehaves.
862
+ mkdirSync(work, { recursive: true });
863
+ const document = assetsFromManifest(readManifest(outDir));
864
+ const entries = shape.entries(document, config.app?.rendering?.cache);
865
+ const input = {};
866
+ for (const name of Object.keys(entries)) {
867
+ writeFileSync(path.join(work, `${name}.js`), entries[name]);
868
+ input[name] = path.join(work, `${name}.js`);
869
+ }
870
+
871
+ const ssr = { ...(inline.ssr ?? {}), noExternal: true };
872
+ if (shape.conditions != null) {
873
+ // Which build of a dependency this target gets, and it is the difference
874
+ // between a worker that renders and one that fails to link. React ships
875
+ // `server.node.js` under the `node` condition and `server.edge.js` under
876
+ // `workerd`; the first one imports `node:stream`, and the router picks its
877
+ // renderer by asking whether `renderToPipeableStream` is there — so the
878
+ // condition list is what decides that, not a flag in the application.
879
+ ssr.resolve = { ...(inline.ssr?.resolve ?? {}), conditions: shape.conditions };
880
+ }
881
+
882
+ await vite.build({
883
+ ...inline,
884
+ customLogger: eventLogger("warn"),
885
+ plugins: [...inline.plugins, nativeAddonGuard()],
886
+ ssr,
887
+ build: {
888
+ ...inline.build,
889
+ manifest: false,
890
+ // The map would describe this bundle rather than the source, and nothing
891
+ // downstream reads it. Off is a smaller directory to copy and one less
892
+ // file to explain.
893
+ sourcemap: false,
894
+ ssr: true,
895
+ outDir: output,
896
+ // `uf` has already removed the directory, and `static/` is copied in
897
+ // after this returns; letting Vite empty it would be Vite deciding when
898
+ // that happens.
899
+ emptyOutDir: false,
900
+ rollupOptions: {
901
+ input,
902
+ output: {
903
+ entryFileNames: "[name].js",
904
+ // Route modules are lazy `import()`s, so the server bundle splits
905
+ // whether or not anything asks it to, and the chunks have to land
906
+ // somewhere. `chunks/` rather than the default `assets/`, because
907
+ // `static/assets/` beside it is the *client's* — two directories
908
+ // with one name in a directory whose whole purpose is to be copied
909
+ // and read by a stranger.
910
+ chunkFileNames: "chunks/[name]-[hash].js",
911
+ format: "es",
912
+ },
913
+ },
914
+ },
915
+ });
916
+
917
+ emit("done", { outDir: path.relative(root, output), pages: 0 });
918
+ process.exit(0);
262
919
  }
263
920
 
264
921
  /**
265
- * Script, stylesheet and preload URLs for the client entry chunk.
922
+ * The source of `handler.js`: the application, as one `fetch` export.
923
+ *
924
+ * `export default { fetch }` as well as the named export, because those are
925
+ * the two spellings the hosts this shape exists for actually read — a worker
926
+ * and Deno Deploy want the default export's `fetch`, and a Node or Bun entry
927
+ * wants the name. Writing both costs a line and removes the one thing that
928
+ * would make an otherwise portable file not portable.
266
929
  *
267
- * The entry is found by its `isEntry` flag rather than by key, because a
268
- * virtual module's manifest key is an implementation detail of the bundler.
930
+ * `beginRequest` is exported beside it, and it is not decoration. `fetch`
931
+ * answers with a `Response`; it does not know when that response reached
932
+ * anybody, and `after()` promises a callback once it has. So the host owns the
933
+ * request: begin it, run `fetch` inside `run`, and `settle` when the bytes are
934
+ * out — `server.js` below does exactly that through
935
+ * `@uniflowed/server/node`, and a worker hands `settle` to `ctx.waitUntil`.
936
+ * It comes from the bundle rather than from the host's own
937
+ * `@uniflowed/server`, because the request lives in an `AsyncLocalStorage`
938
+ * belonging to a module instance and the instance the application reads is the
939
+ * one inlined here. See ubugeeei-prod/uf#389.
940
+ *
941
+ * The document's script and stylesheet URLs are baked in here because they
942
+ * come from the client manifest, which exists at this moment and not in the
943
+ * directory that gets copied.
944
+ *
945
+ * `cache` is `rendering.cache` from `uf.config.js`, and this is where two of
946
+ * its four switches stop being a field in a JSON file: a build that turned
947
+ * `route` or `fetch` on constructs a store here and hands it to the handler,
948
+ * and a build that turned neither on writes the file it always wrote, byte for
949
+ * byte. The store is constructed in the *generated* module rather than reached
950
+ * for inside `@uniflowed/server` for the same reason `beginRequest` is
951
+ * re-exported above — a module-level singleton belongs to whichever copy of the
952
+ * package a bundler happened to give it, and the copy that matters is the one
953
+ * the application resolved. See ubugeeei-prod/uf#277 and #389.
269
954
  */
955
+ function handlerEntrySource(document, cache) {
956
+ const route = cache?.route === true;
957
+ const fetchCache = cache?.fetch === true;
958
+ // Nothing at all when both switches are off, so a default project's
959
+ // `handler.js` is the file it has always been. A cache that appears in
960
+ // generated output nobody asked for is the second half of the complaint
961
+ // #277 makes about the first half.
962
+ const store = route || fetchCache;
963
+ return `// Generated by \`uf build --adapter\`. Not checked in, not edited.
964
+ import { createFetchHandler } from "@uniflowed/server/fetch";
965
+ ${store ? 'import { createCacheStore } from "@uniflowed/server/cache";\n' : ""}import * as app from ${JSON.stringify(VIRTUAL.server)};
966
+
967
+ ${
968
+ store
969
+ ? `// \`rendering.cache\` from uf.config.js. One store per process: it is
970
+ // emptied by a restart and is not shared with any other instance of this
971
+ // application. See ubugeeei-prod/uf#277.
972
+ const cache = { store: createCacheStore(), route: ${String(route)}, fetch: ${String(fetchCache)} };
973
+
974
+ export const fetch = createFetchHandler({ app, document: ${JSON.stringify(document)}, cache });`
975
+ : `export const fetch = createFetchHandler({ app, document: ${JSON.stringify(document)} });`
976
+ }
977
+ export const beginRequest = app.beginRequest;
978
+
979
+ export default { fetch, beginRequest };
980
+ `;
981
+ }
982
+
270
983
  /**
271
- * The tags a prerendered document needs.
272
- *
273
- * Two walks over the manifest, because the two answers are different. A
274
- * `modulepreload` is worth emitting only for a chunk this document will
275
- * certainly load, which is the entry's *static* imports. A stylesheet has to
276
- * be emitted for anything the page might render, and the router loads every
277
- * route module dynamically — so a stylesheet imported by a layout is reached
278
- * through `dynamicImports` and through nothing else. Following only the static
279
- * graph, as this did, meant a layout could import a stylesheet and the built
280
- * HTML would silently ship without it.
281
- *
282
- * The cost is that a project with per-route stylesheets links all of them on
283
- * every page. Narrowing that needs the route table to say which chunk each
284
- * route came from, which the manifest alone cannot tell us.
984
+ * The source of `server.js`: the Node socket around that handler.
985
+ *
986
+ * Everything host-specific about serving a build is in
987
+ * `@uniflowed/server/node`, which is the same module `uf start` reaches
988
+ * through `./internal/serve.js` so a request answered here and the same
989
+ * request answered by `uf start` go through one implementation, not two that
990
+ * agree today.
285
991
  */
286
- function assetsFromManifest(manifest) {
287
- const entry = Object.values(manifest).find((chunk) => chunk.isEntry);
288
- if (entry == null) throw new Error("uf: the client manifest has no entry chunk");
289
-
290
- const styles = new Set(entry.css ?? []);
291
- const seen = new Set();
292
- const collectStyles = (chunk) => {
293
- for (const imported of [...(chunk.imports ?? []), ...(chunk.dynamicImports ?? [])]) {
294
- if (seen.has(imported)) continue;
295
- seen.add(imported);
296
- const dependency = manifest[imported];
297
- if (dependency == null) continue;
298
- for (const css of dependency.css ?? []) styles.add(css);
299
- collectStyles(dependency);
300
- }
301
- };
302
- collectStyles(entry);
303
-
304
- const preloads = new Set();
305
- const collectPreloads = (chunk) => {
306
- for (const imported of chunk.imports ?? []) {
307
- const dependency = manifest[imported];
308
- if (dependency == null || preloads.has(dependency.file)) continue;
309
- preloads.add(dependency.file);
310
- collectPreloads(dependency);
311
- }
312
- };
313
- collectPreloads(entry);
992
+ function nodeEntrySource(handlerSpecifier) {
993
+ return `// Generated by \`uf build --adapter node\`. Not checked in, not edited.
994
+ import path from "node:path";
995
+ import { fileURLToPath } from "node:url";
996
+
997
+ import { serve } from "@uniflowed/server/node";
998
+
999
+ // \`beginRequest\` comes from the handler beside this file rather than from
1000
+ // \`@uniflowed/server/node\` above, because the request has to be established in
1001
+ // the storage the *application* reads, which is the copy bundled into
1002
+ // \`handler.js\`. See ubugeeei-prod/uf#389.
1003
+ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1004
+
1005
+ // Resolved from this file and not from the working directory: a process
1006
+ // manager, a container entrypoint and a person in a shell each start a server
1007
+ // from wherever they happen to be, and a directory that only served its own
1008
+ // assets when it was started from inside itself would be a deployment with a
1009
+ // trap in it.
1010
+ const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1011
+
1012
+ // Not \`await serve(...)\` at the top level. uf parses that now
1013
+ // (ubugeeei-prod/uf#204) and this entry is a module, so it would work; \`.catch\`
1014
+ // is the better spelling regardless — a server that cannot take its port should
1015
+ // say so and exit non-zero, rather than die as an unhandled rejection.
1016
+ serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
1017
+ process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1018
+ process.exit(1);
1019
+ });
1020
+ `;
1021
+ }
1022
+
1023
+ /**
1024
+ * The source of `worker.js`: the Cloudflare Workers entry around that handler.
1025
+ *
1026
+ * `export default { fetch }`, which is the modules-format Worker Cloudflare
1027
+ * runs, and everything host-specific is in `@uniflowed/server/edge` — the
1028
+ * asset lookup through the `ASSETS` binding `wrangler.json` declares, and the
1029
+ * `ctx.waitUntil` that keeps the isolate alive for `after()`.
1030
+ *
1031
+ * `beginRequest` comes from the handler beside this file for the reason
1032
+ * `nodeEntrySource` gives: the request has to be established in the storage the
1033
+ * *application* reads. See ubugeeei-prod/uf#389.
1034
+ */
1035
+ function workerEntrySource(handlerSpecifier) {
1036
+ return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1037
+ import { createWorkerFetch } from "@uniflowed/server/edge";
1038
+
1039
+ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1040
+
1041
+ export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1042
+ `;
1043
+ }
1044
+
1045
+ /**
1046
+ * The source of `lambda.js`: the AWS Lambda entry around that handler.
1047
+ *
1048
+ * `export const handler`, so the function's configured handler is
1049
+ * `lambda.handler`. Everything platform-specific — the payload format 2.0
1050
+ * event, the base64 rules, the `cookies` array — is in
1051
+ * `@uniflowed/server/lambda`.
1052
+ *
1053
+ * `staticDir` points at the `static/` copied beside this file, so an uploaded
1054
+ * package answers a prerendered document without any other infrastructure
1055
+ * existing. That is a starting point rather than a destination, and the module
1056
+ * it is passed to says so at length.
1057
+ */
1058
+ function lambdaEntrySource(handlerSpecifier) {
1059
+ return `// Generated by \`uf build --adapter serverless\`. Not checked in, not edited.
1060
+ import path from "node:path";
1061
+ import { fileURLToPath } from "node:url";
1062
+
1063
+ import { createLambdaHandler } from "@uniflowed/server/lambda";
1064
+
1065
+ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1066
+
1067
+ // Resolved from this file and not from the working directory: Lambda sets the
1068
+ // working directory to the task root today and is under no obligation to keep
1069
+ // doing so, and a deployment that only found its own assets by accident is a
1070
+ // deployment with a trap in it.
1071
+ const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1072
+
1073
+ export const handler = createLambdaHandler({ handle, beginRequest, staticDir });
1074
+ `;
1075
+ }
314
1076
 
1077
+ /**
1078
+ * The source of the module a runtime gets wrapped around.
1079
+ *
1080
+ * Three imports and one call: the shim that serves, the application, and the
1081
+ * bytes of `dist/`. The document's script and stylesheet URLs are baked in
1082
+ * here because they come from the client manifest, which exists at this moment
1083
+ * and not inside the binary.
1084
+ */
1085
+ function entrySource(assetsSpecifier, document) {
1086
+ // Not `await serve(...)` at the top level. uf parses that now
1087
+ // (ubugeeei-prod/uf#204) and this entry is a module, so it would work;
1088
+ // `.catch` is the better spelling regardless: a binary that cannot take its
1089
+ // port should say which port and exit non-zero, rather than die as an
1090
+ // unhandled rejection.
1091
+ return `// Generated by \`uf build --compile\`. Not checked in, not edited.
1092
+ import { serve } from "@uniflowed/server/standalone";
1093
+ import { assets } from ${JSON.stringify(assetsSpecifier)};
1094
+ import * as app from ${JSON.stringify(VIRTUAL.server)};
1095
+
1096
+ serve({ app, assets, document: ${JSON.stringify(document)} }).catch((error) => {
1097
+ process.stderr.write(\`uf: \${error?.message ?? String(error)}\n\`);
1098
+ process.exit(1);
1099
+ });
1100
+ `;
1101
+ }
1102
+
1103
+ /**
1104
+ * Refuse a native addon by name instead of by stack trace.
1105
+ *
1106
+ * A `.node` file is a compiled shared object for one platform: it cannot be
1107
+ * inlined into a JavaScript bundle, and a binary that carried one would stop
1108
+ * being a single file. Without this, `ssr.noExternal: true` hands the addon to
1109
+ * Rolldown and the build fails somewhere inside the bundler with a message
1110
+ * about an unexpected character — which is true, and useless. Failing here
1111
+ * with the addon's path and the importer that reached it is the difference
1112
+ * between a feature and a trap.
1113
+ *
1114
+ * It catches what can be caught: a static `import` or `require` that resolves
1115
+ * to a `.node` file. An addon loaded through a runtime string — `process.dlopen`,
1116
+ * or `require(variable)` — is not visible to any bundler, so such a project
1117
+ * still compiles and still fails on the first request that reaches the addon.
1118
+ * That limit is real, it is not fixable from inside a bundler, and it is
1119
+ * written down in the CLI reference rather than papered over.
1120
+ */
1121
+ function nativeAddonGuard() {
315
1122
  return {
316
- scripts: [`/${entry.file}`],
317
- styles: [...styles].map((file) => `/${file}`),
318
- preloads: [...preloads].map((file) => `/${file}`),
1123
+ name: "uf:no-native-addons",
1124
+ enforce: "pre",
1125
+ resolveId(source, importer) {
1126
+ if (!source.endsWith(".node")) return null;
1127
+ const from = importer == null ? "the application" : path.relative(root, importer);
1128
+ throw new Error(
1129
+ `${from} loads the native addon ${source}, and \`uf build --compile\` cannot put one ` +
1130
+ "inside a single executable: a `.node` file is a shared object built for one " +
1131
+ "platform, and embedding it would make the output two files rather than one. " +
1132
+ "Build without `--compile` and deploy `dist/` with a runtime, or replace the " +
1133
+ "dependency with one that has no native addon.",
1134
+ );
1135
+ },
319
1136
  };
320
1137
  }
321
1138
 
1139
+ /** `word`, pluralised for `count`. */
1140
+ function plural(count, word) {
1141
+ return count === 1 ? word : `${word}s`;
1142
+ }
1143
+
1144
+ async function printConfig() {
1145
+ const config = await loadConfig();
1146
+ emit("config", { config: projectConfig(config) });
1147
+ process.exit(0);
1148
+ }
1149
+
1150
+ function readManifest(outDir) {
1151
+ const file = path.join(outDir, ".vite", "manifest.json");
1152
+ if (!existsSync(file)) throw new Error(`uf: the client build wrote no manifest at ${file}`);
1153
+ return JSON.parse(readFileSync(file, "utf8"));
1154
+ }
1155
+
322
1156
  /**
323
1157
  * The URLs to prerender: every route without parameters, plus every set of
324
1158
  * parameters a page's `generateStaticParams` returns.
@@ -346,9 +1180,12 @@ function fillParams(routePath, params) {
346
1180
  .map((segment) => {
347
1181
  if (segment.endsWith("*")) {
348
1182
  const value = params[segment.slice(1, -1)];
349
- return Array.isArray(value) ? value.map(encodeURIComponent).join("/") : encodeURIComponent(String(value ?? ""));
1183
+ return Array.isArray(value)
1184
+ ? value.map(encodeURIComponent).join("/")
1185
+ : encodeURIComponent(String(value ?? ""));
350
1186
  }
351
- if (segment.startsWith(":")) return encodeURIComponent(String(params[segment.slice(1)] ?? ""));
1187
+ if (segment.startsWith(":"))
1188
+ return encodeURIComponent(String(params[segment.slice(1)] ?? ""));
352
1189
  return segment;
353
1190
  })
354
1191
  .join("/");
@@ -356,5 +1193,7 @@ function fillParams(routePath, params) {
356
1193
 
357
1194
  function htmlPathFor(outDir, url) {
358
1195
  const pathname = url.split("?")[0].replace(/^\/+/, "");
359
- return pathname === "" ? path.join(outDir, "index.html") : path.join(outDir, pathname, "index.html");
1196
+ return pathname === ""
1197
+ ? path.join(outDir, "index.html")
1198
+ : path.join(outDir, pathname, "index.html");
360
1199
  }