diff --git a/crates/node_binding/napi-binding.d.ts b/crates/node_binding/napi-binding.d.ts index e7a4ef7b665f..1619b3376226 100644 --- a/crates/node_binding/napi-binding.d.ts +++ b/crates/node_binding/napi-binding.d.ts @@ -545,6 +545,7 @@ export declare enum BuiltinPluginName { ProvideSharedPlugin = 'ProvideSharedPlugin', ConsumeSharedPlugin = 'ConsumeSharedPlugin', ModuleFederationRuntimePlugin = 'ModuleFederationRuntimePlugin', + ModuleFederationManifestPlugin = 'ModuleFederationManifestPlugin', NamedModuleIdsPlugin = 'NamedModuleIdsPlugin', NaturalModuleIdsPlugin = 'NaturalModuleIdsPlugin', DeterministicModuleIdsPlugin = 'DeterministicModuleIdsPlugin', @@ -2434,6 +2435,32 @@ export interface RawLimitChunkCountPluginOptions { maxChunks: number } +export interface RawManifestExposeOption { + path: string + name: string +} + +export interface RawManifestSharedOption { + name: string + version?: string + requiredVersion?: string + singleton?: boolean +} + +export interface RawModuleFederationManifestPluginOptions { + name?: string + globalName?: string + fileName?: string + filePath?: string + statsFileName?: string + manifestFileName?: string + disableAssetsAnalyze?: boolean + remoteAliasMap?: Record + exposes?: Array + shared?: Array + buildInfo?: RawStatsBuildInfo +} + export interface RawModuleFederationRuntimePluginOptions { entryRuntime?: string | undefined } @@ -2656,6 +2683,11 @@ export interface RawRelated { sourceMap?: string } +export interface RawRemoteAliasTarget { + name: string + entry?: string +} + export interface RawRemoteOptions { key: string external: Array @@ -2815,6 +2847,11 @@ export interface RawSplitChunksOptions { maxInitialSize?: number | RawSplitChunkSizes } +export interface RawStatsBuildInfo { + buildVersion: string + buildName?: string +} + export interface RawStatsOptions { colors: boolean } diff --git a/crates/rspack_binding_api/src/raw_options/raw_builtins/mod.rs b/crates/rspack_binding_api/src/raw_options/raw_builtins/mod.rs index 4b9c99725ca8..a9073f95dba0 100644 --- a/crates/rspack_binding_api/src/raw_options/raw_builtins/mod.rs +++ b/crates/rspack_binding_api/src/raw_options/raw_builtins/mod.rs @@ -32,7 +32,7 @@ use napi_derive::napi; use raw_dll::{RawDllReferenceAgencyPluginOptions, RawFlagAllModulesAsUsedPluginOptions}; use raw_ids::RawOccurrenceChunkIdsPluginOptions; use raw_lightning_css_minimizer::RawLightningCssMinimizerRspackPluginOptions; -use raw_mf::RawModuleFederationRuntimePluginOptions; +use raw_mf::{RawModuleFederationManifestPluginOptions, RawModuleFederationRuntimePluginOptions}; use raw_sri::RawSubresourceIntegrityPluginOptions; use rspack_core::{BoxPlugin, Plugin, PluginExt}; use rspack_error::{Result, ToStringResultToRspackResultExt}; @@ -75,8 +75,8 @@ use rspack_plugin_lightning_css_minimizer::LightningCssMinimizerRspackPlugin; use rspack_plugin_limit_chunk_count::LimitChunkCountPlugin; use rspack_plugin_merge_duplicate_chunks::MergeDuplicateChunksPlugin; use rspack_plugin_mf::{ - ConsumeSharedPlugin, ContainerPlugin, ContainerReferencePlugin, ModuleFederationRuntimePlugin, - ProvideSharedPlugin, ShareRuntimePlugin, + ConsumeSharedPlugin, ContainerPlugin, ContainerReferencePlugin, ModuleFederationManifestPlugin, + ModuleFederationRuntimePlugin, ProvideSharedPlugin, ShareRuntimePlugin, }; use rspack_plugin_module_info_header::ModuleInfoHeaderPlugin; use rspack_plugin_module_replacement::{ContextReplacementPlugin, NormalModuleReplacementPlugin}; @@ -172,6 +172,7 @@ pub enum BuiltinPluginName { ProvideSharedPlugin, ConsumeSharedPlugin, ModuleFederationRuntimePlugin, + ModuleFederationManifestPlugin, NamedModuleIdsPlugin, NaturalModuleIdsPlugin, DeterministicModuleIdsPlugin, @@ -504,6 +505,11 @@ impl<'a> BuiltinPlugin<'a> { .map_err(|report| napi::Error::from_reason(report.to_string()))?; plugins.push(ModuleFederationRuntimePlugin::new(options.into()).boxed()) } + BuiltinPluginName::ModuleFederationManifestPlugin => { + let options = downcast_into::(self.options) + .map_err(|report| napi::Error::from_reason(report.to_string()))?; + plugins.push(ModuleFederationManifestPlugin::new(options.into()).boxed()) + } BuiltinPluginName::NamedModuleIdsPlugin => { plugins.push(NamedModuleIdsPlugin::default().boxed()) } diff --git a/crates/rspack_binding_api/src/raw_options/raw_builtins/raw_mf.rs b/crates/rspack_binding_api/src/raw_options/raw_builtins/raw_mf.rs index 44571fbcca6b..03adc9c05724 100644 --- a/crates/rspack_binding_api/src/raw_options/raw_builtins/raw_mf.rs +++ b/crates/rspack_binding_api/src/raw_options/raw_builtins/raw_mf.rs @@ -1,11 +1,12 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use napi::Either; use napi_derive::napi; use rspack_plugin_mf::{ ConsumeOptions, ConsumeSharedPluginOptions, ConsumeVersion, ContainerPluginOptions, - ContainerReferencePluginOptions, ExposeOptions, ModuleFederationRuntimePluginOptions, - ProvideOptions, ProvideVersion, RemoteOptions, + ContainerReferencePluginOptions, ExposeOptions, ManifestExposeOption, ManifestSharedOption, + ModuleFederationManifestPluginOptions, ModuleFederationRuntimePluginOptions, ProvideOptions, + ProvideVersion, RemoteAliasTarget, RemoteOptions, StatsBuildInfo, }; use crate::options::{ @@ -224,3 +225,99 @@ impl From for ModuleFederationRuntimePl } } } + +#[derive(Debug)] +#[napi(object)] +pub struct RawRemoteAliasTarget { + pub name: String, + pub entry: Option, +} + +#[derive(Debug)] +#[napi(object)] +pub struct RawManifestExposeOption { + pub path: String, + pub name: String, +} + +#[derive(Debug)] +#[napi(object)] +pub struct RawManifestSharedOption { + pub name: String, + pub version: Option, + pub required_version: Option, + pub singleton: Option, +} + +#[derive(Debug)] +#[napi(object)] +pub struct RawStatsBuildInfo { + pub build_version: String, + pub build_name: Option, +} + +#[derive(Debug)] +#[napi(object)] +pub struct RawModuleFederationManifestPluginOptions { + pub name: Option, + pub global_name: Option, + pub file_name: Option, + pub file_path: Option, + pub stats_file_name: Option, + pub manifest_file_name: Option, + pub disable_assets_analyze: Option, + pub remote_alias_map: Option>, + pub exposes: Option>, + pub shared: Option>, + pub build_info: Option, +} + +impl From for ModuleFederationManifestPluginOptions { + fn from(value: RawModuleFederationManifestPluginOptions) -> Self { + ModuleFederationManifestPluginOptions { + name: value.name, + global_name: value.global_name, + stats_file_name: value.stats_file_name.unwrap_or_default(), + manifest_file_name: value.manifest_file_name.unwrap_or_default(), + disable_assets_analyze: value.disable_assets_analyze.unwrap_or(false), + remote_alias_map: value + .remote_alias_map + .unwrap_or_default() + .into_iter() + .map(|(k, v)| { + ( + k, + RemoteAliasTarget { + name: v.name, + entry: v.entry, + }, + ) + }) + .collect::>(), + exposes: value + .exposes + .unwrap_or_default() + .into_iter() + .map(|expose| ManifestExposeOption { + path: expose.path, + name: expose.name, + }) + .collect(), + shared: value + .shared + .unwrap_or_default() + .into_iter() + .map(|shared| ManifestSharedOption { + name: shared.name, + version: shared.version, + required_version: shared.required_version, + singleton: shared.singleton, + }) + .collect(), + build_info: value.build_info.map(|info| StatsBuildInfo { + build_version: info.build_version, + build_name: info.build_name, + }), + } + } +} diff --git a/crates/rspack_plugin_mf/src/container/container_entry_module.rs b/crates/rspack_plugin_mf/src/container/container_entry_module.rs index 24d3d7621b09..a5c00ce67ef2 100644 --- a/crates/rspack_plugin_mf/src/container/container_entry_module.rs +++ b/crates/rspack_plugin_mf/src/container/container_entry_module.rs @@ -73,6 +73,10 @@ impl ContainerEntryModule { enhanced, } } + + pub fn exposes(&self) -> &[(String, ExposeOptions)] { + &self.exposes + } } impl Identifiable for ContainerEntryModule { diff --git a/crates/rspack_plugin_mf/src/lib.rs b/crates/rspack_plugin_mf/src/lib.rs index 6c01ac47d405..2c079ace82da 100644 --- a/crates/rspack_plugin_mf/src/lib.rs +++ b/crates/rspack_plugin_mf/src/lib.rs @@ -1,4 +1,5 @@ mod container; +mod manifest; mod sharing; pub use container::{ @@ -10,6 +11,10 @@ pub use container::{ ModuleFederationRuntimePlugin, ModuleFederationRuntimePluginOptions, }, }; +pub use manifest::{ + ManifestExposeOption, ManifestSharedOption, ModuleFederationManifestPlugin, + ModuleFederationManifestPluginOptions, RemoteAliasTarget, StatsBuildInfo, +}; pub use sharing::{ consume_shared_module::ConsumeSharedModule, consume_shared_plugin::{ diff --git a/crates/rspack_plugin_mf/src/manifest/asset.rs b/crates/rspack_plugin_mf/src/manifest/asset.rs new file mode 100644 index 000000000000..987b5bad7bbc --- /dev/null +++ b/crates/rspack_plugin_mf/src/manifest/asset.rs @@ -0,0 +1,217 @@ +use std::path::Path; + +use rspack_core::{BoxModule, Compilation, ModuleGraph, ModuleIdentifier, NormalModule}; +use rspack_util::fx_hash::FxHashSet as HashSet; + +use super::{ + data::{AssetsSplit, StatsAssetsGroup}, + utils::is_hot_file, +}; + +pub fn collect_assets_from_chunk( + compilation: &Compilation, + chunk_key: &rspack_core::ChunkUkey, + entry_point_names: &HashSet, +) -> StatsAssetsGroup { + let mut js_sync = HashSet::::default(); + let mut js_async = HashSet::::default(); + let mut css_sync = HashSet::::default(); + let mut css_async = HashSet::::default(); + let chunk = compilation.chunk_by_ukey.expect_get(chunk_key); + + for file in chunk.files() { + if file.ends_with(".css") { + css_sync.insert(file.clone()); + } else if !is_hot_file(file) { + js_sync.insert(file.clone()); + } + } + + for cg in chunk.groups() { + let group = compilation.chunk_group_by_ukey.expect_get(cg); + if let Some(name) = group.name() { + let skip = entry_point_names.contains(name); + if !skip { + for file in group.get_files(&compilation.chunk_by_ukey) { + if file.ends_with(".css") { + css_sync.insert(file.to_string()); + } else if !is_hot_file(&file) { + js_sync.insert(file); + } + } + } + } + } + + for async_chunk_key in chunk.get_all_async_chunks(&compilation.chunk_group_by_ukey) { + let async_chunk = compilation.chunk_by_ukey.expect_get(&async_chunk_key); + for file in async_chunk.files() { + if file.ends_with(".css") { + css_async.insert(file.clone()); + } else if !is_hot_file(file) { + js_async.insert(file.clone()); + } + } + for cg in async_chunk.groups() { + let group = compilation.chunk_group_by_ukey.expect_get(cg); + if let Some(name) = group.name() { + let skip = entry_point_names.contains(name); + if !skip { + for file in group.get_files(&compilation.chunk_by_ukey) { + if file.ends_with(".css") { + css_async.insert(file.to_string()); + } else if !is_hot_file(&file) { + js_async.insert(file); + } + } + } + } + } + } + + StatsAssetsGroup { + js: AssetsSplit { + sync: js_sync.into_iter().collect(), + r#async: js_async.into_iter().collect(), + }, + css: AssetsSplit { + sync: css_sync.into_iter().collect(), + r#async: css_async.into_iter().collect(), + }, + } +} + +pub fn merge_assets_group(target: &mut StatsAssetsGroup, source: StatsAssetsGroup) { + target.js.sync.extend(source.js.sync); + target.js.r#async.extend(source.js.r#async); + target.css.sync.extend(source.css.sync); + target.css.r#async.extend(source.css.r#async); +} + +pub fn empty_assets_group() -> StatsAssetsGroup { + StatsAssetsGroup { + js: AssetsSplit::default(), + css: AssetsSplit::default(), + } +} + +pub fn normalize_assets_group(group: &mut StatsAssetsGroup) { + group.js.sync.sort(); + group.js.sync.dedup(); + group.js.r#async.sort(); + group.js.r#async.dedup(); + group.css.sync.sort(); + group.css.sync.dedup(); + group.css.r#async.sort(); + group.css.r#async.dedup(); +} + +pub fn collect_assets_for_module( + compilation: &Compilation, + module_identifier: &ModuleIdentifier, + entry_point_names: &HashSet, +) -> Option { + let chunk_graph = &compilation.chunk_graph; + if chunk_graph.get_number_of_module_chunks(*module_identifier) == 0 { + return None; + } + let mut result = empty_assets_group(); + for chunk_ukey in chunk_graph.get_module_chunks(*module_identifier) { + let chunk_assets = collect_assets_from_chunk(compilation, chunk_ukey, entry_point_names); + merge_assets_group(&mut result, chunk_assets); + } + normalize_assets_group(&mut result); + Some(result) +} + +pub fn collect_usage_files_for_module( + compilation: &Compilation, + module_graph: &ModuleGraph, + module_identifier: &ModuleIdentifier, + entry_point_names: &HashSet, +) -> Vec { + let mut files = HashSet::default(); + for connection in module_graph.get_incoming_connections(module_identifier) { + let origin_identifier = connection + .original_module_identifier + .or(connection.resolved_original_module_identifier); + let Some(origin) = origin_identifier else { + continue; + }; + if let Some(path) = module_graph + .module_by_identifier(&origin) + .and_then(|module| module_source_path(module, compilation)) + { + files.insert(path); + continue; + } + if let Some(assets) = collect_assets_for_module(compilation, &origin, entry_point_names) { + files.extend(assets.js.sync); + files.extend(assets.js.r#async); + } else if let Some(origin_module) = module_graph.module_by_identifier(&origin) { + files.insert(origin_module.identifier().to_string()); + } + } + let mut collected: Vec = files.into_iter().collect(); + collected.sort(); + collected +} + +pub fn module_source_path(module: &BoxModule, compilation: &Compilation) -> Option { + if let Some(normal_module) = module.as_ref().as_any().downcast_ref::() + && let Some(path) = normal_module.resource_resolved_data().path() + { + let context_path = compilation.options.context.as_path(); + let relative = Path::new(path.as_str()) + .strip_prefix(context_path) + .unwrap_or_else(|_| Path::new(path.as_str())); + let mut display = relative.to_string_lossy().into_owned(); + if display.is_empty() { + display = path.as_str().to_string(); + } + if display.starts_with("./") { + display.drain(..2); + } else if display.starts_with('/') { + display = display.trim_start_matches('/').to_string(); + } + if display.is_empty() { + return None; + } + let normalized: String = display + .chars() + .map(|c| if c == '\\' { '/' } else { c }) + .collect(); + if normalized.is_empty() { + return None; + } + return Some(normalized); + } + + let mut identifier = module + .readable_identifier(&compilation.options.context) + .to_string(); + if identifier.is_empty() { + return None; + } + if let Some(pos) = identifier.rfind('!') { + identifier = identifier.split_off(pos + 1); + } + if let Some(pos) = identifier.find('?') { + identifier.truncate(pos); + } + if identifier.starts_with("./") { + identifier.drain(..2); + } + if identifier.is_empty() { + return None; + } + let normalized: String = identifier + .chars() + .map(|c| if c == '\\' { '/' } else { c }) + .collect(); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} diff --git a/crates/rspack_plugin_mf/src/manifest/data.rs b/crates/rspack_plugin_mf/src/manifest/data.rs new file mode 100644 index 000000000000..cac7ab0eebba --- /dev/null +++ b/crates/rspack_plugin_mf/src/manifest/data.rs @@ -0,0 +1,143 @@ +use serde::Serialize; + +#[derive(Debug, Serialize, Clone, Default)] +pub struct StatsAssetsGroup { + #[serde(default)] + pub js: AssetsSplit, + #[serde(default)] + pub css: AssetsSplit, +} + +#[derive(Debug, Serialize, Clone, Default)] +pub struct AssetsSplit { + #[serde(default)] + pub sync: Vec, + #[serde(default)] + pub r#async: Vec, +} + +#[derive(Debug, Serialize, Clone, Default)] +pub struct StatsBuildInfo { + #[serde(rename = "buildVersion")] + pub build_version: String, + #[serde(rename = "buildName", skip_serializing_if = "Option::is_none")] + pub build_name: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct StatsExpose { + pub path: String, + pub id: String, + pub name: String, + #[serde(default)] + pub requires: Vec, + #[serde(default)] + pub assets: StatsAssetsGroup, +} + +#[derive(Debug, Serialize, Clone)] +pub struct StatsShared { + pub id: String, + pub name: String, + pub version: String, + #[serde(default)] + pub requiredVersion: Option, + #[serde(default)] + pub singleton: Option, + #[serde(default)] + pub assets: StatsAssetsGroup, + #[serde(default)] + pub usedIn: Vec, +} + +#[derive(Debug, Serialize, Clone)] +pub struct StatsRemote { + pub alias: String, + pub consumingFederationContainerName: String, + pub federationContainerName: String, + pub moduleName: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub entry: Option, + #[serde(default)] + pub usedIn: Vec, +} + +#[derive(Debug, Serialize, Clone)] +pub struct BasicStatsMetaData { + pub name: String, + pub globalName: String, + #[serde(rename = "buildInfo", skip_serializing_if = "Option::is_none")] + pub build_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub publicPath: Option, + #[serde(default)] + pub remoteEntry: RemoteEntryMeta, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, +} + +#[derive(Debug, Serialize, Clone, Default)] +pub struct RemoteEntryMeta { + #[serde(default)] + pub name: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub r#type: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct StatsRoot { + pub id: String, + pub name: String, + pub metaData: BasicStatsMetaData, + #[serde(default)] + pub shared: Vec, + #[serde(default)] + pub remotes: Vec, + #[serde(default)] + pub exposes: Vec, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ManifestExpose { + pub id: String, + pub name: String, + pub path: String, + pub assets: StatsAssetsGroup, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ManifestShared { + pub id: String, + pub name: String, + pub version: String, + #[serde(default)] + pub requiredVersion: Option, + #[serde(default)] + pub singleton: Option, + #[serde(default)] + pub assets: StatsAssetsGroup, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ManifestRemote { + pub federationContainerName: String, + pub moduleName: String, + pub alias: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub entry: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub struct ManifestRoot { + pub id: String, + pub name: String, + pub metaData: BasicStatsMetaData, + #[serde(default)] + pub shared: Vec, + #[serde(default)] + pub remotes: Vec, + #[serde(default)] + pub exposes: Vec, +} diff --git a/crates/rspack_plugin_mf/src/manifest/mod.rs b/crates/rspack_plugin_mf/src/manifest/mod.rs new file mode 100644 index 000000000000..17cb95e13ecd --- /dev/null +++ b/crates/rspack_plugin_mf/src/manifest/mod.rs @@ -0,0 +1,587 @@ +#![allow(non_snake_case)] + +mod asset; +mod data; +mod options; +mod utils; + +use std::path::Path; + +use asset::{ + collect_assets_for_module, collect_assets_from_chunk, collect_usage_files_for_module, + empty_assets_group, module_source_path, normalize_assets_group, +}; +pub use data::StatsBuildInfo; +use data::{ + BasicStatsMetaData, ManifestExpose, ManifestRemote, ManifestRoot, ManifestShared, + RemoteEntryMeta, StatsAssetsGroup, StatsExpose, StatsRemote, StatsRoot, StatsShared, +}; +pub use options::{ + ManifestExposeOption, ManifestSharedOption, ModuleFederationManifestPluginOptions, + RemoteAliasTarget, +}; +use rspack_core::{ + Compilation, CompilationAsset, CompilationProcessAssets, ModuleIdentifier, ModuleType, Plugin, + PublicPath, + rspack_sources::{RawStringSource, SourceExt}, +}; +use rspack_error::Result; +use rspack_hook::{plugin, plugin_hook}; +use rspack_util::fx_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; +use utils::{ + collect_expose_requirements, compose_id_with_separator, ensure_shared_entry, is_hot_file, + parse_consume_shared_identifier, parse_provide_shared_identifier, record_shared_usage, strip_ext, +}; + +use crate::container::{container_entry_module::ContainerEntryModule, remote_module::RemoteModule}; + +#[plugin] +#[derive(Debug)] +pub struct ModuleFederationManifestPlugin { + options: ModuleFederationManifestPluginOptions, +} +impl ModuleFederationManifestPlugin { + pub fn new(options: ModuleFederationManifestPluginOptions) -> Self { + Self::new_inner(options) + } +} +fn get_remote_entry_name(compilation: &Compilation, container_name: &str) -> Option { + let chunk_group_ukey = compilation.entrypoints.get(container_name)?; + let chunk_group = compilation.chunk_group_by_ukey.expect_get(chunk_group_ukey); + + let pick_chunk_file = |chunk_ukey: &rspack_core::ChunkUkey| -> Option { + let chunk = compilation.chunk_by_ukey.expect_get(chunk_ukey); + chunk + .files() + .iter() + .find(|file| !file.ends_with(".css") && !is_hot_file(file)) + .cloned() + }; + + // Prefer the actual entry chunk if it exists. + let entry_chunk_file = { + let entry_chunk_key = chunk_group.get_entrypoint_chunk(); + pick_chunk_file(&entry_chunk_key) + }; + if entry_chunk_file.is_some() { + return entry_chunk_file; + } + + // Fallback to the runtime chunk (some configurations emit the entry file there). + let runtime_chunk_file = { + let runtime_chunk_key = chunk_group.get_runtime_chunk(&compilation.chunk_group_by_ukey); + pick_chunk_file(&runtime_chunk_key) + }; + if runtime_chunk_file.is_some() { + return runtime_chunk_file; + } + + // Finally, search every chunk in the group. + for chunk_key in chunk_group.chunks.iter() { + if let Some(file) = pick_chunk_file(chunk_key) { + return Some(file); + } + } + None +} +#[plugin_hook(CompilationProcessAssets for ModuleFederationManifestPlugin)] +async fn process_assets(&self, compilation: &mut Compilation) -> Result<()> { + // Prepare entrypoint names + let entry_point_names: HashSet = compilation + .entrypoints + .keys() + .map(|k| k.to_string()) + .collect(); + // Build metaData + let container_name = self + .options + .name + .clone() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| compilation.options.output.unique_name.clone()); + let global_name = self + .options + .global_name + .clone() + .filter(|s| !s.is_empty()) + .or_else(|| { + compilation + .options + .output + .library + .as_ref() + .and_then(|l| match &l.name { + Some(rspack_core::LibraryName::NonUmdObject( + rspack_core::LibraryNonUmdObject::String(s), + )) => Some(s.clone()), + _ => None, + }) + }) + .unwrap_or_else(|| container_name.clone()); + let entry_name = get_remote_entry_name(compilation, &container_name).unwrap_or_default(); + let public_path = match &compilation.options.output.public_path { + PublicPath::Auto => Some("auto".to_string()), + PublicPath::Filename(f) => Some(PublicPath::render_filename(compilation, f).await), + }; + let meta = BasicStatsMetaData { + name: container_name.clone(), + globalName: global_name, + build_info: self.options.build_info.clone(), + publicPath: public_path, + remoteEntry: RemoteEntryMeta { + name: entry_name.clone(), + path: String::new(), + r#type: compilation + .options + .output + .library + .as_ref() + .map(|l| l.library_type.clone()) + .unwrap_or_else(|| "global".to_string()), + }, + r#type: None, + }; + let (exposes, shared, remote_list) = if self.options.disable_assets_analyze { + let exposes = self + .options + .exposes + .iter() + .map(|expose| { + let expose_name = expose.path.trim_start_matches("./").to_string(); + StatsExpose { + path: expose.path.clone(), + id: compose_id_with_separator(&container_name, &expose_name), + name: expose_name, + requires: Vec::new(), + assets: StatsAssetsGroup::default(), + } + }) + .collect::>(); + let shared = self + .options + .shared + .iter() + .map(|shared| StatsShared { + id: compose_id_with_separator(&container_name, &shared.name), + name: shared.name.clone(), + version: shared.version.clone().unwrap_or_default(), + requiredVersion: shared.required_version.clone(), + singleton: shared.singleton, + assets: StatsAssetsGroup::default(), + usedIn: Vec::new(), + }) + .collect::>(); + let remote_list = self + .options + .remote_alias_map + .iter() + .map(|(alias, target)| { + let remote_container_name = if target.name.is_empty() { + alias.clone() + } else { + target.name.clone() + }; + StatsRemote { + alias: alias.clone(), + consumingFederationContainerName: container_name.clone(), + federationContainerName: remote_container_name.clone(), + moduleName: remote_container_name, + entry: target.entry.clone(), + usedIn: vec!["UNKNOWN".to_string()], + } + }) + .collect::>(); + (exposes, shared, remote_list) + } else { + let module_graph = compilation.get_module_graph(); + let should_collect_module = |module_id: &ModuleIdentifier| -> bool { + module_graph + .module_by_identifier(module_id) + .map(|module| { + !matches!( + module.module_type(), + ModuleType::ProvideShared | ModuleType::ConsumeShared | ModuleType::Runtime + ) + }) + .unwrap_or(false) + }; + + let mut exposes_map: HashMap = HashMap::default(); + let mut shared_map: HashMap = HashMap::default(); + let mut shared_usage_links: Vec<(String, String)> = Vec::new(); + let mut shared_module_targets: HashMap> = HashMap::default(); + let mut module_ids_by_name: HashMap = HashMap::default(); + let mut remote_module_ids: Vec = Vec::new(); + let mut container_entry_module: Option = None; + for (_, module) in module_graph.modules().into_iter() { + let module_identifier = module.identifier(); + if let Some(path) = module_source_path(module, compilation) { + let stripped = strip_ext(&path); + if !stripped.is_empty() { + module_ids_by_name + .entry(stripped.clone()) + .or_insert(module_identifier); + if !stripped.starts_with("./") { + module_ids_by_name + .entry(format!("./{}", stripped)) + .or_insert(module_identifier); + } + if let Some(file_name) = Path::new(&stripped).file_name().and_then(|f| f.to_str()) { + module_ids_by_name + .entry(file_name.to_string()) + .or_insert(module_identifier); + let file_base = strip_ext(file_name); + if !file_base.is_empty() { + module_ids_by_name + .entry(file_base.to_string()) + .or_insert(module_identifier); + } + } + } + } + + if let Some(container_entry) = module + .as_ref() + .as_any() + .downcast_ref::() + { + container_entry_module = Some(module_identifier); + for (expose_key, options) in container_entry.exposes() { + let expose_name = expose_key.trim_start_matches("./").to_string(); + let Some(import) = options.import.iter().find(|request| !request.is_empty()) else { + continue; + }; + let id_comp = compose_id_with_separator(&container_name, &expose_name); + let expose_file_key = strip_ext(import); + exposes_map.entry(expose_file_key).or_insert(StatsExpose { + path: expose_key.clone(), + id: id_comp, + name: expose_name, + requires: Vec::new(), + assets: StatsAssetsGroup::default(), + }); + } + continue; + } + + let module_type = module.module_type(); + let identifier = module_identifier.to_string(); + + if matches!(module_type, ModuleType::Remote) { + remote_module_ids.push(module_identifier); + } + + if matches!(module_type, ModuleType::ProvideShared) { + if let Some((pkg, ver)) = parse_provide_shared_identifier(&identifier) { + let entry = ensure_shared_entry(&mut shared_map, &container_name, &pkg); + if entry.version.is_empty() { + entry.version = ver; + } + let targets = shared_module_targets.entry(pkg.clone()).or_default(); + for connection in module_graph.get_outgoing_connections(&module_identifier) { + let referenced = *connection.module_identifier(); + if should_collect_module(&referenced) { + targets.insert(referenced); + } + let resolved = connection.resolved_module; + if should_collect_module(&resolved) { + targets.insert(resolved); + } + } + record_shared_usage( + &mut shared_usage_links, + &pkg, + &module_identifier, + &module_graph, + compilation, + ); + } + continue; + } + + if matches!(module_type, ModuleType::ConsumeShared) + && let Some((pkg, required)) = parse_consume_shared_identifier(&identifier) + { + let mut target_ids: HashSet = HashSet::default(); + for connection in module_graph.get_outgoing_connections(&module_identifier) { + let module_id = *connection.module_identifier(); + if should_collect_module(&module_id) { + target_ids.insert(module_id); + } + let resolved = connection.resolved_module; + if should_collect_module(&resolved) { + target_ids.insert(resolved); + } + } + shared_module_targets + .entry(pkg.clone()) + .or_default() + .extend(target_ids.into_iter()); + let entry = ensure_shared_entry(&mut shared_map, &container_name, &pkg); + if entry.requiredVersion.is_none() && required.is_some() { + entry.requiredVersion = required; + } + record_shared_usage( + &mut shared_usage_links, + &pkg, + &module_identifier, + &module_graph, + compilation, + ); + } + } + + let mut expose_module_paths: HashMap = HashMap::default(); + for expose_key in exposes_map.keys() { + if let Some(module_id) = module_ids_by_name.get(expose_key) + && let Some(module) = module_graph.module_by_identifier(module_id) + && let Some(path) = module_source_path(module, compilation) + { + expose_module_paths.insert(expose_key.clone(), path); + } + } + + let shared_usage_links_for_requirements = shared_usage_links.clone(); + collect_expose_requirements( + &mut shared_map, + &mut exposes_map, + shared_usage_links_for_requirements, + &expose_module_paths, + ); + let chunk_graph = &compilation.chunk_graph; + let mut shared_chunk_map: HashMap> = HashMap::default(); + for (pkg, module_ids) in &shared_module_targets { + let entry = shared_chunk_map.entry(pkg.clone()).or_default(); + for module_id in module_ids { + for chunk_ukey in chunk_graph.get_module_chunks(*module_id).iter() { + entry.insert(*chunk_ukey); + let chunk = compilation.chunk_by_ukey.expect_get(chunk_ukey); + for group_ukey in chunk.groups() { + let group = compilation.chunk_group_by_ukey.expect_get(group_ukey); + if let Some(name) = group.name() + && !entry_point_names.contains(name) + { + for extra_chunk in group.chunks.iter() { + entry.insert(*extra_chunk); + } + } + } + } + } + } + + let mut aggregated_shared_assets: HashMap = HashMap::default(); + for (pkg, chunk_ids) in shared_chunk_map { + let entry = aggregated_shared_assets + .entry(pkg) + .or_insert_with(empty_assets_group); + for chunk_ukey in chunk_ids { + let chunk_assets = collect_assets_from_chunk(compilation, &chunk_ukey, &entry_point_names); + entry.js.sync.extend(chunk_assets.js.sync); + entry.css.sync.extend(chunk_assets.css.sync); + } + } + + let mut shared_asset_files: HashSet = HashSet::default(); + for (pkg, mut assets) in aggregated_shared_assets { + normalize_assets_group(&mut assets); + assets.js.r#async.clear(); + assets.css.r#async.clear(); + shared_asset_files.extend(assets.js.sync.iter().cloned()); + shared_asset_files.extend(assets.css.sync.iter().cloned()); + if let Some(shared_entry) = shared_map.get_mut(&pkg) { + shared_entry.assets = assets; + } + } + + for (expose_file_key, expose) in exposes_map.iter_mut() { + let mut assets = if let Some(module_id) = module_ids_by_name.get(expose_file_key) { + collect_assets_for_module(compilation, module_id, &entry_point_names) + .unwrap_or_else(empty_assets_group) + } else if let Some(chunk_key) = compilation.named_chunks.get(expose_file_key) { + collect_assets_from_chunk(compilation, chunk_key, &entry_point_names) + } else { + empty_assets_group() + }; + if !entry_name.is_empty() { + assets.js.sync.retain(|asset| asset != &entry_name); + assets.js.r#async.retain(|asset| asset != &entry_name); + assets.css.sync.retain(|asset| asset != &entry_name); + assets.css.r#async.retain(|asset| asset != &entry_name); + } + assets + .js + .sync + .retain(|asset| !shared_asset_files.contains(asset)); + assets + .js + .r#async + .retain(|asset| !shared_asset_files.contains(asset)); + assets + .css + .sync + .retain(|asset| !shared_asset_files.contains(asset)); + assets + .css + .r#async + .retain(|asset| !shared_asset_files.contains(asset)); + normalize_assets_group(&mut assets); + expose.assets = assets; + } + + if let Some(module_id) = container_entry_module + && let Some(mut entry_assets) = + collect_assets_for_module(compilation, &module_id, &entry_point_names) + { + entry_assets + .js + .sync + .retain(|asset| !shared_asset_files.contains(asset)); + entry_assets + .css + .sync + .retain(|asset| !shared_asset_files.contains(asset)); + normalize_assets_group(&mut entry_assets); + for expose in exposes_map.values_mut() { + let is_empty = expose.assets.js.sync.is_empty() + && expose.assets.js.r#async.is_empty() + && expose.assets.css.sync.is_empty() + && expose.assets.css.r#async.is_empty(); + if is_empty { + expose.assets = entry_assets.clone(); + } + } + } + + let module_graph = compilation.get_module_graph(); + let mut remote_list = Vec::new(); + let provided_remote_alias_map = self.options.remote_alias_map.clone(); + for module_id in remote_module_ids { + let Some(module) = compilation.module_by_identifier(&module_id) else { + continue; + }; + let Some(remote_module) = module.as_ref().as_any().downcast_ref::() else { + continue; + }; + let alias = remote_module.remote_key.clone(); + let module_name = { + let trimmed = remote_module.internal_request.trim_start_matches("./"); + if trimmed.is_empty() { + remote_module.internal_request.clone() + } else { + trimmed.to_string() + } + }; + let (entry, federation_container_name) = + if let Some(target) = provided_remote_alias_map.get(&alias) { + let remote_container_name = if target.name.is_empty() { + alias.clone() + } else { + target.name.clone() + }; + ( + target.entry.clone().filter(|entry| !entry.is_empty()), + remote_container_name, + ) + } else { + (None, alias.clone()) + }; + let used_in = + collect_usage_files_for_module(compilation, &module_graph, &module_id, &entry_point_names); + remote_list.push(StatsRemote { + alias: alias.clone(), + consumingFederationContainerName: container_name.clone(), + federationContainerName: federation_container_name, + moduleName: module_name, + entry, + usedIn: used_in, + }); + } + + let exposes = exposes_map.values().cloned().collect::>(); + let shared = shared_map + .into_values() + .map(|mut v| { + v.usedIn.sort(); + v.usedIn.dedup(); + v + }) + .collect::>(); + (exposes, shared, remote_list) + }; + let stats_root = StatsRoot { + id: container_name.clone(), + name: container_name.clone(), + metaData: meta.clone(), + shared, + remotes: remote_list.clone(), + exposes: exposes.clone(), + }; + // emit stats + let stats_json = serde_json::to_string_pretty(&stats_root).expect("serialize stats"); + compilation.emit_asset( + self.options.stats_file_name.clone(), + CompilationAsset::new( + Some(RawStringSource::from(stats_json).boxed()), + Default::default(), + ), + ); + // Build manifest from stats + let manifest = ManifestRoot { + id: stats_root.id.clone(), + name: stats_root.name.clone(), + metaData: stats_root.metaData.clone(), + exposes: exposes + .into_iter() + .map(|e| ManifestExpose { + id: e.id, + name: e.name, + path: e.path, + assets: e.assets, + }) + .collect(), + shared: stats_root + .shared + .into_iter() + .map(|s| ManifestShared { + id: s.id, + name: s.name, + version: s.version, + requiredVersion: s.requiredVersion, + singleton: s.singleton, + assets: s.assets, + }) + .collect(), + remotes: remote_list + .into_iter() + .map(|r| ManifestRemote { + federationContainerName: r.federationContainerName, + moduleName: r.moduleName, + alias: r.alias, + entry: r.entry, + }) + .collect(), + }; + let manifest_json: String = serde_json::to_string_pretty(&manifest).expect("serialize manifest"); + compilation.emit_asset( + self.options.manifest_file_name.clone(), + CompilationAsset::new( + Some(RawStringSource::from(manifest_json).boxed()), + Default::default(), + ), + ); + Ok(()) +} +impl Plugin for ModuleFederationManifestPlugin { + fn name(&self) -> &'static str { + "rspack.ModuleFederationManifestPlugin" + } + fn apply(&self, ctx: &mut rspack_core::ApplyContext<'_>) -> Result<()> { + // Align with webpack's stage: PROCESS_ASSETS_STAGE_OPTIMIZE_TRANSFER + ctx + .compilation_hooks + .process_assets + .tap(process_assets::new(self)); + Ok(()) + } +} diff --git a/crates/rspack_plugin_mf/src/manifest/options.rs b/crates/rspack_plugin_mf/src/manifest/options.rs new file mode 100644 index 000000000000..88f1f641acb4 --- /dev/null +++ b/crates/rspack_plugin_mf/src/manifest/options.rs @@ -0,0 +1,36 @@ +use std::collections::HashMap; + +use crate::manifest::data::StatsBuildInfo; + +#[derive(Debug, Clone)] +pub struct RemoteAliasTarget { + pub name: String, + pub entry: Option, +} + +#[derive(Debug, Clone)] +pub struct ManifestExposeOption { + pub path: String, + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct ManifestSharedOption { + pub name: String, + pub version: Option, + pub required_version: Option, + pub singleton: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct ModuleFederationManifestPluginOptions { + pub name: Option, + pub global_name: Option, + pub stats_file_name: String, + pub manifest_file_name: String, + pub disable_assets_analyze: bool, + pub remote_alias_map: HashMap, + pub exposes: Vec, + pub shared: Vec, + pub build_info: Option, +} diff --git a/crates/rspack_plugin_mf/src/manifest/utils.rs b/crates/rspack_plugin_mf/src/manifest/utils.rs new file mode 100644 index 000000000000..ec2e5d2c219e --- /dev/null +++ b/crates/rspack_plugin_mf/src/manifest/utils.rs @@ -0,0 +1,133 @@ +use std::path::Path; + +use rspack_core::{Compilation, ModuleGraph, ModuleIdentifier}; +use rspack_util::fx_hash::FxHashMap as HashMap; + +use super::data::{StatsExpose, StatsShared}; + +const HOT_UPDATE_SUFFIX: &str = ".hot-update"; + +pub fn compose_id_with_separator(container: &str, name: &str) -> String { + format!("{container}:{name}") +} + +pub fn is_hot_file(file: &str) -> bool { + file.contains(HOT_UPDATE_SUFFIX) +} + +pub fn strip_ext(path: &str) -> String { + match Path::new(path).extension() { + Some(_) => path + .trim_end_matches( + Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| format!(".{e}")) + .unwrap_or_default() + .as_str(), + ) + .to_string(), + None => path.to_string(), + } +} + +pub fn ensure_shared_entry<'a>( + shared_map: &'a mut HashMap, + container_name: &str, + pkg: &str, +) -> &'a mut StatsShared { + shared_map + .entry(pkg.to_string()) + .or_insert_with(|| StatsShared { + id: compose_id_with_separator(container_name, pkg), + name: pkg.to_string(), + version: String::new(), + requiredVersion: None, + singleton: None, + assets: super::data::StatsAssetsGroup::default(), + usedIn: Vec::new(), + }) +} + +pub fn record_shared_usage( + shared_usage_links: &mut Vec<(String, String)>, + pkg: &str, + module_identifier: &ModuleIdentifier, + module_graph: &ModuleGraph, + compilation: &Compilation, +) { + if let Some(issuer_module) = module_graph.get_issuer(module_identifier) { + let issuer_name = issuer_module + .readable_identifier(&compilation.options.context) + .to_string(); + if !issuer_name.is_empty() { + let key = strip_ext(&issuer_name); + shared_usage_links.push((pkg.to_string(), key)); + } + } + if let Some(mgm) = module_graph.module_graph_module_by_identifier(module_identifier) { + for dep_id in mgm.incoming_connections() { + let Some(connection) = module_graph.connection_by_dependency_id(dep_id) else { + continue; + }; + let Some(dependency) = module_graph.dependency_by_id(&connection.dependency_id) else { + continue; + }; + let maybe_request = dependency + .as_module_dependency() + .map(|dep| dep.user_request().to_string()) + .or_else(|| { + dependency + .as_context_dependency() + .map(|dep| dep.request().to_string()) + }); + if let Some(request) = maybe_request { + let key = strip_ext(&request); + shared_usage_links.push((pkg.to_string(), key)); + } + } + } +} + +pub fn parse_provide_shared_identifier(identifier: &str) -> Option<(String, String)> { + let (before_request, _) = identifier.split_once(" = ")?; + let token = before_request.split_whitespace().last()?; + let (name, version) = token.split_once('@')?; + Some((name.to_string(), version.to_string())) +} + +pub fn parse_consume_shared_identifier(identifier: &str) -> Option<(String, Option)> { + let (_, rest) = identifier.split_once(") ")?; + let token = rest.split_whitespace().next()?; + let (name, version) = token.split_once('@')?; + let version = version.trim(); + let required = if version.is_empty() || version == "*" { + None + } else { + Some(version.to_string()) + }; + Some((name.to_string(), required)) +} + +pub fn collect_expose_requirements( + shared_map: &mut HashMap, + exposes_map: &mut HashMap, + links: Vec<(String, String)>, + expose_module_paths: &HashMap, +) { + #[cfg(debug_assertions)] + for (pkg, expose_key) in links { + if let Some(expose) = exposes_map.get_mut(&expose_key) { + if !expose.requires.contains(&pkg) { + expose.requires.push(pkg.clone()); + } + if let Some(shared) = shared_map.get_mut(&pkg) { + let target = expose_module_paths + .get(&expose_key) + .cloned() + .unwrap_or_else(|| expose.path.clone()); + shared.usedIn.push(target); + } + } + } +} diff --git a/packages/rspack/etc/core.api.md b/packages/rspack/etc/core.api.md index 99d0e473081f..be9e7f7eeb32 100644 --- a/packages/rspack/etc/core.api.md +++ b/packages/rspack/etc/core.api.md @@ -4584,6 +4584,20 @@ type MakeDirectoryOptions = { mode?: string | number; }; +// @public (undocumented) +type ManifestExposeOption = { + path: string; + name: string; +}; + +// @public (undocumented) +type ManifestSharedOption = { + name: string; + version?: string; + requiredVersion?: string; + singleton?: boolean; +}; + // @public (undocumented) interface MapOptions { columns?: boolean; @@ -4685,6 +4699,18 @@ type ModuleDeclaration = ImportDeclaration | ExportDeclaration | ExportNamedDecl // @public (undocumented) type ModuleExportName = Identifier | StringLiteral; +// @public (undocumented) +type ModuleFederationManifestPluginOptions = { + name?: string; + globalName?: string; + filePath?: string; + disableAssetsAnalyze?: boolean; + fileName?: string; + remoteAliasMap?: RemoteAliasMap; + exposes?: ManifestExposeOption[]; + shared?: ManifestSharedOption[]; +}; + // @public (undocumented) class ModuleFederationPlugin { constructor(_options: ModuleFederationPluginOptions); @@ -4697,6 +4723,8 @@ export interface ModuleFederationPluginOptions extends Omit; + // (undocumented) runtimePlugins?: RuntimePlugins; // (undocumented) shareStrategy?: "version-first" | "loaded-first"; @@ -6046,6 +6074,12 @@ interface RegExpLiteral extends Node_4, HasSpan { type: "RegExpLiteral"; } +// @public (undocumented) +type RemoteAliasMap = Record; + // @public (undocumented) export type Remotes = (RemotesItem | RemotesObject)[] | RemotesObject; diff --git a/packages/rspack/src/container/ModuleFederationManifestPlugin.ts b/packages/rspack/src/container/ModuleFederationManifestPlugin.ts new file mode 100644 index 000000000000..c26bc37909bb --- /dev/null +++ b/packages/rspack/src/container/ModuleFederationManifestPlugin.ts @@ -0,0 +1,179 @@ +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { + type BuiltinPlugin, + BuiltinPluginName, + type RawModuleFederationManifestPluginOptions +} from "@rspack/binding"; +import { + createBuiltinPlugin, + RspackBuiltinPlugin +} from "../builtin-plugin/base"; +import type { Compiler } from "../Compiler"; + +const MANIFEST_FILE_NAME = "mf-manifest.json"; +const STATS_FILE_NAME = "mf-stats.json"; +const LOCAL_BUILD_VERSION = "local"; +const JSON_EXT = ".json"; + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function parseJSON( + input: string, + guard: (value: unknown) => value is T +): T | undefined { + try { + const parsed: unknown = JSON.parse(input); + if (guard(parsed)) { + return parsed; + } + } catch { + // ignore malformed json + } + return undefined; +} + +function readPKGJson(root?: string): Record { + const base = root ? resolve(root) : process.cwd(); + const pkgPath = join(base, "package.json"); + try { + const content = readFileSync(pkgPath, "utf-8"); + const parsed = parseJSON(content, isPlainObject); + if (parsed) { + const filtered: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "string") { + filtered[key] = value; + } + } + if (Object.keys(filtered).length > 0) { + return filtered; + } + } + } catch { + // ignore read/parse errors + } + return {}; +} + +function getBuildInfo(isDev: boolean, root?: string): StatsBuildInfo { + const rootPath = root || process.cwd(); + const pkg = readPKGJson(rootPath); + const buildVersion = isDev ? LOCAL_BUILD_VERSION : pkg?.version; + + return { + buildVersion: process.env.MF_BUILD_VERSION || buildVersion || "UNKNOWN", + buildName: process.env.MF_BUILD_NAME || pkg?.name || "UNKNOWN" + }; +} + +interface StatsBuildInfo { + buildVersion: string; + buildName?: string; +} + +export type RemoteAliasMap = Record; + +export type ManifestExposeOption = { + path: string; + name: string; +}; + +export type ManifestSharedOption = { + name: string; + version?: string; + requiredVersion?: string; + singleton?: boolean; +}; + +export type ModuleFederationManifestPluginOptions = { + name?: string; + globalName?: string; + filePath?: string; + disableAssetsAnalyze?: boolean; + fileName?: string; + remoteAliasMap?: RemoteAliasMap; + exposes?: ManifestExposeOption[]; + shared?: ManifestSharedOption[]; +}; + +function getFileName(manifestOptions: ModuleFederationManifestPluginOptions): { + statsFileName: string; + manifestFileName: string; +} { + if (!manifestOptions) { + return { + statsFileName: STATS_FILE_NAME, + manifestFileName: MANIFEST_FILE_NAME + }; + } + + const filePath = + typeof manifestOptions === "boolean" ? "" : manifestOptions.filePath || ""; + const fileName = + typeof manifestOptions === "boolean" ? "" : manifestOptions.fileName || ""; + + const addExt = (name: string): string => { + if (name.endsWith(JSON_EXT)) { + return name; + } + return `${name}${JSON_EXT}`; + }; + const insertSuffix = (name: string, suffix: string): string => { + return name.replace(JSON_EXT, `${suffix}${JSON_EXT}`); + }; + const manifestFileName = fileName ? addExt(fileName) : MANIFEST_FILE_NAME; + const statsFileName = fileName + ? insertSuffix(manifestFileName, "-stats") + : STATS_FILE_NAME; + + return { + statsFileName: join(filePath, statsFileName), + manifestFileName: join(filePath, manifestFileName) + }; +} + +/** + * JS-side post-processing plugin: reads mf-manifest.json and mf-stats.json, executes additionalData callback and merges/overwrites manifest. + * To avoid cross-NAPI callback complexity, this plugin runs at the afterProcessAssets stage to ensure Rust-side MfManifestPlugin has already output its artifacts. + */ +export class ModuleFederationManifestPlugin extends RspackBuiltinPlugin { + name = BuiltinPluginName.ModuleFederationManifestPlugin; + private opts: ModuleFederationManifestPluginOptions; + constructor(opts: ModuleFederationManifestPluginOptions) { + super(); + this.opts = opts; + } + + raw(compiler: Compiler): BuiltinPlugin { + const { + fileName, + filePath, + disableAssetsAnalyze, + remoteAliasMap, + exposes, + shared + } = this.opts; + const { statsFileName, manifestFileName } = getFileName(this.opts); + + const rawOptions: RawModuleFederationManifestPluginOptions = { + name: this.opts.name, + globalName: this.opts.globalName, + fileName, + filePath, + manifestFileName, + statsFileName, + disableAssetsAnalyze, + remoteAliasMap, + exposes, + shared, + buildInfo: getBuildInfo( + compiler.options.mode === "development", + compiler.context + ) + }; + return createBuiltinPlugin(this.name, rawOptions); + } +} diff --git a/packages/rspack/src/container/ModuleFederationPlugin.ts b/packages/rspack/src/container/ModuleFederationPlugin.ts index 66238ad1e905..505e56bd5b58 100644 --- a/packages/rspack/src/container/ModuleFederationPlugin.ts +++ b/packages/rspack/src/container/ModuleFederationPlugin.ts @@ -1,5 +1,14 @@ import type { Compiler } from "../Compiler"; import type { ExternalsType } from "../config"; +import type { SharedConfig } from "../sharing/SharePlugin"; +import { isRequiredVersion } from "../sharing/utils"; +import { + type ManifestExposeOption, + type ManifestSharedOption, + ModuleFederationManifestPlugin, + type ModuleFederationManifestPluginOptions, + type RemoteAliasMap +} from "./ModuleFederationManifestPlugin"; import type { ModuleFederationPluginV1Options } from "./ModuleFederationPluginV1"; import { ModuleFederationRuntimePlugin } from "./ModuleFederationRuntimePlugin"; import { parseOptions } from "./options"; @@ -11,6 +20,12 @@ export interface ModuleFederationPluginOptions runtimePlugins?: RuntimePlugins; implementation?: string; shareStrategy?: "version-first" | "loaded-first"; + manifest?: + | boolean + | Omit< + ModuleFederationManifestPluginOptions, + "remoteAliasMap" | "globalName" | "name" | "exposes" | "shared" + >; } export type RuntimePlugins = string[] | [string, Record][]; @@ -38,6 +53,49 @@ export class ModuleFederationPlugin { ...this._options, enhanced: true }).apply(compiler); + + if (this._options.manifest) { + const manifestOptions: ModuleFederationManifestPluginOptions = + this._options.manifest === true ? {} : { ...this._options.manifest }; + const containerName = manifestOptions.name ?? this._options.name; + const globalName = + manifestOptions.globalName ?? + resolveLibraryGlobalName(this._options.library) ?? + containerName; + const remoteAliasMap: RemoteAliasMap = Object.entries( + getRemoteInfos(this._options) + ).reduce((sum, cur) => { + if (cur[1].length > 1) { + // no support multiple remotes + return sum; + } + const remoteInfo = cur[1][0]; + const { entry, alias, name } = remoteInfo; + if (entry && name) { + sum[alias] = { + name, + entry + }; + } + return sum; + }, {}); + + const manifestExposes = collectManifestExposes(this._options.exposes); + if (manifestOptions.exposes === undefined && manifestExposes) { + manifestOptions.exposes = manifestExposes; + } + const manifestShared = collectManifestShared(this._options.shared); + if (manifestOptions.shared === undefined && manifestShared) { + manifestOptions.shared = manifestShared; + } + + new ModuleFederationManifestPlugin({ + ...manifestOptions, + name: containerName, + globalName, + remoteAliasMap + }).apply(compiler); + } } } @@ -57,6 +115,89 @@ interface RemoteInfo { type RemoteInfos = Record; +function collectManifestExposes( + exposes: ModuleFederationPluginOptions["exposes"] +): ManifestExposeOption[] | undefined { + if (!exposes) return undefined; + type NormalizedExpose = { import: string[]; name?: string }; + type ExposesConfigInput = { import: string | string[]; name?: string }; + const parsed = parseOptions( + exposes, + (value, key) => ({ + import: Array.isArray(value) ? value : [value], + name: undefined + }), + value => ({ + import: Array.isArray(value.import) ? value.import : [value.import], + name: value.name ?? undefined + }) + ); + const result = parsed.map(([exposeKey, info]) => { + const exposeName = info.name ?? exposeKey.replace(/^\.\//, ""); + return { + path: exposeKey, + name: exposeName + }; + }); + return result.length > 0 ? result : undefined; +} + +function collectManifestShared( + shared: ModuleFederationPluginOptions["shared"] +): ManifestSharedOption[] | undefined { + if (!shared) return undefined; + const parsed = parseOptions( + shared, + (item, key) => { + if (typeof item !== "string") { + throw new Error("Unexpected array in shared"); + } + return item === key || !isRequiredVersion(item) + ? { import: item } + : { import: key, requiredVersion: item }; + }, + item => item + ); + const result = parsed.map(([key, config]) => { + const name = config.shareKey || key; + const version = + typeof config.version === "string" ? config.version : undefined; + const requiredVersion = + typeof config.requiredVersion === "string" + ? config.requiredVersion + : undefined; + return { + name, + version, + requiredVersion, + singleton: config.singleton + }; + }); + return result.length > 0 ? result : undefined; +} + +function resolveLibraryGlobalName( + library: ModuleFederationPluginOptions["library"] +): string | undefined { + if (!library) { + return undefined; + } + const libName = library.name; + if (!libName) { + return undefined; + } + if (typeof libName === "string") { + return libName; + } + if (Array.isArray(libName)) { + return libName[0]; + } + if (typeof libName === "object") { + return libName.root?.[0] ?? libName.amd ?? libName.commonjs ?? undefined; + } + return undefined; +} + function getRemoteInfos(options: ModuleFederationPluginOptions): RemoteInfos { if (!options.remotes) { return {}; diff --git a/tests/rspack-test/configCases/container-1-5/circular/rspack.config.js b/tests/rspack-test/configCases/container-1-5/circular/rspack.config.js index e8712015c3da..fc9368428314 100644 --- a/tests/rspack-test/configCases/container-1-5/circular/rspack.config.js +++ b/tests/rspack-test/configCases/container-1-5/circular/rspack.config.js @@ -10,6 +10,7 @@ function createConfig() { name: "container", library: { type: "commonjs-module" }, exposes: ["./a"], + manifest:false, remotes: { container2: "promise Promise.resolve().then(() => require('./container2.js'))" @@ -19,6 +20,7 @@ function createConfig() { name: "container2", library: { type: "commonjs-module" }, exposes: ["./b"], + manifest:false, remotes: { container: "promise Promise.resolve().then(() => require('./container.js'))" diff --git a/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/index.js b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/index.js new file mode 100644 index 000000000000..e9e84172228e --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/index.js @@ -0,0 +1,42 @@ +const fs = __non_webpack_require__("fs"); +const path = __non_webpack_require__("path"); + +const statsPath = path.join(__dirname, "mf-stats.json"); +const manifestPath = path.join(__dirname, "mf-manifest.json"); + +const stats = JSON.parse(fs.readFileSync(statsPath, "utf-8")); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + +it("should still emit the remote entry", () => { + const remoteEntryFile = stats.metaData.remoteEntry.name; + const remoteEntryPath = path.join(__dirname, remoteEntryFile); + expect(fs.existsSync(remoteEntryPath)).toBe(true); +}); + +it("should omit asset details from stats when disableAssetsAnalyze is true", () => { + expect(stats.shared).toHaveLength(1); + expect(stats.shared[0].assets.js.sync).toEqual([]); + expect(stats.shared[0].assets.js.async).toEqual([]); + expect(stats.exposes).toHaveLength(1); + expect(stats.exposes[0].assets.js.sync).toEqual([]); + expect(stats.exposes[0].assets.js.async).toEqual([]); +}); + +it("should omit asset details from manifest when disableAssetsAnalyze is true", () => { + expect(manifest.shared).toHaveLength(1); + expect(manifest.shared[0].assets.js.sync).toEqual([]); + expect(manifest.shared[0].assets.js.async).toEqual([]); + expect(manifest.exposes).toHaveLength(1); + expect(manifest.exposes[0].assets.js.sync).toEqual([]); + expect(manifest.exposes[0].assets.js.async).toEqual([]); +}); + +it("should mark remote usage locations as UNKNOWN", () => { + expect(stats.remotes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + usedIn: ["UNKNOWN"] + }) + ]) + ); +}); diff --git a/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/lazy-module.js b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/lazy-module.js new file mode 100644 index 000000000000..91e8be7507fe --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/lazy-module.js @@ -0,0 +1 @@ +export const lazy = true diff --git a/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/module.js b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/module.js new file mode 100644 index 000000000000..467801ec7082 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/module.js @@ -0,0 +1,11 @@ +import react from 'react'; +import remote from 'remote'; + +global.react = react; +global.remote = remote; + +import('./lazy-module').then(r=>{ + console.log('lazy module: ',r) +}) + +export const ok = true; diff --git a/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/node_modules/package.json b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/node_modules/package.json new file mode 100644 index 000000000000..a1069cc8a846 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/node_modules/package.json @@ -0,0 +1,4 @@ +{ + "name": "react", + "version": "1.0.0" +} diff --git a/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/node_modules/react.js b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/node_modules/react.js new file mode 100644 index 000000000000..ff64eb395268 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/node_modules/react.js @@ -0,0 +1 @@ +export default "React"; diff --git a/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/rspack.config.js b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/rspack.config.js new file mode 100644 index 000000000000..7a8f5988cede --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-disable-assets-analyze/rspack.config.js @@ -0,0 +1,32 @@ +const { ModuleFederationPlugin } = require("@rspack/core").container; + +/** @type {import("@rspack/core").Configuration} */ +module.exports = { + optimization:{ + chunkIds: 'named', + moduleIds: 'named' + }, + output: { + chunkFilename: '[id].js', + }, + plugins: [ + new ModuleFederationPlugin({ + name: "container", + filename: "container.[chunkhash:8].js", + library: { type: "commonjs-module" }, + exposes: { + 'expose-a': './module.js' + }, + remoteType:'script', + remotes: { + remote: 'remote@http://localhost:8000/remoteEntry.js' + }, + shared: { + react: {} + }, + manifest: { + disableAssetsAnalyze: true + } + }) + ] +}; diff --git a/tests/rspack-test/configCases/container-1-5/manifest-file-name/index.js b/tests/rspack-test/configCases/container-1-5/manifest-file-name/index.js new file mode 100644 index 000000000000..cc4ef6deee7f --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-file-name/index.js @@ -0,0 +1,27 @@ +const fs = __non_webpack_require__("fs"); +const path = __non_webpack_require__("path"); + +const customPath = 'custom-path'; +const customManifestPath = path.join(__dirname, customPath,"custom-manifest.json"); +const customStatsPath = path.join(__dirname, customPath,"custom-manifest-stats.json"); +const defaultManifestPath = path.join(__dirname, "mf-manifest.json"); +const defaultStatsPath = path.join(__dirname, "mf-stats.json"); + +const stats = JSON.parse(fs.readFileSync(customStatsPath, "utf-8")); +const manifest = JSON.parse(fs.readFileSync(customManifestPath, "utf-8")); + +it("should emit manifest with the configured fileName", () => { + expect(fs.existsSync(customManifestPath)).toBe(true); + expect(fs.existsSync(customStatsPath)).toBe(true); +}); + +it("should not emit default manifest file names when fileName is set", () => { + expect(fs.existsSync(defaultManifestPath)).toBe(false); + expect(fs.existsSync(defaultStatsPath)).toBe(false); +}); + +it("should still point to the emitted remote entry", () => { + const remoteEntryFile = stats.metaData.remoteEntry.name; + const remoteEntryPath = path.join(__dirname, remoteEntryFile); + expect(fs.existsSync(remoteEntryPath)).toBe(true); +}); diff --git a/tests/rspack-test/configCases/container-1-5/manifest-file-name/lazy-module.js b/tests/rspack-test/configCases/container-1-5/manifest-file-name/lazy-module.js new file mode 100644 index 000000000000..91e8be7507fe --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-file-name/lazy-module.js @@ -0,0 +1 @@ +export const lazy = true diff --git a/tests/rspack-test/configCases/container-1-5/manifest-file-name/module.js b/tests/rspack-test/configCases/container-1-5/manifest-file-name/module.js new file mode 100644 index 000000000000..467801ec7082 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-file-name/module.js @@ -0,0 +1,11 @@ +import react from 'react'; +import remote from 'remote'; + +global.react = react; +global.remote = remote; + +import('./lazy-module').then(r=>{ + console.log('lazy module: ',r) +}) + +export const ok = true; diff --git a/tests/rspack-test/configCases/container-1-5/manifest-file-name/node_modules/package.json b/tests/rspack-test/configCases/container-1-5/manifest-file-name/node_modules/package.json new file mode 100644 index 000000000000..a1069cc8a846 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-file-name/node_modules/package.json @@ -0,0 +1,4 @@ +{ + "name": "react", + "version": "1.0.0" +} diff --git a/tests/rspack-test/configCases/container-1-5/manifest-file-name/node_modules/react.js b/tests/rspack-test/configCases/container-1-5/manifest-file-name/node_modules/react.js new file mode 100644 index 000000000000..ff64eb395268 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-file-name/node_modules/react.js @@ -0,0 +1 @@ +export default "React"; diff --git a/tests/rspack-test/configCases/container-1-5/manifest-file-name/rspack.config.js b/tests/rspack-test/configCases/container-1-5/manifest-file-name/rspack.config.js new file mode 100644 index 000000000000..e1c0bae783df --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest-file-name/rspack.config.js @@ -0,0 +1,33 @@ +const { ModuleFederationPlugin } = require("@rspack/core").container; + +/** @type {import("@rspack/core").Configuration} */ +module.exports = { + optimization:{ + chunkIds: 'named', + moduleIds: 'named' + }, + output: { + chunkFilename: '[id].js', + }, + plugins: [ + new ModuleFederationPlugin({ + name: "container", + filename: "container.[chunkhash:8].js", + library: { type: "commonjs-module" }, + exposes: { + 'expose-a': './module.js' + }, + remoteType:'script', + remotes: { + remote: 'remote@http://localhost:8000/remoteEntry.js' + }, + shared: { + react: {} + }, + manifest: { + fileName: "custom-manifest.json", + filePath:'custom-path' + } + }) + ] +}; diff --git a/tests/rspack-test/configCases/container-1-5/manifest/index.js b/tests/rspack-test/configCases/container-1-5/manifest/index.js new file mode 100644 index 000000000000..8dd0de36735d --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest/index.js @@ -0,0 +1,99 @@ +const fs = __non_webpack_require__("fs"); +const path = __non_webpack_require__("path"); + +const statsPath = path.join(__dirname, "mf-stats.json"); +const manifestPath = path.join(__dirname, "mf-manifest.json"); +const stats = JSON.parse(fs.readFileSync(statsPath, "utf-8")); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + +it("should emit remote entry with hash", () => { + const remoteEntryFile = stats.metaData.remoteEntry.name; + const remoteEntryPath = path.join(__dirname, remoteEntryFile); + expect(fs.existsSync(remoteEntryPath)).toBe(true); +}); + +// shared +it("should report shared assets in sync only", () => { + expect(stats.shared).toHaveLength(1); + expect(stats.shared[0].assets.js.sync.sort()).toEqual([ + "node_modules_react_js.js" + ]); + expect(stats.shared[0].assets.js.async).toEqual([]); +}); + +it("should materialize in manifest", () => { + expect(manifest.shared).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "react", + assets: expect.objectContaining({ + js: expect.objectContaining({ + sync: expect.arrayContaining([ + "node_modules_react_js.js" + ]), + async: [] + }) + }) + }) + ]) + ); +}); + +//exposes +it("should expose sync assets only", () => { + expect(stats.exposes).toHaveLength(1); + expect(stats.exposes[0].assets.js.sync).toEqual(["_federation_expose_a.js"]); + expect(stats.exposes[0].assets.js.async).toEqual([ + "lazy-module_js.js" + ]); +}); + +it("should reflect expose assets in manifest", () => { + expect(manifest.exposes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "expose-a", + path: "./expose-a", + assets: expect.objectContaining({ + js: expect.objectContaining({ + sync: ["_federation_expose_a.js"], + async: [ + "lazy-module_js.js", + ] + }) + }) + }) + ]) + ); +}); + +// remotes + +it("should record remote usage", () => { + expect(stats.remotes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + alias: "@remote/alias", + consumingFederationContainerName: "container", + federationContainerName: "remote", + moduleName: ".", + usedIn: expect.arrayContaining([ + "module.js" + ]), + entry: 'http://localhost:8000/remoteEntry.js' + }) + ]) + ); +}); + +it("should persist remote metadata in manifest", () => { + expect(manifest.remotes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + alias: "@remote/alias", + federationContainerName: "remote", + moduleName: "." + }) + ]) + ); +}); diff --git a/tests/rspack-test/configCases/container-1-5/manifest/lazy-module.js b/tests/rspack-test/configCases/container-1-5/manifest/lazy-module.js new file mode 100644 index 000000000000..91e8be7507fe --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest/lazy-module.js @@ -0,0 +1 @@ +export const lazy = true diff --git a/tests/rspack-test/configCases/container-1-5/manifest/module.js b/tests/rspack-test/configCases/container-1-5/manifest/module.js new file mode 100644 index 000000000000..40112961dd5c --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest/module.js @@ -0,0 +1,11 @@ +import react from 'react'; +import remote from '@remote/alias'; + +global.react = react; +global.remote = remote; + +import('./lazy-module').then(r=>{ + console.log('lazy module: ',r) +}) + +export const ok = true; diff --git a/tests/rspack-test/configCases/container-1-5/manifest/node_modules/package.json b/tests/rspack-test/configCases/container-1-5/manifest/node_modules/package.json new file mode 100644 index 000000000000..a1069cc8a846 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest/node_modules/package.json @@ -0,0 +1,4 @@ +{ + "name": "react", + "version": "1.0.0" +} diff --git a/tests/rspack-test/configCases/container-1-5/manifest/node_modules/react.js b/tests/rspack-test/configCases/container-1-5/manifest/node_modules/react.js new file mode 100644 index 000000000000..ff64eb395268 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest/node_modules/react.js @@ -0,0 +1 @@ +export default "React"; diff --git a/tests/rspack-test/configCases/container-1-5/manifest/rspack.config.js b/tests/rspack-test/configCases/container-1-5/manifest/rspack.config.js new file mode 100644 index 000000000000..00cf015bc7f0 --- /dev/null +++ b/tests/rspack-test/configCases/container-1-5/manifest/rspack.config.js @@ -0,0 +1,33 @@ +const { ModuleFederationPlugin } = require("@rspack/core").container; + +/** @type {import("@rspack/core").Configuration} */ +module.exports = { + optimization:{ + chunkIds: 'named', + moduleIds: 'named' + }, + output: { + chunkFilename: '[id].js', + }, + plugins: [ + new ModuleFederationPlugin({ + name: "container", + filename: "container.[chunkhash:8].js", + library: { type: "commonjs-module" }, + manifest: true, + exposes: { + './expose-a': { + import: './module.js', + name:'_federation_expose_a' + } + }, + remoteType:'script', + remotes: { + '@remote/alias': 'remote@http://localhost:8000/remoteEntry.js' + }, + shared: { + react: {} + } + }) + ] +}; diff --git a/tests/rspack-test/configCases/container-1-5/module-federation/rspack.config.js b/tests/rspack-test/configCases/container-1-5/module-federation/rspack.config.js index 84949e895667..226046fe7517 100644 --- a/tests/rspack-test/configCases/container-1-5/module-federation/rspack.config.js +++ b/tests/rspack-test/configCases/container-1-5/module-federation/rspack.config.js @@ -11,6 +11,7 @@ function createConfig() { filename: "container.js", library: { type: "system" }, exposes: ["./other", "./self", "./dep"], + manifest:false, remotes: { abc: "ABC", def: "DEF", @@ -22,6 +23,7 @@ function createConfig() { name: "container2", filename: "container2.js", library: { type: "system" }, + manifest:false, exposes: ["./other", "./self", "./dep"], remotes: { abc: "ABC", diff --git a/website/docs/en/plugins/webpack/module-federation-plugin.mdx b/website/docs/en/plugins/webpack/module-federation-plugin.mdx index a419479d8c96..33bd373f9132 100644 --- a/website/docs/en/plugins/webpack/module-federation-plugin.mdx +++ b/website/docs/en/plugins/webpack/module-federation-plugin.mdx @@ -1,4 +1,4 @@ -import { Stability } from '@components/ApiMeta'; +import { Stability, ApiMeta } from '@components/ApiMeta'; # ModuleFederationPlugin @@ -154,6 +154,33 @@ The SharedConfig can include the following sub-options: - strictVersion: Used to strengthen `requiredVersion`. If set to `true`, the shared module must match the version specified in requiredVersion exactly, otherwise an error will be reported and the module will not be loaded. If set to `false`, it can tolerate imprecise matching. - version: Explicitly set the version of the shared module. By default, the version in `package.json` will be used. +### manifest + + + +- Type: + ```ts + type Manifest = boolean | ManifestConfig; + interface ManifestConfig { + filePath?: string; + disableAssetsAnalyze?: boolean; + fileName?: string; + } + ``` + +Used to control whether to generate a manifest and the corresponding generation configuration. + +When enabled, the plugin emits both `mf-manifest.json` and `mf-stats.json` (you can customize the base name through `fileName`) on every build and writes them into the build output so other tooling can read them during the `processAssets` hook or from the final artifacts. + +- `mf-stats.json`: Contains full build statistics, including the asset lists for exposes/shared/remotes, `metaData` (plugin version, build info, `remoteEntry`, etc.), and extra asset analysis data, making it suitable for later aggregation or diagnostics. +- `mf-manifest.json`: A runtime manifest distilled from the stats file. The structure stays stable and can be consumed by Module Federation clients when loading remote modules. The exposes/shared/remotes sections describe what is publicly exposed. + +`ManifestConfig` provides the following options: + +- `filePath`: Target directory for the manifest files. Applies to both manifest and stats outputs. +- `fileName`: Manifest file name. When set, the stats file automatically appends a `-stats` suffix (for example, `fileName: 'mf.json'` produces `mf.json` and `mf-stats.json`). All files are emitted into the directory defined by `filePath` (if provided). +- `disableAssetsAnalyze`: Disables asset analysis. When `true`, the manifest omits the `shared` and `exposes` fields, and the `remotes` entries will not include asset information. + ## FAQ - Found non-downgraded syntax in the build output? @@ -179,3 +206,7 @@ The SharedConfig can include the following sub-options: }, }; ``` + +- Multiple assets emit different content to the same filename mf-manifest.json + + Upgrade the `@module-federation` scoped npm package to `0.21.0` or above. diff --git a/website/docs/zh/plugins/webpack/module-federation-plugin.mdx b/website/docs/zh/plugins/webpack/module-federation-plugin.mdx index afda856ea3f4..3bb022150e98 100644 --- a/website/docs/zh/plugins/webpack/module-federation-plugin.mdx +++ b/website/docs/zh/plugins/webpack/module-federation-plugin.mdx @@ -1,4 +1,4 @@ -import { Stability } from '@components/ApiMeta'; +import { Stability, ApiMeta } from '@components/ApiMeta'; # ModuleFederationPlugin @@ -154,6 +154,31 @@ export default { - strictVersion:用来强化 `requiredVersion`。如果设置为 `true`,那么必须精确地匹配 `requiredVersion` 中规定的版本,否则共享模块会报错并且不会加载该模块。如果设置为 `false`,那么可以容忍不精确的匹配。 - version:显式地设置共享模块的版本。默认会使用 `package.json` 中的版本。 +### manifest + + + +- 类型: + ```ts + type Manifest = boolean | ManifestConfig; + interface ManifestConfig { + filePath?: string; + disableAssetsAnalyze?: boolean; + fileName?: string; + } + ``` + +用于控制是否生成 manifest ,以及对应的生成配置。启用后插件会在每次构建中同时产出 `mf-manifest.json` 与 `mf-stats.json`(名称可通过 `fileName` 自定义),并写入到构建产物中,供其他工具在 `processAssets` 钩子或构建结果中直接读取。 + +- `mf-stats.json`:包含完整的构建统计信息,如 exposes/shared/remotes 的资源列表、`metaData`(插件版本、构建信息、`remoteEntry` 等)以及额外的资产分析结果,适合用于后续合并或诊断。 +- `mf-manifest.json`:在 stats 基础上提炼出的运行时清单,结构稳定,供 Module Federation 消费端在加载远程模块时读取。文件中的 exposes/shared/remotes 对应线上对外暴露的能力。 + +其中 `ManifestConfig` 选项说明如下: + +- filePath:manifest 文件路径,设置后同时作用于 stats 。 +- fileName:manifest 文件名称,如果设置了 `fileName`,对应的 stats 文件名会自动附加 `-stats` 后缀(例如 `fileName: 'mf.json'` 时会同时生成 `mf.json` 与 `mf-stats.json`)。所有文件都会写入 `filePath`(若配置)指定的子目录。 +- disableAssetsAnalyze:禁用产物分析,如果设置为 true ,那么 manifest 中将不会有 shared 、exposes 字段,且 remotes 中也不会有 assets 。 + ## 常见问题 - 构建产物中存在未降级语法? @@ -179,3 +204,7 @@ export default { }, }; ``` + +- Multiple assets emit different content to the same filename mf-manifest.json + + 升级 `@module-federation` scope 下的 npm 包至 `0.21.0` 及以上版本。