@rsc-kit/core 0.8.0 → 0.10.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 (44) hide show
  1. package/dist/action.d.ts +21 -0
  2. package/dist/action.js +67 -36
  3. package/dist/action.js.map +1 -1
  4. package/dist/apiPrerender.d.ts +25 -0
  5. package/dist/apiPrerender.js +195 -0
  6. package/dist/apiPrerender.js.map +1 -0
  7. package/dist/host.d.ts +12 -0
  8. package/dist/host.js +140 -2
  9. package/dist/host.js.map +1 -1
  10. package/dist/js/RouteErrorBoundary.d.ts +36 -0
  11. package/dist/js/RouteErrorBoundary.js +43 -0
  12. package/dist/js/RouteErrorBoundary.js.map +1 -0
  13. package/dist/js/queryClient.js +21 -1
  14. package/dist/js/queryClient.js.map +1 -1
  15. package/dist/manifest.d.ts +33 -0
  16. package/dist/manifest.js.map +1 -1
  17. package/dist/metadata.d.ts +69 -0
  18. package/dist/metadata.js +17 -0
  19. package/dist/metadata.js.map +1 -0
  20. package/dist/notFound.d.ts +24 -0
  21. package/dist/notFound.js +107 -0
  22. package/dist/notFound.js.map +1 -0
  23. package/dist/prerender.d.ts +48 -4
  24. package/dist/prerender.js +79 -9
  25. package/dist/prerender.js.map +1 -1
  26. package/dist/query.d.ts +23 -0
  27. package/dist/query.js +45 -3
  28. package/dist/query.js.map +1 -1
  29. package/dist/redirect.d.ts +8 -0
  30. package/dist/redirect.js +11 -1
  31. package/dist/redirect.js.map +1 -1
  32. package/dist/request.d.ts +7 -0
  33. package/dist/request.js +31 -6
  34. package/dist/request.js.map +1 -1
  35. package/dist/routeSchema.d.ts +115 -0
  36. package/dist/routeSchema.js +182 -0
  37. package/dist/routeSchema.js.map +1 -0
  38. package/dist/routing.d.ts +20 -1
  39. package/dist/routing.js +30 -7
  40. package/dist/routing.js.map +1 -1
  41. package/dist/vite.js +594 -34
  42. package/dist/vite.js.map +1 -1
  43. package/package.json +18 -6
  44. package/dist/types.d.ts +0 -82
package/dist/vite.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // the structural config (entries, output dirs, base). @vitejs/plugin-rsc is
14
14
  // included here so it always runs before any react() layer the app adds.
15
15
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
16
+ import { gzipSync } from 'node:zlib';
16
17
  import { createHash } from 'node:crypto';
17
18
  import { createRequire } from 'node:module';
18
19
  import { dirname, join, relative, resolve } from 'node:path';
@@ -369,6 +370,7 @@ function routeManifest() {
369
370
  segments: urlSegments(name),
370
371
  layouts: ancestors(name, 'layout').map((n) => n),
371
372
  loadings: ancestors(name, 'loading').map((n) => n),
373
+ errors: ancestors(name, 'error'),
372
374
  middleware: ancestors(name, 'middleware').map((n) => n),
373
375
  slots,
374
376
  sections: names.filter((n) => SECTION_FILE.test(n + '.tsx') && dirOf(n) === dirOf(name)),
@@ -386,6 +388,12 @@ function routeManifest() {
386
388
  build: { output, exportPath, payloadName: staticPayloads },
387
389
  routes,
388
390
  intercepts,
391
+ apis: [...apiRoutes.values()].map(({ name, methods }) => ({
392
+ name,
393
+ segments: urlSegments(name),
394
+ methods,
395
+ middleware: ancestors(name, 'middleware'),
396
+ })),
389
397
  };
390
398
  }
391
399
  /**
@@ -413,10 +421,11 @@ function warnIfTypesUnreachable() {
413
421
  const include = JSON.parse(text).include;
414
422
  if (!Array.isArray(include))
415
423
  return;
416
- if (include.some((entry) => typeof entry === 'string' && entry.includes('.rsc-kit')))
417
- return;
418
- log(`tsconfig.json does not include .rsc-kit, where the generated types are written.\n` +
419
- ` Add ".rsc-kit/**/*" to "include", or typed routes and rpc() fall back to string.`);
424
+ const covers = (what) => include.some((entry) => typeof entry === 'string' && entry.includes(what));
425
+ if (!covers('.rsc-kit')) {
426
+ log(`tsconfig.json does not include .rsc-kit, where the generated types are written.\n` +
427
+ ` Add ".rsc-kit/**/*" to "include", or typed routes and rpc() fall back to string.`);
428
+ }
420
429
  }
421
430
  catch {
422
431
  // An unparseable tsconfig is the project's own problem, not this one's.
@@ -451,10 +460,6 @@ function writeHostBindings(manifest) {
451
460
  // and a typecheck cannot see it. Written whether or not there are actions:
452
461
  // server components call it directly too.
453
462
  writeFileSync(join(typesDir, 'rsc-env.d.ts'), renderHostGlobalTypes());
454
- // The engine's own ambient types, copied where the app's typechecker will
455
- // see them. Deliberately a separate file from the one above: this one is
456
- // the engine's and identical everywhere, that one is generated from how
457
- // this host is configured.
458
463
  // The urls this build found, so a link to a page that does not exist fails
459
464
  // the typecheck instead of the browser.
460
465
  writeFileSync(join(typesDir, 'rsc-routes.d.ts'), renderRouteTypes(manifest));
@@ -463,10 +468,6 @@ function writeHostBindings(manifest) {
463
468
  // an app-authored one goes stale — the first version named only RscEngine,
464
469
  // which typechecks a server and fails a prerender script.
465
470
  writeFileSync(join(typesDir, 'rsc-engine.d.ts'), ENGINE_TYPES);
466
- const engineTypes = join(packageDir, 'types.d.ts');
467
- if (existsSync(engineTypes)) {
468
- writeFileSync(join(typesDir, 'rsc-types.d.ts'), readFileSync(engineTypes, 'utf-8'));
469
- }
470
471
  warnIfTypesUnreachable();
471
472
  const target = join(sourceDir, 'server-actions.generated.ts');
472
473
  // A host with no functions of its own leaves no file behind: kept, its
@@ -752,9 +753,10 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
752
753
  'Prerendering renders the app, so it needs the bundle the build just wrote. ' +
753
754
  'Build with prerender: false to render every page on demand instead.');
754
755
  }
755
- const [{ prerender, summary, legend }, { writeTo }] = await Promise.all([
756
+ const [{ prerender, summary, legend, notes, clientJsSize, pathKey: pathKeyOf }, { writeTo }, { prerenderApiRoutes },] = await Promise.all([
756
757
  import('./prerender.js'),
757
758
  import('./files.js'),
759
+ import('./apiPrerender.js'),
758
760
  ]);
759
761
  // Cleared first: a route that changes classification between builds
760
762
  // otherwise leaves its old shell on disk and the host goes on serving it.
@@ -763,22 +765,58 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
763
765
  const engine = (await import(pathToFileURL(bundle).href));
764
766
  const mark = { frozen: '○', shell: '◐', blocked: 'ƒ', error: '✗' };
765
767
  let failed = 0;
768
+ // Weighed from the page the prerenderer just wrote, so the column is what
769
+ // that page actually loads rather than a total every route is charged for.
770
+ const weigh = weighClientJs(assetsDir);
771
+ const pending = [];
766
772
  const results = await prerender({
767
773
  engine,
768
774
  write: writeTo(staticDir),
769
775
  onResult: (r) => {
770
776
  if (r.type === 'error')
771
777
  failed++;
772
- console.log(` ${mark[r.type] ?? ' '} ${r.url}${r.reason ? ` (${r.reason})` : ''}`);
773
- if (r.warning)
774
- console.log(` ⚠ ${r.warning}`);
778
+ const key = pathKeyOf(r.url);
779
+ const file = [`${key}.html`, `${key}.ppr.html`]
780
+ .map((name) => join(staticDir, name))
781
+ .find((path) => existsSync(path));
782
+ pending.push({
783
+ line: ` ${mark[r.type] ?? ' '} ${r.url}`,
784
+ bytes: file ? weigh(readFileSync(file, 'utf-8')) : null,
785
+ extra: [
786
+ ...(r.reason ? [` ${r.reason}`] : []),
787
+ ...(r.warning ? [` ⚠ ${r.warning}`] : []),
788
+ ],
789
+ });
775
790
  },
776
791
  });
792
+ // After the pages, sharing their output. An api route is a url the build
793
+ // either answered or could not, which is the same question the table above
794
+ // is already answering — a second list under its own heading would be two
795
+ // places to look for one fact.
796
+ const apis = await prerenderApiRoutes(engine, engine.manifest(), writeTo(staticDir));
797
+ for (const api of apis) {
798
+ pending.push({
799
+ line: ` ${api.type === 'frozen' ? '○' : 'ƒ'} ${api.url}`,
800
+ bytes: null,
801
+ extra: api.reason ? [` ${api.reason}`] : [],
802
+ });
803
+ }
804
+ // Printed together rather than as each route lands, because a column has to
805
+ // line up and the widest url is not known until the last one is in.
806
+ const column = Math.max(...pending.map((p) => p.line.length)) + 2;
807
+ for (const row of pending) {
808
+ const size = row.bytes === null ? '' : clientJsSize(row.bytes);
809
+ console.log(size ? row.line.padEnd(column) + size : row.line);
810
+ for (const line of row.extra)
811
+ console.log(line);
812
+ }
777
813
  const count = (type) => results.filter((r) => r.type === type).length;
814
+ const note = notes(results);
815
+ const counted = [...results, ...apis];
778
816
  console.log(`
779
- ${legend(results)}
817
+ ${legend(counted)}
780
818
 
781
- ${summary(results)}`);
819
+ ${summary(counted)}${note ? `\n\n${note}` : ''}`);
782
820
  if (failed > 0) {
783
821
  throw new Error(`[rsc-kit] ${failed} route${failed === 1 ? '' : 's'} failed to render.\n` +
784
822
  'Prerendering runs your app: whatever those pages need at render time has to be\n' +
@@ -787,6 +825,49 @@ ${legend(results)}
787
825
  if (output === 'export')
788
826
  await exportAfterPrerender(results, staticDir, assetsDir);
789
827
  }
828
+ /**
829
+ * Weighs the javascript one stored page makes the browser download.
830
+ *
831
+ * Read back out of the html rather than worked out from the module graph,
832
+ * because the html is the answer: React writes a modulepreload for every chunk
833
+ * the page needs, so whatever is in there is what the browser fetches. Nothing
834
+ * here has to agree with the bundler about anything.
835
+ *
836
+ * Gzipped, and each chunk weighed once however many pages name it — the same
837
+ * three files appear on every route and compressing them per page is the whole
838
+ * cost of this function.
839
+ *
840
+ * Returns null when there is no page to weigh, which is a route rendered on
841
+ * demand. A build must not fail over a column it prints for information.
842
+ */
843
+ function weighClientJs(assetsDir) {
844
+ const weighed = new Map();
845
+ const bytesOf = (asset) => {
846
+ const cached = weighed.get(asset);
847
+ if (cached !== undefined)
848
+ return cached;
849
+ const file = join(assetsDir, asset);
850
+ const bytes = existsSync(file) ? gzipSync(readFileSync(file)).byteLength : 0;
851
+ weighed.set(asset, bytes);
852
+ return bytes;
853
+ };
854
+ return (html) => {
855
+ if (!html)
856
+ return null;
857
+ const named = new Set();
858
+ // Split rather than matched: /assets/name.js is the only shape written, and
859
+ // a regex over a whole document is the slower half of this function.
860
+ for (const piece of html.split('/assets/').slice(1)) {
861
+ const name = piece.split(/["'\s)]/)[0];
862
+ if (name.endsWith('.js'))
863
+ named.add('assets/' + name);
864
+ }
865
+ let total = 0;
866
+ for (const asset of named)
867
+ total += bytesOf(asset);
868
+ return total;
869
+ };
870
+ }
790
871
  /**
791
872
  * Turn the frozen output into a directory a static host can serve.
792
873
  *
@@ -907,7 +988,9 @@ function renderHostGlobalTypes() {
907
988
  ].join('\n');
908
989
  }
909
990
  // ── Discovery ────────────────────────────────────────────────────────────────
910
- const ROUTE_FILES = ['page', 'layout', 'loading', 'default', 'middleware'];
991
+ const ROUTE_FILES = ['page', 'layout', 'loading', 'error', 'not-found', 'default', 'middleware'];
992
+ /** The methods a route.ts may export. HEAD and OPTIONS are answered for you. */
993
+ const API_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
911
994
  /** `orders.section.tsx` — a region of a page that can be refreshed by name. */
912
995
  const SECTION_FILE = /\.section\.(tsx|jsx|ts|js)$/;
913
996
  const EXTS = ['tsx', 'jsx', 'ts', 'js'];
@@ -927,6 +1010,15 @@ function toAlias(name) {
927
1010
  return '_c_' + name.replace(/[^a-zA-Z0-9]/g, '_');
928
1011
  }
929
1012
  const components = new Map();
1013
+ /**
1014
+ * `route.ts` files, by the name a url is matched against.
1015
+ *
1016
+ * Kept apart from `components` on purpose: these are not React and never enter
1017
+ * the render. They are imported by the generated entry and called with a
1018
+ * Request, which is why they can export whatever methods they like rather than
1019
+ * a default component.
1020
+ */
1021
+ const apiRoutes = new Map();
930
1022
  function register(absPath) {
931
1023
  const name = componentName(absPath);
932
1024
  const existing = components.get(name);
@@ -937,12 +1029,42 @@ function register(absPath) {
937
1029
  return c;
938
1030
  }
939
1031
  /** Walk app/ collecting page/layout/loading/default/middleware components. */
1032
+ /**
1033
+ * Record a route.ts and the methods it exports.
1034
+ *
1035
+ * Syntactic, deliberately. A handler assembled at runtime is not found, which
1036
+ * errs toward refusing a file whose shape we would be guessing at rather than
1037
+ * registering a url that answers 405 to everything.
1038
+ */
1039
+ function registerApiRoute(absPath) {
1040
+ const name = componentName(absPath);
1041
+ const source = readFileSync(absPath, 'utf-8');
1042
+ const methods = API_METHODS.filter((method) => new RegExp(`^\\s*export\\s+(?:async\\s+function|function|const|let|var)\\s+${method}\\b`, 'm').test(source));
1043
+ if (methods.length === 0) {
1044
+ // `route.ts` is also where a host declares the guards for everything below
1045
+ // it — the file predates api routes and is still read that way. One that
1046
+ // exports middleware is that file, not an endpoint, and saying so would be
1047
+ // telling someone their working config is broken.
1048
+ if (/^\s*export\s+(?:const|let|var|function)\s+middleware\b/m.test(source))
1049
+ return;
1050
+ throw new Error(`[rsc-kit] ${relative(projectRoot, absPath)} exports no request methods.\n` +
1051
+ ` Export one named for the method it answers — export function GET(request: Request) — ` +
1052
+ `or delete the file. One of: ${API_METHODS.join(', ')}.`);
1053
+ }
1054
+ apiRoutes.set(name, { name, absPath, methods });
1055
+ }
940
1056
  function discover(dir) {
941
1057
  for (const base of ROUTE_FILES) {
942
1058
  const p = findRouteFile(dir, base);
943
1059
  if (p)
944
1060
  register(p);
945
1061
  }
1062
+ // route.ts — an api endpoint, colocated with the pages it sits among. Read
1063
+ // for its method exports here rather than at request time, so a route that
1064
+ // exports nothing callable is a build error instead of a 404 nobody explains.
1065
+ const api = findRouteFile(dir, 'route');
1066
+ if (api)
1067
+ registerApiRoute(api);
946
1068
  // Named regions. Registered like any other component so the generated entry
947
1069
  // imports them — which is what runs section() and puts the name in the
948
1070
  // registry the server looks up to re-render one on its own.
@@ -997,6 +1119,25 @@ function hasStaticParams(absPath) {
997
1119
  const src = readFileSync(absPath, 'utf-8');
998
1120
  return /export\s+((async\s+)?function\s+generateStaticParams|const\s+generateStaticParams)/.test(src);
999
1121
  }
1122
+ /**
1123
+ * Which url schemas a page exports.
1124
+ *
1125
+ * Read from the source rather than by importing the module, the same way
1126
+ * metadata and generateStaticParams are: this runs while the graph is being
1127
+ * generated, and importing a page here would pull the app's whole server tree
1128
+ * into the plugin.
1129
+ *
1130
+ * `const` only. A schema is a value — `export function params` would be a
1131
+ * function, which no Standard Schema is, so matching it would generate an
1132
+ * import for something that can never validate.
1133
+ */
1134
+ function urlSchemaExports(absPath) {
1135
+ const src = readFileSync(absPath, 'utf-8');
1136
+ return {
1137
+ params: /export\s+const\s+params\s*[=:]/.test(src),
1138
+ searchParams: /export\s+const\s+searchParams\s*[=:]/.test(src),
1139
+ };
1140
+ }
1000
1141
  // ── Codegen ──────────────────────────────────────────────────────────────────
1001
1142
  /**
1002
1143
  * The dev fall-through, emitted only when there is a backend to hand a url to.
@@ -1153,10 +1294,28 @@ function installHostCallsOnce(): void {
1153
1294
 
1154
1295
  `;
1155
1296
  function generateEntryRsc(fallbackOrigin = '') {
1297
+ // The 404 page, if the app has one, and the layouts it renders inside.
1298
+ // Computed here rather than looked up at runtime: not-found is not a route,
1299
+ // so the manifest has no entry to read its chain from.
1300
+ const notFoundComponent = [...components.keys()].find((name) => name.endsWith('/not-found'));
1301
+ const notFoundLayouts = notFoundComponent
1302
+ ? [...components.keys()]
1303
+ .filter((name) => name.endsWith('/layout') &&
1304
+ notFoundComponent.startsWith(name.slice(0, -'layout'.length)))
1305
+ .sort((a, b) => a.length - b.length)
1306
+ : [];
1156
1307
  const imports = [];
1157
1308
  const mapEntries = [];
1158
1309
  const metaEntries = [];
1159
1310
  const paramEntries = [];
1311
+ const schemaEntries = [];
1312
+ const apiEntries = [];
1313
+ // Namespace imports: a route.ts exports one function per method, and which
1314
+ // ones it exports is the thing the dispatcher needs.
1315
+ for (const [index, route] of [...apiRoutes.values()].entries()) {
1316
+ imports.push(`import * as __api${index} from ${JSON.stringify(route.absPath)}`);
1317
+ apiEntries.push(` ${JSON.stringify(route.name)}: __api${index},`);
1318
+ }
1160
1319
  for (const c of components.values()) {
1161
1320
  imports.push(`import ${c.alias} from ${JSON.stringify(c.absPath)}`);
1162
1321
  mapEntries.push(` ${JSON.stringify(c.name)}: ${c.alias},`);
@@ -1175,6 +1334,15 @@ function generateEntryRsc(fallbackOrigin = '') {
1175
1334
  imports.push(`import * as ${c.alias}_params from ${JSON.stringify(c.absPath)}`);
1176
1335
  paramEntries.push(` ${JSON.stringify(c.name)}: ${c.alias}_params.generateStaticParams,`);
1177
1336
  }
1337
+ const urlSchemas = urlSchemaExports(c.absPath);
1338
+ if (urlSchemas.params || urlSchemas.searchParams) {
1339
+ imports.push(`import * as ${c.alias}_schema from ${JSON.stringify(c.absPath)}`);
1340
+ const fields = [
1341
+ urlSchemas.params ? `params: ${c.alias}_schema.params` : null,
1342
+ urlSchemas.searchParams ? `searchParams: ${c.alias}_schema.searchParams` : null,
1343
+ ].filter(Boolean);
1344
+ schemaEntries.push(` ${JSON.stringify(c.name)}: { ${fields.join(', ')} },`);
1345
+ }
1178
1346
  }
1179
1347
  // The engine's own modules are named without an extension: this plugin runs
1180
1348
  // from src/ in its own repo and from dist/ once published, and Vite resolves
@@ -1183,15 +1351,18 @@ function generateEntryRsc(fallbackOrigin = '') {
1183
1351
  import { SegmentBoundary } from ${JSON.stringify(join(packageDir, "js/SegmentBoundary"))}
1184
1352
  import { DocumentTitle } from ${JSON.stringify(join(packageDir, "js/DocumentTitle"))}
1185
1353
  import { SlotBoundary } from ${JSON.stringify(join(packageDir, "js/SlotBoundary"))}
1354
+ import { RouteErrorBoundary } from ${JSON.stringify(join(packageDir, "js/RouteErrorBoundary"))}
1186
1355
  import { sectionComponent } from ${JSON.stringify(join(packageDir, "js/section"))}
1187
1356
  import { PathnameProvider } from ${JSON.stringify(join(packageDir, "js/PathnameProvider"))}
1188
1357
  import { searchParams as requestSearchParams } from ${JSON.stringify(join(packageDir, "request"))}
1358
+ import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyError } from ${JSON.stringify(join(packageDir, "routeSchema"))}
1359
+ import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
1189
1360
  import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
1190
1361
  import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
1191
1362
  import { httpHostCalls } from ${JSON.stringify(join(packageDir, 'hostCalls'))}
1192
1363
  import { prerenderedBeside } from ${JSON.stringify(join(packageDir, 'files'))}
1193
1364
  import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
1194
- import { isQuery, queryCacheControl } from ${JSON.stringify(join(packageDir, 'query'))}
1365
+ import { isQuery, queryCacheControl, isQueryValidationError } from ${JSON.stringify(join(packageDir, 'query'))}
1195
1366
  import { Suspense, createElement, Fragment } from 'react'
1196
1367
  import { AsyncLocalStorage } from 'node:async_hooks'
1197
1368
  ${imports.join('\n')}
@@ -1204,6 +1375,113 @@ const components: Record<string, any> = {
1204
1375
  ${mapEntries.join('\n')}
1205
1376
  }
1206
1377
 
1378
+ /**
1379
+ * The url schemas a page exported, by component name.
1380
+ *
1381
+ * Empty for a page that exported none, which is the common case — the lookup
1382
+ * below then hands the url through untouched and costs a property read.
1383
+ */
1384
+ const urlSchemas: Record<string, { params?: any; searchParams?: any }> = {
1385
+ ${schemaEntries.join('\n')}
1386
+ }
1387
+
1388
+ /** route.ts modules, by the name the manifest matched. */
1389
+ const apiRoutes: Record<string, any> = {
1390
+ ${apiEntries.join('\n')}
1391
+ }
1392
+
1393
+ /**
1394
+ * Answer an api route.
1395
+ *
1396
+ * The handler is handed an ordinary Request and the route params, and whatever
1397
+ * Response it returns is the answer. Nothing renders; there is no payload and
1398
+ * no client involved.
1399
+ *
1400
+ * HEAD falls back to GET, which is what the spec says it is — the same response
1401
+ * without a body. Answering 405 instead breaks link checkers and anything that
1402
+ * probes before it fetches.
1403
+ */
1404
+ /** Whether a method may carry a body worth reading. */
1405
+ function hasBody(method: string): boolean {
1406
+ return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE'
1407
+ }
1408
+
1409
+ /**
1410
+ * What an api route answers when its own schema refused the request.
1411
+ *
1412
+ * Three statuses, because the three failures are three different things and
1413
+ * collapsing them would leave a client unable to tell a url that names nothing
1414
+ * from one it addressed wrongly:
1415
+ *
1416
+ * 404 the params do not describe a resource - the url names nothing
1417
+ * 400 the query string is wrong - the resource exists, the request did not
1418
+ * 422 the body is wrong - the same status an action returns for the same
1419
+ * failure, so a client has one shape to handle
1420
+ */
1421
+ function refusedInput(error: unknown): Response {
1422
+ const json = (status: number, payload: unknown) =>
1423
+ new Response(JSON.stringify(payload), {
1424
+ status,
1425
+ headers: { 'Content-Type': 'application/json' },
1426
+ })
1427
+
1428
+ if (isNotFoundSignal(error)) return json(404, { message: 'Not found' })
1429
+
1430
+ if (isSearchParamsError(error)) {
1431
+ return json(400, { message: error.message, errors: error.errors })
1432
+ }
1433
+
1434
+ if (isBodyError(error)) return json(422, { message: error.message, errors: error.errors })
1435
+
1436
+ throw error
1437
+ }
1438
+
1439
+ export async function handleApiRoute(
1440
+ name: string,
1441
+ request: Request,
1442
+ params: Record<string, string>,
1443
+ allow: string,
1444
+ ): Promise<Response> {
1445
+ applyHost()
1446
+
1447
+ const mod = apiRoutes[name]
1448
+ const method = request.method
1449
+ const handler = mod?.[method] ?? (method === 'HEAD' ? mod?.GET : undefined)
1450
+
1451
+ if (!handler) {
1452
+ // Allow is not optional on a 405: without it a client cannot tell which
1453
+ // methods would have worked, and neither can a person reading the logs.
1454
+ return new Response('Method not allowed', { status: 405, headers: { Allow: allow } })
1455
+ }
1456
+
1457
+ // Schemas are optional, per route and per kind. A route that exported none
1458
+ // reaches the handler with exactly what it always did — the raw params, the
1459
+ // URLSearchParams, and a body nobody has read — so nothing written before
1460
+ // this existed changes behaviour.
1461
+ let input
1462
+ try {
1463
+ input = {
1464
+ params: await parseParams(mod.params, params),
1465
+ searchParams: await parseSearchParams(
1466
+ mod.searchParams,
1467
+ new URL(request.url).searchParams,
1468
+ ),
1469
+ body: hasBody(method) ? await parseBody(mod.body, request) : undefined,
1470
+ }
1471
+ } catch (error) {
1472
+ return refusedInput(error)
1473
+ }
1474
+
1475
+ const answer = await handler(request, input)
1476
+
1477
+ // A HEAD answered by GET must not carry a body.
1478
+ if (method === 'HEAD' && answer instanceof Response) {
1479
+ return new Response(null, { status: answer.status, headers: answer.headers })
1480
+ }
1481
+
1482
+ return answer
1483
+ }
1484
+
1207
1485
  const metadataMap: Record<string, { static?: any; generate?: (p: any) => any }> = {
1208
1486
  ${metaEntries.join('\n')}
1209
1487
  }
@@ -1340,11 +1618,95 @@ function ownerLayoutIndex(slotComponent: string, layouts: LayoutEntry[]): number
1340
1618
  * nothing wrong. Awaiting it still surfaces the real error.
1341
1619
  */
1342
1620
  function pageSearchParams(): Promise<URLSearchParams> {
1343
- const pending = requestSearchParams()
1621
+ // Lazy. Every page is handed this whether it reads it or not, and reading the
1622
+ // request is what marks a page as needing one — so starting it eagerly told
1623
+ // the build that every page was dynamic, and put url() beside every route in
1624
+ // the output as though someone had written it.
1625
+ //
1626
+ // A thenable rather than a promise, so nothing happens until a page awaits.
1627
+ let pending: Promise<URLSearchParams> | null = null
1628
+
1629
+ const start = (): Promise<URLSearchParams> => {
1630
+ if (!pending) {
1631
+ pending = requestSearchParams()
1632
+
1633
+ // Attached here for the same reason it always was: during a prerender
1634
+ // this never settles, and an unobserved rejection ends the process.
1635
+ pending.catch(() => {})
1636
+ }
1637
+
1638
+ return pending
1639
+ }
1640
+
1641
+ return {
1642
+ then: (ok, fail) => start().then(ok, fail),
1643
+ catch: (fail) => start().catch(fail),
1644
+ finally: (done) => start().finally(done),
1645
+ } as Promise<URLSearchParams>
1646
+ }
1647
+
1648
+ /**
1649
+ * A page's params, through its own schema if it exported one.
1650
+ *
1651
+ * Laziness is preserved on purpose. The params promise may be one that never
1652
+ * settles - that is how the prerender probe says "not for any particular url"
1653
+ * - so this must not await eagerly. Chaining keeps a never-settling promise
1654
+ * never-settling, and the schema runs only if the page reads it.
1655
+ */
1656
+ function checkedParams(
1657
+ schemas: { params?: any } | undefined,
1658
+ params: Promise<Record<string, unknown>>,
1659
+ ): Promise<unknown> {
1660
+ if (!schemas || !schemas.params) return params
1661
+
1662
+ return params.then((value) => parseParams(schemas.params, value))
1663
+ }
1664
+
1665
+ /**
1666
+ * A page's query string, through its own schema if it exported one.
1667
+ *
1668
+ * Wrapped as a thenable rather than chained, so a page that never reads the
1669
+ * query still never starts the read - the whole thing pageSearchParams exists
1670
+ * to guarantee. Calling .then on it here would start it for every page, and
1671
+ * every page would be reported as reading the request.
1672
+ */
1673
+ function checkedSearchParams(
1674
+ schemas: { searchParams?: any } | undefined,
1675
+ search: Promise<URLSearchParams>,
1676
+ ): Promise<unknown> {
1677
+ if (!schemas || !schemas.searchParams) return search
1678
+
1679
+ let pending: Promise<unknown> | null = null
1680
+
1681
+ const start = (): Promise<unknown> => {
1682
+ if (!pending) {
1683
+ pending = search.then((value) => parseSearchParams(schemas.searchParams, value))
1684
+ pending.catch(() => {})
1685
+ }
1686
+
1687
+ return pending
1688
+ }
1344
1689
 
1345
- pending.catch(() => {})
1690
+ return {
1691
+ then: (ok, fail) => start().then(ok, fail),
1692
+ catch: (fail) => start().catch(fail),
1693
+ finally: (done) => start().finally(done),
1694
+ } as Promise<unknown>
1695
+ }
1696
+
1697
+ let errorChains: Record<string, string[]> | null = null
1698
+
1699
+ /** The error.tsx files above a component, outermost first. */
1700
+ function errorChain(component: string): string[] {
1701
+ if (!errorChains) {
1702
+ errorChains = {}
1703
+
1704
+ for (const route of manifest().routes as { component: string; errors?: string[] }[]) {
1705
+ if (route.errors?.length) errorChains[route.component] = route.errors
1706
+ }
1707
+ }
1346
1708
 
1347
- return pending
1709
+ return errorChains[component] ?? []
1348
1710
  }
1349
1711
 
1350
1712
  // Composition: layout(outer..inner) > Suspense(loading, innermost-first) > page.
@@ -1372,13 +1734,39 @@ function buildElement(
1372
1734
  // and renders to completion during the probe — producing a page about an
1373
1735
  // invented value, right for nothing — which is why such a route could only
1374
1736
  // ever be rendered per request.
1375
- let element = createElement(Component, { params, searchParams: pageSearchParams() })
1737
+ const schemas = urlSchemas[component]
1738
+
1739
+ let element = createElement(Component, {
1740
+ params: checkedParams(schemas, params),
1741
+ searchParams: checkedSearchParams(schemas, pageSearchParams()),
1742
+ })
1376
1743
 
1377
1744
  for (let i = loadings.length - 1; i >= 0; i--) {
1378
1745
  const Loading = components[loadings[i]]
1379
1746
  element = createElement(Suspense, { fallback: Loading ? createElement(Loading) : null }, element)
1380
1747
  }
1381
1748
 
1749
+ // Outside the Suspense boundary, innermost first — the nearest error.tsx to
1750
+ // the failure answers, the same rule loading.tsx follows. Outside, so a
1751
+ // component that throws while its fallback is showing is still caught.
1752
+ //
1753
+ // Read from the route table rather than passed in, for the same reason the
1754
+ // middleware chain is: every render path is covered by construction, and no
1755
+ // caller has to remember to forward them.
1756
+ const errors = errorChain(component)
1757
+
1758
+ for (let i = errors.length - 1; i >= 0; i--) {
1759
+ const Fallback = components[errors[i]]
1760
+
1761
+ if (!Fallback) continue
1762
+
1763
+ element = createElement(
1764
+ RouteErrorBoundary,
1765
+ { fallback: Fallback as never, resetKey: pageKey || component },
1766
+ element,
1767
+ )
1768
+ }
1769
+
1382
1770
  // <title>/<meta> go OUTSIDE the Suspense boundaries so they reach the shell
1383
1771
  // immediately — inside, they would be withheld until the page's data
1384
1772
  // resolves, delaying the whole document on a slow page.
@@ -1492,7 +1880,14 @@ async function renderTree(
1492
1880
  if (bootstrap) head.push(createElement(DocumentTitle, { key: '__ts', title: String(md.title) }))
1493
1881
  }
1494
1882
  if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
1495
- for (const [k, v] of Object.entries(md)) {
1883
+ // other is flattened in beside the named keys, because it is a place to put
1884
+ // meta tags rather than a meta tag by that name. A key at the top level
1885
+ // still renders — the type no longer invites one, but an app written
1886
+ // against the old shape must not silently lose its tags.
1887
+ const named = Object.entries(md).filter(([k]) => k !== 'other')
1888
+ const extra = Object.entries((md.other ?? {}) as Record<string, unknown>)
1889
+
1890
+ for (const [k, v] of [...named, ...extra]) {
1496
1891
  if (k === 'title' || k === 'description' || v == null) continue
1497
1892
  head.push(createElement('meta', { key: '__m_' + k, name: k, content: String(v) }))
1498
1893
  }
@@ -1608,6 +2003,13 @@ async function runMiddleware(component: string, props: Record<string, unknown> =
1608
2003
  for (const route of manifest().routes as { component: string; middleware?: string[] }[]) {
1609
2004
  if (route.middleware?.length) middlewareChains[route.component] = route.middleware
1610
2005
  }
2006
+
2007
+ // Api routes too. They are keyed by the module name rather than a
2008
+ // component, but the chain above them is the same one the pages beside
2009
+ // them run.
2010
+ for (const api of (manifest().apis ?? []) as { name: string; middleware?: string[] }[]) {
2011
+ if (api.middleware?.length) middlewareChains[api.name] = api.middleware
2012
+ }
1611
2013
  }
1612
2014
 
1613
2015
  for (const name of middlewareChains[component] ?? []) {
@@ -1689,7 +2091,7 @@ export async function handleRscStream(
1689
2091
  * Returning undefined leaves React's own behaviour alone for everything else.
1690
2092
  */
1691
2093
  function flightOnError(error: unknown): string | undefined {
1692
- const digest = redirectDigest(error)
2094
+ const digest = redirectDigest(error) ?? notFoundDigest(error)
1693
2095
 
1694
2096
  if (digest) return digest
1695
2097
 
@@ -1890,7 +2292,11 @@ export async function handleQuery(
1890
2292
  id: string,
1891
2293
  args: string,
1892
2294
  report?: (error: unknown) => string,
1893
- ): Promise<{ stream: ReadableStream; cacheControl: string } | null> {
2295
+ ): Promise<
2296
+ | { stream: ReadableStream; cacheControl: string }
2297
+ | { status: number; message: string; errors?: Record<string, string[]> }
2298
+ | null
2299
+ > {
1894
2300
  applyHost()
1895
2301
 
1896
2302
  let fn: unknown
@@ -1907,7 +2313,22 @@ export async function handleQuery(
1907
2313
  if (!isQuery(fn)) return null
1908
2314
 
1909
2315
  const decoded = (await decodeReply(args)) as unknown[]
1910
- const result = await (fn as (...a: unknown[]) => unknown)(...decoded)
2316
+
2317
+ // Awaited here rather than handed to the renderer as a promise, so a refusal
2318
+ // is still a status line rather than an error row inside a 200. React strips
2319
+ // a thrown message in production, so a query that rejected mid-stream would
2320
+ // reach the browser as "an error occurred" with the fields gone.
2321
+ let result: unknown
2322
+
2323
+ try {
2324
+ result = await (fn as (...a: unknown[]) => unknown)(...decoded)
2325
+ } catch (error) {
2326
+ if (isQueryValidationError(error)) {
2327
+ return { status: 422, errors: error.errors, message: error.message }
2328
+ }
2329
+
2330
+ return { status: 500, message: report ? report(error) : 'Query failed.' }
2331
+ }
1911
2332
 
1912
2333
  return {
1913
2334
  stream: renderToReadableStream(result, {
@@ -2017,12 +2438,31 @@ export async function resolveMetadata(
2017
2438
  : {}
2018
2439
 
2019
2440
  // Non-title metadata: layout defaults (outer→inner), page overrides.
2441
+ //
2442
+ // other merges per key rather than being replaced, so a page adding one
2443
+ // custom tag keeps the ones its layout set. Assigning it like any other key
2444
+ // would mean a root layout's theme-color disappearing from every page that
2445
+ // happened to declare one of its own.
2020
2446
  const merged: Record<string, unknown> = {}
2447
+ const other: Record<string, unknown> = {}
2448
+
2449
+ const take = (from: Record<string, unknown>) => {
2450
+ for (const [k, v] of Object.entries(from)) {
2451
+ if (k === 'title') continue
2452
+ if (k === 'other') Object.assign(other, v as Record<string, unknown>)
2453
+ else merged[k] = v
2454
+ }
2455
+ }
2456
+
2021
2457
  for (const l of layouts) {
2022
2458
  const s = metadataMap[l.component]?.static
2023
- if (s) for (const [k, v] of Object.entries(s)) if (k !== 'title') merged[k] = v
2459
+
2460
+ if (s) take(s as Record<string, unknown>)
2024
2461
  }
2025
- for (const [k, v] of Object.entries(page)) if (k !== 'title') merged[k] = v
2462
+
2463
+ take(page)
2464
+
2465
+ if (Object.keys(other).length > 0) merged.other = other
2026
2466
 
2027
2467
  // Title: the page title with the NEAREST layout title.template applied; if the
2028
2468
  // page has no title, the nearest layout default/string title.
@@ -2052,7 +2492,7 @@ export async function handleRsc(
2052
2492
  pageKey = '',
2053
2493
  bootstrap = true,
2054
2494
  canReachHost = true,
2055
- ): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; clientComponents: string[] }> {
2495
+ ): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; dynamicBecause: string[]; clientComponents: string[] }> {
2056
2496
  applyHost()
2057
2497
 
2058
2498
  // A build renders this with no host installed, so every rpc() has to suspend
@@ -2063,10 +2503,18 @@ export async function handleRsc(
2063
2503
  // Defaults to true because the other caller is an interception, which runs
2064
2504
  // at request time with a real host and must not be probed.
2065
2505
  let usedDynamicApis = false
2506
+ const hostCalls: string[] = []
2066
2507
 
2067
- const probe = (..._args: unknown[]) => {
2508
+ const probe = (...args: unknown[]) => {
2068
2509
  usedDynamicApis = true
2069
2510
 
2511
+ // The name it was called with, so the build can say rpc("getUser") rather
2512
+ // than "this page reached for the host" and leave you to find which call.
2513
+ const name = typeof args[0] === 'string' ? args[0] : null
2514
+ const said = name ? 'rpc(' + JSON.stringify(name) + ')' : 'rpc()'
2515
+
2516
+ if (!hostCalls.includes(said)) hostCalls.push(said)
2517
+
2070
2518
  return new Promise<never>(() => {})
2071
2519
  }
2072
2520
 
@@ -2382,12 +2830,48 @@ export default async function handler(request: Request): Promise<Response> {
2382
2830
  handleRscResume,
2383
2831
  handleAction,
2384
2832
  handleQuery,
2833
+ handleApiRoute,
2385
2834
  resolveMetadata,
2386
2835
  runRouteMiddleware,
2387
2836
  } as never,
2388
2837
  ${NITRO_HANDLER_OPTIONS}${NITRO_PRERENDERED} })
2389
2838
 
2390
- ${fallbackOrigin ? FALLBACK_BODY : " return (await devHandler(request)) ?? new Response('Not found', { status: 404 })\n"}}
2839
+ ${fallbackOrigin ? FALLBACK_BODY : " return (await devHandler(request)) ?? (await notFound())\n"}}
2840
+
2841
+ /**
2842
+ * The page for a url nothing answers.
2843
+ *
2844
+ * Rendered through its layout chain like any other page, so the 404 a visitor
2845
+ * sees is the app rather than a bare string — and returned with a 404, because
2846
+ * a page that says "not found" under a 200 is a page search engines index.
2847
+ *
2848
+ * Without a not-found.tsx this is the string it always was.
2849
+ */
2850
+ async function notFound(): Promise<Response> {
2851
+ ${notFoundComponent ? `
2852
+ try {
2853
+ const { htmlStream } = await handleRscHtmlStream(
2854
+ ${JSON.stringify(notFoundComponent)},
2855
+ {},
2856
+ ${JSON.stringify(notFoundLayouts.map((component) => ({ component, props: {} })))},
2857
+ [],
2858
+ {},
2859
+ {},
2860
+ undefined,
2861
+ '/404',
2862
+ )
2863
+
2864
+ return new Response(htmlStream, {
2865
+ status: 404,
2866
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
2867
+ })
2868
+ } catch {
2869
+ // A 404 page that throws is still a 404. Falling back rather than
2870
+ // answering 500 keeps the status honest about what happened.
2871
+ }
2872
+ ` : ''}
2873
+ return new Response('Not found', { status: 404 })
2874
+ }
2391
2875
  `;
2392
2876
  }
2393
2877
  function generateEntrySsr() {
@@ -2668,6 +3152,27 @@ function hasLoadingInChain(pageDir) {
2668
3152
  * blank screen. A page whose slow work lives in children behind their own
2669
3153
  * <Suspense> already paints a shell and needs nothing.
2670
3154
  */
3155
+ /**
3156
+ * An `error.tsx` has to be a client component.
3157
+ *
3158
+ * It is rendered inside a React error boundary, which is a class component in
3159
+ * the browser, and it is handed a `reset` callback to call. A server component
3160
+ * can be neither. Caught here rather than at runtime, where the symptom is a
3161
+ * boundary that renders nothing while the error it was written for goes to the
3162
+ * console.
3163
+ */
3164
+ function validateErrorBoundaries() {
3165
+ const wrong = [];
3166
+ for (const c of components.values()) {
3167
+ if (!c.name.endsWith('/error'))
3168
+ continue;
3169
+ const source = readFileSync(c.absPath, 'utf-8');
3170
+ if (!/^\s*['"]use client['"]/m.test(source)) {
3171
+ wrong.push(` ${relative(projectRoot, c.absPath)}`);
3172
+ }
3173
+ }
3174
+ return wrong;
3175
+ }
2671
3176
  function validateLoadingBoundaries() {
2672
3177
  const errors = [];
2673
3178
  for (const c of components.values()) {
@@ -2721,6 +3226,13 @@ export function rscKit(options = {}) {
2721
3226
  throw new Error(`[rsc-kit] ${message}`);
2722
3227
  log(message);
2723
3228
  }
3229
+ const notClient = validateErrorBoundaries();
3230
+ if (notClient.length) {
3231
+ throw new Error('[rsc-kit] An error.tsx must be a client component.\n\n' +
3232
+ notClient.join('\n') +
3233
+ "\n\nAdd 'use client' at the top. It is rendered inside an error boundary and is\n" +
3234
+ 'handed a reset() callback to call, neither of which a server component can do.');
3235
+ }
2724
3236
  const loadingErrors = validateLoadingBoundaries();
2725
3237
  if (loadingErrors.length) {
2726
3238
  throw new Error('[rsc-kit] A page that blocks before it can paint needs a loading.tsx boundary.\n\n' +
@@ -3032,7 +3544,25 @@ export function rscKit(options = {}) {
3032
3544
  // With Nitro, the server handler is Nitro's — it takes the rsc entry's
3033
3545
  // default export and builds the server around it. Leaving plugin-rsc's own
3034
3546
  // handler in place means two things claiming the same role.
3035
- return [appPluginRsc({ serverHandler: false }), routesPlugin];
3547
+ return [
3548
+ appPluginRsc({
3549
+ serverHandler: false,
3550
+ // One chunk per client component, rather than one for the whole app.
3551
+ //
3552
+ // plugin-rsc already loads a client reference with `await import()`, so
3553
+ // the browser only fetches what a page actually renders — but its default
3554
+ // groups every reference by the SERVER chunk that proxies it, and this
3555
+ // package builds the server as a single chunk. So every client component
3556
+ // in an app landed in one group, and a page with a button pulled in the
3557
+ // editor, the chart and the map that live on other routes.
3558
+ //
3559
+ // Grouping by the component's own module restores the split the dynamic
3560
+ // import was there to make use of.
3561
+ clientChunks: (meta) => meta.normalizedId,
3562
+ ...actionEncryptionKey(),
3563
+ }),
3564
+ routesPlugin,
3565
+ ];
3036
3566
  }
3037
3567
  /**
3038
3568
  * @vitejs/plugin-rsc, resolved from the app rather than from here.
@@ -3051,6 +3581,36 @@ export function rscKit(options = {}) {
3051
3581
  * Resolving from the project root gets the app's copy, whose own `vite` import
3052
3582
  * then resolves to the app's Vite as well — one pair, and the check passes.
3053
3583
  */
3584
+ /**
3585
+ * Where the key that encrypts bound action arguments comes from.
3586
+ *
3587
+ * A server action can close over server-side values, and React sends those to
3588
+ * the browser encrypted so the page cannot read them. The process that decrypts
3589
+ * them on the way back has to hold the same key.
3590
+ *
3591
+ * By default plugin-rsc generates one per build and bakes it in. Every instance
3592
+ * of one build therefore agrees, and the only exposure is a deploy: a browser
3593
+ * holding a page from the old build calls an action on the new one, and the
3594
+ * key has changed underneath it. The call fails with nothing useful in it.
3595
+ *
3596
+ * Setting RSC_ACTION_ENCRYPTION_KEY makes the key outlive the build and closes
3597
+ * that window. It is read at RUNTIME, not baked in, so the same artifact can be
3598
+ * deployed anywhere — but it must then be set everywhere the app runs, and set
3599
+ * to the same value. Half-configured is worse than unconfigured: instances
3600
+ * would disagree, and the failure looks like an intermittently broken action.
3601
+ *
3602
+ * Unset, the build-time key is used and nothing changes. That is the default
3603
+ * because it is the one that cannot be got half right.
3604
+ */
3605
+ function actionEncryptionKey() {
3606
+ if (!process.env.RSC_ACTION_ENCRYPTION_KEY)
3607
+ return {};
3608
+ // An expression, not a value: plugin-rsc substitutes this source text where
3609
+ // the key is read, so what ships is the lookup rather than the secret. A
3610
+ // literal here would put the key in the bundle, which is the thing being
3611
+ // avoided.
3612
+ return { defineEncryptionKey: 'process.env.RSC_ACTION_ENCRYPTION_KEY' };
3613
+ }
3054
3614
  async function appPluginRsc(options = {}) {
3055
3615
  try {
3056
3616
  // Resolved against a file *in* the root, since a directory specifier