@rsc-kit/core 0.9.0 → 0.11.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 (50) 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 +33 -0
  5. package/dist/apiPrerender.js +200 -0
  6. package/dist/apiPrerender.js.map +1 -0
  7. package/dist/appAssets.d.ts +30 -0
  8. package/dist/appAssets.js +90 -0
  9. package/dist/appAssets.js.map +1 -0
  10. package/dist/buildReport.d.ts +43 -0
  11. package/dist/buildReport.js +40 -0
  12. package/dist/buildReport.js.map +1 -0
  13. package/dist/host.d.ts +12 -0
  14. package/dist/host.js +148 -2
  15. package/dist/host.js.map +1 -1
  16. package/dist/js/RouteErrorBoundary.d.ts +36 -0
  17. package/dist/js/RouteErrorBoundary.js +43 -0
  18. package/dist/js/RouteErrorBoundary.js.map +1 -0
  19. package/dist/js/queryClient.js +21 -1
  20. package/dist/js/queryClient.js.map +1 -1
  21. package/dist/manifest.d.ts +33 -0
  22. package/dist/manifest.js.map +1 -1
  23. package/dist/notFound.d.ts +24 -0
  24. package/dist/notFound.js +107 -0
  25. package/dist/notFound.js.map +1 -0
  26. package/dist/prerender.d.ts +48 -4
  27. package/dist/prerender.js +79 -9
  28. package/dist/prerender.js.map +1 -1
  29. package/dist/query.d.ts +23 -0
  30. package/dist/query.js +45 -3
  31. package/dist/query.js.map +1 -1
  32. package/dist/redirect.d.ts +8 -0
  33. package/dist/redirect.js +11 -1
  34. package/dist/redirect.js.map +1 -1
  35. package/dist/request.d.ts +27 -0
  36. package/dist/request.js +50 -6
  37. package/dist/request.js.map +1 -1
  38. package/dist/routeSchema.d.ts +115 -0
  39. package/dist/routeSchema.js +182 -0
  40. package/dist/routeSchema.js.map +1 -0
  41. package/dist/routing.d.ts +20 -1
  42. package/dist/routing.js +30 -7
  43. package/dist/routing.js.map +1 -1
  44. package/dist/vite.d.ts +34 -1
  45. package/dist/vite.js +837 -33
  46. package/dist/vite.js.map +1 -1
  47. package/dist/webManifest.d.ts +54 -0
  48. package/dist/webManifest.js +81 -0
  49. package/dist/webManifest.js.map +1 -0
  50. package/package.json +17 -1
package/dist/vite.js CHANGED
@@ -12,13 +12,17 @@
12
12
  // carry the route composition and the worker's render contract, and supplies
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
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
15
+ import { copyFileSync, 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';
19
20
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
21
  import rsc from '@vitejs/plugin-rsc';
21
22
  import { loadEnv } from 'vite';
23
+ import { REPORT_FILE, buildReport } from './buildReport.js';
24
+ import { MANIFEST_PATH, manifestWarning, webManifest } from './webManifest.js';
25
+ import { ASSET_BASE, appAssets, headTags } from './appAssets.js';
22
26
  import { httpHostCalls } from './hostCalls.js';
23
27
  // Resolved once per rscKit() call. One build runs in one process, so these are
24
28
  // module state rather than threaded through every helper.
@@ -57,6 +61,8 @@ let isWatch = false;
57
61
  let viewTransitions = false;
58
62
  /** Whether a service worker is generated and registered — see options. */
59
63
  let offline = false;
64
+ let webManifestOptions = null;
65
+ let foundAssets = { favicon: null, icons: [], appleIcon: null, openGraph: null, twitter: null };
60
66
  /** Host functions to generate stubs for — see RscKitOptions.hostActions. */
61
67
  let hostActions;
62
68
  /**
@@ -200,6 +206,20 @@ function resolvePaths(options) {
200
206
  prerenderAfterBuild = options.prerender ?? process.env.RSC_PRERENDER !== '0';
201
207
  viewTransitions = options.viewTransitions === true;
202
208
  offline = options.offline === true;
209
+ // One place, and it is the file. A plugin option as well would be the same
210
+ // thing sayable in two places, which is the problem the file was moved to
211
+ // solve rather than a convenience to keep beside it.
212
+ webManifestOptions = declaredManifest(join(sourceDir, 'app'));
213
+ foundAssets = appAssets(join(sourceDir, 'app'));
214
+ // An app that put icons where they could be found has already listed them.
215
+ // Writing them again in the manifest is the same set in two places, and the
216
+ // one that goes stale is the one nobody looks at.
217
+ if (webManifestOptions && !webManifestOptions.icons?.length && foundAssets.icons.length) {
218
+ webManifestOptions = {
219
+ ...webManifestOptions,
220
+ icons: foundAssets.icons.map((icon) => icon.href),
221
+ };
222
+ }
203
223
  hostActions = options.hostActions ?? fileHostActions(projectRoot);
204
224
  }
205
225
  function log(...args) {
@@ -369,6 +389,7 @@ function routeManifest() {
369
389
  segments: urlSegments(name),
370
390
  layouts: ancestors(name, 'layout').map((n) => n),
371
391
  loadings: ancestors(name, 'loading').map((n) => n),
392
+ errors: ancestors(name, 'error'),
372
393
  middleware: ancestors(name, 'middleware').map((n) => n),
373
394
  slots,
374
395
  sections: names.filter((n) => SECTION_FILE.test(n + '.tsx') && dirOf(n) === dirOf(name)),
@@ -386,6 +407,12 @@ function routeManifest() {
386
407
  build: { output, exportPath, payloadName: staticPayloads },
387
408
  routes,
388
409
  intercepts,
410
+ apis: [...apiRoutes.values()].map(({ name, methods }) => ({
411
+ name,
412
+ segments: urlSegments(name),
413
+ methods,
414
+ middleware: ancestors(name, 'middleware'),
415
+ })),
389
416
  };
390
417
  }
391
418
  /**
@@ -418,14 +445,6 @@ function warnIfTypesUnreachable() {
418
445
  log(`tsconfig.json does not include .rsc-kit, where the generated types are written.\n` +
419
446
  ` Add ".rsc-kit/**/*" to "include", or typed routes and rpc() fall back to string.`);
420
447
  }
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
448
  }
430
449
  catch {
431
450
  // An unparseable tsconfig is the project's own problem, not this one's.
@@ -547,11 +566,21 @@ function reportAllDynamic() {
547
566
  * Nothing else is touched. An action is a POST and must never be answered from
548
567
  * a cache; anything cross-origin is somebody else's to cache.
549
568
  */
550
- export const SERVICE_WORKER = (version, precache) => `// GENERATED by rscKit() — do not edit.
569
+ export const SERVICE_WORKER = (version, precache, frozen = []) => `// GENERATED by rscKit() — do not edit.
551
570
  const VERSION = ${JSON.stringify(version)}
552
571
  const CACHE = 'rsc-kit-' + VERSION
553
572
  const PRECACHE = ${JSON.stringify(precache, null, 2)}
554
573
 
574
+ // The urls the build stored whole. Their answer cannot change until the next
575
+ // deploy, and a deploy changes VERSION and sweeps this cache — so for these,
576
+ // and only these, the cache is the better source and the network is the
577
+ // fallback rather than the other way round.
578
+ //
579
+ // Everything else stays network-first. A page that reads the request has a
580
+ // right answer that depends on the request, and serving yesterday's from a
581
+ // cache would be wrong in a way the visitor cannot see.
582
+ const FROZEN = new Set(${JSON.stringify(frozen)})
583
+
555
584
  self.addEventListener('install', (event) => {
556
585
  // The new worker takes over rather than waiting for every tab to close.
557
586
  // Safe here because assets are content-hashed: a page already open keeps
@@ -622,6 +651,40 @@ self.addEventListener('fetch', (event) => {
622
651
  return
623
652
  }
624
653
 
654
+ // A stored page, asked for without a query string. Cache first, and refresh
655
+ // in the background so the next visit has the new one even if this worker
656
+ // never updates.
657
+ //
658
+ // The query matters for the same reason it matters to a stored api route:
659
+ // the build answered the bare url, and a page reading ?q= answers
660
+ // differently for every value of it.
661
+ if (FROZEN.has(url.pathname.replace(/\\/+$/, '') || '/') && !url.search) {
662
+ event.respondWith(
663
+ caches.match(keyFor(request)).then((hit) => {
664
+ const fresh = fetch(request)
665
+ .then((response) => {
666
+ if (mayStore(response)) {
667
+ const copy = response.clone()
668
+ caches.open(CACHE).then((cache) => cache.put(keyFor(request), copy))
669
+ }
670
+
671
+ return response
672
+ })
673
+ .catch((error) => {
674
+ if (hit) return hit
675
+
676
+ throw error
677
+ })
678
+
679
+ // Not awaited when there is a hit: the point is that the visitor does
680
+ // not wait for the network for a page that cannot have changed.
681
+ return hit ?? fresh
682
+ }),
683
+ )
684
+
685
+ return
686
+ }
687
+
625
688
  event.respondWith(
626
689
  fetch(request)
627
690
  .then((response) => {
@@ -698,7 +761,98 @@ self.addEventListener('fetch', (event) => {
698
761
  * and a build that changed anything produces a different one — which is the
699
762
  * whole of cache invalidation, without asking anyone to set a variable.
700
763
  */
701
- function writeServiceWorker(clientDir) {
764
+ /**
765
+ * Write the web app manifest into the client output, beside the assets.
766
+ *
767
+ * Into the same directory the browser is served from, because that is where a
768
+ * relative icon path resolves — a manifest served from somewhere else resolves
769
+ * its icons somewhere else too, and the failure is a browser that quietly does
770
+ * not offer to install.
771
+ */
772
+ /**
773
+ * The manifest an app declared beside its routes.
774
+ *
775
+ * `src/app/manifest.ts`, default-exporting the object — the same shape every
776
+ * other thing about a route tree takes here, and the reason this is a file
777
+ * rather than a `vite.config.ts` key. A manifest is not build configuration
778
+ * any more than a page is; it is one more thing the app declares about itself,
779
+ * and it belongs where the app is.
780
+ *
781
+ * Evaluated with the TypeScript stripped rather than imported: this runs while
782
+ * the plugin is being constructed, long before there is a module graph to pull
783
+ * it through, and importing an app module here would drag its imports in with
784
+ * it. The file is a literal object by contract, which is all that has to parse.
785
+ */
786
+ export function declaredManifest(appDir) {
787
+ for (const extension of ['ts', 'tsx', 'js', 'mjs']) {
788
+ const file = join(appDir, `manifest.${extension}`);
789
+ if (!existsSync(file))
790
+ continue;
791
+ const source = readFileSync(file, 'utf-8');
792
+ // The object literal after `export default`, with a `satisfies` or `as`
793
+ // annotation tolerated after it — which is how anyone who wants the type
794
+ // checked will actually write the file, and getting that wrong would refuse
795
+ // the recommended spelling.
796
+ //
797
+ // A manifest that is not a literal — computed, imported from elsewhere — is
798
+ // refused loudly rather than silently ignored, because the failure would
799
+ // otherwise be an app that is simply not installable with nothing said.
800
+ const match = /export\s+default\s+(\{[\s\S]*\})(?:\s+(?:satisfies|as)\s+[\w.<>\[\]| ]+)?\s*;?\s*$/.exec(source.trim());
801
+ if (!match) {
802
+ throw new Error(`[rsc-kit] app/manifest.${extension} must default-export an object literal.\n` +
803
+ 'It is read at build time, before there is a module graph to evaluate it in, so it ' +
804
+ 'cannot be computed or imported from elsewhere.');
805
+ }
806
+ try {
807
+ // Function rather than JSON.parse: the file is TypeScript source with
808
+ // unquoted keys, trailing commas and comments in it, none of which JSON
809
+ // accepts and all of which are ordinary in a file a person edits.
810
+ return new Function(`return (${match[1]})`)();
811
+ }
812
+ catch (error) {
813
+ throw new Error(`[rsc-kit] Could not read app/manifest.${extension}: ${error.message}`);
814
+ }
815
+ }
816
+ return null;
817
+ }
818
+ /**
819
+ * Copy the icons and share images into the client output.
820
+ *
821
+ * They live in `app/` so the build can read them - an icon's filename decides
822
+ * what goes in the manifest - but a browser has to be able to fetch them, and
823
+ * nothing serves `app/`. Copied rather than symlinked: the output is what gets
824
+ * deployed, and a link into a source tree that is not deployed points nowhere.
825
+ */
826
+ function copyAppAssets(clientDir) {
827
+ if (!existsSync(clientDir))
828
+ return;
829
+ const all = [
830
+ ...(foundAssets.favicon ? [foundAssets.favicon] : []),
831
+ ...foundAssets.icons,
832
+ ...(foundAssets.appleIcon ? [foundAssets.appleIcon] : []),
833
+ ...(foundAssets.openGraph ? [foundAssets.openGraph] : []),
834
+ ...(foundAssets.twitter ? [foundAssets.twitter] : []),
835
+ ];
836
+ if (all.length === 0)
837
+ return;
838
+ for (const asset of all) {
839
+ const to = join(clientDir, asset.href.slice(1));
840
+ mkdirSync(dirname(to), { recursive: true });
841
+ copyFileSync(join(sourceDir, 'app', asset.file), to);
842
+ }
843
+ log(`icons: ${all.length} copied from app/`);
844
+ }
845
+ function writeWebManifest(clientDir, options) {
846
+ if (!existsSync(clientDir))
847
+ return;
848
+ writeFileSync(join(clientDir, MANIFEST_PATH.slice(1)), webManifest(options));
849
+ const warning = manifestWarning(options);
850
+ if (warning)
851
+ log(warning);
852
+ else
853
+ log(`manifest: ${options.name} is installable`);
854
+ }
855
+ function writeServiceWorker(clientDir, frozen = []) {
702
856
  if (!existsSync(clientDir))
703
857
  return;
704
858
  const files = [];
@@ -718,7 +872,7 @@ function writeServiceWorker(clientDir) {
718
872
  walk(clientDir, '');
719
873
  const precache = ['/', ...files.map((file) => `/${file}`)].sort();
720
874
  const version = createHash('sha256').update(precache.join('\n')).digest('hex').slice(0, 12);
721
- writeFileSync(join(clientDir, 'sw.js'), SERVICE_WORKER(version, precache));
875
+ writeFileSync(join(clientDir, 'sw.js'), SERVICE_WORKER(version, precache, frozen));
722
876
  log(`offline: ${precache.length} files precached as rsc-kit-${version}`);
723
877
  }
724
878
  /**
@@ -753,9 +907,10 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
753
907
  'Prerendering renders the app, so it needs the bundle the build just wrote. ' +
754
908
  'Build with prerender: false to render every page on demand instead.');
755
909
  }
756
- const [{ prerender, summary, legend }, { writeTo }] = await Promise.all([
910
+ const [{ prerender, summary, legend, notes, clientJsSize, pathKey: pathKeyOf }, { writeTo }, { prerenderApiRoutes },] = await Promise.all([
757
911
  import('./prerender.js'),
758
912
  import('./files.js'),
913
+ import('./apiPrerender.js'),
759
914
  ]);
760
915
  // Cleared first: a route that changes classification between builds
761
916
  // otherwise leaves its old shell on disk and the host goes on serving it.
@@ -764,22 +919,73 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
764
919
  const engine = (await import(pathToFileURL(bundle).href));
765
920
  const mark = { frozen: '○', shell: '◐', blocked: 'ƒ', error: '✗' };
766
921
  let failed = 0;
922
+ // Weighed from the page the prerenderer just wrote, so the column is what
923
+ // that page actually loads rather than a total every route is charged for.
924
+ const weigh = weighClientJs(assetsDir);
925
+ const sized = new Map();
926
+ const pending = [];
767
927
  const results = await prerender({
768
928
  engine,
769
929
  write: writeTo(staticDir),
770
930
  onResult: (r) => {
771
931
  if (r.type === 'error')
772
932
  failed++;
773
- console.log(` ${mark[r.type] ?? ' '} ${r.url}${r.reason ? ` (${r.reason})` : ''}`);
774
- if (r.warning)
775
- console.log(` ⚠ ${r.warning}`);
933
+ const key = pathKeyOf(r.url);
934
+ const file = [`${key}.html`, `${key}.ppr.html`]
935
+ .map((name) => join(staticDir, name))
936
+ .find((path) => existsSync(path));
937
+ const bytes = file ? weigh(readFileSync(file, 'utf-8')) : null;
938
+ if (bytes !== null)
939
+ sized.set(r.url, bytes);
940
+ pending.push({
941
+ line: ` ${mark[r.type] ?? ' '} ${r.url}`,
942
+ bytes,
943
+ extra: [
944
+ ...(r.reason ? [` ${r.reason}`] : []),
945
+ ...(r.warning ? [` ⚠ ${r.warning}`] : []),
946
+ ],
947
+ });
776
948
  },
777
949
  });
950
+ // After the pages, sharing their output. An api route is a url the build
951
+ // either answered or could not, which is the same question the table above
952
+ // is already answering — a second list under its own heading would be two
953
+ // places to look for one fact.
954
+ const manifest = engine.manifest();
955
+ const apis = await prerenderApiRoutes(engine, manifest, writeTo(staticDir));
956
+ for (const api of apis) {
957
+ pending.push({
958
+ line: ` ${api.type === 'frozen' ? '○' : 'ƒ'} ${api.url}`,
959
+ bytes: null,
960
+ extra: api.reason ? [` ${api.reason}`] : [],
961
+ });
962
+ }
963
+ // Printed together rather than as each route lands, because a column has to
964
+ // line up and the widest url is not known until the last one is in.
965
+ const column = Math.max(...pending.map((p) => p.line.length)) + 2;
966
+ for (const row of pending) {
967
+ const size = row.bytes === null ? '' : clientJsSize(row.bytes);
968
+ console.log(size ? row.line.padEnd(column) + size : row.line);
969
+ for (const line of row.extra)
970
+ console.log(line);
971
+ }
778
972
  const count = (type) => results.filter((r) => r.type === type).length;
973
+ // Written from the rows that were just printed rather than recomputed: the
974
+ // report and the terminal must not be able to disagree about what happened.
975
+ writeFileSync(join(outDir, REPORT_FILE), buildReport(results.map((r) => ({
976
+ url: r.url,
977
+ component: r.component,
978
+ type: r.type,
979
+ reason: r.reason,
980
+ warning: r.warning ?? null,
981
+ clientJs: sized.get(r.url) ?? null,
982
+ })), apis.map((a) => ({ url: a.url, name: a.name, type: a.type, reason: a.reason }))));
983
+ const note = notes(results);
984
+ const counted = [...results, ...apis];
779
985
  console.log(`
780
- ${legend(results)}
986
+ ${legend(counted)}
781
987
 
782
- ${summary(results)}`);
988
+ ${summary(counted)}${note ? `\n\n${note}` : ''}`);
783
989
  if (failed > 0) {
784
990
  throw new Error(`[rsc-kit] ${failed} route${failed === 1 ? '' : 's'} failed to render.\n` +
785
991
  'Prerendering runs your app: whatever those pages need at render time has to be\n' +
@@ -787,6 +993,68 @@ ${legend(results)}
787
993
  }
788
994
  if (output === 'export')
789
995
  await exportAfterPrerender(results, staticDir, assetsDir);
996
+ // The urls whose answer cannot change until the next build. The service
997
+ // worker serves these from its cache first rather than asking the network
998
+ // and falling back — see writeServiceWorker.
999
+ //
1000
+ // Two narrowings beyond "frozen", and neither is redundant even though the
1001
+ // worker would survive without them. A guarded route IS frozen — the guard
1002
+ // is a serving decision, not a build one — but its response is per visitor
1003
+ // and must never be read from a cache that has no notion of who asked. A
1004
+ // redirect is stored as a destination rather than a page, so there is no
1005
+ // document to serve from a cache at all.
1006
+ //
1007
+ // Both are already refused downstream: a guarded response carries no-store
1008
+ // and a 3xx is not `ok`. Saying it here as well means the list means what it
1009
+ // says, rather than being a wider list that happens to be filtered later.
1010
+ const guarded = new Set(manifest.routes.filter((r) => r.middleware?.length).map((r) => r.component));
1011
+ return results
1012
+ .filter((r) => r.type === 'frozen' && !guarded.has(r.component))
1013
+ .filter((r) => existsSync(join(staticDir, pathKeyOf(r.url) + '.html')))
1014
+ .map((r) => r.url);
1015
+ }
1016
+ /**
1017
+ * Weighs the javascript one stored page makes the browser download.
1018
+ *
1019
+ * Read back out of the html rather than worked out from the module graph,
1020
+ * because the html is the answer: React writes a modulepreload for every chunk
1021
+ * the page needs, so whatever is in there is what the browser fetches. Nothing
1022
+ * here has to agree with the bundler about anything.
1023
+ *
1024
+ * Gzipped, and each chunk weighed once however many pages name it — the same
1025
+ * three files appear on every route and compressing them per page is the whole
1026
+ * cost of this function.
1027
+ *
1028
+ * Returns null when there is no page to weigh, which is a route rendered on
1029
+ * demand. A build must not fail over a column it prints for information.
1030
+ */
1031
+ function weighClientJs(assetsDir) {
1032
+ const weighed = new Map();
1033
+ const bytesOf = (asset) => {
1034
+ const cached = weighed.get(asset);
1035
+ if (cached !== undefined)
1036
+ return cached;
1037
+ const file = join(assetsDir, asset);
1038
+ const bytes = existsSync(file) ? gzipSync(readFileSync(file)).byteLength : 0;
1039
+ weighed.set(asset, bytes);
1040
+ return bytes;
1041
+ };
1042
+ return (html) => {
1043
+ if (!html)
1044
+ return null;
1045
+ const named = new Set();
1046
+ // Split rather than matched: /assets/name.js is the only shape written, and
1047
+ // a regex over a whole document is the slower half of this function.
1048
+ for (const piece of html.split('/assets/').slice(1)) {
1049
+ const name = piece.split(/["'\s)]/)[0];
1050
+ if (name.endsWith('.js'))
1051
+ named.add('assets/' + name);
1052
+ }
1053
+ let total = 0;
1054
+ for (const asset of named)
1055
+ total += bytesOf(asset);
1056
+ return total;
1057
+ };
790
1058
  }
791
1059
  /**
792
1060
  * Turn the frozen output into a directory a static host can serve.
@@ -908,7 +1176,9 @@ function renderHostGlobalTypes() {
908
1176
  ].join('\n');
909
1177
  }
910
1178
  // ── Discovery ────────────────────────────────────────────────────────────────
911
- const ROUTE_FILES = ['page', 'layout', 'loading', 'default', 'middleware'];
1179
+ const ROUTE_FILES = ['page', 'layout', 'loading', 'error', 'not-found', 'default', 'middleware'];
1180
+ /** The methods a route.ts may export. HEAD and OPTIONS are answered for you. */
1181
+ const API_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
912
1182
  /** `orders.section.tsx` — a region of a page that can be refreshed by name. */
913
1183
  const SECTION_FILE = /\.section\.(tsx|jsx|ts|js)$/;
914
1184
  const EXTS = ['tsx', 'jsx', 'ts', 'js'];
@@ -928,6 +1198,15 @@ function toAlias(name) {
928
1198
  return '_c_' + name.replace(/[^a-zA-Z0-9]/g, '_');
929
1199
  }
930
1200
  const components = new Map();
1201
+ /**
1202
+ * `route.ts` files, by the name a url is matched against.
1203
+ *
1204
+ * Kept apart from `components` on purpose: these are not React and never enter
1205
+ * the render. They are imported by the generated entry and called with a
1206
+ * Request, which is why they can export whatever methods they like rather than
1207
+ * a default component.
1208
+ */
1209
+ const apiRoutes = new Map();
931
1210
  function register(absPath) {
932
1211
  const name = componentName(absPath);
933
1212
  const existing = components.get(name);
@@ -938,12 +1217,42 @@ function register(absPath) {
938
1217
  return c;
939
1218
  }
940
1219
  /** Walk app/ collecting page/layout/loading/default/middleware components. */
1220
+ /**
1221
+ * Record a route.ts and the methods it exports.
1222
+ *
1223
+ * Syntactic, deliberately. A handler assembled at runtime is not found, which
1224
+ * errs toward refusing a file whose shape we would be guessing at rather than
1225
+ * registering a url that answers 405 to everything.
1226
+ */
1227
+ function registerApiRoute(absPath) {
1228
+ const name = componentName(absPath);
1229
+ const source = readFileSync(absPath, 'utf-8');
1230
+ const methods = API_METHODS.filter((method) => new RegExp(`^\\s*export\\s+(?:async\\s+function|function|const|let|var)\\s+${method}\\b`, 'm').test(source));
1231
+ if (methods.length === 0) {
1232
+ // `route.ts` is also where a host declares the guards for everything below
1233
+ // it — the file predates api routes and is still read that way. One that
1234
+ // exports middleware is that file, not an endpoint, and saying so would be
1235
+ // telling someone their working config is broken.
1236
+ if (/^\s*export\s+(?:const|let|var|function)\s+middleware\b/m.test(source))
1237
+ return;
1238
+ throw new Error(`[rsc-kit] ${relative(projectRoot, absPath)} exports no request methods.\n` +
1239
+ ` Export one named for the method it answers — export function GET(request: Request) — ` +
1240
+ `or delete the file. One of: ${API_METHODS.join(', ')}.`);
1241
+ }
1242
+ apiRoutes.set(name, { name, absPath, methods });
1243
+ }
941
1244
  function discover(dir) {
942
1245
  for (const base of ROUTE_FILES) {
943
1246
  const p = findRouteFile(dir, base);
944
1247
  if (p)
945
1248
  register(p);
946
1249
  }
1250
+ // route.ts — an api endpoint, colocated with the pages it sits among. Read
1251
+ // for its method exports here rather than at request time, so a route that
1252
+ // exports nothing callable is a build error instead of a 404 nobody explains.
1253
+ const api = findRouteFile(dir, 'route');
1254
+ if (api)
1255
+ registerApiRoute(api);
947
1256
  // Named regions. Registered like any other component so the generated entry
948
1257
  // imports them — which is what runs section() and puts the name in the
949
1258
  // registry the server looks up to re-render one on its own.
@@ -998,6 +1307,25 @@ function hasStaticParams(absPath) {
998
1307
  const src = readFileSync(absPath, 'utf-8');
999
1308
  return /export\s+((async\s+)?function\s+generateStaticParams|const\s+generateStaticParams)/.test(src);
1000
1309
  }
1310
+ /**
1311
+ * Which url schemas a page exports.
1312
+ *
1313
+ * Read from the source rather than by importing the module, the same way
1314
+ * metadata and generateStaticParams are: this runs while the graph is being
1315
+ * generated, and importing a page here would pull the app's whole server tree
1316
+ * into the plugin.
1317
+ *
1318
+ * `const` only. A schema is a value — `export function params` would be a
1319
+ * function, which no Standard Schema is, so matching it would generate an
1320
+ * import for something that can never validate.
1321
+ */
1322
+ function urlSchemaExports(absPath) {
1323
+ const src = readFileSync(absPath, 'utf-8');
1324
+ return {
1325
+ params: /export\s+const\s+params\s*[=:]/.test(src),
1326
+ searchParams: /export\s+const\s+searchParams\s*[=:]/.test(src),
1327
+ };
1328
+ }
1001
1329
  // ── Codegen ──────────────────────────────────────────────────────────────────
1002
1330
  /**
1003
1331
  * The dev fall-through, emitted only when there is a backend to hand a url to.
@@ -1154,10 +1482,28 @@ function installHostCallsOnce(): void {
1154
1482
 
1155
1483
  `;
1156
1484
  function generateEntryRsc(fallbackOrigin = '') {
1485
+ // The 404 page, if the app has one, and the layouts it renders inside.
1486
+ // Computed here rather than looked up at runtime: not-found is not a route,
1487
+ // so the manifest has no entry to read its chain from.
1488
+ const notFoundComponent = [...components.keys()].find((name) => name.endsWith('/not-found'));
1489
+ const notFoundLayouts = notFoundComponent
1490
+ ? [...components.keys()]
1491
+ .filter((name) => name.endsWith('/layout') &&
1492
+ notFoundComponent.startsWith(name.slice(0, -'layout'.length)))
1493
+ .sort((a, b) => a.length - b.length)
1494
+ : [];
1157
1495
  const imports = [];
1158
1496
  const mapEntries = [];
1159
1497
  const metaEntries = [];
1160
1498
  const paramEntries = [];
1499
+ const schemaEntries = [];
1500
+ const apiEntries = [];
1501
+ // Namespace imports: a route.ts exports one function per method, and which
1502
+ // ones it exports is the thing the dispatcher needs.
1503
+ for (const [index, route] of [...apiRoutes.values()].entries()) {
1504
+ imports.push(`import * as __api${index} from ${JSON.stringify(route.absPath)}`);
1505
+ apiEntries.push(` ${JSON.stringify(route.name)}: __api${index},`);
1506
+ }
1161
1507
  for (const c of components.values()) {
1162
1508
  imports.push(`import ${c.alias} from ${JSON.stringify(c.absPath)}`);
1163
1509
  mapEntries.push(` ${JSON.stringify(c.name)}: ${c.alias},`);
@@ -1176,6 +1522,15 @@ function generateEntryRsc(fallbackOrigin = '') {
1176
1522
  imports.push(`import * as ${c.alias}_params from ${JSON.stringify(c.absPath)}`);
1177
1523
  paramEntries.push(` ${JSON.stringify(c.name)}: ${c.alias}_params.generateStaticParams,`);
1178
1524
  }
1525
+ const urlSchemas = urlSchemaExports(c.absPath);
1526
+ if (urlSchemas.params || urlSchemas.searchParams) {
1527
+ imports.push(`import * as ${c.alias}_schema from ${JSON.stringify(c.absPath)}`);
1528
+ const fields = [
1529
+ urlSchemas.params ? `params: ${c.alias}_schema.params` : null,
1530
+ urlSchemas.searchParams ? `searchParams: ${c.alias}_schema.searchParams` : null,
1531
+ ].filter(Boolean);
1532
+ schemaEntries.push(` ${JSON.stringify(c.name)}: { ${fields.join(', ')} },`);
1533
+ }
1179
1534
  }
1180
1535
  // The engine's own modules are named without an extension: this plugin runs
1181
1536
  // from src/ in its own repo and from dist/ once published, and Vite resolves
@@ -1184,15 +1539,19 @@ function generateEntryRsc(fallbackOrigin = '') {
1184
1539
  import { SegmentBoundary } from ${JSON.stringify(join(packageDir, "js/SegmentBoundary"))}
1185
1540
  import { DocumentTitle } from ${JSON.stringify(join(packageDir, "js/DocumentTitle"))}
1186
1541
  import { SlotBoundary } from ${JSON.stringify(join(packageDir, "js/SlotBoundary"))}
1542
+ import { RouteErrorBoundary } from ${JSON.stringify(join(packageDir, "js/RouteErrorBoundary"))}
1187
1543
  import { sectionComponent } from ${JSON.stringify(join(packageDir, "js/section"))}
1188
1544
  import { PathnameProvider } from ${JSON.stringify(join(packageDir, "js/PathnameProvider"))}
1189
1545
  import { searchParams as requestSearchParams } from ${JSON.stringify(join(packageDir, "request"))}
1546
+ import { parseParams, parseSearchParams, parseBody, isSearchParamsError, isBodyError } from ${JSON.stringify(join(packageDir, "routeSchema"))}
1547
+ import { notFoundDigest, isNotFoundSignal } from ${JSON.stringify(join(packageDir, "notFound"))}
1548
+ import { noteRequestRead } from ${JSON.stringify(join(packageDir, "request"))}
1190
1549
  import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
1191
1550
  import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
1192
1551
  import { httpHostCalls } from ${JSON.stringify(join(packageDir, 'hostCalls'))}
1193
1552
  import { prerenderedBeside } from ${JSON.stringify(join(packageDir, 'files'))}
1194
1553
  import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
1195
- import { isQuery, queryCacheControl } from ${JSON.stringify(join(packageDir, 'query'))}
1554
+ import { isQuery, queryCacheControl, isQueryValidationError } from ${JSON.stringify(join(packageDir, 'query'))}
1196
1555
  import { Suspense, createElement, Fragment } from 'react'
1197
1556
  import { AsyncLocalStorage } from 'node:async_hooks'
1198
1557
  ${imports.join('\n')}
@@ -1205,6 +1564,167 @@ const components: Record<string, any> = {
1205
1564
  ${mapEntries.join('\n')}
1206
1565
  }
1207
1566
 
1567
+ /**
1568
+ * The url schemas a page exported, by component name.
1569
+ *
1570
+ * Empty for a page that exported none, which is the common case — the lookup
1571
+ * below then hands the url through untouched and costs a property read.
1572
+ */
1573
+ /**
1574
+ * The manifest to link from every page, or null when the app declared none.
1575
+ *
1576
+ * A literal rather than a define: defines are configured per environment and
1577
+ * this is read while rendering, in the rsc one. Generated in, so an app with
1578
+ * no manifest carries the word null and no branch worth taking.
1579
+ */
1580
+ /** Head tags for the icons and share images found in app/. */
1581
+ const APP_HEAD: { tag: any; props: Record<string, string> }[] = ${JSON.stringify(headTags(foundAssets))}
1582
+
1583
+ const WEB_MANIFEST: { href: string; themeColor?: string } | null = ${JSON.stringify(webManifestOptions
1584
+ ? { href: MANIFEST_PATH, ...(webManifestOptions.themeColor ? { themeColor: webManifestOptions.themeColor } : {}) }
1585
+ : null)}
1586
+
1587
+ const urlSchemas: Record<string, { params?: any; searchParams?: any }> = {
1588
+ ${schemaEntries.join('\n')}
1589
+ }
1590
+
1591
+ /** route.ts modules, by the name the manifest matched. */
1592
+ const apiRoutes: Record<string, any> = {
1593
+ ${apiEntries.join('\n')}
1594
+ }
1595
+
1596
+ /**
1597
+ * Answer an api route.
1598
+ *
1599
+ * The handler is handed an ordinary Request and the route params, and whatever
1600
+ * Response it returns is the answer. Nothing renders; there is no payload and
1601
+ * no client involved.
1602
+ *
1603
+ * HEAD falls back to GET, which is what the spec says it is — the same response
1604
+ * without a body. Answering 405 instead breaks link checkers and anything that
1605
+ * probes before it fetches.
1606
+ */
1607
+ /**
1608
+ * A promise that does not start until something awaits it.
1609
+ *
1610
+ * A thenable rather than a promise for the reason pageSearchParams is one:
1611
+ * every handler is handed all three of these whether it reads them or not, and
1612
+ * a real promise would run the work - and record the read - for every route on
1613
+ * every request.
1614
+ */
1615
+ function lazily<T>(start: () => Promise<T>): Promise<T> {
1616
+ let pending: Promise<T> | null = null
1617
+
1618
+ const begin = (): Promise<T> => {
1619
+ if (!pending) {
1620
+ pending = start()
1621
+ pending.catch(() => {})
1622
+ }
1623
+
1624
+ return pending
1625
+ }
1626
+
1627
+ return {
1628
+ then: (ok, fail) => begin().then(ok, fail),
1629
+ catch: (fail) => begin().catch(fail),
1630
+ finally: (done) => begin().finally(done),
1631
+ } as Promise<T>
1632
+ }
1633
+
1634
+ /** Whether a method may carry a body worth reading. */
1635
+ function hasBody(method: string): boolean {
1636
+ return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE'
1637
+ }
1638
+
1639
+ /**
1640
+ * What an api route answers when its own schema refused the request.
1641
+ *
1642
+ * Three statuses, because the three failures are three different things and
1643
+ * collapsing them would leave a client unable to tell a url that names nothing
1644
+ * from one it addressed wrongly:
1645
+ *
1646
+ * 404 the params do not describe a resource - the url names nothing
1647
+ * 400 the query string is wrong - the resource exists, the request did not
1648
+ * 422 the body is wrong - the same status an action returns for the same
1649
+ * failure, so a client has one shape to handle
1650
+ */
1651
+ function refusedInput(error: unknown): Response {
1652
+ const json = (status: number, payload: unknown) =>
1653
+ new Response(JSON.stringify(payload), {
1654
+ status,
1655
+ headers: { 'Content-Type': 'application/json' },
1656
+ })
1657
+
1658
+ if (isNotFoundSignal(error)) return json(404, { message: 'Not found' })
1659
+
1660
+ if (isSearchParamsError(error)) {
1661
+ return json(400, { message: error.message, errors: error.errors })
1662
+ }
1663
+
1664
+ if (isBodyError(error)) return json(422, { message: error.message, errors: error.errors })
1665
+
1666
+ throw error
1667
+ }
1668
+
1669
+ export async function handleApiRoute(
1670
+ name: string,
1671
+ request: Request,
1672
+ params: Record<string, string>,
1673
+ allow: string,
1674
+ ): Promise<Response> {
1675
+ applyHost()
1676
+
1677
+ const mod = apiRoutes[name]
1678
+ const method = request.method
1679
+ const handler = mod?.[method] ?? (method === 'HEAD' ? mod?.GET : undefined)
1680
+
1681
+ if (!handler) {
1682
+ // Allow is not optional on a 405: without it a client cannot tell which
1683
+ // methods would have worked, and neither can a person reading the logs.
1684
+ return new Response('Method not allowed', { status: 405, headers: { Allow: allow } })
1685
+ }
1686
+
1687
+ // Awaited by the handler, not before it - the same shape a page's props have,
1688
+ // and for more than symmetry. A route that never awaits its query string
1689
+ // provably does not vary by it, so the build can store one answer and the
1690
+ // host can serve it for any query at all. Resolved eagerly here, that fact
1691
+ // is unknowable and every ?utm_source= misses the stored answer.
1692
+ //
1693
+ // Schemas stay optional per route and per kind. A route that exported none
1694
+ // gets the raw params, the URLSearchParams, and a body nobody has read.
1695
+ const input = {
1696
+ params: lazily(() => parseParams(mod.params, params)),
1697
+ searchParams: lazily(() => {
1698
+ // Declaring a schema is itself a statement that the query matters, so a
1699
+ // route with one is treated as varying by it whether or not the handler
1700
+ // reaches for the value.
1701
+ noteRequestRead('searchParams')
1702
+
1703
+ return parseSearchParams(mod.searchParams, new URL(request.url).searchParams)
1704
+ }),
1705
+ body: hasBody(method) ? lazily(() => parseBody(mod.body, request)) : undefined,
1706
+ }
1707
+
1708
+ if (mod.searchParams) noteRequestRead('searchParams')
1709
+
1710
+ let answer
1711
+ try {
1712
+ answer = await handler(request, input)
1713
+ } catch (error) {
1714
+ // A refusal raised by one of the thenables above surfaces here, because the
1715
+ // handler awaited it and did not catch it. Anything else is the route's own
1716
+ // failure and is left to the caller.
1717
+ return refusedInput(error)
1718
+ }
1719
+
1720
+ // A HEAD answered by GET must not carry a body.
1721
+ if (method === 'HEAD' && answer instanceof Response) {
1722
+ return new Response(null, { status: answer.status, headers: answer.headers })
1723
+ }
1724
+
1725
+ return answer
1726
+ }
1727
+
1208
1728
  const metadataMap: Record<string, { static?: any; generate?: (p: any) => any }> = {
1209
1729
  ${metaEntries.join('\n')}
1210
1730
  }
@@ -1341,11 +1861,95 @@ function ownerLayoutIndex(slotComponent: string, layouts: LayoutEntry[]): number
1341
1861
  * nothing wrong. Awaiting it still surfaces the real error.
1342
1862
  */
1343
1863
  function pageSearchParams(): Promise<URLSearchParams> {
1344
- const pending = requestSearchParams()
1864
+ // Lazy. Every page is handed this whether it reads it or not, and reading the
1865
+ // request is what marks a page as needing one — so starting it eagerly told
1866
+ // the build that every page was dynamic, and put url() beside every route in
1867
+ // the output as though someone had written it.
1868
+ //
1869
+ // A thenable rather than a promise, so nothing happens until a page awaits.
1870
+ let pending: Promise<URLSearchParams> | null = null
1345
1871
 
1346
- pending.catch(() => {})
1872
+ const start = (): Promise<URLSearchParams> => {
1873
+ if (!pending) {
1874
+ pending = requestSearchParams()
1875
+
1876
+ // Attached here for the same reason it always was: during a prerender
1877
+ // this never settles, and an unobserved rejection ends the process.
1878
+ pending.catch(() => {})
1879
+ }
1347
1880
 
1348
- return pending
1881
+ return pending
1882
+ }
1883
+
1884
+ return {
1885
+ then: (ok, fail) => start().then(ok, fail),
1886
+ catch: (fail) => start().catch(fail),
1887
+ finally: (done) => start().finally(done),
1888
+ } as Promise<URLSearchParams>
1889
+ }
1890
+
1891
+ /**
1892
+ * A page's params, through its own schema if it exported one.
1893
+ *
1894
+ * Laziness is preserved on purpose. The params promise may be one that never
1895
+ * settles - that is how the prerender probe says "not for any particular url"
1896
+ * - so this must not await eagerly. Chaining keeps a never-settling promise
1897
+ * never-settling, and the schema runs only if the page reads it.
1898
+ */
1899
+ function checkedParams(
1900
+ schemas: { params?: any } | undefined,
1901
+ params: Promise<Record<string, unknown>>,
1902
+ ): Promise<unknown> {
1903
+ if (!schemas || !schemas.params) return params
1904
+
1905
+ return params.then((value) => parseParams(schemas.params, value))
1906
+ }
1907
+
1908
+ /**
1909
+ * A page's query string, through its own schema if it exported one.
1910
+ *
1911
+ * Wrapped as a thenable rather than chained, so a page that never reads the
1912
+ * query still never starts the read - the whole thing pageSearchParams exists
1913
+ * to guarantee. Calling .then on it here would start it for every page, and
1914
+ * every page would be reported as reading the request.
1915
+ */
1916
+ function checkedSearchParams(
1917
+ schemas: { searchParams?: any } | undefined,
1918
+ search: Promise<URLSearchParams>,
1919
+ ): Promise<unknown> {
1920
+ if (!schemas || !schemas.searchParams) return search
1921
+
1922
+ let pending: Promise<unknown> | null = null
1923
+
1924
+ const start = (): Promise<unknown> => {
1925
+ if (!pending) {
1926
+ pending = search.then((value) => parseSearchParams(schemas.searchParams, value))
1927
+ pending.catch(() => {})
1928
+ }
1929
+
1930
+ return pending
1931
+ }
1932
+
1933
+ return {
1934
+ then: (ok, fail) => start().then(ok, fail),
1935
+ catch: (fail) => start().catch(fail),
1936
+ finally: (done) => start().finally(done),
1937
+ } as Promise<unknown>
1938
+ }
1939
+
1940
+ let errorChains: Record<string, string[]> | null = null
1941
+
1942
+ /** The error.tsx files above a component, outermost first. */
1943
+ function errorChain(component: string): string[] {
1944
+ if (!errorChains) {
1945
+ errorChains = {}
1946
+
1947
+ for (const route of manifest().routes as { component: string; errors?: string[] }[]) {
1948
+ if (route.errors?.length) errorChains[route.component] = route.errors
1949
+ }
1950
+ }
1951
+
1952
+ return errorChains[component] ?? []
1349
1953
  }
1350
1954
 
1351
1955
  // Composition: layout(outer..inner) > Suspense(loading, innermost-first) > page.
@@ -1373,13 +1977,39 @@ function buildElement(
1373
1977
  // and renders to completion during the probe — producing a page about an
1374
1978
  // invented value, right for nothing — which is why such a route could only
1375
1979
  // ever be rendered per request.
1376
- let element = createElement(Component, { params, searchParams: pageSearchParams() })
1980
+ const schemas = urlSchemas[component]
1981
+
1982
+ let element = createElement(Component, {
1983
+ params: checkedParams(schemas, params),
1984
+ searchParams: checkedSearchParams(schemas, pageSearchParams()),
1985
+ })
1377
1986
 
1378
1987
  for (let i = loadings.length - 1; i >= 0; i--) {
1379
1988
  const Loading = components[loadings[i]]
1380
1989
  element = createElement(Suspense, { fallback: Loading ? createElement(Loading) : null }, element)
1381
1990
  }
1382
1991
 
1992
+ // Outside the Suspense boundary, innermost first — the nearest error.tsx to
1993
+ // the failure answers, the same rule loading.tsx follows. Outside, so a
1994
+ // component that throws while its fallback is showing is still caught.
1995
+ //
1996
+ // Read from the route table rather than passed in, for the same reason the
1997
+ // middleware chain is: every render path is covered by construction, and no
1998
+ // caller has to remember to forward them.
1999
+ const errors = errorChain(component)
2000
+
2001
+ for (let i = errors.length - 1; i >= 0; i--) {
2002
+ const Fallback = components[errors[i]]
2003
+
2004
+ if (!Fallback) continue
2005
+
2006
+ element = createElement(
2007
+ RouteErrorBoundary,
2008
+ { fallback: Fallback as never, resetKey: pageKey || component },
2009
+ element,
2010
+ )
2011
+ }
2012
+
1383
2013
  // <title>/<meta> go OUTSIDE the Suspense boundaries so they reach the shell
1384
2014
  // immediately — inside, they would be withheld until the page's data
1385
2015
  // resolves, delaying the whole document on a slow page.
@@ -1481,6 +2111,27 @@ async function renderTree(
1481
2111
  const md = await resolveMetadata(component, props, layouts)
1482
2112
  const head: unknown[] = []
1483
2113
 
2114
+ // Rendered into the tree rather than written into the app's layout: React
2115
+ // hoists a link and a meta into <head> from anywhere, so this works for an
2116
+ // app that already has a layout and never asks anyone to edit one. The
2117
+ // engine knows the manifest exists; the app should not have to.
2118
+ // Icons and share images the build found in app/. Rendered here so React
2119
+ // hoists them into <head>, the same way the manifest link is — an app never
2120
+ // edits its layout to get a favicon.
2121
+ for (const [i, tag] of APP_HEAD.entries()) {
2122
+ head.push(createElement(tag.tag, { key: '__a' + i, ...tag.props }))
2123
+ }
2124
+
2125
+ if (WEB_MANIFEST) {
2126
+ head.push(createElement('link', { key: '__mf', rel: 'manifest', href: WEB_MANIFEST.href }))
2127
+
2128
+ if (WEB_MANIFEST.themeColor) {
2129
+ head.push(
2130
+ createElement('meta', { key: '__tc', name: 'theme-color', content: WEB_MANIFEST.themeColor }),
2131
+ )
2132
+ }
2133
+ }
2134
+
1484
2135
  if (md) {
1485
2136
  if (md.title != null) {
1486
2137
  // The element is what a server render puts in <head>, and what a route
@@ -1616,6 +2267,13 @@ async function runMiddleware(component: string, props: Record<string, unknown> =
1616
2267
  for (const route of manifest().routes as { component: string; middleware?: string[] }[]) {
1617
2268
  if (route.middleware?.length) middlewareChains[route.component] = route.middleware
1618
2269
  }
2270
+
2271
+ // Api routes too. They are keyed by the module name rather than a
2272
+ // component, but the chain above them is the same one the pages beside
2273
+ // them run.
2274
+ for (const api of (manifest().apis ?? []) as { name: string; middleware?: string[] }[]) {
2275
+ if (api.middleware?.length) middlewareChains[api.name] = api.middleware
2276
+ }
1619
2277
  }
1620
2278
 
1621
2279
  for (const name of middlewareChains[component] ?? []) {
@@ -1697,7 +2355,7 @@ export async function handleRscStream(
1697
2355
  * Returning undefined leaves React's own behaviour alone for everything else.
1698
2356
  */
1699
2357
  function flightOnError(error: unknown): string | undefined {
1700
- const digest = redirectDigest(error)
2358
+ const digest = redirectDigest(error) ?? notFoundDigest(error)
1701
2359
 
1702
2360
  if (digest) return digest
1703
2361
 
@@ -1898,7 +2556,11 @@ export async function handleQuery(
1898
2556
  id: string,
1899
2557
  args: string,
1900
2558
  report?: (error: unknown) => string,
1901
- ): Promise<{ stream: ReadableStream; cacheControl: string } | null> {
2559
+ ): Promise<
2560
+ | { stream: ReadableStream; cacheControl: string }
2561
+ | { status: number; message: string; errors?: Record<string, string[]> }
2562
+ | null
2563
+ > {
1902
2564
  applyHost()
1903
2565
 
1904
2566
  let fn: unknown
@@ -1915,7 +2577,22 @@ export async function handleQuery(
1915
2577
  if (!isQuery(fn)) return null
1916
2578
 
1917
2579
  const decoded = (await decodeReply(args)) as unknown[]
1918
- const result = await (fn as (...a: unknown[]) => unknown)(...decoded)
2580
+
2581
+ // Awaited here rather than handed to the renderer as a promise, so a refusal
2582
+ // is still a status line rather than an error row inside a 200. React strips
2583
+ // a thrown message in production, so a query that rejected mid-stream would
2584
+ // reach the browser as "an error occurred" with the fields gone.
2585
+ let result: unknown
2586
+
2587
+ try {
2588
+ result = await (fn as (...a: unknown[]) => unknown)(...decoded)
2589
+ } catch (error) {
2590
+ if (isQueryValidationError(error)) {
2591
+ return { status: 422, errors: error.errors, message: error.message }
2592
+ }
2593
+
2594
+ return { status: 500, message: report ? report(error) : 'Query failed.' }
2595
+ }
1919
2596
 
1920
2597
  return {
1921
2598
  stream: renderToReadableStream(result, {
@@ -2079,7 +2756,7 @@ export async function handleRsc(
2079
2756
  pageKey = '',
2080
2757
  bootstrap = true,
2081
2758
  canReachHost = true,
2082
- ): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; clientComponents: string[] }> {
2759
+ ): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; dynamicBecause: string[]; clientComponents: string[] }> {
2083
2760
  applyHost()
2084
2761
 
2085
2762
  // A build renders this with no host installed, so every rpc() has to suspend
@@ -2090,10 +2767,18 @@ export async function handleRsc(
2090
2767
  // Defaults to true because the other caller is an interception, which runs
2091
2768
  // at request time with a real host and must not be probed.
2092
2769
  let usedDynamicApis = false
2770
+ const hostCalls: string[] = []
2093
2771
 
2094
- const probe = (..._args: unknown[]) => {
2772
+ const probe = (...args: unknown[]) => {
2095
2773
  usedDynamicApis = true
2096
2774
 
2775
+ // The name it was called with, so the build can say rpc("getUser") rather
2776
+ // than "this page reached for the host" and leave you to find which call.
2777
+ const name = typeof args[0] === 'string' ? args[0] : null
2778
+ const said = name ? 'rpc(' + JSON.stringify(name) + ')' : 'rpc()'
2779
+
2780
+ if (!hostCalls.includes(said)) hostCalls.push(said)
2781
+
2097
2782
  return new Promise<never>(() => {})
2098
2783
  }
2099
2784
 
@@ -2409,12 +3094,48 @@ export default async function handler(request: Request): Promise<Response> {
2409
3094
  handleRscResume,
2410
3095
  handleAction,
2411
3096
  handleQuery,
3097
+ handleApiRoute,
2412
3098
  resolveMetadata,
2413
3099
  runRouteMiddleware,
2414
3100
  } as never,
2415
3101
  ${NITRO_HANDLER_OPTIONS}${NITRO_PRERENDERED} })
2416
3102
 
2417
- ${fallbackOrigin ? FALLBACK_BODY : " return (await devHandler(request)) ?? new Response('Not found', { status: 404 })\n"}}
3103
+ ${fallbackOrigin ? FALLBACK_BODY : " return (await devHandler(request)) ?? (await notFound())\n"}}
3104
+
3105
+ /**
3106
+ * The page for a url nothing answers.
3107
+ *
3108
+ * Rendered through its layout chain like any other page, so the 404 a visitor
3109
+ * sees is the app rather than a bare string — and returned with a 404, because
3110
+ * a page that says "not found" under a 200 is a page search engines index.
3111
+ *
3112
+ * Without a not-found.tsx this is the string it always was.
3113
+ */
3114
+ async function notFound(): Promise<Response> {
3115
+ ${notFoundComponent ? `
3116
+ try {
3117
+ const { htmlStream } = await handleRscHtmlStream(
3118
+ ${JSON.stringify(notFoundComponent)},
3119
+ {},
3120
+ ${JSON.stringify(notFoundLayouts.map((component) => ({ component, props: {} })))},
3121
+ [],
3122
+ {},
3123
+ {},
3124
+ undefined,
3125
+ '/404',
3126
+ )
3127
+
3128
+ return new Response(htmlStream, {
3129
+ status: 404,
3130
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
3131
+ })
3132
+ } catch {
3133
+ // A 404 page that throws is still a 404. Falling back rather than
3134
+ // answering 500 keeps the status honest about what happened.
3135
+ }
3136
+ ` : ''}
3137
+ return new Response('Not found', { status: 404 })
3138
+ }
2418
3139
  `;
2419
3140
  }
2420
3141
  function generateEntrySsr() {
@@ -2695,6 +3416,27 @@ function hasLoadingInChain(pageDir) {
2695
3416
  * blank screen. A page whose slow work lives in children behind their own
2696
3417
  * <Suspense> already paints a shell and needs nothing.
2697
3418
  */
3419
+ /**
3420
+ * An `error.tsx` has to be a client component.
3421
+ *
3422
+ * It is rendered inside a React error boundary, which is a class component in
3423
+ * the browser, and it is handed a `reset` callback to call. A server component
3424
+ * can be neither. Caught here rather than at runtime, where the symptom is a
3425
+ * boundary that renders nothing while the error it was written for goes to the
3426
+ * console.
3427
+ */
3428
+ function validateErrorBoundaries() {
3429
+ const wrong = [];
3430
+ for (const c of components.values()) {
3431
+ if (!c.name.endsWith('/error'))
3432
+ continue;
3433
+ const source = readFileSync(c.absPath, 'utf-8');
3434
+ if (!/^\s*['"]use client['"]/m.test(source)) {
3435
+ wrong.push(` ${relative(projectRoot, c.absPath)}`);
3436
+ }
3437
+ }
3438
+ return wrong;
3439
+ }
2698
3440
  function validateLoadingBoundaries() {
2699
3441
  const errors = [];
2700
3442
  for (const c of components.values()) {
@@ -2748,6 +3490,13 @@ export function rscKit(options = {}) {
2748
3490
  throw new Error(`[rsc-kit] ${message}`);
2749
3491
  log(message);
2750
3492
  }
3493
+ const notClient = validateErrorBoundaries();
3494
+ if (notClient.length) {
3495
+ throw new Error('[rsc-kit] An error.tsx must be a client component.\n\n' +
3496
+ notClient.join('\n') +
3497
+ "\n\nAdd 'use client' at the top. It is rendered inside an error boundary and is\n" +
3498
+ 'handed a reset() callback to call, neither of which a server component can do.');
3499
+ }
2751
3500
  const loadingErrors = validateLoadingBoundaries();
2752
3501
  if (loadingErrors.length) {
2753
3502
  throw new Error('[rsc-kit] A page that blocks before it can paint needs a loading.tsx boundary.\n\n' +
@@ -3032,9 +3781,16 @@ export function rscKit(options = {}) {
3032
3781
  const staticDir = clientOut
3033
3782
  ? join(dirname(clientOut), 'server', NITRO_STATIC_DIR)
3034
3783
  : join(outDir, NITRO_STATIC_DIR);
3035
- await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir);
3784
+ const frozen = await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir);
3785
+ // Manifest first. The service worker precaches whatever it finds in this
3786
+ // directory, so writing it afterwards leaves it out of the list — and an
3787
+ // installed app whose manifest is the one file that needs the network is
3788
+ // the wrong way round.
3789
+ copyAppAssets(clientOut ?? publicAssetsDir);
3790
+ if (webManifestOptions)
3791
+ writeWebManifest(clientOut ?? publicAssetsDir, webManifestOptions);
3036
3792
  if (offline)
3037
- writeServiceWorker(clientOut ?? publicAssetsDir);
3793
+ writeServiceWorker(clientOut ?? publicAssetsDir, frozen);
3038
3794
  },
3039
3795
  configResolved(config) {
3040
3796
  isWatch = config.build?.watch != null;
@@ -3059,7 +3815,25 @@ export function rscKit(options = {}) {
3059
3815
  // With Nitro, the server handler is Nitro's — it takes the rsc entry's
3060
3816
  // default export and builds the server around it. Leaving plugin-rsc's own
3061
3817
  // handler in place means two things claiming the same role.
3062
- return [appPluginRsc({ serverHandler: false }), routesPlugin];
3818
+ return [
3819
+ appPluginRsc({
3820
+ serverHandler: false,
3821
+ // One chunk per client component, rather than one for the whole app.
3822
+ //
3823
+ // plugin-rsc already loads a client reference with `await import()`, so
3824
+ // the browser only fetches what a page actually renders — but its default
3825
+ // groups every reference by the SERVER chunk that proxies it, and this
3826
+ // package builds the server as a single chunk. So every client component
3827
+ // in an app landed in one group, and a page with a button pulled in the
3828
+ // editor, the chart and the map that live on other routes.
3829
+ //
3830
+ // Grouping by the component's own module restores the split the dynamic
3831
+ // import was there to make use of.
3832
+ clientChunks: (meta) => meta.normalizedId,
3833
+ ...actionEncryptionKey(),
3834
+ }),
3835
+ routesPlugin,
3836
+ ];
3063
3837
  }
3064
3838
  /**
3065
3839
  * @vitejs/plugin-rsc, resolved from the app rather than from here.
@@ -3078,6 +3852,36 @@ export function rscKit(options = {}) {
3078
3852
  * Resolving from the project root gets the app's copy, whose own `vite` import
3079
3853
  * then resolves to the app's Vite as well — one pair, and the check passes.
3080
3854
  */
3855
+ /**
3856
+ * Where the key that encrypts bound action arguments comes from.
3857
+ *
3858
+ * A server action can close over server-side values, and React sends those to
3859
+ * the browser encrypted so the page cannot read them. The process that decrypts
3860
+ * them on the way back has to hold the same key.
3861
+ *
3862
+ * By default plugin-rsc generates one per build and bakes it in. Every instance
3863
+ * of one build therefore agrees, and the only exposure is a deploy: a browser
3864
+ * holding a page from the old build calls an action on the new one, and the
3865
+ * key has changed underneath it. The call fails with nothing useful in it.
3866
+ *
3867
+ * Setting RSC_ACTION_ENCRYPTION_KEY makes the key outlive the build and closes
3868
+ * that window. It is read at RUNTIME, not baked in, so the same artifact can be
3869
+ * deployed anywhere — but it must then be set everywhere the app runs, and set
3870
+ * to the same value. Half-configured is worse than unconfigured: instances
3871
+ * would disagree, and the failure looks like an intermittently broken action.
3872
+ *
3873
+ * Unset, the build-time key is used and nothing changes. That is the default
3874
+ * because it is the one that cannot be got half right.
3875
+ */
3876
+ function actionEncryptionKey() {
3877
+ if (!process.env.RSC_ACTION_ENCRYPTION_KEY)
3878
+ return {};
3879
+ // An expression, not a value: plugin-rsc substitutes this source text where
3880
+ // the key is read, so what ships is the lookup rather than the secret. A
3881
+ // literal here would put the key in the bundle, which is the thing being
3882
+ // avoided.
3883
+ return { defineEncryptionKey: 'process.env.RSC_ACTION_ENCRYPTION_KEY' };
3884
+ }
3081
3885
  async function appPluginRsc(options = {}) {
3082
3886
  try {
3083
3887
  // Resolved against a file *in* the root, since a directory specifier