@rsc-kit/core 0.5.0 → 0.7.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
@@ -5,8 +5,8 @@
5
5
  // that global is called, and how a route declares dynamic props, are options —
6
6
  // nothing here knows or cares which backend is driving it.
7
7
  //
8
- // import { rscRoutes } from '<package>/vite'
9
- // export default defineConfig({ plugins: [rscRoutes(), react({ compiler: true })] })
8
+ // import { rscKit } from '<package>/vite'
9
+ // export default defineConfig({ plugins: [rscKit(), react({ compiler: true })] })
10
10
  //
11
11
  // The plugin discovers the app/ route tree, generates the three entries that
12
12
  // carry the route composition and the worker's render contract, and supplies
@@ -19,7 +19,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
19
19
  import rsc from '@vitejs/plugin-rsc';
20
20
  import { loadEnv } from 'vite';
21
21
  import { httpHostCalls } from './hostCalls.js';
22
- // Resolved once per rscRoutes() call. One build runs in one process, so these are
22
+ // Resolved once per rscKit() call. One build runs in one process, so these are
23
23
  // module state rather than threaded through every helper.
24
24
  let projectRoot;
25
25
  let sourceDir;
@@ -27,7 +27,6 @@ let outDir;
27
27
  let appDir;
28
28
  let genDir;
29
29
  let publicAssetsDir;
30
- let assetsBaseUrl;
31
30
  let hotFile;
32
31
  let hostCallOptions;
33
32
  let packageDir;
@@ -36,7 +35,7 @@ let interceptManifestFile;
36
35
  let packageAlias;
37
36
  /** Dev-server origin; empty in a build. See devUrls.ts. */
38
37
  let devOrigin;
39
- /** 'server' or 'export' — see RscRoutesOptions.output. */
38
+ /** 'server' or 'export' — see RscKitOptions.output. */
40
39
  let output;
41
40
  /** Where an exported site is written. */
42
41
  let exportPath;
@@ -47,11 +46,11 @@ let exportPath;
47
46
  */
48
47
  let staticPayloads;
49
48
  let routeConfig;
50
- /** Whether `vite build` freezes pages when it finishes — see RscRoutesOptions. */
49
+ /** Whether `vite build` freezes pages when it finishes — see RscKitOptions. */
51
50
  let prerenderAfterBuild;
52
51
  /** True during `vite build --watch`, where re-rendering every route is noise. */
53
52
  let isWatch = false;
54
- /** Host functions to generate stubs for — see RscRoutesOptions.hostActions. */
53
+ /** Host functions to generate stubs for — see RscKitOptions.hostActions. */
55
54
  let hostActions;
56
55
  /**
57
56
  * This file's directory.
@@ -148,10 +147,27 @@ function resolvePaths(options) {
148
147
  // Generated entries live under the (in-project) out dir so module resolution
149
148
  // can walk up to the project's node_modules (@vitejs/plugin-rsc, react, ...).
150
149
  genDir = join(outDir, '.gen');
151
- // The CLIENT bundle is browser-facing and has to be web-served; the rsc/ssr
152
- // bundles are SERVER code and stay under outDir, which must never be public.
153
- publicAssetsDir = resolve(options.assetsDir || process.env.RSC_ASSETS_DIR || join(projectRoot, 'dist/client'));
154
- assetsBaseUrl = options.assetsUrl || process.env.RSC_ASSETS_URL || '/';
150
+ // Not a migration guard — those are not worth carrying in a pre-release.
151
+ // This is the silent-failure guard the rest of this file is written to be.
152
+ //
153
+ // Nitro publishes the assets and serves them from its own root. A prefix set
154
+ // here would be emitted into the markup and answered by nobody: every asset
155
+ // 404s while every page still renders, so the app arrives unstyled, never
156
+ // hydrates, and logs nothing anywhere. Vite does not typecheck a config, so
157
+ // removing the option from the type is not enough to stop it.
158
+ //
159
+ // There is deliberately no check for `nitro`. It is gone from the type, and
160
+ // a config still passing `nitro: true` is asking for exactly what it gets.
161
+ const removed = ['assetsDir', 'assetsUrl'].filter((key) => options[key] !== undefined);
162
+ if (removed.length > 0) {
163
+ throw new Error(`[rsc-kit] ${removed.join(' and ')} ${removed.length === 1 ? 'is' : 'are'} no longer an option.\n\n` +
164
+ 'Nitro publishes the browser assets to .output/public and serves them from its own\n' +
165
+ 'root, so there is no prefix to set. That directory is the deployment — point nginx\n' +
166
+ 'or a CDN at it if something other than the app should serve them.');
167
+ }
168
+ // Vite's own default. Nitro overrides it with .output/public, which is why
169
+ // there is nothing here to configure.
170
+ publicAssetsDir = resolve(join(projectRoot, 'dist/client'));
155
171
  hotFile = options.hotFile || process.env.RSC_HOT_FILE || '';
156
172
  hostCallOptions = options.hostCall;
157
173
  packageDir = resolve(options.packageDir || process.env.RSC_PACKAGE_DIR || thisDir());
@@ -177,7 +193,7 @@ function resolvePaths(options) {
177
193
  hostActions = options.hostActions ?? fileHostActions(projectRoot);
178
194
  }
179
195
  function log(...args) {
180
- console.error('[rsc-routes]', ...args);
196
+ console.error('[rsc-kit]', ...args);
181
197
  }
182
198
  // ── The route manifest ───────────────────────────────────────────────────────
183
199
  /**
@@ -455,15 +471,42 @@ function reportAllDynamic() {
455
471
 
456
472
  ${routes.length} dynamic — prerendering is off`);
457
473
  }
458
- async function prerenderAfterBundles() {
459
- const bundle = join(outDir, 'dist/rsc/index.js');
460
- if (!existsSync(bundle))
461
- return;
474
+ /**
475
+ * The rsc bundle the build just wrote, whatever it decided to call it.
476
+ *
477
+ * Two things were assumed here and both were wrong for somebody. The directory
478
+ * was assumed to be <outDir>/dist/rsc, which Nitro does not use — it builds the
479
+ * environment under node_modules/.nitro. And the file was assumed to be
480
+ * index.js, which Vite only emits for a package that declares `type: module`;
481
+ * everyone else got index.mjs. Either miss returned quietly, and a build that
482
+ * prerendered nothing printed exactly what a build with no pages to freeze
483
+ * prints.
484
+ */
485
+ function resolveRscBundle(dir) {
486
+ for (const name of ['index.js', 'index.mjs']) {
487
+ const candidate = join(dir, name);
488
+ if (existsSync(candidate))
489
+ return candidate;
490
+ }
491
+ return null;
492
+ }
493
+ async function prerenderAfterBundles(bundle, staticDir, assetsDir) {
494
+ // A missing bundle used to be a silent `return`, and under Nitro it was the
495
+ // normal case: the path was assumed to be <outDir>/dist/rsc/index.js, Nitro
496
+ // builds the rsc environment somewhere else entirely, and so every Nitro app
497
+ // — every scaffolded app, and Laravel — prerendered nothing at all. No
498
+ // classification printed, no route frozen, and every page renders per
499
+ // visitor. The caller resolves the path from the environment now, so this is
500
+ // back to being the impossible case it reads as.
501
+ if (!bundle) {
502
+ throw new Error('[rsc-kit] The build produced no rsc bundle to prerender from.\n' +
503
+ 'Prerendering renders the app, so it needs the bundle the build just wrote. ' +
504
+ 'Build with prerender: false to render every page on demand instead.');
505
+ }
462
506
  const [{ prerender, summary, legend }, { writeTo }] = await Promise.all([
463
507
  import('./prerender.js'),
464
508
  import('./files.js'),
465
509
  ]);
466
- const staticDir = join(outDir, 'static');
467
510
  // Cleared first: a route that changes classification between builds
468
511
  // otherwise leaves its old shell on disk and the host goes on serving it.
469
512
  // Nothing warns — the page loads, with content from the previous build.
@@ -488,10 +531,52 @@ ${legend(results)}
488
531
 
489
532
  ${summary(results)}`);
490
533
  if (failed > 0) {
491
- throw new Error(`[rsc-routes] ${failed} route${failed === 1 ? '' : 's'} failed to render.\n` +
534
+ throw new Error(`[rsc-kit] ${failed} route${failed === 1 ? '' : 's'} failed to render.\n` +
492
535
  'Prerendering runs your app: whatever those pages need at render time has to be\n' +
493
536
  'reachable from the build. Fix them, or build with prerender: false and render on demand.');
494
537
  }
538
+ if (output === 'export')
539
+ await exportAfterPrerender(results, staticDir, assetsDir);
540
+ }
541
+ /**
542
+ * Turn the frozen output into a directory a static host can serve.
543
+ *
544
+ * Part of the build rather than a script the app writes, for the same reason
545
+ * prerendering is: it needs the results the build already has, and the engine
546
+ * bundle they came from is somewhere only the build knows. That path used to be
547
+ * stable enough to hard-code in an example — `build/dist/rsc/index.js` — and it
548
+ * is not any more, because Nitro builds the rsc environment under
549
+ * node_modules. A script asking for it would be a script that breaks.
550
+ *
551
+ * RSC_EXPORT_FORCE writes the site anyway and reports what it left out, which
552
+ * is how an app is moved towards being exportable. Without it a route that
553
+ * could not be frozen fails the build, because a shell on a static host is a
554
+ * page that loads and then stays empty forever.
555
+ */
556
+ async function exportAfterPrerender(results, staticDir, assetsDir) {
557
+ const [{ exportSite, NotExportable }, { writeTo, prerenderedFrom, copyAssets }] = await Promise.all([
558
+ import('./export.js'),
559
+ import('./files.js'),
560
+ ]);
561
+ try {
562
+ const { pages, refused } = await exportSite({
563
+ results,
564
+ read: prerenderedFrom(staticDir),
565
+ write: writeTo(exportPath),
566
+ manifest: routeManifest(),
567
+ assets: copyAssets(join(assetsDir, 'assets'), exportPath, '/assets/'),
568
+ force: process.env.RSC_EXPORT_FORCE === '1',
569
+ });
570
+ console.log(`\n Exported ${pages} page${pages === 1 ? '' : 's'} to ${relative(projectRoot, exportPath)}`);
571
+ if (refused.length > 0) {
572
+ console.log(` Left out ${refused.length}: ${refused.map((r) => r.url).join(', ')}`);
573
+ }
574
+ }
575
+ catch (error) {
576
+ if (!(error instanceof NotExportable))
577
+ throw error;
578
+ throw new Error(`[rsc-kit] ${error.message}`);
579
+ }
495
580
  }
496
581
  /** `/posts/[slug]` — the pattern, in the shape the app writes its links in. */
497
582
  function patternOf(segments) {
@@ -664,6 +749,160 @@ function hasStaticParams(absPath) {
664
749
  return /export\s+((async\s+)?function\s+generateStaticParams|const\s+generateStaticParams)/.test(src);
665
750
  }
666
751
  // ── Codegen ──────────────────────────────────────────────────────────────────
752
+ /**
753
+ * The dev fall-through, emitted only when there is a backend to hand a url to.
754
+ *
755
+ * A JavaScript host has no backend — it IS the backend — so its entry should
756
+ * not carry this code, its constants, or the branch that tests them. Nothing
757
+ * generated is cheaper than something generated that returns early, and it
758
+ * keeps every backend-shaped idea out of a runtime that has no backend.
759
+ */
760
+ const FALLBACK_CONSTS = `const FALLBACK_ORIGIN = __ORIGIN__
761
+ const FALLBACK_MARKER = 'x-rsc-renderer-fallback'
762
+ const PROXIED_MARKER = 'x-rsc-proxied-by-backend'
763
+ `;
764
+ const FALLBACK_BODY = ` const answer = await devHandler(request)
765
+
766
+ if (answer) return answer
767
+
768
+ // Nothing here owns this url. In development the backend usually does — a
769
+ // Blade page, /login, a webhook, an uploaded file under /storage — so the
770
+ // request is handed on rather than refused, and this origin is the whole
771
+ // application instead of the RSC half of it.
772
+ //
773
+ // FALLBACK_MARKER is what stops this looping. The backend's own fallback
774
+ // forwards what it cannot route BACK to this server, so without a marker a
775
+ // url neither side owns would bounce between them until something gave out.
776
+ // Seeing it, the backend answers 404 itself.
777
+ // Came from the backend's own proxy, so it has already been through that
778
+ // route table and the answer there was no. Sending it back asks the same
779
+ // question a second time.
780
+ if (!FALLBACK_ORIGIN || request.headers.has(PROXIED_MARKER)) {
781
+ return new Response('Not found', { status: 404 })
782
+ }
783
+
784
+ // Built from the origin rather than by assigning onto a copy of this url.
785
+ // The URL host setter keeps whatever port is already there when the value it
786
+ // is given has none, so a portless backend — every Herd or Valet site —
787
+ // would inherit the dev server's own port and this server would call itself.
788
+ const here = new URL(request.url)
789
+ const target = new URL(here.pathname + here.search, FALLBACK_ORIGIN)
790
+
791
+ const headers = new Headers(request.headers)
792
+
793
+ // Never forwarded: a vhost server routes on it, so telling Herd the host is
794
+ // localhost:5173 means it has no such site and answers 404. fetch sets it
795
+ // from the target instead.
796
+ headers.delete('host')
797
+ headers.set(FALLBACK_MARKER, '1')
798
+
799
+ // What the browser actually asked for. Without these the backend generates
800
+ // absolute urls — url(), route(), redirects, form actions — against its own
801
+ // origin rather than this one, and a redirect walks the browser off this
802
+ // server onto the backend.
803
+ //
804
+ // They only take effect if the backend trusts this proxy: Laravel needs the
805
+ // renderer's address in trustProxies. Sent regardless, because a header an
806
+ // untrusting backend ignores costs nothing, and the alternative is that
807
+ // there is no way to get it right at all.
808
+ headers.set('x-forwarded-host', here.host)
809
+ headers.set('x-forwarded-proto', here.protocol.replace(':', ''))
810
+
811
+ const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
812
+
813
+ try {
814
+ return await fetch(target, {
815
+ method: request.method,
816
+ headers,
817
+ body: hasBody ? request.body : undefined,
818
+ // A redirect is the backend's answer and belongs to the browser.
819
+ // Following it here would return the destination's body under this url.
820
+ redirect: 'manual',
821
+ ...(hasBody ? { duplex: 'half' } : {}),
822
+ } as RequestInit)
823
+ } catch (error) {
824
+ return new Response(
825
+ // The cause, not just the wrapper: fetch reports every network failure
826
+ // as the same 'TypeError: fetch failed', and the refused address
827
+ // underneath it is the whole of the diagnosis.
828
+ 'The backend at ' + FALLBACK_ORIGIN + ' is not answering: ' +
829
+ String((error as { cause?: unknown }).cause ?? error),
830
+ { status: 502 },
831
+ )
832
+ }
833
+ `;
834
+ /**
835
+ * Wire host calls when Nitro is the server.
836
+ *
837
+ * Every other arrangement installs this from outside: the dev server does it in
838
+ * configureServer, and a generated server.ts passes hostCalls to
839
+ * createRscHandler. Under Nitro there is no such file — this module IS the
840
+ * server — so an rpc() page renders its loading fallback forever and the
841
+ * backend never hears from it. Silent, which is the worst kind.
842
+ *
843
+ * Read at request time rather than at build time: the endpoint belongs to the
844
+ * deployment, and baking it in would mean rebuilding to change where the
845
+ * backend is.
846
+ */
847
+ /**
848
+ * What a generated server.ts passes, for the arrangement that has no server.ts.
849
+ *
850
+ * props: a page reading `params` gets its url params from the engine, and the
851
+ * query string is merged in here — `params.q` should mean the same thing
852
+ * whether it arrived in the path or after the ?. Only the Laravel template
853
+ * passed this before, so the other hosts quietly did not have it.
854
+ *
855
+ * version: the client compares it on every navigation and falls back to a full
856
+ * load when it changes. Without one, a browser keeps talking to a deployment
857
+ * that is gone — worst behind a CDN, where the shell it holds may already be
858
+ * older than the payloads it asks for.
859
+ */
860
+ /**
861
+ * Where frozen pages live inside `.output/server`, written by the build and
862
+ * found by the generated entry. One constant because the two halves are in
863
+ * different processes — a name written down twice is a name that drifts, and
864
+ * the failure is silent: the server finds no directory, decides nothing was
865
+ * frozen, and renders every page live exactly as it did before.
866
+ */
867
+ const NITRO_STATIC_DIR = 'rsc-static';
868
+ /**
869
+ * Serve what the build froze.
870
+ *
871
+ * `import.meta.env.PROD` rather than an unconditional reader: in development
872
+ * there is no build output to serve, and a stale one would be worse than none.
873
+ * Vite replaces it with a literal, so the dev bundle keeps no reference to it.
874
+ */
875
+ const NITRO_PRERENDERED = ` prerendered: import.meta.env.PROD
876
+ ? prerenderedBeside(import.meta.url, ${JSON.stringify(NITRO_STATIC_DIR)})
877
+ : undefined,
878
+ `;
879
+ const NITRO_HANDLER_OPTIONS = ` props: (match, request) => ({
880
+ ...match.params,
881
+ ...Object.fromEntries(new URL(request.url).searchParams),
882
+ }),
883
+ version: process.env.RSC_BUILD_VERSION,
884
+ `;
885
+ const NITRO_HOST_CALLS = `
886
+ let hostInstalled = false
887
+
888
+ function installHostCallsOnce(): void {
889
+ if (hostInstalled) return
890
+ hostInstalled = true
891
+
892
+ const origin = process.env.RSC_BACKEND ?? process.env.APP_URL
893
+ const secret = process.env.RSC_HOST_CALL_SECRET
894
+
895
+ // Both, or neither: a secret without a backend has nowhere to go, and a
896
+ // backend without one is refused at the door. See the dev server, which
897
+ // gates on exactly the same pair.
898
+ if (!origin || !secret) return
899
+
900
+ const path = process.env.RSC_HOST_CALL_PATH ?? '/__rsc/host-call'
901
+
902
+ installHostFn(httpHostCalls({ endpoint: origin.replace(/\\/$/, '') + path, secret }))
903
+ }
904
+
905
+ `;
667
906
  function generateEntryRsc(fallbackOrigin = '') {
668
907
  const imports = [];
669
908
  const mapEntries = [];
@@ -691,7 +930,7 @@ function generateEntryRsc(fallbackOrigin = '') {
691
930
  // The engine's own modules are named without an extension: this plugin runs
692
931
  // from src/ in its own repo and from dist/ once published, and Vite resolves
693
932
  // either. Naming .tsx here builds fine from source and fails after publish.
694
- return `// GENERATED by rscRoutes() — do not edit.
933
+ return `// GENERATED by rscKit() — do not edit.
695
934
  import { SegmentBoundary } from ${JSON.stringify(join(packageDir, "js/SegmentBoundary"))}
696
935
  import { DocumentTitle } from ${JSON.stringify(join(packageDir, "js/DocumentTitle"))}
697
936
  import { SlotBoundary } from ${JSON.stringify(join(packageDir, "js/SlotBoundary"))}
@@ -700,6 +939,8 @@ import { PathnameProvider } from ${JSON.stringify(join(packageDir, "js/PathnameP
700
939
  import { searchParams as requestSearchParams } from ${JSON.stringify(join(packageDir, "request"))}
701
940
  import { redirectDigest } from ${JSON.stringify(join(packageDir, "redirectDigest"))}
702
941
  import { createRscHandler } from ${JSON.stringify(join(packageDir, "host"))}
942
+ import { httpHostCalls } from ${JSON.stringify(join(packageDir, 'hostCalls'))}
943
+ import { prerenderedBeside } from ${JSON.stringify(join(packageDir, 'files'))}
703
944
  import { renderToReadableStream, decodeReply, loadServerAction } from '@vitejs/plugin-rsc/rsc'
704
945
  import { Suspense, createElement, Fragment } from 'react'
705
946
  import { AsyncLocalStorage } from 'node:async_hooks'
@@ -783,8 +1024,8 @@ export function installHostFn(fn: HostFn) {
783
1024
  * a time and silently wrong for two: the second overwrites the first's saved
784
1025
  * value, and both pages then call whichever closure assigned last, so
785
1026
  * usedDynamicApis is recorded against the wrong page and routes are
786
- * misclassified. Benign for a pure-JS host, which installs none; wrong for
787
- * Laravel, which does.
1027
+ * misclassified. Benign for a host that installs none; wrong for any host
1028
+ * that does.
788
1029
  */
789
1030
  const probeHost = new AsyncLocalStorage<(...args: unknown[]) => Promise<unknown>>()
790
1031
 
@@ -970,7 +1211,7 @@ function buildElement(
970
1211
 
971
1212
  // Resolve route metadata into React elements. React 19 hoists <title>/<meta>
972
1213
  // rendered anywhere in the tree into <head> — so the "vite way" for metadata is
973
- // to render it as elements, no PHP-side <head> string injection.
1214
+ // to render it as elements, rather than a backend injecting a <head> string.
974
1215
  async function renderTree(
975
1216
  component: string,
976
1217
  props: Record<string, unknown>,
@@ -1202,7 +1443,7 @@ function flightOnError(error: unknown): string | undefined {
1202
1443
 
1203
1444
  if (digest) return digest
1204
1445
 
1205
- console.error('[rsc-routes]', error)
1446
+ console.error('[rsc-kit]', error)
1206
1447
 
1207
1448
  return undefined
1208
1449
  }
@@ -1622,13 +1863,13 @@ export async function handleRscPayload(
1622
1863
 
1623
1864
  // PPR shell + classification (worker: rsc-ppr-shell — build-time).
1624
1865
  //
1625
- // php() is replaced by a probe that records the call and never resolves, so
1866
+ // rpc() is replaced by a probe that records the call and never resolves, so
1626
1867
  // every subtree depending on per-request data stays suspended while everything
1627
1868
  // static renders normally. Whatever React has flushed when the deadline passes
1628
1869
  // IS the shell: layouts, static markup, and Suspense fallbacks.
1629
1870
  //
1630
1871
  // The two flags this returns are what the prerender pipeline classifies on:
1631
- // usedDynamicApis — the page touched php(), so it cannot be frozen whole
1872
+ // usedDynamicApis — the page touched rpc(), so it cannot be frozen whole
1632
1873
  // timedOut — the render never finished, i.e. it is still waiting on
1633
1874
  // data, so only the shell is safe to cache
1634
1875
  // A page that sets neither is genuinely static and can be prerendered fully.
@@ -1660,13 +1901,14 @@ export async function handleRscPprShell(
1660
1901
  // once at build time.
1661
1902
  //
1662
1903
  // 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
1904
+ // call is made and its answer stored. That is the only way a page whose data
1905
+ // lives in the host can be static at all, since a host call is its only route
1906
+ // to that data — and
1665
1907
  // connection() is how such a page opts back out.
1666
1908
  //
1667
1909
  // Asked of the caller rather than read from whatever host happens to be
1668
1910
  // installed, because those are different statements: a test that installed
1669
- // one is not a build that can reach PHP.
1911
+ // one is not a build that can reach the host.
1670
1912
  canReachHost = false,
1671
1913
  ): Promise<{ shellHtml: string; clientChunks: unknown; timedOut: boolean; usedDynamicApis: boolean; error?: string }> {
1672
1914
  // Deliberately no middleware here. The probe is asking whether the content is
@@ -1717,7 +1959,7 @@ export async function handleRscPprShell(
1717
1959
  if (controller.signal.aborted) return undefined
1718
1960
 
1719
1961
  renderFailure ??= e instanceof Error ? e.message : String(e)
1720
- console.error('[rsc-routes]', e)
1962
+ console.error('[rsc-kit]', e)
1721
1963
 
1722
1964
  return undefined
1723
1965
  }
@@ -1810,17 +2052,21 @@ export async function handleRscPprShell(
1810
2052
  * step and no NODE_ENV to match — this is React's development build because
1811
2053
  * Vite is running in development.
1812
2054
  *
1813
- * Assets and prerendered pages are deliberately absent. Vite serves its own
1814
- * assets in dev, and a frozen page is a build artifact: serving one here would
1815
- * hand back the last build's HTML for a file just edited.
2055
+ * Assets are deliberately absent: Vite serves its own.
2056
+ *
2057
+ * Frozen pages are absent *in development* — a frozen page is a build
2058
+ * artifact, and serving one here would hand back the last build's HTML for a
2059
+ * file just edited. Under Nitro this same file is also the production entry,
2060
+ * and reading that reason as applying to both is what left every built Nitro
2061
+ * server rendering live pages it had already frozen. The gate is the mode, not
2062
+ * the file.
1816
2063
  */
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
-
2064
+ ${fallbackOrigin ? FALLBACK_CONSTS.replace('__ORIGIN__', JSON.stringify(fallbackOrigin)) : ''}${NITRO_HOST_CALLS}
1821
2065
  let devHandler: ((request: Request) => Promise<Response | null>) | null = null
1822
2066
 
1823
2067
  export default async function handler(request: Request): Promise<Response> {
2068
+ installHostCallsOnce()
2069
+
1824
2070
  devHandler ??= createRscHandler({
1825
2071
  engine: {
1826
2072
  manifest,
@@ -1837,83 +2083,14 @@ export default async function handler(request: Request): Promise<Response> {
1837
2083
  resolveMetadata,
1838
2084
  runRouteMiddleware,
1839
2085
  } as never,
1840
- })
1841
-
1842
- const answer = await devHandler(request)
2086
+ ${NITRO_HANDLER_OPTIONS}${NITRO_PRERENDERED} })
1843
2087
 
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
- }
1911
- }
2088
+ ${fallbackOrigin ? FALLBACK_BODY : " return (await devHandler(request)) ?? new Response('Not found', { status: 404 })\n"}}
1912
2089
  `;
1913
2090
  }
1914
2091
  function generateEntrySsr() {
1915
2092
  const devUrls = join(packageDir, 'devUrls');
1916
- return `// GENERATED by rscRoutes() — do not edit.
2093
+ return `// GENERATED by rscKit() — do not edit.
1917
2094
  import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
1918
2095
  import { renderToReadableStream, resume } from 'react-dom/server.edge'
1919
2096
  import { prerender } from 'react-dom/static.edge'
@@ -1945,7 +2122,7 @@ export async function handleSsr(
1945
2122
  const html = await renderToReadableStream(root as any, {
1946
2123
  bootstrapScriptContent,
1947
2124
  nonce,
1948
- onError: onError ?? ((error: unknown) => { console.error('[rsc-routes:ssr]', error) }),
2125
+ onError: onError ?? ((error: unknown) => { console.error('[rsc-kit:ssr]', error) }),
1949
2126
  })
1950
2127
 
1951
2128
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
@@ -2010,13 +2187,34 @@ export async function handleSsrResume(
2010
2187
 
2011
2188
  const html = await resume(root as any, postponed as any, {
2012
2189
  nonce,
2013
- onError: (error: unknown) => { console.error('[rsc-routes:resume]', error) },
2190
+ onError: (error: unknown) => { console.error('[rsc-kit:resume]', error) },
2014
2191
  })
2015
2192
 
2016
2193
  return DEV_ORIGIN ? rewriteViteDevUrlStream(html, DEV_ORIGIN) : html
2017
2194
  }
2018
- `;
2195
+ ${SSR_SERVICE}`;
2196
+ }
2197
+ /**
2198
+ * What Nitro calls this environment through.
2199
+ *
2200
+ * Nitro addresses each Vite environment as a service and calls fetch on it —
2201
+ * `mod = _mod.default || _mod; mod.fetch(req)` — so an entry that exports only
2202
+ * named functions is a 500 on every request. The named ones stay: the rsc
2203
+ * entry still reaches them through loadModule. This only adds the door Nitro
2204
+ * knocks on, and the request goes straight back to the rsc entry, which is the
2205
+ * one that owns rendering.
2206
+ */
2207
+ const SSR_SERVICE = `
2208
+ export default {
2209
+ fetch: async (request: Request): Promise<Response> => {
2210
+ const rsc = await import.meta.viteRsc.loadModule<{
2211
+ default: (request: Request) => Promise<Response>
2212
+ }>('rsc', 'index')
2213
+
2214
+ return rsc.default(request)
2215
+ },
2019
2216
  }
2217
+ `;
2020
2218
  function generateEntryBrowser() {
2021
2219
  const clientBootstrap = join(packageDir, 'js/createViteRscApp');
2022
2220
  // Only for an exported build, and only what the client needs to work out how
@@ -2029,7 +2227,7 @@ function generateEntryBrowser() {
2029
2227
  ? routeManifest().routes.map((route) => ({ segments: route.segments, layouts: route.layouts }))
2030
2228
  : null;
2031
2229
  const refreshModule = join(packageDir, 'js/navigate');
2032
- return `// GENERATED by rscRoutes() — do not edit.
2230
+ return `// GENERATED by rscKit() — do not edit.
2033
2231
  import { createViteRscApp } from ${JSON.stringify(clientBootstrap)}
2034
2232
  import { refresh } from ${JSON.stringify(refreshModule)}
2035
2233
 
@@ -2176,20 +2374,20 @@ function validateLoadingBoundaries() {
2176
2374
  // ── Plugin ───────────────────────────────────────────────────────────────────
2177
2375
  /** Names of plugins that transform JSX and must run after rsc() has split it. */
2178
2376
  const JSX_PLUGIN_PATTERN = /react|babel|oxc/i;
2179
- export function rscRoutes(options = {}) {
2377
+ export function rscKit(options = {}) {
2180
2378
  resolvePaths(options);
2181
2379
  const routesPlugin = {
2182
- name: 'rsc-routes',
2380
+ name: 'rsc-kit',
2183
2381
  config(_config, env) {
2184
2382
  if (!existsSync(appDir)) {
2185
- throw new Error(`[rsc-routes] No app directory at ${appDir} — nothing to build.`);
2383
+ throw new Error(`[rsc-kit] No app directory at ${appDir} — nothing to build.`);
2186
2384
  }
2187
2385
  components.clear();
2188
2386
  discover(appDir);
2189
2387
  log(`Discovered ${components.size} route components:`, [...components.keys()].join(', '));
2190
2388
  const loadingErrors = validateLoadingBoundaries();
2191
2389
  if (loadingErrors.length) {
2192
- throw new Error('[rsc-routes] A page that blocks before it can paint needs a loading.tsx boundary.\n\n' +
2390
+ throw new Error('[rsc-kit] A page that blocks before it can paint needs a loading.tsx boundary.\n\n' +
2193
2391
  loadingErrors.join('\n') +
2194
2392
  '\n\nAdd loading.tsx in the page directory (or a parent), or move the slow work\n' +
2195
2393
  'into a child component wrapped in its own <Suspense> so the page can paint.');
@@ -2206,13 +2404,20 @@ export function rscRoutes(options = {}) {
2206
2404
  // cannot end up pointing at different backends. Development only: a
2207
2405
  // build's server.ts decides this for itself.
2208
2406
  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
- '');
2407
+ // Only a backend this server could actually call. The shared secret is
2408
+ // what makes one a backend rather than a url that happens to be in the
2409
+ // environment — APP_URL is not a Laravel-only name, and a JavaScript
2410
+ // host that sets it for its own reasons must not find its 404s being
2411
+ // posted to it. Host calls gate on exactly the same pair, so the two
2412
+ // cannot end up disagreeing about whether a backend is there.
2413
+ //
2414
+ // Naming devFallback explicitly opts in regardless: someone who wrote
2415
+ // the address down means it.
2416
+ const backendSecret = hostCallOptions?.secret ?? backendEnv.RSC_HOST_CALL_SECRET;
2417
+ const detected = backendSecret
2418
+ ? (hostCallOptions?.endpoint ?? backendEnv.RSC_BACKEND ?? backendEnv.APP_URL ?? '')
2419
+ : '';
2420
+ const fallbackOrigin = options.devFallback === false ? '' : (options.devFallback ?? detected);
2216
2421
  writeFileSync(join(genDir, 'entry.rsc.tsx'), generateEntryRsc(fallbackOrigin));
2217
2422
  writeFileSync(join(genDir, 'entry.ssr.tsx'), generateEntrySsr());
2218
2423
  writeFileSync(join(genDir, 'entry.browser.tsx'), generateEntryBrowser());
@@ -2273,7 +2478,7 @@ export function rscRoutes(options = {}) {
2273
2478
  // "The server is configured with a public base URL", which reads as a
2274
2479
  // routing bug rather than as this line. In dev the pages are the root;
2275
2480
  // the assets come from the same origin either way.
2276
- base: env.command === 'build' ? assetsBaseUrl : '/',
2481
+ base: '/',
2277
2482
  root: outDir,
2278
2483
  // Force single instances of React/RSC runtime — critical when the
2279
2484
  // package is symlinked (local dev / monorepo), else "use client"
@@ -2350,7 +2555,7 @@ export function rscRoutes(options = {}) {
2350
2555
  // Reported rather than thrown: the dev server is still useful for
2351
2556
  // every page that needs no data, and a failure here would otherwise
2352
2557
  // look like the server refusing to start.
2353
- server.config.logger.warn(`[rsc-routes] could not wire host calls to ${endpoint}: ` +
2558
+ server.config.logger.warn(`[rsc-kit] could not wire host calls to ${endpoint}: ` +
2354
2559
  (error instanceof Error ? error.message : String(error)));
2355
2560
  }
2356
2561
  });
@@ -2403,7 +2608,7 @@ export function rscRoutes(options = {}) {
2403
2608
  const restart = (file) => {
2404
2609
  if (!affectsRouting(file))
2405
2610
  return;
2406
- server.config.logger.info(`[rsc-routes] route tree changed (${file.slice(sourceDir.length + 1)}) — restarting`);
2611
+ server.config.logger.info(`[rsc-kit] route tree changed (${file.slice(sourceDir.length + 1)}) — restarting`);
2407
2612
  void server.restart();
2408
2613
  };
2409
2614
  // Watched explicitly: the Vite root is the *out* directory, so the app's
@@ -2428,7 +2633,7 @@ export function rscRoutes(options = {}) {
2428
2633
  * Skipped in watch mode. A rebuild on every keystroke that also re-renders
2429
2634
  * every route is not a feedback loop anyone wants.
2430
2635
  */
2431
- async buildApp() {
2636
+ async buildApp(builder) {
2432
2637
  if (isWatch)
2433
2638
  return;
2434
2639
  // Say what the build did, even when it stored nothing.
@@ -2442,7 +2647,25 @@ export function rscRoutes(options = {}) {
2442
2647
  reportAllDynamic();
2443
2648
  return;
2444
2649
  }
2445
- await prerenderAfterBundles();
2650
+ // Asked of the build rather than assumed from `outDir`. The two layouts
2651
+ // differ — <outDir>/dist/rsc on its own, node_modules/.nitro/… under
2652
+ // Nitro — and hard-coding the first is what silently skipped the second.
2653
+ // Both environments emit `index.js`, so this is one path, not a branch.
2654
+ const rscOut = builder?.environments?.rsc?.config?.build?.outDir ?? join(outDir, 'dist/rsc');
2655
+ const bundle = resolveRscBundle(rscOut);
2656
+ // Where the frozen pages go, which is not the same question.
2657
+ //
2658
+ // On its own the plugin owns the output and server.ts reads <outDir>/static
2659
+ // from the repository. Under Nitro the deployment is `.output/` and
2660
+ // nothing outside it is copied to the server — so pages written anywhere
2661
+ // else exist on the build machine and nowhere after that. They go beside
2662
+ // the server bundle instead, and buildApp runs before Nitro assembles, so
2663
+ // they are in place by the time it does.
2664
+ const clientOut = builder?.environments?.client?.config?.build?.outDir;
2665
+ const staticDir = clientOut
2666
+ ? join(dirname(clientOut), 'server', NITRO_STATIC_DIR)
2667
+ : join(outDir, NITRO_STATIC_DIR);
2668
+ await prerenderAfterBundles(bundle, staticDir, clientOut ?? publicAssetsDir);
2446
2669
  },
2447
2670
  configResolved(config) {
2448
2671
  isWatch = config.build?.watch != null;
@@ -2453,18 +2676,21 @@ export function rscRoutes(options = {}) {
2453
2676
  const rscAt = names.findIndex((n) => n === 'rsc' || n.startsWith('rsc:'));
2454
2677
  const jsxAt = names.findIndex((n) => JSX_PLUGIN_PATTERN.test(n));
2455
2678
  if (rscAt !== -1 && jsxAt !== -1 && jsxAt < rscAt) {
2456
- throw new Error(`[rsc-routes] Plugin "${names[jsxAt]}" is resolved ahead of rsc(), so it would ` +
2679
+ throw new Error(`[rsc-kit] Plugin "${names[jsxAt]}" is resolved ahead of rsc(), so it would ` +
2457
2680
  'transform JSX before the client/server split.\n' +
2458
- 'Put rscRoutes() first in your plugins array. If it already is, that plugin ' +
2681
+ 'Put rscKit() first in your plugins array. If it already is, that plugin ' +
2459
2682
  "sets enforce: 'pre' and needs to be moved after rsc() explicitly.");
2460
2683
  }
2461
2684
  },
2462
2685
  };
2463
2686
  // rsc() ships as several plugins, and it has to lead. A promise is a legal
2464
2687
  // member of a Vite plugins array and is flattened in place, so this keeps
2465
- // rscRoutes() one entry in the app's config while still resolving the
2688
+ // rscKit() one entry in the app's config while still resolving the
2466
2689
  // plugin at call time — see appPluginRsc for why that matters.
2467
- return [appPluginRsc(), routesPlugin];
2690
+ // With Nitro, the server handler is Nitro's — it takes the rsc entry's
2691
+ // default export and builds the server around it. Leaving plugin-rsc's own
2692
+ // handler in place means two things claiming the same role.
2693
+ return [appPluginRsc({ serverHandler: false }), routesPlugin];
2468
2694
  }
2469
2695
  /**
2470
2696
  * @vitejs/plugin-rsc, resolved from the app rather than from here.
@@ -2483,18 +2709,18 @@ export function rscRoutes(options = {}) {
2483
2709
  * Resolving from the project root gets the app's copy, whose own `vite` import
2484
2710
  * then resolves to the app's Vite as well — one pair, and the check passes.
2485
2711
  */
2486
- async function appPluginRsc() {
2712
+ async function appPluginRsc(options = {}) {
2487
2713
  try {
2488
2714
  // Resolved against a file *in* the root, since a directory specifier
2489
2715
  // resolves relative to its parent.
2490
2716
  const fromApp = createRequire(join(projectRoot, 'package.json'));
2491
2717
  const entry = fromApp.resolve('@vitejs/plugin-rsc');
2492
2718
  const mod = (await import(pathToFileURL(entry).href));
2493
- return (mod.default ?? rsc)();
2719
+ return (mod.default ?? rsc)(options);
2494
2720
  }
2495
2721
  catch {
2496
2722
  // The app does not have its own; one copy, and the bundled import is it.
2497
- return rsc();
2723
+ return rsc(options);
2498
2724
  }
2499
2725
  }
2500
2726
  //# sourceMappingURL=vite.js.map