Skip to content

Commit dc16ef4

Browse files
feat: Add support for custom microfrontends.json naming (#11022)
### Description Allowing users to customize the name of their microfrontends file using a `VC_MICROFRONTENDS_CONFIG_FILE_NAME` environment variable. Only accepts file names at the root of the package ### Testing Instructions Added some tests and make sure it works manually. --------- Co-authored-by: Anthony Shew <anthonyshew@gmail.com>
1 parent f226417 commit dc16ef4

File tree

3 files changed

+115
-16
lines changed

3 files changed

+115
-16
lines changed

crates/turborepo-lib/src/task_hash.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ impl<'a> TaskHasher<'a> {
478478
"NEXT_*",
479479
"USE_OUTPUT_FOR_EDGE_FUNCTIONS",
480480
"NOW_BUILDER",
481+
"VC_MICROFRONTENDS_CONFIG_FILE_NAME",
481482
// Command Prompt casing of env variables
482483
"APPDATA",
483484
"PATH",

crates/turborepo-microfrontends/src/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ pub enum Error {
3434
},
3535
#[error("Invalid package path: {0}. Path traversal outside repository root is not allowed.")]
3636
PathTraversal(String),
37+
#[error(
38+
"Invalid custom config file name: {0}. Must be a .json or .jsonc file directly in the \
39+
package root (no subdirectories or path traversal)."
40+
)]
41+
InvalidCustomConfigPath(String),
3742
}
3843

3944
impl Error {

crates/turborepo-microfrontends/src/lib.rs

Lines changed: 109 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ mod configv1;
2626
mod error;
2727
mod schema;
2828

29-
use std::io;
30-
3129
use configv1::ConfigV1;
3230
pub use configv1::PathGroup;
3331
pub use error::Error;
@@ -43,6 +41,7 @@ pub const DEFAULT_MICROFRONTENDS_CONFIG_V1: &str = "microfrontends.json";
4341
pub const DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT: &str = "microfrontends.jsonc";
4442
pub const MICROFRONTENDS_PACKAGE: &str = "@vercel/microfrontends";
4543
pub const SUPPORTED_VERSIONS: &[&str] = ["1"].as_slice();
44+
pub const CUSTOM_CONFIG_ENV_VAR: &str = "VC_MICROFRONTENDS_CONFIG_FILE_NAME";
4645

4746
/// Strict Turborepo-only configuration for the microfrontends proxy.
4847
/// This configuration parser only accepts fields that Turborepo's native proxy
@@ -57,6 +56,60 @@ pub struct TurborepoMfeConfig {
5756
}
5857

5958
impl TurborepoMfeConfig {
59+
/// Validates a custom config filename from environment variable.
60+
/// Returns error if:
61+
/// - Path contains ".." (attempting to traverse up)
62+
/// - Path does not end with ".json" or ".jsonc"
63+
/// - Path starts with "/" (must be relative)
64+
/// - Path contains "/" or "\" (no subdirectories allowed)
65+
fn validate_custom_config_name(filename: &str) -> Result<(), Error> {
66+
// Must end with .json or .jsonc
67+
if !filename.ends_with(".json") && !filename.ends_with(".jsonc") {
68+
return Err(Error::InvalidCustomConfigPath(format!(
69+
"{filename}: must be a JSON file ending with .json or .jsonc"
70+
)));
71+
}
72+
73+
// Must not contain directory separators (no subdirectories)
74+
if filename.contains('/') || filename.contains('\\') {
75+
return Err(Error::InvalidCustomConfigPath(format!(
76+
"{filename}: subdirectories not allowed, file must be in package root"
77+
)));
78+
}
79+
80+
// Must not contain path traversal
81+
if filename.contains("..") {
82+
return Err(Error::InvalidCustomConfigPath(format!(
83+
"{filename}: path traversal not allowed"
84+
)));
85+
}
86+
87+
// Must be relative (not start with /)
88+
if filename.starts_with('/') {
89+
return Err(Error::InvalidCustomConfigPath(format!(
90+
"{filename}: must be relative to package root"
91+
)));
92+
}
93+
94+
Ok(())
95+
}
96+
97+
/// Gets the custom config filename from environment variable if set.
98+
/// Returns None if not set, or Error if invalid.
99+
fn get_custom_config_name() -> Result<Option<String>, Error> {
100+
match std::env::var(CUSTOM_CONFIG_ENV_VAR) {
101+
Ok(filename) if !filename.is_empty() => {
102+
Self::validate_custom_config_name(&filename)?;
103+
Ok(Some(filename))
104+
}
105+
Ok(_) => Ok(None), // Empty string means not set
106+
Err(std::env::VarError::NotPresent) => Ok(None),
107+
Err(std::env::VarError::NotUnicode(_)) => Err(Error::InvalidCustomConfigPath(
108+
"environment variable contains invalid UTF-8".to_string(),
109+
)),
110+
}
111+
}
112+
60113
/// Reads config from given path using strict Turborepo schema.
61114
/// Returns `Ok(None)` if the file does not exist
62115
pub fn load(config_path: &AbsoluteSystemPath) -> Result<Option<Self>, Error> {
@@ -89,10 +142,9 @@ impl TurborepoMfeConfig {
89142

90143
Config::validate_package_path(repo_root, &absolute_dir)?;
91144

92-
let Some((contents, path)) = Self::load_v1_dir(&absolute_dir) else {
145+
let Some((contents, path)) = Self::load_v1_dir(&absolute_dir)? else {
93146
return Ok(None);
94147
};
95-
let contents = contents?;
96148
let mut config = Self::from_str_with_mfe_dep(&contents, path.as_str(), has_mfe_dependency)?;
97149
config.filename = path
98150
.file_name()
@@ -200,15 +252,36 @@ impl TurborepoMfeConfig {
200252

201253
fn load_v1_dir(
202254
dir: &AbsoluteSystemPath,
203-
) -> Option<(Result<String, io::Error>, AbsoluteSystemPathBuf)> {
255+
) -> Result<Option<(String, AbsoluteSystemPathBuf)>, Error> {
204256
let load_config =
205-
|filename: &str| -> Option<(Result<String, io::Error>, AbsoluteSystemPathBuf)> {
257+
|filename: &str| -> Option<(Result<String, Error>, AbsoluteSystemPathBuf)> {
206258
let path = dir.join_component(filename);
207-
let contents = path.read_existing_to_string().transpose()?;
259+
let contents = path
260+
.read_existing_to_string()
261+
.transpose()?
262+
.map_err(Error::from);
208263
Some((contents, path))
209264
};
210-
load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1)
211-
.or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT))
265+
266+
// First check if custom config is specified via environment variable
267+
match Self::get_custom_config_name()? {
268+
Some(custom_name) => {
269+
// If environment variable is set, only try that path
270+
let Some((contents, path)) = load_config(&custom_name) else {
271+
return Ok(None);
272+
};
273+
Ok(Some((contents?, path)))
274+
}
275+
None => {
276+
// Otherwise use default config file names
277+
let Some((contents, path)) = load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1)
278+
.or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT))
279+
else {
280+
return Ok(None);
281+
};
282+
Ok(Some((contents?, path)))
283+
}
284+
}
212285
}
213286

214287
pub fn set_path(&mut self, dir: &AnchoredSystemPath) {
@@ -300,10 +373,9 @@ impl Config {
300373
Self::validate_package_path(repo_root, &absolute_dir)?;
301374

302375
// we want to try different paths and then do `from_str`
303-
let Some((contents, path)) = Self::load_v1_dir(&absolute_dir) else {
376+
let Some((contents, path)) = Self::load_v1_dir(&absolute_dir)? else {
304377
return Ok(None);
305378
};
306-
let contents = contents?;
307379
let mut config = Config::from_str(&contents, path.as_str())?;
308380
config.filename = path
309381
.file_name()
@@ -390,15 +462,36 @@ impl Config {
390462

391463
fn load_v1_dir(
392464
dir: &AbsoluteSystemPath,
393-
) -> Option<(Result<String, io::Error>, AbsoluteSystemPathBuf)> {
465+
) -> Result<Option<(String, AbsoluteSystemPathBuf)>, Error> {
394466
let load_config =
395-
|filename: &str| -> Option<(Result<String, io::Error>, AbsoluteSystemPathBuf)> {
467+
|filename: &str| -> Option<(Result<String, Error>, AbsoluteSystemPathBuf)> {
396468
let path = dir.join_component(filename);
397-
let contents = path.read_existing_to_string().transpose()?;
469+
let contents = path
470+
.read_existing_to_string()
471+
.transpose()?
472+
.map_err(Error::from);
398473
Some((contents, path))
399474
};
400-
load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1)
401-
.or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT))
475+
476+
// First check if custom config is specified via environment variable
477+
match TurborepoMfeConfig::get_custom_config_name()? {
478+
Some(custom_name) => {
479+
// If environment variable is set, only try that path
480+
let Some((contents, path)) = load_config(&custom_name) else {
481+
return Ok(None);
482+
};
483+
Ok(Some((contents?, path)))
484+
}
485+
None => {
486+
// Otherwise use default config file names
487+
let Some((contents, path)) = load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1)
488+
.or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT))
489+
else {
490+
return Ok(None);
491+
};
492+
Ok(Some((contents?, path)))
493+
}
494+
}
402495
}
403496

404497
/// Sets the path the configuration was loaded from

0 commit comments

Comments
 (0)