@rsc-kit/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/vite.js CHANGED
@@ -17,6 +17,8 @@ import { createRequire } from 'node:module';
17
17
  import { dirname, join, relative, resolve } from 'node:path';
18
18
  import { fileURLToPath, pathToFileURL } from 'node:url';
19
19
  import rsc from '@vitejs/plugin-rsc';
20
+ import { loadEnv } from 'vite';
21
+ import { httpHostCalls } from './hostCalls.js';
20
22
  // Resolved once per rscRoutes() call. One build runs in one process, so these are
21
23
  // module state rather than threaded through every helper.
22
24
  let projectRoot;
@@ -26,6 +28,8 @@ let appDir;
26
28
  let genDir;
27
29
  let publicAssetsDir;
28
30
  let assetsBaseUrl;
31
+ let hotFile;
32
+ let hostCallOptions;
29
33
  let packageDir;
30
34
  let hostGlobal;
31
35
  let interceptManifestFile;
@@ -58,6 +62,16 @@ let hostActions;
58
62
  * the ordinary case for an app that installs the engine from npm and runs
59
63
  * `vite build` itself.
60
64
  */
65
+ /** This package's name, for excluding it from dep optimization. */
66
+ const PACKAGE_NAME = (() => {
67
+ try {
68
+ const manifest = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
69
+ return manifest.name ?? '@rsc-kit/core';
70
+ }
71
+ catch {
72
+ return '@rsc-kit/core';
73
+ }
74
+ })();
61
75
  function thisDir() {
62
76
  return dirname(fileURLToPath(import.meta.url));
63
77
  }
@@ -69,12 +83,40 @@ function envRouteConfig() {
69
83
  return null;
70
84
  return { file, dynamicPattern: new RegExp(pattern) };
71
85
  }
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)
86
+ /** The file a backend writes its action names into. */
87
+ const HOST_ACTIONS_FILE = 'rsc-host-actions.json';
88
+ /**
89
+ * Host actions, read from a file the backend wrote.
90
+ *
91
+ * A file rather than an environment variable, because the backend no longer
92
+ * drives the build — `vite build` does. Discovery has to stay where the classes
93
+ * are (reflection through Composer's autoloader finds what a class inherits;
94
+ * a regex would silently miss every inherited action), but the handoff is just
95
+ * a map of names, and a JSON file is something any language can write:
96
+ *
97
+ * php artisan rsc:action-manifest > rsc-host-actions.json
98
+ * go run ./cmd/rsc-actions > rsc-host-actions.json
99
+ *
100
+ * Absent is not an error. An app with no host actions has no file, and one
101
+ * that has them regenerates it as part of its build.
102
+ */
103
+ function fileHostActions(root) {
104
+ const path = join(root, HOST_ACTIONS_FILE);
105
+ if (!existsSync(path))
76
106
  return {};
77
- return JSON.parse(raw);
107
+ try {
108
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
109
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
110
+ throw new Error('expected an object of { jsName: "Class.method" }');
111
+ }
112
+ return parsed;
113
+ }
114
+ catch (error) {
115
+ // Loud, because the alternative is generating no stubs: every import of a
116
+ // server action then fails at build time, naming the import rather than
117
+ // this file.
118
+ throw new Error(`Could not read ${HOST_ACTIONS_FILE}: ${error instanceof Error ? error.message : String(error)}`);
119
+ }
78
120
  }
79
121
  /**
80
122
  * The alias that lets a vendored copy of this package be imported by name.
@@ -110,6 +152,8 @@ function resolvePaths(options) {
110
152
  // bundles are SERVER code and stay under outDir, which must never be public.
111
153
  publicAssetsDir = resolve(options.assetsDir || process.env.RSC_ASSETS_DIR || join(projectRoot, 'dist/client'));
112
154
  assetsBaseUrl = options.assetsUrl || process.env.RSC_ASSETS_URL || '/';
155
+ hotFile = options.hotFile || process.env.RSC_HOT_FILE || '';
156
+ hostCallOptions = options.hostCall;
113
157
  packageDir = resolve(options.packageDir || process.env.RSC_PACKAGE_DIR || thisDir());
114
158
  hostGlobal = options.hostGlobal || process.env.RSC_HOST_GLOBAL || 'rpc';
115
159
  interceptManifestFile = resolve(options.interceptManifestFile || process.env.RSC_INTERCEPT_MANIFEST || join(outDir, 'intercept-manifest.json'));
@@ -130,7 +174,7 @@ function resolvePaths(options) {
130
174
  // A host driving the build out of process cannot pass an option, and may
131
175
  // prerender itself afterwards with paths only it knows.
132
176
  prerenderAfterBuild = options.prerender ?? process.env.RSC_PRERENDER !== '0';
133
- hostActions = options.hostActions ?? envHostActions();
177
+ hostActions = options.hostActions ?? fileHostActions(projectRoot);
134
178
  }
135
179
  function log(...args) {
136
180
  console.error('[rsc-routes]', ...args);
@@ -220,6 +264,57 @@ function routeManifest() {
220
264
  }
221
265
  return found;
222
266
  };
267
+ /**
268
+ * Host middleware named by a route.ts beside or above a page.
269
+ *
270
+ * // app/admin/route.ts
271
+ * export const middleware = ['auth', 'can:update,post']
272
+ *
273
+ * Read statically rather than imported, for the same reason
274
+ * generateStaticParams is detected by reading the source: this runs while the
275
+ * manifest is being built, before there is a bundle to execute.
276
+ *
277
+ * The names mean nothing here. They are the host's vocabulary — Laravel
278
+ * middleware aliases, a Go router's names — and the engine only carries them
279
+ * to whoever knows what they mean.
280
+ */
281
+ const middlewareIn = (absDir) => {
282
+ for (const file of ['route.ts', 'route.tsx']) {
283
+ const path = join(absDir, file);
284
+ if (!existsSync(path))
285
+ continue;
286
+ const match = readFileSync(path, 'utf-8').match(/export\s+const\s+middleware\s*(?::[^=]+)?=\s*\[([^\]]*)\]/);
287
+ if (!match)
288
+ continue;
289
+ // Each quoted literal, rather than splitting the list on commas: a
290
+ // middleware name carries its arguments after a colon and those are
291
+ // comma-separated too, so splitting turns 'throttle:60,1' into a
292
+ // throttle of 60 and a middleware called 1.
293
+ return [...match[1].matchAll(/['"`]([^'"`]*)['"`]/g)]
294
+ .map((quoted) => quoted[1].trim())
295
+ .filter(Boolean);
296
+ }
297
+ return [];
298
+ };
299
+ /**
300
+ * Every host middleware above and on a page, outermost first.
301
+ *
302
+ * Order is the whole of it: an outer guard has to run before an inner one, or
303
+ * a check deciding whether the inner check is even reachable runs second.
304
+ * Duplicates are dropped, so a name repeated down the tree runs once, at the
305
+ * outermost point it was asked for.
306
+ */
307
+ const hostMiddleware = (dir) => {
308
+ const parts = dir.split('/').filter(Boolean);
309
+ const found = [];
310
+ for (let depth = 0; depth <= parts.length; depth++) {
311
+ for (const name of middlewareIn(join(sourceDir, ...parts.slice(0, depth)))) {
312
+ if (!found.includes(name))
313
+ found.push(name);
314
+ }
315
+ }
316
+ return found;
317
+ };
223
318
  const routes = [];
224
319
  const intercepts = [];
225
320
  for (const name of names) {
@@ -252,6 +347,7 @@ function routeManifest() {
252
347
  slots,
253
348
  sections: names.filter((n) => SECTION_FILE.test(n + '.tsx') && dirOf(n) === dirOf(name)),
254
349
  config: configIn(join(sourceDir, dirOf(name))),
350
+ hostMiddleware: hostMiddleware(dirOf(name)),
255
351
  ancestorConfigs: ancestorConfigs(dirOf(name)),
256
352
  staticParams: hasStaticParams(components.get(name).absPath),
257
353
  clientJs: shipsClientJs(components.get(name).absPath),
@@ -337,6 +433,28 @@ declare module '*/dist/rsc/index.js' {
337
433
  * render pipeline, and a dev server that never prerenders should not pay for
338
434
  * loading it.
339
435
  */
436
+ /**
437
+ * What the build classified when it classified nothing.
438
+ *
439
+ * With prerendering off there is no probe and no answer to report, so every
440
+ * route is dynamic by construction rather than by measurement — printed the
441
+ * same way so the output means the same thing either way.
442
+ */
443
+ function reportAllDynamic() {
444
+ const routes = routeManifest().routes;
445
+ for (const route of routes) {
446
+ // The pattern rather than a url: nothing was rendered, so there are no
447
+ // params and inventing one would name a page that may not exist.
448
+ const path = route.segments
449
+ .map((segment) => (segment.type === 'static' ? segment.value : `[${segment.value}]`))
450
+ .join('/');
451
+ console.log(` \u0192 /${path}`);
452
+ }
453
+ console.log(`
454
+ \u0192 (Dynamic) server-rendered on demand
455
+
456
+ ${routes.length} dynamic — prerendering is off`);
457
+ }
340
458
  async function prerenderAfterBundles() {
341
459
  const bundle = join(outDir, 'dist/rsc/index.js');
342
460
  if (!existsSync(bundle))
@@ -637,6 +755,16 @@ export async function getStaticParams(component: string): Promise<Record<string,
637
755
  // not reach the Flight render.
638
756
  const HOST_GLOBAL = ${JSON.stringify(hostGlobal)}
639
757
 
758
+ /**
759
+ * The reserved name a host answers route middleware on.
760
+ *
761
+ * Prefixed so it cannot collide with a function an application registered.
762
+ * A host that has never heard of it answers "no such function", which
763
+ * throws — the correct answer for a guarded route on a host that cannot
764
+ * check the guard.
765
+ */
766
+ const HOST_MIDDLEWARE_FN = '__rsc.middleware'
767
+
640
768
  let currentHost: HostFn | null = null
641
769
 
642
770
  export function installHostFn(fn: HostFn) {
@@ -663,8 +791,33 @@ const probeHost = new AsyncLocalStorage<(...args: unknown[]) => Promise<unknown>
663
791
  function applyHost() {
664
792
  // A dispatcher, installed once. App code calls a global; which implementation
665
793
  // that reaches is a question about the render it is inside.
666
- ;(globalThis as Record<string, unknown>)[HOST_GLOBAL] = (...args: unknown[]) =>
667
- (probeHost.getStore() ?? currentHost)?.(...args)
794
+ //
795
+ ;(globalThis as Record<string, unknown>)[HOST_GLOBAL] = (...args: unknown[]) => {
796
+ const fn = probeHost.getStore() ?? currentHost
797
+
798
+ // An optional call was here, and it answered every rpc() with undefined
799
+ // when no host was installed. undefined is a value: the component renders
800
+ // with it, the render succeeds, and a prerender freezes the result. The
801
+ // page then hydrates against an undefined prop, the client component reads
802
+ // a property of it, React unmounts the document, and the browser shows a
803
+ // blank page with nothing in the console.
804
+ //
805
+ // No backticks in this region — everything from the generated entry
806
+ // onward is a template literal, and one ends it here.
807
+ // Rejected rather than thrown: rpc() is documented to return a promise, so
808
+ // a caller that stores it before awaiting must get a rejection, not an
809
+ // exception from the call itself.
810
+ if (!fn) {
811
+ return Promise.reject(
812
+ new Error(
813
+ 'No host callable is installed, so ' + String(args[0]) + ' cannot be answered. ' +
814
+ 'A render that needs the host must either run with one installed or be probed.',
815
+ ),
816
+ )
817
+ }
818
+
819
+ return fn(...args)
820
+ }
668
821
  }
669
822
 
670
823
  /**
@@ -907,7 +1060,55 @@ function segmentStart(
907
1060
  */
908
1061
  let middlewareChains: Record<string, string[]> | null = null
909
1062
 
1063
+ let hostChains: Record<string, string[]> | null = null
1064
+
1065
+ /**
1066
+ * Guards the host runs, named by a route.ts and meaningless here.
1067
+ *
1068
+ * Asked before the engine's own middleware.ts guards, because a host's are the
1069
+ * coarser check — a session, a rate limit — and running application code to
1070
+ * decide whether application code may run is the wrong way round.
1071
+ *
1072
+ * It fails CLOSED, and that is the whole of its design. This call reaches
1073
+ * another process over a network, so it can time out, be refused, or answer
1074
+ * something unparseable — and every one of those is a guarded page rendered to
1075
+ * whoever asked, if the absence of a refusal is read as permission. Only a
1076
+ * literal true allows.
1077
+ */
1078
+ async function runHostMiddleware(component) {
1079
+ if (!hostChains) {
1080
+ hostChains = {}
1081
+
1082
+ for (const route of manifest().routes) {
1083
+ if (route.hostMiddleware?.length) hostChains[route.component] = route.hostMiddleware
1084
+ }
1085
+ }
1086
+
1087
+ const names = hostChains[component] ?? []
1088
+
1089
+ if (names.length === 0) return
1090
+
1091
+ if (!currentHost) {
1092
+ throw new Error(
1093
+ 'Route ' + component + ' declares host middleware (' + names.join(', ') +
1094
+ ') but no host callable is installed, so it cannot be checked.',
1095
+ )
1096
+ }
1097
+
1098
+ const answer = await currentHost(HOST_MIDDLEWARE_FN, names)
1099
+
1100
+ // Anything other than a literal true. A host answering null, undefined, a
1101
+ // string, or an object it happened to build on the way to an error is not
1102
+ // saying yes.
1103
+ if (answer !== true) {
1104
+ throw new Error('Host middleware refused ' + component + ' (' + names.join(', ') + ').')
1105
+ }
1106
+ }
1107
+
1108
+
910
1109
  async function runMiddleware(component: string, props: Record<string, unknown> = {}): Promise<void> {
1110
+ await runHostMiddleware(component)
1111
+
911
1112
  // Read from the route table rather than passed in, so every render path is
912
1113
  // covered by construction and no host has to remember to forward them.
913
1114
  if (!middlewareChains) {
@@ -1308,14 +1509,38 @@ export async function handleRsc(
1308
1509
  from = 0,
1309
1510
  pageKey = '',
1310
1511
  bootstrap = true,
1512
+ canReachHost = true,
1311
1513
  ): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; clientComponents: string[] }> {
1312
1514
  applyHost()
1515
+
1516
+ // A build renders this with no host installed, so every rpc() has to suspend
1517
+ // rather than answer — which is what marks the page as needing a request.
1518
+ // Without the probe those calls found no host at all, and the page was
1519
+ // frozen holding whatever undefined rendered to.
1520
+ //
1521
+ // Defaults to true because the other caller is an interception, which runs
1522
+ // at request time with a real host and must not be probed.
1523
+ let usedDynamicApis = false
1524
+
1525
+ const probe = (..._args: unknown[]) => {
1526
+ usedDynamicApis = true
1527
+
1528
+ return new Promise<never>(() => {})
1529
+ }
1530
+
1531
+ // Not a generic arrow function: this file is generated as .tsx, where <T>
1532
+ // parses as JSX and the build fails on a tag it cannot close.
1533
+ const runWith = canReachHost
1534
+ ? (fn: () => Promise<unknown>) => fn()
1535
+ : (fn: () => Promise<unknown>) => probeHost.run(probe, fn)
1536
+
1313
1537
  // renderTree (not bare buildElement) so the prerendered Flight payload carries
1314
1538
  // the same <title>/<meta> elements the live SPA payload does.
1315
- const flight = renderToReadableStream(
1316
- await renderTree(component, props, layouts, loadings, parallelSlots, {}, from, pageKey, bootstrap),
1317
- { onError: flightOnError },
1539
+ const tree = await runWith(() =>
1540
+ renderTree(component, props, layouts, loadings, parallelSlots, {}, from, pageKey, bootstrap),
1318
1541
  )
1542
+
1543
+ const flight = renderToReadableStream(tree, { onError: flightOnError })
1319
1544
  const [forHtml, forPayload] = flight.tee()
1320
1545
  const rscPayload = await new Response(forPayload).text()
1321
1546
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
@@ -1326,7 +1551,7 @@ export async function handleRsc(
1326
1551
  body,
1327
1552
  rscPayload,
1328
1553
  clientChunks: {},
1329
- usedDynamicApis: false,
1554
+ usedDynamicApis,
1330
1555
  // Client reference rows name the components the browser has to run. Shipping
1331
1556
  // no runtime would leave them as inert markup, so the host refuses — and
1332
1557
  // says which components forced the decision, since they are usually in a
@@ -1428,6 +1653,21 @@ export async function handleRscPprShell(
1428
1653
  // paints immediately: giving that the same budget spends two seconds per
1429
1654
  // route to learn nothing the first millisecond did not say.
1430
1655
  budgetMs = PPR_SHELL_TIMEOUT_MS,
1656
+ // Whether this build can answer host calls.
1657
+ //
1658
+ // False is the old and still the usual answer: the stub suspends, and a page
1659
+ // reaching for its host becomes a shell rather than freezing data fetched
1660
+ // once at build time.
1661
+ //
1662
+ // A host that opened a callback socket for the build says true, and then the
1663
+ // call is made and its answer stored. That is the only way a Laravel page can
1664
+ // be static at all, since a host call is the only route its data has — and
1665
+ // connection() is how such a page opts back out.
1666
+ //
1667
+ // Asked of the caller rather than read from whatever host happens to be
1668
+ // installed, because those are different statements: a test that installed
1669
+ // one is not a build that can reach PHP.
1670
+ canReachHost = false,
1431
1671
  ): Promise<{ shellHtml: string; clientChunks: unknown; timedOut: boolean; usedDynamicApis: boolean; error?: string }> {
1432
1672
  // Deliberately no middleware here. The probe is asking whether the content is
1433
1673
  // the same for everyone, which is a question about the page. Whether a
@@ -1451,16 +1691,49 @@ export async function handleRscPprShell(
1451
1691
  let error: string | undefined
1452
1692
  let postponed: unknown = null
1453
1693
 
1454
- // The budget is an abort signal now rather than a race. \`prerender\` resolves
1455
- // when it is aborted, handing back both what flushed and where it stopped, so
1456
- // there is nothing left to cancel by hand and no window in which the render is
1457
- // still going after the caller has moved on.
1694
+ // The budget, declared before the error handler that consults it.
1458
1695
  const controller = new AbortController()
1459
1696
  const budget = setTimeout(() => controller.abort(), budgetMs)
1460
1697
 
1698
+ // Anything that failed while producing this shell.
1699
+ //
1700
+ // A rejection inside a Suspense boundary does NOT reach the caller: React
1701
+ // catches it, keeps the fallback, and the render goes on to finish. So the
1702
+ // probe looked like a page that had nothing left to do — no postponed state,
1703
+ // no thrown error — and the fallback was frozen as a finished static page.
1704
+ // A database the build machine cannot reach produced a permanently loading
1705
+ // page, stored, with the build reporting success.
1706
+ let renderFailure: string | undefined
1707
+
1708
+ const noteFailure = (e: unknown): string | undefined => {
1709
+ const digest = redirectDigest(e)
1710
+
1711
+ // A redirect is a classification here, not a failure.
1712
+ if (digest) return digest
1713
+
1714
+ // Every probe ends by aborting, so React reports that abort. Asked of the
1715
+ // signal rather than matched against the message: a string test would be a
1716
+ // guess about wording, and would quietly stop working when React changed it.
1717
+ if (controller.signal.aborted) return undefined
1718
+
1719
+ renderFailure ??= e instanceof Error ? e.message : String(e)
1720
+ console.error('[rsc-routes]', e)
1721
+
1722
+ return undefined
1723
+ }
1724
+
1725
+ // The budget is an abort signal rather than a race. \`prerender\` resolves when
1726
+ // it is aborted, handing back both what flushed and where it stopped, so there
1727
+ // is nothing left to cancel by hand and no window in which the render is still
1728
+ // going after the caller has moved on.
1729
+
1461
1730
  // Everything the render does happens inside the scope, so the stand-in host
1462
1731
  // travels with it rather than with the process.
1463
- const produce = probeHost.run(probe, async () => {
1732
+ const runWith = canReachHost
1733
+ ? (fn: () => Promise<void>) => fn()
1734
+ : (fn: () => Promise<void>) => probeHost.run(probe, fn)
1735
+
1736
+ const produce = runWith(async () => {
1464
1737
  try {
1465
1738
  // Params settle only when this probe is for one concrete url. A route
1466
1739
  // that listed its urls is being rendered for one of them, so the page
@@ -1480,14 +1753,17 @@ export async function handleRscPprShell(
1480
1753
  )
1481
1754
  // Quiet about a redirect: during the probe it is a classification, not
1482
1755
  // a failure, and React would otherwise print a stack for every one.
1483
- const flight = renderToReadableStream(tree, { onError: flightOnError })
1756
+ const flight = renderToReadableStream(tree, { onError: noteFailure })
1484
1757
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
1485
1758
  // The flight stream stays open across this. A Flight stream that has
1486
1759
  // closed tells the decoder the connection ended, so the boundary waiting
1487
1760
  // on it errors rather than staying pending — and an errored boundary is
1488
1761
  // finished, not postponed: it comes back null and there is nothing left
1489
1762
  // to resume from.
1490
- const prerendered = await ssr.handleSsrPrerender(flight, { signal: controller.signal })
1763
+ const prerendered = await ssr.handleSsrPrerender(flight, {
1764
+ signal: controller.signal,
1765
+ onError: noteFailure,
1766
+ })
1491
1767
 
1492
1768
  postponed = prerendered.postponed
1493
1769
 
@@ -1520,6 +1796,7 @@ export async function handleRscPprShell(
1520
1796
  usedDynamicApis,
1521
1797
  error,
1522
1798
  postponed,
1799
+ renderFailure,
1523
1800
  }
1524
1801
  }
1525
1802
 
@@ -1881,9 +2158,38 @@ export function rscRoutes(options = {}) {
1881
2158
  define: {
1882
2159
  'process.env.NODE_ENV': JSON.stringify(env.mode === 'development' ? 'development' : 'production'),
1883
2160
  },
1884
- // Public URL for browser-facing client assets (served from public/ by
1885
- // the web server — never through PHP).
1886
- base: assetsBaseUrl,
2161
+ /*
2162
+ * This package's client modules are served as source, never
2163
+ * pre-bundled.
2164
+ *
2165
+ * Vite treats an installed package as a dependency and optimizes it,
2166
+ * which binds its JSX imports to one particular optimized
2167
+ * react/jsx-runtime chunk. The moment Vite discovers another dependency
2168
+ * and re-optimizes, that chunk's hash changes and the binding breaks:
2169
+ *
2170
+ * SyntaxError: The requested module '.../react_jsx-runtime.js?v=...'
2171
+ * does not provide an export named 't'
2172
+ *
2173
+ * Which reads as a React or a bundler bug. What it does is take down
2174
+ * every page importing Link or Form — the shell renders, hydration
2175
+ * throws, React unmounts the document, and the page goes blank with
2176
+ * that message the only clue.
2177
+ *
2178
+ * Excluded rather than pinned, because the package already ships ESM
2179
+ * that needs no conversion. The app's own excludes are kept.
2180
+ */
2181
+ optimizeDeps: {
2182
+ exclude: [PACKAGE_NAME, ...(_config.optimizeDeps?.exclude ?? [])],
2183
+ },
2184
+ // Public URL for browser-facing client assets, and a BUILD concern
2185
+ // only: it says where the built files will be served from.
2186
+ //
2187
+ // Applying it in dev makes it Vite's public base, and then the dev
2188
+ // server answers pages only under that prefix — every route 404s with
2189
+ // "The server is configured with a public base URL", which reads as a
2190
+ // routing bug rather than as this line. In dev the pages are the root;
2191
+ // the assets come from the same origin either way.
2192
+ base: env.command === 'build' ? assetsBaseUrl : '/',
1887
2193
  root: outDir,
1888
2194
  // Force single instances of React/RSC runtime — critical when the
1889
2195
  // package is symlinked (local dev / monorepo), else "use client"
@@ -1930,6 +2236,73 @@ export function rscRoutes(options = {}) {
1930
2236
  * nothing.
1931
2237
  */
1932
2238
  configureServer(server) {
2239
+ // rpc() has to reach the backend while the dev server is serving.
2240
+ //
2241
+ // A built deployment installs this itself: the server running
2242
+ // createRscHandler passes `hostCalls`. Nothing does that here, and with
2243
+ // no host installed every rpc() is refused — so a page whose data comes
2244
+ // from the backend renders its shell and then blanks, which reads as a
2245
+ // hydration bug rather than a missing wire.
2246
+ server.httpServer?.once('listening', async () => {
2247
+ const env = server.environments?.rsc;
2248
+ if (!env?.runner)
2249
+ return;
2250
+ // The app's own .env, unprefixed. A Laravel app already has both of
2251
+ // these, which is what makes this need no configuring: APP_URL is the
2252
+ // backend and RSC_HOST_CALL_SECRET is the secret it checks.
2253
+ const fromEnv = loadEnv(server.config.mode, projectRoot, '');
2254
+ const secret = hostCallOptions?.secret ?? fromEnv.RSC_HOST_CALL_SECRET;
2255
+ const origin = hostCallOptions?.endpoint ?? fromEnv.RSC_BACKEND ?? fromEnv.APP_URL;
2256
+ if (!secret || !origin)
2257
+ return;
2258
+ const path = hostCallOptions?.path ?? fromEnv.RSC_HOST_CALL_PATH ?? '/__rsc/host-call';
2259
+ const endpoint = origin.replace(/\/$/, '') + path;
2260
+ try {
2261
+ const entry = await env.runner.import(join(genDir, 'entry.rsc.tsx'));
2262
+ const install = entry.installHostFn;
2263
+ install?.(httpHostCalls({ endpoint, secret }));
2264
+ }
2265
+ catch (error) {
2266
+ // Reported rather than thrown: the dev server is still useful for
2267
+ // every page that needs no data, and a failure here would otherwise
2268
+ // look like the server refusing to start.
2269
+ server.config.logger.warn(`[rsc-routes] could not wire host calls to ${endpoint}: ` +
2270
+ (error instanceof Error ? error.message : String(error)));
2271
+ }
2272
+ });
2273
+ // Written once the server is listening, because only then is the port
2274
+ // known. Removed on shutdown so a backend can tell a dev server that is
2275
+ // gone from one that is merely slow to answer.
2276
+ if (hotFile) {
2277
+ const remove = () => {
2278
+ try {
2279
+ if (existsSync(hotFile))
2280
+ rmSync(hotFile);
2281
+ }
2282
+ catch { }
2283
+ };
2284
+ server.httpServer?.once('listening', () => {
2285
+ // The url Vite resolved, not one built from the port. A dev server
2286
+ // whose port is already taken on IPv4 binds IPv6 only and keeps the
2287
+ // number — so http://127.0.0.1:<port> is a reachable-looking address
2288
+ // that nothing answers, and the backend reports the renderer as down
2289
+ // while it is plainly running.
2290
+ const resolved = server.resolvedUrls?.local?.[0];
2291
+ const address = server.httpServer?.address();
2292
+ const url = resolved ??
2293
+ (typeof address === 'object' && address
2294
+ ? `http://${address.family === 'IPv6' ? `[${address.address}]` : address.address}:${address.port}`
2295
+ : null);
2296
+ if (!url)
2297
+ return;
2298
+ mkdirSync(dirname(hotFile), { recursive: true });
2299
+ writeFileSync(hotFile, url.replace(/\/$/, ''));
2300
+ });
2301
+ for (const signal of ['SIGINT', 'SIGTERM', 'exit']) {
2302
+ process.once(signal, remove);
2303
+ }
2304
+ server.httpServer?.once('close', remove);
2305
+ }
1933
2306
  const shapes = new Set(ROUTE_FILES.map((name) => name));
1934
2307
  const affectsRouting = (file) => {
1935
2308
  if (!file.startsWith(sourceDir))
@@ -1972,8 +2345,19 @@ export function rscRoutes(options = {}) {
1972
2345
  * every route is not a feedback loop anyone wants.
1973
2346
  */
1974
2347
  async buildApp() {
1975
- if (!prerenderAfterBuild || isWatch)
2348
+ if (isWatch)
2349
+ return;
2350
+ // Say what the build did, even when it stored nothing.
2351
+ //
2352
+ // Turning prerendering off used to print no classification at all — no
2353
+ // marks, no legend, no counts — so a build that stored nothing looked
2354
+ // exactly like a build that had not got to that step. Every route renders
2355
+ // per visitor now, which is a thing worth being told rather than left to
2356
+ // infer from an absence.
2357
+ if (!prerenderAfterBuild) {
2358
+ reportAllDynamic();
1976
2359
  return;
2360
+ }
1977
2361
  await prerenderAfterBundles();
1978
2362
  },
1979
2363
  configResolved(config) {