@uniflowed/vite 0.0.0-alpha.34 → 0.0.0-alpha.35

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
@@ -42,23 +42,31 @@
42
42
  import { randomUUID } from "node:crypto";
43
43
  import { createServer as createHttpServer } from "node:http";
44
44
  import { builtinModules, register } from "node:module";
45
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
45
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
46
46
  import path from "node:path";
47
47
  import { pathToFileURL } from "node:url";
48
48
 
49
49
  import { COMPILE_ASSETS_ID, compileAssetsPlugin } from "./internal/compile-assets.js";
50
+ import {
51
+ survivingImports,
52
+ unavailableOnWorkers,
53
+ workerBuiltinWarnings,
54
+ } from "./internal/worker-builtins.js";
50
55
  import { emit, errorEvent, eventLogger } from "./internal/events.js";
51
56
  import { loadUfConfig, projectConfig } from "./internal/config.js";
52
57
  import { send, toRequest } from "./internal/http.js";
53
58
  import { createOpenApiDocument } from "./internal/openapi.js";
54
59
  import { withProjectConfig } from "./merge.js";
60
+ import { FLIGHT_VIRTUAL, RSC_ENVIRONMENT } from "./internal/flight.js";
55
61
  import { VIRTUAL, resolveRouteTarget, scanRoutes } from "./internal/routes.js";
56
62
  import {
57
63
  BUILD_ID_FILE,
64
+ DOCUMENT_ASSETS_FILE,
58
65
  assetsFromManifest,
59
66
  buildIdentity,
60
67
  createPrerenderGate,
61
68
  createServeHandler,
69
+ documentAssetsFor,
62
70
  loadBuild,
63
71
  nodeListener,
64
72
  providerSpecifier,
@@ -437,6 +445,17 @@ async function preview() {
437
445
  const draftFirst = {
438
446
  name: "uf:draft-before-files",
439
447
  configurePreviewServer(previewServer) {
448
+ // A prerendered payload is a file whose extension Vite's static middleware
449
+ // knows no type for, and the router hands bytes to React only when they
450
+ // are answered as a payload — so a navigation on a preview would silently
451
+ // become a document load. Set here, in front of the file server, which
452
+ // keeps a type it did not choose.
453
+ previewServer.middlewares.use((request, response, next) => {
454
+ if ((request.url ?? "").split("?")[0].endsWith("/__uf.flight")) {
455
+ response.setHeader("content-type", "text/x-component");
456
+ }
457
+ next();
458
+ });
440
459
  if (handle == null) return;
441
460
  const run = answer(previewServer);
442
461
  previewServer.middlewares.use((request, response, next) => {
@@ -589,17 +608,48 @@ async function build() {
589
608
  const staticBuild = flag("--static-build");
590
609
  const because = argument("--because") ?? "this build prerenders every route";
591
610
 
611
+ // 0. The rsc graph, for an application React Server Components render: the
612
+ // route table and every server component, resolved under `react-server`.
613
+ // First, because it is what finds the client modules the next pass has
614
+ // to build; `./internal/flight.js` has the order and the reason for it.
615
+ const flight = flightStateOf(inline);
616
+ const rscDir = path.join(root, ".uf", "build", "rsc");
617
+ if (flight != null) {
618
+ emit("phase", { name: "rsc" });
619
+ await buildRscGraph(vite, inline, flight, { outDir: rscDir, conditions: null });
620
+ }
621
+
592
622
  // 1. The client: everything the browser loads, with a manifest so the
593
- // server render knows which script and stylesheet tags to write.
623
+ // server render knows which script and stylesheet tags to write. Under
624
+ // React Server Components that is the entry and one entry per client
625
+ // module, each keeping its export names, because a payload asks for a
626
+ // chunk by its URL and for a component by its export.
594
627
  emit("phase", { name: "client" });
628
+ const references = flight == null ? [] : [...flight.clientModules].sort();
629
+ const input = { client: VIRTUAL.client };
630
+ references.forEach((file, index) => {
631
+ input[`client-reference-${index}`] = file;
632
+ });
595
633
  await vite.build({
596
634
  ...inline,
597
635
  build: {
598
636
  ...inline.build,
599
- rollupOptions: { input: { client: VIRTUAL.client } },
637
+ rollupOptions:
638
+ flight == null ? { input } : { input, preserveEntrySignatures: "exports-only" },
600
639
  },
601
640
  });
602
641
  const manifest = readManifest(outDir);
642
+ if (flight != null) {
643
+ recordClientChunks(flight, manifest, references, rscDir);
644
+ // What the summary's "pages in the client bundle" reads. None: a browser
645
+ // that hydrates a payload imports no page, whichever route it is on.
646
+ emit("rsc-split", {
647
+ pages: 0,
648
+ routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app"), {
649
+ target: resolveRouteTarget(config, argument("--target")),
650
+ }).routes.length,
651
+ });
652
+ }
603
653
 
604
654
  // 2. The server entry, bundled for the host, outside `dist/` so it is never
605
655
  // deployed by accident.
@@ -635,7 +685,14 @@ async function build() {
635
685
  // arrives here as one word, and this is where it meets the route table.
636
686
  emit("phase", { name: "prerender" });
637
687
  const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
638
- const assets = assetsFromManifest(manifest);
688
+ const assets =
689
+ flight == null
690
+ ? assetsFromManifest(manifest)
691
+ : flightAssets(manifest, references, rscDir, outDir);
692
+ // Recorded beside the server bundle, because whatever serves this build
693
+ // later cannot recompute them from the client manifest alone; see
694
+ // `documentAssetsFor`.
695
+ writeFileSync(path.join(serverDir, DOCUMENT_ASSETS_FILE), `${JSON.stringify(assets, null, 2)}\n`);
639
696
  const openapi = await createOpenApiDocument(server.handlers);
640
697
  const openapiFile = path.join(root, ".uf", "build", "meta", "openapi.json");
641
698
  mkdirSync(path.dirname(openapiFile), { recursive: true });
@@ -701,6 +758,11 @@ async function build() {
701
758
  const file = htmlPathFor(outDir, url);
702
759
  mkdirSync(path.dirname(file), { recursive: true });
703
760
  writeFileSync(file, result.html);
761
+ // The payload the document was rendered from, beside it: what a browser
762
+ // navigating to this route fetches, from whatever serves the files.
763
+ if (result.payload != null) {
764
+ writeFileSync(path.join(path.dirname(file), "__uf.flight"), result.payload);
765
+ }
704
766
  emit("page", {
705
767
  url,
706
768
  file: path.relative(root, file),
@@ -1039,9 +1101,16 @@ async function compile() {
1039
1101
  mkdirSync(bundleDir, { recursive: true });
1040
1102
  writeFileSync(
1041
1103
  entry,
1042
- entrySource(path.relative(root, assets), assetsFromManifest(readManifest(outDir))),
1104
+ entrySource(
1105
+ path.relative(root, assets),
1106
+ await documentAssetsFor(path.join(root, ".uf", "build", "server"), readManifest(outDir)),
1107
+ ),
1043
1108
  );
1044
1109
 
1110
+ // The rsc graph `uf build` built, and the client chunks its references name.
1111
+ const flight = flightStateOf(inline);
1112
+ if (flight != null) loadFlightBuild(flight, path.join(root, ".uf", "build", "rsc"));
1113
+
1045
1114
  await vite.build({
1046
1115
  ...inline,
1047
1116
  customLogger: eventLogger("warn"),
@@ -1141,6 +1210,9 @@ const ADAPTERS = {
1141
1210
  // target which cannot provide a durable store says so, and this is the one
1142
1211
  // target that cannot.
1143
1212
  filesystem: false,
1213
+ // And the Node built-ins it has only as stubs, which the link reports by
1214
+ // module and importer. See `./internal/worker-builtins.js`.
1215
+ workerBuiltins: true,
1144
1216
  },
1145
1217
  serverless: {
1146
1218
  entries: (document, cache, build) => ({
@@ -1267,7 +1339,10 @@ async function deploy() {
1267
1339
  // file is the version a person can open when a deployed directory
1268
1340
  // misbehaves.
1269
1341
  mkdirSync(work, { recursive: true });
1270
- const document = assetsFromManifest(readManifest(outDir));
1342
+ const document = await documentAssetsFor(
1343
+ path.join(root, ".uf", "build", "server"),
1344
+ readManifest(outDir),
1345
+ );
1271
1346
  // Whatever `build` above minted, so a durable cache in the deployed artefact
1272
1347
  // is keyed by the build that produced it and not by the moment it was
1273
1348
  // packaged. Read rather than minted again for exactly that reason: a second
@@ -1290,6 +1365,21 @@ async function deploy() {
1290
1365
  input[name] = path.join(work, `${name}.js`);
1291
1366
  }
1292
1367
 
1368
+ // An application React Server Components render bundles the rsc graph into
1369
+ // its server, and a target with export conditions of its own needs that graph
1370
+ // resolved under them as well: React's Flight server has a Node build and a
1371
+ // worker build, exactly as its HTML renderer does.
1372
+ const flight = flightStateOf(inline);
1373
+ if (flight != null) {
1374
+ loadFlightBuild(flight, path.join(root, ".uf", "build", "rsc"));
1375
+ if (shape.conditions != null) {
1376
+ await buildRscGraph(vite, inline, flight, {
1377
+ outDir: path.join(work, "rsc"),
1378
+ conditions: shape.conditions,
1379
+ });
1380
+ }
1381
+ }
1382
+
1293
1383
  const ssr = { ...(inline.ssr ?? {}), noExternal: true };
1294
1384
  if (shape.conditions != null) {
1295
1385
  // Which build of a dependency this target gets, and it is the difference
@@ -1304,7 +1394,24 @@ async function deploy() {
1304
1394
  await vite.build({
1305
1395
  ...inline,
1306
1396
  customLogger: eventLogger("warn"),
1307
- plugins: [...inline.plugins, nativeAddonGuard()],
1397
+ plugins: [
1398
+ ...inline.plugins,
1399
+ nativeAddonGuard(),
1400
+ ...(shape.workerBuiltins === true ? [workerBuiltinGuard()] : []),
1401
+ ],
1402
+ // Fixed, because this bundle inlines every dependency and so both of each
1403
+ // React package's builds, and a runtime lookup of `NODE_ENV` in a worker
1404
+ // finds nothing and picks the development one. React's Flight client's
1405
+ // development build constructs a `WeakRef` for every response, which
1406
+ // workerd does not have: every document the edge artefact rendered was a
1407
+ // `ReferenceError`. The production build has none, and is the one a
1408
+ // deployment means.
1409
+ define: {
1410
+ ...(inline.define ?? {}),
1411
+ "process.env.NODE_ENV": JSON.stringify(
1412
+ inline.mode === "development" ? "development" : "production",
1413
+ ),
1414
+ },
1308
1415
  ssr,
1309
1416
  build: {
1310
1417
  ...inline.build,
@@ -1671,10 +1778,14 @@ function workerEntrySource(handlerSpecifier, schedules) {
1671
1778
  // would not be there.
1672
1779
  if (declared.length === 0) {
1673
1780
  return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1674
- import { createWorkerFetch } from "@uniflowed/server/edge";
1781
+ import { createWorkerFetch, installWorkerLogger } from "@uniflowed/server/edge";
1675
1782
 
1676
1783
  import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1677
1784
 
1785
+ // After the imports, so a logger the application installed while it loaded is
1786
+ // the one that stays; see \`installWorkerLogger\`.
1787
+ installWorkerLogger();
1788
+
1678
1789
  export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1679
1790
  `;
1680
1791
  }
@@ -1684,10 +1795,14 @@ export default { fetch: createWorkerFetch({ handle, beginRequest }) };
1684
1795
  // `uf` writes both from one list, so the two cannot disagree.
1685
1796
  const routes = Object.fromEntries(declared.map((schedule) => [schedule.cron, schedule.path]));
1686
1797
  return `// Generated by \`uf build --adapter edge\`. Not checked in, not edited.
1687
- import { createWorkerFetch, createWorkerScheduled } from "@uniflowed/server/edge";
1798
+ import { createWorkerFetch, createWorkerScheduled, installWorkerLogger } from "@uniflowed/server/edge";
1688
1799
 
1689
1800
  import { beginRequest, fetch as handle } from ${JSON.stringify(handlerSpecifier)};
1690
1801
 
1802
+ // After the imports, so a logger the application installed while it loaded is
1803
+ // the one that stays; see \`installWorkerLogger\`.
1804
+ installWorkerLogger();
1805
+
1691
1806
  // \`triggers.crons\` in the wrangler.json beside this file names these same
1692
1807
  // expressions. See ubugeeei-prod/uf#531.
1693
1808
  const routes = ${JSON.stringify(routes, null, 2)};
@@ -1803,6 +1918,38 @@ function nativeAddonGuard() {
1803
1918
  };
1804
1919
  }
1805
1920
 
1921
+ /**
1922
+ * Say which Node built-ins the Worker being linked reaches and does not have.
1923
+ *
1924
+ * `--adapter edge` only. Every import the bundler resolves passes through here,
1925
+ * and one naming a module `./internal/worker-builtins.js` measured as a stub at
1926
+ * the compatibility date uf writes is kept with the file that imported it. Only
1927
+ * the ones still in the output are reported — see `survivingImports` for the
1928
+ * fixture that showed why — and as warnings rather than a refusal, for the
1929
+ * reason that module gives: a deployment at a newer date may have the module,
1930
+ * and uf cannot see that deployment. What it can do is say, before anything is
1931
+ * uploaded, which request is going to answer 500 and why.
1932
+ */
1933
+ function workerBuiltinGuard() {
1934
+ const reached = [];
1935
+ return {
1936
+ name: "uf:worker-builtins",
1937
+ enforce: "pre",
1938
+ resolveId(source, importer) {
1939
+ if (importer != null && unavailableOnWorkers(source) != null) {
1940
+ reached.push({ specifier: source, importer });
1941
+ }
1942
+ return null;
1943
+ },
1944
+ generateBundle(_options, bundle) {
1945
+ const chunks = Object.values(bundle).filter((output) => output.type === "chunk");
1946
+ for (const warning of workerBuiltinWarnings(survivingImports(reached, chunks), root)) {
1947
+ this.warn(warning);
1948
+ }
1949
+ },
1950
+ };
1951
+ }
1952
+
1806
1953
  /** `word`, pluralised for `count`. */
1807
1954
  function plural(count, word) {
1808
1955
  return count === 1 ? word : `${word}s`;
@@ -1814,6 +1961,135 @@ async function printConfig() {
1814
1961
  process.exit(0);
1815
1962
  }
1816
1963
 
1964
+ /**
1965
+ * The React Server Components state `@uniflowed/vite` shares with this driver,
1966
+ * or `null` for an application rendered from its modules.
1967
+ *
1968
+ * Read off the plugin rather than decided again here: `rendersFlight` in
1969
+ * `./internal/flight.js` decides from the same `uf.config.js`, and a second
1970
+ * reading of it would be the one that drifts.
1971
+ */
1972
+ function flightStateOf(inline) {
1973
+ const plugins = (inline.plugins ?? []).flat(Number.POSITIVE_INFINITY);
1974
+ return plugins.find((plugin) => plugin?.name === "uf:flow")?.api?.flight ?? null;
1975
+ }
1976
+
1977
+ /** What a later command needs of the rsc build, written beside its output. */
1978
+ const FLIGHT_BUILD_FILE = "uf-flight.json";
1979
+
1980
+ /**
1981
+ * Build the rsc graph into `outDir`.
1982
+ *
1983
+ * `conditions` are a deploy target's own, added to `react-server`; `null` is
1984
+ * the Node server `uf build` writes. Vite's builder rather than `vite.build`,
1985
+ * because the rsc graph is an environment of its own and `vite.build` builds
1986
+ * the two Vite always has.
1987
+ */
1988
+ async function buildRscGraph(vite, inline, state, { outDir, conditions }) {
1989
+ const environment = {
1990
+ build: {
1991
+ outDir,
1992
+ emptyOutDir: true,
1993
+ rollupOptions: {
1994
+ input: { index: FLIGHT_VIRTUAL.entry },
1995
+ output: { entryFileNames: "[name].js", format: "es" },
1996
+ },
1997
+ },
1998
+ };
1999
+ if (conditions != null) {
2000
+ environment.resolve = {
2001
+ conditions: ["react-server", ...conditions],
2002
+ externalConditions: ["react-server", ...conditions],
2003
+ };
2004
+ }
2005
+ const builder = await vite.createBuilder({
2006
+ ...inline,
2007
+ customLogger: eventLogger("warn"),
2008
+ environments: { [RSC_ENVIRONMENT]: environment },
2009
+ });
2010
+ await builder.build(builder.environments[RSC_ENVIRONMENT]);
2011
+ state.rscOutput = path.join(outDir, "index.js");
2012
+ }
2013
+
2014
+ /**
2015
+ * Record the chunk each client module was built into, and write it down.
2016
+ *
2017
+ * Written down because `uf build --adapter` and `uf build --compile` bundle
2018
+ * the server again in a process of their own, and a reference in the rsc
2019
+ * output names its module by path, which only this build's manifest turns
2020
+ * into a URL.
2021
+ */
2022
+ function recordClientChunks(state, manifest, references, rscDir) {
2023
+ for (const file of references) {
2024
+ const key = path.relative(root, file).split(path.sep).join("/");
2025
+ const chunk = manifest[key];
2026
+ if (chunk == null) {
2027
+ throw new Error(
2028
+ `uf: the client build wrote no chunk for ${key}, which a server component renders as a ` +
2029
+ "client component",
2030
+ );
2031
+ }
2032
+ state.chunkUrls.set(file, `/${chunk.file}`);
2033
+ }
2034
+ writeFileSync(
2035
+ path.join(rscDir, FLIGHT_BUILD_FILE),
2036
+ `${JSON.stringify({ chunkUrls: [...state.chunkUrls] }, null, 2)}\n`,
2037
+ );
2038
+ }
2039
+
2040
+ /** What `recordClientChunks` wrote, for a command that runs after `uf build`. */
2041
+ function loadFlightBuild(state, rscDir) {
2042
+ const file = path.join(rscDir, FLIGHT_BUILD_FILE);
2043
+ if (!existsSync(file)) {
2044
+ throw new Error(
2045
+ `uf: ${path.relative(root, file)} is missing, so there is no rsc graph to render routes ` +
2046
+ "with; run `uf build` first",
2047
+ );
2048
+ }
2049
+ state.chunkUrls = new Map(JSON.parse(readFileSync(file, "utf8")).chunkUrls);
2050
+ state.rscOutput = path.join(rscDir, "index.js");
2051
+ }
2052
+
2053
+ /**
2054
+ * The tags a document React Server Components render needs.
2055
+ *
2056
+ * `assetsFromManifest`'s, with stylesheets from three places in the order they
2057
+ * cascade: the rsc graph's first — every layout's and every server component's
2058
+ * — then the client entry's, then each client module's own, which the client
2059
+ * build emits beside that module's chunk and no import from the entry reaches.
2060
+ *
2061
+ * The rsc build's emitted files are copied under `dist/` so those URLs resolve,
2062
+ * and only its assets: a server bundle's JavaScript is never a deployable file.
2063
+ */
2064
+ function flightAssets(manifest, references, rscDir, outDir) {
2065
+ const assets = assetsFromManifest(manifest);
2066
+ const styles = new Set();
2067
+ const rscManifest = path.join(rscDir, ".vite", "manifest.json");
2068
+ if (existsSync(rscManifest)) {
2069
+ const rscStyles = assetsFromManifest(JSON.parse(readFileSync(rscManifest, "utf8"))).styles;
2070
+ for (const href of rscStyles) styles.add(href);
2071
+ }
2072
+ const rscAssets = path.join(rscDir, "assets");
2073
+ if (existsSync(rscAssets)) {
2074
+ cpSync(rscAssets, path.join(outDir, "assets"), {
2075
+ recursive: true,
2076
+ filter: (from) => !/\.(?:[cm]?js|map)$/.test(from),
2077
+ });
2078
+ }
2079
+ for (const href of assets.styles) styles.add(href);
2080
+ const seen = new Set();
2081
+ const visit = (key) => {
2082
+ if (seen.has(key)) return;
2083
+ seen.add(key);
2084
+ const chunk = manifest[key];
2085
+ if (chunk == null) return;
2086
+ for (const css of chunk.css ?? []) styles.add(`/${css}`);
2087
+ for (const imported of chunk.imports ?? []) visit(imported);
2088
+ };
2089
+ for (const file of references) visit(path.relative(root, file).split(path.sep).join("/"));
2090
+ return { ...assets, styles: [...styles] };
2091
+ }
2092
+
1817
2093
  function readManifest(outDir) {
1818
2094
  const file = path.join(outDir, ".vite", "manifest.json");
1819
2095
  if (!existsSync(file)) throw new Error(`uf: the client build wrote no manifest at ${file}`);