@rsc-kit/core 0.3.0 → 0.5.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),
@@ -568,7 +664,7 @@ function hasStaticParams(absPath) {
568
664
  return /export\s+((async\s+)?function\s+generateStaticParams|const\s+generateStaticParams)/.test(src);
569
665
  }
570
666
  // ── Codegen ──────────────────────────────────────────────────────────────────
571
- function generateEntryRsc() {
667
+ function generateEntryRsc(fallbackOrigin = '') {
572
668
  const imports = [];
573
669
  const mapEntries = [];
574
670
  const metaEntries = [];
@@ -659,6 +755,16 @@ export async function getStaticParams(component: string): Promise<Record<string,
659
755
  // not reach the Flight render.
660
756
  const HOST_GLOBAL = ${JSON.stringify(hostGlobal)}
661
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
+
662
768
  let currentHost: HostFn | null = null
663
769
 
664
770
  export function installHostFn(fn: HostFn) {
@@ -686,8 +792,32 @@ function applyHost() {
686
792
  // A dispatcher, installed once. App code calls a global; which implementation
687
793
  // that reaches is a question about the render it is inside.
688
794
  //
689
- ;(globalThis as Record<string, unknown>)[HOST_GLOBAL] = (...args: unknown[]) =>
690
- (probeHost.getStore() ?? currentHost)?.(...args)
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
+ }
691
821
  }
692
822
 
693
823
  /**
@@ -930,7 +1060,55 @@ function segmentStart(
930
1060
  */
931
1061
  let middlewareChains: Record<string, string[]> | null = null
932
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
+
933
1109
  async function runMiddleware(component: string, props: Record<string, unknown> = {}): Promise<void> {
1110
+ await runHostMiddleware(component)
1111
+
934
1112
  // Read from the route table rather than passed in, so every render path is
935
1113
  // covered by construction and no host has to remember to forward them.
936
1114
  if (!middlewareChains) {
@@ -1331,14 +1509,38 @@ export async function handleRsc(
1331
1509
  from = 0,
1332
1510
  pageKey = '',
1333
1511
  bootstrap = true,
1512
+ canReachHost = true,
1334
1513
  ): Promise<{ body: string; rscPayload: string; clientChunks: unknown; usedDynamicApis: boolean; clientComponents: string[] }> {
1335
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
+
1336
1537
  // renderTree (not bare buildElement) so the prerendered Flight payload carries
1337
1538
  // the same <title>/<meta> elements the live SPA payload does.
1338
- const flight = renderToReadableStream(
1339
- await renderTree(component, props, layouts, loadings, parallelSlots, {}, from, pageKey, bootstrap),
1340
- { onError: flightOnError },
1539
+ const tree = await runWith(() =>
1540
+ renderTree(component, props, layouts, loadings, parallelSlots, {}, from, pageKey, bootstrap),
1341
1541
  )
1542
+
1543
+ const flight = renderToReadableStream(tree, { onError: flightOnError })
1342
1544
  const [forHtml, forPayload] = flight.tee()
1343
1545
  const rscPayload = await new Response(forPayload).text()
1344
1546
  const ssr = await (import.meta as any).viteRsc.loadModule('ssr', 'index')
@@ -1349,7 +1551,7 @@ export async function handleRsc(
1349
1551
  body,
1350
1552
  rscPayload,
1351
1553
  clientChunks: {},
1352
- usedDynamicApis: false,
1554
+ usedDynamicApis,
1353
1555
  // Client reference rows name the components the browser has to run. Shipping
1354
1556
  // no runtime would leave them as inert markup, so the host refuses — and
1355
1557
  // says which components forced the decision, since they are usually in a
@@ -1612,6 +1814,10 @@ export async function handleRscPprShell(
1612
1814
  * assets in dev, and a frozen page is a build artifact: serving one here would
1613
1815
  * hand back the last build's HTML for a file just edited.
1614
1816
  */
1817
+ const FALLBACK_ORIGIN = ${JSON.stringify(fallbackOrigin)}
1818
+ const FALLBACK_MARKER = 'x-rsc-renderer-fallback'
1819
+ const PROXIED_MARKER = 'x-rsc-proxied-by-backend'
1820
+
1615
1821
  let devHandler: ((request: Request) => Promise<Response | null>) | null = null
1616
1822
 
1617
1823
  export default async function handler(request: Request): Promise<Response> {
@@ -1633,7 +1839,75 @@ export default async function handler(request: Request): Promise<Response> {
1633
1839
  } as never,
1634
1840
  })
1635
1841
 
1636
- return (await devHandler(request)) ?? new Response('Not found', { status: 404 })
1842
+ const answer = await devHandler(request)
1843
+
1844
+ if (answer) return answer
1845
+
1846
+ // Nothing here owns this url. In development the backend usually does — a
1847
+ // Blade page, /login, a webhook, an uploaded file under /storage — so the
1848
+ // request is handed on rather than refused, and this origin is the whole
1849
+ // application instead of the RSC half of it.
1850
+ //
1851
+ // FALLBACK_MARKER is what stops this looping. The backend's own fallback
1852
+ // forwards what it cannot route BACK to this server, so without a marker a
1853
+ // url neither side owns would bounce between them until something gave out.
1854
+ // Seeing it, the backend answers 404 itself.
1855
+ // Came from the backend's own proxy, so it has already been through that
1856
+ // route table and the answer there was no. Sending it back asks the same
1857
+ // question a second time.
1858
+ if (!FALLBACK_ORIGIN || request.headers.has(PROXIED_MARKER)) {
1859
+ return new Response('Not found', { status: 404 })
1860
+ }
1861
+
1862
+ // Built from the origin rather than by assigning onto a copy of this url.
1863
+ // The URL host setter keeps whatever port is already there when the value it
1864
+ // is given has none, so a portless backend — every Herd or Valet site —
1865
+ // would inherit the dev server's own port and this server would call itself.
1866
+ const here = new URL(request.url)
1867
+ const target = new URL(here.pathname + here.search, FALLBACK_ORIGIN)
1868
+
1869
+ const headers = new Headers(request.headers)
1870
+
1871
+ // Never forwarded: a vhost server routes on it, so telling Herd the host is
1872
+ // localhost:5173 means it has no such site and answers 404. fetch sets it
1873
+ // from the target instead.
1874
+ headers.delete('host')
1875
+ headers.set(FALLBACK_MARKER, '1')
1876
+
1877
+ // What the browser actually asked for. Without these the backend generates
1878
+ // absolute urls — url(), route(), redirects, form actions — against its own
1879
+ // origin rather than this one, and a redirect walks the browser off this
1880
+ // server onto the backend.
1881
+ //
1882
+ // They only take effect if the backend trusts this proxy: Laravel needs the
1883
+ // renderer's address in trustProxies. Sent regardless, because a header an
1884
+ // untrusting backend ignores costs nothing, and the alternative is that
1885
+ // there is no way to get it right at all.
1886
+ headers.set('x-forwarded-host', here.host)
1887
+ headers.set('x-forwarded-proto', here.protocol.replace(':', ''))
1888
+
1889
+ const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
1890
+
1891
+ try {
1892
+ return await fetch(target, {
1893
+ method: request.method,
1894
+ headers,
1895
+ body: hasBody ? request.body : undefined,
1896
+ // A redirect is the backend's answer and belongs to the browser.
1897
+ // Following it here would return the destination's body under this url.
1898
+ redirect: 'manual',
1899
+ ...(hasBody ? { duplex: 'half' } : {}),
1900
+ } as RequestInit)
1901
+ } catch (error) {
1902
+ return new Response(
1903
+ // The cause, not just the wrapper: fetch reports every network failure
1904
+ // as the same 'TypeError: fetch failed', and the refused address
1905
+ // underneath it is the whole of the diagnosis.
1906
+ 'The backend at ' + FALLBACK_ORIGIN + ' is not answering: ' +
1907
+ String((error as { cause?: unknown }).cause ?? error),
1908
+ { status: 502 },
1909
+ )
1910
+ }
1637
1911
  }
1638
1912
  `;
1639
1913
  }
@@ -1927,7 +2201,19 @@ export function rscRoutes(options = {}) {
1927
2201
  if (existsSync(genDir))
1928
2202
  rmSync(genDir, { recursive: true, force: true });
1929
2203
  mkdirSync(genDir, { recursive: true });
1930
- writeFileSync(join(genDir, 'entry.rsc.tsx'), generateEntryRsc());
2204
+ // Where a url this server does not own is handed on. Resolved exactly as
2205
+ // host calls resolve their endpoint, from the app's own .env, so the two
2206
+ // cannot end up pointing at different backends. Development only: a
2207
+ // build's server.ts decides this for itself.
2208
+ const backendEnv = loadEnv(env.mode, projectRoot, '');
2209
+ const fallbackOrigin = options.devFallback === false
2210
+ ? ''
2211
+ : (options.devFallback ??
2212
+ hostCallOptions?.endpoint ??
2213
+ backendEnv.RSC_BACKEND ??
2214
+ backendEnv.APP_URL ??
2215
+ '');
2216
+ writeFileSync(join(genDir, 'entry.rsc.tsx'), generateEntryRsc(fallbackOrigin));
1931
2217
  writeFileSync(join(genDir, 'entry.ssr.tsx'), generateEntrySsr());
1932
2218
  writeFileSync(join(genDir, 'entry.browser.tsx'), generateEntryBrowser());
1933
2219
  // Written beside the entries, for a host to read instead of walking the
@@ -1956,9 +2242,38 @@ export function rscRoutes(options = {}) {
1956
2242
  define: {
1957
2243
  'process.env.NODE_ENV': JSON.stringify(env.mode === 'development' ? 'development' : 'production'),
1958
2244
  },
1959
- // Public URL for browser-facing client assets (served from public/ by
1960
- // the web server — never through PHP).
1961
- base: assetsBaseUrl,
2245
+ /*
2246
+ * This package's client modules are served as source, never
2247
+ * pre-bundled.
2248
+ *
2249
+ * Vite treats an installed package as a dependency and optimizes it,
2250
+ * which binds its JSX imports to one particular optimized
2251
+ * react/jsx-runtime chunk. The moment Vite discovers another dependency
2252
+ * and re-optimizes, that chunk's hash changes and the binding breaks:
2253
+ *
2254
+ * SyntaxError: The requested module '.../react_jsx-runtime.js?v=...'
2255
+ * does not provide an export named 't'
2256
+ *
2257
+ * Which reads as a React or a bundler bug. What it does is take down
2258
+ * every page importing Link or Form — the shell renders, hydration
2259
+ * throws, React unmounts the document, and the page goes blank with
2260
+ * that message the only clue.
2261
+ *
2262
+ * Excluded rather than pinned, because the package already ships ESM
2263
+ * that needs no conversion. The app's own excludes are kept.
2264
+ */
2265
+ optimizeDeps: {
2266
+ exclude: [PACKAGE_NAME, ...(_config.optimizeDeps?.exclude ?? [])],
2267
+ },
2268
+ // Public URL for browser-facing client assets, and a BUILD concern
2269
+ // only: it says where the built files will be served from.
2270
+ //
2271
+ // Applying it in dev makes it Vite's public base, and then the dev
2272
+ // server answers pages only under that prefix — every route 404s with
2273
+ // "The server is configured with a public base URL", which reads as a
2274
+ // routing bug rather than as this line. In dev the pages are the root;
2275
+ // the assets come from the same origin either way.
2276
+ base: env.command === 'build' ? assetsBaseUrl : '/',
1962
2277
  root: outDir,
1963
2278
  // Force single instances of React/RSC runtime — critical when the
1964
2279
  // package is symlinked (local dev / monorepo), else "use client"
@@ -2005,6 +2320,73 @@ export function rscRoutes(options = {}) {
2005
2320
  * nothing.
2006
2321
  */
2007
2322
  configureServer(server) {
2323
+ // rpc() has to reach the backend while the dev server is serving.
2324
+ //
2325
+ // A built deployment installs this itself: the server running
2326
+ // createRscHandler passes `hostCalls`. Nothing does that here, and with
2327
+ // no host installed every rpc() is refused — so a page whose data comes
2328
+ // from the backend renders its shell and then blanks, which reads as a
2329
+ // hydration bug rather than a missing wire.
2330
+ server.httpServer?.once('listening', async () => {
2331
+ const env = server.environments?.rsc;
2332
+ if (!env?.runner)
2333
+ return;
2334
+ // The app's own .env, unprefixed. A Laravel app already has both of
2335
+ // these, which is what makes this need no configuring: APP_URL is the
2336
+ // backend and RSC_HOST_CALL_SECRET is the secret it checks.
2337
+ const fromEnv = loadEnv(server.config.mode, projectRoot, '');
2338
+ const secret = hostCallOptions?.secret ?? fromEnv.RSC_HOST_CALL_SECRET;
2339
+ const origin = hostCallOptions?.endpoint ?? fromEnv.RSC_BACKEND ?? fromEnv.APP_URL;
2340
+ if (!secret || !origin)
2341
+ return;
2342
+ const path = hostCallOptions?.path ?? fromEnv.RSC_HOST_CALL_PATH ?? '/__rsc/host-call';
2343
+ const endpoint = origin.replace(/\/$/, '') + path;
2344
+ try {
2345
+ const entry = await env.runner.import(join(genDir, 'entry.rsc.tsx'));
2346
+ const install = entry.installHostFn;
2347
+ install?.(httpHostCalls({ endpoint, secret }));
2348
+ }
2349
+ catch (error) {
2350
+ // Reported rather than thrown: the dev server is still useful for
2351
+ // every page that needs no data, and a failure here would otherwise
2352
+ // look like the server refusing to start.
2353
+ server.config.logger.warn(`[rsc-routes] could not wire host calls to ${endpoint}: ` +
2354
+ (error instanceof Error ? error.message : String(error)));
2355
+ }
2356
+ });
2357
+ // Written once the server is listening, because only then is the port
2358
+ // known. Removed on shutdown so a backend can tell a dev server that is
2359
+ // gone from one that is merely slow to answer.
2360
+ if (hotFile) {
2361
+ const remove = () => {
2362
+ try {
2363
+ if (existsSync(hotFile))
2364
+ rmSync(hotFile);
2365
+ }
2366
+ catch { }
2367
+ };
2368
+ server.httpServer?.once('listening', () => {
2369
+ // The url Vite resolved, not one built from the port. A dev server
2370
+ // whose port is already taken on IPv4 binds IPv6 only and keeps the
2371
+ // number — so http://127.0.0.1:<port> is a reachable-looking address
2372
+ // that nothing answers, and the backend reports the renderer as down
2373
+ // while it is plainly running.
2374
+ const resolved = server.resolvedUrls?.local?.[0];
2375
+ const address = server.httpServer?.address();
2376
+ const url = resolved ??
2377
+ (typeof address === 'object' && address
2378
+ ? `http://${address.family === 'IPv6' ? `[${address.address}]` : address.address}:${address.port}`
2379
+ : null);
2380
+ if (!url)
2381
+ return;
2382
+ mkdirSync(dirname(hotFile), { recursive: true });
2383
+ writeFileSync(hotFile, url.replace(/\/$/, ''));
2384
+ });
2385
+ for (const signal of ['SIGINT', 'SIGTERM', 'exit']) {
2386
+ process.once(signal, remove);
2387
+ }
2388
+ server.httpServer?.once('close', remove);
2389
+ }
2008
2390
  const shapes = new Set(ROUTE_FILES.map((name) => name));
2009
2391
  const affectsRouting = (file) => {
2010
2392
  if (!file.startsWith(sourceDir))