diff --git a/Cargo.lock b/Cargo.lock index 46054e239253e..32e1a2c97ede5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6483,7 +6483,7 @@ dependencies = [ "turborepo-fs", "turborepo-graph-utils", "turborepo-lockfiles", - "turborepo-micro-frontend", + "turborepo-microfrontends", "turborepo-repository", "turborepo-scm", "turborepo-telemetry", @@ -6539,7 +6539,7 @@ dependencies = [ ] [[package]] -name = "turborepo-micro-frontend" +name = "turborepo-microfrontends" version = "0.1.0" dependencies = [ "biome_deserialize", @@ -6551,6 +6551,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "tempfile", "thiserror", "turbopath", "turborepo-errors", diff --git a/Cargo.toml b/Cargo.toml index 4b0a20b9e9a20..46f694f059679 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,7 @@ turborepo-errors = { path = "crates/turborepo-errors" } turborepo-fs = { path = "crates/turborepo-fs" } turborepo-lib = { path = "crates/turborepo-lib", default-features = false } turborepo-lockfiles = { path = "crates/turborepo-lockfiles" } -turborepo-micro-frontend = { path = "crates/turborepo-micro-frontend" } +turborepo-microfrontends = { path = "crates/turborepo-microfrontends" } turborepo-repository = { path = "crates/turborepo-repository" } turborepo-ui = { path = "crates/turborepo-ui" } turborepo-unescape = { path = "crates/turborepo-unescape" } diff --git a/crates/turborepo-lib/Cargo.toml b/crates/turborepo-lib/Cargo.toml index 644cc96eecac6..fe968a728abea 100644 --- a/crates/turborepo-lib/Cargo.toml +++ b/crates/turborepo-lib/Cargo.toml @@ -133,7 +133,7 @@ turborepo-filewatch = { path = "../turborepo-filewatch" } turborepo-fs = { path = "../turborepo-fs" } turborepo-graph-utils = { path = "../turborepo-graph-utils" } turborepo-lockfiles = { workspace = true } -turborepo-micro-frontend = { workspace = true } +turborepo-microfrontends = { workspace = true } turborepo-repository = { path = "../turborepo-repository" } turborepo-scm = { workspace = true } turborepo-telemetry = { path = "../turborepo-telemetry" } diff --git a/crates/turborepo-lib/src/lib.rs b/crates/turborepo-lib/src/lib.rs index 4bda1819c4407..5584fa4ba0c83 100644 --- a/crates/turborepo-lib/src/lib.rs +++ b/crates/turborepo-lib/src/lib.rs @@ -23,7 +23,7 @@ mod framework; mod gitignore; pub(crate) mod globwatcher; mod hash; -mod micro_frontends; +mod microfrontends; mod opts; mod package_changes_watcher; mod panic_handler; diff --git a/crates/turborepo-lib/src/micro_frontends.rs b/crates/turborepo-lib/src/microfrontends.rs similarity index 53% rename from crates/turborepo-lib/src/micro_frontends.rs rename to crates/turborepo-lib/src/microfrontends.rs index b4ef6a8212437..77f0917fc1705 100644 --- a/crates/turborepo-lib/src/micro_frontends.rs +++ b/crates/turborepo-lib/src/microfrontends.rs @@ -3,9 +3,7 @@ use std::collections::{HashMap, HashSet}; use itertools::Itertools; use tracing::warn; use turbopath::AbsoluteSystemPath; -use turborepo_micro_frontend::{ - Config as MFEConfig, Error, DEFAULT_MICRO_FRONTENDS_CONFIG, MICRO_FRONTENDS_PACKAGES, -}; +use turborepo_microfrontends::{Config as MFEConfig, Error, MICROFRONTENDS_PACKAGES}; use turborepo_repository::package_graph::{PackageGraph, PackageName}; use crate::{ @@ -15,58 +13,44 @@ use crate::{ }; #[derive(Debug, Clone)] -pub struct MicroFrontendsConfigs { +pub struct MicrofrontendsConfigs { configs: HashMap>>, + config_filenames: HashMap, mfe_package: Option<&'static str>, } -impl MicroFrontendsConfigs { +impl MicrofrontendsConfigs { pub fn new( repo_root: &AbsoluteSystemPath, package_graph: &PackageGraph, ) -> Result, Error> { - let mut configs = HashMap::new(); - for (package_name, package_info) in package_graph.packages() { - let config_path = repo_root - .resolve(package_info.package_path()) - .join_component(DEFAULT_MICRO_FRONTENDS_CONFIG); - let Some(config) = MFEConfig::load(&config_path).or_else(|err| { - if matches!(err, turborepo_micro_frontend::Error::UnsupportedVersion(_)) { - warn!("Ignoring {config_path}: {err}"); - Ok(None) - } else { - Err(err) - } - })? - else { - continue; - }; - let tasks = config - .applications - .iter() - .map(|(application, options)| { - let dev_task = options.development.task.as_deref().unwrap_or("dev"); - TaskId::new(application, dev_task).into_owned() - }) - .collect(); - configs.insert(package_name.to_string(), tasks); + let PackageGraphResult { + configs, + config_filenames, + missing_default_apps, + unsupported_version, + mfe_package, + } = PackageGraphResult::new(package_graph.packages().map(|(name, info)| { + ( + name.as_str(), + MFEConfig::load_from_dir(&repo_root.resolve(info.package_path())), + ) + }))?; + + for (package, err) in unsupported_version { + warn!("Ignoring {package}: {err}"); } - let mfe_package = package_graph - .packages() - .map(|(pkg, _)| pkg.as_str()) - .sorted() - // We use `find_map` here instead of a simple `find` so we get the &'static str - // instead of the &str tied to the lifetime of the package graph. - .find_map(|pkg| { - MICRO_FRONTENDS_PACKAGES - .iter() - .find(|static_pkg| pkg == **static_pkg) - }) - .copied(); + if !missing_default_apps.is_empty() { + warn!( + "Missing default applications: {}", + missing_default_apps.join(", ") + ); + } Ok((!configs.is_empty()).then_some(Self { configs, + config_filenames, mfe_package, })) } @@ -89,6 +73,11 @@ impl MicroFrontendsConfigs { .any(|dev_tasks| dev_tasks.contains(task_id)) } + pub fn config_filename(&self, package_name: &str) -> Option<&str> { + let filename = self.config_filenames.get(package_name)?; + Some(filename.as_str()) + } + pub fn update_turbo_json( &self, package_name: &PackageName, @@ -145,6 +134,74 @@ impl MicroFrontendsConfigs { } } +// Internal struct used to capture the results of checking the package graph +struct PackageGraphResult { + configs: HashMap>>, + config_filenames: HashMap, + missing_default_apps: Vec, + unsupported_version: Vec<(String, String)>, + mfe_package: Option<&'static str>, +} + +impl PackageGraphResult { + fn new<'a>( + packages: impl Iterator, Error>)>, + ) -> Result { + let mut configs = HashMap::new(); + let mut config_filenames = HashMap::new(); + let mut referenced_default_apps = HashSet::new(); + let mut unsupported_version = Vec::new(); + let mut mfe_package = None; + // We sort packages to ensure deterministic behavior + let sorted_packages = packages.sorted_by(|(a, _), (b, _)| a.cmp(b)); + for (package_name, config) in sorted_packages { + if let Some(pkg) = MICROFRONTENDS_PACKAGES + .iter() + .find(|static_pkg| package_name == **static_pkg) + { + mfe_package = Some(*pkg); + } + + let Some(config) = config.or_else(|err| match err { + turborepo_microfrontends::Error::UnsupportedVersion(_) => { + unsupported_version.push((package_name.to_string(), err.to_string())); + Ok(None) + } + turborepo_microfrontends::Error::ChildConfig { reference } => { + referenced_default_apps.insert(reference); + Ok(None) + } + err => Err(err), + })? + else { + continue; + }; + let tasks = config + .development_tasks() + .map(|(application, options)| { + let dev_task = options.unwrap_or("dev"); + TaskId::new(application, dev_task).into_owned() + }) + .collect(); + configs.insert(package_name.to_string(), tasks); + config_filenames.insert(package_name.to_string(), config.filename().to_owned()); + } + let default_apps_found = configs.keys().cloned().collect(); + let mut missing_default_apps = referenced_default_apps + .difference(&default_apps_found) + .cloned() + .collect::>(); + missing_default_apps.sort(); + Ok(Self { + configs, + config_filenames, + missing_default_apps, + unsupported_version, + mfe_package, + }) + } +} + #[derive(Debug, PartialEq, Eq)] struct FindResult<'a> { dev: Option>, @@ -153,7 +210,11 @@ struct FindResult<'a> { #[cfg(test)] mod test { + use serde_json::json; use test_case::test_case; + use turborepo_microfrontends::{ + MICROFRONTENDS_PACKAGE_EXTERNAL, MICROFRONTENDS_PACKAGE_INTERNAL, + }; use super::*; @@ -253,8 +314,9 @@ mod test { "mfe-config-pkg" => ["web#dev", "docs#dev"], "mfe-web" => ["mfe-web#dev", "mfe-docs#serve"] ); - let mfe = MicroFrontendsConfigs { + let mfe = MicrofrontendsConfigs { configs, + config_filenames: HashMap::new(), mfe_package: None, }; assert_eq!( @@ -262,4 +324,102 @@ mod test { test.expected() ); } + + #[test] + fn test_mfe_package_is_found() { + let result = PackageGraphResult::new( + vec![ + // These should never be present in the same graph, but if for some reason they + // are, we defer to the external variant. + (MICROFRONTENDS_PACKAGE_EXTERNAL, Ok(None)), + (MICROFRONTENDS_PACKAGE_INTERNAL, Ok(None)), + ] + .into_iter(), + ) + .unwrap(); + assert_eq!(result.mfe_package, Some(MICROFRONTENDS_PACKAGE_EXTERNAL)); + } + + #[test] + fn test_no_mfe_package() { + let result = + PackageGraphResult::new(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( + vec![("foo", Err(Error::UnsupportedVersion("bad version".into())))].into_iter(), + ) + .unwrap(); + assert_eq!(result.configs, HashMap::new()); + } + + #[test] + fn test_child_configs_with_missing_default() { + let result = PackageGraphResult::new( + vec![( + "child", + Err(Error::ChildConfig { + reference: "main".into(), + }), + )] + .into_iter(), + ) + .unwrap(); + assert_eq!(result.configs, HashMap::new()); + assert_eq!(result.missing_default_apps, &["main".to_string()]); + } + + #[test] + fn test_io_err_stops_traversal() { + let result = PackageGraphResult::new( + vec![ + ( + "a", + Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::Other, + "something", + ))), + ), + ( + "b", + Err(Error::ChildConfig { + reference: "main".into(), + }), + ), + ] + .into_iter(), + ); + assert!(result.is_err()); + } + + #[test] + fn test_dev_task_collection() { + let config = MFEConfig::from_str( + &serde_json::to_string_pretty(&json!({ + "version": "2", + "applications": { + "web": {}, + "docs": { + "development": { + "task": "serve" + } + } + } + })) + .unwrap(), + "something.txt", + ) + .unwrap(); + let result = PackageGraphResult::new(vec![("web", Ok(Some(config)))].into_iter()).unwrap(); + assert_eq!( + result.configs, + mfe_configs!( + "web" => ["web#dev", "docs#serve"] + ) + ) + } } diff --git a/crates/turborepo-lib/src/run/builder.rs b/crates/turborepo-lib/src/run/builder.rs index f2cdd39b96755..59f13c71fb73c 100644 --- a/crates/turborepo-lib/src/run/builder.rs +++ b/crates/turborepo-lib/src/run/builder.rs @@ -41,7 +41,7 @@ use crate::{ cli::DryRunMode, commands::CommandBase, engine::{Engine, EngineBuilder}, - micro_frontends::MicroFrontendsConfigs, + microfrontends::MicrofrontendsConfigs, opts::Opts, process::ProcessManager, run::{scope, task_access::TaskAccess, task_id::TaskName, Error, Run, RunCache}, @@ -371,7 +371,7 @@ impl RunBuilder { repo_telemetry.track_package_manager(pkg_dep_graph.package_manager().to_string()); repo_telemetry.track_size(pkg_dep_graph.len()); run_telemetry.track_run_type(self.opts.run_opts.dry_run.is_some()); - let micro_frontend_configs = MicroFrontendsConfigs::new(&self.repo_root, &pkg_dep_graph)?; + let micro_frontend_configs = MicrofrontendsConfigs::new(&self.repo_root, &pkg_dep_graph)?; let scm = scm.await.expect("detecting scm panicked"); let async_cache = AsyncCache::new( diff --git a/crates/turborepo-lib/src/run/error.rs b/crates/turborepo-lib/src/run/error.rs index e4ba94be2a5af..75fc695598251 100644 --- a/crates/turborepo-lib/src/run/error.rs +++ b/crates/turborepo-lib/src/run/error.rs @@ -61,5 +61,5 @@ pub enum Error { #[error(transparent)] Tui(#[from] tui::Error), #[error("Error reading micro frontends configuration: {0}")] - MicroFrontends(#[from] turborepo_micro_frontend::Error), + MicroFrontends(#[from] turborepo_microfrontends::Error), } diff --git a/crates/turborepo-lib/src/run/mod.rs b/crates/turborepo-lib/src/run/mod.rs index 448b8c02d4726..834da32767b73 100644 --- a/crates/turborepo-lib/src/run/mod.rs +++ b/crates/turborepo-lib/src/run/mod.rs @@ -41,7 +41,7 @@ pub use crate::run::error::Error; use crate::{ cli::EnvMode, engine::Engine, - micro_frontends::MicroFrontendsConfigs, + microfrontends::MicrofrontendsConfigs, opts::Opts, process::ProcessManager, run::{global_hash::get_global_hash_inputs, summary::RunTracker, task_access::TaskAccess}, @@ -74,7 +74,7 @@ pub struct Run { task_access: TaskAccess, daemon: Option>, should_print_prelude: bool, - micro_frontend_configs: Option, + micro_frontend_configs: Option, } type UIResult = Result>)>, Error>; diff --git a/crates/turborepo-lib/src/task_graph/visitor/command.rs b/crates/turborepo-lib/src/task_graph/visitor/command.rs index 17b2b315dff46..2eaf2d6f05d44 100644 --- a/crates/turborepo-lib/src/task_graph/visitor/command.rs +++ b/crates/turborepo-lib/src/task_graph/visitor/command.rs @@ -2,12 +2,12 @@ use std::{collections::HashSet, path::PathBuf}; use turbopath::AbsoluteSystemPath; use turborepo_env::EnvironmentVariableMap; -use turborepo_micro_frontend::MICRO_FRONTENDS_PACKAGES; +use turborepo_microfrontends::MICROFRONTENDS_PACKAGES; use turborepo_repository::package_graph::{PackageGraph, PackageInfo, PackageName}; use super::Error; use crate::{ - engine::Engine, micro_frontends::MicroFrontendsConfigs, opts::TaskArgs, process::Command, + engine::Engine, microfrontends::MicrofrontendsConfigs, opts::TaskArgs, process::Command, run::task_id::TaskId, }; @@ -61,7 +61,7 @@ pub struct PackageGraphCommandProvider<'a> { package_graph: &'a PackageGraph, package_manager_binary: Result, task_args: TaskArgs<'a>, - mfe_configs: Option<&'a MicroFrontendsConfigs>, + mfe_configs: Option<&'a MicrofrontendsConfigs>, } impl<'a> PackageGraphCommandProvider<'a> { @@ -69,7 +69,7 @@ impl<'a> PackageGraphCommandProvider<'a> { repo_root: &'a AbsoluteSystemPath, package_graph: &'a PackageGraph, task_args: TaskArgs<'a>, - mfe_configs: Option<&'a MicroFrontendsConfigs>, + mfe_configs: Option<&'a MicrofrontendsConfigs>, ) -> Self { let package_manager_binary = which::which(package_graph.package_manager().command()); Self { @@ -151,7 +151,7 @@ pub struct MicroFrontendProxyProvider<'a> { repo_root: &'a AbsoluteSystemPath, package_graph: &'a PackageGraph, tasks_in_graph: HashSet>, - mfe_configs: &'a MicroFrontendsConfigs, + mfe_configs: &'a MicrofrontendsConfigs, } impl<'a> MicroFrontendProxyProvider<'a> { @@ -159,7 +159,7 @@ impl<'a> MicroFrontendProxyProvider<'a> { repo_root: &'a AbsoluteSystemPath, package_graph: &'a PackageGraph, engine: &Engine, - micro_frontends_configs: &'a MicroFrontendsConfigs, + micro_frontends_configs: &'a MicrofrontendsConfigs, ) -> Self { let tasks_in_graph = engine .tasks() @@ -206,10 +206,12 @@ impl<'a> CommandProvider for MicroFrontendProxyProvider<'a> { let has_mfe_dependency = package_info .package_json .all_dependencies() - .any(|(package, _version)| MICRO_FRONTENDS_PACKAGES.contains(&package.as_str())); + .any(|(package, _version)| MICROFRONTENDS_PACKAGES.contains(&package.as_str())); if !has_mfe_dependency { + let mfe_config_filename = self.mfe_configs.config_filename(task_id.package()); return Err(Error::MissingMFEDependency { package: task_id.package().into(), + mfe_config_filename: mfe_config_filename.unwrap_or_default().to_owned(), }); } let local_apps = dev_tasks @@ -217,7 +219,11 @@ impl<'a> CommandProvider for MicroFrontendProxyProvider<'a> { .filter(|task| self.tasks_in_graph.contains(task)) .map(|task| task.package()); let package_dir = self.repo_root.resolve(package_info.package_path()); - let mfe_path = package_dir.join_component("micro-frontends.jsonc"); + let mfe_config_filename = self + .mfe_configs + .config_filename(task_id.package()) + .expect("every microfrontends default application should have configuration path"); + let mfe_path = package_dir.join_component(mfe_config_filename); let mut args = vec!["proxy", mfe_path.as_str(), "--names"]; args.extend(local_apps); diff --git a/crates/turborepo-lib/src/task_graph/visitor/mod.rs b/crates/turborepo-lib/src/task_graph/visitor/mod.rs index 6dd29bce6ba96..8bb69aad75f74 100644 --- a/crates/turborepo-lib/src/task_graph/visitor/mod.rs +++ b/crates/turborepo-lib/src/task_graph/visitor/mod.rs @@ -23,7 +23,6 @@ use tracing::{debug, error, warn, Span}; use turbopath::{AbsoluteSystemPath, AnchoredSystemPath}; use turborepo_ci::{Vendor, VendorBehavior}; use turborepo_env::{platform::PlatformEnv, EnvironmentVariableMap}; -use turborepo_micro_frontend::DEFAULT_MICRO_FRONTENDS_CONFIG; use turborepo_repository::package_graph::{PackageGraph, PackageName, ROOT_PKG_NAME}; use turborepo_telemetry::events::{ generic::GenericEventBuilder, task::PackageTaskEventBuilder, EventBuilder, TrackedErrors, @@ -35,7 +34,7 @@ use turborepo_ui::{ use crate::{ cli::EnvMode, engine::{Engine, ExecutionOptions}, - micro_frontends::MicroFrontendsConfigs, + microfrontends::MicrofrontendsConfigs, opts::RunOpts, process::ProcessManager, run::{ @@ -67,7 +66,7 @@ pub struct Visitor<'a> { is_watch: bool, ui_sender: Option, warnings: Arc>>, - micro_frontends_configs: Option<&'a MicroFrontendsConfigs>, + micro_frontends_configs: Option<&'a MicrofrontendsConfigs>, } #[derive(Debug, thiserror::Error, Diagnostic)] @@ -101,10 +100,13 @@ pub enum Error { #[error("unable to find package manager binary: {0}")] Which(#[from] which::Error), #[error( - "'{package}' is configured with a {DEFAULT_MICRO_FRONTENDS_CONFIG}, but doesn't have \ + "'{package}' is configured with a {mfe_config_filename}, but doesn't have \ '@vercel/microfrontends' listed as a dependency" )] - MissingMFEDependency { package: String }, + MissingMFEDependency { + package: String, + mfe_config_filename: String, + }, } impl<'a> Visitor<'a> { @@ -127,7 +129,7 @@ impl<'a> Visitor<'a> { global_env: EnvironmentVariableMap, ui_sender: Option, is_watch: bool, - micro_frontends_configs: Option<&'a MicroFrontendsConfigs>, + micro_frontends_configs: Option<&'a MicrofrontendsConfigs>, ) -> Self { let task_hasher = TaskHasher::new( package_inputs_hashes, diff --git a/crates/turborepo-lib/src/turbo_json/loader.rs b/crates/turborepo-lib/src/turbo_json/loader.rs index a37018fd46e77..caab0d837b939 100644 --- a/crates/turborepo-lib/src/turbo_json/loader.rs +++ b/crates/turborepo-lib/src/turbo_json/loader.rs @@ -12,7 +12,7 @@ use super::{Pipeline, RawTaskDefinition, TurboJson, CONFIG_FILE}; use crate::{ cli::EnvMode, config::Error, - micro_frontends::MicroFrontendsConfigs, + microfrontends::MicrofrontendsConfigs, run::{task_access::TASK_ACCESS_CONFIG_PATH, task_id::TaskName}, }; @@ -35,7 +35,7 @@ enum Strategy { Workspace { // Map of package names to their package specific turbo.json packages: HashMap, - micro_frontends_configs: Option, + micro_frontends_configs: Option, }, WorkspaceNoTurboJson { // Map of package names to their scripts @@ -71,7 +71,7 @@ impl TurboJsonLoader { repo_root: AbsoluteSystemPathBuf, root_turbo_json_path: AbsoluteSystemPathBuf, packages: impl Iterator, - micro_frontends_configs: MicroFrontendsConfigs, + micro_frontends_configs: MicrofrontendsConfigs, ) -> Self { let packages = package_turbo_jsons(&repo_root, root_turbo_json_path, packages); Self { diff --git a/crates/turborepo-micro-frontend/src/lib.rs b/crates/turborepo-micro-frontend/src/lib.rs deleted file mode 100644 index 50b7c67fbf128..0000000000000 --- a/crates/turborepo-micro-frontend/src/lib.rs +++ /dev/null @@ -1,106 +0,0 @@ -#![deny(clippy::all)] -mod error; - -use std::collections::BTreeMap; - -use biome_deserialize_macros::Deserializable; -use biome_json_parser::JsonParserOptions; -pub use error::Error; -use serde::Serialize; -use turbopath::AbsoluteSystemPath; - -/// Currently the default path for a package that provides a configuration. -/// -/// This is subject to change at any time. -pub const DEFAULT_MICRO_FRONTENDS_CONFIG: &str = "micro-frontends.jsonc"; -pub const MICRO_FRONTENDS_PACKAGES: &[&str] = [ - MICRO_FRONTENDS_PACKAGE_EXTERNAL, - MICRO_FRONTENDS_PACKAGE_INTERNAL, -] -.as_slice(); -pub const MICRO_FRONTENDS_PACKAGE_INTERNAL: &str = "@vercel/micro-frontends-internal"; -pub const MICRO_FRONTENDS_PACKAGE_EXTERNAL: &str = "@vercel/microfrontends"; -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, Serialize, Deserializable, Default)] -pub struct Config { - pub version: String, - pub applications: BTreeMap, -} - -impl Config { - /// Reads config from given path. - /// Returns `Ok(None)` if the file does not exist - pub fn load(config_path: &AbsoluteSystemPath) -> Result, Error> { - let Some(contents) = config_path.read_existing_to_string()? else { - return Ok(None); - }; - let config = Self::from_str(&contents, config_path.as_str())?; - Ok(Some(config)) - } - - pub fn from_str(input: &str, source: &str) -> Result { - #[derive(Deserializable, Default)] - struct VersionOnly { - version: String, - } - let (version_only, _errs) = biome_deserialize::json::deserialize_from_json_str( - input, - JsonParserOptions::default().with_allow_comments(), - source, - ) - .consume(); - // If parsing just the version fails, fallback to full schema to provide better - // error message - if let Some(VersionOnly { version }) = version_only { - if !SUPPORTED_VERSIONS.contains(&version.as_str()) { - return Err(Error::UnsupportedVersion(version)); - } - } - let (config, errs) = biome_deserialize::json::deserialize_from_json_str( - input, - JsonParserOptions::default().with_allow_comments(), - source, - ) - .consume(); - if let Some(config) = config { - Ok(config) - } else { - Err(Error::biome_error(errs)) - } - } -} - -#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] -pub struct Application { - pub development: Development, -} - -#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] -pub struct Development { - #[serde(skip_serializing_if = "Option::is_none")] - pub task: Option, -} - -#[cfg(test)] -mod test { - use insta::assert_snapshot; - - use super::*; - - #[test] - fn test_example_parses() { - let input = include_str!("../fixtures/sample.jsonc"); - let example_config = Config::from_str(input, "something.json"); - assert!(example_config.is_ok()); - } - - #[test] - fn test_unsupported_version() { - let input = r#"{"version": "yolo"}"#; - let err = Config::from_str(input, "something.json").unwrap_err(); - assert_snapshot!(err, @r###"Unsupported micro-frontends configuration version: yolo. Supported versions: ["1"]"###); - } -} diff --git a/crates/turborepo-micro-frontend/Cargo.toml b/crates/turborepo-microfrontends/Cargo.toml similarity index 89% rename from crates/turborepo-micro-frontend/Cargo.toml rename to crates/turborepo-microfrontends/Cargo.toml index d08ccd33d425f..209cd30805caf 100644 --- a/crates/turborepo-micro-frontend/Cargo.toml +++ b/crates/turborepo-microfrontends/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "turborepo-micro-frontend" +name = "turborepo-microfrontends" version = "0.1.0" edition = "2021" license = "MIT" @@ -19,6 +19,7 @@ turborepo-errors = { workspace = true } [dev-dependencies] insta = { workspace = true } pretty_assertions = { workspace = true } +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/turborepo-micro-frontend/fixtures/sample.jsonc b/crates/turborepo-microfrontends/fixtures/sample.jsonc similarity index 100% rename from crates/turborepo-micro-frontend/fixtures/sample.jsonc rename to crates/turborepo-microfrontends/fixtures/sample.jsonc diff --git a/crates/turborepo-microfrontends/src/configv1.rs b/crates/turborepo-microfrontends/src/configv1.rs new file mode 100644 index 0000000000000..83a98b6fb5a5c --- /dev/null +++ b/crates/turborepo-microfrontends/src/configv1.rs @@ -0,0 +1,55 @@ +use std::collections::BTreeMap; + +use biome_deserialize_macros::Deserializable; +use biome_json_parser::JsonParserOptions; +use serde::Serialize; + +use crate::Error; + +/// The minimal amount of information Turborepo needs to correctly start a local +/// proxy server for microfrontends +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +pub struct ConfigV1 { + pub version: String, + pub applications: BTreeMap, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +pub struct Application { + pub development: Development, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +pub struct Development { + #[serde(skip_serializing_if = "Option::is_none")] + pub task: Option, +} + +impl ConfigV1 { + pub fn from_str(input: &str, source: &str) -> Result { + let (config, errs) = biome_deserialize::json::deserialize_from_json_str::( + input, + JsonParserOptions::default().with_allow_comments(), + source, + ) + .consume(); + if let Some(config) = config { + if config.version == "1" { + Ok(config) + } else { + Err(Error::InvalidVersion { + expected: "1", + actual: config.version, + }) + } + } else { + Err(Error::biome_error(errs)) + } + } + + pub fn development_tasks(&self) -> impl Iterator)> { + self.applications + .iter() + .map(|(application, config)| (application.as_str(), config.development.task.as_deref())) + } +} diff --git a/crates/turborepo-microfrontends/src/configv2.rs b/crates/turborepo-microfrontends/src/configv2.rs new file mode 100644 index 0000000000000..744f741b4e967 --- /dev/null +++ b/crates/turborepo-microfrontends/src/configv2.rs @@ -0,0 +1,120 @@ +use std::collections::BTreeMap; + +use biome_deserialize_macros::Deserializable; +use biome_json_parser::JsonParserOptions; +use serde::Serialize; + +use crate::Error; + +pub enum ParseResult { + Actual(ConfigV2), + Reference(String), +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +pub struct ConfigV2 { + version: String, + applications: BTreeMap, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +struct ChildConfig { + part_of: String, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +struct Application { + development: Option, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)] +struct Development { + task: Option, +} + +impl ConfigV2 { + pub fn from_str(input: &str, source: &str) -> Result { + // attempt to parse a child, ignoring any errors + let (config, errs) = biome_deserialize::json::deserialize_from_json_str::( + input, + JsonParserOptions::default().with_allow_comments(), + source, + ) + .consume(); + + if let Some(ChildConfig { part_of }) = errs.is_empty().then_some(config).flatten() { + return Ok(ParseResult::Reference(part_of)); + } + // attempt to parse a real one + let (config, errs) = biome_deserialize::json::deserialize_from_json_str::( + input, + JsonParserOptions::default().with_allow_comments(), + source, + ) + .consume(); + + if let Some(config) = config { + if config.version == "2" { + Ok(ParseResult::Actual(config)) + } else { + Err(Error::InvalidVersion { + expected: "2", + actual: config.version, + }) + } + } else { + Err(Error::biome_error(errs)) + } + } + + pub fn development_tasks(&self) -> impl Iterator)> { + self.applications + .iter() + .map(|(application, config)| (application.as_str(), config.task())) + } +} + +impl Application { + fn task(&self) -> Option<&str> { + self.development.as_ref()?.task.as_deref() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_child_config_parse() { + let input = r#"{"partOf": "web"}"#; + let config = ConfigV2::from_str(input, "somewhere").unwrap(); + match config { + ParseResult::Actual(_config_v2) => panic!("expected to get reference to default app"), + ParseResult::Reference(default_app) => { + assert_eq!(default_app, "web"); + } + } + } + + #[test] + fn test_root_config_parse() { + let input = r#"{ + "version": "2", + "applications": { + "web": {}, + "docs": {"development": {"task": "serve"}} + } + }"#; + let config = ConfigV2::from_str(input, "somewhere").unwrap(); + match config { + ParseResult::Actual(config_v2) => { + assert_eq!(config_v2.applications.get("web").unwrap().task(), None); + assert_eq!( + config_v2.applications.get("docs").unwrap().task(), + Some("serve") + ); + } + ParseResult::Reference(_) => panic!("expected to get main config"), + } + } +} diff --git a/crates/turborepo-micro-frontend/src/error.rs b/crates/turborepo-microfrontends/src/error.rs similarity index 74% rename from crates/turborepo-micro-frontend/src/error.rs rename to crates/turborepo-microfrontends/src/error.rs index 36354aeec2fb4..90984181bb09a 100644 --- a/crates/turborepo-micro-frontend/src/error.rs +++ b/crates/turborepo-microfrontends/src/error.rs @@ -13,6 +13,13 @@ pub enum Error { {SUPPORTED_VERSIONS:?}" )] UnsupportedVersion(String), + #[error("Configuration references config located in package {reference}")] + ChildConfig { reference: String }, + #[error("Cannot parse config with version '{actual}' as version '{expected}'")] + InvalidVersion { + expected: &'static str, + actual: String, + }, } impl Error { diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs new file mode 100644 index 0000000000000..58581faf6e273 --- /dev/null +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -0,0 +1,244 @@ +#![feature(assert_matches)] +#![deny(clippy::all)] +mod configv1; +mod configv2; +mod error; + +use std::io; + +use biome_deserialize_macros::Deserializable; +use biome_json_parser::JsonParserOptions; +use configv1::ConfigV1; +use configv2::ConfigV2; +pub use error::Error; +use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf}; + +/// Currently the default path for a package that provides a configuration. +/// +/// This is subject to change at any time. +pub const DEFAULT_MICROFRONTENDS_CONFIG_V1: &str = "micro-frontends.jsonc"; +pub const DEFAULT_MICROFRONTENDS_CONFIG_V2: &str = "microfrontends.json"; +pub const DEFAULT_MICROFRONTENDS_CONFIG_V2_ALT: &str = "microfrontends.jsonc"; +pub const MICROFRONTENDS_PACKAGES: &[&str] = [ + MICROFRONTENDS_PACKAGE_EXTERNAL, + MICROFRONTENDS_PACKAGE_INTERNAL, +] +.as_slice(); +pub const MICROFRONTENDS_PACKAGE_INTERNAL: &str = "@vercel/micro-frontends-internal"; +pub const MICROFRONTENDS_PACKAGE_EXTERNAL: &str = "@vercel/microfrontends"; +pub const SUPPORTED_VERSIONS: &[&str] = ["1", "2"].as_slice(); + +/// The minimal amount of information Turborepo needs to correctly start a local +/// proxy server for microfrontends +#[derive(Debug, PartialEq, Eq)] +pub struct Config { + inner: ConfigInner, + filename: String, +} + +#[derive(Debug, PartialEq, Eq)] +enum ConfigInner { + V1(ConfigV1), + V2(ConfigV2), +} + +impl Config { + /// Reads config from given path. + /// Returns `Ok(None)` if the file does not exist + pub fn load(config_path: &AbsoluteSystemPath) -> Result, Error> { + let Some(contents) = config_path.read_existing_to_string()? else { + return Ok(None); + }; + let config = Self::from_str(&contents, config_path.as_str())?; + Ok(Some(config)) + } + + pub fn load_from_dir(dir: &AbsoluteSystemPath) -> Result, Error> { + if let Some(config) = Self::load_v2_dir(dir)? { + Ok(Some(config)) + } else { + Self::load_v1_dir(dir) + } + } + + pub fn from_str(input: &str, source: &str) -> Result { + #[derive(Deserializable, Default)] + struct VersionOnly { + version: String, + } + let (version_only, _errs) = biome_deserialize::json::deserialize_from_json_str( + input, + JsonParserOptions::default().with_allow_comments(), + source, + ) + .consume(); + + let version = match version_only { + Some(VersionOnly { version }) => version, + // Default to version 2 if no version found + None => "2".to_string(), + }; + + let inner = match version.as_str() { + "1" => ConfigV1::from_str(input, source).map(ConfigInner::V1), + "2" => ConfigV2::from_str(input, source).and_then(|result| match result { + configv2::ParseResult::Actual(config_v2) => Ok(ConfigInner::V2(config_v2)), + configv2::ParseResult::Reference(default_app) => Err(Error::ChildConfig { + reference: default_app, + }), + }), + version => Err(Error::UnsupportedVersion(version.to_string())), + }?; + Ok(Self { + inner, + filename: source.to_owned(), + }) + } + + pub fn development_tasks<'a>(&'a self) -> Box)> + 'a> { + match &self.inner { + ConfigInner::V1(config_v1) => Box::new(config_v1.development_tasks()), + ConfigInner::V2(config_v2) => Box::new(config_v2.development_tasks()), + } + } + + /// Filename of the loaded configuration + pub fn filename(&self) -> &str { + &self.filename + } + + fn load_v2_dir(dir: &AbsoluteSystemPath) -> Result, Error> { + 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)) + }; + let Some((contents, path)) = load_config(DEFAULT_MICROFRONTENDS_CONFIG_V2) + .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V2_ALT)) + else { + return Ok(None); + }; + let contents = contents?; + + ConfigV2::from_str(&contents, path.as_str()) + .and_then(|result| match result { + configv2::ParseResult::Actual(config_v2) => Ok(Config { + inner: ConfigInner::V2(config_v2), + filename: path + .file_name() + .expect("microfrontends config should not be root") + .to_owned(), + }), + configv2::ParseResult::Reference(default_app) => Err(Error::ChildConfig { + reference: default_app, + }), + }) + .map(Some) + } + + fn load_v1_dir(dir: &AbsoluteSystemPath) -> Result, Error> { + let path = dir.join_component(DEFAULT_MICROFRONTENDS_CONFIG_V1); + let Some(contents) = path.read_existing_to_string()? else { + return Ok(None); + }; + + ConfigV1::from_str(&contents, path.as_str()) + .map(|config_v1| Self { + inner: ConfigInner::V1(config_v1), + filename: DEFAULT_MICROFRONTENDS_CONFIG_V1.to_owned(), + }) + .map(Some) + } +} + +#[cfg(test)] +mod test { + use std::assert_matches::assert_matches; + + use insta::assert_snapshot; + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_example_parses() { + let input = include_str!("../fixtures/sample.jsonc"); + let example_config = Config::from_str(input, "something.json"); + assert!(example_config.is_ok()); + } + + #[test] + fn test_unsupported_version() { + let input = r#"{"version": "yolo"}"#; + let err = Config::from_str(input, "something.json").unwrap_err(); + assert_snapshot!(err, @r###"Unsupported micro-frontends configuration version: yolo. Supported versions: ["1", "2"]"###); + } + + fn add_v1_config(dir: &AbsoluteSystemPath) -> Result<(), std::io::Error> { + let path = dir.join_component(DEFAULT_MICROFRONTENDS_CONFIG_V1); + path.create_with_contents(r#"{"version": "1", "applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#) + } + + fn add_v2_config(dir: &AbsoluteSystemPath) -> Result<(), std::io::Error> { + let path = dir.join_component(DEFAULT_MICROFRONTENDS_CONFIG_V2); + path.create_with_contents(r#"{"version": "2", "applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#) + } + + fn add_v2_alt_config(dir: &AbsoluteSystemPath) -> Result<(), std::io::Error> { + let path = dir.join_component(DEFAULT_MICROFRONTENDS_CONFIG_V2_ALT); + path.create_with_contents(r#"{"version": "2", "applications": {"web": {"development": {"task": "serve"}}, "docs": {}}}"#) + } + + #[test] + fn test_load_dir_v1() { + let dir = TempDir::new().unwrap(); + let path = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + add_v1_config(path).unwrap(); + let config = Config::load_from_dir(path) + .unwrap() + .map(|config| config.inner); + assert_matches!(config, Some(ConfigInner::V1(_))); + } + + #[test] + fn test_load_dir_v2() { + let dir = TempDir::new().unwrap(); + let path = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + add_v2_config(path).unwrap(); + let config = Config::load_from_dir(path) + .unwrap() + .map(|config| config.inner); + assert_matches!(config, Some(ConfigInner::V2(_))); + } + + #[test] + fn test_load_dir_both() { + let dir = TempDir::new().unwrap(); + let path = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + add_v1_config(path).unwrap(); + add_v2_config(path).unwrap(); + let config = Config::load_from_dir(path) + .unwrap() + .map(|config| config.inner); + assert_matches!(config, Some(ConfigInner::V2(_))); + } + + #[test] + fn test_load_dir_v2_alt() { + let dir = TempDir::new().unwrap(); + let path = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + add_v2_alt_config(path).unwrap(); + let config = Config::load_from_dir(path) + .unwrap() + .map(|config| config.inner); + assert_matches!(config, Some(ConfigInner::V2(_))); + } + + #[test] + fn test_load_dir_none() { + let dir = TempDir::new().unwrap(); + let path = AbsoluteSystemPath::new(dir.path().to_str().unwrap()).unwrap(); + assert!(Config::load_from_dir(path).unwrap().is_none()); + } +}