@rsc-kit/core 0.1.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/README.md +95 -0
- package/SECURITY.md +60 -0
- package/dist/action.d.ts +90 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +137 -0
- package/dist/action.js.map +1 -0
- package/dist/build-rsc-vite.d.ts +39 -0
- package/dist/build-rsc-vite.d.ts.map +1 -0
- package/dist/build-rsc-vite.js +172 -0
- package/dist/build-rsc-vite.js.map +1 -0
- package/dist/cache.d.ts +25 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +108 -0
- package/dist/cache.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +85 -0
- package/dist/cli.js.map +1 -0
- package/dist/compile.d.ts +55 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +141 -0
- package/dist/compile.js.map +1 -0
- package/dist/devServer.d.ts +18 -0
- package/dist/devServer.d.ts.map +1 -0
- package/dist/devServer.js +70 -0
- package/dist/devServer.js.map +1 -0
- package/dist/devUrls.d.ts +11 -0
- package/dist/devUrls.d.ts.map +1 -0
- package/dist/devUrls.js +68 -0
- package/dist/devUrls.js.map +1 -0
- package/dist/embed.d.ts +21 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +119 -0
- package/dist/embed.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +38 -0
- package/dist/errors.js.map +1 -0
- package/dist/export.d.ts +41 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +100 -0
- package/dist/export.js.map +1 -0
- package/dist/files.d.ts +39 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +146 -0
- package/dist/files.js.map +1 -0
- package/dist/headers.d.ts +81 -0
- package/dist/headers.d.ts.map +1 -0
- package/dist/headers.js +96 -0
- package/dist/headers.js.map +1 -0
- package/dist/host.d.ts +86 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +590 -0
- package/dist/host.js.map +1 -0
- package/dist/js/ActivityRouter.d.ts +47 -0
- package/dist/js/ActivityRouter.d.ts.map +1 -0
- package/dist/js/ActivityRouter.js +87 -0
- package/dist/js/ActivityRouter.js.map +1 -0
- package/dist/js/DocumentTitle.d.ts +22 -0
- package/dist/js/DocumentTitle.d.ts.map +1 -0
- package/dist/js/DocumentTitle.js +28 -0
- package/dist/js/DocumentTitle.js.map +1 -0
- package/dist/js/Form.d.ts +54 -0
- package/dist/js/Form.d.ts.map +1 -0
- package/dist/js/Form.js +225 -0
- package/dist/js/Form.js.map +1 -0
- package/dist/js/Link.d.ts +29 -0
- package/dist/js/Link.d.ts.map +1 -0
- package/dist/js/Link.js +112 -0
- package/dist/js/Link.js.map +1 -0
- package/dist/js/PathnameProvider.d.ts +8 -0
- package/dist/js/PathnameProvider.d.ts.map +1 -0
- package/dist/js/PathnameProvider.js +24 -0
- package/dist/js/PathnameProvider.js.map +1 -0
- package/dist/js/RedirectBoundary.d.ts +18 -0
- package/dist/js/RedirectBoundary.d.ts.map +1 -0
- package/dist/js/RedirectBoundary.js +34 -0
- package/dist/js/RedirectBoundary.js.map +1 -0
- package/dist/js/SegmentBoundary.d.ts +8 -0
- package/dist/js/SegmentBoundary.d.ts.map +1 -0
- package/dist/js/SegmentBoundary.js +38 -0
- package/dist/js/SegmentBoundary.js.map +1 -0
- package/dist/js/SlotBoundary.d.ts +15 -0
- package/dist/js/SlotBoundary.d.ts.map +1 -0
- package/dist/js/SlotBoundary.js +17 -0
- package/dist/js/SlotBoundary.js.map +1 -0
- package/dist/js/createViteRscApp.d.ts +8 -0
- package/dist/js/createViteRscApp.d.ts.map +1 -0
- package/dist/js/createViteRscApp.js +185 -0
- package/dist/js/createViteRscApp.js.map +1 -0
- package/dist/js/errors.d.ts +51 -0
- package/dist/js/errors.d.ts.map +1 -0
- package/dist/js/errors.js +93 -0
- package/dist/js/errors.js.map +1 -0
- package/dist/js/navigate.d.ts +118 -0
- package/dist/js/navigate.d.ts.map +1 -0
- package/dist/js/navigate.js +780 -0
- package/dist/js/navigate.js.map +1 -0
- package/dist/js/onlineStore.d.ts +23 -0
- package/dist/js/onlineStore.d.ts.map +1 -0
- package/dist/js/onlineStore.js +42 -0
- package/dist/js/onlineStore.js.map +1 -0
- package/dist/js/pagePayload.d.ts +15 -0
- package/dist/js/pagePayload.d.ts.map +1 -0
- package/dist/js/pagePayload.js +39 -0
- package/dist/js/pagePayload.js.map +1 -0
- package/dist/js/router.d.ts +29 -0
- package/dist/js/router.d.ts.map +1 -0
- package/dist/js/router.js +40 -0
- package/dist/js/router.js.map +1 -0
- package/dist/js/section.d.ts +16 -0
- package/dist/js/section.d.ts.map +1 -0
- package/dist/js/section.js +55 -0
- package/dist/js/section.js.map +1 -0
- package/dist/js/segmentStore.d.ts +69 -0
- package/dist/js/segmentStore.d.ts.map +1 -0
- package/dist/js/segmentStore.js +127 -0
- package/dist/js/segmentStore.js.map +1 -0
- package/dist/js/slotStore.d.ts +35 -0
- package/dist/js/slotStore.d.ts.map +1 -0
- package/dist/js/slotStore.js +46 -0
- package/dist/js/slotStore.js.map +1 -0
- package/dist/js/standardSchema.d.ts +47 -0
- package/dist/js/standardSchema.d.ts.map +1 -0
- package/dist/js/standardSchema.js +41 -0
- package/dist/js/standardSchema.js.map +1 -0
- package/dist/js/useForm.d.ts +43 -0
- package/dist/js/useForm.d.ts.map +1 -0
- package/dist/js/useForm.js +164 -0
- package/dist/js/useForm.js.map +1 -0
- package/dist/js/useLinkStatus.d.ts +2 -0
- package/dist/js/useLinkStatus.d.ts.map +1 -0
- package/dist/js/useLinkStatus.js +3 -0
- package/dist/js/useLinkStatus.js.map +1 -0
- package/dist/js/useOnline.d.ts +8 -0
- package/dist/js/useOnline.d.ts.map +1 -0
- package/dist/js/useOnline.js +20 -0
- package/dist/js/useOnline.js.map +1 -0
- package/dist/js/usePathname.d.ts +9 -0
- package/dist/js/usePathname.d.ts.map +1 -0
- package/dist/js/usePathname.js +32 -0
- package/dist/js/usePathname.js.map +1 -0
- package/dist/js/useSearchParams.d.ts +2 -0
- package/dist/js/useSearchParams.d.ts.map +1 -0
- package/dist/js/useSearchParams.js +60 -0
- package/dist/js/useSearchParams.js.map +1 -0
- package/dist/manifest.d.ts +66 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +13 -0
- package/dist/manifest.js.map +1 -0
- package/dist/prerender.d.ts +138 -0
- package/dist/prerender.d.ts.map +1 -0
- package/dist/prerender.js +387 -0
- package/dist/prerender.js.map +1 -0
- package/dist/redirect.d.ts +34 -0
- package/dist/redirect.d.ts.map +1 -0
- package/dist/redirect.js +108 -0
- package/dist/redirect.js.map +1 -0
- package/dist/redirectDigest.d.ts +20 -0
- package/dist/redirectDigest.d.ts.map +1 -0
- package/dist/redirectDigest.js +66 -0
- package/dist/redirectDigest.js.map +1 -0
- package/dist/request.d.ts +93 -0
- package/dist/request.d.ts.map +1 -0
- package/dist/request.js +308 -0
- package/dist/request.js.map +1 -0
- package/dist/revalidate.d.ts +43 -0
- package/dist/revalidate.d.ts.map +1 -0
- package/dist/revalidate.js +99 -0
- package/dist/revalidate.js.map +1 -0
- package/dist/routes.d.ts +37 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +28 -0
- package/dist/routes.js.map +1 -0
- package/dist/routing.d.ts +52 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +146 -0
- package/dist/routing.js.map +1 -0
- package/dist/runtime.d.ts +36 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +86 -0
- package/dist/runtime.js.map +1 -0
- package/dist/safeUrl.d.ts +10 -0
- package/dist/safeUrl.d.ts.map +1 -0
- package/dist/safeUrl.js +40 -0
- package/dist/safeUrl.js.map +1 -0
- package/dist/streaming.d.ts +62 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +108 -0
- package/dist/streaming.js.map +1 -0
- package/dist/types.d.ts +82 -0
- package/dist/vite.d.ts +109 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +1909 -0
- package/dist/vite.js.map +1 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +863 -0
- package/dist/worker.js.map +1 -0
- package/package.json +223 -0
package/dist/vite.js
ADDED
|
@@ -0,0 +1,1909 @@
|
|
|
1
|
+
// File-based routing for React Server Components, as a Vite plugin.
|
|
2
|
+
//
|
|
3
|
+
// Host-agnostic by design: it discovers an app/ route tree, generates the three
|
|
4
|
+
// entries, and exposes a render contract over a global the host installs. What
|
|
5
|
+
// that global is called, and how a route declares dynamic props, are options —
|
|
6
|
+
// nothing here knows or cares which backend is driving it.
|
|
7
|
+
//
|
|
8
|
+
// import { rscRoutes } from '<package>/vite'
|
|
9
|
+
// export default defineConfig({ plugins: [rscRoutes(), react({ compiler: true })] })
|
|
10
|
+
//
|
|
11
|
+
// The plugin discovers the app/ route tree, generates the three entries that
|
|
12
|
+
// carry the route composition and the worker's render contract, and supplies
|
|
13
|
+
// the structural config (entries, output dirs, base). @vitejs/plugin-rsc is
|
|
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';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
19
|
+
import rsc from '@vitejs/plugin-rsc';
|
|
20
|
+
// Resolved once per rscRoutes() call. One build runs in one process, so these are
|
|
21
|
+
// module state rather than threaded through every helper.
|
|
22
|
+
let projectRoot;
|
|
23
|
+
let sourceDir;
|
|
24
|
+
let outDir;
|
|
25
|
+
let appDir;
|
|
26
|
+
let genDir;
|
|
27
|
+
let publicAssetsDir;
|
|
28
|
+
let assetsBaseUrl;
|
|
29
|
+
let packageDir;
|
|
30
|
+
let hostGlobal;
|
|
31
|
+
let interceptManifestFile;
|
|
32
|
+
let packageAlias;
|
|
33
|
+
/** Dev-server origin; empty in a build. See devUrls.ts. */
|
|
34
|
+
let devOrigin;
|
|
35
|
+
/** 'server' or 'export' — see RscRoutesOptions.output. */
|
|
36
|
+
let output;
|
|
37
|
+
/** Where an exported site is written. */
|
|
38
|
+
let exportPath;
|
|
39
|
+
/**
|
|
40
|
+
* Filename payloads are exported under, empty unless building for a static
|
|
41
|
+
* host. Set, the client asks `<page>/<name>` for a payload instead of asking
|
|
42
|
+
* for the page's own url with a header a static host cannot act on.
|
|
43
|
+
*/
|
|
44
|
+
let staticPayloads;
|
|
45
|
+
let routeConfig;
|
|
46
|
+
/** Whether `vite build` freezes pages when it finishes — see RscRoutesOptions. */
|
|
47
|
+
let prerenderAfterBuild;
|
|
48
|
+
/** True during `vite build --watch`, where re-rendering every route is noise. */
|
|
49
|
+
let isWatch = false;
|
|
50
|
+
/** Host functions to generate stubs for — see RscRoutesOptions.hostActions. */
|
|
51
|
+
let hostActions;
|
|
52
|
+
/**
|
|
53
|
+
* This file's directory.
|
|
54
|
+
*
|
|
55
|
+
* Not `import.meta.dir`, which is Bun-only: Vite bundles the config and runs it
|
|
56
|
+
* under Node, where that is undefined and the path resolution below throws
|
|
57
|
+
* before the build starts. Reached whenever RSC_PACKAGE_DIR is unset — which is
|
|
58
|
+
* the ordinary case for an app that installs the engine from npm and runs
|
|
59
|
+
* `vite build` itself.
|
|
60
|
+
*/
|
|
61
|
+
function thisDir() {
|
|
62
|
+
return dirname(fileURLToPath(import.meta.url));
|
|
63
|
+
}
|
|
64
|
+
/** routeConfig supplied through the environment, for out-of-process hosts. */
|
|
65
|
+
function envRouteConfig() {
|
|
66
|
+
const file = process.env.RSC_ROUTE_CONFIG_FILE;
|
|
67
|
+
const pattern = process.env.RSC_ROUTE_CONFIG_PATTERN;
|
|
68
|
+
if (!file || !pattern)
|
|
69
|
+
return null;
|
|
70
|
+
return { file, dynamicPattern: new RegExp(pattern) };
|
|
71
|
+
}
|
|
72
|
+
/** hostActions supplied through the environment, for out-of-process hosts. */
|
|
73
|
+
function envHostActions() {
|
|
74
|
+
const raw = process.env.RSC_HOST_ACTIONS;
|
|
75
|
+
if (!raw)
|
|
76
|
+
return {};
|
|
77
|
+
return JSON.parse(raw);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The alias that lets a vendored copy of this package be imported by name.
|
|
81
|
+
*
|
|
82
|
+
* Only when the package is not installed, because an alias is a path rewrite
|
|
83
|
+
* and rewrites nothing through the package's own exports. With both in play
|
|
84
|
+
* the specifier meant two different things: `<pkg>/Form` resolved to whatever
|
|
85
|
+
* file happened to sit at js/Form, rather than to what ./Form is declared to
|
|
86
|
+
* mean. Installed from npm, ordinary resolution reads the exports map and the
|
|
87
|
+
* two cannot drift.
|
|
88
|
+
*/
|
|
89
|
+
function aliasEntries() {
|
|
90
|
+
if (!packageAlias)
|
|
91
|
+
return [];
|
|
92
|
+
if (existsSync(join(projectRoot, 'node_modules', packageAlias)))
|
|
93
|
+
return [];
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
find: new RegExp('^' + packageAlias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/(.*)$'),
|
|
97
|
+
replacement: join(packageDir, 'js') + '/$1',
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
function resolvePaths(options) {
|
|
102
|
+
projectRoot = resolve(options.projectRoot || process.env.RSC_PROJECT_ROOT || process.cwd());
|
|
103
|
+
sourceDir = resolve(options.sourceDir || process.env.RSC_SOURCE_DIR || join(projectRoot, 'src'));
|
|
104
|
+
outDir = resolve(options.outDir || process.env.RSC_OUT_DIR || join(projectRoot, '.rsc'));
|
|
105
|
+
appDir = join(sourceDir, 'app');
|
|
106
|
+
// Generated entries live under the (in-project) out dir so module resolution
|
|
107
|
+
// can walk up to the project's node_modules (@vitejs/plugin-rsc, react, ...).
|
|
108
|
+
genDir = join(outDir, '.gen');
|
|
109
|
+
// The CLIENT bundle is browser-facing and has to be web-served; the rsc/ssr
|
|
110
|
+
// bundles are SERVER code and stay under outDir, which must never be public.
|
|
111
|
+
publicAssetsDir = resolve(options.assetsDir || process.env.RSC_ASSETS_DIR || join(projectRoot, 'dist/client'));
|
|
112
|
+
assetsBaseUrl = options.assetsUrl || process.env.RSC_ASSETS_URL || '/';
|
|
113
|
+
packageDir = resolve(options.packageDir || process.env.RSC_PACKAGE_DIR || thisDir());
|
|
114
|
+
hostGlobal = options.hostGlobal || process.env.RSC_HOST_GLOBAL || 'rpc';
|
|
115
|
+
interceptManifestFile = resolve(options.interceptManifestFile || process.env.RSC_INTERCEPT_MANIFEST || join(outDir, 'intercept-manifest.json'));
|
|
116
|
+
packageAlias = options.packageAlias || process.env.RSC_PACKAGE_ALIAS || null;
|
|
117
|
+
devOrigin = options.devOrigin || process.env.RSC_DEV_ORIGIN || '';
|
|
118
|
+
output = options.output || process.env.RSC_OUTPUT || 'server';
|
|
119
|
+
exportPath = options.exportPath || process.env.RSC_EXPORT_PATH || 'dist';
|
|
120
|
+
// An export decides this for itself: the client has to ask for payloads by
|
|
121
|
+
// url because there is no server to read a header, and the name it asks for
|
|
122
|
+
// is the one the export writes.
|
|
123
|
+
staticPayloads =
|
|
124
|
+
options.staticPayloads || process.env.RSC_STATIC_PAYLOADS || (output === 'export' ? 'index.rsc' : '');
|
|
125
|
+
// No default: which file marks a route dynamic is the host's convention, and
|
|
126
|
+
// guessing one here would bake a particular backend into a generic plugin.
|
|
127
|
+
// The env pair exists so a host driving the build out of process can pass it
|
|
128
|
+
// without writing a config file.
|
|
129
|
+
routeConfig = options.routeConfig ?? envRouteConfig();
|
|
130
|
+
// A host driving the build out of process cannot pass an option, and may
|
|
131
|
+
// prerender itself afterwards with paths only it knows.
|
|
132
|
+
prerenderAfterBuild = options.prerender ?? process.env.RSC_PRERENDER !== '0';
|
|
133
|
+
hostActions = options.hostActions ?? envHostActions();
|
|
134
|
+
}
|
|
135
|
+
function log(...args) {
|
|
136
|
+
console.error('[rsc-routes]', ...args);
|
|
137
|
+
}
|
|
138
|
+
// ── The route manifest ───────────────────────────────────────────────────────
|
|
139
|
+
/**
|
|
140
|
+
* What the plugin knows about the route tree, written out for a host to read.
|
|
141
|
+
*
|
|
142
|
+
* The plugin already walks app/ to generate the entries, and every host has to
|
|
143
|
+
* know the same things — which url a component answers, what layouts wrap it,
|
|
144
|
+
* which slots and sections belong to it. Laravel scans the tree a second time
|
|
145
|
+
* to work that out; a JS host would have to write a third. This is the one
|
|
146
|
+
* answer, emitted where both can read it.
|
|
147
|
+
*
|
|
148
|
+
* Urls are expressed as segments rather than as a pattern string, because the
|
|
149
|
+
* pattern is the host's dialect: Laravel writes {slug}, Hono writes :slug, and
|
|
150
|
+
* neither is the plugin's business.
|
|
151
|
+
*/
|
|
152
|
+
/** `[...path]` → catchAll, `[id]` → param, `(group)` → nothing at all. */
|
|
153
|
+
function urlSegments(componentName) {
|
|
154
|
+
const parts = componentName.split('/').slice(1, -1);
|
|
155
|
+
const segments = [];
|
|
156
|
+
for (const part of parts) {
|
|
157
|
+
// A route group organises files without appearing in the url.
|
|
158
|
+
if (part.startsWith('(') && part.endsWith(')'))
|
|
159
|
+
continue;
|
|
160
|
+
// A slot directory is not part of its page's url either.
|
|
161
|
+
if (part.startsWith('@'))
|
|
162
|
+
continue;
|
|
163
|
+
if (part.startsWith('[...') && part.endsWith(']')) {
|
|
164
|
+
segments.push({ type: 'catchAll', value: part.slice(4, -1) });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (part.startsWith('[') && part.endsWith(']')) {
|
|
168
|
+
segments.push({ type: 'param', value: part.slice(1, -1) });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// An interception marker says which url this replaces, not what it is
|
|
172
|
+
// called: (.)photo intercepts the sibling /photo. Left in place the
|
|
173
|
+
// manifest would claim a route at /(.)photo, which nothing can navigate to.
|
|
174
|
+
segments.push({ type: 'static', value: part.replace(/^\(\.{1,3}\)/, '') });
|
|
175
|
+
}
|
|
176
|
+
return segments;
|
|
177
|
+
}
|
|
178
|
+
/** Whether a component sits under an interception marker: (.) (..) (...) */
|
|
179
|
+
function isIntercept(componentName) {
|
|
180
|
+
return componentName.split('/').some((part) => /^\(\.{1,3}\)/.test(part));
|
|
181
|
+
}
|
|
182
|
+
/** The slot directory a component lives under, if any. */
|
|
183
|
+
function slotOf(componentName) {
|
|
184
|
+
const part = componentName.split('/').find((p) => p.startsWith('@'));
|
|
185
|
+
return part ? part.slice(1) : null;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Everything the plugin discovered, as a host needs it.
|
|
189
|
+
*
|
|
190
|
+
* Ancestry is by path prefix: a layout at app/docs applies to everything under
|
|
191
|
+
* app/docs, which is the same rule the composition uses.
|
|
192
|
+
*/
|
|
193
|
+
function routeManifest() {
|
|
194
|
+
const names = [...components.keys()];
|
|
195
|
+
const dirOf = (name) => name.split('/').slice(0, -1).join('/');
|
|
196
|
+
// By path, not by string: 'app/slow3' begins with 'app/slow' as text and is
|
|
197
|
+
// not inside it, which would hand /slow3 the loading state of /slow.
|
|
198
|
+
const isUnder = (dir, ancestor) => dir === ancestor || dir.startsWith(ancestor + '/');
|
|
199
|
+
const ancestors = (name, base) => names
|
|
200
|
+
.filter((n) => n.endsWith('/' + base) && !isIntercept(n) && isUnder(dirOf(name), dirOf(n)))
|
|
201
|
+
.sort((a, b) => a.length - b.length);
|
|
202
|
+
/** Project-root-relative, posix — the same string on every machine. */
|
|
203
|
+
const fromRoot = (abs) => relative(projectRoot, abs).replace(/\\/g, '/');
|
|
204
|
+
/** The host's config file in a directory, if the host named one and it exists. */
|
|
205
|
+
const configIn = (absDir) => {
|
|
206
|
+
if (!routeConfig)
|
|
207
|
+
return null;
|
|
208
|
+
const path = join(absDir, routeConfig.file);
|
|
209
|
+
return existsSync(path) ? fromRoot(path) : null;
|
|
210
|
+
};
|
|
211
|
+
/** Ancestor configs, outermost first, excluding the page's own directory. */
|
|
212
|
+
const ancestorConfigs = (dir) => {
|
|
213
|
+
const found = [];
|
|
214
|
+
const parts = dir.split('/').slice(0, -1);
|
|
215
|
+
while (parts.length > 0) {
|
|
216
|
+
const path = configIn(join(sourceDir, parts.join('/')));
|
|
217
|
+
if (path)
|
|
218
|
+
found.unshift(path);
|
|
219
|
+
parts.pop();
|
|
220
|
+
}
|
|
221
|
+
return found;
|
|
222
|
+
};
|
|
223
|
+
const routes = [];
|
|
224
|
+
const intercepts = [];
|
|
225
|
+
for (const name of names) {
|
|
226
|
+
if (name.endsWith('/page') && isIntercept(name)) {
|
|
227
|
+
const slot = slotOf(name);
|
|
228
|
+
if (slot) {
|
|
229
|
+
const marker = name.split('/').find((p) => /^\(\.{1,3}\)/.test(p))?.match(/^\(\.{1,3}\)/)?.[0] ?? '(.)';
|
|
230
|
+
intercepts.push({ component: name, slot, segments: urlSegments(name), marker });
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (!name.endsWith('/page') || slotOf(name))
|
|
235
|
+
continue;
|
|
236
|
+
const slots = {};
|
|
237
|
+
for (const candidate of names) {
|
|
238
|
+
const slot = slotOf(candidate);
|
|
239
|
+
// A slot belongs to the layout in the directory that declares it, so it
|
|
240
|
+
// applies to a page only if that directory is on the page's path.
|
|
241
|
+
if (!slot || isIntercept(candidate) || !candidate.endsWith('/default'))
|
|
242
|
+
continue;
|
|
243
|
+
if (isUnder(dirOf(name), candidate.split('/@')[0]))
|
|
244
|
+
slots[slot] = candidate;
|
|
245
|
+
}
|
|
246
|
+
routes.push({
|
|
247
|
+
component: name,
|
|
248
|
+
segments: urlSegments(name),
|
|
249
|
+
layouts: ancestors(name, 'layout').map((n) => n),
|
|
250
|
+
loadings: ancestors(name, 'loading').map((n) => n),
|
|
251
|
+
middleware: ancestors(name, 'middleware').map((n) => n),
|
|
252
|
+
slots,
|
|
253
|
+
sections: names.filter((n) => SECTION_FILE.test(n + '.tsx') && dirOf(n) === dirOf(name)),
|
|
254
|
+
config: configIn(join(sourceDir, dirOf(name))),
|
|
255
|
+
ancestorConfigs: ancestorConfigs(dirOf(name)),
|
|
256
|
+
staticParams: hasStaticParams(components.get(name).absPath),
|
|
257
|
+
clientJs: shipsClientJs(components.get(name).absPath),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// What the build decided, for a host that has to act on it afterwards —
|
|
261
|
+
// writing the site out, and knowing which filename the client will ask for.
|
|
262
|
+
return {
|
|
263
|
+
version: 1,
|
|
264
|
+
build: { output, exportPath, payloadName: staticPayloads },
|
|
265
|
+
routes,
|
|
266
|
+
intercepts,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
// ── What the app imports ─────────────────────────────────────────────────────
|
|
270
|
+
/**
|
|
271
|
+
* Write the modules the app's source imports but nobody writes by hand.
|
|
272
|
+
*
|
|
273
|
+
* All three land in the source directory because that is where the app's own
|
|
274
|
+
* imports and its typechecker can reach them: the stubs are imported by
|
|
275
|
+
* relative path, and an ambient declaration is only ambient if it is inside
|
|
276
|
+
* the project. The build owns that path, which is why it owns this.
|
|
277
|
+
*
|
|
278
|
+
* Rewritten on every run. The failure they prevent is invisible at build
|
|
279
|
+
* time — a stale stub calls a global that has since been renamed, and only
|
|
280
|
+
* the browser ever finds out.
|
|
281
|
+
*/
|
|
282
|
+
function writeHostBindings(manifest) {
|
|
283
|
+
mkdirSync(sourceDir, { recursive: true });
|
|
284
|
+
// The global is installed at runtime, so nothing in app source declares it
|
|
285
|
+
// and a typecheck cannot see it. Written whether or not there are actions:
|
|
286
|
+
// server components call it directly too.
|
|
287
|
+
writeFileSync(join(sourceDir, 'rsc-env.d.ts'), renderHostGlobalTypes());
|
|
288
|
+
// The engine's own ambient types, copied where the app's typechecker will
|
|
289
|
+
// see them. Deliberately a separate file from the one above: this one is
|
|
290
|
+
// the engine's and identical everywhere, that one is generated from how
|
|
291
|
+
// this host is configured.
|
|
292
|
+
// The urls this build found, so a link to a page that does not exist fails
|
|
293
|
+
// the typecheck instead of the browser.
|
|
294
|
+
writeFileSync(join(sourceDir, 'rsc-routes.d.ts'), renderRouteTypes(manifest));
|
|
295
|
+
// The bundle the host imports is generated, so nothing declares it. Written
|
|
296
|
+
// here rather than left to the app: every app needs the identical file, and
|
|
297
|
+
// an app-authored one goes stale — the first version named only RscEngine,
|
|
298
|
+
// which typechecks a server and fails a prerender script.
|
|
299
|
+
writeFileSync(join(sourceDir, 'rsc-engine.d.ts'), ENGINE_TYPES);
|
|
300
|
+
const engineTypes = join(packageDir, 'types.d.ts');
|
|
301
|
+
if (existsSync(engineTypes)) {
|
|
302
|
+
writeFileSync(join(sourceDir, 'rsc-types.d.ts'), readFileSync(engineTypes, 'utf-8'));
|
|
303
|
+
}
|
|
304
|
+
const target = join(sourceDir, 'server-actions.generated.ts');
|
|
305
|
+
// A host with no functions of its own leaves no file behind: kept, its
|
|
306
|
+
// stubs would go on naming targets the host has stopped answering for.
|
|
307
|
+
if (Object.keys(hostActions).length === 0) {
|
|
308
|
+
if (existsSync(target))
|
|
309
|
+
rmSync(target);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
writeFileSync(target, renderHostActions());
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The generated engine bundle, as the type its callers expect.
|
|
316
|
+
*
|
|
317
|
+
* Both contracts: createRscHandler serves requests, prerender() renders at
|
|
318
|
+
* build time and needs three methods the first does not have. manifest() is
|
|
319
|
+
* optional on both because a host may be handed an engine without one — this
|
|
320
|
+
* is the generated bundle, which always exports it, and saying so is what lets
|
|
321
|
+
* exportSite() be called without a guard that could never fire.
|
|
322
|
+
*/
|
|
323
|
+
const ENGINE_TYPES = `// @generated — do not edit. Written by the RSC build.
|
|
324
|
+
declare module '*/dist/rsc/index.js' {
|
|
325
|
+
import type { RscEngine } from '@rsc-kit/core/host'
|
|
326
|
+
import type { PrerenderEngine } from '@rsc-kit/core/prerender'
|
|
327
|
+
|
|
328
|
+
const engine: RscEngine & PrerenderEngine & Required<Pick<PrerenderEngine, 'manifest'>>
|
|
329
|
+
|
|
330
|
+
export = engine
|
|
331
|
+
}
|
|
332
|
+
`;
|
|
333
|
+
/**
|
|
334
|
+
* Render every route once and write what can be stored.
|
|
335
|
+
*
|
|
336
|
+
* Imported at call time, not at the top of this file: `prerender` pulls in the
|
|
337
|
+
* render pipeline, and a dev server that never prerenders should not pay for
|
|
338
|
+
* loading it.
|
|
339
|
+
*/
|
|
340
|
+
async function prerenderAfterBundles() {
|
|
341
|
+
const bundle = join(outDir, 'dist/rsc/index.js');
|
|
342
|
+
if (!existsSync(bundle))
|
|
343
|
+
return;
|
|
344
|
+
const [{ prerender }, { writeTo }] = await Promise.all([
|
|
345
|
+
import('./prerender.js'),
|
|
346
|
+
import('./files.js'),
|
|
347
|
+
]);
|
|
348
|
+
const staticDir = join(outDir, 'static');
|
|
349
|
+
// Cleared first: a route that changes classification between builds
|
|
350
|
+
// otherwise leaves its old shell on disk and the host goes on serving it.
|
|
351
|
+
// Nothing warns — the page loads, with content from the previous build.
|
|
352
|
+
rmSync(staticDir, { recursive: true, force: true });
|
|
353
|
+
const engine = (await import(pathToFileURL(bundle).href));
|
|
354
|
+
const mark = { frozen: '○', shell: '◔', error: '✗' };
|
|
355
|
+
let failed = 0;
|
|
356
|
+
const results = await prerender({
|
|
357
|
+
engine,
|
|
358
|
+
write: writeTo(staticDir),
|
|
359
|
+
onResult: (r) => {
|
|
360
|
+
if (r.type === 'error')
|
|
361
|
+
failed++;
|
|
362
|
+
console.log(` ${mark[r.type] ?? ' '} ${r.url}${r.reason ? ` (${r.reason})` : ''}`);
|
|
363
|
+
if (r.warning)
|
|
364
|
+
console.log(` ⚠ ${r.warning}`);
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
const count = (type) => results.filter((r) => r.type === type).length;
|
|
368
|
+
console.log(`
|
|
369
|
+
○ the whole page is stored
|
|
370
|
+
◔ the chrome is stored; the rest is rendered per request
|
|
371
|
+
|
|
372
|
+
${count('frozen')} stored, ${count('shell')} shells`);
|
|
373
|
+
if (failed > 0) {
|
|
374
|
+
throw new Error(`[rsc-routes] ${failed} route${failed === 1 ? '' : 's'} failed to render.\n` +
|
|
375
|
+
'Prerendering runs your app: whatever those pages need at render time has to be\n' +
|
|
376
|
+
'reachable from the build. Fix them, or build with prerender: false and render on demand.');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/** `/posts/[slug]` — the pattern, in the shape the app writes its links in. */
|
|
380
|
+
function patternOf(segments) {
|
|
381
|
+
if (segments.length === 0)
|
|
382
|
+
return '/';
|
|
383
|
+
return ('/' +
|
|
384
|
+
segments
|
|
385
|
+
.map((segment) => segment.type === 'static'
|
|
386
|
+
? segment.value
|
|
387
|
+
: segment.type === 'catchAll'
|
|
388
|
+
? `[...${segment.value}]`
|
|
389
|
+
: `[${segment.value}]`)
|
|
390
|
+
.join('/'));
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The app's routes as a union, for `@rsc-kit/core/routes` to derive from.
|
|
394
|
+
*
|
|
395
|
+
* Rewritten every build like the other generated files: a route deleted from
|
|
396
|
+
* the tree has to stop being a valid href, and the only thing that knows is
|
|
397
|
+
* the walk that just happened.
|
|
398
|
+
*
|
|
399
|
+
* Interception patterns are deliberately absent. An interceptor answers a url
|
|
400
|
+
* that some real route already owns — listing it would put the same href in
|
|
401
|
+
* the union twice and imply you could link to a modal.
|
|
402
|
+
*/
|
|
403
|
+
function renderRouteTypes(manifest) {
|
|
404
|
+
const patterns = [...new Set(manifest.routes.map((route) => patternOf(route.segments)))].sort();
|
|
405
|
+
return [
|
|
406
|
+
'// @generated — do not edit. Written by the RSC build from the route tree.',
|
|
407
|
+
'//',
|
|
408
|
+
'// Turns Link, navigate() and route() into typed apis: an href that no route',
|
|
409
|
+
'// answers stops compiling. Delete this file and they fall back to `string`,',
|
|
410
|
+
'// which is what a project that has not built yet gets.',
|
|
411
|
+
'',
|
|
412
|
+
'// `export {}` is load-bearing: in a file with no import or export,',
|
|
413
|
+
'// `declare module` *replaces* the real module rather than augmenting it,',
|
|
414
|
+
'// and Href and route() vanish from it with no error to explain why.',
|
|
415
|
+
'export {}',
|
|
416
|
+
'',
|
|
417
|
+
"declare module '@rsc-kit/core/routes' {",
|
|
418
|
+
' interface Register {',
|
|
419
|
+
patterns.length > 0
|
|
420
|
+
? ' routes:\n' + patterns.map((p) => ' | ' + JSON.stringify(p)).join('\n')
|
|
421
|
+
: ' // No routes found under the source directory.\n routes: never',
|
|
422
|
+
' }',
|
|
423
|
+
'}',
|
|
424
|
+
'',
|
|
425
|
+
].join('\n');
|
|
426
|
+
}
|
|
427
|
+
/** The "use server" module exposing each host function as a plain async call. */
|
|
428
|
+
function renderHostActions() {
|
|
429
|
+
const lines = [
|
|
430
|
+
'"use server";',
|
|
431
|
+
'// @generated — do not edit. Written by the RSC build from the host action map.',
|
|
432
|
+
'',
|
|
433
|
+
];
|
|
434
|
+
for (const [name, target] of Object.entries(hostActions)) {
|
|
435
|
+
lines.push('export async function ' + name + '(...args: unknown[]) {');
|
|
436
|
+
lines.push(' return await (globalThis as any).' + hostGlobal + '(' + JSON.stringify(target) + ', ...args);');
|
|
437
|
+
lines.push('}');
|
|
438
|
+
lines.push('');
|
|
439
|
+
}
|
|
440
|
+
return lines.join('\n');
|
|
441
|
+
}
|
|
442
|
+
/** Ambient declaration for the host global, written beside the app's source. */
|
|
443
|
+
function renderHostGlobalTypes() {
|
|
444
|
+
return [
|
|
445
|
+
'// @generated — do not edit.',
|
|
446
|
+
'//',
|
|
447
|
+
'// ' + hostGlobal + '() is installed on globalThis by the RSC worker, so it has no',
|
|
448
|
+
'// import to resolve. This declares it for the typechecker; run',
|
|
449
|
+
'// `tsc --noEmit` to catch calls to a host global that no longer exists.',
|
|
450
|
+
'//',
|
|
451
|
+
'// Deliberately not a module — no import/export — so the declaration is',
|
|
452
|
+
'// global to the project without every file having to reference it.',
|
|
453
|
+
'',
|
|
454
|
+
'declare function ' + hostGlobal + '<T = unknown>(name: string, ...args: unknown[]): Promise<T>;',
|
|
455
|
+
'',
|
|
456
|
+
].join('\n');
|
|
457
|
+
}
|
|
458
|
+
// ── Discovery ────────────────────────────────────────────────────────────────
|
|
459
|
+
const ROUTE_FILES = ['page', 'layout', 'loading', 'default', 'middleware'];
|
|
460
|
+
/** `orders.section.tsx` — a region of a page that can be refreshed by name. */
|
|
461
|
+
const SECTION_FILE = /\.section\.(tsx|jsx|ts|js)$/;
|
|
462
|
+
const EXTS = ['tsx', 'jsx', 'ts', 'js'];
|
|
463
|
+
function findRouteFile(dir, base) {
|
|
464
|
+
for (const ext of EXTS) {
|
|
465
|
+
const p = join(dir, `${base}.${ext}`);
|
|
466
|
+
if (existsSync(p))
|
|
467
|
+
return p;
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
function componentName(absPath) {
|
|
472
|
+
const rel = relative(sourceDir, absPath).replace(/\\/g, '/');
|
|
473
|
+
return rel.replace(/\.(tsx|jsx|ts|js)$/, '');
|
|
474
|
+
}
|
|
475
|
+
function toAlias(name) {
|
|
476
|
+
return '_c_' + name.replace(/[^a-zA-Z0-9]/g, '_');
|
|
477
|
+
}
|
|
478
|
+
const components = new Map();
|
|
479
|
+
function register(absPath) {
|
|
480
|
+
const name = componentName(absPath);
|
|
481
|
+
const existing = components.get(name);
|
|
482
|
+
if (existing)
|
|
483
|
+
return existing;
|
|
484
|
+
const c = { name, absPath, alias: toAlias(name) };
|
|
485
|
+
components.set(name, c);
|
|
486
|
+
return c;
|
|
487
|
+
}
|
|
488
|
+
/** Walk app/ collecting page/layout/loading/default/middleware components. */
|
|
489
|
+
function discover(dir) {
|
|
490
|
+
for (const base of ROUTE_FILES) {
|
|
491
|
+
const p = findRouteFile(dir, base);
|
|
492
|
+
if (p)
|
|
493
|
+
register(p);
|
|
494
|
+
}
|
|
495
|
+
// Named regions. Registered like any other component so the generated entry
|
|
496
|
+
// imports them — which is what runs section() and puts the name in the
|
|
497
|
+
// registry the server looks up to re-render one on its own.
|
|
498
|
+
for (const entry of readdirSync(dir)) {
|
|
499
|
+
if (SECTION_FILE.test(entry))
|
|
500
|
+
register(join(dir, entry));
|
|
501
|
+
}
|
|
502
|
+
for (const entry of readdirSync(dir)) {
|
|
503
|
+
const abs = join(dir, entry);
|
|
504
|
+
if (statSync(abs).isDirectory())
|
|
505
|
+
discover(abs);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Which of the two metadata exports a module actually has.
|
|
510
|
+
*
|
|
511
|
+
* Both are read separately because the generated entry names each one it
|
|
512
|
+
* mentions, and naming an export that is not there is a bundler warning on
|
|
513
|
+
* every build — `Import 'generateMetadata' will always be undefined`. Most
|
|
514
|
+
* pages export only the static object, so referencing both meant that warning
|
|
515
|
+
* for almost every route in an app.
|
|
516
|
+
*/
|
|
517
|
+
function metadataExports(absPath) {
|
|
518
|
+
const src = readFileSync(absPath, 'utf-8');
|
|
519
|
+
return {
|
|
520
|
+
static: /export\s+const\s+metadata\b/.test(src),
|
|
521
|
+
generate: /export\s+(async\s+)?function\s+generateMetadata\b/.test(src),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Whether a route ships the client runtime at all.
|
|
526
|
+
*
|
|
527
|
+
* Opting out buys back everything React costs on a page that has nothing to
|
|
528
|
+
* hydrate — react-dom alone is most of it. Declared rather than inferred: a
|
|
529
|
+
* page with no client components today may gain one tomorrow, and the build
|
|
530
|
+
* refusing that is the point.
|
|
531
|
+
*/
|
|
532
|
+
function shipsClientJs(absPath) {
|
|
533
|
+
const src = readFileSync(absPath, 'utf-8');
|
|
534
|
+
return !/export\s+const\s+clientJs\s*(:[^=]+)?=\s*false/.test(src);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Which urls exist for a parameterised route.
|
|
538
|
+
*
|
|
539
|
+
* The one thing about a route the build cannot work out for itself: only the
|
|
540
|
+
* app knows its slugs. Everything else about whether a page can be frozen is
|
|
541
|
+
* observed by rendering it — a page that suspends past the shell budget, or
|
|
542
|
+
* reaches for the host, says so by doing it. This is asked rather than
|
|
543
|
+
* inferred because there is nothing to infer it from.
|
|
544
|
+
*/
|
|
545
|
+
function hasStaticParams(absPath) {
|
|
546
|
+
const src = readFileSync(absPath, 'utf-8');
|
|
547
|
+
return /export\s+((async\s+)?function\s+generateStaticParams|const\s+generateStaticParams)/.test(src);
|
|
548
|
+
}
|
|
549
|
+
// ── Codegen ──────────────────────────────────────────────────────────────────
|
|
550
|
+
function generateEntryRsc() {
|
|
551
|
+
const imports = [];
|
|
552
|
+
const mapEntries = [];
|
|
553
|
+
const metaEntries = [];
|
|
554
|
+
const paramEntries = [];
|
|
555
|
+
for (const c of components.values()) {
|
|
556
|
+
imports.push(`import ${c.alias} from ${JSON.stringify(c.absPath)}`);
|
|
557
|
+
mapEntries.push(` ${JSON.stringify(c.name)}: ${c.alias},`);
|
|
558
|
+
const meta = metadataExports(c.absPath);
|
|
559
|
+
if (meta.static || meta.generate) {
|
|
560
|
+
imports.push(`import * as ${c.alias}_meta from ${JSON.stringify(c.absPath)}`);
|
|
561
|
+
const fields = [
|
|
562
|
+
meta.static ? `static: ${c.alias}_meta.metadata` : null,
|
|
563
|
+
meta.generate ? `generate: ${c.alias}_meta.generateMetadata` : null,
|
|
564
|
+
].filter(Boolean);
|
|
565
|
+
metaEntries.push(` ${JSON.stringify(c.name)}: { ${fields.join(', ')} },`);
|
|
566
|
+
}
|
|
567
|
+
if (hasStaticParams(c.absPath)) {
|
|
568
|
+
// The namespace import may already be in place for metadata; a second
|
|
569
|
+
// one of the same module is the same binding, so this is safe to repeat.
|
|
570
|
+
imports.push(`import * as ${c.alias}_params from ${JSON.stringify(c.absPath)}`);
|
|
571
|
+
paramEntries.push(` ${JSON.stringify(c.name)}: ${c.alias}_params.generateStaticParams,`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// The engine's own modules are named without an extension: this plugin runs
|
|
575
|
+
// from src/ in its own repo and from dist/ once published, and Vite resolves
|
|
576
|
+
// either. Naming .tsx here builds fine from source and fails after publish.
|
|
577
|
+
return `// GENERATED by rscRoutes() — do not edit.
|
|
578
|
+
import { SegmentBoundary } from ${JSON.stringify(join(packageDir, "js/SegmentBoundary"))}
|
|
579
|
+
import { DocumentTitle } from ${JSON.stringify(join(packageDir, "js/DocumentTitle"))}
|
|
580
|
+
import { SlotBoundary } from ${JSON.stringify(join(packageDir, "js/SlotBoundary"))}
|
|
581
|
+
import { sectionComponent } from ${JSON.stringify(join(packageDir, "js/section"))}
|
|
582
|
+
import { PathnameProvider } from ${JSON.stringify(join(packageDir, "js/PathnameProvider"))}
|
|
583
|
+
import { searchParams as requestSearchParams } from ${JSON.stringify(join(packageDir, "request"))}
|
|
584
|
+
import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
|
|
585
|
+
import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
|
|
586
|
+
import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
|
|
587
|
+
import { Suspense, createElement, Fragment } from 'react'
|
|
588
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
589
|
+
${imports.join('\n')}
|
|
590
|
+
|
|
591
|
+
type HostFn = (name: string, ...args: unknown[]) => Promise<unknown>
|
|
592
|
+
type LayoutEntry = { component: string; props?: Record<string, unknown> }
|
|
593
|
+
type SlotOverride = { component: string; props?: Record<string, unknown> }
|
|
594
|
+
|
|
595
|
+
const components: Record<string, any> = {
|
|
596
|
+
${mapEntries.join('\n')}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const metadataMap: Record<string, { static?: any; generate?: (p: any) => any }> = {
|
|
600
|
+
${metaEntries.join('\n')}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const staticParamsMap: Record<string, () => any> = {
|
|
604
|
+
${paramEntries.join('\n')}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The route table this bundle was built from.
|
|
609
|
+
*
|
|
610
|
+
* Embedded rather than read back from routes.json, so a host cannot pair a
|
|
611
|
+
* fresh bundle with a stale manifest — the two came out of the same build and
|
|
612
|
+
* now cannot be separated. The file is still written, because a host that
|
|
613
|
+
* cannot import a JavaScript module has no other way to read it.
|
|
614
|
+
*/
|
|
615
|
+
export function manifest(): any {
|
|
616
|
+
return ${JSON.stringify(routeManifest())}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* The param sets a route declares, or null when it declares none.
|
|
621
|
+
*
|
|
622
|
+
* Null and [] are different answers: no generateStaticParams means the route
|
|
623
|
+
* is rendered on demand, an empty array means the app looked and there is
|
|
624
|
+
* nothing to build. Collapsing them silently prerenders nothing for a route
|
|
625
|
+
* that asked for everything, or the reverse.
|
|
626
|
+
*/
|
|
627
|
+
export async function getStaticParams(component: string): Promise<Record<string, string>[] | null> {
|
|
628
|
+
const generate = staticParamsMap[component]
|
|
629
|
+
|
|
630
|
+
if (!generate) return null
|
|
631
|
+
|
|
632
|
+
return (await generate()) as Record<string, string>[]
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// The host installs its callable via installHostFn. The global must be set
|
|
636
|
+
// synchronously INSIDE each render fn (applyHost) right before
|
|
637
|
+
// renderToReadableStream — setting it once ahead of a separate render call does
|
|
638
|
+
// not reach the Flight render.
|
|
639
|
+
const HOST_GLOBAL = ${JSON.stringify(hostGlobal)}
|
|
640
|
+
|
|
641
|
+
let currentHost: HostFn | null = null
|
|
642
|
+
|
|
643
|
+
export function installHostFn(fn: HostFn) {
|
|
644
|
+
currentHost = fn
|
|
645
|
+
return () => {
|
|
646
|
+
if (currentHost === fn) currentHost = null
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* The probe's stand-in host, for whichever render is asking.
|
|
652
|
+
*
|
|
653
|
+
* Held in async context rather than on the global, because two prerenders
|
|
654
|
+
* running at once each need their own. The previous shape saved the real host,
|
|
655
|
+
* overwrote the global and restored it afterwards — correct for one render at
|
|
656
|
+
* a time and silently wrong for two: the second overwrites the first's saved
|
|
657
|
+
* value, and both pages then call whichever closure assigned last, so
|
|
658
|
+
* usedDynamicApis is recorded against the wrong page and routes are
|
|
659
|
+
* misclassified. Benign for a pure-JS host, which installs none; wrong for
|
|
660
|
+
* Laravel, which does.
|
|
661
|
+
*/
|
|
662
|
+
const probeHost = new AsyncLocalStorage<(...args: unknown[]) => Promise<unknown>>()
|
|
663
|
+
|
|
664
|
+
function applyHost() {
|
|
665
|
+
// A dispatcher, installed once. App code calls a global; which implementation
|
|
666
|
+
// that reaches is a question about the render it is inside.
|
|
667
|
+
;(globalThis as Record<string, unknown>)[HOST_GLOBAL] = (...args: unknown[]) =>
|
|
668
|
+
(probeHost.getStore() ?? currentHost)?.(...args)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Which layout renders a given slot.
|
|
673
|
+
*
|
|
674
|
+
* The slot component's own path names the directory that declares it:
|
|
675
|
+
* app/docs/@modal/default is declared in app/docs, and the layout there is the
|
|
676
|
+
* one whose props it belongs in. Falls back to the innermost layout when
|
|
677
|
+
* nothing matches, which is the shape a single-layout app has anyway.
|
|
678
|
+
*/
|
|
679
|
+
function ownerLayoutIndex(slotComponent: string, layouts: LayoutEntry[]): number {
|
|
680
|
+
const at = slotComponent.indexOf('/@')
|
|
681
|
+
if (at === -1) return layouts.length - 1
|
|
682
|
+
|
|
683
|
+
const ownerDir = slotComponent.slice(0, at)
|
|
684
|
+
const suffix = '/layout'
|
|
685
|
+
const found = layouts.findIndex(
|
|
686
|
+
(l) => (l.component.endsWith(suffix) ? l.component.slice(0, -suffix.length) : l.component) === ownerDir,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
return found === -1 ? layouts.length - 1 : found
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* The query string, prepared for a page that may never ask for it.
|
|
694
|
+
*
|
|
695
|
+
* Created for every render but awaited by almost none, so its rejection has to
|
|
696
|
+
* be claimed here: an unhandled one fails the render of a page that did
|
|
697
|
+
* nothing wrong. Awaiting it still surfaces the real error.
|
|
698
|
+
*/
|
|
699
|
+
function pageSearchParams(): Promise<URLSearchParams> {
|
|
700
|
+
const pending = requestSearchParams()
|
|
701
|
+
|
|
702
|
+
pending.catch(() => {})
|
|
703
|
+
|
|
704
|
+
return pending
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Composition: layout(outer..inner) > Suspense(loading, innermost-first) > page.
|
|
708
|
+
function buildElement(
|
|
709
|
+
component: string,
|
|
710
|
+
props: Record<string, unknown>,
|
|
711
|
+
layouts: LayoutEntry[],
|
|
712
|
+
loadings: string[],
|
|
713
|
+
parallelSlots: Record<string, string>,
|
|
714
|
+
slotOverrides: Record<string, SlotOverride>,
|
|
715
|
+
head: unknown[] = [],
|
|
716
|
+
from = 0,
|
|
717
|
+
pageKey = '',
|
|
718
|
+
bootstrap = true,
|
|
719
|
+
// What await params gives the page. A never-settling one is how the
|
|
720
|
+
// prerender probe says "not for any particular url": the page suspends where
|
|
721
|
+
// it reads, everything above it still paints, and that is a shell one file
|
|
722
|
+
// can serve for every url the route matches.
|
|
723
|
+
params: Promise<Record<string, unknown>> = Promise.resolve(props),
|
|
724
|
+
) {
|
|
725
|
+
const Component = components[component]
|
|
726
|
+
if (!Component) throw new Error('Unknown RSC component: ' + component)
|
|
727
|
+
|
|
728
|
+
// Awaitable rather than spread. Spread, a page reads its slug synchronously
|
|
729
|
+
// and renders to completion during the probe — producing a page about an
|
|
730
|
+
// invented value, right for nothing — which is why such a route could only
|
|
731
|
+
// ever be rendered per request.
|
|
732
|
+
let element = createElement(Component, { params, searchParams: pageSearchParams() })
|
|
733
|
+
|
|
734
|
+
for (let i = loadings.length - 1; i >= 0; i--) {
|
|
735
|
+
const Loading = components[loadings[i]]
|
|
736
|
+
element = createElement(Suspense, { fallback: Loading ? createElement(Loading) : null }, element)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// <title>/<meta> go OUTSIDE the Suspense boundaries so they reach the shell
|
|
740
|
+
// immediately — inside, they would be withheld until the page's data
|
|
741
|
+
// resolves, delaying the whole document on a slow page.
|
|
742
|
+
if (head.length) element = createElement(Fragment, null, ...head, element)
|
|
743
|
+
|
|
744
|
+
// A slot belongs to the layout in the directory that declares it, which is
|
|
745
|
+
// not necessarily the innermost one: slots are collected by walking up from
|
|
746
|
+
// the page to the app root. Handing every slot to the innermost layout drops
|
|
747
|
+
// any the innermost does not declare, silently — the page renders, the modal
|
|
748
|
+
// just never appears.
|
|
749
|
+
const slotsByLayout = new Map<number, Record<string, unknown>>()
|
|
750
|
+
|
|
751
|
+
for (const [slot, value] of Object.entries(parallelSlots)) {
|
|
752
|
+
const override = slotOverrides[slot]
|
|
753
|
+
let rendered: unknown = null
|
|
754
|
+
|
|
755
|
+
// Slot components are pages too — an interceptor is a page in a slot — so
|
|
756
|
+
// they get the same awaitables the page does rather than spread values.
|
|
757
|
+
if (override) {
|
|
758
|
+
const OverrideComp = components[override.component]
|
|
759
|
+
rendered = OverrideComp
|
|
760
|
+
? createElement(OverrideComp, {
|
|
761
|
+
params: Promise.resolve(override.props ?? {}),
|
|
762
|
+
searchParams: pageSearchParams(),
|
|
763
|
+
})
|
|
764
|
+
: null
|
|
765
|
+
} else {
|
|
766
|
+
const SlotComp = components[value]
|
|
767
|
+
rendered = SlotComp
|
|
768
|
+
? createElement(SlotComp, { params, searchParams: pageSearchParams() })
|
|
769
|
+
: null
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const owner = ownerLayoutIndex(value, layouts)
|
|
773
|
+
if (owner < from) continue
|
|
774
|
+
|
|
775
|
+
const bucket = slotsByLayout.get(owner) ?? {}
|
|
776
|
+
// Wrapped so an action can put a re-rendered slot here without the page
|
|
777
|
+
// around it being asked for again. With nothing stored the boundary
|
|
778
|
+
// renders exactly what is passed, so this changes nothing on its own.
|
|
779
|
+
bucket[slot] = bootstrap ? createElement(SlotBoundary, { name: slot }, rendered) : rendered
|
|
780
|
+
slotsByLayout.set(owner, bucket)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Indices stay absolute: from skips the layouts the client already has
|
|
784
|
+
// mounted, so slot ownership and boundary depth mean the same thing whether
|
|
785
|
+
// this is a whole document or one segment of it.
|
|
786
|
+
for (let i = layouts.length - 1; i >= from; i--) {
|
|
787
|
+
const Layout = components[layouts[i].component]
|
|
788
|
+
if (!Layout) continue
|
|
789
|
+
|
|
790
|
+
// The seam a navigation can replace on its own. Depth counts from the
|
|
791
|
+
// outermost layout, so depth 1 is everything below the root layout and the
|
|
792
|
+
// deepest boundary wraps the page alone. With nothing in the client store
|
|
793
|
+
// these render their children unchanged.
|
|
794
|
+
//
|
|
795
|
+
// It is a client component, so a route shipping no runtime must not get
|
|
796
|
+
// one — otherwise every page would drag React in for a seam nothing can
|
|
797
|
+
// use, and no page could ever be JS-free.
|
|
798
|
+
if (bootstrap) {
|
|
799
|
+
element = createElement(SegmentBoundary, { depth: i + 1, pageKey }, element)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
element = createElement(Layout, {
|
|
803
|
+
...(layouts[i].props ?? {}),
|
|
804
|
+
...(slotsByLayout.get(i) ?? {}),
|
|
805
|
+
children: element,
|
|
806
|
+
})
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// The url the client hooks answer with during a server render. Outside the
|
|
810
|
+
// boundaries, so a page keeps it across a partial navigation; omitted with
|
|
811
|
+
// the runtime, since a route shipping none has nothing to read it.
|
|
812
|
+
if (bootstrap && pageKey) {
|
|
813
|
+
element = createElement(PathnameProvider, { value: pageKey }, element)
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return element
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Resolve route metadata into React elements. React 19 hoists <title>/<meta>
|
|
820
|
+
// rendered anywhere in the tree into <head> — so the "vite way" for metadata is
|
|
821
|
+
// to render it as elements, no PHP-side <head> string injection.
|
|
822
|
+
async function renderTree(
|
|
823
|
+
component: string,
|
|
824
|
+
props: Record<string, unknown>,
|
|
825
|
+
layouts: LayoutEntry[],
|
|
826
|
+
loadings: string[],
|
|
827
|
+
parallelSlots: Record<string, string>,
|
|
828
|
+
slotOverrides: Record<string, SlotOverride>,
|
|
829
|
+
from = 0,
|
|
830
|
+
pageKey = '',
|
|
831
|
+
bootstrap = true,
|
|
832
|
+
params?: Promise<Record<string, unknown>>,
|
|
833
|
+
) {
|
|
834
|
+
// The FULL chain, always: a title template lives on an outer layout, and a
|
|
835
|
+
// partial render still has to produce the same <title> the whole document
|
|
836
|
+
// would have.
|
|
837
|
+
const md = await resolveMetadata(component, props, layouts)
|
|
838
|
+
const head: unknown[] = []
|
|
839
|
+
|
|
840
|
+
if (md) {
|
|
841
|
+
if (md.title != null) {
|
|
842
|
+
// The element is what a server render puts in <head>, and what a route
|
|
843
|
+
// with no runtime relies on entirely.
|
|
844
|
+
head.push(createElement('title', { key: '__t' }, String(md.title)))
|
|
845
|
+
|
|
846
|
+
// And the effect is what keeps it right once pages are retained — see
|
|
847
|
+
// DocumentTitle. Only where there is a runtime to run it: a client
|
|
848
|
+
// component on a route that ships none is refused by the build.
|
|
849
|
+
if (bootstrap) head.push(createElement(DocumentTitle, { key: '__ts', title: String(md.title) }))
|
|
850
|
+
}
|
|
851
|
+
if (md.description != null) head.push(createElement('meta', { key: '__d', name: 'description', content: String(md.description) }))
|
|
852
|
+
for (const [k, v] of Object.entries(md)) {
|
|
853
|
+
if (k === 'title' || k === 'description' || v == null) continue
|
|
854
|
+
head.push(createElement('meta', { key: '__m_' + k, name: k, content: String(v) }))
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Metadata elements are rendered INSIDE the document tree so React 19 hoists
|
|
859
|
+
// <title>/<meta> into <head> (hoisting only works from within the tree).
|
|
860
|
+
return buildElement(component, props, layouts, loadings, parallelSlots, slotOverrides, head, from, pageKey, bootstrap, params)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* The shallowest layout this render must actually produce.
|
|
865
|
+
*
|
|
866
|
+
* An interceptor replaces a slot on the layout that declares it. If that layout
|
|
867
|
+
* is one the client already has, a partial render would never reach it and the
|
|
868
|
+
* modal would silently not appear — so the render is widened to include it.
|
|
869
|
+
*/
|
|
870
|
+
function segmentStart(
|
|
871
|
+
from: number,
|
|
872
|
+
layouts: LayoutEntry[],
|
|
873
|
+
parallelSlots: Record<string, string>,
|
|
874
|
+
slotOverrides: Record<string, SlotOverride>,
|
|
875
|
+
): number {
|
|
876
|
+
let start = from
|
|
877
|
+
|
|
878
|
+
for (const slot of Object.keys(slotOverrides)) {
|
|
879
|
+
const declared = parallelSlots[slot]
|
|
880
|
+
if (!declared) continue
|
|
881
|
+
|
|
882
|
+
const owner = ownerLayoutIndex(declared, layouts)
|
|
883
|
+
if (owner < start) start = owner
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return start
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* Run a route's middleware before anything at or below them is rendered.
|
|
891
|
+
*
|
|
892
|
+
* A guard is middleware.ts in a directory: a function that returns nothing and
|
|
893
|
+
* refuses by redirecting or throwing. It exists because a check is not UI, and
|
|
894
|
+
* making it one was the problem — a layout that checks who you are is also a
|
|
895
|
+
* layout that fetches a nav bar, and the two have opposite needs.
|
|
896
|
+
*
|
|
897
|
+
* Layouts are skipped on a partial navigation, which is the whole point of
|
|
898
|
+
* partial navigation, and the client decides how many to skip by naming what
|
|
899
|
+
* it claims to hold. Nothing verifies that claim; nothing can. So a check that
|
|
900
|
+
* lives in a layout is a check the caller can decline. Forcing the layout to
|
|
901
|
+
* run instead makes every navigation pay for its data fetching to re-run a
|
|
902
|
+
* check that costs one query.
|
|
903
|
+
*
|
|
904
|
+
* Guards are not part of that arithmetic. Every render path runs the whole
|
|
905
|
+
* chain, in order, outermost first — a full load, a partial navigation, a
|
|
906
|
+
* revalidation, an interception. There is no marker to forget: the file is the
|
|
907
|
+
* declaration.
|
|
908
|
+
*/
|
|
909
|
+
let middlewareChains: Record<string, string[]> | null = null
|
|
910
|
+
|
|
911
|
+
async function runMiddleware(component: string, props: Record<string, unknown> = {}): Promise<void> {
|
|
912
|
+
// Read from the route table rather than passed in, so every render path is
|
|
913
|
+
// covered by construction and no host has to remember to forward them.
|
|
914
|
+
if (!middlewareChains) {
|
|
915
|
+
middlewareChains = {}
|
|
916
|
+
|
|
917
|
+
for (const route of manifest().routes as { component: string; middleware?: string[] }[]) {
|
|
918
|
+
if (route.middleware?.length) middlewareChains[route.component] = route.middleware
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
for (const name of middlewareChains[component] ?? []) {
|
|
923
|
+
const guard = components[name]
|
|
924
|
+
|
|
925
|
+
// A declared guard that is not in the bundle is not "no guard" — it is a
|
|
926
|
+
// check that silently does not happen, which is the same reasoning the
|
|
927
|
+
// host applies when the engine cannot run middleware at all. Currently
|
|
928
|
+
// unreachable, because the chain and the component map come from one
|
|
929
|
+
// discovery pass; it is one refactor away from being reachable, and this
|
|
930
|
+
// is the place that has to fail closed.
|
|
931
|
+
if (!guard) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
'Route middleware ' + name + ' is declared for ' + component + ' but is not in the bundle.',
|
|
934
|
+
)
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Sequential and awaited, outermost first: an outer guard refusing means
|
|
938
|
+
// the inner one should never have been asked.
|
|
939
|
+
await guard(props)
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// SPA-navigation Flight stream (worker: rsc-stream).
|
|
944
|
+
/**
|
|
945
|
+
* Run a route's middleware without rendering anything.
|
|
946
|
+
*
|
|
947
|
+
* For a host serving a page it did not render: a frozen page is read from disk
|
|
948
|
+
* and never touches the engine, so the check has to be asked for. Refusing
|
|
949
|
+
* throws, exactly as it does mid-render.
|
|
950
|
+
*/
|
|
951
|
+
export async function runRouteMiddleware(component: string, props: Record<string, unknown> = {}): Promise<void> {
|
|
952
|
+
applyHost()
|
|
953
|
+
|
|
954
|
+
return runMiddleware(component, props)
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export async function handleRscStream(
|
|
958
|
+
component: string,
|
|
959
|
+
props: Record<string, unknown> = {},
|
|
960
|
+
layouts: LayoutEntry[] = [],
|
|
961
|
+
loadings: string[] = [],
|
|
962
|
+
parallelSlots: Record<string, string> = {},
|
|
963
|
+
slotOverrides: Record<string, SlotOverride> = {},
|
|
964
|
+
from = 0,
|
|
965
|
+
pageKey = '',
|
|
966
|
+
): Promise<{ stream: ReadableStream; clientChunks: unknown; segmentDepth: number }> {
|
|
967
|
+
applyHost()
|
|
968
|
+
|
|
969
|
+
// The host proposes how much the client already has; the engine decides what
|
|
970
|
+
// is actually safe to skip and reports back what it rendered.
|
|
971
|
+
const start = segmentStart(from, layouts, parallelSlots, slotOverrides)
|
|
972
|
+
|
|
973
|
+
// Before anything below them is rendered, never after.
|
|
974
|
+
await runMiddleware(component, props)
|
|
975
|
+
|
|
976
|
+
return {
|
|
977
|
+
stream: renderToReadableStream(
|
|
978
|
+
await renderTree(component, props, layouts, loadings, parallelSlots, slotOverrides, start, pageKey),
|
|
979
|
+
{ onError: flightOnError },
|
|
980
|
+
),
|
|
981
|
+
clientChunks: {},
|
|
982
|
+
segmentDepth: start,
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The digest React sends to the client in place of a server error's message.
|
|
988
|
+
*
|
|
989
|
+
* A redirect thrown after the shell has flushed has no header left to travel
|
|
990
|
+
* in — the status line is already sent. React transmits a digest for every
|
|
991
|
+
* server error, in production as well as development, so the destination
|
|
992
|
+
* rides there and the client's boundary performs it.
|
|
993
|
+
*
|
|
994
|
+
* redirectDigest is imported by the generated entry, at the top of this
|
|
995
|
+
* template. An import added to this file instead compiles and bundles without
|
|
996
|
+
* complaint, and then throws at render time against a name that is not there.
|
|
997
|
+
*
|
|
998
|
+
* Returning undefined leaves React's own behaviour alone for everything else.
|
|
999
|
+
*/
|
|
1000
|
+
function flightOnError(error: unknown): string | undefined {
|
|
1001
|
+
const digest = redirectDigest(error)
|
|
1002
|
+
|
|
1003
|
+
if (digest) return digest
|
|
1004
|
+
|
|
1005
|
+
console.error('[rsc-routes]', error)
|
|
1006
|
+
|
|
1007
|
+
return undefined
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Initial-load HTML stream + hydration payload (worker: rsc-html-stream).
|
|
1011
|
+
export async function handleRscHtmlStream(
|
|
1012
|
+
component: string,
|
|
1013
|
+
props: Record<string, unknown> = {},
|
|
1014
|
+
layouts: LayoutEntry[] = [],
|
|
1015
|
+
loadings: string[] = [],
|
|
1016
|
+
parallelSlots: Record<string, string> = {},
|
|
1017
|
+
slotOverrides: Record<string, SlotOverride> = {},
|
|
1018
|
+
nonce?: string,
|
|
1019
|
+
pageKey = '',
|
|
1020
|
+
bootstrap = true,
|
|
1021
|
+
): Promise<{ htmlStream: ReadableStream; rscPayloadPromise: Promise<string>; clientChunks: unknown }> {
|
|
1022
|
+
applyHost()
|
|
1023
|
+
await runMiddleware(component, props)
|
|
1024
|
+
const flight = renderToReadableStream(
|
|
1025
|
+
await renderTree(component, props, layouts, loadings, parallelSlots, slotOverrides, 0, pageKey, bootstrap),
|
|
1026
|
+
{ onError: flightOnError },
|
|
1027
|
+
)
|
|
1028
|
+
const [forHtml, forPayload] = flight.tee()
|
|
1029
|
+
const rscPayloadPromise = new Response(forPayload).text()
|
|
1030
|
+
const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
|
|
1031
|
+
const htmlStream = await ssr.handleSsr(forHtml, nonce, undefined, bootstrap)
|
|
1032
|
+
return { htmlStream, rscPayloadPromise, clientChunks: {} }
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Server action (worker: rsc-action).
|
|
1036
|
+
/** The page an action was invoked from, so what it invalidated can be rendered. */
|
|
1037
|
+
interface PageContext {
|
|
1038
|
+
component: string
|
|
1039
|
+
props: Record<string, unknown>
|
|
1040
|
+
layouts: LayoutEntry[]
|
|
1041
|
+
loadings: string[]
|
|
1042
|
+
parallelSlots: Record<string, string>
|
|
1043
|
+
/**
|
|
1044
|
+
* The sections this route declares, which is what bounds a revalidate.
|
|
1045
|
+
*
|
|
1046
|
+
* Optional only because a host built against an older manifest may not send
|
|
1047
|
+
* it; absent, no named section can be revalidated at all. Refusing is the
|
|
1048
|
+
* safe reading — the alternative is the registry, which holds every section
|
|
1049
|
+
* in the app.
|
|
1050
|
+
*/
|
|
1051
|
+
sections?: string[]
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Render one thing an action said it invalidated.
|
|
1056
|
+
*
|
|
1057
|
+
* 'all' the whole document, layouts included
|
|
1058
|
+
* 'page' everything below the layouts, which stay as they are
|
|
1059
|
+
* <slot> a single parallel slot, by the name its directory gave it
|
|
1060
|
+
*
|
|
1061
|
+
* A slot is the only unit smaller than a page the server can name, which is
|
|
1062
|
+
* why two tables have to be slots to be refreshed apart from each other.
|
|
1063
|
+
*/
|
|
1064
|
+
async function renderRevalidated(target: string, page: PageContext): Promise<unknown> {
|
|
1065
|
+
// Every target below 'all' renders without the layout chain above it, which
|
|
1066
|
+
// is the same skip a navigation performs and needs the same guard run.
|
|
1067
|
+
await runMiddleware(page.component, page.props)
|
|
1068
|
+
|
|
1069
|
+
if (target === 'all' || target === 'page') {
|
|
1070
|
+
return renderTree(
|
|
1071
|
+
page.component,
|
|
1072
|
+
page.props,
|
|
1073
|
+
page.layouts,
|
|
1074
|
+
page.loadings,
|
|
1075
|
+
page.parallelSlots,
|
|
1076
|
+
{},
|
|
1077
|
+
target === 'all' ? 0 : page.layouts.length,
|
|
1078
|
+
'',
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// A named region first: it is the lighter of the two, and the one a page
|
|
1083
|
+
// reaches for when it only wants part of itself refreshed.
|
|
1084
|
+
//
|
|
1085
|
+
// Scoped to the sections this route declares. The registry is a module-level
|
|
1086
|
+
// map keyed by name, and the generated entry imports every component in the
|
|
1087
|
+
// app eagerly, so a name-keyed registry holds every section in the app by the
|
|
1088
|
+
// time a request arrives — and two pages may legitimately both call theirs
|
|
1089
|
+
// 'stats', where the last one loaded wins.
|
|
1090
|
+
//
|
|
1091
|
+
// So the target is resolved through the module this route declares, not
|
|
1092
|
+
// through a shared map: the manifest says which section files belong to this
|
|
1093
|
+
// page, the components map turns one into its module, and section() left the
|
|
1094
|
+
// unwrapped component on the export. Identity, rather than string matching.
|
|
1095
|
+
//
|
|
1096
|
+
// Read from the manifest rather than from the message. The host is not the
|
|
1097
|
+
// adversary here, but the route table is build-time truth and already in this
|
|
1098
|
+
// bundle, so there is no reason to depend on a field a host has to remember
|
|
1099
|
+
// to send — one that forgot would be silently unprotected.
|
|
1100
|
+
const owner = manifest().routes.find((route: any) => route.component === page.component)
|
|
1101
|
+
const declared: string[] = owner?.sections ?? page.sections ?? []
|
|
1102
|
+
|
|
1103
|
+
// A target of orders names the file app/ledger/orders.section. Matched on
|
|
1104
|
+
// that stem, so a target cannot reach a sibling by suffix.
|
|
1105
|
+
const path = declared.find((name: string) => name.split('/').pop() === target + '.section')
|
|
1106
|
+
const Section = path ? sectionComponent(components[path]) : undefined
|
|
1107
|
+
|
|
1108
|
+
// No throw here: the target may be a slot, which the branch below resolves.
|
|
1109
|
+
// A name that is a section of some *other* page simply does not match, falls
|
|
1110
|
+
// through, and is refused there — where the error can name both kinds.
|
|
1111
|
+
|
|
1112
|
+
if (Section) {
|
|
1113
|
+
// The component, not the wrapper section() returned. The client replaces
|
|
1114
|
+
// what is inside the boundary, so sending the wrapper would nest a new
|
|
1115
|
+
// boundary inside the old one on every refresh.
|
|
1116
|
+
return createElement(Section, page.props)
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// hasOwn, so a target of __proto__ or constructor names nothing.
|
|
1120
|
+
const slotComponent = Object.hasOwn(page.parallelSlots, target)
|
|
1121
|
+
? page.parallelSlots[target]
|
|
1122
|
+
: undefined
|
|
1123
|
+
|
|
1124
|
+
if (!slotComponent) {
|
|
1125
|
+
throw new Error(
|
|
1126
|
+
'Cannot revalidate ' + target + ': no section or slot of this page by that name. ' +
|
|
1127
|
+
'Sections: ' +
|
|
1128
|
+
(declared.map((n: string) => n.split('/').pop()!.replace('.section', '')).join(', ') ||
|
|
1129
|
+
'none') +
|
|
1130
|
+
'. Slots: ' +
|
|
1131
|
+
(Object.keys(page.parallelSlots).join(', ') || 'none'),
|
|
1132
|
+
)
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const SlotComp = components[slotComponent]
|
|
1136
|
+
|
|
1137
|
+
if (!SlotComp) throw new Error('Unknown RSC component: ' + slotComponent)
|
|
1138
|
+
|
|
1139
|
+
return createElement(SlotComp, {
|
|
1140
|
+
params: Promise.resolve(page.props),
|
|
1141
|
+
searchParams: pageSearchParams(),
|
|
1142
|
+
})
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
export async function handleAction(
|
|
1146
|
+
actionId: string,
|
|
1147
|
+
body: string | FormData | Uint8Array,
|
|
1148
|
+
contentType = 'text/plain',
|
|
1149
|
+
page?: PageContext,
|
|
1150
|
+
takeRevalidated?: () => string[],
|
|
1151
|
+
): Promise<{ stream: ReadableStream }> {
|
|
1152
|
+
applyHost()
|
|
1153
|
+
|
|
1154
|
+
// Every body arrives as bytes on its own socket frame — an upload because it
|
|
1155
|
+
// has to, the rest because the transport does not special-case them. What
|
|
1156
|
+
// differs is what they decode to: multipart is FormData, everything else is
|
|
1157
|
+
// the text encodeReply produced.
|
|
1158
|
+
//
|
|
1159
|
+
// Treating only multipart as bytes and leaving the rest empty is a silent
|
|
1160
|
+
// failure: the action runs with no arguments at all.
|
|
1161
|
+
let decodable: string | FormData
|
|
1162
|
+
|
|
1163
|
+
if (typeof body === 'string') {
|
|
1164
|
+
decodable = body
|
|
1165
|
+
} else if (contentType.includes('multipart/form-data')) {
|
|
1166
|
+
decodable = await new Response(body, { headers: { 'Content-Type': contentType } }).formData()
|
|
1167
|
+
} else {
|
|
1168
|
+
decodable = new TextDecoder().decode(body)
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Checked before decoding, because React's decoder does not fail cleanly on
|
|
1172
|
+
// a malformed payload: the parse error is raised inside a chunk nobody
|
|
1173
|
+
// awaits, so the promise decodeReply returned never settles. The caller
|
|
1174
|
+
// cannot catch that — no try/catch anywhere sees it — and the request hangs
|
|
1175
|
+
// while the rejection escapes. On Node, whose default is to exit on an
|
|
1176
|
+
// unhandled rejection, that is the whole process, reachable by anyone who
|
|
1177
|
+
// can post to the action endpoint.
|
|
1178
|
+
//
|
|
1179
|
+
// A reply that is not multipart is the JSON model encodeReply produced, so
|
|
1180
|
+
// parsing it is both the check and the whole of it.
|
|
1181
|
+
if (typeof decodable === 'string') {
|
|
1182
|
+
try {
|
|
1183
|
+
JSON.parse(decodable)
|
|
1184
|
+
} catch {
|
|
1185
|
+
throw new Error('Malformed server action body: expected the payload encodeReply produces.')
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const args = (await decodeReply(decodable)) as unknown[]
|
|
1190
|
+
const action = await loadServerAction(actionId)
|
|
1191
|
+
const result = await (action as (...a: unknown[]) => unknown)(...args)
|
|
1192
|
+
|
|
1193
|
+
// Read after the action has run: what it invalidated is only known once its
|
|
1194
|
+
// host calls have been made. Rendering here rather than telling the browser
|
|
1195
|
+
// to ask is the whole point — the answer carries what went stale with it.
|
|
1196
|
+
const targets = takeRevalidated?.() ?? []
|
|
1197
|
+
|
|
1198
|
+
if (targets.length === 0 || !page) {
|
|
1199
|
+
return { stream: renderToReadableStream(result) }
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const revalidated: Record<string, unknown> = {}
|
|
1203
|
+
|
|
1204
|
+
for (const target of targets) {
|
|
1205
|
+
revalidated[target] = await renderRevalidated(target, page)
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// Marked, so an action whose own result happens to be an object with a
|
|
1209
|
+
// 'result' key is not mistaken for this envelope.
|
|
1210
|
+
return { stream: renderToReadableStream({ __rscRevalidated: revalidated, result }) }
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
export async function resolveMetadata(
|
|
1214
|
+
component: string,
|
|
1215
|
+
props: Record<string, unknown> = {},
|
|
1216
|
+
layouts: LayoutEntry[] = [],
|
|
1217
|
+
): Promise<Record<string, unknown> | null> {
|
|
1218
|
+
const pageEntry = metadataMap[component]
|
|
1219
|
+
const page: Record<string, unknown> = pageEntry
|
|
1220
|
+
? (pageEntry.generate
|
|
1221
|
+
// The same awaitables the page receives. Resolved rather than
|
|
1222
|
+
// suspending, even during the probe: a title has to be produced for
|
|
1223
|
+
// the shell, and there is no fallback for a <title>.
|
|
1224
|
+
? ((await pageEntry.generate({
|
|
1225
|
+
params: Promise.resolve(props),
|
|
1226
|
+
searchParams: pageSearchParams(),
|
|
1227
|
+
})) ?? {})
|
|
1228
|
+
: { ...(pageEntry.static ?? {}) })
|
|
1229
|
+
: {}
|
|
1230
|
+
|
|
1231
|
+
// Non-title metadata: layout defaults (outer→inner), page overrides.
|
|
1232
|
+
const merged: Record<string, unknown> = {}
|
|
1233
|
+
for (const l of layouts) {
|
|
1234
|
+
const s = metadataMap[l.component]?.static
|
|
1235
|
+
if (s) for (const [k, v] of Object.entries(s)) if (k !== 'title') merged[k] = v
|
|
1236
|
+
}
|
|
1237
|
+
for (const [k, v] of Object.entries(page)) if (k !== 'title') merged[k] = v
|
|
1238
|
+
|
|
1239
|
+
// Title: the page title with the NEAREST layout title.template applied; if the
|
|
1240
|
+
// page has no title, the nearest layout default/string title.
|
|
1241
|
+
let title: string | undefined = typeof page.title === 'string' ? page.title : undefined
|
|
1242
|
+
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
1243
|
+
const lt = metadataMap[layouts[i].component]?.static?.title as
|
|
1244
|
+
| string | { template?: string; default?: string } | undefined
|
|
1245
|
+
if (lt && typeof lt === 'object') {
|
|
1246
|
+
if (title != null && lt.template) { title = lt.template.replace('%s', title); break }
|
|
1247
|
+
if (title == null && lt.default) { title = lt.default; break }
|
|
1248
|
+
} else if (title == null && typeof lt === 'string') { title = lt; break }
|
|
1249
|
+
}
|
|
1250
|
+
if (title != null) merged.title = title
|
|
1251
|
+
|
|
1252
|
+
return Object.keys(merged).length ? merged : null
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Buffered render (worker: rsc / rscWithoutCallbacks — used at prerender time).
|
|
1256
|
+
export async function handleRsc(
|
|
1257
|
+
component: string,
|
|
1258
|
+
props: Record<string, unknown> = {},
|
|
1259
|
+
_callbackSocket: string | null = null,
|
|
1260
|
+
layouts: LayoutEntry[] = [],
|
|
1261
|
+
loadings: string[] = [],
|
|
1262
|
+
parallelSlots: Record<string, string> = {},
|
|
1263
|
+
from = 0,
|
|
1264
|
+
pageKey = '',
|
|
1265
|
+
bootstrap = true,
|
|
1266
|
+
): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; clientComponents: string[] }> {
|
|
1267
|
+
applyHost()
|
|
1268
|
+
// renderTree (not bare buildElement) so the prerendered Flight payload carries
|
|
1269
|
+
// the same <title>/<meta> elements the live SPA payload does.
|
|
1270
|
+
const flight = renderToReadableStream(
|
|
1271
|
+
await renderTree(component, props, layouts, loadings, parallelSlots, {}, from, pageKey, bootstrap),
|
|
1272
|
+
{ onError: flightOnError },
|
|
1273
|
+
)
|
|
1274
|
+
const [forHtml, forPayload] = flight.tee()
|
|
1275
|
+
const rscPayload = await new Response(forPayload).text()
|
|
1276
|
+
const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
|
|
1277
|
+
const htmlStream = await ssr.handleSsr(forHtml, undefined, undefined, bootstrap)
|
|
1278
|
+
const body = await new Response(htmlStream).text()
|
|
1279
|
+
|
|
1280
|
+
return {
|
|
1281
|
+
body,
|
|
1282
|
+
rscPayload,
|
|
1283
|
+
clientChunks: {},
|
|
1284
|
+
usedDynamicApis: false,
|
|
1285
|
+
// Client reference rows name the components the browser has to run. Shipping
|
|
1286
|
+
// no runtime would leave them as inert markup, so the host refuses — and
|
|
1287
|
+
// says which components forced the decision, since they are usually in a
|
|
1288
|
+
// shared layout rather than the page itself.
|
|
1289
|
+
clientComponents: clientReferenceNames(rscPayload),
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Names of the client components a payload references.
|
|
1295
|
+
*
|
|
1296
|
+
* A row reads 1:I["<module>",[],"Name",1]; the fourth quoted field is the
|
|
1297
|
+
* export. Parsed by splitting rather than matching, because this function is
|
|
1298
|
+
* emitted into a template literal where a regex would need double escaping.
|
|
1299
|
+
*/
|
|
1300
|
+
function clientReferenceNames(payload: string): string[] {
|
|
1301
|
+
const names = new Set<string>()
|
|
1302
|
+
|
|
1303
|
+
for (const row of payload.split(':I[').slice(1)) {
|
|
1304
|
+
const name = row.split('"')[3]
|
|
1305
|
+
if (name) names.add(name)
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
return [...names]
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Flight payload only (worker: rsc-payload — build-time).
|
|
1312
|
+
//
|
|
1313
|
+
// The segment variant of a prerendered route needs the payload and nothing
|
|
1314
|
+
// else. handleRsc also renders the HTML, which for that variant is built and
|
|
1315
|
+
// thrown away — a whole SSR pass per route for output nobody reads.
|
|
1316
|
+
/**
|
|
1317
|
+
* Render one thing on its own, for a client asking to refresh it.
|
|
1318
|
+
*
|
|
1319
|
+
* The same targets an action can mark. This is the path for a refresh nobody
|
|
1320
|
+
* mutated anything to earn — a button, a poll, a websocket saying the orders
|
|
1321
|
+
* table moved.
|
|
1322
|
+
*/
|
|
1323
|
+
export async function handleRscRevalidate(
|
|
1324
|
+
target: string,
|
|
1325
|
+
page: PageContext,
|
|
1326
|
+
): Promise<{ rscPayload: string }> {
|
|
1327
|
+
applyHost()
|
|
1328
|
+
|
|
1329
|
+
const flight = renderToReadableStream(await renderRevalidated(target, page))
|
|
1330
|
+
|
|
1331
|
+
return { rscPayload: await new Response(flight).text() }
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
export async function handleRscPayload(
|
|
1335
|
+
component: string,
|
|
1336
|
+
props: Record<string, unknown> = {},
|
|
1337
|
+
layouts: LayoutEntry[] = [],
|
|
1338
|
+
loadings: string[] = [],
|
|
1339
|
+
parallelSlots: Record<string, string> = {},
|
|
1340
|
+
from = 0,
|
|
1341
|
+
pageKey = '',
|
|
1342
|
+
): Promise<{ rscPayload: string }> {
|
|
1343
|
+
applyHost()
|
|
1344
|
+
|
|
1345
|
+
const flight = renderToReadableStream(
|
|
1346
|
+
await renderTree(component, props, layouts, loadings, parallelSlots, {}, from, pageKey),
|
|
1347
|
+
{ onError: flightOnError },
|
|
1348
|
+
)
|
|
1349
|
+
|
|
1350
|
+
return { rscPayload: await new Response(flight).text() }
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// PPR shell + classification (worker: rsc-ppr-shell — build-time).
|
|
1354
|
+
//
|
|
1355
|
+
// php() is replaced by a probe that records the call and never resolves, so
|
|
1356
|
+
// every subtree depending on per-request data stays suspended while everything
|
|
1357
|
+
// static renders normally. Whatever React has flushed when the deadline passes
|
|
1358
|
+
// IS the shell: layouts, static markup, and Suspense fallbacks.
|
|
1359
|
+
//
|
|
1360
|
+
// The two flags this returns are what the prerender pipeline classifies on:
|
|
1361
|
+
// usedDynamicApis — the page touched php(), so it cannot be frozen whole
|
|
1362
|
+
// timedOut — the render never finished, i.e. it is still waiting on
|
|
1363
|
+
// data, so only the shell is safe to cache
|
|
1364
|
+
// A page that sets neither is genuinely static and can be prerendered fully.
|
|
1365
|
+
const PPR_SHELL_TIMEOUT_MS = Number(process.env.RSC_PPR_TIMEOUT_MS || 2000)
|
|
1366
|
+
|
|
1367
|
+
export async function handleRscPprShell(
|
|
1368
|
+
component: string,
|
|
1369
|
+
props: Record<string, unknown> = {},
|
|
1370
|
+
layouts: LayoutEntry[] = [],
|
|
1371
|
+
loadings: string[] = [],
|
|
1372
|
+
parallelSlots: Record<string, string> = {},
|
|
1373
|
+
// The url this shell will be served for, when it is served for exactly one.
|
|
1374
|
+
// Empty for a parameterised route, whose shell is shared across every url it
|
|
1375
|
+
// matches and therefore cannot carry one.
|
|
1376
|
+
pageKey = '',
|
|
1377
|
+
// How long to let the render run before taking whatever has flushed.
|
|
1378
|
+
//
|
|
1379
|
+
// A parameter because not every caller is asking the same question. Deciding
|
|
1380
|
+
// what a page's shell IS needs the full budget — the point is to wait out
|
|
1381
|
+
// everything that can resolve. Asking whether anything paints at all without
|
|
1382
|
+
// a root fallback is a boolean about the first flush, and a page that paints,
|
|
1383
|
+
// paints immediately: giving that the same budget spends two seconds per
|
|
1384
|
+
// route to learn nothing the first millisecond did not say.
|
|
1385
|
+
budgetMs = PPR_SHELL_TIMEOUT_MS,
|
|
1386
|
+
): Promise<{ shellHtml: string; clientChunks: unknown; timedOut: boolean; usedDynamicApis: boolean; error?: string }> {
|
|
1387
|
+
// Deliberately no middleware here. The probe is asking whether the content is
|
|
1388
|
+
// the same for everyone, which is a question about the page. Whether a
|
|
1389
|
+
// particular caller may see it is a question about the request, and there is
|
|
1390
|
+
// no request at build time — running a guard here would refuse every time
|
|
1391
|
+
// and make every guarded route dynamic for the wrong reason.
|
|
1392
|
+
let usedDynamicApis = false
|
|
1393
|
+
|
|
1394
|
+
applyHost()
|
|
1395
|
+
|
|
1396
|
+
const probe = (..._args: unknown[]) => {
|
|
1397
|
+
usedDynamicApis = true
|
|
1398
|
+
|
|
1399
|
+
// Never resolves: the awaiting component suspends and React renders its
|
|
1400
|
+
// Suspense fallback into the shell instead of the real content.
|
|
1401
|
+
return new Promise<unknown>(() => {})
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
let shellHtml = ''
|
|
1405
|
+
let completed = false
|
|
1406
|
+
let error: string | undefined
|
|
1407
|
+
let cancel: (() => void) | null = null
|
|
1408
|
+
|
|
1409
|
+
// Everything the render does happens inside the scope, so the stand-in host
|
|
1410
|
+
// travels with it rather than with the process.
|
|
1411
|
+
const produce = probeHost.run(probe, async () => {
|
|
1412
|
+
try {
|
|
1413
|
+
// Params settle only when this probe is for one concrete url. A route
|
|
1414
|
+
// that listed its urls is being rendered for one of them, so the page
|
|
1415
|
+
// can be frozen whole; a route that listed none is being rendered for
|
|
1416
|
+
// the pattern, where any value would be an invention.
|
|
1417
|
+
const tree = await renderTree(
|
|
1418
|
+
component,
|
|
1419
|
+
props,
|
|
1420
|
+
layouts,
|
|
1421
|
+
loadings,
|
|
1422
|
+
parallelSlots,
|
|
1423
|
+
{},
|
|
1424
|
+
0,
|
|
1425
|
+
pageKey,
|
|
1426
|
+
true,
|
|
1427
|
+
pageKey ? Promise.resolve(props) : new Promise(() => {}),
|
|
1428
|
+
)
|
|
1429
|
+
// Quiet about a redirect: during the probe it is a classification, not
|
|
1430
|
+
// a failure, and React would otherwise print a stack for every one.
|
|
1431
|
+
const flight = renderToReadableStream(tree, { onError: flightOnError })
|
|
1432
|
+
const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
|
|
1433
|
+
// Errors here are expected: the render is aborted once the shell is out.
|
|
1434
|
+
const htmlStream = await ssr.handleSsr(flight, undefined, () => {})
|
|
1435
|
+
|
|
1436
|
+
const reader = htmlStream.getReader()
|
|
1437
|
+
// Cancelling aborts the suspended SSR render, which surfaces React's
|
|
1438
|
+
// "render was aborted" both synchronously and as a rejection. Neither is
|
|
1439
|
+
// interesting — we already have the shell.
|
|
1440
|
+
cancel = () => {
|
|
1441
|
+
try {
|
|
1442
|
+
const pending = reader.cancel()
|
|
1443
|
+
if (pending && typeof pending.catch === 'function') pending.catch(() => {})
|
|
1444
|
+
} catch {}
|
|
1445
|
+
}
|
|
1446
|
+
const decoder = new TextDecoder()
|
|
1447
|
+
|
|
1448
|
+
while (true) {
|
|
1449
|
+
const { done, value } = await reader.read()
|
|
1450
|
+
if (done) break
|
|
1451
|
+
shellHtml += decoder.decode(value, { stream: true })
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
completed = true
|
|
1455
|
+
} catch (e: any) {
|
|
1456
|
+
error = e?.message ?? String(e)
|
|
1457
|
+
}
|
|
1458
|
+
})
|
|
1459
|
+
|
|
1460
|
+
await Promise.race([produce, new Promise((r) => setTimeout(r, budgetMs))])
|
|
1461
|
+
|
|
1462
|
+
// Release the suspended render; its pending php() promises never settle.
|
|
1463
|
+
// Nothing to restore: the stand-in host was scoped to this render, not
|
|
1464
|
+
// written over the global one.
|
|
1465
|
+
if (!completed) cancel?.()
|
|
1466
|
+
|
|
1467
|
+
return { shellHtml, clientChunks: {}, timedOut: !completed, usedDynamicApis, error }
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* What \`vite dev\` serves.
|
|
1472
|
+
*
|
|
1473
|
+
* @vitejs/plugin-rsc's dev server calls this module's default export for every
|
|
1474
|
+
* request, so implementing it is the whole of dev mode: the same handler the
|
|
1475
|
+
* production server builds, over the same route table, against modules Vite
|
|
1476
|
+
* re-evaluates on edit. Nothing is prebuilt, so there is no build to keep in
|
|
1477
|
+
* step and no NODE_ENV to match — this is React's development build because
|
|
1478
|
+
* Vite is running in development.
|
|
1479
|
+
*
|
|
1480
|
+
* Assets and prerendered pages are deliberately absent. Vite serves its own
|
|
1481
|
+
* assets in dev, and a frozen page is a build artifact: serving one here would
|
|
1482
|
+
* hand back the last build's HTML for a file just edited.
|
|
1483
|
+
*/
|
|
1484
|
+
let devHandler: ((request: Request) => Promise<Response | null>) | null = null
|
|
1485
|
+
|
|
1486
|
+
export default async function handler(request: Request): Promise<Response> {
|
|
1487
|
+
devHandler ??= createRscHandler({
|
|
1488
|
+
engine: {
|
|
1489
|
+
manifest,
|
|
1490
|
+
getStaticParams,
|
|
1491
|
+
installHostFn,
|
|
1492
|
+
handleRsc,
|
|
1493
|
+
handleRscStream,
|
|
1494
|
+
handleRscHtmlStream,
|
|
1495
|
+
handleRscRevalidate,
|
|
1496
|
+
handleRscPayload,
|
|
1497
|
+
handleRscPprShell,
|
|
1498
|
+
handleAction,
|
|
1499
|
+
resolveMetadata,
|
|
1500
|
+
runRouteMiddleware,
|
|
1501
|
+
} as never,
|
|
1502
|
+
})
|
|
1503
|
+
|
|
1504
|
+
return (await devHandler(request)) ?? new Response('Not found', { status: 404 })
|
|
1505
|
+
}
|
|
1506
|
+
`;
|
|
1507
|
+
}
|
|
1508
|
+
function generateEntrySsr() {
|
|
1509
|
+
const devUrls = join(packageDir, 'devUrls');
|
|
1510
|
+
return `// GENERATED by rscRoutes() — do not edit.
|
|
1511
|
+
import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
|
|
1512
|
+
import { renderToReadableStream } from 'react-dom/server.edge'
|
|
1513
|
+
import { rewriteViteDevUrlStream } from ${JSON.stringify(devUrls)}
|
|
1514
|
+
|
|
1515
|
+
// Set only by the dev server. @vitejs/plugin-rsc emits its bootstrap and CSS
|
|
1516
|
+
// links root-relative in dev, which would send the browser to the host for
|
|
1517
|
+
// modules only Vite can answer — see devUrls.ts.
|
|
1518
|
+
const DEV_ORIGIN = ${JSON.stringify(devOrigin)}
|
|
1519
|
+
|
|
1520
|
+
export async function handleSsr(
|
|
1521
|
+
rscStream: ReadableStream,
|
|
1522
|
+
nonce?: string,
|
|
1523
|
+
onError?: (error: unknown) => void,
|
|
1524
|
+
bootstrap = true,
|
|
1525
|
+
): Promise<ReadableStream> {
|
|
1526
|
+
const root = await createFromReadableStream(rscStream)
|
|
1527
|
+
|
|
1528
|
+
// Without the bootstrap the page ships no client runtime at all: no React,
|
|
1529
|
+
// no Flight client, no router. HTML only. A page with nothing interactive on
|
|
1530
|
+
// it has no use for 70kB of hydration.
|
|
1531
|
+
const bootstrapScriptContent = bootstrap
|
|
1532
|
+
? await (import.meta as any).viteRsc.loadBootstrapScriptContent('index')
|
|
1533
|
+
: undefined
|
|
1534
|
+
|
|
1535
|
+
// Without an onError handler React rejects each abortable task on its own,
|
|
1536
|
+
// and those rejections surface as unhandled — noisy for the PPR shell render,
|
|
1537
|
+
// which aborts on purpose once it has the shell.
|
|
1538
|
+
const html = await renderToReadableStream(root as any, {
|
|
1539
|
+
bootstrapScriptContent,
|
|
1540
|
+
nonce,
|
|
1541
|
+
onError: onError ?? ((error: unknown) => { console.error('[rsc-routes:ssr]', error) }),
|
|
1542
|
+
})
|
|
1543
|
+
|
|
1544
|
+
return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
|
|
1545
|
+
}
|
|
1546
|
+
`;
|
|
1547
|
+
}
|
|
1548
|
+
function generateEntryBrowser() {
|
|
1549
|
+
const clientBootstrap = join(packageDir, 'js/createViteRscApp');
|
|
1550
|
+
// Only for an exported build, and only what the client needs to work out how
|
|
1551
|
+
// much of a page to ask for: a file server sends no headers, so without this
|
|
1552
|
+
// every navigation takes the whole document and replaces the root. Inlined
|
|
1553
|
+
// rather than fetched, so it costs no request. Omitted entirely otherwise —
|
|
1554
|
+
// a server answers this, and shipping a route table to every browser for
|
|
1555
|
+
// nothing is a page-weight cost with no benefit.
|
|
1556
|
+
const routesForClient = staticPayloads
|
|
1557
|
+
? routeManifest().routes.map((route) => ({ segments: route.segments, layouts: route.layouts }))
|
|
1558
|
+
: null;
|
|
1559
|
+
const refreshModule = join(packageDir, 'js/navigate');
|
|
1560
|
+
return `// GENERATED by rscRoutes() — do not edit.
|
|
1561
|
+
import { createViteRscApp } from ${JSON.stringify(clientBootstrap)}
|
|
1562
|
+
import { refresh } from ${JSON.stringify(refreshModule)}
|
|
1563
|
+
|
|
1564
|
+
createViteRscApp(document, ${JSON.stringify(interceptManifest())}, ${JSON.stringify({
|
|
1565
|
+
staticPayloads: staticPayloads || null,
|
|
1566
|
+
routes: routesForClient,
|
|
1567
|
+
})})
|
|
1568
|
+
|
|
1569
|
+
// A server component is not a module the browser has, so Vite cannot replace
|
|
1570
|
+
// it the way it replaces a client one. @vitejs/plugin-rsc says so instead:
|
|
1571
|
+
// when a module in the rsc graph changes it sends this, and re-fetching the
|
|
1572
|
+
// payload is the update. Without a listener an edit to a page reaches the
|
|
1573
|
+
// server and stops there, and the browser goes on showing the old render
|
|
1574
|
+
// until someone reloads by hand.
|
|
1575
|
+
//
|
|
1576
|
+
// 'all', not 'page': a layout is a server component too, and refreshing only
|
|
1577
|
+
// below it would leave an edited layout on screen unchanged. It costs nothing
|
|
1578
|
+
// extra — a client component below is remounted either way, because the new
|
|
1579
|
+
// payload carries a fresh reference to its module. Editing that component
|
|
1580
|
+
// directly is the case where state survives, and that is Fast Refresh doing
|
|
1581
|
+
// it rather than this.
|
|
1582
|
+
if (import.meta.hot) {
|
|
1583
|
+
import.meta.hot.on('rsc:update', () => {
|
|
1584
|
+
void refresh('all')
|
|
1585
|
+
})
|
|
1586
|
+
}
|
|
1587
|
+
`;
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* Intercepted URL patterns, published by the host before the build.
|
|
1591
|
+
*
|
|
1592
|
+
* The client has to recognise an intercepted link before it asks the server,
|
|
1593
|
+
* so the patterns are baked into the browser entry. The host owns the file
|
|
1594
|
+
* because it owns route discovery; an absent or unreadable one just means no
|
|
1595
|
+
* interception, never a failed build.
|
|
1596
|
+
*/
|
|
1597
|
+
/**
|
|
1598
|
+
* The intercepted urls the client router has to recognise, in its dialect.
|
|
1599
|
+
*
|
|
1600
|
+
* Generated here rather than read from a file the host wrote. The host used to
|
|
1601
|
+
* discover these because it owned the walk of app/; now the plugin does, and a
|
|
1602
|
+
* host writing them meant producing this file before the build that needed it —
|
|
1603
|
+
* an ordering that only worked because the two steps happened to be in the
|
|
1604
|
+
* right sequence.
|
|
1605
|
+
*/
|
|
1606
|
+
function interceptManifest() {
|
|
1607
|
+
return routeManifest().intercepts.map((entry) => ({
|
|
1608
|
+
// The client writes [id] where a Laravel route writes {id}.
|
|
1609
|
+
urlPattern: '/' +
|
|
1610
|
+
entry.segments
|
|
1611
|
+
.map((seg) => (seg.type === 'static' ? seg.value : '[' + seg.value + ']'))
|
|
1612
|
+
.join('/'),
|
|
1613
|
+
slot: entry.slot,
|
|
1614
|
+
}));
|
|
1615
|
+
}
|
|
1616
|
+
// ── Validation ───────────────────────────────────────────────────────────────
|
|
1617
|
+
/**
|
|
1618
|
+
* Extract the body of a file's default-exported function.
|
|
1619
|
+
*
|
|
1620
|
+
* Only the page component's OWN body matters for the loading.tsx rule — sibling
|
|
1621
|
+
* components declared in the same file render behind their own boundaries, so
|
|
1622
|
+
* their host calls do not block the route's shell.
|
|
1623
|
+
*/
|
|
1624
|
+
function defaultExportBody(source) {
|
|
1625
|
+
const match = source.match(/export\s+default\s+(?:async\s+)?function[^(]*\([^)]*\)\s*{/);
|
|
1626
|
+
if (!match)
|
|
1627
|
+
return null;
|
|
1628
|
+
// Walk from the opening brace to its match, ignoring braces in strings.
|
|
1629
|
+
let depth = 0;
|
|
1630
|
+
const start = match.index + match[0].length - 1;
|
|
1631
|
+
for (let i = start; i < source.length; i++) {
|
|
1632
|
+
const ch = source[i];
|
|
1633
|
+
if (ch === '{')
|
|
1634
|
+
depth++;
|
|
1635
|
+
else if (ch === '}' && --depth === 0)
|
|
1636
|
+
return source.slice(start, i + 1);
|
|
1637
|
+
}
|
|
1638
|
+
return null;
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Does the page component's own render block on the host callable?
|
|
1642
|
+
*
|
|
1643
|
+
* Only an awaited call blocks. Starting a call and handing the promise to a
|
|
1644
|
+
* child — for a client component to unwrap with use() inside its own Suspense
|
|
1645
|
+
* boundary — lets the page paint immediately and needs no loading.tsx.
|
|
1646
|
+
*
|
|
1647
|
+
* The check is deliberately syntactic: a call awaited indirectly, through a
|
|
1648
|
+
* variable, is not caught. That errs toward letting a build through rather than
|
|
1649
|
+
* rejecting a page that is actually fine.
|
|
1650
|
+
*/
|
|
1651
|
+
function pageBlocksOnHostCall(source) {
|
|
1652
|
+
const isAsyncDefault = /export\s+default\s+async\s+function/.test(source);
|
|
1653
|
+
if (!isAsyncDefault)
|
|
1654
|
+
return false;
|
|
1655
|
+
const body = defaultExportBody(source);
|
|
1656
|
+
// Matches `await rpc(`, and the explicitly-qualified forms a typed codebase
|
|
1657
|
+
// may use: `await globalThis.rpc(` / `await (globalThis as any).rpc(`.
|
|
1658
|
+
const qualifier = '(?:\\(\\s*globalThis[^)]*\\)\\s*\\.\\s*|globalThis\\s*\\.\\s*)?';
|
|
1659
|
+
const awaited = new RegExp(`\\bawait\\s+${qualifier}${hostGlobal}\\s*[<(]`);
|
|
1660
|
+
return body !== null && awaited.test(body);
|
|
1661
|
+
}
|
|
1662
|
+
/** Walk up from the page directory to app/ looking for a loading file. */
|
|
1663
|
+
function hasLoadingInChain(pageDir) {
|
|
1664
|
+
let dir = pageDir;
|
|
1665
|
+
while (dir.startsWith(appDir)) {
|
|
1666
|
+
if (findRouteFile(dir, 'loading'))
|
|
1667
|
+
return true;
|
|
1668
|
+
if (dir === appDir)
|
|
1669
|
+
break;
|
|
1670
|
+
dir = dirname(dir);
|
|
1671
|
+
}
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* A route needs loading.tsx only when the PAGE ITSELF blocks — an async default
|
|
1676
|
+
* export awaiting the host callable, or the host resolving props dynamically. Both
|
|
1677
|
+
* suspend before anything can paint, so without a boundary the user sees a
|
|
1678
|
+
* blank screen. A page whose slow work lives in children behind their own
|
|
1679
|
+
* <Suspense> already paints a shell and needs nothing.
|
|
1680
|
+
*/
|
|
1681
|
+
function validateLoadingBoundaries() {
|
|
1682
|
+
const errors = [];
|
|
1683
|
+
for (const c of components.values()) {
|
|
1684
|
+
if (!c.name.endsWith('/page') && c.name !== 'app/page')
|
|
1685
|
+
continue;
|
|
1686
|
+
const pageDir = dirname(c.absPath);
|
|
1687
|
+
const source = readFileSync(c.absPath, 'utf-8');
|
|
1688
|
+
let reason = null;
|
|
1689
|
+
if (pageBlocksOnHostCall(source)) {
|
|
1690
|
+
reason = `its default export awaits ${hostGlobal}()`;
|
|
1691
|
+
}
|
|
1692
|
+
else {
|
|
1693
|
+
const configPath = routeConfig ? join(pageDir, routeConfig.file) : null;
|
|
1694
|
+
if (routeConfig && configPath && existsSync(configPath) && routeConfig.dynamicPattern.test(readFileSync(configPath, 'utf-8'))) {
|
|
1695
|
+
reason = `${routeConfig.file} resolves props dynamically`;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
if (reason && !hasLoadingInChain(pageDir)) {
|
|
1699
|
+
errors.push(` ${c.name} — ${reason}, but has no loading.tsx in its directory chain`);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
return errors;
|
|
1703
|
+
}
|
|
1704
|
+
// ── Plugin ───────────────────────────────────────────────────────────────────
|
|
1705
|
+
/** Names of plugins that transform JSX and must run after rsc() has split it. */
|
|
1706
|
+
const JSX_PLUGIN_PATTERN = /react|babel|oxc/i;
|
|
1707
|
+
export function rscRoutes(options = {}) {
|
|
1708
|
+
resolvePaths(options);
|
|
1709
|
+
const routesPlugin = {
|
|
1710
|
+
name: 'rsc-routes',
|
|
1711
|
+
config(_config, env) {
|
|
1712
|
+
if (!existsSync(appDir)) {
|
|
1713
|
+
throw new Error(`[rsc-routes] No app directory at ${appDir} — nothing to build.`);
|
|
1714
|
+
}
|
|
1715
|
+
components.clear();
|
|
1716
|
+
discover(appDir);
|
|
1717
|
+
log(`Discovered ${components.size} route components:`, [...components.keys()].join(', '));
|
|
1718
|
+
const loadingErrors = validateLoadingBoundaries();
|
|
1719
|
+
if (loadingErrors.length) {
|
|
1720
|
+
throw new Error('[rsc-routes] A page that blocks before it can paint needs a loading.tsx boundary.\n\n' +
|
|
1721
|
+
loadingErrors.join('\n') +
|
|
1722
|
+
'\n\nAdd loading.tsx in the page directory (or a parent), or move the slow work\n' +
|
|
1723
|
+
'into a child component wrapped in its own <Suspense> so the page can paint.');
|
|
1724
|
+
}
|
|
1725
|
+
// Before the entries, because the app's own source imports these and the
|
|
1726
|
+
// module graph is walked as soon as this hook returns.
|
|
1727
|
+
const manifest = routeManifest();
|
|
1728
|
+
writeHostBindings(manifest);
|
|
1729
|
+
if (existsSync(genDir))
|
|
1730
|
+
rmSync(genDir, { recursive: true, force: true });
|
|
1731
|
+
mkdirSync(genDir, { recursive: true });
|
|
1732
|
+
writeFileSync(join(genDir, 'entry.rsc.tsx'), generateEntryRsc());
|
|
1733
|
+
writeFileSync(join(genDir, 'entry.ssr.tsx'), generateEntrySsr());
|
|
1734
|
+
writeFileSync(join(genDir, 'entry.browser.tsx'), generateEntryBrowser());
|
|
1735
|
+
// Written beside the entries, for a host to read instead of walking the
|
|
1736
|
+
// route tree itself. Laravel scans it a second time today; a JS host
|
|
1737
|
+
// would otherwise have to write a third walk of the same directories.
|
|
1738
|
+
writeFileSync(join(outDir, 'routes.json'), JSON.stringify(manifest, null, 2));
|
|
1739
|
+
return {
|
|
1740
|
+
// Off, not merely unused: Vite warns when publicDir sits inside outDir,
|
|
1741
|
+
// and assetsDir is normally a directory under the build output. An app
|
|
1742
|
+
// that wants static files can set its own publicDir outside it.
|
|
1743
|
+
publicDir: false,
|
|
1744
|
+
/**
|
|
1745
|
+
* The mode this was built in, baked into the server bundles.
|
|
1746
|
+
*
|
|
1747
|
+
* Vite substitutes `process.env.NODE_ENV` for a client build and
|
|
1748
|
+
* leaves it alone for the server ones, because server code runs where
|
|
1749
|
+
* `process.env` is real. Reasonable in general, and wrong here: React
|
|
1750
|
+
* picks its build from that expression when its module is first
|
|
1751
|
+
* evaluated, so leaving it to the runtime makes every server carry a
|
|
1752
|
+
* NODE_ENV it must not get wrong — and a production bundle started
|
|
1753
|
+
* without one renders every page perfectly and hydrates none of them.
|
|
1754
|
+
*
|
|
1755
|
+
* The build already knows which mode it is. Saying so here means the
|
|
1756
|
+
* answer travels with the bundle instead of with whoever starts it.
|
|
1757
|
+
*/
|
|
1758
|
+
define: {
|
|
1759
|
+
'process.env.NODE_ENV': JSON.stringify(env.mode === 'development' ? 'development' : 'production'),
|
|
1760
|
+
},
|
|
1761
|
+
// Public URL for browser-facing client assets (served from public/ by
|
|
1762
|
+
// the web server — never through PHP).
|
|
1763
|
+
base: assetsBaseUrl,
|
|
1764
|
+
root: outDir,
|
|
1765
|
+
// Force single instances of React/RSC runtime — critical when the
|
|
1766
|
+
// package is symlinked (local dev / monorepo), else "use client"
|
|
1767
|
+
// components SSR against a second React copy and hooks throw.
|
|
1768
|
+
//
|
|
1769
|
+
// react-server-dom-webpack is deliberately absent: @vitejs/plugin-rsc
|
|
1770
|
+
// vendors its own copy, nothing here imports the specifier, and the
|
|
1771
|
+
// built bundles reference it zero times — deduping it was a no-op left
|
|
1772
|
+
// over from the hand-rolled engine.
|
|
1773
|
+
resolve: {
|
|
1774
|
+
dedupe: ['react', 'react-dom', '@vitejs/plugin-rsc'],
|
|
1775
|
+
// `import Link from '<packageAlias>/Link'` resolves to the client
|
|
1776
|
+
// runtime shipped here, for hosts that vendor this package outside
|
|
1777
|
+
// node_modules. Installed from npm the name resolves on its own.
|
|
1778
|
+
alias: aliasEntries(),
|
|
1779
|
+
},
|
|
1780
|
+
build: { emptyOutDir: true },
|
|
1781
|
+
environments: {
|
|
1782
|
+
// Server bundles — stay under the (non-public) out dir.
|
|
1783
|
+
rsc: { build: { rollupOptions: { input: { index: join(genDir, 'entry.rsc.tsx') } } } },
|
|
1784
|
+
ssr: { build: { rollupOptions: { input: { index: join(genDir, 'entry.ssr.tsx') } } } },
|
|
1785
|
+
// Client bundle — emitted into public/ for the web server to serve.
|
|
1786
|
+
client: {
|
|
1787
|
+
build: {
|
|
1788
|
+
outDir: publicAssetsDir,
|
|
1789
|
+
emptyOutDir: true,
|
|
1790
|
+
rollupOptions: { input: { index: join(genDir, 'entry.browser.tsx') } },
|
|
1791
|
+
},
|
|
1792
|
+
},
|
|
1793
|
+
},
|
|
1794
|
+
};
|
|
1795
|
+
},
|
|
1796
|
+
/**
|
|
1797
|
+
* Restart when the route tree changes shape.
|
|
1798
|
+
*
|
|
1799
|
+
* The entries and the route table are generated in `config()`, which runs
|
|
1800
|
+
* once. An edit to a page is picked up because Vite re-evaluates the
|
|
1801
|
+
* module, but a page that did not exist when the server started is not in
|
|
1802
|
+
* the table — the request 404s, and the file is right there on disk, which
|
|
1803
|
+
* is a confusing thing to be told.
|
|
1804
|
+
*
|
|
1805
|
+
* Only add and unlink: a change to an existing file needs no new table,
|
|
1806
|
+
* and restarting on every keystroke would throw away the module graph for
|
|
1807
|
+
* nothing.
|
|
1808
|
+
*/
|
|
1809
|
+
configureServer(server) {
|
|
1810
|
+
const shapes = new Set(ROUTE_FILES.map((name) => name));
|
|
1811
|
+
const affectsRouting = (file) => {
|
|
1812
|
+
if (!file.startsWith(sourceDir))
|
|
1813
|
+
return false;
|
|
1814
|
+
const base = file.split('/').pop() ?? '';
|
|
1815
|
+
const stem = base.replace(/\.(tsx|jsx|ts|js)$/, '');
|
|
1816
|
+
// The host's route-config file, whatever it named it. Hardcoding one
|
|
1817
|
+
// here would put a backend's convention back into a plugin that is
|
|
1818
|
+
// published without any — generic-host.test.ts fails if it reappears.
|
|
1819
|
+
return ((base !== stem && shapes.has(stem)) ||
|
|
1820
|
+
SECTION_FILE.test(base) ||
|
|
1821
|
+
(routeConfig !== null && base === routeConfig.file));
|
|
1822
|
+
};
|
|
1823
|
+
const restart = (file) => {
|
|
1824
|
+
if (!affectsRouting(file))
|
|
1825
|
+
return;
|
|
1826
|
+
server.config.logger.info(`[rsc-routes] route tree changed (${file.slice(sourceDir.length + 1)}) — restarting`);
|
|
1827
|
+
void server.restart();
|
|
1828
|
+
};
|
|
1829
|
+
// Watched explicitly: the Vite root is the *out* directory, so the app's
|
|
1830
|
+
// source tree is outside it and nothing would report a file appearing.
|
|
1831
|
+
server.watcher.add(sourceDir);
|
|
1832
|
+
server.watcher.on('add', restart);
|
|
1833
|
+
server.watcher.on('unlink', restart);
|
|
1834
|
+
},
|
|
1835
|
+
/**
|
|
1836
|
+
* Freeze what can be frozen, once every bundle exists.
|
|
1837
|
+
*
|
|
1838
|
+
* `buildApp` runs after all three environments are built, which is the
|
|
1839
|
+
* first moment the rsc bundle can be imported — prerendering is the app
|
|
1840
|
+
* rendering itself, so it needs the thing the build just produced. This
|
|
1841
|
+
* plugin is ordered after @vitejs/plugin-rsc's, so its own buildApp has
|
|
1842
|
+
* already run and the bundles are on disk.
|
|
1843
|
+
*
|
|
1844
|
+
* Automatic because the alternative is a second command to remember, and
|
|
1845
|
+
* forgetting it costs the whole difference silently: every page still
|
|
1846
|
+
* works, each one just renders again for every visitor.
|
|
1847
|
+
*
|
|
1848
|
+
* Skipped in watch mode. A rebuild on every keystroke that also re-renders
|
|
1849
|
+
* every route is not a feedback loop anyone wants.
|
|
1850
|
+
*/
|
|
1851
|
+
async buildApp() {
|
|
1852
|
+
if (!prerenderAfterBuild || isWatch)
|
|
1853
|
+
return;
|
|
1854
|
+
await prerenderAfterBundles();
|
|
1855
|
+
},
|
|
1856
|
+
configResolved(config) {
|
|
1857
|
+
isWatch = config.build?.watch != null;
|
|
1858
|
+
// rsc() splits the module graph into client and server; a JSX transform
|
|
1859
|
+
// placed ahead of it sees the wrong graph and fails in ways that are hard
|
|
1860
|
+
// to trace back here. Cheaper to refuse than to let it through.
|
|
1861
|
+
const names = config.plugins.map((p) => p.name);
|
|
1862
|
+
const rscAt = names.findIndex((n) => n === 'rsc' || n.startsWith('rsc:'));
|
|
1863
|
+
const jsxAt = names.findIndex((n) => JSX_PLUGIN_PATTERN.test(n));
|
|
1864
|
+
if (rscAt !== -1 && jsxAt !== -1 && jsxAt < rscAt) {
|
|
1865
|
+
throw new Error(`[rsc-routes] Plugin "${names[jsxAt]}" is resolved ahead of rsc(), so it would ` +
|
|
1866
|
+
'transform JSX before the client/server split.\n' +
|
|
1867
|
+
'Put rscRoutes() first in your plugins array. If it already is, that plugin ' +
|
|
1868
|
+
"sets enforce: 'pre' and needs to be moved after rsc() explicitly.");
|
|
1869
|
+
}
|
|
1870
|
+
},
|
|
1871
|
+
};
|
|
1872
|
+
// rsc() ships as several plugins, and it has to lead. A promise is a legal
|
|
1873
|
+
// member of a Vite plugins array and is flattened in place, so this keeps
|
|
1874
|
+
// rscRoutes() one entry in the app's config while still resolving the
|
|
1875
|
+
// plugin at call time — see appPluginRsc for why that matters.
|
|
1876
|
+
return [appPluginRsc(), routesPlugin];
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* @vitejs/plugin-rsc, resolved from the app rather than from here.
|
|
1880
|
+
*
|
|
1881
|
+
* `isRunnableDevEnvironment` is an instanceof check that plugin-rsc runs
|
|
1882
|
+
* against its own copy of Vite. Two copies — this package's and the app's —
|
|
1883
|
+
* make a perfectly runnable environment report false, and the message names
|
|
1884
|
+
* the environment rather than the duplication.
|
|
1885
|
+
*
|
|
1886
|
+
* That is not a hypothetical layout. It is what installing this package from a
|
|
1887
|
+
* directory produces, because the checkout carries its own devDependencies:
|
|
1888
|
+
* the app runs its Vite, this file imports that Vite's plugin, and the two
|
|
1889
|
+
* never recognise each other. A build never reaches the check, so everything
|
|
1890
|
+
* works right up until dev mode.
|
|
1891
|
+
*
|
|
1892
|
+
* Resolving from the project root gets the app's copy, whose own `vite` import
|
|
1893
|
+
* then resolves to the app's Vite as well — one pair, and the check passes.
|
|
1894
|
+
*/
|
|
1895
|
+
async function appPluginRsc() {
|
|
1896
|
+
try {
|
|
1897
|
+
// Resolved against a file *in* the root, since a directory specifier
|
|
1898
|
+
// resolves relative to its parent.
|
|
1899
|
+
const fromApp = createRequire(join(projectRoot, 'package.json'));
|
|
1900
|
+
const entry = fromApp.resolve('@vitejs/plugin-rsc');
|
|
1901
|
+
const mod = (await import(pathToFileURL(entry).href));
|
|
1902
|
+
return (mod.default ?? rsc)();
|
|
1903
|
+
}
|
|
1904
|
+
catch {
|
|
1905
|
+
// The app does not have its own; one copy, and the bundled import is it.
|
|
1906
|
+
return rsc();
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
//# sourceMappingURL=vite.js.map
|