From 9087d6373b30e90cb0ac2738e4a0761732f691e6 Mon Sep 17 00:00:00 2001 From: Kit Foster Date: Mon, 27 Oct 2025 18:01:12 +0100 Subject: [PATCH 01/12] Add support for custom microfrontends.json naming --- crates/turborepo-microfrontends/src/lib.rs | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs index ba2536395f75b..99f89456ccb65 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -41,9 +41,25 @@ use turbopath::{ /// This is subject to change at any time. pub const DEFAULT_MICROFRONTENDS_CONFIG_V1: &str = "microfrontends.json"; pub const DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT: &str = "microfrontends.jsonc"; +pub const DEFAULT_MICROFRONTENDS_CONFIG_V1_CUSTOM: &str = get_default_microfrontends_config_v1_custom().unwrap_or_default(); pub const MICROFRONTENDS_PACKAGE: &str = "@vercel/microfrontends"; pub const SUPPORTED_VERSIONS: &[&str] = ["1"].as_slice(); +pub fn get_default_microfrontends_config_v1_custom() -> Result, Error> { + match std::env::var("VC_MICROFRONTENDS_CONFIG_FILE_NAME") { + Ok(config_filename) => { + let lower = config_filename.to_ascii_lowercase(); + if lower.ends_with(".json") || lower.ends_with(".jsonc") { + Ok(Some(config_filename)) + } else { + Err(Error::InvalidConfigFileName(config_filename)) + } + } + Err(std::env::VarError::NotPresent) => Ok(None), + Err(e) => Err(Error::EnvVarError(e)), + } +} + /// Strict Turborepo-only configuration for the microfrontends proxy. /// This configuration parser only accepts fields that Turborepo's native proxy /// actually uses. Provider packages can extend this with additional fields as @@ -209,6 +225,7 @@ impl TurborepoMfeConfig { }; load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) + .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_CUSTOM)) } pub fn set_path(&mut self, dir: &AnchoredSystemPath) { @@ -399,6 +416,7 @@ impl Config { }; load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) + .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_CUSTOM)) } /// Sets the path the configuration was loaded from @@ -463,6 +481,12 @@ mod test { path.create_with_contents(r#"{"version": "1", "applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#) } + fn add_v1_custom_config(dir: &AbsoluteSystemPath) -> Result<(), std::io::Error> { + let path = dir.join_component(DEFAULT_MICROFRONTENDS_CONFIG_V1_CUSTOM); + path.ensure_dir()?; + path.create_with_contents(r#"{"version": "1", "applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#) + } + struct LoadDirTest { has_v1: bool, has_alt_v1: bool, @@ -534,6 +558,11 @@ mod test { .expects_v1() .with_filename(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT); + const LOAD_V1_CUSTOM: LoadDirTest = LoadDirTest::new("web") + .has_v1_custom() + .expects_v1() + .with_filename(DEFAULT_MICROFRONTENDS_CONFIG_V1_CUSTOM); + const LOAD_NONE: LoadDirTest = LoadDirTest::new("web"); const LOAD_VERSIONLESS: LoadDirTest = LoadDirTest::new("web") @@ -543,6 +572,7 @@ mod test { #[test_case(LOAD_V1)] #[test_case(LOAD_V1_ALT)] + #[test_case(LOAD_V1_CUSTOM)] #[test_case(LOAD_NONE)] #[test_case(LOAD_VERSIONLESS)] fn test_load_dir(case: LoadDirTest) { @@ -556,6 +586,9 @@ mod test { if case.has_alt_v1 { add_v1_alt_config(&pkg_path).unwrap(); } + if case.has_v1_custom { + add_v1_custom_config(&pkg_path).unwrap(); + } if case.has_versionless { add_no_version_config(&pkg_path).unwrap(); } From 997ec7c4adee2db16413c862ee2f6e81c8036f52 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Thu, 30 Oct 2025 12:07:36 -0700 Subject: [PATCH 02/12] rework --- crates/turborepo-microfrontends/src/error.rs | 5 + crates/turborepo-microfrontends/src/lib.rs | 363 +++++++++++-------- 2 files changed, 223 insertions(+), 145 deletions(-) diff --git a/crates/turborepo-microfrontends/src/error.rs b/crates/turborepo-microfrontends/src/error.rs index 39e5990e0ee99..648ff62ead091 100644 --- a/crates/turborepo-microfrontends/src/error.rs +++ b/crates/turborepo-microfrontends/src/error.rs @@ -34,6 +34,11 @@ pub enum Error { }, #[error("Invalid package path: {0}. Path traversal outside repository root is not allowed.")] PathTraversal(String), + #[error( + "Multiple microfrontends configuration files found: {files:?}. Only one configuration \ + file is allowed." + )] + MultipleConfigFiles { files: Vec }, } impl Error { diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs index 99f89456ccb65..80f88ae21e5d8 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -41,25 +41,9 @@ use turbopath::{ /// This is subject to change at any time. pub const DEFAULT_MICROFRONTENDS_CONFIG_V1: &str = "microfrontends.json"; pub const DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT: &str = "microfrontends.jsonc"; -pub const DEFAULT_MICROFRONTENDS_CONFIG_V1_CUSTOM: &str = get_default_microfrontends_config_v1_custom().unwrap_or_default(); pub const MICROFRONTENDS_PACKAGE: &str = "@vercel/microfrontends"; pub const SUPPORTED_VERSIONS: &[&str] = ["1"].as_slice(); -pub fn get_default_microfrontends_config_v1_custom() -> Result, Error> { - match std::env::var("VC_MICROFRONTENDS_CONFIG_FILE_NAME") { - Ok(config_filename) => { - let lower = config_filename.to_ascii_lowercase(); - if lower.ends_with(".json") || lower.ends_with(".jsonc") { - Ok(Some(config_filename)) - } else { - Err(Error::InvalidConfigFileName(config_filename)) - } - } - Err(std::env::VarError::NotPresent) => Ok(None), - Err(e) => Err(Error::EnvVarError(e)), - } -} - /// Strict Turborepo-only configuration for the microfrontends proxy. /// This configuration parser only accepts fields that Turborepo's native proxy /// actually uses. Provider packages can extend this with additional fields as @@ -217,15 +201,44 @@ impl TurborepoMfeConfig { fn load_v1_dir( dir: &AbsoluteSystemPath, ) -> Option<(Result, AbsoluteSystemPathBuf)> { - let load_config = - |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { - let path = dir.join_component(filename); - let contents = path.read_existing_to_string().transpose()?; - Some((contents, path)) - }; - load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) - .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) - .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_CUSTOM)) + // Collect all matching files + let mut matching_files = Vec::new(); + + // Check for microfrontends*.json(c) files + if let Ok(entries) = std::fs::read_dir(dir.as_path()) { + for entry in entries.flatten() { + if let Some(name) = entry.file_name().to_str() + && name.starts_with("microfrontends") + && (name.ends_with(".json") || name.ends_with(".jsonc")) + { + matching_files.push(name.to_string()); + } + } + } + + // Error if multiple files found + if matching_files.len() > 1 { + matching_files.sort(); + return Some(( + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Multiple microfrontends configuration files found: {matching_files:?}. \ + Only one configuration file is allowed." + ), + )), + dir.to_owned(), + )); + } + + // Load the single matching file if found + if let Some(filename) = matching_files.first() { + let path = dir.join_component(filename); + let contents = path.read_existing_to_string().transpose()?; + return Some((contents, path)); + } + + None } pub fn set_path(&mut self, dir: &AnchoredSystemPath) { @@ -408,15 +421,44 @@ impl Config { fn load_v1_dir( dir: &AbsoluteSystemPath, ) -> Option<(Result, AbsoluteSystemPathBuf)> { - let load_config = - |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { - let path = dir.join_component(filename); - let contents = path.read_existing_to_string().transpose()?; - Some((contents, path)) - }; - load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) - .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) - .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_CUSTOM)) + // Collect all matching files + let mut matching_files = Vec::new(); + + // Check for microfrontends*.json(c) files + if let Ok(entries) = std::fs::read_dir(dir.as_path()) { + for entry in entries.flatten() { + if let Some(name) = entry.file_name().to_str() + && name.starts_with("microfrontends") + && (name.ends_with(".json") || name.ends_with(".jsonc")) + { + matching_files.push(name.to_string()); + } + } + } + + // Error if multiple files found + if matching_files.len() > 1 { + matching_files.sort(); + return Some(( + Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "Multiple microfrontends configuration files found: {matching_files:?}. \ + Only one configuration file is allowed." + ), + )), + dir.to_owned(), + )); + } + + // Load the single matching file if found + if let Some(filename) = matching_files.first() { + let path = dir.join_component(filename); + let contents = path.read_existing_to_string().transpose()?; + return Some((contents, path)); + } + + None } /// Sets the path the configuration was loaded from @@ -428,7 +470,6 @@ impl Config { #[cfg(test)] mod test { use tempfile::TempDir; - use test_case::test_case; use super::*; @@ -461,14 +502,6 @@ mod test { path.create_with_contents(r#"{"version": "1", "applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#) } - fn add_no_version_config(dir: &AbsoluteSystemPath) -> Result<(), std::io::Error> { - let path = dir.join_component(DEFAULT_MICROFRONTENDS_CONFIG_V1); - path.ensure_dir()?; - path.create_with_contents( - r#"{"applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#, - ) - } - fn add_v2_config(dir: &AbsoluteSystemPath) -> Result<(), std::io::Error> { let path = dir.join_component(DEFAULT_MICROFRONTENDS_CONFIG_V1); path.ensure_dir()?; @@ -481,125 +514,70 @@ mod test { path.create_with_contents(r#"{"version": "1", "applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#) } - fn add_v1_custom_config(dir: &AbsoluteSystemPath) -> Result<(), std::io::Error> { - let path = dir.join_component(DEFAULT_MICROFRONTENDS_CONFIG_V1_CUSTOM); + fn add_config_with_name( + dir: &AbsoluteSystemPath, + filename: &str, + ) -> Result<(), std::io::Error> { + let path = dir.join_component(filename); path.ensure_dir()?; path.create_with_contents(r#"{"version": "1", "applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#) } - struct LoadDirTest { - has_v1: bool, - has_alt_v1: bool, - has_versionless: bool, - pkg_dir: &'static str, - expected_version: Option, - expected_filename: Option<&'static str>, - } + #[test] + fn test_load_v1() { + let dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + let pkg_dir = AnchoredSystemPath::new("web").unwrap(); + let pkg_path = repo_root.resolve(pkg_dir); + add_v1_config(&pkg_path).unwrap(); - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - enum FoundConfig { - V1, + let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); + assert!(config.is_some()); + let cfg = config.unwrap(); + assert_eq!(cfg.filename(), DEFAULT_MICROFRONTENDS_CONFIG_V1); + assert_eq!(cfg.version(), "1"); } - impl LoadDirTest { - pub const fn new(pkg_dir: &'static str) -> Self { - Self { - pkg_dir, - has_v1: false, - has_alt_v1: false, - has_versionless: false, - expected_version: None, - expected_filename: None, - } - } - - pub const fn has_v1(mut self) -> Self { - self.has_v1 = true; - self - } - - pub const fn has_alt_v1(mut self) -> Self { - self.has_alt_v1 = true; - self - } - - pub const fn has_versionless(mut self) -> Self { - self.has_versionless = true; - self - } - - pub const fn expects_v1(mut self) -> Self { - self.expected_version = Some(FoundConfig::V1); - self - } - - pub const fn with_filename(mut self, filename: &'static str) -> Self { - self.expected_filename = Some(filename); - self - } + #[test] + fn test_load_v1_alt() { + let dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + let pkg_dir = AnchoredSystemPath::new("web").unwrap(); + let pkg_path = repo_root.resolve(pkg_dir); + add_v1_alt_config(&pkg_path).unwrap(); - pub fn expected_path(&self) -> Option { - let filename = self.expected_filename?; - Some( - AnchoredSystemPath::new(self.pkg_dir) - .unwrap() - .join_component(filename), - ) - } + let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); + assert!(config.is_some()); + let cfg = config.unwrap(); + assert_eq!(cfg.filename(), DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT); + assert_eq!(cfg.version(), "1"); } - const LOAD_V1: LoadDirTest = LoadDirTest::new("web") - .has_v1() - .expects_v1() - .with_filename(DEFAULT_MICROFRONTENDS_CONFIG_V1); - - const LOAD_V1_ALT: LoadDirTest = LoadDirTest::new("web") - .has_alt_v1() - .expects_v1() - .with_filename(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT); - - const LOAD_V1_CUSTOM: LoadDirTest = LoadDirTest::new("web") - .has_v1_custom() - .expects_v1() - .with_filename(DEFAULT_MICROFRONTENDS_CONFIG_V1_CUSTOM); - - const LOAD_NONE: LoadDirTest = LoadDirTest::new("web"); + #[test] + fn test_load_v1_custom_path() { + let dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + let pkg_dir = AnchoredSystemPath::new("web").unwrap(); + let pkg_path = repo_root.resolve(pkg_dir); + add_config_with_name(&pkg_path, "microfrontends-custom.json").unwrap(); - const LOAD_VERSIONLESS: LoadDirTest = LoadDirTest::new("web") - .has_versionless() - .expects_v1() - .with_filename(DEFAULT_MICROFRONTENDS_CONFIG_V1); + let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); + assert!(config.is_some()); + let cfg = config.unwrap(); + assert_eq!(cfg.filename(), "microfrontends-custom.json"); + assert_eq!(cfg.version(), "1"); + } - #[test_case(LOAD_V1)] - #[test_case(LOAD_V1_ALT)] - #[test_case(LOAD_V1_CUSTOM)] - #[test_case(LOAD_NONE)] - #[test_case(LOAD_VERSIONLESS)] - fn test_load_dir(case: LoadDirTest) { + #[test] + fn test_load_none() { let dir = TempDir::new().unwrap(); let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); - let pkg_dir = AnchoredSystemPath::new(case.pkg_dir).unwrap(); + let pkg_dir = AnchoredSystemPath::new("web").unwrap(); let pkg_path = repo_root.resolve(pkg_dir); - if case.has_v1 { - add_v1_config(&pkg_path).unwrap(); - } - if case.has_alt_v1 { - add_v1_alt_config(&pkg_path).unwrap(); - } - if case.has_v1_custom { - add_v1_custom_config(&pkg_path).unwrap(); - } - if case.has_versionless { - add_no_version_config(&pkg_path).unwrap(); - } + pkg_path.ensure_dir().unwrap(); let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); - let actual_version = config.as_ref().map(|config| match &config.inner { - ConfigInner::V1(_) => FoundConfig::V1, - }); - let actual_path = config.as_ref().and_then(|config| config.path()); - assert_eq!(actual_version, case.expected_version); - assert_eq!(actual_path, case.expected_path().as_deref()); + assert!(config.is_none()); } #[test] @@ -677,4 +655,99 @@ mod test { assert!(result.is_ok(), "Valid path within repo should be accepted"); assert!(result.unwrap().is_some(), "Config should be loaded"); } + + #[test] + fn test_multiple_config_files_error() { + let dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + let pkg_dir = AnchoredSystemPath::new("web").unwrap(); + let pkg_path = repo_root.resolve(pkg_dir); + + // Add multiple config files + add_v1_config(&pkg_path).unwrap(); + add_config_with_name(&pkg_path, "microfrontends-custom.json").unwrap(); + + let result = Config::load_from_dir(repo_root, pkg_dir); + + assert!( + result.is_err(), + "Multiple config files should result in error" + ); + if let Err(Error::Io(e)) = result { + let msg = e.to_string(); + assert!( + msg.contains("Multiple microfrontends configuration files found"), + "Error message should mention multiple files, got: {}", + msg + ); + } else { + panic!( + "Expected Io error with multiple files message, got: {:?}", + result + ); + } + } + + #[test] + fn test_custom_named_config() { + let dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + let pkg_dir = AnchoredSystemPath::new("web").unwrap(); + let pkg_path = repo_root.resolve(pkg_dir); + + // Add a custom named config file + add_config_with_name(&pkg_path, "microfrontends-staging.jsonc").unwrap(); + + let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); + + assert!(config.is_some(), "Custom named config should be loaded"); + let cfg = config.unwrap(); + assert_eq!(cfg.filename(), "microfrontends-staging.jsonc"); + } + + #[test] + fn test_file_without_hyphen_matched() { + let dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + let pkg_dir = AnchoredSystemPath::new("web").unwrap(); + let pkg_path = repo_root.resolve(pkg_dir); + + // Add a file that starts with "microfrontends" but has no hyphen + add_config_with_name(&pkg_path, "microfrontendsconfig.json").unwrap(); + + let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); + + assert!(config.is_some(), "Files without hyphen should be matched"); + assert_eq!(config.unwrap().filename(), "microfrontendsconfig.json"); + } + + #[test] + fn test_exact_names_still_work() { + let dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + let pkg_dir = AnchoredSystemPath::new("web").unwrap(); + let pkg_path = repo_root.resolve(pkg_dir); + + // Verify microfrontends.json still works + add_v1_config(&pkg_path).unwrap(); + let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); + assert!(config.is_some()); + assert_eq!(config.unwrap().filename(), "microfrontends.json"); + } + + #[test] + fn test_nested_config_not_found() { + let dir = TempDir::new().unwrap(); + let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + let pkg_dir = AnchoredSystemPath::new("web").unwrap(); + let pkg_path = repo_root.resolve(pkg_dir); + + // Create a nested directory with a config file + let nested_path = pkg_path.join_component("config"); + add_v1_config(&nested_path).unwrap(); + + // Should not find the nested config + let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); + assert!(config.is_none(), "Nested config files should not be found"); + } } From 2a5417ab4c327865b4eceab24b28313043733c45 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Thu, 30 Oct 2025 12:27:52 -0700 Subject: [PATCH 03/12] WIP eff99 --- crates/turborepo-microfrontends/src/lib.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs index 80f88ae21e5d8..c4ae584675beb 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -677,14 +677,10 @@ mod test { let msg = e.to_string(); assert!( msg.contains("Multiple microfrontends configuration files found"), - "Error message should mention multiple files, got: {}", - msg + "Error message should mention multiple files, got: {msg}" ); } else { - panic!( - "Expected Io error with multiple files message, got: {:?}", - result - ); + panic!("Expected Io error with multiple files message, got: {result:?}"); } } From dc40cfd7854094200a34eb9e20f5c9da49522bb2 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Thu, 30 Oct 2025 16:07:31 -0600 Subject: [PATCH 04/12] WIP d1818 --- crates/turborepo-microfrontends/src/lib.rs | 48 ++++++++++------------ 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs index c4ae584675beb..6990e7980c998 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -26,8 +26,6 @@ mod configv1; mod error; mod schema; -use std::io; - use configv1::ConfigV1; pub use configv1::PathGroup; pub use error::Error; @@ -200,7 +198,7 @@ impl TurborepoMfeConfig { fn load_v1_dir( dir: &AbsoluteSystemPath, - ) -> Option<(Result, AbsoluteSystemPathBuf)> { + ) -> Option<(Result, AbsoluteSystemPathBuf)> { // Collect all matching files let mut matching_files = Vec::new(); @@ -220,13 +218,9 @@ impl TurborepoMfeConfig { if matching_files.len() > 1 { matching_files.sort(); return Some(( - Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "Multiple microfrontends configuration files found: {matching_files:?}. \ - Only one configuration file is allowed." - ), - )), + Err(Error::MultipleConfigFiles { + files: matching_files, + }), dir.to_owned(), )); } @@ -234,7 +228,10 @@ impl TurborepoMfeConfig { // Load the single matching file if found if let Some(filename) = matching_files.first() { let path = dir.join_component(filename); - let contents = path.read_existing_to_string().transpose()?; + let contents = path + .read_existing_to_string() + .map_err(Error::from) + .transpose()?; return Some((contents, path)); } @@ -420,7 +417,7 @@ impl Config { fn load_v1_dir( dir: &AbsoluteSystemPath, - ) -> Option<(Result, AbsoluteSystemPathBuf)> { + ) -> Option<(Result, AbsoluteSystemPathBuf)> { // Collect all matching files let mut matching_files = Vec::new(); @@ -440,13 +437,9 @@ impl Config { if matching_files.len() > 1 { matching_files.sort(); return Some(( - Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "Multiple microfrontends configuration files found: {matching_files:?}. \ - Only one configuration file is allowed." - ), - )), + Err(Error::MultipleConfigFiles { + files: matching_files, + }), dir.to_owned(), )); } @@ -454,7 +447,10 @@ impl Config { // Load the single matching file if found if let Some(filename) = matching_files.first() { let path = dir.join_component(filename); - let contents = path.read_existing_to_string().transpose()?; + let contents = path + .read_existing_to_string() + .map_err(Error::from) + .transpose()?; return Some((contents, path)); } @@ -673,14 +669,14 @@ mod test { result.is_err(), "Multiple config files should result in error" ); - if let Err(Error::Io(e)) = result { - let msg = e.to_string(); - assert!( - msg.contains("Multiple microfrontends configuration files found"), - "Error message should mention multiple files, got: {msg}" + if let Err(Error::MultipleConfigFiles { files }) = result { + assert_eq!( + files, + vec!["microfrontends-custom.json", "microfrontends.json"], + "Should contain both config files" ); } else { - panic!("Expected Io error with multiple files message, got: {result:?}"); + panic!("Expected MultipleConfigFiles error, got: {result:?}"); } } From 46ac1edb3e5434d548ed139abf7071dee32f5685 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Fri, 31 Oct 2025 06:17:52 -0600 Subject: [PATCH 05/12] WIP 893c5 --- .github/workflows/turborepo-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/turborepo-test.yml b/.github/workflows/turborepo-test.yml index b3acaec036b02..5c02928d82b8c 100644 --- a/.github/workflows/turborepo-test.yml +++ b/.github/workflows/turborepo-test.yml @@ -165,7 +165,7 @@ jobs: if [ -z "${RUSTC_WRAPPER}" ]; then unset RUSTC_WRAPPER fi - turbo run test --filter=turborepo-tests-integration --color --env-mode=strict --token=${{ secrets.TURBO_TOKEN }} --team=${{ vars.TURBO_TEAM }} + turbo run test --force --filter=turborepo-tests-integration --color --env-mode=strict --token=${{ secrets.TURBO_TOKEN }} --team=${{ vars.TURBO_TEAM }} shell: bash env: SCCACHE_BUCKET: turborepo-sccache From cfb5d8e93f812b609fb1c74fc6374b280169ffc2 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Fri, 31 Oct 2025 06:32:10 -0600 Subject: [PATCH 06/12] only files --- crates/turborepo-microfrontends/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs index 6990e7980c998..ac32285018034 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -208,6 +208,7 @@ impl TurborepoMfeConfig { if let Some(name) = entry.file_name().to_str() && name.starts_with("microfrontends") && (name.ends_with(".json") || name.ends_with(".jsonc")) + && entry.metadata().ok().map_or(false, |m| m.is_file()) { matching_files.push(name.to_string()); } @@ -427,6 +428,7 @@ impl Config { if let Some(name) = entry.file_name().to_str() && name.starts_with("microfrontends") && (name.ends_with(".json") || name.ends_with(".jsonc")) + && entry.metadata().ok().map_or(false, |m| m.is_file()) { matching_files.push(name.to_string()); } From 26aedc729cc6035ffe7564d56c73bf1dfd508639 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Fri, 31 Oct 2025 06:41:13 -0600 Subject: [PATCH 07/12] WIP 8e6d9 --- crates/turborepo-microfrontends/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs index ac32285018034..4cccf1ec36182 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -208,7 +208,7 @@ impl TurborepoMfeConfig { if let Some(name) = entry.file_name().to_str() && name.starts_with("microfrontends") && (name.ends_with(".json") || name.ends_with(".jsonc")) - && entry.metadata().ok().map_or(false, |m| m.is_file()) + && entry.metadata().ok().is_some_and(|m| m.is_file()) { matching_files.push(name.to_string()); } @@ -428,7 +428,7 @@ impl Config { if let Some(name) = entry.file_name().to_str() && name.starts_with("microfrontends") && (name.ends_with(".json") || name.ends_with(".jsonc")) - && entry.metadata().ok().map_or(false, |m| m.is_file()) + && entry.metadata().ok().is_some_and(|m| m.is_file()) { matching_files.push(name.to_string()); } From 2e1edaa4a4d5b4710d00a5c2ebbae0eac40753dc Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Fri, 31 Oct 2025 07:23:15 -0600 Subject: [PATCH 08/12] WIP 15097 --- .github/workflows/turborepo-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/turborepo-test.yml b/.github/workflows/turborepo-test.yml index 5c02928d82b8c..b3acaec036b02 100644 --- a/.github/workflows/turborepo-test.yml +++ b/.github/workflows/turborepo-test.yml @@ -165,7 +165,7 @@ jobs: if [ -z "${RUSTC_WRAPPER}" ]; then unset RUSTC_WRAPPER fi - turbo run test --force --filter=turborepo-tests-integration --color --env-mode=strict --token=${{ secrets.TURBO_TOKEN }} --team=${{ vars.TURBO_TEAM }} + turbo run test --filter=turborepo-tests-integration --color --env-mode=strict --token=${{ secrets.TURBO_TOKEN }} --team=${{ vars.TURBO_TEAM }} shell: bash env: SCCACHE_BUCKET: turborepo-sccache From edbbfafdac2960c3fe58a64c6a4a50512e67e5a0 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Fri, 31 Oct 2025 07:48:34 -0600 Subject: [PATCH 09/12] reset --- crates/turborepo-microfrontends/src/error.rs | 5 - crates/turborepo-microfrontends/src/lib.rs | 338 +++++++------------ 2 files changed, 119 insertions(+), 224 deletions(-) diff --git a/crates/turborepo-microfrontends/src/error.rs b/crates/turborepo-microfrontends/src/error.rs index 648ff62ead091..39e5990e0ee99 100644 --- a/crates/turborepo-microfrontends/src/error.rs +++ b/crates/turborepo-microfrontends/src/error.rs @@ -34,11 +34,6 @@ pub enum Error { }, #[error("Invalid package path: {0}. Path traversal outside repository root is not allowed.")] PathTraversal(String), - #[error( - "Multiple microfrontends configuration files found: {files:?}. Only one configuration \ - file is allowed." - )] - MultipleConfigFiles { files: Vec }, } impl Error { diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs index 4cccf1ec36182..ba2536395f75b 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -26,6 +26,8 @@ mod configv1; mod error; mod schema; +use std::io; + use configv1::ConfigV1; pub use configv1::PathGroup; pub use error::Error; @@ -198,45 +200,15 @@ impl TurborepoMfeConfig { fn load_v1_dir( dir: &AbsoluteSystemPath, - ) -> Option<(Result, AbsoluteSystemPathBuf)> { - // Collect all matching files - let mut matching_files = Vec::new(); - - // Check for microfrontends*.json(c) files - if let Ok(entries) = std::fs::read_dir(dir.as_path()) { - for entry in entries.flatten() { - if let Some(name) = entry.file_name().to_str() - && name.starts_with("microfrontends") - && (name.ends_with(".json") || name.ends_with(".jsonc")) - && entry.metadata().ok().is_some_and(|m| m.is_file()) - { - matching_files.push(name.to_string()); - } - } - } - - // Error if multiple files found - if matching_files.len() > 1 { - matching_files.sort(); - return Some(( - Err(Error::MultipleConfigFiles { - files: matching_files, - }), - dir.to_owned(), - )); - } - - // Load the single matching file if found - if let Some(filename) = matching_files.first() { - let path = dir.join_component(filename); - let contents = path - .read_existing_to_string() - .map_err(Error::from) - .transpose()?; - return Some((contents, path)); - } - - None + ) -> Option<(Result, AbsoluteSystemPathBuf)> { + let load_config = + |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { + let path = dir.join_component(filename); + let contents = path.read_existing_to_string().transpose()?; + Some((contents, path)) + }; + load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) + .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) } pub fn set_path(&mut self, dir: &AnchoredSystemPath) { @@ -418,45 +390,15 @@ impl Config { fn load_v1_dir( dir: &AbsoluteSystemPath, - ) -> Option<(Result, AbsoluteSystemPathBuf)> { - // Collect all matching files - let mut matching_files = Vec::new(); - - // Check for microfrontends*.json(c) files - if let Ok(entries) = std::fs::read_dir(dir.as_path()) { - for entry in entries.flatten() { - if let Some(name) = entry.file_name().to_str() - && name.starts_with("microfrontends") - && (name.ends_with(".json") || name.ends_with(".jsonc")) - && entry.metadata().ok().is_some_and(|m| m.is_file()) - { - matching_files.push(name.to_string()); - } - } - } - - // Error if multiple files found - if matching_files.len() > 1 { - matching_files.sort(); - return Some(( - Err(Error::MultipleConfigFiles { - files: matching_files, - }), - dir.to_owned(), - )); - } - - // Load the single matching file if found - if let Some(filename) = matching_files.first() { - let path = dir.join_component(filename); - let contents = path - .read_existing_to_string() - .map_err(Error::from) - .transpose()?; - return Some((contents, path)); - } - - None + ) -> Option<(Result, AbsoluteSystemPathBuf)> { + let load_config = + |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { + let path = dir.join_component(filename); + let contents = path.read_existing_to_string().transpose()?; + Some((contents, path)) + }; + load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) + .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) } /// Sets the path the configuration was loaded from @@ -468,6 +410,7 @@ impl Config { #[cfg(test)] mod test { use tempfile::TempDir; + use test_case::test_case; use super::*; @@ -500,6 +443,14 @@ mod test { path.create_with_contents(r#"{"version": "1", "applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#) } + fn add_no_version_config(dir: &AbsoluteSystemPath) -> Result<(), std::io::Error> { + let path = dir.join_component(DEFAULT_MICROFRONTENDS_CONFIG_V1); + path.ensure_dir()?; + path.create_with_contents( + r#"{"applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#, + ) + } + fn add_v2_config(dir: &AbsoluteSystemPath) -> Result<(), std::io::Error> { let path = dir.join_component(DEFAULT_MICROFRONTENDS_CONFIG_V1); path.ensure_dir()?; @@ -512,70 +463,110 @@ mod test { path.create_with_contents(r#"{"version": "1", "applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#) } - fn add_config_with_name( - dir: &AbsoluteSystemPath, - filename: &str, - ) -> Result<(), std::io::Error> { - let path = dir.join_component(filename); - path.ensure_dir()?; - path.create_with_contents(r#"{"version": "1", "applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#) + struct LoadDirTest { + has_v1: bool, + has_alt_v1: bool, + has_versionless: bool, + pkg_dir: &'static str, + expected_version: Option, + expected_filename: Option<&'static str>, } - #[test] - fn test_load_v1() { - let dir = TempDir::new().unwrap(); - let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); - let pkg_dir = AnchoredSystemPath::new("web").unwrap(); - let pkg_path = repo_root.resolve(pkg_dir); - add_v1_config(&pkg_path).unwrap(); - - let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); - assert!(config.is_some()); - let cfg = config.unwrap(); - assert_eq!(cfg.filename(), DEFAULT_MICROFRONTENDS_CONFIG_V1); - assert_eq!(cfg.version(), "1"); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum FoundConfig { + V1, } - #[test] - fn test_load_v1_alt() { - let dir = TempDir::new().unwrap(); - let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); - let pkg_dir = AnchoredSystemPath::new("web").unwrap(); - let pkg_path = repo_root.resolve(pkg_dir); - add_v1_alt_config(&pkg_path).unwrap(); + impl LoadDirTest { + pub const fn new(pkg_dir: &'static str) -> Self { + Self { + pkg_dir, + has_v1: false, + has_alt_v1: false, + has_versionless: false, + expected_version: None, + expected_filename: None, + } + } - let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); - assert!(config.is_some()); - let cfg = config.unwrap(); - assert_eq!(cfg.filename(), DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT); - assert_eq!(cfg.version(), "1"); - } + pub const fn has_v1(mut self) -> Self { + self.has_v1 = true; + self + } - #[test] - fn test_load_v1_custom_path() { - let dir = TempDir::new().unwrap(); - let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); - let pkg_dir = AnchoredSystemPath::new("web").unwrap(); - let pkg_path = repo_root.resolve(pkg_dir); - add_config_with_name(&pkg_path, "microfrontends-custom.json").unwrap(); + pub const fn has_alt_v1(mut self) -> Self { + self.has_alt_v1 = true; + self + } - let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); - assert!(config.is_some()); - let cfg = config.unwrap(); - assert_eq!(cfg.filename(), "microfrontends-custom.json"); - assert_eq!(cfg.version(), "1"); + pub const fn has_versionless(mut self) -> Self { + self.has_versionless = true; + self + } + + pub const fn expects_v1(mut self) -> Self { + self.expected_version = Some(FoundConfig::V1); + self + } + + pub const fn with_filename(mut self, filename: &'static str) -> Self { + self.expected_filename = Some(filename); + self + } + + pub fn expected_path(&self) -> Option { + let filename = self.expected_filename?; + Some( + AnchoredSystemPath::new(self.pkg_dir) + .unwrap() + .join_component(filename), + ) + } } - #[test] - fn test_load_none() { + const LOAD_V1: LoadDirTest = LoadDirTest::new("web") + .has_v1() + .expects_v1() + .with_filename(DEFAULT_MICROFRONTENDS_CONFIG_V1); + + const LOAD_V1_ALT: LoadDirTest = LoadDirTest::new("web") + .has_alt_v1() + .expects_v1() + .with_filename(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT); + + const LOAD_NONE: LoadDirTest = LoadDirTest::new("web"); + + const LOAD_VERSIONLESS: LoadDirTest = LoadDirTest::new("web") + .has_versionless() + .expects_v1() + .with_filename(DEFAULT_MICROFRONTENDS_CONFIG_V1); + + #[test_case(LOAD_V1)] + #[test_case(LOAD_V1_ALT)] + #[test_case(LOAD_NONE)] + #[test_case(LOAD_VERSIONLESS)] + fn test_load_dir(case: LoadDirTest) { let dir = TempDir::new().unwrap(); let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); - let pkg_dir = AnchoredSystemPath::new("web").unwrap(); + let pkg_dir = AnchoredSystemPath::new(case.pkg_dir).unwrap(); let pkg_path = repo_root.resolve(pkg_dir); - pkg_path.ensure_dir().unwrap(); + if case.has_v1 { + add_v1_config(&pkg_path).unwrap(); + } + if case.has_alt_v1 { + add_v1_alt_config(&pkg_path).unwrap(); + } + if case.has_versionless { + add_no_version_config(&pkg_path).unwrap(); + } let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); - assert!(config.is_none()); + let actual_version = config.as_ref().map(|config| match &config.inner { + ConfigInner::V1(_) => FoundConfig::V1, + }); + let actual_path = config.as_ref().and_then(|config| config.path()); + assert_eq!(actual_version, case.expected_version); + assert_eq!(actual_path, case.expected_path().as_deref()); } #[test] @@ -653,95 +644,4 @@ mod test { assert!(result.is_ok(), "Valid path within repo should be accepted"); assert!(result.unwrap().is_some(), "Config should be loaded"); } - - #[test] - fn test_multiple_config_files_error() { - let dir = TempDir::new().unwrap(); - let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); - let pkg_dir = AnchoredSystemPath::new("web").unwrap(); - let pkg_path = repo_root.resolve(pkg_dir); - - // Add multiple config files - add_v1_config(&pkg_path).unwrap(); - add_config_with_name(&pkg_path, "microfrontends-custom.json").unwrap(); - - let result = Config::load_from_dir(repo_root, pkg_dir); - - assert!( - result.is_err(), - "Multiple config files should result in error" - ); - if let Err(Error::MultipleConfigFiles { files }) = result { - assert_eq!( - files, - vec!["microfrontends-custom.json", "microfrontends.json"], - "Should contain both config files" - ); - } else { - panic!("Expected MultipleConfigFiles error, got: {result:?}"); - } - } - - #[test] - fn test_custom_named_config() { - let dir = TempDir::new().unwrap(); - let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); - let pkg_dir = AnchoredSystemPath::new("web").unwrap(); - let pkg_path = repo_root.resolve(pkg_dir); - - // Add a custom named config file - add_config_with_name(&pkg_path, "microfrontends-staging.jsonc").unwrap(); - - let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); - - assert!(config.is_some(), "Custom named config should be loaded"); - let cfg = config.unwrap(); - assert_eq!(cfg.filename(), "microfrontends-staging.jsonc"); - } - - #[test] - fn test_file_without_hyphen_matched() { - let dir = TempDir::new().unwrap(); - let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); - let pkg_dir = AnchoredSystemPath::new("web").unwrap(); - let pkg_path = repo_root.resolve(pkg_dir); - - // Add a file that starts with "microfrontends" but has no hyphen - add_config_with_name(&pkg_path, "microfrontendsconfig.json").unwrap(); - - let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); - - assert!(config.is_some(), "Files without hyphen should be matched"); - assert_eq!(config.unwrap().filename(), "microfrontendsconfig.json"); - } - - #[test] - fn test_exact_names_still_work() { - let dir = TempDir::new().unwrap(); - let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); - let pkg_dir = AnchoredSystemPath::new("web").unwrap(); - let pkg_path = repo_root.resolve(pkg_dir); - - // Verify microfrontends.json still works - add_v1_config(&pkg_path).unwrap(); - let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); - assert!(config.is_some()); - assert_eq!(config.unwrap().filename(), "microfrontends.json"); - } - - #[test] - fn test_nested_config_not_found() { - let dir = TempDir::new().unwrap(); - let repo_root = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); - let pkg_dir = AnchoredSystemPath::new("web").unwrap(); - let pkg_path = repo_root.resolve(pkg_dir); - - // Create a nested directory with a config file - let nested_path = pkg_path.join_component("config"); - add_v1_config(&nested_path).unwrap(); - - // Should not find the nested config - let config = Config::load_from_dir(repo_root, pkg_dir).unwrap(); - assert!(config.is_none(), "Nested config files should not be found"); - } } From d51378033c597095acb91a3c285515e703bb1898 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Fri, 31 Oct 2025 08:23:28 -0600 Subject: [PATCH 10/12] rework --- crates/turborepo-lib/src/task_hash.rs | 15 ++- crates/turborepo-microfrontends/src/error.rs | 5 + crates/turborepo-microfrontends/src/lib.rs | 129 ++++++++++++++++--- 3 files changed, 126 insertions(+), 23 deletions(-) diff --git a/crates/turborepo-lib/src/task_hash.rs b/crates/turborepo-lib/src/task_hash.rs index 8405b7554f896..07369f57fd756 100644 --- a/crates/turborepo-lib/src/task_hash.rs +++ b/crates/turborepo-lib/src/task_hash.rs @@ -6,25 +6,25 @@ use std::{ use rayon::prelude::*; use serde::Serialize; use thiserror::Error; -use tracing::{debug, Span}; +use tracing::{Span, debug}; use turbopath::{AbsoluteSystemPath, AnchoredSystemPath, AnchoredSystemPathBuf}; use turborepo_cache::CacheHitMetadata; use turborepo_env::{BySource, DetailedMap, EnvironmentVariableMap}; -use turborepo_frameworks::{infer_framework, Slug as FrameworkSlug}; +use turborepo_frameworks::{Slug as FrameworkSlug, infer_framework}; use turborepo_repository::package_graph::{PackageInfo, PackageName}; use turborepo_scm::SCM; use turborepo_task_id::TaskId; use turborepo_telemetry::events::{ - generic::GenericEventBuilder, task::PackageTaskEventBuilder, EventBuilder, + EventBuilder, generic::GenericEventBuilder, task::PackageTaskEventBuilder, }; use crate::{ + DaemonClient, DaemonConnector, cli::EnvMode, engine::TaskNode, hash::{FileHashes, LockFilePackages, TaskHashable, TurboHash}, opts::RunOpts, task_graph::TaskDefinition, - DaemonClient, DaemonConnector, }; #[derive(Debug, Error)] @@ -149,9 +149,9 @@ impl PackageInputsHashes { ); }) .ok() // If we timed out, we don't need to - // error, - // just return None so we can move on to - // local + // error, + // just return None so we can move on to + // local }) .and_then(|result| { match result { @@ -478,6 +478,7 @@ impl<'a> TaskHasher<'a> { "NEXT_*", "USE_OUTPUT_FOR_EDGE_FUNCTIONS", "NOW_BUILDER", + "VC_MICROFRONTENDS_CONFIG_FILE_NAME", // Command Prompt casing of env variables "APPDATA", "PATH", diff --git a/crates/turborepo-microfrontends/src/error.rs b/crates/turborepo-microfrontends/src/error.rs index 39e5990e0ee99..b34aca11b4b74 100644 --- a/crates/turborepo-microfrontends/src/error.rs +++ b/crates/turborepo-microfrontends/src/error.rs @@ -34,6 +34,11 @@ pub enum Error { }, #[error("Invalid package path: {0}. Path traversal outside repository root is not allowed.")] PathTraversal(String), + #[error( + "Invalid custom config file name: {0}. Must be a .json or .jsonc file directly in the \ + package root (no subdirectories or path traversal)." + )] + InvalidCustomConfigPath(String), } impl Error { diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs index ba2536395f75b..bfea37c228151 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -26,8 +26,6 @@ mod configv1; mod error; mod schema; -use std::io; - use configv1::ConfigV1; pub use configv1::PathGroup; pub use error::Error; @@ -43,6 +41,7 @@ pub const DEFAULT_MICROFRONTENDS_CONFIG_V1: &str = "microfrontends.json"; pub const DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT: &str = "microfrontends.jsonc"; pub const MICROFRONTENDS_PACKAGE: &str = "@vercel/microfrontends"; pub const SUPPORTED_VERSIONS: &[&str] = ["1"].as_slice(); +pub const CUSTOM_CONFIG_ENV_VAR: &str = "VC_MICROFRONTENDS_CONFIG_FILE_NAME"; /// Strict Turborepo-only configuration for the microfrontends proxy. /// This configuration parser only accepts fields that Turborepo's native proxy @@ -57,6 +56,64 @@ pub struct TurborepoMfeConfig { } impl TurborepoMfeConfig { + /// Validates a custom config filename from environment variable. + /// Returns error if: + /// - Path contains ".." (attempting to traverse up) + /// - Path does not end with ".json" or ".jsonc" + /// - Path starts with "/" (must be relative) + /// - Path contains "/" or "\" (no subdirectories allowed) + fn validate_custom_config_name(filename: &str) -> Result<(), Error> { + // Must end with .json or .jsonc + if !filename.ends_with(".json") && !filename.ends_with(".jsonc") { + return Err(Error::InvalidCustomConfigPath(format!( + "{}: must be a JSON file ending with .json or .jsonc", + filename + ))); + } + + // Must not contain directory separators (no subdirectories) + if filename.contains('/') || filename.contains('\\') { + return Err(Error::InvalidCustomConfigPath(format!( + "{}: subdirectories not allowed, file must be in package root", + filename + ))); + } + + // Must not contain path traversal + if filename.contains("..") { + return Err(Error::InvalidCustomConfigPath(format!( + "{}: path traversal not allowed", + filename + ))); + } + + // Must be relative (not start with /) + if filename.starts_with('/') { + return Err(Error::InvalidCustomConfigPath(format!( + "{}: must be relative to package root", + filename + ))); + } + + Ok(()) + } + + /// Gets the custom config filename from environment variable if set. + /// Returns None if not set, or Error if invalid. + fn get_custom_config_name() -> Result, Error> { + match std::env::var(CUSTOM_CONFIG_ENV_VAR) { + Ok(filename) if !filename.is_empty() => { + Self::validate_custom_config_name(&filename)?; + Ok(Some(filename)) + } + Ok(_) => Ok(None), // Empty string means not set + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(Error::InvalidCustomConfigPath( + "environment variable contains invalid UTF-8".to_string(), + )), + } + } + /// Reads config from given path using strict Turborepo schema. /// Returns `Ok(None)` if the file does not exist pub fn load(config_path: &AbsoluteSystemPath) -> Result, Error> { @@ -89,10 +146,9 @@ impl TurborepoMfeConfig { Config::validate_package_path(repo_root, &absolute_dir)?; - let Some((contents, path)) = Self::load_v1_dir(&absolute_dir) else { + let Some((contents, path)) = Self::load_v1_dir(&absolute_dir)? else { return Ok(None); }; - let contents = contents?; let mut config = Self::from_str_with_mfe_dep(&contents, path.as_str(), has_mfe_dependency)?; config.filename = path .file_name() @@ -200,15 +256,36 @@ impl TurborepoMfeConfig { fn load_v1_dir( dir: &AbsoluteSystemPath, - ) -> Option<(Result, AbsoluteSystemPathBuf)> { + ) -> Result, Error> { let load_config = - |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { + |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { let path = dir.join_component(filename); - let contents = path.read_existing_to_string().transpose()?; + let contents = path + .read_existing_to_string() + .transpose()? + .map_err(Error::from); Some((contents, path)) }; - load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) - .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) + + // First check if custom config is specified via environment variable + match Self::get_custom_config_name()? { + Some(custom_name) => { + // If environment variable is set, only try that path + let Some((contents, path)) = load_config(&custom_name) else { + return Ok(None); + }; + Ok(Some((contents?, path))) + } + None => { + // Otherwise use default config file names + let Some((contents, path)) = load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) + .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) + else { + return Ok(None); + }; + Ok(Some((contents?, path))) + } + } } pub fn set_path(&mut self, dir: &AnchoredSystemPath) { @@ -300,10 +377,9 @@ impl Config { Self::validate_package_path(repo_root, &absolute_dir)?; // we want to try different paths and then do `from_str` - let Some((contents, path)) = Self::load_v1_dir(&absolute_dir) else { + let Some((contents, path)) = Self::load_v1_dir(&absolute_dir)? else { return Ok(None); }; - let contents = contents?; let mut config = Config::from_str(&contents, path.as_str())?; config.filename = path .file_name() @@ -390,15 +466,36 @@ impl Config { fn load_v1_dir( dir: &AbsoluteSystemPath, - ) -> Option<(Result, AbsoluteSystemPathBuf)> { + ) -> Result, Error> { let load_config = - |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { + |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { let path = dir.join_component(filename); - let contents = path.read_existing_to_string().transpose()?; + let contents = path + .read_existing_to_string() + .transpose()? + .map_err(Error::from); Some((contents, path)) }; - load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) - .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) + + // First check if custom config is specified via environment variable + match TurborepoMfeConfig::get_custom_config_name()? { + Some(custom_name) => { + // If environment variable is set, only try that path + let Some((contents, path)) = load_config(&custom_name) else { + return Ok(None); + }; + Ok(Some((contents?, path))) + } + None => { + // Otherwise use default config file names + let Some((contents, path)) = load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) + .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) + else { + return Ok(None); + }; + Ok(Some((contents?, path))) + } + } } /// Sets the path the configuration was loaded from From 15a9f606f1c3f0cd3f08c00308d768951577c5a9 Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Fri, 31 Oct 2025 08:26:47 -0600 Subject: [PATCH 11/12] fmt --- crates/turborepo-lib/src/task_hash.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/turborepo-lib/src/task_hash.rs b/crates/turborepo-lib/src/task_hash.rs index 07369f57fd756..a8c1a0f16b8b2 100644 --- a/crates/turborepo-lib/src/task_hash.rs +++ b/crates/turborepo-lib/src/task_hash.rs @@ -6,25 +6,25 @@ use std::{ use rayon::prelude::*; use serde::Serialize; use thiserror::Error; -use tracing::{Span, debug}; +use tracing::{debug, Span}; use turbopath::{AbsoluteSystemPath, AnchoredSystemPath, AnchoredSystemPathBuf}; use turborepo_cache::CacheHitMetadata; use turborepo_env::{BySource, DetailedMap, EnvironmentVariableMap}; -use turborepo_frameworks::{Slug as FrameworkSlug, infer_framework}; +use turborepo_frameworks::{infer_framework, Slug as FrameworkSlug}; use turborepo_repository::package_graph::{PackageInfo, PackageName}; use turborepo_scm::SCM; use turborepo_task_id::TaskId; use turborepo_telemetry::events::{ - EventBuilder, generic::GenericEventBuilder, task::PackageTaskEventBuilder, + generic::GenericEventBuilder, task::PackageTaskEventBuilder, EventBuilder, }; use crate::{ - DaemonClient, DaemonConnector, cli::EnvMode, engine::TaskNode, hash::{FileHashes, LockFilePackages, TaskHashable, TurboHash}, opts::RunOpts, task_graph::TaskDefinition, + DaemonClient, DaemonConnector, }; #[derive(Debug, Error)] @@ -149,9 +149,9 @@ impl PackageInputsHashes { ); }) .ok() // If we timed out, we don't need to - // error, - // just return None so we can move on to - // local + // error, + // just return None so we can move on to + // local }) .and_then(|result| { match result { From be17036f65f2fe3bf1c0d1f0a71334aeb70fd6bf Mon Sep 17 00:00:00 2001 From: Anthony Shew Date: Fri, 31 Oct 2025 08:37:57 -0600 Subject: [PATCH 12/12] WIP 6a2a5 --- crates/turborepo-microfrontends/src/lib.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs index bfea37c228151..f1404fdba68cb 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -66,32 +66,28 @@ impl TurborepoMfeConfig { // Must end with .json or .jsonc if !filename.ends_with(".json") && !filename.ends_with(".jsonc") { return Err(Error::InvalidCustomConfigPath(format!( - "{}: must be a JSON file ending with .json or .jsonc", - filename + "{filename}: must be a JSON file ending with .json or .jsonc" ))); } // Must not contain directory separators (no subdirectories) if filename.contains('/') || filename.contains('\\') { return Err(Error::InvalidCustomConfigPath(format!( - "{}: subdirectories not allowed, file must be in package root", - filename + "{filename}: subdirectories not allowed, file must be in package root" ))); } // Must not contain path traversal if filename.contains("..") { return Err(Error::InvalidCustomConfigPath(format!( - "{}: path traversal not allowed", - filename + "{filename}: path traversal not allowed" ))); } // Must be relative (not start with /) if filename.starts_with('/') { return Err(Error::InvalidCustomConfigPath(format!( - "{}: must be relative to package root", - filename + "{filename}: must be relative to package root" ))); }