@rsc-kit/core 0.18.0 → 0.19.0

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.
Files changed (73) hide show
  1. package/dist/action.d.ts +16 -2
  2. package/dist/action.js +21 -12
  3. package/dist/action.js.map +1 -1
  4. package/dist/apiPrerender.js +24 -6
  5. package/dist/apiPrerender.js.map +1 -1
  6. package/dist/clientPackages.d.ts +30 -0
  7. package/dist/clientPackages.js +234 -0
  8. package/dist/clientPackages.js.map +1 -0
  9. package/dist/compress.d.ts +12 -0
  10. package/dist/compress.js +134 -0
  11. package/dist/compress.js.map +1 -0
  12. package/dist/compressRuntime.d.ts +8 -0
  13. package/dist/compressRuntime.js +62 -0
  14. package/dist/compressRuntime.js.map +1 -0
  15. package/dist/files.d.ts +20 -0
  16. package/dist/files.js +38 -0
  17. package/dist/files.js.map +1 -1
  18. package/dist/formSubmit.d.ts +1 -0
  19. package/dist/formSubmit.js +14 -0
  20. package/dist/formSubmit.js.map +1 -0
  21. package/dist/host.d.ts +23 -0
  22. package/dist/host.js +175 -31
  23. package/dist/host.js.map +1 -1
  24. package/dist/hostCalls.d.ts +15 -0
  25. package/dist/hostCalls.js +75 -8
  26. package/dist/hostCalls.js.map +1 -1
  27. package/dist/js/Form.d.ts +19 -2
  28. package/dist/js/Form.js +110 -83
  29. package/dist/js/Form.js.map +1 -1
  30. package/dist/js/LoadingBoundary.d.ts +5 -0
  31. package/dist/js/LoadingBoundary.js +19 -0
  32. package/dist/js/LoadingBoundary.js.map +1 -0
  33. package/dist/js/createViteRscApp.d.ts +1 -0
  34. package/dist/js/createViteRscApp.js +34 -5
  35. package/dist/js/createViteRscApp.js.map +1 -1
  36. package/dist/js/errors.d.ts +3 -1
  37. package/dist/js/errors.js +26 -3
  38. package/dist/js/errors.js.map +1 -1
  39. package/dist/js/fallbackReport.js +10 -8
  40. package/dist/js/fallbackReport.js.map +1 -1
  41. package/dist/js/formEncoding.d.ts +72 -3
  42. package/dist/js/formEncoding.js +284 -20
  43. package/dist/js/formEncoding.js.map +1 -1
  44. package/dist/js/navigate.d.ts +3 -0
  45. package/dist/js/navigate.js +56 -6
  46. package/dist/js/navigate.js.map +1 -1
  47. package/dist/js/updateStore.js +8 -2
  48. package/dist/js/updateStore.js.map +1 -1
  49. package/dist/js/useEvents.js +9 -7
  50. package/dist/js/useEvents.js.map +1 -1
  51. package/dist/js/usePolling.js +38 -37
  52. package/dist/js/usePolling.js.map +1 -1
  53. package/dist/openapi.d.ts +74 -0
  54. package/dist/openapi.js +172 -0
  55. package/dist/openapi.js.map +1 -0
  56. package/dist/prerender.js +1 -0
  57. package/dist/prerender.js.map +1 -1
  58. package/dist/redirect.d.ts +2 -2
  59. package/dist/redirect.js.map +1 -1
  60. package/dist/request.d.ts +56 -2
  61. package/dist/request.js +68 -4
  62. package/dist/request.js.map +1 -1
  63. package/dist/routeSchema.d.ts +49 -0
  64. package/dist/routeSchema.js.map +1 -1
  65. package/dist/routes.d.ts +15 -1
  66. package/dist/routes.js.map +1 -1
  67. package/dist/testing.d.ts +22 -0
  68. package/dist/testing.js +83 -5
  69. package/dist/testing.js.map +1 -1
  70. package/dist/vite.d.ts +80 -1
  71. package/dist/vite.js +625 -82
  72. package/dist/vite.js.map +1 -1
  73. package/package.json +7 -3
package/dist/vite.js CHANGED
@@ -32,6 +32,7 @@ import { ownHosts } from "./hostRouting.js";
32
32
  import { unrollBarrelImports } from "./barrelImports.js";
33
33
  import { serverRendererMessage, SERVER_RENDERER, ssrProxyModule, UseSsrError, } from "./useSsr.js";
34
34
  import { httpHostCalls } from "./hostCalls.js";
35
+ import { clientPackages, importsServerRenderer, packageDir as installedPackageDir, packageEntryInGraph } from "./clientPackages.js";
35
36
  // Resolved once per rscKit() call. One build runs in one process, so these are
36
37
  // module state rather than threaded through every helper.
37
38
  let projectRoot;
@@ -42,6 +43,60 @@ let resolvedConfig = null;
42
43
  let clientLibraryImports = [];
43
44
  /** Modules a runtime provides and no bundle should try to carry. */
44
45
  const RUNTIME_BUILTINS = ["bun", /^bun:/];
46
+ /**
47
+ * Packages the server bundles import rather than inline, by default.
48
+ *
49
+ * Each of these ships a native binary, spawns one, or reads files relative to
50
+ * its own location - none of which survives being rolled into a bundle. Left
51
+ * external, Nitro traces them into .output/server/node_modules with their
52
+ * binaries, and the built server imports them the way the package expects.
53
+ * Next keeps the same list under serverExternalPackages, for the same reason.
54
+ * `rscKit({ serverExternalPackages })` adds to it.
55
+ */
56
+ /**
57
+ * Side-effect polyfills a dependency checks for at module evaluation, which
58
+ * the generated entry imports before anything else when the project has
59
+ * them. Bundler chunking does not keep an external import's place.
60
+ */
61
+ const FIRST_POLYFILLS = ["reflect-metadata"];
62
+ const DEFAULT_SERVER_EXTERNALS = [
63
+ "sharp",
64
+ "canvas",
65
+ "bcrypt",
66
+ "argon2",
67
+ "@node-rs/argon2",
68
+ "@node-rs/bcrypt",
69
+ "better-sqlite3",
70
+ "sqlite3",
71
+ "libsql",
72
+ "@libsql/client",
73
+ "@prisma/client",
74
+ "prisma",
75
+ "puppeteer",
76
+ "puppeteer-core",
77
+ "playwright",
78
+ "playwright-core",
79
+ "jsdom",
80
+ "node-pty",
81
+ "onnxruntime-node",
82
+ "@sentry/profiling-node",
83
+ "pdfkit",
84
+ "mongodb",
85
+ "oslo",
86
+ "@resvg/resvg-js",
87
+ "@napi-rs/canvas",
88
+ // Not native: a polyfill that must run before anything reads Reflect.getMetadata.
89
+ // tsyringe, typeorm and inversify check for it at module evaluation, and a
90
+ // bundler that splits them into their own chunk hoists that chunk's evaluation
91
+ // above the polyfill's - so the check fails in a bundle that has the polyfill
92
+ // in it. External, the import statement stays where the source put it, and
93
+ // Node runs it first.
94
+ "reflect-metadata",
95
+ ];
96
+ /** A package name as a rollup external: the package and every subpath of it. */
97
+ function externalPackage(name) {
98
+ return new RegExp("^" + name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?:/|$)");
99
+ }
45
100
  function arrayOf(value) {
46
101
  return value == null ? [] : Array.isArray(value) ? value : [value];
47
102
  }
@@ -115,6 +170,8 @@ let siteHosts = [];
115
170
  let hostsOption = [];
116
171
  let barrelImports = true;
117
172
  let identify = true;
173
+ /** Resolved from RscKitOptions.openapi; null when off. */
174
+ let openapi = null;
118
175
  let foundAssets = {
119
176
  favicon: null,
120
177
  icons: [],
@@ -177,6 +234,12 @@ function fileHostActions(root) {
177
234
  return {};
178
235
  try {
179
236
  const parsed = JSON.parse(readFileSync(path, "utf-8"));
237
+ // An empty list is an empty map. PHP's json_encode writes an empty
238
+ // array as [] whatever it was declared as, so a backend with no
239
+ // actions yet wrote exactly that - and the first `dev` of a fresh
240
+ // install refused to start over a file that said, correctly, "none".
241
+ if (Array.isArray(parsed) && parsed.length === 0)
242
+ return {};
180
243
  if (parsed === null ||
181
244
  typeof parsed !== "object" ||
182
245
  Array.isArray(parsed)) {
@@ -202,16 +265,23 @@ function fileHostActions(root) {
202
265
  * two cannot drift.
203
266
  */
204
267
  function aliasEntries() {
268
+ const entries = [
269
+ // A library written for Next reads the request through next/headers -
270
+ // Vercel's flags SDK does, through `flags/next` - and headers() and
271
+ // cookies() here have the same names and shapes, with one object per
272
+ // request, which is what its per-request dedupe keys on. Answered by
273
+ // this package, so such a library runs unchanged and without Next.
274
+ { find: /^next\/headers$/, replacement: join(packageDir, "request") },
275
+ ];
205
276
  if (!packageAlias)
206
- return [];
277
+ return entries;
207
278
  if (existsSync(join(projectRoot, "node_modules", packageAlias)))
208
- return [];
209
- return [
210
- {
211
- find: new RegExp("^" + packageAlias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "/(.*)$"),
212
- replacement: join(packageDir, "js") + "/$1",
213
- },
214
- ];
279
+ return entries;
280
+ entries.push({
281
+ find: new RegExp("^" + packageAlias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "/(.*)$"),
282
+ replacement: join(packageDir, "js") + "/$1",
283
+ });
284
+ return entries;
215
285
  }
216
286
  function resolvePaths(options) {
217
287
  projectRoot = resolve(options.projectRoot || process.env.RSC_PROJECT_ROOT || process.cwd());
@@ -280,6 +350,12 @@ function resolvePaths(options) {
280
350
  hostsOption = options.hosts ?? [];
281
351
  barrelImports = options.barrelImports !== false;
282
352
  identify = options.identify !== false;
353
+ openapi = options.openapi
354
+ ? (() => {
355
+ const { path, ...document } = typeof options.openapi === "object" ? options.openapi : {};
356
+ return { path: path || "/openapi.json", document };
357
+ })()
358
+ : null;
283
359
  inlineStylesheets = options.inlineStylesheets ?? "auto";
284
360
  maxActionBody = options.maxActionBody;
285
361
  // One place, and it is the file. A plugin option as well would be the same
@@ -724,7 +800,32 @@ self.addEventListener('install', (event) => {
724
800
  // it is served by the host out of the prerendered directory, which this
725
801
  // worker cannot see. Added here so it is in the cache before it is
726
802
  // needed, which is the only moment it cannot be fetched.
727
- .then((cache) => cache.addAll(OFFLINE_URL ? [...PRECACHE, OFFLINE_URL] : PRECACHE))
803
+ .then((cache) => cache.addAll(OFFLINE_URL ? [...PRECACHE, OFFLINE_URL] : PRECACHE).then(() => cache))
804
+ // And the payload each precached page boots from. A document alone is
805
+ // markup that never hydrates: the client fetches its payload on boot,
806
+ // and a precached / rendered offline and stayed inert because that
807
+ // request had nothing cached to answer it.
808
+ .then((cache) =>
809
+ Promise.all(
810
+ PRECACHE.filter((url) => !/\\.[a-z0-9]+$/i.test(url))
811
+ .concat(OFFLINE_URL ? [OFFLINE_URL] : [])
812
+ .map((url) => {
813
+ const warm = new Request(url, { headers: { 'X-RSC': '1' } })
814
+
815
+ return fetch(warm)
816
+ .then((payload) => {
817
+ if (mayStore(payload)) return cache.put(keyFor(warm), payload)
818
+
819
+ // Said, not swallowed: a page whose payload cannot be kept
820
+ // is one that will render offline and never hydrate, and
821
+ // the reason - a no-store from a guard above it, usually -
822
+ // is only visible here.
823
+ console.warn('[rsc-kit] offline: the payload for ' + url + ' was not stored (' + (payload.headers.get('Cache-Control') || payload.status) + '), so it will not hydrate offline')
824
+ })
825
+ .catch(() => {})
826
+ }),
827
+ ),
828
+ )
728
829
  .then(() => self.skipWaiting()),
729
830
  )
730
831
  })
@@ -733,9 +834,16 @@ self.addEventListener('activate', (event) => {
733
834
  event.waitUntil(
734
835
  caches
735
836
  .keys()
736
- .then((keys) => Promise.all(keys.filter((k) => k.startsWith('rsc-kit-') && k !== CACHE).map((k) => caches.delete(k))))
737
- .then(() => self.clients.claim())
738
- .then(() => tellTheOpenPages()),
837
+ .then((keys) => {
838
+ const older = keys.filter((k) => k.startsWith('rsc-kit-') && k !== CACHE)
839
+
840
+ return Promise.all(older.map((k) => caches.delete(k))).then(() => older.length > 0)
841
+ })
842
+ .then((swept) => self.clients.claim().then(() => swept))
843
+ // Only when something older was swept. The first worker a visitor ever
844
+ // gets activates too, and told them "a new version is ready" on their
845
+ // second page - there was no previous version to be new against.
846
+ .then((swept) => (swept ? tellTheOpenPages() : undefined)),
739
847
  )
740
848
  })
741
849
 
@@ -780,6 +888,37 @@ const keyFor = (request) => {
780
888
  return new Request(url, { headers: request.headers })
781
889
  }
782
890
 
891
+ // Nothing cached for a request and no network: the offline page for a
892
+ // navigation, and for the boot of a page the offline page stood in for -
893
+ // the runtime asks for the payload of the url in the address bar, which is
894
+ // the one thing nothing has - the offline page's own payload, which is what
895
+ // the document on screen is. A boot only, no segments header: a navigation
896
+ // while offline still fails as itself, and the page already open keeps its
897
+ // banner. Null when there is no offline page, or nothing of it cached.
898
+ const standIn = async (request) => {
899
+ if (!OFFLINE_URL) return null
900
+
901
+ if (request.mode === 'navigate') return (await caches.match(OFFLINE_URL)) ?? null
902
+
903
+ if (request.headers.get('X-RSC') && !request.headers.get('X-RSC-Segments')) {
904
+ return (
905
+ (await caches.match(
906
+ keyFor(new Request(new URL(OFFLINE_URL, self.location.origin), { headers: { 'X-RSC': '1' } })),
907
+ MATCH,
908
+ )) ?? null
909
+ )
910
+ }
911
+
912
+ return null
913
+ }
914
+
915
+ // The Cache API honours Vary, and a stored payload varies on X-RSC - so a
916
+ // payload warmed with one spelling of that header was a miss for a boot
917
+ // that sent another, and a precached page rendered offline with its payload
918
+ // sitting in the cache, unmatched. What Vary is for is already in the key:
919
+ // the ?__rsc=<segments> above is the one header the answer differs on.
920
+ const MATCH = { ignoreVary: true }
921
+
783
922
  self.addEventListener('fetch', (event) => {
784
923
  const request = event.request
785
924
  const url = new URL(request.url)
@@ -814,7 +953,7 @@ self.addEventListener('fetch', (event) => {
814
953
  // differently for every value of it.
815
954
  if (FROZEN.has(url.pathname.replace(/\\/+$/, '') || '/') && !url.search) {
816
955
  event.respondWith(
817
- caches.match(keyFor(request)).then((hit) => {
956
+ caches.match(keyFor(request), MATCH).then((hit) => {
818
957
  const fresh = fetch(request)
819
958
  .then((response) => {
820
959
  if (mayStore(response)) {
@@ -824,10 +963,17 @@ self.addEventListener('fetch', (event) => {
824
963
 
825
964
  return response
826
965
  })
827
- .catch((error) => {
966
+ .catch(async (error) => {
828
967
  if (hit) return hit
829
968
 
830
- throw error
969
+ // Stored by the build but never visited from this browser, so
970
+ // nothing is cached for it, and no network: the offline page is
971
+ // the honest answer, the same as for any other navigation - and
972
+ // its payload for the boot that follows, so the page it stood
973
+ // in for hydrates. Without this a frozen page failed with
974
+ // ERR_FAILED where an unstored one showed /offline, and then
975
+ // stood inert where an unstored one hydrated.
976
+ return (await standIn(request)) ?? Promise.reject(error)
831
977
  })
832
978
 
833
979
  // Not awaited when there is a hit: the point is that the visitor does
@@ -852,11 +998,11 @@ self.addEventListener('fetch', (event) => {
852
998
  // cached, and a reload with no network has the markup and nothing to
853
999
  // hydrate it with.
854
1000
  //
855
- // \`X-RSC: true\` and no segments header is exactly what a fresh boot
1001
+ // \`X-RSC: 1\` and no segments header is exactly what a fresh boot
856
1002
  // sends, which is what makes this entry the one it finds: the server
857
1003
  // varies on those, and the Cache API matches on the same.
858
1004
  if (request.mode === 'navigate') {
859
- const warm = new Request(request.url, { headers: { 'X-RSC': 'true' } })
1005
+ const warm = new Request(request.url, { headers: { 'X-RSC': '1' } })
860
1006
 
861
1007
  fetch(warm)
862
1008
  .then((payload) => {
@@ -891,7 +1037,7 @@ self.addEventListener('fetch', (event) => {
891
1037
  return response
892
1038
  })
893
1039
  .catch(async () => {
894
- const hit = await caches.match(keyFor(request))
1040
+ const hit = await caches.match(keyFor(request), MATCH)
895
1041
 
896
1042
  if (hit) return hit
897
1043
 
@@ -906,11 +1052,9 @@ self.addEventListener('fetch', (event) => {
906
1052
  // stand in for another: it is about being offline, not about the url it
907
1053
  // appears under. Navigations only — a payload request answered with a
908
1054
  // document would be decoded as one and throw.
909
- if (OFFLINE_URL && request.mode === 'navigate') {
910
- const page = await caches.match(OFFLINE_URL)
1055
+ const stood = await standIn(request)
911
1056
 
912
- if (page) return page
913
- }
1057
+ if (stood) return stood
914
1058
 
915
1059
  // Letting it fail says what is true, and a page already open is
916
1060
  // unaffected.
@@ -1025,17 +1169,24 @@ function writeWebManifest(clientDir, options) {
1025
1169
  * precached and found wanting at the one moment it matters, and the build says
1026
1170
  * which read did it.
1027
1171
  */
1028
- function offlineFallback(frozen, results) {
1172
+ export function offlineFallback(frozen, results) {
1029
1173
  const found = results.find((r) => r.url === "/offline");
1030
1174
  if (!found)
1031
1175
  return null;
1032
1176
  if (!frozen.includes("/offline")) {
1033
1177
  // The reason already reads "dynamic — called cookies()", and this sentence
1034
1178
  // has said "not stored" by the time it gets there, so the prefix would say
1035
- // it twice with a dash in the middle of both.
1036
- const why = found.reason?.replace(/^dynamic — /, "") ?? null;
1179
+ // it twice with a dash in the middle of both. A shell's reason is a
1180
+ // sentence of its own - "connection() awaited by AuthLinks streams per
1181
+ // request; the rest is stored" - and takes no "it" in front, and the
1182
+ // part about the rest being stored is not the point here.
1183
+ const why = found.type === "shell"
1184
+ ? (found.reason?.replace(/; the rest is stored$/, "") ?? null)
1185
+ : found.reason
1186
+ ? "it " + found.reason.replace(/^dynamic — /, "")
1187
+ : null;
1037
1188
  log("offline: /offline cannot be the fallback" +
1038
- (why ? `, because it ${why}` : "") +
1189
+ (why ? `, because ${why}` : "") +
1039
1190
  ". A fallback has to be servable with no network at all.");
1040
1191
  return null;
1041
1192
  }
@@ -1061,6 +1212,24 @@ function copyServiceWorkerExtra(clientDir) {
1061
1212
  copyFileSync(source, join(clientDir, "sw-app.js"));
1062
1213
  return "/sw-app.js";
1063
1214
  }
1215
+ /**
1216
+ * Whether a public file is worth fetching before it is asked for.
1217
+ *
1218
+ * The precache is what lets the app boot with no network: the scripts, the
1219
+ * stylesheets, the fonts, the manifest and its icons. Everything else in
1220
+ * public/ - an image, a wasm module, the share card - is cached the first
1221
+ * time it is used, which is what the fetch handler does for any hashed
1222
+ * asset. Precaching all of it made an install cost a megabyte before the
1223
+ * visitor had seen a page: 765 kB of it a webp encoder, the rest a png no
1224
+ * browser ever renders.
1225
+ */
1226
+ export function bootsTheApp(file) {
1227
+ if (/\.(?:m?js|css|woff2?|webmanifest|json)$/i.test(file))
1228
+ return !/\.wasm\.js$/i.test(file);
1229
+ if (/(?:^|\/)(?:icon|apple-icon|favicon)[^/]*\.(?:png|ico|svg)$/i.test(file))
1230
+ return true;
1231
+ return false;
1232
+ }
1064
1233
  function writeServiceWorker(clientDir, frozen = [], offlineUrl = null) {
1065
1234
  if (!existsSync(clientDir))
1066
1235
  return;
@@ -1079,7 +1248,7 @@ function writeServiceWorker(clientDir, frozen = [], offlineUrl = null) {
1079
1248
  const path = join(dir, entry.name);
1080
1249
  if (entry.isDirectory())
1081
1250
  walk(path, `${prefix}${entry.name}/`);
1082
- else
1251
+ else if (bootsTheApp(`${prefix}${entry.name}`))
1083
1252
  files.push(`${prefix}${entry.name}`);
1084
1253
  }
1085
1254
  };
@@ -1682,8 +1851,20 @@ function componentName(absPath) {
1682
1851
  const rel = relative(sourceDir, absPath).replace(/\\/g, "/");
1683
1852
  return rel.replace(/\.(tsx|jsx|ts|js)$/, "");
1684
1853
  }
1685
- function toAlias(name) {
1686
- return "_c_" + name.replace(/[^a-zA-Z0-9]/g, "_");
1854
+ /**
1855
+ * The identifier a route file is imported as in the generated entries.
1856
+ *
1857
+ * One-to-one with the path, which the obvious "replace everything odd with an
1858
+ * underscore" is not: app/agent-account/page and app/agent/account/page
1859
+ * both came out as _c_app_agent_account_page, and the entry declared the
1860
+ * same import twice. A slash is an underscore, because that is what every
1861
+ * alias reads like; anything else that is not a letter or digit - a hyphen,
1862
+ * a dot, the brackets of a param, the parens of a group, an underscore of
1863
+ * its own - is its character code, so no two paths share an alias.
1864
+ */
1865
+ export function toAlias(name) {
1866
+ return ("_c_" +
1867
+ name.replace(/[^a-zA-Z0-9]/g, (char) => (char === "/" ? "_" : "$" + char.charCodeAt(0).toString(16))));
1687
1868
  }
1688
1869
  const components = new Map();
1689
1870
  /**
@@ -1695,6 +1876,48 @@ const components = new Map();
1695
1876
  * a default component.
1696
1877
  */
1697
1878
  const apiRoutes = new Map();
1879
+ const OPENAPI_ROUTE_ID = "virtual:rsc-kit/openapi";
1880
+ /**
1881
+ * /openapi.json, when asked for, registered as an api route at the path the
1882
+ * option names. Synthesised like a sitemap: stored by the build, without
1883
+ * middleware - a document exists to be read.
1884
+ */
1885
+ function registerOpenApiRoutes() {
1886
+ if (!openapi)
1887
+ return;
1888
+ const name = `app${openapi.path.replace(/\/$/, "")}/route`;
1889
+ if (apiRoutes.has(name)) {
1890
+ throw new Error(`[rsc-kit] ${openapi.path} is answered by ${apiRoutes.get(name).absPath} and by rscKit({ openapi }). Move one.`);
1891
+ }
1892
+ apiRoutes.set(name, { name, absPath: OPENAPI_ROUTE_ID, methods: ["GET"], generated: { kind: "openapi", file: "" } });
1893
+ }
1894
+ /** The module behind the synthesised /openapi.json route. */
1895
+ function openApiPlugin() {
1896
+ return {
1897
+ name: "rsc-kit:openapi",
1898
+ resolveId(id) {
1899
+ if (id === OPENAPI_ROUTE_ID)
1900
+ return "\0" + id;
1901
+ },
1902
+ load(id) {
1903
+ if (id !== "\0" + OPENAPI_ROUTE_ID || !openapi)
1904
+ return;
1905
+ // Every route.ts the app wrote, imported so its schemas can describe
1906
+ // themselves at request time; the synthesised ones are not an API.
1907
+ const routes = [...apiRoutes.values()].filter((r) => !r.generated);
1908
+ const guarded = new Set((routeManifest().apis ?? []).filter((api) => api.middleware.length > 0).map((api) => api.name));
1909
+ const imports = routes.map((r, i) => `import * as __r${i} from ${JSON.stringify(r.absPath)}`);
1910
+ const entries = routes.map((r, i) => ` { pattern: ${JSON.stringify(patternOf(urlSegments(r.name)))}, methods: ${JSON.stringify(r.methods)}, guarded: ${guarded.has(r.name)}, module: __r${i} }`);
1911
+ return [
1912
+ ...imports,
1913
+ `import { openApiResponse } from ${JSON.stringify(join(packageDir, "openapi"))}`,
1914
+ `const routes = [\n${entries.join(",\n")}\n]`,
1915
+ `export const GET = () => openApiResponse(routes, ${JSON.stringify(openapi.document)})`,
1916
+ "",
1917
+ ].join("\n");
1918
+ },
1919
+ };
1920
+ }
1698
1921
  /** Files beside the root layout served at the root as they are: robots.txt, a hand-written sitemap.xml, humans.txt. */
1699
1922
  let rootFiles = [];
1700
1923
  const METADATA_ROUTE_ID = "virtual:rsc-kit/metadata-route/";
@@ -2030,6 +2253,9 @@ const NITRO_HANDLER_OPTIONS = ` props: (match, request) => ({
2030
2253
  ...Object.fromEntries(new URL(request.url).searchParams),
2031
2254
  }),
2032
2255
  version: process.env.RSC_BUILD_VERSION,
2256
+ // A built server gzips what it answers, where the runtime can; the dev
2257
+ // server answers raw, which is what a person reading a response wants.
2258
+ compress: import.meta.env.PROD,
2033
2259
  `;
2034
2260
  const NITRO_HOST_CALLS = `
2035
2261
  let hostInstalled = false
@@ -2068,8 +2294,15 @@ function installHostCallsOnce(): void {
2068
2294
  function instrumentationFile() {
2069
2295
  for (const ext of ["ts", "tsx", "mts", "js", "mjs"]) {
2070
2296
  const file = join(sourceDir, `instrumentation.${ext}`);
2071
- if (existsSync(file))
2072
- return file;
2297
+ if (!existsSync(file))
2298
+ continue;
2299
+ // Both halves are optional: a file of imports is a bootstrap, a file
2300
+ // with register() is a hook. The entry must only name `register` when it
2301
+ // exists - a namespace import's missing member is a bundler warning on
2302
+ // every build (IMPORT_IS_UNDEFINED), which is the app being told off for
2303
+ // a file written exactly as documented.
2304
+ const hasRegister = /export\s+(?:async\s+)?(?:function\s+register\b|const\s+register\b|let\s+register\b|\{[^}]*\bregister\b[^}]*\})/.test(readFileSync(file, "utf-8"));
2305
+ return { file, hasRegister };
2073
2306
  }
2074
2307
  return null;
2075
2308
  }
@@ -2135,10 +2368,13 @@ ${
2135
2368
  // First, before any page: an import's side effects run in import order,
2136
2369
  // and a package configured here has to be configured before a page module
2137
2370
  // that reads it at evaluation time.
2138
- instrumentation
2139
- ? `import * as __instrumentation from ${JSON.stringify(instrumentation)}`
2140
- : "const __instrumentation: { register?: () => unknown } = {}"}
2371
+ instrumentation?.hasRegister
2372
+ ? `import * as __instrumentation from ${JSON.stringify(instrumentation.file)}`
2373
+ : instrumentation
2374
+ ? `import ${JSON.stringify(instrumentation.file)}\nconst __instrumentation: { register?: () => unknown } = {}`
2375
+ : "const __instrumentation: { register?: () => unknown } = {}"}
2141
2376
  import { SegmentBoundary } from ${JSON.stringify(join(packageDir, "js/SegmentBoundary"))}
2377
+ import { LoadingBoundary } from ${JSON.stringify(join(packageDir, "js/LoadingBoundary"))}
2142
2378
  import { DocumentTitle } from ${JSON.stringify(join(packageDir, "js/DocumentTitle"))}
2143
2379
  import { SlotBoundary } from ${JSON.stringify(join(packageDir, "js/SlotBoundary"))}
2144
2380
  import { RouteErrorBoundary } from ${JSON.stringify(join(packageDir, "js/RouteErrorBoundary"))}
@@ -2148,12 +2384,12 @@ import { DefaultRouteError } from ${JSON.stringify(join(packageDir, "js/DefaultR
2148
2384
  import { searchParams as requestSearchParams } from ${JSON.stringify(join(packageDir, "request"))}
2149
2385
  import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyError } from ${JSON.stringify(join(packageDir, "routeSchema"))}
2150
2386
  import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
2151
- import { noteRequestRead } from ${JSON.stringify(join(packageDir, "request"))}
2387
+ import { noteRequestRead, urlOf } from ${JSON.stringify(join(packageDir, "request"))}
2152
2388
  import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
2153
2389
  import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
2154
2390
  import { httpHostCalls } from ${JSON.stringify(join(packageDir, "hostCalls"))}
2155
2391
  import { prerenderedBeside } from ${JSON.stringify(join(packageDir, "files"))}
2156
- import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
2392
+ import { renderToReadableStream, decodeReply, decodeAction, decodeFormState, loadServerAction } from '@vitejs/plugin-rsc/rsc'
2157
2393
  import { isQuery, queryCacheControl, isQueryValidationError } from ${JSON.stringify(join(packageDir, "query"))}
2158
2394
  import { isActionValidationError, isClientBuilt } from ${JSON.stringify(join(packageDir, "action"))}
2159
2395
  import { noteFallback as noteCaughtRead } from ${JSON.stringify(join(packageDir, "request"))}
@@ -2313,7 +2549,10 @@ export async function handleApiRoute(
2313
2549
  // reaches for the value.
2314
2550
  noteRequestRead('searchParams')
2315
2551
 
2316
- return parseSearchParams(mod.searchParams, new URL(request.url).searchParams)
2552
+ // Through urlOf, so the build's probe books this read to searchParams
2553
+ // rather than to the url - a route reads request.url itself when it
2554
+ // wants the query the Next way, and that read is the one that matters.
2555
+ return parseSearchParams(mod.searchParams, new URL(urlOf(request)).searchParams)
2317
2556
  }),
2318
2557
  body: hasBody(method) ? lazily(() => parseBody(mod.body, request)) : undefined,
2319
2558
  }
@@ -2675,9 +2914,17 @@ function buildElement(
2675
2914
  searchParams: checkedSearchParams(schemas, pageSearchParams()),
2676
2915
  })
2677
2916
 
2917
+ // Through LoadingBoundary when the runtime is shipped, so a server render
2918
+ // can tell the engine's boundary from one the developer wrote by name -
2919
+ // see that file. A page shipping no runtime gets a plain Suspense: a
2920
+ // client component would drag React in for a wrapper nothing can use.
2678
2921
  for (let i = loadings.length - 1; i >= 0; i--) {
2679
2922
  const Loading = components[loadings[i]]
2680
- element = createElement(Suspense, { fallback: Loading ? createElement(Loading) : null }, element)
2923
+ const fallback = Loading ? createElement(Loading) : null
2924
+
2925
+ element = bootstrap
2926
+ ? createElement(LoadingBoundary, { fallback }, element)
2927
+ : createElement(Suspense, { fallback }, element)
2681
2928
  }
2682
2929
 
2683
2930
  // Outside the Suspense boundary, innermost first — the nearest error.tsx to
@@ -3256,6 +3503,54 @@ export async function handleRscHtmlStream(
3256
3503
  return { htmlStream, rscPayloadPromise, clientChunks: {} }
3257
3504
  }
3258
3505
 
3506
+ /**
3507
+ * A form posted to the page before the page had a runtime.
3508
+ *
3509
+ * React writes the action's id into the form it emits for a server action,
3510
+ * so a browser with no javascript yet - or none at all - posts the fields
3511
+ * to the page's own url. The action runs from those fields, exactly as it
3512
+ * would have been called, and the page renders afterwards with what it
3513
+ * returned as React's form state: a useActionState form shows its result,
3514
+ * a redirect() thrown by the action leaves through the scope the way a
3515
+ * page's would, and a cookie it set is on the answer.
3516
+ */
3517
+ export async function handleRscFormPost(
3518
+ component: string,
3519
+ props: Record<string, unknown> = {},
3520
+ layouts: LayoutEntry[] = [],
3521
+ loadings: string[] = [],
3522
+ parallelSlots: Record<string, string> = {},
3523
+ slotOverrides: Record<string, SlotOverride> = {},
3524
+ nonce?: string,
3525
+ pageKey = '',
3526
+ bootstrap = true,
3527
+ formData: FormData = new FormData(),
3528
+ ): Promise<{ htmlStream: ReadableStream }> {
3529
+ await instrumented()
3530
+ applyHost()
3531
+ await runMiddleware(component, props)
3532
+
3533
+ // The function the form named, bound to what it posted. Nothing named -
3534
+ // a plain form posted here by mistake - and the page simply renders.
3535
+ const action = await decodeAction(formData)
3536
+ let formState: unknown
3537
+
3538
+ if (action) {
3539
+ const result = await action()
3540
+
3541
+ formState = await decodeFormState(result, formData)
3542
+ }
3543
+
3544
+ const flight = renderToReadableStream(
3545
+ await renderTree(component, props, layouts, loadings, parallelSlots, slotOverrides, 0, pageKey, bootstrap),
3546
+ { onError: flightOnError },
3547
+ )
3548
+ const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
3549
+ const htmlStream = await ssr.handleSsr(flight, nonce, undefined, bootstrap, formState)
3550
+
3551
+ return { htmlStream }
3552
+ }
3553
+
3259
3554
  /**
3260
3555
  * Finish a shell that was frozen at build time.
3261
3556
  *
@@ -3882,7 +4177,7 @@ export async function handleRscPprShell(
3882
4177
  // say so on the route's line; the digest goes into the document for the
3883
4178
  // browser to recognise.
3884
4179
  if ((e as { digest?: string } | null)?.digest === 'rsc-kit:search-params-fallback') {
3885
- const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4180
+ const where = /at ([A-Z_$][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
3886
4181
  noteCaughtRead('useSearchParams()' + (where ? ' in ' + where : ''))
3887
4182
  return 'rsc-kit:search-params-fallback'
3888
4183
  }
@@ -4032,6 +4327,7 @@ async function serve(request: Request): Promise<Response> {
4032
4327
  handleRsc,
4033
4328
  handleRscStream,
4034
4329
  handleRscHtmlStream,
4330
+ handleRscFormPost,
4035
4331
  handleRscRevalidate,
4036
4332
  handleRscPayload,
4037
4333
  handleRscPprShell,
@@ -4122,6 +4418,8 @@ function generateEntrySsr() {
4122
4418
  const devUrls = join(packageDir, "devUrls");
4123
4419
  const request = join(packageDir, "request");
4124
4420
  const fallbackReport = join(packageDir, "js/fallbackReport");
4421
+ const redirectDigestModule = join(packageDir, "redirectDigest");
4422
+ const notFoundModule = join(packageDir, "notFound");
4125
4423
  return `// GENERATED by rscKit() — do not edit.
4126
4424
  import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
4127
4425
  import { renderToReadableStream, resume } from 'react-dom/server.edge'
@@ -4129,6 +4427,8 @@ import { prerender } from 'react-dom/static.edge'
4129
4427
  import { rewriteViteDevUrlStream } from ${JSON.stringify(devUrls)}
4130
4428
  import { noteFallback } from ${JSON.stringify(request)}
4131
4429
  import { cancelledByConsumer, caughtByLoading } from ${JSON.stringify(fallbackReport)}
4430
+ import { parseRedirectDigest } from ${JSON.stringify(redirectDigestModule)}
4431
+ import { isNotFoundDigest } from ${JSON.stringify(notFoundModule)}
4132
4432
 
4133
4433
  // Set only by the dev server. @vitejs/plugin-rsc emits its bootstrap and CSS
4134
4434
  // links root-relative in dev, which would send the browser to the host for
@@ -4140,6 +4440,7 @@ export async function handleSsr(
4140
4440
  nonce?: string,
4141
4441
  onError?: (error: unknown) => void,
4142
4442
  bootstrap = true,
4443
+ formState?: unknown,
4143
4444
  ): Promise<ReadableStream> {
4144
4445
  const root = await createFromReadableStream(rscStream)
4145
4446
 
@@ -4156,35 +4457,10 @@ export async function handleSsr(
4156
4457
  const html = await renderToReadableStream(root as any, {
4157
4458
  bootstrapScriptContent,
4158
4459
  nonce,
4159
- // The query-string fallback is the designed path for a stored page, and
4160
- // its digest is what lets the client tell it from a fault on hydration;
4161
- // returned here so React writes it into the document.
4162
- onError: onError ?? ((error: unknown, info?: { componentStack?: string }) => {
4163
- // The consumer cancelled - a browser that left mid-stream, a prefetch
4164
- // abandoned. React reports it as an error; the page had none.
4165
- if (cancelledByConsumer(error)) return
4166
- const digest = (error as { digest?: string } | null)?.digest
4167
- if (digest === 'rsc-kit:search-params-fallback') {
4168
- // The component is the first frame of React's stack. Noted on the
4169
- // request so the build attaches it to the route; printed as one line,
4170
- // not a stack, so the dev server says which boundary the build wants.
4171
- const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4172
- noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4173
- // Under a boundary the developer wrote, nothing to say. With nothing
4174
- // closer than a loading.tsx, one line: the whole segment is the
4175
- // fallback until the query arrives.
4176
- if (caughtByLoading(info?.componentStack)) {
4177
- console.error(
4178
- '[rsc-kit] ' + (where ? where + ': ' : '') +
4179
- 'useSearchParams() was read on the server with nothing closer than a loading.tsx, so the whole ' +
4180
- 'segment shows that fallback until the query arrives. A <Suspense> around the component that reads ' +
4181
- 'keeps the rest of the page painted.',
4182
- )
4183
- }
4184
- return digest
4185
- }
4186
- console.error('[rsc-kit:ssr]', error)
4187
- }),
4460
+ onError: onError ?? reportRenderError('ssr'),
4461
+ // What a posted form's action returned, for the useActionState that
4462
+ // asked: React seats it in the form it belongs to.
4463
+ ...(formState !== undefined ? { formState: formState as any } : {}),
4188
4464
  })
4189
4465
 
4190
4466
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
@@ -4224,7 +4500,7 @@ export async function handleSsrPrerender(
4224
4500
  // caught at a boundary is: the build attaches it to the route.
4225
4501
  onError: (error: unknown, info?: { componentStack?: string }) => {
4226
4502
  if ((error as { digest?: string } | null)?.digest !== 'rsc-kit:search-params-fallback') return
4227
- const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4503
+ const where = /at ([A-Z_$][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4228
4504
  noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4229
4505
  return 'rsc-kit:search-params-fallback'
4230
4506
  },
@@ -4236,6 +4512,53 @@ export async function handleSsrPrerender(
4236
4512
  }
4237
4513
  }
4238
4514
 
4515
+ /**
4516
+ * What a server render says about an error, first render and resume alike.
4517
+ *
4518
+ * The query-string fallback is the designed path for a stored page, and its
4519
+ * digest is what lets the client tell it from a fault on hydration; returned
4520
+ * so React writes it into the document. Under a boundary the developer wrote
4521
+ * there is nothing to say; with nothing closer than a loading.tsx, one line.
4522
+ */
4523
+ function reportRenderError(phase: 'ssr' | 'resume') {
4524
+ return (error: unknown, info?: { componentStack?: string }) => {
4525
+ // The consumer cancelled - a browser that left mid-stream, a prefetch
4526
+ // abandoned. React reports it as an error; the page had none.
4527
+ if (cancelledByConsumer(error)) return
4528
+
4529
+ const digest = (error as { digest?: string } | null)?.digest
4530
+
4531
+ // A redirect or a notFound() the render asked for arrives here as the
4532
+ // Flight row's error, once per boundary it was thrown in. The rsc side
4533
+ // already turned it into a digest on purpose; it is the page's answer,
4534
+ // and the host reads it off the digest. Not an error to print, four
4535
+ // times or once.
4536
+ if (parseRedirectDigest(digest) || isNotFoundDigest(digest)) return digest
4537
+
4538
+ if (digest === 'rsc-kit:search-params-fallback') {
4539
+ // The component is the first frame of React's stack. Noted on the
4540
+ // request so the build attaches it to the route; printed as one line,
4541
+ // not a stack, so the server says which boundary it wants.
4542
+ const where = /at ([A-Z_$][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4543
+
4544
+ noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4545
+
4546
+ if (caughtByLoading(info?.componentStack)) {
4547
+ console.error(
4548
+ '[rsc-kit] ' + (where ? where + ': ' : '') +
4549
+ 'useSearchParams() was read on the server with nothing closer than a loading.tsx, so the whole ' +
4550
+ 'segment shows that fallback until the query arrives. A <Suspense> around the component that reads ' +
4551
+ 'keeps the rest of the page painted.',
4552
+ )
4553
+ }
4554
+
4555
+ return digest
4556
+ }
4557
+
4558
+ console.error('[rsc-kit:' + phase + ']', error)
4559
+ }
4560
+ }
4561
+
4239
4562
  /**
4240
4563
  * Pick a build-time render back up, against data that exists now.
4241
4564
  *
@@ -4255,7 +4578,12 @@ export async function handleSsrResume(
4255
4578
 
4256
4579
  const html = await resume(root as any, postponed as any, {
4257
4580
  nonce,
4258
- onError: (error: unknown) => { console.error('[rsc-kit:resume]', error) },
4581
+ // The same reading of an error the first render has. This used to log
4582
+ // every error raw, so a query read the developer's own boundary caught -
4583
+ // the designed path, on every resume of a shell whose hole holds one -
4584
+ // printed as "[rsc-kit:resume] Error: useSearchParams() was read..." on
4585
+ // every request, with advice the app had already followed.
4586
+ onError: reportRenderError('resume'),
4259
4587
  })
4260
4588
 
4261
4589
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
@@ -4305,6 +4633,9 @@ import { refresh } from ${JSON.stringify(refreshModule)}
4305
4633
  createViteRscApp(document, ${JSON.stringify(interceptManifest())}, ${JSON.stringify({
4306
4634
  staticPayloads: staticPayloads || null,
4307
4635
  routes: routesForClient,
4636
+ // Every route.ts, as a url pattern, so a link to one is treated as an
4637
+ // anchor rather than prefetched and fetched as a page.
4638
+ apiRoutes: (routeManifest().apis ?? []).map((api) => patternOf(api.segments)),
4308
4639
  })})
4309
4640
 
4310
4641
  // A server component is not a module the browser has, so Vite cannot replace
@@ -4335,12 +4666,19 @@ ${offline
4335
4666
  // worker answering from a cache in front of it turns every edit into a
4336
4667
  // question about which copy you are looking at.
4337
4668
  if ('serviceWorker' in navigator && import.meta.env.PROD) {
4338
- window.addEventListener('load', () => {
4669
+ const register = () => {
4339
4670
  void navigator.serviceWorker.register('/sw.js').catch(() => {
4340
4671
  // A worker that will not register is not a reason for the page to fail.
4341
4672
  // The app works; it just will not survive being reloaded offline.
4342
4673
  })
4343
- })
4674
+ }
4675
+
4676
+ // This runs after the boot payload has been fetched and decoded, and on a
4677
+ // fast load the page has finished loading by then - a listener added now
4678
+ // waits for an event that has already fired, and nothing registered until
4679
+ // the next navigation.
4680
+ if (document.readyState === 'complete') register()
4681
+ else window.addEventListener('load', register)
4344
4682
  }
4345
4683
  `
4346
4684
  : ""}`;
@@ -4611,7 +4949,21 @@ function useSsrModules() {
4611
4949
  * directive that moves it. The build says the same once, as a warning.
4612
4950
  */
4613
4951
  const RENDERER_STUB = "\0rsc-kit:react-dom-server?from=";
4614
- function serverRendererInRsc() {
4952
+ /** What react-dom/server exports, across its node, edge and browser entries. */
4953
+ const SERVER_RENDERER_EXPORTS = [
4954
+ "renderToString",
4955
+ "renderToStaticMarkup",
4956
+ "renderToReadableStream",
4957
+ "renderToPipeableStream",
4958
+ "renderToStaticNodeStream",
4959
+ "resume",
4960
+ "resumeToPipeableStream",
4961
+ "resumeAndPrerender",
4962
+ "resumeAndPrerenderToNodeStream",
4963
+ "prerender",
4964
+ "prerenderToNodeStream",
4965
+ ];
4966
+ export function serverRendererInRsc() {
4615
4967
  const warned = new Set();
4616
4968
  const appImporter = (ctx, importer) => {
4617
4969
  let current = importer;
@@ -4630,24 +4982,93 @@ function serverRendererInRsc() {
4630
4982
  }
4631
4983
  return importer ? relative(process.cwd(), importer.split("?")[0]) : null;
4632
4984
  };
4985
+ // Per package, so a dependency imported from twenty files is read once.
4986
+ const externalRenderers = new Map();
4987
+ // Packages already named by the warning above, so the stub's own warning
4988
+ // - "imported by node_modules/<package>/dist/index.mjs" - stays quiet for
4989
+ // them: one problem, one line.
4990
+ const warnedPackages = new Set();
4633
4991
  return {
4634
4992
  name: "rsc-kit:server-renderer",
4993
+ // Before Vite's own resolver, which otherwise answers react-dom/server
4994
+ // with React's react-server entry - a real module missing the export,
4995
+ // and a build that fails on MISSING_EXPORT with no mention of the fix.
4996
+ enforce: "pre",
4635
4997
  applyToEnvironment: (environment) => environment.name === "rsc",
4636
- resolveId(source, importer) {
4637
- if (!SERVER_RENDERER.test(source))
4998
+ async resolveId(source, importer) {
4999
+ // Only an import from a module: a resolve with no importer is Vite
5000
+ // asking whether the specifier exists - deciding what to externalise -
5001
+ // and answering that with the stub would answer the wrong question.
5002
+ if (SERVER_RENDERER.test(source)) {
5003
+ return importer ? RENDERER_STUB + encodeURIComponent(importer) : undefined;
5004
+ }
5005
+ // A bare import from app code of a package the build leaves external:
5006
+ // its own import of react-dom/server is never resolved here, so the
5007
+ // stub above never sees it. @react-email/render, imported by an
5008
+ // action, built clean and threw React's refusal at the first visitor.
5009
+ // The package is read once for the import, and the build warns the
5010
+ // same way it does for a direct one - naming the app file and the
5011
+ // package - without stubbing a module that may also be used well.
5012
+ if (!importer || importer.includes("/node_modules/") || importer.startsWith("\0"))
5013
+ return;
5014
+ if (!/^(?:@[\w.-]+\/)?[\w.-]+/.test(source) || source.startsWith("."))
5015
+ return;
5016
+ // The generated entries import plugin-rsc, whose ssr half imports the
5017
+ // renderer for its own reasons; that is the build's, not the app's.
5018
+ if (outDir && importer.startsWith(outDir))
4638
5019
  return;
4639
- return RENDERER_STUB + encodeURIComponent(importer ?? "");
5020
+ const name = source.startsWith("@") ? source.split("/").slice(0, 2).join("/") : source.split("/")[0];
5021
+ if (name === "react-dom" || name === "react" || name.startsWith("@rsc-kit/") || name.startsWith("@vitejs/"))
5022
+ return;
5023
+ let imports = externalRenderers.get(name);
5024
+ if (imports === undefined) {
5025
+ // Vite answers a dependency either as external, by its bare name,
5026
+ // or by the file it resolved to under node_modules - which of the
5027
+ // two depends on how the environment was set up, and both are a
5028
+ // dependency whose imports the build will not look inside.
5029
+ const resolved = await this.resolve(source, importer, { skipSelf: true });
5030
+ const id = resolved?.id ?? "";
5031
+ const marker = `/node_modules/${name}/`;
5032
+ const at = id.lastIndexOf(marker);
5033
+ const dir = resolved?.external
5034
+ ? installedPackageDir(name, dirname(importer.split("?")[0]))
5035
+ : at !== -1
5036
+ ? id.slice(0, at + marker.length - 1)
5037
+ : null;
5038
+ imports = dir !== null && importsServerRenderer(dir);
5039
+ externalRenderers.set(name, imports);
5040
+ }
5041
+ if (!imports)
5042
+ return;
5043
+ const message = `${relative(process.cwd(), importer.split("?")[0])} imports ${name}, which imports react-dom/server, and ` +
5044
+ serverRendererMessage(null).replace(/^react-dom\/server cannot run/, "that cannot run");
5045
+ if (!warned.has(message)) {
5046
+ warned.add(message);
5047
+ warnedPackages.add(name);
5048
+ this.warn(message);
5049
+ }
4640
5050
  },
4641
5051
  load(id) {
4642
5052
  if (!id.startsWith(RENDERER_STUB))
4643
5053
  return;
4644
5054
  const importer = decodeURIComponent(id.slice(RENDERER_STUB.length)) || null;
4645
5055
  const message = serverRendererMessage(appImporter(this, importer));
4646
- if (this.environment.mode === "build" && !warned.has(message)) {
5056
+ // The last node_modules segment: Bun's store nests the real package
5057
+ // under node_modules/.bun/<pkg>@<v>/node_modules/<pkg>.
5058
+ const viaPackage = [...(importer ?? "").matchAll(/\/node_modules\/((?:@[^/]+\/)?[^/]+)\//g)].at(-1)?.[1];
5059
+ if (this.environment.mode === "build" &&
5060
+ !warned.has(message) &&
5061
+ !(viaPackage && warnedPackages.has(viaPackage))) {
4647
5062
  warned.add(message);
4648
5063
  this.warn(message);
4649
5064
  }
4650
- return `throw new Error(${JSON.stringify(message)});\n`;
5065
+ // Every name the renderer exports, each throwing the fix when called.
5066
+ // A module that threw when loaded took the whole server down at boot
5067
+ // for one action nobody had called yet, and a stub with no exports
5068
+ // failed the build on MISSING_EXPORT with the message scrolled past.
5069
+ const refuse = `() => { throw new Error(${JSON.stringify(message)}) }`;
5070
+ return (SERVER_RENDERER_EXPORTS.map((name) => `export const ${name} = ${refuse};`).join("\n") +
5071
+ `\nexport const version = "0.0.0";\nexport default { ${SERVER_RENDERER_EXPORTS.join(", ")}, version };\n`);
4651
5072
  },
4652
5073
  };
4653
5074
  }
@@ -4790,8 +5211,59 @@ export default function rscKitStartup() {
4790
5211
  })
4791
5212
  }
4792
5213
  `;
5214
+ /**
5215
+ * Where NODE_ENV=development came from, and what to do about it.
5216
+ *
5217
+ * Vite reads NODE_ENV from its env files in this order, later winning, and
5218
+ * only when the process did not already set it - so the last file that
5219
+ * names it is the one that decided. No file naming it means the shell or
5220
+ * `--mode` did.
5221
+ */
5222
+ function nodeEnvSetIn(mode, envDir) {
5223
+ const files = [".env", ".env.local", `.env.${mode}`, `.env.${mode}.local`];
5224
+ let culprit = null;
5225
+ // The project's root, not Vite's: this plugin points Vite's root at the
5226
+ // build directory, and the app's .env is read from the project root - by
5227
+ // Nitro's dotenv loading into process.env, and by Vite where envDir says
5228
+ // so. Both places are looked at; the project's is where the file is.
5229
+ const dirs = [...new Set([projectRoot, envDir || projectRoot])];
5230
+ for (const dir of dirs)
5231
+ for (const name of files) {
5232
+ const path = join(dir, name);
5233
+ if (!existsSync(path))
5234
+ continue;
5235
+ const lines = readFileSync(path, "utf-8").split("\n");
5236
+ const at = lines.findIndex((line) => /^\s*(?:export\s+)?NODE_ENV\s*=/.test(line));
5237
+ if (at !== -1)
5238
+ culprit = `${path}:${at + 1}`;
5239
+ }
5240
+ return culprit;
5241
+ }
5242
+ function refuseDevelopmentBuild(config) {
5243
+ const culprit = nodeEnvSetIn(config.mode, config.envDir);
5244
+ return ("[rsc-kit] vite build is running as a development build, and the output cannot work: " +
5245
+ "the pages are compiled against React's development JSX runtime (jsxDEV), the server " +
5246
+ "bundles carry React's production build, and every route fails to render.\n\n" +
5247
+ (culprit
5248
+ ? `NODE_ENV=development is set in ${culprit}. Remove that line - Vite sets NODE_ENV ` +
5249
+ "itself: development under `vite`, production under `vite build`."
5250
+ : `NODE_ENV is "${process.env.NODE_ENV ?? ""}" from the environment or --mode ` +
5251
+ "(mode: " + JSON.stringify(config.mode) + "). Build with NODE_ENV=production, or unset it."));
5252
+ }
4793
5253
  export function rscKit(options = {}) {
4794
5254
  resolvePaths(options);
5255
+ const serverExternals = [
5256
+ ...RUNTIME_BUILTINS,
5257
+ ...[...DEFAULT_SERVER_EXTERNALS, ...(options.serverExternalPackages ?? [])].map(externalPackage),
5258
+ ];
5259
+ // Dependencies with a "use client" file that plugin-rsc would leave
5260
+ // external, because they never declared react as a peer. Bundled into the
5261
+ // server graphs so the directive is read, the same as a package that did.
5262
+ const bundledClientPackages = clientPackages(projectRoot);
5263
+ for (const name of bundledClientPackages) {
5264
+ console.warn(`[rsc-kit] bundling ${name}: it has "use client" files but does not declare react ` +
5265
+ "as a peer dependency, so its components would otherwise run on the server.");
5266
+ }
4795
5267
  const routesPlugin = {
4796
5268
  name: "rsc-kit",
4797
5269
  // A Nitro module, which Nitro's Vite plugin collects from any plugin
@@ -4801,9 +5273,47 @@ export function rscKit(options = {}) {
4801
5273
  nitro: {
4802
5274
  name: "rsc-kit",
4803
5275
  setup(nitro) {
4804
- if (nitro.options.dev || !instrumentationFile())
5276
+ if (nitro.options.dev)
4805
5277
  return;
5278
+ // External at Nitro's layer too. The Vite build leaves these as
5279
+ // imports, and Nitro would then bundle them into its own chunks -
5280
+ // which for a native package fails at load, and for a polyfill like
5281
+ // reflect-metadata runs it after the chunk that checks for it.
5282
+ // Traced, each is copied into .output/server/node_modules and
5283
+ // imported by the built server the way its author expected.
5284
+ nitro.options.traceDeps = [
5285
+ ...(nitro.options.traceDeps ?? []),
5286
+ ...DEFAULT_SERVER_EXTERNALS,
5287
+ ...(options.serverExternalPackages ?? []),
5288
+ ];
5289
+ // The assets, precompressed at build and served with their encoding
5290
+ // by Nitro's own static handler; the host gzips the rest as it
5291
+ // answers. Between them a bun or node server answering the internet
5292
+ // by itself sends nothing raw. A config that set this keeps it.
5293
+ // Nitro's default is a literal false, so ?? would not do; an object
5294
+ // is a configuration and is kept.
5295
+ if (typeof nitro.options.compressPublicAssets !== "object")
5296
+ nitro.options.compressPublicAssets = true;
4806
5297
  nitro.options.virtual ??= {};
5298
+ // A polyfill a dependency checks for at module evaluation, loaded
5299
+ // before any service. The bundler places an external import after
5300
+ // the chunk imports of the module that had it, whatever the source
5301
+ // said, so reflect-metadata came up after tsyringe (under
5302
+ // @peculiar/x509, under @simplewebauthn/server) - by luck survivable
5303
+ // run as a directory, fatal compiled into a binary. A Nitro plugin
5304
+ // is evaluated by the entry itself, and the services that carry the
5305
+ // app are loaded lazily after it; imported by file, the polyfill is
5306
+ // inlined there and runs first wherever the server runs. Only for a
5307
+ // project whose graph has it.
5308
+ const polyfills = FIRST_POLYFILLS.map((name) => packageEntryInGraph(projectRoot, name)).filter((entry) => entry !== null);
5309
+ if (polyfills.length) {
5310
+ nitro.options.virtual["#rsc-kit/polyfills"] =
5311
+ polyfills.map((entry) => `import ${JSON.stringify(entry)};`).join("\n") +
5312
+ "\nexport default () => {};\n";
5313
+ nitro.options.plugins = ["#rsc-kit/polyfills", ...(nitro.options.plugins ?? [])];
5314
+ }
5315
+ if (!instrumentationFile())
5316
+ return;
4807
5317
  nitro.options.virtual["#rsc-kit/startup"] = STARTUP_PLUGIN;
4808
5318
  nitro.options.plugins = [...(nitro.options.plugins ?? []), "#rsc-kit/startup"];
4809
5319
  },
@@ -4819,6 +5329,7 @@ export function rscKit(options = {}) {
4819
5329
  apiRoutes.clear();
4820
5330
  discover(appDir);
4821
5331
  registerMetadataRoutes(appDir);
5332
+ registerOpenApiRoutes();
4822
5333
  siteHosts = ownHosts(rootMetadataBase(appDir), hostsOption);
4823
5334
  // Silent when it worked. The names were printed on every dev start and
4824
5335
  // every build — thirty of them for a middling app, above the output that
@@ -4998,6 +5509,11 @@ export function rscKit(options = {}) {
4998
5509
  alias: aliasEntries(),
4999
5510
  },
5000
5511
  build: { emptyOutDir: true },
5512
+ // What reaches the browser. Vite's own VITE_ prefix, and PUBLIC_ - the
5513
+ // scaffold's spelling, and Next's minus its brand - so a variable
5514
+ // named for a port reads through import.meta.env without a config
5515
+ // line. A config that set its own prefixes keeps them; Vite merges.
5516
+ envPrefix: ["VITE_", "PUBLIC_"],
5001
5517
  environments: {
5002
5518
  // Server bundles — stay under the (non-public) out dir. `bun` and
5003
5519
  // `bun:*` are the runtime's own modules, like `node:*`: nothing to
@@ -5007,17 +5523,21 @@ export function rscKit(options = {}) {
5007
5523
  build: {
5008
5524
  rollupOptions: {
5009
5525
  input: { index: join(genDir, "entry.rsc.tsx") },
5010
- external: RUNTIME_BUILTINS,
5526
+ external: serverExternals,
5011
5527
  },
5012
5528
  },
5529
+ resolve: { noExternal: bundledClientPackages },
5530
+ optimizeDeps: { exclude: bundledClientPackages },
5013
5531
  },
5014
5532
  ssr: {
5015
5533
  build: {
5016
5534
  rollupOptions: {
5017
5535
  input: { index: join(genDir, "entry.ssr.tsx") },
5018
- external: RUNTIME_BUILTINS,
5536
+ external: serverExternals,
5019
5537
  },
5020
5538
  },
5539
+ resolve: { noExternal: bundledClientPackages },
5540
+ optimizeDeps: { exclude: bundledClientPackages },
5021
5541
  },
5022
5542
  // Client bundle — emitted into public/ for the web server to serve.
5023
5543
  client: {
@@ -5239,8 +5759,12 @@ export function rscKit(options = {}) {
5239
5759
  // when the directory is not there. Only under Nitro, whose presets are
5240
5760
  // the ones without a disk; on its own the plugin serves from outDir.
5241
5761
  if (clientOut && existsSync(staticDir)) {
5242
- const { inlineModuleName, inlineModuleSource } = await import("./files.js");
5762
+ const { inlineModuleName, inlineModuleSource, compileEntrySource } = await import("./files.js");
5243
5763
  writeFileSync(join(dirname(staticDir), inlineModuleName(NITRO_STATIC_DIR)), await inlineModuleSource(staticDir));
5764
+ // And the entry a binary is compiled from, so the pages above end up
5765
+ // inside it rather than rendered live: bun build --compile
5766
+ // .output/server/compile.mjs. The scaffold's script names it.
5767
+ writeFileSync(join(dirname(staticDir), "compile.mjs"), compileEntrySource(NITRO_STATIC_DIR));
5244
5768
  }
5245
5769
  // Manifest first. The service worker precaches whatever it finds in this
5246
5770
  // directory, so writing it afterwards leaves it out of the list — and an
@@ -5254,6 +5778,24 @@ export function rscKit(options = {}) {
5254
5778
  }
5255
5779
  },
5256
5780
  configResolved(config) {
5781
+ // A build that is not a production build cannot work here, and the
5782
+ // way it fails is opaque: plugin-react emits the development JSX
5783
+ // runtime (jsxDEV), the server bundles resolve React's production
5784
+ // build, and every route fails to render with React's "message
5785
+ // omitted in production builds". The usual cause is NODE_ENV=development
5786
+ // in a .env file, which Vite honours - a line the scaffold itself once
5787
+ // wrote. Named, with the file and line, before any of that happens.
5788
+ //
5789
+ // Refused rather than overridden, because a plugin cannot override it:
5790
+ // Vite notes whether NODE_ENV was set before it loads the config file
5791
+ // and applies the .env value after the config hooks have run, so an
5792
+ // assignment here is overwritten - and patching the resolved config
5793
+ // afterwards leaves the client bundle's process.env.NODE_ENV and
5794
+ // import.meta.env.DEV already decided, which is a production server
5795
+ // serving a development client.
5796
+ if (config.command === "build" && !config.isProduction) {
5797
+ throw new Error(refuseDevelopmentBuild(config));
5798
+ }
5257
5799
  isWatch = config.build?.watch != null;
5258
5800
  resolvedConfig = config;
5259
5801
  const nitroMain = config.plugins.find((p) => p.name === "nitro:main");
@@ -5307,6 +5849,7 @@ export function rscKit(options = {}) {
5307
5849
  useSsrModules(),
5308
5850
  serverRendererInRsc(),
5309
5851
  metadataRoutesPlugin(),
5852
+ openApiPlugin(),
5310
5853
  extendableClientReferences(),
5311
5854
  typecheckPlugin(),
5312
5855
  clientImportsAudit(),