@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.
Files changed (200) hide show
  1. package/README.md +95 -0
  2. package/SECURITY.md +60 -0
  3. package/dist/action.d.ts +90 -0
  4. package/dist/action.d.ts.map +1 -0
  5. package/dist/action.js +137 -0
  6. package/dist/action.js.map +1 -0
  7. package/dist/build-rsc-vite.d.ts +39 -0
  8. package/dist/build-rsc-vite.d.ts.map +1 -0
  9. package/dist/build-rsc-vite.js +172 -0
  10. package/dist/build-rsc-vite.js.map +1 -0
  11. package/dist/cache.d.ts +25 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +108 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.d.ts.map +1 -0
  17. package/dist/cli.js +85 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/compile.d.ts +55 -0
  20. package/dist/compile.d.ts.map +1 -0
  21. package/dist/compile.js +141 -0
  22. package/dist/compile.js.map +1 -0
  23. package/dist/devServer.d.ts +18 -0
  24. package/dist/devServer.d.ts.map +1 -0
  25. package/dist/devServer.js +70 -0
  26. package/dist/devServer.js.map +1 -0
  27. package/dist/devUrls.d.ts +11 -0
  28. package/dist/devUrls.d.ts.map +1 -0
  29. package/dist/devUrls.js +68 -0
  30. package/dist/devUrls.js.map +1 -0
  31. package/dist/embed.d.ts +21 -0
  32. package/dist/embed.d.ts.map +1 -0
  33. package/dist/embed.js +119 -0
  34. package/dist/embed.js.map +1 -0
  35. package/dist/errors.d.ts +24 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +38 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/export.d.ts +41 -0
  40. package/dist/export.d.ts.map +1 -0
  41. package/dist/export.js +100 -0
  42. package/dist/export.js.map +1 -0
  43. package/dist/files.d.ts +39 -0
  44. package/dist/files.d.ts.map +1 -0
  45. package/dist/files.js +146 -0
  46. package/dist/files.js.map +1 -0
  47. package/dist/headers.d.ts +81 -0
  48. package/dist/headers.d.ts.map +1 -0
  49. package/dist/headers.js +96 -0
  50. package/dist/headers.js.map +1 -0
  51. package/dist/host.d.ts +86 -0
  52. package/dist/host.d.ts.map +1 -0
  53. package/dist/host.js +590 -0
  54. package/dist/host.js.map +1 -0
  55. package/dist/js/ActivityRouter.d.ts +47 -0
  56. package/dist/js/ActivityRouter.d.ts.map +1 -0
  57. package/dist/js/ActivityRouter.js +87 -0
  58. package/dist/js/ActivityRouter.js.map +1 -0
  59. package/dist/js/DocumentTitle.d.ts +22 -0
  60. package/dist/js/DocumentTitle.d.ts.map +1 -0
  61. package/dist/js/DocumentTitle.js +28 -0
  62. package/dist/js/DocumentTitle.js.map +1 -0
  63. package/dist/js/Form.d.ts +54 -0
  64. package/dist/js/Form.d.ts.map +1 -0
  65. package/dist/js/Form.js +225 -0
  66. package/dist/js/Form.js.map +1 -0
  67. package/dist/js/Link.d.ts +29 -0
  68. package/dist/js/Link.d.ts.map +1 -0
  69. package/dist/js/Link.js +112 -0
  70. package/dist/js/Link.js.map +1 -0
  71. package/dist/js/PathnameProvider.d.ts +8 -0
  72. package/dist/js/PathnameProvider.d.ts.map +1 -0
  73. package/dist/js/PathnameProvider.js +24 -0
  74. package/dist/js/PathnameProvider.js.map +1 -0
  75. package/dist/js/RedirectBoundary.d.ts +18 -0
  76. package/dist/js/RedirectBoundary.d.ts.map +1 -0
  77. package/dist/js/RedirectBoundary.js +34 -0
  78. package/dist/js/RedirectBoundary.js.map +1 -0
  79. package/dist/js/SegmentBoundary.d.ts +8 -0
  80. package/dist/js/SegmentBoundary.d.ts.map +1 -0
  81. package/dist/js/SegmentBoundary.js +38 -0
  82. package/dist/js/SegmentBoundary.js.map +1 -0
  83. package/dist/js/SlotBoundary.d.ts +15 -0
  84. package/dist/js/SlotBoundary.d.ts.map +1 -0
  85. package/dist/js/SlotBoundary.js +17 -0
  86. package/dist/js/SlotBoundary.js.map +1 -0
  87. package/dist/js/createViteRscApp.d.ts +8 -0
  88. package/dist/js/createViteRscApp.d.ts.map +1 -0
  89. package/dist/js/createViteRscApp.js +185 -0
  90. package/dist/js/createViteRscApp.js.map +1 -0
  91. package/dist/js/errors.d.ts +51 -0
  92. package/dist/js/errors.d.ts.map +1 -0
  93. package/dist/js/errors.js +93 -0
  94. package/dist/js/errors.js.map +1 -0
  95. package/dist/js/navigate.d.ts +118 -0
  96. package/dist/js/navigate.d.ts.map +1 -0
  97. package/dist/js/navigate.js +780 -0
  98. package/dist/js/navigate.js.map +1 -0
  99. package/dist/js/onlineStore.d.ts +23 -0
  100. package/dist/js/onlineStore.d.ts.map +1 -0
  101. package/dist/js/onlineStore.js +42 -0
  102. package/dist/js/onlineStore.js.map +1 -0
  103. package/dist/js/pagePayload.d.ts +15 -0
  104. package/dist/js/pagePayload.d.ts.map +1 -0
  105. package/dist/js/pagePayload.js +39 -0
  106. package/dist/js/pagePayload.js.map +1 -0
  107. package/dist/js/router.d.ts +29 -0
  108. package/dist/js/router.d.ts.map +1 -0
  109. package/dist/js/router.js +40 -0
  110. package/dist/js/router.js.map +1 -0
  111. package/dist/js/section.d.ts +16 -0
  112. package/dist/js/section.d.ts.map +1 -0
  113. package/dist/js/section.js +55 -0
  114. package/dist/js/section.js.map +1 -0
  115. package/dist/js/segmentStore.d.ts +69 -0
  116. package/dist/js/segmentStore.d.ts.map +1 -0
  117. package/dist/js/segmentStore.js +127 -0
  118. package/dist/js/segmentStore.js.map +1 -0
  119. package/dist/js/slotStore.d.ts +35 -0
  120. package/dist/js/slotStore.d.ts.map +1 -0
  121. package/dist/js/slotStore.js +46 -0
  122. package/dist/js/slotStore.js.map +1 -0
  123. package/dist/js/standardSchema.d.ts +47 -0
  124. package/dist/js/standardSchema.d.ts.map +1 -0
  125. package/dist/js/standardSchema.js +41 -0
  126. package/dist/js/standardSchema.js.map +1 -0
  127. package/dist/js/useForm.d.ts +43 -0
  128. package/dist/js/useForm.d.ts.map +1 -0
  129. package/dist/js/useForm.js +164 -0
  130. package/dist/js/useForm.js.map +1 -0
  131. package/dist/js/useLinkStatus.d.ts +2 -0
  132. package/dist/js/useLinkStatus.d.ts.map +1 -0
  133. package/dist/js/useLinkStatus.js +3 -0
  134. package/dist/js/useLinkStatus.js.map +1 -0
  135. package/dist/js/useOnline.d.ts +8 -0
  136. package/dist/js/useOnline.d.ts.map +1 -0
  137. package/dist/js/useOnline.js +20 -0
  138. package/dist/js/useOnline.js.map +1 -0
  139. package/dist/js/usePathname.d.ts +9 -0
  140. package/dist/js/usePathname.d.ts.map +1 -0
  141. package/dist/js/usePathname.js +32 -0
  142. package/dist/js/usePathname.js.map +1 -0
  143. package/dist/js/useSearchParams.d.ts +2 -0
  144. package/dist/js/useSearchParams.d.ts.map +1 -0
  145. package/dist/js/useSearchParams.js +60 -0
  146. package/dist/js/useSearchParams.js.map +1 -0
  147. package/dist/manifest.d.ts +66 -0
  148. package/dist/manifest.d.ts.map +1 -0
  149. package/dist/manifest.js +13 -0
  150. package/dist/manifest.js.map +1 -0
  151. package/dist/prerender.d.ts +138 -0
  152. package/dist/prerender.d.ts.map +1 -0
  153. package/dist/prerender.js +387 -0
  154. package/dist/prerender.js.map +1 -0
  155. package/dist/redirect.d.ts +34 -0
  156. package/dist/redirect.d.ts.map +1 -0
  157. package/dist/redirect.js +108 -0
  158. package/dist/redirect.js.map +1 -0
  159. package/dist/redirectDigest.d.ts +20 -0
  160. package/dist/redirectDigest.d.ts.map +1 -0
  161. package/dist/redirectDigest.js +66 -0
  162. package/dist/redirectDigest.js.map +1 -0
  163. package/dist/request.d.ts +93 -0
  164. package/dist/request.d.ts.map +1 -0
  165. package/dist/request.js +308 -0
  166. package/dist/request.js.map +1 -0
  167. package/dist/revalidate.d.ts +43 -0
  168. package/dist/revalidate.d.ts.map +1 -0
  169. package/dist/revalidate.js +99 -0
  170. package/dist/revalidate.js.map +1 -0
  171. package/dist/routes.d.ts +37 -0
  172. package/dist/routes.d.ts.map +1 -0
  173. package/dist/routes.js +28 -0
  174. package/dist/routes.js.map +1 -0
  175. package/dist/routing.d.ts +52 -0
  176. package/dist/routing.d.ts.map +1 -0
  177. package/dist/routing.js +146 -0
  178. package/dist/routing.js.map +1 -0
  179. package/dist/runtime.d.ts +36 -0
  180. package/dist/runtime.d.ts.map +1 -0
  181. package/dist/runtime.js +86 -0
  182. package/dist/runtime.js.map +1 -0
  183. package/dist/safeUrl.d.ts +10 -0
  184. package/dist/safeUrl.d.ts.map +1 -0
  185. package/dist/safeUrl.js +40 -0
  186. package/dist/safeUrl.js.map +1 -0
  187. package/dist/streaming.d.ts +62 -0
  188. package/dist/streaming.d.ts.map +1 -0
  189. package/dist/streaming.js +108 -0
  190. package/dist/streaming.js.map +1 -0
  191. package/dist/types.d.ts +82 -0
  192. package/dist/vite.d.ts +109 -0
  193. package/dist/vite.d.ts.map +1 -0
  194. package/dist/vite.js +1909 -0
  195. package/dist/vite.js.map +1 -0
  196. package/dist/worker.d.ts +2 -0
  197. package/dist/worker.d.ts.map +1 -0
  198. package/dist/worker.js +863 -0
  199. package/dist/worker.js.map +1 -0
  200. 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