diff --git a/crates/turborepo-lib/src/microfrontends.rs b/crates/turborepo-lib/src/microfrontends.rs index 7c2f8d19d0468..989358a2a3e2e 100644 --- a/crates/turborepo-lib/src/microfrontends.rs +++ b/crates/turborepo-lib/src/microfrontends.rs @@ -21,6 +21,7 @@ pub struct MicrofrontendsConfigs { #[derive(Debug, Clone, Default, PartialEq)] struct ConfigInfo { tasks: HashSet>, + ports: HashMap, u16>, version: &'static str, path: Option, } @@ -92,6 +93,12 @@ impl MicrofrontendsConfigs { Some(path) } + pub fn dev_task_port(&self, task_id: &TaskId) -> Option { + self.configs + .values() + .find_map(|config| config.ports.get(task_id).copied()) + } + pub fn update_turbo_json( &self, package_name: &PackageName, @@ -242,18 +249,21 @@ struct FindResult<'a> { impl ConfigInfo { fn new(config: &MFEConfig) -> Self { - let tasks = config - .development_tasks() - .map(|(application, options)| { - let dev_task = options.unwrap_or("dev"); - TaskId::new(application, dev_task).into_owned() - }) - .collect(); + 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) { + ports.insert(task.clone(), port); + } + tasks.insert(task); + } let version = config.version(); Self { tasks, version, + ports, path: None, } } @@ -275,7 +285,7 @@ mod test { for _dev_task in $dev_tasks.as_slice() { _dev_tasks.insert(crate::run::task_id::TaskName::from(*_dev_task).task_id().unwrap().into_owned()); } - _map.insert($config_owner.to_string(), ConfigInfo { tasks: _dev_tasks, version: "1", path: None }); + _map.insert($config_owner.to_string(), ConfigInfo { tasks: _dev_tasks, version: "1", path: None, ports: std::collections::HashMap::new() }); )+ _map } @@ -432,7 +442,12 @@ mod test { "something.txt", ) .unwrap(); - let result = PackageGraphResult::new(vec![("web", Ok(Some(config)))].into_iter()).unwrap(); + let mut result = + PackageGraphResult::new(vec![("web", Ok(Some(config)))].into_iter()).unwrap(); + result + .configs + .values_mut() + .for_each(|config| config.ports.clear()); assert_eq!( result.configs, mfe_configs!( @@ -441,6 +456,39 @@ mod test { ) } + #[test] + fn test_port_collection() { + let config = MFEConfig::from_str( + &serde_json::to_string_pretty(&json!({ + "version": "1", + "applications": { + "web": {}, + "docs": { + "development": { + "task": "serve", + "local": { + "port": 3030 + } + } + } + } + })) + .unwrap(), + "something.txt", + ) + .unwrap(); + let result = PackageGraphResult::new(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(), + Some(3030) + ); + assert_eq!( + web_ports.get(&TaskId::new("web", "dev")).copied(), + Some(5588) + ); + } + #[test] fn test_configs_added_as_global_deps() { let configs = MicrofrontendsConfigs { diff --git a/crates/turborepo-lib/src/task_graph/visitor/command.rs b/crates/turborepo-lib/src/task_graph/visitor/command.rs index 3dba4ae7cf9e3..ed22a7a5a6abe 100644 --- a/crates/turborepo-lib/src/task_graph/visitor/command.rs +++ b/crates/turborepo-lib/src/task_graph/visitor/command.rs @@ -1,5 +1,6 @@ use std::{collections::HashSet, path::PathBuf}; +use tracing::debug; use turbopath::AbsoluteSystemPath; use turborepo_env::EnvironmentVariableMap; use turborepo_microfrontends::MICROFRONTENDS_PACKAGE; @@ -137,6 +138,13 @@ impl<'a> CommandProvider for PackageGraphCommandProvider<'a> { { cmd.env("TURBO_TASK_HAS_MFE_PROXY", "true"); } + if let Some(port) = self + .mfe_configs + .and_then(|mfe_configs| mfe_configs.dev_task_port(task_id)) + { + debug!("Found port {port} for {task_id}"); + cmd.env("TURBO_PORT", port.to_string()); + } // We always open stdin and the visitor will close it depending on task // configuration @@ -191,6 +199,11 @@ impl<'a> MicroFrontendProxyProvider<'a> { task_id: task_id.clone().into_owned(), }) } + + fn has_custom_proxy(&self, task_id: &TaskId) -> Result { + let package_info = self.package_info(task_id)?; + Ok(package_info.package_json.scripts.contains_key("proxy")) + } } impl<'a> CommandProvider for MicroFrontendProxyProvider<'a> { @@ -202,13 +215,13 @@ impl<'a> CommandProvider for MicroFrontendProxyProvider<'a> { let Some(dev_tasks) = self.dev_tasks(task_id) else { return Ok(None); }; + let has_custom_proxy = self.has_custom_proxy(task_id)?; let package_info = self.package_info(task_id)?; let has_mfe_dependency = package_info .package_json .all_dependencies() .any(|(package, _version)| package.as_str() == MICROFRONTENDS_PACKAGE); - - if !has_mfe_dependency { + if !has_mfe_dependency && !has_custom_proxy { let mfe_config_filename = self.mfe_configs.config_filename(task_id.package()); return Err(Error::MissingMFEDependency { package: task_id.package().into(), @@ -227,13 +240,30 @@ impl<'a> CommandProvider for MicroFrontendProxyProvider<'a> { .config_filename(task_id.package()) .expect("every microfrontends default application should have configuration path"); let mfe_path = self.repo_root.join_unix_path(mfe_config_filename); - let mut args = vec!["proxy", mfe_path.as_str(), "--names"]; - args.extend(local_apps); - - // TODO: leverage package manager to find the local proxy - let program = package_dir.join_components(&["node_modules", ".bin", "microfrontends"]); - let mut cmd = Command::new(program.as_std_path()); - cmd.current_dir(package_dir).args(args).open_stdin(); + let cmd = if has_custom_proxy { + let package_manager = self.package_graph.package_manager(); + let mut proxy_args = vec![mfe_path.as_str(), "--names"]; + proxy_args.extend(local_apps); + let mut args = vec!["run", "proxy"]; + if let Some(sep) = package_manager.arg_separator(&proxy_args) { + args.push(sep); + } + args.extend(proxy_args); + + let program = which::which(package_manager.command())?; + let mut cmd = Command::new(&program); + cmd.current_dir(package_dir).args(args).open_stdin(); + cmd + } else { + let mut args = vec!["proxy", mfe_path.as_str(), "--names"]; + args.extend(local_apps); + + // TODO: leverage package manager to find the local proxy + let program = package_dir.join_components(&["node_modules", ".bin", "microfrontends"]); + let mut cmd = Command::new(program.as_std_path()); + cmd.current_dir(package_dir).args(args).open_stdin(); + cmd + }; Ok(Some(cmd)) } diff --git a/crates/turborepo-microfrontends/src/configv1.rs b/crates/turborepo-microfrontends/src/configv1.rs index 4bdf8c919105b..fc35199612fb6 100644 --- a/crates/turborepo-microfrontends/src/configv1.rs +++ b/crates/turborepo-microfrontends/src/configv1.rs @@ -30,6 +30,12 @@ struct Application { #[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] struct Development { task: Option, + local: Option, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +struct LocalHost { + port: Option, } impl ConfigV1 { @@ -73,18 +79,58 @@ impl ConfigV1 { .iter() .map(|(application, config)| (application.as_str(), config.task())) } + + pub fn port(&self, name: &str) -> Option { + let application = self.applications.get(name)?; + Some(application.port(name)) + } } impl Application { fn task(&self) -> Option<&str> { self.development.as_ref()?.task.as_deref() } + + fn user_port(&self) -> Option { + self.development.as_ref()?.local.as_ref()?.port + } + + fn port(&self, name: &str) -> u16 { + self.user_port() + .unwrap_or_else(|| generate_port_from_name(name)) + } +} + +const MIN_PORT: u16 = 3000; +const MAX_PORT: u16 = 8000; +const PORT_RANGE: u16 = MAX_PORT - MIN_PORT; + +fn generate_port_from_name(name: &str) -> u16 { + let mut hash: i32 = 0; + for c in name.chars() { + let code = i32::try_from(u32::from(c)).expect("char::MAX is less than 2^31"); + hash = (hash << 5).overflowing_sub(hash).0.overflowing_add(code).0; + } + let hash = hash.abs_diff(0); + let port = hash % u32::from(PORT_RANGE); + MIN_PORT + u16::try_from(port).expect("u32 modulo a u16 number will be a valid u16") } #[cfg(test)] mod test { + use std::char; + use super::*; + #[test] + fn test_char_as_i32() { + let max_char = u32::from(char::MAX); + assert!( + i32::try_from(max_char).is_ok(), + "max char should fit in i32" + ); + } + #[test] fn test_child_config_parse() { let input = r#"{"partOf": "web"}"#; @@ -139,4 +185,9 @@ mod test { 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 fde2ecd891390..7498032fb57e5 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -105,6 +105,12 @@ impl Config { } } + pub fn port(&self, name: &str) -> Option { + match &self.inner { + ConfigInner::V1(config_v1) => config_v1.port(name), + } + } + /// Filename of the loaded configuration pub fn filename(&self) -> &str { &self.filename diff --git a/crates/turborepo-repository/src/package_manager/mod.rs b/crates/turborepo-repository/src/package_manager/mod.rs index 78d39eb2035fc..24f9e33b696dc 100644 --- a/crates/turborepo-repository/src/package_manager/mod.rs +++ b/crates/turborepo-repository/src/package_manager/mod.rs @@ -534,14 +534,14 @@ impl PackageManager { turbo_root.join_component(self.lockfile_name()) } - pub fn arg_separator(&self, user_args: &[String]) -> Option<&str> { + pub fn arg_separator(&self, user_args: &[impl AsRef]) -> Option<&str> { match self { PackageManager::Yarn | PackageManager::Bun => { // Yarn and bun warn and swallows a "--" token. If the user is passing "--", we // need to prepend our own so that the user's doesn't get // swallowed. If they are not passing their own, we don't need // the "--" token and can avoid the warning. - if user_args.iter().any(|arg| arg == "--") { + if user_args.iter().any(|arg| arg.as_ref() == "--") { Some("--") } else { None