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