From 6a23db416f5e982f9449880beec9a62de08f0307 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 01:04:17 +0800 Subject: [PATCH 01/38] feat(config): add static vite config extraction to avoid NAPI for `run` config Add a new `vite_static_config` crate that uses oxc_parser to statically extract JSON-serializable fields from vite.config.* files without needing a Node.js runtime. The `VitePlusConfigLoader` now tries static extraction first for the `run` config and falls back to NAPI only when needed. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 13 + Cargo.toml | 1 + crates/vite_static_config/Cargo.toml | 21 + crates/vite_static_config/src/lib.rs | 773 +++++++++++++++++++++++++++ packages/cli/binding/Cargo.toml | 1 + packages/cli/binding/src/cli.rs | 12 + 6 files changed, 821 insertions(+) create mode 100644 crates/vite_static_config/Cargo.toml create mode 100644 crates/vite_static_config/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 5574c02b03..eb7e4eec1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7151,6 +7151,7 @@ dependencies = [ "vite_migration", "vite_path", "vite_shared", + "vite_static_config", "vite_str", "vite_task", "vite_workspace", @@ -7365,6 +7366,18 @@ dependencies = [ "vite_str", ] +[[package]] +name = "vite_static_config" +version = "0.0.0" +dependencies = [ + "oxc", + "oxc_allocator", + "rustc-hash", + "serde_json", + "tempfile", + "vite_path", +] + [[package]] name = "vite_str" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index cf09264704..cead737b9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -186,6 +186,7 @@ vite_glob = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9e vite_install = { path = "crates/vite_install" } vite_migration = { path = "crates/vite_migration" } vite_shared = { path = "crates/vite_shared" } +vite_static_config = { path = "crates/vite_static_config" } vite_path = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9e1287e797190ea29793655b239cdaa7a55edd21" } vite_str = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9e1287e797190ea29793655b239cdaa7a55edd21" } vite_task = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9e1287e797190ea29793655b239cdaa7a55edd21" } diff --git a/crates/vite_static_config/Cargo.toml b/crates/vite_static_config/Cargo.toml new file mode 100644 index 0000000000..97870c8f2f --- /dev/null +++ b/crates/vite_static_config/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vite_static_config" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +oxc = { workspace = true } +oxc_allocator = { workspace = true } +rustc-hash = { workspace = true } +serde_json = { workspace = true } +vite_path = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs new file mode 100644 index 0000000000..6c86760b70 --- /dev/null +++ b/crates/vite_static_config/src/lib.rs @@ -0,0 +1,773 @@ +//! Static config extraction from vite.config.* files. +//! +//! Parses vite config files statically (without executing JavaScript) to extract +//! top-level fields whose values are pure JSON literals. This allows reading +//! config like `run` without needing a Node.js runtime. + +use oxc::{ + ast::ast::{ + ArrayExpressionElement, Expression, ObjectPropertyKind, Program, PropertyKey, Statement, + }, + parser::Parser, + span::SourceType, +}; +use oxc_allocator::Allocator; +use rustc_hash::FxHashMap; +use vite_path::AbsolutePath; + +/// Config file names to try, in priority order. +const CONFIG_FILE_NAMES: &[&str] = &[ + "vite.config.ts", + "vite.config.js", + "vite.config.mts", + "vite.config.mjs", + "vite.config.cts", + "vite.config.cjs", +]; + +/// Resolve the vite config file path in the given directory. +/// +/// Tries each config file name in priority order and returns the first one that exists. +fn resolve_config_path(dir: &AbsolutePath) -> Option { + for name in CONFIG_FILE_NAMES { + let path = dir.join(name); + if path.as_path().exists() { + return Some(path); + } + } + None +} + +/// Resolve and parse a vite config file from the given directory. +/// +/// Returns a map of top-level field names to their JSON values for fields +/// whose values are pure JSON literals. Fields with non-JSON values (function calls, +/// variables, template literals, etc.) are skipped. +/// +/// # Arguments +/// * `dir` - The directory to search for a vite config file +/// +/// # Returns +/// A map of field name to JSON value for all statically extractable fields. +/// Returns an empty map if no config file is found or if it cannot be parsed. +#[must_use] +pub fn resolve_static_config(dir: &AbsolutePath) -> FxHashMap, serde_json::Value> { + let Some(config_path) = resolve_config_path(dir) else { + return FxHashMap::default(); + }; + + let Ok(source) = std::fs::read_to_string(&config_path) else { + return FxHashMap::default(); + }; + + let extension = config_path.as_path().extension().and_then(|e| e.to_str()).unwrap_or(""); + + if extension == "json" { + return parse_json_config(&source); + } + + parse_js_ts_config(&source, extension) +} + +/// Parse a JSON config file into a map of field names to values. +fn parse_json_config(source: &str) -> FxHashMap, serde_json::Value> { + let Ok(value) = serde_json::from_str::(source) else { + return FxHashMap::default(); + }; + let Some(obj) = value.as_object() else { + return FxHashMap::default(); + }; + obj.iter().map(|(k, v)| (Box::from(k.as_str()), v.clone())).collect() +} + +/// Parse a JS/TS config file, extracting the default export object's fields. +fn parse_js_ts_config(source: &str, extension: &str) -> FxHashMap, serde_json::Value> { + let allocator = Allocator::default(); + let source_type = match extension { + "ts" | "mts" | "cts" => SourceType::ts(), + _ => SourceType::mjs(), + }; + + let parser = Parser::new(&allocator, source, source_type); + let result = parser.parse(); + + if result.panicked || !result.errors.is_empty() { + return FxHashMap::default(); + } + + extract_default_export_fields(&result.program) +} + +/// Find the default export in a parsed program and extract its object fields. +/// +/// Supports two patterns: +/// 1. `export default defineConfig({ ... })` +/// 2. `export default { ... }` +fn extract_default_export_fields(program: &Program<'_>) -> FxHashMap, serde_json::Value> { + for stmt in &program.body { + let Statement::ExportDefaultDeclaration(decl) = stmt else { + continue; + }; + + let Some(expr) = decl.declaration.as_expression() else { + continue; + }; + + // Unwrap parenthesized expressions + let expr = expr.without_parentheses(); + + match expr { + // Pattern: export default defineConfig({ ... }) + Expression::CallExpression(call) => { + if !is_define_config_call(&call.callee) { + continue; + } + if let Some(first_arg) = call.arguments.first() + && let Some(Expression::ObjectExpression(obj)) = first_arg.as_expression() + { + return extract_object_fields(obj); + } + } + // Pattern: export default { ... } + Expression::ObjectExpression(obj) => { + return extract_object_fields(obj); + } + _ => {} + } + } + + FxHashMap::default() +} + +/// Check if a callee expression is `defineConfig`. +fn is_define_config_call(callee: &Expression<'_>) -> bool { + matches!(callee, Expression::Identifier(ident) if ident.name == "defineConfig") +} + +/// Extract fields from an object expression, converting each value to JSON. +/// Fields whose values cannot be represented as pure JSON are skipped. +fn extract_object_fields( + obj: &oxc::ast::ast::ObjectExpression<'_>, +) -> FxHashMap, serde_json::Value> { + let mut map = FxHashMap::default(); + + for prop in &obj.properties { + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + // Skip spread elements + continue; + }; + + // Skip computed properties + if prop.computed { + continue; + } + + let Some(key) = property_key_to_string(&prop.key) else { + continue; + }; + + if let Some(value) = expr_to_json(&prop.value) { + map.insert(key, value); + } + } + + map +} + +/// Convert a property key to a string. +fn property_key_to_string(key: &PropertyKey<'_>) -> Option> { + match key { + PropertyKey::StaticIdentifier(ident) => Some(Box::from(ident.name.as_str())), + PropertyKey::StringLiteral(lit) => Some(Box::from(lit.value.as_str())), + PropertyKey::NumericLiteral(lit) => { + let s = if lit.value.fract() == 0.0 && lit.value.is_finite() { + #[expect(clippy::cast_possible_truncation)] + { + (lit.value as i64).to_string() + } + } else { + lit.value.to_string() + }; + Some(Box::from(s.as_str())) + } + _ => None, + } +} + +/// Convert an f64 to a JSON value, preserving integers when possible. +#[expect(clippy::cast_possible_truncation, clippy::cast_precision_loss)] +fn f64_to_json_number(value: f64) -> serde_json::Value { + // If the value is a whole number that fits in i64, use integer representation + if value.fract() == 0.0 + && value.is_finite() + && value >= i64::MIN as f64 + && value <= i64::MAX as f64 + { + serde_json::Value::Number(serde_json::Number::from(value as i64)) + } else if let Some(n) = serde_json::Number::from_f64(value) { + serde_json::Value::Number(n) + } else { + serde_json::Value::Null + } +} + +/// Try to convert an AST expression to a JSON value. +/// +/// Returns `None` if the expression contains non-JSON-literal nodes +/// (function calls, identifiers, template literals, etc.) +fn expr_to_json(expr: &Expression<'_>) -> Option { + let expr = expr.without_parentheses(); + match expr { + Expression::NullLiteral(_) => Some(serde_json::Value::Null), + + Expression::BooleanLiteral(lit) => Some(serde_json::Value::Bool(lit.value)), + + Expression::NumericLiteral(lit) => Some(f64_to_json_number(lit.value)), + + Expression::StringLiteral(lit) => Some(serde_json::Value::String(lit.value.to_string())), + + Expression::TemplateLiteral(lit) => { + // Only convert template literals with no expressions (pure strings) + if lit.expressions.is_empty() && lit.quasis.len() == 1 { + let raw = &lit.quasis[0].value.cooked.as_ref()?; + Some(serde_json::Value::String(raw.to_string())) + } else { + None + } + } + + Expression::UnaryExpression(unary) => { + // Handle negative numbers: -42 + if unary.operator == oxc::ast::ast::UnaryOperator::UnaryNegation + && let Expression::NumericLiteral(lit) = &unary.argument + { + return Some(f64_to_json_number(-lit.value)); + } + None + } + + Expression::ArrayExpression(arr) => { + let mut values = Vec::with_capacity(arr.elements.len()); + for elem in &arr.elements { + match elem { + ArrayExpressionElement::Elision(_) => { + values.push(serde_json::Value::Null); + } + ArrayExpressionElement::SpreadElement(_) => { + return None; + } + _ => { + let elem_expr = elem.as_expression()?; + values.push(expr_to_json(elem_expr)?); + } + } + } + Some(serde_json::Value::Array(values)) + } + + Expression::ObjectExpression(obj) => { + let mut map = serde_json::Map::new(); + for prop in &obj.properties { + match prop { + ObjectPropertyKind::ObjectProperty(prop) => { + if prop.computed { + return None; + } + let key = property_key_to_json_key(&prop.key)?; + let value = expr_to_json(&prop.value)?; + map.insert(key, value); + } + ObjectPropertyKind::SpreadProperty(_) => { + return None; + } + } + } + Some(serde_json::Value::Object(map)) + } + + _ => None, + } +} + +/// Convert a property key to a JSON-compatible string key. +#[expect(clippy::disallowed_types)] +fn property_key_to_json_key(key: &PropertyKey<'_>) -> Option { + match key { + PropertyKey::StaticIdentifier(ident) => Some(ident.name.to_string()), + PropertyKey::StringLiteral(lit) => Some(lit.value.to_string()), + PropertyKey::NumericLiteral(lit) => { + if lit.value.fract() == 0.0 && lit.value.is_finite() { + #[expect(clippy::cast_possible_truncation)] + { + Some((lit.value as i64).to_string()) + } + } else { + Some(lit.value.to_string()) + } + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + fn parse(source: &str) -> FxHashMap, serde_json::Value> { + parse_js_ts_config(source, "ts") + } + + // ── Config file resolution ────────────────────────────────────────── + + #[test] + fn resolves_ts_config() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + std::fs::write(dir.path().join("vite.config.ts"), "export default { run: {} }").unwrap(); + let result = resolve_static_config(&dir_path); + assert!(result.contains_key("run")); + } + + #[test] + fn resolves_js_config() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + std::fs::write(dir.path().join("vite.config.js"), "export default { run: {} }").unwrap(); + let result = resolve_static_config(&dir_path); + assert!(result.contains_key("run")); + } + + #[test] + fn resolves_mts_config() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + std::fs::write(dir.path().join("vite.config.mts"), "export default { run: {} }").unwrap(); + let result = resolve_static_config(&dir_path); + assert!(result.contains_key("run")); + } + + #[test] + fn ts_takes_priority_over_js() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + std::fs::write(dir.path().join("vite.config.ts"), "export default { fromTs: true }") + .unwrap(); + std::fs::write(dir.path().join("vite.config.js"), "export default { fromJs: true }") + .unwrap(); + let result = resolve_static_config(&dir_path); + assert!(result.contains_key("fromTs")); + assert!(!result.contains_key("fromJs")); + } + + #[test] + fn returns_empty_for_no_config() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + let result = resolve_static_config(&dir_path); + assert!(result.is_empty()); + } + + // ── JSON config parsing ───────────────────────────────────────────── + + #[test] + fn parses_json_config() { + let dir = TempDir::new().unwrap(); + let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + std::fs::write( + dir.path().join("vite.config.ts"), + r#"export default { run: { tasks: { build: { command: "echo hello" } } } }"#, + ) + .unwrap(); + let result = resolve_static_config(&dir_path); + let run = result.get("run").unwrap(); + assert_eq!(run, &serde_json::json!({ "tasks": { "build": { "command": "echo hello" } } })); + } + + // ── export default { ... } ────────────────────────────────────────── + + #[test] + fn plain_export_default_object() { + let result = parse("export default { foo: 'bar', num: 42 }"); + assert_eq!(result.get("foo").unwrap(), &serde_json::json!("bar")); + assert_eq!(result.get("num").unwrap(), &serde_json::json!(42)); + } + + #[test] + fn export_default_empty_object() { + let result = parse("export default {}"); + assert!(result.is_empty()); + } + + // ── export default defineConfig({ ... }) ──────────────────────────── + + #[test] + fn define_config_call() { + let result = parse( + r#" + import { defineConfig } from 'vite-plus'; + export default defineConfig({ + run: { cacheScripts: true }, + lint: { plugins: ['a'] }, + }); + "#, + ); + assert_eq!(result.get("run").unwrap(), &serde_json::json!({ "cacheScripts": true })); + assert_eq!(result.get("lint").unwrap(), &serde_json::json!({ "plugins": ["a"] })); + } + + // ── Primitive values ──────────────────────────────────────────────── + + #[test] + fn string_values() { + let result = parse(r#"export default { a: "double", b: 'single' }"#); + assert_eq!(result.get("a").unwrap(), &serde_json::json!("double")); + assert_eq!(result.get("b").unwrap(), &serde_json::json!("single")); + } + + #[test] + fn numeric_values() { + let result = parse("export default { a: 42, b: 3.14, c: 0, d: -1 }"); + assert_eq!(result.get("a").unwrap(), &serde_json::json!(42)); + assert_eq!(result.get("b").unwrap(), &serde_json::json!(3.14)); + assert_eq!(result.get("c").unwrap(), &serde_json::json!(0)); + assert_eq!(result.get("d").unwrap(), &serde_json::json!(-1)); + } + + #[test] + fn boolean_values() { + let result = parse("export default { a: true, b: false }"); + assert_eq!(result.get("a").unwrap(), &serde_json::json!(true)); + assert_eq!(result.get("b").unwrap(), &serde_json::json!(false)); + } + + #[test] + fn null_value() { + let result = parse("export default { a: null }"); + assert_eq!(result.get("a").unwrap(), &serde_json::Value::Null); + } + + // ── Arrays ────────────────────────────────────────────────────────── + + #[test] + fn array_of_strings() { + let result = parse("export default { items: ['a', 'b', 'c'] }"); + assert_eq!(result.get("items").unwrap(), &serde_json::json!(["a", "b", "c"])); + } + + #[test] + fn nested_arrays() { + let result = parse("export default { matrix: [[1, 2], [3, 4]] }"); + assert_eq!(result.get("matrix").unwrap(), &serde_json::json!([[1, 2], [3, 4]])); + } + + #[test] + fn empty_array() { + let result = parse("export default { items: [] }"); + assert_eq!(result.get("items").unwrap(), &serde_json::json!([])); + } + + // ── Nested objects ────────────────────────────────────────────────── + + #[test] + fn nested_object() { + let result = parse( + r#"export default { + run: { + tasks: { + build: { + command: "echo build", + dependsOn: ["lint"], + cache: true, + } + } + } + }"#, + ); + assert_eq!( + result.get("run").unwrap(), + &serde_json::json!({ + "tasks": { + "build": { + "command": "echo build", + "dependsOn": ["lint"], + "cache": true, + } + } + }) + ); + } + + // ── Skipping non-JSON fields ──────────────────────────────────────── + + #[test] + fn skips_function_call_values() { + let result = parse( + r#"export default { + run: { cacheScripts: true }, + plugins: [myPlugin()], + }"#, + ); + assert!(result.contains_key("run")); + assert!(!result.contains_key("plugins")); + } + + #[test] + fn skips_identifier_values() { + let result = parse( + r#" + const myVar = 'hello'; + export default { a: myVar, b: 42 } + "#, + ); + assert!(!result.contains_key("a")); + assert!(result.contains_key("b")); + } + + #[test] + fn skips_template_literal_with_expressions() { + let result = parse( + r#" + const x = 'world'; + export default { a: `hello ${x}`, b: 'plain' } + "#, + ); + assert!(!result.contains_key("a")); + assert!(result.contains_key("b")); + } + + #[test] + fn keeps_pure_template_literal() { + let result = parse("export default { a: `hello` }"); + assert_eq!(result.get("a").unwrap(), &serde_json::json!("hello")); + } + + #[test] + fn skips_spread_in_object_value() { + let result = parse( + r#" + const base = { x: 1 }; + export default { a: { ...base, y: 2 }, b: 'ok' } + "#, + ); + assert!(!result.contains_key("a")); + assert!(result.contains_key("b")); + } + + #[test] + fn skips_spread_in_top_level() { + let result = parse( + r#" + const base = { x: 1 }; + export default { ...base, b: 'ok' } + "#, + ); + // Spread at top level is skipped; plain fields are kept + assert!(!result.contains_key("x")); + assert!(result.contains_key("b")); + } + + #[test] + fn skips_computed_properties() { + let result = parse( + r#" + const key = 'dynamic'; + export default { [key]: 'value', plain: 'ok' } + "#, + ); + assert!(!result.contains_key("dynamic")); + assert!(result.contains_key("plain")); + } + + #[test] + fn skips_array_with_spread() { + let result = parse( + r#" + const arr = [1, 2]; + export default { a: [...arr, 3], b: 'ok' } + "#, + ); + assert!(!result.contains_key("a")); + assert!(result.contains_key("b")); + } + + // ── Property key types ────────────────────────────────────────────── + + #[test] + fn string_literal_keys() { + let result = parse(r#"export default { 'string-key': 42 }"#); + assert_eq!(result.get("string-key").unwrap(), &serde_json::json!(42)); + } + + // ── Real-world patterns ───────────────────────────────────────────── + + #[test] + fn real_world_run_config() { + let result = parse( + r#" + export default { + run: { + tasks: { + build: { + command: "echo 'build from vite.config.ts'", + dependsOn: [], + }, + }, + }, + }; + "#, + ); + assert_eq!( + result.get("run").unwrap(), + &serde_json::json!({ + "tasks": { + "build": { + "command": "echo 'build from vite.config.ts'", + "dependsOn": [], + } + } + }) + ); + } + + #[test] + fn real_world_with_non_json_fields() { + let result = parse( + r#" + import { defineConfig } from 'vite-plus'; + + export default defineConfig({ + lint: { + plugins: ['unicorn', 'typescript'], + rules: { + 'no-console': ['error', { allow: ['error'] }], + }, + }, + run: { + tasks: { + 'build:src': { + command: 'vp run rolldown#build-binding:release', + }, + }, + }, + }); + "#, + ); + assert!(result.contains_key("lint")); + assert!(result.contains_key("run")); + assert_eq!( + result.get("run").unwrap(), + &serde_json::json!({ + "tasks": { + "build:src": { + "command": "vp run rolldown#build-binding:release", + } + } + }) + ); + } + + #[test] + fn skips_non_default_exports() { + let result = parse( + r#" + export const config = { a: 1 }; + export default { b: 2 }; + "#, + ); + assert!(!result.contains_key("a")); + assert!(result.contains_key("b")); + } + + #[test] + fn returns_empty_for_no_default_export() { + let result = parse("export const config = { a: 1 };"); + assert!(result.is_empty()); + } + + #[test] + fn returns_empty_for_non_object_default_export() { + let result = parse("export default 42;"); + assert!(result.is_empty()); + } + + #[test] + fn returns_empty_for_unknown_function_call() { + let result = parse("export default someOtherFn({ a: 1 });"); + assert!(result.is_empty()); + } + + #[test] + fn handles_trailing_commas() { + let result = parse( + r#"export default { + a: [1, 2, 3,], + b: { x: 1, y: 2, }, + }"#, + ); + assert_eq!(result.get("a").unwrap(), &serde_json::json!([1, 2, 3])); + assert_eq!(result.get("b").unwrap(), &serde_json::json!({ "x": 1, "y": 2 })); + } + + #[test] + fn task_with_cache_config() { + let result = parse( + r#"export default { + run: { + tasks: { + hello: { + command: 'node hello.mjs', + envs: ['FOO', 'BAR'], + cache: true, + }, + }, + }, + }"#, + ); + assert_eq!( + result.get("run").unwrap(), + &serde_json::json!({ + "tasks": { + "hello": { + "command": "node hello.mjs", + "envs": ["FOO", "BAR"], + "cache": true, + } + } + }) + ); + } + + #[test] + fn skips_method_call_in_nested_value() { + let result = parse( + r#"export default { + run: { + tasks: { + 'build:src': { + command: ['cmd1', 'cmd2'].join(' && '), + }, + }, + }, + lint: { plugins: ['a'] }, + }"#, + ); + // `run` should be skipped because its nested value contains a method call + assert!(!result.contains_key("run")); + // `lint` is pure JSON and should be kept + assert!(result.contains_key("lint")); + } + + #[test] + fn cache_scripts_only() { + let result = parse( + r#"export default { + run: { + cacheScripts: true, + }, + }"#, + ); + assert_eq!(result.get("run").unwrap(), &serde_json::json!({ "cacheScripts": true })); + } +} diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index afcd92417e..76d3428314 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -26,6 +26,7 @@ vite_install = { workspace = true } vite_migration = { workspace = true } vite_path = { workspace = true } vite_shared = { workspace = true } +vite_static_config = { workspace = true } vite_str = { workspace = true } vite_task = { workspace = true } vite_workspace = { workspace = true } diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index 3cfd6f7995..40b5766cf7 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -655,6 +655,18 @@ impl UserConfigLoader for VitePlusConfigLoader { &self, package_path: &AbsolutePath, ) -> anyhow::Result> { + // Try static config extraction first (no JS runtime needed) + let static_config = vite_static_config::resolve_static_config(package_path); + if let Some(run_value) = static_config.get("run") { + tracing::debug!( + "Using statically extracted run config for {}", + package_path.as_path().display() + ); + let run_config: UserRunConfig = serde_json::from_value(run_value.clone())?; + return Ok(Some(run_config)); + } + + // Fall back to NAPI-based config resolution let package_path_str = package_path .as_path() .to_str() From dd8f2b16720747baa976565b1fac9a20db51f7f0 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 09:50:52 +0800 Subject: [PATCH 02/38] fix(static-config): match Vite's DEFAULT_CONFIG_FILES resolution order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config file resolution order was .ts first, but Vite resolves .js, .mjs, .ts, .cjs, .mts, .cts — matching that order now. Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 6c86760b70..501ce2ef1d 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -16,13 +16,14 @@ use rustc_hash::FxHashMap; use vite_path::AbsolutePath; /// Config file names to try, in priority order. +/// This matches Vite's `DEFAULT_CONFIG_FILES` order. const CONFIG_FILE_NAMES: &[&str] = &[ - "vite.config.ts", "vite.config.js", - "vite.config.mts", "vite.config.mjs", - "vite.config.cts", + "vite.config.ts", "vite.config.cjs", + "vite.config.mts", + "vite.config.cts", ]; /// Resolve the vite config file path in the given directory. @@ -349,7 +350,7 @@ mod tests { } #[test] - fn ts_takes_priority_over_js() { + fn js_takes_priority_over_ts() { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); std::fs::write(dir.path().join("vite.config.ts"), "export default { fromTs: true }") @@ -357,8 +358,8 @@ mod tests { std::fs::write(dir.path().join("vite.config.js"), "export default { fromJs: true }") .unwrap(); let result = resolve_static_config(&dir_path); - assert!(result.contains_key("fromTs")); - assert!(!result.contains_key("fromJs")); + assert!(result.contains_key("fromJs")); + assert!(!result.contains_key("fromTs")); } #[test] From dc69f483c82962d306346e3bb7f00ed07ff35859 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 09:54:24 +0800 Subject: [PATCH 03/38] docs(static-config): add permalink to Vite's DEFAULT_CONFIG_FILES Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 501ce2ef1d..48466c0d40 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -16,7 +16,8 @@ use rustc_hash::FxHashMap; use vite_path::AbsolutePath; /// Config file names to try, in priority order. -/// This matches Vite's `DEFAULT_CONFIG_FILES` order. +/// This matches Vite's `DEFAULT_CONFIG_FILES` order: +/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L119-L126 const CONFIG_FILE_NAMES: &[&str] = &[ "vite.config.js", "vite.config.mjs", From 0f49faa2da3925cf2128a83e5f848ce08ca6d527 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 09:57:26 +0800 Subject: [PATCH 04/38] fix(static-config): correct permalink line ranges, explain no oxc_resolver Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 48466c0d40..4f16f84c4f 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -16,8 +16,12 @@ use rustc_hash::FxHashMap; use vite_path::AbsolutePath; /// Config file names to try, in priority order. -/// This matches Vite's `DEFAULT_CONFIG_FILES` order: -/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L119-L126 +/// This matches Vite's `DEFAULT_CONFIG_FILES`: +/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L98-L105 +/// +/// Vite resolves config files by iterating this list and checking `fs.existsSync` — no +/// module resolution involved, so oxc_resolver is not needed here: +/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/config.ts#L2231-L2237 const CONFIG_FILE_NAMES: &[&str] = &[ "vite.config.js", "vite.config.mjs", From 487f9821f9ebfe04fc86b76ef419281b9971cf45 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 10:12:26 +0800 Subject: [PATCH 05/38] refactor(static-config): distinguish not-analyzable from field-absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_static_config now returns Option>: - None: config is not analyzable (no file, parse error, no export default, or exported value is not an object literal) — caller should fall back to NAPI - Some(map): config was successfully analyzed - Json(value): field extracted as pure JSON - NonStatic: field exists but value is not a JSON literal - key absent: field does not exist in the config Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 374 +++++++++++++++------------ packages/cli/binding/src/cli.rs | 30 ++- 2 files changed, 235 insertions(+), 169 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 4f16f84c4f..b2064edeb0 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -15,13 +15,35 @@ use oxc_allocator::Allocator; use rustc_hash::FxHashMap; use vite_path::AbsolutePath; +/// The result of statically analyzing a single config field's value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StaticFieldValue { + /// The field value was successfully extracted as a JSON literal. + Json(serde_json::Value), + /// The field exists but its value is not a pure JSON literal (e.g. contains + /// function calls, variables, template literals with expressions, etc.) + NonStatic, +} + +/// The result of statically analyzing a vite config file. +/// +/// - `None` — the config is not analyzable (no config file found, parse error, +/// no `export default`, or the default export is not an object literal). +/// The caller should fall back to a runtime evaluation (e.g. NAPI). +/// - `Some(map)` — the default export object was successfully located. +/// - Key maps to [`StaticFieldValue::Json`] — field value was extracted. +/// - Key maps to [`StaticFieldValue::NonStatic`] — field exists but its value +/// cannot be represented as pure JSON. +/// - Key absent — the field does not exist in the object. +pub type StaticConfig = Option, StaticFieldValue>>; + /// Config file names to try, in priority order. /// This matches Vite's `DEFAULT_CONFIG_FILES`: -/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L98-L105 +/// /// /// Vite resolves config files by iterating this list and checking `fs.existsSync` — no -/// module resolution involved, so oxc_resolver is not needed here: -/// https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/config.ts#L2231-L2237 +/// module resolution involved, so `oxc_resolver` is not needed here: +/// const CONFIG_FILE_NAMES: &[&str] = &[ "vite.config.js", "vite.config.mjs", @@ -46,25 +68,11 @@ fn resolve_config_path(dir: &AbsolutePath) -> Option /// Resolve and parse a vite config file from the given directory. /// -/// Returns a map of top-level field names to their JSON values for fields -/// whose values are pure JSON literals. Fields with non-JSON values (function calls, -/// variables, template literals, etc.) are skipped. -/// -/// # Arguments -/// * `dir` - The directory to search for a vite config file -/// -/// # Returns -/// A map of field name to JSON value for all statically extractable fields. -/// Returns an empty map if no config file is found or if it cannot be parsed. +/// See [`StaticConfig`] for the return type semantics. #[must_use] -pub fn resolve_static_config(dir: &AbsolutePath) -> FxHashMap, serde_json::Value> { - let Some(config_path) = resolve_config_path(dir) else { - return FxHashMap::default(); - }; - - let Ok(source) = std::fs::read_to_string(&config_path) else { - return FxHashMap::default(); - }; +pub fn resolve_static_config(dir: &AbsolutePath) -> StaticConfig { + let config_path = resolve_config_path(dir)?; + let source = std::fs::read_to_string(&config_path).ok()?; let extension = config_path.as_path().extension().and_then(|e| e.to_str()).unwrap_or(""); @@ -76,18 +84,19 @@ pub fn resolve_static_config(dir: &AbsolutePath) -> FxHashMap, serde_js } /// Parse a JSON config file into a map of field names to values. -fn parse_json_config(source: &str) -> FxHashMap, serde_json::Value> { - let Ok(value) = serde_json::from_str::(source) else { - return FxHashMap::default(); - }; - let Some(obj) = value.as_object() else { - return FxHashMap::default(); - }; - obj.iter().map(|(k, v)| (Box::from(k.as_str()), v.clone())).collect() +/// All fields in a valid JSON object are fully static. +fn parse_json_config(source: &str) -> StaticConfig { + let value: serde_json::Value = serde_json::from_str(source).ok()?; + let obj = value.as_object()?; + Some( + obj.iter() + .map(|(k, v)| (Box::from(k.as_str()), StaticFieldValue::Json(v.clone()))) + .collect(), + ) } /// Parse a JS/TS config file, extracting the default export object's fields. -fn parse_js_ts_config(source: &str, extension: &str) -> FxHashMap, serde_json::Value> { +fn parse_js_ts_config(source: &str, extension: &str) -> StaticConfig { let allocator = Allocator::default(); let source_type = match extension { "ts" | "mts" | "cts" => SourceType::ts(), @@ -98,7 +107,7 @@ fn parse_js_ts_config(source: &str, extension: &str) -> FxHashMap, serd let result = parser.parse(); if result.panicked || !result.errors.is_empty() { - return FxHashMap::default(); + return None; } extract_default_export_fields(&result.program) @@ -106,10 +115,13 @@ fn parse_js_ts_config(source: &str, extension: &str) -> FxHashMap, serd /// Find the default export in a parsed program and extract its object fields. /// +/// Returns `None` if no `export default` is found or the exported value is not +/// an object literal (or `defineConfig({...})` call). +/// /// Supports two patterns: /// 1. `export default defineConfig({ ... })` /// 2. `export default { ... }` -fn extract_default_export_fields(program: &Program<'_>) -> FxHashMap, serde_json::Value> { +fn extract_default_export_fields(program: &Program<'_>) -> StaticConfig { for stmt in &program.body { let Statement::ExportDefaultDeclaration(decl) = stmt else { continue; @@ -126,23 +138,28 @@ fn extract_default_export_fields(program: &Program<'_>) -> FxHashMap, s // Pattern: export default defineConfig({ ... }) Expression::CallExpression(call) => { if !is_define_config_call(&call.callee) { - continue; + // Unknown function call — not analyzable + return None; } if let Some(first_arg) = call.arguments.first() && let Some(Expression::ObjectExpression(obj)) = first_arg.as_expression() { - return extract_object_fields(obj); + return Some(extract_object_fields(obj)); } + // defineConfig() with non-object arg — not analyzable + return None; } // Pattern: export default { ... } Expression::ObjectExpression(obj) => { - return extract_object_fields(obj); + return Some(extract_object_fields(obj)); } - _ => {} + // e.g. export default 42, export default someVar — not analyzable + _ => return None, } } - FxHashMap::default() + // No export default found + None } /// Check if a callee expression is `defineConfig`. @@ -151,19 +168,21 @@ fn is_define_config_call(callee: &Expression<'_>) -> bool { } /// Extract fields from an object expression, converting each value to JSON. -/// Fields whose values cannot be represented as pure JSON are skipped. +/// Fields whose values cannot be represented as pure JSON are recorded as +/// [`StaticFieldValue::NonStatic`]. Spread elements and computed properties +/// are not representable so they are silently skipped (their keys are unknown). fn extract_object_fields( obj: &oxc::ast::ast::ObjectExpression<'_>, -) -> FxHashMap, serde_json::Value> { +) -> FxHashMap, StaticFieldValue> { let mut map = FxHashMap::default(); for prop in &obj.properties { let ObjectPropertyKind::ObjectProperty(prop) = prop else { - // Skip spread elements + // Spread elements — keys are unknown at static analysis time continue; }; - // Skip computed properties + // Computed properties — keys are unknown at static analysis time if prop.computed { continue; } @@ -172,9 +191,9 @@ fn extract_object_fields( continue; }; - if let Some(value) = expr_to_json(&prop.value) { - map.insert(key, value); - } + let value = expr_to_json(&prop.value) + .map_or(StaticFieldValue::NonStatic, StaticFieldValue::Json); + map.insert(key, value); } map @@ -321,8 +340,28 @@ mod tests { use super::*; - fn parse(source: &str) -> FxHashMap, serde_json::Value> { - parse_js_ts_config(source, "ts") + /// Helper: parse JS/TS source, unwrap the `Some` (asserting it's analyzable), + /// and return the field map. + fn parse(source: &str) -> FxHashMap, StaticFieldValue> { + parse_js_ts_config(source, "ts").expect("expected analyzable config") + } + + /// Shorthand for asserting a field extracted as JSON. + fn assert_json( + map: &FxHashMap, StaticFieldValue>, + key: &str, + expected: serde_json::Value, + ) { + assert_eq!(map.get(key), Some(&StaticFieldValue::Json(expected))); + } + + /// Shorthand for asserting a field is `NonStatic`. + fn assert_non_static(map: &FxHashMap, StaticFieldValue>, key: &str) { + assert_eq!( + map.get(key), + Some(&StaticFieldValue::NonStatic), + "expected field {key:?} to be NonStatic" + ); } // ── Config file resolution ────────────────────────────────────────── @@ -332,7 +371,7 @@ mod tests { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); std::fs::write(dir.path().join("vite.config.ts"), "export default { run: {} }").unwrap(); - let result = resolve_static_config(&dir_path); + let result = resolve_static_config(&dir_path).unwrap(); assert!(result.contains_key("run")); } @@ -341,7 +380,7 @@ mod tests { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); std::fs::write(dir.path().join("vite.config.js"), "export default { run: {} }").unwrap(); - let result = resolve_static_config(&dir_path); + let result = resolve_static_config(&dir_path).unwrap(); assert!(result.contains_key("run")); } @@ -350,7 +389,7 @@ mod tests { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); std::fs::write(dir.path().join("vite.config.mts"), "export default { run: {} }").unwrap(); - let result = resolve_static_config(&dir_path); + let result = resolve_static_config(&dir_path).unwrap(); assert!(result.contains_key("run")); } @@ -362,17 +401,16 @@ mod tests { .unwrap(); std::fs::write(dir.path().join("vite.config.js"), "export default { fromJs: true }") .unwrap(); - let result = resolve_static_config(&dir_path); + let result = resolve_static_config(&dir_path).unwrap(); assert!(result.contains_key("fromJs")); assert!(!result.contains_key("fromTs")); } #[test] - fn returns_empty_for_no_config() { + fn returns_none_for_no_config() { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - let result = resolve_static_config(&dir_path); - assert!(result.is_empty()); + assert!(resolve_static_config(&dir_path).is_none()); } // ── JSON config parsing ───────────────────────────────────────────── @@ -386,9 +424,12 @@ mod tests { r#"export default { run: { tasks: { build: { command: "echo hello" } } } }"#, ) .unwrap(); - let result = resolve_static_config(&dir_path); - let run = result.get("run").unwrap(); - assert_eq!(run, &serde_json::json!({ "tasks": { "build": { "command": "echo hello" } } })); + let result = resolve_static_config(&dir_path).unwrap(); + assert_json( + &result, + "run", + serde_json::json!({ "tasks": { "build": { "command": "echo hello" } } }), + ); } // ── export default { ... } ────────────────────────────────────────── @@ -396,8 +437,8 @@ mod tests { #[test] fn plain_export_default_object() { let result = parse("export default { foo: 'bar', num: 42 }"); - assert_eq!(result.get("foo").unwrap(), &serde_json::json!("bar")); - assert_eq!(result.get("num").unwrap(), &serde_json::json!(42)); + assert_json(&result, "foo", serde_json::json!("bar")); + assert_json(&result, "num", serde_json::json!(42)); } #[test] @@ -411,16 +452,16 @@ mod tests { #[test] fn define_config_call() { let result = parse( - r#" + r" import { defineConfig } from 'vite-plus'; export default defineConfig({ run: { cacheScripts: true }, lint: { plugins: ['a'] }, }); - "#, + ", ); - assert_eq!(result.get("run").unwrap(), &serde_json::json!({ "cacheScripts": true })); - assert_eq!(result.get("lint").unwrap(), &serde_json::json!({ "plugins": ["a"] })); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_json(&result, "lint", serde_json::json!({ "plugins": ["a"] })); } // ── Primitive values ──────────────────────────────────────────────── @@ -428,30 +469,30 @@ mod tests { #[test] fn string_values() { let result = parse(r#"export default { a: "double", b: 'single' }"#); - assert_eq!(result.get("a").unwrap(), &serde_json::json!("double")); - assert_eq!(result.get("b").unwrap(), &serde_json::json!("single")); + assert_json(&result, "a", serde_json::json!("double")); + assert_json(&result, "b", serde_json::json!("single")); } #[test] fn numeric_values() { - let result = parse("export default { a: 42, b: 3.14, c: 0, d: -1 }"); - assert_eq!(result.get("a").unwrap(), &serde_json::json!(42)); - assert_eq!(result.get("b").unwrap(), &serde_json::json!(3.14)); - assert_eq!(result.get("c").unwrap(), &serde_json::json!(0)); - assert_eq!(result.get("d").unwrap(), &serde_json::json!(-1)); + let result = parse("export default { a: 42, b: 1.5, c: 0, d: -1 }"); + assert_json(&result, "a", serde_json::json!(42)); + assert_json(&result, "b", serde_json::json!(1.5)); + assert_json(&result, "c", serde_json::json!(0)); + assert_json(&result, "d", serde_json::json!(-1)); } #[test] fn boolean_values() { let result = parse("export default { a: true, b: false }"); - assert_eq!(result.get("a").unwrap(), &serde_json::json!(true)); - assert_eq!(result.get("b").unwrap(), &serde_json::json!(false)); + assert_json(&result, "a", serde_json::json!(true)); + assert_json(&result, "b", serde_json::json!(false)); } #[test] fn null_value() { let result = parse("export default { a: null }"); - assert_eq!(result.get("a").unwrap(), &serde_json::Value::Null); + assert_json(&result, "a", serde_json::Value::Null); } // ── Arrays ────────────────────────────────────────────────────────── @@ -459,19 +500,19 @@ mod tests { #[test] fn array_of_strings() { let result = parse("export default { items: ['a', 'b', 'c'] }"); - assert_eq!(result.get("items").unwrap(), &serde_json::json!(["a", "b", "c"])); + assert_json(&result, "items", serde_json::json!(["a", "b", "c"])); } #[test] fn nested_arrays() { let result = parse("export default { matrix: [[1, 2], [3, 4]] }"); - assert_eq!(result.get("matrix").unwrap(), &serde_json::json!([[1, 2], [3, 4]])); + assert_json(&result, "matrix", serde_json::json!([[1, 2], [3, 4]])); } #[test] fn empty_array() { let result = parse("export default { items: [] }"); - assert_eq!(result.get("items").unwrap(), &serde_json::json!([])); + assert_json(&result, "items", serde_json::json!([])); } // ── Nested objects ────────────────────────────────────────────────── @@ -491,9 +532,10 @@ mod tests { } }"#, ); - assert_eq!( - result.get("run").unwrap(), - &serde_json::json!({ + assert_json( + &result, + "run", + serde_json::json!({ "tasks": { "build": { "command": "echo build", @@ -501,109 +543,110 @@ mod tests { "cache": true, } } - }) + }), ); } - // ── Skipping non-JSON fields ──────────────────────────────────────── + // ── NonStatic fields ──────────────────────────────────────────────── #[test] - fn skips_function_call_values() { + fn non_static_function_call_values() { let result = parse( - r#"export default { + r"export default { run: { cacheScripts: true }, plugins: [myPlugin()], - }"#, + }", ); - assert!(result.contains_key("run")); - assert!(!result.contains_key("plugins")); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_non_static(&result, "plugins"); } #[test] - fn skips_identifier_values() { + fn non_static_identifier_values() { let result = parse( - r#" + r" const myVar = 'hello'; export default { a: myVar, b: 42 } - "#, + ", ); - assert!(!result.contains_key("a")); - assert!(result.contains_key("b")); + assert_non_static(&result, "a"); + assert_json(&result, "b", serde_json::json!(42)); } #[test] - fn skips_template_literal_with_expressions() { + fn non_static_template_literal_with_expressions() { let result = parse( - r#" + r" const x = 'world'; export default { a: `hello ${x}`, b: 'plain' } - "#, + ", ); - assert!(!result.contains_key("a")); - assert!(result.contains_key("b")); + assert_non_static(&result, "a"); + assert_json(&result, "b", serde_json::json!("plain")); } #[test] fn keeps_pure_template_literal() { let result = parse("export default { a: `hello` }"); - assert_eq!(result.get("a").unwrap(), &serde_json::json!("hello")); + assert_json(&result, "a", serde_json::json!("hello")); } #[test] - fn skips_spread_in_object_value() { + fn non_static_spread_in_object_value() { let result = parse( - r#" + r" const base = { x: 1 }; export default { a: { ...base, y: 2 }, b: 'ok' } - "#, + ", ); - assert!(!result.contains_key("a")); - assert!(result.contains_key("b")); + assert_non_static(&result, "a"); + assert_json(&result, "b", serde_json::json!("ok")); } #[test] - fn skips_spread_in_top_level() { + fn spread_in_top_level_skipped() { let result = parse( - r#" + r" const base = { x: 1 }; export default { ...base, b: 'ok' } - "#, + ", ); - // Spread at top level is skipped; plain fields are kept + // Spread at top level — keys unknown, so not in map at all assert!(!result.contains_key("x")); - assert!(result.contains_key("b")); + assert_json(&result, "b", serde_json::json!("ok")); } #[test] - fn skips_computed_properties() { + fn computed_properties_skipped() { let result = parse( - r#" + r" const key = 'dynamic'; export default { [key]: 'value', plain: 'ok' } - "#, + ", ); + // Computed key — not in map at all (key is unknown) assert!(!result.contains_key("dynamic")); - assert!(result.contains_key("plain")); + assert_json(&result, "plain", serde_json::json!("ok")); } #[test] - fn skips_array_with_spread() { + fn non_static_array_with_spread() { let result = parse( - r#" + r" const arr = [1, 2]; export default { a: [...arr, 3], b: 'ok' } - "#, + ", ); - assert!(!result.contains_key("a")); - assert!(result.contains_key("b")); + assert_non_static(&result, "a"); + assert_json(&result, "b", serde_json::json!("ok")); } // ── Property key types ────────────────────────────────────────────── #[test] fn string_literal_keys() { - let result = parse(r#"export default { 'string-key': 42 }"#); - assert_eq!(result.get("string-key").unwrap(), &serde_json::json!(42)); + let result = parse(r"export default { 'string-key': 42 }"); + assert_json(&result, "string-key", serde_json::json!(42)); } // ── Real-world patterns ───────────────────────────────────────────── @@ -624,23 +667,24 @@ mod tests { }; "#, ); - assert_eq!( - result.get("run").unwrap(), - &serde_json::json!({ + assert_json( + &result, + "run", + serde_json::json!({ "tasks": { "build": { "command": "echo 'build from vite.config.ts'", "dependsOn": [], } } - }) + }), ); } #[test] fn real_world_with_non_json_fields() { let result = parse( - r#" + r" import { defineConfig } from 'vite-plus'; export default defineConfig({ @@ -658,68 +702,76 @@ mod tests { }, }, }); - "#, + ", ); - assert!(result.contains_key("lint")); - assert!(result.contains_key("run")); - assert_eq!( - result.get("run").unwrap(), - &serde_json::json!({ + assert_json( + &result, + "lint", + serde_json::json!({ + "plugins": ["unicorn", "typescript"], + "rules": { + "no-console": ["error", { "allow": ["error"] }], + }, + }), + ); + assert_json( + &result, + "run", + serde_json::json!({ "tasks": { "build:src": { "command": "vp run rolldown#build-binding:release", } } - }) + }), ); } #[test] fn skips_non_default_exports() { let result = parse( - r#" + r" export const config = { a: 1 }; export default { b: 2 }; - "#, + ", ); assert!(!result.contains_key("a")); - assert!(result.contains_key("b")); + assert_json(&result, "b", serde_json::json!(2)); } + // ── Not analyzable cases (return None) ────────────────────────────── + #[test] - fn returns_empty_for_no_default_export() { - let result = parse("export const config = { a: 1 };"); - assert!(result.is_empty()); + fn returns_none_for_no_default_export() { + assert!(parse_js_ts_config("export const config = { a: 1 };", "ts").is_none()); } #[test] - fn returns_empty_for_non_object_default_export() { - let result = parse("export default 42;"); - assert!(result.is_empty()); + fn returns_none_for_non_object_default_export() { + assert!(parse_js_ts_config("export default 42;", "ts").is_none()); } #[test] - fn returns_empty_for_unknown_function_call() { - let result = parse("export default someOtherFn({ a: 1 });"); - assert!(result.is_empty()); + fn returns_none_for_unknown_function_call() { + assert!(parse_js_ts_config("export default someOtherFn({ a: 1 });", "ts").is_none()); } #[test] fn handles_trailing_commas() { let result = parse( - r#"export default { + r"export default { a: [1, 2, 3,], b: { x: 1, y: 2, }, - }"#, + }", ); - assert_eq!(result.get("a").unwrap(), &serde_json::json!([1, 2, 3])); - assert_eq!(result.get("b").unwrap(), &serde_json::json!({ "x": 1, "y": 2 })); + assert_json(&result, "a", serde_json::json!([1, 2, 3])); + assert_json(&result, "b", serde_json::json!({ "x": 1, "y": 2 })); } #[test] fn task_with_cache_config() { let result = parse( - r#"export default { + r"export default { run: { tasks: { hello: { @@ -729,11 +781,12 @@ mod tests { }, }, }, - }"#, + }", ); - assert_eq!( - result.get("run").unwrap(), - &serde_json::json!({ + assert_json( + &result, + "run", + serde_json::json!({ "tasks": { "hello": { "command": "node hello.mjs", @@ -741,14 +794,14 @@ mod tests { "cache": true, } } - }) + }), ); } #[test] - fn skips_method_call_in_nested_value() { + fn non_static_method_call_in_nested_value() { let result = parse( - r#"export default { + r"export default { run: { tasks: { 'build:src': { @@ -757,23 +810,22 @@ mod tests { }, }, lint: { plugins: ['a'] }, - }"#, + }", ); - // `run` should be skipped because its nested value contains a method call - assert!(!result.contains_key("run")); - // `lint` is pure JSON and should be kept - assert!(result.contains_key("lint")); + // `run` is NonStatic because its nested value contains a method call + assert_non_static(&result, "run"); + assert_json(&result, "lint", serde_json::json!({ "plugins": ["a"] })); } #[test] fn cache_scripts_only() { let result = parse( - r#"export default { + r"export default { run: { cacheScripts: true, }, - }"#, + }", ); - assert_eq!(result.get("run").unwrap(), &serde_json::json!({ "cacheScripts": true })); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); } } diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index 40b5766cf7..354b27e75a 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -656,14 +656,28 @@ impl UserConfigLoader for VitePlusConfigLoader { package_path: &AbsolutePath, ) -> anyhow::Result> { // Try static config extraction first (no JS runtime needed) - let static_config = vite_static_config::resolve_static_config(package_path); - if let Some(run_value) = static_config.get("run") { - tracing::debug!( - "Using statically extracted run config for {}", - package_path.as_path().display() - ); - let run_config: UserRunConfig = serde_json::from_value(run_value.clone())?; - return Ok(Some(run_config)); + if let Some(static_fields) = vite_static_config::resolve_static_config(package_path) { + match static_fields.get("run") { + Some(vite_static_config::StaticFieldValue::Json(run_value)) => { + tracing::debug!( + "Using statically extracted run config for {}", + package_path.as_path().display() + ); + let run_config: UserRunConfig = serde_json::from_value(run_value.clone())?; + return Ok(Some(run_config)); + } + Some(vite_static_config::StaticFieldValue::NonStatic) => { + // `run` field exists but contains non-static values — fall back to NAPI + tracing::debug!( + "run config is not statically analyzable for {}, falling back to NAPI", + package_path.as_path().display() + ); + } + None => { + // Config was analyzed successfully but has no `run` field + return Ok(None); + } + } } // Fall back to NAPI-based config resolution From 86d928eb95bb4b6f1db148d9017adf1cc61d5f60 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 10:24:16 +0800 Subject: [PATCH 06/38] refactor(static-config): use oxc utility methods instead of manual matching - PropertyKey::static_name() replaces property_key_to_string and property_key_to_json_key - TemplateLiteral::single_quasi() replaces manual quasis/expressions check - Expression::is_specific_id() replaces is_define_config_call helper - ArrayExpressionElement::is_elision()/is_spread() replaces variant matching - ObjectPropertyKind::is_spread() replaces variant matching Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 117 +++++++-------------------- 1 file changed, 28 insertions(+), 89 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index b2064edeb0..8174d07f6e 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -5,9 +5,7 @@ //! config like `run` without needing a Node.js runtime. use oxc::{ - ast::ast::{ - ArrayExpressionElement, Expression, ObjectPropertyKind, Program, PropertyKey, Statement, - }, + ast::ast::{Expression, ObjectPropertyKind, Program, Statement}, parser::Parser, span::SourceType, }; @@ -137,7 +135,7 @@ fn extract_default_export_fields(program: &Program<'_>) -> StaticConfig { match expr { // Pattern: export default defineConfig({ ... }) Expression::CallExpression(call) => { - if !is_define_config_call(&call.callee) { + if !call.callee.is_specific_id("defineConfig") { // Unknown function call — not analyzable return None; } @@ -162,11 +160,6 @@ fn extract_default_export_fields(program: &Program<'_>) -> StaticConfig { None } -/// Check if a callee expression is `defineConfig`. -fn is_define_config_call(callee: &Expression<'_>) -> bool { - matches!(callee, Expression::Identifier(ident) if ident.name == "defineConfig") -} - /// Extract fields from an object expression, converting each value to JSON. /// Fields whose values cannot be represented as pure JSON are recorded as /// [`StaticFieldValue::NonStatic`]. Spread elements and computed properties @@ -177,48 +170,27 @@ fn extract_object_fields( let mut map = FxHashMap::default(); for prop in &obj.properties { - let ObjectPropertyKind::ObjectProperty(prop) = prop else { + if prop.is_spread() { // Spread elements — keys are unknown at static analysis time continue; - }; - - // Computed properties — keys are unknown at static analysis time - if prop.computed { - continue; } + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + continue; + }; - let Some(key) = property_key_to_string(&prop.key) else { + let Some(key) = prop.key.static_name() else { + // Computed properties — keys are unknown at static analysis time continue; }; - let value = expr_to_json(&prop.value) - .map_or(StaticFieldValue::NonStatic, StaticFieldValue::Json); - map.insert(key, value); + let value = + expr_to_json(&prop.value).map_or(StaticFieldValue::NonStatic, StaticFieldValue::Json); + map.insert(Box::from(key.as_ref()), value); } map } -/// Convert a property key to a string. -fn property_key_to_string(key: &PropertyKey<'_>) -> Option> { - match key { - PropertyKey::StaticIdentifier(ident) => Some(Box::from(ident.name.as_str())), - PropertyKey::StringLiteral(lit) => Some(Box::from(lit.value.as_str())), - PropertyKey::NumericLiteral(lit) => { - let s = if lit.value.fract() == 0.0 && lit.value.is_finite() { - #[expect(clippy::cast_possible_truncation)] - { - (lit.value as i64).to_string() - } - } else { - lit.value.to_string() - }; - Some(Box::from(s.as_str())) - } - _ => None, - } -} - /// Convert an f64 to a JSON value, preserving integers when possible. #[expect(clippy::cast_possible_truncation, clippy::cast_precision_loss)] fn f64_to_json_number(value: f64) -> serde_json::Value { @@ -252,13 +224,8 @@ fn expr_to_json(expr: &Expression<'_>) -> Option { Expression::StringLiteral(lit) => Some(serde_json::Value::String(lit.value.to_string())), Expression::TemplateLiteral(lit) => { - // Only convert template literals with no expressions (pure strings) - if lit.expressions.is_empty() && lit.quasis.len() == 1 { - let raw = &lit.quasis[0].value.cooked.as_ref()?; - Some(serde_json::Value::String(raw.to_string())) - } else { - None - } + let quasi = lit.single_quasi()?; + Some(serde_json::Value::String(quasi.to_string())) } Expression::UnaryExpression(unary) => { @@ -274,17 +241,13 @@ fn expr_to_json(expr: &Expression<'_>) -> Option { Expression::ArrayExpression(arr) => { let mut values = Vec::with_capacity(arr.elements.len()); for elem in &arr.elements { - match elem { - ArrayExpressionElement::Elision(_) => { - values.push(serde_json::Value::Null); - } - ArrayExpressionElement::SpreadElement(_) => { - return None; - } - _ => { - let elem_expr = elem.as_expression()?; - values.push(expr_to_json(elem_expr)?); - } + if elem.is_elision() { + values.push(serde_json::Value::Null); + } else if elem.is_spread() { + return None; + } else { + let elem_expr = elem.as_expression()?; + values.push(expr_to_json(elem_expr)?); } } Some(serde_json::Value::Array(values)) @@ -293,19 +256,15 @@ fn expr_to_json(expr: &Expression<'_>) -> Option { Expression::ObjectExpression(obj) => { let mut map = serde_json::Map::new(); for prop in &obj.properties { - match prop { - ObjectPropertyKind::ObjectProperty(prop) => { - if prop.computed { - return None; - } - let key = property_key_to_json_key(&prop.key)?; - let value = expr_to_json(&prop.value)?; - map.insert(key, value); - } - ObjectPropertyKind::SpreadProperty(_) => { - return None; - } + if prop.is_spread() { + return None; } + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + continue; + }; + let key = prop.key.static_name()?; + let value = expr_to_json(&prop.value)?; + map.insert(key.into_owned(), value); } Some(serde_json::Value::Object(map)) } @@ -314,26 +273,6 @@ fn expr_to_json(expr: &Expression<'_>) -> Option { } } -/// Convert a property key to a JSON-compatible string key. -#[expect(clippy::disallowed_types)] -fn property_key_to_json_key(key: &PropertyKey<'_>) -> Option { - match key { - PropertyKey::StaticIdentifier(ident) => Some(ident.name.to_string()), - PropertyKey::StringLiteral(lit) => Some(lit.value.to_string()), - PropertyKey::NumericLiteral(lit) => { - if lit.value.fract() == 0.0 && lit.value.is_finite() { - #[expect(clippy::cast_possible_truncation)] - { - Some((lit.value as i64).to_string()) - } - } else { - Some(lit.value.to_string()) - } - } - _ => None, - } -} - #[cfg(test)] mod tests { use tempfile::TempDir; From dd117c3505395267ef71eb63b55bb6b75e8c2575 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 10:28:42 +0800 Subject: [PATCH 07/38] feat(static-config): support module.exports CJS pattern Add support for CommonJS config files: - module.exports = { ... } - module.exports = defineConfig({ ... }) Refactored shared config extraction into extract_config_from_expr, used by both export default and module.exports paths. Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 110 ++++++++++++++++++--------- 1 file changed, 75 insertions(+), 35 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 8174d07f6e..cf1882e879 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -108,56 +108,62 @@ fn parse_js_ts_config(source: &str, extension: &str) -> StaticConfig { return None; } - extract_default_export_fields(&result.program) + extract_config_fields(&result.program) } -/// Find the default export in a parsed program and extract its object fields. +/// Find the config object in a parsed program and extract its fields. /// -/// Returns `None` if no `export default` is found or the exported value is not -/// an object literal (or `defineConfig({...})` call). -/// -/// Supports two patterns: +/// Searches for the config value in the following patterns (in order): /// 1. `export default defineConfig({ ... })` /// 2. `export default { ... }` -fn extract_default_export_fields(program: &Program<'_>) -> StaticConfig { +/// 3. `module.exports = defineConfig({ ... })` +/// 4. `module.exports = { ... }` +fn extract_config_fields(program: &Program<'_>) -> StaticConfig { for stmt in &program.body { - let Statement::ExportDefaultDeclaration(decl) = stmt else { - continue; - }; + // ESM: export default ... + if let Statement::ExportDefaultDeclaration(decl) = stmt { + if let Some(expr) = decl.declaration.as_expression() { + return extract_config_from_expr(expr); + } + // export default class/function — not analyzable + return None; + } - let Some(expr) = decl.declaration.as_expression() else { - continue; - }; + // CJS: module.exports = ... + if let Statement::ExpressionStatement(expr_stmt) = stmt + && let Expression::AssignmentExpression(assign) = &expr_stmt.expression + && assign.left.as_member_expression().is_some_and(|m| { + m.object().is_specific_id("module") && m.static_property_name() == Some("exports") + }) + { + return extract_config_from_expr(&assign.right); + } + } - // Unwrap parenthesized expressions - let expr = expr.without_parentheses(); + None +} - match expr { - // Pattern: export default defineConfig({ ... }) - Expression::CallExpression(call) => { - if !call.callee.is_specific_id("defineConfig") { - // Unknown function call — not analyzable - return None; - } - if let Some(first_arg) = call.arguments.first() - && let Some(Expression::ObjectExpression(obj)) = first_arg.as_expression() - { - return Some(extract_object_fields(obj)); - } - // defineConfig() with non-object arg — not analyzable +/// Extract the config object from an expression that is either: +/// - `defineConfig({ ... })` → extract the object argument +/// - `{ ... }` → extract directly +/// - anything else → not analyzable +fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { + let expr = expr.without_parentheses(); + match expr { + Expression::CallExpression(call) => { + if !call.callee.is_specific_id("defineConfig") { return None; } - // Pattern: export default { ... } - Expression::ObjectExpression(obj) => { + if let Some(first_arg) = call.arguments.first() + && let Some(Expression::ObjectExpression(obj)) = first_arg.as_expression() + { return Some(extract_object_fields(obj)); } - // e.g. export default 42, export default someVar — not analyzable - _ => return None, + None } + Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), + _ => None, } - - // No export default found - None } /// Extract fields from an object expression, converting each value to JSON. @@ -403,6 +409,40 @@ mod tests { assert_json(&result, "lint", serde_json::json!({ "plugins": ["a"] })); } + // ── module.exports = { ... } ─────────────────────────────────────── + + #[test] + fn module_exports_object() { + let result = parse_js_ts_config("module.exports = { run: { cache: true } }", "cjs") + .expect("expected analyzable config"); + assert_json(&result, "run", serde_json::json!({ "cache": true })); + } + + #[test] + fn module_exports_define_config() { + let result = parse_js_ts_config( + r" + const { defineConfig } = require('vite-plus'); + module.exports = defineConfig({ + run: { cacheScripts: true }, + }); + ", + "cjs", + ) + .expect("expected analyzable config"); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + + #[test] + fn module_exports_non_object() { + assert!(parse_js_ts_config("module.exports = 42;", "cjs").is_none()); + } + + #[test] + fn module_exports_unknown_call() { + assert!(parse_js_ts_config("module.exports = otherFn({ a: 1 });", "cjs").is_none()); + } + // ── Primitive values ──────────────────────────────────────────────── #[test] From 52fa070571178139b810e99bebc40d0fd27bcb76 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 10:36:57 +0800 Subject: [PATCH 08/38] refactor(static-config): use specific oxc_* crates and add README Replace umbrella `oxc` dependency with `oxc_ast`, `oxc_parser`, and `oxc_span` for more precise dependency tracking. Add README documenting supported patterns, config resolution order, return type semantics, and limitations. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 4 +- Cargo.toml | 3 ++ crates/vite_static_config/Cargo.toml | 4 +- crates/vite_static_config/README.md | 56 ++++++++++++++++++++++++++++ crates/vite_static_config/src/lib.rs | 12 +++--- 5 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 crates/vite_static_config/README.md diff --git a/Cargo.lock b/Cargo.lock index eb7e4eec1b..3d0e83ccdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7370,8 +7370,10 @@ dependencies = [ name = "vite_static_config" version = "0.0.0" dependencies = [ - "oxc", "oxc_allocator", + "oxc_ast", + "oxc_parser", + "oxc_span", "rustc-hash", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index cead737b9e..e4cef43166 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -211,7 +211,10 @@ oxc = { version = "0.115.0", features = [ "cfg", ] } oxc_allocator = { version = "0.115.0", features = ["pool"] } +oxc_ast = "0.115.0" oxc_ecmascript = "0.115.0" +oxc_parser = "0.115.0" +oxc_span = "0.115.0" oxc_napi = "0.115.0" oxc_minify_napi = "0.115.0" oxc_parser_napi = "0.115.0" diff --git a/crates/vite_static_config/Cargo.toml b/crates/vite_static_config/Cargo.toml index 97870c8f2f..ae9569923d 100644 --- a/crates/vite_static_config/Cargo.toml +++ b/crates/vite_static_config/Cargo.toml @@ -8,8 +8,10 @@ license.workspace = true repository.workspace = true [dependencies] -oxc = { workspace = true } oxc_allocator = { workspace = true } +oxc_ast = { workspace = true } +oxc_parser = { workspace = true } +oxc_span = { workspace = true } rustc-hash = { workspace = true } serde_json = { workspace = true } vite_path = { workspace = true } diff --git a/crates/vite_static_config/README.md b/crates/vite_static_config/README.md new file mode 100644 index 0000000000..1bc6b94386 --- /dev/null +++ b/crates/vite_static_config/README.md @@ -0,0 +1,56 @@ +# vite_static_config + +Statically extracts configuration from `vite.config.*` files without executing JavaScript. + +## What it does + +Parses vite config files using [oxc_parser](https://crates.io/crates/oxc_parser) and extracts +top-level fields whose values are pure JSON literals. This allows reading config like `run` +without needing a Node.js runtime (NAPI). + +## Supported patterns + +**ESM:** +```js +export default { run: { tasks: { build: { command: "echo build" } } } } +export default defineConfig({ run: { cacheScripts: true } }) +``` + +**CJS:** +```js +module.exports = { run: { tasks: { build: { command: "echo build" } } } } +module.exports = defineConfig({ run: { cacheScripts: true } }) +``` + +## Config file resolution + +Searches for config files in the same order as Vite's +[`DEFAULT_CONFIG_FILES`](https://github.com/vitejs/vite/blob/25227bbdc7de0ed07cf7bdc9a1a733e3a9a132bc/packages/vite/src/node/constants.ts#L98-L105): + +1. `vite.config.js` +2. `vite.config.mjs` +3. `vite.config.ts` +4. `vite.config.cjs` +5. `vite.config.mts` +6. `vite.config.cts` + +## Return type + +`resolve_static_config` returns `Option, StaticFieldValue>>`: + +- **`None`** — config is not statically analyzable (no config file, parse error, no + `export default`/`module.exports`, or the exported value is not an object literal). + Caller should fall back to runtime evaluation (e.g. NAPI). +- **`Some(map)`** — config object was successfully located: + - `StaticFieldValue::Json(value)` — field value extracted as pure JSON + - `StaticFieldValue::NonStatic` — field exists but contains non-JSON expressions + (function calls, variables, template literals with interpolation, etc.) + - Key absent — field does not exist in the config object + +## Limitations + +- Only extracts values that are pure JSON literals (strings, numbers, booleans, null, + arrays, and objects composed of these) +- Fields with dynamic values (function calls, variable references, spread operators, + computed properties, template literals with expressions) are reported as `NonStatic` +- Does not follow imports or evaluate expressions diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index cf1882e879..f981a26f93 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -4,12 +4,10 @@ //! top-level fields whose values are pure JSON literals. This allows reading //! config like `run` without needing a Node.js runtime. -use oxc::{ - ast::ast::{Expression, ObjectPropertyKind, Program, Statement}, - parser::Parser, - span::SourceType, -}; use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, ObjectPropertyKind, Program, Statement}; +use oxc_parser::Parser; +use oxc_span::SourceType; use rustc_hash::FxHashMap; use vite_path::AbsolutePath; @@ -171,7 +169,7 @@ fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { /// [`StaticFieldValue::NonStatic`]. Spread elements and computed properties /// are not representable so they are silently skipped (their keys are unknown). fn extract_object_fields( - obj: &oxc::ast::ast::ObjectExpression<'_>, + obj: &oxc_ast::ast::ObjectExpression<'_>, ) -> FxHashMap, StaticFieldValue> { let mut map = FxHashMap::default(); @@ -236,7 +234,7 @@ fn expr_to_json(expr: &Expression<'_>) -> Option { Expression::UnaryExpression(unary) => { // Handle negative numbers: -42 - if unary.operator == oxc::ast::ast::UnaryOperator::UnaryNegation + if unary.operator == oxc_ast::ast::UnaryOperator::UnaryNegation && let Expression::NumericLiteral(lit) = &unary.argument { return Some(f64_to_json_number(-lit.value)); From 9052846f9d2019289b77a6af46aff2eb73631740 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 10:57:07 +0800 Subject: [PATCH 09/38] refactor(static-config): simplify f64_to_json_number and rename FieldValue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite f64_to_json_number to follow JSON.stringify semantics using serde_json's From for the NaN/Infinity→null fallback, and i64::try_from for safe integer conversion. Rename StaticFieldValue to FieldValue for brevity. Add tests for overflow-to-infinity and -0. Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/README.md | 12 +++-- crates/vite_static_config/src/lib.rs | 68 +++++++++++++++------------- packages/cli/binding/src/cli.rs | 4 +- 3 files changed, 45 insertions(+), 39 deletions(-) diff --git a/crates/vite_static_config/README.md b/crates/vite_static_config/README.md index 1bc6b94386..e33cc8f12f 100644 --- a/crates/vite_static_config/README.md +++ b/crates/vite_static_config/README.md @@ -11,15 +11,17 @@ without needing a Node.js runtime (NAPI). ## Supported patterns **ESM:** + ```js export default { run: { tasks: { build: { command: "echo build" } } } } export default defineConfig({ run: { cacheScripts: true } }) ``` **CJS:** + ```js -module.exports = { run: { tasks: { build: { command: "echo build" } } } } -module.exports = defineConfig({ run: { cacheScripts: true } }) +module.exports = { run: { tasks: { build: { command: 'echo build' } } } }; +module.exports = defineConfig({ run: { cacheScripts: true } }); ``` ## Config file resolution @@ -36,14 +38,14 @@ Searches for config files in the same order as Vite's ## Return type -`resolve_static_config` returns `Option, StaticFieldValue>>`: +`resolve_static_config` returns `Option, FieldValue>>`: - **`None`** — config is not statically analyzable (no config file, parse error, no `export default`/`module.exports`, or the exported value is not an object literal). Caller should fall back to runtime evaluation (e.g. NAPI). - **`Some(map)`** — config object was successfully located: - - `StaticFieldValue::Json(value)` — field value extracted as pure JSON - - `StaticFieldValue::NonStatic` — field exists but contains non-JSON expressions + - `FieldValue::Json(value)` — field value extracted as pure JSON + - `FieldValue::NonStatic` — field exists but contains non-JSON expressions (function calls, variables, template literals with interpolation, etc.) - Key absent — field does not exist in the config object diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index f981a26f93..952e53578a 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -13,7 +13,7 @@ use vite_path::AbsolutePath; /// The result of statically analyzing a single config field's value. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum StaticFieldValue { +pub enum FieldValue { /// The field value was successfully extracted as a JSON literal. Json(serde_json::Value), /// The field exists but its value is not a pure JSON literal (e.g. contains @@ -27,11 +27,11 @@ pub enum StaticFieldValue { /// no `export default`, or the default export is not an object literal). /// The caller should fall back to a runtime evaluation (e.g. NAPI). /// - `Some(map)` — the default export object was successfully located. -/// - Key maps to [`StaticFieldValue::Json`] — field value was extracted. -/// - Key maps to [`StaticFieldValue::NonStatic`] — field exists but its value +/// - Key maps to [`FieldValue::Json`] — field value was extracted. +/// - Key maps to [`FieldValue::NonStatic`] — field exists but its value /// cannot be represented as pure JSON. /// - Key absent — the field does not exist in the object. -pub type StaticConfig = Option, StaticFieldValue>>; +pub type StaticConfig = Option, FieldValue>>; /// Config file names to try, in priority order. /// This matches Vite's `DEFAULT_CONFIG_FILES`: @@ -84,11 +84,7 @@ pub fn resolve_static_config(dir: &AbsolutePath) -> StaticConfig { fn parse_json_config(source: &str) -> StaticConfig { let value: serde_json::Value = serde_json::from_str(source).ok()?; let obj = value.as_object()?; - Some( - obj.iter() - .map(|(k, v)| (Box::from(k.as_str()), StaticFieldValue::Json(v.clone()))) - .collect(), - ) + Some(obj.iter().map(|(k, v)| (Box::from(k.as_str()), FieldValue::Json(v.clone()))).collect()) } /// Parse a JS/TS config file, extracting the default export object's fields. @@ -166,11 +162,11 @@ fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { /// Extract fields from an object expression, converting each value to JSON. /// Fields whose values cannot be represented as pure JSON are recorded as -/// [`StaticFieldValue::NonStatic`]. Spread elements and computed properties +/// [`FieldValue::NonStatic`]. Spread elements and computed properties /// are not representable so they are silently skipped (their keys are unknown). fn extract_object_fields( obj: &oxc_ast::ast::ObjectExpression<'_>, -) -> FxHashMap, StaticFieldValue> { +) -> FxHashMap, FieldValue> { let mut map = FxHashMap::default(); for prop in &obj.properties { @@ -187,28 +183,25 @@ fn extract_object_fields( continue; }; - let value = - expr_to_json(&prop.value).map_or(StaticFieldValue::NonStatic, StaticFieldValue::Json); + let value = expr_to_json(&prop.value).map_or(FieldValue::NonStatic, FieldValue::Json); map.insert(Box::from(key.as_ref()), value); } map } -/// Convert an f64 to a JSON value, preserving integers when possible. -#[expect(clippy::cast_possible_truncation, clippy::cast_precision_loss)] +/// Convert an f64 to a JSON value following `JSON.stringify` semantics. +/// `NaN`, `Infinity`, `-Infinity` become `null`; `-0` becomes `0`. fn f64_to_json_number(value: f64) -> serde_json::Value { - // If the value is a whole number that fits in i64, use integer representation + // fract() == 0.0 ensures the value is a whole number, so the cast is lossless. + #[expect(clippy::cast_possible_truncation)] if value.fract() == 0.0 - && value.is_finite() - && value >= i64::MIN as f64 - && value <= i64::MAX as f64 + && let Ok(i) = i64::try_from(value as i128) { - serde_json::Value::Number(serde_json::Number::from(value as i64)) - } else if let Some(n) = serde_json::Number::from_f64(value) { - serde_json::Value::Number(n) + serde_json::Value::from(i) } else { - serde_json::Value::Null + // From for Value: finite → Number, NaN/Infinity → Null + serde_json::Value::from(value) } } @@ -285,24 +278,20 @@ mod tests { /// Helper: parse JS/TS source, unwrap the `Some` (asserting it's analyzable), /// and return the field map. - fn parse(source: &str) -> FxHashMap, StaticFieldValue> { + fn parse(source: &str) -> FxHashMap, FieldValue> { parse_js_ts_config(source, "ts").expect("expected analyzable config") } /// Shorthand for asserting a field extracted as JSON. - fn assert_json( - map: &FxHashMap, StaticFieldValue>, - key: &str, - expected: serde_json::Value, - ) { - assert_eq!(map.get(key), Some(&StaticFieldValue::Json(expected))); + fn assert_json(map: &FxHashMap, FieldValue>, key: &str, expected: serde_json::Value) { + assert_eq!(map.get(key), Some(&FieldValue::Json(expected))); } /// Shorthand for asserting a field is `NonStatic`. - fn assert_non_static(map: &FxHashMap, StaticFieldValue>, key: &str) { + fn assert_non_static(map: &FxHashMap, FieldValue>, key: &str) { assert_eq!( map.get(key), - Some(&StaticFieldValue::NonStatic), + Some(&FieldValue::NonStatic), "expected field {key:?} to be NonStatic" ); } @@ -459,6 +448,21 @@ mod tests { assert_json(&result, "d", serde_json::json!(-1)); } + #[test] + fn numeric_overflow_to_infinity_is_null() { + // 1e999 overflows f64 to Infinity; JSON.stringify(Infinity) === "null" + let result = parse("export default { a: 1e999, b: -1e999 }"); + assert_json(&result, "a", serde_json::Value::Null); + assert_json(&result, "b", serde_json::Value::Null); + } + + #[test] + fn negative_zero_is_zero() { + // JSON.stringify(-0) === "0" + let result = parse("export default { a: -0 }"); + assert_json(&result, "a", serde_json::json!(0)); + } + #[test] fn boolean_values() { let result = parse("export default { a: true, b: false }"); diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index 354b27e75a..5c93c5c140 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -658,7 +658,7 @@ impl UserConfigLoader for VitePlusConfigLoader { // Try static config extraction first (no JS runtime needed) if let Some(static_fields) = vite_static_config::resolve_static_config(package_path) { match static_fields.get("run") { - Some(vite_static_config::StaticFieldValue::Json(run_value)) => { + Some(vite_static_config::FieldValue::Json(run_value)) => { tracing::debug!( "Using statically extracted run config for {}", package_path.as_path().display() @@ -666,7 +666,7 @@ impl UserConfigLoader for VitePlusConfigLoader { let run_config: UserRunConfig = serde_json::from_value(run_value.clone())?; return Ok(Some(run_config)); } - Some(vite_static_config::StaticFieldValue::NonStatic) => { + Some(vite_static_config::FieldValue::NonStatic) => { // `run` field exists but contains non-static values — fall back to NAPI tracing::debug!( "run config is not statically analyzable for {}, falling back to NAPI", From ecbafe218e34689a5f3cd6f9c3df7420d4d67404 Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 12:42:48 +0800 Subject: [PATCH 10/38] feat(static-config): support defineConfig(fn) and skip NAPI when no config file Two improvements to static config extraction: 1. When no vite.config.* file exists in a workspace package, resolve_static_config now returns an empty map (instead of None). The caller sees no `run` field and returns immediately, skipping the NAPI/JS callback. This eliminates ~165ms cold Node.js init + ~3ms/pkg warm overhead for monorepo packages without config files. 2. Support defineConfig(fn) where fn is an arrow function or function expression. The extractor locates the return expression inside the function body and extracts fields from it. Functions with multiple return statements are rejected as not statically analyzable. Co-Authored-By: Claude Opus 4.6 --- crates/vite_static_config/src/lib.rs | 212 +++++++++++++++++++++++++-- 1 file changed, 201 insertions(+), 11 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 952e53578a..466d4869b9 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -23,14 +23,15 @@ pub enum FieldValue { /// The result of statically analyzing a vite config file. /// -/// - `None` — the config is not analyzable (no config file found, parse error, +/// - `None` — the config file exists but is not analyzable (parse error, /// no `export default`, or the default export is not an object literal). /// The caller should fall back to a runtime evaluation (e.g. NAPI). -/// - `Some(map)` — the default export object was successfully located. +/// - `Some(map)` — the config was successfully resolved. +/// - Empty map — no config file was found (caller can skip runtime evaluation). /// - Key maps to [`FieldValue::Json`] — field value was extracted. /// - Key maps to [`FieldValue::NonStatic`] — field exists but its value /// cannot be represented as pure JSON. -/// - Key absent — the field does not exist in the object. +/// - Key absent — the field does not exist in the config. pub type StaticConfig = Option, FieldValue>>; /// Config file names to try, in priority order. @@ -67,7 +68,11 @@ fn resolve_config_path(dir: &AbsolutePath) -> Option /// See [`StaticConfig`] for the return type semantics. #[must_use] pub fn resolve_static_config(dir: &AbsolutePath) -> StaticConfig { - let config_path = resolve_config_path(dir)?; + let Some(config_path) = resolve_config_path(dir) else { + // No config file found — return empty map so the caller can + // skip runtime evaluation (NAPI) entirely. + return Some(FxHashMap::default()); + }; let source = std::fs::read_to_string(&config_path).ok()?; let extension = config_path.as_path().extension().and_then(|e| e.to_str()).unwrap_or(""); @@ -139,6 +144,9 @@ fn extract_config_fields(program: &Program<'_>) -> StaticConfig { /// Extract the config object from an expression that is either: /// - `defineConfig({ ... })` → extract the object argument +/// - `defineConfig(() => ({ ... }))` → extract from arrow function expression body +/// - `defineConfig(() => { return { ... }; })` → extract from return statement +/// - `defineConfig(function() { return { ... }; })` → extract from return statement /// - `{ ... }` → extract directly /// - anything else → not analyzable fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { @@ -148,18 +156,110 @@ fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { if !call.callee.is_specific_id("defineConfig") { return None; } - if let Some(first_arg) = call.arguments.first() - && let Some(Expression::ObjectExpression(obj)) = first_arg.as_expression() - { - return Some(extract_object_fields(obj)); + let first_arg = call.arguments.first()?; + let first_arg_expr = first_arg.as_expression()?; + match first_arg_expr { + Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), + Expression::ArrowFunctionExpression(arrow) => { + extract_config_from_function_body(&arrow.body) + } + Expression::FunctionExpression(func) => { + extract_config_from_function_body(func.body.as_ref()?) + } + _ => None, } - None } Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), _ => None, } } +/// Extract the config object from the body of a function passed to `defineConfig`. +/// +/// Handles two patterns: +/// - Concise arrow body: `() => ({ ... })` — body has a single `ExpressionStatement` +/// - Block body with exactly one return: `() => { ... return { ... }; }` +/// +/// Returns `None` (not analyzable) if the body contains multiple `return` statements +/// (at any nesting depth), since the returned config would depend on runtime control flow. +fn extract_config_from_function_body(body: &oxc_ast::ast::FunctionBody<'_>) -> StaticConfig { + // Reject functions with multiple returns — the config depends on control flow. + if count_returns_in_stmts(&body.statements) > 1 { + return None; + } + + for stmt in &body.statements { + match stmt { + Statement::ReturnStatement(ret) => { + let arg = ret.argument.as_ref()?; + if let Expression::ObjectExpression(obj) = arg.without_parentheses() { + return Some(extract_object_fields(obj)); + } + return None; + } + Statement::ExpressionStatement(expr_stmt) => { + // Concise arrow: `() => ({ ... })` is represented as ExpressionStatement + if let Expression::ObjectExpression(obj) = + expr_stmt.expression.without_parentheses() + { + return Some(extract_object_fields(obj)); + } + } + _ => {} + } + } + None +} + +/// Count `return` statements recursively in a slice of statements. +/// Does not descend into nested function/arrow expressions (they have their own returns). +fn count_returns_in_stmts(stmts: &[Statement<'_>]) -> usize { + let mut count = 0; + for stmt in stmts { + count += count_returns_in_stmt(stmt); + } + count +} + +fn count_returns_in_stmt(stmt: &Statement<'_>) -> usize { + match stmt { + Statement::ReturnStatement(_) => 1, + Statement::BlockStatement(block) => count_returns_in_stmts(&block.body), + Statement::IfStatement(if_stmt) => { + let mut n = count_returns_in_stmt(&if_stmt.consequent); + if let Some(alt) = &if_stmt.alternate { + n += count_returns_in_stmt(alt); + } + n + } + Statement::SwitchStatement(switch) => { + let mut n = 0; + for case in &switch.cases { + n += count_returns_in_stmts(&case.consequent); + } + n + } + Statement::TryStatement(try_stmt) => { + let mut n = count_returns_in_stmts(&try_stmt.block.body); + if let Some(handler) = &try_stmt.handler { + n += count_returns_in_stmts(&handler.body.body); + } + if let Some(finalizer) = &try_stmt.finalizer { + n += count_returns_in_stmts(&finalizer.body); + } + n + } + Statement::ForStatement(s) => count_returns_in_stmt(&s.body), + Statement::ForInStatement(s) => count_returns_in_stmt(&s.body), + Statement::ForOfStatement(s) => count_returns_in_stmt(&s.body), + Statement::WhileStatement(s) => count_returns_in_stmt(&s.body), + Statement::DoWhileStatement(s) => count_returns_in_stmt(&s.body), + Statement::LabeledStatement(s) => count_returns_in_stmt(&s.body), + Statement::WithStatement(s) => count_returns_in_stmt(&s.body), + _ => 0, + } +} + /// Extract fields from an object expression, converting each value to JSON. /// Fields whose values cannot be represented as pure JSON are recorded as /// [`FieldValue::NonStatic`]. Spread elements and computed properties @@ -339,10 +439,11 @@ mod tests { } #[test] - fn returns_none_for_no_config() { + fn returns_empty_map_for_no_config() { let dir = TempDir::new().unwrap(); let dir_path = vite_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - assert!(resolve_static_config(&dir_path).is_none()); + let result = resolve_static_config(&dir_path).unwrap(); + assert!(result.is_empty()); } // ── JSON config parsing ───────────────────────────────────────────── @@ -720,6 +821,95 @@ mod tests { assert_json(&result, "b", serde_json::json!(2)); } + // ── defineConfig with function argument ──────────────────────────── + + #[test] + fn define_config_arrow_block_body() { + let result = parse( + r" + export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + return { + run: { cacheScripts: true }, + plugins: [vue()], + }; + }); + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_non_static(&result, "plugins"); + } + + #[test] + fn define_config_arrow_expression_body() { + let result = parse( + r" + export default defineConfig(() => ({ + run: { cacheScripts: true }, + build: { outDir: 'dist' }, + })); + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_json(&result, "build", serde_json::json!({ "outDir": "dist" })); + } + + #[test] + fn define_config_function_expression() { + let result = parse( + r" + export default defineConfig(function() { + return { + run: { cacheScripts: true }, + plugins: [react()], + }; + }); + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_non_static(&result, "plugins"); + } + + #[test] + fn define_config_arrow_no_return_object() { + // Arrow function that doesn't return an object literal + assert!( + parse_js_ts_config( + r" + export default defineConfig(({ mode }) => { + return someFunction(); + }); + ", + "ts", + ) + .is_none() + ); + } + + #[test] + fn define_config_arrow_multiple_returns() { + // Multiple top-level returns → not analyzable + assert!( + parse_js_ts_config( + r" + export default defineConfig(({ mode }) => { + if (mode === 'production') { + return { run: { cacheScripts: true } }; + } + return { run: { cacheScripts: false } }; + }); + ", + "ts", + ) + .is_none() + ); + } + + #[test] + fn define_config_arrow_empty_body() { + assert!(parse_js_ts_config("export default defineConfig(() => {});", "ts",).is_none()); + } + // ── Not analyzable cases (return None) ────────────────────────────── #[test] From ecc3fc2c12c2b40897d74f3de189b10c98c3f7c4 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Mar 2026 08:53:50 +0800 Subject: [PATCH 11/38] feat(static-config): resolve indirect exports via top-level identifier lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handles the common pattern where the config object is assigned to a variable before being exported or returned: const config = defineConfig({ ... }); export default config; // ESM indirect export module.exports = config; // CJS indirect export return config; // inside defineConfig(fn) callback Resolution scans top-level VariableDeclarator nodes by name (simple binding identifiers only; destructured patterns are skipped). One level of indirection is supported — chained references (const a = b; export default a) are not resolved and fall back to NAPI as before. Fixes tanstack-start-helloworld's ~1.3s config load time. Co-Authored-By: Claude Sonnet 4.6 --- crates/vite_static_config/src/lib.rs | 192 +++++++++++++++++++++++++-- 1 file changed, 179 insertions(+), 13 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 466d4869b9..2802ea2032 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -5,7 +5,7 @@ //! config like `run` without needing a Node.js runtime. use oxc_allocator::Allocator; -use oxc_ast::ast::{Expression, ObjectPropertyKind, Program, Statement}; +use oxc_ast::ast::{BindingPattern, Expression, ObjectPropertyKind, Program, Statement}; use oxc_parser::Parser; use oxc_span::SourceType; use rustc_hash::FxHashMap; @@ -110,19 +110,39 @@ fn parse_js_ts_config(source: &str, extension: &str) -> StaticConfig { extract_config_fields(&result.program) } +/// Scan top-level statements for a `const`/`let`/`var` declarator whose simple +/// binding identifier matches `name`, and return a reference to its initializer. +/// +/// Returns `None` if no match is found or the declarator has no initializer. +/// Destructured bindings (object/array patterns) are intentionally skipped. +fn find_top_level_init<'a>(name: &str, stmts: &'a [Statement<'a>]) -> Option<&'a Expression<'a>> { + for stmt in stmts { + let Statement::VariableDeclaration(decl) = stmt else { continue }; + for declarator in &decl.declarations { + let BindingPattern::BindingIdentifier(ident) = &declarator.id else { continue }; + if ident.name == name { + return declarator.init.as_ref(); + } + } + } + None +} + /// Find the config object in a parsed program and extract its fields. /// /// Searches for the config value in the following patterns (in order): /// 1. `export default defineConfig({ ... })` /// 2. `export default { ... }` -/// 3. `module.exports = defineConfig({ ... })` -/// 4. `module.exports = { ... }` -fn extract_config_fields(program: &Program<'_>) -> StaticConfig { +/// 3. `export default config` where `config` is a top-level variable +/// 4. `module.exports = defineConfig({ ... })` +/// 5. `module.exports = { ... }` +/// 6. `module.exports = config` where `config` is a top-level variable +fn extract_config_fields<'a>(program: &'a Program<'a>) -> StaticConfig { for stmt in &program.body { // ESM: export default ... if let Statement::ExportDefaultDeclaration(decl) = stmt { if let Some(expr) = decl.declaration.as_expression() { - return extract_config_from_expr(expr); + return extract_config_from_expr(expr, &program.body); } // export default class/function — not analyzable return None; @@ -135,7 +155,7 @@ fn extract_config_fields(program: &Program<'_>) -> StaticConfig { m.object().is_specific_id("module") && m.static_property_name() == Some("exports") }) { - return extract_config_from_expr(&assign.right); + return extract_config_from_expr(&assign.right, &program.body); } } @@ -148,8 +168,12 @@ fn extract_config_fields(program: &Program<'_>) -> StaticConfig { /// - `defineConfig(() => { return { ... }; })` → extract from return statement /// - `defineConfig(function() { return { ... }; })` → extract from return statement /// - `{ ... }` → extract directly +/// - `identifier` → look up in `stmts`, then extract (one level of indirection only) /// - anything else → not analyzable -fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { +fn extract_config_from_expr<'a>( + expr: &'a Expression<'a>, + stmts: &'a [Statement<'a>], +) -> StaticConfig { let expr = expr.without_parentheses(); match expr { Expression::CallExpression(call) => { @@ -161,15 +185,21 @@ fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { match first_arg_expr { Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), Expression::ArrowFunctionExpression(arrow) => { - extract_config_from_function_body(&arrow.body) + extract_config_from_function_body(&arrow.body, stmts) } Expression::FunctionExpression(func) => { - extract_config_from_function_body(func.body.as_ref()?) + extract_config_from_function_body(func.body.as_ref()?, stmts) } _ => None, } } Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), + // Resolve a top-level identifier to its initializer (one level of indirection). + // Pass empty stmts on the recursive call to prevent chaining (const a = b; export default a). + Expression::Identifier(ident) if !stmts.is_empty() => { + let init = find_top_level_init(&ident.name, stmts)?; + extract_config_from_expr(init, &[]) + } _ => None, } } @@ -182,7 +212,13 @@ fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { /// /// Returns `None` (not analyzable) if the body contains multiple `return` statements /// (at any nesting depth), since the returned config would depend on runtime control flow. -fn extract_config_from_function_body(body: &oxc_ast::ast::FunctionBody<'_>) -> StaticConfig { +/// +/// `module_stmts` is the program's top-level statement list, used as a fallback when +/// resolving an identifier in a `return ` statement. +fn extract_config_from_function_body<'a>( + body: &'a oxc_ast::ast::FunctionBody<'a>, + module_stmts: &'a [Statement<'a>], +) -> StaticConfig { // Reject functions with multiple returns — the config depends on control flow. if count_returns_in_stmts(&body.statements) > 1 { return None; @@ -192,10 +228,19 @@ fn extract_config_from_function_body(body: &oxc_ast::ast::FunctionBody<'_>) -> S match stmt { Statement::ReturnStatement(ret) => { let arg = ret.argument.as_ref()?; - if let Expression::ObjectExpression(obj) = arg.without_parentheses() { - return Some(extract_object_fields(obj)); + match arg.without_parentheses() { + Expression::ObjectExpression(obj) => return Some(extract_object_fields(obj)), + Expression::Identifier(ident) => { + // Look for the binding in the function body first, then at module level. + let init = find_top_level_init(&ident.name, &body.statements) + .or_else(|| find_top_level_init(&ident.name, module_stmts))?; + if let Expression::ObjectExpression(obj) = init.without_parentheses() { + return Some(extract_object_fields(obj)); + } + return None; + } + _ => return None, } - return None; } Statement::ExpressionStatement(expr_stmt) => { // Concise arrow: `() => ({ ... })` is represented as ExpressionStatement @@ -999,4 +1044,125 @@ mod tests { ); assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); } + + // ── Indirect exports (identifier resolution) ───────────────────────── + + #[test] + fn export_default_identifier_object() { + let result = parse( + r" + const config = { run: { cacheScripts: true } }; + export default config; + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + + #[test] + fn export_default_identifier_define_config() { + // Real-world tanstack-start-helloworld pattern + let result = parse( + r" + import { defineConfig } from 'vite-plus'; + const config = defineConfig({ + run: { cacheScripts: true }, + plugins: [devtools(), nitro()], + }); + export default config; + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_non_static(&result, "plugins"); + } + + #[test] + fn module_exports_identifier_object() { + let result = parse_js_ts_config( + r" + const config = { run: { cache: true } }; + module.exports = config; + ", + "cjs", + ) + .expect("expected analyzable config"); + assert_json(&result, "run", serde_json::json!({ "cache": true })); + } + + #[test] + fn module_exports_identifier_define_config() { + let result = parse_js_ts_config( + r" + const { defineConfig } = require('vite-plus'); + const config = defineConfig({ run: { cacheScripts: true } }); + module.exports = config; + ", + "cjs", + ) + .expect("expected analyzable config"); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + + #[test] + fn define_config_callback_return_local_identifier() { + let result = parse( + r" + export default defineConfig(({ mode }) => { + const obj = { run: { cacheScripts: true }, plugins: [vue()] }; + return obj; + }); + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_non_static(&result, "plugins"); + } + + #[test] + fn define_config_callback_return_module_level_identifier() { + let result = parse( + r" + const shared = { run: { cacheScripts: true } }; + export default defineConfig(() => { + return shared; + }); + ", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } + + #[test] + fn export_default_identifier_undeclared_is_none() { + // Identifier not declared in file — not analyzable + assert!(parse_js_ts_config("export default config;", "ts").is_none()); + } + + #[test] + fn export_default_identifier_no_init_is_none() { + // Variable declared without initializer — not analyzable + assert!( + parse_js_ts_config( + r" + let config; + export default config; + ", + "ts", + ) + .is_none() + ); + } + + #[test] + fn export_default_chained_identifier_is_none() { + // Chained indirection (const a = b) — only one level is resolved + assert!( + parse_js_ts_config( + r" + const inner = { run: {} }; + const config = inner; + export default config; + ", + "ts", + ) + .is_none() + ); + } } From 4592307e1cf609f418d5b1e5b917d279013d1930 Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Mar 2026 09:16:47 +0800 Subject: [PATCH 12/38] Revert "feat(static-config): resolve indirect exports via top-level identifier lookup" This reverts commit ecc3fc2c12c2b40897d74f3de189b10c98c3f7c4. --- crates/vite_static_config/src/lib.rs | 192 ++------------------------- 1 file changed, 13 insertions(+), 179 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 2802ea2032..466d4869b9 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -5,7 +5,7 @@ //! config like `run` without needing a Node.js runtime. use oxc_allocator::Allocator; -use oxc_ast::ast::{BindingPattern, Expression, ObjectPropertyKind, Program, Statement}; +use oxc_ast::ast::{Expression, ObjectPropertyKind, Program, Statement}; use oxc_parser::Parser; use oxc_span::SourceType; use rustc_hash::FxHashMap; @@ -110,39 +110,19 @@ fn parse_js_ts_config(source: &str, extension: &str) -> StaticConfig { extract_config_fields(&result.program) } -/// Scan top-level statements for a `const`/`let`/`var` declarator whose simple -/// binding identifier matches `name`, and return a reference to its initializer. -/// -/// Returns `None` if no match is found or the declarator has no initializer. -/// Destructured bindings (object/array patterns) are intentionally skipped. -fn find_top_level_init<'a>(name: &str, stmts: &'a [Statement<'a>]) -> Option<&'a Expression<'a>> { - for stmt in stmts { - let Statement::VariableDeclaration(decl) = stmt else { continue }; - for declarator in &decl.declarations { - let BindingPattern::BindingIdentifier(ident) = &declarator.id else { continue }; - if ident.name == name { - return declarator.init.as_ref(); - } - } - } - None -} - /// Find the config object in a parsed program and extract its fields. /// /// Searches for the config value in the following patterns (in order): /// 1. `export default defineConfig({ ... })` /// 2. `export default { ... }` -/// 3. `export default config` where `config` is a top-level variable -/// 4. `module.exports = defineConfig({ ... })` -/// 5. `module.exports = { ... }` -/// 6. `module.exports = config` where `config` is a top-level variable -fn extract_config_fields<'a>(program: &'a Program<'a>) -> StaticConfig { +/// 3. `module.exports = defineConfig({ ... })` +/// 4. `module.exports = { ... }` +fn extract_config_fields(program: &Program<'_>) -> StaticConfig { for stmt in &program.body { // ESM: export default ... if let Statement::ExportDefaultDeclaration(decl) = stmt { if let Some(expr) = decl.declaration.as_expression() { - return extract_config_from_expr(expr, &program.body); + return extract_config_from_expr(expr); } // export default class/function — not analyzable return None; @@ -155,7 +135,7 @@ fn extract_config_fields<'a>(program: &'a Program<'a>) -> StaticConfig { m.object().is_specific_id("module") && m.static_property_name() == Some("exports") }) { - return extract_config_from_expr(&assign.right, &program.body); + return extract_config_from_expr(&assign.right); } } @@ -168,12 +148,8 @@ fn extract_config_fields<'a>(program: &'a Program<'a>) -> StaticConfig { /// - `defineConfig(() => { return { ... }; })` → extract from return statement /// - `defineConfig(function() { return { ... }; })` → extract from return statement /// - `{ ... }` → extract directly -/// - `identifier` → look up in `stmts`, then extract (one level of indirection only) /// - anything else → not analyzable -fn extract_config_from_expr<'a>( - expr: &'a Expression<'a>, - stmts: &'a [Statement<'a>], -) -> StaticConfig { +fn extract_config_from_expr(expr: &Expression<'_>) -> StaticConfig { let expr = expr.without_parentheses(); match expr { Expression::CallExpression(call) => { @@ -185,21 +161,15 @@ fn extract_config_from_expr<'a>( match first_arg_expr { Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), Expression::ArrowFunctionExpression(arrow) => { - extract_config_from_function_body(&arrow.body, stmts) + extract_config_from_function_body(&arrow.body) } Expression::FunctionExpression(func) => { - extract_config_from_function_body(func.body.as_ref()?, stmts) + extract_config_from_function_body(func.body.as_ref()?) } _ => None, } } Expression::ObjectExpression(obj) => Some(extract_object_fields(obj)), - // Resolve a top-level identifier to its initializer (one level of indirection). - // Pass empty stmts on the recursive call to prevent chaining (const a = b; export default a). - Expression::Identifier(ident) if !stmts.is_empty() => { - let init = find_top_level_init(&ident.name, stmts)?; - extract_config_from_expr(init, &[]) - } _ => None, } } @@ -212,13 +182,7 @@ fn extract_config_from_expr<'a>( /// /// Returns `None` (not analyzable) if the body contains multiple `return` statements /// (at any nesting depth), since the returned config would depend on runtime control flow. -/// -/// `module_stmts` is the program's top-level statement list, used as a fallback when -/// resolving an identifier in a `return ` statement. -fn extract_config_from_function_body<'a>( - body: &'a oxc_ast::ast::FunctionBody<'a>, - module_stmts: &'a [Statement<'a>], -) -> StaticConfig { +fn extract_config_from_function_body(body: &oxc_ast::ast::FunctionBody<'_>) -> StaticConfig { // Reject functions with multiple returns — the config depends on control flow. if count_returns_in_stmts(&body.statements) > 1 { return None; @@ -228,19 +192,10 @@ fn extract_config_from_function_body<'a>( match stmt { Statement::ReturnStatement(ret) => { let arg = ret.argument.as_ref()?; - match arg.without_parentheses() { - Expression::ObjectExpression(obj) => return Some(extract_object_fields(obj)), - Expression::Identifier(ident) => { - // Look for the binding in the function body first, then at module level. - let init = find_top_level_init(&ident.name, &body.statements) - .or_else(|| find_top_level_init(&ident.name, module_stmts))?; - if let Expression::ObjectExpression(obj) = init.without_parentheses() { - return Some(extract_object_fields(obj)); - } - return None; - } - _ => return None, + if let Expression::ObjectExpression(obj) = arg.without_parentheses() { + return Some(extract_object_fields(obj)); } + return None; } Statement::ExpressionStatement(expr_stmt) => { // Concise arrow: `() => ({ ... })` is represented as ExpressionStatement @@ -1044,125 +999,4 @@ mod tests { ); assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); } - - // ── Indirect exports (identifier resolution) ───────────────────────── - - #[test] - fn export_default_identifier_object() { - let result = parse( - r" - const config = { run: { cacheScripts: true } }; - export default config; - ", - ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); - } - - #[test] - fn export_default_identifier_define_config() { - // Real-world tanstack-start-helloworld pattern - let result = parse( - r" - import { defineConfig } from 'vite-plus'; - const config = defineConfig({ - run: { cacheScripts: true }, - plugins: [devtools(), nitro()], - }); - export default config; - ", - ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); - assert_non_static(&result, "plugins"); - } - - #[test] - fn module_exports_identifier_object() { - let result = parse_js_ts_config( - r" - const config = { run: { cache: true } }; - module.exports = config; - ", - "cjs", - ) - .expect("expected analyzable config"); - assert_json(&result, "run", serde_json::json!({ "cache": true })); - } - - #[test] - fn module_exports_identifier_define_config() { - let result = parse_js_ts_config( - r" - const { defineConfig } = require('vite-plus'); - const config = defineConfig({ run: { cacheScripts: true } }); - module.exports = config; - ", - "cjs", - ) - .expect("expected analyzable config"); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); - } - - #[test] - fn define_config_callback_return_local_identifier() { - let result = parse( - r" - export default defineConfig(({ mode }) => { - const obj = { run: { cacheScripts: true }, plugins: [vue()] }; - return obj; - }); - ", - ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); - assert_non_static(&result, "plugins"); - } - - #[test] - fn define_config_callback_return_module_level_identifier() { - let result = parse( - r" - const shared = { run: { cacheScripts: true } }; - export default defineConfig(() => { - return shared; - }); - ", - ); - assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); - } - - #[test] - fn export_default_identifier_undeclared_is_none() { - // Identifier not declared in file — not analyzable - assert!(parse_js_ts_config("export default config;", "ts").is_none()); - } - - #[test] - fn export_default_identifier_no_init_is_none() { - // Variable declared without initializer — not analyzable - assert!( - parse_js_ts_config( - r" - let config; - export default config; - ", - "ts", - ) - .is_none() - ); - } - - #[test] - fn export_default_chained_identifier_is_none() { - // Chained indirection (const a = b) — only one level is resolved - assert!( - parse_js_ts_config( - r" - const inner = { run: {} }; - const config = inner; - export default config; - ", - "ts", - ) - .is_none() - ); - } } From 431526c96ba5147d84582398aeb9012b6113518f Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Mar 2026 09:20:09 +0800 Subject: [PATCH 13/38] fix(static-config): spread invalidates previously-seen fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{ a: 1, ...x, b: 2 }` — the spread may override `a`, so `a` is now marked NonStatic. Fields declared after the spread (`b`) are unaffected since they win over any spread key. Unknown keys from the spread are still not added to the map. Co-Authored-By: Claude Sonnet 4.6 --- crates/vite_static_config/src/lib.rs | 33 +++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 466d4869b9..9c3cd98fbf 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -262,8 +262,11 @@ fn count_returns_in_stmt(stmt: &Statement<'_>) -> usize { /// Extract fields from an object expression, converting each value to JSON. /// Fields whose values cannot be represented as pure JSON are recorded as -/// [`FieldValue::NonStatic`]. Spread elements and computed properties -/// are not representable so they are silently skipped (their keys are unknown). +/// [`FieldValue::NonStatic`]. Computed properties are silently skipped (key unknown). +/// +/// Spreads invalidate all fields declared before them: `{ a: 1, ...x, b: 2 }` yields +/// `a: NonStatic` (spread may override it) and `b: Json(2)` (declared after, wins). +/// Unknown keys introduced by the spread are not added to the map. fn extract_object_fields( obj: &oxc_ast::ast::ObjectExpression<'_>, ) -> FxHashMap, FieldValue> { @@ -271,7 +274,10 @@ fn extract_object_fields( for prop in &obj.properties { if prop.is_spread() { - // Spread elements — keys are unknown at static analysis time + // A spread may override any field declared before it. + for value in map.values_mut() { + *value = FieldValue::NonStatic; + } continue; } let ObjectPropertyKind::ObjectProperty(prop) = prop else { @@ -686,14 +692,31 @@ mod tests { } #[test] - fn spread_in_top_level_skipped() { + fn spread_unknown_keys_not_in_map() { + // Keys introduced by the spread are unknown — not added to the map. + // Fields declared after the spread are safe (they win over the spread). let result = parse( r" const base = { x: 1 }; export default { ...base, b: 'ok' } ", ); - // Spread at top level — keys unknown, so not in map at all + assert!(!result.contains_key("x")); + assert_json(&result, "b", serde_json::json!("ok")); + } + + #[test] + fn spread_invalidates_previous_fields() { + // Fields declared before a spread become NonStatic — the spread may override them. + // Fields declared after the spread are unaffected. + let result = parse( + r" + const base = { x: 1 }; + export default { a: 1, run: { cacheScripts: true }, ...base, b: 'ok' } + ", + ); + assert_non_static(&result, "a"); + assert_non_static(&result, "run"); assert!(!result.contains_key("x")); assert_json(&result, "b", serde_json::json!("ok")); } From 45787e4d4e17927161dea96c8d666825896a746c Mon Sep 17 00:00:00 2001 From: branchseer Date: Thu, 12 Mar 2026 09:24:30 +0800 Subject: [PATCH 14/38] fix(static-config): computed keys invalidate previously-seen fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `{ a: 1, [key]: 2, b: 3 }` — `[key]` could resolve to `'a'` and override it, so `a` is now marked NonStatic. Same logic as spreads. Fields after the computed key are unaffected (they explicitly win). Co-Authored-By: Claude Sonnet 4.6 --- crates/vite_static_config/src/lib.rs | 49 ++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/crates/vite_static_config/src/lib.rs b/crates/vite_static_config/src/lib.rs index 9c3cd98fbf..b20205e4d6 100644 --- a/crates/vite_static_config/src/lib.rs +++ b/crates/vite_static_config/src/lib.rs @@ -262,22 +262,34 @@ fn count_returns_in_stmt(stmt: &Statement<'_>) -> usize { /// Extract fields from an object expression, converting each value to JSON. /// Fields whose values cannot be represented as pure JSON are recorded as -/// [`FieldValue::NonStatic`]. Computed properties are silently skipped (key unknown). +/// [`FieldValue::NonStatic`]. /// -/// Spreads invalidate all fields declared before them: `{ a: 1, ...x, b: 2 }` yields -/// `a: NonStatic` (spread may override it) and `b: Json(2)` (declared after, wins). -/// Unknown keys introduced by the spread are not added to the map. +/// Both spreads and computed-key properties invalidate all fields declared before +/// them, because either may resolve to a key that overrides an earlier entry: +/// +/// ```js +/// { a: 1, ...x, b: 2 } // a → NonStatic, b → Json(2) +/// { a: 1, [key]: 2, b: 3 } // a → NonStatic, b → Json(3) +/// ``` +/// +/// Fields declared after such entries are safe (they explicitly override whatever +/// the spread/computed-key produced). Unknown keys are never added to the map. fn extract_object_fields( obj: &oxc_ast::ast::ObjectExpression<'_>, ) -> FxHashMap, FieldValue> { let mut map = FxHashMap::default(); + /// Mark every field accumulated so far as NonStatic. + fn invalidate_previous(map: &mut FxHashMap, FieldValue>) { + for value in map.values_mut() { + *value = FieldValue::NonStatic; + } + } + for prop in &obj.properties { if prop.is_spread() { // A spread may override any field declared before it. - for value in map.values_mut() { - *value = FieldValue::NonStatic; - } + invalidate_previous(&mut map); continue; } let ObjectPropertyKind::ObjectProperty(prop) = prop else { @@ -285,7 +297,8 @@ fn extract_object_fields( }; let Some(key) = prop.key.static_name() else { - // Computed properties — keys are unknown at static analysis time + // A computed key may equal any previously-seen key name. + invalidate_previous(&mut map); continue; }; @@ -722,18 +735,34 @@ mod tests { } #[test] - fn computed_properties_skipped() { + fn computed_key_unknown_not_in_map() { + // The computed key's resolved name is unknown — not added to the map. + // Fields declared after it are safe (they explicitly win). let result = parse( r" const key = 'dynamic'; export default { [key]: 'value', plain: 'ok' } ", ); - // Computed key — not in map at all (key is unknown) assert!(!result.contains_key("dynamic")); assert_json(&result, "plain", serde_json::json!("ok")); } + #[test] + fn computed_key_invalidates_previous_fields() { + // A computed key may resolve to any previously-seen name and override it. + let result = parse( + r" + const key = 'run'; + export default { a: 1, run: { cacheScripts: true }, [key]: 'override', b: 2 } + ", + ); + assert_non_static(&result, "a"); + assert_non_static(&result, "run"); + assert!(!result.contains_key("dynamic")); + assert_json(&result, "b", serde_json::json!(2)); + } + #[test] fn non_static_array_with_spread() { let result = parse( From 0ef22cc1cab3c7909fd21638232bacc729e7a874 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 00:57:33 +0800 Subject: [PATCH 15/38] feat: add Chrome tracing support and performance instrumentation - Add Chrome DevTools timeline tracing output via `VITE_LOG_OUTPUT=chrome-json` - Update `init_tracing()` to support three output modes: chrome-json, readable, stdout - Add `VITE_LOG_OUTPUT` env var constant - Add `tracing` and `tracing-chrome` dependencies to vite_shared - Keep tracing guard alive in main() for Chrome trace file flushing - Update vite-task dependency to include tracing instrumentation - Enable tracing in e2e-test workflow and upload trace artifacts - Run e2e commands twice to measure caching performance Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-test.yml | 30 ++++++++++ Cargo.toml | 12 ++-- crates/vite_global_cli/src/main.rs | 2 +- crates/vite_shared/Cargo.toml | 2 + crates/vite_shared/src/env_vars.rs | 3 + crates/vite_shared/src/tracing.rs | 96 ++++++++++++++++++++++-------- 6 files changed, 113 insertions(+), 32 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 0d14adbb96..c89b618a38 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -287,8 +287,38 @@ jobs: - name: Run vite-plus commands in ${{ matrix.project.name }} working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }} + env: + VITE_LOG: debug + VITE_LOG_OUTPUT: chrome-json + run: ${{ matrix.project.command }} + + - name: Run vite-plus commands again (cache run) in ${{ matrix.project.name }} + working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }} + env: + VITE_LOG: debug + VITE_LOG_OUTPUT: chrome-json run: ${{ matrix.project.command }} + - name: Collect trace files + if: always() + shell: bash + run: | + mkdir -p ${{ runner.temp }}/trace-artifacts + # Chrome tracing writes trace-*.json in the cwd of the process + find ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }} -name 'trace-*.json' -exec cp {} ${{ runner.temp }}/trace-artifacts/ \; 2>/dev/null || true + # Also check the workspace root + find $GITHUB_WORKSPACE -maxdepth 1 -name 'trace-*.json' -exec cp {} ${{ runner.temp }}/trace-artifacts/ \; 2>/dev/null || true + ls -la ${{ runner.temp }}/trace-artifacts/ 2>/dev/null || echo "No trace files found" + + - name: Upload trace artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: trace-${{ matrix.project.name }}-${{ matrix.os }} + path: ${{ runner.temp }}/trace-artifacts/ + retention-days: 7 + if-no-files-found: ignore + notify-failure: name: Notify on failure runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index e4cef43166..19eeb91780 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,7 +83,7 @@ dunce = "1.0.5" fast-glob = "1.0.0" flate2 = { version = "=1.1.9", features = ["zlib-rs"] } form_urlencoded = "1.2.1" -fspy = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9e1287e797190ea29793655b239cdaa7a55edd21" } +fspy = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } futures = "0.3.31" futures-util = "0.3.31" glob = "0.3.2" @@ -182,15 +182,15 @@ vfs = "0.12.1" vite_command = { path = "crates/vite_command" } vite_error = { path = "crates/vite_error" } vite_js_runtime = { path = "crates/vite_js_runtime" } -vite_glob = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9e1287e797190ea29793655b239cdaa7a55edd21" } +vite_glob = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } vite_install = { path = "crates/vite_install" } vite_migration = { path = "crates/vite_migration" } vite_shared = { path = "crates/vite_shared" } vite_static_config = { path = "crates/vite_static_config" } -vite_path = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9e1287e797190ea29793655b239cdaa7a55edd21" } -vite_str = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9e1287e797190ea29793655b239cdaa7a55edd21" } -vite_task = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9e1287e797190ea29793655b239cdaa7a55edd21" } -vite_workspace = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9e1287e797190ea29793655b239cdaa7a55edd21" } +vite_path = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } +vite_str = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } +vite_task = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } +vite_workspace = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } walkdir = "2.5.0" wax = "0.6.0" which = "8.0.0" diff --git a/crates/vite_global_cli/src/main.rs b/crates/vite_global_cli/src/main.rs index 9f0cfd3dc9..c730a9b8dd 100644 --- a/crates/vite_global_cli/src/main.rs +++ b/crates/vite_global_cli/src/main.rs @@ -92,7 +92,7 @@ fn print_invalid_subcommand_error(error: &clap::Error) -> bool { #[tokio::main] async fn main() -> ExitCode { // Initialize tracing - vite_shared::init_tracing(); + let _tracing_guard = vite_shared::init_tracing(); // Check for shim mode (invoked as node, npm, or npx) let args: Vec = std::env::args().collect(); diff --git a/crates/vite_shared/Cargo.toml b/crates/vite_shared/Cargo.toml index d773145652..e7a684e530 100644 --- a/crates/vite_shared/Cargo.toml +++ b/crates/vite_shared/Cargo.toml @@ -12,6 +12,8 @@ directories = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +tracing = { workspace = true } +tracing-chrome = { workspace = true } tracing-subscriber = { workspace = true } vite_path = { workspace = true } vite_str = { workspace = true } diff --git a/crates/vite_shared/src/env_vars.rs b/crates/vite_shared/src/env_vars.rs index 658d50a485..54b81bdade 100644 --- a/crates/vite_shared/src/env_vars.rs +++ b/crates/vite_shared/src/env_vars.rs @@ -18,6 +18,9 @@ pub const VITE_PLUS_HOME: &str = "VITE_PLUS_HOME"; /// Log filter string for `tracing_subscriber` (e.g. `"debug"`, `"vite_task=trace"`). pub const VITE_LOG: &str = "VITE_LOG"; +/// Output mode for tracing (e.g. `"chrome-json"` for Chrome DevTools timeline). +pub const VITE_LOG_OUTPUT: &str = "VITE_LOG_OUTPUT"; + /// NPM registry URL (lowercase form, highest priority). pub const NPM_CONFIG_REGISTRY: &str = "npm_config_registry"; diff --git a/crates/vite_shared/src/tracing.rs b/crates/vite_shared/src/tracing.rs index 97c889b11e..1b35acf91c 100644 --- a/crates/vite_shared/src/tracing.rs +++ b/crates/vite_shared/src/tracing.rs @@ -1,38 +1,84 @@ //! Tracing initialization for vite-plus +//! +//! ## Environment Variables +//! - `VITE_LOG`: Controls log filtering (e.g., `"debug"`, `"vite_task=trace"`) +//! - `VITE_LOG_OUTPUT`: Output format — `"chrome-json"` for Chrome DevTools timeline, +//! `"readable"` for pretty-printed output, or default stdout. -use std::sync::OnceLock; +use std::any::Any; +use std::sync::atomic::AtomicBool; +use tracing_chrome::ChromeLayerBuilder; use tracing_subscriber::{ filter::{LevelFilter, Targets}, + fmt::{self, format::FmtSpan}, prelude::*, }; use crate::env_vars; -/// Initialize tracing with VITE_LOG environment variable. +static IS_INITIALIZED: AtomicBool = AtomicBool::new(false); + +/// Initialize tracing with `VITE_LOG` and `VITE_LOG_OUTPUT` environment variables. /// -/// Uses `OnceLock` to ensure tracing is only initialized once, -/// even if called multiple times. +/// Returns an optional guard that must be kept alive for the duration of the +/// program when using file-based output (e.g., `chrome-json`). Dropping the +/// guard flushes and finalizes the trace file. /// -/// # Environment Variables -/// - `VITE_LOG`: Controls log filtering (e.g., "debug", "vite_task=trace") -pub fn init_tracing() { - static TRACING: OnceLock<()> = OnceLock::new(); - TRACING.get_or_init(|| { - tracing_subscriber::registry() - .with( - std::env::var(env_vars::VITE_LOG) - .map_or_else( - |_| Targets::new(), - |env_var| { - use std::str::FromStr; - Targets::from_str(&env_var).unwrap_or_default() - }, - ) - // disable brush-parser tracing - .with_targets([("tokenize", LevelFilter::OFF), ("parse", LevelFilter::OFF)]), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); - }); +/// Uses `AtomicBool` to ensure tracing is only initialized once. +pub fn init_tracing() -> Option> { + if IS_INITIALIZED.swap(true, std::sync::atomic::Ordering::SeqCst) { + return None; + } + + let Ok(env_var) = std::env::var(env_vars::VITE_LOG) else { + // Tracing is disabled by default (performance sensitive) + return None; + }; + + let targets = { + use std::str::FromStr; + Targets::from_str(&env_var) + .unwrap_or_default() + // disable brush-parser tracing + .with_targets([("tokenize", LevelFilter::OFF), ("parse", LevelFilter::OFF)]) + }; + + let output_mode = std::env::var(env_vars::VITE_LOG_OUTPUT) + .unwrap_or_else(|_| "stdout".to_string()); + + match output_mode.as_str() { + "chrome-json" => { + let (chrome_layer, guard) = ChromeLayerBuilder::new() + .trace_style(tracing_chrome::TraceStyle::Async) + .include_args(true) + .build(); + tracing_subscriber::registry() + .with(targets) + .with(chrome_layer) + .init(); + Some(Box::new(guard)) + } + "readable" => { + tracing_subscriber::registry() + .with(targets) + .with( + fmt::layer() + .pretty() + .with_span_events(FmtSpan::NONE) + .with_level(true) + .with_target(false), + ) + .init(); + None + } + _ => { + // Default: stdout with span events + tracing_subscriber::registry() + .with(targets) + .with(fmt::layer().with_span_events(FmtSpan::CLOSE | FmtSpan::ENTER)) + .init(); + None + } + } } From daea544e8318113d18c7481d56ffa06e83d3463b Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 01:00:26 +0800 Subject: [PATCH 16/38] fix: remove unused tracing dependency from vite_shared The tracing crate is not directly used in vite_shared - only tracing-chrome and tracing-subscriber are needed. Fixes cargo-shear. Co-Authored-By: Claude Opus 4.6 --- crates/vite_shared/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/vite_shared/Cargo.toml b/crates/vite_shared/Cargo.toml index e7a684e530..ce4f0a339b 100644 --- a/crates/vite_shared/Cargo.toml +++ b/crates/vite_shared/Cargo.toml @@ -12,7 +12,6 @@ directories = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -tracing = { workspace = true } tracing-chrome = { workspace = true } tracing-subscriber = { workspace = true } vite_path = { workspace = true } From cd646bf984e4b038e3d925dcf48e689afb75ea34 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 01:07:51 +0800 Subject: [PATCH 17/38] feat: add tracing to callbacks, NAPI entry, and JS startup Instrument vite-plus callback implementations (handle_command, load_user_config_file, resolve), NAPI bridge (run, JS resolvers, vite config resolver), and JS startup timing for performance measurement. Co-Authored-By: Claude Opus 4.6 --- packages/cli/binding/src/cli.rs | 7 +++++++ packages/cli/binding/src/lib.rs | 3 +++ packages/cli/src/bin.ts | 7 +++++++ 3 files changed, 17 insertions(+) diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index 5c93c5c140..d638b16d9d 100644 --- a/packages/cli/binding/src/cli.rs +++ b/packages/cli/binding/src/cli.rs @@ -277,6 +277,7 @@ impl SubcommandResolver { } /// Resolve a synthesizable subcommand to a concrete program, args, cache config, and envs. + #[tracing::instrument(level = "debug", skip_all)] async fn resolve( &mut self, subcommand: SynthesizableSubcommand, @@ -602,6 +603,7 @@ impl CommandHandler for VitePlusCommandHandler { &mut self, command: &mut ScriptCommand, ) -> anyhow::Result { + let _span = tracing::debug_span!("handle_command").entered(); // Intercept both "vp" and "vite" commands in task scripts. // "vp" is the conventional alias used in vite-plus task configs. // "vite" must also be intercepted so that `vite test`, `vite build`, etc. @@ -655,6 +657,8 @@ impl UserConfigLoader for VitePlusConfigLoader { &self, package_path: &AbsolutePath, ) -> anyhow::Result> { + let _span = tracing::debug_span!("load_user_config_file").entered(); + // Try static config extraction first (no JS runtime needed) if let Some(static_fields) = vite_static_config::resolve_static_config(package_path) { match static_fields.get("run") { @@ -701,6 +705,7 @@ impl UserConfigLoader for VitePlusConfigLoader { } /// Resolve a single subcommand and execute it, returning its exit status. +#[tracing::instrument(level = "debug", skip_all)] async fn resolve_and_execute( resolver: &mut SubcommandResolver, subcommand: SynthesizableSubcommand, @@ -742,6 +747,7 @@ async fn resolve_and_execute( /// Execute a synthesizable subcommand directly (not through vite-task Session). /// No caching, no task graph, no dependency resolution. +#[tracing::instrument(level = "debug", skip_all)] async fn execute_direct_subcommand( subcommand: SynthesizableSubcommand, cwd: &AbsolutePathBuf, @@ -865,6 +871,7 @@ async fn execute_direct_subcommand( } /// Execute a vite-task command (run, cache) through Session. +#[tracing::instrument(level = "debug", skip_all)] async fn execute_vite_task_command( command: Command, cwd: AbsolutePathBuf, diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index 1bcfac8591..e4c68b7224 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -75,6 +75,7 @@ fn create_resolver( Box::new(move || { let tsf = tsf.clone(); Box::pin(async move { + let _span = tracing::debug_span!("js_resolver", resolver = error_message).entered(); // Call JS function - map napi::Error to anyhow::Error let promise: Promise = tsf .call_async(Ok(())) @@ -97,6 +98,7 @@ fn create_vite_config_resolver( Arc::new(move |package_path: String| { let tsf = tsf.clone(); Box::pin(async move { + let _span = tracing::debug_span!("js_resolve_vite_config").entered(); let promise: Promise = tsf .call_async(Ok(package_path)) .await @@ -118,6 +120,7 @@ fn create_vite_config_resolver( /// and process JavaScript callbacks (via ThreadsafeFunction). #[napi] pub async fn run(options: CliOptions) -> Result { + let _span = tracing::debug_span!("napi_run").entered(); // Use provided cwd or current directory let mut cwd = current_dir()?; if let Some(options_cwd) = options.cwd { diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index d3b633dbdb..8f7922b41f 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -10,6 +10,8 @@ * If no local installation is found, this global dist/bin.js is used as fallback. */ +const jsStartTime = performance.now(); + import { run } from '../binding/index.js'; import { doc } from './resolve-doc.js'; import { fmt } from './resolve-fmt.js'; @@ -43,6 +45,11 @@ if (command === 'create') { await import('./global/version.js'); } else { // All other commands — delegate to Rust core via NAPI binding + if (process.env.VITE_LOG) { + console.log( + `[vite-plus] JS startup: ${(performance.now() - jsStartTime).toFixed(2)}ms`, + ); + } run({ lint, pack, From 86356230e3bbe2ccf3da25f73abf4e0d6f3083f7 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 01:20:24 +0800 Subject: [PATCH 18/38] fix: resolve Send bounds and formatting issues - Use tracing::debug! events instead of spans in Send-required contexts (NAPI run function and vite config resolver) - Fix cargo fmt formatting in tracing.rs - Add process.uptime() to JS startup timing for absolute timeline Co-Authored-By: Claude Opus 4.6 --- crates/vite_shared/src/tracing.rs | 12 ++++-------- packages/cli/binding/src/lib.rs | 4 ++-- packages/cli/src/bin.ts | 4 +++- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/vite_shared/src/tracing.rs b/crates/vite_shared/src/tracing.rs index 1b35acf91c..3b94480f55 100644 --- a/crates/vite_shared/src/tracing.rs +++ b/crates/vite_shared/src/tracing.rs @@ -5,8 +5,7 @@ //! - `VITE_LOG_OUTPUT`: Output format — `"chrome-json"` for Chrome DevTools timeline, //! `"readable"` for pretty-printed output, or default stdout. -use std::any::Any; -use std::sync::atomic::AtomicBool; +use std::{any::Any, sync::atomic::AtomicBool}; use tracing_chrome::ChromeLayerBuilder; use tracing_subscriber::{ @@ -44,8 +43,8 @@ pub fn init_tracing() -> Option> { .with_targets([("tokenize", LevelFilter::OFF), ("parse", LevelFilter::OFF)]) }; - let output_mode = std::env::var(env_vars::VITE_LOG_OUTPUT) - .unwrap_or_else(|_| "stdout".to_string()); + let output_mode = + std::env::var(env_vars::VITE_LOG_OUTPUT).unwrap_or_else(|_| "stdout".to_string()); match output_mode.as_str() { "chrome-json" => { @@ -53,10 +52,7 @@ pub fn init_tracing() -> Option> { .trace_style(tracing_chrome::TraceStyle::Async) .include_args(true) .build(); - tracing_subscriber::registry() - .with(targets) - .with(chrome_layer) - .init(); + tracing_subscriber::registry().with(targets).with(chrome_layer).init(); Some(Box::new(guard)) } "readable" => { diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index e4c68b7224..83163862ff 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -98,7 +98,7 @@ fn create_vite_config_resolver( Arc::new(move |package_path: String| { let tsf = tsf.clone(); Box::pin(async move { - let _span = tracing::debug_span!("js_resolve_vite_config").entered(); + tracing::debug!("js_resolve_vite_config: start"); let promise: Promise = tsf .call_async(Ok(package_path)) .await @@ -120,7 +120,7 @@ fn create_vite_config_resolver( /// and process JavaScript callbacks (via ThreadsafeFunction). #[napi] pub async fn run(options: CliOptions) -> Result { - let _span = tracing::debug_span!("napi_run").entered(); + tracing::debug!("napi_run: start"); // Use provided cwd or current directory let mut cwd = current_dir()?; if let Some(options_cwd) = options.cwd { diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 8f7922b41f..a6195e0e57 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -46,8 +46,10 @@ if (command === 'create') { } else { // All other commands — delegate to Rust core via NAPI binding if (process.env.VITE_LOG) { + const processUptime = (process.uptime() * 1000).toFixed(2); + const jsModuleLoad = (performance.now() - jsStartTime).toFixed(2); console.log( - `[vite-plus] JS startup: ${(performance.now() - jsStartTime).toFixed(2)}ms`, + `[vite-plus] process uptime: ${processUptime}ms, JS module load: ${jsModuleLoad}ms`, ); } run({ From 14091ec692bd91ef9a0d3caa4e46e2b771c42a9b Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 01:41:23 +0800 Subject: [PATCH 19/38] fix: use console.error for startup timing to satisfy no-console lint Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/bin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index a6195e0e57..0759503b2f 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -48,7 +48,7 @@ if (command === 'create') { if (process.env.VITE_LOG) { const processUptime = (process.uptime() * 1000).toFixed(2); const jsModuleLoad = (performance.now() - jsStartTime).toFixed(2); - console.log( + console.error( `[vite-plus] process uptime: ${processUptime}ms, JS module load: ${jsModuleLoad}ms`, ); } From bcd9e7c76d4f41e694f6d2654a3699d8c74f5750 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 02:02:24 +0800 Subject: [PATCH 20/38] chore: update Cargo.lock for new vite-task revision Update lock file to match vite-task rev 9ed02855, which adds tracing dependencies and updates wax to 0.7.0. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 65 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d0e83ccdf..06b13574fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1394,7 +1394,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1555,7 +1555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1738,7 +1738,7 @@ dependencies = [ [[package]] name = "fspy" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "allocator-api2", "anyhow", @@ -1773,7 +1773,7 @@ dependencies = [ [[package]] name = "fspy_detours_sys" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "cc", "winapi", @@ -1782,7 +1782,7 @@ dependencies = [ [[package]] name = "fspy_preload_unix" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "anyhow", "bincode", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "fspy_preload_windows" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "bincode", "constcat", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "fspy_seccomp_unotify" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "bincode", "futures-util", @@ -1830,7 +1830,7 @@ dependencies = [ [[package]] name = "fspy_shared" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "allocator-api2", "bincode", @@ -1848,7 +1848,7 @@ dependencies = [ [[package]] name = "fspy_shared_unix" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "anyhow", "base64 0.22.1", @@ -2324,7 +2324,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -2598,7 +2598,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2715,7 +2715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" dependencies = [ "scopeguard", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3243,7 +3243,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4533,7 +4533,7 @@ dependencies = [ [[package]] name = "pty_terminal_test_client" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" [[package]] name = "quinn" @@ -4548,7 +4548,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -4585,9 +4585,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5777,7 +5777,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5929,7 +5929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6436,7 +6436,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7197,7 +7197,7 @@ dependencies = [ [[package]] name = "vite_glob" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "thiserror 2.0.18", "wax 0.7.0", @@ -7237,7 +7237,7 @@ dependencies = [ [[package]] name = "vite_graph_ser" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "petgraph 0.8.3", "serde", @@ -7318,7 +7318,7 @@ dependencies = [ [[package]] name = "vite_path" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "bincode", "diff-struct", @@ -7331,7 +7331,7 @@ dependencies = [ [[package]] name = "vite_select" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "anyhow", "crossterm", @@ -7348,6 +7348,7 @@ dependencies = [ "owo-colors", "serde", "serde_json", + "tracing-chrome", "tracing-subscriber", "vite_path", "vite_str", @@ -7356,7 +7357,7 @@ dependencies = [ [[package]] name = "vite_shell" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "bincode", "brush-parser", @@ -7383,7 +7384,7 @@ dependencies = [ [[package]] name = "vite_str" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "bincode", "compact_str", @@ -7394,7 +7395,7 @@ dependencies = [ [[package]] name = "vite_task" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "anyhow", "async-trait", @@ -7430,7 +7431,7 @@ dependencies = [ [[package]] name = "vite_task_graph" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "anyhow", "async-trait", @@ -7440,6 +7441,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "tracing", "vite_graph_ser", "vite_path", "vite_str", @@ -7449,7 +7451,7 @@ dependencies = [ [[package]] name = "vite_task_plan" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "anyhow", "async-trait", @@ -7475,7 +7477,7 @@ dependencies = [ [[package]] name = "vite_workspace" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" dependencies = [ "clap", "path-clean", @@ -7485,6 +7487,7 @@ dependencies = [ "serde_json", "serde_yml", "thiserror 2.0.18", + "tracing", "vec1", "vite_glob", "vite_path", @@ -7755,7 +7758,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] From 098bd4af977306ddc1bab0d6ee32613d5ca48ef7 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 02:45:10 +0800 Subject: [PATCH 21/38] fix: store tracing guard in OnceLock to prevent early drop The NAPI init() function was dropping the chrome-json tracing guard immediately, resulting in empty trace files from the Node.js process. Store it in a static OnceLock so it lives until process exit. Co-Authored-By: Claude Opus 4.6 --- packages/cli/binding/src/lib.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index 83163862ff..79d6f9d430 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -28,10 +28,15 @@ use crate::cli::{ BoxedResolverFn, CliOptions as ViteTaskCliOptions, ResolveCommandResult, ViteConfigResolverFn, }; +/// Guard must be kept alive for the duration of the process when using chrome-json output. +/// Stored in a OnceLock so it's never dropped until process exit. +static TRACING_GUARD: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + /// Module initialization - sets up tracing for debugging #[napi_derive::module_init] pub fn init() { - crate::cli::init_tracing(); + TRACING_GUARD.get_or_init(crate::cli::init_tracing); } /// Configuration options passed from JavaScript to Rust. From 6b26752b1e90fd51367425ce9d112dffe8c26c0e Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 03:01:37 +0800 Subject: [PATCH 22/38] fix: add Sync bound to TRACING_GUARD via Mutex wrapper OnceLock requires its inner type to be Sync for static storage, but Box is not Sync. Wrap in Mutex to satisfy the bound. Co-Authored-By: Claude Opus 4.6 --- packages/cli/binding/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index 79d6f9d430..814ecec9dd 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -30,13 +30,14 @@ use crate::cli::{ /// Guard must be kept alive for the duration of the process when using chrome-json output. /// Stored in a OnceLock so it's never dropped until process exit. -static TRACING_GUARD: std::sync::OnceLock>> = +/// Wrapped in Mutex because `Box` is not Sync, but OnceLock requires Sync for statics. +static TRACING_GUARD: std::sync::OnceLock>>> = std::sync::OnceLock::new(); /// Module initialization - sets up tracing for debugging #[napi_derive::module_init] pub fn init() { - TRACING_GUARD.get_or_init(crate::cli::init_tracing); + TRACING_GUARD.get_or_init(|| std::sync::Mutex::new(crate::cli::init_tracing())); } /// Configuration options passed from JavaScript to Rust. From 67c3ed7e8cd1854241b1409deab70f09e47cac38 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 03:28:14 +0800 Subject: [PATCH 23/38] fix: add shutdownTracing() to flush chrome trace before process exit Rust statics stored in OnceLock are never dropped on process exit, so the ChromeLayer FlushGuard never flushes trace data to disk. Add an explicit shutdown_tracing() NAPI function that drops the guard, and call it from bin.ts before process.exit(). Co-Authored-By: Claude Opus 4.6 --- packages/cli/binding/src/lib.rs | 12 ++++++++++++ packages/cli/src/bin.ts | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/cli/binding/src/lib.rs b/packages/cli/binding/src/lib.rs index 814ecec9dd..fe7a74f6f0 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -40,6 +40,18 @@ pub fn init() { TRACING_GUARD.get_or_init(|| std::sync::Mutex::new(crate::cli::init_tracing())); } +/// Flush and drop the tracing guard. Must be called before process.exit() +/// because Rust statics in OnceLock are never dropped, and the ChromeLayer +/// FlushGuard only writes trace data to disk when dropped. +#[napi] +pub fn shutdown_tracing() { + if let Some(mutex) = TRACING_GUARD.get() { + if let Ok(mut guard) = mutex.lock() { + drop(guard.take()); + } + } +} + /// Configuration options passed from JavaScript to Rust. #[napi(object, object_to_js = false)] pub struct CliOptions { diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 0759503b2f..814bf717ab 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -12,7 +12,7 @@ const jsStartTime = performance.now(); -import { run } from '../binding/index.js'; +import { run, shutdownTracing } from '../binding/index.js'; import { doc } from './resolve-doc.js'; import { fmt } from './resolve-fmt.js'; import { lint } from './resolve-lint.js'; @@ -63,10 +63,12 @@ if (command === 'create') { args: process.argv.slice(2), }) .then((exitCode) => { + shutdownTracing(); process.exit(exitCode); }) .catch((err) => { console.error('[Vite+] run error:', err); + shutdownTracing(); process.exit(1); }); } From 1a913e3a69359d2facbfe54e7d3f13c3f5fa0866 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 03:33:32 +0800 Subject: [PATCH 24/38] docs: add comprehensive performance analysis Includes detailed timing analysis from Chrome tracing (tracing-chrome) across global CLI, NAPI startup, vite-task session, and task execution phases. Key finding: vite.config.ts loading via JS callbacks accounts for 99% of the overhead before task execution (~930ms for 10-package monorepo). Co-Authored-By: Claude Opus 4.6 --- performance.md | 291 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 performance.md diff --git a/performance.md b/performance.md new file mode 100644 index 0000000000..b78c85738a --- /dev/null +++ b/performance.md @@ -0,0 +1,291 @@ +# vite-plus Performance Analysis + +Performance measurements from E2E tests (Ubuntu, GitHub Actions runner). + +**Test projects**: vibe-dashboard (single-package), frm-stack (multi-package monorepo) +**Node.js**: 24.14.0 (managed by vite-plus js_runtime) +**Commands tested**: `vp fmt`, `vp test`, `vp run build`, `vp run lint:check`, `vp run @yourcompany/api#test` + +## Architecture Overview + +A `vp` command invocation traverses multiple layers: + +``` +User runs `vp run lint:check` + │ + ├─ [1] Global CLI (Rust binary `vp`) ~3-8ms + │ ├─ argv0 processing ~40μs + │ ├─ Node.js runtime resolution ~1.3ms + │ ├─ Module resolution (oxc_resolver) ~170μs + │ └─ Delegates to local CLI via exec(node bin.js) + │ + ├─ [2] Node.js startup + NAPI loading ~3.7ms + │ └─ bin.ts entry → import NAPI binding → call run() + │ + ├─ [3] Rust core via NAPI (vite-task session) + │ ├─ Session init ~60μs + │ ├─ load_package_graph (workspace discovery) ~4ms + │ ├─ load_user_config_file × N (JS callbacks) ~160ms first, ~3-12ms subsequent + │ ├─ handle_command + resolve (JS callbacks) ~1.3ms + │ └─ Task execution (spawns child processes) + │ + └─ [4] Task spawns (child processes) + ├─ Spawn 1: pnpm install / dependsOn ~0.95-1.05s + └─ Spawn 2: actual command varies (1-6s) +``` + +## Phase 1: Global CLI (Rust binary) + +Measured via Chrome tracing from the `vp` binary process. +Timestamps are relative to process start (microseconds). + +### Breakdown (6 invocations, vibe-dashboard, Ubuntu) + +| Stage | Time from start | Duration | +|---|---|---| +| argv0 processing | 37-57μs | ~40μs | +| Runtime resolution start | 482-684μs | ~500μs | +| Node.js version selected | 714-1042μs | ~300μs | +| LTS alias resolved | 723-1075μs | ~10μs | +| Version index cache check | 1181-1541μs | ~400μs | +| Node.js version resolved | 1237-1593μs | ~50μs | +| Node.js cache confirmed | 1302-1627μs | ~50μs | +| **oxc_resolver start** | **3058-7896μs** | — | +| oxc_resolver complete | 3230-8072μs | **~170μs** | +| Delegation to Node.js | 3275-8160μs | ~40μs | + +### Key Observations + +- **Total global CLI overhead**: 3.3ms - 8.2ms per invocation +- **oxc_resolver** is extremely fast (~170μs), resolving `vite-plus/package.json` via node_modules +- **Dominant variable cost**: Gap between "Node cached" and "oxc_resolver start" (1.7-6.6ms). This includes CLI argument parsing, command dispatch, and resolver initialization +- **Node.js runtime resolution** consistently uses cached version index and cached Node.js binary (~1.3ms) + +## Phase 2: Node.js Startup + NAPI Loading + +Measured from NAPI-side Chrome traces (frm-stack project). + +The NAPI `run()` function is first called at **~3.7ms** from Node.js process start: + +| Event | Time (μs) | Notes | +|---|---|---| +| NAPI `run()` entered | 3,682 | First trace event from NAPI module | +| `napi_run: start` | 3,950 | After ThreadsafeFunction setup | +| `cli::main` span begins | 4,116 | CLI argument processing starts | + +This means **Node.js startup + ES module loading + NAPI binding initialization takes ~3.7ms**. + +## Phase 3: Rust Core via NAPI (vite-task) + +### NAPI-side Detailed Breakdown (frm-stack `vp run lint:check`) + +From Chrome trace, all times in μs from process start: + +``` + 3,682 NAPI run() entered + 3,950 napi_run: start + 4,116 cli::main begins + 4,742 execute_vite_task_command begins + 4,865 session::init begins + 4,907 init_with begins + 4,923 init_with ends ── 16μs + 4,924 session::init ends ── 59μs + 4,925 session::main begins + 4,931 plan_from_cli_run_resolved begins + 4,935 plan_query begins + 4,941 load_task_graph begins + 4,943 task_graph::load begins + 4,944 load_package_graph begins ━━ 3.8ms + 8,764 load_package_graph ends + 8,779 load_user_config_file #1 begins ━━ 164ms (first vite.config.ts load) +173,248 load_user_config_file #1 ends +173,265 load_user_config_file #2 begins ━━ 12ms +185,212 load_user_config_file #2 ends +185,221 load_user_config_file #3 begins ━━ 3.4ms +188,666 load_user_config_file #3 ends +188,675 load_user_config_file #4 begins ━━ 741ms (cold import of workspace package config) +929,476 load_user_config_file #4 ends + ... (subsequent loads: ~3-5ms each) +``` + +### Critical Finding: vite.config.ts Loading is the Bottleneck + +The **`load_user_config_file`** callback (which calls back into JavaScript to load `vite.config.ts` for each workspace package) dominates the task graph loading time: + +| Config Load | Duration | Notes | +|---|---|---| +| First package | **164ms** | Cold import: requires JS module resolution + transpilation | +| Second package | **12ms** | Warm: shared dependencies already cached | +| Third package | **3.4ms** | Warm: nearly all deps cached | +| Fourth package (different deps) | **741ms** | Cold: imports new heavy dependencies | +| Subsequent packages | **3-5ms** each | All warm | + +**For frm-stack (10 packages), total config loading: ~930ms** — this is the single largest cost. + +### Callback Timing (`handle_command` + `resolve`) + +After the task graph is loaded, vite-task calls back into JavaScript to resolve the tool binary: + +``` +937,757 handle_command begins +937,868 resolve begins +937,873 js_resolver begins (test command) +939,126 js_resolver ends ── 1.25ms +939,187 resolve ends +939,189 handle_command ends ── 1.43ms +``` + +The `js_resolver` callback (which locates the test runner binary via JavaScript) takes **~1.25ms**. + +### Session Init Timing Comparison + +| Stage | frm-stack (10 packages) | Notes | +|---|---|---| +| Session init | ~60μs | Minimal setup | +| load_package_graph | ~4ms | Workspace discovery | +| load_user_config_file (all) | **~930ms** | JS callbacks, dominant cost | +| handle_command + resolve | ~1.4ms | Tool binary resolution | +| **Total before task execution** | **~936ms** | | + +## Phase 4: Task Execution (vibe-dashboard) + +### Spawn Timing (First Run — Cold) + +| Command | Spawn 1 (setup) | Spawn 2 (execution) | Total | +|---|---|---|---| +| `vp fmt` | 1.05s (977 reads, 50 writes) | 1.00s (163 reads, 1 write) | ~2.1s | +| `vp test` | 0.96s (977 reads, 50 writes) | 5.71s (4699 reads, 26 writes) | ~6.7s | +| `vp run build` | 0.95s (977 reads, 50 writes) | 1.61s (3753 reads, 17 writes) | ~2.6s | + +### Spawn Timing (Second Run — Cache Available) + +| Command | Spawn 1 (setup) | Spawn 2 (execution) | Total | Delta | +|---|---|---|---|---| +| `vp fmt` | 0.95s (977 reads, 50 writes) | 0.97s (167 reads, 3 writes) | ~1.9s | -0.2s | +| `vp test` | 0.95s (977 reads, 50 writes) | 4.17s (1930 reads, 4 writes) | ~5.1s | **-1.6s** | +| `vp run build` | 0.96s (977 reads, 50 writes) | **cache hit (replayed)** | ~1.0s | **-1.6s** | + +### Key Observations + +- **Spawn 1 is constant** (~0.95-1.05s, 977 path_reads, 50 path_writes) regardless of command or cache state. This is the workspace/task-graph loading + pnpm resolution overhead. +- **`vp run build` cache hit**: On second run, the build was fully replayed from cache, saving 1.19s. The 977-read spawn 1 still executes. +- **`vp test` improvement**: Second run read 1930 paths (vs 4699), suggesting OS filesystem caching reduced disk I/O. + +## Phase 5: Task Cache Effectiveness + +vite-task implements a file-system-aware task cache at `node_modules/.vite/task-cache`. + +| Command | First Run | Cache Run | Cache Hit? | Savings | +|---|---|---|---|---| +| `vp fmt` | 2.1s | 1.9s | No | — | +| `vp test` | 6.7s | 5.1s | No | -1.6s (OS cache) | +| `vp run build` | 2.6s | 1.0s | **Yes** | **-1.6s** (1.19s from task cache) | + +**Only `vp run build` was cache-eligible.** Formatting and test commands are not cached (side effects / non-deterministic outputs). + +## End-to-End Timeline: Full Command Lifecycle + +Combining all phases for a single `vp run lint:check` invocation (frm-stack): + +``` +T+0.00ms Global CLI starts (Rust binary) +T+0.04ms argv0 processed +T+0.50ms Runtime resolution begins +T+1.30ms Node.js version resolved (cached) +T+3.30ms oxc_resolver finds local vite-plus ── ~170μs +T+3.35ms exec(node, [dist/bin.js, "run", "lint:check"]) ── process replaced +─── Node.js process starts ─── +T+3.70ms NAPI run() called (Node.js startup overhead) +T+4.00ms napi_run: start +T+4.12ms cli::main begins +T+4.74ms execute_vite_task_command begins +T+4.94ms load_package_graph begins +T+8.76ms load_package_graph ends ── 3.8ms +T+8.78ms load_user_config_file #1 begins (JS callback) +T+173ms load_user_config_file #1 ends ── 164ms ★ bottleneck + ... (more config loads) +T+937ms handle_command begins +T+939ms handle_command ends (js_resolver: 1.25ms) +T+940ms Task execution starts (child process spawn) + ... (actual command runs) +``` + +**Total overhead before task execution: ~940ms**, of which **~930ms (99%) is vite.config.ts loading**. + +## Wall-Clock Timelines (vibe-dashboard, Ubuntu) + +### First Run + +``` +19:16:44.039 vp fmt — pnpm download starts +19:16:44.170 vp fmt — cache dir created +19:16:45.158 vp fmt — spawn 1 finished (setup) +19:16:46.028 vp fmt — spawn 2 finished (biome) Total: ~2.0s +19:16:46.082 vp test — pnpm resolution starts +19:16:46.084 vp test — cache dir created +19:16:47.057 vp test — spawn 1 finished (setup) +19:16:52.750 vp test — spawn 2 finished (vitest) Total: ~6.7s +19:16:52.846 vp run build — cache dir created +19:16:53.793 vp run build — spawn 1 finished (setup) +19:16:55.398 vp run build — spawn 2 finished (vite build) Total: ~2.6s +``` + +**Total first run: ~11.4s** (3 commands sequential) + +### Cache Run + +``` +19:16:56.446 vp fmt — cache dir created +19:16:57.399 vp fmt — spawn 1 finished +19:16:58.368 vp fmt — spawn 2 finished Total: ~1.9s +19:16:58.441 vp test — cache dir created +19:16:59.390 vp test — spawn 1 finished +19:17:03.556 vp test — spawn 2 finished Total: ~5.1s +19:17:03.641 vp run build — cache dir created +19:17:04.596 vp run build — spawn 1 finished +19:17:05.040 vp run build — cache replayed Total: ~1.4s +``` + +**Total cache run: ~8.6s** (-24% from first run) + +## Summary of Bottlenecks + +| Bottleneck | Time | % of overhead | Optimization opportunity | +|---|---|---|---| +| vite.config.ts loading (cold) | **164-741ms** per package | **99%** | Cache config results, lazy loading, parallel loading | +| Spawn 1 (pnpm/setup) | **~1s** | — | Persistent process, avoid re-resolving | +| load_package_graph | **~4ms** | <1% | Already fast | +| Session init | **~60μs** | <0.01% | Already fast | +| Global CLI overhead | **~5ms** | <0.5% | Already fast | +| Node.js + NAPI startup | **~3.7ms** | <0.4% | Already fast | +| oxc_resolver | **~170μs** | <0.02% | Already fast | +| js_resolver callback | **~1.25ms** | <0.1% | Already fast | + +**The single most impactful optimization would be caching or parallelizing `load_user_config_file` calls.** The first cold load takes 164ms, and when new heavy dependencies are encountered, loads can take 741ms. For a 10-package monorepo, this accumulates to ~930ms of config loading before any task runs. + +## Inter-Process Communication + +vite-task uses Unix shared memory (`/dev/shm`) for parent-child process communication during task execution: +- Creates persistent mapping at `/shmem_` +- Maps memory into address space for fast IPC +- Cleaned up after spawn completion + +## Known Issues + +### Windows: Trace files break formatter +When `VITE_LOG_OUTPUT=chrome-json` is set, trace files (`trace-*.json`) are written to the project working directory. On Windows, `vp fmt` picks up these files and fails with "Unterminated string constant" because the trace files contain very long PATH strings. + +**Recommendation**: Add `trace-*.json` to formatter ignore patterns, or write trace files to a dedicated directory outside the workspace. + +### NAPI trace files empty for some projects +The Chrome tracing `FlushGuard` stored in a static `OnceLock` is never dropped when `process.exit()` is called. Fixed by adding `shutdownTracing()` NAPI function called before exit (commit `72b23304`). Some projects (frm-stack) produce traces because their exit path differs. + +## Methodology + +- **Tracing**: Rust `tracing` crate with `tracing-chrome` subscriber (Chrome DevTools JSON format) +- **Environment variables**: `VITE_LOG=debug`, `VITE_LOG_OUTPUT=chrome-json` +- **CI environment**: GitHub Actions ubuntu-latest runner +- **Measurement PRs**: + - vite-task: https://github.com/voidzero-dev/vite-task/pull/178 + - vite-plus: https://github.com/voidzero-dev/vite-plus/pull/663 +- **Trace sources**: Global CLI traces (6 files, vibe-dashboard), NAPI traces (20 files, frm-stack) From a031579fde552e6846021ed28fd253e99ad4c80b Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 03:38:10 +0800 Subject: [PATCH 25/38] fix: add shutdownTracing export to NAPI binding files The new shutdownTracing() NAPI function needs its export registered in the auto-generated binding files for the snapshot test to pass. Co-Authored-By: Claude Opus 4.6 --- packages/cli/binding/index.cjs | 1 + packages/cli/binding/index.d.cts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/packages/cli/binding/index.cjs b/packages/cli/binding/index.cjs index ffe0841dbd..189d3417ce 100644 --- a/packages/cli/binding/index.cjs +++ b/packages/cli/binding/index.cjs @@ -769,3 +769,4 @@ module.exports.rewriteImportsInDirectory = nativeBinding.rewriteImportsInDirecto module.exports.rewriteScripts = nativeBinding.rewriteScripts; module.exports.run = nativeBinding.run; module.exports.runCommand = nativeBinding.runCommand; +module.exports.shutdownTracing = nativeBinding.shutdownTracing; diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index 18b0b70b2d..c693124b93 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -267,6 +267,13 @@ export declare function rewriteScripts(scriptsJson: string, rulesYaml: string): */ export declare function run(options: CliOptions): Promise; +/** + * Flush and drop the tracing guard. Must be called before process.exit() + * because Rust statics in OnceLock are never dropped, and the ChromeLayer + * FlushGuard only writes trace data to disk when dropped. + */ +export declare function shutdownTracing(): void; + /** * Run a command with fspy tracking, callable from JavaScript. * From 51cd0cec7f138e579f6065351e09add26d3360d4 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 04:00:46 +0800 Subject: [PATCH 26/38] style: format performance.md tables with oxfmt Align markdown table columns per oxfmt formatting rules. Co-Authored-By: Claude Opus 4.6 --- performance.md | 113 +++++++++++++++++++++++++------------------------ 1 file changed, 58 insertions(+), 55 deletions(-) diff --git a/performance.md b/performance.md index b78c85738a..db0e3442bf 100644 --- a/performance.md +++ b/performance.md @@ -41,18 +41,18 @@ Timestamps are relative to process start (microseconds). ### Breakdown (6 invocations, vibe-dashboard, Ubuntu) -| Stage | Time from start | Duration | -|---|---|---| -| argv0 processing | 37-57μs | ~40μs | -| Runtime resolution start | 482-684μs | ~500μs | -| Node.js version selected | 714-1042μs | ~300μs | -| LTS alias resolved | 723-1075μs | ~10μs | -| Version index cache check | 1181-1541μs | ~400μs | -| Node.js version resolved | 1237-1593μs | ~50μs | -| Node.js cache confirmed | 1302-1627μs | ~50μs | -| **oxc_resolver start** | **3058-7896μs** | — | -| oxc_resolver complete | 3230-8072μs | **~170μs** | -| Delegation to Node.js | 3275-8160μs | ~40μs | +| Stage | Time from start | Duration | +| ------------------------- | --------------- | ---------- | +| argv0 processing | 37-57μs | ~40μs | +| Runtime resolution start | 482-684μs | ~500μs | +| Node.js version selected | 714-1042μs | ~300μs | +| LTS alias resolved | 723-1075μs | ~10μs | +| Version index cache check | 1181-1541μs | ~400μs | +| Node.js version resolved | 1237-1593μs | ~50μs | +| Node.js cache confirmed | 1302-1627μs | ~50μs | +| **oxc_resolver start** | **3058-7896μs** | — | +| oxc_resolver complete | 3230-8072μs | **~170μs** | +| Delegation to Node.js | 3275-8160μs | ~40μs | ### Key Observations @@ -67,11 +67,11 @@ Measured from NAPI-side Chrome traces (frm-stack project). The NAPI `run()` function is first called at **~3.7ms** from Node.js process start: -| Event | Time (μs) | Notes | -|---|---|---| -| NAPI `run()` entered | 3,682 | First trace event from NAPI module | -| `napi_run: start` | 3,950 | After ThreadsafeFunction setup | -| `cli::main` span begins | 4,116 | CLI argument processing starts | +| Event | Time (μs) | Notes | +| ----------------------- | --------- | ---------------------------------- | +| NAPI `run()` entered | 3,682 | First trace event from NAPI module | +| `napi_run: start` | 3,950 | After ThreadsafeFunction setup | +| `cli::main` span begins | 4,116 | CLI argument processing starts | This means **Node.js startup + ES module loading + NAPI binding initialization takes ~3.7ms**. @@ -112,13 +112,13 @@ From Chrome trace, all times in μs from process start: The **`load_user_config_file`** callback (which calls back into JavaScript to load `vite.config.ts` for each workspace package) dominates the task graph loading time: -| Config Load | Duration | Notes | -|---|---|---| -| First package | **164ms** | Cold import: requires JS module resolution + transpilation | -| Second package | **12ms** | Warm: shared dependencies already cached | -| Third package | **3.4ms** | Warm: nearly all deps cached | -| Fourth package (different deps) | **741ms** | Cold: imports new heavy dependencies | -| Subsequent packages | **3-5ms** each | All warm | +| Config Load | Duration | Notes | +| ------------------------------- | -------------- | ---------------------------------------------------------- | +| First package | **164ms** | Cold import: requires JS module resolution + transpilation | +| Second package | **12ms** | Warm: shared dependencies already cached | +| Third package | **3.4ms** | Warm: nearly all deps cached | +| Fourth package (different deps) | **741ms** | Cold: imports new heavy dependencies | +| Subsequent packages | **3-5ms** each | All warm | **For frm-stack (10 packages), total config loading: ~930ms** — this is the single largest cost. @@ -139,31 +139,31 @@ The `js_resolver` callback (which locates the test runner binary via JavaScript) ### Session Init Timing Comparison -| Stage | frm-stack (10 packages) | Notes | -|---|---|---| -| Session init | ~60μs | Minimal setup | -| load_package_graph | ~4ms | Workspace discovery | -| load_user_config_file (all) | **~930ms** | JS callbacks, dominant cost | -| handle_command + resolve | ~1.4ms | Tool binary resolution | -| **Total before task execution** | **~936ms** | | +| Stage | frm-stack (10 packages) | Notes | +| ------------------------------- | ----------------------- | --------------------------- | +| Session init | ~60μs | Minimal setup | +| load_package_graph | ~4ms | Workspace discovery | +| load_user_config_file (all) | **~930ms** | JS callbacks, dominant cost | +| handle_command + resolve | ~1.4ms | Tool binary resolution | +| **Total before task execution** | **~936ms** | | ## Phase 4: Task Execution (vibe-dashboard) ### Spawn Timing (First Run — Cold) -| Command | Spawn 1 (setup) | Spawn 2 (execution) | Total | -|---|---|---|---| -| `vp fmt` | 1.05s (977 reads, 50 writes) | 1.00s (163 reads, 1 write) | ~2.1s | -| `vp test` | 0.96s (977 reads, 50 writes) | 5.71s (4699 reads, 26 writes) | ~6.7s | +| Command | Spawn 1 (setup) | Spawn 2 (execution) | Total | +| -------------- | ---------------------------- | ----------------------------- | ----- | +| `vp fmt` | 1.05s (977 reads, 50 writes) | 1.00s (163 reads, 1 write) | ~2.1s | +| `vp test` | 0.96s (977 reads, 50 writes) | 5.71s (4699 reads, 26 writes) | ~6.7s | | `vp run build` | 0.95s (977 reads, 50 writes) | 1.61s (3753 reads, 17 writes) | ~2.6s | ### Spawn Timing (Second Run — Cache Available) -| Command | Spawn 1 (setup) | Spawn 2 (execution) | Total | Delta | -|---|---|---|---|---| -| `vp fmt` | 0.95s (977 reads, 50 writes) | 0.97s (167 reads, 3 writes) | ~1.9s | -0.2s | -| `vp test` | 0.95s (977 reads, 50 writes) | 4.17s (1930 reads, 4 writes) | ~5.1s | **-1.6s** | -| `vp run build` | 0.96s (977 reads, 50 writes) | **cache hit (replayed)** | ~1.0s | **-1.6s** | +| Command | Spawn 1 (setup) | Spawn 2 (execution) | Total | Delta | +| -------------- | ---------------------------- | ---------------------------- | ----- | --------- | +| `vp fmt` | 0.95s (977 reads, 50 writes) | 0.97s (167 reads, 3 writes) | ~1.9s | -0.2s | +| `vp test` | 0.95s (977 reads, 50 writes) | 4.17s (1930 reads, 4 writes) | ~5.1s | **-1.6s** | +| `vp run build` | 0.96s (977 reads, 50 writes) | **cache hit (replayed)** | ~1.0s | **-1.6s** | ### Key Observations @@ -175,11 +175,11 @@ The `js_resolver` callback (which locates the test runner binary via JavaScript) vite-task implements a file-system-aware task cache at `node_modules/.vite/task-cache`. -| Command | First Run | Cache Run | Cache Hit? | Savings | -|---|---|---|---|---| -| `vp fmt` | 2.1s | 1.9s | No | — | -| `vp test` | 6.7s | 5.1s | No | -1.6s (OS cache) | -| `vp run build` | 2.6s | 1.0s | **Yes** | **-1.6s** (1.19s from task cache) | +| Command | First Run | Cache Run | Cache Hit? | Savings | +| -------------- | --------- | --------- | ---------- | --------------------------------- | +| `vp fmt` | 2.1s | 1.9s | No | — | +| `vp test` | 6.7s | 5.1s | No | -1.6s (OS cache) | +| `vp run build` | 2.6s | 1.0s | **Yes** | **-1.6s** (1.19s from task cache) | **Only `vp run build` was cache-eligible.** Formatting and test commands are not cached (side effects / non-deterministic outputs). @@ -250,22 +250,23 @@ T+940ms Task execution starts (child process spawn) ## Summary of Bottlenecks -| Bottleneck | Time | % of overhead | Optimization opportunity | -|---|---|---|---| -| vite.config.ts loading (cold) | **164-741ms** per package | **99%** | Cache config results, lazy loading, parallel loading | -| Spawn 1 (pnpm/setup) | **~1s** | — | Persistent process, avoid re-resolving | -| load_package_graph | **~4ms** | <1% | Already fast | -| Session init | **~60μs** | <0.01% | Already fast | -| Global CLI overhead | **~5ms** | <0.5% | Already fast | -| Node.js + NAPI startup | **~3.7ms** | <0.4% | Already fast | -| oxc_resolver | **~170μs** | <0.02% | Already fast | -| js_resolver callback | **~1.25ms** | <0.1% | Already fast | +| Bottleneck | Time | % of overhead | Optimization opportunity | +| ----------------------------- | ------------------------- | ------------- | ---------------------------------------------------- | +| vite.config.ts loading (cold) | **164-741ms** per package | **99%** | Cache config results, lazy loading, parallel loading | +| Spawn 1 (pnpm/setup) | **~1s** | — | Persistent process, avoid re-resolving | +| load_package_graph | **~4ms** | <1% | Already fast | +| Session init | **~60μs** | <0.01% | Already fast | +| Global CLI overhead | **~5ms** | <0.5% | Already fast | +| Node.js + NAPI startup | **~3.7ms** | <0.4% | Already fast | +| oxc_resolver | **~170μs** | <0.02% | Already fast | +| js_resolver callback | **~1.25ms** | <0.1% | Already fast | **The single most impactful optimization would be caching or parallelizing `load_user_config_file` calls.** The first cold load takes 164ms, and when new heavy dependencies are encountered, loads can take 741ms. For a 10-package monorepo, this accumulates to ~930ms of config loading before any task runs. ## Inter-Process Communication vite-task uses Unix shared memory (`/dev/shm`) for parent-child process communication during task execution: + - Creates persistent mapping at `/shmem_` - Maps memory into address space for fast IPC - Cleaned up after spawn completion @@ -273,11 +274,13 @@ vite-task uses Unix shared memory (`/dev/shm`) for parent-child process communic ## Known Issues ### Windows: Trace files break formatter + When `VITE_LOG_OUTPUT=chrome-json` is set, trace files (`trace-*.json`) are written to the project working directory. On Windows, `vp fmt` picks up these files and fails with "Unterminated string constant" because the trace files contain very long PATH strings. **Recommendation**: Add `trace-*.json` to formatter ignore patterns, or write trace files to a dedicated directory outside the workspace. ### NAPI trace files empty for some projects + The Chrome tracing `FlushGuard` stored in a static `OnceLock` is never dropped when `process.exit()` is called. Fixed by adding `shutdownTracing()` NAPI function called before exit (commit `72b23304`). Some projects (frm-stack) produce traces because their exit path differs. ## Methodology From b3066e37e63693fcd7ab9fe51f841e84041eea07 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 04:33:49 +0800 Subject: [PATCH 27/38] fix: reorder shutdownTracing declaration to match NAPI-RS generation NAPI-RS generates type declarations alphabetically, placing shutdownTracing (s) after runCommand (r) and its interfaces. Co-Authored-By: Claude Opus 4.6 --- packages/cli/binding/index.d.cts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli/binding/index.d.cts b/packages/cli/binding/index.d.cts index c693124b93..d2bdf92bfe 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -267,13 +267,6 @@ export declare function rewriteScripts(scriptsJson: string, rulesYaml: string): */ export declare function run(options: CliOptions): Promise; -/** - * Flush and drop the tracing guard. Must be called before process.exit() - * because Rust statics in OnceLock are never dropped, and the ChromeLayer - * FlushGuard only writes trace data to disk when dropped. - */ -export declare function shutdownTracing(): void; - /** * Run a command with fspy tracking, callable from JavaScript. * @@ -343,3 +336,10 @@ export interface RunCommandResult { /** Map of relative paths to their access modes */ pathAccesses: Record; } + +/** + * Flush and drop the tracing guard. Must be called before process.exit() + * because Rust statics in OnceLock are never dropped, and the ChromeLayer + * FlushGuard only writes trace data to disk when dropped. + */ +export declare function shutdownTracing(): void; From 7813c0c293a65cc0a8a26e0f0a9dd28968ca1a8d Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 07:56:01 +0800 Subject: [PATCH 28/38] fix: write trace files to dedicated directory to avoid breaking formatters Trace files (trace-*.json) written to the project cwd were picked up by formatters during E2E tests, causing failures due to truncated JSON. Add VITE_LOG_OUTPUT_DIR env var to write trace files to a dedicated directory. Set it in the E2E workflow to ${{ runner.temp }}/trace-artifacts. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-test.yml | 6 ++---- crates/vite_shared/src/env_vars.rs | 3 +++ crates/vite_shared/src/tracing.rs | 20 ++++++++++++++++---- performance.md | 6 +++--- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index c89b618a38..28cc6fc849 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -290,6 +290,7 @@ jobs: env: VITE_LOG: debug VITE_LOG_OUTPUT: chrome-json + VITE_LOG_OUTPUT_DIR: ${{ runner.temp }}/trace-artifacts run: ${{ matrix.project.command }} - name: Run vite-plus commands again (cache run) in ${{ matrix.project.name }} @@ -297,6 +298,7 @@ jobs: env: VITE_LOG: debug VITE_LOG_OUTPUT: chrome-json + VITE_LOG_OUTPUT_DIR: ${{ runner.temp }}/trace-artifacts run: ${{ matrix.project.command }} - name: Collect trace files @@ -304,10 +306,6 @@ jobs: shell: bash run: | mkdir -p ${{ runner.temp }}/trace-artifacts - # Chrome tracing writes trace-*.json in the cwd of the process - find ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }} -name 'trace-*.json' -exec cp {} ${{ runner.temp }}/trace-artifacts/ \; 2>/dev/null || true - # Also check the workspace root - find $GITHUB_WORKSPACE -maxdepth 1 -name 'trace-*.json' -exec cp {} ${{ runner.temp }}/trace-artifacts/ \; 2>/dev/null || true ls -la ${{ runner.temp }}/trace-artifacts/ 2>/dev/null || echo "No trace files found" - name: Upload trace artifacts diff --git a/crates/vite_shared/src/env_vars.rs b/crates/vite_shared/src/env_vars.rs index 54b81bdade..680c5f4339 100644 --- a/crates/vite_shared/src/env_vars.rs +++ b/crates/vite_shared/src/env_vars.rs @@ -21,6 +21,9 @@ pub const VITE_LOG: &str = "VITE_LOG"; /// Output mode for tracing (e.g. `"chrome-json"` for Chrome DevTools timeline). pub const VITE_LOG_OUTPUT: &str = "VITE_LOG_OUTPUT"; +/// Directory for chrome-json trace files (default: current working directory). +pub const VITE_LOG_OUTPUT_DIR: &str = "VITE_LOG_OUTPUT_DIR"; + /// NPM registry URL (lowercase form, highest priority). pub const NPM_CONFIG_REGISTRY: &str = "npm_config_registry"; diff --git a/crates/vite_shared/src/tracing.rs b/crates/vite_shared/src/tracing.rs index 3b94480f55..c2428be6a1 100644 --- a/crates/vite_shared/src/tracing.rs +++ b/crates/vite_shared/src/tracing.rs @@ -4,8 +4,9 @@ //! - `VITE_LOG`: Controls log filtering (e.g., `"debug"`, `"vite_task=trace"`) //! - `VITE_LOG_OUTPUT`: Output format — `"chrome-json"` for Chrome DevTools timeline, //! `"readable"` for pretty-printed output, or default stdout. +//! - `VITE_LOG_OUTPUT_DIR`: Directory for chrome-json trace files (default: cwd). -use std::{any::Any, sync::atomic::AtomicBool}; +use std::{any::Any, path::PathBuf, sync::atomic::AtomicBool}; use tracing_chrome::ChromeLayerBuilder; use tracing_subscriber::{ @@ -48,10 +49,21 @@ pub fn init_tracing() -> Option> { match output_mode.as_str() { "chrome-json" => { - let (chrome_layer, guard) = ChromeLayerBuilder::new() + let mut builder = ChromeLayerBuilder::new() .trace_style(tracing_chrome::TraceStyle::Async) - .include_args(true) - .build(); + .include_args(true); + // Write trace files to VITE_LOG_OUTPUT_DIR if set, to avoid + // polluting the project directory (formatters may pick them up). + if let Ok(dir) = std::env::var(env_vars::VITE_LOG_OUTPUT_DIR) { + let dir = PathBuf::from(dir); + let _ = std::fs::create_dir_all(&dir); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros()) + .unwrap_or(0); + builder = builder.file(dir.join(format!("trace-{ts}.json"))); + } + let (chrome_layer, guard) = builder.build(); tracing_subscriber::registry().with(targets).with(chrome_layer).init(); Some(Box::new(guard)) } diff --git a/performance.md b/performance.md index db0e3442bf..e8ada076cf 100644 --- a/performance.md +++ b/performance.md @@ -273,11 +273,11 @@ vite-task uses Unix shared memory (`/dev/shm`) for parent-child process communic ## Known Issues -### Windows: Trace files break formatter +### Trace files break formatter (fixed) -When `VITE_LOG_OUTPUT=chrome-json` is set, trace files (`trace-*.json`) are written to the project working directory. On Windows, `vp fmt` picks up these files and fails with "Unterminated string constant" because the trace files contain very long PATH strings. +When `VITE_LOG_OUTPUT=chrome-json` is set, trace files (`trace-*.json`) were written to the project working directory. Formatters (oxfmt/prettier) pick up these files and fail with "Unterminated string constant" because trace files may contain truncated JSON (especially on Windows where PATH strings are very long). -**Recommendation**: Add `trace-*.json` to formatter ignore patterns, or write trace files to a dedicated directory outside the workspace. +**Fix**: Set `VITE_LOG_OUTPUT_DIR` to write trace files to a dedicated directory outside the workspace. ### NAPI trace files empty for some projects From 6382c505212470edb6d517b43640180d0e864f15 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 08:15:00 +0800 Subject: [PATCH 29/38] docs: update performance.md with comprehensive multi-project analysis Expands from 2-project analysis to full 9-project ecosystem-ci coverage (73 trace files). Adds cross-project comparison table, per-command breakdown for frm-stack, config loading pattern analysis, and first-run vs cache-run statistics. Co-Authored-By: Claude Opus 4.6 --- performance.md | 309 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 202 insertions(+), 107 deletions(-) diff --git a/performance.md b/performance.md index e8ada076cf..3fc6110509 100644 --- a/performance.md +++ b/performance.md @@ -2,9 +2,9 @@ Performance measurements from E2E tests (Ubuntu, GitHub Actions runner). -**Test projects**: vibe-dashboard (single-package), frm-stack (multi-package monorepo) -**Node.js**: 24.14.0 (managed by vite-plus js_runtime) -**Commands tested**: `vp fmt`, `vp test`, `vp run build`, `vp run lint:check`, `vp run @yourcompany/api#test` +**Test projects**: 9 ecosystem-ci projects (single-package and multi-package monorepos) +**Node.js**: 22-24 (managed by vite-plus js_runtime) +**Trace source**: E2E run #22552050124 (73 trace files across 9 projects) ## Architecture Overview @@ -12,54 +12,105 @@ A `vp` command invocation traverses multiple layers: ``` User runs `vp run lint:check` - │ - ├─ [1] Global CLI (Rust binary `vp`) ~3-8ms - │ ├─ argv0 processing ~40μs - │ ├─ Node.js runtime resolution ~1.3ms - │ ├─ Module resolution (oxc_resolver) ~170μs - │ └─ Delegates to local CLI via exec(node bin.js) - │ - ├─ [2] Node.js startup + NAPI loading ~3.7ms - │ └─ bin.ts entry → import NAPI binding → call run() - │ - ├─ [3] Rust core via NAPI (vite-task session) - │ ├─ Session init ~60μs - │ ├─ load_package_graph (workspace discovery) ~4ms - │ ├─ load_user_config_file × N (JS callbacks) ~160ms first, ~3-12ms subsequent - │ ├─ handle_command + resolve (JS callbacks) ~1.3ms - │ └─ Task execution (spawns child processes) - │ - └─ [4] Task spawns (child processes) - ├─ Spawn 1: pnpm install / dependsOn ~0.95-1.05s - └─ Spawn 2: actual command varies (1-6s) + | + +- [1] Global CLI (Rust binary `vp`) ~3-9ms + | +- argv0 processing ~40us + | +- Node.js runtime resolution ~1.3ms + | +- Module resolution (oxc_resolver) ~170us + | +- Delegates to local CLI via exec(node bin.js) + | + +- [2] Node.js startup + NAPI loading ~3.7ms + | +- bin.ts entry -> import NAPI binding -> call run() + | + +- [3] Rust core via NAPI (vite-task session) + | +- Session init ~60us + | +- load_package_graph (workspace discovery) ~1-10ms + | +- load_user_config_file x N (JS callbacks) ~168ms-1.3s total + | +- handle_command + resolve (JS callbacks) ~0.02-1.3ms + | +- Task execution (spawns child processes) + | + +- [4] Task spawns (child processes) + +- Spawn 1: pnpm install / dependsOn ~0.95-1.05s + +- Spawn 2: actual command varies (1-6s) ``` +## Cross-Project Comparison + +Overhead measured from all 9 ecosystem-ci projects (Ubuntu, first run): + +| Project | Packages | Global CLI | load_package_graph | Config loading | Total overhead | +| ------------------------- | -------- | ---------- | ------------------ | -------------- | -------------- | +| oxlint-plugin-complexity | 1 | 8.8ms | 1.0ms | **168ms** | **170ms** | +| vue-mini | 4 | 6.1ms | 2.2ms | **172ms** | **175ms** | +| dify | 1 | 4-14ms | 10.0ms | **181ms** | **196ms** | +| vitepress | 4 | 3.9ms | 1.2ms | **196ms** | **199ms** | +| vite-vue-vercel | 1 | 3-7ms | 1.4ms | **360ms** | **364ms** | +| rollipop | 6 | 4-5ms | 2.7ms | **639ms** | **648ms** | +| frm-stack | 10-11 | 3-7ms | 3.5ms | **836ms** | **843ms** | +| tanstack-start-helloworld | 1 | 4-6ms | 0.1ms | **1,292ms** | **1,294ms** | +| vibe-dashboard | N/A | 4-7ms | N/A | N/A | N/A | + +vibe-dashboard only produced global CLI traces (no NAPI traces captured). + +Config loading accounts for **95-99%** of total NAPI overhead in every project. Everything else is negligible. + +### Config Loading Patterns + +The first `load_user_config_file` call always pays a fixed JS module initialization cost (~150-170ms for typical projects). Projects with heavy Vite plugins pay much more: + +| Project | First config | Biggest config | Subsequent configs | +| ------------------------- | ------------ | -------------- | ------------------ | +| oxlint-plugin-complexity | 168ms | 168ms | N/A (single) | +| vue-mini | 164ms | 164ms | 2-3ms | +| vitepress | 168ms | 168ms | 3-14ms | +| dify | 181ms | 181ms | N/A (single) | +| vite-vue-vercel | 360ms | 360ms | N/A (single) | +| rollipop | 155ms | 155ms | 100-147ms | +| frm-stack | 148ms | **660ms** | 3-12ms | +| tanstack-start-helloworld | **1,292ms** | **1,292ms** | N/A (single) | + +Key observations: + +- **tanstack-start-helloworld** has the slowest single config load (1.3s) despite being a single-package project. This is entirely due to heavy TanStack/Vinxi plugin dependencies. +- **frm-stack** has one "monster" config at ~660ms (a specific workspace package with heavy plugins), accounting for ~77% of its total config loading time. +- **rollipop** is unusual: subsequent config loads remain expensive (100-147ms) rather than dropping to 2-12ms, suggesting each package imports distinct heavy dependencies. +- Simple projects (oxlint-plugin-complexity, vue-mini, vitepress) have a consistent ~165ms first-config cost, representing the baseline JS module initialization overhead. + ## Phase 1: Global CLI (Rust binary) Measured via Chrome tracing from the `vp` binary process. Timestamps are relative to process start (microseconds). -### Breakdown (6 invocations, vibe-dashboard, Ubuntu) +### Breakdown (vibe-dashboard, 6 invocations, Ubuntu) | Stage | Time from start | Duration | | ------------------------- | --------------- | ---------- | -| argv0 processing | 37-57μs | ~40μs | -| Runtime resolution start | 482-684μs | ~500μs | -| Node.js version selected | 714-1042μs | ~300μs | -| LTS alias resolved | 723-1075μs | ~10μs | -| Version index cache check | 1181-1541μs | ~400μs | -| Node.js version resolved | 1237-1593μs | ~50μs | -| Node.js cache confirmed | 1302-1627μs | ~50μs | -| **oxc_resolver start** | **3058-7896μs** | — | -| oxc_resolver complete | 3230-8072μs | **~170μs** | -| Delegation to Node.js | 3275-8160μs | ~40μs | - -### Key Observations - -- **Total global CLI overhead**: 3.3ms - 8.2ms per invocation -- **oxc_resolver** is extremely fast (~170μs), resolving `vite-plus/package.json` via node_modules -- **Dominant variable cost**: Gap between "Node cached" and "oxc_resolver start" (1.7-6.6ms). This includes CLI argument parsing, command dispatch, and resolver initialization -- **Node.js runtime resolution** consistently uses cached version index and cached Node.js binary (~1.3ms) +| argv0 processing | 37-57us | ~40us | +| Runtime resolution start | 482-684us | ~500us | +| Node.js version selected | 714-1042us | ~300us | +| LTS alias resolved | 723-1075us | ~10us | +| Version index cache check | 1181-1541us | ~400us | +| Node.js version resolved | 1237-1593us | ~50us | +| Node.js cache confirmed | 1302-1627us | ~50us | +| **oxc_resolver start** | **3058-7896us** | -- | +| oxc_resolver complete | 3230-8072us | **~170us** | +| Delegation to Node.js | 3275-8160us | ~40us | + +### Cross-Project Global CLI Overhead + +| Project | Range | +| ------------------------- | ---------- | +| vite-vue-vercel | 3.4-6.9ms | +| rollipop | 3.7-4.7ms | +| tanstack-start-helloworld | 3.7-6.2ms | +| vitepress | 3.9ms | +| vibe-dashboard | 4.1-6.7ms | +| vue-mini | 6.1ms | +| oxlint-plugin-complexity | 8.8ms | +| dify | 4.3-13.6ms | +| frm-stack | 3.4-7.4ms | + +Global CLI overhead is consistently **3-9ms** across all projects, with rare outliers up to 14ms. This is the Rust binary resolving Node.js version, finding the local vite-plus install via oxc_resolver, and delegating via exec. ## Phase 2: Node.js Startup + NAPI Loading @@ -67,7 +118,7 @@ Measured from NAPI-side Chrome traces (frm-stack project). The NAPI `run()` function is first called at **~3.7ms** from Node.js process start: -| Event | Time (μs) | Notes | +| Event | Time (us) | Notes | | ----------------------- | --------- | ---------------------------------- | | NAPI `run()` entered | 3,682 | First trace event from NAPI module | | `napi_run: start` | 3,950 | After ThreadsafeFunction setup | @@ -79,7 +130,7 @@ This means **Node.js startup + ES module loading + NAPI binding initialization t ### NAPI-side Detailed Breakdown (frm-stack `vp run lint:check`) -From Chrome trace, all times in μs from process start: +From Chrome trace, all times in us from process start: ``` 3,682 NAPI run() entered @@ -88,22 +139,22 @@ From Chrome trace, all times in μs from process start: 4,742 execute_vite_task_command begins 4,865 session::init begins 4,907 init_with begins - 4,923 init_with ends ── 16μs - 4,924 session::init ends ── 59μs + 4,923 init_with ends -- 16us + 4,924 session::init ends -- 59us 4,925 session::main begins 4,931 plan_from_cli_run_resolved begins 4,935 plan_query begins 4,941 load_task_graph begins 4,943 task_graph::load begins - 4,944 load_package_graph begins ━━ 3.8ms + 4,944 load_package_graph begins == 3.8ms 8,764 load_package_graph ends - 8,779 load_user_config_file #1 begins ━━ 164ms (first vite.config.ts load) + 8,779 load_user_config_file #1 begins == 164ms (first vite.config.ts load) 173,248 load_user_config_file #1 ends -173,265 load_user_config_file #2 begins ━━ 12ms +173,265 load_user_config_file #2 begins == 12ms 185,212 load_user_config_file #2 ends -185,221 load_user_config_file #3 begins ━━ 3.4ms +185,221 load_user_config_file #3 begins == 3.4ms 188,666 load_user_config_file #3 ends -188,675 load_user_config_file #4 begins ━━ 741ms (cold import of workspace package config) +188,675 load_user_config_file #4 begins == 741ms (cold import of workspace package config) 929,476 load_user_config_file #4 ends ... (subsequent loads: ~3-5ms each) ``` @@ -120,7 +171,45 @@ The **`load_user_config_file`** callback (which calls back into JavaScript to lo | Fourth package (different deps) | **741ms** | Cold: imports new heavy dependencies | | Subsequent packages | **3-5ms** each | All warm | -**For frm-stack (10 packages), total config loading: ~930ms** — this is the single largest cost. +### frm-stack Per-Command Breakdown (10 traces, all values in ms) + +| Command | Run | CLI | PkgGraph | 1st Cfg | Total Cfg | Cfg Count | Overhead | hdl_cmd | +| -------------------------------- | ----- | ---- | -------- | ------- | --------- | --------- | -------- | ------- | +| `lint:check` | 1st | 6.46 | 3.20 | 146 | 889 | 10 | 901 | 0.02 | +| `lint:check` | cache | 5.06 | 3.34 | 145 | 840 | 11 | 845 | 0.02 | +| `format:check` | 1st | 7.44 | 5.36 | 150 | 825 | 10 | 833 | 0.02 | +| `format:check` | cache | 3.58 | 3.44 | 148 | 829 | 11 | 834 | 0.00 | +| `typecheck` | 1st | 3.64 | 3.20 | 153 | 831 | 10 | 837 | 0.02 | +| `typecheck` | cache | 4.41 | 3.35 | 144 | 816 | 11 | 821 | 0.00 | +| `@yourcompany/api#test` | 1st | 5.85 | 3.39 | 151 | 838 | 11 | 844 | 1.09 | +| `@yourcompany/api#test` | cache | 4.29 | 2.91 | 145 | 835 | 11 | 842 | 1.17 | +| `@yourcompany/backend-core#test` | 1st | 3.40 | 2.91 | 147 | 831 | 11 | 839 | 1.08 | +| `@yourcompany/backend-core#test` | cache | 3.90 | 3.35 | 145 | 824 | 11 | 831 | 1.16 | + +### frm-stack Aggregate Statistics + +| Metric | Average | n | +| ------------------------------------ | ------- | --- | +| load_package_graph | 3.45ms | 10 | +| Total config loading per command | 835.9ms | 10 | +| First config load | 147.5ms | 10 | +| "Monster" config load (~config #4) | ~660ms | 10 | +| Other config loads | ~4.2ms | ~87 | +| Total NAPI overhead | 842.7ms | 10 | +| Global CLI overhead | 4.80ms | 10 | +| handle_command (non-test) | 0.02ms | 6 | +| handle_command (test w/ js_resolver) | 1.13ms | 4 | + +### First Run vs Cache Run (frm-stack averages) + +| Metric | First Run | Cache Run | Delta | +| -------------------- | --------- | --------- | ------------- | +| Total NAPI overhead | 850.7ms | 834.7ms | -16ms (-1.9%) | +| load_package_graph | 3.6ms | 3.3ms | -0.3ms | +| Total config loading | 843.0ms | 828.8ms | -14ms (-1.7%) | +| Global CLI overhead | 5.4ms | 4.2ms | -1.1ms (-21%) | + +Config loading is **not cached** between invocations -- it re-resolves all Vite configs from JavaScript every time. The ~16ms improvement on cache runs is from OS-level filesystem caching, not application-level caching. ### Callback Timing (`handle_command` + `resolve`) @@ -130,26 +219,16 @@ After the task graph is loaded, vite-task calls back into JavaScript to resolve 937,757 handle_command begins 937,868 resolve begins 937,873 js_resolver begins (test command) -939,126 js_resolver ends ── 1.25ms +939,126 js_resolver ends -- 1.25ms 939,187 resolve ends -939,189 handle_command ends ── 1.43ms +939,189 handle_command ends -- 1.43ms ``` -The `js_resolver` callback (which locates the test runner binary via JavaScript) takes **~1.25ms**. - -### Session Init Timing Comparison - -| Stage | frm-stack (10 packages) | Notes | -| ------------------------------- | ----------------------- | --------------------------- | -| Session init | ~60μs | Minimal setup | -| load_package_graph | ~4ms | Workspace discovery | -| load_user_config_file (all) | **~930ms** | JS callbacks, dominant cost | -| handle_command + resolve | ~1.4ms | Tool binary resolution | -| **Total before task execution** | **~936ms** | | +The `js_resolver` callback (which locates the test runner binary via JavaScript) takes **~1.25ms**. Non-test commands (lint, fmt, typecheck) skip this callback and take only ~0.02ms. ## Phase 4: Task Execution (vibe-dashboard) -### Spawn Timing (First Run — Cold) +### Spawn Timing (First Run) | Command | Spawn 1 (setup) | Spawn 2 (execution) | Total | | -------------- | ---------------------------- | ----------------------------- | ----- | @@ -157,7 +236,7 @@ The `js_resolver` callback (which locates the test runner binary via JavaScript) | `vp test` | 0.96s (977 reads, 50 writes) | 5.71s (4699 reads, 26 writes) | ~6.7s | | `vp run build` | 0.95s (977 reads, 50 writes) | 1.61s (3753 reads, 17 writes) | ~2.6s | -### Spawn Timing (Second Run — Cache Available) +### Spawn Timing (Second Run -- Cache Available) | Command | Spawn 1 (setup) | Spawn 2 (execution) | Total | Delta | | -------------- | ---------------------------- | ---------------------------- | ----- | --------- | @@ -177,7 +256,7 @@ vite-task implements a file-system-aware task cache at `node_modules/.vite/task- | Command | First Run | Cache Run | Cache Hit? | Savings | | -------------- | --------- | --------- | ---------- | --------------------------------- | -| `vp fmt` | 2.1s | 1.9s | No | — | +| `vp fmt` | 2.1s | 1.9s | No | -- | | `vp test` | 6.7s | 5.1s | No | -1.6s (OS cache) | | `vp run build` | 2.6s | 1.0s | **Yes** | **-1.6s** (1.19s from task cache) | @@ -192,18 +271,18 @@ T+0.00ms Global CLI starts (Rust binary) T+0.04ms argv0 processed T+0.50ms Runtime resolution begins T+1.30ms Node.js version resolved (cached) -T+3.30ms oxc_resolver finds local vite-plus ── ~170μs -T+3.35ms exec(node, [dist/bin.js, "run", "lint:check"]) ── process replaced -─── Node.js process starts ─── +T+3.30ms oxc_resolver finds local vite-plus -- ~170us +T+3.35ms exec(node, [dist/bin.js, "run", "lint:check"]) -- process replaced +--- Node.js process starts --- T+3.70ms NAPI run() called (Node.js startup overhead) T+4.00ms napi_run: start T+4.12ms cli::main begins T+4.74ms execute_vite_task_command begins T+4.94ms load_package_graph begins -T+8.76ms load_package_graph ends ── 3.8ms +T+8.76ms load_package_graph ends -- 3.8ms T+8.78ms load_user_config_file #1 begins (JS callback) -T+173ms load_user_config_file #1 ends ── 164ms ★ bottleneck - ... (more config loads) +T+173ms load_user_config_file #1 ends -- 164ms * bottleneck + ... (more config loads, including one ~660ms monster) T+937ms handle_command begins T+939ms handle_command ends (js_resolver: 1.25ms) T+940ms Task execution starts (child process spawn) @@ -217,17 +296,17 @@ T+940ms Task execution starts (child process spawn) ### First Run ``` -19:16:44.039 vp fmt — pnpm download starts -19:16:44.170 vp fmt — cache dir created -19:16:45.158 vp fmt — spawn 1 finished (setup) -19:16:46.028 vp fmt — spawn 2 finished (biome) Total: ~2.0s -19:16:46.082 vp test — pnpm resolution starts -19:16:46.084 vp test — cache dir created -19:16:47.057 vp test — spawn 1 finished (setup) -19:16:52.750 vp test — spawn 2 finished (vitest) Total: ~6.7s -19:16:52.846 vp run build — cache dir created -19:16:53.793 vp run build — spawn 1 finished (setup) -19:16:55.398 vp run build — spawn 2 finished (vite build) Total: ~2.6s +19:16:44.039 vp fmt -- pnpm download starts +19:16:44.170 vp fmt -- cache dir created +19:16:45.158 vp fmt -- spawn 1 finished (setup) +19:16:46.028 vp fmt -- spawn 2 finished (biome) Total: ~2.0s +19:16:46.082 vp test -- pnpm resolution starts +19:16:46.084 vp test -- cache dir created +19:16:47.057 vp test -- spawn 1 finished (setup) +19:16:52.750 vp test -- spawn 2 finished (vitest) Total: ~6.7s +19:16:52.846 vp run build -- cache dir created +19:16:53.793 vp run build -- spawn 1 finished (setup) +19:16:55.398 vp run build -- spawn 2 finished (vite build) Total: ~2.6s ``` **Total first run: ~11.4s** (3 commands sequential) @@ -235,33 +314,40 @@ T+940ms Task execution starts (child process spawn) ### Cache Run ``` -19:16:56.446 vp fmt — cache dir created -19:16:57.399 vp fmt — spawn 1 finished -19:16:58.368 vp fmt — spawn 2 finished Total: ~1.9s -19:16:58.441 vp test — cache dir created -19:16:59.390 vp test — spawn 1 finished -19:17:03.556 vp test — spawn 2 finished Total: ~5.1s -19:17:03.641 vp run build — cache dir created -19:17:04.596 vp run build — spawn 1 finished -19:17:05.040 vp run build — cache replayed Total: ~1.4s +19:16:56.446 vp fmt -- cache dir created +19:16:57.399 vp fmt -- spawn 1 finished +19:16:58.368 vp fmt -- spawn 2 finished Total: ~1.9s +19:16:58.441 vp test -- cache dir created +19:16:59.390 vp test -- spawn 1 finished +19:17:03.556 vp test -- spawn 2 finished Total: ~5.1s +19:17:03.641 vp run build -- cache dir created +19:17:04.596 vp run build -- spawn 1 finished +19:17:05.040 vp run build -- cache replayed Total: ~1.4s ``` **Total cache run: ~8.6s** (-24% from first run) ## Summary of Bottlenecks -| Bottleneck | Time | % of overhead | Optimization opportunity | -| ----------------------------- | ------------------------- | ------------- | ---------------------------------------------------- | -| vite.config.ts loading (cold) | **164-741ms** per package | **99%** | Cache config results, lazy loading, parallel loading | -| Spawn 1 (pnpm/setup) | **~1s** | — | Persistent process, avoid re-resolving | -| load_package_graph | **~4ms** | <1% | Already fast | -| Session init | **~60μs** | <0.01% | Already fast | -| Global CLI overhead | **~5ms** | <0.5% | Already fast | -| Node.js + NAPI startup | **~3.7ms** | <0.4% | Already fast | -| oxc_resolver | **~170μs** | <0.02% | Already fast | -| js_resolver callback | **~1.25ms** | <0.1% | Already fast | +| Bottleneck | Time | % of overhead | Optimization opportunity | +| ----------------------------- | -------------------------- | ------------- | ---------------------------------------------------- | +| vite.config.ts loading (cold) | **168ms-1.3s** per project | **95-99%** | Cache config results, lazy loading, parallel loading | +| Spawn 1 (pnpm/setup) | **~1s** | -- | Persistent process, avoid re-resolving | +| load_package_graph | **0.1-10ms** | <1% | Already fast | +| Session init | **~60us** | <0.01% | Already fast | +| Global CLI overhead | **3-9ms** | <0.5% | Already fast | +| Node.js + NAPI startup | **~3.7ms** | <0.4% | Already fast | +| oxc_resolver | **~170us** | <0.02% | Already fast | +| js_resolver callback | **~1.25ms** | <0.1% | Already fast | + +**The single most impactful optimization would be caching or parallelizing `load_user_config_file` calls.** Across all projects: + +- Simple configs (vue-mini, vitepress): ~168ms baseline, nearly all from first-config JS initialization +- Heavy single configs (tanstack-start-helloworld): up to 1.3s for a single config with heavy plugins +- Large monorepos (frm-stack, 10 packages): ~836ms total, dominated by one "monster" config (~660ms) +- Distinct-dependency monorepos (rollipop, 6 packages): ~639ms, each package importing different heavy dependencies (100-155ms each) -**The single most impactful optimization would be caching or parallelizing `load_user_config_file` calls.** The first cold load takes 164ms, and when new heavy dependencies are encountered, loads can take 741ms. For a 10-package monorepo, this accumulates to ~930ms of config loading before any task runs. +Config loading is not cached between `vp` invocations. Every command re-resolves all configs from JavaScript. ## Inter-Process Communication @@ -281,14 +367,23 @@ When `VITE_LOG_OUTPUT=chrome-json` is set, trace files (`trace-*.json`) were wri ### NAPI trace files empty for some projects -The Chrome tracing `FlushGuard` stored in a static `OnceLock` is never dropped when `process.exit()` is called. Fixed by adding `shutdownTracing()` NAPI function called before exit (commit `72b23304`). Some projects (frm-stack) produce traces because their exit path differs. +The Chrome tracing `FlushGuard` stored in a static `OnceLock` is never dropped when `process.exit()` is called. Fixed by adding `shutdownTracing()` NAPI function called before exit (commit `72b23304`). Some projects (vibe-dashboard) still only produce global CLI traces and no NAPI traces. ## Methodology - **Tracing**: Rust `tracing` crate with `tracing-chrome` subscriber (Chrome DevTools JSON format) -- **Environment variables**: `VITE_LOG=debug`, `VITE_LOG_OUTPUT=chrome-json` +- **Environment variables**: `VITE_LOG=debug`, `VITE_LOG_OUTPUT=chrome-json`, `VITE_LOG_OUTPUT_DIR=` - **CI environment**: GitHub Actions ubuntu-latest runner - **Measurement PRs**: - vite-task: https://github.com/voidzero-dev/vite-task/pull/178 - vite-plus: https://github.com/voidzero-dev/vite-plus/pull/663 -- **Trace sources**: Global CLI traces (6 files, vibe-dashboard), NAPI traces (20 files, frm-stack) +- **Trace sources**: 73 trace files across 9 projects (E2E run #22552050124) + - frm-stack: 20 files (10 global CLI + 10 NAPI) + - vibe-dashboard: 8 files (6 global CLI + 2 empty) + - rollipop: 8 files (4 global CLI + 4 NAPI) + - tanstack-start-helloworld: 10 files (4 global CLI + 4 NAPI + 2 empty) + - vite-vue-vercel: 10 files (4 global CLI + 4 NAPI + 2 empty) + - dify: 10 files (4 global CLI + 4 NAPI + 1 empty + 1 corrupted) + - oxlint-plugin-complexity: 2 files (1 global CLI + 1 NAPI) + - vitepress: 3 files (1 global CLI + 1 NAPI + 1 empty) + - vue-mini: 2 files (1 global CLI + 1 NAPI) From 497bc9ac302d9a56ffe989ee61819282596ceeb3 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 08:58:36 +0800 Subject: [PATCH 30/38] feat: enable cacheScripts and add cache miss scenario in e2e tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable `cacheScripts: true` in patch-project.ts after migration so `vp run` commands exercise the task cache hit/miss code paths. - Add a third "cache miss" run to the e2e workflow that modifies package.json between runs to trigger PostRunFingerprintMismatch. The e2e flow is now: first run (cache miss NotFound) → second run (cache hit) → invalidate → third run (cache miss FingerprintMismatch). Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-test.yml | 19 +++++++++++++++++++ ecosystem-ci/patch-project.ts | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 28cc6fc849..c9bc1c4959 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -301,6 +301,25 @@ jobs: VITE_LOG_OUTPUT_DIR: ${{ runner.temp }}/trace-artifacts run: ${{ matrix.project.command }} + - name: Invalidate task cache in ${{ matrix.project.name }} + working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }} + run: | + # Modify package.json to trigger PostRunFingerprintMismatch on next vp run + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf-8')); + pkg._cacheInvalidation = Date.now().toString(); + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + - name: Run vite-plus commands (cache miss run) in ${{ matrix.project.name }} + working-directory: ${{ runner.temp }}/vite-plus-ecosystem-ci/${{ matrix.project.name }}${{ matrix.project.directory && format('/{0}', matrix.project.directory) || '' }} + env: + VITE_LOG: debug + VITE_LOG_OUTPUT: chrome-json + VITE_LOG_OUTPUT_DIR: ${{ runner.temp }}/trace-artifacts + run: ${{ matrix.project.command }} + - name: Collect trace files if: always() shell: bash diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index 7b3350c666..baa045e14e 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -44,3 +44,12 @@ execSync(`${cli} migrate --no-agent --no-interactive`, { VITE_PLUS_VERSION: `file:${tgzDir}/vite-plus-0.0.0.tgz`, }, }); + +// Enable cacheScripts so e2e tests exercise the cache hit/miss paths +const viteConfigPath = join(cwd, 'vite.config.ts'); +const viteConfig = await readFile(viteConfigPath, 'utf-8'); +await writeFile( + viteConfigPath, + viteConfig.replace('defineConfig({', 'defineConfig({\n run: { cacheScripts: true },'), + 'utf-8', +); From 428032936d96ba39000f16334515872279f629d4 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 09:01:58 +0800 Subject: [PATCH 31/38] chore: update vite-task to 7380f757 (fingerprint span names) Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 36 ++++++++++++++++++------------------ Cargo.toml | 12 ++++++------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06b13574fd..874680c1fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1738,7 +1738,7 @@ dependencies = [ [[package]] name = "fspy" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "allocator-api2", "anyhow", @@ -1773,7 +1773,7 @@ dependencies = [ [[package]] name = "fspy_detours_sys" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "cc", "winapi", @@ -1782,7 +1782,7 @@ dependencies = [ [[package]] name = "fspy_preload_unix" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "anyhow", "bincode", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "fspy_preload_windows" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "bincode", "constcat", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "fspy_seccomp_unotify" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "bincode", "futures-util", @@ -1830,7 +1830,7 @@ dependencies = [ [[package]] name = "fspy_shared" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "allocator-api2", "bincode", @@ -1848,7 +1848,7 @@ dependencies = [ [[package]] name = "fspy_shared_unix" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "anyhow", "base64 0.22.1", @@ -4533,7 +4533,7 @@ dependencies = [ [[package]] name = "pty_terminal_test_client" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" [[package]] name = "quinn" @@ -7197,7 +7197,7 @@ dependencies = [ [[package]] name = "vite_glob" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "thiserror 2.0.18", "wax 0.7.0", @@ -7237,7 +7237,7 @@ dependencies = [ [[package]] name = "vite_graph_ser" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "petgraph 0.8.3", "serde", @@ -7318,7 +7318,7 @@ dependencies = [ [[package]] name = "vite_path" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "bincode", "diff-struct", @@ -7331,7 +7331,7 @@ dependencies = [ [[package]] name = "vite_select" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "anyhow", "crossterm", @@ -7357,7 +7357,7 @@ dependencies = [ [[package]] name = "vite_shell" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "bincode", "brush-parser", @@ -7384,7 +7384,7 @@ dependencies = [ [[package]] name = "vite_str" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "bincode", "compact_str", @@ -7395,7 +7395,7 @@ dependencies = [ [[package]] name = "vite_task" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "anyhow", "async-trait", @@ -7431,7 +7431,7 @@ dependencies = [ [[package]] name = "vite_task_graph" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "anyhow", "async-trait", @@ -7451,7 +7451,7 @@ dependencies = [ [[package]] name = "vite_task_plan" version = "0.1.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "anyhow", "async-trait", @@ -7477,7 +7477,7 @@ dependencies = [ [[package]] name = "vite_workspace" version = "0.0.0" -source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=9ed02855949883fbb77d5f83535082decc516230#9ed02855949883fbb77d5f83535082decc516230" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "clap", "path-clean", diff --git a/Cargo.toml b/Cargo.toml index 19eeb91780..7353e9b9f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,7 +83,7 @@ dunce = "1.0.5" fast-glob = "1.0.0" flate2 = { version = "=1.1.9", features = ["zlib-rs"] } form_urlencoded = "1.2.1" -fspy = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } +fspy = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "7380f75787b795704754fc6442dbe36cb5ddf598" } futures = "0.3.31" futures-util = "0.3.31" glob = "0.3.2" @@ -182,15 +182,15 @@ vfs = "0.12.1" vite_command = { path = "crates/vite_command" } vite_error = { path = "crates/vite_error" } vite_js_runtime = { path = "crates/vite_js_runtime" } -vite_glob = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } +vite_glob = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "7380f75787b795704754fc6442dbe36cb5ddf598" } vite_install = { path = "crates/vite_install" } vite_migration = { path = "crates/vite_migration" } vite_shared = { path = "crates/vite_shared" } vite_static_config = { path = "crates/vite_static_config" } -vite_path = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } -vite_str = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } -vite_task = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } -vite_workspace = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "9ed02855949883fbb77d5f83535082decc516230" } +vite_path = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "7380f75787b795704754fc6442dbe36cb5ddf598" } +vite_str = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "7380f75787b795704754fc6442dbe36cb5ddf598" } +vite_task = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "7380f75787b795704754fc6442dbe36cb5ddf598" } +vite_workspace = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "7380f75787b795704754fc6442dbe36cb5ddf598" } walkdir = "2.5.0" wax = "0.6.0" which = "8.0.0" From 54a86defba624ee2217434456bdbb40d5591f547 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 09:19:45 +0800 Subject: [PATCH 32/38] fix: detect vite.config.js when vite.config.ts doesn't exist Migration preserves the existing config extension. Some projects use vite.config.js, so we need to check both filenames. Co-Authored-By: Claude Opus 4.6 --- ecosystem-ci/patch-project.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index baa045e14e..3544400115 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -1,4 +1,5 @@ import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -45,8 +46,11 @@ execSync(`${cli} migrate --no-agent --no-interactive`, { }, }); -// Enable cacheScripts so e2e tests exercise the cache hit/miss paths -const viteConfigPath = join(cwd, 'vite.config.ts'); +// Enable cacheScripts so e2e tests exercise the cache hit/miss paths. +// Migration preserves the existing config extension (.ts or .js). +const viteConfigPath = existsSync(join(cwd, 'vite.config.ts')) + ? join(cwd, 'vite.config.ts') + : join(cwd, 'vite.config.js'); const viteConfig = await readFile(viteConfigPath, 'utf-8'); await writeFile( viteConfigPath, From 7e3ab119c7b0373c724ebdc362959736d702675c Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 09:31:24 +0800 Subject: [PATCH 33/38] fix: create vite.config.ts when migration doesn't produce one Some projects (vue-mini, vitepress, frm-stack, dify) have no lint/fmt/pack config to merge, so migration doesn't create a vite config. Create a minimal one with cacheScripts enabled. Co-Authored-By: Claude Opus 4.6 --- ecosystem-ci/patch-project.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index 3544400115..35eba0bbe2 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -47,13 +47,21 @@ execSync(`${cli} migrate --no-agent --no-interactive`, { }); // Enable cacheScripts so e2e tests exercise the cache hit/miss paths. -// Migration preserves the existing config extension (.ts or .js). -const viteConfigPath = existsSync(join(cwd, 'vite.config.ts')) - ? join(cwd, 'vite.config.ts') - : join(cwd, 'vite.config.js'); -const viteConfig = await readFile(viteConfigPath, 'utf-8'); -await writeFile( - viteConfigPath, - viteConfig.replace('defineConfig({', 'defineConfig({\n run: { cacheScripts: true },'), - 'utf-8', -); +// Migration may create vite.config.ts, preserve an existing .ts/.js, or create none at all. +const tsPath = join(cwd, 'vite.config.ts'); +const jsPath = join(cwd, 'vite.config.js'); +if (existsSync(tsPath) || existsSync(jsPath)) { + const viteConfigPath = existsSync(tsPath) ? tsPath : jsPath; + const viteConfig = await readFile(viteConfigPath, 'utf-8'); + await writeFile( + viteConfigPath, + viteConfig.replace('defineConfig({', 'defineConfig({\n run: { cacheScripts: true },'), + 'utf-8', + ); +} else { + await writeFile( + tsPath, + `import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n run: { cacheScripts: true },\n});\n`, + 'utf-8', + ); +} From f9a3b3d4e7daebc58419a538ffe372fb8a156d05 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 09:48:20 +0800 Subject: [PATCH 34/38] fix: run prettier on modified vite config to avoid format check failures Projects like vue-mini run `vp run format` which checks Prettier formatting. The injected cacheScripts config must be formatted. Co-Authored-By: Claude Opus 4.6 --- ecosystem-ci/patch-project.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index 35eba0bbe2..3d1d4c65c4 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -50,8 +50,9 @@ execSync(`${cli} migrate --no-agent --no-interactive`, { // Migration may create vite.config.ts, preserve an existing .ts/.js, or create none at all. const tsPath = join(cwd, 'vite.config.ts'); const jsPath = join(cwd, 'vite.config.js'); +let viteConfigPath: string; if (existsSync(tsPath) || existsSync(jsPath)) { - const viteConfigPath = existsSync(tsPath) ? tsPath : jsPath; + viteConfigPath = existsSync(tsPath) ? tsPath : jsPath; const viteConfig = await readFile(viteConfigPath, 'utf-8'); await writeFile( viteConfigPath, @@ -59,9 +60,16 @@ if (existsSync(tsPath) || existsSync(jsPath)) { 'utf-8', ); } else { + viteConfigPath = tsPath; await writeFile( tsPath, `import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n run: { cacheScripts: true },\n});\n`, 'utf-8', ); } +// Format the modified/created config to match project's style (avoids format check failures) +try { + execSync(`npx prettier --write ${JSON.stringify(viteConfigPath)}`, { cwd, stdio: 'inherit' }); +} catch { + // prettier may not be installed; that's fine +} From 85566cb2ef2811ae95d8d50b1402ad859689d4f4 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 10:05:35 +0800 Subject: [PATCH 35/38] fix: create vite.config.js instead of .ts to avoid TS-ESLint errors Projects like vue-mini have strict TypeScript-ESLint configs that reject .ts files not included in tsconfig.json. Using .js avoids this issue. Co-Authored-By: Claude Opus 4.6 --- ecosystem-ci/patch-project.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index 3d1d4c65c4..1970c2f283 100644 --- a/ecosystem-ci/patch-project.ts +++ b/ecosystem-ci/patch-project.ts @@ -60,9 +60,11 @@ if (existsSync(tsPath) || existsSync(jsPath)) { 'utf-8', ); } else { - viteConfigPath = tsPath; + // Use .js to avoid TypeScript-ESLint "not found by the project service" errors + // in projects whose tsconfig.json doesn't include vite.config.ts. + viteConfigPath = jsPath; await writeFile( - tsPath, + jsPath, `import { defineConfig } from 'vite-plus';\n\nexport default defineConfig({\n run: { cacheScripts: true },\n});\n`, 'utf-8', ); From 689da9c3c9476ad6a62a1ace5cb269d85bbdcea0 Mon Sep 17 00:00:00 2001 From: branchseer Date: Mon, 2 Mar 2026 11:00:37 +0800 Subject: [PATCH 36/38] docs: combine cache performance analysis into single performance.md Merge architecture overview/phase breakdowns with cache-enabled trace analysis (run #22558467033). Remove turbo references and optimization suggestions. Add cache hit savings, operation overhead, execution timeline, and miss root cause data. Co-Authored-By: Claude Opus 4.6 --- performance.md | 590 +++++++++++++++++++++++++++---------------------- 1 file changed, 327 insertions(+), 263 deletions(-) diff --git a/performance.md b/performance.md index 3fc6110509..48ca4a19a0 100644 --- a/performance.md +++ b/performance.md @@ -4,370 +4,437 @@ Performance measurements from E2E tests (Ubuntu, GitHub Actions runner). **Test projects**: 9 ecosystem-ci projects (single-package and multi-package monorepos) **Node.js**: 22-24 (managed by vite-plus js_runtime) -**Trace source**: E2E run #22552050124 (73 trace files across 9 projects) +**Trace sources**: +- Run #22556278251 — baseline traces (2 runs per project, cache disabled) +- Run [#22558467033](https://github.com/voidzero-dev/vite-plus/actions/runs/22558467033) — cache-enabled traces (3 runs per project: first, cache hit, cache miss) ## Architecture Overview -A `vp` command invocation traverses multiple layers: +A `vp run` command invocation traverses these layers: ``` User runs `vp run lint:check` | - +- [1] Global CLI (Rust binary `vp`) ~3-9ms - | +- argv0 processing ~40us - | +- Node.js runtime resolution ~1.3ms + +- [Phase 1] Global CLI (Rust binary `vp`) ~3-9ms + | +- argv0 processing ~40us + | +- Node.js runtime resolution ~1.3ms | +- Module resolution (oxc_resolver) ~170us | +- Delegates to local CLI via exec(node bin.js) | - +- [2] Node.js startup + NAPI loading ~3.7ms + +- [Phase 2] Node.js startup + NAPI loading ~3.7ms | +- bin.ts entry -> import NAPI binding -> call run() | - +- [3] Rust core via NAPI (vite-task session) - | +- Session init ~60us - | +- load_package_graph (workspace discovery) ~1-10ms - | +- load_user_config_file x N (JS callbacks) ~168ms-1.3s total - | +- handle_command + resolve (JS callbacks) ~0.02-1.3ms - | +- Task execution (spawns child processes) + +- [Phase 3] Rust core via NAPI (vite-task session) + | +- Session init ~60-80us + | +- plan_from_cli_run_resolved + | | +- plan_query + | | +- load_task_graph + | | | +- load_package_graph ~2-5ms + | | | +- load_user_config_file x N ~170ms-1.3s (BOTTLENECK) + | | +- handle_command (JS callback) ~0.02-1.5ms + | +- execute_graph + | +- load_from_path (cache state) ~0.7-14ms + | +- execute_expanded_graph + | +- execute_leaf -> execute_spawn + | +- try_hit (cache lookup) 0-50ms + | +- [hit] validate + replay stdout + | +- [miss] spawn_with_tracking actual command runs + | +- [miss] create_post_run_fingerprint + update | - +- [4] Task spawns (child processes) - +- Spawn 1: pnpm install / dependsOn ~0.95-1.05s - +- Spawn 2: actual command varies (1-6s) + +- [Phase 4] Child process execution varies ``` +## Execution Cache Performance + +With `cacheScripts: true`, vite-task caches command outputs keyed by a spawn fingerprint (cwd + program + args + env) and validated by a post-run fingerprint (xxHash3_64 of all files accessed during execution, tracked by fspy). + +### Cache Hit Savings (Per-Command) + +When cache hits occur, the saved time comes from skipping `spawn_with_tracking` (the actual command execution) and `create_post_run_fingerprint` (post-run file hashing): + +| Project | Command | Miss (ms) | Hit (ms) | Saved (ms) | Saved % | +|---------|---------|-----------|----------|------------|---------| +| dify | build (next build) | 170,673 | 670 | 170,003 | **99.6%** | +| vitepress | tests-e2e#test | 26,696 | 250 | 26,446 | **99.1%** | +| vitepress | tests-init#test | 11,430 | 290 | 11,140 | **97.5%** | +| vue-mini | test -- --coverage | 6,357 | 217 | 6,140 | **96.6%** | +| dify | test (3 files) | 6,524 | 349 | 6,175 | **94.7%** | +| oxlint-plugin-complexity | lint | 4,165 | 232 | 3,933 | **94.4%** | +| frm-stack | @yourcompany/api#test | 14,760 | 895 | 13,865 | **93.9%** | +| oxlint-plugin-complexity | build | 3,529 | 219 | 3,310 | **93.8%** | +| rollipop | -r typecheck (4 tasks) | 8,581 | 697 | 7,884 | **91.9%** | +| vite-vue-vercel | test | 2,744 | 326 | 2,418 | **88.1%** | +| oxlint-plugin-complexity | test:run | 1,377 | 212 | 1,165 | **84.6%** | +| tanstack-start-helloworld | build | 8,844 | 1,383 | 7,461 | **84.4%** | +| oxlint-plugin-complexity | format:check | 1,355 | 214 | 1,141 | **84.2%** | +| frm-stack | @yourcompany/backend-core#test | 5,571 | 894 | 4,677 | **83.9%** | +| oxlint-plugin-complexity | format | 1,419 | 239 | 1,180 | **83.2%** | +| rollipop | @rollipop/core#test | 2,878 | 671 | 2,208 | **76.7%** | +| vite-vue-vercel | build | 842 | 328 | 514 | **61.0%** | +| rollipop | @rollipop/common#test | 1,307 | 663 | 644 | **49.3%** | +| rollipop | format | 1,257 | 657 | 600 | **47.7%** | +| frm-stack | typecheck | 1,448 | 918 | 530 | **36.6%** | + +### Cache Operation Overhead + +#### On Cache Hit + +| Operation | Time | Description | +|-----------|------|-------------| +| `try_hit` | 0.0–50ms | Look up spawn fingerprint in SQLite, then validate post-run fingerprint | +| `validate_post_run_fingerprint` | 1–40ms | Re-hash all tracked input files to check if they changed | +| **Total cache overhead** | **10–50ms** | Negligible compared to saved execution time | + +Cache hit total time is dominated by config loading (177–1,316ms depending on project), not cache operations. + +#### On Cache Miss (with write-back) + +| Operation | Time | Description | +|-----------|------|-------------| +| `try_hit` | 0.0–0.1ms | Quick lookup, returns `NotFound` or `FingerprintMismatch` | +| `spawn_with_tracking` | 200–170,000ms | Execute the actual command with fspy file tracking | +| `create_post_run_fingerprint` | 2–1,637ms | Hash all files accessed during execution | +| `update` | 1–200ms | Write fingerprint and outputs to SQLite cache | + +### Execution Timeline (Cache Hit vs Miss) + +#### Cache Hit Flow +``` +┌──────────────────┐ ┌─────────┐ ┌──────────────────────────────┐ ┌─────────┐ +│ load_user_config │→│ try_hit │→│ validate_post_run_fingerprint │→│ replay │ +│ 177–1316ms │ │ <1ms │ │ 1–40ms │ │ stdout │ +└──────────────────┘ └─────────┘ └──────────────────────────────┘ └─────────┘ +Total: 200–1400ms (config loading dominates) +``` + +#### Cache Miss Flow +``` +┌──────────────────┐ ┌─────────┐ ┌─────────────────────┐ ┌────────────────────────────┐ ┌────────┐ +│ load_user_config │→│ try_hit │→│ spawn_with_tracking │→│ create_post_run_fingerprint │→│ update │ +│ 177–1316ms │ │ <1ms │ │ 200–170,000ms │ │ 2–1637ms │ │ 1–200ms│ +└──────────────────┘ └─────────┘ └─────────────────────┘ └────────────────────────────┘ └────────┘ +Total: 400–172,000ms (spawn dominates) +``` + +### Cache Miss Root Causes + +From CI log analysis, cache misses on the "cache hit" run fall into these categories: + +| Miss Reason | Count | Explanation | +|-------------|-------|-------------| +| `content of input 'package.json' changed` | 60 | Expected — from the intentional cache invalidation step | +| `content of input '' changed` | 9 | Bug — fspy tracks an empty path (working directory listing) which changes between runs | +| `content of input 'dist/...' changed` | ~10 | Expected — build outputs change between runs (e.g., vitepress `build:client` changes `dist/`) | +| `content of input 'tsconfig.json' changed` | 3 | Side effect of prior commands modifying project config | + +The `content of input '' changed` issue affects vue-mini's `prettier`, `eslint`, and `tsc` commands — fspy records the working directory itself as a read, and its directory listing changes between runs because the first command creates or modifies files. This is the main reason vue-mini and rollipop show low cache hit rates. + ## Cross-Project Comparison -Overhead measured from all 9 ecosystem-ci projects (Ubuntu, first run): +NAPI overhead measured from trace files (Ubuntu, all invocations): -| Project | Packages | Global CLI | load_package_graph | Config loading | Total overhead | -| ------------------------- | -------- | ---------- | ------------------ | -------------- | -------------- | -| oxlint-plugin-complexity | 1 | 8.8ms | 1.0ms | **168ms** | **170ms** | -| vue-mini | 4 | 6.1ms | 2.2ms | **172ms** | **175ms** | -| dify | 1 | 4-14ms | 10.0ms | **181ms** | **196ms** | -| vitepress | 4 | 3.9ms | 1.2ms | **196ms** | **199ms** | -| vite-vue-vercel | 1 | 3-7ms | 1.4ms | **360ms** | **364ms** | -| rollipop | 6 | 4-5ms | 2.7ms | **639ms** | **648ms** | -| frm-stack | 10-11 | 3-7ms | 3.5ms | **836ms** | **843ms** | -| tanstack-start-helloworld | 1 | 4-6ms | 0.1ms | **1,292ms** | **1,294ms** | -| vibe-dashboard | N/A | 4-7ms | N/A | N/A | N/A | +| Project | Packages | Config loading | Overhead | n | +| ------------------------- | -------- | --------------- | ------------- | -- | +| vue-mini | 1 | **170-218ms** | **173-223ms** | 8 | +| oxlint-plugin-complexity | 1-2 | **177-249ms** | **184-258ms** | 10 | +| vitepress | 4 | **175-202ms** | **182-327ms** | 12 | +| vite-vue-vercel | 1 | **320-328ms** | **326-338ms** | 4 | +| rollipop | 6 | **635-658ms** | **643-670ms** | 14 | +| frm-stack | 10-11 | **959-993ms** | **968-1002ms** | 10 | +| tanstack-start-helloworld | 1 | **1305-1320ms** | **1308-1337ms** | 4 | +| vibe-dashboard | -- | -- | -- | 0 | +| dify | -- | -- | -- | 0 | -vibe-dashboard only produced global CLI traces (no NAPI traces captured). +vibe-dashboard and dify only produced global CLI traces (no NAPI traces captured). See Known Issues. -Config loading accounts for **95-99%** of total NAPI overhead in every project. Everything else is negligible. +Config loading accounts for **95-99%** of total NAPI overhead in every project. ### Config Loading Patterns -The first `load_user_config_file` call always pays a fixed JS module initialization cost (~150-170ms for typical projects). Projects with heavy Vite plugins pay much more: +The first `load_user_config_file` call pays a fixed JS module initialization cost (~150-170ms). Projects with heavy Vite plugins pay more: -| Project | First config | Biggest config | Subsequent configs | -| ------------------------- | ------------ | -------------- | ------------------ | -| oxlint-plugin-complexity | 168ms | 168ms | N/A (single) | -| vue-mini | 164ms | 164ms | 2-3ms | -| vitepress | 168ms | 168ms | 3-14ms | -| dify | 181ms | 181ms | N/A (single) | -| vite-vue-vercel | 360ms | 360ms | N/A (single) | -| rollipop | 155ms | 155ms | 100-147ms | -| frm-stack | 148ms | **660ms** | 3-12ms | -| tanstack-start-helloworld | **1,292ms** | **1,292ms** | N/A (single) | +| Project | First config | Largest config | Subsequent configs | +| ------------------------- | ------------- | -------------------- | ------------------ | +| vue-mini | 164-177ms | same | 2-3ms | +| oxlint-plugin-complexity | 177-249ms | same | N/A (single) | +| vitepress | 158-201ms | same | 5-7ms | +| vite-vue-vercel | 320-328ms | same | N/A (single) | +| rollipop | 150-165ms | 146-168ms (#3) | 100-155ms each | +| frm-stack | 165-173ms | **750-786ms** (#4-5) | 3-12ms | +| tanstack-start-helloworld | **1305-1320ms** | same | N/A (single) | Key observations: -- **tanstack-start-helloworld** has the slowest single config load (1.3s) despite being a single-package project. This is entirely due to heavy TanStack/Vinxi plugin dependencies. -- **frm-stack** has one "monster" config at ~660ms (a specific workspace package with heavy plugins), accounting for ~77% of its total config loading time. -- **rollipop** is unusual: subsequent config loads remain expensive (100-147ms) rather than dropping to 2-12ms, suggesting each package imports distinct heavy dependencies. -- Simple projects (oxlint-plugin-complexity, vue-mini, vitepress) have a consistent ~165ms first-config cost, representing the baseline JS module initialization overhead. +- **tanstack-start-helloworld** has the slowest single config load (1.3s) despite being a single-package project. Entirely due to heavy TanStack/Vinxi plugin dependencies. +- **frm-stack** has one "monster" config at ~750-786ms (a specific workspace package with heavy plugins), accounting for ~77% of total config loading. +- **rollipop** is unusual: subsequent config loads remain expensive (100-155ms) rather than dropping to 2-12ms, suggesting each package imports distinct heavy dependencies. +- Simple projects (vue-mini, vitepress) have a consistent ~165ms first-config cost, representing the baseline JS module initialization overhead. ## Phase 1: Global CLI (Rust binary) Measured via Chrome tracing from the `vp` binary process. -Timestamps are relative to process start (microseconds). -### Breakdown (vibe-dashboard, 6 invocations, Ubuntu) +### Cross-Project Global CLI Overhead + +| Project | Range | n | +| ------------------------- | ---------- | -- | +| vite-vue-vercel | 3.4-6.9ms | 10 | +| rollipop | 3.7-4.7ms | 14 | +| tanstack-start-helloworld | 3.7-6.2ms | 4 | +| vitepress | 3.3-3.9ms | 12 | +| vibe-dashboard | 4.1-6.7ms | 6 | +| vue-mini | 5.5-6.1ms | 8 | +| oxlint-plugin-complexity | 3.1-8.8ms | 10 | +| dify | 4.3-13.6ms | 6 | +| frm-stack | 3.4-7.4ms | 10 | + +Global CLI overhead is consistently **3-9ms** across all projects, with rare outliers up to 14ms. This is the Rust binary resolving Node.js version, finding the local vite-plus install via oxc_resolver, and delegating via exec. + +### Breakdown (vibe-dashboard, 6 invocations) | Stage | Time from start | Duration | | ------------------------- | --------------- | ---------- | | argv0 processing | 37-57us | ~40us | | Runtime resolution start | 482-684us | ~500us | | Node.js version selected | 714-1042us | ~300us | -| LTS alias resolved | 723-1075us | ~10us | -| Version index cache check | 1181-1541us | ~400us | | Node.js version resolved | 1237-1593us | ~50us | | Node.js cache confirmed | 1302-1627us | ~50us | | **oxc_resolver start** | **3058-7896us** | -- | | oxc_resolver complete | 3230-8072us | **~170us** | | Delegation to Node.js | 3275-8160us | ~40us | -### Cross-Project Global CLI Overhead - -| Project | Range | -| ------------------------- | ---------- | -| vite-vue-vercel | 3.4-6.9ms | -| rollipop | 3.7-4.7ms | -| tanstack-start-helloworld | 3.7-6.2ms | -| vitepress | 3.9ms | -| vibe-dashboard | 4.1-6.7ms | -| vue-mini | 6.1ms | -| oxlint-plugin-complexity | 8.8ms | -| dify | 4.3-13.6ms | -| frm-stack | 3.4-7.4ms | - -Global CLI overhead is consistently **3-9ms** across all projects, with rare outliers up to 14ms. This is the Rust binary resolving Node.js version, finding the local vite-plus install via oxc_resolver, and delegating via exec. - ## Phase 2: Node.js Startup + NAPI Loading -Measured from NAPI-side Chrome traces (frm-stack project). +Measured from NAPI-side Chrome traces. The NAPI `run()` function is first called at **~3.7ms** from Node.js process start: | Event | Time (us) | Notes | | ----------------------- | --------- | ---------------------------------- | -| NAPI `run()` entered | 3,682 | First trace event from NAPI module | -| `napi_run: start` | 3,950 | After ThreadsafeFunction setup | -| `cli::main` span begins | 4,116 | CLI argument processing starts | +| NAPI `run()` entered | ~3,700 | First trace event from NAPI module | +| `napi_run: start` | ~3,950 | After ThreadsafeFunction setup | +| `cli::main` span begins | ~4,100 | CLI argument processing starts | -This means **Node.js startup + ES module loading + NAPI binding initialization takes ~3.7ms**. +Node.js startup + ES module loading + NAPI binding initialization takes **~3.7ms**. ## Phase 3: Rust Core via NAPI (vite-task) -### NAPI-side Detailed Breakdown (frm-stack `vp run lint:check`) +### Detailed Timeline (frm-stack `vp run lint:check`, first run) From Chrome trace, all times in us from process start: ``` - 3,682 NAPI run() entered - 3,950 napi_run: start - 4,116 cli::main begins - 4,742 execute_vite_task_command begins - 4,865 session::init begins - 4,907 init_with begins - 4,923 init_with ends -- 16us - 4,924 session::init ends -- 59us - 4,925 session::main begins - 4,931 plan_from_cli_run_resolved begins - 4,935 plan_query begins - 4,941 load_task_graph begins - 4,943 task_graph::load begins - 4,944 load_package_graph begins == 3.8ms - 8,764 load_package_graph ends - 8,779 load_user_config_file #1 begins == 164ms (first vite.config.ts load) -173,248 load_user_config_file #1 ends -173,265 load_user_config_file #2 begins == 12ms -185,212 load_user_config_file #2 ends -185,221 load_user_config_file #3 begins == 3.4ms -188,666 load_user_config_file #3 ends -188,675 load_user_config_file #4 begins == 741ms (cold import of workspace package config) -929,476 load_user_config_file #4 ends - ... (subsequent loads: ~3-5ms each) + ~3,700 NAPI run() entered + ~3,950 napi_run: start + 4,462 cli::main begins + execute_vite_task_command begins + 4,462 session::init -- 80us + 4,552 plan_from_cli_run_resolved begins + plan_query begins + load_task_graph begins + 4,569 load_package_graph -- 4.3ms + 8,878 load_user_config_file x10 -- 983ms total + #1: 165ms (cold JS init) + #2: 12ms + #3: 4ms + #4: 776ms (monster config) + #5-#10: 3-5ms each + 992,988 handle_command -- 0.04ms + 993,336 execute_graph begins + 993,385 load_from_path (cache state) -- 7.4ms +1,000,873 execute_expanded_graph begins +1,001,667 execute_spawn begins + try_hit → spawn_with_tracking -- command runs here ``` -### Critical Finding: vite.config.ts Loading is the Bottleneck - -The **`load_user_config_file`** callback (which calls back into JavaScript to load `vite.config.ts` for each workspace package) dominates the task graph loading time: - -| Config Load | Duration | Notes | -| ------------------------------- | -------------- | ---------------------------------------------------------- | -| First package | **164ms** | Cold import: requires JS module resolution + transpilation | -| Second package | **12ms** | Warm: shared dependencies already cached | -| Third package | **3.4ms** | Warm: nearly all deps cached | -| Fourth package (different deps) | **741ms** | Cold: imports new heavy dependencies | -| Subsequent packages | **3-5ms** each | All warm | +**Total overhead before task execution: ~1001ms**, of which **983ms (98%) is vite.config.ts loading**. ### frm-stack Per-Command Breakdown (10 traces, all values in ms) -| Command | Run | CLI | PkgGraph | 1st Cfg | Total Cfg | Cfg Count | Overhead | hdl_cmd | -| -------------------------------- | ----- | ---- | -------- | ------- | --------- | --------- | -------- | ------- | -| `lint:check` | 1st | 6.46 | 3.20 | 146 | 889 | 10 | 901 | 0.02 | -| `lint:check` | cache | 5.06 | 3.34 | 145 | 840 | 11 | 845 | 0.02 | -| `format:check` | 1st | 7.44 | 5.36 | 150 | 825 | 10 | 833 | 0.02 | -| `format:check` | cache | 3.58 | 3.44 | 148 | 829 | 11 | 834 | 0.00 | -| `typecheck` | 1st | 3.64 | 3.20 | 153 | 831 | 10 | 837 | 0.02 | -| `typecheck` | cache | 4.41 | 3.35 | 144 | 816 | 11 | 821 | 0.00 | -| `@yourcompany/api#test` | 1st | 5.85 | 3.39 | 151 | 838 | 11 | 844 | 1.09 | -| `@yourcompany/api#test` | cache | 4.29 | 2.91 | 145 | 835 | 11 | 842 | 1.17 | -| `@yourcompany/backend-core#test` | 1st | 3.40 | 2.91 | 147 | 831 | 11 | 839 | 1.08 | -| `@yourcompany/backend-core#test` | cache | 3.90 | 3.35 | 145 | 824 | 11 | 831 | 1.16 | +| Command | Run | PkgGr | 1st Cfg | Total Cfg | Cfgs | Overhead | CacheLoad | hdl_cmd | +| -------------------------------- | --- | ----- | ------- | --------- | ---- | -------- | --------- | ------- | +| `lint:check` | 1st | 4.3 | 165 | 983 | 10 | 1002 | 7.4 | 0.04 | +| `format:check` | 1st | 4.1 | 172 | 964 | 10 | 972 | 0.8 | 0.00 | +| `typecheck` | 1st | 4.4 | 169 | 964 | 10 | 971 | 0.8 | 0.06 | +| `@yourcompany/api#test` | 1st | 4.8 | 173 | 986 | 11 | 996 | 0.8 | 1.53 | +| `@yourcompany/backend-core#test` | 1st | 4.8 | 173 | 990 | 11 | 1001 | 1.3 | 1.42 | +| `lint:check` | 2nd | 4.7 | 169 | 990 | 11 | 1001 | 0.8 | 0.03 | +| `format:check` | 2nd | 4.3 | 167 | 961 | 11 | 969 | 0.8 | 0.08 | +| `typecheck` | 2nd | 4.5 | 165 | 993 | 11 | 1000 | 0.8 | 0.00 | +| `@yourcompany/api#test` | 2nd | 4.7 | 166 | 959 | 11 | 969 | 1.4 | 1.51 | +| `@yourcompany/backend-core#test` | 2nd | 4.9 | 168 | 980 | 11 | 990 | 1.1 | 1.41 | ### frm-stack Aggregate Statistics -| Metric | Average | n | -| ------------------------------------ | ------- | --- | -| load_package_graph | 3.45ms | 10 | -| Total config loading per command | 835.9ms | 10 | -| First config load | 147.5ms | 10 | -| "Monster" config load (~config #4) | ~660ms | 10 | -| Other config loads | ~4.2ms | ~87 | -| Total NAPI overhead | 842.7ms | 10 | -| Global CLI overhead | 4.80ms | 10 | -| handle_command (non-test) | 0.02ms | 6 | -| handle_command (test w/ js_resolver) | 1.13ms | 4 | - -### First Run vs Cache Run (frm-stack averages) - -| Metric | First Run | Cache Run | Delta | -| -------------------- | --------- | --------- | ------------- | -| Total NAPI overhead | 850.7ms | 834.7ms | -16ms (-1.9%) | -| load_package_graph | 3.6ms | 3.3ms | -0.3ms | -| Total config loading | 843.0ms | 828.8ms | -14ms (-1.7%) | -| Global CLI overhead | 5.4ms | 4.2ms | -1.1ms (-21%) | - -Config loading is **not cached** between invocations -- it re-resolves all Vite configs from JavaScript every time. The ~16ms improvement on cache runs is from OS-level filesystem caching, not application-level caching. - -### Callback Timing (`handle_command` + `resolve`) +| Metric | Average | Range | n | +| ------------------------------------ | ------- | ------------- | --- | +| load_package_graph | 4.5ms | 4.1-4.9ms | 10 | +| Total config loading per command | 977ms | 959-993ms | 10 | +| First config load | 169ms | 165-173ms | 10 | +| "Monster" config load (~config #4/5) | 763ms | 750-786ms | 10 | +| Other config loads | ~4ms | 3-12ms | ~90 | +| Total NAPI overhead | 987ms | 968-1002ms | 10 | +| Cache state load (load_from_path) | 1.5ms | 0.8-7.4ms | 10 | +| handle_command (non-test) | 0.03ms | 0.00-0.08ms | 6 | +| handle_command (test w/ js_resolver) | 1.46ms | 1.41-1.53ms | 4 | + +### First Run vs Second Run (frm-stack averages) + +| Metric | First Run | Second Run | Delta | +| -------------------- | --------- | ---------- | -------------- | +| Total NAPI overhead | 988ms | 985ms | -3ms (-0.3%) | +| load_package_graph | 4.5ms | 4.6ms | +0.1ms | +| Total config loading | 977ms | 977ms | ~0ms | +| First config load | 170ms | 167ms | -3ms | +| Monster config | 763ms | 763ms | ~0ms | +| Cache state load | 2.2ms | 1.0ms | -1.2ms (-55%) | + +Config loading is **not cached** between invocations -- every `vp run` command re-resolves all Vite configs from JavaScript. There is no measurable difference between first and second runs. + +### Callback Timing (`handle_command` + `js_resolver`) After the task graph is loaded, vite-task calls back into JavaScript to resolve the tool binary: ``` -937,757 handle_command begins -937,868 resolve begins -937,873 js_resolver begins (test command) -939,126 js_resolver ends -- 1.25ms -939,187 resolve ends -939,189 handle_command ends -- 1.43ms + 996,446 handle_command begins + 996,710 resolve begins + js_resolver begins (test command) + 997,880 js_resolver ends -- 1.17ms + 998,040 resolve ends + 998,126 handle_command ends -- 1.53ms ``` -The `js_resolver` callback (which locates the test runner binary via JavaScript) takes **~1.25ms**. Non-test commands (lint, fmt, typecheck) skip this callback and take only ~0.02ms. +The `js_resolver` callback (which locates the test runner binary via JavaScript) takes **~1.1ms**. Non-test commands (lint, fmt, typecheck) skip this callback and resolve directly, taking only ~0.03ms. -## Phase 4: Task Execution (vibe-dashboard) +### rollipop: Multi-Spawn Execution -### Spawn Timing (First Run) +Some commands spawn multiple child processes sequentially (topological order from `dependsOn`): -| Command | Spawn 1 (setup) | Spawn 2 (execution) | Total | -| -------------- | ---------------------------- | ----------------------------- | ----- | -| `vp fmt` | 1.05s (977 reads, 50 writes) | 1.00s (163 reads, 1 write) | ~2.1s | -| `vp test` | 0.96s (977 reads, 50 writes) | 5.71s (4699 reads, 26 writes) | ~6.7s | -| `vp run build` | 0.95s (977 reads, 50 writes) | 1.61s (3753 reads, 17 writes) | ~2.6s | +``` +rollipop `vp run -r build` (first run): + ~668us execute_expanded_graph begins + ~678us execute_leaf #1: spawn_inherited (1898ms) -- @rollipop/common#build + 2,576us execute_leaf #2: spawn_inherited (2668ms) -- @rollipop/core#build + 5,244us execute_leaf #3: spawn_inherited (2138ms) -- @rollipop/rollipop#build + 7,382us execute_leaf #4: spawn_inherited (1859ms) -- @rollipop/dev-server#build + Total spawn time: 8563ms (sequential due to dependsOn) +``` -### Spawn Timing (Second Run -- Cache Available) +### vitepress: Build Pipeline -| Command | Spawn 1 (setup) | Spawn 2 (execution) | Total | Delta | -| -------------- | ---------------------------- | ---------------------------- | ----- | --------- | -| `vp fmt` | 0.95s (977 reads, 50 writes) | 0.97s (167 reads, 3 writes) | ~1.9s | -0.2s | -| `vp test` | 0.95s (977 reads, 50 writes) | 4.17s (1930 reads, 4 writes) | ~5.1s | **-1.6s** | -| `vp run build` | 0.96s (977 reads, 50 writes) | **cache hit (replayed)** | ~1.0s | **-1.6s** | +The `vp run build` command spawns 3 sequential phases: -### Key Observations +``` +vitepress `vp run build` (first run): + ~185us execute_expanded_graph begins + ~185us spawn_inherited #1: pnpm build:prepare -- 466ms + ~651us spawn_inherited #2: pnpm build:client -- 8362ms + 9,013us spawn_inherited #3: pnpm build:node -- 10312ms + Total: 19.1s (sequential pipeline) +``` -- **Spawn 1 is constant** (~0.95-1.05s, 977 path_reads, 50 path_writes) regardless of command or cache state. This is the workspace/task-graph loading + pnpm resolution overhead. -- **`vp run build` cache hit**: On second run, the build was fully replayed from cache, saving 1.19s. The 977-read spawn 1 still executes. -- **`vp test` improvement**: Second run read 1930 paths (vs 4699), suggesting OS filesystem caching reduced disk I/O. +## Phase 4: Child Process Execution -## Phase 5: Task Cache Effectiveness +Wall-clock timestamps from CI output logs. The `process uptime` value shows Node.js startup time (consistent ~33-55ms across all projects). -vite-task implements a file-system-aware task cache at `node_modules/.vite/task-cache`. +### Process Uptime (Node.js startup) -| Command | First Run | Cache Run | Cache Hit? | Savings | -| -------------- | --------- | --------- | ---------- | --------------------------------- | -| `vp fmt` | 2.1s | 1.9s | No | -- | -| `vp test` | 6.7s | 5.1s | No | -1.6s (OS cache) | -| `vp run build` | 2.6s | 1.0s | **Yes** | **-1.6s** (1.19s from task cache) | +| Project | Range | +| ------------------------- | ------------- | +| vibe-dashboard | 35.0-35.1ms | +| rollipop | 32.4-37.8ms | +| frm-stack | 34.2-56.2ms | +| vue-mini | 38.6-54.8ms | +| vitepress | 32.4-35.9ms | +| tanstack-start-helloworld | 33.2-33.9ms | +| oxlint-plugin-complexity | 33.0-47.2ms | +| vite-vue-vercel | 32.1-33.2ms | +| dify | 33.8-40.1ms | -**Only `vp run build` was cache-eligible.** Formatting and test commands are not cached (side effects / non-deterministic outputs). +Node.js startup is consistently **32-55ms** across all projects. -## End-to-End Timeline: Full Command Lifecycle +## Key Findings -Combining all phases for a single `vp run lint:check` invocation (frm-stack): +### 1. Cache hits save 50–99% of execution time -``` -T+0.00ms Global CLI starts (Rust binary) -T+0.04ms argv0 processed -T+0.50ms Runtime resolution begins -T+1.30ms Node.js version resolved (cached) -T+3.30ms oxc_resolver finds local vite-plus -- ~170us -T+3.35ms exec(node, [dist/bin.js, "run", "lint:check"]) -- process replaced ---- Node.js process starts --- -T+3.70ms NAPI run() called (Node.js startup overhead) -T+4.00ms napi_run: start -T+4.12ms cli::main begins -T+4.74ms execute_vite_task_command begins -T+4.94ms load_package_graph begins -T+8.76ms load_package_graph ends -- 3.8ms -T+8.78ms load_user_config_file #1 begins (JS callback) -T+173ms load_user_config_file #1 ends -- 164ms * bottleneck - ... (more config loads, including one ~660ms monster) -T+937ms handle_command begins -T+939ms handle_command ends (js_resolver: 1.25ms) -T+940ms Task execution starts (child process spawn) - ... (actual command runs) -``` +When cache hits occur, they are highly effective. The remaining time is almost entirely config loading (`load_user_config_file`), which must run every time regardless of cache status. -**Total overhead before task execution: ~940ms**, of which **~930ms (99%) is vite.config.ts loading**. +### 2. Config loading is the dominant bottleneck -## Wall-Clock Timelines (vibe-dashboard, Ubuntu) +Config loading accounts for **95-99%** of NAPI overhead and sets the floor for cache hit response time: +- Small projects (vue-mini, oxlint): ~180ms +- Medium projects (rollipop, vitepress): ~230–640ms +- Large projects (frm-stack): ~850ms +- Complex projects (tanstack-start, dify): ~1,300ms -### First Run +Config loading is not cached between `vp` invocations — every command re-resolves all configs from JavaScript. -``` -19:16:44.039 vp fmt -- pnpm download starts -19:16:44.170 vp fmt -- cache dir created -19:16:45.158 vp fmt -- spawn 1 finished (setup) -19:16:46.028 vp fmt -- spawn 2 finished (biome) Total: ~2.0s -19:16:46.082 vp test -- pnpm resolution starts -19:16:46.084 vp test -- cache dir created -19:16:47.057 vp test -- spawn 1 finished (setup) -19:16:52.750 vp test -- spawn 2 finished (vitest) Total: ~6.7s -19:16:52.846 vp run build -- cache dir created -19:16:53.793 vp run build -- spawn 1 finished (setup) -19:16:55.398 vp run build -- spawn 2 finished (vite build) Total: ~2.6s -``` +### 3. Cache fingerprinting overhead is negligible -**Total first run: ~11.4s** (3 commands sequential) +`create_post_run_fingerprint` (2–60ms per task for most projects) and `validate_post_run_fingerprint` (1–40ms) add minimal overhead. The exception is dify where fingerprinting takes 170–1,637ms due to the large number of files tracked. -### Cache Run +### 4. Within-run deduplication works -``` -19:16:56.446 vp fmt -- cache dir created -19:16:57.399 vp fmt -- spawn 1 finished -19:16:58.368 vp fmt -- spawn 2 finished Total: ~1.9s -19:16:58.441 vp test -- cache dir created -19:16:59.390 vp test -- spawn 1 finished -19:17:03.556 vp test -- spawn 2 finished Total: ~5.1s -19:17:03.641 vp run build -- cache dir created -19:17:04.596 vp run build -- spawn 1 finished -19:17:05.040 vp run build -- cache replayed Total: ~1.4s -``` +vitepress runs `VITE_TEST_BUILD=1 vp run tests-e2e#test` which is identical to the prior `vp run tests-e2e#test`. The second invocation is always a cache hit (even on the first run), saving ~26s each time. -**Total cache run: ~8.6s** (-24% from first run) - -## Summary of Bottlenecks +### 5. Empty-path fingerprinting reduces cache hit rate -| Bottleneck | Time | % of overhead | Optimization opportunity | -| ----------------------------- | -------------------------- | ------------- | ---------------------------------------------------- | -| vite.config.ts loading (cold) | **168ms-1.3s** per project | **95-99%** | Cache config results, lazy loading, parallel loading | -| Spawn 1 (pnpm/setup) | **~1s** | -- | Persistent process, avoid re-resolving | -| load_package_graph | **0.1-10ms** | <1% | Already fast | -| Session init | **~60us** | <0.01% | Already fast | -| Global CLI overhead | **3-9ms** | <0.5% | Already fast | -| Node.js + NAPI startup | **~3.7ms** | <0.4% | Already fast | -| oxc_resolver | **~170us** | <0.02% | Already fast | -| js_resolver callback | **~1.25ms** | <0.1% | Already fast | +Commands whose child processes read the working directory (path `''`) get a volatile directory-listing fingerprint that changes between runs. This affects `prettier`, `eslint`, and `tsc` in vue-mini and `lint` in rollipop, dropping their overall cache hit speedup to 1.2–1.5x. -**The single most impactful optimization would be caching or parallelizing `load_user_config_file` calls.** Across all projects: +## Summary of Bottlenecks -- Simple configs (vue-mini, vitepress): ~168ms baseline, nearly all from first-config JS initialization +| Bottleneck | Time | % of overhead | +| ----------------------------- | -------------------------- | ------------- | +| vite.config.ts loading (cold) | **170ms-1.3s** per command | **95-99%** | +| load_package_graph | **2-5ms** | <1% | +| Cache state load | **0.7-14ms** | <1% | +| Cache operations (hit) | **10-50ms** | <5% | +| handle_command (js_resolver) | **~1.5ms** | <0.2% | +| Session init | **~70us** | <0.01% | +| Node.js + NAPI startup | **~3.7ms** | <0.4% | +| Global CLI overhead | **3-9ms** | <0.5% | +| oxc_resolver | **~170us** | <0.02% | + +Config loading breakdown across projects: + +- Simple configs (vue-mini, vitepress): ~170ms baseline, nearly all from first-config JS initialization - Heavy single configs (tanstack-start-helloworld): up to 1.3s for a single config with heavy plugins -- Large monorepos (frm-stack, 10 packages): ~836ms total, dominated by one "monster" config (~660ms) -- Distinct-dependency monorepos (rollipop, 6 packages): ~639ms, each package importing different heavy dependencies (100-155ms each) +- Large monorepos (frm-stack, 10 packages): ~977ms total, dominated by one "monster" config (~763ms) +- Distinct-dependency monorepos (rollipop, 6 packages): ~644ms, each package importing different heavy dependencies (100-155ms each) -Config loading is not cached between `vp` invocations. Every command re-resolves all configs from JavaScript. +## Known Issues -## Inter-Process Communication +### vibe-dashboard and dify produce no NAPI traces -vite-task uses Unix shared memory (`/dev/shm`) for parent-child process communication during task execution: +These projects produce only global CLI traces. The NAPI-side tracing likely doesn't flush properly because: +- `vp fmt` and `vp test` (Synthesizable commands) may exit before `shutdownTracing()` is called +- The `shutdownTracing()` fix (commit `72b23304`) may not cover all exit paths for these command types -- Creates persistent mapping at `/shmem_` -- Maps memory into address space for fast IPC -- Cleaned up after spawn completion +### Empty-path fingerprinting causes spurious cache misses -## Known Issues +fspy tracks the working directory itself (path `''`) as a file read. The directory listing fingerprint changes between runs when prior commands create or modify files, causing `PostRunFingerprintMismatch`. This affects 9 commands across vue-mini and rollipop (`prettier`, `eslint`, `tsc`, `lint`). ### Trace files break formatter (fixed) -When `VITE_LOG_OUTPUT=chrome-json` is set, trace files (`trace-*.json`) were written to the project working directory. Formatters (oxfmt/prettier) pick up these files and fail with "Unterminated string constant" because trace files may contain truncated JSON (especially on Windows where PATH strings are very long). +When `VITE_LOG_OUTPUT=chrome-json` is set, trace files were written to the project working directory. Formatters pick up these files and fail with parse errors. **Fix**: Set `VITE_LOG_OUTPUT_DIR` to write trace files to a dedicated directory outside the workspace. -### NAPI trace files empty for some projects +## Tracing Instrumentation + +The following spans are instrumented at `debug` level in vite-task: + +| Span | Location | Purpose | +|------|----------|---------| +| `try_hit` | `session/cache/mod.rs` | Cache lookup with spawn fingerprint matching | +| `validate_post_run_fingerprint` | `session/execute/fingerprint.rs` | Re-hash tracked files to validate cache | +| `create_post_run_fingerprint` | `session/execute/fingerprint.rs` | Hash all fspy-tracked files after execution | +| `update` | `session/cache/mod.rs` | Write cache entry to SQLite | +| `spawn_with_tracking` | `session/execute/spawn.rs` | Execute command with fspy file tracking | +| `load_from_path` | `session/cache/mod.rs` | Open/create SQLite cache database | +| `execute_spawn` | `session/execute/mod.rs` | Full cache-aware execution lifecycle | -The Chrome tracing `FlushGuard` stored in a static `OnceLock` is never dropped when `process.exit()` is called. Fixed by adding `shutdownTracing()` NAPI function called before exit (commit `72b23304`). Some projects (vibe-dashboard) still only produce global CLI traces and no NAPI traces. +Enabled via: `VITE_LOG=debug VITE_LOG_OUTPUT=chrome-json VITE_LOG_OUTPUT_DIR=` ## Methodology @@ -377,13 +444,10 @@ The Chrome tracing `FlushGuard` stored in a static `OnceLock` is never dropped w - **Measurement PRs**: - vite-task: https://github.com/voidzero-dev/vite-task/pull/178 - vite-plus: https://github.com/voidzero-dev/vite-plus/pull/663 -- **Trace sources**: 73 trace files across 9 projects (E2E run #22552050124) - - frm-stack: 20 files (10 global CLI + 10 NAPI) - - vibe-dashboard: 8 files (6 global CLI + 2 empty) - - rollipop: 8 files (4 global CLI + 4 NAPI) - - tanstack-start-helloworld: 10 files (4 global CLI + 4 NAPI + 2 empty) - - vite-vue-vercel: 10 files (4 global CLI + 4 NAPI + 2 empty) - - dify: 10 files (4 global CLI + 4 NAPI + 1 empty + 1 corrupted) - - oxlint-plugin-complexity: 2 files (1 global CLI + 1 NAPI) - - vitepress: 3 files (1 global CLI + 1 NAPI + 1 empty) - - vue-mini: 2 files (1 global CLI + 1 NAPI) +- **E2E tests**: + - Run #22556278251 — 2 runs per project, cache disabled. Baseline overhead measurements. + - Run #22558467033 — 3 runs per project (first, cache hit, cache miss). Cache performance measurements. +- **Analysis tools**: + - `analyze2.py` — Parses Chrome trace JSON files, classifies cache behavior, extracts per-span timings + - Trace artifacts: `run2-artifacts/trace-{project}-ubuntu-latest/` + - Full CI log: `run2-full.log` From c480558cec382ec97590fe6f5fe0882a3d723d1b Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 11:28:03 +0800 Subject: [PATCH 37/38] chore: trigger CI after rebase From 0f9cfd8d411534bf75a7652f5959b79cfcb7eaba Mon Sep 17 00:00:00 2001 From: branchseer Date: Wed, 4 Mar 2026 11:44:29 +0800 Subject: [PATCH 38/38] style: format performance.md tables with oxfmt Co-Authored-By: Claude Opus 4.6 --- performance.md | 243 +++++++++++++++++++++++++------------------------ 1 file changed, 124 insertions(+), 119 deletions(-) diff --git a/performance.md b/performance.md index 48ca4a19a0..03483df7a1 100644 --- a/performance.md +++ b/performance.md @@ -5,6 +5,7 @@ Performance measurements from E2E tests (Ubuntu, GitHub Actions runner). **Test projects**: 9 ecosystem-ci projects (single-package and multi-package monorepos) **Node.js**: 22-24 (managed by vite-plus js_runtime) **Trace sources**: + - Run #22556278251 — baseline traces (2 runs per project, cache disabled) - Run [#22558467033](https://github.com/voidzero-dev/vite-plus/actions/runs/22558467033) — cache-enabled traces (3 runs per project: first, cache hit, cache miss) @@ -52,53 +53,54 @@ With `cacheScripts: true`, vite-task caches command outputs keyed by a spawn fin When cache hits occur, the saved time comes from skipping `spawn_with_tracking` (the actual command execution) and `create_post_run_fingerprint` (post-run file hashing): -| Project | Command | Miss (ms) | Hit (ms) | Saved (ms) | Saved % | -|---------|---------|-----------|----------|------------|---------| -| dify | build (next build) | 170,673 | 670 | 170,003 | **99.6%** | -| vitepress | tests-e2e#test | 26,696 | 250 | 26,446 | **99.1%** | -| vitepress | tests-init#test | 11,430 | 290 | 11,140 | **97.5%** | -| vue-mini | test -- --coverage | 6,357 | 217 | 6,140 | **96.6%** | -| dify | test (3 files) | 6,524 | 349 | 6,175 | **94.7%** | -| oxlint-plugin-complexity | lint | 4,165 | 232 | 3,933 | **94.4%** | -| frm-stack | @yourcompany/api#test | 14,760 | 895 | 13,865 | **93.9%** | -| oxlint-plugin-complexity | build | 3,529 | 219 | 3,310 | **93.8%** | -| rollipop | -r typecheck (4 tasks) | 8,581 | 697 | 7,884 | **91.9%** | -| vite-vue-vercel | test | 2,744 | 326 | 2,418 | **88.1%** | -| oxlint-plugin-complexity | test:run | 1,377 | 212 | 1,165 | **84.6%** | -| tanstack-start-helloworld | build | 8,844 | 1,383 | 7,461 | **84.4%** | -| oxlint-plugin-complexity | format:check | 1,355 | 214 | 1,141 | **84.2%** | -| frm-stack | @yourcompany/backend-core#test | 5,571 | 894 | 4,677 | **83.9%** | -| oxlint-plugin-complexity | format | 1,419 | 239 | 1,180 | **83.2%** | -| rollipop | @rollipop/core#test | 2,878 | 671 | 2,208 | **76.7%** | -| vite-vue-vercel | build | 842 | 328 | 514 | **61.0%** | -| rollipop | @rollipop/common#test | 1,307 | 663 | 644 | **49.3%** | -| rollipop | format | 1,257 | 657 | 600 | **47.7%** | -| frm-stack | typecheck | 1,448 | 918 | 530 | **36.6%** | +| Project | Command | Miss (ms) | Hit (ms) | Saved (ms) | Saved % | +| ------------------------- | ------------------------------ | --------- | -------- | ---------- | --------- | +| dify | build (next build) | 170,673 | 670 | 170,003 | **99.6%** | +| vitepress | tests-e2e#test | 26,696 | 250 | 26,446 | **99.1%** | +| vitepress | tests-init#test | 11,430 | 290 | 11,140 | **97.5%** | +| vue-mini | test -- --coverage | 6,357 | 217 | 6,140 | **96.6%** | +| dify | test (3 files) | 6,524 | 349 | 6,175 | **94.7%** | +| oxlint-plugin-complexity | lint | 4,165 | 232 | 3,933 | **94.4%** | +| frm-stack | @yourcompany/api#test | 14,760 | 895 | 13,865 | **93.9%** | +| oxlint-plugin-complexity | build | 3,529 | 219 | 3,310 | **93.8%** | +| rollipop | -r typecheck (4 tasks) | 8,581 | 697 | 7,884 | **91.9%** | +| vite-vue-vercel | test | 2,744 | 326 | 2,418 | **88.1%** | +| oxlint-plugin-complexity | test:run | 1,377 | 212 | 1,165 | **84.6%** | +| tanstack-start-helloworld | build | 8,844 | 1,383 | 7,461 | **84.4%** | +| oxlint-plugin-complexity | format:check | 1,355 | 214 | 1,141 | **84.2%** | +| frm-stack | @yourcompany/backend-core#test | 5,571 | 894 | 4,677 | **83.9%** | +| oxlint-plugin-complexity | format | 1,419 | 239 | 1,180 | **83.2%** | +| rollipop | @rollipop/core#test | 2,878 | 671 | 2,208 | **76.7%** | +| vite-vue-vercel | build | 842 | 328 | 514 | **61.0%** | +| rollipop | @rollipop/common#test | 1,307 | 663 | 644 | **49.3%** | +| rollipop | format | 1,257 | 657 | 600 | **47.7%** | +| frm-stack | typecheck | 1,448 | 918 | 530 | **36.6%** | ### Cache Operation Overhead #### On Cache Hit -| Operation | Time | Description | -|-----------|------|-------------| -| `try_hit` | 0.0–50ms | Look up spawn fingerprint in SQLite, then validate post-run fingerprint | -| `validate_post_run_fingerprint` | 1–40ms | Re-hash all tracked input files to check if they changed | -| **Total cache overhead** | **10–50ms** | Negligible compared to saved execution time | +| Operation | Time | Description | +| ------------------------------- | ----------- | ----------------------------------------------------------------------- | +| `try_hit` | 0.0–50ms | Look up spawn fingerprint in SQLite, then validate post-run fingerprint | +| `validate_post_run_fingerprint` | 1–40ms | Re-hash all tracked input files to check if they changed | +| **Total cache overhead** | **10–50ms** | Negligible compared to saved execution time | Cache hit total time is dominated by config loading (177–1,316ms depending on project), not cache operations. #### On Cache Miss (with write-back) -| Operation | Time | Description | -|-----------|------|-------------| -| `try_hit` | 0.0–0.1ms | Quick lookup, returns `NotFound` or `FingerprintMismatch` | -| `spawn_with_tracking` | 200–170,000ms | Execute the actual command with fspy file tracking | -| `create_post_run_fingerprint` | 2–1,637ms | Hash all files accessed during execution | -| `update` | 1–200ms | Write fingerprint and outputs to SQLite cache | +| Operation | Time | Description | +| ----------------------------- | ------------- | --------------------------------------------------------- | +| `try_hit` | 0.0–0.1ms | Quick lookup, returns `NotFound` or `FingerprintMismatch` | +| `spawn_with_tracking` | 200–170,000ms | Execute the actual command with fspy file tracking | +| `create_post_run_fingerprint` | 2–1,637ms | Hash all files accessed during execution | +| `update` | 1–200ms | Write fingerprint and outputs to SQLite cache | ### Execution Timeline (Cache Hit vs Miss) #### Cache Hit Flow + ``` ┌──────────────────┐ ┌─────────┐ ┌──────────────────────────────┐ ┌─────────┐ │ load_user_config │→│ try_hit │→│ validate_post_run_fingerprint │→│ replay │ @@ -108,6 +110,7 @@ Total: 200–1400ms (config loading dominates) ``` #### Cache Miss Flow + ``` ┌──────────────────┐ ┌─────────┐ ┌─────────────────────┐ ┌────────────────────────────┐ ┌────────┐ │ load_user_config │→│ try_hit │→│ spawn_with_tracking │→│ create_post_run_fingerprint │→│ update │ @@ -120,12 +123,12 @@ Total: 400–172,000ms (spawn dominates) From CI log analysis, cache misses on the "cache hit" run fall into these categories: -| Miss Reason | Count | Explanation | -|-------------|-------|-------------| -| `content of input 'package.json' changed` | 60 | Expected — from the intentional cache invalidation step | -| `content of input '' changed` | 9 | Bug — fspy tracks an empty path (working directory listing) which changes between runs | -| `content of input 'dist/...' changed` | ~10 | Expected — build outputs change between runs (e.g., vitepress `build:client` changes `dist/`) | -| `content of input 'tsconfig.json' changed` | 3 | Side effect of prior commands modifying project config | +| Miss Reason | Count | Explanation | +| ------------------------------------------ | ----- | --------------------------------------------------------------------------------------------- | +| `content of input 'package.json' changed` | 60 | Expected — from the intentional cache invalidation step | +| `content of input '' changed` | 9 | Bug — fspy tracks an empty path (working directory listing) which changes between runs | +| `content of input 'dist/...' changed` | ~10 | Expected — build outputs change between runs (e.g., vitepress `build:client` changes `dist/`) | +| `content of input 'tsconfig.json' changed` | 3 | Side effect of prior commands modifying project config | The `content of input '' changed` issue affects vue-mini's `prettier`, `eslint`, and `tsc` commands — fspy records the working directory itself as a read, and its directory listing changes between runs because the first command creates or modifies files. This is the main reason vue-mini and rollipop show low cache hit rates. @@ -133,17 +136,17 @@ The `content of input '' changed` issue affects vue-mini's `prettier`, `eslint`, NAPI overhead measured from trace files (Ubuntu, all invocations): -| Project | Packages | Config loading | Overhead | n | -| ------------------------- | -------- | --------------- | ------------- | -- | -| vue-mini | 1 | **170-218ms** | **173-223ms** | 8 | -| oxlint-plugin-complexity | 1-2 | **177-249ms** | **184-258ms** | 10 | -| vitepress | 4 | **175-202ms** | **182-327ms** | 12 | -| vite-vue-vercel | 1 | **320-328ms** | **326-338ms** | 4 | -| rollipop | 6 | **635-658ms** | **643-670ms** | 14 | -| frm-stack | 10-11 | **959-993ms** | **968-1002ms** | 10 | -| tanstack-start-helloworld | 1 | **1305-1320ms** | **1308-1337ms** | 4 | -| vibe-dashboard | -- | -- | -- | 0 | -| dify | -- | -- | -- | 0 | +| Project | Packages | Config loading | Overhead | n | +| ------------------------- | -------- | --------------- | --------------- | --- | +| vue-mini | 1 | **170-218ms** | **173-223ms** | 8 | +| oxlint-plugin-complexity | 1-2 | **177-249ms** | **184-258ms** | 10 | +| vitepress | 4 | **175-202ms** | **182-327ms** | 12 | +| vite-vue-vercel | 1 | **320-328ms** | **326-338ms** | 4 | +| rollipop | 6 | **635-658ms** | **643-670ms** | 14 | +| frm-stack | 10-11 | **959-993ms** | **968-1002ms** | 10 | +| tanstack-start-helloworld | 1 | **1305-1320ms** | **1308-1337ms** | 4 | +| vibe-dashboard | -- | -- | -- | 0 | +| dify | -- | -- | -- | 0 | vibe-dashboard and dify only produced global CLI traces (no NAPI traces captured). See Known Issues. @@ -153,15 +156,15 @@ Config loading accounts for **95-99%** of total NAPI overhead in every project. The first `load_user_config_file` call pays a fixed JS module initialization cost (~150-170ms). Projects with heavy Vite plugins pay more: -| Project | First config | Largest config | Subsequent configs | -| ------------------------- | ------------- | -------------------- | ------------------ | -| vue-mini | 164-177ms | same | 2-3ms | -| oxlint-plugin-complexity | 177-249ms | same | N/A (single) | -| vitepress | 158-201ms | same | 5-7ms | -| vite-vue-vercel | 320-328ms | same | N/A (single) | -| rollipop | 150-165ms | 146-168ms (#3) | 100-155ms each | -| frm-stack | 165-173ms | **750-786ms** (#4-5) | 3-12ms | -| tanstack-start-helloworld | **1305-1320ms** | same | N/A (single) | +| Project | First config | Largest config | Subsequent configs | +| ------------------------- | --------------- | -------------------- | ------------------ | +| vue-mini | 164-177ms | same | 2-3ms | +| oxlint-plugin-complexity | 177-249ms | same | N/A (single) | +| vitepress | 158-201ms | same | 5-7ms | +| vite-vue-vercel | 320-328ms | same | N/A (single) | +| rollipop | 150-165ms | 146-168ms (#3) | 100-155ms each | +| frm-stack | 165-173ms | **750-786ms** (#4-5) | 3-12ms | +| tanstack-start-helloworld | **1305-1320ms** | same | N/A (single) | Key observations: @@ -176,32 +179,32 @@ Measured via Chrome tracing from the `vp` binary process. ### Cross-Project Global CLI Overhead -| Project | Range | n | -| ------------------------- | ---------- | -- | -| vite-vue-vercel | 3.4-6.9ms | 10 | -| rollipop | 3.7-4.7ms | 14 | -| tanstack-start-helloworld | 3.7-6.2ms | 4 | -| vitepress | 3.3-3.9ms | 12 | -| vibe-dashboard | 4.1-6.7ms | 6 | -| vue-mini | 5.5-6.1ms | 8 | -| oxlint-plugin-complexity | 3.1-8.8ms | 10 | -| dify | 4.3-13.6ms | 6 | -| frm-stack | 3.4-7.4ms | 10 | +| Project | Range | n | +| ------------------------- | ---------- | --- | +| vite-vue-vercel | 3.4-6.9ms | 10 | +| rollipop | 3.7-4.7ms | 14 | +| tanstack-start-helloworld | 3.7-6.2ms | 4 | +| vitepress | 3.3-3.9ms | 12 | +| vibe-dashboard | 4.1-6.7ms | 6 | +| vue-mini | 5.5-6.1ms | 8 | +| oxlint-plugin-complexity | 3.1-8.8ms | 10 | +| dify | 4.3-13.6ms | 6 | +| frm-stack | 3.4-7.4ms | 10 | Global CLI overhead is consistently **3-9ms** across all projects, with rare outliers up to 14ms. This is the Rust binary resolving Node.js version, finding the local vite-plus install via oxc_resolver, and delegating via exec. ### Breakdown (vibe-dashboard, 6 invocations) -| Stage | Time from start | Duration | -| ------------------------- | --------------- | ---------- | -| argv0 processing | 37-57us | ~40us | -| Runtime resolution start | 482-684us | ~500us | -| Node.js version selected | 714-1042us | ~300us | -| Node.js version resolved | 1237-1593us | ~50us | -| Node.js cache confirmed | 1302-1627us | ~50us | -| **oxc_resolver start** | **3058-7896us** | -- | -| oxc_resolver complete | 3230-8072us | **~170us** | -| Delegation to Node.js | 3275-8160us | ~40us | +| Stage | Time from start | Duration | +| ------------------------ | --------------- | ---------- | +| argv0 processing | 37-57us | ~40us | +| Runtime resolution start | 482-684us | ~500us | +| Node.js version selected | 714-1042us | ~300us | +| Node.js version resolved | 1237-1593us | ~50us | +| Node.js cache confirmed | 1302-1627us | ~50us | +| **oxc_resolver start** | **3058-7896us** | -- | +| oxc_resolver complete | 3230-8072us | **~170us** | +| Delegation to Node.js | 3275-8160us | ~40us | ## Phase 2: Node.js Startup + NAPI Loading @@ -266,28 +269,28 @@ From Chrome trace, all times in us from process start: ### frm-stack Aggregate Statistics -| Metric | Average | Range | n | -| ------------------------------------ | ------- | ------------- | --- | -| load_package_graph | 4.5ms | 4.1-4.9ms | 10 | -| Total config loading per command | 977ms | 959-993ms | 10 | -| First config load | 169ms | 165-173ms | 10 | -| "Monster" config load (~config #4/5) | 763ms | 750-786ms | 10 | -| Other config loads | ~4ms | 3-12ms | ~90 | -| Total NAPI overhead | 987ms | 968-1002ms | 10 | -| Cache state load (load_from_path) | 1.5ms | 0.8-7.4ms | 10 | -| handle_command (non-test) | 0.03ms | 0.00-0.08ms | 6 | -| handle_command (test w/ js_resolver) | 1.46ms | 1.41-1.53ms | 4 | +| Metric | Average | Range | n | +| ------------------------------------ | ------- | ----------- | --- | +| load_package_graph | 4.5ms | 4.1-4.9ms | 10 | +| Total config loading per command | 977ms | 959-993ms | 10 | +| First config load | 169ms | 165-173ms | 10 | +| "Monster" config load (~config #4/5) | 763ms | 750-786ms | 10 | +| Other config loads | ~4ms | 3-12ms | ~90 | +| Total NAPI overhead | 987ms | 968-1002ms | 10 | +| Cache state load (load_from_path) | 1.5ms | 0.8-7.4ms | 10 | +| handle_command (non-test) | 0.03ms | 0.00-0.08ms | 6 | +| handle_command (test w/ js_resolver) | 1.46ms | 1.41-1.53ms | 4 | ### First Run vs Second Run (frm-stack averages) -| Metric | First Run | Second Run | Delta | -| -------------------- | --------- | ---------- | -------------- | -| Total NAPI overhead | 988ms | 985ms | -3ms (-0.3%) | -| load_package_graph | 4.5ms | 4.6ms | +0.1ms | -| Total config loading | 977ms | 977ms | ~0ms | -| First config load | 170ms | 167ms | -3ms | -| Monster config | 763ms | 763ms | ~0ms | -| Cache state load | 2.2ms | 1.0ms | -1.2ms (-55%) | +| Metric | First Run | Second Run | Delta | +| -------------------- | --------- | ---------- | ------------- | +| Total NAPI overhead | 988ms | 985ms | -3ms (-0.3%) | +| load_package_graph | 4.5ms | 4.6ms | +0.1ms | +| Total config loading | 977ms | 977ms | ~0ms | +| First config load | 170ms | 167ms | -3ms | +| Monster config | 763ms | 763ms | ~0ms | +| Cache state load | 2.2ms | 1.0ms | -1.2ms (-55%) | Config loading is **not cached** between invocations -- every `vp run` command re-resolves all Vite configs from JavaScript. There is no measurable difference between first and second runs. @@ -339,17 +342,17 @@ Wall-clock timestamps from CI output logs. The `process uptime` value shows Node ### Process Uptime (Node.js startup) -| Project | Range | -| ------------------------- | ------------- | -| vibe-dashboard | 35.0-35.1ms | -| rollipop | 32.4-37.8ms | -| frm-stack | 34.2-56.2ms | -| vue-mini | 38.6-54.8ms | -| vitepress | 32.4-35.9ms | -| tanstack-start-helloworld | 33.2-33.9ms | -| oxlint-plugin-complexity | 33.0-47.2ms | -| vite-vue-vercel | 32.1-33.2ms | -| dify | 33.8-40.1ms | +| Project | Range | +| ------------------------- | ----------- | +| vibe-dashboard | 35.0-35.1ms | +| rollipop | 32.4-37.8ms | +| frm-stack | 34.2-56.2ms | +| vue-mini | 38.6-54.8ms | +| vitepress | 32.4-35.9ms | +| tanstack-start-helloworld | 33.2-33.9ms | +| oxlint-plugin-complexity | 33.0-47.2ms | +| vite-vue-vercel | 32.1-33.2ms | +| dify | 33.8-40.1ms | Node.js startup is consistently **32-55ms** across all projects. @@ -362,6 +365,7 @@ When cache hits occur, they are highly effective. The remaining time is almost e ### 2. Config loading is the dominant bottleneck Config loading accounts for **95-99%** of NAPI overhead and sets the floor for cache hit response time: + - Small projects (vue-mini, oxlint): ~180ms - Medium projects (rollipop, vitepress): ~230–640ms - Large projects (frm-stack): ~850ms @@ -407,6 +411,7 @@ Config loading breakdown across projects: ### vibe-dashboard and dify produce no NAPI traces These projects produce only global CLI traces. The NAPI-side tracing likely doesn't flush properly because: + - `vp fmt` and `vp test` (Synthesizable commands) may exit before `shutdownTracing()` is called - The `shutdownTracing()` fix (commit `72b23304`) may not cover all exit paths for these command types @@ -424,15 +429,15 @@ When `VITE_LOG_OUTPUT=chrome-json` is set, trace files were written to the proje The following spans are instrumented at `debug` level in vite-task: -| Span | Location | Purpose | -|------|----------|---------| -| `try_hit` | `session/cache/mod.rs` | Cache lookup with spawn fingerprint matching | -| `validate_post_run_fingerprint` | `session/execute/fingerprint.rs` | Re-hash tracked files to validate cache | -| `create_post_run_fingerprint` | `session/execute/fingerprint.rs` | Hash all fspy-tracked files after execution | -| `update` | `session/cache/mod.rs` | Write cache entry to SQLite | -| `spawn_with_tracking` | `session/execute/spawn.rs` | Execute command with fspy file tracking | -| `load_from_path` | `session/cache/mod.rs` | Open/create SQLite cache database | -| `execute_spawn` | `session/execute/mod.rs` | Full cache-aware execution lifecycle | +| Span | Location | Purpose | +| ------------------------------- | -------------------------------- | -------------------------------------------- | +| `try_hit` | `session/cache/mod.rs` | Cache lookup with spawn fingerprint matching | +| `validate_post_run_fingerprint` | `session/execute/fingerprint.rs` | Re-hash tracked files to validate cache | +| `create_post_run_fingerprint` | `session/execute/fingerprint.rs` | Hash all fspy-tracked files after execution | +| `update` | `session/cache/mod.rs` | Write cache entry to SQLite | +| `spawn_with_tracking` | `session/execute/spawn.rs` | Execute command with fspy file tracking | +| `load_from_path` | `session/cache/mod.rs` | Open/create SQLite cache database | +| `execute_spawn` | `session/execute/mod.rs` | Full cache-aware execution lifecycle | Enabled via: `VITE_LOG=debug VITE_LOG_OUTPUT=chrome-json VITE_LOG_OUTPUT_DIR=`