@uniflowed/vite 0.0.0-alpha.7 → 0.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/driver.js CHANGED
@@ -5,13 +5,20 @@
5
5
  // The driver `uf dev`, `uf build`, `uf build --compile`, `uf preview` and
6
6
  // `uf start` spawn.
7
7
  //
8
- // <host> driver.js dev --root <dir> [--host <h>] [--port <n>] [--strict-port]
9
- // <host> driver.js build --root <dir> [--out-dir <dir>] [--mode <m>]
10
- // <host> driver.js compile --root <dir> [--out-dir <dir>] --assets <file> --bundle <dir>
11
- // <host> driver.js preview --root <dir> [--out-dir <dir>] [--host <h>] [--port <n>]
8
+ // <host> driver.js dev --root <dir> [--mode <m>] [--host <h>] [--port <n>] [--strict-port]
9
+ // <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>]
12
13
  // <host> driver.js start --root <dir> [--out-dir <dir>] [--host <h>] [--port <n>]
13
14
  // <host> driver.js config --root <dir>
14
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
+ //
15
22
  // `uf` in Rust owns the terminal; this process owns Vite. They talk over
16
23
  // stdout, one JSON event per line (see `./internal/events.js`), and the driver
17
24
  // exits when its stdin closes so it cannot outlive the command that started
@@ -29,6 +36,7 @@ import { pathToFileURL } from "node:url";
29
36
 
30
37
  import { emit, errorEvent, eventLogger, reportRenderError } from "./internal/events.js";
31
38
  import { loadUfConfig, projectConfig } from "./internal/config.js";
39
+ import { send, toRequest } from "./internal/http.js";
32
40
  import { withProjectConfig } from "./merge.js";
33
41
  import { VIRTUAL, scanRoutes } from "./internal/routes.js";
34
42
  import {
@@ -36,8 +44,7 @@ import {
36
44
  createServeHandler,
37
45
  loadBuild,
38
46
  nodeListener,
39
- send,
40
- toRequest,
47
+ withRequest,
41
48
  } from "./internal/serve.js";
42
49
 
43
50
  function argument(name) {
@@ -71,7 +78,7 @@ process.stdin.on("end", () => process.exit(0));
71
78
  process.stdin.on("error", () => process.exit(0));
72
79
  process.stdin.resume();
73
80
 
74
- const commands = { dev, build, compile, preview, start, config: printConfig };
81
+ const commands = { dev, build, compile, deploy, preview, start, config: printConfig };
75
82
  const run = commands[command];
76
83
  if (run == null) {
77
84
  emit("error", { message: `unknown driver command ${JSON.stringify(command)}` });
@@ -106,12 +113,23 @@ async function viteConfig(config, mode) {
106
113
  // merely passes on — `allowedHosts` gates binding a routable address, and
107
114
  // `manifest` is how the prerender finds its assets.
108
115
  //
109
- // `envDir: false` rather than `envFile: false`: Vite 8 deprecated the second
110
- // spelling and prints a line saying so on every dev server and every build,
111
- // twice in the docs site's. A project turns the loader back on with
112
- // `vite: { envDir: "." }`, which is the default directory the project's own
113
- // configuration is merged over this one, so it wins. See #259 for why uf
114
- // switches it off at all.
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.
115
133
  const generated = {
116
134
  root,
117
135
  configFile: false,
@@ -169,18 +187,29 @@ async function viteConfig(config, mode) {
169
187
  *
170
188
  * 1. load the server entry through `ssrLoadModule`, so it is transformed the
171
189
  * same way the browser's copy is and picks up edits without a restart;
172
- * 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
173
192
  * at a built asset;
174
- * 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
175
194
  * client and lets any Vite plugin see the document.
176
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
+ *
177
203
  * Anything Vite already serves — a module, a public file — never reaches this,
178
204
  * because the middleware runs after Vite's own.
179
205
  */
180
206
  async function dev() {
181
207
  const { createServer } = await import("vite");
182
208
  const config = await loadConfig();
183
- 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");
184
213
  const server = await createServer({ ...inline, appType: "custom" });
185
214
 
186
215
  // In dev the browser loads the client entry from Vite, not from a manifest;
@@ -189,33 +218,77 @@ async function dev() {
189
218
 
190
219
  server.middlewares.use(async (request, response, next) => {
191
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;
192
225
  try {
193
226
  const entry = await server.ssrLoadModule(VIRTUAL.server);
227
+ const asRequest = await toRequest(request, server.config);
194
228
 
195
- // Route handlers first, and for every method: a handler is the only
196
- // thing that answers a POST, and it may also answer a GET for a path
197
- // that has no page.
198
- const handled = await entry.dispatch(await toRequest(request, server.config));
199
- if (handled != null) {
200
- await send(response, handled);
201
- return;
202
- }
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
+ });
203
278
 
204
- // Only a navigation reaches the renderer. A page cannot answer a POST,
205
- // and letting one try would turn a missing handler into a rendered page
206
- // with a 200 rather than a 404.
207
- if (request.method !== "GET" && request.method !== "HEAD") {
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);
208
286
  next();
209
287
  return;
210
288
  }
211
-
212
- const result = await entry.render(url, assets);
213
- if (result.error != null) reportRenderError(server, url, result.error);
214
- const html = await server.transformIndexHtml(url, result.html);
215
- response.statusCode = result.status ?? 200;
216
- response.setHeader("content-type", "text/html; charset=utf-8");
217
- response.end(html);
289
+ await lifecycle.settle();
218
290
  } catch (error) {
291
+ if (lifecycle != null) await lifecycle.settle();
219
292
  // Map the stack back onto the Flow source before it reaches the overlay.
220
293
  if (error instanceof Error) server.ssrFixStacktrace(error);
221
294
  next(error);
@@ -231,6 +304,7 @@ async function dev() {
231
304
  (route) => route.path,
232
305
  ),
233
306
  });
307
+ watchSources(server);
234
308
 
235
309
  const shutdown = async () => {
236
310
  await server.close();
@@ -240,6 +314,43 @@ async function dev() {
240
314
  process.on("SIGTERM", shutdown);
241
315
  }
242
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
+
243
354
  /**
244
355
  * The preview server: the build, as Vite serves it.
245
356
  *
@@ -263,7 +374,7 @@ async function dev() {
263
374
  async function preview() {
264
375
  const { preview: startPreview } = await import("vite");
265
376
  const config = await loadConfig();
266
- const inline = await viteConfig(config, "production");
377
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
267
378
  const build = await loadBuild({
268
379
  root,
269
380
  outDir: inline.build.outDir,
@@ -274,7 +385,16 @@ async function preview() {
274
385
  const handle = createServeHandler(build);
275
386
  server.middlewares.use(async (request, response, next) => {
276
387
  try {
277
- await send(response, await handle(await toRequest(request, server.config)));
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
+ });
278
398
  } catch (error) {
279
399
  next(error);
280
400
  }
@@ -327,7 +447,7 @@ async function start() {
327
447
 
328
448
  const host = argument("--host") ?? process.env.HOST ?? "0.0.0.0";
329
449
  const port = Number(argument("--port") ?? process.env.PORT ?? 3000);
330
- const server = createHttpServer(nodeListener(createServeHandler(build)));
450
+ const server = createHttpServer(nodeListener(createServeHandler(build), build.entry));
331
451
 
332
452
  await new Promise((resolve, reject) => {
333
453
  server.once("error", reject);
@@ -410,6 +530,12 @@ async function build() {
410
530
  // `createRenderer` renders the error boundary and reports the exception on
411
531
  // the result — so both are checked here. Neither writes a file: an error
412
532
  // page written into `dist/` is a build that shipped its own failure.
533
+ //
534
+ // `prerender`, not `render`: a build wants the document React produces once
535
+ // every boundary has resolved, with the content where the fallback was. The
536
+ // streaming renderer would write a file whose slow parts are `<template>`
537
+ // elements waiting for a script — correct in a browser, blank to a crawler
538
+ // and to `curl`, which is most of what a static file is for.
413
539
  const failures = [];
414
540
  const failed = (url, error) => {
415
541
  failures.push(url);
@@ -418,7 +544,7 @@ async function build() {
418
544
  for (const url of pages) {
419
545
  let result;
420
546
  try {
421
- result = await server.render(url, assets);
547
+ result = await server.prerender(url, assets);
422
548
  } catch (error) {
423
549
  failed(url, error);
424
550
  continue;
@@ -447,16 +573,40 @@ async function build() {
447
573
  // `_uf.not-found.js` is in `app/guide/` would otherwise get a `404.html`
448
574
  // rendered from the framework's bare default, which is worse than the file
449
575
  // it used to write, which was none.
576
+ //
577
+ // Through the same two checks as the loop, and for the same reason. A
578
+ // not-found boundary is a component like any other: it can throw, and when it
579
+ // does `prerender` answers with the *error* page's HTML and a non-null
580
+ // `error` rather than rejecting. Writing that HTML and emitting `page` was a
581
+ // build publishing its own failure as `404.html` and exiting 0 — the static
582
+ // host would then serve uf's error page to every visitor who mistyped a URL,
583
+ // and nothing between the throw and the deploy would have mentioned it.
584
+ let attempted = pages.length;
450
585
  if (server.notFound.some((boundary) => boundary.path === "/")) {
451
- const result = await server.render("/__uf_not_found__", assets);
452
- const file = path.join(outDir, "404.html");
453
- writeFileSync(file, result.html);
454
- emit("page", {
455
- url: "/404",
456
- file: path.relative(root, file),
457
- status: 404,
458
- bytes: Buffer.byteLength(result.html),
459
- });
586
+ attempted += 1;
587
+ // `/404` rather than `/__uf_not_found__`: the internal path is how the
588
+ // router is asked, and the file the reader is looking for is `404.html`.
589
+ let result;
590
+ try {
591
+ result = await server.prerender("/__uf_not_found__", assets);
592
+ } catch (error) {
593
+ failed("/404", error);
594
+ result = null;
595
+ }
596
+ if (result != null && result.error != null) {
597
+ failed("/404", result.error);
598
+ result = null;
599
+ }
600
+ if (result != null) {
601
+ const file = path.join(outDir, "404.html");
602
+ writeFileSync(file, result.html);
603
+ emit("page", {
604
+ url: "/404",
605
+ file: path.relative(root, file),
606
+ status: 404,
607
+ bytes: Buffer.byteLength(result.html),
608
+ });
609
+ }
460
610
  }
461
611
 
462
612
  if (failures.length > 0) {
@@ -467,9 +617,12 @@ async function build() {
467
617
  // as the headline and the one a CI log's last line will be. It read
468
618
  // `... failed:` with the routes below it, and the headline was then a
469
619
  // sentence ending in a colon and nothing.
620
+ // `attempted`, not `pages.length`: the root 404 is prerendered too, and
621
+ // counting a failure of it against a total that excludes it produced
622
+ // "1 of 12" for a build that rendered thirteen things.
470
623
  emit("error", {
471
- message: `${failures.length} of ${pages.length} prerendered ${plural(
472
- pages.length,
624
+ message: `${failures.length} of ${attempted} prerendered ${plural(
625
+ attempted,
473
626
  "route",
474
627
  )} failed\n${failures.map((url) => ` ${url}`).join("\n")}`,
475
628
  });
@@ -574,6 +727,322 @@ async function compile() {
574
727
  process.exit(0);
575
728
  }
576
729
 
730
+ /**
731
+ * What each adapter links, and what it links it against.
732
+ *
733
+ * Every entry in this table produces the same `handler.js` — the application
734
+ * as `Request` → `Response`, from `@uniflowed/server/fetch` — and differs only
735
+ * in the file wrapped around it and, for a target whose dependencies have a
736
+ * different build, in the export conditions that pick one. That is the whole
737
+ * of what an adapter is, and keeping the differences in one object is what
738
+ * stops a second one from quietly becoming a second application.
739
+ *
740
+ * `bun`, `deno` and `static` are deliberately absent; `uf_config`'s
741
+ * `DeployAdapter::is_implemented` is the other half of that fact and
742
+ * `docs/app/reference/cli/_uf.page.mdx` says why for each of them.
743
+ */
744
+ const ADAPTERS = {
745
+ node: {
746
+ entries: (document) => ({
747
+ handler: handlerEntrySource(document),
748
+ server: nodeEntrySource("./handler.js"),
749
+ }),
750
+ },
751
+ // The same two files. What `--adapter container` adds is a `Dockerfile` and
752
+ // a `.dockerignore`, and both are plain text that `uf` writes beside this
753
+ // output rather than anything the bundler produces — see `uf_cli`'s
754
+ // `commands::deploy`.
755
+ container: {
756
+ entries: (document) => ({
757
+ handler: handlerEntrySource(document),
758
+ server: nodeEntrySource("./handler.js"),
759
+ }),
760
+ },
761
+ edge: {
762
+ entries: (document) => ({
763
+ handler: handlerEntrySource(document),
764
+ worker: workerEntrySource("./handler.js"),
765
+ }),
766
+ // `workerd` first, so React resolves to the build that has
767
+ // `renderToReadableStream` and no `node:stream`. `browser` and `module`
768
+ // after it are Vite's own SSR defaults, kept so a dependency with no
769
+ // worker condition still resolves the way it does for every other target.
770
+ conditions: ["workerd", "worker", "edge-light", "browser", "module", "import", "default"],
771
+ },
772
+ serverless: {
773
+ entries: (document) => ({
774
+ handler: handlerEntrySource(document),
775
+ lambda: lambdaEntrySource("./handler.js"),
776
+ }),
777
+ },
778
+ };
779
+
780
+ /**
781
+ * Link the application into a directory that can be copied, for
782
+ * `uf build --adapter`.
783
+ *
784
+ * `uf start` serves a build and `uf build --compile` puts one inside an
785
+ * executable, and between them is the shape most hosts actually want: a
786
+ * directory that carries everything and nothing that is still in the checkout
787
+ * — no `node_modules`, no source, no `uf`. That is what this writes, for
788
+ * whichever of [`ADAPTERS`] was asked for.
789
+ *
790
+ * It differs from the server build in [`build`] in one way, and that one way
791
+ * is the whole of the difference between a build artefact and a checkout:
792
+ * `ssr.noExternal: true`. The ordinary server build leaves `react`,
793
+ * `react-dom` and every other dependency as bare imports, because the host it
794
+ * runs on has `node_modules` beside it; a copied directory does not, so they
795
+ * come in. (`@uniflowed/*` was never external — `index.js` sets
796
+ * `ssr.noExternal: [/^@uniflowed\//]` because Node cannot import Flow — which
797
+ * is why serving a build has never needed `uf transform` alive, and why the
798
+ * blocker ubugeeei-prod/uf#335 records was not one.)
799
+ *
800
+ * # Two entries, because an adapter is exactly one of them
801
+ *
802
+ * `handler.js` is the application as a Web-standard `fetch` export: a
803
+ * `Request` in, a `Response` out, no filesystem, no socket, no `node:` import
804
+ * that a worker does not already have. That is the seam, and it is the same
805
+ * file for every target in [`ADAPTERS`].
806
+ *
807
+ * The second entry is the wrapper for *this* target — `node:http` for `node`
808
+ * and `container`, `export default { fetch }` for a Worker, `export const
809
+ * handler` for a Lambda — and each of them is a handful of lines around an
810
+ * import from `@uniflowed/server`. That is the point: the work is in the
811
+ * handler, and what a new adapter has to write is the handful of lines, not
812
+ * the application.
813
+ *
814
+ * Both are ordinary entries of one Rolldown build, so the wrapper imports the
815
+ * emitted `handler.js` rather than a second copy of the application.
816
+ *
817
+ * The `static/` directory is *not* written here. `uf` copies it (see
818
+ * `uf_cli`'s `commands::deploy`), because walking an output directory and
819
+ * copying every file in it is bulk work over the whole build, which belongs in
820
+ * Rust rather than in the host process — the same division `--compile` makes
821
+ * with its embedded assets.
822
+ */
823
+ async function deploy() {
824
+ const vite = await import("vite");
825
+ const config = await loadConfig();
826
+ const inline = await viteConfig(config, argument("--mode") ?? "production");
827
+ const outDir = path.resolve(root, inline.build.outDir);
828
+ const adapter = argument("--adapter");
829
+ const workArgument = argument("--work");
830
+ const outputArgument = argument("--output");
831
+ if (adapter == null || workArgument == null || outputArgument == null) {
832
+ throw new Error("uf: `driver.js deploy` needs --adapter, --work and --output");
833
+ }
834
+ // The Rust side has already refused every adapter it has no implementation
835
+ // for, by name and with the issue that tracks it. This is the second half of
836
+ // that fact rather than a duplicate of it: the driver may be spawned by a
837
+ // future `uf` that knows an adapter this copy does not, and answering "one
838
+ // moment, here is a directory" for a target nobody wrote would be the silent
839
+ // wrong answer the whole issue is about.
840
+ const shape = ADAPTERS[adapter];
841
+ if (shape == null) {
842
+ throw new Error(
843
+ `uf: this driver implements ${Object.keys(ADAPTERS)
844
+ .map((name) => JSON.stringify(name))
845
+ .join(", ")} and was asked for ${JSON.stringify(adapter)}`,
846
+ );
847
+ }
848
+ const work = path.resolve(root, workArgument);
849
+ const output = path.resolve(root, outputArgument);
850
+
851
+ emit("phase", { name: adapter });
852
+
853
+ // Written to disk rather than served as virtual modules: they are generated
854
+ // per build — `handler.js` names this build's hashed assets — and a real
855
+ // file is the version a person can open when a deployed directory
856
+ // misbehaves.
857
+ mkdirSync(work, { recursive: true });
858
+ const document = assetsFromManifest(readManifest(outDir));
859
+ const entries = shape.entries(document);
860
+ const input = {};
861
+ for (const name of Object.keys(entries)) {
862
+ writeFileSync(path.join(work, `${name}.js`), entries[name]);
863
+ input[name] = path.join(work, `${name}.js`);
864
+ }
865
+
866
+ const ssr = { ...(inline.ssr ?? {}), noExternal: true };
867
+ if (shape.conditions != null) {
868
+ // Which build of a dependency this target gets, and it is the difference
869
+ // between a worker that renders and one that fails to link. React ships
870
+ // `server.node.js` under the `node` condition and `server.edge.js` under
871
+ // `workerd`; the first one imports `node:stream`, and the router picks its
872
+ // renderer by asking whether `renderToPipeableStream` is there — so the
873
+ // condition list is what decides that, not a flag in the application.
874
+ ssr.resolve = { ...(inline.ssr?.resolve ?? {}), conditions: shape.conditions };
875
+ }
876
+
877
+ await vite.build({
878
+ ...inline,
879
+ customLogger: eventLogger("warn"),
880
+ plugins: [...inline.plugins, nativeAddonGuard()],
881
+ ssr,
882
+ build: {
883
+ ...inline.build,
884
+ manifest: false,
885
+ // The map would describe this bundle rather than the source, and nothing
886
+ // downstream reads it. Off is a smaller directory to copy and one less
887
+ // file to explain.
888
+ sourcemap: false,
889
+ ssr: true,
890
+ outDir: output,
891
+ // `uf` has already removed the directory, and `static/` is copied in
892
+ // after this returns; letting Vite empty it would be Vite deciding when
893
+ // that happens.
894
+ emptyOutDir: false,
895
+ rollupOptions: {
896
+ input,
897
+ output: {
898
+ entryFileNames: "[name].js",
899
+ // Route modules are lazy `import()`s, so the server bundle splits
900
+ // whether or not anything asks it to, and the chunks have to land
901
+ // somewhere. `chunks/` rather than the default `assets/`, because
902
+ // `static/assets/` beside it is the *client's* — two directories
903
+ // with one name in a directory whose whole purpose is to be copied
904
+ // and read by a stranger.
905
+ chunkFileNames: "chunks/[name]-[hash].js",
906
+ format: "es",
907
+ },
908
+ },
909
+ },
910
+ });
911
+
912
+ emit("done", { outDir: path.relative(root, output), pages: 0 });
913
+ process.exit(0);
914
+ }
915
+
916
+ /**
917
+ * The source of `handler.js`: the application, as one `fetch` export.
918
+ *
919
+ * `export default { fetch }` as well as the named export, because those are
920
+ * the two spellings the hosts this shape exists for actually read — a worker
921
+ * and Deno Deploy want the default export's `fetch`, and a Node or Bun entry
922
+ * wants the name. Writing both costs a line and removes the one thing that
923
+ * would make an otherwise portable file not portable.
924
+ *
925
+ * `beginRequest` is exported beside it, and it is not decoration. `fetch`
926
+ * answers with a `Response`; it does not know when that response reached
927
+ * anybody, and `after()` promises a callback once it has. So the host owns the
928
+ * request: begin it, run `fetch` inside `run`, and `settle` when the bytes are
929
+ * out — `server.js` below does exactly that through
930
+ * `@uniflowed/server/node`, and a worker hands `settle` to `ctx.waitUntil`.
931
+ * It comes from the bundle rather than from the host's own
932
+ * `@uniflowed/server`, because the request lives in an `AsyncLocalStorage`
933
+ * belonging to a module instance and the instance the application reads is the
934
+ * one inlined here. See ubugeeei-prod/uf#389.
935
+ *
936
+ * The document's script and stylesheet URLs are baked in here because they
937
+ * come from the client manifest, which exists at this moment and not in the
938
+ * directory that gets copied.
939
+ */
940
+ function handlerEntrySource(document) {
941
+ return `// Generated by \`uf build --adapter\`. Not checked in, not edited.
942
+ import { createFetchHandler } from "@uniflowed/server/fetch";
943
+ import * as app from ${JSON.stringify(VIRTUAL.server)};
944
+
945
+ export const fetch = createFetchHandler({ app, document: ${JSON.stringify(document)} });
946
+ export const beginRequest = app.beginRequest;
947
+
948
+ export default { fetch, beginRequest };
949
+ `;
950
+ }
951
+
952
+ /**
953
+ * The source of `server.js`: the Node socket around that handler.
954
+ *
955
+ * Everything host-specific about serving a build is in
956
+ * `@uniflowed/server/node`, which is the same module `uf start` reaches
957
+ * through `./internal/serve.js` — so a request answered here and the same
958
+ * request answered by `uf start` go through one implementation, not two that
959
+ * agree today.
960
+ */
961
+ function nodeEntrySource(handlerSpecifier) {
962
+ return `// Generated by \`uf build --adapter node\`. Not checked in, not edited.
963
+ import path from "node:path";
964
+ import { fileURLToPath } from "node:url";
965
+
966
+ import { serve } from "@uniflowed/server/node";
967
+
968
+ // \`beginRequest\` comes from the handler beside this file rather than from
969
+ // \`@uniflowed/server/node\` above, because the request has to be established in
970
+ // the storage the *application* reads, which is the copy bundled into
971
+ // \`handler.js\`. See ubugeeei-prod/uf#389.
972
+ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
973
+
974
+ // Resolved from this file and not from the working directory: a process
975
+ // manager, a container entrypoint and a person in a shell each start a server
976
+ // from wherever they happen to be, and a directory that only served its own
977
+ // assets when it was started from inside itself would be a deployment with a
978
+ // trap in it.
979
+ const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
980
+
981
+ // Not \`await serve(...)\` at the top level. uf parses that now
982
+ // (ubugeeei-prod/uf#204) and this entry is a module, so it would work; \`.catch\`
983
+ // is the better spelling regardless — a server that cannot take its port should
984
+ // say so and exit non-zero, rather than die as an unhandled rejection.
985
+ serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
986
+ process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
987
+ process.exit(1);
988
+ });
989
+ `;
990
+ }
991
+
992
+ /**
993
+ * The source of `worker.js`: the Cloudflare Workers entry around that handler.
994
+ *
995
+ * `export default { fetch }`, which is the modules-format Worker Cloudflare
996
+ * runs, and everything host-specific is in `@uniflowed/server/edge` — the
997
+ * asset lookup through the `ASSETS` binding `wrangler.json` declares, and the
998
+ * `ctx.waitUntil` that keeps the isolate alive for `after()`.
999
+ *
1000
+ * `beginRequest` comes from the handler beside this file for the reason
1001
+ * `nodeEntrySource` gives: the request has to be established in the storage the
1002
+ * *application* reads. See ubugeeei-prod/uf#389.
1003
+ */
1004
+ function workerEntrySource(handlerSpecifier) {
1005
+ return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1006
+ import { createWorkerFetch } from "@uniflowed/server/edge";
1007
+
1008
+ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1009
+
1010
+ export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1011
+ `;
1012
+ }
1013
+
1014
+ /**
1015
+ * The source of `lambda.js`: the AWS Lambda entry around that handler.
1016
+ *
1017
+ * `export const handler`, so the function's configured handler is
1018
+ * `lambda.handler`. Everything platform-specific — the payload format 2.0
1019
+ * event, the base64 rules, the `cookies` array — is in
1020
+ * `@uniflowed/server/lambda`.
1021
+ *
1022
+ * `staticDir` points at the `static/` copied beside this file, so an uploaded
1023
+ * package answers a prerendered document without any other infrastructure
1024
+ * existing. That is a starting point rather than a destination, and the module
1025
+ * it is passed to says so at length.
1026
+ */
1027
+ function lambdaEntrySource(handlerSpecifier) {
1028
+ return `// Generated by \`uf build --adapter serverless\`. Not checked in, not edited.
1029
+ import path from "node:path";
1030
+ import { fileURLToPath } from "node:url";
1031
+
1032
+ import { createLambdaHandler } from "@uniflowed/server/lambda";
1033
+
1034
+ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1035
+
1036
+ // Resolved from this file and not from the working directory: Lambda sets the
1037
+ // working directory to the task root today and is under no obligation to keep
1038
+ // doing so, and a deployment that only found its own assets by accident is a
1039
+ // deployment with a trap in it.
1040
+ const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1041
+
1042
+ export const handler = createLambdaHandler({ handle, beginRequest, staticDir });
1043
+ `;
1044
+ }
1045
+
577
1046
  /**
578
1047
  * The source of the module a runtime gets wrapped around.
579
1048
  *
@@ -583,11 +1052,11 @@ async function compile() {
583
1052
  * and not inside the binary.
584
1053
  */
585
1054
  function entrySource(assetsSpecifier, document) {
586
- // Not `await serve(...)` at the top level. Node runs top-level `await`
587
- // happily and the Flow parser uf vendors does not parse it (ubugeeei-prod/uf#204),
588
- // so the generated entry would fail its own transform. `.catch` is the better
589
- // spelling anyway: a binary that cannot take its port should say which port
590
- // and exit non-zero, rather than die as an unhandled rejection.
1055
+ // Not `await serve(...)` at the top level. uf parses that now
1056
+ // (ubugeeei-prod/uf#204) and this entry is a module, so it would work;
1057
+ // `.catch` is the better spelling regardless: a binary that cannot take its
1058
+ // port should say which port and exit non-zero, rather than die as an
1059
+ // unhandled rejection.
591
1060
  return `// Generated by \`uf build --compile\`. Not checked in, not edited.
592
1061
  import { serve } from "@uniflowed/server/standalone";
593
1062
  import { assets } from ${JSON.stringify(assetsSpecifier)};