@rsc-kit/core 0.14.0 → 0.16.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 +11 -1
- package/dist/action.js +19 -3
- package/dist/action.js.map +1 -1
- package/dist/buildReport.d.ts +16 -2
- package/dist/buildReport.js +6 -3
- package/dist/buildReport.js.map +1 -1
- package/dist/js/Link.d.ts +11 -4
- package/dist/js/Link.js +5 -1
- package/dist/js/Link.js.map +1 -1
- package/dist/manifest.d.ts +6 -1
- package/dist/manifest.js.map +1 -1
- package/dist/prerender.d.ts +13 -0
- package/dist/prerender.js +75 -12
- package/dist/prerender.js.map +1 -1
- package/dist/routes.d.ts +79 -0
- package/dist/routes.js +41 -0
- package/dist/routes.js.map +1 -1
- package/dist/vite.d.ts +6 -0
- package/dist/vite.js +184 -33
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
package/dist/vite.js
CHANGED
|
@@ -18,7 +18,7 @@ import { createHash } from 'node:crypto';
|
|
|
18
18
|
import { createRequire } from 'node:module';
|
|
19
19
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
20
20
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
21
|
-
import rsc from '@vitejs/plugin-rsc';
|
|
21
|
+
import rsc, { getPluginApi } from '@vitejs/plugin-rsc';
|
|
22
22
|
import { loadEnv } from 'vite';
|
|
23
23
|
import { REPORT_FILE, buildReport } from './buildReport.js';
|
|
24
24
|
import { MANIFEST_PATH, manifestWarning, webManifest } from './webManifest.js';
|
|
@@ -1009,7 +1009,37 @@ function resolveRscBundle(dir) {
|
|
|
1009
1009
|
}
|
|
1010
1010
|
return null;
|
|
1011
1011
|
}
|
|
1012
|
-
|
|
1012
|
+
/**
|
|
1013
|
+
* Every server action the bundle registered, from the plugin's own table.
|
|
1014
|
+
*
|
|
1015
|
+
* Only the app's: the engine registers a few of its own and they are not the
|
|
1016
|
+
* project's to be warned about.
|
|
1017
|
+
*/
|
|
1018
|
+
function knownActionsOf(config, root) {
|
|
1019
|
+
const api = getPluginApi(config);
|
|
1020
|
+
const metaMap = api?.manager?.serverReferences?.metaMap;
|
|
1021
|
+
if (!metaMap)
|
|
1022
|
+
return [];
|
|
1023
|
+
const out = [];
|
|
1024
|
+
for (const meta of metaMap.values()) {
|
|
1025
|
+
if (meta.importId.includes('/node_modules/'))
|
|
1026
|
+
continue;
|
|
1027
|
+
const file = relative(root, meta.importId.split('?')[0] ?? meta.importId);
|
|
1028
|
+
for (const name of meta.exportNames) {
|
|
1029
|
+
out.push({ id: `${meta.referenceKey}#${name}`, name, file });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return out;
|
|
1033
|
+
}
|
|
1034
|
+
async function auditActions(engine, known) {
|
|
1035
|
+
const audit = engine
|
|
1036
|
+
.auditActions;
|
|
1037
|
+
if (!audit || known.length === 0)
|
|
1038
|
+
return [];
|
|
1039
|
+
const byId = new Map(known.map((k) => [k.id, k]));
|
|
1040
|
+
return (await audit(known.map((k) => k.id))).map((a) => ({ ...byId.get(a.id), client: a.client, query: a.query }));
|
|
1041
|
+
}
|
|
1042
|
+
async function prerenderAfterBundles(bundle, staticDir, assetsDir, knownActions = []) {
|
|
1013
1043
|
// A missing bundle used to be a silent `return`, and under Nitro it was the
|
|
1014
1044
|
// normal case: the path was assumed to be <outDir>/dist/rsc/index.js, Nitro
|
|
1015
1045
|
// builds the rsc environment somewhere else entirely, and so every Nitro app
|
|
@@ -1022,7 +1052,7 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
|
|
|
1022
1052
|
'Prerendering renders the app, so it needs the bundle the build just wrote. ' +
|
|
1023
1053
|
'Build with prerender: false to render every page on demand instead.');
|
|
1024
1054
|
}
|
|
1025
|
-
const [{ prerender, summary, legend, notes, clientJsSize, pathKey: pathKeyOf }, { writeTo }, { prerenderApiRoutes },] = await Promise.all([
|
|
1055
|
+
const [{ prerender, NotPrerenderable, summary, legend, notes, clientJsSize, pathKey: pathKeyOf }, { writeTo }, { prerenderApiRoutes },] = await Promise.all([
|
|
1026
1056
|
import('./prerender.js'),
|
|
1027
1057
|
import('./files.js'),
|
|
1028
1058
|
import('./apiPrerender.js'),
|
|
@@ -1032,36 +1062,58 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
|
|
|
1032
1062
|
// Nothing warns — the page loads, with content from the previous build.
|
|
1033
1063
|
rmSync(staticDir, { recursive: true, force: true });
|
|
1034
1064
|
const engine = (await import(pathToFileURL(bundle).href));
|
|
1035
|
-
const mark = { frozen: '○', shell: '◐', blocked: '
|
|
1065
|
+
const mark = { frozen: '○', shell: '◐', blocked: '✗', error: '✗' };
|
|
1036
1066
|
let failed = 0;
|
|
1037
1067
|
// Weighed from the page the prerenderer just wrote, so the column is what
|
|
1038
1068
|
// that page actually loads rather than a total every route is charged for.
|
|
1039
1069
|
const weigh = weighClientJs(assetsDir);
|
|
1040
1070
|
const sized = new Map();
|
|
1041
1071
|
const pending = [];
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1072
|
+
// Every result as it lands, so a refusal still has the whole table.
|
|
1073
|
+
//
|
|
1074
|
+
// prerender() throws NotPrerenderable when a page blocked above every
|
|
1075
|
+
// boundary, and it used to throw past everything below: no table printed,
|
|
1076
|
+
// no report written. The terminal got the advice; the report on disk was
|
|
1077
|
+
// the previous build's, and an agent reading it through the MCP server was
|
|
1078
|
+
// told the routes were fine, as of some minutes ago. A build that refuses
|
|
1079
|
+
// is the build an agent most needs written down.
|
|
1080
|
+
const collected = [];
|
|
1081
|
+
let refusal = null;
|
|
1082
|
+
let results;
|
|
1083
|
+
try {
|
|
1084
|
+
results = await prerender({
|
|
1085
|
+
engine,
|
|
1086
|
+
write: writeTo(staticDir),
|
|
1087
|
+
serviceWorker: offline,
|
|
1088
|
+
onResult: (r) => {
|
|
1089
|
+
collected.push(r);
|
|
1090
|
+
if (r.type === 'error' || r.type === 'blocked')
|
|
1091
|
+
failed++;
|
|
1092
|
+
const key = pathKeyOf(r.url);
|
|
1093
|
+
const file = [`${key}.html`, `${key}.ppr.html`]
|
|
1094
|
+
.map((name) => join(staticDir, name))
|
|
1095
|
+
.find((path) => existsSync(path));
|
|
1096
|
+
const bytes = file ? weigh(readFileSync(file, 'utf-8')) : null;
|
|
1097
|
+
if (bytes !== null)
|
|
1098
|
+
sized.set(r.url, bytes);
|
|
1099
|
+
pending.push({
|
|
1100
|
+
line: ` ${mark[r.type] ?? ' '} ${r.url}`,
|
|
1101
|
+
bytes,
|
|
1102
|
+
extra: [
|
|
1103
|
+
...(r.reason ? [` ${r.reason}`] : []),
|
|
1104
|
+
...(r.note ? [` ${r.note}`] : []),
|
|
1105
|
+
...(r.warning ? [` ⚠ ${r.warning}`] : []),
|
|
1106
|
+
],
|
|
1107
|
+
});
|
|
1108
|
+
},
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
catch (error) {
|
|
1112
|
+
if (!(error instanceof NotPrerenderable))
|
|
1113
|
+
throw error;
|
|
1114
|
+
refusal = error;
|
|
1115
|
+
results = collected;
|
|
1116
|
+
}
|
|
1065
1117
|
// After the pages, sharing their output. An api route is a url the build
|
|
1066
1118
|
// either answered or could not, which is the same question the table above
|
|
1067
1119
|
// is already answering — a second list under its own heading would be two
|
|
@@ -1085,6 +1137,21 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
|
|
|
1085
1137
|
console.log(line);
|
|
1086
1138
|
}
|
|
1087
1139
|
const count = (type) => results.filter((r) => r.type === type).length;
|
|
1140
|
+
// The actions, after the routes. Which ones a client built is a mark on the
|
|
1141
|
+
// loaded function, so the bundle is asked; the answer is the one fact about
|
|
1142
|
+
// an action nothing else in the app states — whether anything checks who
|
|
1143
|
+
// calls it.
|
|
1144
|
+
const audited = await auditActions(engine, knownActions);
|
|
1145
|
+
const bare = audited.filter((a) => !a.client);
|
|
1146
|
+
if (bare.length > 0) {
|
|
1147
|
+
const byFile = new Map();
|
|
1148
|
+
for (const a of bare)
|
|
1149
|
+
byFile.set(a.file, [...(byFile.get(a.file) ?? []), a.name]);
|
|
1150
|
+
console.log(`\n \u26a0 ${bare.length} ${bare.length === 1 ? 'action runs' : 'actions run'} no middleware: ` +
|
|
1151
|
+
[...byFile].map(([file, names]) => `${names.join(', ')} (${file})`).join('; '));
|
|
1152
|
+
console.log(' Nothing checks who calls them. Fine for a public one; otherwise build it\n' +
|
|
1153
|
+
' from an action client, so the check cannot be forgotten.');
|
|
1154
|
+
}
|
|
1088
1155
|
// Written from the rows that were just printed rather than recomputed: the
|
|
1089
1156
|
// report and the terminal must not be able to disagree about what happened.
|
|
1090
1157
|
writeFileSync(join(outDir, REPORT_FILE), buildReport(results.map((r) => ({
|
|
@@ -1093,14 +1160,18 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
|
|
|
1093
1160
|
type: r.type,
|
|
1094
1161
|
reason: r.reason,
|
|
1095
1162
|
warning: r.warning ?? null,
|
|
1163
|
+
note: r.note ?? null,
|
|
1096
1164
|
clientJs: sized.get(r.url) ?? null,
|
|
1097
|
-
})), apis.map((a) => ({ url: a.url, name: a.name, type: a.type, reason: a.reason }))));
|
|
1165
|
+
})), apis.map((a) => ({ url: a.url, name: a.name, type: a.type, reason: a.reason })), audited));
|
|
1098
1166
|
const note = notes(results);
|
|
1099
1167
|
const counted = [...results, ...apis];
|
|
1100
1168
|
console.log(`
|
|
1101
1169
|
${legend(counted)}
|
|
1102
1170
|
|
|
1103
1171
|
${summary(counted)}${note ? `\n\n${note}` : ''}`);
|
|
1172
|
+
// The table and the report are on disk. Now the refusal, in its own words.
|
|
1173
|
+
if (refusal)
|
|
1174
|
+
throw new Error(refusal.message);
|
|
1104
1175
|
if (failed > 0) {
|
|
1105
1176
|
throw new Error(`[rsc-kit] ${failed} route${failed === 1 ? '' : 's'} failed to render.\n` +
|
|
1106
1177
|
'Prerendering runs your app: whatever those pages need at render time has to be\n' +
|
|
@@ -1241,6 +1312,19 @@ function patternOf(segments) {
|
|
|
1241
1312
|
function renderRouteTypes(manifest) {
|
|
1242
1313
|
const patterns = [...new Set(manifest.routes.map((route) => patternOf(route.segments)))].sort();
|
|
1243
1314
|
const apis = [...new Set((manifest.apis ?? []).map((route) => patternOf(route.segments)))].sort();
|
|
1315
|
+
// Each pattern to the page module that answers it, as a type-only import
|
|
1316
|
+
// from the generated file's own directory. SearchExportOf reads the page's
|
|
1317
|
+
// `searchParams` export, or undefined when there is none, so nothing here
|
|
1318
|
+
// has to look inside the file - the typechecker already has every page
|
|
1319
|
+
// open. One line per route, and a page without a schema costs nothing.
|
|
1320
|
+
const search = new Map();
|
|
1321
|
+
for (const route of manifest.routes) {
|
|
1322
|
+
const pattern = patternOf(route.segments);
|
|
1323
|
+
if (search.has(pattern))
|
|
1324
|
+
continue;
|
|
1325
|
+
const target = relative(typesDir, join(sourceDir, route.component)).replace(/\\/g, '/');
|
|
1326
|
+
search.set(pattern, target.startsWith('.') ? target : './' + target);
|
|
1327
|
+
}
|
|
1244
1328
|
return [
|
|
1245
1329
|
'// @generated — do not edit. Written by the RSC build from the route tree.',
|
|
1246
1330
|
'//',
|
|
@@ -1258,6 +1342,11 @@ function renderRouteTypes(manifest) {
|
|
|
1258
1342
|
patterns.length > 0
|
|
1259
1343
|
? ' routes:\n' + patterns.map((p) => ' | ' + JSON.stringify(p)).join('\n')
|
|
1260
1344
|
: ' // No routes found under the source directory.\n routes: never',
|
|
1345
|
+
// The searchParams schema each page exports, read off the module's type.
|
|
1346
|
+
// This is what types Link's `search` prop and href() per route.
|
|
1347
|
+
' search: {',
|
|
1348
|
+
...[...search].sort().map(([pattern, target]) => ' ' + JSON.stringify(pattern) + ': SearchExportOf<typeof import(' + JSON.stringify(target) + ')>'),
|
|
1349
|
+
' }',
|
|
1261
1350
|
' }',
|
|
1262
1351
|
// Api routes are a separate union, so Link refuses an api url and apiUrl()
|
|
1263
1352
|
// refuses a page. Linking to an api route navigates the browser away to a
|
|
@@ -1419,7 +1508,8 @@ function metadataExports(absPath) {
|
|
|
1419
1508
|
*/
|
|
1420
1509
|
function shipsClientJs(absPath) {
|
|
1421
1510
|
const src = readFileSync(absPath, 'utf-8');
|
|
1422
|
-
|
|
1511
|
+
const declared = /export\s+const\s+clientJs\s*(:[^=]+)?=\s*(true|false)/.exec(src);
|
|
1512
|
+
return declared ? declared[2] === 'true' : 'auto';
|
|
1423
1513
|
}
|
|
1424
1514
|
/**
|
|
1425
1515
|
* Which urls exist for a parameterised route.
|
|
@@ -1679,6 +1769,7 @@ import { httpHostCalls } from ${JSON.stringify(join(packageDir, 'hostCalls'))}
|
|
|
1679
1769
|
import { prerenderedBeside } from ${JSON.stringify(join(packageDir, 'files'))}
|
|
1680
1770
|
import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
|
|
1681
1771
|
import { isQuery, queryCacheControl, isQueryValidationError } from ${JSON.stringify(join(packageDir, 'query'))}
|
|
1772
|
+
import { isActionValidationError, isClientBuilt } from ${JSON.stringify(join(packageDir, 'action'))}
|
|
1682
1773
|
import { Suspense, createElement, Fragment } from 'react'
|
|
1683
1774
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
1684
1775
|
${imports.join('\n')}
|
|
@@ -1872,6 +1963,31 @@ export function manifest(): any {
|
|
|
1872
1963
|
return ${JSON.stringify(routeManifest())}
|
|
1873
1964
|
}
|
|
1874
1965
|
|
|
1966
|
+
/**
|
|
1967
|
+
* For each server action id, whether a client built it. The build asks after
|
|
1968
|
+
* the bundle exists, because the answer is a mark on the loaded function and
|
|
1969
|
+
* nothing static could tell a wrapped export from a bare one.
|
|
1970
|
+
*/
|
|
1971
|
+
export async function auditActions(ids: string[]): Promise<{ id: string; client: boolean; query: boolean }[]> {
|
|
1972
|
+
const out: { id: string; client: boolean; query: boolean }[] = []
|
|
1973
|
+
|
|
1974
|
+
for (const id of ids) {
|
|
1975
|
+
let fn: unknown = null
|
|
1976
|
+
|
|
1977
|
+
try {
|
|
1978
|
+
fn = await loadServerAction(id)
|
|
1979
|
+
} catch {
|
|
1980
|
+
continue
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
if (typeof fn !== 'function') continue
|
|
1984
|
+
|
|
1985
|
+
out.push({ id, client: isClientBuilt(fn), query: isQuery(fn) })
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
return out
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1875
1991
|
/**
|
|
1876
1992
|
* The param sets a route declares, or null when it declares none.
|
|
1877
1993
|
*
|
|
@@ -2914,7 +3030,20 @@ export async function handleAction(
|
|
|
2914
3030
|
)
|
|
2915
3031
|
}
|
|
2916
3032
|
|
|
2917
|
-
|
|
3033
|
+
// A plain "use server" function that threw fieldErrors() gets the same
|
|
3034
|
+
// treatment createActionClient gives its handlers: the throw becomes the
|
|
3035
|
+
// returned { validationErrors } that <Form> reads. Left thrown, React
|
|
3036
|
+
// serialises the rejection opaquely — production strips the message — and the
|
|
3037
|
+
// fields it named never reach the browser.
|
|
3038
|
+
let result: unknown
|
|
3039
|
+
|
|
3040
|
+
try {
|
|
3041
|
+
result = await (action as (...a: unknown[]) => unknown)(...args)
|
|
3042
|
+
} catch (error) {
|
|
3043
|
+
if (!isActionValidationError(error)) throw error
|
|
3044
|
+
|
|
3045
|
+
result = { validationErrors: error.errors }
|
|
3046
|
+
}
|
|
2918
3047
|
|
|
2919
3048
|
// Read after the action has run: what it invalidated is only known once its
|
|
2920
3049
|
// host calls have been made. Rendering here rather than telling the browser
|
|
@@ -3009,7 +3138,7 @@ export async function handleRsc(
|
|
|
3009
3138
|
pageKey = '',
|
|
3010
3139
|
bootstrap = true,
|
|
3011
3140
|
canReachHost = true,
|
|
3012
|
-
): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; dynamicBecause: string[]; clientComponents: string[] }> {
|
|
3141
|
+
): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; dynamicBecause: string[]; clientComponents: string[]; serverReferences: boolean }> {
|
|
3013
3142
|
applyHost()
|
|
3014
3143
|
|
|
3015
3144
|
// A build renders this with no host installed, so every rpc() has to suspend
|
|
@@ -3064,6 +3193,7 @@ export async function handleRsc(
|
|
|
3064
3193
|
// says which components forced the decision, since they are usually in a
|
|
3065
3194
|
// shared layout rather than the page itself.
|
|
3066
3195
|
clientComponents: clientReferenceNames(rscPayload),
|
|
3196
|
+
serverReferences: hasServerReference(rscPayload),
|
|
3067
3197
|
}
|
|
3068
3198
|
}
|
|
3069
3199
|
|
|
@@ -3077,14 +3207,35 @@ export async function handleRsc(
|
|
|
3077
3207
|
function clientReferenceNames(payload: string): string[] {
|
|
3078
3208
|
const names = new Set<string>()
|
|
3079
3209
|
|
|
3210
|
+
// A string React has already sent is referenced by row - "$1" for the
|
|
3211
|
+
// string in row 1 - rather than repeated. The export name of a client
|
|
3212
|
+
// reference is exactly the kind of string that repeats, so it arrives that
|
|
3213
|
+
// way and has to be looked up, or the refusal names a component "$1".
|
|
3214
|
+
const strings = new Map<string, string>()
|
|
3215
|
+
|
|
3216
|
+
for (const line of payload.split('\\n')) {
|
|
3217
|
+
const match = /^(\\d+):"(.*)"$/.exec(line)
|
|
3218
|
+
if (match) strings.set('$' + match[1], match[2]!)
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3080
3221
|
for (const row of payload.split(':I[').slice(1)) {
|
|
3081
3222
|
const name = row.split('"')[3]
|
|
3082
|
-
if (name) names.add(name)
|
|
3223
|
+
if (name) names.add(strings.get(name) ?? name)
|
|
3083
3224
|
}
|
|
3084
3225
|
|
|
3085
3226
|
return [...names]
|
|
3086
3227
|
}
|
|
3087
3228
|
|
|
3229
|
+
/**
|
|
3230
|
+
* Whether the payload carries a server reference - an action handed to a
|
|
3231
|
+
* form or a client component. A row of {"id":"...","bound":...} is one,
|
|
3232
|
+
* however it is pointed at ($F for a function prop, $h for a form action).
|
|
3233
|
+
* A page with one needs the runtime to submit it.
|
|
3234
|
+
*/
|
|
3235
|
+
export function hasServerReference(payload: string): boolean {
|
|
3236
|
+
return /^\\d+:\\{"id":"[^"]+","bound":/m.test(payload)
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3088
3239
|
// Flight payload only (worker: rsc-payload — build-time).
|
|
3089
3240
|
//
|
|
3090
3241
|
// The segment variant of a prerendered route needs the payload and nothing
|
|
@@ -4043,7 +4194,7 @@ export function rscKit(options = {}) {
|
|
|
4043
4194
|
const staticDir = clientOut
|
|
4044
4195
|
? join(dirname(clientOut), 'server', NITRO_STATIC_DIR)
|
|
4045
4196
|
: join(outDir, NITRO_STATIC_DIR);
|
|
4046
|
-
const { frozen, results } = await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir);
|
|
4197
|
+
const { frozen, results } = await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir, builder ? knownActionsOf(builder.config, projectRoot) : []);
|
|
4047
4198
|
// Manifest first. The service worker precaches whatever it finds in this
|
|
4048
4199
|
// directory, so writing it afterwards leaves it out of the list — and an
|
|
4049
4200
|
// installed app whose manifest is the one file that needs the network is
|