@rsc-kit/core 0.13.1 → 0.15.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 +36 -4
- package/dist/action.js +31 -5
- package/dist/action.js.map +1 -1
- package/dist/buildReport.d.ts +14 -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/js/nuqs.d.ts +4 -0
- package/dist/js/nuqs.js +64 -0
- package/dist/js/nuqs.js.map +1 -0
- package/dist/js/useSearchParams.js +11 -4
- package/dist/js/useSearchParams.js.map +1 -1
- package/dist/metadata.d.ts +58 -0
- package/dist/metadata.js.map +1 -1
- package/dist/prerender.d.ts +1 -0
- package/dist/prerender.js +29 -10
- 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/testing.d.ts +29 -0
- package/dist/testing.js +102 -0
- package/dist/testing.js.map +1 -0
- package/dist/vite.d.ts +6 -0
- package/dist/vite.js +296 -36
- package/dist/vite.js.map +1 -1
- package/package.json +16 -3
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,56 @@ 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
|
+
onResult: (r) => {
|
|
1088
|
+
collected.push(r);
|
|
1089
|
+
if (r.type === 'error' || r.type === 'blocked')
|
|
1090
|
+
failed++;
|
|
1091
|
+
const key = pathKeyOf(r.url);
|
|
1092
|
+
const file = [`${key}.html`, `${key}.ppr.html`]
|
|
1093
|
+
.map((name) => join(staticDir, name))
|
|
1094
|
+
.find((path) => existsSync(path));
|
|
1095
|
+
const bytes = file ? weigh(readFileSync(file, 'utf-8')) : null;
|
|
1096
|
+
if (bytes !== null)
|
|
1097
|
+
sized.set(r.url, bytes);
|
|
1098
|
+
pending.push({
|
|
1099
|
+
line: ` ${mark[r.type] ?? ' '} ${r.url}`,
|
|
1100
|
+
bytes,
|
|
1101
|
+
extra: [
|
|
1102
|
+
...(r.reason ? [` ${r.reason}`] : []),
|
|
1103
|
+
...(r.warning ? [` ⚠ ${r.warning}`] : []),
|
|
1104
|
+
],
|
|
1105
|
+
});
|
|
1106
|
+
},
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
catch (error) {
|
|
1110
|
+
if (!(error instanceof NotPrerenderable))
|
|
1111
|
+
throw error;
|
|
1112
|
+
refusal = error;
|
|
1113
|
+
results = collected;
|
|
1114
|
+
}
|
|
1065
1115
|
// After the pages, sharing their output. An api route is a url the build
|
|
1066
1116
|
// either answered or could not, which is the same question the table above
|
|
1067
1117
|
// is already answering — a second list under its own heading would be two
|
|
@@ -1085,6 +1135,21 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
|
|
|
1085
1135
|
console.log(line);
|
|
1086
1136
|
}
|
|
1087
1137
|
const count = (type) => results.filter((r) => r.type === type).length;
|
|
1138
|
+
// The actions, after the routes. Which ones a client built is a mark on the
|
|
1139
|
+
// loaded function, so the bundle is asked; the answer is the one fact about
|
|
1140
|
+
// an action nothing else in the app states — whether anything checks who
|
|
1141
|
+
// calls it.
|
|
1142
|
+
const audited = await auditActions(engine, knownActions);
|
|
1143
|
+
const bare = audited.filter((a) => !a.client);
|
|
1144
|
+
if (bare.length > 0) {
|
|
1145
|
+
const byFile = new Map();
|
|
1146
|
+
for (const a of bare)
|
|
1147
|
+
byFile.set(a.file, [...(byFile.get(a.file) ?? []), a.name]);
|
|
1148
|
+
console.log(`\n \u26a0 ${bare.length} ${bare.length === 1 ? 'action runs' : 'actions run'} no middleware: ` +
|
|
1149
|
+
[...byFile].map(([file, names]) => `${names.join(', ')} (${file})`).join('; '));
|
|
1150
|
+
console.log(' Nothing checks who calls them. Fine for a public one; otherwise build it\n' +
|
|
1151
|
+
' from an action client, so the check cannot be forgotten.');
|
|
1152
|
+
}
|
|
1088
1153
|
// Written from the rows that were just printed rather than recomputed: the
|
|
1089
1154
|
// report and the terminal must not be able to disagree about what happened.
|
|
1090
1155
|
writeFileSync(join(outDir, REPORT_FILE), buildReport(results.map((r) => ({
|
|
@@ -1094,13 +1159,16 @@ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
|
|
|
1094
1159
|
reason: r.reason,
|
|
1095
1160
|
warning: r.warning ?? null,
|
|
1096
1161
|
clientJs: sized.get(r.url) ?? null,
|
|
1097
|
-
})), apis.map((a) => ({ url: a.url, name: a.name, type: a.type, reason: a.reason }))));
|
|
1162
|
+
})), apis.map((a) => ({ url: a.url, name: a.name, type: a.type, reason: a.reason })), audited));
|
|
1098
1163
|
const note = notes(results);
|
|
1099
1164
|
const counted = [...results, ...apis];
|
|
1100
1165
|
console.log(`
|
|
1101
1166
|
${legend(counted)}
|
|
1102
1167
|
|
|
1103
1168
|
${summary(counted)}${note ? `\n\n${note}` : ''}`);
|
|
1169
|
+
// The table and the report are on disk. Now the refusal, in its own words.
|
|
1170
|
+
if (refusal)
|
|
1171
|
+
throw new Error(refusal.message);
|
|
1104
1172
|
if (failed > 0) {
|
|
1105
1173
|
throw new Error(`[rsc-kit] ${failed} route${failed === 1 ? '' : 's'} failed to render.\n` +
|
|
1106
1174
|
'Prerendering runs your app: whatever those pages need at render time has to be\n' +
|
|
@@ -1241,6 +1309,19 @@ function patternOf(segments) {
|
|
|
1241
1309
|
function renderRouteTypes(manifest) {
|
|
1242
1310
|
const patterns = [...new Set(manifest.routes.map((route) => patternOf(route.segments)))].sort();
|
|
1243
1311
|
const apis = [...new Set((manifest.apis ?? []).map((route) => patternOf(route.segments)))].sort();
|
|
1312
|
+
// Each pattern to the page module that answers it, as a type-only import
|
|
1313
|
+
// from the generated file's own directory. SearchExportOf reads the page's
|
|
1314
|
+
// `searchParams` export, or undefined when there is none, so nothing here
|
|
1315
|
+
// has to look inside the file - the typechecker already has every page
|
|
1316
|
+
// open. One line per route, and a page without a schema costs nothing.
|
|
1317
|
+
const search = new Map();
|
|
1318
|
+
for (const route of manifest.routes) {
|
|
1319
|
+
const pattern = patternOf(route.segments);
|
|
1320
|
+
if (search.has(pattern))
|
|
1321
|
+
continue;
|
|
1322
|
+
const target = relative(typesDir, join(sourceDir, route.component)).replace(/\\/g, '/');
|
|
1323
|
+
search.set(pattern, target.startsWith('.') ? target : './' + target);
|
|
1324
|
+
}
|
|
1244
1325
|
return [
|
|
1245
1326
|
'// @generated — do not edit. Written by the RSC build from the route tree.',
|
|
1246
1327
|
'//',
|
|
@@ -1258,6 +1339,11 @@ function renderRouteTypes(manifest) {
|
|
|
1258
1339
|
patterns.length > 0
|
|
1259
1340
|
? ' routes:\n' + patterns.map((p) => ' | ' + JSON.stringify(p)).join('\n')
|
|
1260
1341
|
: ' // No routes found under the source directory.\n routes: never',
|
|
1342
|
+
// The searchParams schema each page exports, read off the module's type.
|
|
1343
|
+
// This is what types Link's `search` prop and href() per route.
|
|
1344
|
+
' search: {',
|
|
1345
|
+
...[...search].sort().map(([pattern, target]) => ' ' + JSON.stringify(pattern) + ': SearchExportOf<typeof import(' + JSON.stringify(target) + ')>'),
|
|
1346
|
+
' }',
|
|
1261
1347
|
' }',
|
|
1262
1348
|
// Api routes are a separate union, so Link refuses an api url and apiUrl()
|
|
1263
1349
|
// refuses a page. Linking to an api route navigates the browser away to a
|
|
@@ -1679,6 +1765,7 @@ import { httpHostCalls } from ${JSON.stringify(join(packageDir, 'hostCalls'))}
|
|
|
1679
1765
|
import { prerenderedBeside } from ${JSON.stringify(join(packageDir, 'files'))}
|
|
1680
1766
|
import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
|
|
1681
1767
|
import { isQuery, queryCacheControl, isQueryValidationError } from ${JSON.stringify(join(packageDir, 'query'))}
|
|
1768
|
+
import { isActionValidationError, isClientBuilt } from ${JSON.stringify(join(packageDir, 'action'))}
|
|
1682
1769
|
import { Suspense, createElement, Fragment } from 'react'
|
|
1683
1770
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
1684
1771
|
${imports.join('\n')}
|
|
@@ -1872,6 +1959,31 @@ export function manifest(): any {
|
|
|
1872
1959
|
return ${JSON.stringify(routeManifest())}
|
|
1873
1960
|
}
|
|
1874
1961
|
|
|
1962
|
+
/**
|
|
1963
|
+
* For each server action id, whether a client built it. The build asks after
|
|
1964
|
+
* the bundle exists, because the answer is a mark on the loaded function and
|
|
1965
|
+
* nothing static could tell a wrapped export from a bare one.
|
|
1966
|
+
*/
|
|
1967
|
+
export async function auditActions(ids: string[]): Promise<{ id: string; client: boolean; query: boolean }[]> {
|
|
1968
|
+
const out: { id: string; client: boolean; query: boolean }[] = []
|
|
1969
|
+
|
|
1970
|
+
for (const id of ids) {
|
|
1971
|
+
let fn: unknown = null
|
|
1972
|
+
|
|
1973
|
+
try {
|
|
1974
|
+
fn = await loadServerAction(id)
|
|
1975
|
+
} catch {
|
|
1976
|
+
continue
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
if (typeof fn !== 'function') continue
|
|
1980
|
+
|
|
1981
|
+
out.push({ id, client: isClientBuilt(fn), query: isQuery(fn) })
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
return out
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1875
1987
|
/**
|
|
1876
1988
|
* The param sets a route declares, or null when it declares none.
|
|
1877
1989
|
*
|
|
@@ -2245,8 +2357,27 @@ async function renderTree(
|
|
|
2245
2357
|
// Icons and share images the build found in app/. Rendered here so React
|
|
2246
2358
|
// hoists them into <head>, the same way the manifest link is — an app never
|
|
2247
2359
|
// edits its layout to get a favicon.
|
|
2248
|
-
|
|
2249
|
-
|
|
2360
|
+
// Where the site lives, for making a relative image or url absolute. Read
|
|
2361
|
+
// here, above the found images, because those are the ones most likely to
|
|
2362
|
+
// be relative: an opengraph-image.png in app/ is emitted as /_app/..., and a
|
|
2363
|
+
// share-card scraper needs the origin in front of it.
|
|
2364
|
+
const base = md?.metadataBase ? String(md.metadataBase) : null
|
|
2365
|
+
const absolute = (value: unknown): string => {
|
|
2366
|
+
const text = String(value instanceof URL ? value.href : value)
|
|
2367
|
+
|
|
2368
|
+
if (!base || /^[a-z][a-z0-9+.-]*:/i.test(text)) return text
|
|
2369
|
+
|
|
2370
|
+
return new URL(text, base).href
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
for (const [i, found] of APP_HEAD.entries()) {
|
|
2374
|
+
const props = { ...found.props }
|
|
2375
|
+
|
|
2376
|
+
if (props.content && /^(og:image|twitter:image)$/.test(props.property ?? props.name ?? '')) {
|
|
2377
|
+
props.content = absolute(props.content)
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
head.push(createElement(found.tag, { key: '__a' + i, ...props }))
|
|
2250
2381
|
}
|
|
2251
2382
|
|
|
2252
2383
|
if (WEB_MANIFEST) {
|
|
@@ -2271,16 +2402,123 @@ async function renderTree(
|
|
|
2271
2402
|
if (bootstrap) head.push(createElement(DocumentTitle, { key: '__ts', title: String(md.title) }))
|
|
2272
2403
|
}
|
|
2273
2404
|
if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
|
|
2405
|
+
|
|
2406
|
+
// og: and its relatives are PROPERTY, not name. Facebook's scraper - and
|
|
2407
|
+
// Slack's, and LinkedIn's - reads only property=, so every og tag this
|
|
2408
|
+
// used to emit with name= was invisible to the thing it existed for.
|
|
2409
|
+
// Twitter reads name=, which is why the two are not one rule.
|
|
2410
|
+
const isProperty = (k: string) => /^(og|article|profile|book|music|video|fb):/.test(k)
|
|
2411
|
+
// The keys whose CONTENT is a url. Exact, plus the :url and :secure_url
|
|
2412
|
+
// spellings - not og:image:width, which a looser match turned into
|
|
2413
|
+
// https://site/1200 and no scraper would ever read.
|
|
2414
|
+
const isUrlKey = (k: string) => /^(og:image|og:url|twitter:image)(:(secure_)?url)?$/.test(k)
|
|
2415
|
+
const tag = (k: string, v: unknown) =>
|
|
2416
|
+
createElement('meta', {
|
|
2417
|
+
key: '__m_' + k + '_' + String(v).slice(0, 40),
|
|
2418
|
+
[isProperty(k) ? 'property' : 'name']: k,
|
|
2419
|
+
content: isUrlKey(k) ? absolute(v) : String(v),
|
|
2420
|
+
})
|
|
2421
|
+
|
|
2422
|
+
// One image, or several. Each becomes its own og:image plus the size and
|
|
2423
|
+
// alt tags beside it, which is how a scraper is told which is which.
|
|
2424
|
+
const images = (prefix: string, value: unknown): void => {
|
|
2425
|
+
const list = Array.isArray(value) ? value : [value]
|
|
2426
|
+
|
|
2427
|
+
for (const item of list) {
|
|
2428
|
+
if (item == null) continue
|
|
2429
|
+
|
|
2430
|
+
if (typeof item === 'object' && !(item instanceof URL)) {
|
|
2431
|
+
const image = item as { url: unknown; width?: number; height?: number; alt?: string; type?: string }
|
|
2432
|
+
|
|
2433
|
+
head.push(tag(prefix, image.url))
|
|
2434
|
+
if (image.width) head.push(tag(prefix + ':width', image.width))
|
|
2435
|
+
if (image.height) head.push(tag(prefix + ':height', image.height))
|
|
2436
|
+
if (image.alt) head.push(tag(prefix + ':alt', image.alt))
|
|
2437
|
+
if (image.type) head.push(tag(prefix + ':type', image.type))
|
|
2438
|
+
} else {
|
|
2439
|
+
head.push(tag(prefix, item))
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
if (md.openGraph) {
|
|
2445
|
+
const og = md.openGraph as Record<string, unknown>
|
|
2446
|
+
|
|
2447
|
+
if (og.title != null) head.push(tag('og:title', og.title))
|
|
2448
|
+
if (og.description != null) head.push(tag('og:description', og.description))
|
|
2449
|
+
if (og.url != null) head.push(tag('og:url', og.url))
|
|
2450
|
+
if (og.siteName != null) head.push(tag('og:site_name', og.siteName))
|
|
2451
|
+
if (og.type != null) head.push(tag('og:type', og.type))
|
|
2452
|
+
if (og.locale != null) head.push(tag('og:locale', og.locale))
|
|
2453
|
+
if (og.images != null) images('og:image', og.images)
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
if (md.twitter) {
|
|
2457
|
+
const tw = md.twitter as Record<string, unknown>
|
|
2458
|
+
|
|
2459
|
+
if (tw.card != null) head.push(tag('twitter:card', tw.card))
|
|
2460
|
+
if (tw.title != null) head.push(tag('twitter:title', tw.title))
|
|
2461
|
+
if (tw.description != null) head.push(tag('twitter:description', tw.description))
|
|
2462
|
+
if (tw.site != null) head.push(tag('twitter:site', tw.site))
|
|
2463
|
+
if (tw.creator != null) head.push(tag('twitter:creator', tw.creator))
|
|
2464
|
+
if (tw.images != null) images('twitter:image', tw.images)
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// icons is links, not meta. A string is one icon; an object names which
|
|
2468
|
+
// rel each is for. The ones found in app/ are already in APP_HEAD above,
|
|
2469
|
+
// so this is for an app that wants to say it explicitly.
|
|
2470
|
+
const links = (rel: string, value: unknown): void => {
|
|
2471
|
+
const list = Array.isArray(value) ? value : [value]
|
|
2472
|
+
|
|
2473
|
+
for (const item of list) {
|
|
2474
|
+
if (item == null) continue
|
|
2475
|
+
|
|
2476
|
+
const icon = typeof item === 'object' && !(item instanceof URL)
|
|
2477
|
+
? (item as Record<string, unknown>)
|
|
2478
|
+
: { url: item }
|
|
2479
|
+
|
|
2480
|
+
head.push(
|
|
2481
|
+
createElement('link', {
|
|
2482
|
+
key: '__i_' + rel + '_' + String(icon.url).slice(0, 40),
|
|
2483
|
+
rel: (icon.rel as string) ?? rel,
|
|
2484
|
+
href: absolute(icon.url),
|
|
2485
|
+
...(icon.type ? { type: icon.type } : {}),
|
|
2486
|
+
...(icon.sizes ? { sizes: icon.sizes } : {}),
|
|
2487
|
+
...(icon.media ? { media: icon.media } : {}),
|
|
2488
|
+
}),
|
|
2489
|
+
)
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
if (md.icons != null) {
|
|
2494
|
+
const icons = md.icons as Record<string, unknown> | string | unknown[]
|
|
2495
|
+
|
|
2496
|
+
if (typeof icons === 'string' || Array.isArray(icons) || icons instanceof URL) {
|
|
2497
|
+
links('icon', icons)
|
|
2498
|
+
} else {
|
|
2499
|
+
if (icons.icon != null) links('icon', icons.icon)
|
|
2500
|
+
if (icons.apple != null) links('apple-touch-icon', icons.apple)
|
|
2501
|
+
if (icons.shortcut != null) links('shortcut icon', icons.shortcut)
|
|
2502
|
+
if (icons.other != null) links('icon', icons.other)
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2274
2506
|
// other is flattened in beside the named keys, because it is a place to put
|
|
2275
2507
|
// meta tags rather than a meta tag by that name. A key at the top level
|
|
2276
|
-
// still renders
|
|
2508
|
+
// still renders - the type no longer invites one, but an app written
|
|
2277
2509
|
// against the old shape must not silently lose its tags.
|
|
2278
|
-
const
|
|
2510
|
+
const structured = new Set(['title', 'description', 'metadataBase', 'openGraph', 'twitter', 'icons', 'other'])
|
|
2511
|
+
const named = Object.entries(md).filter(([k]) => !structured.has(k))
|
|
2279
2512
|
const extra = Object.entries((md.other ?? {}) as Record<string, unknown>)
|
|
2280
2513
|
|
|
2281
2514
|
for (const [k, v] of [...named, ...extra]) {
|
|
2282
|
-
if (
|
|
2283
|
-
|
|
2515
|
+
if (v == null) continue
|
|
2516
|
+
|
|
2517
|
+
if (Array.isArray(v)) {
|
|
2518
|
+
for (const item of v) head.push(tag(k, item))
|
|
2519
|
+
} else {
|
|
2520
|
+
head.push(tag(k, v))
|
|
2521
|
+
}
|
|
2284
2522
|
}
|
|
2285
2523
|
}
|
|
2286
2524
|
|
|
@@ -2788,7 +3026,20 @@ export async function handleAction(
|
|
|
2788
3026
|
)
|
|
2789
3027
|
}
|
|
2790
3028
|
|
|
2791
|
-
|
|
3029
|
+
// A plain "use server" function that threw fieldErrors() gets the same
|
|
3030
|
+
// treatment createActionClient gives its handlers: the throw becomes the
|
|
3031
|
+
// returned { validationErrors } that <Form> reads. Left thrown, React
|
|
3032
|
+
// serialises the rejection opaquely — production strips the message — and the
|
|
3033
|
+
// fields it named never reach the browser.
|
|
3034
|
+
let result: unknown
|
|
3035
|
+
|
|
3036
|
+
try {
|
|
3037
|
+
result = await (action as (...a: unknown[]) => unknown)(...args)
|
|
3038
|
+
} catch (error) {
|
|
3039
|
+
if (!isActionValidationError(error)) throw error
|
|
3040
|
+
|
|
3041
|
+
result = { validationErrors: error.errors }
|
|
3042
|
+
}
|
|
2792
3043
|
|
|
2793
3044
|
// Read after the action has run: what it invalidated is only known once its
|
|
2794
3045
|
// host calls have been made. Rendering here rather than telling the browser
|
|
@@ -3917,7 +4168,7 @@ export function rscKit(options = {}) {
|
|
|
3917
4168
|
const staticDir = clientOut
|
|
3918
4169
|
? join(dirname(clientOut), 'server', NITRO_STATIC_DIR)
|
|
3919
4170
|
: join(outDir, NITRO_STATIC_DIR);
|
|
3920
|
-
const { frozen, results } = await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir);
|
|
4171
|
+
const { frozen, results } = await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir, builder ? knownActionsOf(builder.config, projectRoot) : []);
|
|
3921
4172
|
// Manifest first. The service worker precaches whatever it finds in this
|
|
3922
4173
|
// directory, so writing it afterwards leaves it out of the list — and an
|
|
3923
4174
|
// installed app whose manifest is the one file that needs the network is
|
|
@@ -3931,6 +4182,15 @@ export function rscKit(options = {}) {
|
|
|
3931
4182
|
},
|
|
3932
4183
|
configResolved(config) {
|
|
3933
4184
|
isWatch = config.build?.watch != null;
|
|
4185
|
+
const nitroMain = config.plugins.find((p) => p.name === 'nitro:main');
|
|
4186
|
+
if (nitroMain?.hotUpdate) {
|
|
4187
|
+
const original = nitroMain.hotUpdate;
|
|
4188
|
+
nitroMain.hotUpdate = function (ctx) {
|
|
4189
|
+
if (this.environment?.name === 'rsc')
|
|
4190
|
+
return;
|
|
4191
|
+
return original.call(this, ctx);
|
|
4192
|
+
};
|
|
4193
|
+
}
|
|
3934
4194
|
// rsc() splits the module graph into client and server; a JSX transform
|
|
3935
4195
|
// placed ahead of it sees the wrong graph and fails in ways that are hard
|
|
3936
4196
|
// to trace back here. Cheaper to refuse than to let it through.
|