Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/federation-async-startup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'rsbuild-plugin-react-router': patch
---

Make Module Federation work through async startup (#132), and tighten RSC
asset handling.

- Federation (browser): route-module entries are made async so Rspack awaits
the Module Federation startup before exporting (`(await startup).default`);
React Router's synchronous `import * as route from ".../root.js"` and
`import()` of split route chunks now see real exports and hydration
proceeds. Each federation container gets its own runtime chunk, so importing
a container no longer runs the remote app's own share-scope consumes before
the host initializes the share scope (which produced a second React).
- Federation (server): server code splitting is async-only (enforced at the
final `tools.rspack` boundary, including cache groups such as Rsbuild's
`single-vendor` preset; a disabled `splitChunks` is kept), so the CommonJS
server build has no initial chunk dependencies. `@module-federation/node`
replaces Rspack's chunk loader with one that tracks loaded chunks privately,
which left Rspack's startup gate unsatisfied and made the awaited server
build resolve to `undefined`. `experiments.asyncStartup` stays enforced on
every compiler and shared dependencies stay non-eager.
- RSC: the manifest prefix alignment also rebases relative references produced
by an empty browser `assetPrefix` (absolute and protocol-relative URLs are
left alone). RSC filename validation now checks the emitted web output:
JavaScript-emitting chunks are identified from compilation metadata and
their emitted script names (entry or async template, including function
templates and `tools.rspack` overrides) must end in `.js`.
- Federation example: CORS is scoped to the remote's asset handlers instead of
the whole application.
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,17 @@ pluginReactRouter({
| `federation` | `false` | Enables the plugin's experimental Module Federation integration. |

When `federation` is enabled, configure the Module Federation plugin with
`experiments.asyncStartup: true`. The dev server resolves async server build
`experiments.asyncStartup: true` on every compiler (the plugin enforces it) and
keep shared dependencies non-eager. The dev server resolves async server build
exports automatically; production custom servers or adapters should resolve
async exports before passing the build to React Router's request handler.
async exports before passing the build to React Router's request handler
(`resolveReactRouterServerBuild`). Give remote containers an explicit
`filename` (for example `static/js/remote.js`); every other browser chunk keeps
Rsbuild's content hash. Under the hood the plugin gives each container its own
runtime chunk (so importing a container does not start the app's own share
consumes), makes browser route-module entries async so their exports resolve
through the async startup, and keeps server code splitting async-only so the
`@module-federation/node` chunk loader can satisfy the server build's startup.

### React Router Config

Expand Down Expand Up @@ -365,6 +373,16 @@ content-hashed filenames, and `output.filename`, `output.filenameHash`,
`output.distPath`, and `tools.rspack` output settings you configure govern
the emitted files and the manifest URLs that reference them.

Supported browser filename forms differ by mode:

- **Classic mode** accepts any scheme, including `.mjs`/`.cjs` and query-hash
names such as `[name].js?v=[contenthash:8]`; the browser manifest classifies
emitted assets by pathname and keeps the full reference.
- **RSC mode** requires every browser JavaScript asset to be named `*.js`
(hashes are fine, e.g. `[contenthash:8]-[name].js`): rspack's RSC manifest
only records `.js` files, so the build fails with a clear error for query
hashes or other extensions.

## Custom Server Setup

The plugin supports two ways to handle server-side rendering:
Expand Down
31 changes: 21 additions & 10 deletions examples/federation/epic-stack-remote/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,37 +82,48 @@ export async function createApp(devServer?: any) {
app.disable('x-powered-by')

// The host loads this remote's container and the ES modules it imports
// cross-origin (`remoteType: 'import'`), which requires CORS on every asset
// response. Set it before any static handler.
app.use((_req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*')
next()
})
// cross-origin (`remoteType: 'import'`), which requires CORS on the asset
// responses (container, runtime, chunks, manifest). Scope it to the asset
// handlers; documents, data requests, and resource routes are unaffected.
const allowCrossOriginAssets: Parameters<typeof express.static>[1] = {
maxAge: '1h',
setHeaders: (res) => res.setHeader('Access-Control-Allow-Origin', '*'),
}

if (IS_DEV) {
// use rsbuild dev server
if (devServer) {
app.use((req, res, next) => {
if (req.path.startsWith('/static/') || req.path.endsWith('mf-manifest.json')) {
res.setHeader('Access-Control-Allow-Origin', '*')
}
next()
})
app.use(devServer.middlewares)
}
} else {
// Remix fingerprints its assets so we can cache forever.
app.use(
'/assets',
express.static('build/client/assets', { immutable: true, maxAge: '1y' }),
express.static('build/client/assets', {
...allowCrossOriginAssets,
immutable: true,
maxAge: '1y',
}),
)

// Everything else (like favicon.ico) is cached for an hour. You may want to be
// more aggressive with this caching.
app.use(express.static('build/client', { maxAge: '1h' }))
app.use('/server', express.static('build/server', { maxAge: '1h' }))
app.use(express.static('build/client', allowCrossOriginAssets))
app.use('/server', express.static('build/server', allowCrossOriginAssets))
// The host's Node federation runtime resolves this remote's server chunks
// against the node compiler's publicPath (`REMOTE_ASSET_PREFIX`), i.e.
// `<origin>/static/js/async/<chunk>.js`, not against the container URL.
// Publish the server async chunks there as well. Client async chunks are
// content-hashed, so the two trees do not collide.
app.use(
'/static/js/async',
express.static('build/server/static/js/async', { maxAge: '1h' }),
express.static('build/server/static/js/async', allowCrossOriginAssets),
)
}

Expand Down
11 changes: 10 additions & 1 deletion src/environment-output.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { RsbuildPluginAPI, Rspack } from '@rsbuild/core';
import { ensureFederationAsyncStartup } from './federation.js';
import {
enforceAsyncOnlyServerSplitChunks,
ensureFederationAsyncStartup,
isolateFederationContainerRuntime,
} from './federation.js';

/**
* Rspack `output` policy for the web and node environments, in two tiers:
Expand Down Expand Up @@ -70,6 +74,11 @@ export const registerReactRouterEnvironmentOutput = ({
rspack: rspackConfig => {
if (federation) {
ensureFederationAsyncStartup(rspackConfig);
if (name === 'web') {
isolateFederationContainerRuntime(rspackConfig);
} else {
enforceAsyncOnlyServerSplitChunks(rspackConfig);
}
}

if (name === 'node') {
Expand Down
107 changes: 93 additions & 14 deletions src/federation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,73 @@
import type { Rspack } from '@rsbuild/core';

type ModuleFederationPluginOptionsLike = {
name?: string;
experiments?: { asyncStartup?: boolean };
};

type ModuleFederationPluginLike = {
name?: string;
_options?: { experiments?: { asyncStartup?: boolean } };
options?: { experiments?: { asyncStartup?: boolean } };
_options?: ModuleFederationPluginOptionsLike;
options?: ModuleFederationPluginOptionsLike;
};

const getModuleFederationOptions = (
plugin: unknown
): ModuleFederationPluginOptionsLike | undefined => {
if (!plugin || typeof plugin !== 'object') {
return undefined;
}
const federationPlugin = plugin as ModuleFederationPluginLike;
if (
federationPlugin.name !== 'ModuleFederationPlugin' &&
federationPlugin.name !== 'RspackModuleFederationPlugin'
) {
return undefined;
}
return federationPlugin._options ?? federationPlugin.options;
};

/**
* The Module Federation container name(s) configured on this compiler, i.e.
* the entry names of the remote containers it emits.
*/
export const getFederationContainerNames = (
rspackConfig: Rspack.Configuration | undefined
): string[] =>
(rspackConfig?.plugins ?? [])
.map(getModuleFederationOptions)
.map(options => options?.name)
.filter((name): name is string => typeof name === 'string');

/**
* Classic mode shares one runtime chunk across every browser entry so route
* module entries share a module registry. A federation container must not
* share it: importing the container would run the app entries' async startup
* (share-scope consumes) before the host has initialized the share scope,
* yielding duplicate singletons (a second React). Give containers their own
* runtime chunk.
*/
export const isolateFederationContainerRuntime = (
rspackConfig: Rspack.Configuration | undefined
): void => {
const containers = new Set(getFederationContainerNames(rspackConfig));
if (!rspackConfig || containers.size === 0) {
return;
}
const current = rspackConfig.optimization?.runtimeChunk;
const appRuntimeName =
typeof current === 'object' && typeof current?.name === 'string'
? current.name
: 'runtime';
rspackConfig.optimization = {
...rspackConfig.optimization,
runtimeChunk: {
name: (entrypoint: { name: string }) =>
containers.has(entrypoint.name)
? `runtime-${entrypoint.name}`
: appRuntimeName,
},
};
};

export const ensureFederationAsyncStartup = (
Expand All @@ -14,18 +78,7 @@ export const ensureFederationAsyncStartup = (
}

for (const plugin of rspackConfig.plugins) {
if (!plugin || typeof plugin !== 'object') {
continue;
}
const federationPlugin = plugin as ModuleFederationPluginLike;
if (
federationPlugin.name !== 'ModuleFederationPlugin' &&
federationPlugin.name !== 'RspackModuleFederationPlugin'
) {
continue;
}

const pluginOptions = federationPlugin._options ?? federationPlugin.options;
const pluginOptions = getModuleFederationOptions(plugin);
if (!pluginOptions) {
continue;
}
Expand All @@ -36,3 +89,29 @@ export const ensureFederationAsyncStartup = (
};
}
};

/**
* `@module-federation/node` replaces Rspack's `readFileVm` chunk loader with one
* that tracks loaded chunks privately, so initial chunks split off a
* multi-entry server build never satisfy Rspack's startup gate
* (`__webpack_require__.O`) and the async startup resolves to `undefined`
* instead of the server build's exports. Keep server code splitting to async
* chunks only. Runs at the final `tools.rspack` boundary so a user
* `optimization.splitChunks` override or a preset whose cache group selects
* `chunks: 'all'` (e.g. Rsbuild's `single-vendor`, `enforce: true`) cannot
* reintroduce initial chunk dependencies. A disabled `splitChunks` is kept.
*/
export const enforceAsyncOnlyServerSplitChunks = (
rspackConfig: Rspack.Configuration | undefined
): void => {
const splitChunks = rspackConfig?.optimization?.splitChunks;
if (!splitChunks) {
return;
}
splitChunks.chunks = 'async';
for (const group of Object.values(splitChunks.cacheGroups ?? {})) {
if (group && typeof group === 'object' && 'chunks' in group) {
group.chunks = 'async';
}
}
};
63 changes: 47 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type ResolvedReactRouterConfig,
} from './react-router-config.js';
import {
collectUnsupportedRscScriptAssets,
configRoutesToRouteManifest,
createReactRouterManifestStats,
type ReactRouterManifestForDev as ReactRouterManifest,
Expand Down Expand Up @@ -773,6 +774,27 @@ export const pluginReactRouter = (
stats?.compilation,
manifestChunkNames
);
if (isRscMode && stats) {
// Rspack's RSC manifest only records browser scripts whose emitted
// name ends in ".js" (entry files and client-reference chunks
// alike); anything else silently disappears from `entryJsFiles` and
// the client manifest, and the server cannot bootstrap or preload
// it. Check the emitted output, which is what the manifest saw, so
// function filenames and `tools.rspack` overrides are covered too.
const unsupported = collectUnsupportedRscScriptAssets(
stats.compilation
);
if (unsupported.length > 0) {
throw new Error(
`[${PLUGIN_NAME}] RSC mode requires every browser JavaScript asset to be named "*.js" (no query, no other extension): rspack's RSC manifest omits ${unsupported
.slice(0, 5)
.map(asset => JSON.stringify(asset))
.join(
', '
)}${unsupported.length > 5 ? ` and ${unsupported.length - 5} more` : ''}. Adjust web \`output.filename.js\` / \`chunkFilename\`.`
);
}
}
}
if (pluginOptions.federation && ssr) {
const serverBuildDir = resolve(buildDirectory, 'server');
Expand Down Expand Up @@ -848,22 +870,6 @@ export const pluginReactRouter = (

api.modifyRsbuildConfig(async (config, { mergeRsbuildConfig }) => {
const webConfig = config.environments?.web;
const webJsFilename =
webConfig?.output?.filename?.js ?? config.output?.filename?.js;
// Rspack's RSC manifest only records browser entry files named `*.js`
// (`entryJsFiles`), and the server renders its bootstrap scripts from
// that list. Reject filename schemes it would silently drop up front.
if (
isRscMode &&
typeof webJsFilename === 'string' &&
!/\.js$/.test(webJsFilename)
) {
throw new Error(
`[${PLUGIN_NAME}] RSC mode requires web \`output.filename.js\` to end in ".js" (got ${JSON.stringify(
webJsFilename
)}): rspack's RSC manifest omits entry files with a query or another extension, so the server could not render bootstrap scripts.`
);
}
const assetPrefix = resolveEffectiveAssetPrefix(
{ dev: webConfig?.dev, output: webConfig?.output, isBuild },
{ dev: config.dev, output: config.output }
Expand Down Expand Up @@ -1044,6 +1050,31 @@ export const pluginReactRouter = (
webOutput: modePlan.webOutput,
});

if (pluginOptions.federation && modePlan.kind === 'classic') {
// Module Federation's async startup makes every entry's startup a
// promise. React Router imports each browser route-module entry
// synchronously (`import * as route0 from ".../root.js"`) and reads its
// exports right away, and `import()`s split route chunks the same way.
// Making those entry modules async (top-level await) turns Rspack's
// module-library export into `(await startup).default`, so importers
// wait for the awaited startup instead of reading a snapshot of the
// promise (#132). Runs after SWC so it applies to the final module code.
const browserEntryModules = new Set([
finalEntryClientPath,
...routeByFilePath.keys(),
]);
api.transform(
{
environments: ['web'],
order: 'post',
test: (resourcePath: string) => browserEntryModules.has(resourcePath),
},
// `export {}` keeps an otherwise-empty client module (a route with only
// server exports) parsed as ESM, which top-level await requires.
({ code }) => `${code}\nexport {};\nawait Promise.resolve();\n`
);
}

if (modePlan.kind === 'classic' && useRouteModuleTransformLoader) {
api.modifyEnvironmentConfig(
async (config, { name, mergeEnvironmentConfig }) => {
Expand Down
Loading
Loading