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