Skip to content

Commit d365a01

Browse files
wan9chiclaude
andauthored
fix: strip UTF-8 BOM from package.json before parsing (#424)
## Problem `vp run XYZ` fails to load the task graph when a `package.json` is encoded with a UTF-8 byte order mark (BOM, `EF BB BF`): ``` error: Failed to load task graph * Failed to load package graph * Failed to parse JSON file at ".../utf8-bom-package/package.json" ``` `serde_json::from_slice` does not tolerate a leading BOM, so a single BOM-encoded `package.json` (as written by some editors/tools, especially on Windows) aborts the entire package-graph load. ## Fix Added a `strip_bom` helper in `vite_workspace` and applied it at every `package.json` / workspace-JSON parse site before deserializing: - `package_manager.rs` — workspace detection (`find_workspace_root`) - `lib.rs` — npm/yarn workspace JSON, non-workspace package, workspace members (glob walk), and the root package fallback Only a *leading* BOM is stripped; everything else is passed through unchanged. ## Tests - `test_strip_bom` — unit coverage (leading BOM, no BOM, non-leading bytes, empty) - `test_get_package_graph_package_json_with_bom` — pnpm workspace with BOMs in root + member `package.json` - `test_get_package_graph_single_package_with_bom` — non-workspace package path All `vite_workspace` tests pass; `cargo fmt` and `cargo clippy` are clean. https://claude.ai/code/session_017dv1DPTTkkt65M9s55wPHb --- _Generated by [Claude Code](https://claude.ai/code/session_017dv1DPTTkkt65M9s55wPHb)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dfeea9d commit d365a01

3 files changed

Lines changed: 126 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3+
- **Fixed** `package.json` and `pnpm-workspace.yaml` files with a UTF-8 BOM no longer fail to parse ([#424](https://github.com/voidzero-dev/vite-task/pull/424))
34
- **Changed** `vp run --filter <expr>` now exits 0 with a warning when the filter matches no packages, matching pnpm. Use `--fail-if-no-match` to restore the previous strict behavior ([#393](https://github.com/voidzero-dev/vite-task/pull/393))
45
- **Added** task command shorthands for defining tasks as command strings or command string arrays ([#391](https://github.com/voidzero-dev/vite-task/pull/391))
56
- **Changed** Cached logs are stored with colors intact (`FORCE_COLOR=1` is auto-injected into spawned tasks). Colors are then stripped at display time when the terminal does not support them. Other color-related env vars (`NO_COLOR`, `COLORTERM`, `TERM`, `TERM_PROGRAM`) are no longer passed through by default. Opt in via a task's `env`/`untrackedEnv` ([#378](https://github.com/voidzero-dev/vite-task/pull/378))

crates/vite_workspace/src/lib.rs

Lines changed: 118 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ pub use crate::{
2323
},
2424
};
2525

26+
/// Strip a leading UTF-8 byte order mark (BOM) from `bytes`, if present.
27+
///
28+
/// Some editors and tools (notably on Windows, e.g. Notepad or PowerShell's
29+
/// `>` redirection) write `package.json` with a UTF-8 BOM (`EF BB BF`).
30+
/// `serde_json` does not accept a leading BOM and fails with a parse error, so
31+
/// we trim it before parsing.
32+
pub(crate) fn strip_bom(bytes: &[u8]) -> &[u8] {
33+
bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes)
34+
}
35+
2636
/// The workspace configuration for pnpm.
2737
#[derive(Debug, Deserialize)]
2838
struct PnpmWorkspace {
@@ -248,27 +258,33 @@ pub fn load_package_graph(
248258
let mut graph_builder = PackageGraphBuilder::default();
249259
let workspaces = match &workspace_root.workspace_file {
250260
WorkspaceFile::PnpmWorkspaceYaml(file_with_path) => {
251-
let workspace: PnpmWorkspace = serde_norway::from_slice(file_with_path.content())
252-
.map_err(|e| Error::SerdeYaml {
253-
file_path: Arc::clone(file_with_path.path()),
254-
serde_yaml_error: e,
261+
let workspace: PnpmWorkspace =
262+
serde_norway::from_slice(strip_bom(file_with_path.content())).map_err(|e| {
263+
Error::SerdeYaml {
264+
file_path: Arc::clone(file_with_path.path()),
265+
serde_yaml_error: e,
266+
}
255267
})?;
256268
workspace.packages
257269
}
258270
WorkspaceFile::NpmWorkspaceJson(file_with_path) => {
259-
let workspace: NpmWorkspace = serde_json::from_slice(file_with_path.content())
260-
.map_err(|e| Error::SerdeJson {
261-
file_path: Arc::clone(file_with_path.path()),
262-
serde_json_error: e,
271+
let workspace: NpmWorkspace =
272+
serde_json::from_slice(strip_bom(file_with_path.content())).map_err(|e| {
273+
Error::SerdeJson {
274+
file_path: Arc::clone(file_with_path.path()),
275+
serde_json_error: e,
276+
}
263277
})?;
264278
workspace.workspaces.into_packages()
265279
}
266280
WorkspaceFile::NonWorkspacePackage(file_with_path) => {
267281
// For non-workspace packages, add the package.json to the graph as a root package
268-
let package_json: PackageJson = serde_json::from_slice(file_with_path.content())
269-
.map_err(|e| Error::SerdeJson {
270-
file_path: Arc::clone(file_with_path.path()),
271-
serde_json_error: e,
282+
let package_json: PackageJson =
283+
serde_json::from_slice(strip_bom(file_with_path.content())).map_err(|e| {
284+
Error::SerdeJson {
285+
file_path: Arc::clone(file_with_path.path()),
286+
serde_json_error: e,
287+
}
272288
})?;
273289
graph_builder.add_package(
274290
RelativePathBuf::default(),
@@ -285,7 +301,7 @@ pub fn load_package_graph(
285301
for package_json_path in member_globs.get_package_json_paths(&*workspace_root.path)? {
286302
let package_json_path: Arc<AbsolutePath> = package_json_path.clone().into();
287303
let package_json: PackageJson =
288-
serde_json::from_slice(&fs::read(&*package_json_path)?).map_err(|e| {
304+
serde_json::from_slice(strip_bom(&fs::read(&*package_json_path)?)).map_err(|e| {
289305
Error::SerdeJson { file_path: Arc::clone(&package_json_path), serde_json_error: e }
290306
})?;
291307
let absolute_path = package_json_path.parent().unwrap();
@@ -305,7 +321,7 @@ pub fn load_package_graph(
305321
let package_json = match fs::read(&package_json_path) {
306322
Ok(content) => {
307323
let package_json_path: Arc<AbsolutePath> = package_json_path.into();
308-
serde_json::from_slice(&content).map_err(|e| Error::SerdeJson {
324+
serde_json::from_slice(strip_bom(&content)).map_err(|e| Error::SerdeJson {
309325
file_path: package_json_path,
310326
serde_json_error: e,
311327
})?
@@ -363,6 +379,94 @@ mod tests {
363379
assert_eq!(node.path.as_str(), "");
364380
}
365381

382+
#[test]
383+
fn test_strip_bom() {
384+
// Leading UTF-8 BOM is stripped.
385+
assert_eq!(strip_bom(b"\xEF\xBB\xBF{}"), b"{}");
386+
// Content without a BOM is returned unchanged.
387+
assert_eq!(strip_bom(b"{}"), b"{}");
388+
// Only a leading BOM is stripped, not occurrences elsewhere.
389+
assert_eq!(strip_bom(b"{}\xEF\xBB\xBF"), b"{}\xEF\xBB\xBF");
390+
// Empty input is handled.
391+
assert_eq!(strip_bom(b""), b"");
392+
}
393+
394+
/// Regression test for <https://github.com/voidzero-dev/vite-plus/issues/1357>
395+
/// follow-up: a `package.json` written with a UTF-8 BOM (e.g. by some
396+
/// editors on Windows) must still parse instead of failing the whole graph.
397+
#[test]
398+
fn test_get_package_graph_package_json_with_bom() {
399+
let temp_dir = TempDir::new().unwrap();
400+
let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap();
401+
402+
// pnpm workspace so package.json files are read via `fs::read` + parse.
403+
let workspace_yaml = "packages:\n - \"packages/*\"\n";
404+
fs::write(temp_dir_path.join("pnpm-workspace.yaml"), workspace_yaml).unwrap();
405+
406+
// Root package.json with a BOM.
407+
let root_package = serde_json::json!({ "name": "monorepo-root", "private": true });
408+
let mut root_bytes = b"\xEF\xBB\xBF".to_vec();
409+
root_bytes.extend_from_slice(root_package.to_string().as_bytes());
410+
fs::write(temp_dir_path.join("package.json"), root_bytes).unwrap();
411+
412+
// Member package.json with a BOM.
413+
fs::create_dir_all(temp_dir_path.join("packages/pkg-a")).unwrap();
414+
let pkg_a = serde_json::json!({ "name": "pkg-a" });
415+
let mut pkg_a_bytes = b"\xEF\xBB\xBF".to_vec();
416+
pkg_a_bytes.extend_from_slice(pkg_a.to_string().as_bytes());
417+
fs::write(temp_dir_path.join("packages/pkg-a/package.json"), pkg_a_bytes).unwrap();
418+
419+
let graph = discover_package_graph(temp_dir_path).unwrap();
420+
421+
// Both the root and the member package should be present.
422+
assert_eq!(graph.node_count(), 2);
423+
let names: FxHashSet<_> =
424+
graph.node_weights().map(|n| n.package_json.name.as_str()).collect();
425+
assert!(names.contains("monorepo-root"));
426+
assert!(names.contains("pkg-a"));
427+
}
428+
429+
#[test]
430+
fn test_get_package_graph_pnpm_workspace_yaml_with_bom() {
431+
let temp_dir = TempDir::new().unwrap();
432+
let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap();
433+
434+
// pnpm-workspace.yaml with a leading BOM.
435+
let mut yaml_bytes = b"\xEF\xBB\xBF".to_vec();
436+
yaml_bytes.extend_from_slice(b"packages:\n - \"packages/*\"\n");
437+
fs::write(temp_dir_path.join("pnpm-workspace.yaml"), yaml_bytes).unwrap();
438+
439+
let root_package = serde_json::json!({ "name": "monorepo-root", "private": true });
440+
fs::write(temp_dir_path.join("package.json"), root_package.to_string()).unwrap();
441+
442+
fs::create_dir_all(temp_dir_path.join("packages/pkg-a")).unwrap();
443+
let pkg_a = serde_json::json!({ "name": "pkg-a" });
444+
fs::write(temp_dir_path.join("packages/pkg-a/package.json"), pkg_a.to_string()).unwrap();
445+
446+
let graph = discover_package_graph(temp_dir_path).unwrap();
447+
assert_eq!(graph.node_count(), 2);
448+
let names: FxHashSet<_> =
449+
graph.node_weights().map(|n| n.package_json.name.as_str()).collect();
450+
assert!(names.contains("monorepo-root"));
451+
assert!(names.contains("pkg-a"));
452+
}
453+
454+
#[test]
455+
fn test_get_package_graph_single_package_with_bom() {
456+
let temp_dir = TempDir::new().unwrap();
457+
let temp_dir_path = AbsolutePath::new(temp_dir.path()).unwrap();
458+
459+
// Single non-workspace package.json with a BOM (NonWorkspacePackage path).
460+
let package_json = serde_json::json!({ "name": "my-app" });
461+
let mut bytes = b"\xEF\xBB\xBF".to_vec();
462+
bytes.extend_from_slice(package_json.to_string().as_bytes());
463+
fs::write(temp_dir_path.join("package.json"), bytes).unwrap();
464+
465+
let graph = discover_package_graph(temp_dir_path).unwrap();
466+
assert_eq!(graph.node_count(), 1);
467+
assert_eq!(graph.node_weight(NodeIndex::new(0)).unwrap().package_json.name, "my-app");
468+
}
469+
366470
#[test]
367471
fn test_get_package_graph_pnpm_workspace() {
368472
let temp_dir = TempDir::new().unwrap();

crates/vite_workspace/src/package_manager.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,13 @@ pub fn find_workspace_root(
155155
// Check for package.json with workspaces field for npm/yarn workspace
156156
let package_json_path: Arc<AbsolutePath> = cwd.join("package.json").into();
157157
if let Some(file_with_path) = FileWithPath::open_if_exists(package_json_path)? {
158-
let package_json: serde_json::Value = serde_json::from_slice(file_with_path.content())
159-
.map_err(|e| Error::SerdeJson {
160-
file_path: Arc::clone(file_with_path.path()),
161-
serde_json_error: e,
162-
})?;
158+
let package_json: serde_json::Value = serde_json::from_slice(crate::strip_bom(
159+
file_with_path.content(),
160+
))
161+
.map_err(|e| Error::SerdeJson {
162+
file_path: Arc::clone(file_with_path.path()),
163+
serde_json_error: e,
164+
})?;
163165
if package_json.get("workspaces").is_some() {
164166
let relative_cwd =
165167
original_cwd.strip_prefix(cwd)?.expect("cwd must be within the workspace");

0 commit comments

Comments
 (0)