@uniflowed/vite 0.0.0-alpha.18 → 0.0.0-alpha.21

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
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // <host> driver.js dev --root <dir> [--mode <m>] [--host <h>] [--port <n>] [--strict-port]
9
9
  // [--uf-env-file <file>]...
10
- // <host> driver.js build --root <dir> [--mode <m>] [--out-dir <dir>]
10
+ // <host> driver.js build --root <dir> [--mode <m>] [--out-dir <dir>] [--target <target>]
11
11
  // [--prerender everything|possible|nothing]
12
12
  // [--static-build] [--because <sentence>]
13
13
  // <host> driver.js library --root <dir> [--mode <m>] [--out-dir <dir>]
@@ -39,6 +39,7 @@
39
39
  // Rust side reads a config that may hold functions and plugin instances: the
40
40
  // one host that can evaluate the file evaluates it.
41
41
 
42
+ import { randomUUID } from "node:crypto";
42
43
  import { createServer as createHttpServer } from "node:http";
43
44
  import { builtinModules, register } from "node:module";
44
45
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -49,14 +50,18 @@ import { COMPILE_ASSETS_ID, compileAssetsPlugin } from "./internal/compile-asset
49
50
  import { emit, errorEvent, eventLogger } from "./internal/events.js";
50
51
  import { loadUfConfig, projectConfig } from "./internal/config.js";
51
52
  import { send, toRequest } from "./internal/http.js";
53
+ import { createOpenApiDocument } from "./internal/openapi.js";
52
54
  import { withProjectConfig } from "./merge.js";
53
- import { VIRTUAL, scanRoutes } from "./internal/routes.js";
55
+ import { VIRTUAL, resolveRouteTarget, scanRoutes } from "./internal/routes.js";
54
56
  import {
57
+ BUILD_ID_FILE,
55
58
  assetsFromManifest,
59
+ buildIdentity,
56
60
  createPrerenderGate,
57
61
  createServeHandler,
58
62
  loadBuild,
59
63
  nodeListener,
64
+ providerSpecifier,
60
65
  withRequest,
61
66
  } from "./internal/serve.js";
62
67
 
@@ -113,6 +118,33 @@ run().catch((error) => {
113
118
  process.exit(1);
114
119
  });
115
120
 
121
+ /**
122
+ * What this build is called, for anything that outlives it.
123
+ *
124
+ * `UF_BUILD_ID` when it is set, and a fresh random name otherwise — which is
125
+ * `crates/uf_rsc`'s `BuildId::from_env_or_generate` exactly, reading the same
126
+ * variable, because it is the same question asked by a different half of the
127
+ * toolchain. Two artefacts that have to *be* one build say so with the
128
+ * variable; everything else gets a name no other build has.
129
+ *
130
+ * Minted once per build and written into the build, never per process. Four
131
+ * servers started from one artefact are one build and must share one cache;
132
+ * generating this where the server starts would give them four, which is worse
133
+ * than none at all — four copies of everything written into one directory and
134
+ * none of them read.
135
+ *
136
+ * Not a hash of the output. A content hash would be reproducible, which is
137
+ * appealing and wrong here: two builds with identical client bundles can have
138
+ * different server behaviour — a loader's body moves and no asset hash does —
139
+ * and a cache keyed by one would serve the old loader's documents. A name that
140
+ * changes whenever the build ran is the conservative direction to be wrong in.
141
+ */
142
+ function mintBuildId() {
143
+ const named = process.env.UF_BUILD_ID;
144
+ if (typeof named === "string" && named.trim() !== "") return named.trim();
145
+ return randomUUID().replaceAll("-", "");
146
+ }
147
+
116
148
  /** Load `uf.config.js`, reporting where it was found. */
117
149
  async function loadConfig() {
118
150
  const { config, file } = await loadUfConfig(root);
@@ -130,6 +162,7 @@ async function viteConfig(config, mode) {
130
162
  const port = Number(argument("--port") ?? dev.port ?? 5173);
131
163
  const allowedHosts =
132
164
  Array.isArray(dev.allowedHosts) && dev.allowedHosts.length > 0 ? dev.allowedHosts : undefined;
165
+ const routeTarget = resolveRouteTarget(config, argument("--target"));
133
166
 
134
167
  // What uf generates from the semantics it owns: where the project is, which
135
168
  // plugins make Flow compile, and the few settings uf enforces rather than
@@ -160,7 +193,7 @@ async function viteConfig(config, mode) {
160
193
  mode,
161
194
  clearScreen: false,
162
195
  customLogger: eventLogger(argument("--log-level") ?? "info"),
163
- plugins: [uniflowed({ root, config })],
196
+ plugins: [uniflowed({ root, config, target: routeTarget })],
164
197
  server: {
165
198
  host,
166
199
  port,
@@ -224,6 +257,7 @@ async function viteConfig(config, mode) {
224
257
  async function dev() {
225
258
  const { createServer } = await import("vite");
226
259
  const config = await loadConfig();
260
+ const routeTarget = resolveRouteTarget(config, argument("--target"));
227
261
  // The mode is uf's to decide, not this file's: `uf dev` resolves `--mode`,
228
262
  // the profile `uf env use` wrote and `env.active` before it starts anything,
229
263
  // and always passes the answer. The fallback is for a driver started by hand.
@@ -235,9 +269,9 @@ async function dev() {
235
269
  emit("listening", {
236
270
  local: urls.local,
237
271
  network: urls.network,
238
- routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map(
239
- (route) => route.path,
240
- ),
272
+ routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app"), {
273
+ target: routeTarget,
274
+ }).routes.map((route) => route.path),
241
275
  });
242
276
  watchSources(server);
243
277
  watchEnvFiles(server);
@@ -359,6 +393,7 @@ function watchEnvFiles(server) {
359
393
  async function preview() {
360
394
  const { preview: startPreview } = await import("vite");
361
395
  const config = await loadConfig();
396
+ const routeTarget = resolveRouteTarget(config, argument("--target"));
362
397
  const inline = await viteConfig(config, argument("--mode") ?? "production");
363
398
  // A build that declared it emits no server has none to mount. `uf` refuses
364
399
  // `uf start` for such a project and lets this one through, because a preview
@@ -423,6 +458,30 @@ async function preview() {
423
458
  if (handle != null) {
424
459
  server.middlewares.use(answer(server));
425
460
  }
461
+ // The rewrite rule a single-page deployment needs, in the one place uf can
462
+ // apply one. A `["csr"]` build writes `index.html` and nothing else that is a
463
+ // page, so a file server answers `/` and 404s every other URL — and this
464
+ // preview exists to be believed about the deployment. A host serving that
465
+ // build has to send unmatched paths to the shell, so a preview that did not
466
+ // would be right about a deployment nobody is doing.
467
+ //
468
+ // Behind the static middleware, which is what makes it a *fallback*: a real
469
+ // file still wins, so `/assets/client.js` is still the chunk and not the
470
+ // shell. And `Accept: text/html` only, so a `fetch` for a missing JSON file
471
+ // gets a 404 rather than a document — the failure mode of a fallback that
472
+ // answers everything is a parse error two layers away from the missing file.
473
+ if (flag("--spa-fallback")) {
474
+ const shell = path.resolve(root, inline.build.outDir, "index.html");
475
+ server.middlewares.use((request, response, next) => {
476
+ if (request.method !== "GET" && request.method !== "HEAD") return next();
477
+ if (!(request.headers.accept ?? "").includes("text/html")) return next();
478
+ if (!existsSync(shell)) return next();
479
+ response.statusCode = 200;
480
+ response.setHeader("content-type", "text/html; charset=utf-8");
481
+ response.end(request.method === "HEAD" ? undefined : readFileSync(shell));
482
+ return undefined;
483
+ });
484
+ }
426
485
 
427
486
  const urls = server.resolvedUrls ?? { local: [], network: [] };
428
487
  emit("listening", {
@@ -434,9 +493,9 @@ async function preview() {
434
493
  // be the report being wrong about the thing it exists to report.
435
494
  routes:
436
495
  build == null
437
- ? scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map(
438
- (route) => route.path,
439
- )
496
+ ? scanRoutes(path.resolve(root, config.app?.router?.root ?? "app"), {
497
+ target: routeTarget,
498
+ }).routes.map((route) => route.path)
440
499
  : build.entry.routes.map((route) => route.path),
441
500
  handlers: build == null ? [] : build.entry.handlers.map((handler) => handler.path),
442
501
  });
@@ -560,6 +619,13 @@ async function build() {
560
619
  },
561
620
  },
562
621
  });
622
+ // What this build is, beside the bundle it is about. One line, written by
623
+ // every build and read by nothing unless a project turned a durable cache on
624
+ // — at which point it is the thing that stops a deploy answering the new
625
+ // build's URLs with the previous build's documents. See
626
+ // `packages/server/internal/cache-key.js`, which argues the whole of it, and
627
+ // `internal/serve.js`'s `buildIdentity`, which is what reads this.
628
+ writeFileSync(path.join(serverDir, BUILD_ID_FILE), `${mintBuildId()}\n`);
563
629
 
564
630
  // 3. Which routes this build renders when, and every route it renders now.
565
631
  //
@@ -570,6 +636,10 @@ async function build() {
570
636
  emit("phase", { name: "prerender" });
571
637
  const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
572
638
  const assets = assetsFromManifest(manifest);
639
+ const openapi = await createOpenApiDocument(server.handlers);
640
+ const openapiFile = path.join(root, ".uf", "build", "meta", "openapi.json");
641
+ mkdirSync(path.dirname(openapiFile), { recursive: true });
642
+ writeFileSync(openapiFile, `${JSON.stringify(openapi, null, 2)}\n`);
573
643
  const plan = await renderingPlan(server, prerender);
574
644
  emit("rendering", {
575
645
  prerender,
@@ -645,7 +715,7 @@ async function build() {
645
715
  //
646
716
  // The condition is "there is a root boundary", not "there is any boundary",
647
717
  // because `/__uf_not_found__` is a path at the root: a project whose only
648
- // `_uf.not-found.js` is in `app/guide/` would otherwise get a `404.html`
718
+ // `$not-found.js` is in `app/guide/` would otherwise get a `404.html`
649
719
  // rendered from the framework's bare default, which is worse than the file
650
720
  // it used to write, which was none.
651
721
  //
@@ -657,11 +727,50 @@ async function build() {
657
727
  // host would then serve uf's error page to every visitor who mistyped a URL,
658
728
  // and nothing between the throw and the deploy would have mentioned it.
659
729
  let attempted = pages.length;
730
+ // The single-page build's whole output, written here because it is the one
731
+ // document this build has and the loop above had no route to write it for.
732
+ //
733
+ // Twice, to two names, and the second is the load-bearing one. `index.html`
734
+ // is what a host serves for `/`; `404.html` is what a static host serves for
735
+ // every path it has no file for, which under this plan is *every other URL
736
+ // in the application*. Without it a deployment of `dist/` answers `/` and
737
+ // 404s `/orders` — a build with a hole in it, found from a 404, which is the
738
+ // failure ubugeeei-prod/uf#336 is about wearing a different hat.
739
+ //
740
+ // It is still the host's rewrite rule that makes this correct, and the two
741
+ // files are what uf can do without one: a host with a proper SPA fallback
742
+ // serves `index.html` and never looks at `404.html`, and a host with only an
743
+ // error document (Pages, Netlify, an S3 bucket) serves `404.html` and gets
744
+ // the same bytes with a 404 status — which is the right status for a URL
745
+ // this application does not have, and the not-found boundary is what the
746
+ // browser then renders into it.
747
+ if (prerender === "shell") {
748
+ const html = server.shellDocument(assets);
749
+ for (const [file, url, status] of [
750
+ ["index.html", "/", 200],
751
+ ["404.html", "/404", 404],
752
+ ]) {
753
+ const target = path.join(outDir, file);
754
+ writeFileSync(target, html);
755
+ attempted += 1;
756
+ emit("page", {
757
+ url,
758
+ file: path.relative(root, target),
759
+ status,
760
+ bytes: Buffer.byteLength(html),
761
+ });
762
+ }
763
+ }
660
764
  // Not for a build that prerenders nothing. `404.html` is a file a static
661
765
  // host serves for every path it has no file for, and a project whose
662
766
  // `rendering.modes` allows only `ssr` has no such host: its not-found
663
767
  // boundary is rendered per request, by the server, with the right status.
664
- if (prerender !== "nothing" && server.notFound.some((boundary) => boundary.path === "/")) {
768
+ //
769
+ // Nor for a shell build, which has just written its own: the boundary this
770
+ // would render is one the *browser* renders in that plan, and a document
771
+ // holding the framework's 404 markup would be served in place of the shell
772
+ // for every URL the host could not match.
773
+ else if (prerender !== "nothing" && server.notFound.some((boundary) => boundary.path === "/")) {
665
774
  attempted += 1;
666
775
  // `/404` rather than `/__uf_not_found__`: the internal path is how the
667
776
  // router is asked, and the file the reader is looking for is `404.html`.
@@ -969,15 +1078,15 @@ async function compile() {
969
1078
  * of what an adapter is, and keeping the differences in one object is what
970
1079
  * stops a second one from quietly becoming a second application.
971
1080
  *
972
- * `bun`, `deno` and `static` are deliberately absent; `uf_config`'s
1081
+ * `static` is deliberately absent; `uf_config`'s
973
1082
  * `DeployAdapter::is_implemented` is the other half of that fact and
974
- * `docs/app/reference/cli/_uf.page.mdx` says why for each of them.
1083
+ * `docs/app/reference/cli/$page.mdx` says why.
975
1084
  */
976
1085
  const ADAPTERS = {
977
1086
  node: {
978
- entries: (document, cache) => ({
979
- handler: handlerEntrySource(document, cache, NODE_CAPABILITIES),
980
- server: nodeEntrySource("./handler.js"),
1087
+ entries: (document, cache, build, schedules) => ({
1088
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build),
1089
+ server: nodeEntrySource("./handler.js", schedules),
981
1090
  }),
982
1091
  },
983
1092
  // The same two files as `node`, with `@uniflowed/server/bun` in place of
@@ -995,9 +1104,15 @@ const ADAPTERS = {
995
1104
  // nobody here. `edge` pays that price because it must: there is no
996
1105
  // `node:stream` in a Worker.
997
1106
  bun: {
998
- entries: (document, cache) => ({
999
- handler: handlerEntrySource(document, cache, BUN_CAPABILITIES),
1000
- server: bunEntrySource("./handler.js"),
1107
+ entries: (document, cache, build, schedules) => ({
1108
+ handler: handlerEntrySource(document, cache, BUN_CAPABILITIES, build),
1109
+ server: bunEntrySource("./handler.js", schedules),
1110
+ }),
1111
+ },
1112
+ deno: {
1113
+ entries: (document, cache, build, schedules) => ({
1114
+ handler: handlerEntrySource(document, cache, DENO_CAPABILITIES, build),
1115
+ server: denoEntrySource("./handler.js", schedules),
1001
1116
  }),
1002
1117
  },
1003
1118
  // The same two files. What `--adapter container` adds is a `Dockerfile` and
@@ -1005,25 +1120,31 @@ const ADAPTERS = {
1005
1120
  // output rather than anything the bundler produces — see `uf_cli`'s
1006
1121
  // `commands::deploy`.
1007
1122
  container: {
1008
- entries: (document, cache) => ({
1009
- handler: handlerEntrySource(document, cache, NODE_CAPABILITIES),
1010
- server: nodeEntrySource("./handler.js"),
1123
+ entries: (document, cache, build, schedules) => ({
1124
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build),
1125
+ server: nodeEntrySource("./handler.js", schedules),
1011
1126
  }),
1012
1127
  },
1013
1128
  edge: {
1014
- entries: (document, cache) => ({
1015
- handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES),
1016
- worker: workerEntrySource("./handler.js"),
1129
+ entries: (document, cache, build, schedules) => ({
1130
+ handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES, build),
1131
+ worker: workerEntrySource("./handler.js", schedules),
1017
1132
  }),
1018
1133
  // `workerd` first, so React resolves to the build that has
1019
1134
  // `renderToReadableStream` and no `node:stream`. `browser` and `module`
1020
1135
  // after it are Vite's own SSR defaults, kept so a dependency with no
1021
1136
  // worker condition still resolves the way it does for every other target.
1022
1137
  conditions: ["workerd", "worker", "edge-light", "browser", "module", "import", "default"],
1138
+ // A Worker has no filesystem, so uf's built-in durable provider cannot run
1139
+ // here. Refused by name at the build rather than linked into a bundle that
1140
+ // fails on its first `node:fs` import — the deployment rule is that a
1141
+ // target which cannot provide a durable store says so, and this is the one
1142
+ // target that cannot.
1143
+ filesystem: false,
1023
1144
  },
1024
1145
  serverless: {
1025
- entries: (document, cache) => ({
1026
- handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES),
1146
+ entries: (document, cache, build) => ({
1147
+ handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES, build),
1027
1148
  lambda: lambdaEntrySource("./handler.js"),
1028
1149
  }),
1029
1150
  },
@@ -1048,6 +1169,7 @@ const ADAPTERS = {
1048
1169
  */
1049
1170
  const NODE_CAPABILITIES = { module: "@uniflowed/server/node", name: "nodeCapabilities" };
1050
1171
  const BUN_CAPABILITIES = { module: "@uniflowed/server/bun", name: "bunCapabilities" };
1172
+ const DENO_CAPABILITIES = { module: "@uniflowed/server/deno", name: "denoCapabilities" };
1051
1173
  const EDGE_CAPABILITIES = { module: "@uniflowed/server/edge", name: "edgeCapabilities" };
1052
1174
  const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lambdaCapabilities" };
1053
1175
 
@@ -1099,7 +1221,7 @@ const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lam
1099
1221
  * It is the one implemented target with no application to link: a static host
1100
1222
  * returns files, and `uf build` has already written them. So `uf` copies the
1101
1223
  * output directory itself and never spawns this driver for it, which is why
1102
- * [`ADAPTERS`] has four rows and not five. What that target does instead of
1224
+ * [`ADAPTERS`] has six rows and not seven. What that target does instead of
1103
1225
  * linking is refuse a project whose route handlers, middleware, unprerendered
1104
1226
  * routes or server actions a static host cannot answer — in Rust, because the
1105
1227
  * facts it needs are the route table and what the prerender reported.
@@ -1115,6 +1237,12 @@ async function deploy() {
1115
1237
  if (adapter == null || workArgument == null || outputArgument == null) {
1116
1238
  throw new Error("uf: `driver.js deploy` needs --adapter, --work and --output");
1117
1239
  }
1240
+ // What the project declared, read by `uf`'s own walk of the route handlers
1241
+ // and handed over rather than found again here — one reading of a module,
1242
+ // and the same list `wrangler.json`'s `triggers.crons` is written from.
1243
+ // Absent on an older `uf` spawning a newer driver, which is a build with no
1244
+ // schedules rather than an error. See ubugeeei-prod/uf#531.
1245
+ const schedules = JSON.parse(argument("--schedules") ?? "[]");
1118
1246
  // The Rust side has already refused every adapter it has no implementation
1119
1247
  // for, by name and with the issue that tracks it. This is the second half of
1120
1248
  // that fact rather than a duplicate of it: the driver may be spawned by a
@@ -1140,7 +1268,22 @@ async function deploy() {
1140
1268
  // misbehaves.
1141
1269
  mkdirSync(work, { recursive: true });
1142
1270
  const document = assetsFromManifest(readManifest(outDir));
1143
- const entries = shape.entries(document, config.app?.rendering?.cache);
1271
+ // Whatever `build` above minted, so a durable cache in the deployed artefact
1272
+ // is keyed by the build that produced it and not by the moment it was
1273
+ // packaged. Read rather than minted again for exactly that reason: a second
1274
+ // `randomUUID()` here would key the adapter's copy differently from the one
1275
+ // `uf start` serves out of `.uf/build/`, which is two caches for one build.
1276
+ const buildId = await buildIdentity(root, path.join(".uf", "build", "server"));
1277
+ const cacheConfig = config.app?.rendering?.cache;
1278
+ if (shape.filesystem === false && cacheConfig?.store === "filesystem") {
1279
+ throw new Error(
1280
+ `uf: rendering.cache.store is "filesystem" and \`--adapter ${adapter}\` has no ` +
1281
+ "filesystem. Name a module exporting `createCacheProvider` instead — a KV " +
1282
+ "namespace or a Redis behind the same seam — or leave the store in memory. See " +
1283
+ "docs/app/guide/cache.",
1284
+ );
1285
+ }
1286
+ const entries = shape.entries(document, cacheConfig, buildId, schedules);
1144
1287
  const input = {};
1145
1288
  for (const name of Object.keys(entries)) {
1146
1289
  writeFileSync(path.join(work, `${name}.js`), entries[name]);
@@ -1230,8 +1373,22 @@ async function deploy() {
1230
1373
  * re-exported above — a module-level singleton belongs to whichever copy of the
1231
1374
  * package a bundler happened to give it, and the copy that matters is the one
1232
1375
  * the application resolved. See ubugeeei-prod/uf#277 and #389.
1376
+ *
1377
+ * # And where a durable store is named
1378
+ *
1379
+ * `rendering.cache.store` is the fifth key, and it is the one that turns the
1380
+ * store into a shared one: `"filesystem"` links uf's built-in provider, and
1381
+ * anything else is a module specifier the project wrote, imported here by name
1382
+ * so the bundler links it like any other dependency of the application. Neither
1383
+ * appears at all when the key is absent, which is what a default project keeps.
1384
+ *
1385
+ * `build` is baked in beside it, and it is the reason this can be an `import`
1386
+ * at all rather than something read at boot: the build id is a fact about the
1387
+ * artefact being written, known here and nowhere later. `internal/serve.js`'s
1388
+ * `buildIdentity` is where it came from and
1389
+ * `packages/server/internal/cache-key.js` is why it exists.
1233
1390
  */
1234
- function handlerEntrySource(document, cache, capabilities) {
1391
+ function handlerEntrySource(document, cache, capabilities, build) {
1235
1392
  const route = cache?.route === true;
1236
1393
  const fetchCache = cache?.fetch === true;
1237
1394
  // Nothing at all when both switches are off, so a default project's
@@ -1239,6 +1396,7 @@ function handlerEntrySource(document, cache, capabilities) {
1239
1396
  // generated output nobody asked for is the second half of the complaint
1240
1397
  // #277 makes about the first half.
1241
1398
  const store = route || fetchCache;
1399
+ const durable = store ? durableStoreSource(root, cache, build) : null;
1242
1400
  const options = [
1243
1401
  "app",
1244
1402
  `document: ${JSON.stringify(document)}`,
@@ -1246,23 +1404,33 @@ function handlerEntrySource(document, cache, capabilities) {
1246
1404
  "capabilities",
1247
1405
  ].join(", ");
1248
1406
  const cacheImport = store ? 'import { createCacheStore } from "@uniflowed/server/cache";\n' : "";
1407
+ const providerImport = durable == null ? "" : `${durable.import}\n`;
1249
1408
  const from = JSON.stringify(capabilities.module);
1250
1409
  const capabilityImport = `import { ${capabilities.name} } from ${from};`;
1251
1410
  return `// Generated by \`uf build --adapter\`. Not checked in, not edited.
1252
1411
  import { createFetchHandler } from "@uniflowed/server/fetch";
1253
- ${cacheImport}${capabilityImport}
1412
+ ${cacheImport}${providerImport}${capabilityImport}
1254
1413
  import * as app from ${JSON.stringify(VIRTUAL.server)};
1255
1414
 
1256
1415
  ${
1257
1416
  store
1258
- ? `// \`rendering.cache\` from uf.config.js. One store per process: it is
1417
+ ? `// \`rendering.cache\` from uf.config.js. ${
1418
+ durable == null
1419
+ ? `One store per process: it is
1259
1420
  // emptied by a restart and is not shared with any other instance of this
1260
- // application. See ubugeeei-prod/uf#277.
1261
- const cache = { store: createCacheStore(), route: ${String(route)}, fetch: ${String(fetchCache)} };
1421
+ // application.`
1422
+ : `Entries are kept by ${durable.what},
1423
+ // under this build's identity, so a restart finds them where it left them and
1424
+ // every process of this deployment reads one store — and \`revalidateTag\` in
1425
+ // any of them takes an entry out of the store all of them fill from.`
1426
+ } See ubugeeei-prod/uf#277.
1427
+ const cache = { store: createCacheStore(${
1428
+ durable == null ? "" : `{ provider: ${durable.provider}, build: ${JSON.stringify(build)} }`
1429
+ }), route: ${String(route)}, fetch: ${String(fetchCache)} };
1262
1430
 
1263
1431
  `
1264
1432
  : ""
1265
- }// What this target can do, and it is not the same for all four: whether a
1433
+ }// What this target can do, and it is not the same for all six: whether a
1266
1434
  // response body reaches the client as it is produced, and whether the process
1267
1435
  // is still there once it has. A route handler that streams events or takes a
1268
1436
  // socket asks through this rather than finding out in production. Nothing is
@@ -1277,6 +1445,89 @@ export default { fetch, beginRequest };
1277
1445
  `;
1278
1446
  }
1279
1447
 
1448
+ /**
1449
+ * The import and the expression that give a generated handler a durable store.
1450
+ *
1451
+ * `null` for `"memory"` and for a project that said nothing, which is every
1452
+ * project until one asks: persistence is a second opt-in on top of `route` and
1453
+ * `fetch`, not something a build decides on a project's behalf.
1454
+ *
1455
+ * The directory is baked in as written rather than resolved here, and that is
1456
+ * deliberate. This function runs on the machine doing the build; the path has
1457
+ * to mean something on the machine doing the *serving*, which may be a
1458
+ * container with one writable mount or a Lambda with only `/tmp`. A relative
1459
+ * one is resolved against the working directory at boot, by the provider, where
1460
+ * the answer is a fact rather than a guess.
1461
+ *
1462
+ * The specifier is resolved against the project for the same reason
1463
+ * `internal/serve.js`'s `providerSpecifier` does it: `"./cache/redis.js"` in
1464
+ * `uf.config.js` is relative to the project, and this file is written into
1465
+ * `.uf/deploy/work/`, where that path means nothing. Absolute is safe here
1466
+ * because the bundler inlines the module rather than emitting the specifier.
1467
+ *
1468
+ * @param {string} root
1469
+ * @param {{store?: string, storeDir?: string}} cache
1470
+ * @param {string | null} build
1471
+ */
1472
+ function durableStoreSource(root, cache, build) {
1473
+ const named = cache?.store ?? "memory";
1474
+ if (named === "memory") return null;
1475
+ if (build == null) {
1476
+ throw new Error(
1477
+ `uf: rendering.cache.store is ${JSON.stringify(named)}, which keeps entries between ` +
1478
+ "restarts, and this build has no identity to key them by. Run `uf build` so one is " +
1479
+ "written, or set UF_BUILD_ID. Without one the deployment would answer this build's " +
1480
+ "URLs with the previous build's documents.",
1481
+ );
1482
+ }
1483
+ const directory = JSON.stringify(cache?.storeDir ?? path.join(".uf", "cache", "route"));
1484
+ if (named === "filesystem") {
1485
+ return {
1486
+ import: 'import { createFilesystemCache } from "@uniflowed/server/cache/filesystem";',
1487
+ provider: `createFilesystemCache({ directory: ${directory} })`,
1488
+ what: "uf's filesystem provider",
1489
+ };
1490
+ }
1491
+ const from = JSON.stringify(providerSpecifier(root, named));
1492
+ return {
1493
+ import: `import { createCacheProvider } from ${from};`,
1494
+ provider: `createCacheProvider({ build: ${JSON.stringify(build)}, directory: ${directory} })`,
1495
+ what: `${named}'s provider`,
1496
+ };
1497
+ }
1498
+
1499
+ /**
1500
+ * The lines a process entry needs to run what the project declared.
1501
+ *
1502
+ * Shared by `nodeEntrySource` and `bunEntrySource` because the two differ in
1503
+ * which module they take `serve` from and in nothing else — and a schedule
1504
+ * that behaved differently between them would be the drift the whole seam
1505
+ * exists to prevent. Empty strings when a project declared none, so the entry
1506
+ * a default project gets is the file it has always been.
1507
+ */
1508
+ function scheduleLines(module, schedules) {
1509
+ const declared = schedules ?? [];
1510
+ if (declared.length === 0) {
1511
+ return { imports: "", declarations: "", option: "" };
1512
+ }
1513
+ const built = declared
1514
+ .map(
1515
+ (schedule) =>
1516
+ ` routeSchedule({ handle: fetch, beginRequest, path: ${JSON.stringify(
1517
+ schedule.path,
1518
+ )}, cron: ${JSON.stringify(schedule.cron)} }),`,
1519
+ )
1520
+ .join("\n");
1521
+ return {
1522
+ imports: `import { routeSchedule } from ${JSON.stringify(module)};\n`,
1523
+ // A schedule runs the route by asking the application for it, so a
1524
+ // scheduled run and a request for the same path are one code path. See
1525
+ // ubugeeei-prod/uf#531.
1526
+ declarations: `\nconst schedules = [\n${built}\n];\n`,
1527
+ option: ", schedules",
1528
+ };
1529
+ }
1530
+
1280
1531
  /**
1281
1532
  * The source of `server.js`: the Node socket around that handler.
1282
1533
  *
@@ -1286,13 +1537,14 @@ export default { fetch, beginRequest };
1286
1537
  * request answered by `uf start` go through one implementation, not two that
1287
1538
  * agree today.
1288
1539
  */
1289
- function nodeEntrySource(handlerSpecifier) {
1540
+ function nodeEntrySource(handlerSpecifier, schedules) {
1541
+ const cron = scheduleLines("@uniflowed/server/schedule", schedules);
1290
1542
  return `// Generated by \`uf build --adapter node\`. Not checked in, not edited.
1291
1543
  import path from "node:path";
1292
1544
  import { fileURLToPath } from "node:url";
1293
1545
 
1294
1546
  import { serve } from "@uniflowed/server/node";
1295
-
1547
+ ${cron.imports}
1296
1548
  // \`beginRequest\` comes from the handler beside this file rather than from
1297
1549
  // \`@uniflowed/server/node\` above, because the request has to be established in
1298
1550
  // the storage the *application* reads, which is the copy bundled into
@@ -1305,12 +1557,12 @@ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1305
1557
  // assets when it was started from inside itself would be a deployment with a
1306
1558
  // trap in it.
1307
1559
  const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1308
-
1560
+ ${cron.declarations}
1309
1561
  // Not \`await serve(...)\` at the top level. uf parses that now
1310
1562
  // (ubugeeei-prod/uf#204) and this entry is a module, so it would work; \`.catch\`
1311
1563
  // is the better spelling regardless — a server that cannot take its port should
1312
1564
  // say so and exit non-zero, rather than die as an unhandled rejection.
1313
- serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
1565
+ serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1314
1566
  process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1315
1567
  process.exit(1);
1316
1568
  });
@@ -1325,13 +1577,14 @@ serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
1325
1577
  * purpose, so that the two adapters are one contract and a project moving
1326
1578
  * between them changes a flag and nothing else.
1327
1579
  */
1328
- function bunEntrySource(handlerSpecifier) {
1580
+ function bunEntrySource(handlerSpecifier, schedules) {
1581
+ const cron = scheduleLines("@uniflowed/server/schedule", schedules);
1329
1582
  return `// Generated by \`uf build --adapter bun\`. Not checked in, not edited.
1330
1583
  import path from "node:path";
1331
1584
  import { fileURLToPath } from "node:url";
1332
1585
 
1333
1586
  import { serve } from "@uniflowed/server/bun";
1334
-
1587
+ ${cron.imports}
1335
1588
  // \`beginRequest\` comes from the handler beside this file rather than from
1336
1589
  // \`@uniflowed/server/bun\` above, because the request has to be established in
1337
1590
  // the storage the *application* reads, which is the copy bundled into
@@ -1344,14 +1597,60 @@ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1344
1597
  // assets when it was started from inside itself would be a deployment with a
1345
1598
  // trap in it.
1346
1599
  const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1347
-
1348
- serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
1600
+ ${cron.declarations}
1601
+ serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1349
1602
  process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1350
1603
  process.exit(1);
1351
1604
  });
1352
1605
  `;
1353
1606
  }
1354
1607
 
1608
+ /**
1609
+ * The source of `server.js`: the Deno socket around that handler.
1610
+ *
1611
+ * The same generated contract as Node and Bun, with `@uniflowed/server/deno`
1612
+ * owning the host-specific pieces. The static directory is resolved with
1613
+ * `import.meta.url` alone so the entry carries no `node:` imports.
1614
+ */
1615
+ function denoEntrySource(handlerSpecifier, schedules) {
1616
+ const cron = scheduleLines("@uniflowed/server/schedule", schedules);
1617
+ return `// Generated by \`uf build --adapter deno\`. Not checked in, not edited.
1618
+ import { serve } from "@uniflowed/server/deno";
1619
+ ${cron.imports}
1620
+ // \`beginRequest\` comes from the handler beside this file rather than from
1621
+ // \`@uniflowed/server/deno\` above, because the request has to be established in
1622
+ // the storage the *application* reads, which is the copy bundled into
1623
+ // \`handler.js\`. See ubugeeei-prod/uf#389.
1624
+ //
1625
+ // Deno has no Node globals, but dependency bundles may still carry a CommonJS
1626
+ // production branch that expects a few of them. Establish the small environment
1627
+ // shape before \`handler.js\` is evaluated, which requires a dynamic import here
1628
+ // rather than a static one.
1629
+ globalThis.process ??= { env: {} };
1630
+ globalThis.process.env ??= {};
1631
+ globalThis.process.env.NODE_ENV ??= "production";
1632
+ globalThis.Buffer ??= {
1633
+ byteLength(value) {
1634
+ return new TextEncoder().encode(String(value)).byteLength;
1635
+ },
1636
+ };
1637
+ globalThis.setImmediate ??= (callback, ...args) => setTimeout(callback, 0, ...args);
1638
+ globalThis.clearImmediate ??= (handle) => clearTimeout(handle);
1639
+ const { beginRequest, fetch } = await import(${JSON.stringify(handlerSpecifier)});
1640
+
1641
+ // Resolved from this file and not from the working directory: a process
1642
+ // manager and a person in a shell each start a server from wherever they
1643
+ // happen to be, and a directory that only served its own assets when it was
1644
+ // started from inside itself would be a deployment with a trap in it.
1645
+ const staticDir = decodeURIComponent(new URL("./static", import.meta.url).pathname);
1646
+ ${cron.declarations}
1647
+ serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1648
+ console.error(\`uf: \${error?.message ?? String(error)}\`);
1649
+ Deno.exit(1);
1650
+ });
1651
+ `;
1652
+ }
1653
+
1355
1654
  /**
1356
1655
  * The source of `worker.js`: the Cloudflare Workers entry around that handler.
1357
1656
  *
@@ -1364,13 +1663,39 @@ serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
1364
1663
  * `nodeEntrySource` gives: the request has to be established in the storage the
1365
1664
  * *application* reads. See ubugeeei-prod/uf#389.
1366
1665
  */
1367
- function workerEntrySource(handlerSpecifier) {
1368
- return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1666
+ function workerEntrySource(handlerSpecifier, schedules) {
1667
+ const declared = schedules ?? [];
1668
+ // Nothing at all when the project declared none, so a `worker.js` without
1669
+ // schedules is the file it has always been — and `wrangler.json` carries no
1670
+ // `triggers` for it either, so there is nothing to call the export that
1671
+ // would not be there.
1672
+ if (declared.length === 0) {
1673
+ return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1369
1674
  import { createWorkerFetch } from "@uniflowed/server/edge";
1370
1675
 
1371
1676
  import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1372
1677
 
1373
1678
  export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1679
+ `;
1680
+ }
1681
+
1682
+ // The expression Cloudflare fires, mapped to the route that answers it.
1683
+ // `event.cron` arrives spelled exactly as `wrangler.json` spells it, and
1684
+ // `uf` writes both from one list, so the two cannot disagree.
1685
+ const routes = Object.fromEntries(declared.map((schedule) => [schedule.cron, schedule.path]));
1686
+ return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1687
+ import { createWorkerFetch, createWorkerScheduled } from "@uniflowed/server/edge";
1688
+
1689
+ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1690
+
1691
+ // \`triggers.crons\` in the wrangler.json beside this file names these same
1692
+ // expressions. See ubugeeei-prod/uf#531.
1693
+ const routes = ${JSON.stringify(routes, null, 2)};
1694
+
1695
+ export default {
1696
+ fetch: createWorkerFetch({ handle, beginRequest }),
1697
+ scheduled: createWorkerScheduled({ handle, beginRequest, routes }),
1698
+ };
1374
1699
  `;
1375
1700
  }
1376
1701
 
@@ -1525,7 +1850,7 @@ function readManifest(outDir) {
1525
1850
  * this function are about.
1526
1851
  *
1527
1852
  * Handlers and middleware are in the same list, and they belong there: this is
1528
- * the list of things that need a process, and a `_uf.route.js` needs one more
1853
+ * the list of things that need a process, and a `$route.js` needs one more
1529
1854
  * obviously than any page does. They carry no per-route render — the build has
1530
1855
  * never written a file for either — so they appear only when the answer might
1531
1856
  * be a refusal.
@@ -1537,6 +1862,15 @@ async function renderingPlan(server, prerender) {
1537
1862
  const urls = [];
1538
1863
  const perRequest = [];
1539
1864
 
1865
+ // One document, and it is no route's, so there is no route to ask anything
1866
+ // about. `perRequest` is empty rather than "every route": nothing here is
1867
+ // left for a server — the browser answers all of it — and listing routes
1868
+ // under a heading that means "these need a process" would be a build
1869
+ // describing itself wrongly to `uf`, which prints that list.
1870
+ if (prerender === "shell") {
1871
+ return { urls: [], perRequest: [] };
1872
+ }
1873
+
1540
1874
  // Nothing is prerendered and nothing is refused, so no page module is
1541
1875
  // loaded: a project that renders everything per request should not pay for
1542
1876
  // a `generateStaticParams` this build will not call.