rsbuild-plugin-react-router 0.6.5 → 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/src/index.ts CHANGED
@@ -6,11 +6,7 @@ import { rspack, type RsbuildPlugin, type Rspack } from '@rsbuild/core';
6
6
  import { relative, resolve } from 'pathe';
7
7
 
8
8
  import { getDefaultConcurrency } from './concurrency.js';
9
- import {
10
- DEFAULT_JS_DIST_PATH,
11
- JS_EXTENSIONS,
12
- PLUGIN_NAME,
13
- } from './constants.js';
9
+ import { JS_EXTENSIONS, PLUGIN_NAME } from './constants.js';
14
10
  import { guardReactRouterLazyCompilation } from './lazy-compilation.js';
15
11
  import {
16
12
  findEntryFile,
@@ -222,13 +218,25 @@ export const pluginReactRouter = (
222
218
  warnOnClientSourceMaps(normalized, msg => api.logger.warn(msg), 'web');
223
219
  });
224
220
 
221
+ // The manifest / server `publicPath` follows the web environment's asset
222
+ // prefix because that is where the browser assets are served from. A web
223
+ // prefix the server cannot use (`'auto'`) falls back to the root prefix,
224
+ // so `output.assetPrefix: 'https://cdn/'` + web `'auto'` still emits CDN
225
+ // URLs from the server.
225
226
  api.onBeforeCreateCompiler(() => {
226
- const normalized = api.getNormalizedConfig();
227
- assetPrefix = resolveEffectiveAssetPrefix({
228
- dev: normalized.dev,
229
- output: normalized.output,
230
- isBuild: api.context.action === 'build',
231
- });
227
+ const root = api.getNormalizedConfig();
228
+ // `getNormalizedConfig({ environment: 'web' })` throws when the build was
229
+ // narrowed to other environments (`--environment node`), so look the web
230
+ // environment up on the root config instead.
231
+ const web = root.environments.web;
232
+ assetPrefix = resolveEffectiveAssetPrefix(
233
+ {
234
+ dev: web?.dev,
235
+ output: web?.output,
236
+ isBuild: api.context.action === 'build',
237
+ },
238
+ { dev: root.dev, output: root.output }
239
+ );
232
240
  });
233
241
 
234
242
  const configPath = findEntryFile(resolve('react-router.config'));
@@ -585,6 +593,7 @@ export const pluginReactRouter = (
585
593
  buildDirectory,
586
594
  finalEntryRscClientPath,
587
595
  finalEntryRscPath,
596
+ finalEntryRscSsrPath,
588
597
  outputClientPath,
589
598
  pluginName: PLUGIN_NAME,
590
599
  serverBuildFile,
@@ -831,39 +840,35 @@ export const pluginReactRouter = (
831
840
  }
832
841
 
833
842
  // Public requests stay bare while Rspack resolves seeded virtual files.
834
- const createVirtualModulePlugin = (
835
- publicPath: string,
836
- jsDistPath: string
837
- ) => {
843
+ const createVirtualModulePlugin = (publicPath: string) => {
838
844
  return new rspack.experiments.VirtualModulesPlugin(
839
- mapVirtualModules(modePlan.createVirtualModules(publicPath, jsDistPath))
845
+ mapVirtualModules(modePlan.createVirtualModules(publicPath))
840
846
  );
841
847
  };
842
848
 
843
849
  api.modifyRsbuildConfig(async (config, { mergeRsbuildConfig }) => {
844
- // The RSC bootstrap script URL must reflect the user's web js distPath;
845
- // the entry filename itself is deterministic because the plugin forces
846
- // web `output.filename.js` to '[name].js' below.
847
- const webDistPath = config.environments?.web?.output?.distPath;
848
- const rootDistPath = config.output?.distPath;
849
- const jsDistPath =
850
- (typeof webDistPath === 'object' ? webDistPath.js : undefined) ??
851
- (typeof rootDistPath === 'object' ? rootDistPath.js : undefined) ??
852
- DEFAULT_JS_DIST_PATH;
853
- const assetPrefix = resolveEffectiveAssetPrefix({
854
- dev: config.dev,
855
- output: config.output,
856
- isBuild,
857
- });
858
- const vmodPlugin = createVirtualModulePlugin(assetPrefix, jsDistPath);
859
- const useAsyncNodeChunkLoading =
860
- options.federation && resolvedServerOutput === 'commonjs';
861
- let nodeChunkLoading: 'import' | 'async-node' | 'require' = 'require';
862
- if (resolvedServerOutput === 'module') {
863
- nodeChunkLoading = 'import';
864
- } else if (useAsyncNodeChunkLoading) {
865
- nodeChunkLoading = 'async-node';
850
+ const webConfig = config.environments?.web;
851
+ const webJsFilename =
852
+ webConfig?.output?.filename?.js ?? config.output?.filename?.js;
853
+ // Rspack's RSC manifest only records browser entry files named `*.js`
854
+ // (`entryJsFiles`), and the server renders its bootstrap scripts from
855
+ // that list. Reject filename schemes it would silently drop up front.
856
+ if (
857
+ isRscMode &&
858
+ typeof webJsFilename === 'string' &&
859
+ !/\.js$/.test(webJsFilename)
860
+ ) {
861
+ throw new Error(
862
+ `[${PLUGIN_NAME}] RSC mode requires web \`output.filename.js\` to end in ".js" (got ${JSON.stringify(
863
+ webJsFilename
864
+ )}): rspack's RSC manifest omits entry files with a query or another extension, so the server could not render bootstrap scripts.`
865
+ );
866
866
  }
867
+ const assetPrefix = resolveEffectiveAssetPrefix(
868
+ { dev: webConfig?.dev, output: webConfig?.output, isBuild },
869
+ { dev: config.dev, output: config.output }
870
+ );
871
+ const vmodPlugin = createVirtualModulePlugin(assetPrefix);
867
872
  const configuredLazyCompilation = Object.prototype.hasOwnProperty.call(
868
873
  options,
869
874
  'lazyCompilation'
@@ -963,9 +968,6 @@ export const pluginReactRouter = (
963
968
  }),
964
969
  },
965
970
  output: {
966
- filename: {
967
- js: '[name].js',
968
- },
969
971
  distPath: {
970
972
  root: outputClientPath,
971
973
  },
@@ -984,15 +986,6 @@ export const pluginReactRouter = (
984
986
  ],
985
987
  },
986
988
  externalsType: modePlan.webExternalsType,
987
- output: {
988
- ...modePlan.webOutput,
989
- publicPath: assetPrefix,
990
- ...(options.federation
991
- ? {
992
- chunkLoading: 'import',
993
- }
994
- : {}),
995
- },
996
989
  optimization: modePlan.webOptimization,
997
990
  },
998
991
  },
@@ -1037,17 +1030,6 @@ export const pluginReactRouter = (
1037
1030
  externals: modePlan.nodeExternals,
1038
1031
  ...modePlan.nodeDependencies,
1039
1032
  externalsType: resolvedServerOutput,
1040
- output: {
1041
- chunkFormat: resolvedServerOutput,
1042
- chunkLoading: nodeChunkLoading,
1043
- devtoolModuleFilenameTemplate: '[absolute-resource-path]',
1044
- devtoolFallbackModuleFilenameTemplate:
1045
- '[absolute-resource-path]?[hash]',
1046
- workerChunkLoading: nodeChunkLoading,
1047
- wasmLoading: 'fetch',
1048
- module: resolvedServerOutput === 'module',
1049
- chunkFilename: 'static/js/async/[name].js',
1050
- },
1051
1033
  },
1052
1034
  },
1053
1035
  },
@@ -1059,6 +1041,7 @@ export const pluginReactRouter = (
1059
1041
  api,
1060
1042
  federation: pluginOptions.federation,
1061
1043
  resolvedServerOutput,
1044
+ webOutput: modePlan.webOutput,
1062
1045
  });
1063
1046
 
1064
1047
  if (modePlan.kind === 'classic' && useRouteModuleTransformLoader) {
package/src/manifest.ts CHANGED
@@ -167,19 +167,17 @@ type ReactRouterManifestStatsCompilation = {
167
167
  entrypoints?: ReactRouterManifestStatsLookup<ReactRouterManifestStatsEntrypoint>;
168
168
  };
169
169
 
170
- const orderChunkFiles = (chunkName: string, files: string[]): string[] => {
171
- const ownChunkAsset = `${chunkName}.js`;
172
- const ownFileIndex = files.findIndex(file => file.endsWith(ownChunkAsset));
173
- if (ownFileIndex <= 0) {
174
- return files;
175
- }
176
-
177
- return [
178
- files[ownFileIndex],
179
- ...files.slice(0, ownFileIndex),
180
- ...files.slice(ownFileIndex + 1),
181
- ];
182
- };
170
+ // Emitted asset names may carry a query (`output.filename.js:
171
+ // '[name].js?v=[contenthash:8]'`); classify on the pathname but keep the full
172
+ // reference, since the query is part of the URL the browser must request.
173
+ // A chunk's own script is whatever JavaScript the compilation rendered for it,
174
+ // regardless of `output.filename` scheme. In development, HMR update files are
175
+ // also recorded on `chunk.files`; they are never the module to load.
176
+ export const isManifestJsAsset = (asset: string): boolean =>
177
+ /(?<!\.hot-update)\.[cm]?js(?:\?.*)?$/.test(asset);
178
+
179
+ export const isManifestCssAsset = (asset: string): boolean =>
180
+ /\.css(?:\?.*)?$/.test(asset);
183
181
 
184
182
  const collectManifestFilesByName = <T>(
185
183
  items: ReactRouterManifestStatsLookup<T>,
@@ -241,8 +239,7 @@ export const createReactRouterManifestStats = (
241
239
  const assetsByChunkName = collectManifestFilesByName(
242
240
  compilation.namedChunks,
243
241
  chunkNames,
244
- (chunkName, chunk) =>
245
- orderChunkFiles(chunkName, Array.from(chunk.files ?? []))
242
+ (_chunkName, chunk) => Array.from(chunk.files ?? [])
246
243
  );
247
244
  const entrypointFilesByName = compilation.entrypoints
248
245
  ? collectManifestFilesByName(
@@ -303,22 +300,31 @@ const createChunkAssetResolver = (
303
300
 
304
301
  const cssAssets = new Set<string>();
305
302
  const jsAssets = new Set<string>();
303
+ // The chunk's own files come first so `js[0]` is the route module itself;
304
+ // entrypoint files (runtime, shared vendor chunks) follow as `imports`.
306
305
  for (const asset of assets) {
307
- if (asset.endsWith('.css')) {
306
+ if (isManifestCssAsset(asset)) {
308
307
  cssAssets.add(asset);
309
- } else if (asset.endsWith('.js')) {
308
+ } else if (isManifestJsAsset(asset)) {
310
309
  jsAssets.add(asset);
311
310
  }
312
311
  }
313
312
  for (const asset of clientStats?.entrypointFilesByName?.[chunkName] ?? []) {
314
- if (asset.endsWith('.css')) {
313
+ if (isManifestCssAsset(asset)) {
315
314
  cssAssets.add(asset);
316
- } else if (includeEntrypointJs && asset.endsWith('.js')) {
315
+ } else if (includeEntrypointJs && isManifestJsAsset(asset)) {
317
316
  jsAssets.add(asset);
318
317
  }
319
318
  }
320
319
  if (jsAssets.size === 0) {
321
- jsAssets.add(`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`);
320
+ // Compilation metadata exists for this chunk but names no module script.
321
+ // Guessing `<dir>/<chunk>.js` here would turn an identifiable build
322
+ // problem into a browser 404, so surface it at build time instead.
323
+ throw new Error(
324
+ `[react-router] Chunk "${chunkName}" emitted no JavaScript asset the browser manifest can reference (files: ${
325
+ assets.join(', ') || 'none'
326
+ }). Check the web \`output.filename.js\` scheme.`
327
+ );
322
328
  }
323
329
 
324
330
  const result = { js: [...jsAssets], css: [...cssAssets] };
package/src/mode-plan.ts CHANGED
@@ -36,10 +36,7 @@ type CommonModePlan = {
36
36
  manifestChunkNames: Set<string>;
37
37
  webEntries: Record<string, string | RsbuildEntryDescription>;
38
38
  nodeEntries: Record<string, string | RsbuildEntryDescription>;
39
- createVirtualModules(
40
- publicPath: string,
41
- jsDistPath: string
42
- ): Record<string, string>;
39
+ createVirtualModules(publicPath: string): Record<string, string>;
43
40
  createResolveConfig(rootPath: string): Rspack.Configuration['resolve'];
44
41
  server: RsbuildConfig['server'] | undefined;
45
42
  webExternalsType: 'module' | undefined;
@@ -104,6 +101,7 @@ type CreateRscModePlanOptions = ModePlanContext & {
104
101
  buildDirectory: string;
105
102
  finalEntryRscClientPath: string;
106
103
  finalEntryRscPath: string;
104
+ finalEntryRscSsrPath: string;
107
105
  outputClientPath: string;
108
106
  pluginName: string;
109
107
  serverBuildFile: string | undefined;
@@ -133,6 +131,7 @@ const createRscModePlan = async ({
133
131
  customServer,
134
132
  finalEntryRscClientPath,
135
133
  finalEntryRscPath,
134
+ finalEntryRscSsrPath,
136
135
  isBuild,
137
136
  outputClientPath,
138
137
  pluginName,
@@ -181,14 +180,13 @@ const createRscModePlan = async ({
181
180
  layer: RSC_LAYERS.rsc,
182
181
  },
183
182
  },
184
- createVirtualModules: (publicPath: string, jsDistPath: string) =>
183
+ createVirtualModules: (publicPath: string) =>
185
184
  createReactRouterRscVirtualModules({
186
185
  allowedActionOrigins: allowedActionOriginsForBuild,
187
186
  appDirectory,
188
187
  basename,
189
188
  buildDirectory,
190
189
  isBuild,
191
- jsDistPath,
192
190
  outputClientPath,
193
191
  publicPath,
194
192
  routeDiscovery,
@@ -197,7 +195,9 @@ const createRscModePlan = async ({
197
195
  }),
198
196
  createResolveConfig: (rootPath: string) => ({
199
197
  modules: [resolve(rootPath, 'node_modules'), 'node_modules'],
200
- alias: createReactRouterRscResolveAliases(rootPath),
198
+ alias: createReactRouterRscResolveAliases(rootPath, {
199
+ entrySsrPath: finalEntryRscSsrPath,
200
+ }),
201
201
  }),
202
202
  server: !customServer
203
203
  ? {
@@ -310,7 +310,7 @@ const createClassicModePlan = async ({
310
310
  defaultEntryName,
311
311
  serverBundleEntries: artifacts.serverBundleEntries,
312
312
  }),
313
- createVirtualModules: (publicPath: string, _jsDistPath: string) =>
313
+ createVirtualModules: (publicPath: string) =>
314
314
  createClassicVirtualModules({
315
315
  allowedActionOrigins: allowedActionOriginsForBuild,
316
316
  appDirectory,
@@ -345,11 +345,9 @@ const createClassicModePlan = async ({
345
345
  wasmLoading: 'fetch',
346
346
  library: { type: 'module' },
347
347
  module: true,
348
- // Async chunks are addressed by id at runtime, so the chunk name only
349
- // adds bytes to the filename and to every place that references it.
350
- ...(isBuild
351
- ? { chunkFilename: 'static/js/async/[id]-[contenthash:16].js' }
352
- : {}),
348
+ // Async chunk filenames are left to Rsbuild, which derives them from
349
+ // `output.filename.js`, `output.filenameHash`, and `distPath.jsAsync`,
350
+ // so user configuration governs every JavaScript filename (#129).
353
351
  },
354
352
  webOptimization: {
355
353
  avoidEntryIife: true,
@@ -7,6 +7,7 @@ import {
7
7
  generateReactRouterManifestForDev,
8
8
  getReactRouterManifestChunkNames,
9
9
  getReactRouterManifestPath,
10
+ isManifestJsAsset,
10
11
  type ReactRouterManifestForDev as ReactRouterManifest,
11
12
  type RouteChunkManifestOptions,
12
13
  type RouteManifestModuleExports,
@@ -83,7 +84,7 @@ const addIntegrity = (
83
84
  ) => {
84
85
  if (
85
86
  typeof assetName !== 'string' ||
86
- !assetName.endsWith('.js') ||
87
+ !isManifestJsAsset(assetName) ||
87
88
  typeof integrity !== 'string'
88
89
  ) {
89
90
  return;
@@ -117,7 +118,7 @@ export const collectSubresourceIntegrity = (
117
118
  const assets =
118
119
  compilation.getAssets() as readonly CompilationAssetWithIntegrity[];
119
120
  for (const asset of assets) {
120
- if (!asset.name.endsWith('.js')) {
121
+ if (!isManifestJsAsset(asset.name)) {
121
122
  continue;
122
123
  }
123
124
  addIntegrity(
@@ -200,8 +201,7 @@ export function registerModifyBrowserManifestAssets(
200
201
 
201
202
  if (isBuild) {
202
203
  const entryAssets = stats?.assetsByChunkName?.['entry.client'];
203
- const entryJsAssets =
204
- entryAssets?.filter(asset => asset.endsWith('.js')) || [];
204
+ const entryJsAssets = entryAssets?.filter(isManifestJsAsset) || [];
205
205
  const manifestPath = getReactRouterManifestPath({
206
206
  version: manifest.version,
207
207
  isBuild: true,
@@ -79,34 +79,48 @@ export function normalizeAssetPrefix(assetPrefix?: string): string {
79
79
  return assetPrefix.endsWith('/') ? assetPrefix : `${assetPrefix}/`;
80
80
  }
81
81
 
82
+ type AssetPrefixConfig = {
83
+ dev?: { assetPrefix?: unknown };
84
+ output?: { assetPrefix?: unknown };
85
+ };
86
+
87
+ const asString = (value: unknown): string | undefined =>
88
+ typeof value === 'string' ? value : undefined;
89
+
90
+ const pickConfiguredAssetPrefix = (
91
+ { dev, output }: AssetPrefixConfig,
92
+ isBuild: boolean
93
+ ): string | undefined =>
94
+ isBuild
95
+ ? asString(output?.assetPrefix)
96
+ : (asString(dev?.assetPrefix) ?? asString(output?.assetPrefix));
97
+
82
98
  /**
83
- * Resolve the asset prefix Rsbuild applies to emitted asset URLs for the given
84
- * mode. In development the effective prefix is `dev.assetPrefix` (which Rsbuild
85
- * defaults from `server.base`, falling back to `output.assetPrefix`); in a
86
- * production build it is `output.assetPrefix`. Both fields are already resolved
87
- * on the normalized config, so this mirrors Rsbuild's own precedence rather than
88
- * re-deriving it from `server.base`.
99
+ * Resolve the absolute asset prefix the server build and browser manifest use
100
+ * for asset URLs. In development the effective prefix is `dev.assetPrefix`
101
+ * (which Rsbuild defaults from `server.base`, falling back to
102
+ * `output.assetPrefix`); in a production build it is `output.assetPrefix`.
103
+ *
104
+ * `fallbacks` are consulted in order (e.g. the root config after the web
105
+ * environment) when the preceding config only offers a prefix the server
106
+ * cannot use: `'auto'` and empty values are browser-runtime-only, so a root
107
+ * CDN prefix must survive a web `'auto'`. The choice happens before
108
+ * normalization so that `'auto'` is not first folded into `'/'`.
89
109
  *
90
110
  * `dev.assetPrefix` may be a boolean on the raw config (`false` disables it);
91
- * boolean/`'auto'`/empty values normalize to the root prefix `'/'`.
111
+ * boolean/`'auto'`/empty values ultimately normalize to the root prefix `'/'`.
92
112
  */
93
- export function resolveEffectiveAssetPrefix(config: {
94
- dev?: { assetPrefix?: unknown };
95
- output?: { assetPrefix?: unknown };
96
- isBuild: boolean;
97
- }): string {
98
- const outputPrefix =
99
- typeof config.output?.assetPrefix === 'string'
100
- ? config.output.assetPrefix
101
- : undefined;
102
- if (config.isBuild) {
103
- return normalizeAssetPrefix(outputPrefix);
113
+ export function resolveEffectiveAssetPrefix(
114
+ config: AssetPrefixConfig & { isBuild: boolean },
115
+ ...fallbacks: AssetPrefixConfig[]
116
+ ): string {
117
+ for (const candidate of [config, ...fallbacks]) {
118
+ const prefix = pickConfiguredAssetPrefix(candidate, config.isBuild);
119
+ if (prefix && prefix !== 'auto') {
120
+ return normalizeAssetPrefix(prefix);
121
+ }
104
122
  }
105
- const devPrefix =
106
- typeof config.dev?.assetPrefix === 'string'
107
- ? config.dev.assetPrefix
108
- : undefined;
109
- return normalizeAssetPrefix(devPrefix ?? outputPrefix);
123
+ return '/';
110
124
  }
111
125
 
112
126
  export function createRouteId(file: string): string {
@@ -623,6 +623,12 @@ const createServerRouteEntry = async (
623
623
  let needsReactImport = false;
624
624
  let needsEnsureHmrImport = false;
625
625
  let needsStyleEntryImport = false;
626
+ const pushStylesheetLinks = (entryCssFilesExpression: string): void => {
627
+ lines.push(` ...(${entryCssFilesExpression} ?? []).map(href =>`);
628
+ lines.push(
629
+ ' React.createElement("link", { key: href, rel: "stylesheet", href: href, precedence: "default" })),'
630
+ );
631
+ };
626
632
 
627
633
  // A client route whose default component is a client reference: its bundled
628
634
  // (non-vanilla) side-effect CSS is orphaned in the initial browser entry
@@ -651,12 +657,7 @@ const createServerRouteEntry = async (
651
657
  'export default function RscClientRouteWithStyles___(props) {'
652
658
  );
653
659
  lines.push(' return React.createElement(React.Fragment, null,');
654
- lines.push(
655
- ` ...(${RSC_ROUTE_STYLE_ENTRY_EXPORT}.entryCssFiles ?? []).map(href =>`
656
- );
657
- lines.push(
658
- ' React.createElement("link", { key: href, rel: "stylesheet", href: href, precedence: "default" })),'
659
- );
660
+ pushStylesheetLinks(`${RSC_ROUTE_STYLE_ENTRY_EXPORT}.entryCssFiles`);
660
661
  lines.push(' React.createElement(RscClientRouteDefault___, props),');
661
662
  lines.push(' );');
662
663
  lines.push('}');
@@ -682,12 +683,7 @@ const createServerRouteEntry = async (
682
683
  // graph contributes. Render those as stylesheet links at the top of the
683
684
  // stream (mirrors upstream's `import.meta.viteRsc.loadCss()`); React's
684
685
  // float support hoists them into `<head>`.
685
- lines.push(
686
- ` ...(${exportName}WithoutClientChunk.entryCssFiles ?? []).map(href =>`
687
- );
688
- lines.push(
689
- ' React.createElement("link", { key: href, rel: "stylesheet", href: href, precedence: "default" })),'
690
- );
686
+ pushStylesheetLinks(`${exportName}WithoutClientChunk.entryCssFiles`);
691
687
  lines.push(
692
688
  ' React.createElement(EnsureClientRouteModuleForHMR___, null),'
693
689
  );
@@ -99,6 +99,17 @@ declare module 'virtual/react-router/unstable_rsc/react-router-serve-config' {
99
99
 
100
100
  declare module 'virtual/react-router/unstable_rsc/inject-hmr-runtime' {}
101
101
 
102
+ declare module 'virtual/react-router/unstable_rsc/entry-ssr' {
103
+ export function generateHTML(
104
+ request: Request,
105
+ serverResponse: Response,
106
+ options?: {
107
+ bootstrapScripts?: string[];
108
+ bootstrapModules?: string[];
109
+ }
110
+ ): Promise<Response>;
111
+ }
112
+
102
113
  declare module 'virtual/react-router/unstable_rsc/bootstrap-scripts' {
103
114
  const bootstrapScripts: string[];
104
115
  export default bootstrapScripts;
@@ -1,7 +1,7 @@
1
1
  import { relative, resolve } from 'pathe';
2
2
  import type { Config } from './react-router-config.js';
3
3
  import type { Route } from './types.js';
4
- import { combineURLs, normalizeAssetPrefix } from './plugin-utils.js';
4
+ import { normalizeAssetPrefix } from './plugin-utils.js';
5
5
  import { getVirtualModuleFilePath } from './virtual-modules.js';
6
6
  import {
7
7
  createRscInternalClientModule,
@@ -19,6 +19,7 @@ const RSC_VIRTUAL_ALIAS_IDS = [
19
19
  'allowed-action-origins',
20
20
  'client-version',
21
21
  'react-router-serve-config',
22
+ 'manifest-prefix',
22
23
  'bootstrap-scripts',
23
24
  'server-manifest',
24
25
  ] as const;
@@ -29,8 +30,6 @@ type RscVirtualModulesOptions = {
29
30
  basename: string;
30
31
  buildDirectory: string;
31
32
  isBuild: boolean;
32
- /** Resolved web `output.distPath.js` segment, e.g. `static/js`. */
33
- jsDistPath: string;
34
33
  outputClientPath: string;
35
34
  publicPath: string;
36
35
  routeDiscovery: Config['routeDiscovery'];
@@ -39,8 +38,18 @@ type RscVirtualModulesOptions = {
39
38
  };
40
39
 
41
40
  export const createReactRouterRscResolveAliases = (
42
- rootPath: string
41
+ rootPath: string,
42
+ options: { entrySsrPath?: string } = {}
43
43
  ): Record<string, string> => ({
44
+ // The RSC entry template imports the SSR entry through this alias so a
45
+ // user-provided `app/entry.ssr.tsx` replaces the template inside the SSR
46
+ // layer instead of leaving the template to be compiled as server code.
47
+ ...(options.entrySsrPath
48
+ ? {
49
+ 'virtual:react-router/unstable_rsc/entry-ssr': options.entrySsrPath,
50
+ 'virtual/react-router/unstable_rsc/entry-ssr': options.entrySsrPath,
51
+ }
52
+ : {}),
44
53
  ...Object.fromEntries(
45
54
  RSC_VIRTUAL_ALIAS_IDS.flatMap(id => {
46
55
  const moduleId = `virtual/react-router/unstable_rsc/${id}`;
@@ -63,7 +72,6 @@ export const createReactRouterRscVirtualModules = ({
63
72
  basename,
64
73
  buildDirectory,
65
74
  isBuild,
66
- jsDistPath,
67
75
  outputClientPath,
68
76
  publicPath,
69
77
  routeDiscovery,
@@ -74,7 +82,10 @@ export const createReactRouterRscVirtualModules = ({
74
82
  resolve(buildDirectory, 'server'),
75
83
  outputClientPath
76
84
  );
77
- const bootstrapPublicPath = normalizeAssetPrefix(publicPath);
85
+ // Absolute prefix the server must use for initial asset URLs (see
86
+ // `resolveEffectiveAssetPrefix`); the browser compiler may itself be on
87
+ // `'auto'`, which no server-rendered URL can express.
88
+ const serverPublicPath = normalizeAssetPrefix(publicPath);
78
89
 
79
90
  return {
80
91
  'virtual/react-router/unstable_rsc/routes': createRscRouteConfig({
@@ -101,9 +112,51 @@ export const createReactRouterRscVirtualModules = ({
101
112
  assetsBuildDirectory: rscAssetsBuildDirectory,
102
113
  publicPath,
103
114
  }),
104
- 'virtual/react-router/unstable_rsc/bootstrap-scripts': defaultExport([
105
- combineURLs(bootstrapPublicPath, `${jsDistPath}/index.js`),
106
- ]),
115
+ // Every server-facing asset URL in the rspack RSC manifest -- bootstrap
116
+ // `entryJsFiles`, route `entryCssFiles`, client references' `cssFiles`
117
+ // (react-server-dom-rspack renders those as <link>s), and Flight's
118
+ // `moduleLoading.prefix` for client-chunk preloads -- carries the browser
119
+ // compiler's public path, recorded in `moduleLoading.prefix`. With the
120
+ // browser compiler on `'auto'` rspack records `/`, which the server cannot
121
+ // serve from. `__rspack_rsc_manifest__` is this same object, so aligning
122
+ // it once, in place (arrays are mutated, not replaced, because
123
+ // `createServerEntry` has already captured references), makes every
124
+ // consumer agree on the server prefix without stacking a second one. The
125
+ // aligned manifest is exported (not imported for side effects only) so a
126
+ // `sideEffects: false` package cannot tree-shake the alignment away.
127
+ 'virtual/react-router/unstable_rsc/manifest-prefix': `const manifest = __webpack_require__.rscM;
128
+ const serverPrefix = ${JSON.stringify(serverPublicPath)};
129
+ const appliedPrefix = manifest?.moduleLoading?.prefix;
130
+ if (appliedPrefix && appliedPrefix !== serverPrefix) {
131
+ const rewrite = url =>
132
+ typeof url === "string" && url.startsWith(appliedPrefix)
133
+ ? serverPrefix + url.slice(appliedPrefix.length)
134
+ : url;
135
+ const rewriteAll = list => {
136
+ if (Array.isArray(list)) for (let i = 0; i < list.length; i++) list[i] = rewrite(list[i]);
137
+ };
138
+ manifest.moduleLoading.prefix = serverPrefix;
139
+ rewriteAll(manifest.entryJsFiles);
140
+ for (const files of Object.values(manifest.entryCssFiles ?? {})) rewriteAll(files);
141
+ for (const reference of Object.values(manifest.clientManifest ?? {})) rewriteAll(reference.cssFiles);
142
+ }
143
+ export const rscManifest = manifest;
144
+ `,
145
+ // The compiled browser entry scripts come from the manifest (like Next's
146
+ // `buildManifest` or the Vite plugin's `loadBootstrapScriptContent`), so
147
+ // hashed entry filenames resolve without any naming contract. The list and
148
+ // its order are preserved. An empty list is a build problem (rspack only
149
+ // records entry files named `*.js`); fail loudly instead of guessing a
150
+ // filename that would render a document which cannot hydrate.
151
+ 'virtual/react-router/unstable_rsc/bootstrap-scripts': `import { rscManifest } from "virtual/react-router/unstable_rsc/manifest-prefix";
152
+ const entryJsFiles = rscManifest?.entryJsFiles;
153
+ if (!entryJsFiles?.length) {
154
+ throw new Error(
155
+ "[rsbuild-plugin-react-router] The rspack RSC manifest lists no browser entry script (entryJsFiles is empty), so the server cannot render bootstrap scripts. Rspack only records entry files whose name ends in \\".js\\"; web output.filename.js values with a query (for example \\"[name].js?v=[contenthash:8]\\") or another extension are not supported in RSC mode."
156
+ );
157
+ }
158
+ export default entryJsFiles;
159
+ `,
107
160
  'virtual/react-router/unstable_rsc/server-manifest': `export default function getServerManifest() {
108
161
  return __webpack_require__.rscM?.serverManifest;
109
162
  }
@@ -19,7 +19,7 @@ import clientVersion from 'virtual/react-router/unstable_rsc/client-version';
19
19
  import unstable_reactRouterServeConfig from 'virtual/react-router/unstable_rsc/react-router-serve-config';
20
20
  import bootstrapScripts from 'virtual/react-router/unstable_rsc/bootstrap-scripts';
21
21
  import getServerManifest from 'virtual/react-router/unstable_rsc/server-manifest';
22
- import { generateHTML } from './entry.rsc.ssr.js';
22
+ import { generateHTML } from 'virtual/react-router/unstable_rsc/entry-ssr';
23
23
 
24
24
  export { unstable_reactRouterServeConfig };
25
25