@rsc-kit/core 0.9.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.
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
  /**
@@ -418,14 +426,6 @@ function warnIfTypesUnreachable() {
418
426
  log(`tsconfig.json does not include .rsc-kit, where the generated types are written.\n` +
419
427
  ` Add ".rsc-kit/**/*" to "include", or typed routes and rpc() fall back to string.`);
420
428
  }
421
- // Only when there is something there to check. An app with no api routes
422
- // has nothing to be warned about, and a warning it cannot act on is one it
423
- // learns to scroll past.
424
- if (existsSync(join(projectRoot, 'server')) && !covers('server')) {
425
- log(`tsconfig.json does not include server/, where your api handlers live.\n` +
426
- ` Add "server/**/*" to "include", or they are not type-checked at all — ` +
427
- `a handler returning the wrong shape builds and deploys without complaint.`);
428
- }
429
429
  }
430
430
  catch {
431
431
  // An unparseable tsconfig is the project's own problem, not this one's.
@@ -753,9 +753,10 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
753
753
  'Prerendering renders the app, so it needs the bundle the build just wrote. ' +
754
754
  'Build with prerender: false to render every page on demand instead.');
755
755
  }
756
- const [{ prerender, summary, legend }, { writeTo }] = await Promise.all([
756
+ const [{ prerender, summary, legend, notes, clientJsSize, pathKey: pathKeyOf }, { writeTo }, { prerenderApiRoutes },] = await Promise.all([
757
757
  import('./prerender.js'),
758
758
  import('./files.js'),
759
+ import('./apiPrerender.js'),
759
760
  ]);
760
761
  // Cleared first: a route that changes classification between builds
761
762
  // otherwise leaves its old shell on disk and the host goes on serving it.
@@ -764,22 +765,58 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
764
765
  const engine = (await import(pathToFileURL(bundle).href));
765
766
  const mark = { frozen: '○', shell: '◐', blocked: 'ƒ', error: '✗' };
766
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 = [];
767
772
  const results = await prerender({
768
773
  engine,
769
774
  write: writeTo(staticDir),
770
775
  onResult: (r) => {
771
776
  if (r.type === 'error')
772
777
  failed++;
773
- console.log(` ${mark[r.type] ?? ' '} ${r.url}${r.reason ? ` (${r.reason})` : ''}`);
774
- if (r.warning)
775
- 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
+ });
776
790
  },
777
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
+ }
778
813
  const count = (type) => results.filter((r) => r.type === type).length;
814
+ const note = notes(results);
815
+ const counted = [...results, ...apis];
779
816
  console.log(`
780
- ${legend(results)}
817
+ ${legend(counted)}
781
818
 
782
- ${summary(results)}`);
819
+ ${summary(counted)}${note ? `\n\n${note}` : ''}`);
783
820
  if (failed > 0) {
784
821
  throw new Error(`[rsc-kit] ${failed} route${failed === 1 ? '' : 's'} failed to render.\n` +
785
822
  'Prerendering runs your app: whatever those pages need at render time has to be\n' +
@@ -788,6 +825,49 @@ ${legend(results)}
788
825
  if (output === 'export')
789
826
  await exportAfterPrerender(results, staticDir, assetsDir);
790
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
+ }
791
871
  /**
792
872
  * Turn the frozen output into a directory a static host can serve.
793
873
  *
@@ -908,7 +988,9 @@ function renderHostGlobalTypes() {
908
988
  ].join('\n');
909
989
  }
910
990
  // ── Discovery ────────────────────────────────────────────────────────────────
911
- 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'];
912
994
  /** `orders.section.tsx` — a region of a page that can be refreshed by name. */
913
995
  const SECTION_FILE = /\.section\.(tsx|jsx|ts|js)$/;
914
996
  const EXTS = ['tsx', 'jsx', 'ts', 'js'];
@@ -928,6 +1010,15 @@ function toAlias(name) {
928
1010
  return '_c_' + name.replace(/[^a-zA-Z0-9]/g, '_');
929
1011
  }
930
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();
931
1022
  function register(absPath) {
932
1023
  const name = componentName(absPath);
933
1024
  const existing = components.get(name);
@@ -938,12 +1029,42 @@ function register(absPath) {
938
1029
  return c;
939
1030
  }
940
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
+ }
941
1056
  function discover(dir) {
942
1057
  for (const base of ROUTE_FILES) {
943
1058
  const p = findRouteFile(dir, base);
944
1059
  if (p)
945
1060
  register(p);
946
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);
947
1068
  // Named regions. Registered like any other component so the generated entry
948
1069
  // imports them — which is what runs section() and puts the name in the
949
1070
  // registry the server looks up to re-render one on its own.
@@ -998,6 +1119,25 @@ function hasStaticParams(absPath) {
998
1119
  const src = readFileSync(absPath, 'utf-8');
999
1120
  return /export\s+((async\s+)?function\s+generateStaticParams|const\s+generateStaticParams)/.test(src);
1000
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
+ }
1001
1141
  // ── Codegen ──────────────────────────────────────────────────────────────────
1002
1142
  /**
1003
1143
  * The dev fall-through, emitted only when there is a backend to hand a url to.
@@ -1154,10 +1294,28 @@ function installHostCallsOnce(): void {
1154
1294
 
1155
1295
  `;
1156
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
+ : [];
1157
1307
  const imports = [];
1158
1308
  const mapEntries = [];
1159
1309
  const metaEntries = [];
1160
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
+ }
1161
1319
  for (const c of components.values()) {
1162
1320
  imports.push(`import ${c.alias} from ${JSON.stringify(c.absPath)}`);
1163
1321
  mapEntries.push(` ${JSON.stringify(c.name)}: ${c.alias},`);
@@ -1176,6 +1334,15 @@ function generateEntryRsc(fallbackOrigin = '') {
1176
1334
  imports.push(`import * as ${c.alias}_params from ${JSON.stringify(c.absPath)}`);
1177
1335
  paramEntries.push(` ${JSON.stringify(c.name)}: ${c.alias}_params.generateStaticParams,`);
1178
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
+ }
1179
1346
  }
1180
1347
  // The engine's own modules are named without an extension: this plugin runs
1181
1348
  // from src/ in its own repo and from dist/ once published, and Vite resolves
@@ -1184,15 +1351,18 @@ function generateEntryRsc(fallbackOrigin = '') {
1184
1351
  import { SegmentBoundary } from ${JSON.stringify(join(packageDir, "js/SegmentBoundary"))}
1185
1352
  import { DocumentTitle } from ${JSON.stringify(join(packageDir, "js/DocumentTitle"))}
1186
1353
  import { SlotBoundary } from ${JSON.stringify(join(packageDir, "js/SlotBoundary"))}
1354
+ import { RouteErrorBoundary } from ${JSON.stringify(join(packageDir, "js/RouteErrorBoundary"))}
1187
1355
  import { sectionComponent } from ${JSON.stringify(join(packageDir, "js/section"))}
1188
1356
  import { PathnameProvider } from ${JSON.stringify(join(packageDir, "js/PathnameProvider"))}
1189
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"))}
1190
1360
  import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
1191
1361
  import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
1192
1362
  import { httpHostCalls } from ${JSON.stringify(join(packageDir, 'hostCalls'))}
1193
1363
  import { prerenderedBeside } from ${JSON.stringify(join(packageDir, 'files'))}
1194
1364
  import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
1195
- import { isQuery, queryCacheControl } from ${JSON.stringify(join(packageDir, 'query'))}
1365
+ import { isQuery, queryCacheControl, isQueryValidationError } from ${JSON.stringify(join(packageDir, 'query'))}
1196
1366
  import { Suspense, createElement, Fragment } from 'react'
1197
1367
  import { AsyncLocalStorage } from 'node:async_hooks'
1198
1368
  ${imports.join('\n')}
@@ -1205,6 +1375,113 @@ const components: Record<string, any> = {
1205
1375
  ${mapEntries.join('\n')}
1206
1376
  }
1207
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
+
1208
1485
  const metadataMap: Record<string, { static?: any; generate?: (p: any) => any }> = {
1209
1486
  ${metaEntries.join('\n')}
1210
1487
  }
@@ -1341,11 +1618,95 @@ function ownerLayoutIndex(slotComponent: string, layouts: LayoutEntry[]): number
1341
1618
  * nothing wrong. Awaiting it still surfaces the real error.
1342
1619
  */
1343
1620
  function pageSearchParams(): Promise<URLSearchParams> {
1344
- 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
+ }
1345
1637
 
1346
- pending.catch(() => {})
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
+ }
1689
+
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
1347
1698
 
1348
- return pending
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
+ }
1708
+
1709
+ return errorChains[component] ?? []
1349
1710
  }
1350
1711
 
1351
1712
  // Composition: layout(outer..inner) > Suspense(loading, innermost-first) > page.
@@ -1373,13 +1734,39 @@ function buildElement(
1373
1734
  // and renders to completion during the probe — producing a page about an
1374
1735
  // invented value, right for nothing — which is why such a route could only
1375
1736
  // ever be rendered per request.
1376
- 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
+ })
1377
1743
 
1378
1744
  for (let i = loadings.length - 1; i >= 0; i--) {
1379
1745
  const Loading = components[loadings[i]]
1380
1746
  element = createElement(Suspense, { fallback: Loading ? createElement(Loading) : null }, element)
1381
1747
  }
1382
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
+
1383
1770
  // <title>/<meta> go OUTSIDE the Suspense boundaries so they reach the shell
1384
1771
  // immediately — inside, they would be withheld until the page's data
1385
1772
  // resolves, delaying the whole document on a slow page.
@@ -1616,6 +2003,13 @@ async function runMiddleware(component: string, props: Record<string, unknown> =
1616
2003
  for (const route of manifest().routes as { component: string; middleware?: string[] }[]) {
1617
2004
  if (route.middleware?.length) middlewareChains[route.component] = route.middleware
1618
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
+ }
1619
2013
  }
1620
2014
 
1621
2015
  for (const name of middlewareChains[component] ?? []) {
@@ -1697,7 +2091,7 @@ export async function handleRscStream(
1697
2091
  * Returning undefined leaves React's own behaviour alone for everything else.
1698
2092
  */
1699
2093
  function flightOnError(error: unknown): string | undefined {
1700
- const digest = redirectDigest(error)
2094
+ const digest = redirectDigest(error) ?? notFoundDigest(error)
1701
2095
 
1702
2096
  if (digest) return digest
1703
2097
 
@@ -1898,7 +2292,11 @@ export async function handleQuery(
1898
2292
  id: string,
1899
2293
  args: string,
1900
2294
  report?: (error: unknown) => string,
1901
- ): 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
+ > {
1902
2300
  applyHost()
1903
2301
 
1904
2302
  let fn: unknown
@@ -1915,7 +2313,22 @@ export async function handleQuery(
1915
2313
  if (!isQuery(fn)) return null
1916
2314
 
1917
2315
  const decoded = (await decodeReply(args)) as unknown[]
1918
- 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
+ }
1919
2332
 
1920
2333
  return {
1921
2334
  stream: renderToReadableStream(result, {
@@ -2079,7 +2492,7 @@ export async function handleRsc(
2079
2492
  pageKey = '',
2080
2493
  bootstrap = true,
2081
2494
  canReachHost = true,
2082
- ): 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[] }> {
2083
2496
  applyHost()
2084
2497
 
2085
2498
  // A build renders this with no host installed, so every rpc() has to suspend
@@ -2090,10 +2503,18 @@ export async function handleRsc(
2090
2503
  // Defaults to true because the other caller is an interception, which runs
2091
2504
  // at request time with a real host and must not be probed.
2092
2505
  let usedDynamicApis = false
2506
+ const hostCalls: string[] = []
2093
2507
 
2094
- const probe = (..._args: unknown[]) => {
2508
+ const probe = (...args: unknown[]) => {
2095
2509
  usedDynamicApis = true
2096
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
+
2097
2518
  return new Promise<never>(() => {})
2098
2519
  }
2099
2520
 
@@ -2409,12 +2830,48 @@ export default async function handler(request: Request): Promise<Response> {
2409
2830
  handleRscResume,
2410
2831
  handleAction,
2411
2832
  handleQuery,
2833
+ handleApiRoute,
2412
2834
  resolveMetadata,
2413
2835
  runRouteMiddleware,
2414
2836
  } as never,
2415
2837
  ${NITRO_HANDLER_OPTIONS}${NITRO_PRERENDERED} })
2416
2838
 
2417
- ${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
+ }
2418
2875
  `;
2419
2876
  }
2420
2877
  function generateEntrySsr() {
@@ -2695,6 +3152,27 @@ function hasLoadingInChain(pageDir) {
2695
3152
  * blank screen. A page whose slow work lives in children behind their own
2696
3153
  * <Suspense> already paints a shell and needs nothing.
2697
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
+ }
2698
3176
  function validateLoadingBoundaries() {
2699
3177
  const errors = [];
2700
3178
  for (const c of components.values()) {
@@ -2748,6 +3226,13 @@ export function rscKit(options = {}) {
2748
3226
  throw new Error(`[rsc-kit] ${message}`);
2749
3227
  log(message);
2750
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
+ }
2751
3236
  const loadingErrors = validateLoadingBoundaries();
2752
3237
  if (loadingErrors.length) {
2753
3238
  throw new Error('[rsc-kit] A page that blocks before it can paint needs a loading.tsx boundary.\n\n' +
@@ -3059,7 +3544,25 @@ export function rscKit(options = {}) {
3059
3544
  // With Nitro, the server handler is Nitro's — it takes the rsc entry's
3060
3545
  // default export and builds the server around it. Leaving plugin-rsc's own
3061
3546
  // handler in place means two things claiming the same role.
3062
- 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
+ ];
3063
3566
  }
3064
3567
  /**
3065
3568
  * @vitejs/plugin-rsc, resolved from the app rather than from here.
@@ -3078,6 +3581,36 @@ export function rscKit(options = {}) {
3078
3581
  * Resolving from the project root gets the app's copy, whose own `vite` import
3079
3582
  * then resolves to the app's Vite as well — one pair, and the check passes.
3080
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
+ }
3081
3614
  async function appPluginRsc(options = {}) {
3082
3615
  try {
3083
3616
  // Resolved against a file *in* the root, since a directory specifier