Skip to content

Commit 4f5f048

Browse files
feat: sibling tasks (#9504)
### Description This PR does 2 things: - Adds a `sibling` attribute for task definitions that has the ability to bring another task into the graph without depending on it. This is currently only an attribute that can be set by us inside Rust, but I hope this use case proves useful enough to eventually make this a public API. - Switches MFE proxy injection over to use `siblings` where each dev task gets a `"siblings": ["mfe-pkg#proxy"]` to ensure the proxy gets run. A sibling relationship acts similar to `dependsOn` except that it will not wait for the task to exit before starting and the sibling's task hash will not affect the current task's hash. Currently it's very hard to get `web#proxy` to run if the user runs a command like `turbo dev --filter=docs` as just adding `web` to the filter will mean `web#dev` gets picked up which isn't what we desire. As you can see in `run/builder.rs` this also removes the need of microfrontend knowledge from building the task graph. ### Testing Instructions Added unit tests for sibling behavior as well as unit tests for MFE task injection. Manual testing for the MFE behavior to make sure proxy still gets picked up. --------- Co-authored-by: Nicholas Yang <[email protected]>
1 parent e3fcdee commit 4f5f048

File tree

9 files changed

+405
-76
lines changed

9 files changed

+405
-76
lines changed

crates/turborepo-errors/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ impl<T> Spanned<T> {
176176
text: self.text,
177177
}
178178
}
179+
180+
/// Gets a mutable ref to the inner value
181+
pub fn as_inner_mut(&mut self) -> &mut T {
182+
&mut self.value
183+
}
179184
}
180185

181186
impl<T> Spanned<Option<T>> {

crates/turborepo-lib/src/engine/builder.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,19 @@ impl<'a> EngineBuilder<'a> {
395395
}
396396
});
397397

398+
for (sibling, span) in task_definition
399+
.siblings
400+
.iter()
401+
.flatten()
402+
.map(|s| s.as_ref().split())
403+
{
404+
let sibling_task_id = sibling
405+
.task_id()
406+
.unwrap_or_else(|| TaskId::new(to_task_id.package(), sibling.task()))
407+
.into_owned();
408+
traversal_queue.push_back(span.to(sibling_task_id));
409+
}
410+
398411
for (dep, span) in deps {
399412
let from_task_id = dep
400413
.task_id()
@@ -1333,6 +1346,44 @@ mod test {
13331346
assert_eq!(all_dependencies(&engine), expected);
13341347
}
13351348

1349+
#[test]
1350+
fn test_sibling_task() {
1351+
let repo_root_dir = TempDir::with_prefix("repo").unwrap();
1352+
let repo_root = AbsoluteSystemPathBuf::new(repo_root_dir.path().to_str().unwrap()).unwrap();
1353+
let package_graph = mock_package_graph(
1354+
&repo_root,
1355+
package_jsons! {
1356+
repo_root,
1357+
"web" => [],
1358+
"api" => []
1359+
},
1360+
);
1361+
let turbo_jsons = vec![(PackageName::Root, {
1362+
let mut t_json = turbo_json(json!({
1363+
"tasks": {
1364+
"web#dev": { "persistent": true },
1365+
"api#serve": { "persistent": true }
1366+
}
1367+
}));
1368+
t_json.with_sibling(TaskName::from("web#dev"), &TaskName::from("api#serve"));
1369+
t_json
1370+
})]
1371+
.into_iter()
1372+
.collect();
1373+
let loader = TurboJsonLoader::noop(turbo_jsons);
1374+
let engine = EngineBuilder::new(&repo_root, &package_graph, loader, false)
1375+
.with_tasks(Some(Spanned::new(TaskName::from("dev"))))
1376+
.with_workspaces(vec![PackageName::from("web")])
1377+
.build()
1378+
.unwrap();
1379+
1380+
let expected = deps! {
1381+
"web#dev" => ["___ROOT___"],
1382+
"api#serve" => ["___ROOT___"]
1383+
};
1384+
assert_eq!(all_dependencies(&engine), expected);
1385+
}
1386+
13361387
#[test]
13371388
fn test_run_package_task_exact_error() {
13381389
let repo_root_dir = TempDir::with_prefix("repo").unwrap();

crates/turborepo-lib/src/micro_frontends.rs

Lines changed: 202 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
use std::collections::{HashMap, HashSet};
22

3+
use itertools::Itertools;
34
use tracing::warn;
45
use turbopath::AbsoluteSystemPath;
5-
use turborepo_micro_frontend::{Config as MFEConfig, Error, DEFAULT_MICRO_FRONTENDS_CONFIG};
6-
use turborepo_repository::package_graph::PackageGraph;
6+
use turborepo_micro_frontend::{
7+
Config as MFEConfig, Error, DEFAULT_MICRO_FRONTENDS_CONFIG, MICRO_FRONTENDS_PACKAGES,
8+
};
9+
use turborepo_repository::package_graph::{PackageGraph, PackageName};
710

8-
use crate::run::task_id::TaskId;
11+
use crate::{
12+
config,
13+
run::task_id::{TaskId, TaskName},
14+
turbo_json::TurboJson,
15+
};
916

1017
#[derive(Debug, Clone)]
1118
pub struct MicroFrontendsConfigs {
1219
configs: HashMap<String, HashSet<TaskId<'static>>>,
20+
mfe_package: Option<&'static str>,
1321
}
1422

1523
impl MicroFrontendsConfigs {
@@ -44,7 +52,23 @@ impl MicroFrontendsConfigs {
4452
configs.insert(package_name.to_string(), tasks);
4553
}
4654

47-
Ok((!configs.is_empty()).then_some(Self { configs }))
55+
let mfe_package = package_graph
56+
.packages()
57+
.map(|(pkg, _)| pkg.as_str())
58+
.sorted()
59+
// We use `find_map` here instead of a simple `find` so we get the &'static str
60+
// instead of the &str tied to the lifetime of the package graph.
61+
.find_map(|pkg| {
62+
MICRO_FRONTENDS_PACKAGES
63+
.iter()
64+
.find(|static_pkg| pkg == **static_pkg)
65+
})
66+
.copied();
67+
68+
Ok((!configs.is_empty()).then_some(Self {
69+
configs,
70+
mfe_package,
71+
}))
4872
}
4973

5074
pub fn contains_package(&self, package_name: &str) -> bool {
@@ -64,4 +88,178 @@ impl MicroFrontendsConfigs {
6488
.values()
6589
.any(|dev_tasks| dev_tasks.contains(task_id))
6690
}
91+
92+
pub fn update_turbo_json(
93+
&self,
94+
package_name: &PackageName,
95+
turbo_json: Result<TurboJson, config::Error>,
96+
) -> Result<TurboJson, config::Error> {
97+
// If package either
98+
// - contains the proxy task
99+
// - a member of one of the microfrontends
100+
// then we need to modify its task definitions
101+
if let Some(FindResult { dev, proxy }) = self.package_turbo_json_update(package_name) {
102+
// We need to modify turbo.json, use default one if there isn't one present
103+
let mut turbo_json = turbo_json.or_else(|err| match err {
104+
config::Error::NoTurboJSON => Ok(TurboJson::default()),
105+
err => Err(err),
106+
})?;
107+
108+
// If the current package contains the proxy task, then add that definition
109+
if proxy.package() == package_name.as_str() {
110+
turbo_json.with_proxy(self.mfe_package);
111+
}
112+
113+
if let Some(dev) = dev {
114+
// If this package has a dev task that's part of the MFE, then we make sure the
115+
// proxy gets included in the task graph.
116+
turbo_json.with_sibling(
117+
TaskName::from(dev.task()).into_owned(),
118+
&proxy.as_task_name(),
119+
);
120+
}
121+
122+
Ok(turbo_json)
123+
} else {
124+
turbo_json
125+
}
126+
}
127+
128+
fn package_turbo_json_update<'a>(
129+
&'a self,
130+
package_name: &PackageName,
131+
) -> Option<FindResult<'a>> {
132+
self.configs().find_map(|(config, tasks)| {
133+
let dev_task = tasks.iter().find_map(|task| {
134+
(task.package() == package_name.as_str()).then(|| FindResult {
135+
dev: Some(task.as_borrowed()),
136+
proxy: TaskId::new(config, "proxy"),
137+
})
138+
});
139+
let proxy_owner = (config.as_str() == package_name.as_str()).then(|| FindResult {
140+
dev: None,
141+
proxy: TaskId::new(config, "proxy"),
142+
});
143+
dev_task.or(proxy_owner)
144+
})
145+
}
146+
}
147+
148+
#[derive(Debug, PartialEq, Eq)]
149+
struct FindResult<'a> {
150+
dev: Option<TaskId<'a>>,
151+
proxy: TaskId<'a>,
152+
}
153+
154+
#[cfg(test)]
155+
mod test {
156+
use test_case::test_case;
157+
158+
use super::*;
159+
160+
macro_rules! mfe_configs {
161+
{$($config_owner:expr => $dev_tasks:expr),+} => {
162+
{
163+
let mut _map = std::collections::HashMap::new();
164+
$(
165+
let mut _dev_tasks = std::collections::HashSet::new();
166+
for _dev_task in $dev_tasks.as_slice() {
167+
_dev_tasks.insert(crate::run::task_id::TaskName::from(*_dev_task).task_id().unwrap().into_owned());
168+
}
169+
_map.insert($config_owner.to_string(), _dev_tasks);
170+
)+
171+
_map
172+
}
173+
};
174+
}
175+
176+
struct PackageUpdateTest {
177+
package_name: &'static str,
178+
result: Option<TestFindResult>,
179+
}
180+
181+
struct TestFindResult {
182+
dev: Option<&'static str>,
183+
proxy: &'static str,
184+
}
185+
186+
impl PackageUpdateTest {
187+
pub const fn new(package_name: &'static str) -> Self {
188+
Self {
189+
package_name,
190+
result: None,
191+
}
192+
}
193+
194+
pub const fn dev(mut self, dev: &'static str, proxy: &'static str) -> Self {
195+
self.result = Some(TestFindResult {
196+
dev: Some(dev),
197+
proxy,
198+
});
199+
self
200+
}
201+
202+
pub const fn proxy_only(mut self, proxy: &'static str) -> Self {
203+
self.result = Some(TestFindResult { dev: None, proxy });
204+
self
205+
}
206+
207+
pub fn package_name(&self) -> PackageName {
208+
PackageName::from(self.package_name)
209+
}
210+
211+
pub fn expected(&self) -> Option<FindResult> {
212+
match self.result {
213+
Some(TestFindResult {
214+
dev: Some(dev),
215+
proxy,
216+
}) => Some(FindResult {
217+
dev: Some(Self::str_to_task(dev)),
218+
proxy: Self::str_to_task(proxy),
219+
}),
220+
Some(TestFindResult { dev: None, proxy }) => Some(FindResult {
221+
dev: None,
222+
proxy: Self::str_to_task(proxy),
223+
}),
224+
None => None,
225+
}
226+
}
227+
228+
fn str_to_task(s: &str) -> TaskId<'static> {
229+
crate::run::task_id::TaskName::from(s)
230+
.task_id()
231+
.unwrap()
232+
.into_owned()
233+
}
234+
}
235+
236+
const NON_MFE_PKG: PackageUpdateTest = PackageUpdateTest::new("other-pkg");
237+
const MFE_CONFIG_PKG: PackageUpdateTest =
238+
PackageUpdateTest::new("mfe-config-pkg").proxy_only("mfe-config-pkg#proxy");
239+
const MFE_CONFIG_PKG_DEV_TASK: PackageUpdateTest =
240+
PackageUpdateTest::new("web").dev("web#dev", "mfe-config-pkg#proxy");
241+
const DEFAULT_APP_PROXY: PackageUpdateTest =
242+
PackageUpdateTest::new("mfe-docs").dev("mfe-docs#serve", "mfe-web#proxy");
243+
const DEFAULT_APP_PROXY_AND_DEV: PackageUpdateTest =
244+
PackageUpdateTest::new("mfe-web").dev("mfe-web#dev", "mfe-web#proxy");
245+
246+
#[test_case(NON_MFE_PKG)]
247+
#[test_case(MFE_CONFIG_PKG)]
248+
#[test_case(MFE_CONFIG_PKG_DEV_TASK)]
249+
#[test_case(DEFAULT_APP_PROXY)]
250+
#[test_case(DEFAULT_APP_PROXY_AND_DEV)]
251+
fn test_package_turbo_json_update(test: PackageUpdateTest) {
252+
let configs = mfe_configs!(
253+
"mfe-config-pkg" => ["web#dev", "docs#dev"],
254+
"mfe-web" => ["mfe-web#dev", "mfe-docs#serve"]
255+
);
256+
let mfe = MicroFrontendsConfigs {
257+
configs,
258+
mfe_package: None,
259+
};
260+
assert_eq!(
261+
mfe.package_turbo_json_update(&test.package_name()),
262+
test.expected()
263+
);
264+
}
67265
}

0 commit comments

Comments
 (0)