Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ jobs:
- name: Run Examples Test
run: pnpm run test:examples

- name: Run Federation Example Test
run: pnpm run test:federation

# ======== e2e ========
e2e:
needs: [prepare, lint, ut]
Expand Down
4 changes: 4 additions & 0 deletions e2e/federation/fixtures/basic/absolute-target.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Loaded through the `__rstest_dynamic_import__` global fallback by absolute
// filesystem path — the way vm-evaluated Module Federation chunks resolve
// externalized dynamic imports. Must stay un-bundled (plain .mjs on disk).
export const answer = 42;
28 changes: 28 additions & 0 deletions e2e/federation/fixtures/basic/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, it } from '@rstest/core';

it('should expose the federation flag and dynamic import fallback', async () => {
expect((globalThis as any).__rstest_federation__).toBe(true);

const dynamicImport = (globalThis as any).__rstest_dynamic_import__;
expect(typeof dynamicImport).toBe('function');

// The fallback must load modules via native dynamic import, the way
// vm-evaluated Module Federation runtime chunks rely on it.
const pathModule = await dynamicImport('node:path');
expect(typeof pathModule.join).toBe('function');
});

it('should load absolute file paths through the dynamic import fallback', async () => {
const dynamicImport = (globalThis as any).__rstest_dynamic_import__;

// Federated async-node chunks call the fallback with raw absolute paths
// (`C:\...` on Windows), which must be normalized to `file://` URLs before
// they reach native `import()`.
const { join } = await import('node:path');
const mod = await dynamicImport(join(__dirname, 'absolute-target.mjs'));
expect(mod.answer).toBe(42);
});

it('should set the federation flag during global setup', () => {
expect(process.env.RSTEST_E2E_FEDERATION_IN_SETUP).toBe('true');
});
5 changes: 5 additions & 0 deletions e2e/federation/fixtures/basic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "e2e-federation-basic",
"private": true,
"type": "module"
}
6 changes: 6 additions & 0 deletions e2e/federation/fixtures/basic/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from '@rstest/core';

export default defineConfig({
federation: true,
globalSetup: ['./setup.ts'],
});
7 changes: 7 additions & 0 deletions e2e/federation/fixtures/basic/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const setup = (): void => {
// Record the worker-wide flag so the test can assert it was already set
// while global setup code ran.
process.env.RSTEST_E2E_FEDERATION_IN_SETUP = String(
(globalThis as any).__rstest_federation__,
);
};
6 changes: 6 additions & 0 deletions e2e/federation/fixtures/cli/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { expect, it } from '@rstest/core';

it('should enable federation mode from the CLI flag', () => {
expect((globalThis as any).__rstest_federation__).toBe(true);
expect(typeof (globalThis as any).__rstest_dynamic_import__).toBe('function');
});
5 changes: 5 additions & 0 deletions e2e/federation/fixtures/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "e2e-federation-cli",
"private": true,
"type": "module"
}
6 changes: 6 additions & 0 deletions e2e/federation/fixtures/disabled/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { expect, it } from '@rstest/core';

it('should not install federation shims by default', () => {
expect((globalThis as any).__rstest_federation__).toBe(false);
expect((globalThis as any).__rstest_dynamic_import__).toBeUndefined();
});
5 changes: 5 additions & 0 deletions e2e/federation/fixtures/disabled/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "e2e-federation-disabled",
"private": true,
"type": "module"
}
51 changes: 51 additions & 0 deletions e2e/federation/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it } from '@rstest/core';
import { runRstestCli } from '../scripts';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

describe('federation', () => {
it('should install federation runtime shims when enabled in config', async () => {
const { expectExecSuccess } = await runRstestCli({
command: 'rstest',
args: ['run'],
options: {
nodeOptions: {
cwd: join(__dirname, 'fixtures/basic'),
},
},
});

await expectExecSuccess();
});

it('should not install federation runtime shims by default', async () => {
const { expectExecSuccess } = await runRstestCli({
command: 'rstest',
args: ['run'],
options: {
nodeOptions: {
cwd: join(__dirname, 'fixtures/disabled'),
},
},
});

await expectExecSuccess();
});

it('should enable federation mode via the --federation CLI flag', async () => {
const { expectExecSuccess } = await runRstestCli({
command: 'rstest',
args: ['run', '--federation'],
options: {
nodeOptions: {
cwd: join(__dirname, 'fixtures/cli'),
},
},
});

await expectExecSuccess();
});
});
29 changes: 29 additions & 0 deletions examples/federation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Module Federation example

A Module Federation case with React, built with Rsbuild and tested with Rstest's `federation` compatibility mode.

## Project directory

### component-app

Exposes UI components (`Button`, `Dialog`, `Logo`, `ToolTip`) via Module Federation. It is a pure `remote`, with both a browser build (`dist/`) and a Node-targeted build (`dist-node/`) for server-side consumption.

### main-app

The top-level app, which consumes `component-app` over HTTP and `node-local-remote` via a local CommonJS path. It is a pure `host`.

### node-local-remote

A minimal Node-targeted remote consumed directly from its built `remoteEntry.js` on disk, without an HTTP server.

## How to use

- `pnpm install`
- `pnpm run start`

After running these commands, open your browser at `http://localhost:3002` and open the DevTools network tab to see resource loading details.

## Testing

- `pnpm run test` runs the Rstest suites of `main-app` (jsdom host consuming both remotes) and `component-app` (Node SSR against the local remote).
- Both projects enable `federation: true` in `rstest.config.ts` and configure Module Federation through the `@module-federation/rstest` plugin.
43 changes: 43 additions & 0 deletions examples/federation/component-app/App.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react';
import Button from './src/Button';
import Dialog from './src/Dialog';
import Logo from './src/Logo';
export default class App extends React.Component {
constructor(props) {
super(props);
this.state = {
dialogVisible: false,
};
this.handleClick = this.handleClick.bind(this);
this.handleSwitchVisible = this.handleSwitchVisible.bind(this);
}
handleClick(ev) {
console.log(ev);
this.setState({
dialogVisible: true,
});
}
handleSwitchVisible(visible) {
this.setState({
dialogVisible: visible,
});
}
render() {
return (
<div>
<Logo />
<br />
<Button />
<br />

<button type="button" onClick={this.handleClick}>
click to open dialog
</button>
<Dialog
switchVisible={this.handleSwitchVisible}
visible={this.state.dialogVisible}
/>
</div>
);
}
}
5 changes: 5 additions & 0 deletions examples/federation/component-app/bootstrap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('app')).render(<App />);
1 change: 1 addition & 0 deletions examples/federation/component-app/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import('./bootstrap.js');
26 changes: 26 additions & 0 deletions examples/federation/component-app/package.json
Comment thread
ScriptedAlchemy marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@examples/federation-component-app",
"private": true,
"scripts": {
"build": "rsbuild build --config rsbuild.browser.config.js",
"build:node": "rsbuild build --config rsbuild.node.config.js",
"serve": "serve dist -p 3001",
"serve:node": "serve dist-node -p 3001",
"start": "concurrently \"npm run build\" \"npm run serve\"",
"pretest": "pnpm -C ../node-local-remote build:node",
"test": "rstest --reporter=verbose"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@module-federation/rsbuild-plugin": "https://pkg.pr.new/module-federation/core/@module-federation/rsbuild-plugin@58cc69a",
"@module-federation/rstest": "https://pkg.pr.new/module-federation/core/@module-federation/rstest@58cc69a",
"@rsbuild/core": "~2.0.12",
"@rsbuild/plugin-react": "^2.0.1",
"@rstest/core": "workspace:*",
"concurrently": "8.2.2",
"serve": "14.2.3"
}
}
12 changes: 12 additions & 0 deletions examples/federation/component-app/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
46 changes: 46 additions & 0 deletions examples/federation/component-app/rsbuild.browser.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
const { pluginModuleFederation } = require('@module-federation/rsbuild-plugin');
const { defineConfig } = require('@rsbuild/core');
const { pluginReact } = require('@rsbuild/plugin-react');

module.exports = defineConfig({
mode: 'development',
dev: {
assetPrefix: 'http://localhost:3001/',
},
source: {
entry: {
index: './index.js',
},
},
output: {
assetPrefix: 'http://localhost:3001/',
cleanDistPath: true,
filenameHash: false,
sourceMap: {
js: 'hidden-source-map',
},
},
html: {
template: './public/index.html',
},
plugins: [
pluginReact(),
pluginModuleFederation({
name: 'component_app',
experiments: {
asyncStartup: true,
},
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button.jsx',
'./Dialog': './src/Dialog.jsx',
'./Logo': './src/Logo.jsx',
'./ToolTip': './src/ToolTip.jsx',
},
shared: {
react: { singleton: true, requiredVersion: '^19.2.0' },
'react-dom': { singleton: true, requiredVersion: '^19.2.0' },
},
}),
],
});
69 changes: 69 additions & 0 deletions examples/federation/component-app/rsbuild.node.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
const { pluginModuleFederation } = require('@module-federation/rsbuild-plugin');
const { defineConfig } = require('@rsbuild/core');
const { pluginReact } = require('@rsbuild/plugin-react');

/**
* Node-targeted remote build used by Rstest's Module Federation test.
* This differs from the browser build (rsbuild.browser.config.js):
* - target: async-node
* - includes a Node runtime plugin so remote chunks can be loaded over HTTP
* - outputs to dist-node (served on a separate port from the browser remote)
*/
module.exports = defineConfig({
mode: 'development',
environments: {
node: {
dev: {
assetPrefix: 'http://localhost:3001/',
},
output: {
assetPrefix: 'http://localhost:3001/',
},
},
},
source: {
entry: {
index: {
import: './index.js',
html: false,
},
},
},
output: {
assetPrefix: 'http://localhost:3001/',
cleanDistPath: true,
distPath: {
root: 'dist-node',
},
emitCss: false,
filenameHash: false,
module: false,
sourceMap: {
js: 'hidden-source-map',
},
target: 'node',
},
plugins: [
pluginReact(),
pluginModuleFederation(
{
name: 'component_app',
experiments: {
asyncStartup: true,
},
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button.jsx',
'./Dialog': './src/Dialog.jsx',
'./Logo': './src/Logo.jsx',
'./ToolTip': './src/ToolTip.jsx',
},
shared: {
react: { singleton: true, requiredVersion: '^19.2.0' },
'react-dom': { singleton: true, requiredVersion: '^19.2.0' },
},
},
{ target: 'node' },
),
],
});
Loading
Loading