diff --git a/crates/turborepo-lib/src/engine/mod.rs b/crates/turborepo-lib/src/engine/mod.rs index f87f669ab4375..2f06c7328862e 100644 --- a/crates/turborepo-lib/src/engine/mod.rs +++ b/crates/turborepo-lib/src/engine/mod.rs @@ -394,6 +394,13 @@ impl Engine { self.task_graph.node_weights() } + pub fn task_ids(&self) -> impl Iterator> { + self.tasks().filter_map(|task| match task { + crate::engine::TaskNode::Task(task_id) => Some(task_id), + crate::engine::TaskNode::Root => None, + }) + } + /// Return all tasks that have a command to be run pub fn tasks_with_command(&self, pkg_graph: &PackageGraph) -> Vec { self.tasks() diff --git a/crates/turborepo-lib/src/microfrontends.rs b/crates/turborepo-lib/src/microfrontends.rs index be562802b034e..e70aded21853f 100644 --- a/crates/turborepo-lib/src/microfrontends.rs +++ b/crates/turborepo-lib/src/microfrontends.rs @@ -20,7 +20,8 @@ pub struct MicrofrontendsConfigs { #[derive(Debug, Clone, Default, PartialEq)] struct ConfigInfo { - tasks: HashSet>, + // A map from tasks declared in the configuration to the application that they belong to + tasks: HashMap, String>, ports: HashMap, u16>, version: &'static str, path: Option, @@ -32,24 +33,33 @@ impl MicrofrontendsConfigs { repo_root: &AbsoluteSystemPath, package_graph: &PackageGraph, ) -> Result, Error> { - Self::from_configs(package_graph.packages().map(|(name, info)| { - ( - name.as_str(), - MFEConfig::load_from_dir(repo_root, info.package_path()), - ) - })) + let package_names = package_graph + .packages() + .map(|(name, _)| name.as_str()) + .collect(); + Self::from_configs( + package_names, + package_graph.packages().map(|(name, info)| { + ( + name.as_str(), + MFEConfig::load_from_dir(repo_root, info.package_path()), + ) + }), + ) } /// Constructs a collection of configurations from a list of configurations pub fn from_configs<'a>( + package_names: HashSet<&str>, configs: impl Iterator, Error>)>, ) -> Result, Error> { let PackageGraphResult { configs, missing_default_apps, + missing_applications, unsupported_version, mfe_package, - } = PackageGraphResult::new(configs)?; + } = PackageGraphResult::new(package_names, configs)?; for (package, err) in unsupported_version { warn!("Ignoring {package}: {err}"); @@ -62,21 +72,26 @@ impl MicrofrontendsConfigs { ); } + if !missing_applications.is_empty() { + warn!( + "Unable to find packages referenced in 'microfrontends.json' in workspace. Local \ + proxy will not route to the following applications if they are running locally: \ + {}", + missing_applications.join(", ") + ); + } + Ok((!configs.is_empty()).then_some(Self { configs, mfe_package, })) } - pub fn contains_package(&self, package_name: &str) -> bool { - self.configs.contains_key(package_name) - } - - pub fn configs(&self) -> impl Iterator>)> { + pub fn configs(&self) -> impl Iterator, String>)> { self.configs.iter().map(|(pkg, info)| (pkg, &info.tasks)) } - pub fn get(&self, package_name: &str) -> Option<&HashSet>> { + pub fn get(&self, package_name: &str) -> Option<&HashMap, String>> { let info = self.configs.get(package_name)?; Some(&info.tasks) } @@ -84,7 +99,7 @@ impl MicrofrontendsConfigs { pub fn task_has_mfe_proxy(&self, task_id: &TaskId) -> bool { self.configs .values() - .any(|info| info.tasks.contains(task_id)) + .any(|info| info.tasks.contains_key(task_id)) } pub fn config_filename(&self, package_name: &str) -> Option<&RelativeUnixPath> { @@ -152,7 +167,7 @@ impl MicrofrontendsConfigs { package_name: &PackageName, ) -> Option> { let results = self.configs.iter().filter_map(|(config, info)| { - let dev_task = info.tasks.iter().find_map(|task| { + let dev_task = info.tasks.iter().find_map(|(task, _)| { (task.package() == package_name.as_str()).then(|| FindResult { dev: Some(task.as_borrowed()), proxy: TaskId::new(config, "proxy"), @@ -186,16 +201,19 @@ impl MicrofrontendsConfigs { struct PackageGraphResult { configs: HashMap, missing_default_apps: Vec, + missing_applications: Vec, unsupported_version: Vec<(String, String)>, mfe_package: Option<&'static str>, } impl PackageGraphResult { fn new<'a>( + packages_in_graph: HashSet<&str>, packages: impl Iterator, Error>)>, ) -> Result { let mut configs = HashMap::new(); let mut referenced_default_apps = HashSet::new(); + let mut referenced_packages = HashSet::new(); let mut unsupported_version = Vec::new(); let mut mfe_package = None; // We sort packages to ensure deterministic behavior @@ -223,6 +241,8 @@ impl PackageGraphResult { if let Some(path) = config.path() { info.path = Some(path.to_unix()); } + referenced_packages.insert(package_name.to_string()); + referenced_packages.extend(info.tasks.keys().map(|task| task.package().to_string())); configs.insert(package_name.to_string(), info); } let default_apps_found = configs.keys().cloned().collect(); @@ -231,9 +251,15 @@ impl PackageGraphResult { .cloned() .collect::>(); missing_default_apps.sort(); + let mut missing_applications = referenced_packages + .into_iter() + .filter(|package| !packages_in_graph.contains(package.as_str())) + .collect::>(); + missing_applications.sort(); Ok(Self { configs, missing_default_apps, + missing_applications, unsupported_version, mfe_package, }) @@ -250,13 +276,13 @@ struct FindResult<'a> { impl ConfigInfo { fn new(config: &MFEConfig) -> Self { let mut ports = HashMap::new(); - let mut tasks = HashSet::new(); - for (application, dev_task) in config.development_tasks() { - let task = TaskId::new(application, dev_task.unwrap_or("dev")).into_owned(); - if let Some(port) = config.port(application) { + let mut tasks = HashMap::new(); + for dev_task in config.development_tasks() { + let task = TaskId::new(dev_task.package, dev_task.task.unwrap_or("dev")).into_owned(); + if let Some(port) = config.port(dev_task.application_name) { ports.insert(task.clone(), port); } - tasks.insert(task); + tasks.insert(task, dev_task.application_name.to_owned()); } let version = config.version(); @@ -281,9 +307,11 @@ mod test { { let mut _map = std::collections::HashMap::new(); $( - let mut _dev_tasks = std::collections::HashSet::new(); + let mut _dev_tasks = std::collections::HashMap::new(); for _dev_task in $dev_tasks.as_slice() { - _dev_tasks.insert(crate::run::task_id::TaskName::from(*_dev_task).task_id().unwrap().into_owned()); + let _dev_task_id = crate::run::task_id::TaskName::from(*_dev_task).task_id().unwrap().into_owned(); + let _dev_application = _dev_task_id.package().to_owned(); + _dev_tasks.insert(_dev_task_id, _dev_application); } _map.insert($config_owner.to_string(), ConfigInfo { tasks: _dev_tasks, version: "1", path: None, ports: std::collections::HashMap::new() }); )+ @@ -363,22 +391,28 @@ mod test { #[test] fn test_mfe_package_is_found() { - let result = - PackageGraphResult::new(vec![(MICROFRONTENDS_PACKAGE, Ok(None))].into_iter()).unwrap(); + let result = PackageGraphResult::new( + HashSet::default(), + vec![(MICROFRONTENDS_PACKAGE, Ok(None))].into_iter(), + ) + .unwrap(); assert_eq!(result.mfe_package, Some(MICROFRONTENDS_PACKAGE)); } #[test] fn test_no_mfe_package() { - let result = - PackageGraphResult::new(vec![("foo", Ok(None)), ("bar", Ok(None))].into_iter()) - .unwrap(); + let result = PackageGraphResult::new( + HashSet::default(), + vec![("foo", Ok(None)), ("bar", Ok(None))].into_iter(), + ) + .unwrap(); assert_eq!(result.mfe_package, None); } #[test] fn test_unsupported_versions_ignored() { let result = PackageGraphResult::new( + HashSet::default(), vec![("foo", Err(Error::UnsupportedVersion("bad version".into())))].into_iter(), ) .unwrap(); @@ -388,6 +422,7 @@ mod test { #[test] fn test_child_configs_with_missing_default() { let result = PackageGraphResult::new( + HashSet::default(), vec![( "child", Err(Error::ChildConfig { @@ -404,6 +439,7 @@ mod test { #[test] fn test_io_err_stops_traversal() { let result = PackageGraphResult::new( + HashSet::default(), vec![ ( "a", @@ -442,8 +478,11 @@ mod test { "something.txt", ) .unwrap(); - let mut result = - PackageGraphResult::new(vec![("web", Ok(Some(config)))].into_iter()).unwrap(); + let mut result = PackageGraphResult::new( + HashSet::default(), + vec![("web", Ok(Some(config)))].into_iter(), + ) + .unwrap(); result .configs .values_mut() @@ -456,6 +495,42 @@ mod test { ) } + #[test] + fn test_missing_packages() { + let config = MFEConfig::from_str( + &serde_json::to_string_pretty(&json!({ + "version": "1", + "applications": { + "web": {}, + "docs": { + "development": { + "task": "serve" + } + } + } + })) + .unwrap(), + "something.txt", + ) + .unwrap(); + let missing_result = PackageGraphResult::new( + HashSet::default(), + vec![("web", Ok(Some(config.clone())))].into_iter(), + ) + .unwrap(); + assert_eq!(missing_result.missing_applications, vec!["docs", "web"]); + let found_result = PackageGraphResult::new( + HashSet::from_iter(["docs", "web"].iter().copied()), + vec![("web", Ok(Some(config)))].into_iter(), + ) + .unwrap(); + assert!( + found_result.missing_applications.is_empty(), + "Expected no missing applications: {:?}", + found_result.missing_applications + ); + } + #[test] fn test_port_collection() { let config = MFEConfig::from_str( @@ -477,7 +552,11 @@ mod test { "something.txt", ) .unwrap(); - let result = PackageGraphResult::new(vec![("web", Ok(Some(config)))].into_iter()).unwrap(); + let result = PackageGraphResult::new( + HashSet::default(), + vec![("web", Ok(Some(config)))].into_iter(), + ) + .unwrap(); let web_ports = result.configs["web"].ports.clone(); assert_eq!( web_ports.get(&TaskId::new("docs", "serve")).copied(), diff --git a/crates/turborepo-lib/src/task_graph/visitor/command.rs b/crates/turborepo-lib/src/task_graph/visitor/command.rs index f60c108192672..948e519716194 100644 --- a/crates/turborepo-lib/src/task_graph/visitor/command.rs +++ b/crates/turborepo-lib/src/task_graph/visitor/command.rs @@ -1,16 +1,20 @@ -use std::{collections::HashSet, path::PathBuf}; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, +}; use tracing::debug; use turbopath::AbsoluteSystemPath; use turborepo_env::EnvironmentVariableMap; use turborepo_microfrontends::MICROFRONTENDS_PACKAGE; use turborepo_process::Command; -use turborepo_repository::package_graph::{PackageGraph, PackageInfo, PackageName}; +use turborepo_repository::{ + package_graph::{PackageGraph, PackageInfo, PackageName}, + package_manager::PackageManager, +}; use super::Error; -use crate::{ - engine::Engine, microfrontends::MicrofrontendsConfigs, opts::TaskArgs, run::task_id::TaskId, -}; +use crate::{microfrontends::MicrofrontendsConfigs, opts::TaskArgs, run::task_id::TaskId}; pub trait CommandProvider { fn command( @@ -155,37 +159,29 @@ impl<'a> CommandProvider for PackageGraphCommandProvider<'a> { } #[derive(Debug)] -pub struct MicroFrontendProxyProvider<'a> { +pub struct MicroFrontendProxyProvider<'a, T> { repo_root: &'a AbsoluteSystemPath, - package_graph: &'a PackageGraph, + package_graph: &'a T, tasks_in_graph: HashSet>, mfe_configs: &'a MicrofrontendsConfigs, } -impl<'a> MicroFrontendProxyProvider<'a> { - pub fn new( +impl<'a, T: PackageInfoProvider> MicroFrontendProxyProvider<'a, T> { + pub fn new<'b>( repo_root: &'a AbsoluteSystemPath, - package_graph: &'a PackageGraph, - engine: &Engine, + package_graph: &'a T, + tasks_in_graph: impl Iterator>, micro_frontends_configs: &'a MicrofrontendsConfigs, ) -> Self { - let tasks_in_graph = engine - .tasks() - .filter_map(|task| match task { - crate::engine::TaskNode::Task(task_id) => Some(task_id), - crate::engine::TaskNode::Root => None, - }) - .cloned() - .collect(); Self { repo_root, package_graph, - tasks_in_graph, + tasks_in_graph: tasks_in_graph.cloned().collect(), mfe_configs: micro_frontends_configs, } } - fn dev_tasks(&self, task_id: &TaskId) -> Option<&HashSet>> { + fn dev_tasks(&self, task_id: &TaskId) -> Option<&HashMap, String>> { (task_id.task() == "proxy") .then(|| self.mfe_configs.get(task_id.package())) .flatten() @@ -206,7 +202,7 @@ impl<'a> MicroFrontendProxyProvider<'a> { } } -impl<'a> CommandProvider for MicroFrontendProxyProvider<'a> { +impl<'a, T: PackageInfoProvider> CommandProvider for MicroFrontendProxyProvider<'a, T> { fn command( &self, task_id: &TaskId, @@ -230,10 +226,11 @@ impl<'a> CommandProvider for MicroFrontendProxyProvider<'a> { .unwrap_or_default(), }); } - let local_apps = dev_tasks - .iter() - .filter(|task| self.tasks_in_graph.contains(task)) - .map(|task| task.package()); + let local_apps = dev_tasks.iter().filter_map(|(task, app_name)| { + self.tasks_in_graph + .contains(task) + .then_some(app_name.as_str()) + }); let package_dir = self.repo_root.resolve(package_info.package_path()); let mfe_config_filename = self .mfe_configs @@ -269,11 +266,31 @@ impl<'a> CommandProvider for MicroFrontendProxyProvider<'a> { } } +/// A trait for fetching package information required to execute commands +pub trait PackageInfoProvider { + fn package_manager(&self) -> &PackageManager; + + fn package_info(&self, name: &PackageName) -> Option<&PackageInfo>; +} + +impl PackageInfoProvider for PackageGraph { + fn package_manager(&self) -> &PackageManager { + PackageGraph::package_manager(self) + } + + fn package_info(&self, name: &PackageName) -> Option<&PackageInfo> { + PackageGraph::package_info(self, name) + } +} + #[cfg(test)] mod test { use std::ffi::OsStr; use insta::assert_snapshot; + use turbopath::AnchoredSystemPath; + use turborepo_microfrontends::Config; + use turborepo_repository::package_json::PackageJson; use super::*; @@ -363,4 +380,86 @@ mod test { .unwrap(); assert!(cmd.is_none(), "expected no cmd, got {cmd:?}"); } + + #[test] + fn test_mfe_application_passed() { + let repo_root = AbsoluteSystemPath::new(if cfg!(windows) { + "C:\\repo-root" + } else { + "/tmp/repo-root" + }) + .unwrap(); + struct MockPackageInfo(PackageInfo); + impl PackageInfoProvider for MockPackageInfo { + fn package_manager(&self) -> &PackageManager { + &PackageManager::Npm + } + + fn package_info(&self, name: &PackageName) -> Option<&PackageInfo> { + match name { + PackageName::Root => unimplemented!(), + PackageName::Other(name) => match name.as_str() { + "web" | "docs" => Some(&self.0), + _ => None, + }, + } + } + } + let mut config = Config::from_str( + r#" + { + "applications": { + "web-app": { + "packageName": "web" + }, + "docs-app": { + "packageName": "docs", + "routes": [] + } + } + }"#, + "microfrontends.json", + ) + .unwrap(); + config.set_path(AnchoredSystemPath::new("microfrontends.json").unwrap()); + let microfrontends_configs = MicrofrontendsConfigs::from_configs( + ["web", "docs"].iter().copied().collect(), + std::iter::once(("web", Ok(Some(config)))), + ) + .unwrap() + .unwrap(); + + let mock_package_info = MockPackageInfo(PackageInfo { + package_json: PackageJson { + dependencies: Some( + vec![(MICROFRONTENDS_PACKAGE.to_owned(), "1.0.0".to_owned())] + .into_iter() + .collect(), + ), + ..Default::default() + }, + package_json_path: AnchoredSystemPath::new("package.json").unwrap().to_owned(), + unresolved_external_dependencies: None, + transitive_dependencies: None, + }); + let mut factory = CommandFactory::new(); + factory.add_provider(MicroFrontendProxyProvider::new( + repo_root, + &mock_package_info, + [TaskId::new("docs", "dev"), TaskId::new("web", "proxy")].iter(), + µfrontends_configs, + )); + let cmd = factory + .command( + &TaskId::new("web", "proxy"), + EnvironmentVariableMap::default(), + ) + .unwrap() + .unwrap(); + assert!( + cmd.label().ends_with("--names docs-app"), + "Expected command to use application name instead of package name: {}", + cmd.label(), + ); + } } diff --git a/crates/turborepo-lib/src/task_graph/visitor/exec.rs b/crates/turborepo-lib/src/task_graph/visitor/exec.rs index d01fb7d71c536..9cfffcaafa815 100644 --- a/crates/turborepo-lib/src/task_graph/visitor/exec.rs +++ b/crates/turborepo-lib/src/task_graph/visitor/exec.rs @@ -52,8 +52,8 @@ impl<'a> ExecContextFactory<'a> { if let Some(micro_frontends_configs) = visitor.micro_frontends_configs { command_factory.add_provider(MicroFrontendProxyProvider::new( visitor.repo_root, - &visitor.package_graph, - engine, + visitor.package_graph.as_ref(), + engine.task_ids(), micro_frontends_configs, )); } diff --git a/crates/turborepo-lib/src/turbo_json/loader.rs b/crates/turborepo-lib/src/turbo_json/loader.rs index f5e24b6d426cf..8741acd9bb40f 100644 --- a/crates/turborepo-lib/src/turbo_json/loader.rs +++ b/crates/turborepo-lib/src/turbo_json/loader.rs @@ -451,7 +451,10 @@ fn select_turbo_json( #[cfg(test)] mod test { - use std::{collections::BTreeMap, fs}; + use std::{ + collections::{BTreeMap, HashSet}, + fs, + }; use anyhow::Result; use insta::assert_snapshot; @@ -820,6 +823,7 @@ mod test { .collect(); let microfrontends_configs = MicrofrontendsConfigs::from_configs( + HashSet::from_iter(["web", "docs"].iter().copied()), vec![ ( "web", diff --git a/crates/turborepo-microfrontends/src/configv1.rs b/crates/turborepo-microfrontends/src/configv1.rs index e9ba66a205b3e..0e395fbe48ba2 100644 --- a/crates/turborepo-microfrontends/src/configv1.rs +++ b/crates/turborepo-microfrontends/src/configv1.rs @@ -4,36 +4,37 @@ use biome_deserialize_macros::Deserializable; use biome_json_parser::JsonParserOptions; use serde::Serialize; -use crate::Error; +use crate::{DevelopmentTask, Error}; pub enum ParseResult { Actual(ConfigV1), Reference(String), } -#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default, Clone)] pub struct ConfigV1 { version: Option, applications: BTreeMap, } -#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default, Clone)] struct ChildConfig { part_of: String, } -#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default, Clone)] struct Application { + package_name: Option, development: Option, } -#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default, Clone)] struct Development { task: Option, local: Option, } -#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default, Clone, Copy)] struct LocalHost { port: Option, } @@ -75,10 +76,14 @@ impl ConfigV1 { } } - pub fn development_tasks(&self) -> impl Iterator)> { + pub fn development_tasks(&self) -> impl Iterator { self.applications .iter() - .map(|(application, config)| (application.as_str(), config.task())) + .map(|(application, config)| DevelopmentTask { + application_name: application, + package: config.package_name(application), + task: config.task(), + }) } pub fn port(&self, name: &str) -> Option { @@ -100,6 +105,10 @@ impl Application { self.user_port() .unwrap_or_else(|| generate_port_from_name(name)) } + + fn package_name<'a>(&'a self, key: &'a str) -> &'a str { + self.package_name.as_deref().unwrap_or(key) + } } const MIN_PORT: u16 = 3000; @@ -187,6 +196,69 @@ mod test { } } + #[test] + fn test_package_name_parse() { + let input = r#"{ + "applications": { + "web": { + "packageName": "@acme/web" + }, + "docs": {"development": {"task": "serve"}} + } + }"#; + + let config = ConfigV1::from_str(input, "somewhere").unwrap(); + match config { + ParseResult::Actual(config_v1) => { + assert_eq!( + config_v1.applications.get("web").unwrap().package_name, + Some("@acme/web".into()) + ); + assert_eq!( + config_v1.applications.get("docs").unwrap().package_name, + None + ); + } + ParseResult::Reference(_) => panic!("expected to get main config"), + } + } + + #[test] + fn test_package_name_development_tasks() { + let input = r#"{ + "applications": { + "web": { + "packageName": "@acme/web" + }, + "docs": {"development": {"task": "serve"}} + } + }"#; + + let config = ConfigV1::from_str(input, "somewhere").unwrap(); + match config { + ParseResult::Actual(config_v1) => { + let mut dev_tasks = config_v1.development_tasks().collect::>(); + dev_tasks.sort(); + assert_eq!( + dev_tasks, + vec![ + DevelopmentTask { + application_name: "docs", + package: "docs", + task: Some("serve") + }, + DevelopmentTask { + application_name: "web", + package: "@acme/web", + task: None + }, + ] + ); + } + ParseResult::Reference(_) => panic!("expected to get main config"), + } + } + #[test] fn test_generate_port() { assert_eq!(generate_port_from_name("test-450"), 7724); diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs index 66fe896024304..2b50b517334bb 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -23,14 +23,23 @@ pub const SUPPORTED_VERSIONS: &[&str] = ["1"].as_slice(); /// The minimal amount of information Turborepo needs to correctly start a local /// proxy server for microfrontends -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone)] pub struct Config { inner: ConfigInner, filename: String, path: Option, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +pub struct DevelopmentTask<'a> { + // The key in the applications object in microfrontends.json + // This will match package unless packageName is provided + pub application_name: &'a str, + pub package: &'a str, + pub task: Option<&'a str>, +} + +#[derive(Debug, PartialEq, Eq, Clone)] enum ConfigInner { V1(ConfigV1), } @@ -103,9 +112,7 @@ impl Config { }) } - pub fn development_tasks<'a>( - &'a self, - ) -> Box)> + 'a> { + pub fn development_tasks<'a>(&'a self) -> Box> + 'a> { match &self.inner { ConfigInner::V1(config_v1) => Box::new(config_v1.development_tasks()), }