Skip to content

Commit

Permalink
feat(mfe): support user specified local proxy (#9695)
Browse files Browse the repository at this point in the history
### Description

Adds support for users specifying a custom proxy command instead of the
default MFE one. This is done by having a `proxy` script on the default
application.

The interface the proxy must implement:
 - First argument is the path to the `microfrontends.json` file
- Next arguments are `--name` and a list of applications currently being
run. These will be a subset of the `applications` section in the passed
configuration file.

The schema that Turborepo will require of `microfrontends.json` is very
permissive to allow for extensibility:
 - `version?: string` field can be omitted or must be `"2"` explicitly
- `applications: {[string]: { dev?: string, local?: { port:?: number } }
}`: keys must match the `name`s in `package.json`

To use the provided `@turbo/proxy` implementation, `microfrontends.json`
will need to match the existing MFE schema.

### Testing Instructions

Some unit tests. A quick sample repo with following changes from a fresh
`create-turbo`:
```
[0 olszewski@chriss-mbp] /tmp/turborepo-next-basic $ git diff HEAD^1
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 78f191e..a976901 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -4,7 +4,7 @@
   "type": "module",
   "private": true,
   "scripts": {
-    "dev": "next dev --turbopack",
+    "dev": "next dev --turbopack --port=${TURBO_PORT:-3001}",
     "build": "next build",
     "start": "next start",
     "lint": "next lint --max-warnings 0",
diff --git a/apps/web/microfrontends.json b/apps/web/microfrontends.json
new file mode 100644
index 0000000..b940ac9
--- /dev/null
+++ b/apps/web/microfrontends.json
@@ -0,0 +1,6 @@
+{
+  "applications": {
+    "web": {},
+    "docs": {}
+  }
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 8016401..50f985d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -4,11 +4,12 @@
   "type": "module",
   "private": true,
   "scripts": {
-    "dev": "next dev --turbopack",
+    "dev": "next dev --turbopack --port=${TURBO_PORT:-3000}",
     "build": "next build",
     "start": "next start",
     "lint": "next lint --max-warnings 0",
-    "check-types": "tsc --noEmit"
+    "check-types": "tsc --noEmit",
+    "proxy": "./proxy.sh"
   },
   "dependencies": {
     "@repo/ui": "workspace:*",
diff --git a/apps/web/proxy.sh b/apps/web/proxy.sh
new file mode 100755
index 0000000..6094f03
--- /dev/null
+++ b/apps/web/proxy.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+
+echo "Proxy ran with $@"
+cat
```

Now if we run the dev task:
```
[0 olszewski@chriss-mbp] /tmp/turborepo-next-basic $ turbo_dev --skip-infer dev
turbo 2.3.4-canary.6

• Packages in scope: @repo/eslint-config, @repo/typescript-config, @repo/ui, docs, web
• Running dev in 5 packages
• Remote caching disabled
┌ web#proxy > cache bypass, force executing 257fe50862891df3 
│ 
│ 
│ > [email protected] proxy /private/tmp/turborepo-next-basic/apps/web
│ > ./proxy.sh "/private/tmp/turborepo-next-basic/apps/web/microfrontends.json" "--names" "docs" "web"
│ 
│ Proxy ran with /private/tmp/turborepo-next-basic/apps/web/microfrontends.json --names docs web
└────>
┌ web#dev > cache bypass, force executing ab1756f2a3090b20 
│ 
│ > [email protected] dev /private/tmp/turborepo-next-basic/apps/web
│ > next dev --turbopack --port=${TURBO_PORT:-3000}
│ 
│    ▲ Next.js 15.1.0 (Turbopack)
│    - Local:        http://localhost:5588
│    - Network:      http://192.168.86.46:5588
│ 
│  ✓ Starting...
│  ✓ Ready in 670ms
└────>
┌ docs#dev > cache bypass, force executing 30705dbd6c0968b4 
│ 
│ > [email protected] dev /private/tmp/turborepo-next-basic/apps/docs
│ > next dev --turbopack --port=${TURBO_PORT:-3001}
│ 
│    ▲ Next.js 15.1.0 (Turbopack)
│    - Local:        http://localhost:6955
│    - Network:      http://192.168.86.46:6955
│ 
│  ✓ Starting...
│  ✓ Ready in 671ms
└────>
```
 Things to take note of:
- First arg is the path to the `microfrontends.json` configuration file.
- `--names` arg for the proxy matches the names of the applications that
have dev tasks running
- The dev tasks got ran with `TURBO_PORT` which was derived from the
application name
  • Loading branch information
chris-olszewski authored Jan 27, 2025
1 parent 0de8cf4 commit 9cca183
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 20 deletions.
66 changes: 57 additions & 9 deletions crates/turborepo-lib/src/microfrontends.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub struct MicrofrontendsConfigs {
#[derive(Debug, Clone, Default, PartialEq)]
struct ConfigInfo {
tasks: HashSet<TaskId<'static>>,
ports: HashMap<TaskId<'static>, u16>,
version: &'static str,
path: Option<RelativeUnixPathBuf>,
}
Expand Down Expand Up @@ -92,6 +93,12 @@ impl MicrofrontendsConfigs {
Some(path)
}

pub fn dev_task_port(&self, task_id: &TaskId) -> Option<u16> {
self.configs
.values()
.find_map(|config| config.ports.get(task_id).copied())
}

pub fn update_turbo_json(
&self,
package_name: &PackageName,
Expand Down Expand Up @@ -242,18 +249,21 @@ struct FindResult<'a> {

impl ConfigInfo {
fn new(config: &MFEConfig) -> Self {
let tasks = config
.development_tasks()
.map(|(application, options)| {
let dev_task = options.unwrap_or("dev");
TaskId::new(application, dev_task).into_owned()
})
.collect();
let mut ports = HashMap::new();
let mut tasks = HashSet::new();
for (application, dev_task) in config.development_tasks() {
let task = TaskId::new(application, dev_task.unwrap_or("dev")).into_owned();
if let Some(port) = config.port(application) {
ports.insert(task.clone(), port);
}
tasks.insert(task);
}
let version = config.version();

Self {
tasks,
version,
ports,
path: None,
}
}
Expand All @@ -275,7 +285,7 @@ mod test {
for _dev_task in $dev_tasks.as_slice() {
_dev_tasks.insert(crate::run::task_id::TaskName::from(*_dev_task).task_id().unwrap().into_owned());
}
_map.insert($config_owner.to_string(), ConfigInfo { tasks: _dev_tasks, version: "1", path: None });
_map.insert($config_owner.to_string(), ConfigInfo { tasks: _dev_tasks, version: "1", path: None, ports: std::collections::HashMap::new() });
)+
_map
}
Expand Down Expand Up @@ -432,7 +442,12 @@ mod test {
"something.txt",
)
.unwrap();
let result = PackageGraphResult::new(vec![("web", Ok(Some(config)))].into_iter()).unwrap();
let mut result =
PackageGraphResult::new(vec![("web", Ok(Some(config)))].into_iter()).unwrap();
result
.configs
.values_mut()
.for_each(|config| config.ports.clear());
assert_eq!(
result.configs,
mfe_configs!(
Expand All @@ -441,6 +456,39 @@ mod test {
)
}

#[test]
fn test_port_collection() {
let config = MFEConfig::from_str(
&serde_json::to_string_pretty(&json!({
"version": "1",
"applications": {
"web": {},
"docs": {
"development": {
"task": "serve",
"local": {
"port": 3030
}
}
}
}
}))
.unwrap(),
"something.txt",
)
.unwrap();
let result = PackageGraphResult::new(vec![("web", Ok(Some(config)))].into_iter()).unwrap();
let web_ports = result.configs["web"].ports.clone();
assert_eq!(
web_ports.get(&TaskId::new("docs", "serve")).copied(),
Some(3030)
);
assert_eq!(
web_ports.get(&TaskId::new("web", "dev")).copied(),
Some(5588)
);
}

#[test]
fn test_configs_added_as_global_deps() {
let configs = MicrofrontendsConfigs {
Expand Down
48 changes: 39 additions & 9 deletions crates/turborepo-lib/src/task_graph/visitor/command.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{collections::HashSet, path::PathBuf};

use tracing::debug;
use turbopath::AbsoluteSystemPath;
use turborepo_env::EnvironmentVariableMap;
use turborepo_microfrontends::MICROFRONTENDS_PACKAGE;
Expand Down Expand Up @@ -137,6 +138,13 @@ impl<'a> CommandProvider for PackageGraphCommandProvider<'a> {
{
cmd.env("TURBO_TASK_HAS_MFE_PROXY", "true");
}
if let Some(port) = self
.mfe_configs
.and_then(|mfe_configs| mfe_configs.dev_task_port(task_id))
{
debug!("Found port {port} for {task_id}");
cmd.env("TURBO_PORT", port.to_string());
}

// We always open stdin and the visitor will close it depending on task
// configuration
Expand Down Expand Up @@ -191,6 +199,11 @@ impl<'a> MicroFrontendProxyProvider<'a> {
task_id: task_id.clone().into_owned(),
})
}

fn has_custom_proxy(&self, task_id: &TaskId) -> Result<bool, Error> {
let package_info = self.package_info(task_id)?;
Ok(package_info.package_json.scripts.contains_key("proxy"))
}
}

impl<'a> CommandProvider for MicroFrontendProxyProvider<'a> {
Expand All @@ -202,13 +215,13 @@ impl<'a> CommandProvider for MicroFrontendProxyProvider<'a> {
let Some(dev_tasks) = self.dev_tasks(task_id) else {
return Ok(None);
};
let has_custom_proxy = self.has_custom_proxy(task_id)?;
let package_info = self.package_info(task_id)?;
let has_mfe_dependency = package_info
.package_json
.all_dependencies()
.any(|(package, _version)| package.as_str() == MICROFRONTENDS_PACKAGE);

if !has_mfe_dependency {
if !has_mfe_dependency && !has_custom_proxy {
let mfe_config_filename = self.mfe_configs.config_filename(task_id.package());
return Err(Error::MissingMFEDependency {
package: task_id.package().into(),
Expand All @@ -227,13 +240,30 @@ impl<'a> CommandProvider for MicroFrontendProxyProvider<'a> {
.config_filename(task_id.package())
.expect("every microfrontends default application should have configuration path");
let mfe_path = self.repo_root.join_unix_path(mfe_config_filename);
let mut args = vec!["proxy", mfe_path.as_str(), "--names"];
args.extend(local_apps);

// TODO: leverage package manager to find the local proxy
let program = package_dir.join_components(&["node_modules", ".bin", "microfrontends"]);
let mut cmd = Command::new(program.as_std_path());
cmd.current_dir(package_dir).args(args).open_stdin();
let cmd = if has_custom_proxy {
let package_manager = self.package_graph.package_manager();
let mut proxy_args = vec![mfe_path.as_str(), "--names"];
proxy_args.extend(local_apps);
let mut args = vec!["run", "proxy"];
if let Some(sep) = package_manager.arg_separator(&proxy_args) {
args.push(sep);
}
args.extend(proxy_args);

let program = which::which(package_manager.command())?;
let mut cmd = Command::new(&program);
cmd.current_dir(package_dir).args(args).open_stdin();
cmd
} else {
let mut args = vec!["proxy", mfe_path.as_str(), "--names"];
args.extend(local_apps);

// TODO: leverage package manager to find the local proxy
let program = package_dir.join_components(&["node_modules", ".bin", "microfrontends"]);
let mut cmd = Command::new(program.as_std_path());
cmd.current_dir(package_dir).args(args).open_stdin();
cmd
};

Ok(Some(cmd))
}
Expand Down
51 changes: 51 additions & 0 deletions crates/turborepo-microfrontends/src/configv1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ struct Application {
#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)]
struct Development {
task: Option<String>,
local: Option<LocalHost>,
}

#[derive(Debug, PartialEq, Eq, Serialize, Deserializable, Default)]
struct LocalHost {
port: Option<u16>,
}

impl ConfigV1 {
Expand Down Expand Up @@ -73,18 +79,58 @@ impl ConfigV1 {
.iter()
.map(|(application, config)| (application.as_str(), config.task()))
}

pub fn port(&self, name: &str) -> Option<u16> {
let application = self.applications.get(name)?;
Some(application.port(name))
}
}

impl Application {
fn task(&self) -> Option<&str> {
self.development.as_ref()?.task.as_deref()
}

fn user_port(&self) -> Option<u16> {
self.development.as_ref()?.local.as_ref()?.port
}

fn port(&self, name: &str) -> u16 {
self.user_port()
.unwrap_or_else(|| generate_port_from_name(name))
}
}

const MIN_PORT: u16 = 3000;
const MAX_PORT: u16 = 8000;
const PORT_RANGE: u16 = MAX_PORT - MIN_PORT;

fn generate_port_from_name(name: &str) -> u16 {
let mut hash: i32 = 0;
for c in name.chars() {
let code = i32::try_from(u32::from(c)).expect("char::MAX is less than 2^31");
hash = (hash << 5).overflowing_sub(hash).0.overflowing_add(code).0;
}
let hash = hash.abs_diff(0);
let port = hash % u32::from(PORT_RANGE);
MIN_PORT + u16::try_from(port).expect("u32 modulo a u16 number will be a valid u16")
}

#[cfg(test)]
mod test {
use std::char;

use super::*;

#[test]
fn test_char_as_i32() {
let max_char = u32::from(char::MAX);
assert!(
i32::try_from(max_char).is_ok(),
"max char should fit in i32"
);
}

#[test]
fn test_child_config_parse() {
let input = r#"{"partOf": "web"}"#;
Expand Down Expand Up @@ -139,4 +185,9 @@ mod test {
ParseResult::Reference(_) => panic!("expected to get main config"),
}
}

#[test]
fn test_generate_port() {
assert_eq!(generate_port_from_name("test-450"), 7724);
}
}
6 changes: 6 additions & 0 deletions crates/turborepo-microfrontends/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ impl Config {
}
}

pub fn port(&self, name: &str) -> Option<u16> {
match &self.inner {
ConfigInner::V1(config_v1) => config_v1.port(name),
}
}

/// Filename of the loaded configuration
pub fn filename(&self) -> &str {
&self.filename
Expand Down
4 changes: 2 additions & 2 deletions crates/turborepo-repository/src/package_manager/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -534,14 +534,14 @@ impl PackageManager {
turbo_root.join_component(self.lockfile_name())
}

pub fn arg_separator(&self, user_args: &[String]) -> Option<&str> {
pub fn arg_separator(&self, user_args: &[impl AsRef<str>]) -> Option<&str> {
match self {
PackageManager::Yarn | PackageManager::Bun => {
// Yarn and bun warn and swallows a "--" token. If the user is passing "--", we
// need to prepend our own so that the user's doesn't get
// swallowed. If they are not passing their own, we don't need
// the "--" token and can avoid the warning.
if user_args.iter().any(|arg| arg == "--") {
if user_args.iter().any(|arg| arg.as_ref() == "--") {
Some("--")
} else {
None
Expand Down

0 comments on commit 9cca183

Please sign in to comment.