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':