diff --git a/e2e/cases/server/ssr/index.test.ts b/e2e/cases/server/ssr/index.test.ts
new file mode 100644
index 0000000000..ed9363aff6
--- /dev/null
+++ b/e2e/cases/server/ssr/index.test.ts
@@ -0,0 +1,55 @@
+import { dev, rspackOnlyTest } from '@e2e/helper';
+import { expect } from '@playwright/test';
+
+rspackOnlyTest('support SSR', async ({ page }) => {
+ const rsbuild = await dev({
+ cwd: __dirname,
+ rsbuildConfig: {},
+ });
+
+ const url1 = new URL(`http://localhost:${rsbuild.port}`);
+
+ const res = await page.goto(url1.href);
+
+ expect(await res?.text()).toMatch(/Rsbuild with React/);
+
+ await rsbuild.close();
+});
+
+rspackOnlyTest('support SSR with esm target', async ({ page }) => {
+ process.env.TEST_ESM_LIBRARY = '1';
+
+ const rsbuild = await dev({
+ cwd: __dirname,
+ rsbuildConfig: {},
+ });
+
+ const url1 = new URL(`http://localhost:${rsbuild.port}`);
+
+ const res = await page.goto(url1.href);
+
+ expect(await res?.text()).toMatch(/Rsbuild with React/);
+
+ await rsbuild.close();
+
+ delete process.env.TEST_ESM_LIBRARY;
+});
+
+rspackOnlyTest('support SSR with split chunk', async ({ page }) => {
+ process.env.TEST_SPLIT_CHUNK = '1';
+
+ const rsbuild = await dev({
+ cwd: __dirname,
+ rsbuildConfig: {},
+ });
+
+ const url1 = new URL(`http://localhost:${rsbuild.port}`);
+
+ const res = await page.goto(url1.href);
+
+ expect(await res?.text()).toMatch(/Rsbuild with React/);
+
+ await rsbuild.close();
+
+ delete process.env.TEST_SPLIT_CHUNK;
+});
diff --git a/e2e/cases/server/ssr/package.json b/e2e/cases/server/ssr/package.json
new file mode 100644
index 0000000000..ecb7c000bb
--- /dev/null
+++ b/e2e/cases/server/ssr/package.json
@@ -0,0 +1,9 @@
+{
+ "private": true,
+ "name": "@e2e/custom-server-ssr",
+ "version": "1.0.0",
+ "scripts": {
+ "dev": "npx rsbuild dev",
+ "dev:esm": "TEST_ESM_LIBRARY=true npx rsbuild dev"
+ }
+}
diff --git a/e2e/cases/server/ssr/rsbuild.config.ts b/e2e/cases/server/ssr/rsbuild.config.ts
new file mode 100644
index 0000000000..f898c18a01
--- /dev/null
+++ b/e2e/cases/server/ssr/rsbuild.config.ts
@@ -0,0 +1,122 @@
+import {
+ type RequestHandler,
+ type ServerAPIs,
+ defineConfig,
+ logger,
+} from '@rsbuild/core';
+import { pluginReact } from '@rsbuild/plugin-react';
+
+export const serverRender =
+ (serverAPI: ServerAPIs): RequestHandler =>
+ async (_req, res, _next) => {
+ const indexModule = await serverAPI.environments.ssr.loadBundle<{
+ render: () => string;
+ }>('index');
+
+ const markup = indexModule.render();
+
+ const template =
+ await serverAPI.environments.web.getTransformedHtml('index');
+
+ const html = template.replace('', markup);
+
+ res.writeHead(200, {
+ 'Content-Type': 'text/html',
+ });
+ res.end(html);
+ };
+
+export default defineConfig({
+ plugins: [pluginReact()],
+ dev: {
+ setupMiddlewares: [
+ ({ unshift }, serverAPI) => {
+ const serverRenderMiddleware = serverRender(serverAPI);
+
+ unshift(async (req, res, next) => {
+ if (req.method === 'GET' && req.url === '/') {
+ try {
+ await serverRenderMiddleware(req, res, next);
+ } catch (err) {
+ logger.error('SSR render error, downgrade to CSR...\n', err);
+ next();
+ }
+ } else {
+ next();
+ }
+ });
+ },
+ ],
+ },
+ environments: {
+ web: {
+ output: {
+ target: 'web',
+ },
+ source: {
+ entry: {
+ index: './src/index',
+ },
+ },
+ },
+ ssr: {
+ output: {
+ target: 'node',
+ },
+ source: {
+ entry: {
+ index: './src/index.server',
+ },
+ },
+ tools: {
+ rspack: (config) => {
+ if (process.env.TEST_ESM_LIBRARY) {
+ return {
+ ...config,
+ experiments: {
+ ...config.experiments,
+ outputModule: true,
+ },
+ output: {
+ ...config.output,
+ filename: '[name].mjs',
+ chunkFilename: '[name].mjs',
+ chunkFormat: 'module',
+ chunkLoading: 'import',
+ library: {
+ type: 'module',
+ },
+ },
+ };
+ }
+
+ if (process.env.TEST_SPLIT_CHUNK) {
+ return {
+ ...config,
+ optimization: {
+ runtimeChunk: true,
+ splitChunks: {
+ chunks: 'all',
+ enforceSizeThreshold: 50000,
+ minSize: 0,
+ cacheGroups: {
+ 'lib-react': {
+ test: /[\\/]node_modules[\\/](react|react-dom|scheduler|react-refresh|@rspack[\\/]plugin-react-refresh)[\\/]/,
+ priority: 0,
+ name: 'lib-react',
+ reuseExistingChunk: true,
+ },
+ },
+ },
+ },
+ };
+ }
+ return config;
+ },
+ },
+ },
+ },
+ html: {
+ template: './template.html',
+ },
+});
diff --git a/e2e/cases/server/ssr/src/App.css b/e2e/cases/server/ssr/src/App.css
new file mode 100644
index 0000000000..164c0a6aa5
--- /dev/null
+++ b/e2e/cases/server/ssr/src/App.css
@@ -0,0 +1,26 @@
+body {
+ margin: 0;
+ color: #fff;
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+ background-image: linear-gradient(to bottom, #020917, #101725);
+}
+
+.content {
+ display: flex;
+ min-height: 100vh;
+ line-height: 1.1;
+ text-align: center;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.content h1 {
+ font-size: 3.6rem;
+ font-weight: 700;
+}
+
+.content p {
+ font-size: 1.2rem;
+ font-weight: 400;
+ opacity: 0.5;
+}
diff --git a/e2e/cases/server/ssr/src/App.tsx b/e2e/cases/server/ssr/src/App.tsx
new file mode 100644
index 0000000000..dff1751231
--- /dev/null
+++ b/e2e/cases/server/ssr/src/App.tsx
@@ -0,0 +1,12 @@
+import './App.css';
+
+const App = () => {
+ return (
+
+
Rsbuild with React
+
Start building amazing things with Rsbuild.
+
+ );
+};
+
+export default App;
diff --git a/e2e/cases/server/ssr/src/env.d.ts b/e2e/cases/server/ssr/src/env.d.ts
new file mode 100644
index 0000000000..b0ac762b09
--- /dev/null
+++ b/e2e/cases/server/ssr/src/env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/e2e/cases/server/ssr/src/index.server.tsx b/e2e/cases/server/ssr/src/index.server.tsx
new file mode 100644
index 0000000000..9c0b1b1aad
--- /dev/null
+++ b/e2e/cases/server/ssr/src/index.server.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOMServer from 'react-dom/server';
+import App from './App';
+
+// test dynamic import
+import('./test');
+
+export function render() {
+ return ReactDOMServer.renderToString(
+
+
+ ,
+ );
+}
diff --git a/e2e/cases/server/ssr/src/index.tsx b/e2e/cases/server/ssr/src/index.tsx
new file mode 100644
index 0000000000..2b875af736
--- /dev/null
+++ b/e2e/cases/server/ssr/src/index.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+
+const root = ReactDOM.createRoot(document.getElementById('root')!);
+root.render(
+
+
+ ,
+);
diff --git a/e2e/cases/server/ssr/src/test.ts b/e2e/cases/server/ssr/src/test.ts
new file mode 100644
index 0000000000..7ffafc6670
--- /dev/null
+++ b/e2e/cases/server/ssr/src/test.ts
@@ -0,0 +1,3 @@
+export const getA = () => {
+ return 'A';
+};
diff --git a/e2e/cases/server/ssr/template.html b/e2e/cases/server/ssr/template.html
new file mode 100644
index 0000000000..a8a5e608ff
--- /dev/null
+++ b/e2e/cases/server/ssr/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ Rsbuild + React + TS
+
+
+
+
+
diff --git a/e2e/package.json b/e2e/package.json
index 15eda2530e..d3a626ad53 100644
--- a/e2e/package.json
+++ b/e2e/package.json
@@ -4,7 +4,7 @@
"version": "1.0.0",
"scripts": {
"test": "pnpm test:rspack && pnpm test:webpack",
- "test:rspack": "playwright test",
+ "test:rspack": "cross-env NODE_OPTIONS=--experimental-vm-modules playwright test",
"test:webpack": "cross-env PROVIDE_TYPE=webpack playwright test"
},
"dependencies": {
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 12173aba57..a697b8e13b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -120,6 +120,8 @@ export type {
ModifyRsbuildConfigFn,
TransformFn,
TransformHandler,
+ ServerAPIs,
+ RequestHandler,
} from '@rsbuild/shared';
export {
diff --git a/packages/core/src/server/devServer.ts b/packages/core/src/server/devServer.ts
index e339dbbf54..6fadae550e 100644
--- a/packages/core/src/server/devServer.ts
+++ b/packages/core/src/server/devServer.ts
@@ -21,6 +21,7 @@ import type {
NormalizedConfig,
NormalizedDevConfig,
} from '../types';
+import { getTransformedHtml, loadBundle } from './environment';
import {
type RsbuildDevMiddlewareOptions,
getMiddlewares,
@@ -222,25 +223,54 @@ export async function createDevServer<
compileMiddlewareAPI,
});
+ const pwd = options.context.rootPath;
+
+ const readFileSync = (fileName: string) => {
+ if ('readFileSync' in outputFileSystem) {
+ // bundle require needs a synchronous method, although readFileSync is not within the outputFileSystem type definition, but nodejs fs API implemented.
+ // @ts-expect-error
+ return outputFileSystem.readFileSync(fileName, 'utf-8');
+ }
+ return fs.readFileSync(fileName, 'utf-8');
+ };
+
const environmentAPI = Object.fromEntries(
- Object.keys(options.context.environments).map((name, index) => {
- return [
- name,
- {
- getStats: async () => {
- if (!runCompile) {
- throw new Error("can't get stats info when runCompile is false");
- }
- await waitFirstCompileDone;
- return lastStats[index];
+ Object.entries(options.context.environments).map(
+ ([name, environment], index) => {
+ return [
+ name,
+ {
+ getStats: async () => {
+ if (!runCompile) {
+ throw new Error(
+ "can't get stats info when runCompile is false",
+ );
+ }
+ await waitFirstCompileDone;
+ return lastStats[index];
+ },
+ loadBundle: async (entryName: string) => {
+ await waitFirstCompileDone;
+ return loadBundle(lastStats[index], entryName, {
+ readFileSync,
+ environment,
+ });
+ },
+ getTransformedHtml: async (entryName: string) => {
+ await waitFirstCompileDone;
+ return getTransformedHtml(entryName, {
+ readFileSync,
+ environment,
+ });
+ },
},
- },
- ];
- }),
+ ];
+ },
+ ),
);
const devMiddlewares = await getMiddlewares({
- pwd: options.context.rootPath,
+ pwd,
compileMiddlewareAPI,
dev: devConfig,
server: config.server,
diff --git a/packages/core/src/server/environment.ts b/packages/core/src/server/environment.ts
new file mode 100644
index 0000000000..7b36f9ecd2
--- /dev/null
+++ b/packages/core/src/server/environment.ts
@@ -0,0 +1,76 @@
+import { join } from 'node:path';
+import type { EnvironmentContext, Stats } from '@rsbuild/shared';
+import { run } from './runner';
+
+export type ServerUtils = {
+ readFileSync: (fileName: string) => string;
+ environment: EnvironmentContext;
+};
+
+export const loadBundle = async (
+ stats: Stats,
+ entryName: string,
+ utils: ServerUtils,
+): Promise => {
+ const { chunks, entrypoints, outputPath } = stats.toJson({
+ all: false,
+ chunks: true,
+ entrypoints: true,
+ outputPath: true,
+ });
+
+ if (!entrypoints?.[entryName]) {
+ throw new Error(`can't find entry(${entryName})`);
+ }
+
+ const { chunks: entryChunks } = entrypoints[entryName];
+
+ // find main entryChunk from chunks
+ const files = entryChunks.reduce((prev, curr) => {
+ const c = curr
+ ? chunks?.find((c) => c.names.includes(curr) && c.entry)
+ : undefined;
+
+ return c
+ ? prev.concat(c?.files.filter((file) => !file.endsWith('.css')))
+ : prev;
+ }, []);
+
+ if (files.length === 0) {
+ throw new Error(`can't get bundle by entryName(${entryName})`);
+ }
+
+ // An entrypoint should have only one entryChunk, but there may be some boundary cases
+ if (files.length > 1) {
+ throw new Error(
+ `only support load single entry chunk, but got ${files.length}: ${files.join(',')}`,
+ );
+ }
+
+ const res = await run(
+ files[0],
+ outputPath!,
+ stats.compilation.options,
+ utils.readFileSync,
+ );
+
+ return res;
+};
+
+export const getTransformedHtml = async (
+ entryName: string,
+ utils: ServerUtils,
+): Promise => {
+ const { htmlPaths, distPath } = utils.environment;
+ const htmlPath = htmlPaths[entryName];
+
+ if (!htmlPath) {
+ throw new Error(`can't get html file by entryName(${entryName})`);
+ }
+
+ const fileName = join(distPath, htmlPath);
+
+ const fileContent = utils.readFileSync(fileName);
+
+ return fileContent;
+};
diff --git a/packages/core/src/server/runner/asModule.ts b/packages/core/src/server/runner/asModule.ts
new file mode 100644
index 0000000000..3061443f2f
--- /dev/null
+++ b/packages/core/src/server/runner/asModule.ts
@@ -0,0 +1,33 @@
+import vm from 'node:vm';
+
+const SYNTHETIC_MODULES_STORE = '__SYNTHETIC_MODULES_STORE';
+
+export const asModule = async (
+ something: Record,
+ context: Record,
+ unlinked?: boolean,
+): Promise => {
+ if (something instanceof vm.Module) {
+ return something;
+ }
+ context[SYNTHETIC_MODULES_STORE] = context[SYNTHETIC_MODULES_STORE] || [];
+ const i = context[SYNTHETIC_MODULES_STORE].length;
+ context[SYNTHETIC_MODULES_STORE].push(something);
+ const code = [...new Set(['default', ...Object.keys(something)])]
+ .map(
+ (name) =>
+ `const _${name} = ${SYNTHETIC_MODULES_STORE}[${i}]${
+ name === 'default' ? '' : `[${JSON.stringify(name)}]`
+ }; export { _${name} as ${name}};`,
+ )
+ .join('\n');
+ const m = new vm.SourceTextModule(code, {
+ context,
+ });
+ if (unlinked) return m;
+ await m.link((() => {}) as () => vm.Module);
+ // @ts-expect-error copy from webpack
+ if (m.instantiate) m.instantiate();
+ await m.evaluate();
+ return m;
+};
diff --git a/packages/core/src/server/runner/basic.ts b/packages/core/src/server/runner/basic.ts
new file mode 100644
index 0000000000..498f5be0d5
--- /dev/null
+++ b/packages/core/src/server/runner/basic.ts
@@ -0,0 +1,123 @@
+import path from 'node:path';
+
+import type { CompilerOptions, Runner } from './type';
+
+import type {
+ BasicGlobalContext,
+ BasicModuleScope,
+ BasicRunnerFile,
+ ModuleObject,
+ RunnerRequirer,
+} from './type';
+
+const isRelativePath = (p: string) => /^\.\.?\//.test(p);
+const getSubPath = (p: string) => {
+ const lastSlash = p.lastIndexOf('/');
+ let firstSlash = p.indexOf('/');
+
+ if (lastSlash !== -1 && firstSlash !== lastSlash) {
+ if (firstSlash !== -1) {
+ let next = p.indexOf('/', firstSlash + 1);
+ let dir = p.slice(firstSlash + 1, next);
+
+ while (dir === '.') {
+ firstSlash = next;
+ next = p.indexOf('/', firstSlash + 1);
+ dir = p.slice(firstSlash + 1, next);
+ }
+ }
+
+ return p.slice(firstSlash + 1, lastSlash + 1);
+ }
+ return '';
+};
+
+export interface IBasicRunnerOptions {
+ name: string;
+ runInNewContext?: boolean;
+ readFileSync: (path: string) => string;
+ dist: string;
+ compilerOptions: CompilerOptions;
+}
+
+export abstract class BasicRunner implements Runner {
+ protected globalContext: BasicGlobalContext | null = null;
+ protected baseModuleScope: BasicModuleScope | null = null;
+ protected requirers: Map = new Map();
+ constructor(protected _options: IBasicRunnerOptions) {}
+
+ run(file: string): Promise {
+ if (!this.globalContext) {
+ this.globalContext = this.createGlobalContext();
+ }
+ this.baseModuleScope = this.createBaseModuleScope();
+ this.createRunner();
+ const res = this.getRequire()(
+ this._options.dist,
+ file.startsWith('./') ? file : `./${file}`,
+ );
+ if (typeof res === 'object' && 'then' in res) {
+ return res as Promise;
+ }
+ return Promise.resolve(res);
+ }
+
+ getRequire(): RunnerRequirer {
+ const entryRequire = this.requirers.get('entry')!;
+ return (currentDirectory, modulePath, context = {}) => {
+ const p = Array.isArray(modulePath)
+ ? modulePath
+ : modulePath.split('?')[0]!;
+ return entryRequire(currentDirectory, p, context);
+ };
+ }
+
+ protected abstract createGlobalContext(): BasicGlobalContext;
+ protected abstract createBaseModuleScope(): BasicModuleScope;
+ protected abstract createModuleScope(
+ requireFn: RunnerRequirer,
+ m: ModuleObject,
+ file: BasicRunnerFile,
+ ): BasicModuleScope;
+
+ protected getFile(
+ modulePath: string[] | string,
+ currentDirectory: string,
+ ): BasicRunnerFile | null {
+ if (Array.isArray(modulePath)) {
+ return {
+ path: path.join(currentDirectory, '.array-require.js'),
+ content: `module.exports = (${modulePath
+ .map((arg) => {
+ return `require(${JSON.stringify(`./${arg}`)})`;
+ })
+ .join(', ')});`,
+ subPath: '',
+ };
+ }
+ if (isRelativePath(modulePath)) {
+ const p = path.join(currentDirectory, modulePath);
+ return {
+ path: p,
+ content: this._options.readFileSync(p),
+ subPath: getSubPath(modulePath),
+ };
+ }
+ return null;
+ }
+
+ protected preExecute(_code: string, _file: BasicRunnerFile): void {}
+ protected postExecute(
+ _m: Record,
+ _file: BasicRunnerFile,
+ ): void {}
+
+ protected createRunner(): void {
+ this.requirers.set(
+ 'entry',
+ (_currentDirectory, _modulePath, _context = {}) => {
+ throw new Error('Not implement');
+ },
+ );
+ }
+}
diff --git a/packages/core/src/server/runner/cjs.ts b/packages/core/src/server/runner/cjs.ts
new file mode 100644
index 0000000000..bcb0abcfed
--- /dev/null
+++ b/packages/core/src/server/runner/cjs.ts
@@ -0,0 +1,122 @@
+import path from 'node:path';
+import vm from 'node:vm';
+
+import { BasicRunner } from './basic';
+import type {
+ BasicGlobalContext,
+ BasicModuleScope,
+ BasicRunnerFile,
+ ModuleObject,
+ RunnerRequirer,
+} from './type';
+
+const define = (...args: unknown[]) => {
+ const factory = args.pop() as () => void;
+ factory();
+};
+
+export class CommonJsRunner extends BasicRunner {
+ protected createGlobalContext(): BasicGlobalContext {
+ return {
+ console: console,
+ setTimeout: ((
+ cb: (...args: any[]) => void,
+ ms: number | undefined,
+ ...args: any
+ ) => {
+ const timeout = setTimeout(cb, ms, ...args);
+ timeout.unref();
+ return timeout;
+ }) as typeof setTimeout,
+ clearTimeout: clearTimeout,
+ };
+ }
+
+ protected createBaseModuleScope(): BasicModuleScope {
+ const baseModuleScope: BasicModuleScope = {
+ console: this.globalContext!.console,
+ setTimeout: this.globalContext!.setTimeout,
+ clearTimeout: this.globalContext!.clearTimeout,
+ nsObj: (m: Record) => {
+ Object.defineProperty(m, Symbol.toStringTag, {
+ value: 'Module',
+ });
+ return m;
+ },
+ };
+ return baseModuleScope;
+ }
+
+ protected createModuleScope(
+ requireFn: RunnerRequirer,
+ m: ModuleObject,
+ file: BasicRunnerFile,
+ ): BasicModuleScope {
+ return {
+ ...this.baseModuleScope!,
+ require: requireFn.bind(null, path.dirname(file.path)),
+ module: m,
+ exports: m.exports,
+ __dirname: path.dirname(file.path),
+ __filename: file.path,
+ define,
+ };
+ }
+
+ protected createRunner(): void {
+ this.requirers.set('miss', this.createMissRequirer());
+ this.requirers.set('entry', this.createCjsRequirer());
+ }
+
+ protected createMissRequirer(): RunnerRequirer {
+ return (_currentDirectory, modulePath, _context = {}) => {
+ const modulePathStr = modulePath as string;
+ return require(
+ modulePathStr.startsWith('node:')
+ ? modulePathStr.slice(5)
+ : modulePathStr,
+ );
+ };
+ }
+
+ protected createCjsRequirer(): RunnerRequirer {
+ const requireCache = Object.create(null);
+
+ return (currentDirectory, modulePath, context = {}) => {
+ const file = context.file || this.getFile(modulePath, currentDirectory);
+ if (!file) {
+ return this.requirers.get('miss')!(currentDirectory, modulePath);
+ }
+
+ if (file.path in requireCache) {
+ return requireCache[file.path].exports;
+ }
+
+ const m = {
+ exports: {},
+ };
+ requireCache[file.path] = m;
+ const currentModuleScope = this.createModuleScope(
+ this.getRequire(),
+ m,
+ file,
+ );
+
+ const args = Object.keys(currentModuleScope);
+ const argValues = args.map((arg) => currentModuleScope[arg]);
+ const code = `(function(${args.join(', ')}) {
+ ${file.content}
+ })`;
+
+ this.preExecute(code, file);
+ const fn = this._options.runInNewContext
+ ? vm.runInNewContext(code, this.globalContext!, file.path)
+ : vm.runInThisContext(code, file.path);
+
+ fn.call(m.exports, ...argValues);
+
+ this.postExecute(m, file);
+ return m.exports;
+ };
+ }
+}
diff --git a/packages/core/src/server/runner/esm.ts b/packages/core/src/server/runner/esm.ts
new file mode 100644
index 0000000000..16092d7137
--- /dev/null
+++ b/packages/core/src/server/runner/esm.ts
@@ -0,0 +1,107 @@
+import path from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import vm, { type SourceTextModule } from 'node:vm';
+import { asModule } from './asModule';
+
+import { CommonJsRunner } from './cjs';
+import { EsmMode, type RunnerRequirer } from './type';
+
+export class EsmRunner extends CommonJsRunner {
+ protected createRunner(): void {
+ super.createRunner();
+ this.requirers.set('cjs', this.getRequire());
+ this.requirers.set('esm', this.createEsmRequirer());
+ this.requirers.set('entry', (currentDirectory, modulePath, context) => {
+ const file = this.getFile(modulePath, currentDirectory);
+ if (!file) {
+ return this.requirers.get('miss')!(currentDirectory, modulePath);
+ }
+
+ if (
+ file.path.endsWith('.mjs') &&
+ this._options.compilerOptions.experiments?.outputModule
+ ) {
+ return this.requirers.get('esm')!(currentDirectory, modulePath, {
+ ...context,
+ file,
+ });
+ }
+ return this.requirers.get('cjs')!(currentDirectory, modulePath, {
+ ...context,
+ file,
+ });
+ });
+ }
+
+ protected createEsmRequirer(): RunnerRequirer {
+ const esmContext = vm.createContext(this.baseModuleScope!, {
+ name: 'context for esm',
+ });
+ const esmCache = new Map();
+ const esmIdentifier = this._options.name;
+ return (currentDirectory, modulePath, context = {}) => {
+ if (!vm.SourceTextModule) {
+ throw new Error(
+ "Running esm bundle needs add Node.js option '--experimental-vm-modules'.",
+ );
+ }
+ const _require = this.getRequire();
+ const file = context.file || this.getFile(modulePath, currentDirectory);
+ if (!file) {
+ return this.requirers.get('miss')!(currentDirectory, modulePath);
+ }
+
+ let esm = esmCache.get(file.path);
+ if (!esm) {
+ esm = new vm.SourceTextModule(file.content, {
+ identifier: `${esmIdentifier}-${file.path}`,
+ // no attribute
+ url: `${pathToFileURL(file.path).href}?${esmIdentifier}`,
+ context: esmContext,
+ initializeImportMeta: (meta: { url: string }, _: any) => {
+ meta.url = pathToFileURL(file!.path).href;
+ },
+ importModuleDynamically: async (
+ specifier: any,
+ module: { context: any },
+ ) => {
+ const result = await _require(path.dirname(file!.path), specifier, {
+ esmMode: EsmMode.Evaluated,
+ });
+ return await asModule(result, module.context);
+ },
+ } as any);
+ esmCache.set(file.path, esm);
+ }
+ if (context.esmMode === EsmMode.Unlinked) return esm;
+ return (async () => {
+ await esm.link(async (specifier, referencingModule) => {
+ return await asModule(
+ await _require(
+ path.dirname(
+ referencingModule.identifier
+ ? referencingModule.identifier.slice(esmIdentifier.length + 1)
+ : fileURLToPath((referencingModule as any).url),
+ ),
+ specifier,
+ {
+ esmMode: EsmMode.Unlinked,
+ },
+ ),
+ referencingModule.context,
+ true,
+ );
+ });
+ if ((esm as any).instantiate) (esm as any).instantiate();
+ await esm.evaluate();
+ if (context.esmMode === EsmMode.Evaluated) {
+ return esm;
+ }
+ const ns = esm.namespace as {
+ default: unknown;
+ };
+ return ns.default && ns.default instanceof Promise ? ns.default : ns;
+ })();
+ };
+ }
+}
diff --git a/packages/core/src/server/runner/index.ts b/packages/core/src/server/runner/index.ts
new file mode 100644
index 0000000000..22791d5886
--- /dev/null
+++ b/packages/core/src/server/runner/index.ts
@@ -0,0 +1,59 @@
+/**
+ * The following code is modified based on @rspack/test-tools/runner
+ *
+ * TODO: should be extracted as a separate bundle-runner pkg
+ */
+import { EsmRunner } from './esm';
+import type { CompilerOptions, Runner, RunnerFactory } from './type';
+
+export class BasicRunnerFactory implements RunnerFactory {
+ constructor(protected name: string) {}
+
+ create(
+ compilerOptions: CompilerOptions,
+ dist: string,
+ readFileSync: (path: string) => string,
+ ): Runner {
+ const runner = this.createRunner(compilerOptions, dist, readFileSync);
+ return runner;
+ }
+
+ protected createRunner(
+ compilerOptions: CompilerOptions,
+ dist: string,
+ readFileSync: (path: string) => string,
+ ): Runner {
+ const runnerOptions = {
+ name: this.name,
+ dist,
+ compilerOptions,
+ readFileSync,
+ };
+ if (
+ compilerOptions.target === 'web' ||
+ compilerOptions.target === 'webworker'
+ ) {
+ throw new Error(
+ `not support run ${compilerOptions.target} resource in rsbuild server`,
+ );
+ }
+ return new EsmRunner(runnerOptions);
+ }
+}
+
+export const run = async (
+ bundlePath: string,
+ outputPath: string,
+ compilerOptions: CompilerOptions,
+ readFileSync: (path: string) => string,
+): Promise => {
+ const runnerFactory = new BasicRunnerFactory(bundlePath);
+ const runner = runnerFactory.create(
+ compilerOptions,
+ outputPath,
+ readFileSync,
+ );
+ const mod = runner.run(bundlePath);
+
+ return mod as T;
+};
diff --git a/packages/core/src/server/runner/type.ts b/packages/core/src/server/runner/type.ts
new file mode 100644
index 0000000000..7c439994c0
--- /dev/null
+++ b/packages/core/src/server/runner/type.ts
@@ -0,0 +1,51 @@
+import type { RspackOptionsNormalized } from '@rspack/core';
+
+export type RunnerRequirer = (
+ currentDirectory: string,
+ modulePath: string[] | string,
+ context?: {
+ file?: BasicRunnerFile;
+ esmMode?: EsmMode;
+ },
+) => Record | Promise>;
+
+export type BasicRunnerFile = {
+ path: string;
+ content: string;
+ subPath: string;
+};
+
+export enum EsmMode {
+ Unknown = 0,
+ Evaluated = 1,
+ Unlinked = 2,
+}
+
+export interface BasicModuleScope {
+ console: Console;
+ [key: string]: any;
+}
+
+export interface BasicGlobalContext {
+ console: Console;
+ setTimeout: typeof setTimeout;
+ clearTimeout: typeof clearTimeout;
+ [key: string]: any;
+}
+
+export type ModuleObject = { exports: unknown };
+
+export type CompilerOptions = RspackOptionsNormalized;
+
+export interface Runner {
+ run(file: string): Promise;
+ getRequire(): RunnerRequirer;
+}
+
+export interface RunnerFactory {
+ create(
+ compilerOptions: CompilerOptions,
+ dist: string,
+ readFileSync: (fileName: string) => string,
+ ): Runner;
+}
diff --git a/packages/shared/src/types/config/dev.ts b/packages/shared/src/types/config/dev.ts
index 4218b9f8da..8ccfcbedac 100644
--- a/packages/shared/src/types/config/dev.ts
+++ b/packages/shared/src/types/config/dev.ts
@@ -18,6 +18,19 @@ export type RequestHandler = (
export type EnvironmentAPI = {
[name: string]: {
getStats: () => Promise;
+
+ /**
+ * Load and execute stats bundle in Server.
+ *
+ * @param entryName - relate to rsbuild source.entry
+ * @returns the return of entry module.
+ */
+ loadBundle: (entryName: string) => Promise;
+
+ /**
+ * Get the compiled HTML template.
+ */
+ getTransformedHtml: (entryName: string) => Promise;
};
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 836a9a29c5..4b912c84fa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -295,6 +295,8 @@ importers:
specifier: ^0.5.2
version: 0.5.2
+ e2e/cases/server/ssr: {}
+
e2e/cases/source-build/app:
dependencies:
'@e2e/source-build-components':