Skip to content

Commit e426ba7

Browse files
committed
fix(workspace): support object-form workspaces in package.json
Bun and Yarn classic support `workspaces` as an object with a `packages` field (e.g., for Bun catalogs), not just an array. Add an untagged enum to handle both forms during deserialization. Closes voidzero-dev/vite-plus#1247
1 parent 1ef4e2f commit e426ba7

1 file changed

Lines changed: 142 additions & 7 deletions

File tree

  • crates/vite_workspace/src

crates/vite_workspace/src/lib.rs

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,41 @@ struct PnpmWorkspace {
3333
packages: Vec<Str>,
3434
}
3535

36-
/// The workspace configuration for npm/yarn.
36+
/// The `workspaces` field in package.json can be either an array of glob patterns
37+
/// or an object with a `packages` field (used by Bun catalogs and Yarn classic nohoist).
38+
///
39+
/// Array form: `"workspaces": ["packages/*", "apps/*"]`
40+
/// Object form: `"workspaces": {"packages": ["packages/*", "apps/*"], "catalog": {...}}`
41+
///
42+
/// Bun: <https://bun.sh/docs/install/workspaces>
43+
/// Yarn classic: <https://classic.yarnpkg.com/en/docs/workspaces/>
44+
#[derive(Debug, Deserialize)]
45+
#[serde(untagged)]
46+
enum NpmWorkspaces {
47+
/// Array of glob patterns (npm, yarn, bun).
48+
Array(Vec<Str>),
49+
/// Object form with a `packages` field (Bun catalogs, Yarn classic nohoist).
50+
Object { packages: Vec<Str> },
51+
}
52+
53+
impl NpmWorkspaces {
54+
fn into_packages(self) -> Vec<Str> {
55+
match self {
56+
Self::Array(packages) | Self::Object { packages } => packages,
57+
}
58+
}
59+
}
60+
61+
/// The workspace configuration for npm/yarn/bun.
3762
///
3863
/// npm: <https://docs.npmjs.com/cli/v11/using-npm/workspaces>
3964
/// yarn: <https://yarnpkg.com/features/workspaces>
65+
/// bun: <https://bun.sh/docs/install/workspaces>
4066
#[derive(Debug, Deserialize)]
4167
struct NpmWorkspace {
42-
/// Array of folder glob patterns referencing the workspaces of the project.
43-
///
44-
/// <https://docs.npmjs.com/cli/v11/configuring-npm/package-json#workspaces>
45-
/// <https://yarnpkg.com/configuration/manifest#workspaces>
46-
workspaces: Vec<Str>,
68+
/// Glob patterns referencing the workspaces of the project.
69+
/// Accepts both array form and object form (with `packages` key).
70+
workspaces: NpmWorkspaces,
4771
}
4872

4973
#[derive(Debug)]
@@ -237,7 +261,7 @@ pub fn load_package_graph(
237261
file_path: Arc::clone(file_with_path.path()),
238262
serde_json_error: e,
239263
})?;
240-
workspace.workspaces
264+
workspace.workspaces.into_packages()
241265
}
242266
WorkspaceFile::NonWorkspacePackage(file_with_path) => {
243267
// For non-workspace packages, add the package.json to the graph as a root package
@@ -1096,4 +1120,115 @@ mod tests {
10961120
// External dependencies should not create edges
10971121
assert_eq!(graph.edge_count(), 1, "Should only have one edge for workspace dependency");
10981122
}
1123+
1124+
#[test]
1125+
fn test_get_package_graph_npm_workspace_object_form() {
1126+
let temp_dir = TempDir::new().unwrap();
1127+
let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap();
1128+
1129+
// Create package.json with object-form workspaces (Bun/Yarn classic style)
1130+
let root_package = serde_json::json!({
1131+
"name": "bun-monorepo",
1132+
"private": true,
1133+
"workspaces": {
1134+
"packages": ["packages/*", "apps/*"]
1135+
}
1136+
});
1137+
fs::write(temp_dir_path.join("package.json"), root_package.to_string()).unwrap();
1138+
1139+
// Create packages directory structure
1140+
fs::create_dir_all(temp_dir_path.join("packages")).unwrap();
1141+
fs::create_dir_all(temp_dir_path.join("apps")).unwrap();
1142+
1143+
// Create shared library package
1144+
fs::create_dir_all(temp_dir_path.join("packages/shared")).unwrap();
1145+
let shared_pkg = serde_json::json!({
1146+
"name": "@myorg/shared",
1147+
"version": "1.0.0"
1148+
});
1149+
fs::write(temp_dir_path.join("packages/shared/package.json"), shared_pkg.to_string())
1150+
.unwrap();
1151+
1152+
// Create app that depends on shared
1153+
fs::create_dir_all(temp_dir_path.join("apps/web")).unwrap();
1154+
let web_app = serde_json::json!({
1155+
"name": "web-app",
1156+
"version": "0.1.0",
1157+
"dependencies": {
1158+
"@myorg/shared": "workspace:*"
1159+
}
1160+
});
1161+
fs::write(temp_dir_path.join("apps/web/package.json"), web_app.to_string()).unwrap();
1162+
1163+
let graph = discover_package_graph(temp_dir_path).unwrap();
1164+
1165+
// Should have 3 nodes: root + shared + web-app
1166+
assert_eq!(graph.node_count(), 3);
1167+
1168+
// Verify packages were found
1169+
let mut packages_found = FxHashSet::<Str>::default();
1170+
for node in graph.node_weights() {
1171+
packages_found.insert(node.package_json.name.clone());
1172+
}
1173+
assert!(packages_found.contains("bun-monorepo"));
1174+
assert!(packages_found.contains("@myorg/shared"));
1175+
assert!(packages_found.contains("web-app"));
1176+
1177+
// Verify dependency edge
1178+
let mut found_web_to_shared = false;
1179+
for edge_ref in graph.edge_references() {
1180+
let source = &graph[edge_ref.source()];
1181+
let target = &graph[edge_ref.target()];
1182+
if source.package_json.name == "web-app" && target.package_json.name == "@myorg/shared"
1183+
{
1184+
found_web_to_shared = true;
1185+
}
1186+
}
1187+
assert!(found_web_to_shared, "Web app should depend on shared");
1188+
}
1189+
1190+
#[test]
1191+
fn test_get_package_graph_bun_workspace_with_catalog() {
1192+
let temp_dir = TempDir::new().unwrap();
1193+
let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap();
1194+
1195+
// Create package.json with Bun catalog in object-form workspaces
1196+
let root_package = serde_json::json!({
1197+
"name": "bun-catalog-monorepo",
1198+
"private": true,
1199+
"workspaces": {
1200+
"packages": ["packages/*"],
1201+
"catalog": {
1202+
"react": "^19.0.0",
1203+
"vite": "npm:@voidzero-dev/vite-plus-core@latest"
1204+
}
1205+
}
1206+
});
1207+
fs::write(temp_dir_path.join("package.json"), root_package.to_string()).unwrap();
1208+
1209+
// Create packages directory
1210+
fs::create_dir_all(temp_dir_path.join("packages")).unwrap();
1211+
1212+
// Create a package
1213+
fs::create_dir_all(temp_dir_path.join("packages/app")).unwrap();
1214+
let app_pkg = serde_json::json!({
1215+
"name": "my-app",
1216+
"dependencies": {
1217+
"react": "catalog:"
1218+
}
1219+
});
1220+
fs::write(temp_dir_path.join("packages/app/package.json"), app_pkg.to_string()).unwrap();
1221+
1222+
let graph = discover_package_graph(temp_dir_path).unwrap();
1223+
1224+
// Should have 2 nodes: root + app (catalog field is silently ignored)
1225+
assert_eq!(graph.node_count(), 2);
1226+
1227+
let mut packages_found = FxHashSet::<Str>::default();
1228+
for node in graph.node_weights() {
1229+
packages_found.insert(node.package_json.name.clone());
1230+
}
1231+
assert!(packages_found.contains("bun-catalog-monorepo"));
1232+
assert!(packages_found.contains("my-app"));
1233+
}
10991234
}

0 commit comments

Comments
 (0)