diff --git a/.changeset/composed-runtime-default-major.md b/.changeset/composed-runtime-default-major.md new file mode 100644 index 00000000000..b335595305c --- /dev/null +++ b/.changeset/composed-runtime-default-major.md @@ -0,0 +1,23 @@ +--- +'@module-federation/runtime-core': major +'@module-federation/runtime': major +'@module-federation/webpack-bundler-runtime': major +'@module-federation/enhanced': major +'@module-federation/rspack': major +'@module-federation/sdk': major +'@module-federation/node': major +'@module-federation/managers': major +--- + +The composed federation runtime is the default. `ModuleFederationPlugin` generates a bootstrap that imports only the runtime parts the build uses, and `experiments.composedRuntime` is removed. A build that cannot compose gets the define-free full-runtime bootstrap: an external runtime (`experiments.externalRuntime`, `provideExternalRuntime`), a runtime package in `externals` or in a user `resolve.alias`, or rspack without `experiments.VirtualModulesPlugin`. It warns, except for the two external-runtime experiments, which ask for the full runtime. + +Removed: + +- The `FEDERATION_OPTIMIZE_NO_SHARED`, `FEDERATION_OPTIMIZE_NO_REMOTE`, `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN`, `FEDERATION_HAS_EXPOSES`, and `FEDERATION_BUILD_IDENTIFIER` defines. The plugins no longer emit them and the runtime no longer reads them. Use `experiments.optimization.disable*` with the plugin, or import the capabilities you need from `@module-federation/runtime/compose` when you bundle the runtime yourself. +- The `@module-federation/runtime` namespace on `__webpack_require__.federation.runtime`, `federation.runtime.loadScriptNode`, and the named `runtime` export of `@module-federation/webpack-bundler-runtime` (so also of `@module-federation/runtime-tools/webpack-bundler-runtime`). `federation.runtime` keeps only `init`, because the native runtime of `@rspack/core` before 2.0.0-beta.1 calls `federation.runtime.init`. Use `__webpack_require__.federation.instance`, `instance.platform.loadScriptNode` for the Node loader, or import `@module-federation/runtime` directly. +- The fallback in `@module-federation/node` to `federation.runtime.loadScriptNode`. +- `resolveRspackRuntimeAlias` from `@module-federation/rspack/plugin`. The wrapper no longer aliases `@module-federation/runtime$`, so the helper has no use. + +`@module-federation/rspack` now needs `@rspack/core` 1.5 or newer, the first release with `experiments.VirtualModulesPlugin`. The wrapper still falls back to the full runtime, with a warning, when the plugin is missing. + +The build now fails when the runtime packages it resolves cannot compose. It fails when a runtime package cannot be resolved (` could not be resolved from `), when the resolved package has another name (` at is named ""`), or when an older release lacks the subpath exports: ` at does not export "": the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them (2.10.0)`. 2.10.0 is the release that adds the subpath exports. `ENV_TARGET`, `FEDERATION_ALLOW_NEW_FUNCTION`, and `FEDERATION_DEBUG` are unchanged. diff --git a/apps/website-new/docs/en/configure/experiments.mdx b/apps/website-new/docs/en/configure/experiments.mdx index 5c85dff1d08..333e6cf0134 100644 --- a/apps/website-new/docs/en/configure/experiments.mdx +++ b/apps/website-new/docs/en/configure/experiments.mdx @@ -53,7 +53,7 @@ This object contains flags related to build-time optimizations that can affect t - Required: No - Default: `false` -When set to `true`, this option defines the `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` global constant as `true` during the build. In the `@module-federation/runtime-core`, this prevents the `snapshotPlugin()` and `generatePreloadAssetsPlugin()` from being included and initialized within the Module Federation instance. +When set to `true`, the federation bootstrap that the plugin generates does not import the snapshot capability of `@module-federation/runtime-core`. The `snapshotPlugin()` and `generatePreloadAssetsPlugin()` are then not in the build and are not initialized within the Module Federation instance. **Impact:** diff --git a/apps/website-new/docs/en/guide/advanced/runtime-size-optimization.mdx b/apps/website-new/docs/en/guide/advanced/runtime-size-optimization.mdx index 75213ffe767..8c4dcc0674e 100644 --- a/apps/website-new/docs/en/guide/advanced/runtime-size-optimization.mdx +++ b/apps/website-new/docs/en/guide/advanced/runtime-size-optimization.mdx @@ -56,48 +56,26 @@ For Webpack, change the import to `@module-federation/enhanced/webpack`. This combination is suitable for a minimal producer that only exposes modules, does not consume other Remotes, does not use shared dependencies, and does not depend on Manifest / Snapshot data. Start from the defaults and enable only options that are safe for your build. -### Configure a runtime-only project with environment variables +### Compose the runtime in a project without ModuleFederationPlugin -If your project bundles `@module-federation/runtime` or `@module-federation/runtime-core` directly without `ModuleFederationPlugin`, use environment variables and replace them with build-time booleans in your bundler configuration. +If your project bundles the runtime directly without `ModuleFederationPlugin`, import only the capabilities it uses from `@module-federation/runtime/compose`. A capability that you do not import is not in the module graph, in any optimization mode. -For example, a project that does not consume Remotes, use shared dependencies or Snapshot data, and has no `exposes` can build with: +For example, a web host that only consumes Remotes: -```bash -FEDERATION_OPTIMIZE_NO_REMOTE=true \ -FEDERATION_OPTIMIZE_NO_SHARED=true \ -FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN=true \ -FEDERATION_HAS_EXPOSES=false \ -pnpm build -``` - -Webpack must replace the values explicitly: - -```ts title="webpack.config.ts" -import webpack from 'webpack'; - -const federationDefines = { - FEDERATION_OPTIMIZE_NO_REMOTE: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_REMOTE === 'true', - ), - FEDERATION_OPTIMIZE_NO_SHARED: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SHARED === 'true', - ), - FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN === 'true', - ), - FEDERATION_HAS_EXPOSES: JSON.stringify( - process.env.FEDERATION_HAS_EXPOSES !== 'false', - ), -}; +```ts +import { init } from '@module-federation/runtime/compose'; +import { remote } from '@module-federation/runtime-core/remote'; +import { web } from '@module-federation/runtime-core/platform/web'; -export default { - plugins: [new webpack.DefinePlugin(federationDefines)], -}; +const host = init({ name: 'host', remotes }, { remote, platform: web }); +await host.loadRemote('shop/Button'); ``` -With Rspack, pass the same `federationDefines` object to `rspack.DefinePlugin`. With Vite, pass it to the top-level `define` option. +Add `shared` from `@module-federation/runtime-core/shared` and `snapshot` from `@module-federation/runtime-core/snapshot` when the build needs them. `import { init } from '@module-federation/runtime'` still gives the full runtime. -Leaving environment variables as runtime strings is not enough. The bundler must replace them with `true` or `false` at build time so the minifier can remove the corresponding code. All capabilities remain enabled when no value is provided, preserving existing behavior. +:::info +The `FEDERATION_OPTIMIZE_NO_REMOTE`, `FEDERATION_OPTIMIZE_NO_SHARED`, `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN`, and `FEDERATION_HAS_EXPOSES` defines were removed. The runtime no longer reads them. +::: ## What each option removes diff --git a/apps/website-new/docs/pt-BR/configure/experiments.mdx b/apps/website-new/docs/pt-BR/configure/experiments.mdx index 1556fa1a6d8..6ca30ee5edc 100644 --- a/apps/website-new/docs/pt-BR/configure/experiments.mdx +++ b/apps/website-new/docs/pt-BR/configure/experiments.mdx @@ -53,7 +53,7 @@ Este objeto contains flags related para build-time optimizations esse pode affec - Obrigatório: No - Padrão: `false` -Quando definir para `true`, este opção defines o `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` global constant as `true` during o build. No `@module-federation/runtime-core`, este prevents o `snapshotPlugin()` e `generatePreloadAssetsPlugin()` de being included e initialized within o Module Federation instance. +Quando definida como `true`, o bootstrap de federation gerado pelo plugin não importa a capacidade de snapshot do `@module-federation/runtime-core`. Assim, `snapshotPlugin()` e `generatePreloadAssetsPlugin()` não entram no build e não são inicializados na instância do Module Federation. **Impact:** diff --git a/apps/website-new/docs/pt-BR/guide/advanced/runtime-size-optimization.mdx b/apps/website-new/docs/pt-BR/guide/advanced/runtime-size-optimization.mdx index 240ec0c983e..b307d249dd8 100644 --- a/apps/website-new/docs/pt-BR/guide/advanced/runtime-size-optimization.mdx +++ b/apps/website-new/docs/pt-BR/guide/advanced/runtime-size-optimization.mdx @@ -56,48 +56,26 @@ Para Webpack, altere o import para `@module-federation/enhanced/webpack`. Essa combinação é adequada para um produtor mínimo que apenas expõe módulos, não consome outros Remotes, não usa dependências compartilhadas e não depende de Manifest / Snapshot. Comece com os valores padrão e habilite somente opções que sejam seguras para o seu build. -### Configurar um projeto que usa apenas o runtime com variáveis de ambiente +### Compor o runtime em um projeto sem ModuleFederationPlugin -Se o projeto empacota `@module-federation/runtime` ou `@module-federation/runtime-core` diretamente, sem `ModuleFederationPlugin`, use variáveis de ambiente e substitua-as por valores booleanos durante o build na configuração do bundler. +Se o seu projeto empacota o runtime diretamente, sem `ModuleFederationPlugin`, importe de `@module-federation/runtime/compose` apenas as capacidades que ele usa. Uma capacidade que você não importa não entra no grafo de módulos, em nenhum modo de otimização. -Por exemplo, um projeto que não consome Remotes, não usa dependências compartilhadas nem Snapshot e não tem `exposes` pode usar: +Por exemplo, um host web que apenas consome Remotes: -```bash -FEDERATION_OPTIMIZE_NO_REMOTE=true \ -FEDERATION_OPTIMIZE_NO_SHARED=true \ -FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN=true \ -FEDERATION_HAS_EXPOSES=false \ -pnpm build -``` - -No Webpack, os valores devem ser substituídos explicitamente: - -```ts title="webpack.config.ts" -import webpack from 'webpack'; - -const federationDefines = { - FEDERATION_OPTIMIZE_NO_REMOTE: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_REMOTE === 'true', - ), - FEDERATION_OPTIMIZE_NO_SHARED: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SHARED === 'true', - ), - FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN === 'true', - ), - FEDERATION_HAS_EXPOSES: JSON.stringify( - process.env.FEDERATION_HAS_EXPOSES !== 'false', - ), -}; +```ts +import { init } from '@module-federation/runtime/compose'; +import { remote } from '@module-federation/runtime-core/remote'; +import { web } from '@module-federation/runtime-core/platform/web'; -export default { - plugins: [new webpack.DefinePlugin(federationDefines)], -}; +const host = init({ name: 'host', remotes }, { remote, platform: web }); +await host.loadRemote('shop/Button'); ``` -No Rspack, passe o mesmo objeto `federationDefines` para `rspack.DefinePlugin`. No Vite, passe-o para a opção `define` no nível principal. +Adicione `shared` de `@module-federation/runtime-core/shared` e `snapshot` de `@module-federation/runtime-core/snapshot` quando o build precisar deles. `import { init } from '@module-federation/runtime'` continua fornecendo o runtime completo. -Não basta deixar as variáveis de ambiente como strings no código executado. O bundler deve substituí-las por `true` ou `false` durante o build para que o minificador remova o código correspondente. Quando nenhum valor é fornecido, todos os recursos continuam habilitados, preservando o comportamento existente. +:::info +Os defines `FEDERATION_OPTIMIZE_NO_REMOTE`, `FEDERATION_OPTIMIZE_NO_SHARED`, `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` e `FEDERATION_HAS_EXPOSES` foram removidos. O runtime não os lê mais. +::: ## O que cada opção remove diff --git a/apps/website-new/docs/zh/configure/experiments.mdx b/apps/website-new/docs/zh/configure/experiments.mdx index dd4e9357cb3..4b56867656a 100644 --- a/apps/website-new/docs/zh/configure/experiments.mdx +++ b/apps/website-new/docs/zh/configure/experiments.mdx @@ -53,7 +53,7 @@ - 是否必填:否 - 默认值:`false` -当设置为 `true` 时,该选项会在构建期间将全局常量 `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` 定义为 `true`。在 `@module-federation/runtime-core` 中,这会阻止 `snapshotPlugin()` 与 `generatePreloadAssetsPlugin()` 被打包并在 Module Federation 实例中初始化。 +当设置为 `true` 时,插件生成的 federation 启动代码不会导入 `@module-federation/runtime-core` 的 snapshot 能力,因此 `snapshotPlugin()` 与 `generatePreloadAssetsPlugin()` 不会被打包,也不会在 Module Federation 实例中初始化。 **影响:** diff --git a/apps/website-new/docs/zh/guide/advanced/runtime-size-optimization.mdx b/apps/website-new/docs/zh/guide/advanced/runtime-size-optimization.mdx index d5eb498de2c..14950ed127a 100644 --- a/apps/website-new/docs/zh/guide/advanced/runtime-size-optimization.mdx +++ b/apps/website-new/docs/zh/guide/advanced/runtime-size-optimization.mdx @@ -56,48 +56,26 @@ export default { 上面的组合适用于一个非常精简的纯生产者:它只对外提供模块,不消费其他 Remote、不使用共享依赖,也不依赖 Manifest / Snapshot。实际项目应从默认值开始,只打开已经确认安全的选项。 -### 纯 Runtime 项目通过环境变量配置 +### 不使用 ModuleFederationPlugin 时按需组合 Runtime -如果项目直接打包 `@module-federation/runtime` 或 `@module-federation/runtime-core`,没有使用上面的 `ModuleFederationPlugin`,可以用环境变量控制构建,并在打包配置中把环境变量替换为编译期布尔值。 +如果项目不使用 `ModuleFederationPlugin`,而是直接打包 Runtime,请从 `@module-federation/runtime/compose` 只导入项目用到的能力。没有导入的能力不会进入模块图,与优化模式无关。 -例如,一个不消费 Remote、不使用共享依赖和 Snapshot,也不包含 `exposes` 的项目可以这样构建: +例如,一个只消费 Remote 的 Web Host: -```bash -FEDERATION_OPTIMIZE_NO_REMOTE=true \ -FEDERATION_OPTIMIZE_NO_SHARED=true \ -FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN=true \ -FEDERATION_HAS_EXPOSES=false \ -pnpm build -``` - -Webpack 配置需要显式完成替换: - -```ts title="webpack.config.ts" -import webpack from 'webpack'; - -const federationDefines = { - FEDERATION_OPTIMIZE_NO_REMOTE: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_REMOTE === 'true', - ), - FEDERATION_OPTIMIZE_NO_SHARED: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SHARED === 'true', - ), - FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: JSON.stringify( - process.env.FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN === 'true', - ), - FEDERATION_HAS_EXPOSES: JSON.stringify( - process.env.FEDERATION_HAS_EXPOSES !== 'false', - ), -}; +```ts +import { init } from '@module-federation/runtime/compose'; +import { remote } from '@module-federation/runtime-core/remote'; +import { web } from '@module-federation/runtime-core/platform/web'; -export default { - plugins: [new webpack.DefinePlugin(federationDefines)], -}; +const host = init({ name: 'host', remotes }, { remote, platform: web }); +await host.loadRemote('shop/Button'); ``` -Rspack 使用 `rspack.DefinePlugin` 传入同一份 `federationDefines`。Vite 可以把它传给顶层 `define` 配置。 +构建需要时,再从 `@module-federation/runtime-core/shared` 导入 `shared`,从 `@module-federation/runtime-core/snapshot` 导入 `snapshot`。`import { init } from '@module-federation/runtime'` 仍然提供完整的 Runtime。 -环境变量不能直接作为字符串留在运行时代码中,必须在构建时替换成 `true` 或 `false`,压缩工具才能删除对应代码。没有显式配置时,上述能力都会保持开启,兼容现有行为。 +:::info +`FEDERATION_OPTIMIZE_NO_REMOTE`、`FEDERATION_OPTIMIZE_NO_SHARED`、`FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` 和 `FEDERATION_HAS_EXPOSES` 已被移除,Runtime 不再读取它们。 +::: ## 每个开关会移除什么 diff --git a/arch-doc/architecture-overview.md b/arch-doc/architecture-overview.md index 32d1b7665ff..96439dcb93c 100644 --- a/arch-doc/architecture-overview.md +++ b/arch-doc/architecture-overview.md @@ -474,7 +474,6 @@ sequenceDiagram ```typescript // Pattern for any bundler interface BundlerRuntimeBridge { - runtime: typeof coreRuntime; // Core runtime reference instance?: ModuleFederation; // Runtime instance bundlerRuntime: { // Bundler-specific implementations remotes: (options) => Promise; @@ -726,7 +725,7 @@ graph TB subgraph "Snapshot Handler" SnapshotHandler[SnapshotHandler] - OptCheck["Optimization Check
FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN"] + OptCheck["Snapshot Capability
runtime-core/snapshot"] ManifestGen["Manifest Generation
generateSnapshotFromManifest"] end diff --git a/arch-doc/implementation-guide.md b/arch-doc/implementation-guide.md index 7d2cf61ea8d..be4bfffc7b1 100644 --- a/arch-doc/implementation-guide.md +++ b/arch-doc/implementation-guide.md @@ -657,13 +657,12 @@ export class FederationRuntimeModule extends RuntimeModule { return Template.asString([ `// Federation Runtime Module`, `var ${federationGlobal} = ${federationGlobal} || {};`, - `${federationGlobal}.runtime = __bundler_require__("${RUNTIME_PATH}");`, `${federationGlobal}.initOptions = ${JSON.stringify(this.initOptions)};`, // Initialize federation runtime `if (!${federationGlobal}.instance) {`, Template.indent([ - `${federationGlobal}.instance = ${federationGlobal}.runtime.init(${federationGlobal}.initOptions);` + `${federationGlobal}.instance = __bundler_require__("${RUNTIME_PATH}").init(${federationGlobal}.initOptions);` ]), `}`, diff --git a/arch-doc/runtime-architecture.md b/arch-doc/runtime-architecture.md index 1a3d15371d1..8580f8bb793 100644 --- a/arch-doc/runtime-architecture.md +++ b/arch-doc/runtime-architecture.md @@ -78,20 +78,19 @@ import { loadScript, loadScriptNode } from '@module-federation/sdk'; The foundation of the runtime system is the `ModuleFederation` class in `@module-federation/runtime-core`: -### Conditional Feature Inclusion +### Capabilities -The snapshot plugins (`snapshotPlugin()` and `generatePreloadAssetsPlugin()`) are conditionally registered based on the `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` build-time flag (the `SnapshotHandler` instance itself is always constructed): +`ModuleFederation` extends `FederationKernel`, which takes its handlers as capabilities: `shared`, `remote`, `snapshot`, and a `platform` loader. The public root constructs `ModuleFederation` with every capability and the universal platform. A generated bootstrap imports only the capabilities its build uses from the `@module-federation/runtime-core` subpaths and passes them to `@module-federation/runtime/compose`: ```typescript -// Declared in core.ts with DefinePlugin -declare const FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: boolean; -const USE_SNAPSHOT = - typeof FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN === 'boolean' - ? !FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN - : true; // Default to true (use snapshot) when not explicitly defined +import { init } from '@module-federation/runtime/compose'; +import { remote } from '@module-federation/runtime-core/remote'; +import { web } from '@module-federation/runtime-core/platform/web'; + +const host = init({ name: 'host', remotes }, { remote, platform: web }); ``` -When `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` is `true`, snapshot functionality is disabled for smaller bundle sizes. +A capability that is not passed gets a disabled handler. The snapshot plugins (`snapshotPlugin()` and `generatePreloadAssetsPlugin()`) are registered only with the `snapshot` capability, and `snapshot` needs `remote`. ```mermaid classDiagram @@ -154,7 +153,7 @@ classDiagram -getManifestJson(url, moduleInfo, extraOptions): Promise~Manifest~ } - note "Snapshot plugins are conditionally registered based on the FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN flag" + note "Snapshot plugins are registered only with the snapshot capability" ModuleFederation --> SharedHandler ModuleFederation --> RemoteHandler @@ -293,38 +292,28 @@ export function loadShare(...args: Parameters) ``` ### Build Identifier Integration -```typescript -// Build identifier support for instance resolution -export function getBuilderId(): string { - //@ts-ignore - return typeof FEDERATION_BUILD_IDENTIFIER !== 'undefined' - ? //@ts-ignore - FEDERATION_BUILD_IDENTIFIER - : ''; -} +The plugins pass `name:version` as `options.id` when a build has one `ModuleFederationPlugin`. The composed bootstrap passes it through `createFederation({ buildId })`, and the full-runtime bootstrap sets `initOptions.id`. The instance registers its share scope in `__FEDERATION__.__SHARE__` under that id. + +`init()` reuses the instance this bundle already created. Otherwise it looks for a page-global instance with the same name and version: + +```typescript export function getGlobalFederationInstance( name: string, version: string | undefined, + preferred?: ModuleFederation | null, ): ModuleFederation | undefined { - const buildId = getBuilderId(); - return CurrentGlobal.__FEDERATION__.__INSTANCES__.find((GMInstance) => { - // Priority 1: Build ID match (most specific) - if (buildId && GMInstance.options.id === buildId) { - return true; - } - - // Priority 2: Exact name match without version (both undefined) - if (GMInstance.options.name === name && !GMInstance.options.version && !version) { - return true; - } - - // Priority 3: Name + version exact match - if (GMInstance.options.name === name && version && GMInstance.options.version === version) { - return true; - } - return false; - }); + return [preferred, ...CurrentGlobal.__FEDERATION__.__INSTANCES__].find( + (GMInstance): GMInstance is ModuleFederation => { + if (!GMInstance) return false; + // Exact name match without version (both undefined) + if (GMInstance.options.name === name && !GMInstance.options.version && !version) { + return true; + } + // Name + version exact match + return Boolean(GMInstance.options.name === name && version && GMInstance.options.version === version); + }, + ); } ``` @@ -334,10 +323,7 @@ The `@module-federation/webpack-bundler-runtime` creates a bridge between webpac ### Federation Object Structure ```typescript -import * as runtime from '@module-federation/runtime'; - const federation: Federation = { - runtime, // Reference to convenience runtime instance: undefined, // Will hold the ModuleFederation instance initOptions: undefined, // Initialization options bundlerRuntime: { // Webpack-specific implementations diff --git a/arch-doc/runtime-loading-contract.md b/arch-doc/runtime-loading-contract.md index 492d9c732c2..c0b110c5a0d 100644 --- a/arch-doc/runtime-loading-contract.md +++ b/arch-doc/runtime-loading-contract.md @@ -241,7 +241,6 @@ export function isLegallyVersion(version: string): boolean { interface BundlerRuntimeIntegration { // 1. Create bundler-specific runtime bridge createBundlerRuntime(): { - runtime: typeof runtime; instance?: ModuleFederation; bundlerRuntime: { remotes: (options: BundlerRemoteOptions) => Promise; @@ -289,7 +288,7 @@ class ViteBundlerRuntime implements BundlerRuntimeIntegration { ### Build-Time Responsibilities The build-time layer handles: -- **DefinePlugin Integration**: Defines `FEDERATION_BUILD_IDENTIFIER` and `FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN` flags +- **Bootstrap Generation**: Generates a bootstrap that imports only the runtime capabilities and adapters the build uses, and passes the build id - **Bundle Generation**: Creates remote entry files and module manifests - **Static Analysis**: Determines shared dependencies and remote configurations - **Code Splitting**: Separates remote modules from host bundles @@ -306,13 +305,12 @@ The runtime layer handles: ### Critical Integration Points ```typescript -// Build-time defines these globals, runtime consumes them -declare const FEDERATION_BUILD_IDENTIFIER: string; -declare const FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: boolean; - -// Runtime uses build-time generated information -const buildId = getBuilderId(); // Reads FEDERATION_BUILD_IDENTIFIER -const useSnapshot = !FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN; // Feature flag +// The generated bootstrap passes the build's capabilities, adapters, and id +var federation = createFederation({ + buildId: 'host:1.0.0', + capabilities: { remote, snapshot, platform: web }, // no snapshot: no snapshot plugins + adapters: [remotes, shareScope], +}); // Build-time generates manifest, runtime consumes it const manifest = await fetch('./mf-manifest.json'); diff --git a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts index 4e55fb15692..47a57a188d6 100644 --- a/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts +++ b/packages/enhanced/src/lib/container/ModuleFederationPlugin.ts @@ -8,12 +8,10 @@ import { DtsPlugin } from '@module-federation/dts-plugin'; import { ContainerManager, optionsParticipant, - utils, } from '@module-federation/managers'; import { StatsPlugin } from '@module-federation/manifest'; import { bindLoggerToCompiler, - composeKeyWithSeparator, type moduleFederationPlugin, infrastructureLogger, } from '@module-federation/sdk'; @@ -27,7 +25,6 @@ import ContainerReferencePlugin from './ContainerReferencePlugin'; import FederationRuntimePlugin from './runtime/FederationRuntimePlugin'; import FederationCompositionPlugin, { COVERED_BY_OPTIONS, - composedEntryOf, } from './runtime/FederationCompositionPlugin'; import { RemoteEntryPlugin } from '@module-federation/rspack/remote-entry-plugin'; import StartupChunkDependenciesPlugin from '../startup/MfStartupChunkDependenciesPlugin'; @@ -105,45 +102,9 @@ class ModuleFederationPlugin implements WebpackPluginInstance { } private _patchBundlerConfig(compiler: Compiler): void { - const { name, experiments, exposes } = this._options; + const { experiments } = this._options; const definePluginOptions: Record = {}; - const MFPluginNum = compiler.options.plugins.filter( - (p): p is WebpackPluginInstance => - !!p && (p as any).name === 'ModuleFederationPlugin', - ).length; - - if (name && MFPluginNum < 2) { - definePluginOptions['FEDERATION_BUILD_IDENTIFIER'] = JSON.stringify( - composeKeyWithSeparator(name, utils.getBuildVersion()), - ); - } - - const disableSnapshot = experiments?.optimization?.disableSnapshot ?? false; - definePluginOptions['FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN'] = - disableSnapshot; - definePluginOptions['FEDERATION_OPTIMIZE_NO_REMOTE'] = - experiments?.optimization?.disableRemote ?? false; - definePluginOptions['FEDERATION_OPTIMIZE_NO_SHARED'] = - experiments?.optimization?.disableShared ?? false; - definePluginOptions['FEDERATION_HAS_EXPOSES'] = - hasExposes(exposes) || - compiler.options.plugins.some((plugin) => { - if (!plugin || typeof plugin !== 'object') { - return false; - } - - const namedPlugin = plugin as WebpackPluginInstance & { - name?: string; - _options?: moduleFederationPlugin.ModuleFederationPluginOptions; - }; - if (namedPlugin.name !== 'ModuleFederationPlugin') { - return false; - } - - return hasExposes(namedPlugin._options?.exposes); - }); - // Determine ENV_TARGET: only if manually specified in experiments.optimization.target if ( experiments?.optimization && @@ -163,16 +124,6 @@ class ModuleFederationPlugin implements WebpackPluginInstance { // No inference for ENV_TARGET. If not manually set and valid, it's not defined. new compiler.webpack.DefinePlugin(definePluginOptions).apply(compiler); - - if (experiments?.composedRuntime) { - // DefinePlugin reads its definitions per compilation, after the plan picked a mode. - compiler.hooks.compile.tap('ModuleFederationPlugin', () => { - if (!composedEntryOf(compiler)) return; - for (const key of Object.keys(definePluginOptions)) { - if (key !== 'ENV_TARGET') delete definePluginOptions[key]; - } - }); - } } /** diff --git a/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts index 6b791b68c50..26528cc4a36 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationCompositionPlugin.ts @@ -14,6 +14,7 @@ import { type GraphModule, type Participant, type RuntimeFamily, + type RuntimeMode, } from '@module-federation/managers'; import { composeKeyWithSeparator, @@ -36,8 +37,8 @@ export interface ComposedEntry { interface Outcome { family?: RuntimeFamily; + mode: RuntimeMode; entry?: ComposedEntry; - legacyReason?: string; } export interface CompositionSlot { @@ -57,6 +58,21 @@ function slotOf(compiler: Compiler): CompositionSlot { return (target[SLOT] ??= { participants: [], sealed: false }); } +/** The build id both bootstraps pass as options.id: only with one ModuleFederationPlugin. */ +export function buildIdOf( + compiler: Compiler, + { name }: { name?: string }, +): string | undefined { + const federationPlugins = compiler.options.plugins.filter( + (plugin) => + !!plugin && + (plugin as { name?: unknown }).name === 'ModuleFederationPlugin', + ).length; + return name && federationPlugins < 2 + ? composeKeyWithSeparator(name, utils.getBuildVersion()) + : undefined; +} + export const composedEntryOf = (compiler: Compiler) => (compiler as SlotCompiler)[SLOT]?.entry; @@ -114,8 +130,11 @@ class FederationCompositionPlugin { const plan = this._plan; if (!plan) { return { - legacyReason: - 'the federation plan never ran: ModuleFederationPlugin was applied after afterResolvers or to a child compiler', + mode: { + mode: 'legacy', + reason: + 'the federation plan never ran: ModuleFederationPlugin was applied after afterResolvers or to a child compiler', + }, }; } const family = resolveRuntimeFamily( @@ -128,36 +147,29 @@ class FederationCompositionPlugin { alias: compiler.options.resolve.alias as never, aliasExemptions: this._aliasTargets, }); - if (mode.mode === 'legacy') return { family, legacyReason: mode.reason }; + if (mode.mode !== 'composed') return { family, mode }; const composition = renderComposition( plan, resolveImports(plan, family), - this._buildId(compiler), + buildIdOf(compiler, this._options), ); slot.entry = { ...this._createEntry(composition), adapters: plan.adapters }; - return { family, entry: slot.entry }; - } - - private _buildId(compiler: Compiler): string | undefined { - const federationPlugins = compiler.options.plugins.filter( - (plugin) => - !!plugin && - (plugin as { name?: unknown }).name === 'ModuleFederationPlugin', - ).length; - const { name } = this._options; - return name && federationPlugins < 2 - ? composeKeyWithSeparator(name, utils.getBuildVersion()) - : undefined; + return { family, mode, entry: slot.entry }; } - private _check( - compilation: Compilation, - { family, entry, legacyReason }: Outcome, - ) { - if (legacyReason !== undefined) { + private _check(compilation: Compilation, { family, mode, entry }: Outcome) { + if (mode.mode === 'unsupported') { + compilation.errors.push( + new WebpackError( + `The federation runtime cannot be composed: ${mode.reason}.`, + ), + ); + return; + } + if (mode.mode === 'legacy' && !mode.requested) { compilation.warnings.push( new WebpackError( - `experiments.composedRuntime is set, but this build uses the full federation runtime because ${legacyReason}.`, + `This build uses the full federation runtime because ${mode.reason}.`, ), ); } diff --git a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts index 4d87ff85fc2..25f6072d9b1 100644 --- a/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts +++ b/packages/enhanced/src/lib/container/runtime/FederationRuntimePlugin.ts @@ -21,6 +21,7 @@ import FederationModulesPlugin from './FederationModulesPlugin'; import HoistContainerReferences from '../HoistContainerReferencesPlugin'; import FederationRuntimeDependency from './FederationRuntimeDependency'; import FederationCompositionPlugin, { + buildIdOf, composedEntryOf, } from './FederationCompositionPlugin'; @@ -188,9 +189,7 @@ class FederationRuntimePlugin { }); } const embedRuntimeLines = Template.asString([ - composition === undefined - ? `if(!${federationGlobal}.runtime || !${federationGlobal}.bundlerRuntime){` - : `if(!${federationGlobal}.bundlerRuntime){`, + `if(!${federationGlobal}.bundlerRuntime){`, Template.indent([ `var prevFederation = ${federationGlobal};`, `${federationGlobal} = {}`, @@ -204,6 +203,10 @@ class FederationRuntimePlugin { '}', ]); + // createFederation passes the build id to a composed bootstrap. + const buildId = + composition === undefined ? buildIdOf(compiler, options) : undefined; + return Template.asString([ composition ?? `import federation from '${normalizedBundlerRuntimePath}';`, @@ -211,6 +214,9 @@ class FederationRuntimePlugin { embedRuntimeLines, `if(!${federationGlobal}.instance){`, Template.indent([ + buildId + ? `${federationGlobal}.initOptions.id = ${federationGlobal}.initOptions.id || ${JSON.stringify(buildId)};` + : '', runtimePluginCalls.length ? Template.asString([ `var pluginsToAdd = [`, @@ -222,7 +228,6 @@ class FederationRuntimePlugin { `${federationGlobal}.initOptions.plugins.concat(pluginsToAdd) : pluginsToAdd;`, ]) : '', - // `${federationGlobal}.instance = ${federationGlobal}.runtime.init(${federationGlobal}.initOptions);`, `${federationGlobal}.instance = ${federationGlobal}.bundlerRuntime.init({webpackRequire:${RuntimeGlobals.require}});`, `if(${federationGlobal}.attachShareScopeMap){`, Template.indent([ @@ -529,7 +534,7 @@ class FederationRuntimePlugin { // dont run multiple times on every apply() if (!onceForCompiler.has(compiler)) { const options = this.options; - if (options?.experiments?.composedRuntime) { + if (options) { new FederationCompositionPlugin( options, (composition) => { diff --git a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts index e564c8b5591..bf811930a68 100644 --- a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts +++ b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.check.ts @@ -463,7 +463,6 @@ const t = { type: 'object', properties: { asyncStartup: { type: 'boolean' }, - composedRuntime: { type: 'boolean' }, externalRuntime: { type: 'boolean' }, provideExternalRuntime: { type: 'boolean' }, optimization: { @@ -3751,8 +3750,8 @@ function D( : u.push(e), c++); } - var R = i === c; - if (((o = o || R), !o)) { + var L = i === c; + if (((o = o || L), !o)) { const e = c; if (c === e) if ( @@ -3779,9 +3778,9 @@ function D( : u.push(e), c++); } - var L = e === c; - } else L = !0; - if (L) { + var E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.abortOnError @@ -3801,9 +3800,9 @@ function D( : u.push(e), c++); } - L = e === c; - } else L = !0; - if (L) { + E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.remoteTypesFolder @@ -3823,9 +3822,9 @@ function D( : u.push(e), c++); } - L = e === c; - } else L = !0; - if (L) { + E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.deleteTypesFolder @@ -3845,9 +3844,9 @@ function D( : u.push(e), c++); } - L = e === c; - } else L = !0; - if (L) { + E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.maxRetries @@ -3871,9 +3870,9 @@ function D( ), c++); } - L = e === c; - } else L = !0; - if (L) { + E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.consumeAPITypes @@ -3900,9 +3899,9 @@ function D( ), c++); } - L = e === c; - } else L = !0; - if (L) { + E = e === c; + } else E = !0; + if (E) { if ( void 0 !== r.runtimePkgs @@ -3978,11 +3977,11 @@ function D( ), c++); } - L = + E = t === c; } else - L = !0; - if (L) { + E = !0; + if (E) { if ( void 0 !== r.remoteTypeUrls @@ -4018,13 +4017,13 @@ function D( ), c++); } - var E = + var R = o === c; if ( ((s = s || - E), + R), !s) ) { const t = @@ -4280,12 +4279,12 @@ function D( ), c++); } - ((E = + ((R = t === c), (s = s || - E)); + R)); } if (s) ((c = @@ -4314,12 +4313,12 @@ function D( ), c++); } - L = + E = t === c; } else - L = !0; - if (L) { + E = !0; + if (E) { if ( void 0 !== r.timeout @@ -4348,13 +4347,13 @@ function D( ), c++); } - L = + E = e === c; } else - L = + E = !0; - if (L) { + if (E) { if ( void 0 !== r.family @@ -4397,13 +4396,13 @@ function D( ), c++); } - L = + E = n === c; } else - L = + E = !0; - if (L) + if (E) if ( void 0 !== r.typesOnBuild @@ -4432,11 +4431,11 @@ function D( ), c++); } - L = + E = e === c; } else - L = + E = !0; } } @@ -4458,8 +4457,8 @@ function D( : u.push(e), c++); } - ((R = e === c), - (o = o || R)); + ((L = e === c), + (o = o || L)); } if (o) ((c = s), @@ -4651,12 +4650,12 @@ function D( } else $ = !0; if ($) { if ( - void 0 !== e.composedRuntime + void 0 !== e.externalRuntime ) { const t = c; if ( 'boolean' != - typeof e.composedRuntime + typeof e.externalRuntime ) return ( (D.errors = [ @@ -4672,12 +4671,13 @@ function D( } else $ = !0; if ($) { if ( - void 0 !== e.externalRuntime + void 0 !== + e.provideExternalRuntime ) { const t = c; if ( 'boolean' != - typeof e.externalRuntime + typeof e.provideExternalRuntime ) return ( (D.errors = [ @@ -4691,83 +4691,82 @@ function D( ); $ = t === c; } else $ = !0; - if ($) { + if ($) if ( - void 0 !== - e.provideExternalRuntime + void 0 !== e.optimization ) { - const t = c; - if ( - 'boolean' != - typeof e.provideExternalRuntime - ) - return ( - (D.errors = [ - { - params: { - type: 'boolean', + let r = e.optimization; + const n = c; + if (c === n) { + if ( + !r || + 'object' != typeof r || + Array.isArray(r) + ) + return ( + (D.errors = [ + { + params: { + type: 'object', + }, }, - }, - ]), - !1 - ); - $ = t === c; - } else $ = !0; - if ($) - if ( - void 0 !== e.optimization - ) { - let r = e.optimization; - const n = c; - if (c === n) { - if ( - !r || - 'object' != - typeof r || - Array.isArray(r) - ) - return ( - (D.errors = [ - { - params: { - type: 'object', + ]), + !1 + ); + { + const e = c; + for (const e in r) + if ( + 'disableSnapshot' !== + e && + 'disableRemote' !== + e && + 'disableShared' !== + e && + 'target' !== e + ) + return ( + (D.errors = [ + { + params: { + additionalProperty: + e, + }, }, - }, - ]), - !1 - ); - { - const e = c; - for (const e in r) + ]), + !1 + ); + if (e === c) { + if ( + void 0 !== + r.disableSnapshot + ) { + const e = c; if ( - 'disableSnapshot' !== - e && - 'disableRemote' !== - e && - 'disableShared' !== - e && - 'target' !== e + 'boolean' != + typeof r.disableSnapshot ) return ( (D.errors = [ { params: { - additionalProperty: - e, + type: 'boolean', }, }, ]), !1 ); - if (e === c) { + var I = e === c; + } else I = !0; + if (I) { if ( void 0 !== - r.disableSnapshot + r.disableRemote ) { const e = c; if ( 'boolean' != - typeof r.disableSnapshot + typeof r.disableRemote ) return ( (D.errors = [ @@ -4779,17 +4778,17 @@ function D( ]), !1 ); - var I = e === c; + I = e === c; } else I = !0; if (I) { if ( void 0 !== - r.disableRemote + r.disableShared ) { const e = c; if ( 'boolean' != - typeof r.disableRemote + typeof r.disableShared ) return ( (D.errors = @@ -4805,15 +4804,18 @@ function D( ); I = e === c; } else I = !0; - if (I) { + if (I) if ( void 0 !== - r.disableShared + r.target ) { - const e = c; + let e = + r.target; + const n = c; if ( - 'boolean' != - typeof r.disableShared + 'web' !== + e && + 'node' !== e ) return ( (D.errors = @@ -4821,58 +4823,29 @@ function D( { params: { - type: 'boolean', + allowedValues: + t + .properties + .experiments + .properties + .optimization + .properties + .target + .enum, }, }, ]), !1 ); - I = e === c; + I = n === c; } else I = !0; - if (I) - if ( - void 0 !== - r.target - ) { - let e = - r.target; - const n = c; - if ( - 'web' !== - e && - 'node' !== - e - ) - return ( - (D.errors = - [ - { - params: - { - allowedValues: - t - .properties - .experiments - .properties - .optimization - .properties - .target - .enum, - }, - }, - ]), - !1 - ); - I = n === c; - } else I = !0; - } } } } } - $ = n === c; - } else $ = !0; - } + } + $ = n === c; + } else $ = !0; } } } diff --git a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json index 7482a90ce3e..42acc6b9ff2 100644 --- a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json +++ b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.json @@ -873,10 +873,6 @@ "description": "Enable async startup for the container", "type": "boolean" }, - "composedRuntime": { - "description": "Generate a federation bootstrap that imports only the runtime capabilities this build uses. Falls back to the full runtime when the installed runtime cannot be composed.", - "type": "boolean" - }, "externalRuntime": { "description": "Externalize '@module-federation/runtime-core' and expect global '_FEDERATION_RUNTIME_CORE' at runtime.", "type": "boolean" diff --git a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts index a931f8d9d26..0a101950abf 100644 --- a/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts +++ b/packages/enhanced/src/schemas/container/ModuleFederationPlugin.ts @@ -971,11 +971,6 @@ export default { description: 'Enable async startup for the container', type: 'boolean', }, - composedRuntime: { - description: - 'Generate a federation bootstrap that imports only the runtime capabilities this build uses. Falls back to the full runtime when the installed runtime cannot be composed.', - type: 'boolean', - }, externalRuntime: { description: "Externalize '@module-federation/runtime-core' and expect global '_FEDERATION_RUNTIME_CORE' at runtime.", diff --git a/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts b/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts index fb334ec9ced..11836b9ea20 100644 --- a/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts +++ b/packages/enhanced/test/compiler-unit/container/FederationCompositionPlugin.test.ts @@ -1,8 +1,11 @@ +import { execFile } from 'child_process'; import fs from 'fs'; import { createRequire } from 'module'; import os from 'os'; import path from 'path'; +import { promisify } from 'util'; import { normalizeWebpackPath } from '@module-federation/sdk/normalize-webpack-path'; +import { MIN_RUNTIME_VERSION } from '@module-federation/managers'; import ModuleFederationPlugin from '../../../src/lib/container/ModuleFederationPlugin'; import ContainerReferencePlugin from '../../../src/lib/container/ContainerReferencePlugin'; import { COVERED_BY_OPTIONS } from '../../../src/lib/container/runtime/FederationCompositionPlugin'; @@ -46,48 +49,48 @@ function fixture(files: Record) { function compile(context: string, config: Record) { const outputPath = path.join(context, `dist-${dirs.length}-${Math.random()}`); - return new Promise<{ stats: any; output: Record }>( - (resolve, reject) => { - webpack( - { - context, - mode: 'production', - devtool: false, - // Two builds of one checkout can otherwise concatenate around different roots. - parallelism: 1, - target: 'async-node', - entry: './index.js', - optimization: { minimize: false }, - output: { path: outputPath, uniqueName: 'composition-unit' }, - ...config, - }, - (err, stats) => { - if (err) return reject(err); - const output = {}; - const files = fs.existsSync(outputPath) - ? fs.readdirSync(outputPath) - : []; - for (const file of files.sort()) { - if (file.endsWith('.js')) - output[file] = fs.readFileSync( - path.join(outputPath, file), - 'utf8', - ); - } - resolve({ - stats: stats.toJson({ - all: false, - errors: true, - warnings: true, - modules: true, - nestedModules: true, - }), - output, - }); - }, - ); - }, - ); + return new Promise<{ + stats: any; + output: Record; + outputPath: string; + }>((resolve, reject) => { + webpack( + { + context, + mode: 'production', + devtool: false, + // Two builds of one checkout can otherwise concatenate around different roots. + parallelism: 1, + target: 'async-node', + entry: './index.js', + optimization: { minimize: false }, + output: { path: outputPath, uniqueName: 'composition-unit' }, + ...config, + }, + (err, stats) => { + if (err) return reject(err); + const output = {}; + const files = fs.existsSync(outputPath) + ? fs.readdirSync(outputPath) + : []; + for (const file of files.sort()) { + if (file.endsWith('.js')) + output[file] = fs.readFileSync(path.join(outputPath, file), 'utf8'); + } + resolve({ + stats: stats.toJson({ + all: false, + errors: true, + warnings: true, + modules: true, + nestedModules: true, + }), + output, + outputPath, + }); + }, + ); + }); } const moduleNames = (stats) => { @@ -121,26 +124,28 @@ describe('FederationCompositionPlugin', () => { ...extra, }); - it('selects the legacy bootstrap, unchanged, for an older runtime family', async () => { + it('fails the build for an older runtime family and names the minimum version', async () => { const context = fixture({ 'index.js': 'export default 1;' }); - const implementation = olderRuntimeTools(); - const off = await compile(context, { - plugins: [host(undefined, { implementation })], - }); - const on = await compile(context, { - plugins: [host({ composedRuntime: true }, { implementation })], + const { stats } = await compile(context, { + optimization: { minimize: false, emitOnErrors: true }, + plugins: [host(undefined, { implementation: olderRuntimeTools() })], }); - expect(messages(on.stats.errors)).toEqual([]); - expect(messages(on.stats.warnings)).toEqual([ - expect.stringMatching( - /composedRuntime is set, but this build uses the full federation runtime because @module-federation\/webpack-bundler-runtime at .* does not export "\.\/compose"/, + expect(messages(stats.errors)).toEqual([ + expect.stringContaining( + `does not export "./compose": the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them (${MIN_RUNTIME_VERSION})`, ), ]); - expect(moduleNames(on.stats).some((name) => COMPOSE.test(name))).toBe( - false, - ); - expect(on.output).toEqual(off.output); + }); + + it('keeps the full bootstrap without a warning for experiments.externalRuntime', async () => { + const context = fixture({ 'index.js': 'export default 1;' }); + const { stats } = await compile(context, { + plugins: [host({ externalRuntime: true })], + }); + + expect(messages(stats.warnings)).toEqual([]); + expect(moduleNames(stats).some((name) => COMPOSE.test(name))).toBe(false); }); it('selects legacy when a function external names runtime-core', async () => { @@ -152,7 +157,7 @@ describe('FederationCompositionPlugin', () => { ? callback(null, 'var {}') : callback(), ], - plugins: [host({ composedRuntime: true })], + plugins: [host()], }); expect(messages(stats.warnings)).toEqual([ @@ -174,7 +179,7 @@ describe('FederationCompositionPlugin', () => { ), }, }, - plugins: [host({ composedRuntime: true })], + plugins: [host()], }); expect(messages(stats.warnings)).toEqual([ @@ -185,25 +190,59 @@ describe('FederationCompositionPlugin', () => { expect(moduleNames(stats).some((name) => COMPOSE.test(name))).toBe(false); }); - it('emits ENV_TARGET but no capability or build-id define when composed', async () => { + it.each([ + ['the composed bootstrap', {}], + [ + 'the full bootstrap a user alias selects', + { + resolve: { + alias: { + '@module-federation/runtime$': path.join( + path.dirname(require.resolve('@module-federation/runtime')), + 'index.js', + ), + }, + }, + }, + ], + ])( + 'registers the share scope of %s under name:version', + async (_, config) => { + const previous = process.env['MF_BUILD_VERSION']; + process.env['MF_BUILD_VERSION'] = '9.9.9'; + try { + const context = fixture({ 'index.js': 'export default 1;' }); + const { stats, outputPath } = await compile(context, { + ...config, + plugins: [host()], + }); + expect(messages(stats.errors)).toEqual([]); + const { stdout } = await promisify(execFile)(process.execPath, [ + '-e', + `require(${JSON.stringify(path.join(outputPath, 'main.js'))}); + console.log(JSON.stringify(Object.keys(globalThis.__FEDERATION__.__SHARE__)));`, + ]); + expect(JSON.parse(stdout)).toEqual(['composition_host:9.9.9']); + } finally { + if (previous === undefined) delete process.env['MF_BUILD_VERSION']; + else process.env['MF_BUILD_VERSION'] = previous; + } + }, + ); + + it('emits ENV_TARGET but no capability or build-id define', async () => { const context = fixture({ 'index.js': 'export default [typeof FEDERATION_BUILD_IDENTIFIER, typeof FEDERATION_OPTIMIZE_NO_SHARED, ENV_TARGET];', }); - const composed = await compile(context, { - plugins: [ - host({ composedRuntime: true, optimization: { target: 'web' } }), - ], - }); - const legacy = await compile(context, { + const { stats, output } = await compile(context, { plugins: [host({ optimization: { target: 'web' } })], }); - expect(messages(composed.stats.errors)).toEqual([]); - expect(composed.output['main.js']).toContain( + expect(messages(stats.errors)).toEqual([]); + expect(output['main.js']).toContain( '[typeof FEDERATION_BUILD_IDENTIFIER, typeof FEDERATION_OPTIMIZE_NO_SHARED, "web"]', ); - expect(legacy.output['main.js']).toContain('["string", "boolean", "web"]'); }); it('plans once across two copies of enhanced', async () => { @@ -216,7 +255,6 @@ describe('FederationCompositionPlugin', () => { exposes: { './index': './index.js' }, dts: false, manifest: false, - experiments: { composedRuntime: true }, }), new OtherContainerReferencePlugin({ remoteType: 'script', @@ -240,7 +278,7 @@ describe('FederationCompositionPlugin', () => { }); const { stats } = await compile(context, { plugins: [ - host({ composedRuntime: true }), + host(), { apply(compiler) { new compiler.webpack.ExternalsPlugin('global', { @@ -269,7 +307,6 @@ describe('FederationCompositionPlugin', () => { exposes: { './index': './index.js' }, dts: false, manifest: false, - experiments: { composedRuntime: true }, }), // Claims ModuleFederationPlugin covers it, so nothing registers the remotes need. new ContainerReferencePlugin( @@ -296,7 +333,7 @@ describe('FederationCompositionPlugin', () => { { apply(compiler) { compiler.hooks.afterResolvers.tap('Late', () => - host({ composedRuntime: true }).apply(compiler), + host().apply(compiler), ); }, }, @@ -305,7 +342,7 @@ describe('FederationCompositionPlugin', () => { expect(messages(stats.warnings)).toEqual([ expect.stringMatching( - /composedRuntime is set, but this build uses the full federation runtime because the federation plan never ran/, + /^This build uses the full federation runtime because the federation plan never ran/, ), ]); }); @@ -329,7 +366,7 @@ describe('FederationCompositionPlugin', () => { }; await compile(context, { - plugins: [host({ composedRuntime: true }), late], + plugins: [host(), late], }); expect(error?.message).toMatch( @@ -350,18 +387,15 @@ describe('FederationCompositionPlugin', () => { }); const { stats } = await compile(context, { plugins: [ - host( - { composedRuntime: true }, - { - library: { type: 'commonjs-module', name: 'composition_host' }, - shared: { - 'ui-lib': { - requiredVersion: '*', - treeShaking: { mode: 'runtime-infer' }, - }, + host(undefined, { + library: { type: 'commonjs-module', name: 'composition_host' }, + shared: { + 'ui-lib': { + requiredVersion: '*', + treeShaking: { mode: 'runtime-infer' }, }, }, - ), + }), ], }); diff --git a/packages/enhanced/test/configCases/composed-runtime/after-plugins/webpack.config.js b/packages/enhanced/test/configCases/composed-runtime/after-plugins/webpack.config.js index 8627954013f..e6e6d2fc23a 100644 --- a/packages/enhanced/test/configCases/composed-runtime/after-plugins/webpack.config.js +++ b/packages/enhanced/test/configCases/composed-runtime/after-plugins/webpack.config.js @@ -23,7 +23,6 @@ module.exports = { filename: 'container.js', library: { type: 'commonjs-module' }, exposes: { './Button': './Button' }, - experiments: { composedRuntime: true }, }), ], }; diff --git a/packages/enhanced/test/configCases/composed-runtime/all-off/webpack.config.js b/packages/enhanced/test/configCases/composed-runtime/all-off/webpack.config.js index 0476fc63dd4..4052ea29f63 100644 --- a/packages/enhanced/test/configCases/composed-runtime/all-off/webpack.config.js +++ b/packages/enhanced/test/configCases/composed-runtime/all-off/webpack.config.js @@ -6,7 +6,6 @@ module.exports = { new ModuleFederationPlugin({ name: 'composed_all_off', experiments: { - composedRuntime: true, optimization: { disableRemote: true, disableShared: true, diff --git a/packages/enhanced/test/configCases/composed-runtime/default/webpack.config.js b/packages/enhanced/test/configCases/composed-runtime/default/webpack.config.js index cdbb49e7819..d307efbd991 100644 --- a/packages/enhanced/test/configCases/composed-runtime/default/webpack.config.js +++ b/packages/enhanced/test/configCases/composed-runtime/default/webpack.config.js @@ -19,7 +19,6 @@ module.exports = { requiredVersion: '^1.0.0', }, }, - experiments: { composedRuntime: true }, }), ], }; diff --git a/packages/enhanced/test/configCases/composed-runtime/exposes-only/webpack.config.js b/packages/enhanced/test/configCases/composed-runtime/exposes-only/webpack.config.js index 154917cb3cb..1ad66964cf3 100644 --- a/packages/enhanced/test/configCases/composed-runtime/exposes-only/webpack.config.js +++ b/packages/enhanced/test/configCases/composed-runtime/exposes-only/webpack.config.js @@ -14,7 +14,6 @@ module.exports = [ library: { type: 'commonjs-module' }, exposes: { './Button': './Button' }, experiments: { - composedRuntime: true, optimization: { disableRemote: true, disableShared: true }, }, }), @@ -27,7 +26,6 @@ module.exports = [ name: 'exposes_only_host', library: { type: 'commonjs-module' }, remotes: { remote: './remote/remoteEntry.js' }, - experiments: { composedRuntime: true }, }), ], }, diff --git a/packages/enhanced/test/configCases/composed-runtime/remotes-only/webpack.config.js b/packages/enhanced/test/configCases/composed-runtime/remotes-only/webpack.config.js index 066f62df0f7..d1a062ee97c 100644 --- a/packages/enhanced/test/configCases/composed-runtime/remotes-only/webpack.config.js +++ b/packages/enhanced/test/configCases/composed-runtime/remotes-only/webpack.config.js @@ -24,7 +24,6 @@ module.exports = [ library: { type: 'commonjs-module' }, remotes: { remote: './remote/remoteEntry.js' }, experiments: { - composedRuntime: true, optimization: { disableShared: true, disableSnapshot: true, diff --git a/packages/enhanced/test/configCases/composed-runtime/shared-only/webpack.config.js b/packages/enhanced/test/configCases/composed-runtime/shared-only/webpack.config.js index 76b10748406..019601e0853 100644 --- a/packages/enhanced/test/configCases/composed-runtime/shared-only/webpack.config.js +++ b/packages/enhanced/test/configCases/composed-runtime/shared-only/webpack.config.js @@ -13,7 +13,6 @@ module.exports = { }, }, experiments: { - composedRuntime: true, optimization: { disableRemote: true, disableSnapshot: true, diff --git a/packages/enhanced/test/configCases/composed-runtime/standalone-share-plugin/webpack.config.js b/packages/enhanced/test/configCases/composed-runtime/standalone-share-plugin/webpack.config.js index 4a43fba6c67..8e34f0a2412 100644 --- a/packages/enhanced/test/configCases/composed-runtime/standalone-share-plugin/webpack.config.js +++ b/packages/enhanced/test/configCases/composed-runtime/standalone-share-plugin/webpack.config.js @@ -9,7 +9,6 @@ module.exports = { library: { type: 'commonjs-module' }, exposes: { './Button': './Button' }, remotes: { self: './container.js' }, - experiments: { composedRuntime: true }, }), new SharePlugin({ shared: { diff --git a/packages/enhanced/test/configCases/container/experiments-optimization/index.js b/packages/enhanced/test/configCases/container/experiments-optimization/index.js index e9679a5fa3a..a41b32b7c0e 100644 --- a/packages/enhanced/test/configCases/container/experiments-optimization/index.js +++ b/packages/enhanced/test/configCases/container/experiments-optimization/index.js @@ -77,29 +77,37 @@ if (!globalThis.__EXPERIMENTS_OPTIMIZATION_CASE__) { expect(nodeRemoteEntryEsm).toContain('attrs:{name'); }); - it('should eliminate the complete remote consumption path', () => { + it('should eliminate the remote handler but keep the remotes adapter its remote modules call', () => { expect(noRemote.length).toBeLessThan(fullCapabilities.length); expect(fullCapabilities).toContain('availableExposes'); expect(fullCapabilities).toContain('mf_module_id'); - expect(fullCapabilities).toContain('Container missing'); expect(noRemote).not.toContain('availableExposes'); expect(noRemote).not.toContain('mf_module_id'); - expect(noRemote).not.toContain('Container missing'); + expect(noRemote).toContain('Container missing'); }); - it('should eliminate the complete shared consumption path', () => { - const sharedRuntimeMarkers = [ + it('should eliminate the shared handler but keep the share scope its container and remotes initialize', () => { + const sharedHandlerMarkers = ['afterRegisterShare', 'errorLoadShare']; + const shareScopeMarkers = [ 'Initialization of sharing external failed', 'Shared module is not available for eager consumption', - 'No fallback item found for shareKey', - 'tree-shake-plugin', ]; expect(noShared.length).toBeLessThan(fullCapabilities.length); - sharedRuntimeMarkers.forEach((marker) => { + sharedHandlerMarkers.forEach((marker) => { expect(fullCapabilities).toContain(marker); expect(noShared).not.toContain(marker); }); + shareScopeMarkers.forEach((marker) => { + expect(noShared).toContain(marker); + }); + // No build here configures shared, so no plan includes the consumes adapter. + ['No fallback item found for shareKey', 'tree-shake-plugin'].forEach( + (marker) => { + expect(fullCapabilities).not.toContain(marker); + expect(noShared).not.toContain(marker); + }, + ); }); it('should eliminate container initialization from the consumer entry without exposes', () => { diff --git a/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts b/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts index c3cc2ca7f1f..8891d6fb109 100644 --- a/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts +++ b/packages/enhanced/test/unit/container/FederationRuntimePlugin.test.ts @@ -22,6 +22,7 @@ describe('FederationRuntimePlugin runtimePluginCalls', () => { compiler = { options: { context: '/test/path', + plugins: [], }, hooks: { thisCompilation: { @@ -192,7 +193,7 @@ describe('FederationRuntimePlugin runtimePluginCalls', () => { }); describe('runtime bootstrap guards', () => { - it('rehydrates bundler runtime when runtime exists but bundlerRuntime is missing', () => { + it('rehydrates federation only when bundlerRuntime is missing', () => { const template = FederationRuntimePlugin.getTemplate( compiler as Compiler, mockOptions, @@ -200,8 +201,9 @@ describe('FederationRuntimePlugin runtimePluginCalls', () => { ); expect(template).toContain( - 'if(!__webpack_require__.federation.runtime || !__webpack_require__.federation.bundlerRuntime)', + 'if(!__webpack_require__.federation.bundlerRuntime){', ); + expect(template).not.toContain('__webpack_require__.federation.runtime'); }); }); diff --git a/packages/enhanced/test/unit/container/ModuleFederationPlugin.defines.test.ts b/packages/enhanced/test/unit/container/ModuleFederationPlugin.defines.test.ts new file mode 100644 index 00000000000..2ab549b5400 --- /dev/null +++ b/packages/enhanced/test/unit/container/ModuleFederationPlugin.defines.test.ts @@ -0,0 +1,49 @@ +/* + * @rstest-environment node + */ +import ModuleFederationPlugin from '../../../src/lib/container/ModuleFederationPlugin'; + +type Options = ConstructorParameters[0]; + +function definesFor(options: Options) { + const definitions: Record[] = []; + class DefinePlugin { + constructor(value: Record) { + definitions.push(value); + } + apply() {} + } + const compiler = { + options: { plugins: [] }, + webpack: { DefinePlugin }, + }; + ( + new ModuleFederationPlugin(options) as unknown as { + _patchBundlerConfig(compiler: unknown): void; + } + )._patchBundlerConfig(compiler); + return Object.assign({}, ...definitions); +} + +describe('ModuleFederationPlugin defines', () => { + it('defines only ENV_TARGET, from experiments.optimization.target', () => { + expect( + definesFor({ + name: 'host', + exposes: { './Button': './Button' }, + experiments: { + optimization: { + target: 'web', + disableRemote: true, + disableShared: true, + disableSnapshot: true, + }, + }, + }), + ).toEqual({ ENV_TARGET: '"web"' }); + }); + + it('defines nothing without a target', () => { + expect(definesFor({ name: 'host' })).toEqual({}); + }); +}); diff --git a/packages/esbuild/global.d.ts b/packages/esbuild/global.d.ts index 7eff77bc2bc..8012a54f427 100644 --- a/packages/esbuild/global.d.ts +++ b/packages/esbuild/global.d.ts @@ -1,4 +1,3 @@ declare const __VERSION__: string; declare const FEDERATION_DEBUG: string; -declare const FEDERATION_BUILD_IDENTIFIER: string | undefined; declare const __RELEASE_NUMBER__: number; diff --git a/packages/managers/__tests__/composition/selectMode.spec.ts b/packages/managers/__tests__/composition/selectMode.spec.ts index 82146421e8c..d33262b4081 100644 --- a/packages/managers/__tests__/composition/selectMode.spec.ts +++ b/packages/managers/__tests__/composition/selectMode.spec.ts @@ -3,7 +3,10 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { resolveRuntimeFamily } from '../../src/composition/family'; +import { + MIN_RUNTIME_VERSION, + resolveRuntimeFamily, +} from '../../src/composition/family'; import { selectMode, type ModeInputs } from '../../src/composition/selectMode'; import { composableFamily, packageDir, tempDir } from './fixtures'; @@ -13,13 +16,17 @@ const legacyReason = async (inputs: ModeInputs, family = composable()) => { const mode = await selectMode(family, inputs); return mode.mode === 'legacy' ? mode.reason : undefined; }; +const unsupportedReason = async (family: ReturnType) => { + const mode = await selectMode(family, {}); + return mode.mode === 'unsupported' ? mode.reason : undefined; +}; describe('selectMode', () => { it('composes a family that exports every required subpath', async () => { expect(await selectMode(composable(), {})).toEqual({ mode: 'composed' }); }); - it('selects legacy for the older runtime-tools in the pnpm store', async () => { + it('rejects the older runtime-tools in the pnpm store and names the minimum version', async () => { const store = path.resolve(__dirname, '../../../../node_modules/.pnpm'); const [older] = fs .readdirSync(store) @@ -28,8 +35,7 @@ describe('selectMode', () => { ); expect(older).toBeDefined(); - const reason = await legacyReason( - {}, + const reason = await unsupportedReason( resolveRuntimeFamily( path.join( store, @@ -42,6 +48,10 @@ describe('selectMode', () => { expect(reason).toMatch( /@module-federation\/webpack-bundler-runtime at .* does not export "\.\/compose"/, ); + expect(reason).toContain( + `the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them (${MIN_RUNTIME_VERSION})`, + ); + expect(reason).not.toContain('or newer'); }); it('does not count a "./*" pattern as the required key', async () => { @@ -50,33 +60,35 @@ describe('selectMode', () => { exports: { '.': './dist/index.js', './*': './dist/*.js' }, }, }); - expect(await legacyReason({}, resolveRuntimeFamily(root))).toMatch( + expect(await unsupportedReason(resolveRuntimeFamily(root))).toMatch( /runtime-core at .* does not export "\.\/kernel"/, ); }); - it('selects legacy when a member has another name', async () => { + it('rejects a member with another name', async () => { const root = composableFamily(tempDir(), { '@module-federation/runtime-core': { name: 'runtime-core-fork' }, }); - expect(await legacyReason({}, resolveRuntimeFamily(root))).toMatch( - /is named "runtime-core-fork"/, - ); + const reason = await unsupportedReason(resolveRuntimeFamily(root)); + expect(reason).toMatch(/is named "runtime-core-fork"/); + expect(reason).not.toContain(MIN_RUNTIME_VERSION); }); - it('selects legacy when a member does not resolve', async () => { + it('rejects a member that does not resolve', async () => { const root = composableFamily(tempDir()); fs.rmSync(packageDir(root, '@module-federation/sdk'), { recursive: true }); - expect(await legacyReason({}, resolveRuntimeFamily(root))).toMatch( + const reason = await unsupportedReason(resolveRuntimeFamily(root)); + expect(reason).toMatch( /@module-federation\/sdk could not be resolved from/, ); + expect(reason).not.toContain(MIN_RUNTIME_VERSION); }); - it('selects legacy for an exports string with no subpath keys', async () => { + it('rejects an exports string with no subpath keys', async () => { const root = composableFamily(tempDir(), { '@module-federation/runtime': { exports: './dist/index.js' as never }, }); - expect(await legacyReason({}, resolveRuntimeFamily(root))).toMatch( + expect(await unsupportedReason(resolveRuntimeFamily(root))).toMatch( /runtime at .* does not export "\.\/compose"/, ); }); @@ -85,7 +97,11 @@ describe('selectMode', () => { ['externalRuntime', { externalRuntime: true }], ['provideExternalRuntime', { provideExternalRuntime: true }], ])('selects legacy on experiments.%s', async (_, experiments) => { - expect(await legacyReason({ experiments })).toMatch(/experiments\./); + expect(await selectMode(composable(), { experiments })).toEqual({ + mode: 'legacy', + reason: expect.stringMatching(/experiments\./), + requested: true, + }); }); describe('externals', () => { diff --git a/packages/managers/src/composition/checkFederationGraph.ts b/packages/managers/src/composition/checkFederationGraph.ts index dd9280e1b9f..2ce20153fc7 100644 --- a/packages/managers/src/composition/checkFederationGraph.ts +++ b/packages/managers/src/composition/checkFederationGraph.ts @@ -63,7 +63,7 @@ export function checkFederationGraph( for (const request of [...new Set(summary.externalUserRequests)].sort()) { if (familyPackageOf(request)) { errors.push( - `"${request}" is external, but the composed federation bootstrap imports the runtime. Remove it from externals or disable experiments.composedRuntime.`, + `"${request}" is external, but the composed federation bootstrap imports the runtime. Remove it from externals, or set experiments.externalRuntime to externalize the whole runtime.`, ); } } diff --git a/packages/managers/src/composition/family.ts b/packages/managers/src/composition/family.ts index 8fb35dab632..7965e583f25 100644 --- a/packages/managers/src/composition/family.ts +++ b/packages/managers/src/composition/family.ts @@ -3,6 +3,10 @@ import path from 'node:path'; import enhancedResolve from 'enhanced-resolve'; import { findPackageJson } from '../findPackageJson'; +/** The first runtime release that exports every subpath in RUNTIME_FAMILY. */ +// Set to the actual minor that ships the subpath exports when it is released. +export const MIN_RUNTIME_VERSION = '2.10.0'; + export const RUNTIME_FAMILY = { '@module-federation/runtime-tools': [], '@module-federation/webpack-bundler-runtime': [ diff --git a/packages/managers/src/composition/selectMode.ts b/packages/managers/src/composition/selectMode.ts index bd75c764cbe..fe2a047e4a2 100644 --- a/packages/managers/src/composition/selectMode.ts +++ b/packages/managers/src/composition/selectMode.ts @@ -1,4 +1,9 @@ -import { FAMILY_PACKAGES, RUNTIME_FAMILY, type RuntimeFamily } from './family'; +import { + FAMILY_PACKAGES, + MIN_RUNTIME_VERSION, + RUNTIME_FAMILY, + type RuntimeFamily, +} from './family'; type ExternalCallback = (err?: Error | null, value?: unknown) => void; type ExternalFunction = (...args: any[]) => unknown; @@ -25,7 +30,13 @@ export interface ModeInputs { export type RuntimeMode = | { mode: 'composed' } - | { mode: 'legacy'; reason: string }; + | { + mode: 'legacy'; + reason: string; + /** Set when experiments.externalRuntime or provideExternalRuntime asks for the full runtime. */ + requested?: true; + } + | { mode: 'unsupported'; reason: string }; const hasOwn = (object: object, key: string) => Object.prototype.hasOwnProperty.call(object, key); @@ -34,9 +45,13 @@ export async function selectMode( family: RuntimeFamily, inputs: ModeInputs, ): Promise { + const problem = familyProblem(family); + if (problem !== undefined) return { mode: 'unsupported', reason: problem }; + const requested = experimentProblem(inputs); + if (requested !== undefined) { + return { mode: 'legacy', reason: requested, requested: true }; + } const reason = - familyProblem(family) ?? - experimentProblem(inputs) ?? (await externalsProblem(inputs)) ?? aliasProblem(inputs) ?? (inputs.virtualModulesPlugin === false @@ -58,7 +73,9 @@ function familyProblem({ anchor, members }: RuntimeFamily): string | undefined { const missing = RUNTIME_FAMILY[pkg].find( (key) => !declaresExportKey(member.exports, key), ); - if (missing) return `${pkg} at ${member.root} does not export "${missing}"`; + if (missing) { + return `${pkg} at ${member.root} does not export "${missing}": the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them (${MIN_RUNTIME_VERSION})`; + } from = member.root; } return undefined; diff --git a/packages/managers/src/index.ts b/packages/managers/src/index.ts index 26b5dfd9106..26f426201b5 100644 --- a/packages/managers/src/index.ts +++ b/packages/managers/src/index.ts @@ -22,6 +22,7 @@ export type { } from './composition/plan'; export { FAMILY_PACKAGES, + MIN_RUNTIME_VERSION, RUNTIME_FAMILY, resolveRuntimeFamily, } from './composition/family'; diff --git a/packages/node/src/__tests__/runtimePlugin.test.ts b/packages/node/src/__tests__/runtimePlugin.test.ts index d0543c580ff..28b01fd02ef 100644 --- a/packages/node/src/__tests__/runtimePlugin.test.ts +++ b/packages/node/src/__tests__/runtimePlugin.test.ts @@ -52,10 +52,10 @@ const mockWebpackRequire = { o: jest.fn(), l: jest.fn(), federation: { - runtime: { - loadScriptNode: jest.fn().mockResolvedValue({}), - }, instance: { + platform: { + loadScriptNode: jest.fn().mockResolvedValue({}), + }, initRawContainer: jest.fn().mockReturnValue({}), }, chunkMatcher: jest.fn().mockReturnValue(true), @@ -788,20 +788,18 @@ describe('runtimePlugin', () => { ); expect( - (global as any).__webpack_require__.federation.runtime.loadScriptNode, + (global as any).__webpack_require__.federation.instance.platform + .loadScriptNode, ).toHaveBeenCalledWith('http://localhost:3001/remoteEntry.js', { attrs: { globalName: 'test-remote' }, }); }); - it('loads through the instance platform when it has loadScriptNode', async () => { + it('loads through the instance platform', async () => { const federation = (global as any).__webpack_require__.federation; const calls: unknown[][] = []; const container = { get: jest.fn() }; - const legacyLoader = jest.fn(); const originalInstance = federation.instance; - const originalRuntime = federation.runtime; - federation.runtime = { loadScriptNode: legacyLoader }; federation.instance = { platform: { loadScriptNode: async (...args: unknown[]) => { @@ -830,28 +828,20 @@ describe('runtimePlugin', () => { { attrs: { globalName: 'platform-remote' } }, ], ]); - expect(legacyLoader).not.toHaveBeenCalled(); expect(done).toHaveBeenCalledWith({ wrapped: container }); } finally { federation.instance = originalInstance; - federation.runtime = originalRuntime; delete (globalThis as any)['platform-remote']; } }); - it('falls back to federation.runtime.loadScriptNode without a platform loader', async () => { + it('fails the load when the instance platform has no Node loader', async () => { const federation = (global as any).__webpack_require__.federation; - const container = { get: jest.fn() }; const originalInstance = federation.instance; - const originalRuntime = federation.runtime; - federation.runtime = { - loadScriptNode: jest.fn().mockResolvedValue(container), - }; + federation.runtime = { loadScriptNode: jest.fn() }; federation.instance = { - platform: { isBrowser: () => false }, - initRawContainer: (_name: string, _url: string, res: unknown) => ({ - wrapped: res, - }), + platform: { isBrowser: () => true }, + initRawContainer: jest.fn(), }; try { setupScriptLoader(); @@ -859,28 +849,28 @@ describe('runtimePlugin', () => { (global as any).__webpack_require__.l( 'http://localhost:3001/remoteEntry.js', done, - 'legacy-remote', + 'web-remote', '', ); await new Promise(process.nextTick); - expect(federation.runtime.loadScriptNode).toHaveBeenCalledWith( - 'http://localhost:3001/remoteEntry.js', - { attrs: { globalName: 'legacy-remote' } }, + expect(federation.runtime.loadScriptNode).not.toHaveBeenCalled(); + expect(done).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringMatching( + /needs a node or universal platform/, + ), + }), ); - expect(done).toHaveBeenCalledWith({ wrapped: container }); } finally { federation.instance = originalInstance; - federation.runtime = originalRuntime; - delete (globalThis as any)['legacy-remote']; + delete federation.runtime; } }); it('routes a missing instance error to the loader callback', async () => { const federation = (global as any).__webpack_require__.federation; const originalInstance = federation.instance; - const originalRuntime = federation.runtime; - federation.runtime = { loadScriptNode: jest.fn().mockResolvedValue({}) }; federation.instance = undefined; try { setupScriptLoader(); @@ -895,10 +885,10 @@ describe('runtimePlugin', () => { ).not.toThrow(); await new Promise(process.nextTick); - expect(done).toHaveBeenCalledWith(expect.any(TypeError)); + expect(done).toHaveBeenCalledWith(expect.any(Error)); + expect(done.mock.calls[0][0].message).toMatch(/no federation instance/); } finally { federation.instance = originalInstance; - federation.runtime = originalRuntime; } }); @@ -1060,10 +1050,10 @@ describe('runtimePlugin', () => { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, - runtime: { - loadScriptNode: jest.fn().mockResolvedValue({}), - }, instance: { + platform: { + loadScriptNode: jest.fn().mockResolvedValue({}), + }, initRawContainer: jest.fn().mockReturnValue({}), }, }, @@ -1136,10 +1126,10 @@ describe('runtimePlugin', () => { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, - runtime: { - loadScriptNode: jest.fn().mockResolvedValue({}), - }, instance: { + platform: { + loadScriptNode: jest.fn().mockResolvedValue({}), + }, initRawContainer: jest.fn().mockReturnValue({}), }, }, @@ -1176,7 +1166,8 @@ describe('runtimePlugin', () => { ); expect( - (global as any).__webpack_require__.federation.runtime.loadScriptNode, + (global as any).__webpack_require__.federation.instance.platform + .loadScriptNode, ).toHaveBeenCalledWith('http://localhost:3001/remoteEntry.js', { attrs: { globalName: 'test-remote' }, }); @@ -1206,8 +1197,11 @@ describe('runtimePlugin', () => { it('should handle errors in loadScriptNode', async () => { // Mock loadScriptNode to reject with an error - (global as any).__webpack_require__.federation.runtime.loadScriptNode = - jest.fn().mockRejectedValue(new Error('Loading error')); + ( + global as any + ).__webpack_require__.federation.instance.platform.loadScriptNode = jest + .fn() + .mockRejectedValue(new Error('Loading error')); const doneMock = jest.fn(); @@ -1274,10 +1268,10 @@ describe('runtimePlugin', () => { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, - runtime: { - loadScriptNode: jest.fn().mockResolvedValue({}), - }, instance: { + platform: { + loadScriptNode: jest.fn().mockResolvedValue({}), + }, initRawContainer: jest.fn().mockReturnValue({}), }, }, @@ -1308,10 +1302,11 @@ describe('runtimePlugin', () => { expect((global as any).__webpack_require__).toBeDefined(); expect((global as any).__webpack_require__.federation).toBeDefined(); expect( - (global as any).__webpack_require__.federation.runtime, + (global as any).__webpack_require__.federation.instance.platform, ).toBeDefined(); expect( - (global as any).__webpack_require__.federation.runtime.loadScriptNode, + (global as any).__webpack_require__.federation.instance.platform + .loadScriptNode, ).toBeDefined(); expect((global as any).__webpack_require__.l).toBeDefined(); expect((global as any).__webpack_require__.f).toBeDefined(); @@ -1378,10 +1373,10 @@ describe('runtimePlugin', () => { ...mockWebpackRequire, federation: { ...mockWebpackRequire.federation, - runtime: { - loadScriptNode: jest.fn().mockResolvedValue({}), - }, instance: { + platform: { + loadScriptNode: jest.fn().mockResolvedValue({}), + }, initRawContainer: jest.fn().mockReturnValue({}), }, }, @@ -1410,7 +1405,9 @@ describe('runtimePlugin', () => { it('should load remote entries correctly', () => { const loadScriptNodeMock = jest.fn().mockResolvedValue({}); - (global as any).__webpack_require__.federation.runtime.loadScriptNode = + ( + global as any + ).__webpack_require__.federation.instance.platform.loadScriptNode = loadScriptNodeMock; const doneMock = jest.fn(); diff --git a/packages/node/src/__tests__/webpackChunkUtilities.test.ts b/packages/node/src/__tests__/webpackChunkUtilities.test.ts index 2b8227d4c8f..738a5f0b026 100644 --- a/packages/node/src/__tests__/webpackChunkUtilities.test.ts +++ b/packages/node/src/__tests__/webpackChunkUtilities.test.ts @@ -24,13 +24,11 @@ const initRawContainer = (_name: string, _url: string, res: unknown) => ({ }); describe('generateLoadScript', () => { - it('loads through the instance platform when it has loadScriptNode', async () => { + it('loads through the instance platform', async () => { const container = { get: jest.fn() }; const calls: unknown[][] = []; - const legacyLoader = jest.fn(); const result = await runEmittedLoader({ - runtime: { loadScriptNode: legacyLoader }, instance: { platform: { loadScriptNode: async (...args: unknown[]) => { @@ -45,24 +43,23 @@ describe('generateLoadScript', () => { expect(calls).toEqual([ ['http://localhost:3001/remoteEntry.js', { attrs: {} }], ]); - expect(legacyLoader).not.toHaveBeenCalled(); expect(result).toEqual({ wrapped: container }); }); - it('falls back to federation.runtime.loadScriptNode without a platform loader', async () => { - const container = { get: jest.fn() }; - const legacyLoader = jest.fn().mockResolvedValue(container); + it('fails the load when the instance platform has no Node loader', async () => { + const legacyLoader = jest.fn(); const result = await runEmittedLoader({ runtime: { loadScriptNode: legacyLoader }, - instance: { initRawContainer }, + instance: { platform: { isBrowser: () => true }, initRawContainer }, }); - expect(legacyLoader).toHaveBeenCalledWith( - 'http://localhost:3001/remoteEntry.js', - { attrs: {} }, + expect(legacyLoader).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + message: expect.stringMatching(/needs a node or universal platform/), + }), ); - expect(result).toEqual({ wrapped: container }); }); it('reports a named error when nothing can load a Node script', async () => { const result = (await runEmittedLoader({ diff --git a/packages/node/src/plugins/webpackChunkUtilities.ts b/packages/node/src/plugins/webpackChunkUtilities.ts index c63aeb60e3e..06a3cd117cc 100644 --- a/packages/node/src/plugins/webpackChunkUtilities.ts +++ b/packages/node/src/plugins/webpackChunkUtilities.ts @@ -289,16 +289,12 @@ export function generateLoadScript(runtimeTemplate: any): string { } } try { - const federation = ${RuntimeGlobals.require}.federation; - const platform = federation.instance.platform; - const loader = platform && platform.loadScriptNode - ? platform - : federation.runtime && federation.runtime.loadScriptNode - ? federation.runtime - : null; - if (!loader) throw new Error('No Node script loader: the federation instance needs a node or universal platform to load remote entries in Node.'); - const res = await loader.loadScriptNode(url, { attrs: {} }); - const enhancedRemote = federation.instance.initRawContainer(name, url, res); + const instance = ${RuntimeGlobals.require}.federation.instance; + if (!instance.platform.loadScriptNode) { + throw new Error('No Node script loader: the federation instance needs a node or universal platform to load remote entries in Node.'); + } + const res = await instance.platform.loadScriptNode(url, { attrs: {} }); + const enhancedRemote = instance.initRawContainer(name, url, res); // use normal global assignment if(!usesInternalRef && !globalThis[name]) { globalThis[name] = enhancedRemote diff --git a/packages/node/src/runtimePlugin.ts b/packages/node/src/runtimePlugin.ts index 8d0844f6bff..113da0f26af 100644 --- a/packages/node/src/runtimePlugin.ts +++ b/packages/node/src/runtimePlugin.ts @@ -20,7 +20,6 @@ type WebpackRequire = { chunkId: string, ) => void; federation: { - runtime?: { loadScriptNode?: LoadScriptNode }; instance: ModuleFederation; chunkMatcher?: (chunkId: string) => boolean; rootOutputDir?: string; @@ -373,7 +372,7 @@ export const deleteChunk = ( }; // Hoisted function to set up webpack script loader -const NO_NODE_LOADER_MESSAGE = +const NO_NODE_LOADER = 'No Node script loader: the federation instance needs a node or universal platform to load remote entries in Node.'; export const setupScriptLoader = (): void => { @@ -385,17 +384,24 @@ export const setupScriptLoader = (): void => { ): void => { if (!key || chunkId) throw new Error(`__webpack_require__.l name is required for ${url}`); - const { instance, runtime } = __webpack_require__.federation; - const platform = instance?.platform as - | (ModuleFederation['platform'] & { loadScriptNode?: LoadScriptNode }) - | undefined; - const info = { attrs: { globalName: key } }; - (platform?.loadScriptNode - ? platform.loadScriptNode(url, info) - : runtime?.loadScriptNode - ? runtime.loadScriptNode(url, info) - : Promise.reject(new Error(NO_NODE_LOADER_MESSAGE)) - ) + const { instance } = __webpack_require__.federation; + if (!instance) { + done( + new Error( + `Cannot load ${url}: there is no federation instance; the federation runtime has not been initialized.`, + ), + ); + return; + } + const platform = instance.platform as ModuleFederation['platform'] & { + loadScriptNode?: LoadScriptNode; + }; + if (!platform.loadScriptNode) { + done(new Error(NO_NODE_LOADER)); + return; + } + platform + .loadScriptNode(url, { attrs: { globalName: key } }) .then((res) => { const enhancedRemote = __webpack_require__.federation.instance.initRawContainer( diff --git a/packages/rspack/__tests__/ComposedRuntimePlugin.spec.ts b/packages/rspack/__tests__/ComposedRuntimePlugin.spec.ts index 9bd7825a9dc..fa1ec1fa98d 100644 --- a/packages/rspack/__tests__/ComposedRuntimePlugin.spec.ts +++ b/packages/rspack/__tests__/ComposedRuntimePlugin.spec.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { MIN_RUNTIME_VERSION } from '@module-federation/managers'; import { ComposedRuntimePlugin } from '../src/ComposedRuntimePlugin'; import { resolveRspackRuntimeImplementation } from '../src/ModuleFederationPlugin'; @@ -10,7 +13,6 @@ function rspack07Compiler() { tap: (_: string, fn: Tap) => (taps[name] = fn), tapPromise: (_: string, fn: Tap) => (taps[name] = fn), }); - const defines: Record[] = []; const compiler = { context: __dirname, options: { resolve: { alias: {} }, externals: undefined }, @@ -22,22 +24,16 @@ function rspack07Compiler() { }, webpack: { WebpackError: Error, - DefinePlugin: class { - constructor(definitions: Record) { - defines.push(definitions); - } - apply() {} - }, }, }; - return { compiler, taps, defines }; + return { compiler, taps }; } it('keeps the full runtime and warns on an @rspack/core without compiler.rspack', async () => { - const { compiler, taps, defines } = rspack07Compiler(); + const { compiler, taps } = rspack07Compiler(); const implementation = resolveRspackRuntimeImplementation(); new ComposedRuntimePlugin( - { name: 'host', experiments: { composedRuntime: true } }, + { name: 'host' }, { runtimeTools: implementation, bundlerRuntime: require.resolve( @@ -49,7 +45,6 @@ it('keeps the full runtime and warns on an @rspack/core without compiler.rspack' }), }, 'host:1.0.0', - { FEDERATION_OPTIMIZE_NO_SHARED: false }, ).apply(compiler as never); taps['environment'](); @@ -62,6 +57,49 @@ it('keeps the full runtime and warns on an @rspack/core without compiler.rspack' 'this @rspack/core has no experiments.VirtualModulesPlugin', ), ]); - expect(defines).toEqual([{ FEDERATION_OPTIMIZE_NO_SHARED: false }]); + expect(compiler.options.resolve.alias).toEqual({}); +}); + +it('fails the build for an older runtime family', async () => { + const store = path.resolve(__dirname, '../../../node_modules/.pnpm'); + const [older] = fs + .readdirSync(store) + .filter((dir) => + /^@module-federation\+runtime-tools@2\.\d+\.\d+$/.test(dir), + ); + const implementation = path.join( + store, + older, + 'node_modules/@module-federation/runtime-tools', + ); + const { compiler, taps } = rspack07Compiler(); + new ComposedRuntimePlugin( + { name: 'host' }, + { + runtimeTools: implementation, + bundlerRuntime: require.resolve( + '@module-federation/webpack-bundler-runtime', + { paths: [implementation] }, + ), + runtime: require.resolve('@module-federation/runtime', { + paths: [implementation], + }), + }, + 'host:1.0.0', + ).apply(compiler as never); + + taps['environment'](); + await taps['beforeRun'](); + const compilation = { errors: [] as Error[], warnings: [] as Error[] }; + taps['thisCompilation'](compilation); + + expect(compilation.errors.map((e) => e.message)).toEqual([ + expect.stringMatching( + new RegExp( + `^The federation runtime cannot be composed: @module-federation/webpack-bundler-runtime at .* does not export "\\./compose": the installed runtime family lacks the subpath exports this build needs; update the @module-federation runtime packages to the release that added them \\(${MIN_RUNTIME_VERSION.replace(/\./g, '\\.')}\\)\\.$`, + ), + ), + ]); + expect(compilation.warnings).toEqual([]); expect(compiler.options.resolve.alias).toEqual({}); }); diff --git a/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts b/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts index ff101840e3e..61d03928d56 100644 --- a/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts +++ b/packages/rspack/__tests__/ModuleFederationPlugin.spec.ts @@ -1,6 +1,6 @@ +import * as pluginEntry from '../src/plugin'; import { ModuleFederationPlugin, - resolveRspackRuntimeAlias, resolveRspackRuntimeImplementation, } from '../src/ModuleFederationPlugin'; @@ -56,21 +56,21 @@ describe('runtime resolution compatibility', () => { if ( basedFromLegacy && - request === '@module-federation/runtime/bundler' + request === '@module-federation/runtime-tools/bundler' ) { throw new Error(`Cannot find module '${request}'`); } - if (request === '@module-federation/runtime/dist/index.js') { - return '/legacy/runtime/dist/index.js'; + if (request === '@module-federation/runtime-tools/dist/index.js') { + return '/legacy/runtime-tools/dist/index.js'; } throw new Error(`Unexpected request: ${request}`); }, ) as typeof require.resolve; - expect(resolveRspackRuntimeAlias('/legacy/runtime-tools', resolve)).toBe( - '/legacy/runtime/dist/index.js', - ); + expect( + resolveRspackRuntimeImplementation('/legacy/runtime-tools', resolve), + ).toBe('/legacy/runtime-tools/dist/index.js'); }); it('falls back to legacy cjs runtime entries when esm legacy builds are unavailable', () => { @@ -80,56 +80,44 @@ describe('runtime resolution compatibility', () => { if ( basedFromLegacy && - (request === '@module-federation/runtime/bundler' || - request === '@module-federation/runtime/dist/index.js') + (request === '@module-federation/runtime-tools/bundler' || + request === '@module-federation/runtime-tools/dist/index.js') ) { throw new Error(`Cannot find module '${request}'`); } - if (request === '@module-federation/runtime/dist/index.cjs') { - return '/legacy/runtime/dist/index.cjs'; + if (request === '@module-federation/runtime-tools/dist/index.cjs') { + return '/legacy/runtime-tools/dist/index.cjs'; } throw new Error(`Unexpected request: ${request}`); }, ) as typeof require.resolve; - expect(resolveRspackRuntimeAlias('/legacy/runtime-tools', resolve)).toBe( - '/legacy/runtime/dist/index.cjs', - ); + expect( + resolveRspackRuntimeImplementation('/legacy/runtime-tools', resolve), + ).toBe('/legacy/runtime-tools/dist/index.cjs'); }); }); -describe('runtime capability optimization defines', () => { - it('keeps all runtime capabilities enabled by default', () => { - expect(getOptimizationDefines()).toMatchObject({ - FEDERATION_OPTIMIZE_NO_REMOTE: false, - FEDERATION_OPTIMIZE_NO_SHARED: false, - FEDERATION_HAS_EXPOSES: false, - }); +describe('@module-federation/rspack/plugin', () => { + it('no longer exports resolveRspackRuntimeAlias', () => { + expect(pluginEntry).not.toHaveProperty('resolveRspackRuntimeAlias'); }); +}); - it('derives expose capability from the container configuration', () => { - expect(getOptimizationDefines(undefined, {})).toMatchObject({ - FEDERATION_HAS_EXPOSES: false, - }); +describe('runtime optimization defines', () => { + it('defines only ENV_TARGET, from experiments.optimization.target', () => { expect( - getOptimizationDefines(undefined, { - './Button': './src/Button', - }), - ).toMatchObject({ - FEDERATION_HAS_EXPOSES: true, - }); + getOptimizationDefines( + { target: 'web', disableRemote: true, disableShared: true }, + { './Button': './src/Button' }, + ), + ).toEqual({ ENV_TARGET: '"web"' }); }); - it('defines each disabled runtime capability independently', () => { + it('defines nothing without a target', () => { expect( - getOptimizationDefines({ - disableRemote: true, - disableShared: true, - }), - ).toMatchObject({ - FEDERATION_OPTIMIZE_NO_REMOTE: true, - FEDERATION_OPTIMIZE_NO_SHARED: true, - }); + getOptimizationDefines({ disableSnapshot: true }, { './Button': './B' }), + ).toEqual({}); }); }); diff --git a/packages/rspack/__tests__/composedRuntime.spec.ts b/packages/rspack/__tests__/composedRuntime.spec.ts index b13a561f0a9..b55f6edd853 100644 --- a/packages/rspack/__tests__/composedRuntime.spec.ts +++ b/packages/rspack/__tests__/composedRuntime.spec.ts @@ -76,11 +76,6 @@ async function harness( return results; } -const composed = (experiments: Record = {}) => ({ - composedRuntime: true, - ...experiments, -}); - const remote = ( target: 'node' | 'web', extra: Record = {}, @@ -117,7 +112,9 @@ function expectComposed(b: Build, name: string) { expect.stringMatching(new RegExp(`/${name}\\.[0-9a-f]{12}\\.mjs$`)), ]); expect(b.modules.filter((m) => LEGACY_ENTRY.test(m))).toEqual([]); - expect(b.warnings.filter((w) => w.includes('composedRuntime'))).toEqual([]); + expect( + b.warnings.filter((w) => w.includes('full federation runtime')), + ).toEqual([]); } function serve(root: string): Promise { @@ -145,13 +142,13 @@ async function runHostAndRemote(out: string, rspackCore?: string) { out: `${out}/remote`, target: 'node', singleChunk: true, - mf: remote('node', { shared: SHARED, experiments: composed() }), + mf: remote('node', { shared: SHARED }), }, { out: `${out}/host`, target: 'node', mf: host( - { shared: SHARED, experiments: composed() }, + { shared: SHARED }, `http://127.0.0.1:${port}/${out}/remote/remoteEntry.js`, ), }, @@ -176,7 +173,7 @@ async function runHostAndRemote(out: string, rspackCore?: string) { } } -describe('experiments.composedRuntime', () => { +describe('composed runtime', () => { it('runs a composed host against a composed remote with one shared singleton', () => runHostAndRemote('run')); @@ -199,16 +196,14 @@ describe('experiments.composedRuntime', () => { out: 'graph/host', target: 'web', mf: host({ - experiments: composed({ + experiments: { optimization: { disableShared: true, disableSnapshot: true }, - }), + }, }), }, ]); expectComposed(b, 'host'); - expect(mainCode('graph/host')).toContain( - 'typeof FEDERATION_BUILD_IDENTIFIER', - ); + expect(mainCode('graph/host')).not.toContain('FEDERATION_BUILD_IDENTIFIER'); expect( parts(b, [ 'remote', @@ -235,7 +230,7 @@ describe('experiments.composedRuntime', () => { { out: 'graph/async-host', target: 'node', - mf: host({ experiments: composed({ asyncStartup: true }) }), + mf: host({ experiments: { asyncStartup: true } }), }, ]); expectComposed(b, 'host'); @@ -248,9 +243,9 @@ describe('experiments.composedRuntime', () => { out: 'graph/remote', target: 'web', mf: remote('web', { - experiments: composed({ + experiments: { optimization: { disableRemote: true, disableShared: true }, - }), + }, }), }, ]); @@ -282,7 +277,7 @@ describe('experiments.composedRuntime', () => { { out: 'check/externals', target: 'node', - mf: host({ shared: SHARED, experiments: composed() }), + mf: host({ shared: SHARED }), externalsPattern: '^@module-federation/runtime-core', }, ], @@ -301,7 +296,7 @@ describe('experiments.composedRuntime', () => { { out: 'check/remotes', target: 'node', - mf: { name: 'host', shared: SHARED, experiments: composed() }, + mf: { name: 'host', shared: SHARED }, referenceRemotes: { remoteApp: 'remoteApp@http://localhost/x.js' }, }, ], @@ -320,7 +315,7 @@ describe('experiments.composedRuntime', () => { out: 'legacy/host', target: 'web', noVirtualModules: true, - mf: host({ experiments: composed() }), + mf: host(), }, ]); expect(composedEntries(b)).toEqual([]); @@ -330,11 +325,37 @@ describe('experiments.composedRuntime', () => { ); expect(b.warnings).toEqual([ expect.stringContaining( - 'this @rspack/core has no experiments.VirtualModulesPlugin', + 'This build uses the full federation runtime because this @rspack/core has no experiments.VirtualModulesPlugin', ), ]); }); + it.each([ + ['the composed bootstrap', {}], + [ + 'the full bootstrap a user alias selects', + { + alias: { + '@module-federation/runtime-core$': path.resolve( + __dirname, + '../../runtime-core/dist/index.js', + ), + }, + }, + ], + ])('registers the share scope of %s under name:version', async (_, extra) => { + const out = `build-id/${Object.keys(extra).length}`; + await harness([ + { out, target: 'node', buildVersion: '9.9.9', mf: host(), ...extra }, + ]); + const { stdout } = await promisify(execFile)(process.execPath, [ + '-e', + 'require(process.argv[1]).default.catch(() => {}); console.log(JSON.stringify(Object.keys(globalThis.__FEDERATION__.__SHARE__)))', + path.join(outRoot, out, 'main.js'), + ]); + expect(JSON.parse(stdout)).toEqual(['host:9.9.9']); + }); + it('keeps the full runtime and warns when resolve.alias already maps the bundler runtime', async () => { const bundlerRuntime = require.resolve( '@module-federation/webpack-bundler-runtime', @@ -345,7 +366,7 @@ describe('experiments.composedRuntime', () => { out: 'user-alias/bundler-runtime', target: 'web', alias: { [bundlerRuntime]: bundlerRuntime }, - mf: host({ experiments: composed() }), + mf: host(), }, ]); expect(composedEntries(b)).toEqual([]); @@ -365,7 +386,7 @@ describe('experiments.composedRuntime', () => { out: 'user-alias/runtime-core', target: 'web', alias: { '@module-federation/runtime-core$': runtimeCore }, - mf: host({ experiments: composed() }), + mf: host(), }, ]); expect(composedEntries(b)).toEqual([]); @@ -377,10 +398,21 @@ describe('experiments.composedRuntime', () => { ]); }); - it('keeps the full runtime without the experiment', async () => { + it('composes by default', async () => { const [b] = await harness([{ out: 'off/host', target: 'web', mf: host() }]); + expectComposed(b, 'host'); + expect(b.warnings).toEqual([]); + }); + + it('keeps the full runtime, without a warning, for experiments.externalRuntime', async () => { + const [b] = await harness([ + { + out: 'external/host', + target: 'web', + mf: host({ experiments: { externalRuntime: true } }), + }, + ]); expect(composedEntries(b)).toEqual([]); - expect(mainCode('off/host')).not.toContain('FEDERATION_BUILD_IDENTIFIER'); expect(b.modules.some((m) => LEGACY_ENTRY.test(m))).toBe(true); expect(b.warnings).toEqual([]); }); @@ -391,7 +423,7 @@ describe('experiments.composedRuntime', () => { { out: 'watch/host', target: 'web', - mf: host({ experiments: composed() }), + mf: host(), }, ], { watch: true }, @@ -408,18 +440,18 @@ describe('experiments.composedRuntime', () => { out: 'multi/host', target: 'web', mf: host({ - experiments: composed({ + experiments: { optimization: { disableShared: true, disableSnapshot: true }, - }), + }, }), }, { out: 'multi/remote', target: 'web', mf: remote('web', { - experiments: composed({ + experiments: { optimization: { disableRemote: true, disableShared: true }, - }), + }, }), }, ], @@ -456,7 +488,7 @@ describe('experiments.composedRuntime', () => { cacheDir, buildVersion: 'buildVersion' in step ? step.buildVersion : undefined, mf: host({ - experiments: composed({ optimization: plans[step.plan] }), + experiments: { optimization: plans[step.plan] }, }), }, ]); diff --git a/packages/rspack/package.json b/packages/rspack/package.json index 95c59b27dfb..3087d54461c 100644 --- a/packages/rspack/package.json +++ b/packages/rspack/package.json @@ -84,7 +84,7 @@ "peerDependencies": { "typescript": "^4.9.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vue-tsc": ">=1.0.24", - "@rspack/core": "^0.7.0 || ^1.0.0 || ^2.0.0-0" + "@rspack/core": "^1.5.0 || ^2.0.0-0" }, "peerDependenciesMeta": { "typescript": { diff --git a/packages/rspack/src/ComposedRuntimePlugin.ts b/packages/rspack/src/ComposedRuntimePlugin.ts index 800485b0238..f6914023676 100644 --- a/packages/rspack/src/ComposedRuntimePlugin.ts +++ b/packages/rspack/src/ComposedRuntimePlugin.ts @@ -13,6 +13,7 @@ import { type GraphModule, type Participant, type RuntimeFamily, + type RuntimeMode, } from '@module-federation/managers'; import type { moduleFederationPlugin } from '@module-federation/sdk'; @@ -37,14 +38,12 @@ export class ComposedRuntimePlugin { runtime: string; }, private readonly _buildId: string, - private readonly _defines: Record, ) {} apply(compiler: Compiler): void { let composition: Composition | undefined; - let deciding: Promise | undefined; - let legacyReason: string | undefined; - let composed = false; + let deciding: Promise | undefined; + let mode: RuntimeMode | undefined; // VirtualModulesPlugin reads its modules in afterEnvironment. compiler.hooks.environment.tap(PLUGIN_NAME, () => { @@ -53,23 +52,29 @@ export class ComposedRuntimePlugin { // rspack reads resolve.alias and its builtin plugins when it creates its native // compiler, after beforeRun and watchRun. const decide = async () => { - deciding ??= this._decide(compiler, composition!).then((reason) => { - this._applyDefines(compiler, reason === undefined); - return reason; - }); - legacyReason = await deciding; - composed = legacyReason === undefined; + deciding ??= this._decide(compiler, composition!); + mode = await deciding; }; compiler.hooks.beforeRun.tapPromise(PLUGIN_NAME, decide); compiler.hooks.watchRun.tapPromise(PLUGIN_NAME, decide); compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { - if (legacyReason !== undefined) { + const { WebpackError } = compiler.webpack; + if (mode?.mode === 'unsupported') { + compilation.errors.push( + new WebpackError( + `The federation runtime cannot be composed: ${mode.reason}.`, + ), + ); + return; + } + if (mode?.mode === 'legacy' && !mode.requested) { compilation.warnings.push( - new compiler.webpack.WebpackError( - `experiments.composedRuntime is set, but this build uses the full federation runtime because ${legacyReason}.`, + new WebpackError( + `This build uses the full federation runtime because ${mode.reason}.`, ), ); - } else if (composed) { + } + if (mode?.mode === 'composed') { compilation.hooks.finishModules.tap(PLUGIN_NAME, (modules) => checkGraph(compilation, modules, composition!), ); @@ -113,7 +118,7 @@ export class ComposedRuntimePlugin { private async _decide( compiler: Compiler, { family, bootstrapPath, renderError }: Composition, - ): Promise { + ): Promise { const { resolve } = compiler.options; const alias = (resolve.alias ?? {}) as Record; const mode = await selectMode(family, { @@ -124,10 +129,13 @@ export class ComposedRuntimePlugin { aliasExemptions: [this._native.runtimeTools, this._native.runtime], virtualModulesPlugin: Boolean(virtualModulesPluginOf(compiler)), }); - if (mode.mode === 'legacy') return mode.reason; + if (mode.mode !== 'composed') return mode; if (!bootstrapPath) throw renderError; if (alias[this._native.bundlerRuntime] !== undefined) { - return `resolve.alias already maps ${this._native.bundlerRuntime}`; + return { + mode: 'legacy', + reason: `resolve.alias already maps ${this._native.bundlerRuntime}`, + }; } const runtime = alias[RUNTIME]; resolve.alias = { @@ -137,15 +145,7 @@ export class ComposedRuntimePlugin { [RUNTIME]: resolveRuntimeEsm(family), }), } as typeof resolve.alias; - return undefined; - } - - private _applyDefines(compiler: Compiler, composed: boolean) { - const { DefinePlugin } = compiler.webpack; - if (!composed) return new DefinePlugin(this._defines).apply(compiler); - const { ENV_TARGET } = this._defines; - if (ENV_TARGET !== undefined) - new DefinePlugin({ ENV_TARGET }).apply(compiler); + return mode; } } diff --git a/packages/rspack/src/ModuleFederationPlugin.ts b/packages/rspack/src/ModuleFederationPlugin.ts index c6644371738..26b23185010 100644 --- a/packages/rspack/src/ModuleFederationPlugin.ts +++ b/packages/rspack/src/ModuleFederationPlugin.ts @@ -38,17 +38,6 @@ type RuntimeEntrySpec = { cjs: string; }; -function hasExposes( - exposes: moduleFederationPlugin.ModuleFederationPluginOptions['exposes'], -): boolean { - return Boolean( - exposes && - (Array.isArray(exposes) - ? exposes.length > 0 - : Object.keys(exposes).length > 0), - ); -} - function resolveRuntimeEntry( spec: RuntimeEntrySpec, implementation: string | undefined, @@ -86,22 +75,6 @@ export function resolveRspackRuntimeImplementation( ); } -/** @deprecated The wrapper no longer aliases `@module-federation/runtime$`. */ -export function resolveRspackRuntimeAlias( - implementation: string, - resolve: ResolveFn = require.resolve, -) { - return resolveRuntimeEntry( - { - bundler: '@module-federation/runtime/bundler', - esm: '@module-federation/runtime/dist/index.js', - cjs: '@module-federation/runtime/dist/index.cjs', - }, - implementation, - resolve, - ); -} - export class ModuleFederationPlugin implements RspackPluginInstance { readonly name = PLUGIN_NAME; private _options: moduleFederationPlugin.ModuleFederationPluginOptions; @@ -111,25 +84,9 @@ export class ModuleFederationPlugin implements RspackPluginInstance { this._options = options; } - private _patchBundlerConfig( - compiler: Compiler, - ): Record { - const { name, experiments, exposes } = this._options; + private _patchBundlerConfig(compiler: Compiler): void { + const { experiments } = this._options; const definePluginOptions: Record = {}; - if (name) { - definePluginOptions['FEDERATION_BUILD_IDENTIFIER'] = JSON.stringify( - composeKeyWithSeparator(name, utils.getBuildVersion()), - ); - } - // Add FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN - const disableSnapshot = experiments?.optimization?.disableSnapshot ?? false; - definePluginOptions['FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN'] = - disableSnapshot; - definePluginOptions['FEDERATION_OPTIMIZE_NO_REMOTE'] = - experiments?.optimization?.disableRemote ?? false; - definePluginOptions['FEDERATION_OPTIMIZE_NO_SHARED'] = - experiments?.optimization?.disableShared ?? false; - definePluginOptions['FEDERATION_HAS_EXPOSES'] = hasExposes(exposes); // Determine ENV_TARGET: only if manually specified in experiments.optimization.target if ( @@ -149,10 +106,7 @@ export class ModuleFederationPlugin implements RspackPluginInstance { } // No inference for ENV_TARGET. If not manually set and valid, it's not defined. - if (!experiments?.composedRuntime) { - new compiler.webpack.DefinePlugin(definePluginOptions).apply(compiler); - } - return definePluginOptions; + new compiler.webpack.DefinePlugin(definePluginOptions).apply(compiler); } private _checkSingleton(compiler: Compiler): void { @@ -183,7 +137,7 @@ export class ModuleFederationPlugin implements RspackPluginInstance { throw new Error('[ ModuleFederationPlugin ]: name is required'); } this._checkSingleton(compiler); - const defines = this._patchBundlerConfig(compiler); + this._patchBundlerConfig(compiler); const containerManager = new ContainerManager(); containerManager.init(options); @@ -239,28 +193,36 @@ export class ModuleFederationPlugin implements RspackPluginInstance { } } + const buildId = composeKeyWithSeparator( + options.name, + utils.getBuildVersion(), + ); + // rspack's native runtime passes no id to init; this plugin sets it before the share scope registers. + // A data: module, because @rspack/core before 1.6 takes no runtime plugin params. + options.runtimePlugins = [ + ...(options.runtimePlugins || []), + `data:text/javascript,export default function(){return{name:"build-id-plugin",beforeInit(args){args.userOptions.id||=${JSON.stringify(buildId)};return args}}}`, + ]; + new compiler.webpack.container.ModuleFederationPlugin( options as unknown as ModuleFederationPluginOptions, ).apply(compiler); - if (options.experiments?.composedRuntime) { - new ComposedRuntimePlugin( - options, - { - runtimeTools: implementationPath, - // The same resolutions as rspack's native plugin, which imports bundlerRuntime. - bundlerRuntime: require.resolve( - '@module-federation/webpack-bundler-runtime', - { paths: [implementationPath] }, - ), - runtime: require.resolve('@module-federation/runtime', { - paths: [implementationPath], - }), - }, - composeKeyWithSeparator(options.name, utils.getBuildVersion()), - defines, - ).apply(compiler); - } + new ComposedRuntimePlugin( + options, + { + runtimeTools: implementationPath, + // The same resolutions as rspack's native plugin, which imports bundlerRuntime. + bundlerRuntime: require.resolve( + '@module-federation/webpack-bundler-runtime', + { paths: [implementationPath] }, + ), + runtime: require.resolve('@module-federation/runtime', { + paths: [implementationPath], + }), + }, + buildId, + ).apply(compiler); if (!disableManifest) { this._statsPlugin = new StatsPlugin(options, { diff --git a/packages/rstest/src/externals-bypass.ts b/packages/rstest/src/externals-bypass.ts index a28858c4252..be085dedf34 100644 --- a/packages/rstest/src/externals-bypass.ts +++ b/packages/rstest/src/externals-bypass.ts @@ -2,7 +2,7 @@ import type { Rspack } from '@rsbuild/core'; import { NODE_RUNTIME_PLUGIN } from './runtime-plugin'; -const DATA_JAVASCRIPT_REQUEST = /!=!data:text\/javascript(?:;|,)/i; +const DATA_JAVASCRIPT_REQUEST = /(?:^|!=!)data:text\/javascript(?:;|,)/i; // Rspack's native plugin imports this path, and the rspack wrapper aliases it to a virtual module. const BUNDLER_RUNTIME_REQUEST = /[\\/]webpack-bundler-runtime[\\/]dist[\\/]index\.c?js$/; @@ -32,17 +32,15 @@ const isFederationRemoteRequest = ( export const shouldKeepBundledForFederation = ( request: string, remoteNames?: Set, - composedRuntime = false, ): boolean => { if (DATA_JAVASCRIPT_REQUEST.test(request)) { return true; } - if (request === NODE_RUNTIME_PLUGIN) { - return true; - } - - if (composedRuntime && BUNDLER_RUNTIME_REQUEST.test(request)) { + if ( + request === NODE_RUNTIME_PLUGIN || + BUNDLER_RUNTIME_REQUEST.test(request) + ) { return true; } @@ -59,7 +57,6 @@ export const shouldKeepBundledForFederation = ( export const createFederationExternalBypass = ( getRemoteNames: () => Set, - composedRuntime = false, ): (( data: Rspack.ExternalItemFunctionData, callback: ( @@ -71,11 +68,7 @@ export const createFederationExternalBypass = ( return function federationExternalBypass({ request }, callback) { if ( !request || - !shouldKeepBundledForFederation( - request, - getRemoteNames(), - composedRuntime, - ) + !shouldKeepBundledForFederation(request, getRemoteNames()) ) { return callback(); } diff --git a/packages/rstest/src/index.test.ts b/packages/rstest/src/index.test.ts index ee4bfc4a059..f4036b402db 100644 --- a/packages/rstest/src/index.test.ts +++ b/packages/rstest/src/index.test.ts @@ -278,6 +278,14 @@ describe('shouldKeepBundledForFederation', () => { ).toBe(true); }); + it('keeps plain data: javascript requests bundled', () => { + expect( + shouldKeepBundledForFederation( + 'data:text/javascript,export default function(){}', + ), + ).toBe(true); + }); + it('keeps @module-federation/* requests bundled', () => { expect(shouldKeepBundledForFederation('@module-federation/node')).toBe( true, @@ -293,17 +301,16 @@ describe('shouldKeepBundledForFederation', () => { }); it('keeps the absolute bundler runtime path that rspack aliases to the composed bootstrap bundled', () => { - const paths = [ - '/app/node_modules/@module-federation/webpack-bundler-runtime/dist/index.cjs', - 'C:\\app\\node_modules\\@module-federation\\webpack-bundler-runtime\\dist\\index.cjs', - ]; - for (const request of paths) { - expect(shouldKeepBundledForFederation(request, undefined, true)).toBe( - true, - ); - // Without experiments.composedRuntime, rstest's own externals still decide. - expect(shouldKeepBundledForFederation(request)).toBe(false); - } + expect( + shouldKeepBundledForFederation( + '/app/node_modules/@module-federation/webpack-bundler-runtime/dist/index.cjs', + ), + ).toBe(true); + expect( + shouldKeepBundledForFederation( + 'C:\\app\\node_modules\\@module-federation\\webpack-bundler-runtime\\dist\\index.cjs', + ), + ).toBe(true); }); it('keeps webpack container reference requests bundled', () => { diff --git a/packages/rstest/src/plugin.ts b/packages/rstest/src/plugin.ts index 3f473da8b3c..7e3bbf2ee3e 100644 --- a/packages/rstest/src/plugin.ts +++ b/packages/rstest/src/plugin.ts @@ -66,7 +66,7 @@ const createRspackPatcher = ( createFederationExternalBypass(() => { remoteNames ??= collectRemoteNames(moduleFederationOptions.remotes); return remoteNames; - }, moduleFederationOptions.experiments?.composedRuntime === true), + }), ...toArray(rspackConfig.externals), ]; }; diff --git a/packages/runtime-core/__tests__/kernel.spec.ts b/packages/runtime-core/__tests__/kernel.spec.ts index 6a5dfdc1ad2..23458a5fe93 100644 --- a/packages/runtime-core/__tests__/kernel.spec.ts +++ b/packages/runtime-core/__tests__/kernel.spec.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, it, rs } from '@rstest/core'; import { FederationKernel, getRemoteEntry } from '../src/kernel'; import { remote } from '../src/remote/capability'; -import { PLATFORM_UNAVAILABLE_MESSAGE } from '../src/core'; -import { ModuleFederation } from '../src'; import { shared } from '../src/shared/capability'; import { CurrentGlobal } from '../src/global'; +import { PLATFORM_UNAVAILABLE_MESSAGE } from '../src/core'; +import { Module, ModuleFederation } from '../src'; +import { Module as RemoteModule } from '../src/module'; import type { ModuleFederationRuntimePlugin, Platform } from '../src/type'; declare global { @@ -12,6 +13,8 @@ declare global { var FEDERATION_OPTIMIZE_NO_REMOTE: boolean | undefined; // eslint-disable-next-line no-var var FEDERATION_OPTIMIZE_NO_SHARED: boolean | undefined; + // eslint-disable-next-line no-var + var FEDERATION_BUILD_IDENTIFIER: string | undefined; } const remoteInfo = { @@ -28,6 +31,8 @@ const pluginNames = (instance: FederationKernel) => describe('FederationKernel', () => { afterEach(() => { delete globalThis.FEDERATION_OPTIMIZE_NO_REMOTE; + delete globalThis.FEDERATION_OPTIMIZE_NO_SHARED; + delete globalThis.FEDERATION_BUILD_IDENTIFIER; }); it('has disabled handlers and a platform that rejects loads without capabilities', async () => { @@ -135,12 +140,35 @@ describe('FederationKernel', () => { undefined, ); }); + + it('registers its share scope under an id a beforeInit plugin sets', () => { + const buildId: ModuleFederationRuntimePlugin = { + name: 'build-id', + beforeInit(args) { + args.userOptions.id ||= 'kernel-plugin-id:1.0.0'; + return args; + }, + }; + const kernel = new FederationKernel( + { name: 'kernel-plugin-id', plugins: [buildId] }, + { shared }, + ); + + expect(kernel.options.id).toBe('kernel-plugin-id:1.0.0'); + expect( + CurrentGlobal.__FEDERATION__.__SHARE__['kernel-plugin-id:1.0.0'], + ).toBe(kernel.shareScopeMap); + expect(CurrentGlobal.__FEDERATION__.__SHARE__['kernel-plugin-id']).toBe( + undefined, + ); + }); }); describe('root ModuleFederation', () => { afterEach(() => { delete globalThis.FEDERATION_OPTIMIZE_NO_REMOTE; delete globalThis.FEDERATION_OPTIMIZE_NO_SHARED; + delete globalThis.FEDERATION_BUILD_IDENTIFIER; }); it('composes shared, remote, snapshot and the universal platform', () => { @@ -164,35 +192,28 @@ describe('root ModuleFederation', () => { expect(instance.options.inBrowser).toBe(true); }); - it('drops remote and snapshot when FEDERATION_OPTIMIZE_NO_REMOTE is set', async () => { + it('ignores the removed capability and build-id defines', () => { globalThis.FEDERATION_OPTIMIZE_NO_REMOTE = true; + globalThis.FEDERATION_OPTIMIZE_NO_SHARED = true; + globalThis.FEDERATION_BUILD_IDENTIFIER = 'root:1.0.0'; const instance = new ModuleFederation({ - name: 'root-no-remote', + name: 'root-defines', remotes: [{ name: 'app', entry: remoteInfo.entry }], + shared: { + react: { version: '18.0.0', lib: () => ({ root: true }) }, + }, }); - expect(pluginNames(instance)).toEqual([]); - expect(instance.options.remotes).toEqual([]); - await expect(instance.loadRemote('app/Button')).rejects.toThrow( - 'Remote loading is disabled', - ); - }); - - it('has no platform loader when remote and shared are both disabled', async () => { - globalThis.FEDERATION_OPTIMIZE_NO_REMOTE = true; - globalThis.FEDERATION_OPTIMIZE_NO_SHARED = true; - - const instance = new ModuleFederation({ name: 'root-all-off' }); - - expect(() => instance.loadShareSync('react')).toThrow( - 'Shared dependency loading is disabled', - ); - await expect( - instance.platform.loadEntry({ - remoteInfo, - loaderHook: instance.loaderHook, - }), - ).rejects.toThrow(PLATFORM_UNAVAILABLE_MESSAGE); + expect(pluginNames(instance)).toEqual([ + 'snapshot-plugin', + 'generate-preload-assets-plugin', + ]); + expect(instance.options.remotes.map((r) => r.name)).toEqual(['app']); + expect(instance.loadShareSync<{ root: boolean }>('react')()).toEqual({ + root: true, + }); + expect(instance.options.id).toBe(''); + expect(Module).toBe(RemoteModule); }); }); diff --git a/packages/runtime-core/global.d.ts b/packages/runtime-core/global.d.ts index a74d2d87a02..012c2a032d0 100644 --- a/packages/runtime-core/global.d.ts +++ b/packages/runtime-core/global.d.ts @@ -1,5 +1,4 @@ declare const __VERSION__: string; declare const FEDERATION_DEBUG: string; -declare const FEDERATION_BUILD_IDENTIFIER: string | undefined; declare const __RELEASE_NUMBER__: number; declare const FEDERATION_ALLOW_NEW_FUNCTION: string | undefined; diff --git a/packages/runtime-core/src/core.ts b/packages/runtime-core/src/core.ts index cf638bc05d2..d262804a4e0 100644 --- a/packages/runtime-core/src/core.ts +++ b/packages/runtime-core/src/core.ts @@ -28,7 +28,7 @@ import { SnapshotHandlerContract, Platform, } from './type'; -import { getBuilderId, registerPlugins, getRemoteEntry, error } from './utils'; +import { registerPlugins, getRemoteEntry, error } from './utils'; import { getShortErrorMsg, RUNTIME_010, @@ -298,7 +298,7 @@ export class FederationCore { // TODO: Validate the details of the options // Initialize options with default values const defaultOptions: Options = { - id: userOptions.id || getBuilderId(), + id: userOptions.id || '', name: userOptions.name, plugins, remotes: [], diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 4da1042335c..94bdbd45194 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -1,8 +1,6 @@ import helpersDefault, { type IGlobalUtils, type IShareUtils } from './helpers'; -import { Module as RemoteModule } from './module'; -import { UnavailableRemoteModule, disabledRemote } from './remote/disabled'; -import { disabledShared } from './shared/disabled'; -import { FederationCore, unavailablePlatform } from './core'; +import { Module } from './module'; +import { FederationCore } from './core'; export { FederationKernel } from './core'; import { shared } from './shared'; @@ -11,50 +9,17 @@ import { snapshot } from './plugins/snapshot'; import { universal } from './platform/universal'; import type { ResolvedCapabilities, UserOptions } from './type'; -declare const FEDERATION_OPTIMIZE_NO_REMOTE: boolean; -declare const FEDERATION_OPTIMIZE_NO_SHARED: boolean; -declare const FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN: boolean; - const helpers = helpersDefault; -const Module = ( - typeof FEDERATION_OPTIMIZE_NO_REMOTE === 'boolean' && - FEDERATION_OPTIMIZE_NO_REMOTE - ? UnavailableRemoteModule - : RemoteModule -) as typeof RemoteModule; - -// Each check stays inline so the bundler folds it at parse time and drops the -// unused capability import. -const legacyCapabilities = (): ResolvedCapabilities => ({ - shared: - typeof FEDERATION_OPTIMIZE_NO_SHARED === 'boolean' && - FEDERATION_OPTIMIZE_NO_SHARED - ? disabledShared - : shared, - remote: - typeof FEDERATION_OPTIMIZE_NO_REMOTE === 'boolean' && - FEDERATION_OPTIMIZE_NO_REMOTE - ? disabledRemote - : remote, - snapshot: - (typeof FEDERATION_OPTIMIZE_NO_REMOTE === 'boolean' && - FEDERATION_OPTIMIZE_NO_REMOTE) || - (typeof FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN === 'boolean' && - FEDERATION_OPTIMIZE_NO_SNAPSHOT_PLUGIN) - ? undefined - : snapshot, - platform: - typeof FEDERATION_OPTIMIZE_NO_REMOTE === 'boolean' && - FEDERATION_OPTIMIZE_NO_REMOTE && - typeof FEDERATION_OPTIMIZE_NO_SHARED === 'boolean' && - FEDERATION_OPTIMIZE_NO_SHARED - ? unavailablePlatform - : universal, -}); +const fullCapabilities: ResolvedCapabilities = { + shared, + remote, + snapshot, + platform: universal, +}; export class ModuleFederation extends FederationCore { constructor(userOptions: UserOptions) { - super(userOptions, legacyCapabilities()); + super(userOptions, fullCapabilities); } } export { diff --git a/packages/runtime-core/src/remote/disabled.ts b/packages/runtime-core/src/remote/disabled.ts index 1fa11a02b55..5cfe67b3eba 100644 --- a/packages/runtime-core/src/remote/disabled.ts +++ b/packages/runtime-core/src/remote/disabled.ts @@ -5,12 +5,6 @@ import { DisabledSnapshotHandler } from '../plugins/snapshot/disabled'; const REMOTE_DISABLED_MESSAGE = 'Remote loading is disabled by experiments.optimization.disableRemote.'; -export class UnavailableRemoteModule { - constructor() { - throw new Error(REMOTE_DISABLED_MESSAGE); - } -} - export class DisabledRemoteHandler implements RemoteHandlerContract { // getRemoteEntry emits loadEntry for shared fallback entries, which load without remotes. hooks: RemoteHandlerContract['hooks'] = new PluginSystem({ diff --git a/packages/runtime-core/src/shared/index.ts b/packages/runtime-core/src/shared/index.ts index 004aeb2202a..4ff667276a3 100644 --- a/packages/runtime-core/src/shared/index.ts +++ b/packages/runtime-core/src/shared/index.ts @@ -138,7 +138,10 @@ export class SharedHandler { this.host = host; this.shareScopeMap = {}; this.initTokens = {}; - this._setGlobalShareScopeMap(host.options); + // A beforeInit plugin may set the id, so register once the options are final. + host.hooks.lifecycle.init.on(({ options }) => + this._setGlobalShareScopeMap(options), + ); } private emitAfterRegisterShare( diff --git a/packages/runtime-core/src/utils/env.ts b/packages/runtime-core/src/utils/env.ts index 9c87af66172..b667a746599 100644 --- a/packages/runtime-core/src/utils/env.ts +++ b/packages/runtime-core/src/utils/env.ts @@ -7,11 +7,3 @@ export { export function isDevelopmentMode(): boolean { return true; } - -export function getBuilderId(): string { - //@ts-ignore - return typeof FEDERATION_BUILD_IDENTIFIER !== 'undefined' - ? //@ts-ignore - FEDERATION_BUILD_IDENTIFIER - : ''; -} diff --git a/packages/runtime/__tests__/compose.spec.ts b/packages/runtime/__tests__/compose.spec.ts index 30c23326fb0..3962861a809 100644 --- a/packages/runtime/__tests__/compose.spec.ts +++ b/packages/runtime/__tests__/compose.spec.ts @@ -30,6 +30,23 @@ describe('runtime/compose', () => { expect(host.options.name).toBe('compose-debug'); }); + it('does not match a page-global instance by a build-id define', () => { + ( + globalThis as { FEDERATION_BUILD_IDENTIFIER?: string } + ).FEDERATION_BUILD_IDENTIFIER = 'foreign:1.0.0'; + const foreign = new FederationKernel({ + name: 'foreign-build', + id: 'foreign:1.0.0', + }); + CurrentGlobal.__FEDERATION__.__INSTANCES__.unshift(foreign); + try { + expect(init({ name: 'own-build' })).not.toBe(foreign); + } finally { + delete (globalThis as { FEDERATION_BUILD_IDENTIFIER?: string }) + .FEDERATION_BUILD_IDENTIFIER; + } + }); + it('reuses the bundle-local instance before a page-global match', () => { const first = composeInit({ name: 'compose-reuse' }, {}); const foreign = registerForeignInstance('compose-reuse'); diff --git a/packages/runtime/global.d.ts b/packages/runtime/global.d.ts index a74d2d87a02..012c2a032d0 100644 --- a/packages/runtime/global.d.ts +++ b/packages/runtime/global.d.ts @@ -1,5 +1,4 @@ declare const __VERSION__: string; declare const FEDERATION_DEBUG: string; -declare const FEDERATION_BUILD_IDENTIFIER: string | undefined; declare const __RELEASE_NUMBER__: number; declare const FEDERATION_ALLOW_NEW_FUNCTION: string | undefined; diff --git a/packages/runtime/src/instance.ts b/packages/runtime/src/instance.ts index b7075388fa2..c3e7c13dddc 100644 --- a/packages/runtime/src/instance.ts +++ b/packages/runtime/src/instance.ts @@ -5,30 +5,16 @@ import { type UserOptions, } from '@module-federation/runtime-core/kernel'; -// injected by bundler, so it can not use runtime-core stuff -export function getBuilderId(): string { - //@ts-ignore - return typeof FEDERATION_BUILD_IDENTIFIER !== 'undefined' - ? //@ts-ignore - FEDERATION_BUILD_IDENTIFIER - : ''; -} - export function getGlobalFederationInstance( name: string, version: string | undefined, preferred?: ModuleFederation | null, ): ModuleFederation | undefined { - const buildId = getBuilderId(); return [preferred, ...CurrentGlobal.__FEDERATION__.__INSTANCES__].find( (GMInstance): GMInstance is ModuleFederation => { if (!GMInstance) { return false; } - if (buildId && GMInstance.options.id === buildId) { - return true; - } - if ( GMInstance.options.name === name && !GMInstance.options.version && diff --git a/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts b/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts index 5aebb29acf8..c129a8db928 100644 --- a/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts +++ b/packages/sdk/src/types/plugins/ModuleFederationPlugin.ts @@ -484,11 +484,6 @@ export interface ModuleFederationPluginOptions { dts?: boolean | PluginDtsOptions; virtualRuntimeEntry?: boolean; experiments?: { - /** - * Generate a federation bootstrap that imports only the runtime capabilities this build uses. - * Falls back to the full runtime when the installed runtime cannot be composed. - */ - composedRuntime?: boolean; externalRuntime?: boolean; provideExternalRuntime?: boolean; asyncStartup?: boolean; diff --git a/packages/webpack-bundler-runtime/README.md b/packages/webpack-bundler-runtime/README.md index b82a0435544..1b9739f3b48 100644 --- a/packages/webpack-bundler-runtime/README.md +++ b/packages/webpack-bundler-runtime/README.md @@ -5,7 +5,7 @@ ## Usage -The package needs to be used with webpack/rspack bundler. It will export federation object which includes runtime, instance, bundlerRuntime, initOptions, attachShareScopeMap, bundlerRuntimeOptions. +The package needs to be used with webpack/rspack bundler. It exports a federation object with instance, bundlerRuntime, initOptions, attachShareScopeMap, and bundlerRuntimeOptions. The root includes every adapter and the full runtime. `@module-federation/webpack-bundler-runtime/compose` exports `createFederation`, which the plugins use to build a federation object from only the adapters and capabilities a build needs. After referencing, mount it to the corresponding bundler runtime, and then use the corresponding api/instance. diff --git a/packages/webpack-bundler-runtime/__tests__/compose.spec.ts b/packages/webpack-bundler-runtime/__tests__/compose.spec.ts index f66969b5aaa..4b470b5a387 100644 --- a/packages/webpack-bundler-runtime/__tests__/compose.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/compose.spec.ts @@ -199,6 +199,7 @@ describe('createFederation', () => { federation.initOptions = { name, remotes: [] }; federation.instance = federation.runtime.init(federation.initOptions); expect(federation.instance.options.id).toBe(`${name}:1.0.0`); + expect(federation.runtime).not.toHaveProperty('loadScriptNode'); }); }); diff --git a/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts b/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts index 14c5778fbee..e8fe284cc63 100644 --- a/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts +++ b/packages/webpack-bundler-runtime/__tests__/legacy-root.spec.ts @@ -3,12 +3,12 @@ import * as legacyRoot from '../src/index'; describe('legacy root keeps the keys rspack copies onto federation', () => { test('keeps the default export keys', () => { expect(Object.keys(legacyRoot.default)).toEqual([ - 'runtime', 'instance', 'initOptions', 'bundlerRuntime', 'attachShareScopeMap', 'bundlerRuntimeOptions', + 'runtime', ]); }); @@ -27,6 +27,30 @@ describe('legacy root keeps the keys rspack copies onto federation', () => { ); }); + test('ignores the removed capability defines', () => { + const globals = globalThis as Record; + globals['FEDERATION_OPTIMIZE_NO_REMOTE'] = true; + globals['FEDERATION_OPTIMIZE_NO_SHARED'] = true; + globals['FEDERATION_HAS_EXPOSES'] = false; + try { + jest.isolateModules(() => { + const root = require('../src/index') as typeof legacyRoot; + expect(Object.keys(root.default.bundlerRuntime!)).toEqual( + expect.arrayContaining([ + 'remotes', + 'consumes', + 'I', + 'initContainerEntry', + ]), + ); + }); + } finally { + delete globals['FEDERATION_OPTIMIZE_NO_REMOTE']; + delete globals['FEDERATION_OPTIMIZE_NO_SHARED']; + delete globals['FEDERATION_HAS_EXPOSES']; + } + }); + test('keeps the named exports', () => { expect(Object.keys(legacyRoot).sort()).toEqual( [ @@ -36,13 +60,23 @@ describe('legacy root keeps the keys rspack copies onto federation', () => { 'default', 'initOptions', 'instance', - 'runtime', ].sort(), ); }); - test('keeps federation.runtime as the runtime namespace', () => { - expect(typeof legacyRoot.default.runtime!.init).toBe('function'); - expect(legacyRoot.runtime).toBe(legacyRoot.default.runtime); + test('keeps only runtime.init, not the runtime namespace', () => { + expect(Object.keys(legacyRoot.default.runtime!)).toEqual(['init']); + expect(legacyRoot).not.toHaveProperty('runtime'); + }); + + test('rspack 1.x initializes through runtime.init on the copied keys', () => { + // @rspack/core 1.7.9 moduleFederationDefaultRuntime: copy the keys, then call runtime.init. + const federation: any = {}; + for (const key in legacyRoot.default) { + federation[key] = (legacyRoot.default as any)[key]; + } + federation.initOptions = { name: 'legacy-root-rspack1', remotes: [] }; + federation.instance = federation.runtime.init(federation.initOptions); + expect(federation.instance.name).toBe('legacy-root-rspack1'); }); }); diff --git a/packages/webpack-bundler-runtime/src/compose.ts b/packages/webpack-bundler-runtime/src/compose.ts index 8ef78bb662b..9e3c9d4a45d 100644 --- a/packages/webpack-bundler-runtime/src/compose.ts +++ b/packages/webpack-bundler-runtime/src/compose.ts @@ -14,7 +14,7 @@ import type { export type ComposedFederation = Omit< Federation, - 'runtime' | 'instance' | 'bundlerRuntime' + 'instance' | 'bundlerRuntime' | 'runtime' > & { instance?: FederationKernel; bundlerRuntime: Partial & { diff --git a/packages/webpack-bundler-runtime/src/index.ts b/packages/webpack-bundler-runtime/src/index.ts index 984c9cadefd..924a8c76bd6 100644 --- a/packages/webpack-bundler-runtime/src/index.ts +++ b/packages/webpack-bundler-runtime/src/index.ts @@ -1,4 +1,4 @@ -import * as runtime from '@module-federation/runtime'; +import { init } from '@module-federation/runtime'; import type { Adapter, Federation, WebpackRequire } from './types'; import { attachShareScopeMap } from './attachShareScopeMap'; import { remotes } from './adapters/remotes'; @@ -6,27 +6,9 @@ import { consumes } from './adapters/consumes'; import { shareScope } from './adapters/share-scope'; import { container } from './adapters/container'; -declare const FEDERATION_OPTIMIZE_NO_REMOTE: boolean; -declare const FEDERATION_OPTIMIZE_NO_SHARED: boolean; -declare const FEDERATION_HAS_EXPOSES: boolean; - export * from './types'; -const USE_REMOTE = - typeof FEDERATION_OPTIMIZE_NO_REMOTE === 'boolean' - ? !FEDERATION_OPTIMIZE_NO_REMOTE - : true; -const USE_SHARED = - typeof FEDERATION_OPTIMIZE_NO_SHARED === 'boolean' - ? !FEDERATION_OPTIMIZE_NO_SHARED - : true; -const USE_EXPOSES = - typeof FEDERATION_HAS_EXPOSES === 'boolean' ? FEDERATION_HAS_EXPOSES : true; - -const adapters: Adapter[] = []; -if (USE_REMOTE) adapters.push(remotes); -if (USE_SHARED) adapters.push(consumes, shareScope); -if (USE_EXPOSES) adapters.push(container); +const adapters: Adapter[] = [remotes, consumes, shareScope, container]; const bundlerRuntime = Object.assign( { @@ -39,23 +21,24 @@ const bundlerRuntime = Object.assign( for (const adapter of adapters) { adapter.beforeInit?.(webpackRequire, initOptions); } - return webpackRequire.federation.runtime!.init(initOptions); + return init(initOptions); }, }, ...adapters.map((adapter) => adapter.bundlerRuntime), ) as NonNullable; const federation: Federation = { - runtime, instance: undefined, initOptions: undefined, bundlerRuntime, attachShareScopeMap, bundlerRuntimeOptions: {}, + // rspack native runtimes before 2.0.0-beta.1 call federation.runtime.init. + runtime: { init }, }; // Keep CJS interop stable for consumers that iterate required keys directly. -export { runtime, attachShareScopeMap }; +export { attachShareScopeMap }; export const instance: Federation['instance'] = federation.instance; export const initOptions: Federation['initOptions'] = federation.initOptions; export { bundlerRuntime }; diff --git a/packages/webpack-bundler-runtime/src/types.ts b/packages/webpack-bundler-runtime/src/types.ts index c8f475c36cb..a491c76f3c9 100644 --- a/packages/webpack-bundler-runtime/src/types.ts +++ b/packages/webpack-bundler-runtime/src/types.ts @@ -216,7 +216,7 @@ export interface GetSharedFallbackGetterOptions { } export interface Federation { - runtime?: typeof runtime; + runtime?: { init: typeof runtime.init }; instance?: runtime.ModuleFederation; initOptions?: InitOptions; installInitialConsumes?: (options: InstallInitialConsumesOptions) => any;