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

- name: Run Federation Example Test
if: runner.os == 'macOS'
Comment thread
ScriptedAlchemy marked this conversation as resolved.
Outdated
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();
});
});
31 changes: 31 additions & 0 deletions examples/federation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Module Federation example

[中文](./README_zh-cn.md)

A Module Federation case with React, built with Rspack 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.
31 changes: 31 additions & 0 deletions examples/federation/README_zh-cn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Module Federation 示例
Comment thread
ScriptedAlchemy marked this conversation as resolved.
Outdated

[English](./README.md)

一个基于 React 的 Module Federation 项目案例,使用 Rspack 构建,并通过 Rstest 的 `federation` 兼容模式进行测试。

## 目录结构

### component-app

通过 Module Federation 暴露 UI 组件(`Button`、`Dialog`、`Logo`、`ToolTip`)。它是一个纯粹的 `remote`,同时提供浏览器构建(`dist/`)和面向 Node 的构建(`dist-node/`)用于服务端消费。

### main-app

上层 App,通过 HTTP 消费 `component-app`,并通过本地 CommonJS 路径消费 `node-local-remote`。它是一个纯粹的 `host`。

### node-local-remote

一个最小化的 Node 端 remote,直接从磁盘上构建出的 `remoteEntry.js` 消费,无需启动 HTTP 服务。

## 如何使用

- `pnpm install`
- `pnpm run start`

执行完上述命令后,打开浏览器访问 `http://localhost:3002`,并打开 DevTools 的 Network 面板查看资源加载详情。

## 测试

- `pnpm run test` 会运行 `main-app`(jsdom 环境的 host,消费两个 remote)和 `component-app`(基于本地 remote 的 Node SSR)的 Rstest 测试。
- 两个项目都在 `rstest.config.ts` 中开启了 `federation: true`,并通过 `@module-federation/rstest` 插件配置 Module Federation。
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');
28 changes: 28 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,28 @@
{
"name": "@examples/federation-component-app",
"private": true,
"scripts": {
"build": "rspack build --config rspack.browser.config.js",
"build:node": "rspack build --config rspack.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": {
"@module-federation/node": "2.7.32",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@module-federation/enhanced": "https://pkg.pr.new/module-federation/core/@module-federation/enhanced@3fe0a1a3b6b5eab3d2d8eba2e4fd554fc1bda214",
"@module-federation/rstest": "https://pkg.pr.new/module-federation/core/@module-federation/rstest@5a6de69",
"@rsbuild/plugin-react": "^2.0.1",
"@rspack/cli": "~2.0.8",
"@rspack/core": "~2.0.8",
"@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>
62 changes: 62 additions & 0 deletions examples/federation/component-app/rspack.browser.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
const { HtmlRspackPlugin } = require('@rspack/core');
const {
ModuleFederationPlugin,
} = require('@module-federation/enhanced/rspack');

module.exports = {
entry: './index.js',
mode: 'development',
devtool: 'hidden-source-map',
output: {
publicPath: 'http://localhost:3001/',
clean: true,
},
resolve: {
extensions: ['.jsx', '.js', '.json', '.wasm'],
},
experiments: {
css: true,
},
module: {
rules: [
{ test: /\.(jpg|png|gif|jpeg)$/, type: 'asset/resource' },
{
test: /\.css$/,
type: 'css/auto',
},
{
test: /\.(js|jsx)$/,
use: {
loader: 'builtin:swc-loader',
options: {
jsc: {
parser: { syntax: 'ecmascript', jsx: true },
transform: { react: { runtime: 'automatic' } },
},
},
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'component_app',
experiments: {
asyncStartup: true,
},
filename: 'remoteEntry.js',
library: { type: 'var', name: 'component_app' },
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' },
},
}),
new HtmlRspackPlugin({ template: './public/index.html' }),
],
};
Loading
Loading