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