diff --git a/.typos.toml b/.typos.toml index c97dbfde..f20f6f8b 100644 --- a/.typos.toml +++ b/.typos.toml @@ -9,4 +9,6 @@ extend-exclude = [ # Intentional typos for testing fuzzy matching and "did you mean" suggestions "crates/vite_select/src/fuzzy.rs", "crates/vite_task_bin/tests/e2e_snapshots/fixtures/task_select", + # pnpm patch files — hunk context includes third-party code we don't own + "patches", ] diff --git a/CLAUDE.md b/CLAUDE.md index 3abd2b85..0ce45b23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,6 +148,10 @@ All code must work on both Unix and Windows without platform skipping: - Platform differences should be handled gracefully, not skipped - After major changes to `fspy*` or platform-specific crates, run `just lint-linux` and `just lint-windows` +## New Crates and Packages + +When creating a new Rust crate or npm package, add a concise `README.md` stating its goal in one or two sentences. Do not include implementation details, API docs, or links to other docs — those belong in source comments or the design docs. + ## Changelog When a change is user-facing (new feature, changed behavior, bug fix, removal, or perf improvement), run `/update-changelog` to add an entry to `CHANGELOG.md`. Do not add entries for internal refactors, CI, dep bumps, test fixes, or docs changes. diff --git a/Cargo.lock b/Cargo.lock index c10de4e0..19810d54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,7 +430,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -557,6 +557,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "copy_dir" version = "0.1.3" @@ -703,8 +712,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" dependencies = [ - "ctor-proc-macro", - "dtor", + "ctor-proc-macro 0.0.7", + "dtor 0.1.1", +] + +[[package]] +name = "ctor" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95d0d11eb38e7642efca359c3cf6eb7b2e528182d09110165de70192b0352775" +dependencies = [ + "ctor-proc-macro 0.0.12", + "dtor 0.7.0", ] [[package]] @@ -713,6 +732,12 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctor-proc-macro" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ab264ea985f1bd27887d7b21ea2bb046728e05d11909ca138d700c494730db" + [[package]] name = "ctrlc" version = "3.5.2" @@ -858,7 +883,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version 0.4.1", @@ -937,6 +962,12 @@ dependencies = [ "objc2", ] +[[package]] +name = "doctest-file" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + [[package]] name = "document-features" version = "0.2.12" @@ -958,7 +989,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" dependencies = [ - "dtor-proc-macro", + "dtor-proc-macro 0.0.6", +] + +[[package]] +name = "dtor" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f72721db8027a4e96dd6fb50d2a1d32259c9d3da1b63dee612ccd981e14293" +dependencies = [ + "dtor-proc-macro 0.0.12", ] [[package]] @@ -967,6 +1007,12 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +[[package]] +name = "dtor-proc-macro" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c98b077c7463d01d22dde8a24378ddf1ca7263dc687cffbed38819ea6c21131" + [[package]] name = "either" version = "1.15.0" @@ -1161,7 +1207,7 @@ dependencies = [ "bstr", "bumpalo", "csv-async", - "ctor", + "ctor 0.6.3", "derive_more", "flate2", "fspy_detours_sys", @@ -1220,7 +1266,7 @@ version = "0.0.0" dependencies = [ "anyhow", "bstr", - "ctor", + "ctor 0.6.3", "fspy_shared", "fspy_shared_unix", "libc", @@ -1271,7 +1317,7 @@ dependencies = [ "bitflags 2.11.1", "bstr", "bytemuck", - "ctor", + "ctor 0.6.3", "native_str", "os_str_bytes", "rustc-hash", @@ -1587,6 +1633,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "interprocess" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be5e5c847dbdb44564bd85294740d031f4f8aeb3464e5375ef7141f7538db69" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -1743,6 +1804,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.16" @@ -1955,6 +2026,63 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "napi" +version = "3.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa73b028610e2b26e9e40bd2c8ff8a98e6d7ed5d67d89ebf4bfd2f992616b024" +dependencies = [ + "bitflags 2.11.1", + "ctor 0.10.0", + "futures", + "napi-build", + "napi-sys", + "nohash-hasher", + "rustc-hash", +] + +[[package]] +name = "napi-build" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" + +[[package]] +name = "napi-derive" +version = "3.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7430702d3cc05cf55f0a2c9e41d991c3b7a53f91e6146a8f282b1bfc7f3fd133" +dependencies = [ + "convert_case 0.11.0", + "ctor 0.10.0", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "napi-derive-backend" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ca5a083f2c9b49a0c7d33ec75c083498849c6fcc46f5497317faa39ea77f5d5" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "semver 1.0.28", + "syn 2.0.117", +] + +[[package]] +name = "napi-sys" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eb602b84d7c1edae45e50bbf1374696548f36ae179dfa667f577e384bb90c2b" +dependencies = [ + "libloading 0.9.0", +] + [[package]] name = "native_str" version = "0.0.0" @@ -2027,6 +2155,12 @@ dependencies = [ "libc", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -2623,7 +2757,7 @@ name = "pty_terminal" version = "0.0.0" dependencies = [ "anyhow", - "ctor", + "ctor 0.6.3", "ctrlc", "nix 0.30.1", "ntest", @@ -2640,7 +2774,7 @@ version = "0.0.0" dependencies = [ "anyhow", "crossterm", - "ctor", + "ctor 0.6.3", "ntest", "portable-pty", "pty_terminal", @@ -2837,6 +2971,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3303,7 +3443,7 @@ name = "subprocess_test" version = "0.0.0" dependencies = [ "base64", - "ctor", + "ctor 0.6.3", "fspy", "portable-pty", "rustc-hash", @@ -3972,6 +4112,8 @@ dependencies = [ "derive_more", "fspy", "futures-util", + "materialized_artifact", + "materialized_artifact_build", "nix 0.30.1", "once_cell", "owo-colors", @@ -3993,8 +4135,11 @@ dependencies = [ "vite_path", "vite_select", "vite_str", + "vite_task_client_napi", "vite_task_graph", + "vite_task_ipc_shared", "vite_task_plan", + "vite_task_server", "vite_workspace", "wax", "winapi", @@ -4035,6 +4180,28 @@ dependencies = [ "which", ] +[[package]] +name = "vite_task_client" +version = "0.0.0" +dependencies = [ + "interprocess", + "native_str", + "vite_path", + "vite_task_ipc_shared", + "wincode", +] + +[[package]] +name = "vite_task_client_napi" +version = "0.1.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "vite_str", + "vite_task_client", +] + [[package]] name = "vite_task_graph" version = "0.1.0" @@ -4058,6 +4225,14 @@ dependencies = [ "wincode", ] +[[package]] +name = "vite_task_ipc_shared" +version = "0.0.0" +dependencies = [ + "native_str", + "wincode", +] + [[package]] name = "vite_task_plan" version = "0.1.0" @@ -4095,6 +4270,26 @@ dependencies = [ "wincode", ] +[[package]] +name = "vite_task_server" +version = "0.0.0" +dependencies = [ + "futures", + "interprocess", + "native_str", + "rustc-hash", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "vite_path", + "vite_task_client", + "vite_task_ipc_shared", + "wincode", +] + [[package]] name = "vite_tui" version = "0.0.0" @@ -4474,13 +4669,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -4492,6 +4696,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -4499,16 +4719,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", + "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm", + "windows_x86_64_gnullvm 0.53.1", "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" @@ -4521,6 +4747,12 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17cffbe740121affb56fad0fc0e421804adf0ae00891205213b5cecd30db881d" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" @@ -4533,12 +4765,24 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2564fde759adb79129d9b4f54be42b32c89970c18ebf93124ca8870a498688ed" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" @@ -4551,6 +4795,12 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cd9d32ba70453522332c14d38814bceeb747d80b3958676007acadd7e166956" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" @@ -4563,12 +4813,24 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfce6deae227ee8d356d19effc141a509cc503dfd1f850622ec4b0f84428e1f4" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" @@ -4581,6 +4843,12 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d19538ccc21819d01deaf88d6a17eae6596a12e9aafdbb97916fb49896d89de9" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" diff --git a/Cargo.toml b/Cargo.toml index 51034947..4f925655 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,7 @@ fspy_shared = { path = "crates/fspy_shared" } fspy_shared_unix = { path = "crates/fspy_shared_unix" } futures = "0.3.31" futures-util = "0.3.31" +interprocess = "2" jsonc-parser = { version = "0.29.0", features = ["serde"] } libc = "0.2.172" libtest-mimic = "0.8.2" @@ -158,8 +159,15 @@ widestring = "1.2.0" winapi = "0.3.9" winsafe = { version = "0.0.24", features = ["kernel"] } xxhash-rust = { version = "0.8.15", features = ["const_xxh3"] } +napi = "3" +napi-build = "2" +napi-derive = "3" ntest = "0.9.5" terminal_size = "0.4" +vite_task_client = { path = "crates/vite_task_client" } +vite_task_client_napi = { path = "crates/vite_task_client_napi", artifact = "cdylib", target = "target" } +vite_task_ipc_shared = { path = "crates/vite_task_ipc_shared" } +vite_task_server = { path = "crates/vite_task_server" } zstd = "0.13" [workspace.metadata.cargo-shear] @@ -167,6 +175,7 @@ ignored = [ # These are artifact dependencies. They are not directly `use`d in Rust code. "fspy_preload_unix", "fspy_preload_windows", + "vite_task_client_napi", ] [profile.dev] diff --git a/crates/fspy/build.rs b/crates/fspy/build.rs index b6659f15..6c35b8a9 100644 --- a/crates/fspy/build.rs +++ b/crates/fspy/build.rs @@ -158,7 +158,6 @@ fn register_preload_cdylib() -> anyhow::Result<()> { } fn main() -> anyhow::Result<()> { - println!("cargo:rerun-if-changed=build.rs"); let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap()); fetch_macos_binaries(&out_dir).context("Failed to fetch macOS binaries")?; register_preload_cdylib().context("Failed to register preload cdylib")?; diff --git a/crates/vite_task/Cargo.toml b/crates/vite_task/Cargo.toml index f57298c5..22021fa9 100644 --- a/crates/vite_task/Cargo.toml +++ b/crates/vite_task/Cargo.toml @@ -35,16 +35,27 @@ tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "io-util", tokio-util = { workspace = true } tracing = { workspace = true } twox-hash = { workspace = true } +materialized_artifact = { workspace = true } uuid = { workspace = true, features = ["v4"] } vite_path = { workspace = true } vite_select = { workspace = true } vite_str = { workspace = true } vite_task_graph = { workspace = true } +vite_task_ipc_shared = { workspace = true } vite_task_plan = { workspace = true } +vite_task_server = { workspace = true } vite_workspace = { workspace = true } wax = { workspace = true } zstd = { workspace = true } +# Artifact build-deps must be unconditional: cargo's resolver panics when +# `artifact = "cdylib"` deps live under a `[target.cfg.build-dependencies]` +# block on cross-compile. +[build-dependencies] +anyhow = { workspace = true } +materialized_artifact_build = { workspace = true } +vite_task_client_napi = { workspace = true } + [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/vite_task/build.rs b/crates/vite_task/build.rs new file mode 100644 index 00000000..5ca74cc1 --- /dev/null +++ b/crates/vite_task/build.rs @@ -0,0 +1,17 @@ +#![expect( + clippy::disallowed_types, + clippy::disallowed_macros, + reason = "build.rs interfaces with std::path and cargo's env-var API" +)] + +use std::{env, path::Path}; + +use anyhow::Context; + +fn main() -> anyhow::Result<()> { + let env_name = "CARGO_CDYLIB_FILE_VITE_TASK_CLIENT_NAPI"; + println!("cargo:rerun-if-env-changed={env_name}"); + let dylib_path = env::var_os(env_name).with_context(|| format!("{env_name} not set"))?; + materialized_artifact_build::register("vite_task_client_napi", Path::new(&dylib_path)); + Ok(()) +} diff --git a/crates/vite_task/src/lib.rs b/crates/vite_task/src/lib.rs index c1659756..d2235252 100644 --- a/crates/vite_task/src/lib.rs +++ b/crates/vite_task/src/lib.rs @@ -1,5 +1,6 @@ mod cli; mod collections; +mod napi_client; pub mod session; // Public exports for vite_task_bin diff --git a/crates/vite_task/src/napi_client.rs b/crates/vite_task/src/napi_client.rs new file mode 100644 index 00000000..dce8328d --- /dev/null +++ b/crates/vite_task/src/napi_client.rs @@ -0,0 +1,32 @@ +//! The `vite_task_client_napi` cdylib is embedded into the `vp` binary and +//! materialized to disk on first use so tools can `require()` it at runtime. + +use std::{env, fs, sync::LazyLock}; + +use materialized_artifact::artifact; +use vite_path::{AbsolutePath, AbsolutePathBuf}; + +/// Path to the materialized `vite_task_client_napi` `.node` addon. +/// +/// The file is written to a process-wide temp directory on first call and +/// reused on every subsequent call (content-addressed filename; no re-writes). +/// +/// # Panics +/// +/// Panics if the materialization fails on first call — this mirrors fspy's +/// `SPY_IMPL` and the same reasoning applies: if we can't write into the +/// system temp dir, the runner can't run tasks anyway. +#[must_use] +pub fn napi_client_path() -> &'static AbsolutePath { + static PATH: LazyLock = LazyLock::new(|| { + let dir = env::temp_dir().join("vite_task_client_napi"); + let _ = fs::create_dir(&dir); + let path = artifact!("vite_task_client_napi") + .materialize() + .suffix(".node") + .at(&dir) + .expect("materialize vite_task_client_napi"); + AbsolutePathBuf::new(path).expect("system temp dir yields an absolute path") + }); + PATH.as_absolute_path() +} diff --git a/crates/vite_task/src/session/cache/display.rs b/crates/vite_task/src/session/cache/display.rs index 9dbcabd5..bb5b4556 100644 --- a/crates/vite_task/src/session/cache/display.rs +++ b/crates/vite_task/src/session/cache/display.rs @@ -179,6 +179,11 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option { let desc = format_input_change_str(*kind, path.as_str()); return Some(vite_str::format!("○ cache miss: {desc}, executing")); } + FingerprintMismatch::TrackedEnvChanged { name, .. } => { + return Some(vite_str::format!( + "○ cache miss: tracked env '{name}' changed, executing" + )); + } }; Some(vite_str::format!("○ cache miss: {reason}, executing")) } diff --git a/crates/vite_task/src/session/cache/mod.rs b/crates/vite_task/src/session/cache/mod.rs index e37c7ecf..25834208 100644 --- a/crates/vite_task/src/session/cache/mod.rs +++ b/crates/vite_task/src/session/cache/mod.rs @@ -158,6 +158,12 @@ pub enum FingerprintMismatch { kind: InputChangeKind, path: RelativePathBuf, }, + /// A tool-tracked env var changed between runs. + TrackedEnvChanged { + name: Str, + old: Option, + new: Option, + }, } impl Display for FingerprintMismatch { @@ -175,6 +181,18 @@ impl Display for FingerprintMismatch { Self::InputChanged { kind, path } => { write!(f, "{}", display::format_input_change_str(*kind, path.as_str())) } + Self::TrackedEnvChanged { name, old, new } => { + write!(f, "tracked env {name}: ")?; + match old { + Some(value) => write!(f, "{:?}", value.as_str())?, + None => write!(f, "(unset)")?, + } + write!(f, " → ")?; + match new { + Some(value) => write!(f, "{:?}", value.as_str()), + None => write!(f, "(unset)"), + } + } } } } @@ -215,16 +233,16 @@ impl ExecutionCache { "CREATE TABLE task_fingerprints (key BLOB PRIMARY KEY, value BLOB);", (), )?; - conn.execute("PRAGMA user_version = 12", ())?; + conn.execute("PRAGMA user_version = 13", ())?; } - 1..=11 => { + 1..=12 => { // old internal db version. reset conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, true)?; conn.execute("VACUUM", ())?; conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?; } - 12 => break, // current version - 13.. => { + 13 => break, // current version + 14.. => { return Err(anyhow::anyhow!("Unrecognized database version: {user_version}")); } } @@ -262,11 +280,20 @@ impl ExecutionCache { return Ok(Err(CacheMiss::FingerprintMismatch(mismatch))); } - // Validate post-run fingerprint (inferred inputs from fspy) - if let Some((kind, path)) = cache_value.post_run_fingerprint.validate(workspace_root)? { - return Ok(Err(CacheMiss::FingerprintMismatch( - FingerprintMismatch::InputChanged { kind, path }, - ))); + // Validate post-run fingerprint (inferred inputs + tracked envs) + if let Some(mismatch) = cache_value.post_run_fingerprint.validate(workspace_root)? { + let fingerprint_mismatch = match mismatch { + crate::session::execute::fingerprint::PostRunMismatch::InputChanged { + kind, + path, + } => FingerprintMismatch::InputChanged { kind, path }, + crate::session::execute::fingerprint::PostRunMismatch::TrackedEnvChanged { + name, + old, + new, + } => FingerprintMismatch::TrackedEnvChanged { name, old, new }, + }; + return Ok(Err(CacheMiss::FingerprintMismatch(fingerprint_mismatch))); } // Associate the execution key to the cache entry key if not already, // so that next time we can find it and report what changed diff --git a/crates/vite_task/src/session/event.rs b/crates/vite_task/src/session/event.rs index 57aec779..487a20d0 100644 --- a/crates/vite_task/src/session/event.rs +++ b/crates/vite_task/src/session/event.rs @@ -1,6 +1,7 @@ use std::{process::ExitStatus, time::Duration}; use vite_path::RelativePathBuf; +use vite_task_server::Error as IpcServerError; use super::cache::CacheMiss; @@ -43,6 +44,12 @@ pub enum ExecutionError { /// Creating the post-run fingerprint failed after successful execution. #[error("Failed to create post-run fingerprint")] PostRunFingerprint(#[source] anyhow::Error), + + /// The runner-aware IPC server failed to bind for this task. Reported + /// instead of silently degrading so that `{ auto: true }` inputs stay + /// observable end-to-end. + #[error("Failed to start runner IPC server")] + IpcServerBind(#[source] std::io::Error), } #[derive(Debug, Clone)] @@ -72,6 +79,14 @@ pub enum CacheNotUpdatedReason { /// First path that was both read and written during execution. path: RelativePathBuf, }, + /// The runner's IPC server failed during execution, so the collected + /// reports may be incomplete. Caching such a run would risk stale + /// inputs/outputs on the next hit. Carries the underlying error for + /// user-facing reporting. + IpcServerError(IpcServerError), + /// A runner-aware tool explicitly requested that this run not be cached + /// (e.g. vite dev-server, a watch task). + ToolRequested, } #[derive(Debug)] diff --git a/crates/vite_task/src/session/execute/fingerprint.rs b/crates/vite_task/src/session/execute/fingerprint.rs index d73a7f61..0f9cfaf0 100644 --- a/crates/vite_task/src/session/execute/fingerprint.rs +++ b/crates/vite_task/src/session/execute/fingerprint.rs @@ -26,6 +26,20 @@ pub struct PostRunFingerprint { /// Paths inferred from fspy during execution with their content fingerprints. /// Only populated when `input_config.includes_auto` is true. pub inferred_inputs: HashMap, + + /// Env vars observed via runner-aware IPC with `tracked: true`. Key is the + /// env name; value is the env value at execution time (or `None` if unset). + /// Validated at cache lookup by comparing against the current parent env. + pub tracked_envs: BTreeMap>, +} + +/// A mismatch between the stored post-run fingerprint and the current state. +#[derive(Debug, Clone)] +pub enum PostRunMismatch { + /// An inferred input file or directory changed. + InputChanged { kind: InputChangeKind, path: RelativePathBuf }, + /// A tool-tracked env var changed value (or was added/removed). + TrackedEnvChanged { name: Str, old: Option, new: Option }, } /// Fingerprint for a single path (file or directory) @@ -64,11 +78,13 @@ impl PostRunFingerprint { /// * `inferred_path_reads` - Map of paths that were read during execution (from fspy) /// * `base_dir` - Workspace root for resolving relative paths /// * `globbed_inputs` - Prerun glob fingerprint; paths here are skipped + /// * `tracked_envs` - Tool-requested env vars (name → value), validated on lookup #[tracing::instrument(level = "debug", skip_all, name = "create_post_run_fingerprint")] pub fn create( inferred_path_reads: &HashMap, base_dir: &AbsolutePath, globbed_inputs: &BTreeMap, + tracked_envs: BTreeMap>, ) -> anyhow::Result { let inferred_inputs = inferred_path_reads .par_iter() @@ -80,16 +96,13 @@ impl PostRunFingerprint { }) .collect::>>()?; - Ok(Self { inferred_inputs }) + Ok(Self { inferred_inputs, tracked_envs }) } - /// Validates the fingerprint against current filesystem state. - /// Returns `Some((kind, path))` if an input changed, `None` if all valid. + /// Validates the fingerprint against current filesystem and env state. + /// Returns `Some(mismatch)` on the first divergence, `None` if all valid. #[tracing::instrument(level = "debug", skip_all, name = "validate_post_run_fingerprint")] - pub fn validate( - &self, - base_dir: &AbsolutePath, - ) -> anyhow::Result> { + pub fn validate(&self, base_dir: &AbsolutePath) -> anyhow::Result> { let input_mismatch = self.inferred_inputs.par_iter().find_map_any( |(input_relative_path, path_fingerprint)| { let input_full_path = Arc::::from(base_dir.join(input_relative_path)); @@ -115,11 +128,27 @@ impl PostRunFingerprint { } else { input_relative_path.clone() }; - Some(Ok((kind, path))) + Some(Ok(PostRunMismatch::InputChanged { kind, path })) } }, ); - input_mismatch.transpose() + if let Some(result) = input_mismatch { + return result.map(Some); + } + + // Validate tracked envs against the current parent env. + for (name, stored_value) in &self.tracked_envs { + let current_value = + std::env::var_os(name.as_str()).and_then(|v| v.to_str().map(Str::from)); + if current_value.as_ref() != stored_value.as_ref() { + return Ok(Some(PostRunMismatch::TrackedEnvChanged { + name: name.clone(), + old: stored_value.clone(), + new: current_value, + })); + } + } + Ok(None) } } diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 95ee16a5..30079799 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -7,7 +7,14 @@ pub mod tracked_accesses; #[cfg(windows)] mod win_job; -use std::{cell::RefCell, collections::BTreeMap, io::Write as _, sync::Arc, time::Instant}; +use std::{ + cell::RefCell, + collections::BTreeMap, + ffi::{OsStr, OsString}, + io::Write as _, + sync::Arc, + time::Instant, +}; use futures_util::{FutureExt, StreamExt, future::LocalBoxFuture, stream::FuturesUnordered}; use petgraph::Direction; @@ -16,10 +23,12 @@ use tokio::sync::Semaphore; use tokio_util::sync::CancellationToken; use vite_path::{AbsolutePath, RelativePathBuf}; use vite_str::Str; +use vite_task_ipc_shared::NODE_CLIENT_PATH_ENV_NAME; use vite_task_plan::{ ExecutionGraph, ExecutionItemDisplay, ExecutionItemKind, LeafExecutionKind, SpawnExecution, cache_metadata::CacheMetadata, execution_graph::ExecutionNodeIndex, }; +use vite_task_server::{Recorder, Reports, ServerHandle, StopAccepting, serve}; use wax::Program as _; use self::{ @@ -229,10 +238,6 @@ impl ExecutionContext<'_> { false } LeafExecutionKind::Spawn(spawn_execution) => { - #[expect( - clippy::large_futures, - reason = "spawn execution with cache management creates large futures" - )] let outcome = execute_spawn( leaf_reporter, spawn_execution, @@ -290,11 +295,20 @@ struct CacheState<'a> { /// Captured stdout/stderr for cache replay. Written in place during drain; /// always present (possibly empty) once we reach the cache-update phase. std_outputs: Vec, - /// Fspy tracking status and pre-resolved input negative globs. - /// `None` means fspy tracking is off for this task. `Some(globs)` means - /// fspy is on; the globs are used to filter inferred input reads (not - /// writes — output negatives are applied separately during archiving). - fspy_input_negatives: Option>>, + /// `Some` iff auto-input tracking is on (`includes_auto` + successful IPC + /// bind). Bundles fspy's negative globs with the per-task IPC server that + /// runner-aware tools talk to. Parts are borrowed in place during the + /// wait/join; the struct is never moved out. + tracking: Option, +} + +/// Per-task tracking: fspy globs + IPC server handle. Lifetime-tied to a +/// single `execute_spawn` call. +struct Tracking { + input_negative_globs: Vec>, + ipc_envs: Vec<(&'static OsStr, OsString)>, + ipc_server_fut: LocalBoxFuture<'static, Result>, + stop_accepting: StopAccepting, } /// Execute a spawned process with cache-aware lifecycle. @@ -430,19 +444,19 @@ pub async fn execute_spawn( // ───────────────────────────────────────────────────────────────────── let mut mode: ExecutionMode<'_> = match cache_metadata { Some(metadata) => { - let fspy = + let tracking = if metadata.input_config.includes_auto || metadata.output_config.includes_auto { // Resolve input negative globs for fspy path filtering - // (already workspace-root-relative). Output negatives are applied - // later in `collect_and_archive_outputs`. - match metadata + // (already workspace-root-relative). Output negatives are + // applied later in `collect_and_archive_outputs`. + let negatives = match metadata .input_config .negative_globs .iter() .map(|p| Ok(wax::Glob::new(p.as_str())?.into_owned())) .collect::>>() { - Ok(negs) => Some(negs), + Ok(negs) => negs, Err(err) => { leaf_reporter.finish( None, @@ -451,18 +465,42 @@ pub async fn execute_spawn( ); return SpawnOutcome::Failed; } + }; + // fspy + IPC are bundled. If binding the IPC server fails + // we abort the execution — tools that rely on IPC would + // otherwise silently diverge from the cache. + // + // The IPC `getEnv` endpoint serves values from the runner's + // own parent env (not the task's filtered `all_envs`), so a + // tool can ask for vars the user never declared and have + // them fingerprinted via the tool's `tracked: true` flag. + let env_map: FxHashMap, Arc> = std::env::vars_os() + .map(|(k, v)| { + (Arc::::from(k.as_os_str()), Arc::::from(v.as_os_str())) + }) + .collect(); + match serve(Recorder::new(env_map)) { + Ok((envs, ServerHandle { driver, stop_accepting })) => Some(Tracking { + input_negative_globs: negatives, + ipc_envs: envs.collect(), + ipc_server_fut: driver, + stop_accepting, + }), + Err(err) => { + leaf_reporter.finish( + None, + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), + Some(ExecutionError::IpcServerBind(err)), + ); + return SpawnOutcome::Failed; + } } } else { None }; ExecutionMode::Cached { pipe_writers: stdio_config.writers, - state: CacheState { - metadata, - globbed_inputs, - std_outputs: Vec::new(), - fspy_input_negatives: fspy, - }, + state: CacheState { metadata, globbed_inputs, std_outputs: Vec::new(), tracking }, } } None => ExecutionMode::Uncached { @@ -473,13 +511,26 @@ pub async fn execute_spawn( // 5. Derive the arguments for `spawn()` from the mode without consuming it. let (spawn_stdio, fspy_enabled) = match &mode { - ExecutionMode::Cached { state, .. } => { - (SpawnStdio::Piped, state.fspy_input_negatives.is_some()) - } + ExecutionMode::Cached { state, .. } => (SpawnStdio::Piped, state.tracking.is_some()), ExecutionMode::Uncached { pipe_writers: Some(_) } => (SpawnStdio::Piped, false), ExecutionMode::Uncached { pipe_writers: None } => (SpawnStdio::Inherited, false), }; + // Build the extra envs to inject: IPC connection info + napi addon path. + // Empty when tracking is off. + let extra_envs: Vec<(&OsStr, &OsStr)> = match &mode { + ExecutionMode::Cached { state: CacheState { tracking: Some(t), .. }, .. } => { + let mut envs: Vec<(&OsStr, &OsStr)> = + t.ipc_envs.iter().map(|(k, v)| (*k as &OsStr, v.as_os_str())).collect(); + envs.push(( + OsStr::new(NODE_CLIENT_PATH_ENV_NAME), + crate::napi_client::napi_client_path().as_path().as_os_str(), + )); + envs + } + _ => Vec::new(), + }; + // Measure end-to-end duration here — spawn() no longer tracks time. let start = Instant::now(); @@ -490,6 +541,7 @@ pub async fn execute_spawn( fspy_enabled, spawn_stdio, fast_fail_token.clone(), + extra_envs, ) .await { @@ -504,64 +556,153 @@ pub async fn execute_spawn( } }; - // 7. Build `PipeSinks` by borrowing into `mode`. The drain fills - // `state.std_outputs` in place (via the borrow inside `capture`), so no - // post-drain transfer is needed. `sinks` is `None` only in the - // inherited-uncached case, where there are no pipes to drain. - let sinks: Option> = match &mut mode { - ExecutionMode::Cached { pipe_writers, state } => Some(PipeSinks { - stdout_writer: &mut pipe_writers.stdout_writer, - stderr_writer: &mut pipe_writers.stderr_writer, - capture: Some(&mut state.std_outputs), - }), - ExecutionMode::Uncached { pipe_writers: Some(pipe_writers) } => Some(PipeSinks { - stdout_writer: &mut pipe_writers.stdout_writer, - stderr_writer: &mut pipe_writers.stderr_writer, - capture: None, - }), - ExecutionMode::Uncached { pipe_writers: None } => None, + // 7. Extract all mode-scoped borrows in one pass: + // - `sinks`: pipe writers + stdout capture slot (writes in place). + // - `stop_accepting` / `driver`: the IPC server's handles, if tracking + // is on. Disjoint field borrows inside the same match arm. + let (sinks, stop_accepting, ipc_server_fut) = match &mut mode { + ExecutionMode::Cached { pipe_writers, state } => { + let sinks = Some(PipeSinks { + stdout_writer: &mut pipe_writers.stdout_writer, + stderr_writer: &mut pipe_writers.stderr_writer, + capture: Some(&mut state.std_outputs), + }); + let (stop_accepting, ipc_server_fut) = match &mut state.tracking { + Some(t) => (Some(&t.stop_accepting), Some(&mut t.ipc_server_fut)), + None => (None, None), + }; + (sinks, stop_accepting, ipc_server_fut) + } + ExecutionMode::Uncached { pipe_writers: Some(pipe_writers) } => ( + Some(PipeSinks { + stdout_writer: &mut pipe_writers.stdout_writer, + stderr_writer: &mut pipe_writers.stderr_writer, + capture: None, + }), + None, + None, + ), + ExecutionMode::Uncached { pipe_writers: None } => (None, None, None), }; - if let Some(sinks) = sinks { - let stdout = child.stdout.take().expect("SpawnStdio::Piped yields a stdout pipe"); - let stderr = child.stderr.take().expect("SpawnStdio::Piped yields a stderr pipe"); - #[expect( - clippy::large_futures, - reason = "pipe_stdio streams child I/O and creates a large future" - )] - let pipe_result = pipe_stdio(stdout, stderr, sinks, fast_fail_token.clone()).await; - if let Err(err) = pipe_result { - // Cancel so `child.wait` kills the child instead of orphaning it. - fast_fail_token.cancel(); - let _ = child.wait.await; - leaf_reporter.finish( - None, - CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), - Some(ExecutionError::Spawn(err.into())), - ); - return SpawnOutcome::Failed; + // 8. Drive pipe_stdio + child.wait concurrently with the IPC driver. + // The driver must be polled during pipe drain — otherwise a tool doing + // a blocking `getEnv` can deadlock: child stalls on IPC, stdout stays + // open, pipe_stdio waits for EOF, driver never runs. `stop_accepting` + // fires after child.wait so the driver drains any in-flight clients. + let child_fast_fail = fast_fail_token.clone(); + let child_work = async move { + let pipe_result: anyhow::Result<()> = if let Some(sinks) = sinks { + let stdout = child.stdout.take().expect("SpawnStdio::Piped yields a stdout pipe"); + let stderr = child.stderr.take().expect("SpawnStdio::Piped yields a stderr pipe"); + #[expect( + clippy::large_futures, + reason = "pipe_stdio streams child I/O and creates a large future" + )] + let r = pipe_stdio(stdout, stderr, sinks, child_fast_fail.clone()).await; + r.map_err(anyhow::Error::from) + } else { + Ok(()) + }; + + let wait_result = match pipe_result { + Ok(()) => child.wait.await.map_err(anyhow::Error::from), + Err(err) => { + // Pipe failed — cancel so `child.wait` kills the child + // instead of orphaning it. Still signal the server so it + // can drain. + child_fast_fail.cancel(); + let _ = child.wait.await; + Err(err) + } + }; + + if let Some(s) = stop_accepting { + s.signal(); } - } + wait_result + }; + + // Box::pin to keep the child-and-pipe stack off the enclosing future: + // pipe_stdio alone makes the combined future large enough to trip + // clippy::large_futures on the non-driver branch. + let child_work = Box::pin(child_work); + // `None` iff tracking was off. Otherwise carries either the collected + // reports (Ok) or the IPC server's error (Err). Consumed below: Ok drops + // unused for step 5 (step 6 will use the reports); Err short-circuits + // cache update to `IpcServerError`. + let mut ipc_server_result: Option> = None; + let wait_result = match ipc_server_fut { + Some(ipc_server_fut) => { + let (wait_result, join_result) = tokio::join!(child_work, ipc_server_fut); + if let Err(e) = &join_result { + tracing::warn!(?e, "IPC server failed; cache will not be updated"); + } + ipc_server_result = Some(join_result.map(Recorder::into_reports)); + wait_result + } + None => child_work.await, + }; - // 8. Wait for exit (handles cancellation internally). - let outcome = match child.wait.await { + let outcome = match wait_result { Ok(outcome) => outcome, Err(err) => { leaf_reporter.finish( None, CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), - Some(ExecutionError::Spawn(err.into())), + Some(ExecutionError::Spawn(err)), ); return SpawnOutcome::Failed; } }; + // Extract reports, or short-circuit when the IPC server failed. An Err + // here means reports may be incomplete: caching this run would risk + // stale inputs/outputs, so skip all cache-related computation entirely. + let reports: Option = match ipc_server_result { + Some(Ok(r)) => { + tracing::debug!(?r, "runner-aware tools reported"); + Some(r) + } + None => None, + Some(Err(err)) => { + leaf_reporter.finish( + Some(outcome.exit_status), + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::IpcServerError(err)), + None, + ); + return SpawnOutcome::Spawned(outcome.exit_status); + } + }; let duration = start.elapsed(); // 9. Cache update (only when we were in `Cached` mode). Errors during cache // update are reported but do not affect the exit status we return. let (cache_update_status, cache_error) = 'cache_update: { if let ExecutionMode::Cached { state, .. } = mode { - let CacheState { metadata, globbed_inputs, std_outputs, fspy_input_negatives } = state; + let CacheState { metadata, globbed_inputs, std_outputs, tracking } = state; + let input_negative_globs = tracking.as_ref().map(|t| t.input_negative_globs.as_slice()); + + // Runner-aware tools can short-circuit caching entirely via + // `disableCache()` (e.g. a dev server with no deterministic output). + if let Some(r) = reports.as_ref() + && r.cache_disabled + { + break 'cache_update ( + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::ToolRequested), + None, + ); + } + + // Tool-reported paths to exclude from auto-tracking. Absolute paths + // are normalized to workspace-relative; anything outside is dropped. + let ignored_input_rels: FxHashSet = reports + .as_ref() + .map(|r| normalize_ignored_paths(&r.ignored_inputs, cache_base_path)) + .unwrap_or_default(); + let ignored_output_rels: FxHashSet = reports + .as_ref() + .map(|r| normalize_ignored_paths(&r.ignored_outputs, cache_base_path)) + .unwrap_or_default(); // Normalize fspy accesses. `Some` iff fspy was enabled at spawn time. // User-configured negatives are applied below, separately for reads @@ -572,20 +713,22 @@ pub async fn execute_spawn( .as_ref() .map(|raw| TrackedPathAccesses::from_raw(raw, cache_base_path)); - // Inferred input reads: gated by `input_config.includes_auto` and - // filtered by input negatives. When input auto is disabled, no reads - // contribute to the fingerprint even if fspy was enabled for output - // tracking. + // Inferred input reads: gated by `input_config.includes_auto`, + // filtered by user-configured negatives, and by tool-reported + // `ignoreInput` paths (directory-aware). When input auto is + // disabled, no reads contribute to the fingerprint even if fspy + // was enabled for output tracking. let empty_reads = HashMap::default(); let inferred_reads: HashMap = if metadata.input_config.includes_auto && let Some(pa) = path_accesses.as_ref() - && let Some(negatives) = fspy_input_negatives.as_deref() + && let Some(negatives) = input_negative_globs { pa.path_reads .iter() .filter(|(path, _)| { !negatives.iter().any(|neg| wax::Program::is_match(neg, path.as_str())) + && !is_ignored(path, &ignored_input_rels) }) .map(|(path, read)| (path.clone(), *read)) .collect() @@ -598,14 +741,16 @@ pub async fn execute_spawn( // Cancelled (Ctrl-C or sibling failure) — result is untrustworthy (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::Cancelled), None) } else if outcome.exit_status.success() { - // Check for read-write overlap: if the task wrote to any file it also - // read (as an inferred input), the inputs were modified during - // execution — don't cache. Reads excluded by input negatives (or - // when input auto is off) don't count. - if let Some(path) = path_accesses - .as_ref() - .and_then(|pa| inferred_reads.keys().find(|p| pa.path_writes.contains(*p))) - { + // Check for read-write overlap: if the task wrote to any file + // it also read (as an inferred input), the inputs were modified + // during execution — don't cache. Reads excluded by negatives + // or by `ignoreInput` don't count; neither do writes excluded + // by `ignoreOutput` (those aren't real outputs). + if let Some(path) = path_accesses.as_ref().and_then(|pa| { + inferred_reads.keys().find(|p| { + pa.path_writes.contains(*p) && !is_ignored(p, &ignored_output_rels) + }) + }) { ( CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified { path: path.clone(), @@ -613,6 +758,14 @@ pub async fn execute_spawn( None, ) } else { + // Collect tool-reported tracked envs for the post-run + // fingerprint. User-declared `env` wins — skip names that + // are already in the spawn fingerprint. + let tracked_envs = reports + .as_ref() + .map(|r| collect_tracked_envs(r, metadata)) + .unwrap_or_default(); + // Execution succeeded — attempt to create fingerprint and update cache. // Paths already in globbed_inputs are skipped: Rule 1 (above) guarantees // no input modification, so the prerun hash is the correct post-exec hash. @@ -620,12 +773,15 @@ pub async fn execute_spawn( &inferred_reads, cache_base_path, &globbed_inputs, + tracked_envs, ) { Ok(post_run_fingerprint) => { - // Collect output files and create archive + // Collect output files and create archive. Tool-reported + // `ignoreOutput` paths are excluded from archiving too. let output_archive = match collect_and_archive_outputs( metadata, path_accesses.as_ref(), + &ignored_output_rels, cache_base_path, cache_dir, ) { @@ -687,29 +843,75 @@ pub async fn execute_spawn( SpawnOutcome::Spawned(outcome.exit_status) } +/// Normalize tool-reported absolute paths to workspace-relative. Paths outside +/// the workspace are dropped — they can't contribute to inputs or outputs. +fn normalize_ignored_paths( + paths: &FxHashSet>, + workspace_root: &AbsolutePath, +) -> FxHashSet { + paths.iter().filter_map(|p| p.strip_prefix(workspace_root).ok().flatten()).collect() +} + +/// Whether `path` is covered by any `ignored` entry. An ignored entry matches +/// itself (exact file) and everything under it (directory subtree). +fn is_ignored(path: &RelativePathBuf, ignored: &FxHashSet) -> bool { + if ignored.is_empty() { + return false; + } + if ignored.contains(path) { + return true; + } + ignored.iter().any(|ig| path.strip_prefix(ig).is_some()) +} + +/// Select tool-reported env records to embed in the post-run fingerprint. +/// Only `tracked: true` records are included, and names that the user already +/// declared as fingerprinted are skipped (their value is already in the cache +/// key via the spawn fingerprint). +fn collect_tracked_envs(reports: &Reports, metadata: &CacheMetadata) -> BTreeMap> { + let fingerprinted = &metadata.spawn_fingerprint.env_fingerprints().fingerprinted_envs; + reports + .env_records + .iter() + .filter(|(_, record)| record.tracked) + .filter_map(|(name, record)| { + let name_str = name.to_str()?; + if fingerprinted.contains_key(name_str) { + return None; + } + let value = record.value.as_ref().and_then(|v| v.to_str().map(Str::from)); + Some((Str::from(name_str), value)) + }) + .collect() +} + /// Collect output files and create a tar.zst archive in the cache directory. /// /// Output files are determined by: /// - fspy-tracked writes (when `output_config.includes_auto` is true) /// - Positive output globs (always, if configured) /// - Filtered by negative output globs +/// - Filtered by tool-reported `ignoreOutput` paths (auto writes only) /// /// Returns `Some(archive_filename)` if files were archived, `None` if no output files. fn collect_and_archive_outputs( cache_metadata: &vite_task_plan::cache_metadata::CacheMetadata, path_accesses: Option<&TrackedPathAccesses>, + ignored_output_rels: &FxHashSet, workspace_root: &AbsolutePath, cache_dir: &AbsolutePath, ) -> anyhow::Result> { let output_config = &cache_metadata.output_config; - // Collect output files from auto-detection (fspy writes) + // Collect output files from auto-detection (fspy writes), excluding + // anything the tool reported via `ignoreOutput`. let mut output_files: FxHashSet = FxHashSet::default(); if output_config.includes_auto && let Some(pa) = path_accesses { - output_files.extend(pa.path_writes.iter().cloned()); + output_files + .extend(pa.path_writes.iter().filter(|p| !is_ignored(p, ignored_output_rels)).cloned()); } // Collect output files from positive globs diff --git a/crates/vite_task/src/session/execute/spawn.rs b/crates/vite_task/src/session/execute/spawn.rs index 0f29926b..e3381624 100644 --- a/crates/vite_task/src/session/execute/spawn.rs +++ b/crates/vite_task/src/session/execute/spawn.rs @@ -4,7 +4,7 @@ //! cancellation-aware `wait` future. Draining the pipes is [`super::pipe`]'s //! job; normalizing fspy path accesses is [`super::tracked_accesses`]'s. -use std::{io, process::Stdio}; +use std::{ffi::OsStr, io, process::Stdio}; use fspy::PathAccessIterable; use futures_util::{FutureExt, future::LocalBoxFuture}; @@ -45,18 +45,29 @@ pub struct ChildOutcome { /// Spawn a command with the requested fspy and stdio configuration. /// +/// `extra_envs` are applied **after** `cmd.all_envs`, so runtime-injected +/// entries (e.g. the runner's IPC name + napi addon path) override any +/// same-named key from the plan. +/// /// Cancellation is unified: whether fspy is enabled or not, the returned `wait` /// future observes `cancellation_token` and kills the child before resolving. #[tracing::instrument(level = "debug", skip_all)] -pub async fn spawn( +pub async fn spawn( cmd: &SpawnCommand, fspy: bool, stdio: SpawnStdio, cancellation_token: CancellationToken, -) -> anyhow::Result { + extra_envs: E, +) -> anyhow::Result +where + E: IntoIterator, + K: AsRef, + V: AsRef, +{ let mut fspy_cmd = fspy::Command::new(cmd.program_path.as_path()); fspy_cmd.args(cmd.args.iter().map(vite_str::Str::as_str)); fspy_cmd.envs(cmd.all_envs.iter()); + fspy_cmd.envs(extra_envs); fspy_cmd.current_dir(&*cmd.cwd); match stdio { diff --git a/crates/vite_task/src/session/mod.rs b/crates/vite_task/src/session/mod.rs index 8b97fb6b..06d767b7 100644 --- a/crates/vite_task/src/session/mod.rs +++ b/crates/vite_task/src/session/mod.rs @@ -643,10 +643,6 @@ impl<'a> Session<'a> { /// /// Returns an error if planning or execution of the synthetic command fails. #[tracing::instrument(level = "debug", skip_all)] - #[expect( - clippy::large_futures, - reason = "execution plan future is large but only awaited once" - )] pub async fn execute_synthetic( &self, synthetic_plan_request: SyntheticPlanRequest, diff --git a/crates/vite_task/src/session/reporter/summary.rs b/crates/vite_task/src/session/reporter/summary.rs index 3f4124a3..fc6f6110 100644 --- a/crates/vite_task/src/session/reporter/summary.rs +++ b/crates/vite_task/src/session/reporter/summary.rs @@ -102,6 +102,12 @@ pub enum SpawnOutcome { /// First path that was both read and written, causing cache to be skipped. /// Only set when fspy detected a read-write overlap. input_modified_path: Option, + /// Rendered message of the IPC server error that caused the cache to + /// be skipped, if any. + ipc_server_error: Option, + /// Set when a runner-aware tool called `disableCache()`, skipping + /// cache update. + tool_disabled_cache: bool, }, /// Process exited with non-zero status. @@ -124,6 +130,8 @@ pub enum SavedCacheMissReason { ConfigChanged, /// An input file or folder changed. InputChanged { kind: InputChangeKind, path: Str }, + /// A runner-aware tool reported a tracked env var that changed between runs. + TrackedEnvChanged { name: Str, old: Option, new: Option }, } /// An execution error, serializable for persistence. @@ -136,6 +144,7 @@ pub enum SavedExecutionError { Cache { kind: SavedCacheErrorKind, message: Str }, Spawn { message: Str }, PostRunFingerprint { message: Str }, + IpcServerBind { message: Str }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -223,6 +232,9 @@ impl SavedExecutionError { ExecutionError::PostRunFingerprint(source) => { Self::PostRunFingerprint { message: vite_str::format!("{source:#}") } } + ExecutionError::IpcServerBind(source) => { + Self::IpcServerBind { message: vite_str::format!("{source:#}") } + } } } @@ -242,6 +254,7 @@ impl SavedExecutionError { Self::PostRunFingerprint { message } => { vite_str::format!("Failed to create post-run fingerprint: {message}") } + Self::IpcServerBind { .. } => Str::from("Failed to start runner IPC server"), } } } @@ -260,6 +273,13 @@ impl SavedCacheMissReason { FingerprintMismatch::InputChanged { kind, path } => { Self::InputChanged { kind: *kind, path: Str::from(path.as_str()) } } + FingerprintMismatch::TrackedEnvChanged { name, old, new } => { + Self::TrackedEnvChanged { + name: name.clone(), + old: old.clone(), + new: new.clone(), + } + } }, } } @@ -284,6 +304,16 @@ impl TaskResult { } _ => None, }; + let ipc_server_error = match cache_update_status { + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::IpcServerError(err)) => { + Some(vite_str::format!("{err}")) + } + _ => None, + }; + let tool_disabled_cache = matches!( + cache_update_status, + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::ToolRequested) + ); match cache_status { CacheStatus::Hit { replayed_duration } => { @@ -296,6 +326,8 @@ impl TaskResult { exit_status, saved_error, input_modified_path, + ipc_server_error, + tool_disabled_cache, ), }, CacheStatus::Miss(cache_miss) => Self::Spawned { @@ -306,6 +338,8 @@ impl TaskResult { exit_status, saved_error, input_modified_path, + ipc_server_error, + tool_disabled_cache, ), }, } @@ -317,14 +351,19 @@ fn spawn_outcome_from_execution( exit_status: Option, saved_error: Option<&SavedExecutionError>, input_modified_path: Option, + ipc_server_error: Option, + tool_disabled_cache: bool, ) -> SpawnOutcome { match (exit_status, saved_error) { // Spawn error — process never ran (None, Some(err)) => SpawnOutcome::SpawnError(err.clone()), // Process exited successfully, possible infra error - (Some(status), _) if status.success() => { - SpawnOutcome::Success { infra_error: saved_error.cloned(), input_modified_path } - } + (Some(status), _) if status.success() => SpawnOutcome::Success { + infra_error: saved_error.cloned(), + input_modified_path, + ipc_server_error, + tool_disabled_cache, + }, // Process exited with non-zero code (Some(status), _) => { let code = crate::session::event::exit_status_to_code(status); @@ -338,7 +377,12 @@ fn spawn_outcome_from_execution( // No exit status, no error — this is the cache hit / in-process path, // handled by TaskResult::CacheHit / InProcess before reaching here. // If we somehow get here, treat as success. - (None, None) => SpawnOutcome::Success { infra_error: None, input_modified_path: None }, + (None, None) => SpawnOutcome::Success { + infra_error: None, + input_modified_path: None, + ipc_server_error: None, + tool_disabled_cache: false, + }, } } @@ -449,7 +493,26 @@ impl TaskResult { /// - "→ Cache miss: no previous cache entry found" /// - "→ Cache disabled in task configuration" fn format_cache_detail(&self) -> Str { - // Check for input modification first — it overrides the cache miss reason + // Check for IPC server error first — short-circuits before any cache + // computation in `execute_spawn`, so it takes priority. + if let Self::Spawned { + outcome: SpawnOutcome::Success { ipc_server_error: Some(err), .. }, + .. + } = self + { + return vite_str::format!("→ Not cached: IPC server error: {err}"); + } + + // Tool-reported cache disable — the tool said it shouldn't be cached. + if let Self::Spawned { + outcome: SpawnOutcome::Success { tool_disabled_cache: true, .. }, + .. + } = self + { + return Str::from("→ Not cached: tool requested disableCache"); + } + + // Check for input modification next — it overrides the cache miss reason if let Self::Spawned { outcome: SpawnOutcome::Success { input_modified_path: Some(path), .. }, .. @@ -488,6 +551,9 @@ impl TaskResult { let desc = format_input_change_str(*kind, path.as_str()); vite_str::format!("→ Cache miss: {desc}") } + SavedCacheMissReason::TrackedEnvChanged { name, .. } => { + vite_str::format!("→ Cache miss: tracked env '{name}' changed") + } }, }, } diff --git a/crates/vite_task_bin/src/vtt/grep_file.rs b/crates/vite_task_bin/src/vtt/grep_file.rs new file mode 100644 index 00000000..50b3281b --- /dev/null +++ b/crates/vite_task_bin/src/vtt/grep_file.rs @@ -0,0 +1,16 @@ +pub fn run(args: &[String]) { + let [path, pattern] = args else { + eprintln!("Usage: vtt grep-file "); + std::process::exit(2); + }; + match std::fs::read_to_string(path) { + Ok(content) => { + if content.contains(pattern.as_str()) { + println!("{path}: found {pattern:?}"); + } else { + println!("{path}: missing {pattern:?}"); + } + } + Err(_) => println!("{path}: not found"), + } +} diff --git a/crates/vite_task_bin/src/vtt/main.rs b/crates/vite_task_bin/src/vtt/main.rs index 527e423b..d9fccb73 100644 --- a/crates/vite_task_bin/src/vtt/main.rs +++ b/crates/vite_task_bin/src/vtt/main.rs @@ -11,6 +11,7 @@ mod check_tty; mod cp; mod exit; mod exit_on_ctrlc; +mod grep_file; mod mkdir; mod pipe_stdin; mod print; @@ -20,6 +21,7 @@ mod print_file; mod read_stdin; mod replace_file_content; mod rm; +mod stat_file; mod touch_file; mod write_file; @@ -28,7 +30,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, mkdir, pipe-stdin, print, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, touch-file, write-file" + "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, grep-file, mkdir, pipe-stdin, print, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, stat-file, touch-file, write-file" ); std::process::exit(1); } @@ -42,6 +44,10 @@ fn main() { "cp" => cp::run(&args[2..]), "exit" => exit::run(&args[2..]), "exit-on-ctrlc" => exit_on_ctrlc::run(), + "grep-file" => { + grep_file::run(&args[2..]); + Ok(()) + } "mkdir" => mkdir::run(&args[2..]), "pipe-stdin" => pipe_stdin::run(&args[2..]), "print" => { @@ -54,6 +60,10 @@ fn main() { "read-stdin" => read_stdin::run(), "replace-file-content" => replace_file_content::run(&args[2..]), "rm" => rm::run(&args[2..]), + "stat-file" => { + stat_file::run(&args[2..]); + Ok(()) + } "touch-file" => touch_file::run(&args[2..]), "write-file" => write_file::run(&args[2..]), other => { diff --git a/crates/vite_task_bin/src/vtt/stat_file.rs b/crates/vite_task_bin/src/vtt/stat_file.rs new file mode 100644 index 00000000..75fbebf4 --- /dev/null +++ b/crates/vite_task_bin/src/vtt/stat_file.rs @@ -0,0 +1,9 @@ +pub fn run(args: &[String]) { + for file in args { + if std::fs::metadata(file).is_ok() { + println!("{file}: exists"); + } else { + println!("{file}: missing"); + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/package.json new file mode 100644 index 00000000..be1e0be8 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/package.json @@ -0,0 +1,5 @@ +{ + "name": "ipc-client-test", + "private": true, + "type": "module" +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/disable_cache.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/disable_cache.mjs new file mode 100644 index 00000000..f868cef5 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/disable_cache.mjs @@ -0,0 +1,8 @@ +import { disableCache } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +// Produce an output, then ask the runner not to cache this execution — the +// next `vt run` should re-execute the task. +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'ok\n'); +disableCache(); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_env.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_env.mjs new file mode 100644 index 00000000..b54ba789 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/fetch_env.mjs @@ -0,0 +1,11 @@ +import { fetchEnv } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +// fetchEnv populates process.env from the runner and — with tracked: true — +// adds the env to the post-run fingerprint, so a change between runs +// invalidates the cache. +fetchEnv('PROBE_ENV', { tracked: true }); +const value = process.env.PROBE_ENV ?? '(unset)'; + +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'PROBE_ENV=' + value + '\n'); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_input.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_input.mjs new file mode 100644 index 00000000..e368bb9c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_input.mjs @@ -0,0 +1,15 @@ +import { ignoreInput } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, readFileSync } from 'node:fs'; +import { mkdirSync } from 'node:fs'; + +// The task reads from `cache_like/` (which we want the runner to IGNORE as +// an input), and writes to `dist/`. Without the ignore, the auto-input +// fingerprint would fluctuate with cache_like/ contents even though they're +// not semantic inputs. +mkdirSync('cache_like', { recursive: true }); +writeFileSync('cache_like/stale.txt', 'stale-' + Date.now() + '\n'); +ignoreInput('cache_like'); +readFileSync('cache_like/stale.txt', 'utf8'); + +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'ok\n'); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_output.mjs b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_output.mjs new file mode 100644 index 00000000..efb1aa16 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/scripts/ignore_output.mjs @@ -0,0 +1,14 @@ +import { ignoreOutput } from '@voidzero-dev/vite-task-client'; +import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'; + +// The task both reads and writes `sidecar/tmp.txt`. If the runner didn't +// treat `sidecar/` as an ignored output, the read-write overlap check would +// refuse to cache the task. `dist/out.txt` is the real output. +mkdirSync('sidecar', { recursive: true }); +writeFileSync('sidecar/tmp.txt', 'initial\n'); +readFileSync('sidecar/tmp.txt', 'utf8'); +writeFileSync('sidecar/tmp.txt', 'final\n'); +ignoreOutput('sidecar'); + +mkdirSync('dist', { recursive: true }); +writeFileSync('dist/out.txt', 'ok\n'); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml new file mode 100644 index 00000000..d4cae1b2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml @@ -0,0 +1,134 @@ +[[e2e]] +name = "ignore_input_keeps_cache_valid" +comment = """ +Exercises `ignoreInput` through `@voidzero-dev/vite-task-client`. +The runner treats `cache_like/` as non-input, so mutations to it between +runs do not invalidate the cache. +""" +# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child +# with "failed to start the persistent thread of the Interprocess linger +# pool: Access is denied" when the Node addon tries to connect. +platform = "unix" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "ignore-input", + ], comment = "populate the cache" }, + { argv = [ + "vtt", + "write-file", + "cache_like/other.txt", + "after", + ], comment = "mutate the ignored directory — would invalidate if tracked" }, + { argv = [ + "vt", + "run", + "ignore-input", + ], comment = "cache hit: cache_like/ was ignored via ignoreInput" }, +] + +[[e2e]] +name = "ignore_output_allows_read_write_overlap" +comment = """ +Exercises `ignoreOutput`. The task reads and writes `sidecar/tmp.txt`; +without the ignore the runner's read-write overlap check would refuse to +cache the run ("read and wrote 'sidecar/tmp.txt'"). +""" +# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child +# with "failed to start the persistent thread of the Interprocess linger +# pool: Access is denied" when the Node addon tries to connect. +platform = "unix" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "ignore-output", + ], comment = "first run populates the cache" }, + { argv = [ + "vtt", + "rm", + "dist/out.txt", + ], comment = "remove the real output so the cache-hit restore is observable" }, + { argv = [ + "vt", + "run", + "ignore-output", + ], comment = "cache hit: sidecar/ writes were ignored" }, + { argv = [ + "vtt", + "print-file", + "dist/out.txt", + ], comment = "restored from the cache archive" }, +] + +[[e2e]] +name = "disable_cache_forces_reexecution" +comment = """ +Exercises `disableCache`. The tool asks the runner not to cache this run, +so the next invocation re-executes instead of hitting a prior entry. +""" +# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child +# with "failed to start the persistent thread of the Interprocess linger +# pool: Access is denied" when the Node addon tries to connect. +platform = "unix" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "disable-cache", + ], comment = "first run — tool calls disableCache" }, + { argv = [ + "vt", + "run", + "disable-cache", + ], comment = "cache miss (NotFound) because nothing was cached" }, +] + +[[e2e]] +name = "fetch_env_tracked_invalidates_on_change" +comment = """ +Exercises `fetchEnv(name, { tracked: true })`. The env value becomes part +of the post-run fingerprint: the same value still hits, a different value +misses with `tracked env 'PROBE_ENV' changed`. +""" +# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child +# with "failed to start the persistent thread of the Interprocess linger +# pool: Access is denied" when the Node addon tries to connect. +platform = "unix" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "fetch-env", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "first run captures PROBE_ENV=first in the fingerprint" }, + { argv = [ + "vt", + "run", + "fetch-env", + ], envs = [ + [ + "PROBE_ENV", + "first", + ], + ], comment = "cache hit: PROBE_ENV unchanged" }, + { argv = [ + "vt", + "run", + "fetch-env", + ], envs = [ + [ + "PROBE_ENV", + "second", + ], + ], comment = "cache miss: tracked env changed" }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/disable_cache_forces_reexecution.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/disable_cache_forces_reexecution.md new file mode 100644 index 00000000..15015854 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/disable_cache_forces_reexecution.md @@ -0,0 +1,20 @@ +# disable_cache_forces_reexecution + +Exercises `disableCache`. The tool asks the runner not to cache this run, +so the next invocation re-executes instead of hitting a prior entry. + +## `vt run disable-cache` + +first run — tool calls disableCache + +``` +$ node scripts/disable_cache.mjs +``` + +## `vt run disable-cache` + +cache miss (NotFound) because nothing was cached + +``` +$ node scripts/disable_cache.mjs +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_tracked_invalidates_on_change.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_tracked_invalidates_on_change.md new file mode 100644 index 00000000..54cb6ac2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/fetch_env_tracked_invalidates_on_change.md @@ -0,0 +1,32 @@ +# fetch_env_tracked_invalidates_on_change + +Exercises `fetchEnv(name, { tracked: true })`. The env value becomes part +of the post-run fingerprint: the same value still hits, a different value +misses with `tracked env 'PROBE_ENV' changed`. + +## `PROBE_ENV=first vt run fetch-env` + +first run captures PROBE_ENV=first in the fingerprint + +``` +$ node scripts/fetch_env.mjs +``` + +## `PROBE_ENV=first vt run fetch-env` + +cache hit: PROBE_ENV unchanged + +``` +$ node scripts/fetch_env.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `PROBE_ENV=second vt run fetch-env` + +cache miss: tracked env changed + +``` +$ node scripts/fetch_env.mjs ○ cache miss: tracked env 'PROBE_ENV' changed, executing +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_input_keeps_cache_valid.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_input_keeps_cache_valid.md new file mode 100644 index 00000000..d624c0c4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_input_keeps_cache_valid.md @@ -0,0 +1,31 @@ +# ignore_input_keeps_cache_valid + +Exercises `ignoreInput` through `@voidzero-dev/vite-task-client`. +The runner treats `cache_like/` as non-input, so mutations to it between +runs do not invalidate the cache. + +## `vt run ignore-input` + +populate the cache + +``` +$ node scripts/ignore_input.mjs +``` + +## `vtt write-file cache_like/other.txt after` + +mutate the ignored directory — would invalidate if tracked + +``` +``` + +## `vt run ignore-input` + +cache hit: cache_like/ was ignored via ignoreInput + +``` +$ node scripts/ignore_input.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_output_allows_read_write_overlap.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_output_allows_read_write_overlap.md new file mode 100644 index 00000000..70ee4002 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots/ignore_output_allows_read_write_overlap.md @@ -0,0 +1,39 @@ +# ignore_output_allows_read_write_overlap + +Exercises `ignoreOutput`. The task reads and writes `sidecar/tmp.txt`; +without the ignore the runner's read-write overlap check would refuse to +cache the run ("read and wrote 'sidecar/tmp.txt'"). + +## `vt run ignore-output` + +first run populates the cache + +``` +$ node scripts/ignore_output.mjs +``` + +## `vtt rm dist/out.txt` + +remove the real output so the cache-hit restore is observable + +``` +``` + +## `vt run ignore-output` + +cache hit: sidecar/ writes were ignored + +``` +$ node scripts/ignore_output.mjs ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `vtt print-file dist/out.txt` + +restored from the cache archive + +``` +ok +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json new file mode 100644 index 00000000..b080bb50 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/vite-task.json @@ -0,0 +1,20 @@ +{ + "tasks": { + "ignore-input": { + "command": "node scripts/ignore_input.mjs", + "cache": true + }, + "ignore-output": { + "command": "node scripts/ignore_output.mjs", + "cache": true + }, + "disable-cache": { + "command": "node scripts/disable_cache.mjs", + "cache": true + }, + "fetch-env": { + "command": "node scripts/fetch_env.mjs", + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/index.html b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/index.html new file mode 100644 index 00000000..20fc85a4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/index.html @@ -0,0 +1,9 @@ + + + + vp-run-vite-cache + + + + + diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/package.json new file mode 100644 index 00000000..e4e3497f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/package.json @@ -0,0 +1,5 @@ +{ + "name": "vite-build-cache-fixture", + "private": true, + "type": "module" +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml new file mode 100644 index 00000000..1b9dfb6c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml @@ -0,0 +1,117 @@ +[[e2e]] +name = "vite_build_caches_and_restores_outputs" +comment = """ +`vt run --cache build` must produce a cache hit on the second run without +any manual input/output configuration. Vite reports +`ignoreInput(outDir)` + `ignoreInput/Output(cacheDir)` via +`@voidzero-dev/vite-task-client`, so fspy-detected reads of `dist/` and +writes to `node_modules/.vite/` don't poison the cache. +""" +# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child +# with "failed to start the persistent thread of the Interprocess linger +# pool: Access is denied" when the Node addon tries to connect. +platform = "unix" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "--cache", + "build", + ], comment = "first run: cache miss, emits dist/" }, + { argv = [ + "vtt", + "stat-file", + "dist/assets/main.js", + ], comment = "existence check — content would drift across Vite versions" }, + { argv = [ + "vtt", + "rm", + "dist/assets/main.js", + ], comment = "remove the artefact so the cache-hit restore is observable" }, + { argv = [ + "vt", + "run", + "--cache", + "build", + ], comment = "cache hit: outputs restored without manual config" }, + { argv = [ + "vtt", + "stat-file", + "dist/assets/main.js", + ], comment = "restored from the cache archive" }, +] + +[[e2e]] +name = "node_env_change_invalidates_cache" +comment = """ +NODE_ENV is picked up by Vite via `fetchEnv("NODE_ENV", { tracked: true })` +in `resolveConfig`. Flipping its value between runs must both invalidate +the cache AND change the build output — Vite's `define` plugin substitutes +`process.env.NODE_ENV` at build time, so dead-code elimination leaves only +the branch matching the current mode. +""" +# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child +# with "failed to start the persistent thread of the Interprocess linger +# pool: Access is denied" when the Node addon tries to connect. +platform = "unix" +ignore = true +steps = [ + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "NODE_ENV", + "production", + ], + ], comment = "first run: production build" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "BUILD_MODE_PROD", + ], comment = "production build: PROD marker survived DCE" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "BUILD_MODE_DEV", + ], comment = "dev branch is gone" }, + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "NODE_ENV", + "production", + ], + ], comment = "cache hit: NODE_ENV unchanged" }, + { argv = [ + "vt", + "run", + "--cache", + "build", + ], envs = [ + [ + "NODE_ENV", + "development", + ], + ], comment = "cache miss: Vite's fetchEnv marked NODE_ENV as tracked" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "BUILD_MODE_PROD", + ], comment = "PROD marker gone after the dev rebuild" }, + { argv = [ + "vtt", + "grep-file", + "dist/assets/main.js", + "BUILD_MODE_DEV", + ], comment = "DEV marker now in the bundle" }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/node_env_change_invalidates_cache.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/node_env_change_invalidates_cache.md new file mode 100644 index 00000000..781c2fb4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/node_env_change_invalidates_cache.md @@ -0,0 +1,66 @@ +# node_env_change_invalidates_cache + +NODE_ENV is picked up by Vite via `fetchEnv("NODE_ENV", { tracked: true })` +in `resolveConfig`. Flipping its value between runs must both invalidate +the cache AND change the build output — Vite's `define` plugin substitutes +`process.env.NODE_ENV` at build time, so dead-code elimination leaves only +the branch matching the current mode. + +## `NODE_ENV=production vt run --cache build` + +first run: production build + +``` +$ vite build +``` + +## `vtt grep-file dist/assets/main.js BUILD_MODE_PROD` + +production build: PROD marker survived DCE + +``` +dist/assets/main.js: found "BUILD_MODE_PROD" +``` + +## `vtt grep-file dist/assets/main.js BUILD_MODE_DEV` + +dev branch is gone + +``` +dist/assets/main.js: missing "BUILD_MODE_DEV" +``` + +## `NODE_ENV=production vt run --cache build` + +cache hit: NODE_ENV unchanged + +``` +$ vite build ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `NODE_ENV=development vt run --cache build` + +cache miss: Vite's fetchEnv marked NODE_ENV as tracked + +``` +$ vite build ○ cache miss: tracked env 'NODE_ENV' changed, executing +``` + +## `vtt grep-file dist/assets/main.js BUILD_MODE_PROD` + +PROD marker gone after the dev rebuild + +``` +dist/assets/main.js: missing "BUILD_MODE_PROD" +``` + +## `vtt grep-file dist/assets/main.js BUILD_MODE_DEV` + +DEV marker now in the bundle + +``` +dist/assets/main.js: found "BUILD_MODE_DEV" +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_build_caches_and_restores_outputs.md b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_build_caches_and_restores_outputs.md new file mode 100644 index 00000000..46dab08c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots/vite_build_caches_and_restores_outputs.md @@ -0,0 +1,49 @@ +# vite_build_caches_and_restores_outputs + +`vt run --cache build` must produce a cache hit on the second run without +any manual input/output configuration. Vite reports +`ignoreInput(outDir)` + `ignoreInput/Output(cacheDir)` via +`@voidzero-dev/vite-task-client`, so fspy-detected reads of `dist/` and +writes to `node_modules/.vite/` don't poison the cache. + +## `vt run --cache build` + +first run: cache miss, emits dist/ + +``` +$ vite build +``` + +## `vtt stat-file dist/assets/main.js` + +existence check — content would drift across Vite versions + +``` +dist/assets/main.js: exists +``` + +## `vtt rm dist/assets/main.js` + +remove the artefact so the cache-hit restore is observable + +``` +``` + +## `vt run --cache build` + +cache hit: outputs restored without manual config + +``` +$ vite build ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `vtt stat-file dist/assets/main.js` + +restored from the cache archive + +``` +dist/assets/main.js: exists +``` diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js new file mode 100644 index 00000000..6f70d2c6 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/src/main.js @@ -0,0 +1,8 @@ +// Vite's `define` plugin replaces `process.env.NODE_ENV` at build time, so +// whichever branch runs becomes a static part of the bundle. The markers let +// the e2e test assert that flipping NODE_ENV actually changed what was built. +if (process.env.NODE_ENV === 'production') { + document.body.append('BUILD_MODE_PROD'); +} else { + document.body.append('BUILD_MODE_DEV'); +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite-task.json new file mode 100644 index 00000000..7446e73e --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite-task.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "build": { + "command": "vite build", + // No `"env": ["NODE_ENV"]` — Vite reports NODE_ENV to the runner itself. + "cache": true + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite.config.js b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite.config.js new file mode 100644 index 00000000..cc83efec --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + logLevel: 'silent', + build: { + rollupOptions: { + output: { + // Stable filenames make cache behaviour deterministic across runs. + entryFileNames: 'assets/main.js', + chunkFileNames: 'assets/chunk.js', + assetFileNames: 'assets/[name][extname]', + }, + }, + }, +}); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index ae8e3d17..5d9ac95f 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -233,6 +233,56 @@ enum TerminationState { TimedOut, } +/// Copy the `@voidzero-dev/vite-task-client` JS wrapper into the fixture's +/// staging `node_modules` so Node scripts can resolve it by name. Idempotent — +/// silently skipped if the source package is not found. +#[expect(clippy::disallowed_types, reason = "std::path::Path required for filesystem operations")] +fn populate_vite_task_client_package(stage_path: &AbsolutePath) { + let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = manifest_dir.parent().unwrap().parent().unwrap(); + let src = repo_root.join("packages/vite-task-client"); + if !src.is_dir() { + return; + } + let dst = stage_path.as_path().join("node_modules/@voidzero-dev/vite-task-client"); + std::fs::create_dir_all(dst.parent().unwrap()).unwrap(); + CopyOptions::new().copy_tree(&src, &dst).unwrap(); +} + +/// Symlink installed Node packages from the repo's `packages/tools/node_modules` +/// into the fixture's staging `node_modules` so fixtures can resolve them by +/// name without a per-fixture pnpm install. Only packages whose staging-side +/// symlink targets exist are created; missing targets are silently skipped. +#[expect(clippy::disallowed_types, reason = "std::path::Path required for filesystem operations")] +fn link_tools_packages(stage_path: &AbsolutePath, names: &[&str]) { + let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = manifest_dir.parent().unwrap().parent().unwrap(); + let stage_node_modules = stage_path.as_path().join("node_modules"); + std::fs::create_dir_all(&stage_node_modules).unwrap(); + + for name in names { + let src = repo_root.join("packages/tools/node_modules").join(name); + // Follow the symlink so the absolute target (pnpm's .pnpm store) is + // what we pin into the staging tree. Relative symlinks into pnpm + // internals would break outside the repo. + let Ok(canonical) = std::fs::canonicalize(&src) else { + continue; + }; + let link = stage_node_modules.join(name); + let _ = std::fs::remove_file(&link); + #[cfg(unix)] + std::os::unix::fs::symlink(&canonical, &link).unwrap(); + #[cfg(windows)] + { + if canonical.is_dir() { + std::os::windows::fs::symlink_dir(&canonical, &link).unwrap(); + } else { + std::os::windows::fs::symlink_file(&canonical, &link).unwrap(); + } + } + } +} + /// Append a fenced markdown block containing `body`. The opening and closing /// fences sit on their own lines, and trailing whitespace inside `body` is /// trimmed so the close fence isn't preceded by blank lines. @@ -269,6 +319,19 @@ fn run_case( let e2e_stage_path = tmpdir.join(vite_str::format!("{fixture_name}_case_{case_index}")); CopyOptions::new().copy_tree(fixture_path, e2e_stage_path.as_path()).unwrap(); + // Make `@voidzero-dev/vite-task-client` importable from any fixture's Node + // scripts by copying the wrapper package into the staging dir's + // `node_modules`. This mirrors the user-facing flow (`import { ... } from + // "@voidzero-dev/vite-task-client"`) without requiring pnpm install. + populate_vite_task_client_package(&e2e_stage_path); + + // Fixtures that exercise real Node toolchains (e.g. `vite build`) link + // those packages from the repo's `packages/tools/node_modules` so the + // tool and its transitive deps (resolved via pnpm) stay reachable. + if fixture_name == "vite_build_cache" { + link_tools_packages(&e2e_stage_path, &["vite"]); + } + let (workspace_root, _cwd) = find_workspace_root(&e2e_stage_path).unwrap(); assert_eq!( &e2e_stage_path, &*workspace_root.path, @@ -281,8 +344,19 @@ fn run_case( let bin = AbsolutePathBuf::new(std::path::PathBuf::from(bin_path)).unwrap(); Arc::::from(bin.parent().unwrap().as_path().as_os_str()) }); + + // Also expose tool bins installed under packages/tools/node_modules/.bin + // (e.g. `vite`) so ignored e2e fixtures can exercise real toolchains. + #[expect(clippy::disallowed_types, reason = "PathBuf needed for workspace path arithmetic")] + let tools_bin_dir: Option> = { + let manifest_dir = std::path::PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = manifest_dir.parent().unwrap().parent().unwrap(); + let tools_bin = repo_root.join("packages/tools/node_modules/.bin"); + tools_bin.is_dir().then(|| Arc::::from(tools_bin.into_os_string())) + }; + let e2e_env_path = join_paths( - bin_dirs.iter().cloned().chain( + bin_dirs.iter().cloned().chain(tools_bin_dir.iter().cloned()).chain( // the existing PATH split_paths(&env::var_os("PATH").unwrap()) .map(|path| Arc::::from(path.into_os_string())), diff --git a/crates/vite_task_client/Cargo.toml b/crates/vite_task_client/Cargo.toml new file mode 100644 index 00000000..540a023a --- /dev/null +++ b/crates/vite_task_client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vite_task_client" +version = "0.0.0" +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +interprocess = { workspace = true } +native_str = { workspace = true } +vite_path = { workspace = true } +vite_task_ipc_shared = { workspace = true } +wincode = { workspace = true, features = ["derive"] } + +[lints] +workspace = true + +[lib] +doctest = false +test = false diff --git a/crates/vite_task_client/README.md b/crates/vite_task_client/README.md new file mode 100644 index 00000000..f1ab5ee4 --- /dev/null +++ b/crates/vite_task_client/README.md @@ -0,0 +1,3 @@ +# vite_task_client + +IPC client that connects from tool processes to the task runner to report inputs/outputs, request env values, and disable caching. diff --git a/crates/vite_task_client/src/lib.rs b/crates/vite_task_client/src/lib.rs new file mode 100644 index 00000000..011f1c4e --- /dev/null +++ b/crates/vite_task_client/src/lib.rs @@ -0,0 +1,141 @@ +use std::{ + cell::RefCell, + ffi::OsStr, + io::{self, Read, Write}, + sync::Arc, +}; + +use interprocess::local_socket::{Stream, prelude::*}; +use native_str::NativeStr; +use vite_path::{self, AbsolutePath}; +use vite_task_ipc_shared::{GetEnvResponse, IPC_ENV_NAME, Request}; + +pub struct Client { + stream: RefCell, + scratch: RefCell>, +} + +impl Client { + /// Scans `envs` for the runner's IPC connection info and connects if + /// present. Typical callers pass `std::env::vars_os()`. + /// + /// Returns `Ok(None)` if the IPC env is absent (running outside the runner). + /// `Err(..)` if the env is set but connecting fails. + /// + /// # Errors + /// + /// Returns an error if the env var is set but the server cannot be reached. + pub fn from_envs( + envs: impl Iterator, impl AsRef)>, + ) -> io::Result> { + for (name, value) in envs { + if name.as_ref() == IPC_ENV_NAME { + let stream = Stream::connect(resolve_name(value.as_ref())?)?; + return Ok(Some(Self::from_stream(stream))); + } + } + Ok(None) + } + + const fn from_stream(stream: Stream) -> Self { + Self { stream: RefCell::new(stream), scratch: RefCell::new(Vec::new()) } + } + + /// `path` can be a file or a directory; for a directory, all files inside + /// it are ignored. Relative paths are resolved against the current working + /// directory before being sent to the runner. + /// + /// # Errors + /// + /// Returns an error if the request fails to send, or (for a relative + /// `path`) if the current working directory cannot be read. + pub fn ignore_input(&self, path: &OsStr) -> io::Result<()> { + let ns = resolve_path(path)?; + self.send(&Request::IgnoreInput(&ns)) + } + + /// `path` can be a file or a directory; for a directory, all files inside + /// it are ignored. Relative paths are resolved against the current working + /// directory before being sent to the runner. + /// + /// # Errors + /// + /// Returns an error if the request fails to send, or (for a relative + /// `path`) if the current working directory cannot be read. + pub fn ignore_output(&self, path: &OsStr) -> io::Result<()> { + let ns = resolve_path(path)?; + self.send(&Request::IgnoreOutput(&ns)) + } + + /// # Errors + /// + /// Returns an error if the request fails to send. + pub fn disable_cache(&self) -> io::Result<()> { + self.send(&Request::DisableCache) + } + + /// Requests an env value from the runner. Returns `None` if the runner reports + /// the env is not available. + /// + /// # Errors + /// + /// Returns an error if the request or response fails. + pub fn get_env(&self, name: &OsStr, tracked: bool) -> io::Result>> { + let name = Box::::from(name); + + self.send(&Request::GetEnv { name: &name, tracked })?; + self.recv_with(|bytes| { + let response: GetEnvResponse<'_> = wincode::deserialize_exact(bytes) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + Ok(response + .env_value + .map(|env_value| Arc::::from(env_value.to_cow_os_str().as_ref()))) + }) + } + + fn send(&self, request: &Request<'_>) -> io::Result<()> { + let bytes = wincode::serialize(request) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let len = u32::try_from(bytes.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "request too large"))?; + let mut stream = self.stream.borrow_mut(); + stream.write_all(&len.to_le_bytes())?; + stream.write_all(&bytes)?; + stream.flush()?; + Ok(()) + } + + fn recv_with(&self, extract: impl FnOnce(&[u8]) -> io::Result) -> io::Result { + let mut stream = self.stream.borrow_mut(); + let mut scratch = self.scratch.borrow_mut(); + let mut len_bytes = [0u8; 4]; + stream.read_exact(&mut len_bytes)?; + let len = u32::from_le_bytes(len_bytes) as usize; + scratch.clear(); + scratch.resize(len, 0); + stream.read_exact(&mut scratch)?; + extract(&scratch) + } +} + +#[cfg(unix)] +fn resolve_name(name: &OsStr) -> io::Result> { + use interprocess::local_socket::{GenericFilePath, ToFsName}; + name.to_fs_name::() +} + +#[cfg(windows)] +fn resolve_name(name: &OsStr) -> io::Result> { + use interprocess::local_socket::{GenericNamespaced, ToNsName}; + name.to_ns_name::() +} + +fn resolve_path(path: &OsStr) -> io::Result> { + if let Some(abs) = AbsolutePath::new(path) { + Ok(Box::::from(abs.as_path().as_os_str())) + } else { + let mut buf = vite_path::current_dir()?; + buf.push(path); + Ok(Box::::from(buf.as_path().as_os_str())) + } +} diff --git a/crates/vite_task_client_napi/Cargo.toml b/crates/vite_task_client_napi/Cargo.toml new file mode 100644 index 00000000..4a91be9d --- /dev/null +++ b/crates/vite_task_client_napi/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "vite_task_client_napi" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lib] +crate-type = ["cdylib"] +test = false +doctest = false + +[dependencies] +napi = { workspace = true, features = ["napi6"] } +napi-derive = { workspace = true } +vite_str = { workspace = true } +vite_task_client = { workspace = true } + +[build-dependencies] +napi-build = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_task_client_napi/README.md b/crates/vite_task_client_napi/README.md new file mode 100644 index 00000000..c85a17f9 --- /dev/null +++ b/crates/vite_task_client_napi/README.md @@ -0,0 +1,3 @@ +# vite_task_client_napi + +Node addon that lets JS/TS tools running inside a `vp run` task talk to the runner over IPC via `vite_task_client`. diff --git a/crates/vite_task_client_napi/build.rs b/crates/vite_task_client_napi/build.rs new file mode 100644 index 00000000..9fc23678 --- /dev/null +++ b/crates/vite_task_client_napi/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/crates/vite_task_client_napi/src/lib.rs b/crates/vite_task_client_napi/src/lib.rs new file mode 100644 index 00000000..f227a31a --- /dev/null +++ b/crates/vite_task_client_napi/src/lib.rs @@ -0,0 +1,103 @@ +//! Node addon that exposes module-level functions for tools to talk to a +//! `vp run` runner over IPC. Not intended to be published directly — the +//! runner hands the compiled `.node` file to child processes via the +//! `VP_RUN_NODE_CLIENT_PATH` env var, and the JS wrapper in +//! `@voidzero-dev/vite-task-client` `require()`s it lazily. +//! +//! The module is loadable **only** inside a runner-spawned task: when +//! module-init runs outside that context the registration fails, the JS +//! `require()` throws, and the wrapper falls into no-op mode. + +// The napi boundary forces std `String` through function signatures; clippy's +// blanket bans on disallowed types / needless-pass-by-value / missing Errors +// sections are all about pure-Rust call sites and don't apply here (JS never +// reads rustdoc). +#![expect( + clippy::disallowed_types, + clippy::missing_errors_doc, + clippy::needless_pass_by_value, + reason = "napi bindings require owned std String at the JS boundary" +)] + +use std::{cell::OnceCell, ffi::OsStr}; + +use napi::{Env, Error, Result}; +use napi_derive::napi; +use vite_task_client::Client; + +thread_local! { + // Per-thread so each Node Worker (which runs init on its own thread) gets + // its own slot. `Client` is `!Sync`, so a process-global `OnceLock` would + // be unsound across Workers. + static CLIENT: OnceCell = const { OnceCell::new() }; +} + +#[napi(module_exports)] +pub fn init(_env: Env) -> Result<()> { + let client = Client::from_envs(std::env::vars_os()) + .map_err(|err| { + err_string(vite_str::format!("vp run client: failed to connect to runner IPC: {err}")) + })? + .ok_or_else(|| { + err_static( + "vp run client: runner IPC env is not set; this module is only usable \ + inside a `vp run` task", + ) + })?; + CLIENT.with(|slot| { + slot.set(client).map_err(|_| err_static("vp run client: already initialised")) + })?; + Ok(()) +} + +fn with_client(f: impl FnOnce(&Client) -> Result) -> Result { + CLIENT.with(|slot| { + let client = slot.get().ok_or_else(|| err_static("vp run client: module state missing"))?; + f(client) + }) +} + +fn err_static(msg: &'static str) -> Error { + Error::from_reason(msg) +} + +fn err_string(msg: vite_str::Str) -> Error { + Error::from_reason(msg.as_str()) +} + +#[napi] +pub fn ignore_input(path: String) -> Result<()> { + with_client(|client| { + client.ignore_input(OsStr::new(&path)).map_err(|err| err_string(vite_str::format!("{err}"))) + }) +} + +#[napi] +pub fn ignore_output(path: String) -> Result<()> { + with_client(|client| { + client + .ignore_output(OsStr::new(&path)) + .map_err(|err| err_string(vite_str::format!("{err}"))) + }) +} + +#[napi] +pub fn disable_cache() -> Result<()> { + with_client(|client| { + client.disable_cache().map_err(|err| err_string(vite_str::format!("{err}"))) + }) +} + +#[napi] +pub fn get_env(name: String, tracked: bool) -> Result> { + with_client(|client| { + let value = client + .get_env(OsStr::new(&name), tracked) + .map_err(|err| err_string(vite_str::format!("{err}")))?; + value.map_or(Ok(None), |value| { + value.to_str().map(|s| Some(s.to_owned())).ok_or_else(|| { + err_string(vite_str::format!("env value for {name} is not valid UTF-8")) + }) + }) + }) +} diff --git a/crates/vite_task_ipc_shared/.clippy.toml b/crates/vite_task_ipc_shared/.clippy.toml new file mode 120000 index 00000000..c7929b36 --- /dev/null +++ b/crates/vite_task_ipc_shared/.clippy.toml @@ -0,0 +1 @@ +../../.non-vite.clippy.toml \ No newline at end of file diff --git a/crates/vite_task_ipc_shared/Cargo.toml b/crates/vite_task_ipc_shared/Cargo.toml new file mode 100644 index 00000000..fd685ac0 --- /dev/null +++ b/crates/vite_task_ipc_shared/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "vite_task_ipc_shared" +version = "0.0.0" +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +native_str = { workspace = true } +wincode = { workspace = true, features = ["derive"] } + +[lints] +workspace = true + +[lib] +doctest = false +test = false diff --git a/crates/vite_task_ipc_shared/README.md b/crates/vite_task_ipc_shared/README.md new file mode 100644 index 00000000..26116e79 --- /dev/null +++ b/crates/vite_task_ipc_shared/README.md @@ -0,0 +1,3 @@ +# vite_task_ipc_shared + +Shared IPC message types for communication between the task runner and tools. diff --git a/crates/vite_task_ipc_shared/src/lib.rs b/crates/vite_task_ipc_shared/src/lib.rs new file mode 100644 index 00000000..fbe5a9fe --- /dev/null +++ b/crates/vite_task_ipc_shared/src/lib.rs @@ -0,0 +1,27 @@ +use native_str::NativeStr; +use wincode::{SchemaRead, SchemaWrite}; + +pub const IPC_ENV_NAME: &str = "VP_RUN_IPC_NAME"; + +/// Path to the Node client module that JS/TS tools `require()` to talk to +/// the runner. +/// +/// Implementation-detail leakage (`napi`, `.node`, `addon`) is intentionally +/// kept out of the name: from the consumer's point of view this is just a +/// path they can `require()`. The `NODE_` scope reserves room for a future +/// C-ABI client library advertised via its own env var for non-Node +/// consumers. +pub const NODE_CLIENT_PATH_ENV_NAME: &str = "VP_RUN_NODE_CLIENT_PATH"; + +#[derive(Debug, SchemaWrite, SchemaRead)] +pub enum Request<'a> { + IgnoreInput(&'a NativeStr), + IgnoreOutput(&'a NativeStr), + GetEnv { name: &'a NativeStr, tracked: bool }, + DisableCache, +} + +#[derive(Debug, SchemaWrite, SchemaRead)] +pub struct GetEnvResponse<'a> { + pub env_value: Option<&'a NativeStr>, +} diff --git a/crates/vite_task_server/Cargo.toml b/crates/vite_task_server/Cargo.toml new file mode 100644 index 00000000..96d28e59 --- /dev/null +++ b/crates/vite_task_server/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "vite_task_server" +version = "0.0.0" +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +futures = { workspace = true } +interprocess = { workspace = true, features = ["tokio"] } +native_str = { workspace = true } +rustc-hash = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["io-util", "net", "rt", "macros"] } +tokio-util = { workspace = true } +tracing = { workspace = true } +vite_path = { workspace = true } +vite_task_ipc_shared = { workspace = true } +wincode = { workspace = true, features = ["derive"] } + +[target.'cfg(windows)'.dependencies] +uuid = { workspace = true, features = ["v4"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["io-util", "net", "rt", "macros", "time"] } +vite_task_client = { workspace = true } + +[lints] +workspace = true + +[lib] +doctest = false +test = false diff --git a/crates/vite_task_server/README.md b/crates/vite_task_server/README.md new file mode 100644 index 00000000..cdcbdcdd --- /dev/null +++ b/crates/vite_task_server/README.md @@ -0,0 +1,3 @@ +# vite_task_server + +IPC server that runs per task execution, receiving messages from tools (runner-aware tools) and dispatching them to a user-provided handler. diff --git a/crates/vite_task_server/src/lib.rs b/crates/vite_task_server/src/lib.rs new file mode 100644 index 00000000..f2df72d0 --- /dev/null +++ b/crates/vite_task_server/src/lib.rs @@ -0,0 +1,348 @@ +use std::{ + cell::RefCell, + ffi::{OsStr, OsString}, + io, + sync::Arc, +}; + +use futures::{FutureExt, StreamExt, future::LocalBoxFuture, stream::FuturesUnordered}; +use interprocess::local_socket::{ + ListenerOptions, + tokio::{Listener, Stream, prelude::*}, +}; +use native_str::NativeStr; +use rustc_hash::{FxHashMap, FxHashSet}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_util::sync::CancellationToken; +use vite_path::AbsolutePath; +use vite_task_ipc_shared::{GetEnvResponse, IPC_ENV_NAME, Request}; +use wincode::{SchemaWrite, config::DefaultConfig}; + +pub trait Handler { + fn ignore_input(&mut self, path: &Arc); + fn ignore_output(&mut self, path: &Arc); + fn disable_cache(&mut self); + fn get_env(&mut self, name: &OsStr, tracked: bool) -> Option>; +} + +/// A protocol-level failure observed while servicing a client. +/// +/// The driver retains only the first such error across all clients, then +/// completes gracefully (existing clients drain, new connections are rejected). +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("failed to read request frame from client")] + ReadFrame(#[source] io::Error), + + #[error("failed to deserialize request from client")] + InvalidRequest(#[source] wincode::ReadError), + + #[error("non-absolute path from client: {path:?}")] + NonAbsolutePath { path: OsString }, + + #[error("failed to write response to client")] + WriteResponse(#[source] io::Error), +} + +/// A [`Handler`] that records every report and resolves `get_env` against +/// a provided env map. +/// +/// Call [`Recorder::into_reports`] after the driver future completes to +/// recover the collected [`Reports`]. +pub struct Recorder { + ignored_inputs: FxHashSet>, + ignored_outputs: FxHashSet>, + cache_disabled: bool, + env_records: FxHashMap, EnvRecord>, + env_map: FxHashMap, Arc>, +} + +/// A record of an env value requested via `get_env`. +/// +/// `tracked` is the monotonic OR of every `tracked` flag sent for this name +/// — once `true`, it stays `true`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnvRecord { + pub tracked: bool, + pub value: Option>, +} + +/// The data collected by a [`Recorder`] over the server's lifetime. +#[derive(Debug, Default)] +pub struct Reports { + pub ignored_inputs: FxHashSet>, + pub ignored_outputs: FxHashSet>, + pub cache_disabled: bool, + pub env_records: FxHashMap, EnvRecord>, +} + +impl Recorder { + #[must_use] + pub fn new(env_map: FxHashMap, Arc>) -> Self { + Self { + ignored_inputs: FxHashSet::default(), + ignored_outputs: FxHashSet::default(), + cache_disabled: false, + env_records: FxHashMap::default(), + env_map, + } + } + + #[must_use] + pub fn into_reports(self) -> Reports { + Reports { + ignored_inputs: self.ignored_inputs, + ignored_outputs: self.ignored_outputs, + cache_disabled: self.cache_disabled, + env_records: self.env_records, + } + } +} + +impl Handler for Recorder { + fn ignore_input(&mut self, path: &Arc) { + self.ignored_inputs.insert(Arc::clone(path)); + } + + fn ignore_output(&mut self, path: &Arc) { + self.ignored_outputs.insert(Arc::clone(path)); + } + + fn disable_cache(&mut self) { + self.cache_disabled = true; + } + + fn get_env(&mut self, name: &OsStr, tracked: bool) -> Option> { + if let Some(existing) = self.env_records.get_mut(name) { + existing.tracked |= tracked; + return existing.value.clone(); + } + let value = self.env_map.get(name).cloned(); + self.env_records.insert(name.into(), EnvRecord { tracked, value: value.clone() }); + value + } +} + +/// Handle to a running IPC server. +/// +/// `driver` must be polled to accept clients and handle messages. It resolves +/// only after [`StopAccepting::signal`] has been called AND all in-flight +/// per-client tasks have drained, returning the owned handler. +/// +/// The driver resolves to `Err(Error)` if any client triggered a protocol +/// violation (see [`Error`]). The first such error is retained; subsequent +/// errors during drain are discarded. On `Err`, the handler is not returned. +/// +/// Dropping `driver` before it resolves tears everything down immediately — +/// listener closed, per-client tasks cancelled, handler discarded. +pub struct ServerHandle<'h, H> { + pub driver: LocalBoxFuture<'h, Result>, + pub stop_accepting: StopAccepting, +} + +/// Signal that tells the server to stop accepting new clients. Existing +/// clients continue until they naturally close the connection; the driver +/// future resolves once that drain completes. +/// +/// [`signal`](Self::signal) takes `&self` and the underlying cancellation +/// is idempotent, so calling it twice or from a shared borrow is safe. +pub struct StopAccepting { + token: CancellationToken, +} + +impl StopAccepting { + pub fn signal(&self) { + self.token.cancel(); + } +} + +/// Starts an IPC server. +/// +/// Returns the env entries that a child process must inherit to find and +/// connect to this server, plus a handle bundling the driver future and the +/// `StopAccepting` signal. See [`ServerHandle`] for driver semantics. +/// +/// # Errors +/// +/// Returns an error if creating the listener fails (on Unix, this includes +/// creating the temp socket path). +pub fn serve<'h, H: Handler + 'h>( + handler: H, +) -> io::Result<(impl Iterator, ServerHandle<'h, H>)> { + let stop_token = CancellationToken::new(); + let (name, bound) = bind_listener()?; + + let run_stop = stop_token.clone(); + let driver = async move { + // Multiple per-client futures coexist inside `FuturesUnordered` and each + // calls `&mut self` handler methods. `RefCell` provides the interior + // mutability that makes these shared-access method calls compile; at + // runtime the `borrow_mut()` never conflicts because we're on a + // single-threaded runtime and handler methods are synchronous (no + // awaits, so no borrow spans a yield point). + let handler = RefCell::new(handler); + let first_err = run(bound, &handler, run_stop).await; + first_err.map_or_else(|| Ok(handler.into_inner()), Err) + } + .boxed_local(); + + Ok(( + std::iter::once((OsStr::new(IPC_ENV_NAME), name)), + ServerHandle { driver, stop_accepting: StopAccepting { token: stop_token } }, + )) +} + +#[cfg(unix)] +type Bound = tempfile::NamedTempFile; +#[cfg(windows)] +type Bound = Listener; + +#[cfg(unix)] +fn bind_listener() -> io::Result<(OsString, Bound)> { + use interprocess::local_socket::{GenericFilePath, ToFsName}; + + let bound = tempfile::Builder::new().prefix("vite_task_ipc_").make(|path| { + let name = path.to_fs_name::()?; + ListenerOptions::new().name(name).create_tokio() + })?; + let name = bound.path().as_os_str().to_owned(); + Ok((name, bound)) +} + +#[cfg(windows)] +fn bind_listener() -> io::Result<(OsString, Bound)> { + use interprocess::local_socket::{GenericNamespaced, ToNsName}; + + #[expect( + clippy::disallowed_macros, + reason = "socket name always exceeds Str inline capacity; format! is the simplest construction" + )] + let name = OsString::from(format!("vite_task_ipc_{}", uuid::Uuid::new_v4())); + + let ns_name = name.as_os_str().to_ns_name::()?; + let listener = ListenerOptions::new().name(ns_name).create_tokio()?; + Ok((name, listener)) +} + +#[cfg(unix)] +fn listener_of(bound: &Bound) -> &Listener { + bound.as_file() +} + +#[cfg(windows)] +const fn listener_of(bound: &Bound) -> &Listener { + bound +} + +async fn run( + bound: Bound, + handler: &RefCell, + shutdown: CancellationToken, +) -> Option { + let mut clients = FuturesUnordered::new(); + let mut first_err: Option = None; + + // Accept phase: accept new clients until shutdown fires. + loop { + let listener = listener_of(&bound); + tokio::select! { + () = shutdown.cancelled() => break, + accept_result = listener.accept() => { + match accept_result { + Ok(stream) => { + clients.push(handle_client(stream, handler).boxed_local()); + } + Err(err) => { + tracing::warn!(?err, "vite_task_server: accept failed"); + } + } + } + Some(result) = clients.next(), if !clients.is_empty() => { + if let Err(err) = result + && first_err.is_none() + { + first_err = Some(err); + shutdown.cancel(); + } + } + } + } + + // Stop accepting: drop the listener (and on Unix unlink the socket file). + // Existing client streams continue to work. + drop(bound); + + // Drain phase: wait for all in-flight per-client tasks to finish. + while let Some(result) = clients.next().await { + if let Err(err) = result + && first_err.is_none() + { + first_err = Some(err); + } + } + + first_err +} + +async fn handle_client(mut stream: Stream, handler: &RefCell) -> Result<(), Error> { + let mut buf = Vec::new(); + loop { + match read_frame(&mut stream, &mut buf).await { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => return Ok(()), + Err(err) => return Err(Error::ReadFrame(err)), + } + + let request: Request<'_> = + wincode::deserialize_exact(&buf).map_err(Error::InvalidRequest)?; + + match request { + Request::IgnoreInput(ns) => { + let path = native_str_to_abs_path(ns)?; + handler.borrow_mut().ignore_input(&path); + } + Request::IgnoreOutput(ns) => { + let path = native_str_to_abs_path(ns)?; + handler.borrow_mut().ignore_output(&path); + } + Request::DisableCache => handler.borrow_mut().disable_cache(), + Request::GetEnv { name, tracked } => { + let value = handler.borrow_mut().get_env(name.to_cow_os_str().as_ref(), tracked); + let boxed: Option> = value.as_deref().map(Into::into); + let response = GetEnvResponse { env_value: boxed.as_deref() }; + write_response(&mut stream, &response).await.map_err(Error::WriteResponse)?; + } + } + } +} + +fn native_str_to_abs_path(ns: &NativeStr) -> Result, Error> { + let os_str = ns.to_cow_os_str(); + AbsolutePath::new(&*os_str) + .map(Arc::from) + .ok_or_else(|| Error::NonAbsolutePath { path: os_str.into_owned() }) +} + +async fn read_frame(stream: &mut Stream, buf: &mut Vec) -> io::Result<()> { + let mut len_bytes = [0u8; 4]; + stream.read_exact(&mut len_bytes).await?; + let len = u32::from_le_bytes(len_bytes) as usize; + buf.clear(); + buf.resize(len, 0); + stream.read_exact(buf).await?; + Ok(()) +} + +async fn write_response(stream: &mut Stream, response: &T) -> io::Result<()> +where + T: SchemaWrite + ?Sized, +{ + let bytes = wincode::serialize(response) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let len = u32::try_from(bytes.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "response too large"))?; + stream.write_all(&len.to_le_bytes()).await?; + stream.write_all(&bytes).await?; + stream.flush().await?; + Ok(()) +} diff --git a/crates/vite_task_server/tests/integration.rs b/crates/vite_task_server/tests/integration.rs new file mode 100644 index 00000000..7c3affff --- /dev/null +++ b/crates/vite_task_server/tests/integration.rs @@ -0,0 +1,217 @@ +use std::{ + ffi::{OsStr, OsString}, + io::{self, Read, Write}, + sync::Arc, + thread, +}; + +use interprocess::local_socket::{Stream, prelude::*}; +use native_str::NativeStr; +use rustc_hash::FxHashMap; +use tokio::runtime::Builder; +use vite_task_client::Client; +use vite_task_ipc_shared::Request; +use vite_task_server::{Error, Recorder, Reports, ServerHandle, serve}; + +fn env_map(pairs: &[(&str, &str)]) -> FxHashMap, Arc> { + pairs + .iter() + .map(|(k, v)| (Arc::::from(OsStr::new(k)), Arc::::from(OsStr::new(v)))) + .collect() +} + +fn run_with_server( + envs: FxHashMap, Arc>, + client_work: F, +) -> Result +where + F: FnOnce(Vec<(&'static OsStr, OsString)>) + Send + 'static, +{ + let recorder = Recorder::new(envs); + + let rt = Builder::new_current_thread().enable_all().build().unwrap(); + rt.block_on(async move { + let (envs, ServerHandle { driver, stop_accepting }) = serve(recorder).expect("bind server"); + let envs: Vec<_> = envs.collect(); + + let client = async move { + tokio::task::spawn_blocking(move || client_work(envs)) + .await + .expect("client work panicked"); + stop_accepting.signal(); + }; + + let (result, ()) = tokio::join!(driver, client); + result.map(Recorder::into_reports) + }) +} + +fn connect(envs: &[(&'static OsStr, OsString)]) -> Client { + Client::from_envs(envs.iter().map(|(k, v)| (k, v))) + .expect("connect") + .expect("serve should yield an IPC env") +} + +/// Force a round-trip so the server has definitely processed every prior +/// fire-and-forget frame on this connection: frames on a single stream are +/// read sequentially, so once the server answers a `get_env` everything +/// before it must already have been dispatched to the handler. +fn flush(client: &Client) { + let _ = client.get_env(OsStr::new("__VP_TEST_FLUSH__"), false).unwrap(); +} + +#[cfg(unix)] +fn connect_raw(name: &OsStr) -> Stream { + use interprocess::local_socket::{GenericFilePath, ToFsName}; + let resolved = name.to_fs_name::().expect("resolve socket name"); + Stream::connect(resolved).expect("connect raw") +} + +#[cfg(windows)] +fn connect_raw(name: &OsStr) -> Stream { + use interprocess::local_socket::{GenericNamespaced, ToNsName}; + let resolved = name.to_ns_name::().expect("resolve pipe name"); + Stream::connect(resolved).expect("connect raw") +} + +fn send_frame(stream: &mut Stream, request: &Request<'_>) { + let bytes = wincode::serialize(request).expect("serialize"); + let len = u32::try_from(bytes.len()).expect("frame length fits u32"); + stream.write_all(&len.to_le_bytes()).expect("write len"); + stream.write_all(&bytes).expect("write body"); + stream.flush().expect("flush"); +} + +#[test] +fn single_client_fire_and_forget() { + // Absolute paths look different on each platform; bare forward-slash + // paths are relative on Windows (no drive letter) and would be rewritten + // by the client before the server sees them. + #[cfg(unix)] + let (in_path, out_path) = ("/tmp/in.txt", "/tmp/out.txt"); + #[cfg(windows)] + let (in_path, out_path) = (r"C:\tmp\in.txt", r"C:\tmp\out.txt"); + + let reports = run_with_server(env_map(&[]), |envs| { + let client = connect(&envs); + client.ignore_input(OsStr::new(in_path)).unwrap(); + client.ignore_output(OsStr::new(out_path)).unwrap(); + client.disable_cache().unwrap(); + flush(&client); + }) + .expect("driver returned error"); + + let inputs: Vec<_> = reports.ignored_inputs.iter().map(|p| p.as_path().as_os_str()).collect(); + let outputs: Vec<_> = reports.ignored_outputs.iter().map(|p| p.as_path().as_os_str()).collect(); + assert_eq!(inputs, vec![OsStr::new(in_path)]); + assert_eq!(outputs, vec![OsStr::new(out_path)]); + assert!(reports.cache_disabled); +} + +#[test] +fn get_env_found_and_not_found() { + let reports = run_with_server(env_map(&[("NODE_ENV", "production")]), |envs| { + let client = connect(&envs); + let present = client.get_env(OsStr::new("NODE_ENV"), true).unwrap(); + assert_eq!(present.as_deref(), Some(OsStr::new("production"))); + let missing = client.get_env(OsStr::new("MISSING"), false).unwrap(); + assert!(missing.is_none()); + }) + .expect("driver returned error"); + + let node = reports.env_records.get(OsStr::new("NODE_ENV")).expect("NODE_ENV recorded"); + assert!(node.tracked); + assert_eq!(node.value.as_deref(), Some(OsStr::new("production"))); + + let missing = reports.env_records.get(OsStr::new("MISSING")).expect("MISSING recorded"); + assert!(!missing.tracked); + assert!(missing.value.is_none()); +} + +#[test] +fn get_env_tracked_upgrade_is_monotonic() { + let reports = run_with_server(env_map(&[("NODE_ENV", "production")]), |envs| { + let client = connect(&envs); + let a = client.get_env(OsStr::new("NODE_ENV"), false).unwrap(); + let b = client.get_env(OsStr::new("NODE_ENV"), true).unwrap(); + let c = client.get_env(OsStr::new("NODE_ENV"), false).unwrap(); + for v in [a, b, c] { + assert_eq!(v.as_deref(), Some(OsStr::new("production"))); + } + }) + .expect("driver returned error"); + + let node = reports.env_records.get(OsStr::new("NODE_ENV")).expect("recorded"); + assert!(node.tracked, "tracked must remain true once set"); +} + +#[test] +fn concurrent_clients() { + #[cfg(unix)] + let paths = ["/tmp/worker_0", "/tmp/worker_1", "/tmp/worker_2", "/tmp/worker_3"]; + #[cfg(windows)] + let paths = [r"C:\tmp\worker_0", r"C:\tmp\worker_1", r"C:\tmp\worker_2", r"C:\tmp\worker_3"]; + let reports = run_with_server(env_map(&[("SHARED", "value")]), move |envs| { + let threads: Vec<_> = paths + .iter() + .map(|path| { + let envs = envs.clone(); + let path = *path; + thread::spawn(move || { + let client = connect(&envs); + client.ignore_input(OsStr::new(path)).unwrap(); + let value = client.get_env(OsStr::new("SHARED"), true).unwrap(); + assert_eq!(value.as_deref(), Some(OsStr::new("value"))); + }) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + }) + .expect("driver returned error"); + + assert_eq!(reports.ignored_inputs.len(), 4); + let shared = reports.env_records.get(OsStr::new("SHARED")).expect("recorded"); + assert!(shared.tracked); + assert_eq!(shared.value.as_deref(), Some(OsStr::new("value"))); +} + +#[test] +fn relative_input_joined_with_cwd() { + let cwd = vite_path::current_dir().expect("cwd"); + let expected = cwd.as_path().join("sub/file.txt"); + + let reports = run_with_server(env_map(&[]), |envs| { + let client = connect(&envs); + client.ignore_input(OsStr::new("sub/file.txt")).unwrap(); + flush(&client); + }) + .expect("driver returned error"); + + let inputs: Vec<_> = reports.ignored_inputs.iter().map(|p| p.as_path().as_os_str()).collect(); + assert_eq!(inputs, vec![expected.as_os_str()]); +} + +#[test] +fn server_returns_error_on_non_absolute_path() { + let err = run_with_server(env_map(&[]), |envs| { + let name = &envs[0].1; + let mut stream = connect_raw(name); + + let ns: Box = OsStr::new("relative/path").into(); + send_frame(&mut stream, &Request::IgnoreInput(&ns)); + + let mut buf = [0u8; 1]; + let read_err = stream.read_exact(&mut buf).expect_err("server should close connection"); + assert_eq!(read_err.kind(), io::ErrorKind::UnexpectedEof); + }) + .expect_err("driver should surface the protocol error"); + + match err { + Error::NonAbsolutePath { path } => { + assert_eq!(path, OsStr::new("relative/path")); + } + other => panic!("unexpected error variant: {other:?}"), + } +} diff --git a/docs/runner-task-ipc/design-decisions.md b/docs/runner-task-ipc/design-decisions.md new file mode 100644 index 00000000..4cb6bbab --- /dev/null +++ b/docs/runner-task-ipc/design-decisions.md @@ -0,0 +1,15 @@ +## Why change code in tools instead of configuring in vite-plus + +- logic locality +- dynamic decision at runtime +- provide api to tools' plugins. + +## Why implement client in rust (instead of pure js) + +- Consumable by both rust and js (via napi) +- Easier to implement sync api + +## Why provide client at runtime (instead of bundling in the tools) + +- Makes IPC protocol a implementation detail. Allows us to evolve IPC implementation or data schema without breaking clients (as long as we maintain the client API contract) +- Easier for 3rd party client implementation in other languages (for example, esbuild can create a golang wrapper over the client ffi) diff --git a/docs/runner-task-ipc/index.md b/docs/runner-task-ipc/index.md new file mode 100644 index 00000000..5bb8c5e4 --- /dev/null +++ b/docs/runner-task-ipc/index.md @@ -0,0 +1,58 @@ +# runner-aware tools + +## Motivation + +Report information from the tools to the runner, to help runner cache results without needing user's manual configs. + +### What information vite-task knows without runner-awareness of tools? + +- All files that are read/written by the tools +- All directory that are read/written by the tools + +### What information vite-task doesn't know without runner-awareness of tools? + +- **Why** did the tool read/write the file/directory? (e.g. files in cache should not be considered as inputs even when they are read by the tool, and should not be considered as outputs even when they are written by the tool) +- **What** env variables are the tool interested in? (they are not available to the tool's process env if the user doesn't explicitly define them in `env` in the config) +- **Whether** the tool needs be cached at all? (e.g. dev server doesn't need to be cached, but build does) + +## Implementation + +Rust crates: + +1. crates/vite_task_ipc_shared: defines the IPC message types shared between client and server. Uses wincode's zero-copy `SchemaWrite`/`SchemaRead` to minimize allocation. `Request` variants: `IgnoreInput`, `IgnoreOutput`, `GetEnv { id, name, tracked }`, `DisableCache`. Only `GetEnv` expects a `Response` (correlated via `id`); the others are fire-and-forget. +2. vite_task_server: exposes a `Handler` trait and a `serve(&handler, shutdown)` free function that binds a listener (via `interprocess`, auto-cleaned via `tempfile` on Unix) and returns the socket path / pipe name plus a single-threaded future. The future accepts new clients until the `shutdown` future resolves, then stops accepting and waits for every in-flight per-client task to drain (each drains naturally when its client closes the stream — e.g. the task process exits). Uses `FuturesUnordered` (not `spawn_local`) so the handler can be borrowed and hold `!Send` state (`Rc`, `RefCell`) without locking. +3. vite_task_client: a sync, blocking client with `&mut self` methods. Reads IPC connection info and the client-napi dylib path from the process env (specific env var names are an implementation detail shared with `vite_task_server`). Falls back to no-op if the envs are not defined, so that it won't break the tool when it's not run by the runner. +4. vite_task_client_napi: a NAPI wrapper around the client, so that tools can require it in JavaScript/TypeScript and use it to communicate with the vite task runner. + +Js Packages: + +1. @voidzero-dev/vite-task-client: npm package that wraps the vite_task_client_napi with jsdoc types, and provides a convenient API for tools to use in JavaScript/TypeScript. + +Notes: + +- ignored input/output files reported from the runner are considered as part of `{ auto: true }`, which means if the user defines `input`/`output` without `auto: true`, in the config, the runner will only consider the files defined in the config as inputs/outputs, and ignore what's reported from the tools. +- envs requested from the tools are additional to the envs defined in the config. User config always wins: if an env is already defined in the config (e.g. as `untrackedEnv`), the tool cannot override it (e.g. upgrade it to tracked). + +Workflow: + +1. `vite_task_server` uses crate `interprocess` to create a server along with a unique name, and listens to messages from tools. +2. `vite_task` calls vite_task_server to run server for every spawn execution. and collect what's reported from tools, and respond with requested envs +3. `vite_task` embed `vite_task_client_napi` dylib and write to temp folder in the same way as `crates/fspy/src/unix/mod.rs:56`. (we should extract crates/fspy/src/artifact.rs into a separate crate) +4. `vite_task` passes both the dylib path and the IPC connection info to the tool's process env, via env vars that `vite_task_client` knows to look up. +5. the tool is expected to use `@voidzero-dev/vite-task-client` +6. `@voidzero-dev/vite-task-client` initializes `vite_task_client_napi`, which internally reads the env vars and sets up the connection. + +`vite_task_client_napi` APIs: + +- `ignoreInputs({ path: string, glob: bool }[])` / `ignoreOutputs({ path: string, glob: bool }[])`: tells the runner to ignore the file as an input/output, even if it's read/written by the tool. The path can be a glob pattern if `glob` is true. Paths are all must be absolute paths. + +- `requestEnvs(envRequests: { tracked: bool, glob: bool, name: string }[]): Record`: tells the runner which env variables the tool is interested in, whether they should be fingerprinted or not, whether the name should be interpreted as a glob, and receive the env values from the runner. + +- `disableCache()`: tells the runner that the tool doesn't need to be cached. + +`@voidzero-dev/vite-task-client` APIs: + +- `ignoreInputs` / `ignoreOutputs` / `disableCache`: wrapper around the corresponding APIs in `vite_task_client_napi`. No-op if the client is not connected to the server. +- `fetchEnvs(envRequests: { tracked: bool, glob: bool, name: string }[])`: + - Wrapper around `requestEnvs` in `vite_task_client_napi`. No-op if the client is not connected to the server. Fills the returned env values to `process.env`. + - For every non-glob env request, if it already exists in `process.env`, it will not send the request to the server, because the tool's `process.env` could contain envs that were set by the tool itself or a non-runner parent — not just envs provided by the runner. diff --git a/docs/runner-task-ipc/plan.md b/docs/runner-task-ipc/plan.md new file mode 100644 index 00000000..3f2f8440 --- /dev/null +++ b/docs/runner-task-ipc/plan.md @@ -0,0 +1,8 @@ +# Implementation Plan + +1. **Protocol** — `vite_task_ipc_shared`. Define message types and serialization. Everything else depends on this. ✅ +2. **Transport** — `vite_task_server` + `vite_task_client`. Build both sides, test them against each other directly in Rust. ✅ +3. **Extract artifact** — Pull `artifact.rs` out of fspy into a shared crate. Prerequisite for dylib embedding. ✅ +4. **JS bridge** — `vite_task_client_napi` (real impl) + `@voidzero-dev/vite-task-client` (JS wrapper with `fetchEnvs` logic). ✅ +5. **Runner integration** — Wire into `vite_task` spawn: start server per execution, embed/extract dylib, inject the IPC envs via `serve()`'s returned iterator. ✅ +6. **Cache integration** — Runner consumes the reported data (ignored inputs/outputs, requested envs, disable cache) and adjusts caching behavior. ✅ diff --git a/docs/runner-task-ipc/server-design.md b/docs/runner-task-ipc/server-design.md new file mode 100644 index 00000000..51e14267 --- /dev/null +++ b/docs/runner-task-ipc/server-design.md @@ -0,0 +1,147 @@ +# Server API & Lifecycle + +## Goal + +The IPC server runs per spawn execution **only when fspy is enabled**, letting tools report runtime-only facts to the runner (`ignoreInputs`, `ignoreOutputs`, `disableCache`, `getEnv`). The runner uses these reports alongside fspy's tracked accesses for cache correctness. + +## Key principles + +1. **Server doesn't take a cancellation token.** The caller signals "stop accepting" via `StopAccepting::signal()`. The server has no awareness of external cancellation. +2. **Handler is moved in, returned out.** The caller doesn't keep a reference. The driver owns the handler; on drain completion it returns it by value. No self-reference, no `&H` lifetime. +3. **`CancellationToken` is internal** — hidden from the public API (exposed only via `StopAccepting`). +4. **Driver is `!Send`**, lifetime bounded by `H`'s lifetime — if `H: 'static`, the driver is `'static`; if `H` borrows, the driver respects that. + +## Server API + +```rust +pub trait Handler { + fn ignore_input(&self, path: &Arc); + fn ignore_output(&self, path: &Arc); + fn disable_cache(&self); + fn get_env(&self, name: &str, tracked: bool) -> Option>; +} + +pub fn serve<'h, H: Handler + 'h>( + handler: H, +) -> io::Result<(impl Iterator, ServerHandle<'h, H>)>; + +pub struct ServerHandle<'h, H> { + pub driver: LocalBoxFuture<'h, H>, + pub stop_accepting: StopAccepting, +} + +pub struct StopAccepting { /* opaque */ } +impl StopAccepting { + pub fn signal(self); +} +``` + +## Driver semantics + +The driver future, when polled: + +1. **Accept phase** — accepts new clients and pumps per-client futures (`FuturesUnordered`) until `StopAccepting::signal()` fires. +2. **Listener teardown** — drops listener; Unix socket file auto-cleaned via `tempfile::NamedTempFile`. +3. **Drain phase** — waits for in-flight per-client futures to complete naturally (each ends on client EOF). +4. **Returns `H`** — the owned handler that was moved in at `serve()`. + +Dropping the driver before it resolves tears everything down immediately. Handler is dropped without being returned. + +## Lifecycle in `execute_spawn` + +### When to start + +Only when fspy is enabled (`cache_metadata.input_config.includes_auto`). No fspy → no IPC server. + +### Construction (at `ExecutionMode` build time) + +`serve()` yields an env-pair iterator that the caller chains directly into the spawn's envs. The specific env var(s) used for IPC handoff are an implementation detail between the server and client crates — the runner never has to know their names. + +```rust +let (ipc_envs, server) = serve(IpcRecorder::new(env_config))?; +let envs = cmd.all_envs.iter().map(|(k, v)| (&**k, &**v)).chain(ipc_envs); +let child = spawn(&cmd, envs, true, SpawnStdio::Piped, token).await?; +// After the child is spawned, nothing else needs the IPC envs. + +let fspy = FspyState { + negatives, + server, // ServerHandle<'h, IpcRecorder> +}; +``` + +### `FspyState` shape + +```rust +struct FspyState { + negatives: Vec>, + server: ServerHandle, +} +``` + +**Not stored:** + +- IPC env name/value — consumed once to build the spawn envs, dropped immediately. +- `handler` — lives inside `server.driver`'s async state; recovered by value when the driver resolves. + +### Driving the server during `pipe_stdio` / `child.wait` + +The driver is polled as an extra arm in the existing `tokio::select!` blocks. `LocalBoxFuture<'static, H>` is `Unpin`, so `&mut driver` is a valid select arm: + +```rust +tokio::select! { + r = &mut pipe_fut => r, + _recorder = &mut fspy_state.server.driver => { + unreachable!("driver resolved before stop_accepting.signal()") + } +} +``` + +The driver only resolves after `stop_accepting.signal()` + drain — neither happens during these phases, so the branch is unreachable. + +### Completion paths + +```rust +// Normal exit: +if !fast_fail_token.is_cancelled() && !interrupt_token.is_cancelled() { + if let Some(fspy_state) = fspy.take() { + fspy_state.server.stop_accepting.signal(); + let recorder = fspy_state.server.driver.await; + // recorder.into_reports() flows into cache-update + } +} + +// Cancellation: fspy dropped at scope end → driver dropped → teardown. +``` + +## Design-decision log + +### Why no `'static` bound on `H`? + +The driver future _owns_ the handler (via `Rc` internally). It doesn't need to outlive `H` — it just needs `H` to outlive the future. So the signature is `serve<'h, H: Handler + 'h>` and the returned `ServerHandle<'h, H>` carries the lifetime. If the caller's `H` is `'static`, the driver is `'static`; if `H` borrows, the driver respects that. + +If the caller wants to store `ServerHandle` in a struct without a lifetime parameter, they can use a `'static` handler (naturally satisfied by handlers that own all their state via `RefCell<...>` + cloned data). + +### Handler is owned by the driver, not shared via `Rc` + +The driver's async function owns `handler: H` as a local. Per-client futures borrow `&handler` from that same async state; Rust's async-fn state machine makes this self-borrow sound (the state is pinned and never moves). All per-client futures live inside `FuturesUnordered` which is also part of the same state — borrow scopes are contained. + +When drain completes and all per-client futures have been dropped, the outer async returns `handler` by move. No `Rc`, no `try_unwrap`, no panic possible. + +### Why return `H` from the driver? + +Caller doesn't keep the handler around separately. Avoids `Rc::try_unwrap` at the call site. Makes it impossible to forget recovering the state. + +### Why `StopAccepting::signal(self)` instead of exposing `CancellationToken`? + +- Hides the implementation (could swap `CancellationToken` for `oneshot` or `Notify` later). +- Reads as intent: "stop accepting" vs. "cancel". +- `self`-consuming method signals one-shot semantics. +- Keeps the public API free of `tokio_util` types. + +### Why not pass `shutdown: impl Future` or `CancellationToken`? + +Earlier direction: "server doesn't care about cancellation token; it simply stops accepting when the process exits." The caller doesn't have a token to pass — they have a moment (child exit) when they want to stop accepting. `StopAccepting::signal()` is that moment. + +### On `spawn()` changes (deferred) + +`spawn()` will need to accept extra envs (e.g. `envs: impl IntoIterator, impl AsRef)>`) so the caller can inject the IPC envs without cloning `Arc`. Not part of this step. diff --git a/docs/runner-task-ipc/todo.md b/docs/runner-task-ipc/todo.md new file mode 100644 index 00000000..6b31e6a1 --- /dev/null +++ b/docs/runner-task-ipc/todo.md @@ -0,0 +1,30 @@ +# TODO + +## Native addon should return `null` on connect failure + +**Status**: not started. + +**Decision**: when `Client::from_envs` returns `Ok(None)` (env missing) or `Err` (connect failed), the `.node` addon's `require()` must resolve to `null` instead of throwing. + +### Why + +- **Impossible to misuse** — a caller writes `const addon = require(path); if (!addon) return;` once. You can't call a method on `null`, so there's no silent-no-op trap. +- **Smallest API surface** — one null-check at load time replaces per-call `try/catch` or per-call readiness checks everywhere. +- **Reserves room to upgrade** — we can later promote truly unexpected errors (bugs, misconfiguration) to throw while keeping the expected "no runner / no socket" cases as `null`, without changing tools or the JS wrapper. Committing to "throw on failure" today closes that door forever. + +### Why not the alternatives + +- **Throw (today)** — forces every third-party consumer into `try/catch` forever. Forgetting it crashes the tool in non-runner contexts. +- **Readiness flag on an always-returned object** — pushes the no-op decision into the methods. Every call site either needs a guard or relies on silent no-op (the tool thinks it's reporting, but isn't). + +### How + +napi-rs 3.x has no hook to change what its `napi_register_module_v1` returns ([napi-3.8.5/src/bindgen_runtime/module_register.rs:487-545](https://) hard-codes `Ok(exports)`), so we have to bypass it: + +- Enable napi's `noop` feature on [crates/vite_task_client_napi/Cargo.toml](../../crates/vite_task_client_napi/Cargo.toml) — disables napi-rs's own `napi_register_module_v1` and avoids a duplicate-symbol linker error. +- Drop `napi-derive` and the `#[napi]` decorators. Rewrite [crates/vite_task_client_napi/src/lib.rs](../../crates/vite_task_client_napi/src/lib.rs) using raw `napi::sys::*`: one `#[unsafe(no_mangle)] extern "C" fn napi_register_module_v1`, four `extern "C"` callbacks, properties registered via `sys::napi_define_properties`. +- On `Ok(Some(client))`: store client in a `thread_local! { static CLIENT: OnceCell }` and populate `exports`. On any other outcome: return `sys::napi_get_null(env)`. +- Update [packages/vite-task-client/index.js](../../packages/vite-task-client/index.js) — drop the `try/catch` in `load()`; `require()`'s result is already `null` or an object. +- Add an e2e test that spawns Node without `VP_RUN_IPC_NAME` and asserts `require(addonPath) === null`. + +Estimated size: ~200 lines. We only depend on `sys::*`, which is the stable Node ABI, so napi-rs internal churn can't break us. diff --git a/docs/runner-task-ipc/transport.md b/docs/runner-task-ipc/transport.md new file mode 100644 index 00000000..c902f361 --- /dev/null +++ b/docs/runner-task-ipc/transport.md @@ -0,0 +1,18 @@ +# IPC Transport + +Cross-platform IPC via `interprocess` crate: + +| Platform | Type | +| ------------------ | ------------------ | +| Unix (macOS/Linux) | Unix domain socket | +| Windows | Named pipe | + +The socket path or pipe name is passed to the task process via an env var shared between `vite_task_server` and `vite_task_client` (the specific name is an implementation detail). Clients check for its presence and skip IPC gracefully if absent. + +## Server Model + +One listener per task execution. The runner creates a new socket just before spawning the task and tears it down after the task exits. + +The listener runs an accept loop and handles multiple concurrent clients — build tools may spawn worker processes or threads that each connect independently. + +Platform differences are handled via `#[cfg(unix)]` / `#[cfg(windows)]`. diff --git a/docs/runner-task-ipc/vite-rolldown-env-operations.md b/docs/runner-task-ipc/vite-rolldown-env-operations.md new file mode 100644 index 00000000..a16fb928 --- /dev/null +++ b/docs/runner-task-ipc/vite-rolldown-env-operations.md @@ -0,0 +1,83 @@ +# Vite & Rolldown Environment Variable Operations + +## Vite + +### Reads that affect build output (must be tracked for cache correctness) + +| File | Line | Variable | Effect on output | +| -------------------------------------------------------------------------------------------------------------------------------- | ---- | ------------------------------ | ------------------------------------------------- | +| [config.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/config.ts#L1383) | 1383 | `NODE_ENV` | Build mode, affects dead-code elimination | +| [config.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/config.ts#L1661) | 1661 | `VITE_USER_NODE_ENV` | User-set NODE_ENV from `.env` file | +| [build.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/build.ts#L644) | 644 | `NODE_ENV` | Preserved for bundler | +| [plugins/clientInjections.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/clientInjections.ts#L52) | 52 | `NODE_ENV` | Injected into client bundle | +| [plugins/define.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/define.ts#L20) | 20 | `NODE_ENV` | Define replacement in output | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L1281) | 1281 | `NODE_ENV` | Dep pre-bundling config | +| [env.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/env.ts#L86) | 86 | `VITE_*` (all matching prefix) | Injected into client bundle via `import.meta.env` | + +### Reads that do not affect output (untracked) + +| File | Line | Variable | Effect | +| ---------------------------------------------------------------------------------------------------------------- | ---- | ----------------------- | ------------------------------ | +| [logger.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/logger.ts#L84) | 84 | `CI` | Disables color output only | +| [build.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/build.ts#L1067) | 1067 | `CI` | Disables TTY progress only | +| [utils.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/utils.ts#L176) | 176 | `DEBUG` | Debug logging only | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L1269) | 1269 | `npm_config_user_agent` | Package manager detection only | + +### Writes to `process.env` + +| File | Line | Variable | Reason | +| ---------------------------------------------------------------------------------------------- | ---- | -------------------- | ----------------------------------------------------- | +| [config.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/config.ts#L1389) | 1389 | `NODE_ENV` | Sets default if unset | +| [config.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/config.ts#L1664) | 1664 | `NODE_ENV` | Overrides to `'development'` if user set it in `.env` | +| [env.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/env.ts#L62) | 62 | `VITE_USER_NODE_ENV` | Stores NODE_ENV read from `.env` | + +### `.env` file loading + +Handled by `loadEnv()` at [env.ts:27](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/env.ts#L27). Reads `.env`, `.env.local`, `.env.{mode}`, `.env.{mode}.local` from `envDir`. All `VITE_*` vars become `import.meta.env.*` in the client bundle. + +This is **file input fingerprinting**, not an env var concern — fspy automatically tracks the `readFileSync` calls on `.env` files as inferred inputs. + +--- + +## Rolldown + +### Rust — reads + +| File | Line | Variable | Effect | +| ---------------------------------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | --------------------------- | +| [rolldown_binding/src/lib.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown_binding/src/lib.rs#L88) | 88 | `ROLLDOWN_MAX_BLOCKING_THREADS` | Tokio blocking thread count | +| [rolldown_binding/src/lib.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown_binding/src/lib.rs#L95) | 95 | `ROLLDOWN_WORKER_THREADS` | Tokio worker thread count | +| [rolldown_tracing/src/lib.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown_tracing/src/lib.rs#L25) | 25 | `RD_LOG` | Tracing log levels | +| [rolldown_tracing/src/lib.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown_tracing/src/lib.rs#L33) | 33 | `RD_LOG_OUTPUT` | Log output mode | + +None of these affect build output. + +### JS (NAPI binding loader) — reads + +| File | Line | Variable | Effect | +| --------------------------------------------------------------------------------------------------------------------------------- | ---- | ------------------------------- | -------------------------------------------------- | +| [packages/rolldown/src/binding.cjs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/packages/rolldown/src/binding.cjs#L64) | 64 | `NAPI_RS_NATIVE_LIBRARY_PATH` | Custom native lib path for loading `.node` binding | +| [packages/rolldown/src/binding.cjs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/packages/rolldown/src/binding.cjs#L80) | 80 | `NAPI_RS_ENFORCE_VERSION_CHECK` | Version mismatch behavior | + +--- + +## Implications for `getEnv` IPC + +Today, env vars read from `process.env` inside the task process are invisible to +the runner — no file read happens, so fspy cannot track them. The runner's current +`env`/`untrackedEnv` config requires the user to declare them manually. + +With `getEnv` IPC, a Vite plugin could request vars at runtime and have them +automatically fingerprinted: + +```ts +buildStart() { + await getEnv('NODE_ENV', { tracked: true }) // affects output — fingerprint it + await getEnv('CI', { tracked: false }) // affects behavior only — pass through +} +``` + +The key vars for Vite cache correctness via `getEnv`: + +- **`NODE_ENV`** — affects dead-code elimination, define replacements, and `import.meta.env.MODE` +- **`VITE_*`** — any matching var is injected into the client bundle; all must be tracked diff --git a/docs/runner-task-ipc/vite-rolldown-fs-operations.md b/docs/runner-task-ipc/vite-rolldown-fs-operations.md new file mode 100644 index 00000000..57cb2bf1 --- /dev/null +++ b/docs/runner-task-ipc/vite-rolldown-fs-operations.md @@ -0,0 +1,109 @@ +# Vite & Rolldown Filesystem Operations + +File reads and writes relevant to output restoration and cache fingerprinting. +All paths are relative to the package root unless noted. + +## Who writes output files + +When Vite uses Rolldown as its bundler, the actual chunk/asset writes happen in +Rolldown's Rust core (`bundle.rs`). Vite calls `bundle.write(output)` on the +`RolldownBuild` object; it does not write chunks itself. Rolldown's TypeScript +`build.ts` is only the standalone public API and is bypassed when called from +Vite. + +Vite owns only the surrounding operations: emptying the output dir and copying +public assets. + +--- + +## Vite — Output Directory (`build.outDir`, default `dist/`) + +| File | Line | Operation | Description | +| ----------------------------------------------------------------------------------------------------------------------- | ---- | -------------------------------- | ------------------------------------------------------------------- | +| [prepareOutDir.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/prepareOutDir.ts#L69) | 69 | read (`readdirSync`) | `emptyDir(outDir)` — lists then deletes all contents before build | +| [utils.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/utils.ts#L591) | 591 | read (`readdirSync`, `statSync`) | `emptyDir()` and `copyDir()` implementations | +| [prepareOutDir.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/prepareOutDir.ts#L89) | 89 | write | `copyDir(publicDir, outDir)` — copies public assets into output dir | +| [build.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/build.ts#L874) | 874 | write (delegates) | `bundle.write(output)` — hands off to Rolldown Rust core | +| [license.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/license.ts#L97) | 97 | write | emits `dist/.vite/license.json` via `emitFile()` | +| [license.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/plugins/license.ts#L107) | 107 | write | emits `dist/.vite/license.md` via `emitFile()` | +| [ssrManifestPlugin.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/ssr/ssrManifestPlugin.ts#L106) | 106 | write | emits `dist/.vite/ssr-manifest.json` via `emitFile()` | + +Note: `manifest.json` is emitted by a native Rolldown plugin (`native:manifest`), +not by Vite JS code. + +## Vite — Cache Directory (`cacheDir`, default `node_modules/.vite/`) + +| File | Line | Operation | Description | +| ---------------------------------------------------------------------------------------------------------------- | ---- | ------------------------ | ------------------------------------------------------------------- | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L405) | 405 | read | reads `cacheDir/deps/_metadata.json` | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L600) | 600 | read | `existsSync(depsCacheDir)` — checks cache presence | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L1417) | 1417 | read (`readdir`, `stat`) | scans for stale temp dirs older than 24h | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L531) | 531 | write | writes `package.json` (`"type":"module"`) into processing cache dir | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L586) | 586 | write | writes `_metadata.json` | +| [optimizer/index.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/optimizer/index.ts#L858) | 858 | write | `bundle.write()` — writes pre-bundled deps to `cacheDir/deps/` | +| [config.ts](https://github.com/vitejs/vite/blob/v8.0.8/packages/vite/src/node/config.ts#L2542) | 2542 | write | creates `node_modules/.vite-temp/` for bundled config files | + +--- + +## Rolldown — TypeScript API (`output.dir`, default `dist/`) + +| File | Line | Operation | Description | +| -------------------------------------------------------------------------------------------------------------------------------- | ---- | ----------------- | ---------------------------------------------------------------------- | +| [output-options.ts](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/packages/rolldown/src/options/output-options.ts#L702) | 702 | — | `cleanDir?: boolean` option definition | +| [build.ts](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/packages/rolldown/src/api/build.ts#L65) | 65 | write (delegates) | `build.write(output)` — standalone entry point, delegates to Rust core | + +## Rolldown — Rust Core + +| File | Line | Operation | Description | +| --------------------------------------------------------------------------------------------------------------------- | ---- | ----------------- | --------------------------------------------------------------------- | +| [bundle/bundle.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/bundle/bundle.rs#L161) | 161 | read + delete | calls `clean_dir(&fs, &dist_dir)` when `clean_dir` option is set | +| [bundle/bundle.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/bundle/bundle.rs#L175) | 175 | write | `fs.create_dir_all(&dist_dir)` — creates output dir | +| [bundle/bundle.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/bundle/bundle.rs#L202) | 202 | write | `fs.create_dir_all(p)` — creates parent dirs for output chunks | +| [bundle/bundle.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/bundle/bundle.rs#L209) | 209 | write | `fs.write(&dest, chunk.content_as_bytes())` — writes each output file | +| [utils/fs_utils.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/utils/fs_utils.rs#L10) | 10 | — | `clean_dir()` function definition | +| [utils/fs_utils.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/utils/fs_utils.rs#L25) | 25 | read (`read_dir`) | lists directory entries to clean | +| [utils/fs_utils.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/utils/fs_utils.rs#L27) | 27 | delete | `remove_dir_all` for subdirectories | +| [utils/fs_utils.rs](https://github.com/rolldown/rolldown/blob/v1.0.0-rc.15/crates/rolldown/src/utils/fs_utils.rs#L29) | 29 | delete | `remove_file` for files | + +Rolldown has no disk cache. + +--- + +## Where to add `ignoreInputs` / `ignoreOutputs` + +A single Vite plugin calling the runner IPC covers all of the above because +Rolldown's Rust code runs as a NAPI addon inside the same Node.js process — +fspy traces syscalls regardless of whether they originate from JS or Rust. + +```ts +// Vite plugin (added once to vite.config.ts, no-op when VP_IPC is absent) +buildStart() { + const ipcPath = process.env.VP_IPC + if (!ipcPath) return + const outDir = this.environment.config.build.outDir // e.g. "dist" + const cacheDir = this.environment.config.cacheDir // e.g. "node_modules/.vite" + ignoreInputs([outDir, cacheDir]) // suppress reads: emptyDir, clean_dir, dep optimizer + ignoreOutputs([cacheDir]) // suppress writes: pre-bundled deps, metadata + // outDir writes are real outputs — do NOT ignore them +} +``` + +`ignoreInputs(["dist"])` covers: + +- Vite `emptyDir` reads (`readdirSync` in `utils.ts:591`) +- Rolldown `clean_dir` reads (`read_dir` in `fs_utils.rs:25`) — same process, same syscalls + +`ignoreInputs(["node_modules/.vite"])` covers: + +- Dep optimizer `readFile`, `existsSync`, `readdir` reads + +`ignoreOutputs(["node_modules/.vite"])` covers: + +- Dep optimizer `bundle.write`, `writeFileSync`, `.vite-temp` writes — not real task outputs + +### Injection without modifying `vite.config.ts` + +Vite has no env-based plugin injection mechanism. Options: + +- **`NODE_OPTIONS=--import`**: monkey-patch Vite's `build`/`createServer` before startup — works but fragile across Vite versions, requires Node 20+ +- **Explicit plugin in config**: stable, recommended — the plugin is a no-op outside of `vp run` diff --git a/packages/tools/package.json b/packages/tools/package.json index 9ffcfdfa..4698dc5b 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -6,6 +6,7 @@ "cross-env": "^10.1.0", "oxfmt": "0.42.0", "oxlint": "catalog:", - "oxlint-tsgolint": "catalog:" + "oxlint-tsgolint": "catalog:", + "vite": "catalog:" } } diff --git a/packages/vite-task-client/README.md b/packages/vite-task-client/README.md new file mode 100644 index 00000000..a702fcea --- /dev/null +++ b/packages/vite-task-client/README.md @@ -0,0 +1,3 @@ +# @voidzero-dev/vite-task-client + +Node client that lets JS/TS tools report ignored inputs/outputs, fetch tracked env values, and opt out of caching when running inside a `vp run` task. diff --git a/packages/vite-task-client/index.js b/packages/vite-task-client/index.js new file mode 100644 index 00000000..57394fb0 --- /dev/null +++ b/packages/vite-task-client/index.js @@ -0,0 +1,59 @@ +import { createRequire } from 'node:module'; + +/** + * @typedef {{ + * ignoreInput(path: string): void, + * ignoreOutput(path: string): void, + * disableCache(): void, + * getEnv(name: string, tracked: boolean): (string | null), + * }} Addon + */ + +/** @type {Addon | null | undefined} */ +let addon; + +/** @returns {Addon | null} */ +function load() { + if (addon !== undefined) return addon; + try { + const path = process.env.VP_RUN_NODE_CLIENT_PATH; + if (path) { + addon = /** @type {Addon} */ (createRequire(import.meta.url)(path)); + return addon; + } + } catch { + // Fall through — the runner's IPC env is absent or the addon refused to load. + // Memoize the unavailable decision so subsequent calls don't retry. + } + addon = null; + return addon; +} + +/** @param {string} path */ +export function ignoreInput(path) { + load()?.ignoreInput(path); +} + +/** @param {string} path */ +export function ignoreOutput(path) { + load()?.ignoreOutput(path); +} + +export function disableCache() { + load()?.disableCache(); +} + +/** + * Populates `process.env[name]` from the runner if it is not already set. + * The caller reads the value back via `process.env[name]`. + * + * @param {string} name + * @param {{ tracked?: boolean }} [options] + */ +export function fetchEnv(name, { tracked = true } = {}) { + if (process.env[name] !== undefined) return; + const a = load(); + if (!a) return; + const value = a.getEnv(name, tracked); + if (value != null) process.env[name] = value; +} diff --git a/packages/vite-task-client/package.json b/packages/vite-task-client/package.json new file mode 100644 index 00000000..5b570e97 --- /dev/null +++ b/packages/vite-task-client/package.json @@ -0,0 +1,7 @@ +{ + "name": "@voidzero-dev/vite-task-client", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./index.js" +} diff --git a/patches/vite.patch b/patches/vite.patch new file mode 100644 index 00000000..9a0905f3 --- /dev/null +++ b/patches/vite.patch @@ -0,0 +1,66 @@ +diff --git a/dist/node/chunks/node.js b/dist/node/chunks/node.js +index 5be94a01d8aecf2502e76c05087b207980f2b06d..3cfee1b787d28dd84ab477316a9354da5d1411d1 100644 +--- a/dist/node/chunks/node.js ++++ b/dist/node/chunks/node.js +@@ -1,6 +1,10 @@ + import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM, r as __exportAll, t as __commonJSMin } from "./chunk.js"; + import { A as OPTIMIZABLE_ENTRY_RE, C as ERR_FILE_NOT_FOUND_IN_OPTIMIZED_DEP_DIR, D as JS_TYPES_RE, E as FS_PREFIX, F as defaultAllowedOrigins, I as loopbackHosts, L as wildcardHosts, M as SPECIAL_QUERY_RE, N as VERSION, O as KNOWN_ASSET_TYPES, P as VITE_PACKAGE_DIR, R as require_picocolors, S as ENV_PUBLIC_PATH, T as ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET, _ as DEFAULT_SERVER_CONDITIONS, a as CLIENT_ENTRY, b as DEV_PROD_CONDITION, c as DEFAULT_ASSETS_INLINE_LIMIT, d as DEFAULT_CLIENT_MAIN_FIELDS, f as DEFAULT_CONFIG_FILES, g as DEFAULT_PREVIEW_PORT, h as DEFAULT_EXTERNAL_CONDITIONS, i as CLIENT_DIR, j as ROLLUP_HOOKS, k as METADATA_FILENAME, l as DEFAULT_ASSETS_RE, m as DEFAULT_EXTENSIONS, n as createLogger, o as CLIENT_PUBLIC_PATH, p as DEFAULT_DEV_PORT, r as printServerUrls, s as CSS_LANGS_RE, t as LogLevels, u as DEFAULT_CLIENT_CONDITIONS, v as DEFAULT_SERVER_MAIN_FIELDS, w as ERR_OPTIMIZE_DEPS_PROCESSING_ERROR, x as ENV_ENTRY, y as DEP_VERSION_RE } from "./logger.js"; + import { builtinModules, createRequire } from "node:module"; ++// vp:runner-aware-tools — report inputs/outputs/tracked envs to the runner so ++// that caching of `vite build` works without manual input/output config. ++// No-op when `@voidzero-dev/vite-task-client` is not connected to a runner. ++import { ignoreInput, ignoreOutput, fetchEnv } from "@voidzero-dev/vite-task-client"; + import { parseAst, parseAstAsync } from "rolldown/parseAst"; + import { esmExternalRequirePlugin, esmExternalRequirePlugin as esmExternalRequirePlugin$1 } from "rolldown/plugins"; + import { TsconfigCache, Visitor, minify, minifySync, parse, parseSync, transformSync } from "rolldown/utils"; +@@ -31322,6 +31326,12 @@ async function loadCachedDepOptimizationMetadata(environment, force = environmen + setTimeout(() => cleanupDepsCacheStaleDirs(environment.getTopLevelConfig()), 0).unref(); + } + const depsCacheDir = getDepsCacheDir(environment); ++ // vp:runner-aware-tools — the dep optimizer cache is read and written ++ // under the same directory (metadata, pre-bundled deps). Tell the runner ++ // to treat the directory as neither input nor output; the lockfile hash ++ // stored in the metadata already drives invalidation of the real inputs. ++ ignoreInput(depsCacheDir); ++ ignoreOutput(depsCacheDir); + if (!force) { + let cachedMetadata; + try { +@@ -32706,6 +32716,10 @@ function prepareOutDir(outDirs, emptyOutDir, environment) { + const { publicDir } = environment.config; + const outDirsArray = [...outDirs]; + for (const outDir of outDirs) { ++ // vp:runner-aware-tools — emptyDir() below reads the entries of outDir. ++ // Without ignoring it, those reads become inferred inputs and mix with ++ // the writes that follow, tripping the runner's read-write overlap check. ++ ignoreInput(outDir); + if (emptyOutDir !== false && fs.existsSync(outDir)) emptyDir(outDir, [...outDirsArray.map((dir) => { + const relative = path.relative(outDir, dir); + if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) return relative; +@@ -34120,6 +34134,10 @@ async function resolveConfig(inlineConfig, command, defaultMode = "development", + } + let configFileDependencies = []; + let mode = inlineConfig.mode || defaultMode; ++ // vp:runner-aware-tools — NODE_ENV drives dead-code elimination, define ++ // replacements, and `import.meta.env.MODE`; fingerprint it as a tracked ++ // env so cache invalidation follows the value used at build time. ++ fetchEnv("NODE_ENV", { tracked: true }); + const isNodeEnvSet = !!process.env.NODE_ENV; + const packageCache = /* @__PURE__ */ new Map(); + if (!isNodeEnvSet) process.env.NODE_ENV = defaultNodeEnv; +@@ -34675,6 +34693,15 @@ async function loadConfigFromBundledFile(fileName, bundledCode, isESM) { + if (e.code === "EACCES") nodeModulesDir = void 0; + else throw e; + } ++ // vp:runner-aware-tools — the bundled config is written into ++ // `.vite-temp/..mjs` and then imported back. Ignore ++ // the directory as both input and output so the read-write of this ++ // transient file doesn't poison the runner's cache. ++ if (nodeModulesDir) { ++ const viteTempDir = path.resolve(nodeModulesDir, ".vite-temp"); ++ ignoreInput(viteTempDir); ++ ignoreOutput(viteTempDir); ++ } + const hash = `timestamp-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const tempFileName = nodeModulesDir ? path.resolve(nodeModulesDir, `.vite-temp/${path.basename(fileName)}.${hash}.mjs`) : `${fileName}.${hash}.mjs`; + await fsp.writeFile(tempFileName, bundledCode); diff --git a/playground/README.md b/playground/README.md index 863dce49..fee21627 100644 --- a/playground/README.md +++ b/playground/README.md @@ -7,8 +7,8 @@ A workspace for manually testing `cargo run --bin vt run ...`. ``` playground/ ├── packages/ -│ ├── app/ → depends on @playground/lib -│ ├── lib/ → depends on @playground/utils +│ ├── app/ → depends on lib +│ ├── lib/ → depends on utils │ └── utils/ → no dependencies └── vite-task.json → workspace-level task config ``` @@ -19,10 +19,10 @@ Dependency chain: `app → lib → utils` Tasks are defined in each package's `vite-task.json` with caching enabled. `dev` is a package.json script (not cached). -| Name | Type | Packages | Cached | Description | -| ----------- | ------ | --------------- | ------ | ---------------------------------------------- | -| `build` | task | app, lib, utils | yes | Prints a build message | -| `test` | task | app, lib, utils | yes | Prints a test message | -| `lint` | task | app, lib, utils | yes | Prints a lint message | -| `typecheck` | task | app, lib | yes | Prints a typecheck message | -| `dev` | script | app, lib | no | Long-running process (prints every 2s, ctrl-c) | +| Name | Type | Packages | Cached | Description | +| ----------- | ------ | --------------- | ------ | ----------------------------------------------------- | +| `build` | task | app, lib, utils | yes | `vite build` in app; prints a build message elsewhere | +| `test` | task | app, lib, utils | yes | Prints a test message | +| `lint` | task | app, lib, utils | yes | Prints a lint message | +| `typecheck` | task | app, lib | yes | Prints a typecheck message | +| `dev` | script | app, lib | no | Long-running process (prints every 2s, ctrl-c) | diff --git a/playground/packages/app/build.mjs b/playground/packages/app/build.mjs deleted file mode 100644 index 9b3bc870..00000000 --- a/playground/packages/app/build.mjs +++ /dev/null @@ -1 +0,0 @@ -console.log('Building app'); diff --git a/playground/packages/app/index.html b/playground/packages/app/index.html new file mode 100644 index 00000000..d4dce07f --- /dev/null +++ b/playground/packages/app/index.html @@ -0,0 +1,9 @@ + + + + playground-app + + + + + diff --git a/playground/packages/app/package.json b/playground/packages/app/package.json index 055ace37..6f132338 100644 --- a/playground/packages/app/package.json +++ b/playground/packages/app/package.json @@ -1,11 +1,13 @@ { - "name": "@playground/app", + "name": "app", "version": "0.0.0", "private": true, + "type": "module", "scripts": { "dev": "node dev.mjs" }, "dependencies": { - "@playground/lib": "workspace:*" + "lib": "workspace:*", + "vite": "catalog:" } } diff --git a/playground/packages/app/src/index.ts b/playground/packages/app/src/index.ts index 7db55b49..f6235bd6 100644 --- a/playground/packages/app/src/index.ts +++ b/playground/packages/app/src/index.ts @@ -1,3 +1,7 @@ -import { sum } from '@playground/lib'; +import { sum } from 'lib'; -console.log(sum(1, 2, 3)); +if (process.env.NODE_ENV === 'production') { + console.log('PROD build:', sum(1, 2, 3)); +} else { + console.log('DEV build:', sum(1, 2, 3)); +} diff --git a/playground/packages/app/vite-task.json b/playground/packages/app/vite-task.json index 58a9b2b4..1ad6fee4 100644 --- a/playground/packages/app/vite-task.json +++ b/playground/packages/app/vite-task.json @@ -1,7 +1,8 @@ { "tasks": { "build": { - "command": "node build.mjs" + "command": "vite build", + "cache": true }, "test": { "command": "node test.mjs" diff --git a/playground/packages/lib/package.json b/playground/packages/lib/package.json index fddb3aab..e8b699cb 100644 --- a/playground/packages/lib/package.json +++ b/playground/packages/lib/package.json @@ -1,11 +1,12 @@ { - "name": "@playground/lib", + "name": "lib", "version": "0.0.0", "private": true, + "main": "./src/index.ts", "scripts": { "dev": "node dev.mjs" }, "dependencies": { - "@playground/utils": "workspace:*" + "utils": "workspace:*" } } diff --git a/playground/packages/lib/src/index.ts b/playground/packages/lib/src/index.ts index f7fa1e13..8e6da590 100644 --- a/playground/packages/lib/src/index.ts +++ b/playground/packages/lib/src/index.ts @@ -1,4 +1,4 @@ -import { add } from '@playground/utils'; +import { add } from 'utils'; export function sum(...nums: number[]): number { return nums.reduce((acc, n) => add(acc, n), 0); diff --git a/playground/packages/utils/package.json b/playground/packages/utils/package.json index 8036670a..656f9b11 100644 --- a/playground/packages/utils/package.json +++ b/playground/packages/utils/package.json @@ -1,5 +1,6 @@ { - "name": "@playground/utils", + "name": "utils", "version": "0.0.0", - "private": true + "private": true, + "main": "./src/index.ts" } diff --git a/playground/pnpm-lock.yaml b/playground/pnpm-lock.yaml index ef78a0a0..dd0853c3 100644 --- a/playground/pnpm-lock.yaml +++ b/playground/pnpm-lock.yaml @@ -4,20 +4,511 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +catalogs: + default: + vite: + specifier: ^8.0.8 + version: 8.0.8 + +packageExtensionsChecksum: sha256-0p1nijyIYmO20Zod8fYruINNF8yHQutWS96PraqUHIA= + +patchedDependencies: + vite: + hash: a79d76bba22db0c17e49dcf55066e27a8c040d83a644d37fd22281b9f7bf2c51 + path: ../patches/vite.patch + importers: .: {} + ../packages/vite-task-client: {} + packages/app: dependencies: - '@playground/lib': + lib: specifier: workspace:* version: link:../lib + vite: + specifier: 'catalog:' + version: 8.0.8(patch_hash=a79d76bba22db0c17e49dcf55066e27a8c040d83a644d37fd22281b9f7bf2c51) packages/lib: dependencies: - '@playground/utils': + utils: specifier: workspace:* version: link:../utils packages/utils: {} + +packages: + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.124.0': + resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + + '@rolldown/binding-android-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.15': + resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.0.0-rc.15: + resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + vite@8.0.8: + resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.124.0': {} + + '@rolldown/binding-android-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.15': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + nanoid@3.3.11: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.0.0-rc.15: + dependencies: + '@oxc-project/types': 0.124.0 + '@rolldown/pluginutils': 1.0.0-rc.15 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-x64': 1.0.0-rc.15 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tslib@2.8.1: + optional: true + + vite@8.0.8(patch_hash=a79d76bba22db0c17e49dcf55066e27a8c040d83a644d37fd22281b9f7bf2c51): + dependencies: + '@voidzero-dev/vite-task-client': link:../packages/vite-task-client + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.10 + rolldown: 1.0.0-rc.15 + tinyglobby: 0.2.16 + optionalDependencies: + fsevents: 2.3.3 diff --git a/playground/pnpm-workspace.yaml b/playground/pnpm-workspace.yaml index 924b55f4..eca67dd6 100644 --- a/playground/pnpm-workspace.yaml +++ b/playground/pnpm-workspace.yaml @@ -1,2 +1,14 @@ packages: - packages/* + - ../packages/vite-task-client + +catalog: + vite: ^8.0.8 + +packageExtensions: + vite: + dependencies: + '@voidzero-dev/vite-task-client': workspace:* + +patchedDependencies: + vite: ../patches/vite.patch diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d72f0b5b..e117ddf4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,16 @@ catalogs: oxlint-tsgolint: specifier: ^0.18.0 version: 0.18.1 + vite: + specifier: ^8.0.8 + version: 8.0.8 + +packageExtensionsChecksum: sha256-0p1nijyIYmO20Zod8fYruINNF8yHQutWS96PraqUHIA= + +patchedDependencies: + vite: + hash: a79d76bba22db0c17e49dcf55066e27a8c040d83a644d37fd22281b9f7bf2c51 + path: patches/vite.patch importers: @@ -62,12 +72,35 @@ importers: oxlint-tsgolint: specifier: 'catalog:' version: 0.18.1 + vite: + specifier: 'catalog:' + version: 8.0.8(patch_hash=a79d76bba22db0c17e49dcf55066e27a8c040d83a644d37fd22281b9f7bf2c51)(@types/node@25.0.3)(yaml@2.8.2) + + packages/vite-task-client: {} packages: + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.124.0': + resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + '@oxfmt/binding-android-arm-eabi@0.42.0': resolution: {integrity: sha512-dsqPTYsozeokRjlrt/b4E7Pj0z3eS3Eg74TWQuuKbjY4VttBmA88rB7d50Xrd+TZ986qdXCNeZRPEzZHAe+jow==} engines: {node: ^20.19.0 || >=22.12.0} @@ -326,6 +359,101 @@ packages: cpu: [x64] os: [win32] + '@rolldown/binding-android-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.15': + resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} @@ -369,6 +497,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -379,10 +511,24 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + get-east-asian-width@1.5.0: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} @@ -403,6 +549,76 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lint-staged@16.3.3: resolution: {integrity: sha512-RLq2koZ5fGWrx7tcqx2tSTMQj4lRkfNJaebO/li/uunhCJbtZqwTuwPHpgIimAHHi/2nZIiGrkCHDCOeR1onxA==} engines: {node: '>=20.17'} @@ -424,6 +640,11 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -451,10 +672,21 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + engines: {node: ^10 || ^12 || >=14} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -462,6 +694,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown@1.0.0-rc.15: + resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -482,6 +719,10 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -502,6 +743,10 @@ packages: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + tinypool@2.1.0: resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} engines: {node: ^20.0.0 || >=22.0.0} @@ -510,9 +755,55 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + vite@8.0.8: + resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -529,8 +820,33 @@ packages: snapshots: + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@epic-web/invariant@1.0.0': {} + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@oxc-project/types@0.124.0': {} + '@oxfmt/binding-android-arm-eabi@0.42.0': optional: true @@ -663,6 +979,62 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.55.0': optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.15': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + '@types/node@25.0.3': dependencies: undici-types: 7.16.0 @@ -703,16 +1075,25 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + detect-libc@2.1.2: {} + emoji-regex@10.6.0: {} environment@1.1.0: {} eventemitter3@5.0.4: {} + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + fsevents@2.3.3: + optional: true + get-east-asian-width@1.5.0: {} husky@9.1.7: {} @@ -725,6 +1106,55 @@ snapshots: isexe@2.0.0: {} + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lint-staged@16.3.3: dependencies: commander: 14.0.3 @@ -758,6 +1188,8 @@ snapshots: mimic-function@5.0.1: {} + nanoid@3.3.11: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -820,8 +1252,18 @@ snapshots: path-key@3.1.1: {} + picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.4: {} + + postcss@8.5.10: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -829,6 +1271,27 @@ snapshots: rfdc@1.4.1: {} + rolldown@1.0.0-rc.15: + dependencies: + '@oxc-project/types': 0.124.0 + '@rolldown/pluginutils': 1.0.0-rc.15 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 + '@rolldown/binding-darwin-x64': 1.0.0-rc.15 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -847,6 +1310,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + source-map-js@1.2.1: {} + string-argv@0.3.2: {} string-width@7.2.0: @@ -866,14 +1331,35 @@ snapshots: tinyexec@1.0.2: {} + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@2.1.0: {} to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tslib@2.8.1: + optional: true + undici-types@7.16.0: {} + vite@8.0.8(patch_hash=a79d76bba22db0c17e49dcf55066e27a8c040d83a644d37fd22281b9f7bf2c51)(@types/node@25.0.3)(yaml@2.8.2): + dependencies: + '@voidzero-dev/vite-task-client': link:packages/vite-task-client + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.10 + rolldown: 1.0.0-rc.15 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.0.3 + fsevents: 2.3.3 + yaml: 2.8.2 + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7f2b69a6..5779ae47 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - . - packages/tools + - packages/vite-task-client catalog: '@types/node': 25.0.3 @@ -9,5 +10,14 @@ catalog: oxfmt: 0.42.0 oxlint: ^1.55.0 oxlint-tsgolint: ^0.18.0 + vite: ^8.0.8 catalogMode: prefer + +packageExtensions: + vite: + dependencies: + '@voidzero-dev/vite-task-client': workspace:* + +patchedDependencies: + vite: patches/vite.patch