diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 0d14adbb96..c9bc1c4959 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -287,8 +287,55 @@ 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 + VITE_LOG_OUTPUT_DIR: ${{ runner.temp }}/trace-artifacts + 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 + 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 + run: | + mkdir -p ${{ runner.temp }}/trace-artifacts + 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.lock b/Cargo.lock index 5574c02b03..874680c1fc 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=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=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +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=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +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=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +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=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +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=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +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=9e1287e797190ea29793655b239cdaa7a55edd21#9e1287e797190ea29793655b239cdaa7a55edd21" +source = "git+ssh://git@github.com/voidzero-dev/vite-task.git?rev=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" 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=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" [[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]] @@ -7151,6 +7151,7 @@ dependencies = [ "vite_migration", "vite_path", "vite_shared", + "vite_static_config", "vite_str", "vite_task", "vite_workspace", @@ -7196,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=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "thiserror 2.0.18", "wax 0.7.0", @@ -7236,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=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "petgraph 0.8.3", "serde", @@ -7317,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=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "bincode", "diff-struct", @@ -7330,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=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "anyhow", "crossterm", @@ -7347,6 +7348,7 @@ dependencies = [ "owo-colors", "serde", "serde_json", + "tracing-chrome", "tracing-subscriber", "vite_path", "vite_str", @@ -7355,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=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "bincode", "brush-parser", @@ -7365,10 +7367,24 @@ dependencies = [ "vite_str", ] +[[package]] +name = "vite_static_config" +version = "0.0.0" +dependencies = [ + "oxc_allocator", + "oxc_ast", + "oxc_parser", + "oxc_span", + "rustc-hash", + "serde_json", + "tempfile", + "vite_path", +] + [[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=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "bincode", "compact_str", @@ -7379,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=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "anyhow", "async-trait", @@ -7415,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=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "anyhow", "async-trait", @@ -7425,6 +7441,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "tracing", "vite_graph_ser", "vite_path", "vite_str", @@ -7434,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=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "anyhow", "async-trait", @@ -7460,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=7380f75787b795704754fc6442dbe36cb5ddf598#7380f75787b795704754fc6442dbe36cb5ddf598" dependencies = [ "clap", "path-clean", @@ -7470,6 +7487,7 @@ dependencies = [ "serde_json", "serde_yml", "thiserror 2.0.18", + "tracing", "vec1", "vite_glob", "vite_path", @@ -7740,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]] diff --git a/Cargo.toml b/Cargo.toml index cf09264704..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 = "9e1287e797190ea29793655b239cdaa7a55edd21" } +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,14 +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 = "7380f75787b795704754fc6442dbe36cb5ddf598" } vite_install = { path = "crates/vite_install" } vite_migration = { path = "crates/vite_migration" } vite_shared = { path = "crates/vite_shared" } -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_static_config = { path = "crates/vite_static_config" } +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" @@ -210,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_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..ce4f0a339b 100644 --- a/crates/vite_shared/Cargo.toml +++ b/crates/vite_shared/Cargo.toml @@ -12,6 +12,7 @@ directories = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true } serde_json = { 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..680c5f4339 100644 --- a/crates/vite_shared/src/env_vars.rs +++ b/crates/vite_shared/src/env_vars.rs @@ -18,6 +18,12 @@ 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"; + +/// 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 97c889b11e..c2428be6a1 100644 --- a/crates/vite_shared/src/tracing.rs +++ b/crates/vite_shared/src/tracing.rs @@ -1,38 +1,92 @@ //! 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. +//! - `VITE_LOG_OUTPUT_DIR`: Directory for chrome-json trace files (default: cwd). -use std::sync::OnceLock; +use std::{any::Any, path::PathBuf, 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 mut builder = ChromeLayerBuilder::new() + .trace_style(tracing_chrome::TraceStyle::Async) + .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)) + } + "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 + } + } } diff --git a/crates/vite_static_config/Cargo.toml b/crates/vite_static_config/Cargo.toml new file mode 100644 index 0000000000..ae9569923d --- /dev/null +++ b/crates/vite_static_config/Cargo.toml @@ -0,0 +1,23 @@ +[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_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 } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_static_config/README.md b/crates/vite_static_config/README.md new file mode 100644 index 0000000000..e33cc8f12f --- /dev/null +++ b/crates/vite_static_config/README.md @@ -0,0 +1,58 @@ +# 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, 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: + - `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 + +## 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 new file mode 100644 index 0000000000..b20205e4d6 --- /dev/null +++ b/crates/vite_static_config/src/lib.rs @@ -0,0 +1,1054 @@ +//! 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_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; + +/// The result of statically analyzing a single config field's value. +#[derive(Debug, Clone, PartialEq, Eq)] +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 + /// function calls, variables, template literals with expressions, etc.) + NonStatic, +} + +/// The result of statically analyzing a vite config file. +/// +/// - `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 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 config. +pub type StaticConfig = Option, FieldValue>>; + +/// Config file names to try, in priority order. +/// This matches Vite's `DEFAULT_CONFIG_FILES`: +/// +/// +/// Vite resolves config files by iterating this list and checking `fs.existsSync` — no +/// module resolution involved, so `oxc_resolver` is not needed here: +/// +const CONFIG_FILE_NAMES: &[&str] = &[ + "vite.config.js", + "vite.config.mjs", + "vite.config.ts", + "vite.config.cjs", + "vite.config.mts", + "vite.config.cts", +]; + +/// 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. +/// +/// See [`StaticConfig`] for the return type semantics. +#[must_use] +pub fn resolve_static_config(dir: &AbsolutePath) -> StaticConfig { + 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(""); + + 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. +/// 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()), FieldValue::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) -> StaticConfig { + 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 None; + } + + extract_config_fields(&result.program) +} + +/// 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 { + 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); + } + // export default class/function — not analyzable + return None; + } + + // 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); + } + } + + None +} + +/// 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 { + let expr = expr.without_parentheses(); + match expr { + Expression::CallExpression(call) => { + if !call.callee.is_specific_id("defineConfig") { + return None; + } + 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, + } + } + 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`]. +/// +/// 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. + invalidate_previous(&mut map); + continue; + } + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + continue; + }; + + let Some(key) = prop.key.static_name() else { + // A computed key may equal any previously-seen key name. + invalidate_previous(&mut map); + continue; + }; + + 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 following `JSON.stringify` semantics. +/// `NaN`, `Infinity`, `-Infinity` become `null`; `-0` becomes `0`. +fn f64_to_json_number(value: f64) -> serde_json::Value { + // 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 + && let Ok(i) = i64::try_from(value as i128) + { + serde_json::Value::from(i) + } else { + // From for Value: finite → Number, NaN/Infinity → Null + serde_json::Value::from(value) + } +} + +/// 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) => { + let quasi = lit.single_quasi()?; + Some(serde_json::Value::String(quasi.to_string())) + } + + 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 { + 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)) + } + + Expression::ObjectExpression(obj) => { + let mut map = serde_json::Map::new(); + for prop in &obj.properties { + 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)) + } + + _ => None, + } +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + /// Helper: parse JS/TS source, unwrap the `Some` (asserting it's analyzable), + /// and return the field map. + 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, 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, FieldValue>, key: &str) { + assert_eq!( + map.get(key), + Some(&FieldValue::NonStatic), + "expected field {key:?} to be NonStatic" + ); + } + + // ── 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).unwrap(); + 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).unwrap(); + 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).unwrap(); + assert!(result.contains_key("run")); + } + + #[test] + 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 }") + .unwrap(); + std::fs::write(dir.path().join("vite.config.js"), "export default { fromJs: true }") + .unwrap(); + let result = resolve_static_config(&dir_path).unwrap(); + assert!(result.contains_key("fromJs")); + assert!(!result.contains_key("fromTs")); + } + + #[test] + 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(); + let result = resolve_static_config(&dir_path).unwrap(); + 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).unwrap(); + assert_json( + &result, + "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_json(&result, "foo", serde_json::json!("bar")); + assert_json(&result, "num", 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_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + 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] + fn string_values() { + let result = parse(r#"export default { a: "double", b: '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: 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 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 }"); + 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_json(&result, "a", serde_json::Value::Null); + } + + // ── Arrays ────────────────────────────────────────────────────────── + + #[test] + fn array_of_strings() { + let result = parse("export default { items: ['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_json(&result, "matrix", serde_json::json!([[1, 2], [3, 4]])); + } + + #[test] + fn empty_array() { + let result = parse("export default { items: [] }"); + assert_json(&result, "items", 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_json( + &result, + "run", + serde_json::json!({ + "tasks": { + "build": { + "command": "echo build", + "dependsOn": ["lint"], + "cache": true, + } + } + }), + ); + } + + // ── NonStatic fields ──────────────────────────────────────────────── + + #[test] + fn non_static_function_call_values() { + let result = parse( + r"export default { + run: { cacheScripts: true }, + plugins: [myPlugin()], + }", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + assert_non_static(&result, "plugins"); + } + + #[test] + fn non_static_identifier_values() { + let result = parse( + r" + const myVar = 'hello'; + export default { a: myVar, b: 42 } + ", + ); + assert_non_static(&result, "a"); + assert_json(&result, "b", serde_json::json!(42)); + } + + #[test] + fn non_static_template_literal_with_expressions() { + let result = parse( + r" + const x = 'world'; + export default { a: `hello ${x}`, b: 'plain' } + ", + ); + 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_json(&result, "a", serde_json::json!("hello")); + } + + #[test] + fn non_static_spread_in_object_value() { + let result = parse( + r" + const base = { x: 1 }; + export default { a: { ...base, y: 2 }, b: 'ok' } + ", + ); + assert_non_static(&result, "a"); + assert_json(&result, "b", serde_json::json!("ok")); + } + + #[test] + 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' } + ", + ); + 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")); + } + + #[test] + 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' } + ", + ); + 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( + r" + const arr = [1, 2]; + export default { a: [...arr, 3], b: 'ok' } + ", + ); + 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_json(&result, "string-key", 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_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" + 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_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" + export const config = { a: 1 }; + export default { b: 2 }; + ", + ); + assert!(!result.contains_key("a")); + 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] + fn returns_none_for_no_default_export() { + assert!(parse_js_ts_config("export const config = { a: 1 };", "ts").is_none()); + } + + #[test] + fn returns_none_for_non_object_default_export() { + assert!(parse_js_ts_config("export default 42;", "ts").is_none()); + } + + #[test] + 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 { + a: [1, 2, 3,], + b: { 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 { + run: { + tasks: { + hello: { + command: 'node hello.mjs', + envs: ['FOO', 'BAR'], + cache: true, + }, + }, + }, + }", + ); + assert_json( + &result, + "run", + serde_json::json!({ + "tasks": { + "hello": { + "command": "node hello.mjs", + "envs": ["FOO", "BAR"], + "cache": true, + } + } + }), + ); + } + + #[test] + fn non_static_method_call_in_nested_value() { + let result = parse( + r"export default { + run: { + tasks: { + 'build:src': { + command: ['cmd1', 'cmd2'].join(' && '), + }, + }, + }, + lint: { plugins: ['a'] }, + }", + ); + // `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 { + run: { + cacheScripts: true, + }, + }", + ); + assert_json(&result, "run", serde_json::json!({ "cacheScripts": true })); + } +} diff --git a/ecosystem-ci/patch-project.ts b/ecosystem-ci/patch-project.ts index 7b3350c666..1970c2f283 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'; @@ -44,3 +45,33 @@ 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. +// 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)) { + 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 { + // 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( + jsPath, + `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 +} 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/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..d2bdf92bfe 100644 --- a/packages/cli/binding/index.d.cts +++ b/packages/cli/binding/index.d.cts @@ -336,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; diff --git a/packages/cli/binding/src/cli.rs b/packages/cli/binding/src/cli.rs index 3cfd6f7995..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,34 @@ 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") { + Some(vite_static_config::FieldValue::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::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", + 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 let package_path_str = package_path .as_path() .to_str() @@ -675,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, @@ -716,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, @@ -839,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..fe7a74f6f0 100644 --- a/packages/cli/binding/src/lib.rs +++ b/packages/cli/binding/src/lib.rs @@ -28,10 +28,28 @@ 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. +/// 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() { - crate::cli::init_tracing(); + 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. @@ -75,6 +93,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 +116,7 @@ fn create_vite_config_resolver( Arc::new(move |package_path: String| { let tsf = tsf.clone(); Box::pin(async move { + tracing::debug!("js_resolve_vite_config: start"); let promise: Promise = tsf .call_async(Ok(package_path)) .await @@ -118,6 +138,7 @@ fn create_vite_config_resolver( /// and process JavaScript callbacks (via ThreadsafeFunction). #[napi] pub async fn run(options: CliOptions) -> Result { + 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 d3b633dbdb..814bf717ab 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -10,7 +10,9 @@ * If no local installation is found, this global dist/bin.js is used as fallback. */ -import { run } from '../binding/index.js'; +const jsStartTime = performance.now(); + +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'; @@ -43,6 +45,13 @@ if (command === 'create') { await import('./global/version.js'); } 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.error( + `[vite-plus] process uptime: ${processUptime}ms, JS module load: ${jsModuleLoad}ms`, + ); + } run({ lint, pack, @@ -54,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); }); } diff --git a/performance.md b/performance.md new file mode 100644 index 0000000000..03483df7a1 --- /dev/null +++ b/performance.md @@ -0,0 +1,458 @@ +# vite-plus Performance Analysis + +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) + +## Architecture Overview + +A `vp run` command invocation traverses these layers: + +``` +User runs `vp run lint:check` + | + +- [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) + | + +- [Phase 2] Node.js startup + NAPI loading ~3.7ms + | +- bin.ts entry -> import NAPI binding -> call run() + | + +- [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 + | + +- [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 + +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 | + +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. + +### Config Loading Patterns + +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) | + +Key observations: + +- **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. + +### 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 | +| 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 + +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,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 | + +Node.js startup + ES module loading + NAPI binding initialization takes **~3.7ms**. + +## Phase 3: Rust Core via NAPI (vite-task) + +### Detailed Timeline (frm-stack `vp run lint:check`, first run) + +From Chrome trace, all times in us from process start: + +``` + ~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 +``` + +**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 | 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 | 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: + +``` + 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.1ms**. Non-test commands (lint, fmt, typecheck) skip this callback and resolve directly, taking only ~0.03ms. + +### rollipop: Multi-Spawn Execution + +Some commands spawn multiple child processes sequentially (topological order from `dependsOn`): + +``` +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) +``` + +### vitepress: Build Pipeline + +The `vp run build` command spawns 3 sequential phases: + +``` +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) +``` + +## Phase 4: Child Process Execution + +Wall-clock timestamps from CI output logs. The `process uptime` value shows Node.js startup time (consistent ~33-55ms across all projects). + +### 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 | + +Node.js startup is consistently **32-55ms** across all projects. + +## Key Findings + +### 1. Cache hits save 50–99% of execution time + +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. + +### 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 +- Complex projects (tanstack-start, dify): ~1,300ms + +Config loading is not cached between `vp` invocations — every command re-resolves all configs from JavaScript. + +### 3. Cache fingerprinting overhead is negligible + +`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. + +### 4. Within-run deduplication works + +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. + +### 5. Empty-path fingerprinting reduces cache hit rate + +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. + +## Summary of Bottlenecks + +| 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): ~977ms total, dominated by one "monster" config (~763ms) +- Distinct-dependency monorepos (rollipop, 6 packages): ~644ms, each package importing different heavy dependencies (100-155ms each) + +## Known Issues + +### 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 + +### Empty-path fingerprinting causes spurious cache misses + +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 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. + +## 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 | + +Enabled via: `VITE_LOG=debug VITE_LOG_OUTPUT=chrome-json VITE_LOG_OUTPUT_DIR=` + +## Methodology + +- **Tracing**: Rust `tracing` crate with `tracing-chrome` subscriber (Chrome DevTools JSON format) +- **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 +- **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`