@rsc-kit/core 0.18.1 → 0.20.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 (77) 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 +25 -7
  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 +21 -4
  28. package/dist/js/Form.js +110 -83
  29. package/dist/js/Form.js.map +1 -1
  30. package/dist/js/Link.d.ts +5 -5
  31. package/dist/js/Link.js.map +1 -1
  32. package/dist/js/RedirectBoundary.js.map +1 -1
  33. package/dist/js/RouteErrorBoundary.d.ts +6 -2
  34. package/dist/js/RouteErrorBoundary.js +10 -0
  35. package/dist/js/RouteErrorBoundary.js.map +1 -1
  36. package/dist/js/SlotBoundary.d.ts +7 -1
  37. package/dist/js/SlotBoundary.js +9 -1
  38. package/dist/js/SlotBoundary.js.map +1 -1
  39. package/dist/js/createViteRscApp.d.ts +1 -0
  40. package/dist/js/createViteRscApp.js +54 -5
  41. package/dist/js/createViteRscApp.js.map +1 -1
  42. package/dist/js/errors.d.ts +3 -1
  43. package/dist/js/errors.js +26 -3
  44. package/dist/js/errors.js.map +1 -1
  45. package/dist/js/formEncoding.d.ts +72 -3
  46. package/dist/js/formEncoding.js +284 -20
  47. package/dist/js/formEncoding.js.map +1 -1
  48. package/dist/js/navigate.d.ts +6 -3
  49. package/dist/js/navigate.js +57 -7
  50. package/dist/js/navigate.js.map +1 -1
  51. package/dist/js/nuqs.js.map +1 -1
  52. package/dist/js/router.d.ts +3 -3
  53. package/dist/js/router.js.map +1 -1
  54. package/dist/js/updateStore.js +8 -2
  55. package/dist/js/updateStore.js.map +1 -1
  56. package/dist/manifest.d.ts +6 -0
  57. package/dist/manifest.js.map +1 -1
  58. package/dist/openapi.d.ts +74 -0
  59. package/dist/openapi.js +172 -0
  60. package/dist/openapi.js.map +1 -0
  61. package/dist/redirect.d.ts +4 -4
  62. package/dist/redirect.js.map +1 -1
  63. package/dist/request.d.ts +56 -2
  64. package/dist/request.js +68 -4
  65. package/dist/request.js.map +1 -1
  66. package/dist/routes.d.ts +17 -6
  67. package/dist/routes.js.map +1 -1
  68. package/dist/testing.d.ts +14 -0
  69. package/dist/testing.js +56 -2
  70. package/dist/testing.js.map +1 -1
  71. package/dist/useSsr.d.ts +11 -0
  72. package/dist/useSsr.js +15 -0
  73. package/dist/useSsr.js.map +1 -1
  74. package/dist/vite.d.ts +69 -1
  75. package/dist/vite.js +481 -51
  76. package/dist/vite.js.map +1 -1
  77. package/package.json +5 -1
package/dist/vite.js CHANGED
@@ -30,8 +30,9 @@ import { serverImportsOfClientPackages } from "./clientImports.js";
30
30
  import { automaticSitemap, METADATA_ROUTES, ROOT_FILES, rootFileType, } from "./metadataRoutes.js";
31
31
  import { ownHosts } from "./hostRouting.js";
32
32
  import { unrollBarrelImports } from "./barrelImports.js";
33
- import { serverRendererMessage, SERVER_RENDERER, ssrProxyModule, UseSsrError, } from "./useSsr.js";
33
+ import { serverRendererMessage, SERVER_RENDERER, ssrProxyModule, UseSsrError, withoutSsrDirective } 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;
@@ -52,6 +53,12 @@ const RUNTIME_BUILTINS = ["bun", /^bun:/];
52
53
  * Next keeps the same list under serverExternalPackages, for the same reason.
53
54
  * `rscKit({ serverExternalPackages })` adds to it.
54
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"];
55
62
  const DEFAULT_SERVER_EXTERNALS = [
56
63
  "sharp",
57
64
  "canvas",
@@ -78,6 +85,13 @@ const DEFAULT_SERVER_EXTERNALS = [
78
85
  "oslo",
79
86
  "@resvg/resvg-js",
80
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",
81
95
  ];
82
96
  /** A package name as a rollup external: the package and every subpath of it. */
83
97
  function externalPackage(name) {
@@ -156,6 +170,8 @@ let siteHosts = [];
156
170
  let hostsOption = [];
157
171
  let barrelImports = true;
158
172
  let identify = true;
173
+ /** Resolved from RscKitOptions.openapi; null when off. */
174
+ let openapi = null;
159
175
  let foundAssets = {
160
176
  favicon: null,
161
177
  icons: [],
@@ -218,6 +234,12 @@ function fileHostActions(root) {
218
234
  return {};
219
235
  try {
220
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 {};
221
243
  if (parsed === null ||
222
244
  typeof parsed !== "object" ||
223
245
  Array.isArray(parsed)) {
@@ -243,16 +265,23 @@ function fileHostActions(root) {
243
265
  * two cannot drift.
244
266
  */
245
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
+ ];
246
276
  if (!packageAlias)
247
- return [];
277
+ return entries;
248
278
  if (existsSync(join(projectRoot, "node_modules", packageAlias)))
249
- return [];
250
- return [
251
- {
252
- find: new RegExp("^" + packageAlias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "/(.*)$"),
253
- replacement: join(packageDir, "js") + "/$1",
254
- },
255
- ];
279
+ return entries;
280
+ entries.push({
281
+ find: new RegExp("^" + packageAlias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "/(.*)$"),
282
+ replacement: join(packageDir, "js") + "/$1",
283
+ });
284
+ return entries;
256
285
  }
257
286
  function resolvePaths(options) {
258
287
  projectRoot = resolve(options.projectRoot || process.env.RSC_PROJECT_ROOT || process.cwd());
@@ -321,6 +350,12 @@ function resolvePaths(options) {
321
350
  hostsOption = options.hosts ?? [];
322
351
  barrelImports = options.barrelImports !== false;
323
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;
324
359
  inlineStylesheets = options.inlineStylesheets ?? "auto";
325
360
  maxActionBody = options.maxActionBody;
326
361
  // One place, and it is the file. A plugin option as well would be the same
@@ -550,10 +585,16 @@ function routeManifest() {
550
585
  },
551
586
  routes,
552
587
  intercepts,
553
- apis: [...apiRoutes.values()].map(({ name, methods, generated }) => ({
588
+ apis: [...apiRoutes.values()].map(({ name, methods, generated, absPath }) => ({
554
589
  name,
555
590
  segments: urlSegments(name),
556
591
  methods,
592
+ // The OpenAPI document is synthesised from nothing on disk.
593
+ source: generated
594
+ ? generated.file
595
+ ? relative(sourceDir, generated.file).replace(/\\/g, "/")
596
+ : undefined
597
+ : relative(sourceDir, absPath).replace(/\\/g, "/"),
557
598
  // A synthesised robots.txt or sitemap.xml runs no guard: it exists to be
558
599
  // read by anyone, and a guard on the root would 401 the crawler.
559
600
  middleware: generated ? [] : ancestors(name, "middleware"),
@@ -765,7 +806,32 @@ self.addEventListener('install', (event) => {
765
806
  // it is served by the host out of the prerendered directory, which this
766
807
  // worker cannot see. Added here so it is in the cache before it is
767
808
  // needed, which is the only moment it cannot be fetched.
768
- .then((cache) => cache.addAll(OFFLINE_URL ? [...PRECACHE, OFFLINE_URL] : PRECACHE))
809
+ .then((cache) => cache.addAll(OFFLINE_URL ? [...PRECACHE, OFFLINE_URL] : PRECACHE).then(() => cache))
810
+ // And the payload each precached page boots from. A document alone is
811
+ // markup that never hydrates: the client fetches its payload on boot,
812
+ // and a precached / rendered offline and stayed inert because that
813
+ // request had nothing cached to answer it.
814
+ .then((cache) =>
815
+ Promise.all(
816
+ PRECACHE.filter((url) => !/\\.[a-z0-9]+$/i.test(url))
817
+ .concat(OFFLINE_URL ? [OFFLINE_URL] : [])
818
+ .map((url) => {
819
+ const warm = new Request(url, { headers: { 'X-RSC': '1' } })
820
+
821
+ return fetch(warm)
822
+ .then((payload) => {
823
+ if (mayStore(payload)) return cache.put(keyFor(warm), payload)
824
+
825
+ // Said, not swallowed: a page whose payload cannot be kept
826
+ // is one that will render offline and never hydrate, and
827
+ // the reason - a no-store from a guard above it, usually -
828
+ // is only visible here.
829
+ 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')
830
+ })
831
+ .catch(() => {})
832
+ }),
833
+ ),
834
+ )
769
835
  .then(() => self.skipWaiting()),
770
836
  )
771
837
  })
@@ -774,9 +840,16 @@ self.addEventListener('activate', (event) => {
774
840
  event.waitUntil(
775
841
  caches
776
842
  .keys()
777
- .then((keys) => Promise.all(keys.filter((k) => k.startsWith('rsc-kit-') && k !== CACHE).map((k) => caches.delete(k))))
778
- .then(() => self.clients.claim())
779
- .then(() => tellTheOpenPages()),
843
+ .then((keys) => {
844
+ const older = keys.filter((k) => k.startsWith('rsc-kit-') && k !== CACHE)
845
+
846
+ return Promise.all(older.map((k) => caches.delete(k))).then(() => older.length > 0)
847
+ })
848
+ .then((swept) => self.clients.claim().then(() => swept))
849
+ // Only when something older was swept. The first worker a visitor ever
850
+ // gets activates too, and told them "a new version is ready" on their
851
+ // second page - there was no previous version to be new against.
852
+ .then((swept) => (swept ? tellTheOpenPages() : undefined)),
780
853
  )
781
854
  })
782
855
 
@@ -821,6 +894,37 @@ const keyFor = (request) => {
821
894
  return new Request(url, { headers: request.headers })
822
895
  }
823
896
 
897
+ // Nothing cached for a request and no network: the offline page for a
898
+ // navigation, and for the boot of a page the offline page stood in for -
899
+ // the runtime asks for the payload of the url in the address bar, which is
900
+ // the one thing nothing has - the offline page's own payload, which is what
901
+ // the document on screen is. A boot only, no segments header: a navigation
902
+ // while offline still fails as itself, and the page already open keeps its
903
+ // banner. Null when there is no offline page, or nothing of it cached.
904
+ const standIn = async (request) => {
905
+ if (!OFFLINE_URL) return null
906
+
907
+ if (request.mode === 'navigate') return (await caches.match(OFFLINE_URL)) ?? null
908
+
909
+ if (request.headers.get('X-RSC') && !request.headers.get('X-RSC-Segments')) {
910
+ return (
911
+ (await caches.match(
912
+ keyFor(new Request(new URL(OFFLINE_URL, self.location.origin), { headers: { 'X-RSC': '1' } })),
913
+ MATCH,
914
+ )) ?? null
915
+ )
916
+ }
917
+
918
+ return null
919
+ }
920
+
921
+ // The Cache API honours Vary, and a stored payload varies on X-RSC - so a
922
+ // payload warmed with one spelling of that header was a miss for a boot
923
+ // that sent another, and a precached page rendered offline with its payload
924
+ // sitting in the cache, unmatched. What Vary is for is already in the key:
925
+ // the ?__rsc=<segments> above is the one header the answer differs on.
926
+ const MATCH = { ignoreVary: true }
927
+
824
928
  self.addEventListener('fetch', (event) => {
825
929
  const request = event.request
826
930
  const url = new URL(request.url)
@@ -855,7 +959,7 @@ self.addEventListener('fetch', (event) => {
855
959
  // differently for every value of it.
856
960
  if (FROZEN.has(url.pathname.replace(/\\/+$/, '') || '/') && !url.search) {
857
961
  event.respondWith(
858
- caches.match(keyFor(request)).then((hit) => {
962
+ caches.match(keyFor(request), MATCH).then((hit) => {
859
963
  const fresh = fetch(request)
860
964
  .then((response) => {
861
965
  if (mayStore(response)) {
@@ -865,10 +969,17 @@ self.addEventListener('fetch', (event) => {
865
969
 
866
970
  return response
867
971
  })
868
- .catch((error) => {
972
+ .catch(async (error) => {
869
973
  if (hit) return hit
870
974
 
871
- throw error
975
+ // Stored by the build but never visited from this browser, so
976
+ // nothing is cached for it, and no network: the offline page is
977
+ // the honest answer, the same as for any other navigation - and
978
+ // its payload for the boot that follows, so the page it stood
979
+ // in for hydrates. Without this a frozen page failed with
980
+ // ERR_FAILED where an unstored one showed /offline, and then
981
+ // stood inert where an unstored one hydrated.
982
+ return (await standIn(request)) ?? Promise.reject(error)
872
983
  })
873
984
 
874
985
  // Not awaited when there is a hit: the point is that the visitor does
@@ -893,11 +1004,11 @@ self.addEventListener('fetch', (event) => {
893
1004
  // cached, and a reload with no network has the markup and nothing to
894
1005
  // hydrate it with.
895
1006
  //
896
- // \`X-RSC: true\` and no segments header is exactly what a fresh boot
1007
+ // \`X-RSC: 1\` and no segments header is exactly what a fresh boot
897
1008
  // sends, which is what makes this entry the one it finds: the server
898
1009
  // varies on those, and the Cache API matches on the same.
899
1010
  if (request.mode === 'navigate') {
900
- const warm = new Request(request.url, { headers: { 'X-RSC': 'true' } })
1011
+ const warm = new Request(request.url, { headers: { 'X-RSC': '1' } })
901
1012
 
902
1013
  fetch(warm)
903
1014
  .then((payload) => {
@@ -932,7 +1043,7 @@ self.addEventListener('fetch', (event) => {
932
1043
  return response
933
1044
  })
934
1045
  .catch(async () => {
935
- const hit = await caches.match(keyFor(request))
1046
+ const hit = await caches.match(keyFor(request), MATCH)
936
1047
 
937
1048
  if (hit) return hit
938
1049
 
@@ -947,11 +1058,9 @@ self.addEventListener('fetch', (event) => {
947
1058
  // stand in for another: it is about being offline, not about the url it
948
1059
  // appears under. Navigations only — a payload request answered with a
949
1060
  // document would be decoded as one and throw.
950
- if (OFFLINE_URL && request.mode === 'navigate') {
951
- const page = await caches.match(OFFLINE_URL)
1061
+ const stood = await standIn(request)
952
1062
 
953
- if (page) return page
954
- }
1063
+ if (stood) return stood
955
1064
 
956
1065
  // Letting it fail says what is true, and a page already open is
957
1066
  // unaffected.
@@ -1066,17 +1175,24 @@ function writeWebManifest(clientDir, options) {
1066
1175
  * precached and found wanting at the one moment it matters, and the build says
1067
1176
  * which read did it.
1068
1177
  */
1069
- function offlineFallback(frozen, results) {
1178
+ export function offlineFallback(frozen, results) {
1070
1179
  const found = results.find((r) => r.url === "/offline");
1071
1180
  if (!found)
1072
1181
  return null;
1073
1182
  if (!frozen.includes("/offline")) {
1074
1183
  // The reason already reads "dynamic — called cookies()", and this sentence
1075
1184
  // has said "not stored" by the time it gets there, so the prefix would say
1076
- // it twice with a dash in the middle of both.
1077
- const why = found.reason?.replace(/^dynamic — /, "") ?? null;
1185
+ // it twice with a dash in the middle of both. A shell's reason is a
1186
+ // sentence of its own - "connection() awaited by AuthLinks streams per
1187
+ // request; the rest is stored" - and takes no "it" in front, and the
1188
+ // part about the rest being stored is not the point here.
1189
+ const why = found.type === "shell"
1190
+ ? (found.reason?.replace(/; the rest is stored$/, "") ?? null)
1191
+ : found.reason
1192
+ ? "it " + found.reason.replace(/^dynamic — /, "")
1193
+ : null;
1078
1194
  log("offline: /offline cannot be the fallback" +
1079
- (why ? `, because it ${why}` : "") +
1195
+ (why ? `, because ${why}` : "") +
1080
1196
  ". A fallback has to be servable with no network at all.");
1081
1197
  return null;
1082
1198
  }
@@ -1102,6 +1218,24 @@ function copyServiceWorkerExtra(clientDir) {
1102
1218
  copyFileSync(source, join(clientDir, "sw-app.js"));
1103
1219
  return "/sw-app.js";
1104
1220
  }
1221
+ /**
1222
+ * Whether a public file is worth fetching before it is asked for.
1223
+ *
1224
+ * The precache is what lets the app boot with no network: the scripts, the
1225
+ * stylesheets, the fonts, the manifest and its icons. Everything else in
1226
+ * public/ - an image, a wasm module, the share card - is cached the first
1227
+ * time it is used, which is what the fetch handler does for any hashed
1228
+ * asset. Precaching all of it made an install cost a megabyte before the
1229
+ * visitor had seen a page: 765 kB of it a webp encoder, the rest a png no
1230
+ * browser ever renders.
1231
+ */
1232
+ export function bootsTheApp(file) {
1233
+ if (/\.(?:m?js|css|woff2?|webmanifest|json)$/i.test(file))
1234
+ return !/\.wasm\.js$/i.test(file);
1235
+ if (/(?:^|\/)(?:icon|apple-icon|favicon)[^/]*\.(?:png|ico|svg)$/i.test(file))
1236
+ return true;
1237
+ return false;
1238
+ }
1105
1239
  function writeServiceWorker(clientDir, frozen = [], offlineUrl = null) {
1106
1240
  if (!existsSync(clientDir))
1107
1241
  return;
@@ -1120,7 +1254,7 @@ function writeServiceWorker(clientDir, frozen = [], offlineUrl = null) {
1120
1254
  const path = join(dir, entry.name);
1121
1255
  if (entry.isDirectory())
1122
1256
  walk(path, `${prefix}${entry.name}/`);
1123
- else
1257
+ else if (bootsTheApp(`${prefix}${entry.name}`))
1124
1258
  files.push(`${prefix}${entry.name}`);
1125
1259
  }
1126
1260
  };
@@ -1607,7 +1741,7 @@ function renderRouteTypes(manifest) {
1607
1741
  "",
1608
1742
  "// `export {}` is load-bearing: in a file with no import or export,",
1609
1743
  "// `declare module` *replaces* the real module rather than augmenting it,",
1610
- "// and Href and route() vanish from it with no error to explain why.",
1744
+ "// and Route and route() vanish from it with no error to explain why.",
1611
1745
  "export {}",
1612
1746
  "",
1613
1747
  "declare module '@rsc-kit/core/routes' {",
@@ -1723,8 +1857,20 @@ function componentName(absPath) {
1723
1857
  const rel = relative(sourceDir, absPath).replace(/\\/g, "/");
1724
1858
  return rel.replace(/\.(tsx|jsx|ts|js)$/, "");
1725
1859
  }
1726
- function toAlias(name) {
1727
- return "_c_" + name.replace(/[^a-zA-Z0-9]/g, "_");
1860
+ /**
1861
+ * The identifier a route file is imported as in the generated entries.
1862
+ *
1863
+ * One-to-one with the path, which the obvious "replace everything odd with an
1864
+ * underscore" is not: app/agent-account/page and app/agent/account/page
1865
+ * both came out as _c_app_agent_account_page, and the entry declared the
1866
+ * same import twice. A slash is an underscore, because that is what every
1867
+ * alias reads like; anything else that is not a letter or digit - a hyphen,
1868
+ * a dot, the brackets of a param, the parens of a group, an underscore of
1869
+ * its own - is its character code, so no two paths share an alias.
1870
+ */
1871
+ export function toAlias(name) {
1872
+ return ("_c_" +
1873
+ name.replace(/[^a-zA-Z0-9]/g, (char) => (char === "/" ? "_" : "$" + char.charCodeAt(0).toString(16))));
1728
1874
  }
1729
1875
  const components = new Map();
1730
1876
  /**
@@ -1736,6 +1882,48 @@ const components = new Map();
1736
1882
  * a default component.
1737
1883
  */
1738
1884
  const apiRoutes = new Map();
1885
+ const OPENAPI_ROUTE_ID = "virtual:rsc-kit/openapi";
1886
+ /**
1887
+ * /openapi.json, when asked for, registered as an api route at the path the
1888
+ * option names. Synthesised like a sitemap: stored by the build, without
1889
+ * middleware - a document exists to be read.
1890
+ */
1891
+ function registerOpenApiRoutes() {
1892
+ if (!openapi)
1893
+ return;
1894
+ const name = `app${openapi.path.replace(/\/$/, "")}/route`;
1895
+ if (apiRoutes.has(name)) {
1896
+ throw new Error(`[rsc-kit] ${openapi.path} is answered by ${apiRoutes.get(name).absPath} and by rscKit({ openapi }). Move one.`);
1897
+ }
1898
+ apiRoutes.set(name, { name, absPath: OPENAPI_ROUTE_ID, methods: ["GET"], generated: { kind: "openapi", file: "" } });
1899
+ }
1900
+ /** The module behind the synthesised /openapi.json route. */
1901
+ function openApiPlugin() {
1902
+ return {
1903
+ name: "rsc-kit:openapi",
1904
+ resolveId(id) {
1905
+ if (id === OPENAPI_ROUTE_ID)
1906
+ return "\0" + id;
1907
+ },
1908
+ load(id) {
1909
+ if (id !== "\0" + OPENAPI_ROUTE_ID || !openapi)
1910
+ return;
1911
+ // Every route.ts the app wrote, imported so its schemas can describe
1912
+ // themselves at request time; the synthesised ones are not an API.
1913
+ const routes = [...apiRoutes.values()].filter((r) => !r.generated);
1914
+ const guarded = new Set((routeManifest().apis ?? []).filter((api) => api.middleware.length > 0).map((api) => api.name));
1915
+ const imports = routes.map((r, i) => `import * as __r${i} from ${JSON.stringify(r.absPath)}`);
1916
+ 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} }`);
1917
+ return [
1918
+ ...imports,
1919
+ `import { openApiResponse } from ${JSON.stringify(join(packageDir, "openapi"))}`,
1920
+ `const routes = [\n${entries.join(",\n")}\n]`,
1921
+ `export const GET = () => openApiResponse(routes, ${JSON.stringify(openapi.document)})`,
1922
+ "",
1923
+ ].join("\n");
1924
+ },
1925
+ };
1926
+ }
1739
1927
  /** Files beside the root layout served at the root as they are: robots.txt, a hand-written sitemap.xml, humans.txt. */
1740
1928
  let rootFiles = [];
1741
1929
  const METADATA_ROUTE_ID = "virtual:rsc-kit/metadata-route/";
@@ -2071,6 +2259,9 @@ const NITRO_HANDLER_OPTIONS = ` props: (match, request) => ({
2071
2259
  ...Object.fromEntries(new URL(request.url).searchParams),
2072
2260
  }),
2073
2261
  version: process.env.RSC_BUILD_VERSION,
2262
+ // A built server gzips what it answers, where the runtime can; the dev
2263
+ // server answers raw, which is what a person reading a response wants.
2264
+ compress: import.meta.env.PROD,
2074
2265
  `;
2075
2266
  const NITRO_HOST_CALLS = `
2076
2267
  let hostInstalled = false
@@ -2199,12 +2390,12 @@ import { DefaultRouteError } from ${JSON.stringify(join(packageDir, "js/DefaultR
2199
2390
  import { searchParams as requestSearchParams } from ${JSON.stringify(join(packageDir, "request"))}
2200
2391
  import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyError } from ${JSON.stringify(join(packageDir, "routeSchema"))}
2201
2392
  import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
2202
- import { noteRequestRead } from ${JSON.stringify(join(packageDir, "request"))}
2393
+ import { noteRequestRead, urlOf } from ${JSON.stringify(join(packageDir, "request"))}
2203
2394
  import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
2204
2395
  import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
2205
2396
  import { httpHostCalls } from ${JSON.stringify(join(packageDir, "hostCalls"))}
2206
2397
  import { prerenderedBeside } from ${JSON.stringify(join(packageDir, "files"))}
2207
- import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
2398
+ import { renderToReadableStream, decodeReply, decodeAction, decodeFormState, loadServerAction } from '@vitejs/plugin-rsc/rsc'
2208
2399
  import { isQuery, queryCacheControl, isQueryValidationError } from ${JSON.stringify(join(packageDir, "query"))}
2209
2400
  import { isActionValidationError, isClientBuilt } from ${JSON.stringify(join(packageDir, "action"))}
2210
2401
  import { noteFallback as noteCaughtRead } from ${JSON.stringify(join(packageDir, "request"))}
@@ -2364,7 +2555,10 @@ export async function handleApiRoute(
2364
2555
  // reaches for the value.
2365
2556
  noteRequestRead('searchParams')
2366
2557
 
2367
- return parseSearchParams(mod.searchParams, new URL(request.url).searchParams)
2558
+ // Through urlOf, so the build's probe books this read to searchParams
2559
+ // rather than to the url - a route reads request.url itself when it
2560
+ // wants the query the Next way, and that read is the one that matters.
2561
+ return parseSearchParams(mod.searchParams, new URL(urlOf(request)).searchParams)
2368
2562
  }),
2369
2563
  body: hasBody(method) ? lazily(() => parseBody(mod.body, request)) : undefined,
2370
2564
  }
@@ -3215,8 +3409,18 @@ async function runMiddleware(component: string, props: Record<string, unknown> =
3215
3409
  }
3216
3410
 
3217
3411
  // Sequential and awaited, outermost first: an outer guard refusing means
3218
- // the inner one should never have been asked.
3219
- await guard(props)
3412
+ // the inner one should never have been asked. A directory may declare
3413
+ // several as a list - reused checks imported from one place - and they
3414
+ // run in the order written, stopping at the first refusal.
3415
+ for (const check of Array.isArray(guard) ? guard : [guard]) {
3416
+ if (typeof check !== 'function') {
3417
+ throw new Error(
3418
+ 'Route middleware ' + name + ' exports something that is not a function or a list of them.',
3419
+ )
3420
+ }
3421
+
3422
+ await check(props)
3423
+ }
3220
3424
  }
3221
3425
  }
3222
3426
 
@@ -3315,6 +3519,54 @@ export async function handleRscHtmlStream(
3315
3519
  return { htmlStream, rscPayloadPromise, clientChunks: {} }
3316
3520
  }
3317
3521
 
3522
+ /**
3523
+ * A form posted to the page before the page had a runtime.
3524
+ *
3525
+ * React writes the action's id into the form it emits for a server action,
3526
+ * so a browser with no javascript yet - or none at all - posts the fields
3527
+ * to the page's own url. The action runs from those fields, exactly as it
3528
+ * would have been called, and the page renders afterwards with what it
3529
+ * returned as React's form state: a useActionState form shows its result,
3530
+ * a redirect() thrown by the action leaves through the scope the way a
3531
+ * page's would, and a cookie it set is on the answer.
3532
+ */
3533
+ export async function handleRscFormPost(
3534
+ component: string,
3535
+ props: Record<string, unknown> = {},
3536
+ layouts: LayoutEntry[] = [],
3537
+ loadings: string[] = [],
3538
+ parallelSlots: Record<string, string> = {},
3539
+ slotOverrides: Record<string, SlotOverride> = {},
3540
+ nonce?: string,
3541
+ pageKey = '',
3542
+ bootstrap = true,
3543
+ formData: FormData = new FormData(),
3544
+ ): Promise<{ htmlStream: ReadableStream }> {
3545
+ await instrumented()
3546
+ applyHost()
3547
+ await runMiddleware(component, props)
3548
+
3549
+ // The function the form named, bound to what it posted. Nothing named -
3550
+ // a plain form posted here by mistake - and the page simply renders.
3551
+ const action = await decodeAction(formData)
3552
+ let formState: unknown
3553
+
3554
+ if (action) {
3555
+ const result = await action()
3556
+
3557
+ formState = await decodeFormState(result, formData)
3558
+ }
3559
+
3560
+ const flight = renderToReadableStream(
3561
+ await renderTree(component, props, layouts, loadings, parallelSlots, slotOverrides, 0, pageKey, bootstrap),
3562
+ { onError: flightOnError },
3563
+ )
3564
+ const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
3565
+ const htmlStream = await ssr.handleSsr(flight, nonce, undefined, bootstrap, formState)
3566
+
3567
+ return { htmlStream }
3568
+ }
3569
+
3318
3570
  /**
3319
3571
  * Finish a shell that was frozen at build time.
3320
3572
  *
@@ -3941,7 +4193,7 @@ export async function handleRscPprShell(
3941
4193
  // say so on the route's line; the digest goes into the document for the
3942
4194
  // browser to recognise.
3943
4195
  if ((e as { digest?: string } | null)?.digest === 'rsc-kit:search-params-fallback') {
3944
- const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4196
+ const where = /at ([A-Z_$][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
3945
4197
  noteCaughtRead('useSearchParams()' + (where ? ' in ' + where : ''))
3946
4198
  return 'rsc-kit:search-params-fallback'
3947
4199
  }
@@ -4091,6 +4343,7 @@ async function serve(request: Request): Promise<Response> {
4091
4343
  handleRsc,
4092
4344
  handleRscStream,
4093
4345
  handleRscHtmlStream,
4346
+ handleRscFormPost,
4094
4347
  handleRscRevalidate,
4095
4348
  handleRscPayload,
4096
4349
  handleRscPprShell,
@@ -4181,6 +4434,8 @@ function generateEntrySsr() {
4181
4434
  const devUrls = join(packageDir, "devUrls");
4182
4435
  const request = join(packageDir, "request");
4183
4436
  const fallbackReport = join(packageDir, "js/fallbackReport");
4437
+ const redirectDigestModule = join(packageDir, "redirectDigest");
4438
+ const notFoundModule = join(packageDir, "notFound");
4184
4439
  return `// GENERATED by rscKit() — do not edit.
4185
4440
  import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
4186
4441
  import { renderToReadableStream, resume } from 'react-dom/server.edge'
@@ -4188,6 +4443,8 @@ import { prerender } from 'react-dom/static.edge'
4188
4443
  import { rewriteViteDevUrlStream } from ${JSON.stringify(devUrls)}
4189
4444
  import { noteFallback } from ${JSON.stringify(request)}
4190
4445
  import { cancelledByConsumer, caughtByLoading } from ${JSON.stringify(fallbackReport)}
4446
+ import { parseRedirectDigest } from ${JSON.stringify(redirectDigestModule)}
4447
+ import { isNotFoundDigest } from ${JSON.stringify(notFoundModule)}
4191
4448
 
4192
4449
  // Set only by the dev server. @vitejs/plugin-rsc emits its bootstrap and CSS
4193
4450
  // links root-relative in dev, which would send the browser to the host for
@@ -4199,6 +4456,7 @@ export async function handleSsr(
4199
4456
  nonce?: string,
4200
4457
  onError?: (error: unknown) => void,
4201
4458
  bootstrap = true,
4459
+ formState?: unknown,
4202
4460
  ): Promise<ReadableStream> {
4203
4461
  const root = await createFromReadableStream(rscStream)
4204
4462
 
@@ -4216,6 +4474,9 @@ export async function handleSsr(
4216
4474
  bootstrapScriptContent,
4217
4475
  nonce,
4218
4476
  onError: onError ?? reportRenderError('ssr'),
4477
+ // What a posted form's action returned, for the useActionState that
4478
+ // asked: React seats it in the form it belongs to.
4479
+ ...(formState !== undefined ? { formState: formState as any } : {}),
4219
4480
  })
4220
4481
 
4221
4482
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
@@ -4255,7 +4516,7 @@ export async function handleSsrPrerender(
4255
4516
  // caught at a boundary is: the build attaches it to the route.
4256
4517
  onError: (error: unknown, info?: { componentStack?: string }) => {
4257
4518
  if ((error as { digest?: string } | null)?.digest !== 'rsc-kit:search-params-fallback') return
4258
- const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4519
+ const where = /at ([A-Z_$][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4259
4520
  noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4260
4521
  return 'rsc-kit:search-params-fallback'
4261
4522
  },
@@ -4283,11 +4544,18 @@ function reportRenderError(phase: 'ssr' | 'resume') {
4283
4544
 
4284
4545
  const digest = (error as { digest?: string } | null)?.digest
4285
4546
 
4547
+ // A redirect or a notFound() the render asked for arrives here as the
4548
+ // Flight row's error, once per boundary it was thrown in. The rsc side
4549
+ // already turned it into a digest on purpose; it is the page's answer,
4550
+ // and the host reads it off the digest. Not an error to print, four
4551
+ // times or once.
4552
+ if (parseRedirectDigest(digest) || isNotFoundDigest(digest)) return digest
4553
+
4286
4554
  if (digest === 'rsc-kit:search-params-fallback') {
4287
4555
  // The component is the first frame of React's stack. Noted on the
4288
4556
  // request so the build attaches it to the route; printed as one line,
4289
4557
  // not a stack, so the server says which boundary it wants.
4290
- const where = /at ([A-Z][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4558
+ const where = /at ([A-Z_$][\\w$]*)/.exec(info?.componentStack ?? '')?.[1]
4291
4559
 
4292
4560
  noteFallback('useSearchParams()' + (where ? ' in ' + where : ''))
4293
4561
 
@@ -4381,6 +4649,9 @@ import { refresh } from ${JSON.stringify(refreshModule)}
4381
4649
  createViteRscApp(document, ${JSON.stringify(interceptManifest())}, ${JSON.stringify({
4382
4650
  staticPayloads: staticPayloads || null,
4383
4651
  routes: routesForClient,
4652
+ // Every route.ts, as a url pattern, so a link to one is treated as an
4653
+ // anchor rather than prefetched and fetched as a page.
4654
+ apiRoutes: (routeManifest().apis ?? []).map((api) => patternOf(api.segments)),
4384
4655
  })})
4385
4656
 
4386
4657
  // A server component is not a module the browser has, so Vite cannot replace
@@ -4411,12 +4682,19 @@ ${offline
4411
4682
  // worker answering from a cache in front of it turns every edit into a
4412
4683
  // question about which copy you are looking at.
4413
4684
  if ('serviceWorker' in navigator && import.meta.env.PROD) {
4414
- window.addEventListener('load', () => {
4685
+ const register = () => {
4415
4686
  void navigator.serviceWorker.register('/sw.js').catch(() => {
4416
4687
  // A worker that will not register is not a reason for the page to fail.
4417
4688
  // The app works; it just will not survive being reloaded offline.
4418
4689
  })
4419
- })
4690
+ }
4691
+
4692
+ // This runs after the boot payload has been fetched and decoded, and on a
4693
+ // fast load the page has finished loading by then - a listener added now
4694
+ // waits for an event that has already fired, and nothing registered until
4695
+ // the next navigation.
4696
+ if (document.readyState === 'complete') register()
4697
+ else window.addEventListener('load', register)
4420
4698
  }
4421
4699
  `
4422
4700
  : ""}`;
@@ -4664,10 +4942,16 @@ function useSsrModules() {
4664
4942
  return {
4665
4943
  name: "rsc-kit:use-ssr",
4666
4944
  enforce: "pre",
4667
- applyToEnvironment: (environment) => environment.name === "rsc",
4945
+ applyToEnvironment: (environment) => environment.name === "rsc" || environment.name === "ssr",
4668
4946
  transform(code, id) {
4669
4947
  if (!code.includes("use ssr"))
4670
4948
  return;
4949
+ // In the environment the module runs in, the directive has done its
4950
+ // work and is a string the bundler warns about. Out it goes.
4951
+ if (this.environment.name === "ssr") {
4952
+ const stripped = withoutSsrDirective(code);
4953
+ return stripped === null ? undefined : { code: stripped, map: null };
4954
+ }
4671
4955
  try {
4672
4956
  const proxy = ssrProxyModule(code, id);
4673
4957
  return proxy === null ? undefined : { code: proxy, map: null };
@@ -4687,7 +4971,21 @@ function useSsrModules() {
4687
4971
  * directive that moves it. The build says the same once, as a warning.
4688
4972
  */
4689
4973
  const RENDERER_STUB = "\0rsc-kit:react-dom-server?from=";
4690
- function serverRendererInRsc() {
4974
+ /** What react-dom/server exports, across its node, edge and browser entries. */
4975
+ const SERVER_RENDERER_EXPORTS = [
4976
+ "renderToString",
4977
+ "renderToStaticMarkup",
4978
+ "renderToReadableStream",
4979
+ "renderToPipeableStream",
4980
+ "renderToStaticNodeStream",
4981
+ "resume",
4982
+ "resumeToPipeableStream",
4983
+ "resumeAndPrerender",
4984
+ "resumeAndPrerenderToNodeStream",
4985
+ "prerender",
4986
+ "prerenderToNodeStream",
4987
+ ];
4988
+ export function serverRendererInRsc() {
4691
4989
  const warned = new Set();
4692
4990
  const appImporter = (ctx, importer) => {
4693
4991
  let current = importer;
@@ -4706,24 +5004,93 @@ function serverRendererInRsc() {
4706
5004
  }
4707
5005
  return importer ? relative(process.cwd(), importer.split("?")[0]) : null;
4708
5006
  };
5007
+ // Per package, so a dependency imported from twenty files is read once.
5008
+ const externalRenderers = new Map();
5009
+ // Packages already named by the warning above, so the stub's own warning
5010
+ // - "imported by node_modules/<package>/dist/index.mjs" - stays quiet for
5011
+ // them: one problem, one line.
5012
+ const warnedPackages = new Set();
4709
5013
  return {
4710
5014
  name: "rsc-kit:server-renderer",
5015
+ // Before Vite's own resolver, which otherwise answers react-dom/server
5016
+ // with React's react-server entry - a real module missing the export,
5017
+ // and a build that fails on MISSING_EXPORT with no mention of the fix.
5018
+ enforce: "pre",
4711
5019
  applyToEnvironment: (environment) => environment.name === "rsc",
4712
- resolveId(source, importer) {
4713
- if (!SERVER_RENDERER.test(source))
5020
+ async resolveId(source, importer) {
5021
+ // Only an import from a module: a resolve with no importer is Vite
5022
+ // asking whether the specifier exists - deciding what to externalise -
5023
+ // and answering that with the stub would answer the wrong question.
5024
+ if (SERVER_RENDERER.test(source)) {
5025
+ return importer ? RENDERER_STUB + encodeURIComponent(importer) : undefined;
5026
+ }
5027
+ // A bare import from app code of a package the build leaves external:
5028
+ // its own import of react-dom/server is never resolved here, so the
5029
+ // stub above never sees it. @react-email/render, imported by an
5030
+ // action, built clean and threw React's refusal at the first visitor.
5031
+ // The package is read once for the import, and the build warns the
5032
+ // same way it does for a direct one - naming the app file and the
5033
+ // package - without stubbing a module that may also be used well.
5034
+ if (!importer || importer.includes("/node_modules/") || importer.startsWith("\0"))
5035
+ return;
5036
+ if (!/^(?:@[\w.-]+\/)?[\w.-]+/.test(source) || source.startsWith("."))
5037
+ return;
5038
+ // The generated entries import plugin-rsc, whose ssr half imports the
5039
+ // renderer for its own reasons; that is the build's, not the app's.
5040
+ if (outDir && importer.startsWith(outDir))
5041
+ return;
5042
+ const name = source.startsWith("@") ? source.split("/").slice(0, 2).join("/") : source.split("/")[0];
5043
+ if (name === "react-dom" || name === "react" || name.startsWith("@rsc-kit/") || name.startsWith("@vitejs/"))
5044
+ return;
5045
+ let imports = externalRenderers.get(name);
5046
+ if (imports === undefined) {
5047
+ // Vite answers a dependency either as external, by its bare name,
5048
+ // or by the file it resolved to under node_modules - which of the
5049
+ // two depends on how the environment was set up, and both are a
5050
+ // dependency whose imports the build will not look inside.
5051
+ const resolved = await this.resolve(source, importer, { skipSelf: true });
5052
+ const id = resolved?.id ?? "";
5053
+ const marker = `/node_modules/${name}/`;
5054
+ const at = id.lastIndexOf(marker);
5055
+ const dir = resolved?.external
5056
+ ? installedPackageDir(name, dirname(importer.split("?")[0]))
5057
+ : at !== -1
5058
+ ? id.slice(0, at + marker.length - 1)
5059
+ : null;
5060
+ imports = dir !== null && importsServerRenderer(dir);
5061
+ externalRenderers.set(name, imports);
5062
+ }
5063
+ if (!imports)
4714
5064
  return;
4715
- return RENDERER_STUB + encodeURIComponent(importer ?? "");
5065
+ const message = `${relative(process.cwd(), importer.split("?")[0])} imports ${name}, which imports react-dom/server, and ` +
5066
+ serverRendererMessage(null).replace(/^react-dom\/server cannot run/, "that cannot run");
5067
+ if (!warned.has(message)) {
5068
+ warned.add(message);
5069
+ warnedPackages.add(name);
5070
+ this.warn(message);
5071
+ }
4716
5072
  },
4717
5073
  load(id) {
4718
5074
  if (!id.startsWith(RENDERER_STUB))
4719
5075
  return;
4720
5076
  const importer = decodeURIComponent(id.slice(RENDERER_STUB.length)) || null;
4721
5077
  const message = serverRendererMessage(appImporter(this, importer));
4722
- if (this.environment.mode === "build" && !warned.has(message)) {
5078
+ // The last node_modules segment: Bun's store nests the real package
5079
+ // under node_modules/.bun/<pkg>@<v>/node_modules/<pkg>.
5080
+ const viaPackage = [...(importer ?? "").matchAll(/\/node_modules\/((?:@[^/]+\/)?[^/]+)\//g)].at(-1)?.[1];
5081
+ if (this.environment.mode === "build" &&
5082
+ !warned.has(message) &&
5083
+ !(viaPackage && warnedPackages.has(viaPackage))) {
4723
5084
  warned.add(message);
4724
5085
  this.warn(message);
4725
5086
  }
4726
- return `throw new Error(${JSON.stringify(message)});\n`;
5087
+ // Every name the renderer exports, each throwing the fix when called.
5088
+ // A module that threw when loaded took the whole server down at boot
5089
+ // for one action nobody had called yet, and a stub with no exports
5090
+ // failed the build on MISSING_EXPORT with the message scrolled past.
5091
+ const refuse = `() => { throw new Error(${JSON.stringify(message)}) }`;
5092
+ return (SERVER_RENDERER_EXPORTS.map((name) => `export const ${name} = ${refuse};`).join("\n") +
5093
+ `\nexport const version = "0.0.0";\nexport default { ${SERVER_RENDERER_EXPORTS.join(", ")}, version };\n`);
4727
5094
  },
4728
5095
  };
4729
5096
  }
@@ -4911,6 +5278,14 @@ export function rscKit(options = {}) {
4911
5278
  ...RUNTIME_BUILTINS,
4912
5279
  ...[...DEFAULT_SERVER_EXTERNALS, ...(options.serverExternalPackages ?? [])].map(externalPackage),
4913
5280
  ];
5281
+ // Dependencies with a "use client" file that plugin-rsc would leave
5282
+ // external, because they never declared react as a peer. Bundled into the
5283
+ // server graphs so the directive is read, the same as a package that did.
5284
+ const bundledClientPackages = clientPackages(projectRoot);
5285
+ for (const name of bundledClientPackages) {
5286
+ console.warn(`[rsc-kit] bundling ${name}: it has "use client" files but does not declare react ` +
5287
+ "as a peer dependency, so its components would otherwise run on the server.");
5288
+ }
4914
5289
  const routesPlugin = {
4915
5290
  name: "rsc-kit",
4916
5291
  // A Nitro module, which Nitro's Vite plugin collects from any plugin
@@ -4920,9 +5295,49 @@ export function rscKit(options = {}) {
4920
5295
  nitro: {
4921
5296
  name: "rsc-kit",
4922
5297
  setup(nitro) {
4923
- if (nitro.options.dev || !instrumentationFile())
5298
+ if (nitro.options.dev)
4924
5299
  return;
5300
+ // External at Nitro's layer too. The Vite build leaves these as
5301
+ // imports, and Nitro would then bundle them into its own chunks -
5302
+ // which for a native package fails at load, and for a polyfill like
5303
+ // reflect-metadata runs it after the chunk that checks for it.
5304
+ // Traced, each is copied into .output/server/node_modules and
5305
+ // imported by the built server the way its author expected.
5306
+ // Only the ones the project has. Nitro's tracer says so, once per
5307
+ // name, for every entry it cannot find - and a list of every native
5308
+ // package anyone might install is mostly ones this app does not.
5309
+ nitro.options.traceDeps = [
5310
+ ...(nitro.options.traceDeps ?? []),
5311
+ ...[...DEFAULT_SERVER_EXTERNALS, ...(options.serverExternalPackages ?? [])].filter((name) => installedPackageDir(name, projectRoot) !== null || packageEntryInGraph(projectRoot, name) !== null),
5312
+ ];
5313
+ // The assets, precompressed at build and served with their encoding
5314
+ // by Nitro's own static handler; the host gzips the rest as it
5315
+ // answers. Between them a bun or node server answering the internet
5316
+ // by itself sends nothing raw. A config that set this keeps it.
5317
+ // Nitro's default is a literal false, so ?? would not do; an object
5318
+ // is a configuration and is kept.
5319
+ if (typeof nitro.options.compressPublicAssets !== "object")
5320
+ nitro.options.compressPublicAssets = true;
4925
5321
  nitro.options.virtual ??= {};
5322
+ // A polyfill a dependency checks for at module evaluation, loaded
5323
+ // before any service. The bundler places an external import after
5324
+ // the chunk imports of the module that had it, whatever the source
5325
+ // said, so reflect-metadata came up after tsyringe (under
5326
+ // @peculiar/x509, under @simplewebauthn/server) - by luck survivable
5327
+ // run as a directory, fatal compiled into a binary. A Nitro plugin
5328
+ // is evaluated by the entry itself, and the services that carry the
5329
+ // app are loaded lazily after it; imported by file, the polyfill is
5330
+ // inlined there and runs first wherever the server runs. Only for a
5331
+ // project whose graph has it.
5332
+ const polyfills = FIRST_POLYFILLS.map((name) => packageEntryInGraph(projectRoot, name)).filter((entry) => entry !== null);
5333
+ if (polyfills.length) {
5334
+ nitro.options.virtual["#rsc-kit/polyfills"] =
5335
+ polyfills.map((entry) => `import ${JSON.stringify(entry)};`).join("\n") +
5336
+ "\nexport default () => {};\n";
5337
+ nitro.options.plugins = ["#rsc-kit/polyfills", ...(nitro.options.plugins ?? [])];
5338
+ }
5339
+ if (!instrumentationFile())
5340
+ return;
4926
5341
  nitro.options.virtual["#rsc-kit/startup"] = STARTUP_PLUGIN;
4927
5342
  nitro.options.plugins = [...(nitro.options.plugins ?? []), "#rsc-kit/startup"];
4928
5343
  },
@@ -4938,6 +5353,7 @@ export function rscKit(options = {}) {
4938
5353
  apiRoutes.clear();
4939
5354
  discover(appDir);
4940
5355
  registerMetadataRoutes(appDir);
5356
+ registerOpenApiRoutes();
4941
5357
  siteHosts = ownHosts(rootMetadataBase(appDir), hostsOption);
4942
5358
  // Silent when it worked. The names were printed on every dev start and
4943
5359
  // every build — thirty of them for a middling app, above the output that
@@ -5117,6 +5533,11 @@ export function rscKit(options = {}) {
5117
5533
  alias: aliasEntries(),
5118
5534
  },
5119
5535
  build: { emptyOutDir: true },
5536
+ // What reaches the browser. Vite's own VITE_ prefix, and PUBLIC_ - the
5537
+ // scaffold's spelling, and Next's minus its brand - so a variable
5538
+ // named for a port reads through import.meta.env without a config
5539
+ // line. A config that set its own prefixes keeps them; Vite merges.
5540
+ envPrefix: ["VITE_", "PUBLIC_"],
5120
5541
  environments: {
5121
5542
  // Server bundles — stay under the (non-public) out dir. `bun` and
5122
5543
  // `bun:*` are the runtime's own modules, like `node:*`: nothing to
@@ -5129,6 +5550,8 @@ export function rscKit(options = {}) {
5129
5550
  external: serverExternals,
5130
5551
  },
5131
5552
  },
5553
+ resolve: { noExternal: bundledClientPackages },
5554
+ optimizeDeps: { exclude: bundledClientPackages },
5132
5555
  },
5133
5556
  ssr: {
5134
5557
  build: {
@@ -5137,6 +5560,8 @@ export function rscKit(options = {}) {
5137
5560
  external: serverExternals,
5138
5561
  },
5139
5562
  },
5563
+ resolve: { noExternal: bundledClientPackages },
5564
+ optimizeDeps: { exclude: bundledClientPackages },
5140
5565
  },
5141
5566
  // Client bundle — emitted into public/ for the web server to serve.
5142
5567
  client: {
@@ -5358,8 +5783,12 @@ export function rscKit(options = {}) {
5358
5783
  // when the directory is not there. Only under Nitro, whose presets are
5359
5784
  // the ones without a disk; on its own the plugin serves from outDir.
5360
5785
  if (clientOut && existsSync(staticDir)) {
5361
- const { inlineModuleName, inlineModuleSource } = await import("./files.js");
5786
+ const { inlineModuleName, inlineModuleSource, compileEntrySource } = await import("./files.js");
5362
5787
  writeFileSync(join(dirname(staticDir), inlineModuleName(NITRO_STATIC_DIR)), await inlineModuleSource(staticDir));
5788
+ // And the entry a binary is compiled from, so the pages above end up
5789
+ // inside it rather than rendered live: bun build --compile
5790
+ // .output/server/compile.mjs. The scaffold's script names it.
5791
+ writeFileSync(join(dirname(staticDir), "compile.mjs"), compileEntrySource(NITRO_STATIC_DIR));
5363
5792
  }
5364
5793
  // Manifest first. The service worker precaches whatever it finds in this
5365
5794
  // directory, so writing it afterwards leaves it out of the list — and an
@@ -5444,6 +5873,7 @@ export function rscKit(options = {}) {
5444
5873
  useSsrModules(),
5445
5874
  serverRendererInRsc(),
5446
5875
  metadataRoutesPlugin(),
5876
+ openApiPlugin(),
5447
5877
  extendableClientReferences(),
5448
5878
  typecheckPlugin(),
5449
5879
  clientImportsAudit(),