diff --git a/.changeset/quick-forks-wonder.md b/.changeset/quick-forks-wonder.md new file mode 100644 index 00000000000..794486ba0b5 --- /dev/null +++ b/.changeset/quick-forks-wonder.md @@ -0,0 +1,5 @@ +--- +'@module-federation/rsbuild-plugin': patch +--- + +Fix app-mode `target: 'node'` handling to respect custom `environment` names, improve missing-environment errors, auto-detect default environment names by caller/tooling when `environment` is omitted, and ensure selected node-target environments still receive federation plugin injection for commonjs-like SSR outputs. diff --git a/apps/website-new/docs/en/guide/build-plugins/plugins-rsbuild.mdx b/apps/website-new/docs/en/guide/build-plugins/plugins-rsbuild.mdx index afb41c3c92a..42df4d6c6fc 100644 --- a/apps/website-new/docs/en/guide/build-plugins/plugins-rsbuild.mdx +++ b/apps/website-new/docs/en/guide/build-plugins/plugins-rsbuild.mdx @@ -92,7 +92,10 @@ If you need to use the Module Federation runtime capabilities, please install [@ export declare const pluginModuleFederation: (moduleFederationOptions: ModuleFederationOptions, rsbuildOptions?: RSBUILD_PLUGIN_OPTIONS) => RsbuildPlugin; type RSBUILD_PLUGIN_OPTIONS = { + target?: 'web' | 'node' | 'dual'; ssr?: boolean; + ssrDir?: string; + environment?: string; } ``` @@ -109,7 +112,7 @@ Additional configuration for the Rsbuild plugin. :::tip -Only supported when used as a global plugin in Rslib. +`target: 'dual'` is only supported when used as a global plugin in Rslib/Rspress. ::: @@ -120,6 +123,46 @@ Used to specify the target runtime environment for the output. When set to `dual After generating SSR output with `target: 'dual'`, you can refer to [Create a Modern.js Consumer](../../../practice/frameworks/modern/index), create a consumer, and integrate the corresponding Rslib SSR producer for development. +For Rsbuild app SSR, use `target: 'node'` with `environment` to apply Module Federation to a specific app environment. + +```ts title='rsbuild.config.ts' +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; +import { defineConfig } from '@rsbuild/core'; + +export default defineConfig({ + environments: { + client: {}, + ssr: {}, + }, + plugins: [ + pluginModuleFederation( + { + name: 'host', + remotes: { + remote: 'remote@http://localhost:3001/mf-manifest.json', + }, + }, + { + target: 'node', + environment: 'ssr', + }, + ), + ], +}); +``` + +#### environment + +* Type: `string` +* Default: auto-detected by caller/tooling: + * Rslib: `'mf'` + * Rsbuild app + `target: 'web'`: `'web'` + * Rsbuild app + `target: 'node'`: `'node'` + * Rspress + `target: 'web'`: `'web'` + * Rspress + `target: 'node'`: `'node'` + +Environment name used by `target: 'node'` to select which environment config receives Node-target federation behavior. + #### ssr diff --git a/apps/website-new/docs/zh/guide/build-plugins/plugins-rsbuild.mdx b/apps/website-new/docs/zh/guide/build-plugins/plugins-rsbuild.mdx index d4dfaed6397..ed21279363c 100644 --- a/apps/website-new/docs/zh/guide/build-plugins/plugins-rsbuild.mdx +++ b/apps/website-new/docs/zh/guide/build-plugins/plugins-rsbuild.mdx @@ -92,7 +92,10 @@ export default defineConfig({ export declare const pluginModuleFederation: (moduleFederationOptions: ModuleFederationOptions, rsbuildOptions?: RSBUILD_PLUGIN_OPTIONS) => RsbuildPlugin; type RSBUILD_PLUGIN_OPTIONS = { + target?: 'web' | 'node' | 'dual'; ssr?: boolean; + ssrDir?: string; + environment?: string; } ``` @@ -108,7 +111,7 @@ Rsbuild 插件额外配置。 :::tip -仅支持 Rslib 全局插件。 +`target: 'dual'` 仅支持 Rslib/Rspress 全局插件。 ::: @@ -119,6 +122,46 @@ Rsbuild 插件额外配置。 使用 `target: 'dual'` 生成 SSR 产物后,可参考 [创建 Modern.js 消费者](../../../practice/frameworks/modern/index) 创建消费者,并接入对应的 Rslib SSR 生产者进行开发。 +对于 Rsbuild App 的 SSR,可使用 `target: 'node'` + `environment`,将 Module Federation 应用到指定环境。 + +```ts title='rsbuild.config.ts' +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; +import { defineConfig } from '@rsbuild/core'; + +export default defineConfig({ + environments: { + client: {}, + ssr: {}, + }, + plugins: [ + pluginModuleFederation( + { + name: 'host', + remotes: { + remote: 'remote@http://localhost:3001/mf-manifest.json', + }, + }, + { + target: 'node', + environment: 'ssr', + }, + ), + ], +}); +``` + +#### environment + +* 类型:`string` +* 默认值:按调用方/工具自动推断: + * Rslib:`'mf'` + * Rsbuild App + `target: 'web'`:`'web'` + * Rsbuild App + `target: 'node'`:`'node'` + * Rspress + `target: 'web'`:`'web'` + * Rspress + `target: 'node'`:`'node'` + +在 `target: 'node'` 下用于指定要应用 Node Federation 行为的环境名称。 + #### ssr diff --git a/packages/rsbuild-plugin/README.md b/packages/rsbuild-plugin/README.md index 14d324c8b6e..c9fdb3d357f 100644 --- a/packages/rsbuild-plugin/README.md +++ b/packages/rsbuild-plugin/README.md @@ -34,6 +34,53 @@ export default defineConfig({ }); ``` +### Rsbuild App SSR (Node target with custom environment) + +Use `target: 'node'` with an explicit `environment` to apply federation to a +specific Rsbuild app environment (for example `ssr`). + +```ts +import { pluginModuleFederation } from '@module-federation/rsbuild-plugin'; +import { defineConfig } from '@rsbuild/core'; + +export default defineConfig({ + environments: { + client: {}, + ssr: {}, + }, + plugins: [ + pluginModuleFederation( + { + name: 'host', + remotes: { + remote: 'remote@http://localhost:3001/mf-manifest.json', + }, + }, + { + target: 'node', + environment: 'ssr', + }, + ), + ], +}); +``` + +`target: 'dual'` support remains scoped to Rslib/Rspress workflows. + +### Default environment detection + +If `environment` is omitted, the plugin will choose a default per tool: + +- **Rslib**: `mf` +- **Rsbuild app**: + - `target: 'web'` → `web` + - `target: 'node'` → `node` +- **Rspress**: + - `target: 'web'` → `web` + - `target: 'node'` → `node` + +You can still override with `environment` when your project uses custom names. + ### Rslib Module ```js diff --git a/packages/rsbuild-plugin/src/cli/index.ts b/packages/rsbuild-plugin/src/cli/index.ts index 1a39b856985..5b6184fa2da 100644 --- a/packages/rsbuild-plugin/src/cli/index.ts +++ b/packages/rsbuild-plugin/src/cli/index.ts @@ -29,12 +29,7 @@ import { RSPRESS_SSR_DIR, RSPRESS_SSG_MD_ENV_NAME, } from '../constant'; -import { - ENV_NAME, - patchNodeConfig, - patchNodeMFConfig, - patchToolsTspack, -} from '../utils/ssr'; +import { patchNodeConfig, patchToolsTspack } from '../utils/ssr'; type ModuleFederationOptions = moduleFederationPlugin.ModuleFederationPluginOptions; @@ -47,7 +42,7 @@ type RSBUILD_PLUGIN_OPTIONS = { ssr?: boolean; // ssr dir, default is ssr ssrDir?: string; - // target copy environment name, default is mf + // target environment name. If omitted, defaults are inferred by caller/tool. environment?: string; }; @@ -75,6 +70,33 @@ export { RSBUILD_PLUGIN_MODULE_FEDERATION_NAME, PLUGIN_NAME, SSR_DIR }; const LIB_FORMAT = ['umd', 'modern-module']; const DEFAULT_MF_ENVIRONMENT_NAME = 'mf'; +const DEFAULT_WEB_ENVIRONMENT_NAME = 'web'; +const DEFAULT_NODE_ENVIRONMENT_NAME = 'node'; + +const resolveDefaultEnvironmentName = ({ + callerName, + target, +}: { + callerName: string; + target: RSBUILD_PLUGIN_OPTIONS['target']; +}) => { + if (callerName === CALL_NAME_MAP.RSLIB) { + return DEFAULT_MF_ENVIRONMENT_NAME; + } + + if (callerName === CALL_NAME_MAP.RSPRESS) { + if (target === 'node') { + return DEFAULT_NODE_ENVIRONMENT_NAME; + } + return DEFAULT_WEB_ENVIRONMENT_NAME; + } + + if (target === 'node') { + return DEFAULT_NODE_ENVIRONMENT_NAME; + } + + return DEFAULT_WEB_ENVIRONMENT_NAME; +}; function isStoryBook(rsbuildConfig: RsbuildConfig) { if ( @@ -118,11 +140,16 @@ export const pluginModuleFederation = ( ): RsbuildPlugin => ({ name: RSBUILD_PLUGIN_MODULE_FEDERATION_NAME, setup: (api) => { + if (!moduleFederationOptions?.name) { + throw new Error( + 'The module federation option "name" is required in @module-federation/rsbuild-plugin.', + ); + } const { target = 'web', ssr = undefined, ssrDir = SSR_DIR, - environment = DEFAULT_MF_ENVIRONMENT_NAME, + environment: configuredEnvironment, } = rsbuildOptions || {}; if (ssr) { throw new Error( @@ -140,6 +167,12 @@ export const pluginModuleFederation = ( const isRslib = callerName === CALL_NAME_MAP.RSLIB; const isRspress = callerName === CALL_NAME_MAP.RSPRESS; const isSSR = target === 'dual'; + const environment = + configuredEnvironment ?? + resolveDefaultEnvironmentName({ + callerName, + target, + }); if (isSSR && !isStoryBook(originalRsbuildConfig)) { if (!isRslib && !isRspress) { @@ -255,8 +288,18 @@ export const pluginModuleFederation = ( }); } } else if (target === 'node') { - const mfEnv = config.environments![ENV_NAME]!; - patchToolsTspack(mfEnv, (config, { environment }) => { + const nodeTargetEnv = config.environments?.[environment]; + if (!nodeTargetEnv) { + const availableEnvironments = Object.keys(config.environments || {}); + const availableEnvironmentsLabel = + availableEnvironments.length > 0 + ? availableEnvironments.join(', ') + : '(none)'; + throw new Error( + `Can not find environment '${environment}' when using target: 'node'. Available environments: ${availableEnvironmentsLabel}.`, + ); + } + patchToolsTspack(nodeTargetEnv, (config, { environment }) => { config.target = 'async-node'; }); } @@ -360,7 +403,43 @@ export const pluginModuleFederation = ( throw new Error('Can not get bundlerConfigs!'); } bundlerConfigs.forEach((bundlerConfig) => { - if (!isMFFormat(bundlerConfig) && !isRspress) { + const bundlerConfigName = bundlerConfig.name || ''; + const isConfiguredEnvironmentConfig = bundlerConfigName === environment; + const isNodeTargetEnvironmentConfig = + target === 'node' && bundlerConfigName === environment; + const isRspressSSGEnvironmentConfig = isRspressSSGConfig( + bundlerConfig.name, + ); + const isActiveRspressSSGEnvironmentConfig = + isRspress && isRspressSSGEnvironmentConfig; + const shouldUseSSRPluginConfig = + isSSRConfig(bundlerConfig.name) || isNodeTargetEnvironmentConfig; + + if ( + target === 'node' && + !isNodeTargetEnvironmentConfig && + !isActiveRspressSSGEnvironmentConfig + ) { + return; + } + + // For non-node targets, scope each plugin instance to its configured + // environment plus explicit SSR/SSG environments. This prevents a + // browser-targeted instance from mutating SSR configs. + if ( + target !== 'node' && + !isConfiguredEnvironmentConfig && + !shouldUseSSRPluginConfig && + !isActiveRspressSSGEnvironmentConfig + ) { + return; + } + + if ( + !isMFFormat(bundlerConfig) && + !isRspress && + !isNodeTargetEnvironmentConfig + ) { return; } else if (isStoryBook(originalRsbuildConfig)) { bundlerConfig.output!.uniqueName = `${moduleFederationOptions.name} -storybook - host`; @@ -372,9 +451,13 @@ export const pluginModuleFederation = ( ); addDataFetchExposes( moduleFederationOptions.exposes, - isSSRConfig(bundlerConfig.name), + shouldUseSSRPluginConfig, ); + const ssrModuleFederationOptions = shouldUseSSRPluginConfig + ? createSSRMFConfig(moduleFederationOptions) + : undefined; + delete bundlerConfig.optimization?.runtimeChunk; const externals = bundlerConfig.externals; if (Array.isArray(externals)) { @@ -427,17 +510,19 @@ export const pluginModuleFederation = ( if ( !bundlerConfig.output?.chunkLoadingGlobal && - !isSSRConfig(bundlerConfig.name) && - !isRspressSSGConfig(bundlerConfig.name) && + !shouldUseSSRPluginConfig && + !isActiveRspressSSGEnvironmentConfig && target !== 'node' ) { bundlerConfig.output!.chunkLoading = 'jsonp'; bundlerConfig.output!.chunkLoadingGlobal = `chunk_${moduleFederationOptions.name} `; } - if (target === 'node' && isMFFormat(bundlerConfig)) { - patchNodeConfig(bundlerConfig, moduleFederationOptions); - patchNodeMFConfig(moduleFederationOptions); + if (isNodeTargetEnvironmentConfig) { + patchNodeConfig( + bundlerConfig, + ssrModuleFederationOptions ?? moduleFederationOptions, + ); } // `uniqueName` is required for react refresh to work @@ -449,8 +534,8 @@ export const pluginModuleFederation = ( // This allows remote chunks to load from the same origin as the remote application's manifest if ( bundlerConfig.output?.publicPath === undefined && - !isSSRConfig(bundlerConfig.name) && - !isRspressSSGConfig(bundlerConfig.name) + !shouldUseSSRPluginConfig && + !isActiveRspressSSGEnvironmentConfig ) { bundlerConfig.output!.publicPath = 'auto'; } @@ -458,18 +543,19 @@ export const pluginModuleFederation = ( if ( !bundlerConfig.plugins!.find((p) => p && p.name === PLUGIN_NAME) ) { - if (isSSRConfig(bundlerConfig.name)) { + if (shouldUseSSRPluginConfig) { + const ssrMFConfig = + ssrModuleFederationOptions ?? + createSSRMFConfig(moduleFederationOptions); generateMergedStatsAndManifestOptions.options.nodePlugin = - new ModuleFederationPlugin( - createSSRMFConfig(moduleFederationOptions), - ); + new ModuleFederationPlugin(ssrMFConfig); generateMergedStatsAndManifestOptions.options.nodeEnvironmentName = bundlerConfig.name || SSR_ENV_NAME; bundlerConfig.plugins!.push( generateMergedStatsAndManifestOptions.options.nodePlugin, ); return; - } else if (isRspressSSGConfig(bundlerConfig.name)) { + } else if (isActiveRspressSSGEnvironmentConfig) { const mfConfig = { ...createSSRMFConfig(moduleFederationOptions), // expose in mf-ssg env diff --git a/packages/rsbuild-plugin/src/cli/node-target.spec.ts b/packages/rsbuild-plugin/src/cli/node-target.spec.ts new file mode 100644 index 00000000000..034297cb589 --- /dev/null +++ b/packages/rsbuild-plugin/src/cli/node-target.spec.ts @@ -0,0 +1,274 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + pluginModuleFederation, + RSBUILD_PLUGIN_MODULE_FEDERATION_NAME, +} from './index'; +import { CALL_NAME_MAP } from '../constant'; + +import type { moduleFederationPlugin } from '@module-federation/sdk'; +import type { Rspack } from '@rsbuild/core'; + +type MockApiState = { + beforeCreateCompiler?: (args: { + bundlerConfigs?: Rspack.Configuration[]; + }) => void; +}; + +function createMockApi( + rsbuildConfig: Record, + callerName = 'rsbuild', +) { + const originalConfig = JSON.parse(JSON.stringify(rsbuildConfig)); + const state: MockApiState = {}; + + const api = { + context: { + callerName, + }, + getRsbuildConfig: (kind?: 'original') => + kind === 'original' ? originalConfig : rsbuildConfig, + modifyRsbuildConfig: (callback: (config: Record) => void) => { + callback(rsbuildConfig); + }, + modifyEnvironmentConfig: vi.fn(), + expose: vi.fn(), + processAssets: vi.fn(), + onBeforeCreateCompiler: ( + callback: (args: { bundlerConfigs?: Rspack.Configuration[] }) => void, + ) => { + state.beforeCreateCompiler = callback; + }, + onDevCompileDone: vi.fn(), + onAfterBuild: vi.fn(), + }; + + return { api, state, rsbuildConfig }; +} + +function createMfOptions(): moduleFederationPlugin.ModuleFederationPluginOptions { + return { + name: 'host', + remotes: { + remote: 'remote@http://localhost:3001/mf-manifest.json', + }, + }; +} + +describe('pluginModuleFederation node target environment behavior', () => { + it('uses the configured custom environment instead of requiring "mf"', () => { + const plugin = pluginModuleFederation(createMfOptions(), { + target: 'node', + environment: 'ssr', + }); + const { api, rsbuildConfig } = createMockApi({ + environments: { + client: {}, + ssr: {}, + }, + }); + + plugin.setup(api as any); + + expect(rsbuildConfig.environments.ssr.tools?.rspack).toBeDefined(); + expect(rsbuildConfig.environments.client.tools?.rspack).toBeUndefined(); + }); + + it('throws a clear error when target=node environment is missing', () => { + const plugin = pluginModuleFederation(createMfOptions(), { + target: 'node', + environment: 'ssr', + }); + const { api } = createMockApi({ + environments: { + client: {}, + }, + }); + + expect(() => plugin.setup(api as any)).toThrow( + "Can not find environment 'ssr' when using target: 'node'. Available environments: client.", + ); + }); + + it('defaults to the rsbuild node environment when target=node and environment is omitted', () => { + const plugin = pluginModuleFederation(createMfOptions(), { + target: 'node', + }); + const { api, rsbuildConfig } = createMockApi({ + environments: { + web: {}, + node: {}, + }, + }); + + plugin.setup(api as any); + + expect(rsbuildConfig.environments.node.tools?.rspack).toBeDefined(); + expect(rsbuildConfig.environments.web.tools?.rspack).toBeUndefined(); + }); + + it('defaults to the rspress node environment when target=node and environment is omitted', () => { + const plugin = pluginModuleFederation(createMfOptions(), { + target: 'node', + }); + const { api, rsbuildConfig } = createMockApi( + { + environments: { + web: {}, + node: {}, + node_md: {}, + }, + }, + CALL_NAME_MAP.RSPRESS, + ); + + plugin.setup(api as any); + + expect(rsbuildConfig.environments.node.tools?.rspack).toBeDefined(); + expect(rsbuildConfig.environments.web.tools?.rspack).toBeUndefined(); + }); + + it('still applies MF to the selected node environment when output is commonjs-like', () => { + const plugin = pluginModuleFederation(createMfOptions(), { + target: 'node', + environment: 'ssr', + }); + const { api, state } = createMockApi({ + environments: { + client: {}, + ssr: {}, + }, + }); + const ssrBundlerConfig: Rspack.Configuration = { + name: 'ssr', + output: { + path: '/tmp/ssr', + publicPath: '/', + chunkFilename: 'chunks/[name].js', + library: { + type: 'commonjs2', + }, + }, + optimization: {}, + plugins: [], + }; + const clientBundlerConfig: Rspack.Configuration = { + name: 'client', + output: { + path: '/tmp/client', + publicPath: '/', + library: { + type: 'commonjs2', + }, + }, + optimization: {}, + plugins: [], + }; + + plugin.setup(api as any); + expect(state.beforeCreateCompiler).toBeDefined(); + + state.beforeCreateCompiler!({ + bundlerConfigs: [ssrBundlerConfig, clientBundlerConfig], + }); + + expect(ssrBundlerConfig.target).toBe('async-node'); + expect(ssrBundlerConfig.plugins?.length).toBeGreaterThan(0); + expect(clientBundlerConfig.target).toBeUndefined(); + expect(clientBundlerConfig.plugins?.length).toBe(0); + const exposedApi = (api.expose as ReturnType).mock.calls.find( + ([name]) => name === RSBUILD_PLUGIN_MODULE_FEDERATION_NAME, + )?.[1] as { + options: { + nodePlugin?: unknown; + browserPlugin?: unknown; + }; + }; + expect(exposedApi.options.nodePlugin).toBeDefined(); + expect(exposedApi.options.browserPlugin).toBeUndefined(); + }); + + it('skips MF injection for non-selected MF-format environments', () => { + const mfOptions = createMfOptions(); + const plugin = pluginModuleFederation(mfOptions, { + target: 'node', + environment: 'ssr', + }); + const { api, state } = createMockApi({ + environments: { + client: {}, + ssr: {}, + }, + }); + const ssrBundlerConfig: Rspack.Configuration = { + name: 'ssr', + output: { + path: '/tmp/ssr', + publicPath: '/', + chunkFilename: 'chunks/[name].js', + library: { + type: 'commonjs2', + }, + }, + optimization: {}, + plugins: [], + }; + // No output.library => treated as MF format by isMFFormat. + const clientBundlerConfig: Rspack.Configuration = { + name: 'client', + output: { + path: '/tmp/client', + publicPath: '/', + }, + optimization: {}, + plugins: [], + }; + + plugin.setup(api as any); + expect(state.beforeCreateCompiler).toBeDefined(); + + state.beforeCreateCompiler!({ + bundlerConfigs: [ssrBundlerConfig, clientBundlerConfig], + }); + + expect(ssrBundlerConfig.target).toBe('async-node'); + expect(ssrBundlerConfig.plugins?.length).toBeGreaterThan(0); + expect(clientBundlerConfig.target).toBeUndefined(); + expect(clientBundlerConfig.plugins?.length).toBe(0); + expect(mfOptions.runtimePlugins).toBeUndefined(); + expect(mfOptions.library).toBeUndefined(); + expect(mfOptions.remoteType).toBeUndefined(); + }); + + it('keeps target=dual restriction for non-rslib/non-rspress callers', () => { + const plugin = pluginModuleFederation(createMfOptions(), { + target: 'dual', + }); + const { api } = createMockApi({ + environments: { + mf: {}, + }, + }); + + expect(() => plugin.setup(api as any)).toThrow( + "'target' option is only supported in Rslib.", + ); + }); + + it('preserves default mf environment behavior when no custom environment is provided', () => { + const plugin = pluginModuleFederation(createMfOptions(), { + target: 'node', + }); + const { api, rsbuildConfig } = createMockApi( + { + environments: { + mf: {}, + }, + }, + CALL_NAME_MAP.RSLIB, + ); + + plugin.setup(api as any); + + expect(rsbuildConfig.environments.mf.tools?.rspack).toBeDefined(); + }); +}); diff --git a/packages/rsbuild-plugin/src/utils/ssr.spec.ts b/packages/rsbuild-plugin/src/utils/ssr.spec.ts index 0bb6ada0ca8..85624747dcd 100644 --- a/packages/rsbuild-plugin/src/utils/ssr.spec.ts +++ b/packages/rsbuild-plugin/src/utils/ssr.spec.ts @@ -3,6 +3,9 @@ import { createSSRMFConfig, patchSSRRspackConfig, SSR_DIR } from './ssr'; import type { Rspack } from '@rsbuild/core'; import type { moduleFederationPlugin } from '@module-federation/sdk'; +const RECORD_DYNAMIC_REMOTE_ENTRY_HASH_PLUGIN_PATTERN = + /record(?:-dynamic-remote-entry-hash-plugin|DynamicRemoteEntryHashPlugin)(\.js)?$/; + describe('createSSRMFConfig', () => { const baseMFConfig: moduleFederationPlugin.ModuleFederationPluginOptions = { name: 'testApp', @@ -14,9 +17,8 @@ describe('createSSRMFConfig', () => { expect(ssrMFConfig.library?.type).toBe('commonjs-module'); expect(ssrMFConfig.dts).toBe(false); expect(ssrMFConfig.dev).toBe(false); - expect(ssrMFConfig.runtimePlugins).toEqual([ - require.resolve('@module-federation/node/runtimePlugin'), - ]); + expect(ssrMFConfig.runtimePlugins).toHaveLength(1); + expect(ssrMFConfig.runtimePlugins?.[0]).toMatch(/runtimePlugin(\.js)?$/); }); it('should preserve library.type if already defined', () => { @@ -36,13 +38,9 @@ describe('createSSRMFConfig', () => { const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'development'; const ssrMFConfig = createSSRMFConfig(baseMFConfig); - expect(ssrMFConfig.runtimePlugins).toContain( - require.resolve('@module-federation/node/runtimePlugin'), - ); - expect(ssrMFConfig.runtimePlugins).toContain( - require.resolve( - '@module-federation/node/record-dynamic-remote-entry-hash-plugin', - ), + expect(ssrMFConfig.runtimePlugins?.[0]).toMatch(/runtimePlugin(\.js)?$/); + expect(ssrMFConfig.runtimePlugins?.[1]).toMatch( + RECORD_DYNAMIC_REMOTE_ENTRY_HASH_PLUGIN_PATTERN, ); process.env.NODE_ENV = originalNodeEnv; // Restore original NODE_ENV }); @@ -51,22 +49,23 @@ describe('createSSRMFConfig', () => { const originalNodeEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; const ssrMFConfig = createSSRMFConfig(baseMFConfig); - expect(ssrMFConfig.runtimePlugins).toEqual([ - require.resolve('@module-federation/node/runtimePlugin'), - ]); + expect(ssrMFConfig.runtimePlugins).toHaveLength(1); + expect(ssrMFConfig.runtimePlugins?.[0]).toMatch(/runtimePlugin(\.js)?$/); process.env.NODE_ENV = originalNodeEnv; // Restore original NODE_ENV }); it('should initialize runtimePlugins if it is undefined', () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; const mfConfigWithoutRuntimePlugins: moduleFederationPlugin.ModuleFederationPluginOptions = { name: 'testApp', runtimePlugins: undefined, }; const ssrMFConfig = createSSRMFConfig(mfConfigWithoutRuntimePlugins); - expect(ssrMFConfig.runtimePlugins).toEqual([ - require.resolve('@module-federation/node/runtimePlugin'), - ]); + expect(ssrMFConfig.runtimePlugins).toHaveLength(1); + expect(ssrMFConfig.runtimePlugins?.[0]).toMatch(/runtimePlugin(\.js)?$/); + process.env.NODE_ENV = originalNodeEnv; }); }); @@ -91,12 +90,12 @@ describe('patchSSRRspackConfig', () => { ); }); - it('should throw error if publicPath is "auto"', () => { + it('should normalize "auto" publicPath for SSR node output', () => { const config = JSON.parse(JSON.stringify(baseConfig)); config.output.publicPath = 'auto'; - expect(() => patchSSRRspackConfig(config, baseMfConfig, 'ssr')).toThrow( - 'publicPath can not be "auto"!', - ); + const patchedConfig = patchSSRRspackConfig(config, baseMfConfig, 'ssr'); + expect(patchedConfig.output?.publicPath).toBe(''); + expect(patchedConfig.target).toBe('async-node'); }); it('should update publicPath correctly', () => { @@ -137,9 +136,7 @@ describe('patchSSRRspackConfig', () => { name: 'myApp', }; const patchedConfig = patchSSRRspackConfig(config, mfConfig, 'ssr'); - expect(patchedConfig.output?.chunkFilename).toBe( - 'js/[name]myApp-[contenthash].js', - ); + expect(patchedConfig.output?.chunkFilename).toBe('js/[name]myApp.js'); }); it('should modify chunkFilename when conditions are met (uniqueName from config.output.uniqueName)', () => { @@ -154,7 +151,7 @@ describe('patchSSRRspackConfig', () => { const mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions = {}; // No name in mfConfig const patchedConfig = patchSSRRspackConfig(config, mfConfig, 'ssr'); expect(patchedConfig.output?.chunkFilename).toBe( - 'js/[name]myOutputUniqueName-[contenthash].js', + 'js/[name]myOutputUniqueName.js', ); }); diff --git a/packages/rsbuild-plugin/src/utils/ssr.ts b/packages/rsbuild-plugin/src/utils/ssr.ts index cdc61573363..08fc8e53b90 100644 --- a/packages/rsbuild-plugin/src/utils/ssr.ts +++ b/packages/rsbuild-plugin/src/utils/ssr.ts @@ -15,6 +15,22 @@ import type { moduleFederationPlugin } from '@module-federation/sdk'; const require = createRequire(import.meta.url); const resolve = require.resolve; +function resolveOrRequest(request: string): string { + try { + return resolve(request); + } catch { + return request; + } +} + +function safeRequire(request: string): T | undefined { + try { + return require(request) as T; + } catch { + return undefined; + } +} + export const SSR_DIR = 'ssr'; export const SSR_ENV_NAME = 'mf-ssr'; export const ENV_NAME = 'mf'; @@ -32,12 +48,25 @@ export function patchNodeConfig( mfConfig: moduleFederationPlugin.ModuleFederationPluginOptions, ) { config.output ||= {}; + if (config.output.publicPath === 'auto') { + config.output.publicPath = ''; + } config.target = 'async-node'; - // @module-federation/node/universe-entry-chunk-tracker-plugin only export cjs + // Force node federation output to CJS + async chunk loading. + // This prevents browser jsonp runtime handlers from leaking into SSR remotes. + config.output.module = false; + config.output.chunkFormat = 'commonjs'; + config.output.chunkLoading = 'async-node'; + delete config.output.chunkLoadingGlobal; + const UniverseEntryChunkTrackerPluginModule = safeRequire<{ + default?: new () => Rspack.RspackPluginInstance; + }>('@module-federation/node/universe-entry-chunk-tracker-plugin'); const UniverseEntryChunkTrackerPlugin = - require('@module-federation/node/universe-entry-chunk-tracker-plugin').default; + UniverseEntryChunkTrackerPluginModule?.default; config.plugins ||= []; - isDev() && config.plugins.push(new UniverseEntryChunkTrackerPlugin()); + if (isDev() && UniverseEntryChunkTrackerPlugin) { + config.plugins.push(new UniverseEntryChunkTrackerPlugin()); + } const uniqueName = mfConfig.name || config.output?.uniqueName; const chunkFileName = config.output.chunkFilename; @@ -46,8 +75,10 @@ export function patchNodeConfig( uniqueName && !chunkFileName.includes(uniqueName) ) { - const suffix = `${encodeName(uniqueName)}-[contenthash].js`; - config.output.chunkFilename = chunkFileName.replace('.js', suffix); + const encodedName = encodeName(uniqueName); + config.output.chunkFilename = chunkFileName.endsWith('.js') + ? chunkFileName.replace(/\.js$/, `${encodedName}.js`) + : `${chunkFileName}${encodedName}`; } } @@ -65,12 +96,10 @@ export function patchSSRRspackConfig( throw new Error('publicPath must be string!'); } const publicPath = config.output.publicPath; - if (publicPath === 'auto') { - throw new Error('publicPath can not be "auto"!'); + if (publicPath !== 'auto') { + const publicPathWithSSRDir = `${publicPath}${ssrDir}/`; + config.output.publicPath = publicPathWithSSRDir; } - - const publicPathWithSSRDir = `${publicPath}${ssrDir}/`; - config.output.publicPath = publicPathWithSSRDir; } if (callerName === CALL_NAME_MAP.RSPRESS && resetEntry) { @@ -158,12 +187,11 @@ export function patchNodeMFConfig( mfConfig.runtimePlugins = [...(mfConfig.runtimePlugins || [])]; mfConfig.runtimePlugins.push( - resolve('@module-federation/node/runtimePlugin'), + resolveOrRequest('@module-federation/node/runtimePlugin'), ); if (isDev()) { mfConfig.runtimePlugins.push( - // @ts-ignore - resolve( + resolveOrRequest( '@module-federation/node/record-dynamic-remote-entry-hash-plugin', ), ); diff --git a/packages/rsbuild-plugin/tsconfig.json b/packages/rsbuild-plugin/tsconfig.json index 4304f9dbc6e..bc1635aab04 100644 --- a/packages/rsbuild-plugin/tsconfig.json +++ b/packages/rsbuild-plugin/tsconfig.json @@ -8,7 +8,7 @@ "module": "es2022", "target": "es2022", "skipLibCheck": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "allowJs": false, "strict": true, "types": ["jest", "node"], diff --git a/packages/sdk/__tests__/generateSnapshotFromManifest.spec.ts b/packages/sdk/__tests__/generateSnapshotFromManifest.spec.ts index fe54d48ad6c..c1662388843 100644 --- a/packages/sdk/__tests__/generateSnapshotFromManifest.spec.ts +++ b/packages/sdk/__tests__/generateSnapshotFromManifest.spec.ts @@ -33,6 +33,18 @@ describe('generateSnapshotFromManifest', () => { expect(remoteSnapshot).toEqual(snapshot.devAppSnapshotWithVersion); }); + it('infers publicPath from manifest url when manifest publicPath is empty', () => { + const manifestWithEmptyPublicPath = JSON.parse( + JSON.stringify(manifest.devAppManifest), + ); + manifestWithEmptyPublicPath.metaData.publicPath = ''; + const remoteSnapshot = generateSnapshotFromManifest( + manifestWithEmptyPublicPath, + { version: 'http://localhost:2006/ssr/mf-manifest.json' }, + ); + expect(remoteSnapshot.publicPath).toBe('http://localhost:2006/ssr/'); + }); + it('return basic app snapshot with only manifest params in dev with getPublicPath', () => { const remoteSnapshot = generateSnapshotFromManifest( manifest.devAppManifestWithGetPublicPath, @@ -151,5 +163,6 @@ describe('generateSnapshotFromManifest', () => { {}, ); expect(remoteSnapshot).toEqual(snapshot.ssrProdAppSnapshotWithAllParams); + expect('ssrPublicPath' in remoteSnapshot).toBe(false); }); }); diff --git a/packages/sdk/__tests__/utils.spec.ts b/packages/sdk/__tests__/utils.spec.ts index fbaf96d0cc7..ed1c4e35c9c 100644 --- a/packages/sdk/__tests__/utils.spec.ts +++ b/packages/sdk/__tests__/utils.spec.ts @@ -49,6 +49,15 @@ describe('getResourceUrl', () => { expect(result).toBe('https://ssr.com/test.js'); }); + test('should fallback to publicPath when ssrPublicPath is undefined', () => { + const publicPath = 'https://public.com/'; + module = { publicPath, ssrPublicPath: undefined } as ModuleInfo; + (isBrowserEnv as jest.Mock).mockReturnValue(false); + (isReactNativeEnv as jest.Mock).mockReturnValue(false); + const result = getResourceUrl(module, sourceUrl); + expect(result).toBe('https://public.com/test.js'); + }); + test('should log warning and return empty string when no public path info', () => { module = {} as ModuleInfo; const consoleWarnSpy = jest.spyOn(console, 'warn'); diff --git a/packages/sdk/src/generateSnapshotFromManifest.ts b/packages/sdk/src/generateSnapshotFromManifest.ts index 053083f3026..87951e2105d 100644 --- a/packages/sdk/src/generateSnapshotFromManifest.ts +++ b/packages/sdk/src/generateSnapshotFromManifest.ts @@ -67,7 +67,11 @@ export function generateSnapshotFromManifest( const getPublicPath = (): string => { if ('publicPath' in manifest.metaData) { - if (manifest.metaData.publicPath === 'auto' && version) { + if ( + (manifest.metaData.publicPath === 'auto' || + manifest.metaData.publicPath === '') && + version + ) { // use same implementation as publicPath auto runtime module implements return inferAutoPublicPath(version); } @@ -178,8 +182,10 @@ export function generateSnapshotFromManifest( remoteSnapshot = { ...basicRemoteSnapshot, publicPath: getPublicPath(), - ssrPublicPath: manifest.metaData.ssrPublicPath, }; + if (typeof manifest.metaData.ssrPublicPath === 'string') { + remoteSnapshot.ssrPublicPath = manifest.metaData.ssrPublicPath; + } } else { remoteSnapshot = { ...basicRemoteSnapshot, diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index 1a3888c5b6d..eb49198b819 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -189,7 +189,12 @@ const getResourceUrl = (module: ModuleInfo, sourceUrl: string): string => { return `${publicPath}${sourceUrl}`; } else if ('publicPath' in module) { - if (!isBrowserEnv() && !isReactNativeEnv() && 'ssrPublicPath' in module) { + if ( + !isBrowserEnv() && + !isReactNativeEnv() && + 'ssrPublicPath' in module && + typeof module.ssrPublicPath === 'string' + ) { return `${module.ssrPublicPath}${sourceUrl}`; } return `${module.publicPath}${sourceUrl}`;