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 bf11ea1b..e5839525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -448,7 +448,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -574,6 +574,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" @@ -720,8 +729,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]] @@ -730,6 +749,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" @@ -875,7 +900,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", @@ -954,6 +979,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" @@ -975,7 +1006,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]] @@ -984,6 +1024,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" @@ -1184,7 +1230,7 @@ dependencies = [ "bstr", "bumpalo", "csv-async", - "ctor", + "ctor 0.6.3", "derive_more", "flate2", "fspy_detours_sys", @@ -1243,7 +1289,7 @@ version = "0.0.0" dependencies = [ "anyhow", "bstr", - "ctor", + "ctor 0.6.3", "fspy_shared", "fspy_shared_unix", "libc", @@ -1294,7 +1340,7 @@ dependencies = [ "bitflags 2.10.0", "bstr", "bytemuck", - "ctor", + "ctor 0.6.3", "native_str", "os_str_bytes", "rustc-hash", @@ -1605,6 +1651,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" @@ -1736,6 +1797,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.12" @@ -1947,6 +2018,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.10.0", + "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.27", + "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" @@ -2019,6 +2147,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" @@ -2615,7 +2749,7 @@ name = "pty_terminal" version = "0.0.0" dependencies = [ "anyhow", - "ctor", + "ctor 0.6.3", "ctrlc", "nix 0.30.1", "ntest", @@ -2632,7 +2766,7 @@ version = "0.0.0" dependencies = [ "anyhow", "crossterm", - "ctor", + "ctor 0.6.3", "ntest", "portable-pty", "pty_terminal", @@ -2823,6 +2957,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" @@ -3289,7 +3429,7 @@ name = "subprocess_test" version = "0.0.0" dependencies = [ "base64", - "ctor", + "ctor 0.6.3", "fspy", "portable-pty", "rustc-hash", @@ -3999,6 +4139,32 @@ 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", + "rustc-hash", + "tokio", + "vite_path", + "vite_str", + "vite_task_client", + "vite_task_server", +] + [[package]] name = "vite_task_graph" version = "0.1.0" @@ -4022,6 +4188,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" @@ -4059,6 +4233,25 @@ dependencies = [ "wincode", ] +[[package]] +name = "vite_task_server" +version = "0.0.0" +dependencies = [ + "futures", + "interprocess", + "native_str", + "rustc-hash", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "uuid", + "vite_path", + "vite_task_client", + "vite_task_ipc_shared", + "wincode", +] + [[package]] name = "vite_tui" version = "0.0.0" @@ -4440,13 +4633,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]] @@ -4458,6 +4660,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" @@ -4465,16 +4683,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" @@ -4487,6 +4711,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" @@ -4499,12 +4729,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" @@ -4517,6 +4759,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" @@ -4529,12 +4777,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" @@ -4547,6 +4807,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 85c0f40f..7d4a5693 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,14 @@ 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_ipc_shared = { path = "crates/vite_task_ipc_shared" } +vite_task_server = { path = "crates/vite_task_server" } [workspace.metadata.cargo-shear] ignored = [ diff --git a/crates/vite_task_client/Cargo.toml b/crates/vite_task_client/Cargo.toml new file mode 100644 index 00000000..8d400b5d --- /dev/null +++ b/crates/vite_task_client/Cargo.toml @@ -0,0 +1,20 @@ +[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 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..d2abbf42 --- /dev/null +++ b/crates/vite_task_client/src/lib.rs @@ -0,0 +1,139 @@ +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::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) + } + + /// Connects to a server at the given socket path (Unix) or pipe name (Windows). + /// + /// # Errors + /// + /// Returns an error if the connection cannot be established. + pub fn from_name(name: &OsStr) -> io::Result { + let stream = Stream::connect(resolve_name(name)?)?; + Ok(Self::from_stream(stream)) + } + + 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. + /// + /// # Errors + /// + /// Returns an error if the request fails to send. + pub fn ignore_input(&self, path: &AbsolutePath) -> io::Result<()> { + let ns = path_to_native_str(path); + self.send(&Request::IgnoreInput(&ns)) + } + + /// `path` can be a file or a directory; for a directory, all files inside it are ignored. + /// + /// # Errors + /// + /// Returns an error if the request fails to send. + pub fn ignore_output(&self, path: &AbsolutePath) -> io::Result<()> { + let ns = path_to_native_str(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 path_to_native_str(path: &AbsolutePath) -> Box { + Box::::from(path.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..840b4d87 --- /dev/null +++ b/crates/vite_task_client_napi/Cargo.toml @@ -0,0 +1,28 @@ +[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"] + +[dependencies] +napi = { workspace = true, features = ["napi6"] } +napi-derive = { workspace = true } +vite_path = { workspace = true } +vite_str = { workspace = true } +vite_task_client = { workspace = true } + +[build-dependencies] +napi-build = { workspace = true } + +[dev-dependencies] +rustc-hash = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros"] } +vite_task_server = { 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..9e668770 --- /dev/null +++ b/crates/vite_task_client_napi/src/lib.rs @@ -0,0 +1,93 @@ +//! 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::ffi::OsStr; + +use napi::{Env, Error, Result}; +use napi_derive::napi; +use vite_path::AbsolutePath; +use vite_task_client::Client; + +struct State { + client: Client, +} + +#[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", + ) + })?; + env.set_instance_data(State { client }, (), |_| {})?; + Ok(()) +} + +fn client(env: Env) -> Result<&'static Client> { + env.get_instance_data::()? + .map(|state| &state.client as &Client) + .ok_or_else(|| err_static("vp run client: module state missing")) +} + +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(env: Env, path: String) -> Result<()> { + let abs = AbsolutePath::new(&path) + .ok_or_else(|| err_string(vite_str::format!("path must be absolute: {path}")))?; + client(env)?.ignore_input(abs).map_err(|err| err_string(vite_str::format!("{err}"))) +} + +#[napi] +pub fn ignore_output(env: Env, path: String) -> Result<()> { + let abs = AbsolutePath::new(&path) + .ok_or_else(|| err_string(vite_str::format!("path must be absolute: {path}")))?; + client(env)?.ignore_output(abs).map_err(|err| err_string(vite_str::format!("{err}"))) +} + +#[napi] +pub fn disable_cache(env: Env) -> Result<()> { + client(env)?.disable_cache().map_err(|err| err_string(vite_str::format!("{err}"))) +} + +#[napi] +pub fn get_env(env: Env, name: String, tracked: bool) -> Result> { + let value = client(env)? + .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_client_napi/tests/e2e.rs b/crates/vite_task_client_napi/tests/e2e.rs new file mode 100644 index 00000000..6ca1d79d --- /dev/null +++ b/crates/vite_task_client_napi/tests/e2e.rs @@ -0,0 +1,95 @@ +//! End-to-end test for the node addon. Requires Node.js on PATH and the +//! `@voidzero-dev/vite-task-client` JS package + the built `.node` addon +//! on disk. +//! +//! Expected env vars: +//! - `VP_RUN_NODE_CLIENT_ADDON_PATH`: absolute path to the built `.node` dylib +//! (e.g. `target/release/libvite_task_client_napi.so` copied or symlinked +//! as `.node`). +//! - `VP_RUN_NODE_CLIENT_JS_PATH`: absolute path to +//! `packages/vite-task-client/index.js`. +//! +//! Both must be set or the test is silently skipped. + +use std::{ + ffi::{OsStr, OsString}, + process::Command, + sync::Arc, +}; + +use rustc_hash::FxHashMap; +use tokio::runtime::Builder; +use vite_task_server::{Recorder, ServerHandle, serve}; + +#[test] +#[ignore = "requires built .node addon and Node.js on PATH"] +fn addon_round_trip() { + let addon_path = std::env::var_os("VP_RUN_NODE_CLIENT_ADDON_PATH") + .expect("VP_RUN_NODE_CLIENT_ADDON_PATH must point at the built .node addon"); + let js_path = std::env::var_os("VP_RUN_NODE_CLIENT_JS_PATH") + .expect("VP_RUN_NODE_CLIENT_JS_PATH must point at packages/vite-task-client/index.js"); + + let mut envs: FxHashMap, Arc> = FxHashMap::default(); + envs.insert( + Arc::::from(OsStr::new("NODE_ENV")), + Arc::::from(OsStr::new("production")), + ); + + let rt = Builder::new_current_thread().enable_all().build().unwrap(); + let reports = rt.block_on(async move { + let recorder = Recorder::new(envs); + let (ipc_envs, ServerHandle { driver, stop_accepting }) = + serve(recorder).expect("bind server"); + let (ipc_name, ipc_value) = ipc_envs.into_iter().next().expect("one IPC env"); + let ipc_name: OsString = ipc_name.to_os_string(); + + let child = tokio::task::spawn_blocking(move || { + let js_path_str = js_path.to_str().expect("JS path is utf-8"); + let script = vite_str::format!( + "\ +import({js_path_literal:?}).then(m => {{\n\ + m.ignoreInput('/tmp/vp_run_test_in.txt');\n\ + m.ignoreOutput('/tmp/vp_run_test_out.txt');\n\ + m.disableCache();\n\ + m.fetchEnv('NODE_ENV');\n\ + if (process.env.NODE_ENV !== 'production') {{\n\ + console.error('expected production, got ' + process.env.NODE_ENV);\n\ + process.exit(1);\n\ + }}\n\ + m.fetchEnv('MISSING');\n\ + if (process.env.MISSING !== undefined) {{\n\ + console.error('MISSING should be undefined');\n\ + process.exit(1);\n\ + }}\n\ +}});\n", + js_path_literal = js_path_str + ); + let status = Command::new("node") + .arg("--input-type=module") + .arg("-e") + .arg(script.as_str()) + .env::<&OsStr, &OsStr>(&ipc_name, &ipc_value) + .env("VP_RUN_NODE_CLIENT_PATH", &addon_path) + .status() + .expect("spawn node"); + stop_accepting.signal(); + assert!(status.success(), "node exited with {status}"); + }); + + let (recorder, child_result) = tokio::join!(driver, child); + child_result.expect("node runner panicked"); + recorder.into_reports() + }); + + assert!(reports.cache_disabled, "disableCache should propagate"); + assert_eq!(reports.ignored_inputs.len(), 1, "ignoreInput should record one path"); + assert_eq!(reports.ignored_outputs.len(), 1, "ignoreOutput should record one path"); + + let node_env = reports.env_records.get(OsStr::new("NODE_ENV")).expect("NODE_ENV recorded"); + assert!(node_env.tracked); + assert_eq!(node_env.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()); +} 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..90bbf27b --- /dev/null +++ b/crates/vite_task_ipc_shared/Cargo.toml @@ -0,0 +1,17 @@ +[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 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..62bb4533 --- /dev/null +++ b/crates/vite_task_server/Cargo.toml @@ -0,0 +1,33 @@ +[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 } +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 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..1f0ac3e6 --- /dev/null +++ b/crates/vite_task_server/src/lib.rs @@ -0,0 +1,322 @@ +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 [`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. +/// +/// 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, H>, + pub stop_accepting: StopAccepting, +} + +/// One-shot 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. +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); + run(bound, &handler, run_stop).await; + handler.into_inner() + } + .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) { + let mut clients = FuturesUnordered::new(); + + // 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(()) = clients.next(), if !clients.is_empty() => {} + } + } + + // 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 clients.next().await.is_some() {} +} + +async fn handle_client(mut stream: Stream, handler: &RefCell) { + let mut buf = Vec::new(); + loop { + match read_frame(&mut stream, &mut buf).await { + Ok(()) => {} + Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => return, + Err(err) => { + tracing::warn!(?err, "vite_task_server: read error; closing client"); + return; + } + } + + let request: Request<'_> = match wincode::deserialize_exact(&buf) { + Ok(req) => req, + Err(err) => { + tracing::warn!(?err, "vite_task_server: invalid request; closing client"); + return; + } + }; + + match request { + Request::IgnoreInput(ns) => { + if let Some(path) = native_str_to_abs_path(ns) { + handler.borrow_mut().ignore_input(&path); + } + } + Request::IgnoreOutput(ns) => { + if let Some(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() }; + if let Err(err) = write_response(&mut stream, &response).await { + tracing::warn!(?err, "vite_task_server: write response failed"); + return; + } + } + } + } +} + +fn native_str_to_abs_path(ns: &NativeStr) -> Option> { + let os_str = ns.to_cow_os_str(); + AbsolutePath::new(&*os_str).map_or_else( + || { + tracing::warn!( + path = ?&*os_str, + "vite_task_server: dropping non-absolute path from client", + ); + None + }, + |abs| Some(Arc::from(abs)), + ) +} + +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..f859ce9c --- /dev/null +++ b/crates/vite_task_server/tests/integration.rs @@ -0,0 +1,124 @@ +use std::{ + ffi::{OsStr, OsString}, + sync::Arc, + thread, +}; + +use rustc_hash::FxHashMap; +use tokio::runtime::Builder; +use vite_path::AbsolutePathBuf; +use vite_task_client::Client; +use vite_task_server::{Recorder, Reports, ServerHandle, serve}; + +fn abs(path: &str) -> AbsolutePathBuf { + AbsolutePathBuf::new(path.into()).expect("absolute path literal") +} + +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) -> Reports +where + F: FnOnce(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 name = envs.into_iter().next().expect("serve should yield an IPC env").1; + + let client = async move { + tokio::task::spawn_blocking(move || client_work(name)) + .await + .expect("client work panicked"); + stop_accepting.signal(); + }; + + let (recorder, ()) = tokio::join!(driver, client); + recorder.into_reports() + }) +} + +#[test] +fn single_client_fire_and_forget() { + let reports = run_with_server(env_map(&[]), |name| { + let client = Client::from_name(&name).expect("connect"); + client.ignore_input(abs("/tmp/in.txt").as_absolute_path()).unwrap(); + client.ignore_output(abs("/tmp/out.txt").as_absolute_path()).unwrap(); + client.disable_cache().unwrap(); + }); + + 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("/tmp/in.txt")]); + assert_eq!(outputs, vec![OsStr::new("/tmp/out.txt")]); + assert!(reports.cache_disabled); +} + +#[test] +fn get_env_found_and_not_found() { + let reports = run_with_server(env_map(&[("NODE_ENV", "production")]), |name| { + let client = Client::from_name(&name).expect("connect"); + 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()); + }); + + 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")]), |name| { + let client = Client::from_name(&name).expect("connect"); + 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"))); + } + }); + + 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() { + let paths = ["/tmp/worker_0", "/tmp/worker_1", "/tmp/worker_2", "/tmp/worker_3"]; + let reports = run_with_server(env_map(&[("SHARED", "value")]), move |name| { + let threads: Vec<_> = paths + .iter() + .map(|path| { + let name = name.clone(); + let path = *path; + thread::spawn(move || { + let client = Client::from_name(&name).expect("connect"); + client.ignore_input(abs(path).as_absolute_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(); + } + }); + + 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"))); +} 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..812b8392 --- /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 respone 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..22e8169b --- /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/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/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..09238f95 --- /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/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7f2b69a6..cabb9428 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