@uniflowed/vite 0.0.0-alpha.6 → 0.0.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/driver.js CHANGED
@@ -2,11 +2,15 @@
2
2
  //
3
3
  // Plain JavaScript: the host runs this file directly.
4
4
  //
5
- // The driver `uf dev`, `uf build` and `uf preview` spawn.
5
+ // The driver `uf dev`, `uf build`, `uf build --compile`, `uf preview` and
6
+ // `uf start` spawn.
6
7
  //
7
8
  // <host> driver.js dev --root <dir> [--host <h>] [--port <n>] [--strict-port]
8
9
  // <host> driver.js build --root <dir> [--out-dir <dir>] [--mode <m>]
9
- // <host> driver.js preview --root <dir> [--host <h>] [--port <n>]
10
+ // <host> driver.js compile --root <dir> [--out-dir <dir>] --assets <file> --bundle <dir>
11
+ // <host> driver.js deploy --root <dir> [--out-dir <dir>] --adapter <name> --work <dir> --output <dir>
12
+ // <host> driver.js preview --root <dir> [--out-dir <dir>] [--host <h>] [--port <n>]
13
+ // <host> driver.js start --root <dir> [--out-dir <dir>] [--host <h>] [--port <n>]
10
14
  // <host> driver.js config --root <dir>
11
15
  //
12
16
  // `uf` in Rust owns the terminal; this process owns Vite. They talk over
@@ -18,15 +22,24 @@
18
22
  // Rust side reads a config that may hold functions and plugin instances: the
19
23
  // one host that can evaluate the file evaluates it.
20
24
 
25
+ import { createServer as createHttpServer } from "node:http";
21
26
  import { register } from "node:module";
22
27
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
28
  import path from "node:path";
24
29
  import { pathToFileURL } from "node:url";
25
30
 
26
- import { emit, errorEvent, eventLogger } from "./internal/events.js";
31
+ import { emit, errorEvent, eventLogger, reportRenderError } from "./internal/events.js";
27
32
  import { loadUfConfig, projectConfig } from "./internal/config.js";
33
+ import { send, toRequest } from "./internal/http.js";
28
34
  import { withProjectConfig } from "./merge.js";
29
35
  import { VIRTUAL, scanRoutes } from "./internal/routes.js";
36
+ import {
37
+ assetsFromManifest,
38
+ createServeHandler,
39
+ loadBuild,
40
+ nodeListener,
41
+ withRequest,
42
+ } from "./internal/serve.js";
30
43
 
31
44
  function argument(name) {
32
45
  const at = process.argv.indexOf(name);
@@ -59,7 +72,7 @@ process.stdin.on("end", () => process.exit(0));
59
72
  process.stdin.on("error", () => process.exit(0));
60
73
  process.stdin.resume();
61
74
 
62
- const commands = { dev, build, preview, config: printConfig };
75
+ const commands = { dev, build, compile, deploy, preview, start, config: printConfig };
63
76
  const run = commands[command];
64
77
  if (run == null) {
65
78
  emit("error", { message: `unknown driver command ${JSON.stringify(command)}` });
@@ -93,10 +106,17 @@ async function viteConfig(config, mode) {
93
106
  // plugins make Flow compile, and the few settings uf enforces rather than
94
107
  // merely passes on — `allowedHosts` gates binding a routable address, and
95
108
  // `manifest` is how the prerender finds its assets.
109
+ //
110
+ // `envDir: false` rather than `envFile: false`: Vite 8 deprecated the second
111
+ // spelling and prints a line saying so on every dev server and every build,
112
+ // twice in the docs site's. A project turns the loader back on with
113
+ // `vite: { envDir: "." }`, which is the default directory — the project's own
114
+ // configuration is merged over this one, so it wins. See #259 for why uf
115
+ // switches it off at all.
96
116
  const generated = {
97
117
  root,
98
118
  configFile: false,
99
- envFile: false,
119
+ envDir: false,
100
120
  mode,
101
121
  clearScreen: false,
102
122
  customLogger: eventLogger(argument("--log-level") ?? "info"),
@@ -111,7 +131,17 @@ async function viteConfig(config, mode) {
111
131
  deny: dev.fs?.deny,
112
132
  },
113
133
  },
114
- preview: { host, port },
134
+ // Not `server`, and not `dev.port` either. Vite's own default for a
135
+ // preview is 4173 rather than 5173, and the reason is the case this
136
+ // command exists for: somebody comparing a build against the dev server
137
+ // they left running. Taking `dev.port` would have made the two collide,
138
+ // and Vite would have moved the preview to the next free port and served
139
+ // it somewhere nobody was looking.
140
+ preview: {
141
+ host: argument("--host") ?? "127.0.0.1",
142
+ port: Number(argument("--port") ?? 4173),
143
+ strictPort: flag("--strict-port"),
144
+ },
115
145
  build: {
116
146
  outDir: argument("--out-dir") ?? build.outDir ?? "dist",
117
147
  sourcemap: build.sourcemap ?? true,
@@ -140,11 +170,19 @@ async function viteConfig(config, mode) {
140
170
  *
141
171
  * 1. load the server entry through `ssrLoadModule`, so it is transformed the
142
172
  * same way the browser's copy is and picks up edits without a restart;
143
- * 2. render the URL, pointing the client script at the dev entry rather than
173
+ * 2. run the middleware guarding this path, which may answer instead;
174
+ * 3. render the URL, pointing the client script at the dev entry rather than
144
175
  * at a built asset;
145
- * 3. hand the HTML to `transformIndexHtml`, which is what injects the HMR
176
+ * 4. hand the HTML to `transformIndexHtml`, which is what injects the HMR
146
177
  * client and lets any Vite plugin see the document.
147
178
  *
179
+ * Step 4 is why `uf dev` collects the stream instead of piping it: Vite's HTML
180
+ * hook takes a whole document and any plugin may rewrite any part of it, so
181
+ * there is no first byte to send until it has run. `uf start` and `uf preview`
182
+ * have no such hook and stream — see `internal/serve.js` — and it is worth
183
+ * being clear that this is a property of the development server rather than of
184
+ * the renderer. Streaming through the transform is ubugeeei-prod/uf#374.
185
+ *
148
186
  * Anything Vite already serves — a module, a public file — never reaches this,
149
187
  * because the middleware runs after Vite's own.
150
188
  */
@@ -160,32 +198,77 @@ async function dev() {
160
198
 
161
199
  server.middlewares.use(async (request, response, next) => {
162
200
  const url = request.originalUrl ?? request.url ?? "/";
201
+ // Declared out here so the catch below can still settle: a request that
202
+ // failed is a request that happened, and a middleware that logged its
203
+ // arrival is owed its callback either way.
204
+ let lifecycle = null;
163
205
  try {
164
206
  const entry = await server.ssrLoadModule(VIRTUAL.server);
207
+ const asRequest = await toRequest(request, server.config);
165
208
 
166
- // Route handlers first, and for every method: a handler is the only
167
- // thing that answers a POST, and it may also answer a GET for a path
168
- // that has no page.
169
- const handled = await entry.dispatch(await toRequest(request, server.config));
170
- if (handled != null) {
171
- await send(response, handled);
172
- return;
173
- }
209
+ // The request begins here and ends when the document has been written,
210
+ // which is what `after()` promises and what `uf preview`, `uf start` and
211
+ // a compiled binary all do too — a middleware that logs a response's
212
+ // status has to mean the same thing in development as in production.
213
+ // `entry.beginRequest` rather than an import: the storage that holds the
214
+ // request belongs to the application's own copy of `@uniflowed/server`.
215
+ // See `internal/serve.js` and ubugeeei-prod/uf#389.
216
+ lifecycle = entry.beginRequest(asRequest);
217
+ const answered = await lifecycle.run(async () => {
218
+ // Middleware first, above everything: it guards a subtree, so it has to
219
+ // run for a page, for a route handler, and for a path under it that
220
+ // matches neither. Running it inside the dispatcher and again inside the
221
+ // renderer would have left `/dashboard/typo` unguarded and run it twice
222
+ // for a path that is both.
223
+ const guarded = await entry.runMiddleware(asRequest);
224
+ if (guarded != null) {
225
+ await send(response, guarded);
226
+ return true;
227
+ }
228
+
229
+ // Route handlers next, and for every method: a handler is the only
230
+ // thing that answers a POST, and it may also answer a GET for a path
231
+ // that has no page.
232
+ const handled = await entry.dispatch(asRequest);
233
+ if (handled != null) {
234
+ await send(response, handled);
235
+ return true;
236
+ }
237
+
238
+ // Only a navigation reaches the renderer. A page cannot answer a POST,
239
+ // and letting one try would turn a missing handler into a rendered page
240
+ // with a 200 rather than a 404.
241
+ if (request.method !== "GET" && request.method !== "HEAD") {
242
+ return false;
243
+ }
174
244
 
175
- // Only a navigation reaches the renderer. A page cannot answer a POST,
176
- // and letting one try would turn a missing handler into a rendered page
177
- // with a 200 rather than a 404.
178
- if (request.method !== "GET" && request.method !== "HEAD") {
245
+ const result = await entry.render(url, assets, {
246
+ // A boundary that threw after the shell went out. `result.error` cannot
247
+ // carry it the caller already has the result by then — so the
248
+ // terminal hears about it here or not at all.
249
+ onError: (error) => reportRenderError(server, url, error),
250
+ });
251
+ if (result.error != null) reportRenderError(server, url, result.error);
252
+ const html = await server.transformIndexHtml(url, await result.text());
253
+ response.statusCode = result.status ?? 200;
254
+ response.setHeader("content-type", "text/html; charset=utf-8");
255
+ response.end(html);
256
+ return true;
257
+ });
258
+
259
+ if (!answered) {
260
+ // The one path where uf is not the one writing the response: a
261
+ // non-navigation nothing claimed goes back to Vite's chain. The guard
262
+ // has still run and may have deferred work, so `close` — the socket
263
+ // saying the response is over, however it ended — is the only honest
264
+ // signal left that the bytes are out.
265
+ response.once("close", lifecycle.settle);
179
266
  next();
180
267
  return;
181
268
  }
182
-
183
- const result = await entry.render(url, assets);
184
- const html = await server.transformIndexHtml(url, result.html);
185
- response.statusCode = result.status ?? 200;
186
- response.setHeader("content-type", "text/html; charset=utf-8");
187
- response.end(html);
269
+ await lifecycle.settle();
188
270
  } catch (error) {
271
+ if (lifecycle != null) await lifecycle.settle();
189
272
  // Map the stack back onto the Flow source before it reaches the overlay.
190
273
  if (error instanceof Error) server.ssrFixStacktrace(error);
191
274
  next(error);
@@ -201,6 +284,7 @@ async function dev() {
201
284
  (route) => route.path,
202
285
  ),
203
286
  });
287
+ watchSources(server);
204
288
 
205
289
  const shutdown = async () => {
206
290
  await server.close();
@@ -211,67 +295,99 @@ async function dev() {
211
295
  }
212
296
 
213
297
  /**
214
- * A Node request as a `Request`.
298
+ * Tell the Rust side when a module under the project root changed.
299
+ *
300
+ * `uf dev` answers questions Vite does not: whether a module is a Server
301
+ * Component, and whether a Server Component reaches for something that only
302
+ * exists in a browser. Those are whole-project answers, so they go stale on
303
+ * any edit and there is no module to recompute them *for* — which is why this
304
+ * event carries no path. What it carries is "ask again".
215
305
  *
216
- * The handler contract is the platform's, so the adapter belongs here rather
217
- * than in every handler. The body is read as a stream where the host supports
218
- * it, because a handler that accepts an upload should not need the whole thing
219
- * buffered before it starts.
306
+ * Vite's watcher is the only watcher. A second one over the same tree, in
307
+ * Rust, would be a second answer to "did this file change", and two watchers
308
+ * disagree exactly when an editor writes through a temporary file which is
309
+ * every editor, and which is not a thing anybody tests.
310
+ *
311
+ * Debounced, because a `git checkout` is one intention and several hundred
312
+ * `change` events, and unrefed so a pending timer cannot keep this process
313
+ * alive after the server has closed.
220
314
  */
221
- async function toRequest(incoming, config) {
222
- const host = incoming.headers.host ?? "localhost";
223
- const protocol = config?.server?.https == null ? "http" : "https";
224
- const url = new URL(incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
225
-
226
- const headers = new Headers();
227
- for (const [name, value] of Object.entries(incoming.headers)) {
228
- if (value == null) continue;
229
- for (const entry of Array.isArray(value) ? value : [value]) {
230
- headers.append(name, entry);
231
- }
232
- }
233
-
234
- const method = (incoming.method ?? "GET").toUpperCase();
235
- const init = { method, headers };
236
- if (method !== "GET" && method !== "HEAD") {
237
- // `duplex` is required by the specification whenever a body is a stream,
238
- // and Node throws without it.
239
- init.body = incoming;
240
- init.duplex = "half";
241
- }
242
- return new Request(url, init);
243
- }
244
-
245
- /** Write a `Response` to a Node response. */
246
- async function send(outgoing, result) {
247
- outgoing.statusCode = result.status;
248
- if (result.statusText !== "") {
249
- outgoing.statusMessage = result.statusText;
250
- }
251
- for (const [name, value] of result.headers) {
252
- outgoing.setHeader(name, value);
253
- }
254
- if (result.body == null) {
255
- outgoing.end();
256
- return;
257
- }
258
- // Streamed rather than buffered, so a handler returning a large or
259
- // open-ended body is not read into memory first.
260
- const reader = result.body.getReader();
261
- while (true) {
262
- const { done, value } = await reader.read();
263
- if (done) break;
264
- outgoing.write(value);
315
+ function watchSources(server) {
316
+ let timer = null;
317
+ const changed = () => {
318
+ if (timer != null) clearTimeout(timer);
319
+ timer = setTimeout(() => {
320
+ timer = null;
321
+ emit("source-changed");
322
+ }, 50);
323
+ timer.unref?.();
324
+ };
325
+ const isSource = (file) =>
326
+ (file.endsWith(".js") || file.endsWith(".jsx")) && !file.includes("node_modules");
327
+ for (const event of ["add", "change", "unlink"]) {
328
+ server.watcher.on(event, (file) => {
329
+ if (isSource(file)) changed();
330
+ });
265
331
  }
266
- outgoing.end();
267
332
  }
268
333
 
334
+ /**
335
+ * The preview server: the build, as Vite serves it.
336
+ *
337
+ * Vite's `preview()` is a static file server, and a uf build is not only
338
+ * static files — a route handler answers a `POST` and a route with parameters
339
+ * and no `generateStaticParams` was never prerendered. On its own it would
340
+ * therefore 404 every request the interesting half of an application exists to
341
+ * answer, which is worse than having no preview at all, because a preview is
342
+ * checked and believed.
343
+ *
344
+ * So the application handler is mounted behind it, and `appType: "custom"` is
345
+ * what makes that reachable: with Vite's default `spa` it inserts an
346
+ * index.html fallback and a 404 middleware of its own, so every unmatched path
347
+ * would have been answered with the home page — a 200 for a path that does not
348
+ * exist — before anything of uf's ran.
349
+ *
350
+ * The static middleware still runs first, and that is deliberate rather than
351
+ * incidental; see `internal/serve.js` for why `uf start` orders itself the
352
+ * same way.
353
+ */
269
354
  async function preview() {
270
355
  const { preview: startPreview } = await import("vite");
271
356
  const config = await loadConfig();
272
- const server = await startPreview(await viteConfig(config, "production"));
357
+ const inline = await viteConfig(config, "production");
358
+ const build = await loadBuild({
359
+ root,
360
+ outDir: inline.build.outDir,
361
+ serverDir: path.join(".uf", "build", "server"),
362
+ });
363
+
364
+ const server = await startPreview({ ...inline, appType: "custom" });
365
+ const handle = createServeHandler(build);
366
+ server.middlewares.use(async (request, response, next) => {
367
+ try {
368
+ const asRequest = await toRequest(request, server.config);
369
+ // The same lifecycle `uf start` gets from `nodeListener`, spelled out
370
+ // because this door is Vite's connect chain rather than a bare
371
+ // `node:http` server: the whole request runs inside it, and it settles
372
+ // once `send` has returned. A preview whose `after()` fired at a
373
+ // different moment from the production server's would be a preview that
374
+ // is checked and believed and wrong.
375
+ await withRequest(build.entry, asRequest, async () => {
376
+ await send(response, await handle(asRequest));
377
+ });
378
+ } catch (error) {
379
+ next(error);
380
+ }
381
+ });
382
+
273
383
  const urls = server.resolvedUrls ?? { local: [], network: [] };
274
- emit("listening", { local: urls.local, network: urls.network, routes: [] });
384
+ emit("listening", {
385
+ local: urls.local,
386
+ network: urls.network,
387
+ routes: build.entry.routes.map((route) => route.path),
388
+ handlers: build.entry.handlers.map((handler) => handler.path),
389
+ });
390
+
275
391
  const shutdown = async () => {
276
392
  await server.close();
277
393
  process.exit(0);
@@ -280,6 +396,65 @@ async function preview() {
280
396
  process.on("SIGTERM", shutdown);
281
397
  }
282
398
 
399
+ /**
400
+ * The production server: the build, with no bundler in the process.
401
+ *
402
+ * `preview` proves the build works through Vite. This is the thing that is
403
+ * actually deployed, and it imports `vite` nowhere — a host running a built
404
+ * application should not need the bundler that produced it, and the moment it
405
+ * does, "portable output" is a claim rather than a property.
406
+ *
407
+ * There is no `--strict-port` here and there is nothing to add: this server
408
+ * binds the port it was given or fails, where Vite's would have quietly moved
409
+ * to the next free one. `PORT` and `HOST` are read from the environment
410
+ * because that is how every process manager and container platform says which
411
+ * socket to take, and a production server that could only be told on the
412
+ * command line would need a wrapper script everywhere it ran.
413
+ *
414
+ * It is not the only thing that can be deployed. `uf build --compile` puts
415
+ * this same application behind this same resolution order inside a single
416
+ * executable, for a host that should not have to have a JavaScript runtime
417
+ * installed at all; see [`compile`] for what that costs and what it shares.
418
+ */
419
+ async function start() {
420
+ const config = await loadConfig();
421
+ const outDir = argument("--out-dir") ?? config.build?.outDir ?? "dist";
422
+ const build = await loadBuild({
423
+ root,
424
+ outDir,
425
+ serverDir: path.join(".uf", "build", "server"),
426
+ });
427
+
428
+ const host = argument("--host") ?? process.env.HOST ?? "0.0.0.0";
429
+ const port = Number(argument("--port") ?? process.env.PORT ?? 3000);
430
+ const server = createHttpServer(nodeListener(createServeHandler(build), build.entry));
431
+
432
+ await new Promise((resolve, reject) => {
433
+ server.once("error", reject);
434
+ server.listen(port, host, resolve);
435
+ });
436
+
437
+ const bound = server.address();
438
+ // `0.0.0.0` is not a URL anybody can open, so the loopback spelling is what
439
+ // is printed as `local` and the bound address is reported as the network
440
+ // one — the same split `uf dev` prints, and for the same reason: one of the
441
+ // two is a link and the other is a fact about the socket.
442
+ const shown = `${bound.address}:${bound.port}`;
443
+ const wildcard = bound.address === "0.0.0.0" || bound.address === "::";
444
+ emit("listening", {
445
+ local: [`http://${wildcard ? `localhost:${bound.port}` : shown}/`],
446
+ network: wildcard ? [`http://${shown}/`] : [],
447
+ routes: build.entry.routes.map((route) => route.path),
448
+ handlers: build.entry.handlers.map((handler) => handler.path),
449
+ });
450
+
451
+ const shutdown = () => {
452
+ server.close(() => process.exit(0));
453
+ };
454
+ process.on("SIGINT", shutdown);
455
+ process.on("SIGTERM", shutdown);
456
+ }
457
+
283
458
  async function build() {
284
459
  const vite = await import("vite");
285
460
  const config = await loadConfig();
@@ -324,8 +499,40 @@ async function build() {
324
499
  const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
325
500
  const assets = assetsFromManifest(manifest);
326
501
  const pages = await staticPaths(server.routes);
502
+
503
+ // A route that throws fails *that route*, and the rest of the build still
504
+ // happens. This loop had no `try`: the first page to throw rejected out of
505
+ // `build()`, `run().catch` reported the exception, and which URL was being
506
+ // rendered was a local variable nobody could see. One broken page was the
507
+ // whole build, and the message named a stack rather than a route.
508
+ //
509
+ // The render itself no longer throws for an ordinary component failure —
510
+ // `createRenderer` renders the error boundary and reports the exception on
511
+ // the result — so both are checked here. Neither writes a file: an error
512
+ // page written into `dist/` is a build that shipped its own failure.
513
+ //
514
+ // `prerender`, not `render`: a build wants the document React produces once
515
+ // every boundary has resolved, with the content where the fallback was. The
516
+ // streaming renderer would write a file whose slow parts are `<template>`
517
+ // elements waiting for a script — correct in a browser, blank to a crawler
518
+ // and to `curl`, which is most of what a static file is for.
519
+ const failures = [];
520
+ const failed = (url, error) => {
521
+ failures.push(url);
522
+ emit("page-failed", { url, ...errorEvent(error) });
523
+ };
327
524
  for (const url of pages) {
328
- const result = await server.render(url, assets);
525
+ let result;
526
+ try {
527
+ result = await server.prerender(url, assets);
528
+ } catch (error) {
529
+ failed(url, error);
530
+ continue;
531
+ }
532
+ if (result.error != null) {
533
+ failed(url, result.error);
534
+ continue;
535
+ }
329
536
  const file = htmlPathFor(outDir, url);
330
537
  mkdirSync(path.dirname(file), { recursive: true });
331
538
  writeFileSync(file, result.html);
@@ -336,92 +543,447 @@ async function build() {
336
543
  bytes: Buffer.byteLength(result.html),
337
544
  });
338
545
  }
339
- if (server.notFound != null) {
340
- const result = await server.render("/__uf_not_found__", assets);
341
- const file = path.join(outDir, "404.html");
342
- writeFileSync(file, result.html);
343
- emit("page", {
344
- url: "/404",
345
- file: path.relative(root, file),
346
- status: 404,
347
- bytes: Buffer.byteLength(result.html),
546
+ // One `404.html`, from the boundary at the router root: a static host serves
547
+ // a single error document for the whole site, so the nested boundaries a
548
+ // project declares are the server's and the client's to render, not
549
+ // something this loop can write a file for.
550
+ //
551
+ // The condition is "there is a root boundary", not "there is any boundary",
552
+ // because `/__uf_not_found__` is a path at the root: a project whose only
553
+ // `_uf.not-found.js` is in `app/guide/` would otherwise get a `404.html`
554
+ // rendered from the framework's bare default, which is worse than the file
555
+ // it used to write, which was none.
556
+ //
557
+ // Through the same two checks as the loop, and for the same reason. A
558
+ // not-found boundary is a component like any other: it can throw, and when it
559
+ // does `prerender` answers with the *error* page's HTML and a non-null
560
+ // `error` rather than rejecting. Writing that HTML and emitting `page` was a
561
+ // build publishing its own failure as `404.html` and exiting 0 — the static
562
+ // host would then serve uf's error page to every visitor who mistyped a URL,
563
+ // and nothing between the throw and the deploy would have mentioned it.
564
+ let attempted = pages.length;
565
+ if (server.notFound.some((boundary) => boundary.path === "/")) {
566
+ attempted += 1;
567
+ // `/404` rather than `/__uf_not_found__`: the internal path is how the
568
+ // router is asked, and the file the reader is looking for is `404.html`.
569
+ let result;
570
+ try {
571
+ result = await server.prerender("/__uf_not_found__", assets);
572
+ } catch (error) {
573
+ failed("/404", error);
574
+ result = null;
575
+ }
576
+ if (result != null && result.error != null) {
577
+ failed("/404", result.error);
578
+ result = null;
579
+ }
580
+ if (result != null) {
581
+ const file = path.join(outDir, "404.html");
582
+ writeFileSync(file, result.html);
583
+ emit("page", {
584
+ url: "/404",
585
+ file: path.relative(root, file),
586
+ status: 404,
587
+ bytes: Buffer.byteLength(result.html),
588
+ });
589
+ }
590
+ }
591
+
592
+ if (failures.length > 0) {
593
+ // Emitted rather than thrown, so the message is the routes and not the
594
+ // last exception: each one has already been reported with its own frame.
595
+ //
596
+ // The first line stands on its own, because it is the one `uf build` uses
597
+ // as the headline and the one a CI log's last line will be. It read
598
+ // `... failed:` with the routes below it, and the headline was then a
599
+ // sentence ending in a colon and nothing.
600
+ // `attempted`, not `pages.length`: the root 404 is prerendered too, and
601
+ // counting a failure of it against a total that excludes it produced
602
+ // "1 of 12" for a build that rendered thirteen things.
603
+ emit("error", {
604
+ message: `${failures.length} of ${attempted} prerendered ${plural(
605
+ attempted,
606
+ "route",
607
+ )} failed\n${failures.map((url) => ` ${url}`).join("\n")}`,
348
608
  });
609
+ process.exit(1);
349
610
  }
350
611
 
351
612
  emit("done", { outDir: path.relative(root, outDir), pages: pages.length });
352
613
  process.exit(0);
353
614
  }
354
615
 
355
- async function printConfig() {
616
+ /**
617
+ * Link the whole application into one JavaScript file, for `uf build --compile`.
618
+ *
619
+ * This runs after `build`, on a `dist/` that is already complete, and produces
620
+ * the module a runtime is wrapped around. It differs from the server build in
621
+ * `build()` in exactly three ways, and each of them is what "one file" means:
622
+ *
623
+ * * `ssr.noExternal: true` — the server build leaves `react`, `react-dom`
624
+ * and every other dependency as bare imports, because the host it runs on
625
+ * has `node_modules` beside it. A binary does not, so they come in.
626
+ * * `codeSplitting: false` — a route is a lazy `import()` so that the browser
627
+ * can fetch one chunk per page. On the server that split buys nothing and
628
+ * costs everything: chunks are separate files, and separate files are the
629
+ * one thing this output may not have.
630
+ * * the native-addon guard below, which turns "cannot resolve" into a
631
+ * sentence naming the package that cannot be compiled.
632
+ *
633
+ * The embedded copy of `dist/` is *not* built here. `uf` writes it (see
634
+ * `uf_bundle::embed`) and passes its path in `--assets`, because walking an
635
+ * output directory and encoding every file in it is bulk work over the whole
636
+ * build, which belongs in Rust rather than in the host process.
637
+ *
638
+ * # Three front doors onto one build, and why they are not one function
639
+ *
640
+ * `preview` and `start` above serve `dist/` from disk, and they share a single
641
+ * handler in `./internal/serve.js` for the express purpose of being unable to
642
+ * answer differently. What is linked here is a third front door onto the same
643
+ * build, and it deliberately does *not* import that module. Two reasons, and
644
+ * either would be enough: `internal/serve.js` answers by opening files under
645
+ * `dist/`, and a compiled binary has no `dist/` to open — it carries the bytes
646
+ * — so the half that reads a request would arrive with a half that cannot run;
647
+ * and it lives in `@uniflowed/vite`, so linking it would put the package named
648
+ * after the bundler inside the artefact a deployment runs, which is the one
649
+ * thing `start` exists to avoid.
650
+ *
651
+ * What a binary uses instead is `@uniflowed/server/standalone`, and the thing
652
+ * that is shared between the three is not code but the *answer*: an asset or a
653
+ * prerendered document first, then a route handler, then a render for whatever
654
+ * is left. That order is not a preference. `preview` cannot deviate from it —
655
+ * Vite's preview server runs its own file middleware before anything uf mounts
656
+ * behind it — so `start` matches Vite, and the binary matches `start`. A
657
+ * compiled application that resolved a collision the other way would be the
658
+ * trap `preview` exists to prevent, one deployment further along, and the only
659
+ * copy nobody can check with `uf preview` first.
660
+ */
661
+ async function compile() {
662
+ const vite = await import("vite");
356
663
  const config = await loadConfig();
357
- emit("config", { config: projectConfig(config) });
664
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
665
+ const outDir = path.resolve(root, inline.build.outDir);
666
+ const assetsArgument = argument("--assets");
667
+ const bundleArgument = argument("--bundle");
668
+ if (assetsArgument == null || bundleArgument == null) {
669
+ throw new Error("uf: `driver.js compile` needs both --assets and --bundle");
670
+ }
671
+ const assets = path.resolve(root, assetsArgument);
672
+ const bundleDir = path.resolve(root, bundleArgument);
673
+
674
+ emit("phase", { name: "standalone" });
675
+
676
+ // The entry is written to disk rather than served as another virtual module:
677
+ // it is generated per build (it names this build's asset file), and a real
678
+ // file is the version a person can open when a compiled binary misbehaves.
679
+ const entry = path.join(bundleDir, "entry.js");
680
+ mkdirSync(bundleDir, { recursive: true });
681
+ const specifier = `./${path.relative(bundleDir, assets)}`;
682
+ writeFileSync(entry, entrySource(specifier, assetsFromManifest(readManifest(outDir))));
683
+
684
+ await vite.build({
685
+ ...inline,
686
+ customLogger: eventLogger("warn"),
687
+ plugins: [...inline.plugins, nativeAddonGuard()],
688
+ ssr: { ...(inline.ssr ?? {}), noExternal: true },
689
+ build: {
690
+ ...inline.build,
691
+ manifest: false,
692
+ // The map would describe this intermediate bundle rather than the
693
+ // binary, and nothing downstream reads it. Turning it off is a smaller
694
+ // `.uf/` and one less file to explain.
695
+ sourcemap: false,
696
+ ssr: true,
697
+ outDir: bundleDir,
698
+ emptyOutDir: false,
699
+ rollupOptions: {
700
+ input: { server: entry },
701
+ output: { entryFileNames: "server.js", format: "es", codeSplitting: false },
702
+ },
703
+ },
704
+ });
705
+
706
+ emit("done", { outDir: path.relative(root, bundleDir), pages: 0 });
358
707
  process.exit(0);
359
708
  }
360
709
 
361
- function readManifest(outDir) {
362
- const file = path.join(outDir, ".vite", "manifest.json");
363
- if (!existsSync(file)) throw new Error(`uf: the client build wrote no manifest at ${file}`);
364
- return JSON.parse(readFileSync(file, "utf8"));
710
+ /**
711
+ * Link the application into a directory that can be copied, for
712
+ * `uf build --adapter`.
713
+ *
714
+ * `uf start` serves a build and `uf build --compile` puts one inside an
715
+ * executable, and between them is the shape most hosts actually want: a
716
+ * directory you copy onto a machine that has a JavaScript runtime and nothing
717
+ * else — no `node_modules`, no checkout, no `uf`. That is what this writes.
718
+ *
719
+ * It differs from the server build in [`build`] in one way, and that one way
720
+ * is the whole of the difference between a build artefact and a checkout:
721
+ * `ssr.noExternal: true`. The ordinary server build leaves `react`,
722
+ * `react-dom` and every other dependency as bare imports, because the host it
723
+ * runs on has `node_modules` beside it; a copied directory does not, so they
724
+ * come in. (`@uniflowed/*` was never external — `index.js` sets
725
+ * `ssr.noExternal: [/^@uniflowed\//]` because Node cannot import Flow — which
726
+ * is why serving a build has never needed `uf transform` alive, and why the
727
+ * blocker ubugeeei-prod/uf#335 records was not one.)
728
+ *
729
+ * # Two entries, because an adapter is exactly one of them
730
+ *
731
+ * `handler.js` is the application as a Web-standard `fetch` export: a
732
+ * `Request` in, a `Response` out, no filesystem, no socket, no `node:` import
733
+ * that a worker does not already have. That is the seam — every other target
734
+ * in `app.runtime.deploy.adapters` is this file with a different thing wrapped
735
+ * around it.
736
+ *
737
+ * `server.js` is the wrapper for *this* target: `node:http`, with the build's
738
+ * files served from `static/` beside it. It is thirty lines, and that is the
739
+ * point — the work is in the handler, and what a second adapter has to write
740
+ * is the thirty lines, not the application.
741
+ *
742
+ * Both are ordinary entries of one Rolldown build, so `server.js` imports the
743
+ * emitted `handler.js` rather than a second copy of the application.
744
+ *
745
+ * The `static/` directory is *not* written here. `uf` copies it (see
746
+ * `uf_cli`'s `commands::deploy`), because walking an output directory and
747
+ * copying every file in it is bulk work over the whole build, which belongs in
748
+ * Rust rather than in the host process — the same division `--compile` makes
749
+ * with its embedded assets.
750
+ */
751
+ async function deploy() {
752
+ const vite = await import("vite");
753
+ const config = await loadConfig();
754
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
755
+ const outDir = path.resolve(root, inline.build.outDir);
756
+ const adapter = argument("--adapter");
757
+ const workArgument = argument("--work");
758
+ const outputArgument = argument("--output");
759
+ if (adapter == null || workArgument == null || outputArgument == null) {
760
+ throw new Error("uf: `driver.js deploy` needs --adapter, --work and --output");
761
+ }
762
+ // The Rust side has already refused every adapter it has no implementation
763
+ // for, by name and with the issue that tracks it. This is the second half of
764
+ // that fact rather than a duplicate of it: the driver may be spawned by a
765
+ // future `uf` that knows an adapter this copy does not, and answering "one
766
+ // moment, here is a directory" for a target nobody wrote would be the silent
767
+ // wrong answer the whole issue is about.
768
+ if (adapter !== "node") {
769
+ throw new Error(
770
+ `uf: this driver implements the \`node\` adapter and was asked for ${JSON.stringify(
771
+ adapter,
772
+ )}`,
773
+ );
774
+ }
775
+ const work = path.resolve(root, workArgument);
776
+ const output = path.resolve(root, outputArgument);
777
+
778
+ emit("phase", { name: adapter });
779
+
780
+ // Written to disk rather than served as virtual modules: they are generated
781
+ // per build — `handler.js` names this build's hashed assets — and a real
782
+ // file is the version a person can open when a deployed directory
783
+ // misbehaves.
784
+ mkdirSync(work, { recursive: true });
785
+ const document = assetsFromManifest(readManifest(outDir));
786
+ writeFileSync(path.join(work, "handler.js"), handlerEntrySource(document));
787
+ writeFileSync(path.join(work, "server.js"), nodeEntrySource("./handler.js"));
788
+
789
+ await vite.build({
790
+ ...inline,
791
+ customLogger: eventLogger("warn"),
792
+ plugins: [...inline.plugins, nativeAddonGuard()],
793
+ ssr: { ...(inline.ssr ?? {}), noExternal: true },
794
+ build: {
795
+ ...inline.build,
796
+ manifest: false,
797
+ // The map would describe this bundle rather than the source, and nothing
798
+ // downstream reads it. Off is a smaller directory to copy and one less
799
+ // file to explain.
800
+ sourcemap: false,
801
+ ssr: true,
802
+ outDir: output,
803
+ // `uf` has already removed the directory, and `static/` is copied in
804
+ // after this returns; letting Vite empty it would be Vite deciding when
805
+ // that happens.
806
+ emptyOutDir: false,
807
+ rollupOptions: {
808
+ input: {
809
+ handler: path.join(work, "handler.js"),
810
+ server: path.join(work, "server.js"),
811
+ },
812
+ output: {
813
+ entryFileNames: "[name].js",
814
+ // Route modules are lazy `import()`s, so the server bundle splits
815
+ // whether or not anything asks it to, and the chunks have to land
816
+ // somewhere. `chunks/` rather than the default `assets/`, because
817
+ // `static/assets/` beside it is the *client's* — two directories
818
+ // with one name in a directory whose whole purpose is to be copied
819
+ // and read by a stranger.
820
+ chunkFileNames: "chunks/[name]-[hash].js",
821
+ format: "es",
822
+ },
823
+ },
824
+ },
825
+ });
826
+
827
+ emit("done", { outDir: path.relative(root, output), pages: 0 });
828
+ process.exit(0);
365
829
  }
366
830
 
367
831
  /**
368
- * Script, stylesheet and preload URLs for the client entry chunk.
832
+ * The source of `handler.js`: the application, as one `fetch` export.
833
+ *
834
+ * `export default { fetch }` as well as the named export, because those are
835
+ * the two spellings the hosts this shape exists for actually read — a worker
836
+ * and Deno Deploy want the default export's `fetch`, and a Node or Bun entry
837
+ * wants the name. Writing both costs a line and removes the one thing that
838
+ * would make an otherwise portable file not portable.
839
+ *
840
+ * `beginRequest` is exported beside it, and it is not decoration. `fetch`
841
+ * answers with a `Response`; it does not know when that response reached
842
+ * anybody, and `after()` promises a callback once it has. So the host owns the
843
+ * request: begin it, run `fetch` inside `run`, and `settle` when the bytes are
844
+ * out — `server.js` below does exactly that through
845
+ * `@uniflowed/server/node`, and a worker hands `settle` to `ctx.waitUntil`.
846
+ * It comes from the bundle rather than from the host's own
847
+ * `@uniflowed/server`, because the request lives in an `AsyncLocalStorage`
848
+ * belonging to a module instance and the instance the application reads is the
849
+ * one inlined here. See ubugeeei-prod/uf#389.
369
850
  *
370
- * The entry is found by its `isEntry` flag rather than by key, because a
371
- * virtual module's manifest key is an implementation detail of the bundler.
851
+ * The document's script and stylesheet URLs are baked in here because they
852
+ * come from the client manifest, which exists at this moment and not in the
853
+ * directory that gets copied.
372
854
  */
855
+ function handlerEntrySource(document) {
856
+ return `// Generated by \`uf build --adapter\`. Not checked in, not edited.
857
+ import { createFetchHandler } from "@uniflowed/server/fetch";
858
+ import * as app from ${JSON.stringify(VIRTUAL.server)};
859
+
860
+ export const fetch = createFetchHandler({ app, document: ${JSON.stringify(document)} });
861
+ export const beginRequest = app.beginRequest;
862
+
863
+ export default { fetch, beginRequest };
864
+ `;
865
+ }
866
+
373
867
  /**
374
- * The tags a prerendered document needs.
375
- *
376
- * Two walks over the manifest, because the two answers are different. A
377
- * `modulepreload` is worth emitting only for a chunk this document will
378
- * certainly load, which is the entry's *static* imports. A stylesheet has to
379
- * be emitted for anything the page might render, and the router loads every
380
- * route module dynamically — so a stylesheet imported by a layout is reached
381
- * through `dynamicImports` and through nothing else. Following only the static
382
- * graph, as this did, meant a layout could import a stylesheet and the built
383
- * HTML would silently ship without it.
384
- *
385
- * The cost is that a project with per-route stylesheets links all of them on
386
- * every page. Narrowing that needs the route table to say which chunk each
387
- * route came from, which the manifest alone cannot tell us.
868
+ * The source of `server.js`: the Node socket around that handler.
869
+ *
870
+ * Everything host-specific about serving a build is in
871
+ * `@uniflowed/server/node`, which is the same module `uf start` reaches
872
+ * through `./internal/serve.js` so a request answered here and the same
873
+ * request answered by `uf start` go through one implementation, not two that
874
+ * agree today.
388
875
  */
389
- function assetsFromManifest(manifest) {
390
- const entry = Object.values(manifest).find((chunk) => chunk.isEntry);
391
- if (entry == null) throw new Error("uf: the client manifest has no entry chunk");
392
-
393
- const styles = new Set(entry.css ?? []);
394
- const seen = new Set();
395
- const collectStyles = (chunk) => {
396
- for (const imported of [...(chunk.imports ?? []), ...(chunk.dynamicImports ?? [])]) {
397
- if (seen.has(imported)) continue;
398
- seen.add(imported);
399
- const dependency = manifest[imported];
400
- if (dependency == null) continue;
401
- for (const css of dependency.css ?? []) styles.add(css);
402
- collectStyles(dependency);
403
- }
404
- };
405
- collectStyles(entry);
406
-
407
- const preloads = new Set();
408
- const collectPreloads = (chunk) => {
409
- for (const imported of chunk.imports ?? []) {
410
- const dependency = manifest[imported];
411
- if (dependency == null || preloads.has(dependency.file)) continue;
412
- preloads.add(dependency.file);
413
- collectPreloads(dependency);
414
- }
415
- };
416
- collectPreloads(entry);
876
+ function nodeEntrySource(handlerSpecifier) {
877
+ return `// Generated by \`uf build --adapter node\`. Not checked in, not edited.
878
+ import path from "node:path";
879
+ import { fileURLToPath } from "node:url";
880
+
881
+ import { serve } from "@uniflowed/server/node";
882
+
883
+ // \`beginRequest\` comes from the handler beside this file rather than from
884
+ // \`@uniflowed/server/node\` above, because the request has to be established in
885
+ // the storage the *application* reads, which is the copy bundled into
886
+ // \`handler.js\`. See ubugeeei-prod/uf#389.
887
+ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
888
+
889
+ // Resolved from this file and not from the working directory: a process
890
+ // manager, a container entrypoint and a person in a shell each start a server
891
+ // from wherever they happen to be, and a directory that only served its own
892
+ // assets when it was started from inside itself would be a deployment with a
893
+ // trap in it.
894
+ const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
895
+
896
+ // Not \`await serve(...)\` at the top level: Node runs top-level \`await\` happily
897
+ // and the Flow parser uf vendors does not (ubugeeei-prod/uf#204), so the generated
898
+ // entry would fail its own transform. \`.catch\` is the better spelling anyway —
899
+ // a server that cannot take its port should say so and exit non-zero, rather
900
+ // than die as an unhandled rejection.
901
+ serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
902
+ process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
903
+ process.exit(1);
904
+ });
905
+ `;
906
+ }
907
+
908
+ /**
909
+ * The source of the module a runtime gets wrapped around.
910
+ *
911
+ * Three imports and one call: the shim that serves, the application, and the
912
+ * bytes of `dist/`. The document's script and stylesheet URLs are baked in
913
+ * here because they come from the client manifest, which exists at this moment
914
+ * and not inside the binary.
915
+ */
916
+ function entrySource(assetsSpecifier, document) {
917
+ // Not `await serve(...)` at the top level. Node runs top-level `await`
918
+ // happily and the Flow parser uf vendors does not parse it (ubugeeei-prod/uf#204),
919
+ // so the generated entry would fail its own transform. `.catch` is the better
920
+ // spelling anyway: a binary that cannot take its port should say which port
921
+ // and exit non-zero, rather than die as an unhandled rejection.
922
+ return `// Generated by \`uf build --compile\`. Not checked in, not edited.
923
+ import { serve } from "@uniflowed/server/standalone";
924
+ import { assets } from ${JSON.stringify(assetsSpecifier)};
925
+ import * as app from ${JSON.stringify(VIRTUAL.server)};
417
926
 
927
+ serve({ app, assets, document: ${JSON.stringify(document)} }).catch((error) => {
928
+ process.stderr.write(\`uf: \${error?.message ?? String(error)}\n\`);
929
+ process.exit(1);
930
+ });
931
+ `;
932
+ }
933
+
934
+ /**
935
+ * Refuse a native addon by name instead of by stack trace.
936
+ *
937
+ * A `.node` file is a compiled shared object for one platform: it cannot be
938
+ * inlined into a JavaScript bundle, and a binary that carried one would stop
939
+ * being a single file. Without this, `ssr.noExternal: true` hands the addon to
940
+ * Rolldown and the build fails somewhere inside the bundler with a message
941
+ * about an unexpected character — which is true, and useless. Failing here
942
+ * with the addon's path and the importer that reached it is the difference
943
+ * between a feature and a trap.
944
+ *
945
+ * It catches what can be caught: a static `import` or `require` that resolves
946
+ * to a `.node` file. An addon loaded through a runtime string — `process.dlopen`,
947
+ * or `require(variable)` — is not visible to any bundler, so such a project
948
+ * still compiles and still fails on the first request that reaches the addon.
949
+ * That limit is real, it is not fixable from inside a bundler, and it is
950
+ * written down in the CLI reference rather than papered over.
951
+ */
952
+ function nativeAddonGuard() {
418
953
  return {
419
- scripts: [`/${entry.file}`],
420
- styles: [...styles].map((file) => `/${file}`),
421
- preloads: [...preloads].map((file) => `/${file}`),
954
+ name: "uf:no-native-addons",
955
+ enforce: "pre",
956
+ resolveId(source, importer) {
957
+ if (!source.endsWith(".node")) return null;
958
+ const from = importer == null ? "the application" : path.relative(root, importer);
959
+ throw new Error(
960
+ `${from} loads the native addon ${source}, and \`uf build --compile\` cannot put one ` +
961
+ "inside a single executable: a `.node` file is a shared object built for one " +
962
+ "platform, and embedding it would make the output two files rather than one. " +
963
+ "Build without `--compile` and deploy `dist/` with a runtime, or replace the " +
964
+ "dependency with one that has no native addon.",
965
+ );
966
+ },
422
967
  };
423
968
  }
424
969
 
970
+ /** `word`, pluralised for `count`. */
971
+ function plural(count, word) {
972
+ return count === 1 ? word : `${word}s`;
973
+ }
974
+
975
+ async function printConfig() {
976
+ const config = await loadConfig();
977
+ emit("config", { config: projectConfig(config) });
978
+ process.exit(0);
979
+ }
980
+
981
+ function readManifest(outDir) {
982
+ const file = path.join(outDir, ".vite", "manifest.json");
983
+ if (!existsSync(file)) throw new Error(`uf: the client build wrote no manifest at ${file}`);
984
+ return JSON.parse(readFileSync(file, "utf8"));
985
+ }
986
+
425
987
  /**
426
988
  * The URLs to prerender: every route without parameters, plus every set of
427
989
  * parameters a page's `generateStaticParams` returns.