@uniflowed/vite 0.0.0-alpha.7 → 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
@@ -8,6 +8,7 @@
8
8
  // <host> driver.js dev --root <dir> [--host <h>] [--port <n>] [--strict-port]
9
9
  // <host> driver.js build --root <dir> [--out-dir <dir>] [--mode <m>]
10
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>
11
12
  // <host> driver.js preview --root <dir> [--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>
@@ -29,6 +30,7 @@ import { pathToFileURL } from "node:url";
29
30
 
30
31
  import { emit, errorEvent, eventLogger, reportRenderError } from "./internal/events.js";
31
32
  import { loadUfConfig, projectConfig } from "./internal/config.js";
33
+ import { send, toRequest } from "./internal/http.js";
32
34
  import { withProjectConfig } from "./merge.js";
33
35
  import { VIRTUAL, scanRoutes } from "./internal/routes.js";
34
36
  import {
@@ -36,8 +38,7 @@ import {
36
38
  createServeHandler,
37
39
  loadBuild,
38
40
  nodeListener,
39
- send,
40
- toRequest,
41
+ withRequest,
41
42
  } from "./internal/serve.js";
42
43
 
43
44
  function argument(name) {
@@ -71,7 +72,7 @@ process.stdin.on("end", () => process.exit(0));
71
72
  process.stdin.on("error", () => process.exit(0));
72
73
  process.stdin.resume();
73
74
 
74
- const commands = { dev, build, compile, preview, start, config: printConfig };
75
+ const commands = { dev, build, compile, deploy, preview, start, config: printConfig };
75
76
  const run = commands[command];
76
77
  if (run == null) {
77
78
  emit("error", { message: `unknown driver command ${JSON.stringify(command)}` });
@@ -169,11 +170,19 @@ async function viteConfig(config, mode) {
169
170
  *
170
171
  * 1. load the server entry through `ssrLoadModule`, so it is transformed the
171
172
  * 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
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
173
175
  * at a built asset;
174
- * 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
175
177
  * client and lets any Vite plugin see the document.
176
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
+ *
177
186
  * Anything Vite already serves — a module, a public file — never reaches this,
178
187
  * because the middleware runs after Vite's own.
179
188
  */
@@ -189,33 +198,77 @@ async function dev() {
189
198
 
190
199
  server.middlewares.use(async (request, response, next) => {
191
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;
192
205
  try {
193
206
  const entry = await server.ssrLoadModule(VIRTUAL.server);
207
+ const asRequest = await toRequest(request, server.config);
194
208
 
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
- }
209
+ // The request begins here and ends when the document has been written,
210
+ // which is what `after()` promises and what `uf preview`, `uf start` and
211
+ // a compiled binary all do too — a middleware that logs a response's
212
+ // status has to mean the same thing in development as in production.
213
+ // `entry.beginRequest` rather than an import: the storage that holds the
214
+ // request belongs to the application's own copy of `@uniflowed/server`.
215
+ // See `internal/serve.js` and ubugeeei-prod/uf#389.
216
+ lifecycle = entry.beginRequest(asRequest);
217
+ const answered = await lifecycle.run(async () => {
218
+ // Middleware first, above everything: it guards a subtree, so it has to
219
+ // run for a page, for a route handler, and for a path under it that
220
+ // matches neither. Running it inside the dispatcher and again inside the
221
+ // renderer would have left `/dashboard/typo` unguarded and run it twice
222
+ // for a path that is both.
223
+ const guarded = await entry.runMiddleware(asRequest);
224
+ if (guarded != null) {
225
+ await send(response, guarded);
226
+ return true;
227
+ }
228
+
229
+ // Route handlers next, and for every method: a handler is the only
230
+ // thing that answers a POST, and it may also answer a GET for a path
231
+ // that has no page.
232
+ const handled = await entry.dispatch(asRequest);
233
+ if (handled != null) {
234
+ await send(response, handled);
235
+ return true;
236
+ }
237
+
238
+ // Only a navigation reaches the renderer. A page cannot answer a POST,
239
+ // and letting one try would turn a missing handler into a rendered page
240
+ // with a 200 rather than a 404.
241
+ if (request.method !== "GET" && request.method !== "HEAD") {
242
+ return false;
243
+ }
244
+
245
+ const result = await entry.render(url, assets, {
246
+ // A boundary that threw after the shell went out. `result.error` cannot
247
+ // carry it — the caller already has the result by then — so the
248
+ // terminal hears about it here or not at all.
249
+ onError: (error) => reportRenderError(server, url, error),
250
+ });
251
+ if (result.error != null) reportRenderError(server, url, result.error);
252
+ const html = await server.transformIndexHtml(url, await result.text());
253
+ response.statusCode = result.status ?? 200;
254
+ response.setHeader("content-type", "text/html; charset=utf-8");
255
+ response.end(html);
256
+ return true;
257
+ });
203
258
 
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") {
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);
208
266
  next();
209
267
  return;
210
268
  }
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);
269
+ await lifecycle.settle();
218
270
  } catch (error) {
271
+ if (lifecycle != null) await lifecycle.settle();
219
272
  // Map the stack back onto the Flow source before it reaches the overlay.
220
273
  if (error instanceof Error) server.ssrFixStacktrace(error);
221
274
  next(error);
@@ -231,6 +284,7 @@ async function dev() {
231
284
  (route) => route.path,
232
285
  ),
233
286
  });
287
+ watchSources(server);
234
288
 
235
289
  const shutdown = async () => {
236
290
  await server.close();
@@ -240,6 +294,43 @@ async function dev() {
240
294
  process.on("SIGTERM", shutdown);
241
295
  }
242
296
 
297
+ /**
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".
305
+ *
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.
314
+ */
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
+ });
331
+ }
332
+ }
333
+
243
334
  /**
244
335
  * The preview server: the build, as Vite serves it.
245
336
  *
@@ -274,7 +365,16 @@ async function preview() {
274
365
  const handle = createServeHandler(build);
275
366
  server.middlewares.use(async (request, response, next) => {
276
367
  try {
277
- await send(response, await handle(await toRequest(request, server.config)));
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
+ });
278
378
  } catch (error) {
279
379
  next(error);
280
380
  }
@@ -327,7 +427,7 @@ async function start() {
327
427
 
328
428
  const host = argument("--host") ?? process.env.HOST ?? "0.0.0.0";
329
429
  const port = Number(argument("--port") ?? process.env.PORT ?? 3000);
330
- const server = createHttpServer(nodeListener(createServeHandler(build)));
430
+ const server = createHttpServer(nodeListener(createServeHandler(build), build.entry));
331
431
 
332
432
  await new Promise((resolve, reject) => {
333
433
  server.once("error", reject);
@@ -410,6 +510,12 @@ async function build() {
410
510
  // `createRenderer` renders the error boundary and reports the exception on
411
511
  // the result — so both are checked here. Neither writes a file: an error
412
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.
413
519
  const failures = [];
414
520
  const failed = (url, error) => {
415
521
  failures.push(url);
@@ -418,7 +524,7 @@ async function build() {
418
524
  for (const url of pages) {
419
525
  let result;
420
526
  try {
421
- result = await server.render(url, assets);
527
+ result = await server.prerender(url, assets);
422
528
  } catch (error) {
423
529
  failed(url, error);
424
530
  continue;
@@ -447,16 +553,40 @@ async function build() {
447
553
  // `_uf.not-found.js` is in `app/guide/` would otherwise get a `404.html`
448
554
  // rendered from the framework's bare default, which is worse than the file
449
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;
450
565
  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
- });
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
+ }
460
590
  }
461
591
 
462
592
  if (failures.length > 0) {
@@ -467,9 +597,12 @@ async function build() {
467
597
  // as the headline and the one a CI log's last line will be. It read
468
598
  // `... failed:` with the routes below it, and the headline was then a
469
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.
470
603
  emit("error", {
471
- message: `${failures.length} of ${pages.length} prerendered ${plural(
472
- pages.length,
604
+ message: `${failures.length} of ${attempted} prerendered ${plural(
605
+ attempted,
473
606
  "route",
474
607
  )} failed\n${failures.map((url) => ` ${url}`).join("\n")}`,
475
608
  });
@@ -574,6 +707,204 @@ async function compile() {
574
707
  process.exit(0);
575
708
  }
576
709
 
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);
829
+ }
830
+
831
+ /**
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.
850
+ *
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.
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
+
867
+ /**
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.
875
+ */
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
+
577
908
  /**
578
909
  * The source of the module a runtime gets wrapped around.
579
910
  *