use std::path::{Path, PathBuf};
use oxc_ast::ast::{
Argument, CallExpression, Expression, NewExpression, ObjectExpression, ObjectPropertyKind,
Program, PropertyKey,
};
use oxc_ast_visit::{Visit, walk};
use super::config_parser;
use super::{Plugin, PluginResult, ProvidedDependencyRule};
const ENABLERS: &[&str] = &[
"@module-federation/enhanced",
"@module-federation/modern-js",
"@module-federation/nextjs-mf",
"@module-federation/node",
"@module-federation/rsbuild-plugin",
"@module-federation/rspack",
"@module-federation/runtime",
"@module-federation/vite",
"@module-federation/webpack-bundler-runtime",
"@originjs/vite-plugin-federation",
];
const BUILD_PLUGIN_ENABLERS: &[&str] = &[
"@module-federation/enhanced",
"@module-federation/modern-js",
"@module-federation/nextjs-mf",
"@module-federation/node",
"@module-federation/rsbuild-plugin",
"@module-federation/rspack",
"@module-federation/vite",
"@originjs/vite-plugin-federation",
];
const IDENTITY_WRAPPERS: &[&str] = &["createModuleFederationConfig", "defineConfig"];
const CONFIG_PATTERNS: &[&str] = &["module-federation.config.{ts,js,mjs,cjs,mts,cts}"];
const ALWAYS_USED: &[&str] = CONFIG_PATTERNS;
const AMBIGUOUS_CALLEE: &str = "federation";
const FEDERATION_CALLEES: &[&str] = &[
"ModuleFederationPlugin",
"NextFederationPlugin",
"moduleFederationPlugin",
"pluginModuleFederation",
"federation",
];
const EXPOSE_EXTENSIONS: &str = super::REQUEST_EXTENSIONS;
const SCOPE_SUFFIX: &str = "**/*";
#[derive(Debug, Default, PartialEq, Eq)]
struct FederationConfig {
pub exposed_targets: Vec<String>,
pub exposed_packages: Vec<String>,
pub remote_aliases: Vec<String>,
pub shared_packages: Vec<String>,
}
struct FederationSites {
pub read_plugin_calls: bool,
pub read_config_object: bool,
}
struct ConfigLocation<'a> {
pub config_path: &'a Path,
pub source_path: &'a Path,
pub root: &'a Path,
pub context: Option<&'a Path>,
pub package_dir: Option<&'a Path>,
}
#[derive(Debug, Clone, Copy, Default)]
pub(super) struct FederationBase<'a> {
pub context: Option<&'a Path>,
pub package_dir: Option<&'a Path>,
}
impl ConfigLocation<'_> {
fn target_base(&self) -> std::borrow::Cow<'_, Path> {
match self.context.or(self.package_dir) {
Some(context) => {
std::borrow::Cow::Owned(self.root.join(context).join("module-federation"))
}
None => std::borrow::Cow::Borrowed(self.config_path),
}
}
fn relative_config_path(&self) -> Option<String> {
if let Ok(relative) = self.config_path.strip_prefix(self.root) {
return Some(config_parser::path_to_config_string(relative));
}
(!self.config_path.is_absolute())
.then(|| config_parser::path_to_config_string(self.config_path))
}
fn owning_manifest(&self) -> PathBuf {
let mut directory = self.config_path.parent();
while let Some(current) = directory {
if !current.starts_with(self.root) {
break;
}
let manifest = current.join("package.json");
if manifest.is_file() {
return manifest;
}
directory = current.parent();
}
self.root.join("package.json")
}
fn scope_pattern(&self) -> String {
let directory = match self.package_dir {
Some(package_dir) => Some(config_parser::path_to_config_string(package_dir)),
None => self.relative_config_path().and_then(|relative| {
Path::new(&relative)
.parent()
.map(config_parser::path_to_config_string)
}),
}
.filter(|directory| !directory.is_empty());
match directory {
Some(directory) => format!("{directory}/{SCOPE_SUFFIX}"),
None => SCOPE_SUFFIX.to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FederationKey {
Exposes,
Remotes,
Shared,
}
impl FederationKey {
const DIAGNOSED: [Self; 2] = [Self::Exposes, Self::Remotes];
const fn name(self) -> &'static str {
match self {
Self::Exposes => "exposes",
Self::Remotes => "remotes",
Self::Shared => "shared",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UnreadReason {
NotObjectLiteral,
ArrayForm,
Spread,
Entries,
UnrecognizedCall,
ImportTargetUnreadable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct UnreadDeclaration {
key: FederationKey,
reason: UnreadReason,
}
impl UnreadReason {
const fn token(self) -> &'static str {
match self {
Self::NotObjectLiteral => "not-object-literal",
Self::ArrayForm => "array-form",
Self::Spread => "spread",
Self::Entries => "unreadable-entries",
Self::UnrecognizedCall => "unrecognized-call",
Self::ImportTargetUnreadable => "import-target-unreadable",
}
}
}
fn extract(
result: &mut PluginResult,
source: &str,
location: &ConfigLocation<'_>,
plugin_label: &str,
sites: &FederationSites,
) -> FederationRead {
let read = read_declarations(source, location.source_path, sites);
for declaration in &read.unread {
result
.config_diagnostics
.push(super::PluginConfigDiagnostic::unreadable(
location.source_path,
plugin_label,
declaration.key.name(),
declaration.reason.token(),
));
}
read
}
fn apply_from_source(
result: &mut PluginResult,
source: &str,
location: &ConfigLocation<'_>,
plugin_label: &str,
sites: &FederationSites,
) -> bool {
let read = extract(result, source, location, plugin_label, sites);
apply(result, &read.config, location, plugin_label);
read.declares_key
}
pub(super) fn apply_bundler_plugin_options(
result: &mut PluginResult,
source: &str,
config_path: &Path,
root: &Path,
base: FederationBase<'_>,
plugin_label: &str,
) {
let sites = FederationSites {
read_plugin_calls: true,
read_config_object: false,
};
let location = ConfigLocation {
config_path,
source_path: config_path,
root,
context: base.context,
package_dir: base.package_dir,
};
apply_from_source(result, source, &location, plugin_label, &sites);
for (helper_path, helper_source) in imported_helper_modules(source, config_path, root) {
let helper = ConfigLocation {
source_path: &helper_path,
..location
};
apply_from_source(result, &helper_source, &helper, plugin_label, &sites);
}
}
fn imported_helper_modules(
source: &str,
config_path: &Path,
root: &Path,
) -> Vec<(PathBuf, String)> {
let specifiers = config_parser::extract_from_source(source, config_path, |program| {
let mut collector = RelativeSpecifierCollector::default();
collector.visit_program(program);
Some(collector.specifiers)
})
.unwrap_or_default();
let mut helpers: Vec<(PathBuf, String)> = Vec::new();
for specifier in specifiers {
let Some((path, helper_source)) =
config_parser::resolve_sibling_module(config_path, &specifier)
else {
continue;
};
let path = normalize_lexically(&path);
let Ok(real_path) = path.canonicalize() else {
continue;
};
let inside_root = root
.canonicalize()
.is_ok_and(|root| real_path.starts_with(root));
let is_config = config_path
.canonicalize()
.is_ok_and(|config| config == real_path);
let names_callee = FEDERATION_CALLEES
.iter()
.any(|callee| helper_source.contains(callee));
if inside_root
&& !is_config
&& names_callee
&& !helpers.iter().any(|(known, _)| *known == path)
{
helpers.push((path, helper_source));
}
}
helpers
}
fn normalize_lexically(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if !normalized.pop() {
normalized.push(component);
}
}
other => normalized.push(other),
}
}
normalized
}
#[derive(Default)]
struct RelativeSpecifierCollector {
specifiers: Vec<String>,
}
impl RelativeSpecifierCollector {
fn push(&mut self, specifier: &str) {
if (specifier.starts_with("./") || specifier.starts_with("../"))
&& !self.specifiers.iter().any(|known| known == specifier)
{
self.specifiers.push(specifier.to_owned());
}
}
}
impl<'a> Visit<'a> for RelativeSpecifierCollector {
fn visit_import_declaration(&mut self, declaration: &oxc_ast::ast::ImportDeclaration<'a>) {
if !declaration.import_kind.is_type() {
self.push(declaration.source.value.as_str());
}
}
fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
if config_parser::is_require_call(call)
&& let Some(specifier) = config_parser::get_require_source(call)
{
self.push(&specifier);
}
walk::walk_call_expression(self, call);
}
}
fn apply(
result: &mut PluginResult,
config: &FederationConfig,
location: &ConfigLocation<'_>,
plugin_label: &str,
) {
let base = location.target_base();
let source = |target| super::FederationSource {
target,
config_path: location.source_path.to_path_buf(),
plugin: plugin_label.to_owned(),
key: FederationKey::Exposes.name(),
};
for target in &config.exposed_targets {
let first_new = result.entry_patterns.len();
push_exposed_entry_patterns(result, target, &base, location.root);
let exposed: Vec<super::FederationSource> = result.entry_patterns[first_new..]
.iter()
.map(|rule| source(super::FederationSourceTarget::Exposed(rule.clone())))
.collect();
result.federation_sources.extend(exposed);
}
if !config.exposed_packages.is_empty() || !config.shared_packages.is_empty() {
let manifest = location.owning_manifest();
result.package_referenced_dependencies.extend(
config
.exposed_packages
.iter()
.chain(&config.shared_packages)
.map(|package| (manifest.clone(), package.clone())),
);
}
if config.remote_aliases.is_empty() {
return;
}
let scope = location.scope_pattern();
for alias in &config.remote_aliases {
result
.provided_dependencies
.push(ProvidedDependencyRule::new(
scope.clone(),
[alias.clone()],
[format!("{alias}/")],
));
result.federation_sources.push(super::FederationSource {
key: FederationKey::Remotes.name(),
..source(super::FederationSourceTarget::Remote(alias.clone()))
});
}
}
fn push_exposed_entry_patterns(result: &mut PluginResult, target: &str, base: &Path, root: &Path) {
let trimmed = target.trim();
if trimmed.is_empty() || trimmed.contains(':') {
return;
}
let Some(normalized) = config_parser::normalize_config_path(trimmed, base, root)
.or_else(|| config_parser::parent_relative_config_path(trimmed, base, root))
else {
return;
};
let escaped = globset::escape(&normalized);
let patterns = if super::has_source_extension(&normalized) {
vec![escaped]
} else {
vec![
format!("{escaped}.{EXPOSE_EXTENSIONS}"),
format!("{escaped}/index.{EXPOSE_EXTENSIONS}"),
]
};
let parent_relative = normalized.starts_with("../");
for pattern in patterns {
if parent_relative {
result.push_parent_relative_entry_pattern(pattern);
} else {
result.push_entry_pattern(pattern);
}
}
}
#[derive(Debug, Default)]
struct FederationRead {
config: FederationConfig,
unread: Vec<UnreadDeclaration>,
declares_key: bool,
}
#[cfg(test)]
fn read(
source: &str,
config_path: &Path,
sites: &FederationSites,
) -> (FederationConfig, Vec<UnreadDeclaration>) {
let read = read_declarations(source, config_path, sites);
(read.config, read.unread)
}
fn read_declarations(source: &str, config_path: &Path, sites: &FederationSites) -> FederationRead {
config_parser::extract_from_source(source, config_path, |program| {
let mut collector = FederationCallCollector::new(program, config_path);
if sites.read_config_object {
let mut options = ResolvedOptions::default();
if read_config_object_options(program, config_path, &mut options) {
collector.merge(options, true);
}
}
if sites.read_plugin_calls {
collector.visit_program(program);
}
Some(FederationRead {
config: collector.config,
unread: collector.unread,
declares_key: collector.declares_key,
})
})
.unwrap_or_default()
}
fn read_config_object_options(
program: &Program<'_>,
config_path: &Path,
options: &mut ResolvedOptions,
) -> bool {
if config_parser::find_module_export_expression(program)
.is_some_and(|export| resolve_options(program, config_path, export, 0, options))
{
return true;
}
let Some(config_object) = config_parser::find_config_object(program) else {
return false;
};
read_options_object(program, config_path, config_object, 0, options);
true
}
struct FederationCallCollector<'a, 'p> {
program: &'a Program<'a>,
config_path: &'p Path,
config: FederationConfig,
unread: Vec<UnreadDeclaration>,
declares_key: bool,
}
impl<'a, 'p> FederationCallCollector<'a, 'p> {
fn new(program: &'a Program<'a>, config_path: &'p Path) -> Self {
Self {
program,
config_path,
config: FederationConfig::default(),
unread: Vec::new(),
declares_key: false,
}
}
fn read_plugin_call(&mut self, callee: &Expression<'a>, arguments: &[Argument<'a>]) {
let Some(callee_name) = federation_callee_name(callee) else {
return;
};
let Some(argument) = arguments.first().and_then(Argument::as_expression) else {
return;
};
let mut options = ResolvedOptions::default();
if !resolve_options(self.program, self.config_path, argument, 0, &mut options) {
return;
}
self.merge(options, callee_name != AMBIGUOUS_CALLEE);
}
fn merge(&mut self, options: ResolvedOptions, federation_specific: bool) {
let has_unread_part =
options.unreadable_spread || options.unreadable_import || options.unrecognized_call;
if options.declared.is_empty() && !(has_unread_part && federation_specific) {
return;
}
self.declares_key |= !options.declared.is_empty();
for target in options.config.exposed_targets {
push_unique(&mut self.config.exposed_targets, target);
}
for package in options.config.exposed_packages {
push_unique(&mut self.config.exposed_packages, package);
}
for alias in options.config.remote_aliases {
push_unique(&mut self.config.remote_aliases, alias);
}
for package in options.config.shared_packages {
push_unique(&mut self.config.shared_packages, package);
}
for declaration in options.unread {
push_unique(&mut self.unread, declaration);
}
let declares_diagnosed_key = FederationKey::DIAGNOSED
.iter()
.any(|key| options.declared.contains(key));
if !federation_specific && !declares_diagnosed_key {
return;
}
for key in FederationKey::DIAGNOSED {
let declared = options.declared.contains(&key);
let reasons = [
(
options.unrecognized_keys.contains(&key)
|| (options.unrecognized_call && !declares_diagnosed_key),
UnreadReason::UnrecognizedCall,
),
(
options.unreadable_import && !declared,
UnreadReason::ImportTargetUnreadable,
),
(options.unreadable_spread && !declared, UnreadReason::Spread),
];
for (applies, reason) in reasons {
if applies {
push_unique(&mut self.unread, UnreadDeclaration { key, reason });
}
}
}
}
}
const MAX_OPTIONS_DEPTH: usize = 4;
#[derive(Debug, Default)]
struct ResolvedOptions {
config: FederationConfig,
unread: Vec<UnreadDeclaration>,
declared: Vec<FederationKey>,
unreadable_spread: bool,
unreadable_import: bool,
unrecognized_call: bool,
unrecognized_keys: Vec<FederationKey>,
}
impl ResolvedOptions {
fn absorb(&mut self, other: Self) {
for target in other.config.exposed_targets {
push_unique(&mut self.config.exposed_targets, target);
}
for package in other.config.exposed_packages {
push_unique(&mut self.config.exposed_packages, package);
}
for alias in other.config.remote_aliases {
push_unique(&mut self.config.remote_aliases, alias);
}
for package in other.config.shared_packages {
push_unique(&mut self.config.shared_packages, package);
}
for declaration in other.unread {
push_unique(&mut self.unread, declaration);
}
for key in other.declared {
push_unique(&mut self.declared, key);
}
for key in other.unrecognized_keys {
push_unique(&mut self.unrecognized_keys, key);
}
self.unreadable_spread |= other.unreadable_spread;
self.unreadable_import |= other.unreadable_import;
self.unrecognized_call |= other.unrecognized_call;
}
}
fn resolve_options(
program: &Program<'_>,
path: &Path,
expr: &Expression<'_>,
depth: usize,
options: &mut ResolvedOptions,
) -> bool {
if depth > MAX_OPTIONS_DEPTH {
return false;
}
match unwrap_expression(expr) {
Expression::ObjectExpression(object) => {
read_options_object(program, path, object, depth, options);
true
}
Expression::CallExpression(call) if config_parser::is_require_call(call) => {
resolve_required_options(path, call, depth, options)
}
Expression::CallExpression(call) if is_object_assign(&call.callee) => {
for argument in &call.arguments {
let resolved = argument.as_expression().is_some_and(|argument| {
resolve_options(program, path, argument, depth + 1, options)
});
options.unreadable_spread |= !resolved;
}
true
}
Expression::CallExpression(call) => {
resolve_wrapped_options(program, path, unwrap_expression(expr), call, depth, options)
}
Expression::Identifier(identifier) => {
resolve_options_name(program, path, &identifier.name, depth, options)
}
_ => false,
}
}
fn resolve_wrapped_options(
program: &Program<'_>,
path: &Path,
expr: &Expression<'_>,
call: &CallExpression<'_>,
depth: usize,
options: &mut ResolvedOptions,
) -> bool {
let mut received = ResolvedOptions::default();
let resolved = call
.arguments
.first()
.and_then(Argument::as_expression)
.is_some_and(|argument| resolve_options(program, path, argument, depth + 1, &mut received))
|| config_parser::extract_object_from_expression(expr).is_some_and(|object| {
read_options_object(program, path, object, depth + 1, &mut received);
true
});
if !resolved {
return false;
}
if !is_identity_wrapper(&call.callee) {
received.unrecognized_call = true;
received.unrecognized_keys.clone_from(&received.declared);
}
options.absorb(received);
true
}
fn is_identity_wrapper(callee: &Expression<'_>) -> bool {
let name = match unwrap_expression(callee) {
Expression::Identifier(identifier) => identifier.name.as_str(),
Expression::StaticMemberExpression(member) => member.property.name.as_str(),
_ => return false,
};
IDENTITY_WRAPPERS.contains(&name)
}
fn read_options_object(
program: &Program<'_>,
path: &Path,
object: &ObjectExpression<'_>,
depth: usize,
options: &mut ResolvedOptions,
) {
for key in [
FederationKey::Exposes,
FederationKey::Remotes,
FederationKey::Shared,
] {
if config_parser::property_expr(object, key.name()).is_some() {
push_unique(&mut options.declared, key);
}
}
read_exposes(object, &mut options.config, &mut options.unread);
read_remotes(object, &mut options.config, &mut options.unread);
read_shared(object, &mut options.config);
for property in &object.properties {
if let ObjectPropertyKind::SpreadProperty(spread) = property {
let resolved = resolve_options(program, path, &spread.argument, depth + 1, options);
options.unreadable_spread |= !resolved;
}
}
}
fn resolve_options_name(
program: &Program<'_>,
path: &Path,
name: &str,
depth: usize,
options: &mut ResolvedOptions,
) -> bool {
if let Some(init) = config_parser::find_stable_binding_init(program, name) {
return resolve_options_init(program, path, init, depth + 1, options);
}
let Some((specifier, imported_name)) =
config_parser::find_relative_import_binding(program, name)
else {
return false;
};
resolve_module_options(path, &specifier, imported_name.as_deref(), depth, options)
}
fn record_unreadable_import(resolved: bool, options: &mut ResolvedOptions) -> bool {
options.unreadable_import |= !resolved;
true
}
fn resolve_required_options(
path: &Path,
call: &CallExpression<'_>,
depth: usize,
options: &mut ResolvedOptions,
) -> bool {
let Some(specifier) = config_parser::get_require_source(call)
.filter(|specifier| config_parser::is_relative_specifier(specifier))
else {
return false;
};
resolve_module_options(path, &specifier, None, depth, options)
}
fn resolve_module_options(
path: &Path,
specifier: &str,
export_name: Option<&str>,
depth: usize,
options: &mut ResolvedOptions,
) -> bool {
let Some((module_path, source)) = config_parser::resolve_sibling_module(path, specifier) else {
return record_unreadable_import(false, options);
};
let resolved = config_parser::extract_from_source(&source, &module_path, |module| {
let init = match export_name {
Some(name) => config_parser::find_exported_init(module, Some(name))?,
None => config_parser::find_module_export_expression(module)?,
};
resolve_options_init(module, &module_path, init, depth + 1, options).then_some(())
})
.is_some();
record_unreadable_import(resolved, options)
}
fn resolve_options_init(
program: &Program<'_>,
path: &Path,
init: &Expression<'_>,
depth: usize,
options: &mut ResolvedOptions,
) -> bool {
if resolve_options(program, path, init, depth, options) {
return true;
}
let Some(object) = config_parser::extract_object_from_expression(init) else {
return false;
};
read_options_object(program, path, object, depth, options);
true
}
fn is_object_assign(callee: &Expression<'_>) -> bool {
matches!(
unwrap_expression(callee),
Expression::StaticMemberExpression(member)
if member.property.name == "assign"
&& matches!(&member.object, Expression::Identifier(object) if object.name == "Object")
)
}
impl<'a> Visit<'a> for FederationCallCollector<'a, '_> {
fn visit_new_expression(&mut self, new_expression: &NewExpression<'a>) {
self.read_plugin_call(&new_expression.callee, &new_expression.arguments);
walk::walk_new_expression(self, new_expression);
}
fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
self.read_plugin_call(&call.callee, &call.arguments);
walk::walk_call_expression(self, call);
}
}
fn read_exposes(
options: &ObjectExpression<'_>,
config: &mut FederationConfig,
unread: &mut Vec<UnreadDeclaration>,
) {
let mut has_unread_entry = false;
for declaration in federation_key_declarations(options, FederationKey::Exposes, unread) {
let mapping = match declaration {
KeyDeclaration::Target(target) => {
classify_exposed_target(&target, config);
continue;
}
KeyDeclaration::Mapping(mapping) => mapping,
};
for property in &mapping.properties {
let ObjectPropertyKind::ObjectProperty(property) = property else {
continue;
};
let targets = exposed_target_strings(&property.value);
if targets.is_empty() {
has_unread_entry = true;
continue;
}
for target in targets {
classify_exposed_target(&target, config);
}
}
}
if has_unread_entry {
push_unique(
unread,
UnreadDeclaration {
key: FederationKey::Exposes,
reason: UnreadReason::Entries,
},
);
}
}
fn exposed_target_strings(value: &Expression<'_>) -> Vec<String> {
match config_parser::object_expression(value) {
Some(descriptor) => config_parser::property_expr(descriptor, "import")
.map(config_parser::expression_to_string_or_array)
.unwrap_or_default(),
None => config_parser::expression_to_string_or_array(value),
}
}
fn classify_exposed_target(target: &str, config: &mut FederationConfig) {
let trimmed = target.trim();
if trimmed.is_empty() {
return;
}
if let Some(request) = super::module_request(trimmed) {
push_unique(
&mut config.exposed_packages,
crate::resolve::extract_package_name(request),
);
return;
}
push_unique(&mut config.exposed_targets, trimmed.to_string());
}
fn read_remotes(
options: &ObjectExpression<'_>,
config: &mut FederationConfig,
unread: &mut Vec<UnreadDeclaration>,
) {
for declaration in federation_key_declarations(options, FederationKey::Remotes, unread) {
let KeyDeclaration::Mapping(mapping) = declaration else {
continue;
};
for property in &mapping.properties {
let ObjectPropertyKind::ObjectProperty(property) = property else {
continue;
};
if let Some(alias) = property_key_name(&property.key)
&& is_bare_specifier(&alias)
{
push_unique(&mut config.remote_aliases, alias);
}
}
}
}
fn read_shared(options: &ObjectExpression<'_>, config: &mut FederationConfig) {
let Some(value) = config_parser::property_expr(options, FederationKey::Shared.name()) else {
return;
};
if let Some(mapping) = config_parser::object_expression(value) {
read_shared_mapping(mapping, config);
return;
}
let Some(array) = config_parser::array_expression(value) else {
return;
};
for element in &array.elements {
let Some(expr) = element.as_expression() else {
continue;
};
if let Some(mapping) = config_parser::object_expression(expr) {
read_shared_mapping(mapping, config);
} else if let Some(request) = config_parser::expression_to_string(expr) {
push_shared_package(&request, config);
}
}
}
fn read_shared_mapping(mapping: &ObjectExpression<'_>, config: &mut FederationConfig) {
for property in &mapping.properties {
let ObjectPropertyKind::ObjectProperty(property) = property else {
continue;
};
if let Some(key) = property_key_name(&property.key) {
push_shared_package(&key, config);
}
let Some(descriptor) = config_parser::object_expression(&property.value) else {
continue;
};
for field in ["import", "packageName"] {
if let Some(request) = config_parser::property_expr(descriptor, field)
.and_then(config_parser::expression_to_string)
{
push_shared_package(&request, config);
}
}
}
}
fn push_shared_package(request: &str, config: &mut FederationConfig) {
let request = request.trim();
if !is_bare_specifier(request) {
return;
}
push_unique(
&mut config.shared_packages,
crate::resolve::extract_package_name(request),
);
}
enum KeyDeclaration<'a> {
Mapping(&'a ObjectExpression<'a>),
Target(String),
}
fn federation_key_declarations<'a>(
options: &'a ObjectExpression<'a>,
key: FederationKey,
unread: &mut Vec<UnreadDeclaration>,
) -> Vec<KeyDeclaration<'a>> {
let Some(value) = config_parser::property_expr(options, key.name()) else {
return Vec::new();
};
if let Some(mapping) = config_parser::object_expression(value) {
record_spread(mapping, key, unread);
return vec![KeyDeclaration::Mapping(mapping)];
}
let Some(array) = config_parser::array_expression(value) else {
push_unique(
unread,
UnreadDeclaration {
key,
reason: UnreadReason::NotObjectLiteral,
},
);
return Vec::new();
};
if key == FederationKey::Remotes {
push_unique(
unread,
UnreadDeclaration {
key,
reason: UnreadReason::ArrayForm,
},
);
return Vec::new();
}
let mut declarations = Vec::new();
let mut has_unread_element = false;
for element in &array.elements {
let Some(expr) = element.as_expression() else {
has_unread_element = true;
continue;
};
if let Some(mapping) = config_parser::object_expression(expr) {
record_spread(mapping, key, unread);
declarations.push(KeyDeclaration::Mapping(mapping));
continue;
}
let target = config_parser::expression_to_string(expr)
.filter(|target| !super::has_glob_syntax(target));
let Some(target) = target else {
has_unread_element = true;
continue;
};
declarations.push(KeyDeclaration::Target(target));
}
if has_unread_element {
push_unique(
unread,
UnreadDeclaration {
key,
reason: UnreadReason::Entries,
},
);
}
declarations
}
fn record_spread(
mapping: &ObjectExpression<'_>,
key: FederationKey,
unread: &mut Vec<UnreadDeclaration>,
) {
if mapping
.properties
.iter()
.any(|property| matches!(property, ObjectPropertyKind::SpreadProperty(_)))
{
push_unique(
unread,
UnreadDeclaration {
key,
reason: UnreadReason::Spread,
},
);
}
}
fn federation_callee_name<'a>(callee: &'a Expression<'a>) -> Option<&'a str> {
let name = match unwrap_expression(callee) {
Expression::Identifier(identifier) => identifier.name.as_str(),
Expression::StaticMemberExpression(member) => member.property.name.as_str(),
Expression::ComputedMemberExpression(member) => match unwrap_expression(&member.expression)
{
Expression::StringLiteral(literal) => literal.value.as_str(),
_ => return None,
},
_ => return None,
};
FEDERATION_CALLEES.contains(&name).then_some(name)
}
fn unwrap_expression<'a>(expr: &'a Expression<'a>) -> &'a Expression<'a> {
match expr {
Expression::ParenthesizedExpression(paren) => unwrap_expression(&paren.expression),
Expression::TSAsExpression(ts_as) => unwrap_expression(&ts_as.expression),
Expression::TSSatisfiesExpression(ts_satisfies) => {
unwrap_expression(&ts_satisfies.expression)
}
Expression::TSNonNullExpression(non_null) => unwrap_expression(&non_null.expression),
_ => expr,
}
}
fn property_key_name(key: &PropertyKey<'_>) -> Option<String> {
match key {
PropertyKey::StaticIdentifier(identifier) => Some(identifier.name.to_string()),
PropertyKey::StringLiteral(literal) => Some(literal.value.to_string()),
_ => None,
}
}
fn is_bare_specifier(name: &str) -> bool {
config_parser::is_package_specifier(name) && !name.starts_with('.')
}
fn push_unique<T: PartialEq>(values: &mut Vec<T>, value: T) {
if !values.contains(&value) {
values.push(value);
}
}
const DYNAMIC_ARGUMENT: &str = "dynamic-argument";
#[derive(Debug, Default)]
pub struct RuntimeRemotes {
pub rules: Vec<ProvidedDependencyRule>,
pub diagnostics: Vec<super::PluginConfigDiagnostic>,
}
pub fn runtime_remotes<'a>(
sources: impl IntoIterator<Item = (&'a Path, &'a [fallow_types::extract::SemanticFact])>,
root: &Path,
workspaces: &[fallow_config::WorkspaceInfo],
) -> RuntimeRemotes {
let mut remotes = RuntimeRemotes::default();
for (path, facts) in sources {
let mut scope = None;
for fact in facts {
let fallow_types::extract::SemanticFact::FederationRuntimeRemote(fact) = fact else {
continue;
};
let Some(remote) = &fact.remote else {
let diagnostic = super::PluginConfigDiagnostic::unreadable(
path,
"module-federation",
fact.call.name(),
DYNAMIC_ARGUMENT,
);
push_unique(&mut remotes.diagnostics, diagnostic);
continue;
};
let scope = scope.get_or_insert_with(|| runtime_scope(path, root, workspaces));
let rule = ProvidedDependencyRule::new(
scope.clone(),
[remote.clone()],
[format!("{remote}/")],
);
push_unique(&mut remotes.rules, rule);
}
}
remotes
}
fn runtime_scope(path: &Path, root: &Path, workspaces: &[fallow_config::WorkspaceInfo]) -> String {
workspaces
.iter()
.filter(|workspace| path.starts_with(&workspace.root))
.max_by_key(|workspace| workspace.root.components().count())
.and_then(|workspace| workspace.root.strip_prefix(root).ok())
.map(config_parser::path_to_config_string)
.filter(|directory| !directory.is_empty())
.map_or_else(
|| SCOPE_SUFFIX.to_string(),
|directory| format!("{directory}/{SCOPE_SUFFIX}"),
)
}
define_plugin! {
struct ModuleFederationPlugin => "module-federation",
enablers: ENABLERS,
config_patterns: CONFIG_PATTERNS,
always_used: ALWAYS_USED,
resolve_config(config_path, source, root) {
let mut result = PluginResult::default();
super::add_import_referenced_dependencies(&mut result, source, config_path);
let location = ConfigLocation {
config_path,
source_path: config_path,
root,
context: None,
package_dir: None,
};
if let Some(relative) = location.relative_config_path() {
result.always_used_files.push(globset::escape(&relative));
}
let declares_key = apply_from_source(
&mut result,
source,
&location,
"module-federation",
&FederationSites {
read_plugin_calls: false,
read_config_object: true,
},
);
if declares_key {
result
.referenced_dependencies
.extend(BUILD_PLUGIN_ENABLERS.iter().map(|name| (*name).to_string()));
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
const CONFIG: &str = "/project/module-federation.config.ts";
const ROOT: &str = "/project";
fn resolve(source: &str) -> PluginResult {
ModuleFederationPlugin.resolve_config(Path::new(CONFIG), source, Path::new(ROOT))
}
fn entry_patterns(result: &PluginResult) -> Vec<String> {
result
.entry_patterns
.iter()
.map(|rule| rule.pattern.clone())
.collect()
}
fn covers(pattern: &str, path: &str) -> bool {
globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.expect("entry pattern compiles")
.compile_matcher()
.is_match(path)
}
fn standalone(source: &str) -> (FederationConfig, Vec<UnreadDeclaration>) {
read(
source,
Path::new(CONFIG),
&FederationSites {
read_plugin_calls: false,
read_config_object: true,
},
)
}
fn bundler(source: &str) -> (FederationConfig, Vec<UnreadDeclaration>) {
read(
source,
Path::new("webpack.config.js"),
&FederationSites {
read_plugin_calls: true,
read_config_object: false,
},
)
}
#[test]
fn exposes_string_target_becomes_entry_pattern() {
let result = resolve(
r"
import { createModuleFederationConfig } from '@module-federation/enhanced';
export default createModuleFederationConfig({
name: 'checkout',
exposes: { './Button': './src/components/Button.tsx' },
});
",
);
assert_eq!(entry_patterns(&result), vec!["src/components/Button.tsx"]);
assert!(
result
.referenced_dependencies
.contains(&"@module-federation/enhanced".to_string())
);
}
#[test]
fn exposes_import_descriptor_becomes_entry_pattern() {
let result = resolve(
r"
export default {
exposes: { './Button': { import: './src/components/Button.tsx' } },
};
",
);
assert_eq!(entry_patterns(&result), vec!["src/components/Button.tsx"]);
}
#[test]
fn extensionless_exposes_target_expands_file_and_directory_index() {
let result = resolve(r"export default { exposes: { './Button': './src/Button' } };");
assert_eq!(
entry_patterns(&result),
vec![
format!("src/Button.{EXPOSE_EXTENSIONS}"),
format!("src/Button/index.{EXPOSE_EXTENSIONS}"),
]
);
}
#[test]
fn bracketed_target_covers_the_exposed_file_only() {
let result = resolve(r"export default { exposes: { './Page': './src/pages/[id].tsx' } };");
let patterns = entry_patterns(&result);
assert_eq!(patterns.len(), 1, "got {patterns:?}");
assert!(
covers(&patterns[0], "src/pages/[id].tsx"),
"the exposed file is covered, got {patterns:?}"
);
assert!(
!covers(&patterns[0], "src/pages/d.tsx"),
"a bracket is not a character class, got {patterns:?}"
);
}
#[test]
fn wildcard_target_does_not_cover_files_the_config_does_not_name() {
let result = resolve(r"export default { exposes: { './all': './src/*' } };");
let patterns = entry_patterns(&result);
assert!(
!patterns
.iter()
.any(|pattern| covers(pattern, "src/unrelated.ts")),
"got {patterns:?}"
);
assert!(
patterns.iter().any(|pattern| covers(pattern, "src/*.ts")),
"a file literally named `*` is still covered, got {patterns:?}"
);
}
#[test]
fn a_target_naming_a_discovered_extension_is_used_as_written() {
let result = resolve(r"export default { exposes: { './Button': './src/Button.gts' } };");
assert_eq!(entry_patterns(&result), vec!["src/Button.gts"]);
}
#[test]
fn bare_module_request_target_is_credited_as_dependency() {
let result = resolve(r"export default { exposes: { './utils': 'shared-utils' } };");
assert!(entry_patterns(&result).is_empty());
assert!(
result
.package_referenced_dependencies
.iter()
.any(|(_, package)| package == "shared-utils")
);
assert!(
!result
.referenced_dependencies
.contains(&"shared-utils".to_string())
);
}
#[test]
fn target_outside_the_project_root_matches_no_project_file() {
let result = resolve(r"export default { exposes: { './Button': '../other/Button.tsx' } };");
let patterns = entry_patterns(&result);
assert_eq!(patterns, vec!["../other/Button.tsx".to_string()]);
assert!(!covers(&patterns[0], "other/Button.tsx"));
}
#[test]
fn remote_alias_covers_the_alias_and_its_subpaths_only() {
let result = resolve(
r"
export default {
remotes: { checkout: 'checkout@https://example.test/remoteEntry.js' },
};
",
);
let rules = &result.provided_dependencies;
assert_eq!(rules.len(), 1);
let rule = &rules[0];
assert!(rule.covers_specifier("checkout"));
assert!(rule.covers_specifier("checkout/Button"));
assert!(!rule.covers_specifier("checkout-ui"));
assert!(rule.may_cover_package("checkout"));
}
#[test]
fn remote_rule_is_scoped_to_the_directory_that_declared_it() {
let source = r"
export default {
remotes: { checkout: 'checkout@https://example.test/remoteEntry.js' },
};
";
let nested = ModuleFederationPlugin.resolve_config(
Path::new("/project/packages/host/module-federation.config.ts"),
source,
Path::new("/project"),
);
assert_eq!(
nested.provided_dependencies[0].path.pattern,
format!("packages/host/{SCOPE_SUFFIX}")
);
let at_root = resolve(source);
assert_eq!(at_root.provided_dependencies[0].path.pattern, SCOPE_SUFFIX);
}
#[test]
fn remote_external_descriptor_still_yields_the_alias() {
let result = resolve(
r"
export default {
remotes: {
remote: { external: 'app@http://example.test/remoteEntry.js', shareScope: 'default' },
},
};
",
);
assert_eq!(result.provided_dependencies.len(), 1);
assert!(result.provided_dependencies[0].covers_specifier("remote/Thing"));
}
#[test]
fn common_js_config_reads_both_keys() {
let result = resolve(
r"
module.exports = {
exposes: { './Button': './src/Button.tsx' },
remotes: { checkout: 'checkout@https://example.test/remoteEntry.js' },
};
",
);
assert_eq!(entry_patterns(&result), vec!["src/Button.tsx"]);
assert_eq!(result.provided_dependencies.len(), 1);
}
fn unread(key: FederationKey, reason: UnreadReason) -> Vec<UnreadDeclaration> {
vec![UnreadDeclaration { key, reason }]
}
#[test]
fn computed_exposes_reports_the_key_and_keeps_literal_siblings() {
let (config, declarations) =
standalone(r"export default { exposes: computeExposes(), remotes: {} };");
assert!(config.exposed_targets.is_empty());
assert_eq!(
declarations,
unread(FederationKey::Exposes, UnreadReason::NotObjectLiteral)
);
let (config, declarations) = standalone(
r"
export default {
exposes: { './a': './src/a.ts', ...extraExposes },
};
",
);
assert_eq!(config.exposed_targets, vec!["./src/a.ts".to_string()]);
assert_eq!(
declarations,
unread(FederationKey::Exposes, UnreadReason::Spread)
);
}
#[test]
fn an_entry_whose_target_is_not_readable_is_reported_once_beside_its_siblings() {
let (config, declarations) = standalone(
r"
const widget = './src/Widget.tsx';
export default {
exposes: {
'./Button': './src/Button.tsx',
'./Widget': widget,
'./Card': { name: 'card' },
},
};
",
);
assert_eq!(config.exposed_targets, vec!["./src/Button.tsx".to_string()]);
assert_eq!(
declarations,
unread(FederationKey::Exposes, UnreadReason::Entries),
"two unreadable entries under one key are one advisory"
);
}
#[test]
fn exposes_array_form_reads_string_elements() {
let (config, declarations) =
standalone(r"export default { exposes: ['./src/Button.tsx', 'shared-utils'] };");
assert_eq!(config.exposed_targets, vec!["./src/Button.tsx".to_string()]);
assert_eq!(config.exposed_packages, vec!["shared-utils".to_string()]);
assert!(declarations.is_empty(), "got {declarations:?}");
}
#[test]
fn exposes_array_form_reads_object_elements() {
let (config, declarations) = standalone(
r"
export default {
exposes: [
{ './Button': './src/Button.tsx' },
{ './Card': { import: './src/Card.tsx' } },
],
};
",
);
assert_eq!(
config.exposed_targets,
vec!["./src/Button.tsx".to_string(), "./src/Card.tsx".to_string()]
);
assert!(declarations.is_empty(), "got {declarations:?}");
}
#[test]
fn exposes_array_elements_that_are_not_a_request_are_reported() {
for source in [
r"export default { exposes: ['./src/*.tsx'] };",
r"export default { exposes: [['./src/Button.tsx']] };",
r"export default { exposes: [42] };",
] {
let (config, declarations) = standalone(source);
assert_eq!(config, FederationConfig::default(), "source: {source}");
assert_eq!(
declarations,
unread(FederationKey::Exposes, UnreadReason::Entries),
"source: {source}"
);
}
}
#[test]
fn exposes_array_element_without_a_readable_target_is_reported() {
let (config, declarations) = standalone(
r"
const widget = './src/Widget.tsx';
export default { exposes: ['./src/Button.tsx', widget] };
",
);
assert_eq!(config.exposed_targets, vec!["./src/Button.tsx".to_string()]);
assert_eq!(
declarations,
unread(FederationKey::Exposes, UnreadReason::Entries)
);
}
#[test]
fn remotes_array_form_is_not_read() {
let (config, declarations) = standalone(
r"export default { remotes: ['checkout@https://example.test/remoteEntry.js'] };",
);
assert!(config.remote_aliases.is_empty());
assert_eq!(
declarations,
unread(FederationKey::Remotes, UnreadReason::ArrayForm)
);
}
#[test]
fn each_unread_shape_carries_its_own_reason_token() {
assert_eq!(UnreadReason::NotObjectLiteral.token(), "not-object-literal");
assert_eq!(UnreadReason::ArrayForm.token(), "array-form");
assert_eq!(UnreadReason::Spread.token(), "spread");
assert_eq!(UnreadReason::Entries.token(), "unreadable-entries");
}
#[test]
fn an_unreadable_key_records_a_diagnostic_on_its_config_file() {
let result = resolve(r"export default { exposes: computeExposes() };");
assert_eq!(result.config_diagnostics.len(), 1);
let diagnostic = &result.config_diagnostics[0];
assert_eq!(diagnostic.config_path, Path::new(CONFIG));
assert_eq!(diagnostic.plugin, "module-federation");
assert_eq!(diagnostic.key, "exposes");
assert_eq!(diagnostic.reason, "not-object-literal");
assert_eq!(
diagnostic.effect,
super::super::PluginConfigEffect::Unreadable
);
}
#[test]
fn both_unreadable_keys_in_one_config_are_recorded() {
let result = resolve(
r"
export default {
exposes: makeExposes(),
remotes: { ...envRemotes },
};
",
);
let recorded: Vec<(&str, &str)> = result
.config_diagnostics
.iter()
.map(|diagnostic| (diagnostic.key.as_str(), diagnostic.reason.as_str()))
.collect();
assert_eq!(
recorded,
vec![("exposes", "not-object-literal"), ("remotes", "spread")],
"{:?}",
result.config_diagnostics
);
}
#[test]
fn a_readable_config_records_no_diagnostic() {
let result = resolve(
r"
export default {
exposes: { './Button': './src/Button.tsx' },
remotes: { checkout: 'checkout@https://example.test/remoteEntry.js' },
};
",
);
assert!(
result.config_diagnostics.is_empty(),
"{:?}",
result.config_diagnostics
);
}
#[test]
fn inline_bundler_options_record_under_the_bundler_plugin() {
let mut result = PluginResult::default();
let config_path = Path::new("/project/webpack.config.js");
apply_bundler_plugin_options(
&mut result,
r"
module.exports = {
plugins: [new ModuleFederationPlugin({ remotes: envRemotes() })],
};
",
config_path,
Path::new("/project"),
FederationBase::default(),
"webpack",
);
assert_eq!(result.config_diagnostics.len(), 1);
let diagnostic = &result.config_diagnostics[0];
assert_eq!(diagnostic.plugin, "webpack");
assert_eq!(diagnostic.key, "remotes");
assert_eq!(diagnostic.config_path, config_path);
}
#[test]
fn a_result_carrying_only_a_diagnostic_is_not_empty() {
let mut result = PluginResult::default();
assert!(result.is_empty());
result
.config_diagnostics
.push(super::super::PluginConfigDiagnostic::unreadable(
Path::new("/project/webpack.config.js"),
"webpack",
"remotes",
"not-object-literal",
));
assert!(
!result.is_empty(),
"the advisory is the whole contribution of this config"
);
}
#[test]
fn shorthand_remotes_property_reports_the_key() {
let (config, declarations) = standalone(
r"
const remotes = { checkout: 'checkout@https://example.test/remoteEntry.js' };
export default { remotes };
",
);
assert!(config.remote_aliases.is_empty());
assert_eq!(
declarations,
unread(FederationKey::Remotes, UnreadReason::NotObjectLiteral)
);
}
#[test]
fn config_without_federation_keys_contributes_only_its_own_file() {
let result = resolve(r"export default { name: 'checkout' };");
assert!(entry_patterns(&result).is_empty());
assert!(result.provided_dependencies.is_empty());
assert!(result.referenced_dependencies.is_empty());
assert_eq!(
result.always_used_files,
vec!["module-federation.config.ts".to_string()]
);
}
#[test]
fn a_nested_config_file_is_credited_as_used() {
let nested = ModuleFederationPlugin.resolve_config(
Path::new("/project/packages/host/module-federation.config.ts"),
r"export default { exposes: { './Button': './src/Button.tsx' } };",
Path::new("/project"),
);
assert_eq!(
nested.always_used_files,
vec!["packages/host/module-federation.config.ts".to_string()]
);
}
#[test]
fn inline_plugin_options_are_read_from_a_plugins_array() {
let (config, computed) = bundler(
r"
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
exposes: { './Button': './src/Button.tsx' },
remotes: { checkout: 'checkout@https://example.test/remoteEntry.js' },
}),
],
};
",
);
assert_eq!(config.exposed_targets, vec!["./src/Button.tsx".to_string()]);
assert_eq!(config.remote_aliases, vec!["checkout".to_string()]);
assert!(computed.is_empty());
}
#[test]
fn member_expression_callee_is_recognised() {
let (config, _) = bundler(
r"
module.exports = {
plugins: [
new webpack.container.ModuleFederationPlugin({
exposes: { './B': './src/B.tsx' },
}),
],
};
",
);
assert_eq!(config.exposed_targets, vec!["./src/B.tsx".to_string()]);
}
#[test]
fn federation_options_in_a_nested_plugin_array_are_read() {
let (config, computed) = bundler(
r"
export default defineConfig({
plugins: [
[react(), federation({ exposes: { './Button': './src/Button.tsx' } })],
other(),
],
});
",
);
assert_eq!(config.exposed_targets, vec!["./src/Button.tsx".to_string()]);
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn federation_options_outside_the_plugins_array_are_read() {
let (config, computed) = bundler(
r"
module.exports = {
webpack(config, options) {
config.plugins.push(
new NextFederationPlugin({
name: 'shop',
exposes: { './pages-map': './pages-map.js' },
}),
);
return config;
},
};
",
);
assert_eq!(config.exposed_targets, vec!["./pages-map.js".to_string()]);
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn federation_options_from_a_plugins_identifier_are_read() {
let (config, _) = bundler(
r"
const plugins = [
new ModuleFederationPlugin({ exposes: { './Button': './src/Button.tsx' } }),
];
module.exports = { plugins };
",
);
assert_eq!(config.exposed_targets, vec!["./src/Button.tsx".to_string()]);
}
#[test]
fn federation_options_under_a_tool_key_are_read() {
let (config, _) = bundler(
r"
export default {
tools: {
rspack: {
plugins: [
new ModuleFederationPlugin({
exposes: { './Button': './src/Button.tsx' },
}),
],
},
},
};
",
);
assert_eq!(config.exposed_targets, vec!["./src/Button.tsx".to_string()]);
}
#[test]
fn federation_options_bound_to_a_local_const_are_read() {
let (config, computed) = bundler(
r"
const mfConfig = {
name: 'host',
exposes: { './Button': './src/Button.tsx' },
remotes: { checkout: 'checkout@https://example.test/remoteEntry.js' },
};
module.exports = {
plugins: [new ModuleFederationPlugin(mfConfig)],
};
",
);
assert_eq!(config.exposed_targets, vec!["./src/Button.tsx".to_string()]);
assert_eq!(config.remote_aliases, vec!["checkout".to_string()]);
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn two_calls_that_share_one_options_const_register_one_target() {
let (config, _) = bundler(
r"
const mfConfig = { exposes: { './Button': './src/Button.tsx' } };
module.exports = {
plugins: [new ModuleFederationPlugin(mfConfig)],
webpack(config) {
config.plugins.push(new ModuleFederationPlugin(mfConfig));
return config;
},
};
",
);
assert_eq!(config.exposed_targets, vec!["./src/Button.tsx".to_string()]);
}
#[test]
fn a_shadowed_options_name_is_not_read() {
let (config, computed) = bundler(
r"
const mfConfig = { exposes: { './Top': './src/Top.tsx' } };
module.exports = {
webpack(config) {
const mfConfig = { exposes: { './Hook': './src/Hook.tsx' } };
config.plugins.push(new ModuleFederationPlugin(mfConfig));
return config;
},
};
",
);
assert_eq!(config, FederationConfig::default());
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn an_options_parameter_is_not_read() {
let (config, computed) = bundler(
r"
const options = { exposes: { './Top': './src/Top.tsx' } };
function make(options) {
return new ModuleFederationPlugin(options);
}
module.exports = { plugins: [make(buildOptions())] };
",
);
assert_eq!(config, FederationConfig::default());
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn options_that_the_config_writes_to_are_not_read() {
for source in [
r"
let mfConfig = { exposes: { './A': './src/A.tsx' } };
mfConfig = buildConfig();
module.exports = { plugins: [new ModuleFederationPlugin(mfConfig)] };
",
r"
const mfConfig = { exposes: { './Old': './src/Old.tsx' } };
delete mfConfig.exposes['./Old'];
module.exports = { plugins: [new ModuleFederationPlugin(mfConfig)] };
",
r"
const mfConfig = { exposes: { './A': './src/A.tsx' } };
mfConfig.exposes['./B'] = './src/B.tsx';
module.exports = { plugins: [new ModuleFederationPlugin(mfConfig)] };
",
] {
let (config, computed) = bundler(source);
assert_eq!(config, FederationConfig::default(), "source: {source}");
assert!(computed.is_empty(), "source: {source}");
}
}
#[test]
fn options_bound_by_var_are_not_read() {
let (config, computed) = bundler(
r"
var mfConfig = { exposes: { './Button': './src/Button.tsx' } };
module.exports = { plugins: [new ModuleFederationPlugin(mfConfig)] };
",
);
assert_eq!(config, FederationConfig::default());
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn plugin_call_without_federation_keys_is_inert() {
for source in [
r"module.exports = { plugins: [new ModuleFederationPlugin({ name: 'x' })] };",
r"module.exports = { plugins: [somethingElse({ exposes: { './a': './src/a.ts' } })] };",
r"module.exports = { plugins: [federation(mfConfig)] };",
r"module.exports = { plugins: [federation('graphql-schema', { batch: true })] };",
r"module.exports = { plugins: [federation({ ...opts })] };",
r"
const options = { registry: './src/registry.ts' };
module.exports = { plugins: [federation(options)] };
",
r"
export default defineConfig({
plugins: [[federation(), other()]],
});
",
] {
let (config, computed) = bundler(source);
assert_eq!(config, FederationConfig::default(), "source: {source}");
assert!(computed.is_empty(), "source: {source}");
}
}
fn bundler_in_temp_dir(source: &str) -> (FederationConfig, Vec<UnreadDeclaration>) {
let dir = tempfile::tempdir().expect("temp dir");
read(
source,
&dir.path().join("webpack.config.js"),
&FederationSites {
read_plugin_calls: true,
read_config_object: false,
},
)
}
fn exposed(target: &str) -> FederationConfig {
FederationConfig {
exposed_targets: vec![target.to_string()],
..FederationConfig::default()
}
}
#[test]
fn exported_options_const_is_read() {
let (config, computed) = bundler(
r"
export const mfConfig = { exposes: { './Button': './src/Button.tsx' } };
export default { plugins: [new ModuleFederationPlugin(mfConfig)] };
",
);
assert_eq!(config, exposed("./src/Button.tsx"));
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn non_null_options_and_computed_member_callee_are_read() {
let (config, computed) = read(
r"
const mfConfig = { exposes: { './Button': './src/Button.tsx' } };
export default { plugins: [new ModuleFederationPlugin(mfConfig!)] };
",
Path::new("webpack.config.ts"),
&FederationSites {
read_plugin_calls: true,
read_config_object: false,
},
);
assert_eq!(config, exposed("./src/Button.tsx"));
assert!(computed.is_empty(), "got {computed:?}");
let (config, computed) = bundler(
r"
const container = require('@module-federation/enhanced');
module.exports = {
plugins: [new container['ModuleFederationPlugin']({
exposes: { './Button': './src/Button.tsx' },
})],
};
",
);
assert_eq!(config, exposed("./src/Button.tsx"));
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn spread_and_object_assign_over_local_bindings_are_read() {
for source in [
r"
const base = { exposes: { './Button': './src/Button.tsx' } };
const mfConfig = { ...base };
module.exports = { plugins: [new ModuleFederationPlugin(mfConfig)] };
",
r"
const base = { exposes: { './Button': './src/Button.tsx' } };
module.exports = { plugins: [new ModuleFederationPlugin(Object.assign({}, base))] };
",
r"
const base = createModuleFederationConfig({ exposes: { './Button': './src/Button.tsx' } });
module.exports = { plugins: [new ModuleFederationPlugin({ name: 'app', ...base })] };
",
] {
let (config, computed) = bundler(source);
assert_eq!(config, exposed("./src/Button.tsx"), "source: {source}");
assert!(computed.is_empty(), "source: {source}");
}
}
#[test]
fn an_unreadable_spread_is_recorded_against_each_undeclared_key() {
let (config, computed) = bundler_in_temp_dir(
r"
module.exports = {
plugins: [new ModuleFederationPlugin({
...getShared(),
exposes: { './Button': './src/Button.tsx' },
})],
};
",
);
assert_eq!(config, exposed("./src/Button.tsx"));
assert_eq!(
computed,
unread(FederationKey::Remotes, UnreadReason::Spread)
);
for source in [
r"module.exports = { plugins: [new ModuleFederationPlugin({ ...shared })] };",
r"module.exports = { plugins: [new ModuleFederationPlugin(Object.assign({}, shared))] };",
] {
let (config, computed) = bundler_in_temp_dir(source);
assert_eq!(config, FederationConfig::default(), "source: {source}");
assert_eq!(
computed,
vec![
UnreadDeclaration {
key: FederationKey::Exposes,
reason: UnreadReason::Spread,
},
UnreadDeclaration {
key: FederationKey::Remotes,
reason: UnreadReason::Spread,
},
],
"source: {source}"
);
}
}
#[test]
fn a_spread_cycle_ends_as_an_unreadable_spread() {
let (config, computed) = bundler(
r"
const a = { ...b, exposes: { './Button': './src/Button.tsx' } };
const b = { ...a };
module.exports = { plugins: [new ModuleFederationPlugin(a)] };
",
);
assert_eq!(config, exposed("./src/Button.tsx"));
assert_eq!(
computed,
unread(FederationKey::Remotes, UnreadReason::Spread)
);
}
#[test]
fn a_package_require_is_silent() {
let (config, computed) = bundler(
r"
const mfConfig = require('shared-federation-config');
module.exports = { plugins: [new ModuleFederationPlugin(mfConfig)] };
",
);
assert_eq!(config, FederationConfig::default());
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn options_from_a_relative_require_are_read() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(
dir.path().join("mf.config.js"),
r"
const options = { exposes: { './Button': './src/Button.tsx' } };
module.exports = options;
",
)
.expect("write sibling config");
let (config, computed) = read(
r"
const mfConfig = require('./mf.config');
module.exports = {
plugins: [
new ModuleFederationPlugin(mfConfig),
new ModuleFederationPlugin({ ...require('./mf.config.js') }),
],
};
",
&dir.path().join("webpack.config.js"),
&FederationSites {
read_plugin_calls: true,
read_config_object: false,
},
);
assert_eq!(config, exposed("./src/Button.tsx"));
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn options_from_a_relative_import_are_read() {
let dir = tempfile::tempdir().expect("temp dir");
let config_path = dir.path().join("webpack.config.ts");
std::fs::write(
dir.path().join("mf.config.ts"),
r"
const base = { exposes: { './Button': './src/Button.tsx' } };
export const mfConfig = { ...base };
export default createModuleFederationConfig({ remotes: { checkout: 'checkout@x' } });
",
)
.expect("write sibling config");
let (config, computed) = read(
r"
import remote, { mfConfig } from './mf.config';
export default {
plugins: [
new ModuleFederationPlugin(mfConfig),
new ModuleFederationPlugin(remote),
],
};
",
&config_path,
&FederationSites {
read_plugin_calls: true,
read_config_object: false,
},
);
assert_eq!(
config,
FederationConfig {
exposed_targets: vec!["./src/Button.tsx".to_string()],
remote_aliases: vec!["checkout".to_string()],
..FederationConfig::default()
}
);
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn a_standalone_config_reads_a_spread_of_a_local_binding() {
let (config, computed) = standalone(
r"
const base = { exposes: { './Button': './src/Button.tsx' } };
export default { name: 'app', ...base };
",
);
assert_eq!(config, exposed("./src/Button.tsx"));
assert!(computed.is_empty(), "got {computed:?}");
}
fn unread_both(reason: UnreadReason) -> Vec<UnreadDeclaration> {
vec![
UnreadDeclaration {
key: FederationKey::Exposes,
reason,
},
UnreadDeclaration {
key: FederationKey::Remotes,
reason,
},
]
}
#[test]
fn identity_wrappers_are_read_without_a_diagnostic() {
for source in [
r"module.exports = { plugins: [new ModuleFederationPlugin(createModuleFederationConfig({ exposes: { './Button': './src/Button.tsx' } }))] };",
r"module.exports = { plugins: [new ModuleFederationPlugin(defineConfig({ exposes: { './Button': './src/Button.tsx' } }))] };",
r"
const mf = createModuleFederationConfig({ exposes: { './Button': './src/Button.tsx' } });
module.exports = { plugins: [new ModuleFederationPlugin(mf)] };
",
r"
const base = { exposes: { './Button': './src/Button.tsx' } };
module.exports = { plugins: [new ModuleFederationPlugin(mf.createModuleFederationConfig(base))] };
",
] {
let (config, computed) = bundler(source);
assert_eq!(config, exposed("./src/Button.tsx"), "source: {source}");
assert!(computed.is_empty(), "source: {source}, got {computed:?}");
}
for source in [
r"export default createModuleFederationConfig({ exposes: { './Button': './src/Button.tsx' } });",
r"export default defineConfig({ exposes: { './Button': './src/Button.tsx' } });",
] {
let (config, computed) = standalone(source);
assert_eq!(config, exposed("./src/Button.tsx"), "source: {source}");
assert!(computed.is_empty(), "source: {source}, got {computed:?}");
}
}
#[test]
fn an_unrecognized_call_keeps_the_inner_literal_and_is_recorded() {
let expected = unread(FederationKey::Exposes, UnreadReason::UnrecognizedCall);
for source in [
r"module.exports = { plugins: [new ModuleFederationPlugin(federationConfig({ name: 'app', exposes: { './Button': './src/Button.tsx' } }))] };",
r"
const mf = federationConfig({ name: 'app', exposes: { './Button': './src/Button.tsx' } });
module.exports = { plugins: [new ModuleFederationPlugin(mf)] };
",
r"
const base = { name: 'app', exposes: { './Button': './src/Button.tsx' } };
module.exports = { plugins: [new ModuleFederationPlugin(withShared(base))] };
",
] {
let (config, computed) = bundler(source);
assert_eq!(config, exposed("./src/Button.tsx"), "source: {source}");
assert_eq!(computed, expected, "source: {source}");
}
let (config, computed) = standalone(
r"export default federationConfig({ exposes: { './Button': './src/Button.tsx' } });",
);
assert_eq!(config, exposed("./src/Button.tsx"));
assert_eq!(computed, expected);
let (config, computed) = bundler(
r"module.exports = { plugins: [new ModuleFederationPlugin(withShared({ name: 'app' }))] };",
);
assert_eq!(config, FederationConfig::default());
assert_eq!(computed, unread_both(UnreadReason::UnrecognizedCall));
let (config, computed) =
bundler(r"export default { plugins: [federation(withShared({ name: 'app' }))] };");
assert_eq!(config, FederationConfig::default());
assert!(computed.is_empty(), "got {computed:?}");
}
#[test]
fn an_unrecognized_call_records_only_the_keys_it_receives() {
let (config, computed) = bundler(
r"
module.exports = { plugins: [new ModuleFederationPlugin({
...withShared({ exposes: { './Button': './src/Button.tsx' } }),
remotes: { checkout: 'checkout@x' },
})] };
",
);
assert_eq!(
config,
FederationConfig {
exposed_targets: vec!["./src/Button.tsx".to_string()],
remote_aliases: vec!["checkout".to_string()],
..FederationConfig::default()
}
);
assert_eq!(
computed,
unread(FederationKey::Exposes, UnreadReason::UnrecognizedCall)
);
}
#[test]
fn an_unreadable_import_target_is_recorded() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(
dir.path().join("mf.options.js"),
"const build = require('./build');\nmodule.exports = build();\n",
)
.expect("write sibling config");
let read_in_dir = |source: &str| {
read(
source,
&dir.path().join("webpack.config.js"),
&FederationSites {
read_plugin_calls: true,
read_config_object: false,
},
)
};
for source in [
r"module.exports = { plugins: [new ModuleFederationPlugin(require('./missing.options'))] };",
r"module.exports = { plugins: [new ModuleFederationPlugin(require('./mf.options'))] };",
r"
const mf = require('./mf.options');
module.exports = { plugins: [new ModuleFederationPlugin(mf)] };
",
r"
import mf from './missing.options';
export default { plugins: [new ModuleFederationPlugin(mf)] };
",
] {
let (config, computed) = read_in_dir(source);
assert_eq!(config, FederationConfig::default(), "source: {source}");
assert_eq!(
computed,
unread_both(UnreadReason::ImportTargetUnreadable),
"source: {source}"
);
}
let (config, computed) = read_in_dir(
r"
module.exports = { plugins: [new ModuleFederationPlugin({
...require('./missing.options'),
exposes: { './Button': './src/Button.tsx' },
})] };
",
);
assert_eq!(config, exposed("./src/Button.tsx"));
assert_eq!(
computed,
unread(FederationKey::Remotes, UnreadReason::ImportTargetUnreadable)
);
for source in [
r"module.exports = { plugins: [new ModuleFederationPlugin(require('shared-federation-options'))] };",
r"module.exports = { plugins: [new ModuleFederationPlugin(options)] };",
r"module.exports = { plugins: [federation(require('./missing.options'))] };",
] {
let (config, computed) = read_in_dir(source);
assert_eq!(config, FederationConfig::default(), "source: {source}");
assert!(computed.is_empty(), "source: {source}, got {computed:?}");
}
}
#[test]
fn the_new_reasons_carry_their_own_tokens() {
assert_eq!(UnreadReason::UnrecognizedCall.token(), "unrecognized-call");
assert_eq!(
UnreadReason::ImportTargetUnreadable.token(),
"import-target-unreadable"
);
}
#[test]
fn a_standalone_config_that_declares_a_key_credits_the_build_plugins() {
let result = resolve(
r"module.exports = { name: 'app', exposes: { './Button': './src/Button.tsx' } };",
);
for package in [
"@module-federation/enhanced",
"@module-federation/rsbuild-plugin",
"@module-federation/vite",
] {
assert!(
result
.referenced_dependencies
.iter()
.any(|dep| dep == package),
"{package} is credited, got {:?}",
result.referenced_dependencies
);
}
assert!(
!result
.referenced_dependencies
.iter()
.any(|dep| dep == "@module-federation/runtime"),
"the runtime is not credited, got {:?}",
result.referenced_dependencies
);
let result = resolve(r"module.exports = { remotes: { checkout: 'checkout@x' } };");
assert!(
result
.referenced_dependencies
.iter()
.any(|dep| dep == "@module-federation/enhanced"),
"a remotes key credits too, got {:?}",
result.referenced_dependencies
);
let result = resolve(r"module.exports = { name: 'app', filename: 'remoteEntry.js' };");
assert!(
result.referenced_dependencies.is_empty(),
"no Federation key, no credit, got {:?}",
result.referenced_dependencies
);
}
#[test]
fn a_target_outside_the_plugin_root_keeps_its_parent_segments() {
let result = ModuleFederationPlugin.resolve_config(
Path::new("/project/packages/app/webpack.config.js"),
r"export default { exposes: { './Thing': '../shared/src/Thing.tsx', './Lib': '../shared/src/lib' } };",
Path::new("/project/packages/app"),
);
let patterns = entry_patterns(&result);
assert!(
patterns.contains(&"../shared/src/Thing.tsx".to_string()),
"got {patterns:?}"
);
assert!(
patterns
.iter()
.any(|pattern| covers(pattern, "../shared/src/lib/index.ts")),
"got {patterns:?}"
);
}
fn shared(packages: &[&str]) -> FederationConfig {
FederationConfig {
shared_packages: packages.iter().map(|name| (*name).to_string()).collect(),
..FederationConfig::default()
}
}
#[test]
fn shared_object_and_array_forms_name_their_packages() {
let (config, unread) = bundler(
r"
module.exports = { plugins: [new ModuleFederationPlugin({
shared: {
react: { singleton: true },
'react-dom': '^18.0.0',
'@scope/ui/': {},
alias: { import: 'lodash/merge', packageName: 'lodash' },
'./src/local': {},
off: { import: false },
},
})] };
",
);
assert_eq!(
config,
shared(&["react", "react-dom", "@scope/ui", "alias", "lodash", "off"])
);
assert!(unread.is_empty(), "got {unread:?}");
let (config, unread) = standalone(
r"export default { shared: ['react', { 'react-dom': { singleton: true } }, 42] };",
);
assert_eq!(config, shared(&["react", "react-dom"]));
assert!(unread.is_empty(), "got {unread:?}");
}
#[test]
fn an_unreadable_shared_value_records_no_diagnostic() {
for source in [
r"export default { shared: makeShared() };",
r"export default { shared: { ...deps, react: {} } };",
] {
let (_, unread) = standalone(source);
assert!(unread.is_empty(), "{source}: got {unread:?}");
}
let (config, _) = standalone(r"export default { shared: { ...deps, react: {} } };");
assert_eq!(config, shared(&["react"]));
}
#[test]
fn shared_passes_the_shape_gate_of_the_ambiguous_callee() {
let (config, _) = read(
r"export default { plugins: [federation({ name: 'app', shared: ['vue'] })] };",
Path::new("vite.config.ts"),
&FederationSites {
read_plugin_calls: true,
read_config_object: false,
},
);
assert_eq!(config, shared(&["vue"]));
let result = resolve(r"export default { name: 'app', shared: ['react'] };");
assert!(
result
.referenced_dependencies
.contains(&"@module-federation/enhanced".to_string()),
"a shared key credits the build plugins, got {:?}",
result.referenced_dependencies
);
assert!(
!result
.referenced_dependencies
.contains(&"react".to_string()),
"a shared package is not credited project-wide, got {:?}",
result.referenced_dependencies
);
assert_eq!(
result.package_referenced_dependencies,
vec![(PathBuf::from("/project/package.json"), "react".to_string())]
);
}
#[test]
fn an_unrecognized_call_that_receives_only_shared_records_both_keys() {
let (config, unread) = bundler(
r"
module.exports = { plugins: [new ModuleFederationPlugin(
withDefaults({ shared: ['react'] }),
)] };
",
);
assert_eq!(config, shared(&["react"]));
assert_eq!(
unread,
vec![
UnreadDeclaration {
key: FederationKey::Exposes,
reason: UnreadReason::UnrecognizedCall,
},
UnreadDeclaration {
key: FederationKey::Remotes,
reason: UnreadReason::UnrecognizedCall,
},
]
);
}
#[test]
fn a_bare_federation_call_that_receives_only_shared_records_nothing() {
let (config, unread) = read(
r#"
const federation = (options) => options;
export default { plugins: [federation(withDefaults({ shared: ["vue"] }))] };
"#,
Path::new("vite.config.ts"),
&FederationSites {
read_plugin_calls: true,
read_config_object: false,
},
);
assert_eq!(config, shared(&["vue"]));
assert!(unread.is_empty(), "got {unread:?}");
}
}