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

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
@@ -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";
@@ -52,11 +53,14 @@ import { send, toRequest } from "./internal/http.js";
52
53
  import { withProjectConfig } from "./merge.js";
53
54
  import { VIRTUAL, scanRoutes } from "./internal/routes.js";
54
55
  import {
56
+ BUILD_ID_FILE,
55
57
  assetsFromManifest,
58
+ buildIdentity,
56
59
  createPrerenderGate,
57
60
  createServeHandler,
58
61
  loadBuild,
59
62
  nodeListener,
63
+ providerSpecifier,
60
64
  withRequest,
61
65
  } from "./internal/serve.js";
62
66
 
@@ -113,6 +117,33 @@ run().catch((error) => {
113
117
  process.exit(1);
114
118
  });
115
119
 
120
+ /**
121
+ * What this build is called, for anything that outlives it.
122
+ *
123
+ * `UF_BUILD_ID` when it is set, and a fresh random name otherwise — which is
124
+ * `crates/uf_rsc`'s `BuildId::from_env_or_generate` exactly, reading the same
125
+ * variable, because it is the same question asked by a different half of the
126
+ * toolchain. Two artefacts that have to *be* one build say so with the
127
+ * variable; everything else gets a name no other build has.
128
+ *
129
+ * Minted once per build and written into the build, never per process. Four
130
+ * servers started from one artefact are one build and must share one cache;
131
+ * generating this where the server starts would give them four, which is worse
132
+ * than none at all — four copies of everything written into one directory and
133
+ * none of them read.
134
+ *
135
+ * Not a hash of the output. A content hash would be reproducible, which is
136
+ * appealing and wrong here: two builds with identical client bundles can have
137
+ * different server behaviour — a loader's body moves and no asset hash does —
138
+ * and a cache keyed by one would serve the old loader's documents. A name that
139
+ * changes whenever the build ran is the conservative direction to be wrong in.
140
+ */
141
+ function mintBuildId() {
142
+ const named = process.env.UF_BUILD_ID;
143
+ if (typeof named === "string" && named.trim() !== "") return named.trim();
144
+ return randomUUID().replaceAll("-", "");
145
+ }
146
+
116
147
  /** Load `uf.config.js`, reporting where it was found. */
117
148
  async function loadConfig() {
118
149
  const { config, file } = await loadUfConfig(root);
@@ -423,6 +454,30 @@ async function preview() {
423
454
  if (handle != null) {
424
455
  server.middlewares.use(answer(server));
425
456
  }
457
+ // The rewrite rule a single-page deployment needs, in the one place uf can
458
+ // apply one. A `["csr"]` build writes `index.html` and nothing else that is a
459
+ // page, so a file server answers `/` and 404s every other URL — and this
460
+ // preview exists to be believed about the deployment. A host serving that
461
+ // build has to send unmatched paths to the shell, so a preview that did not
462
+ // would be right about a deployment nobody is doing.
463
+ //
464
+ // Behind the static middleware, which is what makes it a *fallback*: a real
465
+ // file still wins, so `/assets/client.js` is still the chunk and not the
466
+ // shell. And `Accept: text/html` only, so a `fetch` for a missing JSON file
467
+ // gets a 404 rather than a document — the failure mode of a fallback that
468
+ // answers everything is a parse error two layers away from the missing file.
469
+ if (flag("--spa-fallback")) {
470
+ const shell = path.resolve(root, inline.build.outDir, "index.html");
471
+ server.middlewares.use((request, response, next) => {
472
+ if (request.method !== "GET" && request.method !== "HEAD") return next();
473
+ if (!(request.headers.accept ?? "").includes("text/html")) return next();
474
+ if (!existsSync(shell)) return next();
475
+ response.statusCode = 200;
476
+ response.setHeader("content-type", "text/html; charset=utf-8");
477
+ response.end(request.method === "HEAD" ? undefined : readFileSync(shell));
478
+ return undefined;
479
+ });
480
+ }
426
481
 
427
482
  const urls = server.resolvedUrls ?? { local: [], network: [] };
428
483
  emit("listening", {
@@ -560,6 +615,13 @@ async function build() {
560
615
  },
561
616
  },
562
617
  });
618
+ // What this build is, beside the bundle it is about. One line, written by
619
+ // every build and read by nothing unless a project turned a durable cache on
620
+ // — at which point it is the thing that stops a deploy answering the new
621
+ // build's URLs with the previous build's documents. See
622
+ // `packages/server/internal/cache-key.js`, which argues the whole of it, and
623
+ // `internal/serve.js`'s `buildIdentity`, which is what reads this.
624
+ writeFileSync(path.join(serverDir, BUILD_ID_FILE), `${mintBuildId()}\n`);
563
625
 
564
626
  // 3. Which routes this build renders when, and every route it renders now.
565
627
  //
@@ -645,7 +707,7 @@ async function build() {
645
707
  //
646
708
  // The condition is "there is a root boundary", not "there is any boundary",
647
709
  // 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`
710
+ // `$not-found.js` is in `app/guide/` would otherwise get a `404.html`
649
711
  // rendered from the framework's bare default, which is worse than the file
650
712
  // it used to write, which was none.
651
713
  //
@@ -657,11 +719,50 @@ async function build() {
657
719
  // host would then serve uf's error page to every visitor who mistyped a URL,
658
720
  // and nothing between the throw and the deploy would have mentioned it.
659
721
  let attempted = pages.length;
722
+ // The single-page build's whole output, written here because it is the one
723
+ // document this build has and the loop above had no route to write it for.
724
+ //
725
+ // Twice, to two names, and the second is the load-bearing one. `index.html`
726
+ // is what a host serves for `/`; `404.html` is what a static host serves for
727
+ // every path it has no file for, which under this plan is *every other URL
728
+ // in the application*. Without it a deployment of `dist/` answers `/` and
729
+ // 404s `/orders` — a build with a hole in it, found from a 404, which is the
730
+ // failure ubugeeei-prod/uf#336 is about wearing a different hat.
731
+ //
732
+ // It is still the host's rewrite rule that makes this correct, and the two
733
+ // files are what uf can do without one: a host with a proper SPA fallback
734
+ // serves `index.html` and never looks at `404.html`, and a host with only an
735
+ // error document (Pages, Netlify, an S3 bucket) serves `404.html` and gets
736
+ // the same bytes with a 404 status — which is the right status for a URL
737
+ // this application does not have, and the not-found boundary is what the
738
+ // browser then renders into it.
739
+ if (prerender === "shell") {
740
+ const html = server.shellDocument(assets);
741
+ for (const [file, url, status] of [
742
+ ["index.html", "/", 200],
743
+ ["404.html", "/404", 404],
744
+ ]) {
745
+ const target = path.join(outDir, file);
746
+ writeFileSync(target, html);
747
+ attempted += 1;
748
+ emit("page", {
749
+ url,
750
+ file: path.relative(root, target),
751
+ status,
752
+ bytes: Buffer.byteLength(html),
753
+ });
754
+ }
755
+ }
660
756
  // Not for a build that prerenders nothing. `404.html` is a file a static
661
757
  // host serves for every path it has no file for, and a project whose
662
758
  // `rendering.modes` allows only `ssr` has no such host: its not-found
663
759
  // boundary is rendered per request, by the server, with the right status.
664
- if (prerender !== "nothing" && server.notFound.some((boundary) => boundary.path === "/")) {
760
+ //
761
+ // Nor for a shell build, which has just written its own: the boundary this
762
+ // would render is one the *browser* renders in that plan, and a document
763
+ // holding the framework's 404 markup would be served in place of the shell
764
+ // for every URL the host could not match.
765
+ else if (prerender !== "nothing" && server.notFound.some((boundary) => boundary.path === "/")) {
665
766
  attempted += 1;
666
767
  // `/404` rather than `/__uf_not_found__`: the internal path is how the
667
768
  // router is asked, and the file the reader is looking for is `404.html`.
@@ -971,13 +1072,13 @@ async function compile() {
971
1072
  *
972
1073
  * `bun`, `deno` and `static` are deliberately absent; `uf_config`'s
973
1074
  * `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.
1075
+ * `docs/app/reference/cli/$page.mdx` says why for each of them.
975
1076
  */
976
1077
  const ADAPTERS = {
977
1078
  node: {
978
- entries: (document, cache) => ({
979
- handler: handlerEntrySource(document, cache, NODE_CAPABILITIES),
980
- server: nodeEntrySource("./handler.js"),
1079
+ entries: (document, cache, build, schedules) => ({
1080
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build),
1081
+ server: nodeEntrySource("./handler.js", schedules),
981
1082
  }),
982
1083
  },
983
1084
  // The same two files as `node`, with `@uniflowed/server/bun` in place of
@@ -995,9 +1096,9 @@ const ADAPTERS = {
995
1096
  // nobody here. `edge` pays that price because it must: there is no
996
1097
  // `node:stream` in a Worker.
997
1098
  bun: {
998
- entries: (document, cache) => ({
999
- handler: handlerEntrySource(document, cache, BUN_CAPABILITIES),
1000
- server: bunEntrySource("./handler.js"),
1099
+ entries: (document, cache, build, schedules) => ({
1100
+ handler: handlerEntrySource(document, cache, BUN_CAPABILITIES, build),
1101
+ server: bunEntrySource("./handler.js", schedules),
1001
1102
  }),
1002
1103
  },
1003
1104
  // The same two files. What `--adapter container` adds is a `Dockerfile` and
@@ -1005,25 +1106,31 @@ const ADAPTERS = {
1005
1106
  // output rather than anything the bundler produces — see `uf_cli`'s
1006
1107
  // `commands::deploy`.
1007
1108
  container: {
1008
- entries: (document, cache) => ({
1009
- handler: handlerEntrySource(document, cache, NODE_CAPABILITIES),
1010
- server: nodeEntrySource("./handler.js"),
1109
+ entries: (document, cache, build, schedules) => ({
1110
+ handler: handlerEntrySource(document, cache, NODE_CAPABILITIES, build),
1111
+ server: nodeEntrySource("./handler.js", schedules),
1011
1112
  }),
1012
1113
  },
1013
1114
  edge: {
1014
- entries: (document, cache) => ({
1015
- handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES),
1016
- worker: workerEntrySource("./handler.js"),
1115
+ entries: (document, cache, build, schedules) => ({
1116
+ handler: handlerEntrySource(document, cache, EDGE_CAPABILITIES, build),
1117
+ worker: workerEntrySource("./handler.js", schedules),
1017
1118
  }),
1018
1119
  // `workerd` first, so React resolves to the build that has
1019
1120
  // `renderToReadableStream` and no `node:stream`. `browser` and `module`
1020
1121
  // after it are Vite's own SSR defaults, kept so a dependency with no
1021
1122
  // worker condition still resolves the way it does for every other target.
1022
1123
  conditions: ["workerd", "worker", "edge-light", "browser", "module", "import", "default"],
1124
+ // A Worker has no filesystem, so uf's built-in durable provider cannot run
1125
+ // here. Refused by name at the build rather than linked into a bundle that
1126
+ // fails on its first `node:fs` import — the deployment rule is that a
1127
+ // target which cannot provide a durable store says so, and this is the one
1128
+ // target that cannot.
1129
+ filesystem: false,
1023
1130
  },
1024
1131
  serverless: {
1025
- entries: (document, cache) => ({
1026
- handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES),
1132
+ entries: (document, cache, build) => ({
1133
+ handler: handlerEntrySource(document, cache, SERVERLESS_CAPABILITIES, build),
1027
1134
  lambda: lambdaEntrySource("./handler.js"),
1028
1135
  }),
1029
1136
  },
@@ -1115,6 +1222,12 @@ async function deploy() {
1115
1222
  if (adapter == null || workArgument == null || outputArgument == null) {
1116
1223
  throw new Error("uf: `driver.js deploy` needs --adapter, --work and --output");
1117
1224
  }
1225
+ // What the project declared, read by `uf`'s own walk of the route handlers
1226
+ // and handed over rather than found again here — one reading of a module,
1227
+ // and the same list `wrangler.json`'s `triggers.crons` is written from.
1228
+ // Absent on an older `uf` spawning a newer driver, which is a build with no
1229
+ // schedules rather than an error. See ubugeeei-prod/uf#531.
1230
+ const schedules = JSON.parse(argument("--schedules") ?? "[]");
1118
1231
  // The Rust side has already refused every adapter it has no implementation
1119
1232
  // for, by name and with the issue that tracks it. This is the second half of
1120
1233
  // that fact rather than a duplicate of it: the driver may be spawned by a
@@ -1140,7 +1253,22 @@ async function deploy() {
1140
1253
  // misbehaves.
1141
1254
  mkdirSync(work, { recursive: true });
1142
1255
  const document = assetsFromManifest(readManifest(outDir));
1143
- const entries = shape.entries(document, config.app?.rendering?.cache);
1256
+ // Whatever `build` above minted, so a durable cache in the deployed artefact
1257
+ // is keyed by the build that produced it and not by the moment it was
1258
+ // packaged. Read rather than minted again for exactly that reason: a second
1259
+ // `randomUUID()` here would key the adapter's copy differently from the one
1260
+ // `uf start` serves out of `.uf/build/`, which is two caches for one build.
1261
+ const buildId = await buildIdentity(root, path.join(".uf", "build", "server"));
1262
+ const cacheConfig = config.app?.rendering?.cache;
1263
+ if (shape.filesystem === false && cacheConfig?.store === "filesystem") {
1264
+ throw new Error(
1265
+ `uf: rendering.cache.store is "filesystem" and \`--adapter ${adapter}\` has no ` +
1266
+ "filesystem. Name a module exporting `createCacheProvider` instead — a KV " +
1267
+ "namespace or a Redis behind the same seam — or leave the store in memory. See " +
1268
+ "docs/app/guide/cache.",
1269
+ );
1270
+ }
1271
+ const entries = shape.entries(document, cacheConfig, buildId, schedules);
1144
1272
  const input = {};
1145
1273
  for (const name of Object.keys(entries)) {
1146
1274
  writeFileSync(path.join(work, `${name}.js`), entries[name]);
@@ -1230,8 +1358,22 @@ async function deploy() {
1230
1358
  * re-exported above — a module-level singleton belongs to whichever copy of the
1231
1359
  * package a bundler happened to give it, and the copy that matters is the one
1232
1360
  * the application resolved. See ubugeeei-prod/uf#277 and #389.
1361
+ *
1362
+ * # And where a durable store is named
1363
+ *
1364
+ * `rendering.cache.store` is the fifth key, and it is the one that turns the
1365
+ * store into a shared one: `"filesystem"` links uf's built-in provider, and
1366
+ * anything else is a module specifier the project wrote, imported here by name
1367
+ * so the bundler links it like any other dependency of the application. Neither
1368
+ * appears at all when the key is absent, which is what a default project keeps.
1369
+ *
1370
+ * `build` is baked in beside it, and it is the reason this can be an `import`
1371
+ * at all rather than something read at boot: the build id is a fact about the
1372
+ * artefact being written, known here and nowhere later. `internal/serve.js`'s
1373
+ * `buildIdentity` is where it came from and
1374
+ * `packages/server/internal/cache-key.js` is why it exists.
1233
1375
  */
1234
- function handlerEntrySource(document, cache, capabilities) {
1376
+ function handlerEntrySource(document, cache, capabilities, build) {
1235
1377
  const route = cache?.route === true;
1236
1378
  const fetchCache = cache?.fetch === true;
1237
1379
  // Nothing at all when both switches are off, so a default project's
@@ -1239,6 +1381,7 @@ function handlerEntrySource(document, cache, capabilities) {
1239
1381
  // generated output nobody asked for is the second half of the complaint
1240
1382
  // #277 makes about the first half.
1241
1383
  const store = route || fetchCache;
1384
+ const durable = store ? durableStoreSource(root, cache, build) : null;
1242
1385
  const options = [
1243
1386
  "app",
1244
1387
  `document: ${JSON.stringify(document)}`,
@@ -1246,19 +1389,29 @@ function handlerEntrySource(document, cache, capabilities) {
1246
1389
  "capabilities",
1247
1390
  ].join(", ");
1248
1391
  const cacheImport = store ? 'import { createCacheStore } from "@uniflowed/server/cache";\n' : "";
1392
+ const providerImport = durable == null ? "" : `${durable.import}\n`;
1249
1393
  const from = JSON.stringify(capabilities.module);
1250
1394
  const capabilityImport = `import { ${capabilities.name} } from ${from};`;
1251
1395
  return `// Generated by \`uf build --adapter\`. Not checked in, not edited.
1252
1396
  import { createFetchHandler } from "@uniflowed/server/fetch";
1253
- ${cacheImport}${capabilityImport}
1397
+ ${cacheImport}${providerImport}${capabilityImport}
1254
1398
  import * as app from ${JSON.stringify(VIRTUAL.server)};
1255
1399
 
1256
1400
  ${
1257
1401
  store
1258
- ? `// \`rendering.cache\` from uf.config.js. One store per process: it is
1402
+ ? `// \`rendering.cache\` from uf.config.js. ${
1403
+ durable == null
1404
+ ? `One store per process: it is
1259
1405
  // 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)} };
1406
+ // application.`
1407
+ : `Entries are kept by ${durable.what},
1408
+ // under this build's identity, so a restart finds them where it left them and
1409
+ // every process of this deployment reads one store — and \`revalidateTag\` in
1410
+ // any of them takes an entry out of the store all of them fill from.`
1411
+ } See ubugeeei-prod/uf#277.
1412
+ const cache = { store: createCacheStore(${
1413
+ durable == null ? "" : `{ provider: ${durable.provider}, build: ${JSON.stringify(build)} }`
1414
+ }), route: ${String(route)}, fetch: ${String(fetchCache)} };
1262
1415
 
1263
1416
  `
1264
1417
  : ""
@@ -1277,6 +1430,89 @@ export default { fetch, beginRequest };
1277
1430
  `;
1278
1431
  }
1279
1432
 
1433
+ /**
1434
+ * The import and the expression that give a generated handler a durable store.
1435
+ *
1436
+ * `null` for `"memory"` and for a project that said nothing, which is every
1437
+ * project until one asks: persistence is a second opt-in on top of `route` and
1438
+ * `fetch`, not something a build decides on a project's behalf.
1439
+ *
1440
+ * The directory is baked in as written rather than resolved here, and that is
1441
+ * deliberate. This function runs on the machine doing the build; the path has
1442
+ * to mean something on the machine doing the *serving*, which may be a
1443
+ * container with one writable mount or a Lambda with only `/tmp`. A relative
1444
+ * one is resolved against the working directory at boot, by the provider, where
1445
+ * the answer is a fact rather than a guess.
1446
+ *
1447
+ * The specifier is resolved against the project for the same reason
1448
+ * `internal/serve.js`'s `providerSpecifier` does it: `"./cache/redis.js"` in
1449
+ * `uf.config.js` is relative to the project, and this file is written into
1450
+ * `.uf/deploy/work/`, where that path means nothing. Absolute is safe here
1451
+ * because the bundler inlines the module rather than emitting the specifier.
1452
+ *
1453
+ * @param {string} root
1454
+ * @param {{store?: string, storeDir?: string}} cache
1455
+ * @param {string | null} build
1456
+ */
1457
+ function durableStoreSource(root, cache, build) {
1458
+ const named = cache?.store ?? "memory";
1459
+ if (named === "memory") return null;
1460
+ if (build == null) {
1461
+ throw new Error(
1462
+ `uf: rendering.cache.store is ${JSON.stringify(named)}, which keeps entries between ` +
1463
+ "restarts, and this build has no identity to key them by. Run `uf build` so one is " +
1464
+ "written, or set UF_BUILD_ID. Without one the deployment would answer this build's " +
1465
+ "URLs with the previous build's documents.",
1466
+ );
1467
+ }
1468
+ const directory = JSON.stringify(cache?.storeDir ?? path.join(".uf", "cache", "route"));
1469
+ if (named === "filesystem") {
1470
+ return {
1471
+ import: 'import { createFilesystemCache } from "@uniflowed/server/cache/filesystem";',
1472
+ provider: `createFilesystemCache({ directory: ${directory} })`,
1473
+ what: "uf's filesystem provider",
1474
+ };
1475
+ }
1476
+ const from = JSON.stringify(providerSpecifier(root, named));
1477
+ return {
1478
+ import: `import { createCacheProvider } from ${from};`,
1479
+ provider: `createCacheProvider({ build: ${JSON.stringify(build)}, directory: ${directory} })`,
1480
+ what: `${named}'s provider`,
1481
+ };
1482
+ }
1483
+
1484
+ /**
1485
+ * The lines a process entry needs to run what the project declared.
1486
+ *
1487
+ * Shared by `nodeEntrySource` and `bunEntrySource` because the two differ in
1488
+ * which module they take `serve` from and in nothing else — and a schedule
1489
+ * that behaved differently between them would be the drift the whole seam
1490
+ * exists to prevent. Empty strings when a project declared none, so the entry
1491
+ * a default project gets is the file it has always been.
1492
+ */
1493
+ function scheduleLines(module, schedules) {
1494
+ const declared = schedules ?? [];
1495
+ if (declared.length === 0) {
1496
+ return { imports: "", declarations: "", option: "" };
1497
+ }
1498
+ const built = declared
1499
+ .map(
1500
+ (schedule) =>
1501
+ ` routeSchedule({ handle: fetch, beginRequest, path: ${JSON.stringify(
1502
+ schedule.path,
1503
+ )}, cron: ${JSON.stringify(schedule.cron)} }),`,
1504
+ )
1505
+ .join("\n");
1506
+ return {
1507
+ imports: `import { routeSchedule } from ${JSON.stringify(module)};\n`,
1508
+ // A schedule runs the route by asking the application for it, so a
1509
+ // scheduled run and a request for the same path are one code path. See
1510
+ // ubugeeei-prod/uf#531.
1511
+ declarations: `\nconst schedules = [\n${built}\n];\n`,
1512
+ option: ", schedules",
1513
+ };
1514
+ }
1515
+
1280
1516
  /**
1281
1517
  * The source of `server.js`: the Node socket around that handler.
1282
1518
  *
@@ -1286,13 +1522,14 @@ export default { fetch, beginRequest };
1286
1522
  * request answered by `uf start` go through one implementation, not two that
1287
1523
  * agree today.
1288
1524
  */
1289
- function nodeEntrySource(handlerSpecifier) {
1525
+ function nodeEntrySource(handlerSpecifier, schedules) {
1526
+ const cron = scheduleLines("@uniflowed/server/schedule", schedules);
1290
1527
  return `// Generated by \`uf build --adapter node\`. Not checked in, not edited.
1291
1528
  import path from "node:path";
1292
1529
  import { fileURLToPath } from "node:url";
1293
1530
 
1294
1531
  import { serve } from "@uniflowed/server/node";
1295
-
1532
+ ${cron.imports}
1296
1533
  // \`beginRequest\` comes from the handler beside this file rather than from
1297
1534
  // \`@uniflowed/server/node\` above, because the request has to be established in
1298
1535
  // the storage the *application* reads, which is the copy bundled into
@@ -1305,12 +1542,12 @@ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1305
1542
  // assets when it was started from inside itself would be a deployment with a
1306
1543
  // trap in it.
1307
1544
  const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1308
-
1545
+ ${cron.declarations}
1309
1546
  // Not \`await serve(...)\` at the top level. uf parses that now
1310
1547
  // (ubugeeei-prod/uf#204) and this entry is a module, so it would work; \`.catch\`
1311
1548
  // is the better spelling regardless — a server that cannot take its port should
1312
1549
  // say so and exit non-zero, rather than die as an unhandled rejection.
1313
- serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
1550
+ serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1314
1551
  process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1315
1552
  process.exit(1);
1316
1553
  });
@@ -1325,13 +1562,14 @@ serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
1325
1562
  * purpose, so that the two adapters are one contract and a project moving
1326
1563
  * between them changes a flag and nothing else.
1327
1564
  */
1328
- function bunEntrySource(handlerSpecifier) {
1565
+ function bunEntrySource(handlerSpecifier, schedules) {
1566
+ const cron = scheduleLines("@uniflowed/server/schedule", schedules);
1329
1567
  return `// Generated by \`uf build --adapter bun\`. Not checked in, not edited.
1330
1568
  import path from "node:path";
1331
1569
  import { fileURLToPath } from "node:url";
1332
1570
 
1333
1571
  import { serve } from "@uniflowed/server/bun";
1334
-
1572
+ ${cron.imports}
1335
1573
  // \`beginRequest\` comes from the handler beside this file rather than from
1336
1574
  // \`@uniflowed/server/bun\` above, because the request has to be established in
1337
1575
  // the storage the *application* reads, which is the copy bundled into
@@ -1344,8 +1582,8 @@ import { beginRequest, fetch } from ${JSON.stringify(handlerSpecifier)};
1344
1582
  // assets when it was started from inside itself would be a deployment with a
1345
1583
  // trap in it.
1346
1584
  const staticDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
1347
-
1348
- serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
1585
+ ${cron.declarations}
1586
+ serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1349
1587
  process.stderr.write(\`uf: \${error?.message ?? String(error)}\\n\`);
1350
1588
  process.exit(1);
1351
1589
  });
@@ -1364,13 +1602,39 @@ serve({ handle: fetch, staticDir, beginRequest }).catch((error) => {
1364
1602
  * `nodeEntrySource` gives: the request has to be established in the storage the
1365
1603
  * *application* reads. See ubugeeei-prod/uf#389.
1366
1604
  */
1367
- function workerEntrySource(handlerSpecifier) {
1368
- return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1605
+ function workerEntrySource(handlerSpecifier, schedules) {
1606
+ const declared = schedules ?? [];
1607
+ // Nothing at all when the project declared none, so a `worker.js` without
1608
+ // schedules is the file it has always been — and `wrangler.json` carries no
1609
+ // `triggers` for it either, so there is nothing to call the export that
1610
+ // would not be there.
1611
+ if (declared.length === 0) {
1612
+ return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1369
1613
  import { createWorkerFetch } from "@uniflowed/server/edge";
1370
1614
 
1371
1615
  import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1372
1616
 
1373
1617
  export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1618
+ `;
1619
+ }
1620
+
1621
+ // The expression Cloudflare fires, mapped to the route that answers it.
1622
+ // `event.cron` arrives spelled exactly as `wrangler.json` spells it, and
1623
+ // `uf` writes both from one list, so the two cannot disagree.
1624
+ const routes = Object.fromEntries(declared.map((schedule) => [schedule.cron, schedule.path]));
1625
+ return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1626
+ import { createWorkerFetch, createWorkerScheduled } from "@uniflowed/server/edge";
1627
+
1628
+ import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1629
+
1630
+ // \`triggers.crons\` in the wrangler.json beside this file names these same
1631
+ // expressions. See ubugeeei-prod/uf#531.
1632
+ const routes = ${JSON.stringify(routes, null, 2)};
1633
+
1634
+ export default {
1635
+ fetch: createWorkerFetch({ handle, beginRequest }),
1636
+ scheduled: createWorkerScheduled({ handle, beginRequest, routes }),
1637
+ };
1374
1638
  `;
1375
1639
  }
1376
1640
 
@@ -1525,7 +1789,7 @@ function readManifest(outDir) {
1525
1789
  * this function are about.
1526
1790
  *
1527
1791
  * 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
1792
+ * the list of things that need a process, and a `$route.js` needs one more
1529
1793
  * obviously than any page does. They carry no per-route render — the build has
1530
1794
  * never written a file for either — so they appear only when the answer might
1531
1795
  * be a refusal.
@@ -1537,6 +1801,15 @@ async function renderingPlan(server, prerender) {
1537
1801
  const urls = [];
1538
1802
  const perRequest = [];
1539
1803
 
1804
+ // One document, and it is no route's, so there is no route to ask anything
1805
+ // about. `perRequest` is empty rather than "every route": nothing here is
1806
+ // left for a server — the browser answers all of it — and listing routes
1807
+ // under a heading that means "these need a process" would be a build
1808
+ // describing itself wrongly to `uf`, which prints that list.
1809
+ if (prerender === "shell") {
1810
+ return { urls: [], perRequest: [] };
1811
+ }
1812
+
1540
1813
  // Nothing is prerendered and nothing is refused, so no page module is
1541
1814
  // loaded: a project that renders everything per request should not pay for
1542
1815
  // a `generateStaticParams` this build will not call.