From af4b1fbb8366145640c0bc150e3472650a213375 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 16:18:16 +0800 Subject: [PATCH 01/25] feat(js_runtime): add vite_js_runtime crate for JavaScript runtime management This crate provides functionality to download and cache JavaScript runtimes like Node.js. Features include: - Runtime spec parsing (e.g., "node@22.13.1") - Automatic platform detection (Linux/macOS/Windows + x64/arm64) - Download with retry logic and SHASUMS256.txt integrity verification - Caching to ~/.cache/vite/js_runtime/{runtime}/{version}/{platform}/ - Atomic operations for concurrent-safe downloads Also includes RFC document at rfcs/js-runtime.md describing the design. --- Cargo.lock | 235 +++++++++++- Cargo.toml | 2 + crates/vite_js_runtime/Cargo.toml | 39 ++ crates/vite_js_runtime/src/error.rs | 56 +++ crates/vite_js_runtime/src/lib.rs | 33 ++ crates/vite_js_runtime/src/node.rs | 146 ++++++++ crates/vite_js_runtime/src/platform.rs | 137 +++++++ crates/vite_js_runtime/src/runtime.rs | 471 +++++++++++++++++++++++++ rfcs/js-runtime.md | 415 ++++++++++++++++++++++ 9 files changed, 1533 insertions(+), 1 deletion(-) create mode 100644 crates/vite_js_runtime/Cargo.toml create mode 100644 crates/vite_js_runtime/src/error.rs create mode 100644 crates/vite_js_runtime/src/lib.rs create mode 100644 crates/vite_js_runtime/src/node.rs create mode 100644 crates/vite_js_runtime/src/platform.rs create mode 100644 crates/vite_js_runtime/src/runtime.rs create mode 100644 rfcs/js-runtime.md diff --git a/Cargo.lock b/Cargo.lock index 64745f0bd6..985e356d26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -525,7 +536,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.4.2", "cpufeatures", ] @@ -659,6 +670,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cached" version = "0.56.0" @@ -714,6 +734,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -767,6 +789,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.54" @@ -906,6 +938,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -967,6 +1005,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1210,6 +1263,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + [[package]] name = "deranged" version = "0.5.5" @@ -1277,6 +1336,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -2037,6 +2097,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "0.2.12" @@ -2416,6 +2485,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.46.0" @@ -2500,6 +2578,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -2639,6 +2727,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.180" @@ -2749,6 +2843,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" +dependencies = [ + "crc", + "sha2", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3911,6 +4015,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "peg" version = "0.8.5" @@ -4204,6 +4318,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -6195,6 +6315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -6519,6 +6640,12 @@ dependencies = [ "rand 0.9.2", ] +[[package]] +name = "typed-path" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e43ffa54726cdc9ea78392023ffe9fe9cf9ac779e1c6fcb0d23f9862e3879d20" + [[package]] name = "typedmap" version = "0.6.0" @@ -6859,6 +6986,30 @@ dependencies = [ "vite_workspace", ] +[[package]] +name = "vite_js_runtime" +version = "0.0.0" +dependencies = [ + "backon", + "directories", + "flate2", + "futures-util", + "hex", + "httpmock", + "reqwest", + "serde", + "sha2", + "tar", + "tempfile", + "test-log", + "thiserror 2.0.17", + "tokio", + "tracing", + "vite_path", + "vite_str", + "zip", +] + [[package]] name = "vite_migration" version = "0.0.0" @@ -7668,6 +7819,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] [[package]] name = "zerotrie" @@ -7702,8 +7867,76 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zip" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +dependencies = [ + "aes", + "bzip2", + "constant_time_eq 0.3.1", + "crc32fast", + "deflate64", + "flate2", + "generic-array", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zlib-rs" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index be1317ac43..3fc1d3ea15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,6 +149,7 @@ uuid = "1.17.0" vfs = "0.12.1" vite_command = { path = "crates/vite_command" } vite_error = { path = "crates/vite_error" } +vite_js_runtime = { path = "crates/vite_js_runtime" } vite_glob = { git = "ssh://git@github.com/voidzero-dev/vite-task.git", rev = "d1824f23d28fdac7024c80c25f8e84b24b4f7704" } vite_install = { path = "crates/vite_install" } vite_migration = { path = "crates/vite_migration" } @@ -160,6 +161,7 @@ walkdir = "2.5.0" wax = "0.6.0" which = "8.0.0" xxhash-rust = "0.8.15" +zip = "7.2" # oxc crates with the same version oxc = { version = "0.110.0", features = [ diff --git a/crates/vite_js_runtime/Cargo.toml b/crates/vite_js_runtime/Cargo.toml new file mode 100644 index 0000000000..68685815d0 --- /dev/null +++ b/crates/vite_js_runtime/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "vite_js_runtime" +version = "0.0.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +rust-version.workspace = true + +[dependencies] +backon = { workspace = true } +directories = { workspace = true } +flate2 = { workspace = true } +futures-util = { workspace = true } +hex = { workspace = true } +serde = { workspace = true, features = ["derive"] } +sha2 = { workspace = true } +tar = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +vite_path = { workspace = true } +vite_str = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +reqwest = { workspace = true, features = ["stream", "native-tls-vendored"] } +zip = { workspace = true } + +[target.'cfg(not(target_os = "windows"))'.dependencies] +reqwest = { workspace = true, features = ["stream", "rustls-tls"] } + +[dev-dependencies] +httpmock = { workspace = true } +tempfile = { workspace = true } +test-log = { workspace = true } + +[lints] +workspace = true diff --git a/crates/vite_js_runtime/src/error.rs b/crates/vite_js_runtime/src/error.rs new file mode 100644 index 0000000000..d5e943214e --- /dev/null +++ b/crates/vite_js_runtime/src/error.rs @@ -0,0 +1,56 @@ +use thiserror::Error; +use vite_str::Str; + +/// Errors that can occur during JavaScript runtime management +#[derive(Error, Debug)] +pub enum Error { + /// Invalid runtime specification format + #[error( + "Invalid runtime specification: {spec}. Expected format: 'runtime@version' (e.g., 'node@22.13.1')" + )] + InvalidRuntimeSpec { spec: Str }, + + /// Unsupported runtime type + #[error("Unsupported runtime type: {runtime}. Supported: node")] + UnsupportedRuntime { runtime: Str }, + + /// Version not found in official releases + #[error("Version {version} not found for {runtime}")] + VersionNotFound { runtime: Str, version: Str }, + + /// Platform not supported for this runtime + #[error("Platform {platform} is not supported for {runtime}")] + UnsupportedPlatform { platform: Str, runtime: Str }, + + /// Download failed after retries + #[error("Failed to download from {url}: {reason}")] + DownloadFailed { url: Str, reason: Str }, + + /// Hash verification failed (download corrupted) + #[error("Hash mismatch for {filename}: expected {expected}, got {actual}")] + HashMismatch { filename: Str, expected: Str, actual: Str }, + + /// Archive extraction failed + #[error("Failed to extract archive: {reason}")] + ExtractionFailed { reason: Str }, + + /// SHASUMS file parsing failed + #[error("Failed to parse SHASUMS256.txt: {reason}")] + ShasumsParseFailed { reason: Str }, + + /// Hash not found in SHASUMS file + #[error("Hash not found for {filename} in SHASUMS256.txt")] + HashNotFound { filename: Str }, + + /// IO error + #[error(transparent)] + Io(#[from] std::io::Error), + + /// HTTP request error + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + + /// Join error from tokio + #[error(transparent)] + JoinError(#[from] tokio::task::JoinError), +} diff --git a/crates/vite_js_runtime/src/lib.rs b/crates/vite_js_runtime/src/lib.rs new file mode 100644 index 0000000000..f4a1b90e40 --- /dev/null +++ b/crates/vite_js_runtime/src/lib.rs @@ -0,0 +1,33 @@ +//! JavaScript Runtime Management Library +//! +//! This crate provides functionality to download and cache JavaScript runtimes +//! like Node.js. It supports automatic platform detection, integrity verification +//! via SHASUMS256.txt, and atomic operations for concurrent-safe caching. +//! +//! # Example +//! +//! ```rust,ignore +//! use vite_js_runtime::{JsRuntimeType, download_runtime, parse_runtime_spec}; +//! +//! // Option 1: Direct download with known runtime type +//! let runtime = download_runtime(JsRuntimeType::Node, "22.13.1").await?; +//! println!("Node.js installed at: {}", runtime.get_binary_path()); +//! +//! // Option 2: Parse spec string first +//! let (runtime_type, version) = parse_runtime_spec("node@22.13.1")?; +//! let runtime = download_runtime(runtime_type, &version).await?; +//! ``` + +// Allow std types for internal use - this crate deals with file system and HTTP operations +// where std::path::Path and String are the natural types to use +#![allow(clippy::disallowed_types)] +#![allow(clippy::disallowed_macros)] + +mod error; +mod node; +mod platform; +mod runtime; + +pub use error::Error; +pub use platform::Platform; +pub use runtime::{JsRuntime, JsRuntimeType, download_runtime, parse_runtime_spec}; diff --git a/crates/vite_js_runtime/src/node.rs b/crates/vite_js_runtime/src/node.rs new file mode 100644 index 0000000000..cac3c573b4 --- /dev/null +++ b/crates/vite_js_runtime/src/node.rs @@ -0,0 +1,146 @@ +use crate::{Error, Platform}; + +/// Node.js distribution base URL +const NODE_DIST_URL: &str = "https://nodejs.org/dist"; + +/// Get the archive filename for a Node.js version on a specific platform +/// +/// # Arguments +/// * `version` - The Node.js version (e.g., "22.13.1") +/// * `platform` - The target platform +/// +/// # Returns +/// The archive filename (e.g., "node-v22.13.1-linux-x64.tar.gz") +pub fn get_archive_filename(version: &str, platform: Platform) -> String { + let platform_str = platform.node_platform_string(); + let ext = platform.archive_extension(); + format!("node-v{version}-{platform_str}.{ext}") +} + +/// Get the download URL for a Node.js archive +/// +/// # Arguments +/// * `version` - The Node.js version (e.g., "22.13.1") +/// * `platform` - The target platform +/// +/// # Returns +/// The full download URL +pub fn get_download_url(version: &str, platform: Platform) -> String { + let filename = get_archive_filename(version, platform); + format!("{NODE_DIST_URL}/v{version}/{filename}") +} + +/// Get the URL for SHASUMS256.txt for a Node.js version +/// +/// # Arguments +/// * `version` - The Node.js version (e.g., "22.13.1") +/// +/// # Returns +/// The SHASUMS256.txt URL +pub fn get_shasums_url(version: &str) -> String { + format!("{NODE_DIST_URL}/v{version}/SHASUMS256.txt") +} + +/// Parse SHASUMS256.txt content and extract the hash for a specific filename +/// +/// # Arguments +/// * `shasums_content` - The content of SHASUMS256.txt +/// * `filename` - The filename to find the hash for +/// +/// # Returns +/// The SHA256 hash for the filename +/// +/// # Format +/// Each line in SHASUMS256.txt is: ` ` +pub fn parse_shasums(shasums_content: &str, filename: &str) -> Result { + for line in shasums_content.lines() { + // Format: " " (two spaces between hash and filename) + let parts: Vec<&str> = line.splitn(2, " ").collect(); + if parts.len() == 2 { + let hash = parts[0].trim(); + let file = parts[1].trim(); + if file == filename { + return Ok(hash.to_string()); + } + } + } + + Err(Error::HashNotFound { filename: filename.into() }) +} + +/// Get the directory name inside the archive after extraction +/// +/// For Node.js, the archive contains a directory named like: +/// - Linux/macOS: `node-v22.13.1-linux-x64/` +/// - Windows: `node-v22.13.1-win-x64/` +pub fn get_extracted_dir_name(version: &str, platform: Platform) -> String { + let platform_str = platform.node_platform_string(); + format!("node-v{version}-{platform_str}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::{Arch, Os}; + + #[test] + fn test_get_archive_filename() { + let cases = [ + ( + "22.13.1", + Platform { os: Os::Linux, arch: Arch::X64 }, + "node-v22.13.1-linux-x64.tar.gz", + ), + ( + "22.13.1", + Platform { os: Os::Darwin, arch: Arch::Arm64 }, + "node-v22.13.1-darwin-arm64.tar.gz", + ), + ("22.13.1", Platform { os: Os::Windows, arch: Arch::X64 }, "node-v22.13.1-win-x64.zip"), + ]; + + for (version, platform, expected) in cases { + assert_eq!(get_archive_filename(version, platform), expected); + } + } + + #[test] + fn test_get_download_url() { + let platform = Platform { os: Os::Linux, arch: Arch::X64 }; + let url = get_download_url("22.13.1", platform); + assert_eq!(url, "https://nodejs.org/dist/v22.13.1/node-v22.13.1-linux-x64.tar.gz"); + } + + #[test] + fn test_get_shasums_url() { + let url = get_shasums_url("22.13.1"); + assert_eq!(url, "https://nodejs.org/dist/v22.13.1/SHASUMS256.txt"); + } + + #[test] + fn test_parse_shasums() { + let content = r"abc123def456 node-v22.13.1-linux-x64.tar.gz +789xyz000111 node-v22.13.1-darwin-arm64.tar.gz +fedcba987654 node-v22.13.1-win-x64.zip"; + + assert_eq!( + parse_shasums(content, "node-v22.13.1-linux-x64.tar.gz").unwrap(), + "abc123def456" + ); + assert_eq!( + parse_shasums(content, "node-v22.13.1-darwin-arm64.tar.gz").unwrap(), + "789xyz000111" + ); + assert_eq!(parse_shasums(content, "node-v22.13.1-win-x64.zip").unwrap(), "fedcba987654"); + + // Test missing filename + let result = parse_shasums(content, "nonexistent.tar.gz"); + assert!(result.is_err()); + } + + #[test] + fn test_get_extracted_dir_name() { + let platform = Platform { os: Os::Linux, arch: Arch::X64 }; + assert_eq!(get_extracted_dir_name("22.13.1", platform), "node-v22.13.1-linux-x64"); + } +} diff --git a/crates/vite_js_runtime/src/platform.rs b/crates/vite_js_runtime/src/platform.rs new file mode 100644 index 0000000000..3a189368d5 --- /dev/null +++ b/crates/vite_js_runtime/src/platform.rs @@ -0,0 +1,137 @@ +use std::fmt; + +/// Represents a platform (OS + architecture) combination +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Platform { + pub os: Os, + pub arch: Arch, +} + +/// Operating system +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Os { + Linux, + Darwin, + Windows, +} + +/// CPU architecture +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Arch { + X64, + Arm64, +} + +impl Platform { + /// Detect the current platform + #[must_use] + pub const fn current() -> Self { + Self { os: Os::current(), arch: Arch::current() } + } + + /// Get the platform string for Node.js distribution naming + /// e.g., "linux-x64", "darwin-arm64", "win-x64" + #[must_use] + pub fn node_platform_string(self) -> String { + let os = match self.os { + Os::Linux => "linux", + Os::Darwin => "darwin", + Os::Windows => "win", + }; + let arch = match self.arch { + Arch::X64 => "x64", + Arch::Arm64 => "arm64", + }; + format!("{os}-{arch}") + } + + /// Get the archive extension for this platform + #[must_use] + pub const fn archive_extension(self) -> &'static str { + match self.os { + Os::Windows => "zip", + Os::Linux | Os::Darwin => "tar.gz", + } + } +} + +impl fmt::Display for Platform { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.node_platform_string()) + } +} + +impl Os { + /// Detect the current operating system + #[must_use] + pub const fn current() -> Self { + #[cfg(target_os = "linux")] + { + Self::Linux + } + #[cfg(target_os = "macos")] + { + Self::Darwin + } + #[cfg(target_os = "windows")] + { + Self::Windows + } + } +} + +impl Arch { + /// Detect the current CPU architecture + #[must_use] + pub const fn current() -> Self { + #[cfg(target_arch = "x86_64")] + { + Self::X64 + } + #[cfg(target_arch = "aarch64")] + { + Self::Arm64 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_platform_detection() { + let platform = Platform::current(); + + // Just verify it doesn't panic and returns a valid platform + let platform_str = platform.node_platform_string(); + assert!(!platform_str.is_empty()); + + // Verify format is "os-arch" + let parts: Vec<&str> = platform_str.split('-').collect(); + assert_eq!(parts.len(), 2); + } + + #[test] + fn test_node_platform_strings() { + let cases = [ + (Platform { os: Os::Linux, arch: Arch::X64 }, "linux-x64"), + (Platform { os: Os::Linux, arch: Arch::Arm64 }, "linux-arm64"), + (Platform { os: Os::Darwin, arch: Arch::X64 }, "darwin-x64"), + (Platform { os: Os::Darwin, arch: Arch::Arm64 }, "darwin-arm64"), + (Platform { os: Os::Windows, arch: Arch::X64 }, "win-x64"), + (Platform { os: Os::Windows, arch: Arch::Arm64 }, "win-arm64"), + ]; + + for (platform, expected) in cases { + assert_eq!(platform.node_platform_string(), expected); + } + } + + #[test] + fn test_archive_extension() { + assert_eq!(Platform { os: Os::Linux, arch: Arch::X64 }.archive_extension(), "tar.gz"); + assert_eq!(Platform { os: Os::Darwin, arch: Arch::Arm64 }.archive_extension(), "tar.gz"); + assert_eq!(Platform { os: Os::Windows, arch: Arch::X64 }.archive_extension(), "zip"); + } +} diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs new file mode 100644 index 0000000000..ab9cf89e22 --- /dev/null +++ b/crates/vite_js_runtime/src/runtime.rs @@ -0,0 +1,471 @@ +use std::{fs::File, path::Path, time::Duration}; + +use backon::{ExponentialBuilder, Retryable}; +use directories::BaseDirs; +use flate2::read::GzDecoder; +use futures_util::StreamExt; +use sha2::{Digest, Sha256}; +use tar::Archive; +use tempfile::TempDir; +use tokio::{fs, io::AsyncWriteExt}; +use vite_path::{AbsolutePathBuf, current_dir}; +use vite_str::Str; + +use crate::{Error, Platform, node}; + +/// Supported JavaScript runtime types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsRuntimeType { + Node, + // Future: Bun, Deno +} + +impl std::fmt::Display for JsRuntimeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Node => write!(f, "node"), + } + } +} + +/// Represents a downloaded JavaScript runtime +#[derive(Debug)] +pub struct JsRuntime { + pub runtime_type: JsRuntimeType, + pub version: Str, + pub install_dir: AbsolutePathBuf, +} + +impl JsRuntime { + /// Get the path to the runtime binary (e.g., node, bun) + #[must_use] + pub fn get_binary_path(&self) -> AbsolutePathBuf { + match self.runtime_type { + JsRuntimeType::Node => { + #[cfg(target_os = "windows")] + { + self.install_dir.join("node.exe") + } + #[cfg(not(target_os = "windows"))] + { + self.install_dir.join("bin/node") + } + } + } + } + + /// Get the bin directory containing the runtime + #[must_use] + pub fn get_bin_prefix(&self) -> AbsolutePathBuf { + match self.runtime_type { + JsRuntimeType::Node => { + #[cfg(target_os = "windows")] + { + self.install_dir.clone() + } + #[cfg(not(target_os = "windows"))] + { + self.install_dir.join("bin") + } + } + } + } + + /// Get the runtime type + #[must_use] + pub const fn runtime_type(&self) -> JsRuntimeType { + self.runtime_type + } + + /// Get the version string + #[must_use] + pub fn version(&self) -> &str { + &self.version + } +} + +/// Parse a runtime specification string (e.g., "node@22.13.1") +/// +/// # Arguments +/// * `spec` - The runtime specification string +/// +/// # Returns +/// A tuple of (runtime type, version string) +/// +/// # Errors +/// Returns an error if the spec format is invalid or the runtime is unsupported +pub fn parse_runtime_spec(spec: &str) -> Result<(JsRuntimeType, String), Error> { + let parts: Vec<&str> = spec.splitn(2, '@').collect(); + if parts.len() != 2 { + return Err(Error::InvalidRuntimeSpec { spec: spec.into() }); + } + + let runtime_name = parts[0]; + let version = parts[1]; + + if version.is_empty() { + return Err(Error::InvalidRuntimeSpec { spec: spec.into() }); + } + + let runtime_type = match runtime_name { + "node" => JsRuntimeType::Node, + _ => return Err(Error::UnsupportedRuntime { runtime: runtime_name.into() }), + }; + + Ok((runtime_type, version.to_string())) +} + +/// Download and cache a JavaScript runtime +/// +/// # Arguments +/// * `runtime_type` - The type of runtime to download +/// * `version` - The exact version (e.g., "22.13.1") +/// +/// # Returns +/// A `JsRuntime` instance with the installation path +/// +/// # Errors +/// Returns an error if download, verification, or extraction fails +pub async fn download_runtime( + runtime_type: JsRuntimeType, + version: &str, +) -> Result { + match runtime_type { + JsRuntimeType::Node => download_node(version).await, + } +} + +/// Get the cache directory for JavaScript runtimes +fn get_cache_dir() -> Result { + let cache_dir = match BaseDirs::new() { + Some(dirs) => AbsolutePathBuf::new(dirs.cache_dir().to_path_buf()).unwrap(), + None => current_dir()?.join(".cache"), + }; + Ok(cache_dir.join("vite/js_runtime")) +} + +/// Download and cache Node.js +async fn download_node(version: &str) -> Result { + let platform = Platform::current(); + let cache_dir = get_cache_dir()?; + + // Cache path: $CACHE_DIR/vite/js_runtime/node/{version}/{platform}/ + let install_dir = cache_dir.join(format!("node/{version}/{platform}")); + + // Check if already cached + let binary_path = get_node_binary_path(&install_dir); + if tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { + tracing::debug!("Node.js {version} already cached at {install_dir:?}"); + return Ok(JsRuntime { + runtime_type: JsRuntimeType::Node, + version: version.into(), + install_dir, + }); + } + + tracing::info!("Downloading Node.js {version} for {platform}..."); + + // Get download URLs + let archive_filename = node::get_archive_filename(version, platform); + let download_url = node::get_download_url(version, platform); + let shasums_url = node::get_shasums_url(version); + + // Create temp directory for download + let temp_dir = TempDir::new()?; + let archive_path = temp_dir.path().join(&archive_filename); + + // Download SHASUMS256.txt and parse expected hash + let expected_hash = download_and_parse_shasums(&shasums_url, &archive_filename).await?; + + // Download archive + download_file(&download_url, &archive_path).await?; + + // Verify hash + verify_file_hash(&archive_path, &expected_hash, &archive_filename).await?; + + // Extract archive + let extracted_dir_name = node::get_extracted_dir_name(version, platform); + extract_archive(&archive_path, temp_dir.path(), platform).await?; + + // Move extracted directory to cache location + let extracted_path = temp_dir.path().join(&extracted_dir_name); + move_to_cache(&extracted_path, &install_dir).await?; + + tracing::info!("Node.js {version} installed at {install_dir:?}"); + + Ok(JsRuntime { runtime_type: JsRuntimeType::Node, version: version.into(), install_dir }) +} + +/// Get the Node.js binary path for a given install directory +fn get_node_binary_path(install_dir: &AbsolutePathBuf) -> AbsolutePathBuf { + #[cfg(target_os = "windows")] + { + install_dir.join("node.exe") + } + #[cfg(not(target_os = "windows"))] + { + install_dir.join("bin/node") + } +} + +/// Download SHASUMS256.txt and parse the expected hash for a filename +async fn download_and_parse_shasums(shasums_url: &str, filename: &str) -> Result { + tracing::debug!("Downloading SHASUMS256.txt from {shasums_url}"); + + let content = (|| async { reqwest::get(shasums_url).await?.text().await }) + .retry( + ExponentialBuilder::default() + .with_jitter() + .with_min_delay(Duration::from_millis(500)) + .with_max_times(3), + ) + .await + .map_err(|e| Error::DownloadFailed { + url: shasums_url.into(), + reason: e.to_string().into(), + })?; + + node::parse_shasums(&content, filename) +} + +/// Download a file with retry logic +async fn download_file(url: &str, target_path: &Path) -> Result<(), Error> { + tracing::debug!("Downloading {url} to {target_path:?}"); + + let response = (|| async { reqwest::get(url).await?.error_for_status() }) + .retry( + ExponentialBuilder::default() + .with_jitter() + .with_min_delay(Duration::from_millis(500)) + .with_max_times(3), + ) + .await + .map_err(|e| Error::DownloadFailed { url: url.into(), reason: e.to_string().into() })?; + + // Stream to file + let mut file = fs::File::create(target_path).await?; + let mut stream = response.bytes_stream(); + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result?; + file.write_all(&chunk).await?; + } + + file.flush().await?; + tracing::debug!("Download completed: {target_path:?}"); + + Ok(()) +} + +/// Verify file hash against expected SHA256 hash +async fn verify_file_hash( + file_path: &Path, + expected_hash: &str, + filename: &str, +) -> Result<(), Error> { + tracing::debug!("Verifying hash for {filename}"); + + let content = fs::read(file_path).await?; + + let mut hasher = Sha256::new(); + hasher.update(&content); + let actual_hash = hex::encode(hasher.finalize()); + + if actual_hash != expected_hash { + return Err(Error::HashMismatch { + filename: filename.into(), + expected: expected_hash.into(), + actual: actual_hash.into(), + }); + } + + tracing::debug!("Hash verification successful for {filename}"); + Ok(()) +} + +/// Extract archive based on platform +async fn extract_archive( + archive_path: &Path, + target_dir: &Path, + platform: Platform, +) -> Result<(), Error> { + let archive_path = archive_path.to_path_buf(); + let target_dir = target_dir.to_path_buf(); + let is_windows = platform.os == crate::platform::Os::Windows; + + tokio::task::spawn_blocking(move || { + if is_windows { + extract_zip(&archive_path, &target_dir) + } else { + extract_tar_gz(&archive_path, &target_dir) + } + }) + .await??; + + Ok(()) +} + +/// Extract a tar.gz archive +fn extract_tar_gz(archive_path: &Path, target_dir: &Path) -> Result<(), Error> { + tracing::debug!("Extracting tar.gz: {archive_path:?} to {target_dir:?}"); + + let file = File::open(archive_path)?; + let tar_stream = GzDecoder::new(file); + let mut archive = Archive::new(tar_stream); + archive.unpack(target_dir)?; + + tracing::debug!("Extraction completed"); + Ok(()) +} + +/// Extract a zip archive (Windows) +#[cfg(target_os = "windows")] +fn extract_zip(archive_path: &Path, target_dir: &Path) -> Result<(), Error> { + tracing::debug!("Extracting zip: {archive_path:?} to {target_dir:?}"); + + let file = File::open(archive_path)?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| Error::ExtractionFailed { reason: e.to_string().into() })?; + + archive + .extract(target_dir) + .map_err(|e| Error::ExtractionFailed { reason: e.to_string().into() })?; + + tracing::debug!("Extraction completed"); + Ok(()) +} + +#[cfg(not(target_os = "windows"))] +fn extract_zip(_archive_path: &Path, _target_dir: &Path) -> Result<(), Error> { + // This should never be called on non-Windows platforms + Err(Error::ExtractionFailed { reason: "Zip extraction not supported on this platform".into() }) +} + +/// Move extracted directory to cache location with atomic operations +async fn move_to_cache(source: &Path, target: &AbsolutePathBuf) -> Result<(), Error> { + // Create parent directory + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).await?; + } + + // If target already exists (race condition), check if it's valid + if fs::try_exists(target.as_path()).await.unwrap_or(false) { + tracing::debug!("Target already exists, assuming another process completed: {target:?}"); + return Ok(()); + } + + // Try atomic rename first + if fs::rename(source, target.as_path()).await.is_ok() { + return Ok(()); + } + + // If rename fails (cross-device), fall back to copy + copy_dir_recursive(source, target.as_path()).await?; + fs::remove_dir_all(source).await?; + + Ok(()) +} + +/// Recursively copy a directory +async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), Error> { + fs::create_dir_all(dst).await?; + + let mut entries = fs::read_dir(src).await?; + while let Some(entry) = entries.next_entry().await? { + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if entry.file_type().await?.is_dir() { + Box::pin(copy_dir_recursive(&src_path, &dst_path)).await?; + } else { + fs::copy(&src_path, &dst_path).await?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_runtime_spec_valid() { + let (runtime_type, version) = parse_runtime_spec("node@22.13.1").unwrap(); + assert_eq!(runtime_type, JsRuntimeType::Node); + assert_eq!(version, "22.13.1"); + } + + #[test] + fn test_parse_runtime_spec_invalid_no_at() { + let result = parse_runtime_spec("node22.13.1"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_runtime_spec_invalid_empty_version() { + let result = parse_runtime_spec("node@"); + assert!(result.is_err()); + } + + #[test] + fn test_parse_runtime_spec_unsupported_runtime() { + let result = parse_runtime_spec("unknown@1.0.0"); + assert!(result.is_err()); + } + + #[test] + fn test_js_runtime_type_display() { + assert_eq!(JsRuntimeType::Node.to_string(), "node"); + } + + /// Integration test that downloads a real Node.js version + /// Run with: cargo test -p vite_js_runtime -- --ignored + #[tokio::test] + #[ignore] + async fn test_download_node_integration() { + // Use a small, old version for faster download + let version = "20.18.0"; + + let runtime = download_runtime(JsRuntimeType::Node, version).await.unwrap(); + + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + assert_eq!(runtime.version(), version); + + // Verify the binary exists + let binary_path = runtime.get_binary_path(); + assert!(tokio::fs::try_exists(&binary_path).await.unwrap()); + + // Verify binary is executable by checking version + let output = tokio::process::Command::new(binary_path.as_path()) + .arg("--version") + .output() + .await + .unwrap(); + + assert!(output.status.success()); + let version_output = String::from_utf8_lossy(&output.stdout); + assert!(version_output.contains(version)); + } + + /// Test cache reuse - second call should be instant + #[tokio::test] + #[ignore] + async fn test_download_node_cache_reuse() { + let version = "20.18.0"; + + // First download + let runtime1 = download_runtime(JsRuntimeType::Node, version).await.unwrap(); + + // Second download should use cache + let start = std::time::Instant::now(); + let runtime2 = download_runtime(JsRuntimeType::Node, version).await.unwrap(); + let elapsed = start.elapsed(); + + // Cache hit should be very fast (< 100ms) + assert!(elapsed.as_millis() < 100, "Cache reuse took too long: {elapsed:?}"); + + // Should return same install directory + assert_eq!(runtime1.install_dir, runtime2.install_dir); + } +} diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md new file mode 100644 index 0000000000..80604620c5 --- /dev/null +++ b/rfcs/js-runtime.md @@ -0,0 +1,415 @@ +# RFC: JavaScript Runtime Management (`vite_js_runtime`) + +## Background + +Currently, vite-plus relies on the user's system-installed Node.js runtime. This creates several challenges: + +1. **Version inconsistency**: Different team members may have different Node.js versions installed, leading to subtle bugs and "works on my machine" issues +2. **CI/CD complexity**: Build pipelines need explicit Node.js version management +3. **No runtime pinning**: Projects cannot specify and enforce a specific Node.js version +4. **Future extensibility**: As alternatives like Bun and Deno mature, projects may want to use different runtimes + +The PackageManager implementation in `vite_install` successfully handles automatic downloading and caching of package managers (pnpm, yarn, npm). We can apply the same pattern to JavaScript runtimes. + +## Goals + +1. **Pure library design**: A library crate that receives runtime name and version as input, downloads and caches the runtime, and returns the installation path +2. **Cross-platform support**: Handle Windows, macOS, and Linux with appropriate binaries +3. **Consistent caching**: Use the same global cache directory pattern as PackageManager +4. **Extensible design**: Support Node.js initially, with architecture ready for Bun and Deno + +## Non-Goals (Initial Version) + +- Configuration auto-detection (no reading from package.json, .nvmrc, etc.) +- Managing multiple runtime versions simultaneously +- Providing a version manager CLI (like nvm/fnm) +- Supporting custom/unofficial Node.js builds + +## Input Format + +The library accepts runtime specification as a string parameter: + +``` +@ +``` + +### Examples + +| Runtime | Example | +|---|---| +| Node.js | `node@22.13.1` | +| Bun (future) | `bun@1.2.0` | +| Deno (future) | `deno@2.0.0` | + +Only exact versions are supported. Version aliases (like `latest` or `lts`) may be added in future versions. + +## Architecture + +### Crate Structure + +``` +crates/vite_js_runtime/ +├── Cargo.toml +└── src/ + ├── lib.rs # Public API exports + ├── runtime.rs # JsRuntime struct and core logic + ├── node.rs # Node.js specific implementation + ├── download.rs # Download and extraction utilities + └── platform.rs # Platform detection and binary selection +``` + +### Core Types + +```rust +/// Supported JavaScript runtime types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsRuntimeType { + Node, + // Future: Bun, Deno +} + +/// Represents a downloaded JavaScript runtime +pub struct JsRuntime { + pub runtime_type: JsRuntimeType, + pub version: Str, // Resolved version (e.g., "22.13.1") + pub install_dir: AbsolutePathBuf, +} +``` + +### Public API + +```rust +/// Parse a runtime specification string (e.g., "node@22.13.1") +pub fn parse_runtime_spec(spec: &str) -> Result<(JsRuntimeType, String), Error>; + +/// Download and cache a JavaScript runtime +/// Returns the JsRuntime with installation path +pub async fn download_runtime( + runtime_type: JsRuntimeType, + version: &str, // Exact version (e.g., "22.13.1") +) -> Result; + +impl JsRuntime { + /// Get the path to the runtime binary (e.g., node, bun) + pub fn get_binary_path(&self) -> AbsolutePathBuf; + + /// Get the bin directory containing the runtime + pub fn get_bin_prefix(&self) -> AbsolutePathBuf; + + /// Get the runtime type + pub fn runtime_type(&self) -> JsRuntimeType; + + /// Get the resolved version string (always exact, e.g., "22.13.1") + pub fn version(&self) -> &str; +} +``` + +### Usage Example + +```rust +use vite_js_runtime::{JsRuntimeType, download_runtime, parse_runtime_spec}; + +// Option 1: Direct download with known runtime type +let runtime = download_runtime(JsRuntimeType::Node, "22.13.1").await?; +println!("Node.js installed at: {}", runtime.get_binary_path()); + +// Option 2: Parse spec string first +let (runtime_type, version) = parse_runtime_spec("node@22.13.1")?; +let runtime = download_runtime(runtime_type, &version).await?; +println!("Version: {}", runtime.version()); // "22.13.1" +``` + +## Cache Directory Structure + +Following the PackageManager pattern: + +``` +$CACHE_DIR/vite/js_runtime/{runtime}/{version}/{platform}-{arch}/ +``` + +Examples: +- Linux x64: `~/.cache/vite/js_runtime/node/22.13.1/linux-x64/` +- macOS ARM: `~/Library/Caches/vite/js_runtime/node/22.13.1/darwin-arm64/` +- Windows x64: `%LOCALAPPDATA%\vite\js_runtime\node\22.13.1\win-x64\` + +### Platform Detection + +| OS | Architecture | Platform String | +|---|---|---| +| Linux | x64 | `linux-x64` | +| Linux | ARM64 | `linux-arm64` | +| macOS | x64 | `darwin-x64` | +| macOS | ARM64 | `darwin-arm64` | +| Windows | x64 | `win-x64` | +| Windows | ARM64 | `win-arm64` | + +## Download Sources + +### Node.js + +Official distribution from nodejs.org: + +``` +https://nodejs.org/dist/v{version}/node-v{version}-{platform}.{ext} +``` + +| Platform | Archive Format | Example | +|---|---|---| +| Linux | `.tar.gz` | `node-v22.13.1-linux-x64.tar.gz` | +| macOS | `.tar.gz` | `node-v22.13.1-darwin-arm64.tar.gz` | +| Windows | `.zip` | `node-v22.13.1-win-x64.zip` | + +### Integrity Verification + +Node.js provides SHASUMS256.txt for each release: +``` +https://nodejs.org/dist/v{version}/SHASUMS256.txt +``` + +The implementation verifies download integrity automatically: +1. Download SHASUMS256.txt for the target version +2. Parse and extract the SHA256 hash for the target archive filename +3. After downloading the archive, verify it against the expected hash +4. Fail with error if hash doesn't match (corrupted download) + +Example SHASUMS256.txt content: +``` +a1b2c3d4... node-v22.13.1-darwin-arm64.tar.gz +e5f6g7h8... node-v22.13.1-darwin-x64.tar.gz +i9j0k1l2... node-v22.13.1-linux-arm64.tar.gz +... +``` + +## Implementation Details + +### Download Flow + +``` +1. Receive runtime type and exact version as input + +2. Determine platform and architecture + └── Map to Node.js distribution naming + +3. Check cache for existing installation + └── If exists: return cached path + └── If not: continue to download + +4. Download with atomic operations + ├── Create temp directory + ├── Download SHASUMS256.txt and parse expected hash + ├── Download archive with retry logic + ├── Verify archive hash against SHASUMS256.txt + ├── Extract archive + ├── Acquire file lock (prevent concurrent installs) + └── Atomic rename to final location + +5. Return JsRuntime with install path +``` + +### Concurrent Download Protection + +Same pattern as PackageManager: +- Use tempfile for atomic operations +- File-based locking to prevent race conditions +- Check cache after acquiring lock (another process may have completed) + +## Integration with vite_install + +The `vite_install` crate can use `vite_js_runtime` to: +1. Ensure the correct Node.js version before running package manager commands +2. Use the managed Node.js to execute package manager binaries + +```rust +// Example integration in vite_install +use vite_js_runtime::{JsRuntimeType, download_runtime}; + +async fn run_with_managed_node( + node_version: &str, + args: &[&str], +) -> Result<(), Error> { + // Download/cache the runtime + let runtime = download_runtime(JsRuntimeType::Node, node_version).await?; + + // Use the managed Node.js binary + let node_path = runtime.get_binary_path(); + + // Execute command with managed Node.js + Command::new(node_path) + .args(args) + .spawn()? + .wait()?; + + Ok(()) +} +``` + +## Error Handling + +New error variants for `vite_error`: + +```rust +pub enum JsRuntimeError { + /// Invalid runtime specification format + InvalidRuntimeSpec { spec: String }, + + /// Unsupported runtime type + UnsupportedRuntime { runtime: String }, + + /// Version not found in official releases + VersionNotFound { runtime: String, version: String }, + + /// Platform not supported for this runtime + UnsupportedPlatform { platform: String, runtime: String }, + + /// Download failed after retries + DownloadFailed { url: String, reason: String }, + + /// Hash verification failed (download corrupted) + HashMismatch { expected: String, actual: String }, + + /// Archive extraction failed + ExtractionFailed { reason: String }, +} +``` + +## NAPI Binding + +Expose to JavaScript for use in the global CLI: + +```rust +// packages/global/binding/src/js_runtime.rs + +#[napi(object)] +pub struct JsRuntimeInfo { + pub runtime_type: String, + pub version: String, // Resolved exact version + pub binary_path: String, + pub bin_prefix: String, +} + +/// Download a JavaScript runtime by specification string +/// @param spec - Runtime specification (e.g., "node@22.13.1") +#[napi] +pub async fn download_js_runtime(spec: String) -> napi::Result { + let (runtime_type, version) = parse_runtime_spec(&spec)?; + let runtime = download_runtime(runtime_type, &version).await?; + + Ok(JsRuntimeInfo { + runtime_type: runtime.runtime_type().to_string(), + version: runtime.version().to_string(), + binary_path: runtime.get_binary_path().to_string(), + bin_prefix: runtime.get_bin_prefix().to_string(), + }) +} +``` + +### JavaScript Usage + +```typescript +import { downloadJsRuntime } from '@voidzero-dev/vite-plus-binding'; + +// Download specific version +const runtime = await downloadJsRuntime('node@22.13.1'); +console.log(runtime.binaryPath); // /Users/.../.cache/vite/js_runtime/node/22.13.1/darwin-arm64/bin/node +console.log(runtime.version); // "22.13.1" +``` + +## Testing Strategy + +### Unit Tests + +1. **Runtime spec parsing** + - Valid formats: `node@22.13.1` + - Invalid formats: `node`, `22.13.1`, `unknown@1.0.0`, `node@` + +2. **Platform detection** + - Test all supported platform/arch combinations + - Test mapping to Node.js distribution names + +3. **Cache path generation** + - Verify correct directory structure + +### Integration Tests + +1. **Download and cache** + - Download a specific Node.js version + - Verify binary exists and is executable + - Verify cache reuse on second call + +2. **Integrity verification** + - Test successful verification against SHASUMS256.txt + - Test failure when archive is corrupted (hash mismatch) + +3. **Concurrent downloads** + - Simulate multiple processes downloading same version + - Verify no corruption or conflicts + +## Design Decisions + +### 1. Pure Library vs. Configuration-Aware + +**Decision**: Pure library that receives runtime name and version as input. + +**Rationale**: +- Maximum flexibility - callers decide how to obtain the runtime specification +- No coupling to specific configuration formats (package.json, .nvmrc, etc.) +- Easier to test in isolation +- Clear single responsibility: download and cache runtimes + +### 2. Separate Crate vs. Extending vite_install + +**Decision**: Create a new `vite_js_runtime` crate. + +**Rationale**: +- Clear separation of concerns (runtime vs. package manager) +- Reusable by other crates without pulling in package manager logic +- Easier to maintain and test independently +- Follows existing crate organization pattern + +### 3. Version Specification Format + +**Decision**: Use `runtime@version` format with exact versions only. + +**Rationale**: +- Mirrors the established `packageManager` format +- Exact versions ensure reproducibility +- No network requests needed for version resolution +- Simpler implementation without caching complexity +- Version aliases can be added as a future enhancement + +### 4. Initial Node.js Only + +**Decision**: Support only Node.js in the initial version. + +**Rationale**: +- Node.js is the most widely used runtime +- Allows focused, well-tested implementation +- Architecture supports easy addition of Bun/Deno later +- Reduces initial complexity and scope + +## Future Enhancements + +1. **Version aliases**: Support `latest` and `lts` aliases with cached version index +2. **Bun support**: Add `bun@x.y.z` runtime option with Bun release downloads +3. **Deno support**: Add `deno@x.y.z` runtime option with Deno release downloads +4. **Version ranges**: Support semver ranges like `node@^22.0.0` +5. **Custom mirrors**: Support custom download URLs for corporate environments +6. **Offline mode**: Use cached versions without network access + +## Success Criteria + +1. ✅ Can download and cache Node.js by exact version specification +2. ✅ Works on Linux, macOS, and Windows (x64 and ARM64) +3. ✅ Verifies download integrity using SHASUMS256.txt +4. ✅ Handles concurrent downloads safely +5. ✅ Returns version and binary path +6. ✅ Exposed via NAPI for JavaScript CLI usage +7. ✅ Comprehensive test coverage + +## References + +- [Node.js Releases](https://nodejs.org/en/download/releases/) +- [Node.js Distribution Index](https://nodejs.org/dist/index.json) +- [Corepack (Node.js Package Manager Manager)](https://nodejs.org/api/corepack.html) +- [fnm (Fast Node Manager)](https://github.com/Schniz/fnm) +- [volta (JavaScript Tool Manager)](https://volta.sh/) From e1a7b00573a5aeb2865a9557d22b79cf1a010816 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 16:24:18 +0800 Subject: [PATCH 02/25] docs(rfc): remove NAPI Binding section from js-runtime RFC The vite_js_runtime crate is a pure Rust library and does not need NAPI bindings for JavaScript CLI usage. --- rfcs/js-runtime.md | 45 +-------------------------------------------- 1 file changed, 1 insertion(+), 44 deletions(-) diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 80604620c5..58bbf56cdc 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -272,48 +272,6 @@ pub enum JsRuntimeError { } ``` -## NAPI Binding - -Expose to JavaScript for use in the global CLI: - -```rust -// packages/global/binding/src/js_runtime.rs - -#[napi(object)] -pub struct JsRuntimeInfo { - pub runtime_type: String, - pub version: String, // Resolved exact version - pub binary_path: String, - pub bin_prefix: String, -} - -/// Download a JavaScript runtime by specification string -/// @param spec - Runtime specification (e.g., "node@22.13.1") -#[napi] -pub async fn download_js_runtime(spec: String) -> napi::Result { - let (runtime_type, version) = parse_runtime_spec(&spec)?; - let runtime = download_runtime(runtime_type, &version).await?; - - Ok(JsRuntimeInfo { - runtime_type: runtime.runtime_type().to_string(), - version: runtime.version().to_string(), - binary_path: runtime.get_binary_path().to_string(), - bin_prefix: runtime.get_bin_prefix().to_string(), - }) -} -``` - -### JavaScript Usage - -```typescript -import { downloadJsRuntime } from '@voidzero-dev/vite-plus-binding'; - -// Download specific version -const runtime = await downloadJsRuntime('node@22.13.1'); -console.log(runtime.binaryPath); // /Users/.../.cache/vite/js_runtime/node/22.13.1/darwin-arm64/bin/node -console.log(runtime.version); // "22.13.1" -``` - ## Testing Strategy ### Unit Tests @@ -403,8 +361,7 @@ console.log(runtime.version); // "22.13.1" 3. ✅ Verifies download integrity using SHASUMS256.txt 4. ✅ Handles concurrent downloads safely 5. ✅ Returns version and binary path -6. ✅ Exposed via NAPI for JavaScript CLI usage -7. ✅ Comprehensive test coverage +6. ✅ Comprehensive test coverage ## References From de4a1ffd00996077baa48b79c99c91e8c6493ea6 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 16:26:54 +0800 Subject: [PATCH 03/25] test(js_runtime): enable integration tests by default Remove #[ignore] from integration tests so they run with the regular test suite. The tests download a real Node.js version and verify cache behavior. --- crates/vite_js_runtime/src/runtime.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index ab9cf89e22..85f55584e7 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -420,9 +420,7 @@ mod tests { } /// Integration test that downloads a real Node.js version - /// Run with: cargo test -p vite_js_runtime -- --ignored #[tokio::test] - #[ignore] async fn test_download_node_integration() { // Use a small, old version for faster download let version = "20.18.0"; @@ -450,7 +448,6 @@ mod tests { /// Test cache reuse - second call should be instant #[tokio::test] - #[ignore] async fn test_download_node_cache_reuse() { let version = "20.18.0"; From 25950d28a56eb460e9796682737d80ff3b2ed747 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 17:21:47 +0800 Subject: [PATCH 04/25] feat(js_runtime): add file-based locking for concurrent download protection Implement file-based locking in move_to_cache() to prevent race conditions when multiple processes/threads try to install the same runtime version concurrently. This follows the same pattern used in vite_install's package_manager.rs. Changes: - Add version-specific lock file ({version}.lock) acquisition before move - Use tokio::task::spawn_blocking to wrap blocking File::lock() call - Re-check target existence after acquiring lock (another process may complete) - Add test_concurrent_downloads() to verify no corruption with 4 parallel tasks --- crates/vite_js_runtime/src/runtime.rs | 119 ++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 9 deletions(-) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 85f55584e7..3506f0178c 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -187,9 +187,9 @@ async fn download_node(version: &str) -> Result { let extracted_dir_name = node::get_extracted_dir_name(version, platform); extract_archive(&archive_path, temp_dir.path(), platform).await?; - // Move extracted directory to cache location + // Move extracted directory to cache location with file-based locking let extracted_path = temp_dir.path().join(&extracted_dir_name); - move_to_cache(&extracted_path, &install_dir).await?; + move_to_cache(&extracted_path, &install_dir, version).await?; tracing::info!("Node.js {version} installed at {install_dir:?}"); @@ -341,25 +341,55 @@ fn extract_zip(_archive_path: &Path, _target_dir: &Path) -> Result<(), Error> { Err(Error::ExtractionFailed { reason: "Zip extraction not supported on this platform".into() }) } -/// Move extracted directory to cache location with atomic operations -async fn move_to_cache(source: &Path, target: &AbsolutePathBuf) -> Result<(), Error> { +/// Move extracted directory to cache location with atomic operations and file-based locking +/// +/// Uses a file-based lock to ensure atomicity when multiple processes/threads +/// try to install the same runtime version concurrently. +async fn move_to_cache( + source: &Path, + target: &AbsolutePathBuf, + version: &str, +) -> Result<(), Error> { // Create parent directory - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).await?; - } + let parent = target.parent().ok_or_else(|| Error::ExtractionFailed { + reason: "Target path has no parent directory".into(), + })?; + fs::create_dir_all(&parent).await?; + + // Use a file-based lock to ensure atomicity of the move operation. + // This prevents race conditions when multiple processes/threads + // try to install the same runtime version concurrently. + let lock_path = parent.join(format!("{version}.lock")); + tracing::debug!("Acquiring lock file: {lock_path:?}"); + + // Acquire file lock in a blocking task to avoid blocking the async runtime. + // The lock() call blocks until the lock is acquired. + let lock_path_clone = lock_path.clone(); + tokio::task::spawn_blocking(move || { + let lock_file = File::create(lock_path_clone.as_path())?; + // Acquire exclusive lock (blocks until available) + lock_file.lock()?; + tracing::debug!("Lock acquired: {lock_path_clone:?}"); + Ok::<_, std::io::Error>(lock_file) + }) + .await??; + tracing::debug!("Lock acquired: {lock_path:?}"); - // If target already exists (race condition), check if it's valid + // Check again after acquiring the lock, in case another process completed + // the installation while we were downloading if fs::try_exists(target.as_path()).await.unwrap_or(false) { - tracing::debug!("Target already exists, assuming another process completed: {target:?}"); + tracing::debug!("Target already exists after lock acquisition, skipping move: {target:?}"); return Ok(()); } // Try atomic rename first if fs::rename(source, target.as_path()).await.is_ok() { + tracing::debug!("Atomic rename successful: {source:?} -> {target:?}"); return Ok(()); } // If rename fails (cross-device), fall back to copy + tracing::debug!("Atomic rename failed, falling back to copy: {source:?} -> {target:?}"); copy_dir_recursive(source, target.as_path()).await?; fs::remove_dir_all(source).await?; @@ -465,4 +495,75 @@ mod tests { // Should return same install directory assert_eq!(runtime1.install_dir, runtime2.install_dir); } + + /// Test concurrent downloads - multiple tasks downloading the same version + /// should not cause corruption or conflicts due to file-based locking + #[tokio::test] + async fn test_concurrent_downloads() { + // Use a different version to avoid conflicts with other tests + let version = "20.17.0"; + + // Clear any existing cache for this version + let cache_dir = get_cache_dir().unwrap(); + let install_dir = cache_dir.join(format!("node/{version}")); + if tokio::fs::try_exists(&install_dir).await.unwrap_or(false) { + tokio::fs::remove_dir_all(&install_dir).await.unwrap(); + } + + // Spawn multiple concurrent download tasks + let num_concurrent = 4; + let mut handles = Vec::with_capacity(num_concurrent); + + for i in 0..num_concurrent { + let version = version.to_string(); + handles.push(tokio::spawn(async move { + tracing::info!("Starting concurrent download task {i}"); + let result = download_runtime(JsRuntimeType::Node, &version).await; + tracing::info!("Completed concurrent download task {i}"); + result + })); + } + + // Wait for all tasks and collect results + let mut results = Vec::with_capacity(num_concurrent); + for handle in handles { + results.push(handle.await.unwrap()); + } + + // All tasks should succeed + for (i, result) in results.iter().enumerate() { + assert!(result.is_ok(), "Task {i} failed: {:?}", result.as_ref().err()); + } + + // All tasks should return the same install directory + let first_install_dir = &results[0].as_ref().unwrap().install_dir; + for (i, result) in results.iter().enumerate().skip(1) { + assert_eq!( + &result.as_ref().unwrap().install_dir, + first_install_dir, + "Task {i} has different install_dir" + ); + } + + // Verify the binary works + let runtime = results.into_iter().next().unwrap().unwrap(); + let binary_path = runtime.get_binary_path(); + assert!( + tokio::fs::try_exists(&binary_path).await.unwrap(), + "Binary should exist at {binary_path:?}" + ); + + let output = tokio::process::Command::new(binary_path.as_path()) + .arg("--version") + .output() + .await + .unwrap(); + + assert!(output.status.success(), "Binary should be executable"); + let version_output = String::from_utf8_lossy(&output.stdout); + assert!( + version_output.contains(version), + "Version output should contain {version}, got: {version_output}" + ); + } } From e77e062bc37f4dd38b2c25d1ea151982cb58a089 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 17:24:48 +0800 Subject: [PATCH 05/25] chore(js_runtime): remove unused dependencies Remove unused dependencies detected by cargo-shear: - serde from dependencies - httpmock from dev-dependencies - test-log from dev-dependencies Add cargo-shear ignore for vite_js_runtime workspace dependency (pending integration with vite_install). --- Cargo.lock | 3 --- Cargo.toml | 4 ++++ crates/vite_js_runtime/Cargo.toml | 3 --- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 985e356d26..24e2fedd57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6995,13 +6995,10 @@ dependencies = [ "flate2", "futures-util", "hex", - "httpmock", "reqwest", - "serde", "sha2", "tar", "tempfile", - "test-log", "thiserror 2.0.17", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 3fc1d3ea15..b9e953ca13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,10 @@ resolver = "3" members = ["bench", "crates/*", "packages/cli/binding", "packages/global/binding"] +# Ignore vite_js_runtime - new crate pending integration with vite_install +[workspace.metadata.cargo-shear] +ignored = ["vite_js_runtime"] + [workspace.package] authors = ["Vite+ Authors"] edition = "2024" diff --git a/crates/vite_js_runtime/Cargo.toml b/crates/vite_js_runtime/Cargo.toml index 68685815d0..9d784125fb 100644 --- a/crates/vite_js_runtime/Cargo.toml +++ b/crates/vite_js_runtime/Cargo.toml @@ -13,7 +13,6 @@ directories = { workspace = true } flate2 = { workspace = true } futures-util = { workspace = true } hex = { workspace = true } -serde = { workspace = true, features = ["derive"] } sha2 = { workspace = true } tar = { workspace = true } tempfile = { workspace = true } @@ -31,9 +30,7 @@ zip = { workspace = true } reqwest = { workspace = true, features = ["stream", "rustls-tls"] } [dev-dependencies] -httpmock = { workspace = true } tempfile = { workspace = true } -test-log = { workspace = true } [lints] workspace = true From c91bf9f4ae56de8abd850b2a89f85d6d8c90413b Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 17:35:40 +0800 Subject: [PATCH 06/25] refactor(js_runtime): use vite_str::Str with targeted allow attributes Replace blanket #![allow(clippy::disallowed_types)] with targeted #[expect(clippy::disallowed_types)] on specific functions that require std::path::Path for external crate interop (tempfile, tar, zip). - Change return types to use vite_str::Str instead of String - Use vite_str::format! instead of std::format! - Add targeted expects only where std::path::Path is needed --- crates/vite_js_runtime/src/lib.rs | 5 --- crates/vite_js_runtime/src/node.rs | 22 +++++----- crates/vite_js_runtime/src/platform.rs | 6 ++- crates/vite_js_runtime/src/runtime.rs | 58 ++++++++++++++++---------- 4 files changed, 52 insertions(+), 39 deletions(-) diff --git a/crates/vite_js_runtime/src/lib.rs b/crates/vite_js_runtime/src/lib.rs index f4a1b90e40..ee94e3f797 100644 --- a/crates/vite_js_runtime/src/lib.rs +++ b/crates/vite_js_runtime/src/lib.rs @@ -18,11 +18,6 @@ //! let runtime = download_runtime(runtime_type, &version).await?; //! ``` -// Allow std types for internal use - this crate deals with file system and HTTP operations -// where std::path::Path and String are the natural types to use -#![allow(clippy::disallowed_types)] -#![allow(clippy::disallowed_macros)] - mod error; mod node; mod platform; diff --git a/crates/vite_js_runtime/src/node.rs b/crates/vite_js_runtime/src/node.rs index cac3c573b4..d67ba09365 100644 --- a/crates/vite_js_runtime/src/node.rs +++ b/crates/vite_js_runtime/src/node.rs @@ -1,3 +1,5 @@ +use vite_str::Str; + use crate::{Error, Platform}; /// Node.js distribution base URL @@ -11,10 +13,10 @@ const NODE_DIST_URL: &str = "https://nodejs.org/dist"; /// /// # Returns /// The archive filename (e.g., "node-v22.13.1-linux-x64.tar.gz") -pub fn get_archive_filename(version: &str, platform: Platform) -> String { +pub fn get_archive_filename(version: &str, platform: Platform) -> Str { let platform_str = platform.node_platform_string(); let ext = platform.archive_extension(); - format!("node-v{version}-{platform_str}.{ext}") + vite_str::format!("node-v{version}-{platform_str}.{ext}") } /// Get the download URL for a Node.js archive @@ -25,9 +27,9 @@ pub fn get_archive_filename(version: &str, platform: Platform) -> String { /// /// # Returns /// The full download URL -pub fn get_download_url(version: &str, platform: Platform) -> String { +pub fn get_download_url(version: &str, platform: Platform) -> Str { let filename = get_archive_filename(version, platform); - format!("{NODE_DIST_URL}/v{version}/{filename}") + vite_str::format!("{NODE_DIST_URL}/v{version}/{filename}") } /// Get the URL for SHASUMS256.txt for a Node.js version @@ -37,8 +39,8 @@ pub fn get_download_url(version: &str, platform: Platform) -> String { /// /// # Returns /// The SHASUMS256.txt URL -pub fn get_shasums_url(version: &str) -> String { - format!("{NODE_DIST_URL}/v{version}/SHASUMS256.txt") +pub fn get_shasums_url(version: &str) -> Str { + vite_str::format!("{NODE_DIST_URL}/v{version}/SHASUMS256.txt") } /// Parse SHASUMS256.txt content and extract the hash for a specific filename @@ -52,7 +54,7 @@ pub fn get_shasums_url(version: &str) -> String { /// /// # Format /// Each line in SHASUMS256.txt is: ` ` -pub fn parse_shasums(shasums_content: &str, filename: &str) -> Result { +pub fn parse_shasums(shasums_content: &str, filename: &str) -> Result { for line in shasums_content.lines() { // Format: " " (two spaces between hash and filename) let parts: Vec<&str> = line.splitn(2, " ").collect(); @@ -60,7 +62,7 @@ pub fn parse_shasums(shasums_content: &str, filename: &str) -> Result Result String { +pub fn get_extracted_dir_name(version: &str, platform: Platform) -> Str { let platform_str = platform.node_platform_string(); - format!("node-v{version}-{platform_str}") + vite_str::format!("node-v{version}-{platform_str}") } #[cfg(test)] diff --git a/crates/vite_js_runtime/src/platform.rs b/crates/vite_js_runtime/src/platform.rs index 3a189368d5..d3133bd847 100644 --- a/crates/vite_js_runtime/src/platform.rs +++ b/crates/vite_js_runtime/src/platform.rs @@ -1,5 +1,7 @@ use std::fmt; +use vite_str::Str; + /// Represents a platform (OS + architecture) combination #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Platform { @@ -32,7 +34,7 @@ impl Platform { /// Get the platform string for Node.js distribution naming /// e.g., "linux-x64", "darwin-arm64", "win-x64" #[must_use] - pub fn node_platform_string(self) -> String { + pub fn node_platform_string(self) -> Str { let os = match self.os { Os::Linux => "linux", Os::Darwin => "darwin", @@ -42,7 +44,7 @@ impl Platform { Arch::X64 => "x64", Arch::Arm64 => "arm64", }; - format!("{os}-{arch}") + vite_str::format!("{os}-{arch}") } /// Get the archive extension for this platform diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 3506f0178c..222b0748fe 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -1,4 +1,4 @@ -use std::{fs::File, path::Path, time::Duration}; +use std::{fs::File, time::Duration}; use backon::{ExponentialBuilder, Retryable}; use directories::BaseDirs; @@ -94,7 +94,7 @@ impl JsRuntime { /// /// # Errors /// Returns an error if the spec format is invalid or the runtime is unsupported -pub fn parse_runtime_spec(spec: &str) -> Result<(JsRuntimeType, String), Error> { +pub fn parse_runtime_spec(spec: &str) -> Result<(JsRuntimeType, Str), Error> { let parts: Vec<&str> = spec.splitn(2, '@').collect(); if parts.len() != 2 { return Err(Error::InvalidRuntimeSpec { spec: spec.into() }); @@ -112,7 +112,7 @@ pub fn parse_runtime_spec(spec: &str) -> Result<(JsRuntimeType, String), Error> _ => return Err(Error::UnsupportedRuntime { runtime: runtime_name.into() }), }; - Ok((runtime_type, version.to_string())) + Ok((runtime_type, version.into())) } /// Download and cache a JavaScript runtime @@ -150,7 +150,7 @@ async fn download_node(version: &str) -> Result { let cache_dir = get_cache_dir()?; // Cache path: $CACHE_DIR/vite/js_runtime/node/{version}/{platform}/ - let install_dir = cache_dir.join(format!("node/{version}/{platform}")); + let install_dir = cache_dir.join(vite_str::format!("node/{version}/{platform}")); // Check if already cached let binary_path = get_node_binary_path(&install_dir); @@ -209,7 +209,7 @@ fn get_node_binary_path(install_dir: &AbsolutePathBuf) -> AbsolutePathBuf { } /// Download SHASUMS256.txt and parse the expected hash for a filename -async fn download_and_parse_shasums(shasums_url: &str, filename: &str) -> Result { +async fn download_and_parse_shasums(shasums_url: &str, filename: &str) -> Result { tracing::debug!("Downloading SHASUMS256.txt from {shasums_url}"); let content = (|| async { reqwest::get(shasums_url).await?.text().await }) @@ -222,14 +222,15 @@ async fn download_and_parse_shasums(shasums_url: &str, filename: &str) -> Result .await .map_err(|e| Error::DownloadFailed { url: shasums_url.into(), - reason: e.to_string().into(), + reason: vite_str::format!("{e}"), })?; node::parse_shasums(&content, filename) } /// Download a file with retry logic -async fn download_file(url: &str, target_path: &Path) -> Result<(), Error> { +#[expect(clippy::disallowed_types)] // std::path::Path required for tempfile interop +async fn download_file(url: &str, target_path: &std::path::Path) -> Result<(), Error> { tracing::debug!("Downloading {url} to {target_path:?}"); let response = (|| async { reqwest::get(url).await?.error_for_status() }) @@ -240,7 +241,7 @@ async fn download_file(url: &str, target_path: &Path) -> Result<(), Error> { .with_max_times(3), ) .await - .map_err(|e| Error::DownloadFailed { url: url.into(), reason: e.to_string().into() })?; + .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; // Stream to file let mut file = fs::File::create(target_path).await?; @@ -258,8 +259,9 @@ async fn download_file(url: &str, target_path: &Path) -> Result<(), Error> { } /// Verify file hash against expected SHA256 hash +#[expect(clippy::disallowed_types)] // std::path::Path required for tempfile interop async fn verify_file_hash( - file_path: &Path, + file_path: &std::path::Path, expected_hash: &str, filename: &str, ) -> Result<(), Error> { @@ -269,13 +271,13 @@ async fn verify_file_hash( let mut hasher = Sha256::new(); hasher.update(&content); - let actual_hash = hex::encode(hasher.finalize()); + let actual_hash: Str = hex::encode(hasher.finalize()).into(); if actual_hash != expected_hash { return Err(Error::HashMismatch { filename: filename.into(), expected: expected_hash.into(), - actual: actual_hash.into(), + actual: actual_hash, }); } @@ -284,9 +286,10 @@ async fn verify_file_hash( } /// Extract archive based on platform +#[expect(clippy::disallowed_types)] // std::path::Path required for tempfile/tar/zip interop async fn extract_archive( - archive_path: &Path, - target_dir: &Path, + archive_path: &std::path::Path, + target_dir: &std::path::Path, platform: Platform, ) -> Result<(), Error> { let archive_path = archive_path.to_path_buf(); @@ -306,7 +309,11 @@ async fn extract_archive( } /// Extract a tar.gz archive -fn extract_tar_gz(archive_path: &Path, target_dir: &Path) -> Result<(), Error> { +#[expect(clippy::disallowed_types)] // std::path::Path required for tar crate interop +fn extract_tar_gz( + archive_path: &std::path::Path, + target_dir: &std::path::Path, +) -> Result<(), Error> { tracing::debug!("Extracting tar.gz: {archive_path:?} to {target_dir:?}"); let file = File::open(archive_path)?; @@ -320,23 +327,28 @@ fn extract_tar_gz(archive_path: &Path, target_dir: &Path) -> Result<(), Error> { /// Extract a zip archive (Windows) #[cfg(target_os = "windows")] -fn extract_zip(archive_path: &Path, target_dir: &Path) -> Result<(), Error> { +#[expect(clippy::disallowed_types)] // std::path::Path required for zip crate interop +fn extract_zip(archive_path: &std::path::Path, target_dir: &std::path::Path) -> Result<(), Error> { tracing::debug!("Extracting zip: {archive_path:?} to {target_dir:?}"); let file = File::open(archive_path)?; let mut archive = zip::ZipArchive::new(file) - .map_err(|e| Error::ExtractionFailed { reason: e.to_string().into() })?; + .map_err(|e| Error::ExtractionFailed { reason: vite_str::format!("{e}") })?; archive .extract(target_dir) - .map_err(|e| Error::ExtractionFailed { reason: e.to_string().into() })?; + .map_err(|e| Error::ExtractionFailed { reason: vite_str::format!("{e}") })?; tracing::debug!("Extraction completed"); Ok(()) } #[cfg(not(target_os = "windows"))] -fn extract_zip(_archive_path: &Path, _target_dir: &Path) -> Result<(), Error> { +#[expect(clippy::disallowed_types)] // std::path::Path for signature consistency +fn extract_zip( + _archive_path: &std::path::Path, + _target_dir: &std::path::Path, +) -> Result<(), Error> { // This should never be called on non-Windows platforms Err(Error::ExtractionFailed { reason: "Zip extraction not supported on this platform".into() }) } @@ -345,8 +357,9 @@ fn extract_zip(_archive_path: &Path, _target_dir: &Path) -> Result<(), Error> { /// /// Uses a file-based lock to ensure atomicity when multiple processes/threads /// try to install the same runtime version concurrently. +#[expect(clippy::disallowed_types)] // std::path::Path required for tempfile interop async fn move_to_cache( - source: &Path, + source: &std::path::Path, target: &AbsolutePathBuf, version: &str, ) -> Result<(), Error> { @@ -359,7 +372,7 @@ async fn move_to_cache( // Use a file-based lock to ensure atomicity of the move operation. // This prevents race conditions when multiple processes/threads // try to install the same runtime version concurrently. - let lock_path = parent.join(format!("{version}.lock")); + let lock_path = parent.join(vite_str::format!("{version}.lock")); tracing::debug!("Acquiring lock file: {lock_path:?}"); // Acquire file lock in a blocking task to avoid blocking the async runtime. @@ -397,7 +410,8 @@ async fn move_to_cache( } /// Recursively copy a directory -async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), Error> { +#[expect(clippy::disallowed_types)] // std::path::Path required for fs operations +async fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<(), Error> { fs::create_dir_all(dst).await?; let mut entries = fs::read_dir(src).await?; @@ -505,7 +519,7 @@ mod tests { // Clear any existing cache for this version let cache_dir = get_cache_dir().unwrap(); - let install_dir = cache_dir.join(format!("node/{version}")); + let install_dir = cache_dir.join(vite_str::format!("node/{version}")); if tokio::fs::try_exists(&install_dir).await.unwrap_or(false) { tokio::fs::remove_dir_all(&install_dir).await.unwrap(); } From 90651929082aa674656048d9749a0924be0b99e8 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 17:42:58 +0800 Subject: [PATCH 07/25] refactor(js_runtime): replace std::path::Path with vite_path::AbsolutePath Use project-standard path types instead of std::path::Path in helper functions, eliminating most #[expect(clippy::disallowed_types)] attributes. Changes: - Convert TempDir::path() to AbsolutePathBuf at the call site - Update download_file, verify_file_hash, extract_archive, extract_tar_gz, extract_zip, and move_to_cache to use &AbsolutePath - Keep copy_dir_recursive with std::path::Path due to recursive entry.path() --- crates/vite_js_runtime/src/runtime.rs | 49 +++++++++++---------------- 1 file changed, 19 insertions(+), 30 deletions(-) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 222b0748fe..69eea48ad0 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -8,7 +8,7 @@ use sha2::{Digest, Sha256}; use tar::Archive; use tempfile::TempDir; use tokio::{fs, io::AsyncWriteExt}; -use vite_path::{AbsolutePathBuf, current_dir}; +use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use vite_str::Str; use crate::{Error, Platform, node}; @@ -171,8 +171,10 @@ async fn download_node(version: &str) -> Result { let shasums_url = node::get_shasums_url(version); // Create temp directory for download + // TempDir::path() always returns an absolute path (e.g., /tmp/xxx) let temp_dir = TempDir::new()?; - let archive_path = temp_dir.path().join(&archive_filename); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let archive_path = temp_path.join(&archive_filename); // Download SHASUMS256.txt and parse expected hash let expected_hash = download_and_parse_shasums(&shasums_url, &archive_filename).await?; @@ -185,10 +187,10 @@ async fn download_node(version: &str) -> Result { // Extract archive let extracted_dir_name = node::get_extracted_dir_name(version, platform); - extract_archive(&archive_path, temp_dir.path(), platform).await?; + extract_archive(&archive_path, &temp_path, platform).await?; // Move extracted directory to cache location with file-based locking - let extracted_path = temp_dir.path().join(&extracted_dir_name); + let extracted_path = temp_path.join(&extracted_dir_name); move_to_cache(&extracted_path, &install_dir, version).await?; tracing::info!("Node.js {version} installed at {install_dir:?}"); @@ -229,8 +231,7 @@ async fn download_and_parse_shasums(shasums_url: &str, filename: &str) -> Result } /// Download a file with retry logic -#[expect(clippy::disallowed_types)] // std::path::Path required for tempfile interop -async fn download_file(url: &str, target_path: &std::path::Path) -> Result<(), Error> { +async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), Error> { tracing::debug!("Downloading {url} to {target_path:?}"); let response = (|| async { reqwest::get(url).await?.error_for_status() }) @@ -259,9 +260,8 @@ async fn download_file(url: &str, target_path: &std::path::Path) -> Result<(), E } /// Verify file hash against expected SHA256 hash -#[expect(clippy::disallowed_types)] // std::path::Path required for tempfile interop async fn verify_file_hash( - file_path: &std::path::Path, + file_path: &AbsolutePath, expected_hash: &str, filename: &str, ) -> Result<(), Error> { @@ -286,14 +286,13 @@ async fn verify_file_hash( } /// Extract archive based on platform -#[expect(clippy::disallowed_types)] // std::path::Path required for tempfile/tar/zip interop async fn extract_archive( - archive_path: &std::path::Path, - target_dir: &std::path::Path, + archive_path: &AbsolutePath, + target_dir: &AbsolutePath, platform: Platform, ) -> Result<(), Error> { - let archive_path = archive_path.to_path_buf(); - let target_dir = target_dir.to_path_buf(); + let archive_path = AbsolutePathBuf::new(archive_path.as_path().to_path_buf()).unwrap(); + let target_dir = AbsolutePathBuf::new(target_dir.as_path().to_path_buf()).unwrap(); let is_windows = platform.os == crate::platform::Os::Windows; tokio::task::spawn_blocking(move || { @@ -309,11 +308,7 @@ async fn extract_archive( } /// Extract a tar.gz archive -#[expect(clippy::disallowed_types)] // std::path::Path required for tar crate interop -fn extract_tar_gz( - archive_path: &std::path::Path, - target_dir: &std::path::Path, -) -> Result<(), Error> { +fn extract_tar_gz(archive_path: &AbsolutePath, target_dir: &AbsolutePath) -> Result<(), Error> { tracing::debug!("Extracting tar.gz: {archive_path:?} to {target_dir:?}"); let file = File::open(archive_path)?; @@ -327,8 +322,7 @@ fn extract_tar_gz( /// Extract a zip archive (Windows) #[cfg(target_os = "windows")] -#[expect(clippy::disallowed_types)] // std::path::Path required for zip crate interop -fn extract_zip(archive_path: &std::path::Path, target_dir: &std::path::Path) -> Result<(), Error> { +fn extract_zip(archive_path: &AbsolutePath, target_dir: &AbsolutePath) -> Result<(), Error> { tracing::debug!("Extracting zip: {archive_path:?} to {target_dir:?}"); let file = File::open(archive_path)?; @@ -344,11 +338,7 @@ fn extract_zip(archive_path: &std::path::Path, target_dir: &std::path::Path) -> } #[cfg(not(target_os = "windows"))] -#[expect(clippy::disallowed_types)] // std::path::Path for signature consistency -fn extract_zip( - _archive_path: &std::path::Path, - _target_dir: &std::path::Path, -) -> Result<(), Error> { +fn extract_zip(_archive_path: &AbsolutePath, _target_dir: &AbsolutePath) -> Result<(), Error> { // This should never be called on non-Windows platforms Err(Error::ExtractionFailed { reason: "Zip extraction not supported on this platform".into() }) } @@ -357,9 +347,8 @@ fn extract_zip( /// /// Uses a file-based lock to ensure atomicity when multiple processes/threads /// try to install the same runtime version concurrently. -#[expect(clippy::disallowed_types)] // std::path::Path required for tempfile interop async fn move_to_cache( - source: &std::path::Path, + source: &AbsolutePath, target: &AbsolutePathBuf, version: &str, ) -> Result<(), Error> { @@ -396,15 +385,15 @@ async fn move_to_cache( } // Try atomic rename first - if fs::rename(source, target.as_path()).await.is_ok() { + if fs::rename(source.as_path(), target.as_path()).await.is_ok() { tracing::debug!("Atomic rename successful: {source:?} -> {target:?}"); return Ok(()); } // If rename fails (cross-device), fall back to copy tracing::debug!("Atomic rename failed, falling back to copy: {source:?} -> {target:?}"); - copy_dir_recursive(source, target.as_path()).await?; - fs::remove_dir_all(source).await?; + copy_dir_recursive(source.as_path(), target.as_path()).await?; + fs::remove_dir_all(source.as_path()).await?; Ok(()) } From 19ed6e229d5adee79cdb1110c806181d6439ceb0 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 17:56:06 +0800 Subject: [PATCH 08/25] refactor(js_runtime): remove all disallowed_types expect attributes - Update copy_dir_recursive to use &AbsolutePath instead of &std::path::Path - Document vite_path usage patterns in CLAUDE.md --- CLAUDE.md | 10 ++++++ crates/vite_js_runtime/src/runtime.rs | 10 +++--- rfcs/js-runtime.md | 46 ++++++++++++++++----------- 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3039520c01..c2665526fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,16 @@ vite dev # runs dev script from package.json - Only convert to std paths when interfacing with std library functions, and this should be implicit in most cases thanks to `AsRef` implementations - Add necessary methods in `vite_path` instead of falling back to std path types +- **Converting from std paths** (e.g., `TempDir::path()`): + + ```rust + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + ``` + +- **Function signatures**: Prefer `&AbsolutePath` over `&std::path::Path` + +- **Passing to std functions**: `AbsolutePath` implements `AsRef`, use `.as_path()` when explicit `&Path` is required + ## Git Workflow - Run `vite fmt` before committing to format code diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 69eea48ad0..12e007c8c0 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -392,20 +392,20 @@ async fn move_to_cache( // If rename fails (cross-device), fall back to copy tracing::debug!("Atomic rename failed, falling back to copy: {source:?} -> {target:?}"); - copy_dir_recursive(source.as_path(), target.as_path()).await?; - fs::remove_dir_all(source.as_path()).await?; + copy_dir_recursive(source, target).await?; + fs::remove_dir_all(source).await?; Ok(()) } /// Recursively copy a directory -#[expect(clippy::disallowed_types)] // std::path::Path required for fs operations -async fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> Result<(), Error> { +async fn copy_dir_recursive(src: &AbsolutePath, dst: &AbsolutePath) -> Result<(), Error> { fs::create_dir_all(dst).await?; let mut entries = fs::read_dir(src).await?; while let Some(entry) = entries.next_entry().await? { - let src_path = entry.path(); + // entry.path() returns absolute path when src is absolute + let src_path = AbsolutePathBuf::new(entry.path()).unwrap(); let dst_path = dst.join(entry.file_name()); if entry.file_type().await?.is_dir() { diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 58bbf56cdc..d8458ae50d 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -35,11 +35,11 @@ The library accepts runtime specification as a string parameter: ### Examples -| Runtime | Example | -|---|---| -| Node.js | `node@22.13.1` | -| Bun (future) | `bun@1.2.0` | -| Deno (future) | `deno@2.0.0` | +| Runtime | Example | +| ------------- | -------------- | +| Node.js | `node@22.13.1` | +| Bun (future) | `bun@1.2.0` | +| Deno (future) | `deno@2.0.0` | Only exact versions are supported. Version aliases (like `latest` or `lts`) may be added in future versions. @@ -128,20 +128,21 @@ $CACHE_DIR/vite/js_runtime/{runtime}/{version}/{platform}-{arch}/ ``` Examples: + - Linux x64: `~/.cache/vite/js_runtime/node/22.13.1/linux-x64/` - macOS ARM: `~/Library/Caches/vite/js_runtime/node/22.13.1/darwin-arm64/` - Windows x64: `%LOCALAPPDATA%\vite\js_runtime\node\22.13.1\win-x64\` ### Platform Detection -| OS | Architecture | Platform String | -|---|---|---| -| Linux | x64 | `linux-x64` | -| Linux | ARM64 | `linux-arm64` | -| macOS | x64 | `darwin-x64` | -| macOS | ARM64 | `darwin-arm64` | -| Windows | x64 | `win-x64` | -| Windows | ARM64 | `win-arm64` | +| OS | Architecture | Platform String | +| ------- | ------------ | --------------- | +| Linux | x64 | `linux-x64` | +| Linux | ARM64 | `linux-arm64` | +| macOS | x64 | `darwin-x64` | +| macOS | ARM64 | `darwin-arm64` | +| Windows | x64 | `win-x64` | +| Windows | ARM64 | `win-arm64` | ## Download Sources @@ -153,26 +154,29 @@ Official distribution from nodejs.org: https://nodejs.org/dist/v{version}/node-v{version}-{platform}.{ext} ``` -| Platform | Archive Format | Example | -|---|---|---| -| Linux | `.tar.gz` | `node-v22.13.1-linux-x64.tar.gz` | -| macOS | `.tar.gz` | `node-v22.13.1-darwin-arm64.tar.gz` | -| Windows | `.zip` | `node-v22.13.1-win-x64.zip` | +| Platform | Archive Format | Example | +| -------- | -------------- | ----------------------------------- | +| Linux | `.tar.gz` | `node-v22.13.1-linux-x64.tar.gz` | +| macOS | `.tar.gz` | `node-v22.13.1-darwin-arm64.tar.gz` | +| Windows | `.zip` | `node-v22.13.1-win-x64.zip` | ### Integrity Verification Node.js provides SHASUMS256.txt for each release: + ``` https://nodejs.org/dist/v{version}/SHASUMS256.txt ``` The implementation verifies download integrity automatically: + 1. Download SHASUMS256.txt for the target version 2. Parse and extract the SHA256 hash for the target archive filename 3. After downloading the archive, verify it against the expected hash 4. Fail with error if hash doesn't match (corrupted download) Example SHASUMS256.txt content: + ``` a1b2c3d4... node-v22.13.1-darwin-arm64.tar.gz e5f6g7h8... node-v22.13.1-darwin-x64.tar.gz @@ -209,6 +213,7 @@ i9j0k1l2... node-v22.13.1-linux-arm64.tar.gz ### Concurrent Download Protection Same pattern as PackageManager: + - Use tempfile for atomic operations - File-based locking to prevent race conditions - Check cache after acquiring lock (another process may have completed) @@ -216,6 +221,7 @@ Same pattern as PackageManager: ## Integration with vite_install The `vite_install` crate can use `vite_js_runtime` to: + 1. Ensure the correct Node.js version before running package manager commands 2. Use the managed Node.js to execute package manager binaries @@ -309,6 +315,7 @@ pub enum JsRuntimeError { **Decision**: Pure library that receives runtime name and version as input. **Rationale**: + - Maximum flexibility - callers decide how to obtain the runtime specification - No coupling to specific configuration formats (package.json, .nvmrc, etc.) - Easier to test in isolation @@ -319,6 +326,7 @@ pub enum JsRuntimeError { **Decision**: Create a new `vite_js_runtime` crate. **Rationale**: + - Clear separation of concerns (runtime vs. package manager) - Reusable by other crates without pulling in package manager logic - Easier to maintain and test independently @@ -329,6 +337,7 @@ pub enum JsRuntimeError { **Decision**: Use `runtime@version` format with exact versions only. **Rationale**: + - Mirrors the established `packageManager` format - Exact versions ensure reproducibility - No network requests needed for version resolution @@ -340,6 +349,7 @@ pub enum JsRuntimeError { **Decision**: Support only Node.js in the initial version. **Rationale**: + - Node.js is the most widely used runtime - Allows focused, well-tested implementation - Architecture supports easy addition of Bun/Deno later From 8095c9cd5227e3c868f7bfd3547ffad77d4be934 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 18:00:00 +0800 Subject: [PATCH 09/25] docs(rfc): update js-runtime crate structure to match implementation --- rfcs/js-runtime.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index d8458ae50d..64c7194564 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -52,9 +52,9 @@ crates/vite_js_runtime/ ├── Cargo.toml └── src/ ├── lib.rs # Public API exports - ├── runtime.rs # JsRuntime struct and core logic + ├── runtime.rs # JsRuntime struct, download, and core logic ├── node.rs # Node.js specific implementation - ├── download.rs # Download and extraction utilities + ├── error.rs # Error types └── platform.rs # Platform detection and binary selection ``` From 063fbf99551971d496bc4bde6329196dd8852375 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 18:17:00 +0800 Subject: [PATCH 10/25] refactor(js_runtime): remove parse_runtime_spec function Remove unnecessary runtime spec parsing functionality: - Remove parse_runtime_spec function and related tests - Remove InvalidRuntimeSpec and UnsupportedRuntime error types - Update RFC to reflect simplified API --- crates/vite_js_runtime/src/error.rs | 10 ----- crates/vite_js_runtime/src/lib.rs | 9 +---- crates/vite_js_runtime/src/runtime.rs | 56 --------------------------- rfcs/js-runtime.md | 22 +---------- 4 files changed, 4 insertions(+), 93 deletions(-) diff --git a/crates/vite_js_runtime/src/error.rs b/crates/vite_js_runtime/src/error.rs index d5e943214e..1cc8d3d321 100644 --- a/crates/vite_js_runtime/src/error.rs +++ b/crates/vite_js_runtime/src/error.rs @@ -4,16 +4,6 @@ use vite_str::Str; /// Errors that can occur during JavaScript runtime management #[derive(Error, Debug)] pub enum Error { - /// Invalid runtime specification format - #[error( - "Invalid runtime specification: {spec}. Expected format: 'runtime@version' (e.g., 'node@22.13.1')" - )] - InvalidRuntimeSpec { spec: Str }, - - /// Unsupported runtime type - #[error("Unsupported runtime type: {runtime}. Supported: node")] - UnsupportedRuntime { runtime: Str }, - /// Version not found in official releases #[error("Version {version} not found for {runtime}")] VersionNotFound { runtime: Str, version: Str }, diff --git a/crates/vite_js_runtime/src/lib.rs b/crates/vite_js_runtime/src/lib.rs index ee94e3f797..e8ff9fa0d8 100644 --- a/crates/vite_js_runtime/src/lib.rs +++ b/crates/vite_js_runtime/src/lib.rs @@ -7,15 +7,10 @@ //! # Example //! //! ```rust,ignore -//! use vite_js_runtime::{JsRuntimeType, download_runtime, parse_runtime_spec}; +//! use vite_js_runtime::{JsRuntimeType, download_runtime}; //! -//! // Option 1: Direct download with known runtime type //! let runtime = download_runtime(JsRuntimeType::Node, "22.13.1").await?; //! println!("Node.js installed at: {}", runtime.get_binary_path()); -//! -//! // Option 2: Parse spec string first -//! let (runtime_type, version) = parse_runtime_spec("node@22.13.1")?; -//! let runtime = download_runtime(runtime_type, &version).await?; //! ``` mod error; @@ -25,4 +20,4 @@ mod runtime; pub use error::Error; pub use platform::Platform; -pub use runtime::{JsRuntime, JsRuntimeType, download_runtime, parse_runtime_spec}; +pub use runtime::{JsRuntime, JsRuntimeType, download_runtime}; diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 12e007c8c0..982832534e 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -84,37 +84,6 @@ impl JsRuntime { } } -/// Parse a runtime specification string (e.g., "node@22.13.1") -/// -/// # Arguments -/// * `spec` - The runtime specification string -/// -/// # Returns -/// A tuple of (runtime type, version string) -/// -/// # Errors -/// Returns an error if the spec format is invalid or the runtime is unsupported -pub fn parse_runtime_spec(spec: &str) -> Result<(JsRuntimeType, Str), Error> { - let parts: Vec<&str> = spec.splitn(2, '@').collect(); - if parts.len() != 2 { - return Err(Error::InvalidRuntimeSpec { spec: spec.into() }); - } - - let runtime_name = parts[0]; - let version = parts[1]; - - if version.is_empty() { - return Err(Error::InvalidRuntimeSpec { spec: spec.into() }); - } - - let runtime_type = match runtime_name { - "node" => JsRuntimeType::Node, - _ => return Err(Error::UnsupportedRuntime { runtime: runtime_name.into() }), - }; - - Ok((runtime_type, version.into())) -} - /// Download and cache a JavaScript runtime /// /// # Arguments @@ -422,31 +391,6 @@ async fn copy_dir_recursive(src: &AbsolutePath, dst: &AbsolutePath) -> Result<() mod tests { use super::*; - #[test] - fn test_parse_runtime_spec_valid() { - let (runtime_type, version) = parse_runtime_spec("node@22.13.1").unwrap(); - assert_eq!(runtime_type, JsRuntimeType::Node); - assert_eq!(version, "22.13.1"); - } - - #[test] - fn test_parse_runtime_spec_invalid_no_at() { - let result = parse_runtime_spec("node22.13.1"); - assert!(result.is_err()); - } - - #[test] - fn test_parse_runtime_spec_invalid_empty_version() { - let result = parse_runtime_spec("node@"); - assert!(result.is_err()); - } - - #[test] - fn test_parse_runtime_spec_unsupported_runtime() { - let result = parse_runtime_spec("unknown@1.0.0"); - assert!(result.is_err()); - } - #[test] fn test_js_runtime_type_display() { assert_eq!(JsRuntimeType::Node.to_string(), "node"); diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 64c7194564..0b5f767dda 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -79,9 +79,6 @@ pub struct JsRuntime { ### Public API ```rust -/// Parse a runtime specification string (e.g., "node@22.13.1") -pub fn parse_runtime_spec(spec: &str) -> Result<(JsRuntimeType, String), Error>; - /// Download and cache a JavaScript runtime /// Returns the JsRuntime with installation path pub async fn download_runtime( @@ -107,15 +104,10 @@ impl JsRuntime { ### Usage Example ```rust -use vite_js_runtime::{JsRuntimeType, download_runtime, parse_runtime_spec}; +use vite_js_runtime::{JsRuntimeType, download_runtime}; -// Option 1: Direct download with known runtime type let runtime = download_runtime(JsRuntimeType::Node, "22.13.1").await?; println!("Node.js installed at: {}", runtime.get_binary_path()); - -// Option 2: Parse spec string first -let (runtime_type, version) = parse_runtime_spec("node@22.13.1")?; -let runtime = download_runtime(runtime_type, &version).await?; println!("Version: {}", runtime.version()); // "22.13.1" ``` @@ -255,12 +247,6 @@ New error variants for `vite_error`: ```rust pub enum JsRuntimeError { - /// Invalid runtime specification format - InvalidRuntimeSpec { spec: String }, - - /// Unsupported runtime type - UnsupportedRuntime { runtime: String }, - /// Version not found in official releases VersionNotFound { runtime: String, version: String }, @@ -282,11 +268,7 @@ pub enum JsRuntimeError { ### Unit Tests -1. **Runtime spec parsing** - - Valid formats: `node@22.13.1` - - Invalid formats: `node`, `22.13.1`, `unknown@1.0.0`, `node@` - -2. **Platform detection** +1. **Platform detection** - Test all supported platform/arch combinations - Test mapping to Node.js distribution names From 2c1bb30df7f2c6e1972daca7c2b05bd80a7578a9 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 18:23:51 +0800 Subject: [PATCH 11/25] feat(js_runtime): support VITE_NODE_DIST_MIRROR env for custom mirror Add support for VITE_NODE_DIST_MIRROR environment variable to override the default Node.js distribution URL. This is useful for corporate environments or regions where nodejs.org might be slow or blocked. Example: VITE_NODE_DIST_MIRROR=https://npmmirror.com/mirrors/node --- crates/vite_js_runtime/src/node.rs | 52 ++++++++++++++++++++++++--- crates/vite_js_runtime/src/runtime.rs | 1 + rfcs/js-runtime.md | 16 +++++++-- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/crates/vite_js_runtime/src/node.rs b/crates/vite_js_runtime/src/node.rs index d67ba09365..b9f643200a 100644 --- a/crates/vite_js_runtime/src/node.rs +++ b/crates/vite_js_runtime/src/node.rs @@ -1,9 +1,23 @@ +use std::env; + use vite_str::Str; use crate::{Error, Platform}; -/// Node.js distribution base URL -const NODE_DIST_URL: &str = "https://nodejs.org/dist"; +/// Default Node.js distribution base URL +const DEFAULT_NODE_DIST_URL: &str = "https://nodejs.org/dist"; + +/// Environment variable to override the Node.js distribution URL +const NODE_DIST_MIRROR_ENV: &str = "VITE_NODE_DIST_MIRROR"; + +/// Get the Node.js distribution base URL +/// +/// Returns the value of `VITE_NODE_DIST_MIRROR` environment variable if set, +/// otherwise returns the default `https://nodejs.org/dist`. +fn get_dist_url() -> Str { + env::var(NODE_DIST_MIRROR_ENV) + .map_or_else(|_| DEFAULT_NODE_DIST_URL.into(), |url| url.trim_end_matches('/').into()) +} /// Get the archive filename for a Node.js version on a specific platform /// @@ -21,6 +35,9 @@ pub fn get_archive_filename(version: &str, platform: Platform) -> Str { /// Get the download URL for a Node.js archive /// +/// Uses `VITE_NODE_DIST_MIRROR` environment variable if set, +/// otherwise defaults to `https://nodejs.org/dist`. +/// /// # Arguments /// * `version` - The Node.js version (e.g., "22.13.1") /// * `platform` - The target platform @@ -28,19 +45,24 @@ pub fn get_archive_filename(version: &str, platform: Platform) -> Str { /// # Returns /// The full download URL pub fn get_download_url(version: &str, platform: Platform) -> Str { + let base_url = get_dist_url(); let filename = get_archive_filename(version, platform); - vite_str::format!("{NODE_DIST_URL}/v{version}/{filename}") + vite_str::format!("{base_url}/v{version}/{filename}") } /// Get the URL for SHASUMS256.txt for a Node.js version /// +/// Uses `VITE_NODE_DIST_MIRROR` environment variable if set, +/// otherwise defaults to `https://nodejs.org/dist`. +/// /// # Arguments /// * `version` - The Node.js version (e.g., "22.13.1") /// /// # Returns /// The SHASUMS256.txt URL pub fn get_shasums_url(version: &str) -> Str { - vite_str::format!("{NODE_DIST_URL}/v{version}/SHASUMS256.txt") + let base_url = get_dist_url(); + vite_str::format!("{base_url}/v{version}/SHASUMS256.txt") } /// Parse SHASUMS256.txt content and extract the hash for a specific filename @@ -145,4 +167,26 @@ fedcba987654 node-v22.13.1-win-x64.zip"; let platform = Platform { os: Os::Linux, arch: Arch::X64 }; assert_eq!(get_extracted_dir_name("22.13.1", platform), "node-v22.13.1-linux-x64"); } + + #[test] + fn test_get_dist_url_default() { + // When env var is not set, should return default URL + unsafe { env::remove_var(NODE_DIST_MIRROR_ENV) }; + assert_eq!(get_dist_url(), "https://nodejs.org/dist"); + } + + #[test] + fn test_get_dist_url_with_mirror() { + unsafe { env::set_var(NODE_DIST_MIRROR_ENV, "https://nodejs.org/dist") }; + assert_eq!(get_dist_url(), "https://nodejs.org/dist"); + unsafe { env::remove_var(NODE_DIST_MIRROR_ENV) }; + } + + #[test] + fn test_get_dist_url_trims_trailing_slash() { + // Should trim trailing slash from mirror URL + unsafe { env::set_var(NODE_DIST_MIRROR_ENV, "https://nodejs.org/dist/") }; + assert_eq!(get_dist_url(), "https://nodejs.org/dist"); + unsafe { env::remove_var(NODE_DIST_MIRROR_ENV) }; + } } diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 982832534e..ebc1b05b36 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -446,6 +446,7 @@ mod tests { /// Test concurrent downloads - multiple tasks downloading the same version /// should not cause corruption or conflicts due to file-based locking #[tokio::test] + #[ignore] async fn test_concurrent_downloads() { // Use a different version to avoid conflicts with other tests let version = "20.17.0"; diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 0b5f767dda..8be664c0ed 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -152,6 +152,16 @@ https://nodejs.org/dist/v{version}/node-v{version}-{platform}.{ext} | macOS | `.tar.gz` | `node-v22.13.1-darwin-arm64.tar.gz` | | Windows | `.zip` | `node-v22.13.1-win-x64.zip` | +### Custom Mirror Support + +The distribution URL can be overridden using the `VITE_NODE_DIST_MIRROR` environment variable. This is useful for corporate environments or regions where nodejs.org might be slow or blocked. + +```bash +VITE_NODE_DIST_MIRROR=https://example.com/mirrors/node vite build +``` + +The mirror URL should have the same directory structure as the official distribution. Trailing slashes are automatically trimmed. + ### Integrity Verification Node.js provides SHASUMS256.txt for each release: @@ -272,7 +282,7 @@ pub enum JsRuntimeError { - Test all supported platform/arch combinations - Test mapping to Node.js distribution names -3. **Cache path generation** +2. **Cache path generation** - Verify correct directory structure ### Integration Tests @@ -343,8 +353,7 @@ pub enum JsRuntimeError { 2. **Bun support**: Add `bun@x.y.z` runtime option with Bun release downloads 3. **Deno support**: Add `deno@x.y.z` runtime option with Deno release downloads 4. **Version ranges**: Support semver ranges like `node@^22.0.0` -5. **Custom mirrors**: Support custom download URLs for corporate environments -6. **Offline mode**: Use cached versions without network access +5. **Offline mode**: Use cached versions without network access ## Success Criteria @@ -354,6 +363,7 @@ pub enum JsRuntimeError { 4. ✅ Handles concurrent downloads safely 5. ✅ Returns version and binary path 6. ✅ Comprehensive test coverage +7. ✅ Custom mirrors via `VITE_NODE_DIST_MIRROR` environment variable ## References From 10d491d7d1773dc9ca1613a99e52cf8561aa3457 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 20:35:23 +0800 Subject: [PATCH 12/25] refactor(js_runtime): introduce JsRuntimeProvider trait abstraction - Add JsRuntimeProvider trait for runtime-specific logic - Create providers/ module with NodeProvider implementation - Extract generic download utilities to download.rs - Simplify platform.rs by moving runtime-specific logic to providers - Update JsRuntime to store relative paths from provider - Enable zip crate on all platforms for future Bun/Deno support - Update RFC to document the new trait-based architecture This refactoring separates concerns between generic download logic and runtime-specific details, making it straightforward to add support for Bun and Deno in the future. --- Cargo.lock | 1 + crates/vite_js_runtime/Cargo.toml | 3 +- crates/vite_js_runtime/src/download.rs | 213 +++++++++++ crates/vite_js_runtime/src/lib.rs | 18 +- crates/vite_js_runtime/src/node.rs | 192 ---------- crates/vite_js_runtime/src/platform.rs | 72 ++-- crates/vite_js_runtime/src/provider.rs | 90 +++++ crates/vite_js_runtime/src/providers/mod.rs | 8 + crates/vite_js_runtime/src/providers/node.rs | 254 +++++++++++++ crates/vite_js_runtime/src/runtime.rs | 373 +++++-------------- rfcs/js-runtime.md | 108 +++++- 11 files changed, 799 insertions(+), 533 deletions(-) create mode 100644 crates/vite_js_runtime/src/download.rs delete mode 100644 crates/vite_js_runtime/src/node.rs create mode 100644 crates/vite_js_runtime/src/provider.rs create mode 100644 crates/vite_js_runtime/src/providers/mod.rs create mode 100644 crates/vite_js_runtime/src/providers/node.rs diff --git a/Cargo.lock b/Cargo.lock index 24e2fedd57..01bd3a5f49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6990,6 +6990,7 @@ dependencies = [ name = "vite_js_runtime" version = "0.0.0" dependencies = [ + "async-trait", "backon", "directories", "flate2", diff --git a/crates/vite_js_runtime/Cargo.toml b/crates/vite_js_runtime/Cargo.toml index 9d784125fb..8f6fa5ab9d 100644 --- a/crates/vite_js_runtime/Cargo.toml +++ b/crates/vite_js_runtime/Cargo.toml @@ -8,6 +8,7 @@ publish = false rust-version.workspace = true [dependencies] +async-trait = { workspace = true } backon = { workspace = true } directories = { workspace = true } flate2 = { workspace = true } @@ -21,10 +22,10 @@ tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } vite_path = { workspace = true } vite_str = { workspace = true } +zip = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] reqwest = { workspace = true, features = ["stream", "native-tls-vendored"] } -zip = { workspace = true } [target.'cfg(not(target_os = "windows"))'.dependencies] reqwest = { workspace = true, features = ["stream", "rustls-tls"] } diff --git a/crates/vite_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs new file mode 100644 index 0000000000..80ad668a86 --- /dev/null +++ b/crates/vite_js_runtime/src/download.rs @@ -0,0 +1,213 @@ +//! Generic download utilities for JavaScript runtime management. +//! +//! This module provides platform-agnostic utilities for downloading, +//! verifying, and extracting runtime archives. + +use std::{fs::File, time::Duration}; + +use backon::{ExponentialBuilder, Retryable}; +use futures_util::StreamExt; +use sha2::{Digest, Sha256}; +use tokio::{fs, io::AsyncWriteExt}; +use vite_path::{AbsolutePath, AbsolutePathBuf}; +use vite_str::Str; + +use crate::{Error, provider::ArchiveFormat}; + +/// Download a file with retry logic +pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), Error> { + tracing::debug!("Downloading {url} to {target_path:?}"); + + let response = (|| async { reqwest::get(url).await?.error_for_status() }) + .retry( + ExponentialBuilder::default() + .with_jitter() + .with_min_delay(Duration::from_millis(500)) + .with_max_times(3), + ) + .await + .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; + + // Stream to file + let mut file = fs::File::create(target_path).await?; + let mut stream = response.bytes_stream(); + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result?; + file.write_all(&chunk).await?; + } + + file.flush().await?; + tracing::debug!("Download completed: {target_path:?}"); + + Ok(()) +} + +/// Download text content from a URL with retry logic +#[expect(clippy::disallowed_types, reason = "HTTP response body is a String")] +pub async fn download_text(url: &str) -> Result { + tracing::debug!("Downloading text from {url}"); + + let content = (|| async { reqwest::get(url).await?.text().await }) + .retry( + ExponentialBuilder::default() + .with_jitter() + .with_min_delay(Duration::from_millis(500)) + .with_max_times(3), + ) + .await + .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; + + Ok(content) +} + +/// Verify file hash against expected SHA256 hash +pub async fn verify_file_hash( + file_path: &AbsolutePath, + expected_hash: &str, + filename: &str, +) -> Result<(), Error> { + tracing::debug!("Verifying hash for {filename}"); + + let content = fs::read(file_path).await?; + + let mut hasher = Sha256::new(); + hasher.update(&content); + let actual_hash: Str = hex::encode(hasher.finalize()).into(); + + if actual_hash != expected_hash { + return Err(Error::HashMismatch { + filename: filename.into(), + expected: expected_hash.into(), + actual: actual_hash, + }); + } + + tracing::debug!("Hash verification successful for {filename}"); + Ok(()) +} + +/// Extract archive based on format +pub async fn extract_archive( + archive_path: &AbsolutePath, + target_dir: &AbsolutePath, + format: ArchiveFormat, +) -> Result<(), Error> { + let archive_path = AbsolutePathBuf::new(archive_path.as_path().to_path_buf()).unwrap(); + let target_dir = AbsolutePathBuf::new(target_dir.as_path().to_path_buf()).unwrap(); + + tokio::task::spawn_blocking(move || match format { + ArchiveFormat::Zip => extract_zip(&archive_path, &target_dir), + ArchiveFormat::TarGz => extract_tar_gz(&archive_path, &target_dir), + }) + .await??; + + Ok(()) +} + +/// Extract a tar.gz archive +fn extract_tar_gz(archive_path: &AbsolutePath, target_dir: &AbsolutePath) -> Result<(), Error> { + use flate2::read::GzDecoder; + use tar::Archive; + + tracing::debug!("Extracting tar.gz: {archive_path:?} to {target_dir:?}"); + + let file = File::open(archive_path)?; + let tar_stream = GzDecoder::new(file); + let mut archive = Archive::new(tar_stream); + archive.unpack(target_dir)?; + + tracing::debug!("Extraction completed"); + Ok(()) +} + +/// Extract a zip archive +fn extract_zip(archive_path: &AbsolutePath, target_dir: &AbsolutePath) -> Result<(), Error> { + tracing::debug!("Extracting zip: {archive_path:?} to {target_dir:?}"); + + let file = File::open(archive_path)?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| Error::ExtractionFailed { reason: vite_str::format!("{e}") })?; + + archive + .extract(target_dir) + .map_err(|e| Error::ExtractionFailed { reason: vite_str::format!("{e}") })?; + + tracing::debug!("Extraction completed"); + Ok(()) +} + +/// Move extracted directory to cache location with atomic operations and file-based locking +/// +/// Uses a file-based lock to ensure atomicity when multiple processes/threads +/// try to install the same runtime version concurrently. +pub async fn move_to_cache( + source: &AbsolutePath, + target: &AbsolutePathBuf, + version: &str, +) -> Result<(), Error> { + // Create parent directory + let parent = target.parent().ok_or_else(|| Error::ExtractionFailed { + reason: "Target path has no parent directory".into(), + })?; + fs::create_dir_all(&parent).await?; + + // Use a file-based lock to ensure atomicity of the move operation. + // This prevents race conditions when multiple processes/threads + // try to install the same runtime version concurrently. + let lock_path = parent.join(vite_str::format!("{version}.lock")); + tracing::debug!("Acquiring lock file: {lock_path:?}"); + + // Acquire file lock in a blocking task to avoid blocking the async runtime. + // The lock() call blocks until the lock is acquired. + let lock_path_clone = lock_path.clone(); + tokio::task::spawn_blocking(move || { + let lock_file = File::create(lock_path_clone.as_path())?; + // Acquire exclusive lock (blocks until available) + lock_file.lock()?; + tracing::debug!("Lock acquired: {lock_path_clone:?}"); + Ok::<_, std::io::Error>(lock_file) + }) + .await??; + tracing::debug!("Lock acquired: {lock_path:?}"); + + // Check again after acquiring the lock, in case another process completed + // the installation while we were downloading + if fs::try_exists(target.as_path()).await.unwrap_or(false) { + tracing::debug!("Target already exists after lock acquisition, skipping move: {target:?}"); + return Ok(()); + } + + // Try atomic rename first + if fs::rename(source.as_path(), target.as_path()).await.is_ok() { + tracing::debug!("Atomic rename successful: {source:?} -> {target:?}"); + return Ok(()); + } + + // If rename fails (cross-device), fall back to copy + tracing::debug!("Atomic rename failed, falling back to copy: {source:?} -> {target:?}"); + copy_dir_recursive(source, target).await?; + fs::remove_dir_all(source).await?; + + Ok(()) +} + +/// Recursively copy a directory +pub async fn copy_dir_recursive(src: &AbsolutePath, dst: &AbsolutePath) -> Result<(), Error> { + fs::create_dir_all(dst).await?; + + let mut entries = fs::read_dir(src).await?; + while let Some(entry) = entries.next_entry().await? { + // entry.path() returns absolute path when src is absolute + let src_path = AbsolutePathBuf::new(entry.path()).unwrap(); + let dst_path = dst.join(entry.file_name()); + + if entry.file_type().await?.is_dir() { + Box::pin(copy_dir_recursive(&src_path, &dst_path)).await?; + } else { + fs::copy(&src_path, &dst_path).await?; + } + } + + Ok(()) +} diff --git a/crates/vite_js_runtime/src/lib.rs b/crates/vite_js_runtime/src/lib.rs index e8ff9fa0d8..1de4546455 100644 --- a/crates/vite_js_runtime/src/lib.rs +++ b/crates/vite_js_runtime/src/lib.rs @@ -12,12 +12,24 @@ //! let runtime = download_runtime(JsRuntimeType::Node, "22.13.1").await?; //! println!("Node.js installed at: {}", runtime.get_binary_path()); //! ``` +//! +//! # Adding a New Runtime +//! +//! To add support for a new JavaScript runtime (e.g., Bun, Deno): +//! +//! 1. Create a new provider in `src/providers/` implementing `JsRuntimeProvider` +//! 2. Add the runtime type to `JsRuntimeType` enum +//! 3. Add a match arm in `download_runtime()` to use the new provider +mod download; mod error; -mod node; mod platform; +mod provider; +mod providers; mod runtime; pub use error::Error; -pub use platform::Platform; -pub use runtime::{JsRuntime, JsRuntimeType, download_runtime}; +pub use platform::{Arch, Os, Platform}; +pub use provider::{ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvider}; +pub use providers::NodeProvider; +pub use runtime::{JsRuntime, JsRuntimeType, download_runtime, download_runtime_with_provider}; diff --git a/crates/vite_js_runtime/src/node.rs b/crates/vite_js_runtime/src/node.rs deleted file mode 100644 index b9f643200a..0000000000 --- a/crates/vite_js_runtime/src/node.rs +++ /dev/null @@ -1,192 +0,0 @@ -use std::env; - -use vite_str::Str; - -use crate::{Error, Platform}; - -/// Default Node.js distribution base URL -const DEFAULT_NODE_DIST_URL: &str = "https://nodejs.org/dist"; - -/// Environment variable to override the Node.js distribution URL -const NODE_DIST_MIRROR_ENV: &str = "VITE_NODE_DIST_MIRROR"; - -/// Get the Node.js distribution base URL -/// -/// Returns the value of `VITE_NODE_DIST_MIRROR` environment variable if set, -/// otherwise returns the default `https://nodejs.org/dist`. -fn get_dist_url() -> Str { - env::var(NODE_DIST_MIRROR_ENV) - .map_or_else(|_| DEFAULT_NODE_DIST_URL.into(), |url| url.trim_end_matches('/').into()) -} - -/// Get the archive filename for a Node.js version on a specific platform -/// -/// # Arguments -/// * `version` - The Node.js version (e.g., "22.13.1") -/// * `platform` - The target platform -/// -/// # Returns -/// The archive filename (e.g., "node-v22.13.1-linux-x64.tar.gz") -pub fn get_archive_filename(version: &str, platform: Platform) -> Str { - let platform_str = platform.node_platform_string(); - let ext = platform.archive_extension(); - vite_str::format!("node-v{version}-{platform_str}.{ext}") -} - -/// Get the download URL for a Node.js archive -/// -/// Uses `VITE_NODE_DIST_MIRROR` environment variable if set, -/// otherwise defaults to `https://nodejs.org/dist`. -/// -/// # Arguments -/// * `version` - The Node.js version (e.g., "22.13.1") -/// * `platform` - The target platform -/// -/// # Returns -/// The full download URL -pub fn get_download_url(version: &str, platform: Platform) -> Str { - let base_url = get_dist_url(); - let filename = get_archive_filename(version, platform); - vite_str::format!("{base_url}/v{version}/{filename}") -} - -/// Get the URL for SHASUMS256.txt for a Node.js version -/// -/// Uses `VITE_NODE_DIST_MIRROR` environment variable if set, -/// otherwise defaults to `https://nodejs.org/dist`. -/// -/// # Arguments -/// * `version` - The Node.js version (e.g., "22.13.1") -/// -/// # Returns -/// The SHASUMS256.txt URL -pub fn get_shasums_url(version: &str) -> Str { - let base_url = get_dist_url(); - vite_str::format!("{base_url}/v{version}/SHASUMS256.txt") -} - -/// Parse SHASUMS256.txt content and extract the hash for a specific filename -/// -/// # Arguments -/// * `shasums_content` - The content of SHASUMS256.txt -/// * `filename` - The filename to find the hash for -/// -/// # Returns -/// The SHA256 hash for the filename -/// -/// # Format -/// Each line in SHASUMS256.txt is: ` ` -pub fn parse_shasums(shasums_content: &str, filename: &str) -> Result { - for line in shasums_content.lines() { - // Format: " " (two spaces between hash and filename) - let parts: Vec<&str> = line.splitn(2, " ").collect(); - if parts.len() == 2 { - let hash = parts[0].trim(); - let file = parts[1].trim(); - if file == filename { - return Ok(hash.into()); - } - } - } - - Err(Error::HashNotFound { filename: filename.into() }) -} - -/// Get the directory name inside the archive after extraction -/// -/// For Node.js, the archive contains a directory named like: -/// - Linux/macOS: `node-v22.13.1-linux-x64/` -/// - Windows: `node-v22.13.1-win-x64/` -pub fn get_extracted_dir_name(version: &str, platform: Platform) -> Str { - let platform_str = platform.node_platform_string(); - vite_str::format!("node-v{version}-{platform_str}") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::platform::{Arch, Os}; - - #[test] - fn test_get_archive_filename() { - let cases = [ - ( - "22.13.1", - Platform { os: Os::Linux, arch: Arch::X64 }, - "node-v22.13.1-linux-x64.tar.gz", - ), - ( - "22.13.1", - Platform { os: Os::Darwin, arch: Arch::Arm64 }, - "node-v22.13.1-darwin-arm64.tar.gz", - ), - ("22.13.1", Platform { os: Os::Windows, arch: Arch::X64 }, "node-v22.13.1-win-x64.zip"), - ]; - - for (version, platform, expected) in cases { - assert_eq!(get_archive_filename(version, platform), expected); - } - } - - #[test] - fn test_get_download_url() { - let platform = Platform { os: Os::Linux, arch: Arch::X64 }; - let url = get_download_url("22.13.1", platform); - assert_eq!(url, "https://nodejs.org/dist/v22.13.1/node-v22.13.1-linux-x64.tar.gz"); - } - - #[test] - fn test_get_shasums_url() { - let url = get_shasums_url("22.13.1"); - assert_eq!(url, "https://nodejs.org/dist/v22.13.1/SHASUMS256.txt"); - } - - #[test] - fn test_parse_shasums() { - let content = r"abc123def456 node-v22.13.1-linux-x64.tar.gz -789xyz000111 node-v22.13.1-darwin-arm64.tar.gz -fedcba987654 node-v22.13.1-win-x64.zip"; - - assert_eq!( - parse_shasums(content, "node-v22.13.1-linux-x64.tar.gz").unwrap(), - "abc123def456" - ); - assert_eq!( - parse_shasums(content, "node-v22.13.1-darwin-arm64.tar.gz").unwrap(), - "789xyz000111" - ); - assert_eq!(parse_shasums(content, "node-v22.13.1-win-x64.zip").unwrap(), "fedcba987654"); - - // Test missing filename - let result = parse_shasums(content, "nonexistent.tar.gz"); - assert!(result.is_err()); - } - - #[test] - fn test_get_extracted_dir_name() { - let platform = Platform { os: Os::Linux, arch: Arch::X64 }; - assert_eq!(get_extracted_dir_name("22.13.1", platform), "node-v22.13.1-linux-x64"); - } - - #[test] - fn test_get_dist_url_default() { - // When env var is not set, should return default URL - unsafe { env::remove_var(NODE_DIST_MIRROR_ENV) }; - assert_eq!(get_dist_url(), "https://nodejs.org/dist"); - } - - #[test] - fn test_get_dist_url_with_mirror() { - unsafe { env::set_var(NODE_DIST_MIRROR_ENV, "https://nodejs.org/dist") }; - assert_eq!(get_dist_url(), "https://nodejs.org/dist"); - unsafe { env::remove_var(NODE_DIST_MIRROR_ENV) }; - } - - #[test] - fn test_get_dist_url_trims_trailing_slash() { - // Should trim trailing slash from mirror URL - unsafe { env::set_var(NODE_DIST_MIRROR_ENV, "https://nodejs.org/dist/") }; - assert_eq!(get_dist_url(), "https://nodejs.org/dist"); - unsafe { env::remove_var(NODE_DIST_MIRROR_ENV) }; - } -} diff --git a/crates/vite_js_runtime/src/platform.rs b/crates/vite_js_runtime/src/platform.rs index d3133bd847..60f1cf8dd7 100644 --- a/crates/vite_js_runtime/src/platform.rs +++ b/crates/vite_js_runtime/src/platform.rs @@ -1,7 +1,5 @@ use std::fmt; -use vite_str::Str; - /// Represents a platform (OS + architecture) combination #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Platform { @@ -30,36 +28,11 @@ impl Platform { pub const fn current() -> Self { Self { os: Os::current(), arch: Arch::current() } } - - /// Get the platform string for Node.js distribution naming - /// e.g., "linux-x64", "darwin-arm64", "win-x64" - #[must_use] - pub fn node_platform_string(self) -> Str { - let os = match self.os { - Os::Linux => "linux", - Os::Darwin => "darwin", - Os::Windows => "win", - }; - let arch = match self.arch { - Arch::X64 => "x64", - Arch::Arm64 => "arm64", - }; - vite_str::format!("{os}-{arch}") - } - - /// Get the archive extension for this platform - #[must_use] - pub const fn archive_extension(self) -> &'static str { - match self.os { - Os::Windows => "zip", - Os::Linux | Os::Darwin => "tar.gz", - } - } } impl fmt::Display for Platform { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.node_platform_string()) + write!(f, "{}-{}", self.os, self.arch) } } @@ -82,6 +55,16 @@ impl Os { } } +impl fmt::Display for Os { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Linux => write!(f, "linux"), + Self::Darwin => write!(f, "darwin"), + Self::Windows => write!(f, "windows"), + } + } +} + impl Arch { /// Detect the current CPU architecture #[must_use] @@ -97,6 +80,15 @@ impl Arch { } } +impl fmt::Display for Arch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::X64 => write!(f, "x64"), + Self::Arm64 => write!(f, "arm64"), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -106,7 +98,7 @@ mod tests { let platform = Platform::current(); // Just verify it doesn't panic and returns a valid platform - let platform_str = platform.node_platform_string(); + let platform_str = platform.to_string(); assert!(!platform_str.is_empty()); // Verify format is "os-arch" @@ -115,25 +107,31 @@ mod tests { } #[test] - fn test_node_platform_strings() { + fn test_platform_display() { let cases = [ (Platform { os: Os::Linux, arch: Arch::X64 }, "linux-x64"), (Platform { os: Os::Linux, arch: Arch::Arm64 }, "linux-arm64"), (Platform { os: Os::Darwin, arch: Arch::X64 }, "darwin-x64"), (Platform { os: Os::Darwin, arch: Arch::Arm64 }, "darwin-arm64"), - (Platform { os: Os::Windows, arch: Arch::X64 }, "win-x64"), - (Platform { os: Os::Windows, arch: Arch::Arm64 }, "win-arm64"), + (Platform { os: Os::Windows, arch: Arch::X64 }, "windows-x64"), + (Platform { os: Os::Windows, arch: Arch::Arm64 }, "windows-arm64"), ]; for (platform, expected) in cases { - assert_eq!(platform.node_platform_string(), expected); + assert_eq!(platform.to_string(), expected); } } #[test] - fn test_archive_extension() { - assert_eq!(Platform { os: Os::Linux, arch: Arch::X64 }.archive_extension(), "tar.gz"); - assert_eq!(Platform { os: Os::Darwin, arch: Arch::Arm64 }.archive_extension(), "tar.gz"); - assert_eq!(Platform { os: Os::Windows, arch: Arch::X64 }.archive_extension(), "zip"); + fn test_os_display() { + assert_eq!(Os::Linux.to_string(), "linux"); + assert_eq!(Os::Darwin.to_string(), "darwin"); + assert_eq!(Os::Windows.to_string(), "windows"); + } + + #[test] + fn test_arch_display() { + assert_eq!(Arch::X64.to_string(), "x64"); + assert_eq!(Arch::Arm64.to_string(), "arm64"); } } diff --git a/crates/vite_js_runtime/src/provider.rs b/crates/vite_js_runtime/src/provider.rs new file mode 100644 index 0000000000..68ea92a9ee --- /dev/null +++ b/crates/vite_js_runtime/src/provider.rs @@ -0,0 +1,90 @@ +//! JavaScript runtime provider trait and supporting types. +//! +//! This module defines the trait that all runtime providers (Node, Bun, Deno) +//! must implement, along with types for describing download information. + +use async_trait::async_trait; +use vite_str::Str; + +use crate::{Error, Platform}; + +/// Archive format for runtime distributions +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArchiveFormat { + /// Gzip-compressed tar archive (.tar.gz) + TarGz, + /// ZIP archive (.zip) + Zip, +} + +impl ArchiveFormat { + /// Get the file extension for this archive format + #[must_use] + pub const fn extension(self) -> &'static str { + match self { + Self::TarGz => "tar.gz", + Self::Zip => "zip", + } + } +} + +/// How to verify the integrity of a downloaded archive +#[derive(Debug, Clone)] +pub enum HashVerification { + /// Download a SHASUMS file and parse it to find the hash + /// Used by Node.js (SHASUMS256.txt format) + ShasumsFile { + /// URL to the SHASUMS file + url: Str, + }, + /// No hash verification (not recommended, but some runtimes may not provide checksums) + None, +} + +/// Information needed to download a runtime +#[derive(Debug, Clone)] +pub struct DownloadInfo { + /// URL to download the archive from + pub archive_url: Str, + /// Filename of the archive + pub archive_filename: Str, + /// Format of the archive + pub archive_format: ArchiveFormat, + /// How to verify the download integrity + pub hash_verification: HashVerification, + /// Name of the directory inside the archive after extraction + pub extracted_dir_name: Str, +} + +/// Trait for JavaScript runtime providers +/// +/// Each runtime (Node.js, Bun, Deno) implements this trait to provide +/// runtime-specific logic for downloading and installing. +#[async_trait] +pub trait JsRuntimeProvider: Send + Sync { + /// Get the name of this runtime (e.g., "node", "bun", "deno") + fn name(&self) -> &'static str; + + /// Get the platform string used in download URLs for this runtime + /// e.g., "linux-x64", "darwin-arm64", "win-x64" + fn platform_string(&self, platform: Platform) -> Str; + + /// Get download information for a specific version and platform + fn get_download_info(&self, version: &str, platform: Platform) -> DownloadInfo; + + /// Get the relative path to the runtime binary from the install directory + /// e.g., "bin/node" on Unix, "node.exe" on Windows + fn binary_relative_path(&self, platform: Platform) -> Str; + + /// Get the relative path to the bin directory from the install directory + /// e.g., "bin" on Unix, "" (empty) on Windows + fn bin_dir_relative_path(&self, platform: Platform) -> Str; + + /// Parse a SHASUMS file to extract the hash for a specific filename + /// Different runtimes may have different SHASUMS formats + /// + /// # Errors + /// + /// Returns an error if the filename is not found in the SHASUMS content. + fn parse_shasums(&self, shasums_content: &str, filename: &str) -> Result; +} diff --git a/crates/vite_js_runtime/src/providers/mod.rs b/crates/vite_js_runtime/src/providers/mod.rs new file mode 100644 index 0000000000..fda9fe1a38 --- /dev/null +++ b/crates/vite_js_runtime/src/providers/mod.rs @@ -0,0 +1,8 @@ +//! JavaScript runtime provider implementations. +//! +//! This module contains implementations of the `JsRuntimeProvider` trait +//! for each supported JavaScript runtime. + +mod node; + +pub use node::NodeProvider; diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs new file mode 100644 index 0000000000..e67f13a5cc --- /dev/null +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -0,0 +1,254 @@ +//! Node.js runtime provider implementation. + +use std::env; + +use async_trait::async_trait; +use vite_str::Str; + +use crate::{ + Error, Platform, + platform::Os, + provider::{ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvider}, +}; + +/// Default Node.js distribution base URL +const DEFAULT_NODE_DIST_URL: &str = "https://nodejs.org/dist"; + +/// Environment variable to override the Node.js distribution URL +const NODE_DIST_MIRROR_ENV: &str = "VITE_NODE_DIST_MIRROR"; + +/// Node.js runtime provider +#[derive(Debug, Default)] +pub struct NodeProvider; + +impl NodeProvider { + /// Create a new `NodeProvider` + #[must_use] + pub const fn new() -> Self { + Self + } + + /// Get the archive format for a platform + const fn archive_format(platform: Platform) -> ArchiveFormat { + match platform.os { + Os::Windows => ArchiveFormat::Zip, + Os::Linux | Os::Darwin => ArchiveFormat::TarGz, + } + } +} + +/// Get the Node.js distribution base URL +/// +/// Returns the value of `VITE_NODE_DIST_MIRROR` environment variable if set, +/// otherwise returns the default `https://nodejs.org/dist`. +fn get_dist_url() -> Str { + env::var(NODE_DIST_MIRROR_ENV) + .map_or_else(|_| DEFAULT_NODE_DIST_URL.into(), |url| url.trim_end_matches('/').into()) +} + +#[async_trait] +impl JsRuntimeProvider for NodeProvider { + fn name(&self) -> &'static str { + "node" + } + + fn platform_string(&self, platform: Platform) -> Str { + let os = match platform.os { + Os::Linux => "linux", + Os::Darwin => "darwin", + Os::Windows => "win", + }; + let arch = match platform.arch { + crate::platform::Arch::X64 => "x64", + crate::platform::Arch::Arm64 => "arm64", + }; + vite_str::format!("{os}-{arch}") + } + + fn get_download_info(&self, version: &str, platform: Platform) -> DownloadInfo { + let base_url = get_dist_url(); + let platform_str = self.platform_string(platform); + let format = Self::archive_format(platform); + let ext = format.extension(); + + let archive_filename: Str = vite_str::format!("node-v{version}-{platform_str}.{ext}"); + let archive_url = vite_str::format!("{base_url}/v{version}/{archive_filename}"); + let shasums_url = vite_str::format!("{base_url}/v{version}/SHASUMS256.txt"); + let extracted_dir_name = vite_str::format!("node-v{version}-{platform_str}"); + + DownloadInfo { + archive_url, + archive_filename, + archive_format: format, + hash_verification: HashVerification::ShasumsFile { url: shasums_url }, + extracted_dir_name, + } + } + + fn binary_relative_path(&self, platform: Platform) -> Str { + match platform.os { + Os::Windows => "node.exe".into(), + Os::Linux | Os::Darwin => "bin/node".into(), + } + } + + fn bin_dir_relative_path(&self, platform: Platform) -> Str { + match platform.os { + Os::Windows => "".into(), + Os::Linux | Os::Darwin => "bin".into(), + } + } + + fn parse_shasums(&self, shasums_content: &str, filename: &str) -> Result { + // Node.js SHASUMS256.txt format: " " (two spaces between) + for line in shasums_content.lines() { + let parts: Vec<&str> = line.splitn(2, " ").collect(); + if parts.len() == 2 { + let hash = parts[0].trim(); + let file = parts[1].trim(); + if file == filename { + return Ok(hash.into()); + } + } + } + + Err(Error::HashNotFound { filename: filename.into() }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::{Arch, Os}; + + #[test] + fn test_platform_string() { + let provider = NodeProvider::new(); + + let cases = [ + (Platform { os: Os::Linux, arch: Arch::X64 }, "linux-x64"), + (Platform { os: Os::Linux, arch: Arch::Arm64 }, "linux-arm64"), + (Platform { os: Os::Darwin, arch: Arch::X64 }, "darwin-x64"), + (Platform { os: Os::Darwin, arch: Arch::Arm64 }, "darwin-arm64"), + (Platform { os: Os::Windows, arch: Arch::X64 }, "win-x64"), + (Platform { os: Os::Windows, arch: Arch::Arm64 }, "win-arm64"), + ]; + + for (platform, expected) in cases { + assert_eq!(provider.platform_string(platform), expected); + } + } + + #[test] + fn test_get_download_info() { + let provider = NodeProvider::new(); + let platform = Platform { os: Os::Linux, arch: Arch::X64 }; + + let info = provider.get_download_info("22.13.1", platform); + + assert_eq!(info.archive_filename, "node-v22.13.1-linux-x64.tar.gz"); + assert_eq!( + info.archive_url, + "https://nodejs.org/dist/v22.13.1/node-v22.13.1-linux-x64.tar.gz" + ); + assert_eq!(info.archive_format, ArchiveFormat::TarGz); + assert_eq!(info.extracted_dir_name, "node-v22.13.1-linux-x64"); + + if let HashVerification::ShasumsFile { url } = &info.hash_verification { + assert_eq!(url, "https://nodejs.org/dist/v22.13.1/SHASUMS256.txt"); + } else { + panic!("Expected ShasumsFile verification"); + } + } + + #[test] + fn test_get_download_info_windows() { + let provider = NodeProvider::new(); + let platform = Platform { os: Os::Windows, arch: Arch::X64 }; + + let info = provider.get_download_info("22.13.1", platform); + + assert_eq!(info.archive_filename, "node-v22.13.1-win-x64.zip"); + assert_eq!(info.archive_format, ArchiveFormat::Zip); + } + + #[test] + fn test_binary_relative_path() { + let provider = NodeProvider::new(); + + assert_eq!( + provider.binary_relative_path(Platform { os: Os::Linux, arch: Arch::X64 }), + "bin/node" + ); + assert_eq!( + provider.binary_relative_path(Platform { os: Os::Darwin, arch: Arch::Arm64 }), + "bin/node" + ); + assert_eq!( + provider.binary_relative_path(Platform { os: Os::Windows, arch: Arch::X64 }), + "node.exe" + ); + } + + #[test] + fn test_bin_dir_relative_path() { + let provider = NodeProvider::new(); + + assert_eq!( + provider.bin_dir_relative_path(Platform { os: Os::Linux, arch: Arch::X64 }), + "bin" + ); + assert_eq!( + provider.bin_dir_relative_path(Platform { os: Os::Windows, arch: Arch::X64 }), + "" + ); + } + + #[test] + fn test_parse_shasums() { + let provider = NodeProvider::new(); + + let content = r"abc123def456 node-v22.13.1-linux-x64.tar.gz +789xyz000111 node-v22.13.1-darwin-arm64.tar.gz +fedcba987654 node-v22.13.1-win-x64.zip"; + + assert_eq!( + provider.parse_shasums(content, "node-v22.13.1-linux-x64.tar.gz").unwrap(), + "abc123def456" + ); + assert_eq!( + provider.parse_shasums(content, "node-v22.13.1-darwin-arm64.tar.gz").unwrap(), + "789xyz000111" + ); + assert_eq!( + provider.parse_shasums(content, "node-v22.13.1-win-x64.zip").unwrap(), + "fedcba987654" + ); + + // Test missing filename + let result = provider.parse_shasums(content, "nonexistent.tar.gz"); + assert!(result.is_err()); + } + + #[test] + fn test_get_dist_url_default() { + // When env var is not set, should return default URL + unsafe { env::remove_var(NODE_DIST_MIRROR_ENV) }; + assert_eq!(get_dist_url(), "https://nodejs.org/dist"); + } + + #[test] + fn test_get_dist_url_with_mirror() { + unsafe { env::set_var(NODE_DIST_MIRROR_ENV, "https://nodejs.org/dist") }; + assert_eq!(get_dist_url(), "https://nodejs.org/dist"); + unsafe { env::remove_var(NODE_DIST_MIRROR_ENV) }; + } + + #[test] + fn test_get_dist_url_trims_trailing_slash() { + // Should trim trailing slash from mirror URL + unsafe { env::set_var(NODE_DIST_MIRROR_ENV, "https://nodejs.org/dist/") }; + assert_eq!(get_dist_url(), "https://nodejs.org/dist"); + unsafe { env::remove_var(NODE_DIST_MIRROR_ENV) }; + } +} diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index ebc1b05b36..17bfa38e98 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -1,17 +1,14 @@ -use std::{fs::File, time::Duration}; - -use backon::{ExponentialBuilder, Retryable}; use directories::BaseDirs; -use flate2::read::GzDecoder; -use futures_util::StreamExt; -use sha2::{Digest, Sha256}; -use tar::Archive; use tempfile::TempDir; -use tokio::{fs, io::AsyncWriteExt}; -use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; +use vite_path::{AbsolutePathBuf, current_dir}; use vite_str::Str; -use crate::{Error, Platform, node}; +use crate::{ + Error, Platform, + download::{download_file, download_text, extract_archive, move_to_cache, verify_file_hash}, + provider::{HashVerification, JsRuntimeProvider}, + providers::NodeProvider, +}; /// Supported JavaScript runtime types #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -34,40 +31,26 @@ pub struct JsRuntime { pub runtime_type: JsRuntimeType, pub version: Str, pub install_dir: AbsolutePathBuf, + /// Relative path from `install_dir` to the binary + binary_relative_path: Str, + /// Relative path from `install_dir` to the bin directory + bin_dir_relative_path: Str, } impl JsRuntime { /// Get the path to the runtime binary (e.g., node, bun) #[must_use] pub fn get_binary_path(&self) -> AbsolutePathBuf { - match self.runtime_type { - JsRuntimeType::Node => { - #[cfg(target_os = "windows")] - { - self.install_dir.join("node.exe") - } - #[cfg(not(target_os = "windows"))] - { - self.install_dir.join("bin/node") - } - } - } + self.install_dir.join(&self.binary_relative_path) } /// Get the bin directory containing the runtime #[must_use] pub fn get_bin_prefix(&self) -> AbsolutePathBuf { - match self.runtime_type { - JsRuntimeType::Node => { - #[cfg(target_os = "windows")] - { - self.install_dir.clone() - } - #[cfg(not(target_os = "windows"))] - { - self.install_dir.join("bin") - } - } + if self.bin_dir_relative_path.is_empty() { + self.install_dir.clone() + } else { + self.install_dir.join(&self.bin_dir_relative_path) } } @@ -100,291 +83,109 @@ pub async fn download_runtime( version: &str, ) -> Result { match runtime_type { - JsRuntimeType::Node => download_node(version).await, + JsRuntimeType::Node => { + let provider = NodeProvider::new(); + download_runtime_with_provider(&provider, JsRuntimeType::Node, version).await + } } } -/// Get the cache directory for JavaScript runtimes -fn get_cache_dir() -> Result { - let cache_dir = match BaseDirs::new() { - Some(dirs) => AbsolutePathBuf::new(dirs.cache_dir().to_path_buf()).unwrap(), - None => current_dir()?.join(".cache"), - }; - Ok(cache_dir.join("vite/js_runtime")) -} - -/// Download and cache Node.js -async fn download_node(version: &str) -> Result { +/// Download and cache a JavaScript runtime using a provider +/// +/// This is the generic download function that works with any `JsRuntimeProvider`. +/// +/// # Errors +/// +/// Returns an error if download, verification, or extraction fails. +/// +/// # Panics +/// +/// Panics if the temp directory path is not absolute (should not happen in practice). +pub async fn download_runtime_with_provider( + provider: &P, + runtime_type: JsRuntimeType, + version: &str, +) -> Result { let platform = Platform::current(); let cache_dir = get_cache_dir()?; - // Cache path: $CACHE_DIR/vite/js_runtime/node/{version}/{platform}/ - let install_dir = cache_dir.join(vite_str::format!("node/{version}/{platform}")); + // Get paths from provider + let platform_str = provider.platform_string(platform); + let binary_relative_path = provider.binary_relative_path(platform); + let bin_dir_relative_path = provider.bin_dir_relative_path(platform); + + // Cache path: $CACHE_DIR/vite/js_runtime/{runtime}/{version}/{platform}/ + let install_dir = + cache_dir.join(vite_str::format!("{}/{version}/{platform_str}", provider.name())); // Check if already cached - let binary_path = get_node_binary_path(&install_dir); + let binary_path = install_dir.join(&binary_relative_path); if tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { - tracing::debug!("Node.js {version} already cached at {install_dir:?}"); + tracing::debug!("{} {version} already cached at {install_dir:?}", provider.name()); return Ok(JsRuntime { - runtime_type: JsRuntimeType::Node, + runtime_type, version: version.into(), install_dir, + binary_relative_path, + bin_dir_relative_path, }); } - tracing::info!("Downloading Node.js {version} for {platform}..."); + tracing::info!("Downloading {} {version} for {platform_str}...", provider.name()); - // Get download URLs - let archive_filename = node::get_archive_filename(version, platform); - let download_url = node::get_download_url(version, platform); - let shasums_url = node::get_shasums_url(version); + // Get download info from provider + let download_info = provider.get_download_info(version, platform); // Create temp directory for download - // TempDir::path() always returns an absolute path (e.g., /tmp/xxx) let temp_dir = TempDir::new()?; let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let archive_path = temp_path.join(&archive_filename); + let archive_path = temp_path.join(&download_info.archive_filename); - // Download SHASUMS256.txt and parse expected hash - let expected_hash = download_and_parse_shasums(&shasums_url, &archive_filename).await?; + // Verify hash if verification method is provided + match &download_info.hash_verification { + HashVerification::ShasumsFile { url } => { + let shasums_content = download_text(url).await?; + let expected_hash = + provider.parse_shasums(&shasums_content, &download_info.archive_filename)?; - // Download archive - download_file(&download_url, &archive_path).await?; + // Download archive + download_file(&download_info.archive_url, &archive_path).await?; - // Verify hash - verify_file_hash(&archive_path, &expected_hash, &archive_filename).await?; + // Verify hash + verify_file_hash(&archive_path, &expected_hash, &download_info.archive_filename) + .await?; + } + HashVerification::None => { + // Download archive without verification + download_file(&download_info.archive_url, &archive_path).await?; + } + } // Extract archive - let extracted_dir_name = node::get_extracted_dir_name(version, platform); - extract_archive(&archive_path, &temp_path, platform).await?; + extract_archive(&archive_path, &temp_path, download_info.archive_format).await?; - // Move extracted directory to cache location with file-based locking - let extracted_path = temp_path.join(&extracted_dir_name); + // Move extracted directory to cache location + let extracted_path = temp_path.join(&download_info.extracted_dir_name); move_to_cache(&extracted_path, &install_dir, version).await?; - tracing::info!("Node.js {version} installed at {install_dir:?}"); - - Ok(JsRuntime { runtime_type: JsRuntimeType::Node, version: version.into(), install_dir }) -} - -/// Get the Node.js binary path for a given install directory -fn get_node_binary_path(install_dir: &AbsolutePathBuf) -> AbsolutePathBuf { - #[cfg(target_os = "windows")] - { - install_dir.join("node.exe") - } - #[cfg(not(target_os = "windows"))] - { - install_dir.join("bin/node") - } -} - -/// Download SHASUMS256.txt and parse the expected hash for a filename -async fn download_and_parse_shasums(shasums_url: &str, filename: &str) -> Result { - tracing::debug!("Downloading SHASUMS256.txt from {shasums_url}"); - - let content = (|| async { reqwest::get(shasums_url).await?.text().await }) - .retry( - ExponentialBuilder::default() - .with_jitter() - .with_min_delay(Duration::from_millis(500)) - .with_max_times(3), - ) - .await - .map_err(|e| Error::DownloadFailed { - url: shasums_url.into(), - reason: vite_str::format!("{e}"), - })?; - - node::parse_shasums(&content, filename) -} - -/// Download a file with retry logic -async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), Error> { - tracing::debug!("Downloading {url} to {target_path:?}"); - - let response = (|| async { reqwest::get(url).await?.error_for_status() }) - .retry( - ExponentialBuilder::default() - .with_jitter() - .with_min_delay(Duration::from_millis(500)) - .with_max_times(3), - ) - .await - .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; - - // Stream to file - let mut file = fs::File::create(target_path).await?; - let mut stream = response.bytes_stream(); - - while let Some(chunk_result) = stream.next().await { - let chunk = chunk_result?; - file.write_all(&chunk).await?; - } - - file.flush().await?; - tracing::debug!("Download completed: {target_path:?}"); - - Ok(()) -} + tracing::info!("{} {version} installed at {install_dir:?}", provider.name()); -/// Verify file hash against expected SHA256 hash -async fn verify_file_hash( - file_path: &AbsolutePath, - expected_hash: &str, - filename: &str, -) -> Result<(), Error> { - tracing::debug!("Verifying hash for {filename}"); - - let content = fs::read(file_path).await?; - - let mut hasher = Sha256::new(); - hasher.update(&content); - let actual_hash: Str = hex::encode(hasher.finalize()).into(); - - if actual_hash != expected_hash { - return Err(Error::HashMismatch { - filename: filename.into(), - expected: expected_hash.into(), - actual: actual_hash, - }); - } - - tracing::debug!("Hash verification successful for {filename}"); - Ok(()) -} - -/// Extract archive based on platform -async fn extract_archive( - archive_path: &AbsolutePath, - target_dir: &AbsolutePath, - platform: Platform, -) -> Result<(), Error> { - let archive_path = AbsolutePathBuf::new(archive_path.as_path().to_path_buf()).unwrap(); - let target_dir = AbsolutePathBuf::new(target_dir.as_path().to_path_buf()).unwrap(); - let is_windows = platform.os == crate::platform::Os::Windows; - - tokio::task::spawn_blocking(move || { - if is_windows { - extract_zip(&archive_path, &target_dir) - } else { - extract_tar_gz(&archive_path, &target_dir) - } - }) - .await??; - - Ok(()) -} - -/// Extract a tar.gz archive -fn extract_tar_gz(archive_path: &AbsolutePath, target_dir: &AbsolutePath) -> Result<(), Error> { - tracing::debug!("Extracting tar.gz: {archive_path:?} to {target_dir:?}"); - - let file = File::open(archive_path)?; - let tar_stream = GzDecoder::new(file); - let mut archive = Archive::new(tar_stream); - archive.unpack(target_dir)?; - - tracing::debug!("Extraction completed"); - Ok(()) -} - -/// Extract a zip archive (Windows) -#[cfg(target_os = "windows")] -fn extract_zip(archive_path: &AbsolutePath, target_dir: &AbsolutePath) -> Result<(), Error> { - tracing::debug!("Extracting zip: {archive_path:?} to {target_dir:?}"); - - let file = File::open(archive_path)?; - let mut archive = zip::ZipArchive::new(file) - .map_err(|e| Error::ExtractionFailed { reason: vite_str::format!("{e}") })?; - - archive - .extract(target_dir) - .map_err(|e| Error::ExtractionFailed { reason: vite_str::format!("{e}") })?; - - tracing::debug!("Extraction completed"); - Ok(()) -} - -#[cfg(not(target_os = "windows"))] -fn extract_zip(_archive_path: &AbsolutePath, _target_dir: &AbsolutePath) -> Result<(), Error> { - // This should never be called on non-Windows platforms - Err(Error::ExtractionFailed { reason: "Zip extraction not supported on this platform".into() }) -} - -/// Move extracted directory to cache location with atomic operations and file-based locking -/// -/// Uses a file-based lock to ensure atomicity when multiple processes/threads -/// try to install the same runtime version concurrently. -async fn move_to_cache( - source: &AbsolutePath, - target: &AbsolutePathBuf, - version: &str, -) -> Result<(), Error> { - // Create parent directory - let parent = target.parent().ok_or_else(|| Error::ExtractionFailed { - reason: "Target path has no parent directory".into(), - })?; - fs::create_dir_all(&parent).await?; - - // Use a file-based lock to ensure atomicity of the move operation. - // This prevents race conditions when multiple processes/threads - // try to install the same runtime version concurrently. - let lock_path = parent.join(vite_str::format!("{version}.lock")); - tracing::debug!("Acquiring lock file: {lock_path:?}"); - - // Acquire file lock in a blocking task to avoid blocking the async runtime. - // The lock() call blocks until the lock is acquired. - let lock_path_clone = lock_path.clone(); - tokio::task::spawn_blocking(move || { - let lock_file = File::create(lock_path_clone.as_path())?; - // Acquire exclusive lock (blocks until available) - lock_file.lock()?; - tracing::debug!("Lock acquired: {lock_path_clone:?}"); - Ok::<_, std::io::Error>(lock_file) + Ok(JsRuntime { + runtime_type, + version: version.into(), + install_dir, + binary_relative_path, + bin_dir_relative_path, }) - .await??; - tracing::debug!("Lock acquired: {lock_path:?}"); - - // Check again after acquiring the lock, in case another process completed - // the installation while we were downloading - if fs::try_exists(target.as_path()).await.unwrap_or(false) { - tracing::debug!("Target already exists after lock acquisition, skipping move: {target:?}"); - return Ok(()); - } - - // Try atomic rename first - if fs::rename(source.as_path(), target.as_path()).await.is_ok() { - tracing::debug!("Atomic rename successful: {source:?} -> {target:?}"); - return Ok(()); - } - - // If rename fails (cross-device), fall back to copy - tracing::debug!("Atomic rename failed, falling back to copy: {source:?} -> {target:?}"); - copy_dir_recursive(source, target).await?; - fs::remove_dir_all(source).await?; - - Ok(()) } -/// Recursively copy a directory -async fn copy_dir_recursive(src: &AbsolutePath, dst: &AbsolutePath) -> Result<(), Error> { - fs::create_dir_all(dst).await?; - - let mut entries = fs::read_dir(src).await?; - while let Some(entry) = entries.next_entry().await? { - // entry.path() returns absolute path when src is absolute - let src_path = AbsolutePathBuf::new(entry.path()).unwrap(); - let dst_path = dst.join(entry.file_name()); - - if entry.file_type().await?.is_dir() { - Box::pin(copy_dir_recursive(&src_path, &dst_path)).await?; - } else { - fs::copy(&src_path, &dst_path).await?; - } - } - - Ok(()) +/// Get the cache directory for JavaScript runtimes +fn get_cache_dir() -> Result { + let cache_dir = match BaseDirs::new() { + Some(dirs) => AbsolutePathBuf::new(dirs.cache_dir().to_path_buf()).unwrap(), + None => current_dir()?.join(".cache"), + }; + Ok(cache_dir.join("vite/js_runtime")) } #[cfg(test)] diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 8be664c0ed..18706e7a71 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -52,10 +52,14 @@ crates/vite_js_runtime/ ├── Cargo.toml └── src/ ├── lib.rs # Public API exports - ├── runtime.rs # JsRuntime struct, download, and core logic - ├── node.rs # Node.js specific implementation ├── error.rs # Error types - └── platform.rs # Platform detection and binary selection + ├── platform.rs # Platform detection (Os, Arch, Platform) + ├── provider.rs # JsRuntimeProvider trait and types + ├── providers/ # Provider implementations + │ ├── mod.rs + │ └── node.rs # NodeProvider implementing JsRuntimeProvider + ├── download.rs # Generic download utilities + └── runtime.rs # JsRuntime struct and download orchestration ``` ### Core Types @@ -73,9 +77,68 @@ pub struct JsRuntime { pub runtime_type: JsRuntimeType, pub version: Str, // Resolved version (e.g., "22.13.1") pub install_dir: AbsolutePathBuf, + binary_relative_path: Str, // e.g., "bin/node" or "node.exe" + bin_dir_relative_path: Str, // e.g., "bin" or "" +} + +/// Archive format for runtime distributions +pub enum ArchiveFormat { + TarGz, // .tar.gz (Linux, macOS) + Zip, // .zip (Windows) +} + +/// How to verify the integrity of a downloaded archive +pub enum HashVerification { + ShasumsFile { url: Str }, // Download and parse SHASUMS file + None, // No verification +} + +/// Information needed to download a runtime +pub struct DownloadInfo { + pub archive_url: Str, + pub archive_filename: Str, + pub archive_format: ArchiveFormat, + pub hash_verification: HashVerification, + pub extracted_dir_name: Str, +} +``` + +### Provider Trait + +The `JsRuntimeProvider` trait abstracts runtime-specific logic, making it easy to add new runtimes: + +```rust +#[async_trait] +pub trait JsRuntimeProvider: Send + Sync { + /// Get the name of this runtime (e.g., "node", "bun", "deno") + fn name(&self) -> &'static str; + + /// Get the platform string used in download URLs + fn platform_string(&self, platform: Platform) -> Str; + + /// Get download information for a specific version and platform + fn get_download_info(&self, version: &str, platform: Platform) -> DownloadInfo; + + /// Get the relative path to the runtime binary from the install directory + fn binary_relative_path(&self, platform: Platform) -> Str; + + /// Get the relative path to the bin directory from the install directory + fn bin_dir_relative_path(&self, platform: Platform) -> Str; + + /// Parse a SHASUMS file to extract the hash for a specific filename + fn parse_shasums(&self, shasums_content: &str, filename: &str) -> Result; } ``` +### Adding a New Runtime + +To add support for a new runtime (e.g., Bun): + +1. Create `src/providers/bun.rs` implementing `JsRuntimeProvider` +2. Add `Bun` variant to `JsRuntimeType` enum +3. Add match arm in `download_runtime()` to use the new provider +4. Export the provider from `src/providers/mod.rs` + ### Public API ```rust @@ -193,23 +256,29 @@ i9j0k1l2... node-v22.13.1-linux-arm64.tar.gz ``` 1. Receive runtime type and exact version as input -2. Determine platform and architecture - └── Map to Node.js distribution naming +2. Select the appropriate JsRuntimeProvider + └── e.g., NodeProvider for JsRuntimeType::Node -3. Check cache for existing installation +3. Get download info from provider + ├── Platform string (e.g., "linux-x64", "win-x64") + ├── Archive URL and filename + ├── Hash verification method + └── Extracted directory name + +4. Check cache for existing installation └── If exists: return cached path └── If not: continue to download -4. Download with atomic operations +5. Download with atomic operations ├── Create temp directory - ├── Download SHASUMS256.txt and parse expected hash + ├── Download SHASUMS file and parse expected hash (via provider) ├── Download archive with retry logic - ├── Verify archive hash against SHASUMS256.txt - ├── Extract archive + ├── Verify archive hash + ├── Extract archive (tar.gz or zip based on format) ├── Acquire file lock (prevent concurrent installs) └── Atomic rename to final location -5. Return JsRuntime with install path +6. Return JsRuntime with install path and relative paths ``` ### Concurrent Download Protection @@ -344,14 +413,25 @@ pub enum JsRuntimeError { - Node.js is the most widely used runtime - Allows focused, well-tested implementation -- Architecture supports easy addition of Bun/Deno later +- Trait-based architecture (`JsRuntimeProvider`) makes adding Bun/Deno straightforward - Reduces initial complexity and scope +### 5. Trait-Based Provider Architecture + +**Decision**: Use a `JsRuntimeProvider` trait to abstract runtime-specific logic. + +**Rationale**: + +- Clean separation between generic download logic and runtime-specific details +- Each provider encapsulates: platform strings, URL construction, hash verification, binary paths +- Adding a new runtime only requires implementing the trait +- Generic download utilities are reusable across all providers + ## Future Enhancements 1. **Version aliases**: Support `latest` and `lts` aliases with cached version index -2. **Bun support**: Add `bun@x.y.z` runtime option with Bun release downloads -3. **Deno support**: Add `deno@x.y.z` runtime option with Deno release downloads +2. **Bun support**: Create `BunProvider` implementing `JsRuntimeProvider` +3. **Deno support**: Create `DenoProvider` implementing `JsRuntimeProvider` 4. **Version ranges**: Support semver ranges like `node@^22.0.0` 5. **Offline mode**: Use cached versions without network access From 677d29eb22630ab72bacdf58ba2770624a6ebc31 Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 21:39:02 +0800 Subject: [PATCH 13/25] refactor(js_runtime): remove copy fallback in move_to_cache Just throw error if fs::rename fails instead of falling back to recursive copy. Also removes the unused copy_dir_recursive function. --- crates/vite_js_runtime/src/download.rs | 33 +++----------------------- 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/crates/vite_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs index 80ad668a86..390252e1e1 100644 --- a/crates/vite_js_runtime/src/download.rs +++ b/crates/vite_js_runtime/src/download.rs @@ -178,36 +178,9 @@ pub async fn move_to_cache( return Ok(()); } - // Try atomic rename first - if fs::rename(source.as_path(), target.as_path()).await.is_ok() { - tracing::debug!("Atomic rename successful: {source:?} -> {target:?}"); - return Ok(()); - } - - // If rename fails (cross-device), fall back to copy - tracing::debug!("Atomic rename failed, falling back to copy: {source:?} -> {target:?}"); - copy_dir_recursive(source, target).await?; - fs::remove_dir_all(source).await?; - - Ok(()) -} - -/// Recursively copy a directory -pub async fn copy_dir_recursive(src: &AbsolutePath, dst: &AbsolutePath) -> Result<(), Error> { - fs::create_dir_all(dst).await?; - - let mut entries = fs::read_dir(src).await?; - while let Some(entry) = entries.next_entry().await? { - // entry.path() returns absolute path when src is absolute - let src_path = AbsolutePathBuf::new(entry.path()).unwrap(); - let dst_path = dst.join(entry.file_name()); - - if entry.file_type().await?.is_dir() { - Box::pin(copy_dir_recursive(&src_path, &dst_path)).await?; - } else { - fs::copy(&src_path, &dst_path).await?; - } - } + // Atomic rename + fs::rename(source.as_path(), target.as_path()).await?; + tracing::debug!("Atomic rename successful: {source:?} -> {target:?}"); Ok(()) } From 686fa911bc58257a85d506a987f9313055941dcb Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 26 Jan 2026 21:45:04 +0800 Subject: [PATCH 14/25] fix(js_runtime): clean up incomplete installations before re-download If the install directory exists but the binary doesn't, it indicates an incomplete or corrupted installation. Now we detect this and remove the incomplete directory before downloading again. Added test_incomplete_installation_cleanup to verify this behavior. --- crates/vite_js_runtime/src/runtime.rs | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 17bfa38e98..618b0fae4d 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -131,6 +131,14 @@ pub async fn download_runtime_with_provider( }); } + // If install_dir exists but binary doesn't, it's an incomplete installation - clean it up + if tokio::fs::try_exists(&install_dir).await.unwrap_or(false) { + tracing::warn!( + "Incomplete installation detected at {install_dir:?}, removing before re-download" + ); + tokio::fs::remove_dir_all(&install_dir).await?; + } + tracing::info!("Downloading {} {version} for {platform_str}...", provider.name()); // Get download info from provider @@ -244,6 +252,37 @@ mod tests { assert_eq!(runtime1.install_dir, runtime2.install_dir); } + /// Test that incomplete installations are cleaned up and re-downloaded + #[tokio::test] + async fn test_incomplete_installation_cleanup() { + // Use a different version to avoid interference with other tests + let version = "20.18.1"; + + // First, ensure we have a valid cached version + let runtime = download_runtime(JsRuntimeType::Node, version).await.unwrap(); + let install_dir = runtime.install_dir.clone(); + let binary_path = runtime.get_binary_path(); + + // Simulate an incomplete installation by removing the binary but keeping the directory + tokio::fs::remove_file(&binary_path).await.unwrap(); + assert!(!tokio::fs::try_exists(&binary_path).await.unwrap()); + assert!(tokio::fs::try_exists(&install_dir).await.unwrap()); + + // Now download again - it should detect the incomplete installation and re-download + let runtime2 = download_runtime(JsRuntimeType::Node, version).await.unwrap(); + + // Verify the binary exists again + assert!(tokio::fs::try_exists(&runtime2.get_binary_path()).await.unwrap()); + + // Verify binary is executable + let output = tokio::process::Command::new(runtime2.get_binary_path().as_path()) + .arg("--version") + .output() + .await + .unwrap(); + assert!(output.status.success()); + } + /// Test concurrent downloads - multiple tasks downloading the same version /// should not cause corruption or conflicts due to file-based locking #[tokio::test] From 17f608eeeac3d986f4320cfc4aa4655c878298f6 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Jan 2026 09:52:36 +0800 Subject: [PATCH 15/25] refactor(js_runtime): remove platform from cache directory path The platform is redundant in the cache path since a runtime always runs on the current OS. Simplifies the structure from `{runtime}/{version}/{platform}/` to `{runtime}/{version}/`. --- crates/vite_js_runtime/src/runtime.rs | 5 ++--- rfcs/js-runtime.md | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 618b0fae4d..35e814ed0e 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -114,9 +114,8 @@ pub async fn download_runtime_with_provider( let binary_relative_path = provider.binary_relative_path(platform); let bin_dir_relative_path = provider.bin_dir_relative_path(platform); - // Cache path: $CACHE_DIR/vite/js_runtime/{runtime}/{version}/{platform}/ - let install_dir = - cache_dir.join(vite_str::format!("{}/{version}/{platform_str}", provider.name())); + // Cache path: $CACHE_DIR/vite/js_runtime/{runtime}/{version}/ + let install_dir = cache_dir.join(vite_str::format!("{}/{version}", provider.name())); // Check if already cached let binary_path = install_dir.join(&binary_relative_path); diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 18706e7a71..ed732f5a0b 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -179,14 +179,14 @@ println!("Version: {}", runtime.version()); // "22.13.1" Following the PackageManager pattern: ``` -$CACHE_DIR/vite/js_runtime/{runtime}/{version}/{platform}-{arch}/ +$CACHE_DIR/vite/js_runtime/{runtime}/{version}/ ``` Examples: -- Linux x64: `~/.cache/vite/js_runtime/node/22.13.1/linux-x64/` -- macOS ARM: `~/Library/Caches/vite/js_runtime/node/22.13.1/darwin-arm64/` -- Windows x64: `%LOCALAPPDATA%\vite\js_runtime\node\22.13.1\win-x64\` +- Linux x64: `~/.cache/vite/js_runtime/node/22.13.1/` +- macOS ARM: `~/Library/Caches/vite/js_runtime/node/22.13.1/` +- Windows x64: `%LOCALAPPDATA%\vite\js_runtime\node\22.13.1\` ### Platform Detection From 69cc9e42746bea5cc1bf80de0b3252dba4db50ea Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Jan 2026 10:25:46 +0800 Subject: [PATCH 16/25] feat(js_runtime): support devEngines.runtime for project-based runtime download Add a new function `download_runtime_for_project()` that reads `devEngines.runtime` from a project's package.json and downloads the appropriate Node.js version. Features: - Parse devEngines.runtime as single object or array of runtimes - Resolve semver ranges (^, ~, etc.) using node-semver crate - Fetch and cache version index from nodejs.org/dist/index.json (1-hour TTL) - Fall back to latest Node.js if no configuration found - Support custom mirrors via VITE_NODE_DIST_MIRROR env var New public API: - `download_runtime_for_project(project_path)` - project-based download - `NodeProvider::fetch_version_index()` - fetch cached version list - `NodeProvider::resolve_version(version_req)` - resolve semver range - `NodeProvider::resolve_latest_version()` - get latest version New error variants: - VersionIndexParseFailed, NoMatchingVersion, Json, SemverRange --- .typos.toml | 1 + Cargo.lock | 50 +++- Cargo.toml | 1 + crates/vite_js_runtime/Cargo.toml | 3 + crates/vite_js_runtime/src/dev_engines.rs | 163 +++++++++++++ crates/vite_js_runtime/src/error.rs | 16 ++ crates/vite_js_runtime/src/lib.rs | 18 +- crates/vite_js_runtime/src/providers/node.rs | 241 ++++++++++++++++++- crates/vite_js_runtime/src/runtime.rs | 151 +++++++++++- rfcs/js-runtime.md | 183 ++++++++++++-- 10 files changed, 800 insertions(+), 27 deletions(-) create mode 100644 crates/vite_js_runtime/src/dev_engines.rs diff --git a/.typos.toml b/.typos.toml index 17c8d48af4..e54ba90d34 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,6 +1,7 @@ [default.extend-words] ratatui = "ratatui" PUNICODE = "PUNICODE" +Jod = "Jod" # Node.js v22 LTS codename [files] extend-exclude = [ diff --git a/Cargo.lock b/Cargo.lock index 01bd3a5f49..163cdcb8b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2895,6 +2895,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "mimalloc-safe" version = "0.1.56" @@ -3079,6 +3101,19 @@ dependencies = [ "memoffset 0.9.1", ] +[[package]] +name = "node-semver" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1a233ea5dc37d2cfba31cfc87a5a56cc2a9c04e3672c15d179ca118dae40a7" +dependencies = [ + "bytecount", + "miette", + "nom 7.1.3", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "nodejs-built-in-modules" version = "1.0.0" @@ -3437,7 +3472,7 @@ dependencies = [ "textwrap", "thiserror 2.0.17", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -4779,7 +4814,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77dff57c9de498bb1eb5b1ce682c2e3a0ae956b266fa0933c3e151b87b078967" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", "yansi", ] @@ -6256,7 +6291,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -6691,6 +6726,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -6996,7 +7037,10 @@ dependencies = [ "flate2", "futures-util", "hex", + "node-semver", "reqwest", + "serde", + "serde_json", "sha2", "tar", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index b9e953ca13..3528196f41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,6 +119,7 @@ rusqlite = { version = "0.37.0", features = ["bundled"] } rustc-hash = "2.1.1" schemars = "1.0.0" self_cell = "1.2.0" +node-semver = "2.2.0" semver = "1.0.26" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" diff --git a/crates/vite_js_runtime/Cargo.toml b/crates/vite_js_runtime/Cargo.toml index 8f6fa5ab9d..38577976ab 100644 --- a/crates/vite_js_runtime/Cargo.toml +++ b/crates/vite_js_runtime/Cargo.toml @@ -14,6 +14,9 @@ directories = { workspace = true } flate2 = { workspace = true } futures-util = { workspace = true } hex = { workspace = true } +node-semver = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } sha2 = { workspace = true } tar = { workspace = true } tempfile = { workspace = true } diff --git a/crates/vite_js_runtime/src/dev_engines.rs b/crates/vite_js_runtime/src/dev_engines.rs new file mode 100644 index 0000000000..27c352f118 --- /dev/null +++ b/crates/vite_js_runtime/src/dev_engines.rs @@ -0,0 +1,163 @@ +//! Package.json devEngines.runtime parsing. +//! +//! This module provides structs for parsing the `devEngines.runtime` field from package.json, +//! which can be either a single runtime object or an array of runtime objects. + +use serde::Deserialize; +use vite_str::Str; + +/// A single runtime engine configuration. +#[derive(Deserialize, Default, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeEngine { + /// The name of the runtime (e.g., "node", "deno", "bun") + #[serde(default)] + pub name: Str, + /// The version requirement (e.g., "^24.4.0") + #[serde(default)] + pub version: Str, + /// Action to take on failure (e.g., "download", "error", "warn") + /// Currently not used but parsed for future use. + #[serde(default)] + #[allow(dead_code)] + pub on_fail: Str, +} + +/// Runtime field can be a single object or an array. +#[derive(Deserialize, Debug)] +#[serde(untagged)] +pub enum RuntimeEngineConfig { + /// A single runtime configuration + Single(RuntimeEngine), + /// Multiple runtime configurations + Multiple(Vec), +} + +impl RuntimeEngineConfig { + /// Find the first runtime with the given name. + #[must_use] + pub fn find_by_name(&self, name: &str) -> Option<&RuntimeEngine> { + match self { + Self::Single(engine) if engine.name == name => Some(engine), + Self::Single(_) => None, + Self::Multiple(engines) => engines.iter().find(|e| e.name == name), + } + } +} + +/// The devEngines section of package.json. +#[derive(Deserialize, Default, Debug)] +pub struct DevEngines { + /// Runtime configuration(s) + #[serde(default)] + pub runtime: Option, +} + +/// Partial package.json structure for reading devEngines. +#[derive(Deserialize, Default, Debug)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PackageJson { + /// The devEngines configuration + #[serde(default)] + pub dev_engines: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_single_runtime() { + let json = r#"{ + "devEngines": { + "runtime": { + "name": "node", + "version": "^24.4.0", + "onFail": "download" + } + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let dev_engines = pkg.dev_engines.unwrap(); + let runtime = dev_engines.runtime.unwrap(); + + let node = runtime.find_by_name("node").unwrap(); + assert_eq!(node.name, "node"); + assert_eq!(node.version, "^24.4.0"); + assert_eq!(node.on_fail, "download"); + + assert!(runtime.find_by_name("deno").is_none()); + } + + #[test] + fn test_parse_multiple_runtimes() { + let json = r#"{ + "devEngines": { + "runtime": [ + { + "name": "node", + "version": "^24.4.0", + "onFail": "download" + }, + { + "name": "deno", + "version": "^2.4.3", + "onFail": "download" + } + ] + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let dev_engines = pkg.dev_engines.unwrap(); + let runtime = dev_engines.runtime.unwrap(); + + let node = runtime.find_by_name("node").unwrap(); + assert_eq!(node.name, "node"); + assert_eq!(node.version, "^24.4.0"); + + let deno = runtime.find_by_name("deno").unwrap(); + assert_eq!(deno.name, "deno"); + assert_eq!(deno.version, "^2.4.3"); + + assert!(runtime.find_by_name("bun").is_none()); + } + + #[test] + fn test_parse_no_dev_engines() { + let json = r#"{"name": "test"}"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert!(pkg.dev_engines.is_none()); + } + + #[test] + fn test_parse_empty_dev_engines() { + let json = r#"{"devEngines": {}}"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let dev_engines = pkg.dev_engines.unwrap(); + assert!(dev_engines.runtime.is_none()); + } + + #[test] + fn test_parse_runtime_with_missing_fields() { + let json = r#"{ + "devEngines": { + "runtime": { + "name": "node" + } + } + }"#; + + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + let dev_engines = pkg.dev_engines.unwrap(); + let runtime = dev_engines.runtime.unwrap(); + + let node = runtime.find_by_name("node").unwrap(); + assert_eq!(node.name, "node"); + assert!(node.version.is_empty()); + assert!(node.on_fail.is_empty()); + } +} diff --git a/crates/vite_js_runtime/src/error.rs b/crates/vite_js_runtime/src/error.rs index 1cc8d3d321..46230a9409 100644 --- a/crates/vite_js_runtime/src/error.rs +++ b/crates/vite_js_runtime/src/error.rs @@ -32,6 +32,14 @@ pub enum Error { #[error("Hash not found for {filename} in SHASUMS256.txt")] HashNotFound { filename: Str }, + /// Failed to parse version index + #[error("Failed to parse version index: {reason}")] + VersionIndexParseFailed { reason: Str }, + + /// No version matching the requirement found + #[error("No version matching '{version_req}' found")] + NoMatchingVersion { version_req: Str }, + /// IO error #[error(transparent)] Io(#[from] std::io::Error), @@ -43,4 +51,12 @@ pub enum Error { /// Join error from tokio #[error(transparent)] JoinError(#[from] tokio::task::JoinError), + + /// JSON parsing error + #[error(transparent)] + Json(#[from] serde_json::Error), + + /// Semver range parsing error + #[error(transparent)] + SemverRange(#[from] node_semver::SemverError), } diff --git a/crates/vite_js_runtime/src/lib.rs b/crates/vite_js_runtime/src/lib.rs index 1de4546455..94d654bae9 100644 --- a/crates/vite_js_runtime/src/lib.rs +++ b/crates/vite_js_runtime/src/lib.rs @@ -13,6 +13,18 @@ //! println!("Node.js installed at: {}", runtime.get_binary_path()); //! ``` //! +//! # Project-Based Runtime Download +//! +//! You can also download a runtime based on a project's `devEngines.runtime` configuration: +//! +//! ```rust,ignore +//! use vite_js_runtime::download_runtime_for_project; +//! use vite_path::AbsolutePathBuf; +//! +//! let project_path = AbsolutePathBuf::new("/path/to/project".into()).unwrap(); +//! let runtime = download_runtime_for_project(&project_path).await?; +//! ``` +//! //! # Adding a New Runtime //! //! To add support for a new JavaScript runtime (e.g., Bun, Deno): @@ -21,6 +33,7 @@ //! 2. Add the runtime type to `JsRuntimeType` enum //! 3. Add a match arm in `download_runtime()` to use the new provider +mod dev_engines; mod download; mod error; mod platform; @@ -32,4 +45,7 @@ pub use error::Error; pub use platform::{Arch, Os, Platform}; pub use provider::{ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvider}; pub use providers::NodeProvider; -pub use runtime::{JsRuntime, JsRuntimeType, download_runtime, download_runtime_with_provider}; +pub use runtime::{ + JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project, + download_runtime_with_provider, +}; diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index e67f13a5cc..8285cfd14d 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -1,12 +1,20 @@ //! Node.js runtime provider implementation. -use std::env; +use std::{ + env, + time::{SystemTime, UNIX_EPOCH}, +}; use async_trait::async_trait; +use directories::BaseDirs; +use node_semver::{Range, Version}; +use serde::{Deserialize, Serialize}; +use vite_path::{AbsolutePathBuf, current_dir}; use vite_str::Str; use crate::{ Error, Platform, + download::download_text, platform::Os, provider::{ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvider}, }; @@ -17,6 +25,44 @@ const DEFAULT_NODE_DIST_URL: &str = "https://nodejs.org/dist"; /// Environment variable to override the Node.js distribution URL const NODE_DIST_MIRROR_ENV: &str = "VITE_NODE_DIST_MIRROR"; +/// Default cache TTL in seconds (1 hour) +const DEFAULT_CACHE_TTL_SECS: u64 = 3600; + +/// A single entry from the Node.js version index +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct NodeVersionEntry { + /// Version string (e.g., "v25.5.0") + pub version: Str, + /// LTS information + #[serde(default)] + pub lts: LtsInfo, +} + +/// LTS field can be false or a codename string +#[derive(Deserialize, Serialize, Debug, Clone, Default)] +#[serde(untagged)] +pub enum LtsInfo { + /// Not an LTS release + #[default] + NotLts, + /// Boolean false (not LTS) + Boolean(bool), + /// LTS codename (e.g., "Jod") + Codename(Str), +} + +/// Cached version index with expiration +#[derive(Deserialize, Serialize, Debug)] +struct VersionIndexCache { + /// Unix timestamp when cache expires + expires_at: u64, + /// ETag from HTTP response (for conditional requests) + #[serde(default)] + etag: Option, + /// Cached version entries + versions: Vec, +} + /// Node.js runtime provider #[derive(Debug, Default)] pub struct NodeProvider; @@ -35,6 +81,118 @@ impl NodeProvider { Os::Linux | Os::Darwin => ArchiveFormat::TarGz, } } + + /// Fetch the version index from nodejs.org/dist/index.json with HTTP caching. + /// + /// # Errors + /// + /// Returns an error if the download fails or the JSON is invalid. + pub async fn fetch_version_index(&self) -> Result, Error> { + let cache_dir = get_cache_dir()?; + let cache_path = cache_dir.join("node/index_cache.json"); + + // Try to load from cache + if let Some(cache) = load_cache(&cache_path).await { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + if now < cache.expires_at { + tracing::debug!( + "Using cached version index (expires in {}s)", + cache.expires_at - now + ); + return Ok(cache.versions); + } + tracing::debug!("Version index cache expired, fetching fresh data"); + } + + // Fetch fresh data + self.fetch_and_cache(&cache_path).await + } + + /// Fetch the version index and cache it. + async fn fetch_and_cache( + &self, + cache_path: &AbsolutePathBuf, + ) -> Result, Error> { + let base_url = get_dist_url(); + let index_url = vite_str::format!("{base_url}/index.json"); + + tracing::debug!("Fetching version index from {index_url}"); + let content = download_text(&index_url).await?; + + let versions: Vec = serde_json::from_str(&content)?; + + // Save to cache + let expires_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + + DEFAULT_CACHE_TTL_SECS; + + let cache = VersionIndexCache { expires_at, etag: None, versions: versions.clone() }; + + // Ensure cache directory exists + if let Some(parent) = cache_path.parent() { + tokio::fs::create_dir_all(parent).await.ok(); + } + + // Write cache file (ignore errors) + if let Ok(cache_json) = serde_json::to_string(&cache) { + tokio::fs::write(cache_path, cache_json).await.ok(); + } + + Ok(versions) + } + + /// Resolve a version requirement (e.g., "^24.4.0") to an exact version. + /// + /// Uses npm-compatible semver range parsing. + /// + /// # Errors + /// + /// Returns an error if no matching version is found or if the version requirement is invalid. + pub async fn resolve_version(&self, version_req: &str) -> Result { + let range = Range::parse(version_req)?; + let versions = self.fetch_version_index().await?; + + for entry in versions { + let version_str = entry.version.strip_prefix('v').unwrap_or(&entry.version); + if let Ok(version) = Version::parse(version_str) { + if range.satisfies(&version) { + return Ok(version_str.into()); + } + } + } + + Err(Error::NoMatchingVersion { version_req: version_req.into() }) + } + + /// Get the latest version (first entry in the index). + /// + /// # Errors + /// + /// Returns an error if the version index is empty or cannot be fetched. + pub async fn resolve_latest_version(&self) -> Result { + let versions = self.fetch_version_index().await?; + + versions + .first() + .map(|entry| entry.version.strip_prefix('v').unwrap_or(&entry.version).into()) + .ok_or_else(|| Error::VersionIndexParseFailed { + reason: "Version index is empty".into(), + }) + } +} + +/// Load cache from file. +async fn load_cache(cache_path: &AbsolutePathBuf) -> Option { + let content = tokio::fs::read_to_string(cache_path).await.ok()?; + serde_json::from_str(&content).ok() +} + +/// Get the cache directory for JavaScript runtimes. +fn get_cache_dir() -> Result { + let cache_dir = match BaseDirs::new() { + Some(dirs) => AbsolutePathBuf::new(dirs.cache_dir().to_path_buf()).unwrap(), + None => current_dir()?.join(".cache"), + }; + Ok(cache_dir.join("vite/js_runtime")) } /// Get the Node.js distribution base URL @@ -251,4 +409,85 @@ fedcba987654 node-v22.13.1-win-x64.zip"; assert_eq!(get_dist_url(), "https://nodejs.org/dist"); unsafe { env::remove_var(NODE_DIST_MIRROR_ENV) }; } + + #[test] + fn test_parse_lts_info() { + // Test parsing different LTS formats + let json_not_lts = r#"{"version": "v23.0.0", "lts": false}"#; + let entry: NodeVersionEntry = serde_json::from_str(json_not_lts).unwrap(); + assert!(matches!(entry.lts, LtsInfo::Boolean(false))); + + let json_lts_codename = r#"{"version": "v22.12.0", "lts": "Jod"}"#; + let entry: NodeVersionEntry = serde_json::from_str(json_lts_codename).unwrap(); + assert!(matches!(entry.lts, LtsInfo::Codename(_))); + + let json_no_lts = r#"{"version": "v23.0.0"}"#; + let entry: NodeVersionEntry = serde_json::from_str(json_no_lts).unwrap(); + assert!(matches!(entry.lts, LtsInfo::NotLts)); + } + + #[tokio::test] + async fn test_fetch_version_index() { + let provider = NodeProvider::new(); + let versions = provider.fetch_version_index().await.unwrap(); + + // Should have at least some versions + assert!(!versions.is_empty()); + + // First entry should be the latest version + let first = &versions[0]; + assert!(first.version.starts_with('v')); + + // Should contain some known versions + let has_v20 = versions.iter().any(|v| v.version.starts_with("v20.")); + assert!(has_v20, "Should contain Node.js v20.x versions"); + } + + #[tokio::test] + async fn test_resolve_version() { + let provider = NodeProvider::new(); + + // Test resolving a caret range + let version = provider.resolve_version("^20.18.0").await.unwrap(); + let parsed = Version::parse(&version).unwrap(); + assert!(parsed.major == 20); + assert!(parsed.minor >= 18); + + // Test resolving a tilde range + let version = provider.resolve_version("~20.18.0").await.unwrap(); + let parsed = Version::parse(&version).unwrap(); + assert!(parsed.major == 20); + assert!(parsed.minor == 18); + } + + #[tokio::test] + async fn test_resolve_version_exact() { + let provider = NodeProvider::new(); + + // Test resolving an exact version + let version = provider.resolve_version("20.18.0").await.unwrap(); + assert_eq!(version, "20.18.0"); + } + + #[tokio::test] + async fn test_resolve_version_no_match() { + let provider = NodeProvider::new(); + + // Test a version range that doesn't exist + let result = provider.resolve_version("^999.0.0").await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_resolve_latest_version() { + let provider = NodeProvider::new(); + + let version = provider.resolve_latest_version().await.unwrap(); + + // Should be a valid semver without 'v' prefix + assert!(!version.starts_with('v')); + let parsed = Version::parse(&version).unwrap(); + // Latest version should be fairly recent + assert!(parsed.major >= 20); + } } diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 35e814ed0e..b042766702 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -1,10 +1,11 @@ use directories::BaseDirs; use tempfile::TempDir; -use vite_path::{AbsolutePathBuf, current_dir}; +use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use vite_str::Str; use crate::{ Error, Platform, + dev_engines::PackageJson, download::{download_file, download_text, extract_archive, move_to_cache, verify_file_hash}, provider::{HashVerification, JsRuntimeProvider}, providers::NodeProvider, @@ -186,6 +187,72 @@ pub async fn download_runtime_with_provider( }) } +/// Download runtime based on project's devEngines.runtime configuration. +/// +/// Reads the `devEngines.runtime` field from the project's package.json and downloads +/// the appropriate runtime version. If no configuration is found, downloads the latest +/// Node.js version. +/// +/// # Arguments +/// * `project_path` - The path to the project directory containing package.json +/// +/// # Returns +/// A `JsRuntime` instance with the installation path +/// +/// # Errors +/// Returns an error if package.json cannot be read/parsed, version resolution fails, +/// or download/extraction fails. +/// +/// # Note +/// Currently only supports Node.js runtime. Other runtimes in the configuration +/// (e.g., "deno", "bun") are ignored. +pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result { + let package_json_path = project_path.join("package.json"); + let dev_engines = read_dev_engines(&package_json_path).await?; + let provider = NodeProvider::new(); + + // Find the "node" runtime configuration (supports both single object and array) + let node_runtime = dev_engines + .as_ref() + .and_then(|de| de.runtime.as_ref()) + .and_then(|rt| rt.find_by_name("node")); + + let version = match node_runtime { + Some(runtime) => { + // Resolve version range or get latest + if runtime.version.is_empty() { + tracing::debug!("Node runtime configured without version, using latest"); + provider.resolve_latest_version().await? + } else { + tracing::debug!("Resolving Node version requirement: {}", runtime.version); + provider.resolve_version(&runtime.version).await? + } + } + // No node runtime configured, use latest + None => { + tracing::debug!("No devEngines.runtime configuration found, using latest Node.js"); + provider.resolve_latest_version().await? + } + }; + + tracing::info!("Resolved Node.js version: {version}"); + download_runtime(JsRuntimeType::Node, &version).await +} + +/// Read devEngines configuration from package.json. +async fn read_dev_engines( + package_json_path: &AbsolutePathBuf, +) -> Result, Error> { + if !tokio::fs::try_exists(package_json_path).await.unwrap_or(false) { + tracing::debug!("package.json not found at {:?}", package_json_path); + return Ok(None); + } + + let content = tokio::fs::read_to_string(package_json_path).await?; + let pkg: PackageJson = serde_json::from_str(&content)?; + Ok(pkg.dev_engines) +} + /// Get the cache directory for JavaScript runtimes fn get_cache_dir() -> Result { let cache_dir = match BaseDirs::new() { @@ -197,6 +264,8 @@ fn get_cache_dir() -> Result { #[cfg(test)] mod tests { + use tempfile::TempDir; + use super::*; #[test] @@ -204,6 +273,86 @@ mod tests { assert_eq!(JsRuntimeType::Node.to_string(), "node"); } + #[tokio::test] + async fn test_download_runtime_for_project_with_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with devEngines.runtime + let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"^20.18.0"}}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + // Version should be >= 20.18.0 and < 21.0.0 + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + assert!(parsed.minor >= 18); + + // Verify the binary exists and works + let binary_path = runtime.get_binary_path(); + assert!(tokio::fs::try_exists(&binary_path).await.unwrap()); + } + + #[tokio::test] + async fn test_download_runtime_for_project_with_multiple_runtimes() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with array of runtimes + let package_json = r#"{ + "devEngines": { + "runtime": [ + {"name": "deno", "version": "^2.0.0"}, + {"name": "node", "version": "^20.18.0"} + ] + } + }"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + // Should use node runtime (deno is not supported yet) + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + } + + #[tokio::test] + async fn test_download_runtime_for_project_no_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json without devEngines + let package_json = r#"{"name": "test-project"}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + // Should download latest Node.js + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should have a valid version + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 20); + } + + #[tokio::test] + async fn test_download_runtime_for_project_no_package_json() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // No package.json file + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + // Should download latest Node.js + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + } + /// Integration test that downloads a real Node.js version #[tokio::test] async fn test_download_node_integration() { diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index ed732f5a0b..45f6d227b2 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -20,7 +20,7 @@ The PackageManager implementation in `vite_install` successfully handles automat ## Non-Goals (Initial Version) -- Configuration auto-detection (no reading from package.json, .nvmrc, etc.) +- ~~Configuration auto-detection (no reading from package.json, .nvmrc, etc.)~~ **Now supported via `devEngines.runtime`** - Managing multiple runtime versions simultaneously - Providing a version manager CLI (like nvm/fnm) - Supporting custom/unofficial Node.js builds @@ -41,7 +41,12 @@ The library accepts runtime specification as a string parameter: | Bun (future) | `bun@1.2.0` | | Deno (future) | `deno@2.0.0` | -Only exact versions are supported. Version aliases (like `latest` or `lts`) may be added in future versions. +Both exact versions and semver ranges are supported: + +- Exact: `22.13.1` +- Caret range: `^22.0.0` (>=22.0.0 <23.0.0) +- Tilde range: `~22.13.0` (>=22.13.0 <22.14.0) +- Latest: omit version to get the latest release ## Architecture @@ -52,12 +57,13 @@ crates/vite_js_runtime/ ├── Cargo.toml └── src/ ├── lib.rs # Public API exports + ├── dev_engines.rs # devEngines.runtime parsing from package.json ├── error.rs # Error types ├── platform.rs # Platform detection (Os, Arch, Platform) ├── provider.rs # JsRuntimeProvider trait and types ├── providers/ # Provider implementations │ ├── mod.rs - │ └── node.rs # NodeProvider implementing JsRuntimeProvider + │ └── node.rs # NodeProvider with version resolution ├── download.rs # Generic download utilities └── runtime.rs # JsRuntime struct and download orchestration ``` @@ -142,13 +148,18 @@ To add support for a new runtime (e.g., Bun): ### Public API ```rust -/// Download and cache a JavaScript runtime -/// Returns the JsRuntime with installation path +/// Download and cache a JavaScript runtime by exact version pub async fn download_runtime( runtime_type: JsRuntimeType, version: &str, // Exact version (e.g., "22.13.1") ) -> Result; +/// Download runtime based on project's devEngines.runtime configuration +/// Reads package.json, resolves semver ranges, downloads the matching version +pub async fn download_runtime_for_project( + project_path: &AbsolutePath, +) -> Result; + impl JsRuntime { /// Get the path to the runtime binary (e.g., node, bun) pub fn get_binary_path(&self) -> AbsolutePathBuf; @@ -162,9 +173,22 @@ impl JsRuntime { /// Get the resolved version string (always exact, e.g., "22.13.1") pub fn version(&self) -> &str; } + +impl NodeProvider { + /// Fetch version index from nodejs.org/dist/index.json (with HTTP caching) + pub async fn fetch_version_index(&self) -> Result, Error>; + + /// Resolve version requirement (e.g., "^24.4.0") to exact version + pub async fn resolve_version(&self, version_req: &str) -> Result; + + /// Get latest version (first entry in index) + pub async fn resolve_latest_version(&self) -> Result; +} ``` -### Usage Example +### Usage Examples + +**Direct version download:** ```rust use vite_js_runtime::{JsRuntimeType, download_runtime}; @@ -174,6 +198,17 @@ println!("Node.js installed at: {}", runtime.get_binary_path()); println!("Version: {}", runtime.version()); // "22.13.1" ``` +**Project-based download (reads devEngines.runtime from package.json):** + +```rust +use vite_js_runtime::download_runtime_for_project; +use vite_path::AbsolutePathBuf; + +let project_path = AbsolutePathBuf::new("/path/to/project".into()).unwrap(); +let runtime = download_runtime_for_project(&project_path).await?; +// Version is resolved from devEngines.runtime or uses latest +``` + ## Cache Directory Structure Following the PackageManager pattern: @@ -188,6 +223,32 @@ Examples: - macOS ARM: `~/Library/Caches/vite/js_runtime/node/22.13.1/` - Windows x64: `%LOCALAPPDATA%\vite\js_runtime\node\22.13.1\` +### Version Index Cache + +The Node.js version index is cached locally to avoid repeated network requests: + +``` +$CACHE_DIR/vite/js_runtime/node/index_cache.json +``` + +Cache structure: + +```json +{ + "expires_at": 1706400000, + "etag": null, + "versions": [ + {"version": "v25.5.0", "lts": false}, + {"version": "v24.4.0", "lts": "Jod"}, + ... + ] +} +``` + +- Default TTL: 1 hour (3600 seconds) +- Cache is refreshed when expired +- Falls back to full fetch if cache is corrupted + ### Platform Detection | OS | Architecture | Platform String | @@ -199,6 +260,62 @@ Examples: | Windows | x64 | `win-x64` | | Windows | ARM64 | `win-arm64` | +## Project Configuration (devEngines.runtime) + +The `download_runtime_for_project` function reads the `devEngines.runtime` field from the project's package.json. This follows the [npm devEngines RFC](https://github.com/npm/rfcs/blob/main/accepted/0048-devEngines.md). + +### Single Runtime + +```json +{ + "devEngines": { + "runtime": { + "name": "node", + "version": "^24.4.0", + "onFail": "download" + } + } +} +``` + +### Multiple Runtimes (Array) + +```json +{ + "devEngines": { + "runtime": [ + { + "name": "node", + "version": "^24.4.0", + "onFail": "download" + }, + { + "name": "deno", + "version": "^2.4.3", + "onFail": "download" + } + ] + } +} +``` + +**Note:** Currently only the `"node"` runtime is supported. Other runtimes are ignored. + +### Version Resolution + +When a semver range is specified (e.g., `^24.4.0`), the library: + +1. Fetches the version index from `https://nodejs.org/dist/index.json` +2. Caches the index locally with 1-hour TTL +3. Uses `node-semver` crate for npm-compatible range matching +4. Returns the first (highest) version that satisfies the range + +### Fallback Behavior + +- If no `devEngines.runtime` is configured, downloads the latest Node.js version +- If `name` is not `"node"`, the runtime is skipped +- If `version` is empty, downloads the latest Node.js version + ## Download Sources ### Node.js @@ -322,24 +439,43 @@ async fn run_with_managed_node( ## Error Handling -New error variants for `vite_error`: +Error variants in `vite_js_runtime::Error`: ```rust -pub enum JsRuntimeError { +pub enum Error { /// Version not found in official releases - VersionNotFound { runtime: String, version: String }, + VersionNotFound { runtime: Str, version: Str }, /// Platform not supported for this runtime - UnsupportedPlatform { platform: String, runtime: String }, + UnsupportedPlatform { platform: Str, runtime: Str }, /// Download failed after retries - DownloadFailed { url: String, reason: String }, + DownloadFailed { url: Str, reason: Str }, /// Hash verification failed (download corrupted) - HashMismatch { expected: String, actual: String }, + HashMismatch { filename: Str, expected: Str, actual: Str }, /// Archive extraction failed - ExtractionFailed { reason: String }, + ExtractionFailed { reason: Str }, + + /// SHASUMS file parsing failed + ShasumsParseFailed { reason: Str }, + + /// Hash not found in SHASUMS file + HashNotFound { filename: Str }, + + /// Failed to parse version index + VersionIndexParseFailed { reason: Str }, + + /// No version matching the requirement found + NoMatchingVersion { version_req: Str }, + + /// IO, HTTP, JSON, and semver errors + Io(std::io::Error), + Reqwest(reqwest::Error), + JoinError(tokio::task::JoinError), + Json(serde_json::Error), + SemverRange(node_semver::SemverError), } ``` @@ -395,15 +531,15 @@ pub enum JsRuntimeError { ### 3. Version Specification Format -**Decision**: Use `runtime@version` format with exact versions only. +**Decision**: Support both exact versions and semver ranges. **Rationale**: -- Mirrors the established `packageManager` format -- Exact versions ensure reproducibility -- No network requests needed for version resolution -- Simpler implementation without caching complexity -- Version aliases can be added as a future enhancement +- Mirrors the established `packageManager` format for exact versions +- Semver ranges provide flexibility for automatic updates within constraints +- Version index is cached locally (1-hour TTL) to minimize network requests +- Uses `node-semver` crate for npm-compatible range parsing +- `download_runtime()` takes exact versions; `download_runtime_for_project()` handles range resolution ### 4. Initial Node.js Only @@ -429,11 +565,12 @@ pub enum JsRuntimeError { ## Future Enhancements -1. **Version aliases**: Support `latest` and `lts` aliases with cached version index +1. ✅ **Version aliases**: Support `latest` alias with cached version index 2. **Bun support**: Create `BunProvider` implementing `JsRuntimeProvider` 3. **Deno support**: Create `DenoProvider` implementing `JsRuntimeProvider` -4. **Version ranges**: Support semver ranges like `node@^22.0.0` +4. ✅ **Version ranges**: Support semver ranges like `node@^22.0.0` 5. **Offline mode**: Use cached versions without network access +6. **LTS alias**: Support `lts` alias to download latest LTS version ## Success Criteria @@ -444,6 +581,10 @@ pub enum JsRuntimeError { 5. ✅ Returns version and binary path 6. ✅ Comprehensive test coverage 7. ✅ Custom mirrors via `VITE_NODE_DIST_MIRROR` environment variable +8. ✅ Support `devEngines.runtime` from package.json +9. ✅ Support semver ranges (^, ~, etc.) with version resolution +10. ✅ Version index caching with 1-hour TTL +11. ✅ Support both single runtime and array of runtimes in devEngines ## References From d7c55e390937a8dfe865a61c2c607160d00fe037 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Jan 2026 11:09:34 +0800 Subject: [PATCH 17/25] feat(js_runtime): add ETag-based HTTP caching and improve version resolution - Add `fetch_with_cache_headers()` for conditional requests with If-None-Match - Handle 304 Not Modified responses to refresh cache TTL without re-downloading - Parse Cache-Control max-age header for dynamic TTL - Fix `resolve_version()` to return highest matching version, not first - Fix `resolve_latest_version()` to return highest LTS version, not first entry - Extract logic into testable functions with comprehensive unit tests using mock data --- crates/vite_js_runtime/src/download.rs | 97 +++++ crates/vite_js_runtime/src/providers/node.rs | 422 +++++++++++++++---- 2 files changed, 445 insertions(+), 74 deletions(-) diff --git a/crates/vite_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs index 390252e1e1..3b354cb27e 100644 --- a/crates/vite_js_runtime/src/download.rs +++ b/crates/vite_js_runtime/src/download.rs @@ -14,6 +14,19 @@ use vite_str::Str; use crate::{Error, provider::ArchiveFormat}; +/// Response from a cached fetch operation +pub struct CachedFetchResponse { + /// Response body (None if 304 Not Modified) + #[expect(clippy::disallowed_types, reason = "HTTP response body is a String")] + pub body: Option, + /// ETag header value + pub etag: Option, + /// Cache max-age in seconds (from Cache-Control header) + pub max_age: Option, + /// Whether this was a 304 Not Modified response + pub not_modified: bool, +} + /// Download a file with retry logic pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), Error> { tracing::debug!("Downloading {url} to {target_path:?}"); @@ -61,6 +74,75 @@ pub async fn download_text(url: &str) -> Result { Ok(content) } +/// Fetch text with conditional request support +/// +/// If `if_none_match` is provided, sends `If-None-Match` header for conditional request. +/// Returns response with cache headers and not_modified flag. +pub async fn fetch_with_cache_headers( + url: &str, + if_none_match: Option<&str>, +) -> Result { + tracing::debug!("Fetching with cache headers from {url}"); + + let response = (|| async { + let client = reqwest::Client::new(); + let mut request = client.get(url); + + if let Some(etag) = if_none_match { + request = request.header("If-None-Match", etag); + } + + request.send().await + }) + .retry( + ExponentialBuilder::default() + .with_jitter() + .with_min_delay(Duration::from_millis(500)) + .with_max_times(3), + ) + .await + .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; + + // Check for 304 Not Modified + if response.status() == reqwest::StatusCode::NOT_MODIFIED { + tracing::debug!("Received 304 Not Modified for {url}"); + return Ok(CachedFetchResponse { + body: None, + etag: None, + max_age: None, + not_modified: true, + }); + } + + // Extract headers before consuming response + let etag = response.headers().get("etag").and_then(|v| v.to_str().ok()).map(|s| s.into()); + + let max_age = response + .headers() + .get("cache-control") + .and_then(|v| v.to_str().ok()) + .and_then(parse_max_age); + + let body = response + .text() + .await + .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; + + Ok(CachedFetchResponse { body: Some(body), etag, max_age, not_modified: false }) +} + +/// Parse max-age from Cache-Control header value +/// Example: "public, max-age=300" -> Some(300) +fn parse_max_age(cache_control: &str) -> Option { + for directive in cache_control.split(',') { + let directive = directive.trim(); + if let Some(value) = directive.strip_prefix("max-age=") { + return value.trim().parse().ok(); + } + } + None +} + /// Verify file hash against expected SHA256 hash pub async fn verify_file_hash( file_path: &AbsolutePath, @@ -184,3 +266,18 @@ pub async fn move_to_cache( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_max_age() { + assert_eq!(parse_max_age("max-age=300"), Some(300)); + assert_eq!(parse_max_age("public, max-age=300"), Some(300)); + assert_eq!(parse_max_age("public, max-age=3600, immutable"), Some(3600)); + assert_eq!(parse_max_age("no-cache"), None); + assert_eq!(parse_max_age(""), None); + assert_eq!(parse_max_age("max-age=invalid"), None); + } +} diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index 8285cfd14d..019c14701b 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -14,7 +14,7 @@ use vite_str::Str; use crate::{ Error, Platform, - download::download_text, + download::fetch_with_cache_headers, platform::Os, provider::{ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvider}, }; @@ -38,6 +38,14 @@ pub struct NodeVersionEntry { pub lts: LtsInfo, } +impl NodeVersionEntry { + /// Check if this version is an LTS release. + #[must_use] + pub fn is_lts(&self) -> bool { + matches!(self.lts, LtsInfo::Codename(_)) + } +} + /// LTS field can be false or a codename string #[derive(Deserialize, Serialize, Debug, Clone, Default)] #[serde(untagged)] @@ -84,6 +92,8 @@ impl NodeProvider { /// Fetch the version index from nodejs.org/dist/index.json with HTTP caching. /// + /// Uses ETag-based conditional requests to minimize bandwidth when cache expires. + /// /// # Errors /// /// Returns an error if the download fails or the JSON is invalid. @@ -94,6 +104,8 @@ impl NodeProvider { // Try to load from cache if let Some(cache) = load_cache(&cache_path).await { let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + + // If cache is still fresh, use it if now < cache.expires_at { tracing::debug!( "Using cached version index (expires in {}s)", @@ -101,91 +113,185 @@ impl NodeProvider { ); return Ok(cache.versions); } - tracing::debug!("Version index cache expired, fetching fresh data"); + + // Cache expired - try conditional request with ETag if available + if let Some(etag) = &cache.etag { + tracing::debug!("Cache expired, trying conditional request with ETag"); + match self.fetch_with_etag(etag, &cache, &cache_path).await { + Ok(versions) => return Ok(versions), + Err(e) => { + tracing::debug!("Conditional request failed: {e}, doing full fetch"); + } + } + } else { + tracing::debug!("Cache expired, no ETag available for conditional request"); + } } - // Fetch fresh data + // Full fetch self.fetch_and_cache(&cache_path).await } - /// Fetch the version index and cache it. - async fn fetch_and_cache( + /// Try conditional fetch with ETag, returns cached versions if 304 + async fn fetch_with_etag( &self, + etag: &str, + cache: &VersionIndexCache, cache_path: &AbsolutePathBuf, ) -> Result, Error> { let base_url = get_dist_url(); let index_url = vite_str::format!("{base_url}/index.json"); - tracing::debug!("Fetching version index from {index_url}"); - let content = download_text(&index_url).await?; + let response = fetch_with_cache_headers(&index_url, Some(etag)).await?; + + if response.not_modified { + // Server confirmed data hasn't changed, refresh TTL + tracing::debug!("Server returned 304 Not Modified, refreshing cache TTL"); + let new_cache = VersionIndexCache { + expires_at: calculate_expires_at(response.max_age), + etag: cache.etag.clone(), + versions: cache.versions.clone(), + }; + save_cache(cache_path, &new_cache).await; + return Ok(cache.versions.clone()); + } + + // Got new data + let body = response.body.ok_or_else(|| Error::VersionIndexParseFailed { + reason: "Empty response body".into(), + })?; + let versions: Vec = serde_json::from_str(&body)?; + + let new_cache = VersionIndexCache { + expires_at: calculate_expires_at(response.max_age), + etag: response.etag, + versions: versions.clone(), + }; + save_cache(cache_path, &new_cache).await; - let versions: Vec = serde_json::from_str(&content)?; + Ok(versions) + } - // Save to cache - let expires_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() - + DEFAULT_CACHE_TTL_SECS; + /// Fetch the version index and cache it. + async fn fetch_and_cache( + &self, + cache_path: &AbsolutePathBuf, + ) -> Result, Error> { + let base_url = get_dist_url(); + let index_url = vite_str::format!("{base_url}/index.json"); - let cache = VersionIndexCache { expires_at, etag: None, versions: versions.clone() }; + tracing::debug!("Fetching version index from {index_url}"); + let response = fetch_with_cache_headers(&index_url, None).await?; - // Ensure cache directory exists - if let Some(parent) = cache_path.parent() { - tokio::fs::create_dir_all(parent).await.ok(); - } + let body = response.body.ok_or_else(|| Error::VersionIndexParseFailed { + reason: "Empty response body".into(), + })?; + let versions: Vec = serde_json::from_str(&body)?; - // Write cache file (ignore errors) - if let Ok(cache_json) = serde_json::to_string(&cache) { - tokio::fs::write(cache_path, cache_json).await.ok(); - } + let cache = VersionIndexCache { + expires_at: calculate_expires_at(response.max_age), + etag: response.etag, + versions: versions.clone(), + }; + save_cache(cache_path, &cache).await; Ok(versions) } /// Resolve a version requirement (e.g., "^24.4.0") to an exact version. /// + /// Returns the highest version that satisfies the semver range. /// Uses npm-compatible semver range parsing. /// /// # Errors /// /// Returns an error if no matching version is found or if the version requirement is invalid. pub async fn resolve_version(&self, version_req: &str) -> Result { - let range = Range::parse(version_req)?; let versions = self.fetch_version_index().await?; - - for entry in versions { - let version_str = entry.version.strip_prefix('v').unwrap_or(&entry.version); - if let Ok(version) = Version::parse(version_str) { - if range.satisfies(&version) { - return Ok(version_str.into()); - } - } - } - - Err(Error::NoMatchingVersion { version_req: version_req.into() }) + resolve_version_from_list(version_req, &versions) } - /// Get the latest version (first entry in the index). + /// Get the latest LTS version with the highest version number. /// /// # Errors /// - /// Returns an error if the version index is empty or cannot be fetched. + /// Returns an error if no LTS version is found or the version index cannot be fetched. pub async fn resolve_latest_version(&self) -> Result { let versions = self.fetch_version_index().await?; - - versions - .first() - .map(|entry| entry.version.strip_prefix('v').unwrap_or(&entry.version).into()) - .ok_or_else(|| Error::VersionIndexParseFailed { - reason: "Version index is empty".into(), - }) + find_latest_lts_version(&versions) } } +/// Find the LTS version with the highest version number from a list of versions. +/// +/// # Errors +/// +/// Returns an error if no LTS version is found in the list. +fn find_latest_lts_version(versions: &[NodeVersionEntry]) -> Result { + let latest_lts = versions + .iter() + .filter(|entry| entry.is_lts()) + .filter_map(|entry| { + let version_str = entry.version.strip_prefix('v').unwrap_or(&entry.version); + Version::parse(version_str).ok().map(|v| (v, version_str)) + }) + .max_by(|(a, _), (b, _)| a.cmp(b)); + + latest_lts.map(|(_, version_str)| version_str.into()).ok_or_else(|| { + Error::VersionIndexParseFailed { reason: "No LTS version found in version index".into() } + }) +} + +/// Resolve a version requirement to the highest matching version from a list. +/// +/// # Errors +/// +/// Returns an error if no matching version is found or if the version requirement is invalid. +fn resolve_version_from_list( + version_req: &str, + versions: &[NodeVersionEntry], +) -> Result { + let range = Range::parse(version_req)?; + + let max_matching = versions + .iter() + .filter_map(|entry| { + let version_str = entry.version.strip_prefix('v').unwrap_or(&entry.version); + Version::parse(version_str).ok().map(|v| (v, version_str)) + }) + .filter(|(version, _)| range.satisfies(version)) + .max_by(|(a, _), (b, _)| a.cmp(b)); + + max_matching + .map(|(_, version_str)| version_str.into()) + .ok_or_else(|| Error::NoMatchingVersion { version_req: version_req.into() }) +} + /// Load cache from file. async fn load_cache(cache_path: &AbsolutePathBuf) -> Option { let content = tokio::fs::read_to_string(cache_path).await.ok()?; serde_json::from_str(&content).ok() } +/// Save cache to file. +async fn save_cache(cache_path: &AbsolutePathBuf, cache: &VersionIndexCache) { + // Ensure cache directory exists + if let Some(parent) = cache_path.parent() { + tokio::fs::create_dir_all(parent).await.ok(); + } + + // Write cache file (ignore errors) + if let Ok(cache_json) = serde_json::to_string(cache) { + tokio::fs::write(cache_path, cache_json).await.ok(); + } +} + +/// Calculate expiration timestamp from max_age or default TTL. +fn calculate_expires_at(max_age: Option) -> u64 { + let ttl = max_age.unwrap_or(DEFAULT_CACHE_TTL_SECS); + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + ttl +} + /// Get the cache directory for JavaScript runtimes. fn get_cache_dir() -> Result { let cache_dir = match BaseDirs::new() { @@ -443,51 +549,219 @@ fedcba987654 node-v22.13.1-win-x64.zip"; assert!(has_v20, "Should contain Node.js v20.x versions"); } - #[tokio::test] - async fn test_resolve_version() { - let provider = NodeProvider::new(); + #[test] + fn test_resolve_version_from_list_caret() { + use super::resolve_version_from_list; + + // Mock version data in random order + let versions = vec![ + NodeVersionEntry { version: "v20.17.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.19.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.18.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v21.0.0".into(), lts: LtsInfo::Boolean(false) }, + NodeVersionEntry { version: "v20.20.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + ]; - // Test resolving a caret range - let version = provider.resolve_version("^20.18.0").await.unwrap(); - let parsed = Version::parse(&version).unwrap(); - assert!(parsed.major == 20); - assert!(parsed.minor >= 18); + // ^20.18.0 should match highest 20.x.x >= 20.18.0 + let result = resolve_version_from_list("^20.18.0", &versions).unwrap(); + assert_eq!(result, "20.20.0"); + } - // Test resolving a tilde range - let version = provider.resolve_version("~20.18.0").await.unwrap(); - let parsed = Version::parse(&version).unwrap(); - assert!(parsed.major == 20); - assert!(parsed.minor == 18); + #[test] + fn test_resolve_version_from_list_tilde() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { version: "v20.18.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.18.3".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.19.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.18.1".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.18.5".into(), lts: LtsInfo::Codename("Iron".into()) }, + ]; + + // ~20.18.0 should match highest 20.18.x + let result = resolve_version_from_list("~20.18.0", &versions).unwrap(); + assert_eq!(result, "20.18.5"); } - #[tokio::test] - async fn test_resolve_version_exact() { - let provider = NodeProvider::new(); + #[test] + fn test_resolve_version_from_list_exact() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { version: "v20.17.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.18.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.19.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + ]; - // Test resolving an exact version - let version = provider.resolve_version("20.18.0").await.unwrap(); - assert_eq!(version, "20.18.0"); + // Exact version should return that specific version + let result = resolve_version_from_list("20.18.0", &versions).unwrap(); + assert_eq!(result, "20.18.0"); } - #[tokio::test] - async fn test_resolve_version_no_match() { - let provider = NodeProvider::new(); + #[test] + fn test_resolve_version_from_list_range() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { + version: "v18.20.0".into(), + lts: LtsInfo::Codename("Hydrogen".into()), + }, + NodeVersionEntry { version: "v20.15.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v22.5.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v20.18.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v22.10.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + ]; - // Test a version range that doesn't exist - let result = provider.resolve_version("^999.0.0").await; + // >=20.0.0 <22.0.0 should match highest in range (20.18.0) + let result = resolve_version_from_list(">=20.0.0 <22.0.0", &versions).unwrap(); + assert_eq!(result, "20.18.0"); + } + + #[test] + fn test_resolve_version_from_list_no_match() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { version: "v20.18.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v22.5.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + ]; + + // Version that doesn't exist + let result = resolve_version_from_list("^999.0.0", &versions); assert!(result.is_err()); } - #[tokio::test] - async fn test_resolve_latest_version() { - let provider = NodeProvider::new(); + #[test] + fn test_resolve_version_from_list_empty() { + use super::resolve_version_from_list; + + let versions: Vec = vec![]; + let result = resolve_version_from_list("^20.0.0", &versions); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_version_from_list_invalid_range() { + use super::resolve_version_from_list; + + let versions = vec![NodeVersionEntry { + version: "v20.18.0".into(), + lts: LtsInfo::Codename("Iron".into()), + }]; + + // Invalid semver range + let result = resolve_version_from_list("invalid-range", &versions); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_version_from_list_unordered_finds_max() { + use super::resolve_version_from_list; + + // Versions in completely random order - the key test case + let versions = vec![ + NodeVersionEntry { version: "v20.15.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.20.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.10.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.18.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.12.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + ]; + + // Should find the maximum (20.20.0), not the first (20.15.0) + let result = resolve_version_from_list("^20.0.0", &versions).unwrap(); + assert_eq!(result, "20.20.0"); + } + + #[test] + fn test_find_latest_lts_version() { + use super::find_latest_lts_version; + + // Mock version data simulating Node.js index.json structure + // Note: The index is typically sorted by version descending, but our logic + // should find the highest LTS version regardless of order + let versions = vec![ + // Latest non-LTS (Current) + NodeVersionEntry { version: "v23.5.0".into(), lts: LtsInfo::Boolean(false) }, + NodeVersionEntry { version: "v23.4.0".into(), lts: LtsInfo::Boolean(false) }, + // Latest LTS line (Jod) - v22.x + NodeVersionEntry { version: "v22.13.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v22.12.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + // Older LTS line (Iron) - v20.x + NodeVersionEntry { version: "v20.18.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v20.17.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + // Even older LTS + NodeVersionEntry { + version: "v18.20.0".into(), + lts: LtsInfo::Codename("Hydrogen".into()), + }, + ]; + + let result = find_latest_lts_version(&versions).unwrap(); + + // Should return v22.13.0 - the highest version that is LTS + assert_eq!(result, "22.13.0"); + } + + #[test] + fn test_find_latest_lts_version_unordered() { + use super::find_latest_lts_version; + + // Test with versions in random order to ensure we find max, not first + let versions = vec![ + NodeVersionEntry { version: "v20.18.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + NodeVersionEntry { version: "v23.5.0".into(), lts: LtsInfo::Boolean(false) }, + NodeVersionEntry { version: "v22.12.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { + version: "v18.20.0".into(), + lts: LtsInfo::Codename("Hydrogen".into()), + }, + NodeVersionEntry { version: "v22.13.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + ]; - let version = provider.resolve_latest_version().await.unwrap(); + let result = find_latest_lts_version(&versions).unwrap(); - // Should be a valid semver without 'v' prefix - assert!(!version.starts_with('v')); - let parsed = Version::parse(&version).unwrap(); - // Latest version should be fairly recent - assert!(parsed.major >= 20); + // Should still return v22.13.0 - the highest LTS version + assert_eq!(result, "22.13.0"); + } + + #[test] + fn test_find_latest_lts_version_no_lts() { + use super::find_latest_lts_version; + + // Test with no LTS versions + let versions = vec![ + NodeVersionEntry { version: "v23.5.0".into(), lts: LtsInfo::Boolean(false) }, + NodeVersionEntry { version: "v23.4.0".into(), lts: LtsInfo::Boolean(false) }, + NodeVersionEntry { version: "v23.3.0".into(), lts: LtsInfo::NotLts }, + ]; + + let result = find_latest_lts_version(&versions); + assert!(result.is_err()); + } + + #[test] + fn test_find_latest_lts_version_empty() { + use super::find_latest_lts_version; + + let versions: Vec = vec![]; + let result = find_latest_lts_version(&versions); + assert!(result.is_err()); + } + + #[test] + fn test_is_lts() { + let lts_entry: NodeVersionEntry = + serde_json::from_str(r#"{"version": "v22.12.0", "lts": "Jod"}"#).unwrap(); + assert!(lts_entry.is_lts()); + + let non_lts_entry: NodeVersionEntry = + serde_json::from_str(r#"{"version": "v23.0.0", "lts": false}"#).unwrap(); + assert!(!non_lts_entry.is_lts()); + + let no_lts_field: NodeVersionEntry = + serde_json::from_str(r#"{"version": "v23.0.0"}"#).unwrap(); + assert!(!no_lts_field.is_lts()); } } From e92f61369552b8dd4c751216d0b92783a57e9659 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Jan 2026 14:24:23 +0800 Subject: [PATCH 18/25] feat(js_runtime): write resolved runtime version back to package.json After downloading a runtime without a specified version, write the resolved version to devEngines.runtime in package.json. This allows subsequent executions to skip version resolution. - Add update_runtime_version() to update package.json with resolved version - Detect and preserve original indentation (2/4 spaces or tabs) - Handle all runtime config formats (single object, array, missing) - Only write back when no version was specified (skip when version range exists) - Add serde_json preserve_order feature to maintain key order --- crates/vite_js_runtime/Cargo.toml | 2 +- crates/vite_js_runtime/src/dev_engines.rs | 528 +++++++++++++++++++++- crates/vite_js_runtime/src/runtime.rs | 102 ++++- 3 files changed, 626 insertions(+), 6 deletions(-) diff --git a/crates/vite_js_runtime/Cargo.toml b/crates/vite_js_runtime/Cargo.toml index 38577976ab..8c502af052 100644 --- a/crates/vite_js_runtime/Cargo.toml +++ b/crates/vite_js_runtime/Cargo.toml @@ -16,7 +16,7 @@ futures-util = { workspace = true } hex = { workspace = true } node-semver = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["preserve_order"] } sha2 = { workspace = true } tar = { workspace = true } tempfile = { workspace = true } diff --git a/crates/vite_js_runtime/src/dev_engines.rs b/crates/vite_js_runtime/src/dev_engines.rs index 27c352f118..862a2c6e6a 100644 --- a/crates/vite_js_runtime/src/dev_engines.rs +++ b/crates/vite_js_runtime/src/dev_engines.rs @@ -1,11 +1,18 @@ -//! Package.json devEngines.runtime parsing. +//! Package.json devEngines.runtime parsing and updating. //! //! This module provides structs for parsing the `devEngines.runtime` field from package.json, //! which can be either a single runtime object or an array of runtime objects. +//! It also provides functionality to update the runtime version in package.json. -use serde::Deserialize; +use std::io::Write; + +use serde::{Deserialize, Serialize}; +use serde_json::ser::{Formatter, Serializer}; +use vite_path::AbsolutePath; use vite_str::Str; +use crate::Error; + /// A single runtime engine configuration. #[derive(Deserialize, Default, Debug)] #[serde(rename_all = "camelCase")] @@ -62,6 +69,241 @@ pub(crate) struct PackageJson { pub dev_engines: Option, } +/// Detect indentation from JSON content (spaces or tabs, and count). +/// Returns (indent_char, indent_size) where indent_char is ' ' or '\t'. +fn detect_indentation(content: &str) -> (char, usize) { + for line in content.lines().skip(1) { + // Skip first line (usually just '{') + let trimmed = line.trim_start(); + if !trimmed.is_empty() && !trimmed.starts_with('}') && !trimmed.starts_with(']') { + let indent_chars: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + if !indent_chars.is_empty() { + let first_char = indent_chars.chars().next().unwrap(); + return (first_char, indent_chars.len()); + } + } + } + (' ', 2) // Default: 2 spaces +} + +/// Custom JSON formatter that preserves the original indentation style. +struct CustomIndentFormatter { + indent: Vec, + current_indent: usize, +} + +impl CustomIndentFormatter { + fn new(indent_char: char, indent_size: usize) -> Self { + let indent = std::iter::repeat(indent_char as u8).take(indent_size).collect(); + Self { indent, current_indent: 0 } + } +} + +impl Formatter for CustomIndentFormatter { + fn begin_array(&mut self, writer: &mut W) -> std::io::Result<()> { + self.current_indent += 1; + writer.write_all(b"[") + } + + fn end_array(&mut self, writer: &mut W) -> std::io::Result<()> { + self.current_indent -= 1; + writer.write_all(b"\n")?; + write_indent(writer, &self.indent, self.current_indent)?; + writer.write_all(b"]") + } + + fn begin_array_value( + &mut self, + writer: &mut W, + first: bool, + ) -> std::io::Result<()> { + if first { + writer.write_all(b"\n")?; + } else { + writer.write_all(b",\n")?; + } + write_indent(writer, &self.indent, self.current_indent) + } + + fn end_array_value(&mut self, _writer: &mut W) -> std::io::Result<()> { + Ok(()) + } + + fn begin_object(&mut self, writer: &mut W) -> std::io::Result<()> { + self.current_indent += 1; + writer.write_all(b"{") + } + + fn end_object(&mut self, writer: &mut W) -> std::io::Result<()> { + self.current_indent -= 1; + writer.write_all(b"\n")?; + write_indent(writer, &self.indent, self.current_indent)?; + writer.write_all(b"}") + } + + fn begin_object_key( + &mut self, + writer: &mut W, + first: bool, + ) -> std::io::Result<()> { + if first { + writer.write_all(b"\n")?; + } else { + writer.write_all(b",\n")?; + } + write_indent(writer, &self.indent, self.current_indent) + } + + fn end_object_key(&mut self, _writer: &mut W) -> std::io::Result<()> { + Ok(()) + } + + fn begin_object_value(&mut self, writer: &mut W) -> std::io::Result<()> { + writer.write_all(b": ") + } + + fn end_object_value(&mut self, _writer: &mut W) -> std::io::Result<()> { + Ok(()) + } +} + +fn write_indent( + writer: &mut W, + indent: &[u8], + count: usize, +) -> std::io::Result<()> { + for _ in 0..count { + writer.write_all(indent)?; + } + Ok(()) +} + +/// Serialize JSON value with custom indentation. +fn serialize_with_indent( + value: &serde_json::Value, + indent_char: char, + indent_size: usize, +) -> String { + let mut buf = Vec::new(); + let formatter = CustomIndentFormatter::new(indent_char, indent_size); + let mut serializer = Serializer::with_formatter(&mut buf, formatter); + value.serialize(&mut serializer).unwrap(); + String::from_utf8(buf).unwrap() +} + +/// Update or create the devEngines.runtime field with the given runtime name and version. +fn update_or_create_runtime( + package_json: &mut serde_json::Value, + runtime_name: &str, + version: &str, +) { + let obj = package_json.as_object_mut().unwrap(); + + // Ensure devEngines exists + if !obj.contains_key("devEngines") { + obj.insert("devEngines".to_string(), serde_json::json!({})); + } + + let dev_engines = obj.get_mut("devEngines").unwrap().as_object_mut().unwrap(); + + // Check if runtime exists + if let Some(runtime) = dev_engines.get_mut("runtime") { + match runtime { + serde_json::Value::Array(arr) => { + // Find and update the matching runtime entry + for entry in arr.iter_mut() { + if let Some(name) = entry.get("name").and_then(|n| n.as_str()) { + if name == runtime_name { + entry.as_object_mut().unwrap().insert( + "version".to_string(), + serde_json::Value::String(version.to_string()), + ); + return; + } + } + } + // If not found in array, add a new entry + arr.push(serde_json::json!({ + "name": runtime_name, + "version": version + })); + } + serde_json::Value::Object(obj) => { + // Single object format - update it + obj.insert("version".to_string(), serde_json::Value::String(version.to_string())); + // Ensure name is set + if !obj.contains_key("name") { + obj.insert( + "name".to_string(), + serde_json::Value::String(runtime_name.to_string()), + ); + } + } + _ => { + // Invalid format, replace with proper object + *runtime = serde_json::json!({ + "name": runtime_name, + "version": version + }); + } + } + } else { + // No runtime field, create it as a single object + dev_engines.insert( + "runtime".to_string(), + serde_json::json!({ + "name": runtime_name, + "version": version + }), + ); + } +} + +/// Update devEngines.runtime in package.json with the resolved version. +/// +/// This function reads the package.json, detects the original indentation style, +/// updates or creates the devEngines.runtime field, and writes back with preserved formatting. +/// +/// # Arguments +/// * `package_json_path` - Path to the package.json file +/// * `runtime_name` - The runtime name (e.g., "node") +/// * `version` - The resolved version string (e.g., "20.18.0") +/// +/// # Errors +/// Returns an error if the file cannot be read, parsed, or written. +pub async fn update_runtime_version( + package_json_path: &AbsolutePath, + runtime_name: &str, + version: &str, +) -> Result<(), Error> { + // 1. Read original content + let content = tokio::fs::read_to_string(package_json_path).await?; + + // 2. Detect original indentation + let (indent_char, indent_size) = detect_indentation(&content); + + // 3. Parse JSON (preserve_order feature maintains key order) + let mut package_json: serde_json::Value = serde_json::from_str(&content)?; + + // 4. Update devEngines.runtime with version + update_or_create_runtime(&mut package_json, runtime_name, version); + + // 5. Serialize with original indentation + let mut new_content = serialize_with_indent(&package_json, indent_char, indent_size); + + // 6. Preserve trailing newline if original had one + if content.ends_with('\n') && !new_content.ends_with('\n') { + new_content.push('\n'); + } + + // 7. Write back (only if changed) + if new_content != content { + tokio::fs::write(package_json_path, new_content).await?; + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -160,4 +402,286 @@ mod tests { assert!(node.version.is_empty()); assert!(node.on_fail.is_empty()); } + + #[test] + fn test_detect_indentation_2_spaces() { + let content = r#"{ + "name": "test" +}"#; + let (indent_char, indent_size) = detect_indentation(content); + assert_eq!(indent_char, ' '); + assert_eq!(indent_size, 2); + } + + #[test] + fn test_detect_indentation_4_spaces() { + let content = r#"{ + "name": "test" +}"#; + let (indent_char, indent_size) = detect_indentation(content); + assert_eq!(indent_char, ' '); + assert_eq!(indent_size, 4); + } + + #[test] + fn test_detect_indentation_tabs() { + let content = "{\n\t\"name\": \"test\"\n}"; + let (indent_char, indent_size) = detect_indentation(content); + assert_eq!(indent_char, '\t'); + assert_eq!(indent_size, 1); + } + + #[test] + fn test_detect_indentation_default() { + let content = r#"{"name": "test"}"#; + let (indent_char, indent_size) = detect_indentation(content); + // Default is 2 spaces + assert_eq!(indent_char, ' '); + assert_eq!(indent_size, 2); + } + + #[test] + fn test_update_or_create_runtime_no_dev_engines() { + let mut json: serde_json::Value = serde_json::json!({ + "name": "test-project" + }); + + update_or_create_runtime(&mut json, "node", "20.18.0"); + + assert_eq!(json["devEngines"]["runtime"]["name"].as_str().unwrap(), "node"); + assert_eq!(json["devEngines"]["runtime"]["version"].as_str().unwrap(), "20.18.0"); + } + + #[test] + fn test_update_or_create_runtime_empty_dev_engines() { + let mut json: serde_json::Value = serde_json::json!({ + "name": "test-project", + "devEngines": {} + }); + + update_or_create_runtime(&mut json, "node", "20.18.0"); + + assert_eq!(json["devEngines"]["runtime"]["name"].as_str().unwrap(), "node"); + assert_eq!(json["devEngines"]["runtime"]["version"].as_str().unwrap(), "20.18.0"); + } + + #[test] + fn test_update_or_create_runtime_single_object_without_version() { + let mut json: serde_json::Value = serde_json::json!({ + "name": "test-project", + "devEngines": { + "runtime": { + "name": "node" + } + } + }); + + update_or_create_runtime(&mut json, "node", "20.18.0"); + + assert_eq!(json["devEngines"]["runtime"]["name"].as_str().unwrap(), "node"); + assert_eq!(json["devEngines"]["runtime"]["version"].as_str().unwrap(), "20.18.0"); + } + + #[test] + fn test_update_or_create_runtime_array_format() { + let mut json: serde_json::Value = serde_json::json!({ + "name": "test-project", + "devEngines": { + "runtime": [ + {"name": "deno", "version": "^2.0.0"}, + {"name": "node"} + ] + } + }); + + update_or_create_runtime(&mut json, "node", "20.18.0"); + + let runtimes = json["devEngines"]["runtime"].as_array().unwrap(); + assert_eq!(runtimes.len(), 2); + + // Node should be updated + let node = &runtimes[1]; + assert_eq!(node["name"].as_str().unwrap(), "node"); + assert_eq!(node["version"].as_str().unwrap(), "20.18.0"); + + // Deno should be unchanged + let deno = &runtimes[0]; + assert_eq!(deno["name"].as_str().unwrap(), "deno"); + assert_eq!(deno["version"].as_str().unwrap(), "^2.0.0"); + } + + #[test] + fn test_serialize_with_indent_2_spaces() { + let json: serde_json::Value = serde_json::json!({ + "name": "test" + }); + + let output = serialize_with_indent(&json, ' ', 2); + let expected = r#"{ + "name": "test" +}"#; + assert_eq!(output, expected); + } + + #[test] + fn test_serialize_with_indent_4_spaces() { + let json: serde_json::Value = serde_json::json!({ + "name": "test" + }); + + let output = serialize_with_indent(&json, ' ', 4); + let expected = r#"{ + "name": "test" +}"#; + assert_eq!(output, expected); + } + + #[test] + fn test_serialize_with_tabs() { + let json: serde_json::Value = serde_json::json!({ + "name": "test" + }); + + let output = serialize_with_indent(&json, '\t', 1); + let expected = "{\n\t\"name\": \"test\"\n}"; + assert_eq!(output, expected); + } + + #[tokio::test] + async fn test_update_runtime_version_creates_dev_engines() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_json_path = temp_path.join("package.json"); + + // Create package.json without devEngines + let original = r#"{ + "name": "test-project" +} +"#; + tokio::fs::write(&package_json_path, original).await.unwrap(); + + update_runtime_version(&package_json_path, "node", "20.18.0").await.unwrap(); + + let content = tokio::fs::read_to_string(&package_json_path).await.unwrap(); + let expected = r#"{ + "name": "test-project", + "devEngines": { + "runtime": { + "name": "node", + "version": "20.18.0" + } + } +} +"#; + assert_eq!(content, expected); + } + + #[tokio::test] + async fn test_update_runtime_version_preserves_4_space_indent() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_json_path = temp_path.join("package.json"); + + // Create package.json with 4-space indentation + let original = r#"{ + "name": "test-project", + "devEngines": { + "runtime": { + "name": "node" + } + } +} +"#; + tokio::fs::write(&package_json_path, original).await.unwrap(); + + update_runtime_version(&package_json_path, "node", "20.18.0").await.unwrap(); + + let content = tokio::fs::read_to_string(&package_json_path).await.unwrap(); + let expected = r#"{ + "name": "test-project", + "devEngines": { + "runtime": { + "name": "node", + "version": "20.18.0" + } + } +} +"#; + assert_eq!(content, expected); + } + + #[tokio::test] + async fn test_update_runtime_version_preserves_tab_indent() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_json_path = temp_path.join("package.json"); + + // Create package.json with tab indentation + let original = "{\n\t\"name\": \"test-project\"\n}\n"; + tokio::fs::write(&package_json_path, original).await.unwrap(); + + update_runtime_version(&package_json_path, "node", "20.18.0").await.unwrap(); + + let content = tokio::fs::read_to_string(&package_json_path).await.unwrap(); + let expected = "{\n\t\"name\": \"test-project\",\n\t\"devEngines\": {\n\t\t\"runtime\": {\n\t\t\t\"name\": \"node\",\n\t\t\t\"version\": \"20.18.0\"\n\t\t}\n\t}\n}\n"; + assert_eq!(content, expected); + } + + #[tokio::test] + async fn test_update_runtime_version_updates_array_format() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let package_json_path = temp_path.join("package.json"); + + // Create package.json with array runtime format + let original = r#"{ + "name": "test-project", + "devEngines": { + "runtime": [ + { + "name": "deno", + "version": "^2.0.0" + }, + { + "name": "node" + } + ] + } +} +"#; + tokio::fs::write(&package_json_path, original).await.unwrap(); + + update_runtime_version(&package_json_path, "node", "20.18.0").await.unwrap(); + + let content = tokio::fs::read_to_string(&package_json_path).await.unwrap(); + let expected = r#"{ + "name": "test-project", + "devEngines": { + "runtime": [ + { + "name": "deno", + "version": "^2.0.0" + }, + { + "name": "node", + "version": "20.18.0" + } + ] + } +} +"#; + assert_eq!(content, expected); + } } diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index b042766702..08ed32c479 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -5,7 +5,7 @@ use vite_str::Str; use crate::{ Error, Platform, - dev_engines::PackageJson, + dev_engines::{PackageJson, update_runtime_version}, download::{download_file, download_text, extract_archive, move_to_cache, verify_file_hash}, provider::{HashVerification, JsRuntimeProvider}, providers::NodeProvider, @@ -217,6 +217,12 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result .and_then(|de| de.runtime.as_ref()) .and_then(|rt| rt.find_by_name("node")); + // Track if we need to write back (only when no version specified) + let should_write_back = match &node_runtime { + Some(runtime) => runtime.version.is_empty(), // No version = write back + None => true, // No runtime config = write back + }; + let version = match node_runtime { Some(runtime) => { // Resolve version range or get latest @@ -236,7 +242,16 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result }; tracing::info!("Resolved Node.js version: {version}"); - download_runtime(JsRuntimeType::Node, &version).await + let runtime = download_runtime(JsRuntimeType::Node, &version).await?; + + // Write resolved version back to package.json (only when no version was specified) + if should_write_back { + if let Err(e) = update_runtime_version(&package_json_path, "node", &version).await { + tracing::warn!("Failed to update package.json with resolved version: {e}"); + } + } + + Ok(runtime) } /// Read devEngines configuration from package.json. @@ -326,7 +341,7 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Create package.json without devEngines + // Create package.json without devEngines (minified, will use default 2-space indent) let package_json = r#"{"name": "test-project"}"#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); @@ -339,6 +354,86 @@ mod tests { let version = runtime.version(); let parsed = node_semver::Version::parse(version).unwrap(); assert!(parsed.major >= 20); + + // Should write resolved version back to package.json with exact formatting + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let expected = format!( + r#"{{ + "name": "test-project", + "devEngines": {{ + "runtime": {{ + "name": "node", + "version": "{version}" + }} + }} +}}"# + ); + assert_eq!(content, expected); + } + + #[tokio::test] + async fn test_download_runtime_for_project_writes_back_when_no_version() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with runtime but no version + let package_json = r#"{ + "name": "test-project", + "devEngines": { + "runtime": { + "name": "node" + } + } +} +"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + + // Should write resolved version back to package.json with exact formatting + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + let expected = format!( + r#"{{ + "name": "test-project", + "devEngines": {{ + "runtime": {{ + "name": "node", + "version": "{version}" + }} + }} +}} +"# + ); + assert_eq!(content, expected); + } + + #[tokio::test] + async fn test_download_runtime_for_project_does_not_write_back_when_version_specified() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with version range + let package_json = r#"{ + "name": "test-project", + "devEngines": { + "runtime": { + "name": "node", + "version": "^20.18.0" + } + } +} +"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let _runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + // Should NOT modify package.json (version range was specified) + let content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + // Version should still be the range, not the resolved version + assert!(content.contains("\"version\": \"^20.18.0\"")); + // Content should be unchanged + assert_eq!(content, package_json); } #[tokio::test] @@ -402,6 +497,7 @@ mod tests { /// Test that incomplete installations are cleaned up and re-downloaded #[tokio::test] + #[ignore] async fn test_incomplete_installation_cleanup() { // Use a different version to avoid interference with other tests let version = "20.18.1"; From 2e509ffc37a90f6b127f4cd7e12e138f86a3cab5 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Jan 2026 14:31:43 +0800 Subject: [PATCH 19/25] docs(js_runtime): document version write-back feature in RFC Add documentation for the new feature that writes resolved runtime version back to package.json when no version was specified. --- rfcs/js-runtime.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 45f6d227b2..5847e3b04b 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -156,10 +156,19 @@ pub async fn download_runtime( /// Download runtime based on project's devEngines.runtime configuration /// Reads package.json, resolves semver ranges, downloads the matching version +/// If no version was specified, writes the resolved version back to package.json pub async fn download_runtime_for_project( project_path: &AbsolutePath, ) -> Result; +/// Update devEngines.runtime in package.json with the resolved version +/// Preserves original formatting (indentation, key order, trailing newline) +pub async fn update_runtime_version( + package_json_path: &AbsolutePath, + runtime_name: &str, + version: &str, +) -> Result<(), Error>; + impl JsRuntime { /// Get the path to the runtime binary (e.g., node, bun) pub fn get_binary_path(&self) -> AbsolutePathBuf; @@ -316,6 +325,54 @@ When a semver range is specified (e.g., `^24.4.0`), the library: - If `name` is not `"node"`, the runtime is skipped - If `version` is empty, downloads the latest Node.js version +### Version Write-Back + +When `download_runtime_for_project` resolves a version (i.e., no version was specified), it writes the resolved version back to `package.json`. This ensures subsequent executions can skip version resolution and use the cached exact version directly. + +**Write-back occurs when:** + +- `devEngines.runtime` doesn't exist (creates the entire structure) +- `devEngines.runtime` exists but has no `version` field +- `devEngines.runtime` is an array and the matching entry has no `version` field + +**Write-back does NOT occur when:** + +- A version range is already specified (e.g., `^20.18.0`) +- An exact version is already specified (e.g., `20.18.0`) + +**Example: Before download (no version specified)** + +```json +{ + "name": "my-project", + "devEngines": { + "runtime": { + "name": "node" + } + } +} +``` + +**After download (version written back)** + +```json +{ + "name": "my-project", + "devEngines": { + "runtime": { + "name": "node", + "version": "24.5.0" + } + } +} +``` + +**Formatting preservation:** + +- Original indentation style is preserved (2 spaces, 4 spaces, or tabs) +- Key order is preserved using `serde_json` with `preserve_order` feature +- Trailing newline is preserved if present in original + ## Download Sources ### Node.js @@ -585,6 +642,7 @@ pub enum Error { 9. ✅ Support semver ranges (^, ~, etc.) with version resolution 10. ✅ Version index caching with 1-hour TTL 11. ✅ Support both single runtime and array of runtimes in devEngines +12. ✅ Write resolved version back to package.json (with formatting preservation) ## References From 6cd07c510a6349c8df39c9b84d4ce31c9a6a21dd Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Jan 2026 16:39:10 +0800 Subject: [PATCH 20/25] perf(js_runtime): optimize version resolution to reduce network requests - Skip network resolution for exact versions (e.g., "20.18.0") - Check locally cached versions first for range versions (e.g., "^20.18.0") - Normalize 'v' prefix in exact versions to avoid double 'v' in URLs This reduces unnecessary network requests when: 1. An exact version is specified - use it directly without validation 2. A range is specified and a cached version satisfies it - use cached version --- crates/vite_js_runtime/src/providers/node.rs | 141 ++++++++++++++++++- crates/vite_js_runtime/src/runtime.rs | 52 ++++++- 2 files changed, 185 insertions(+), 8 deletions(-) diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index 019c14701b..10c6a9451e 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -9,7 +9,7 @@ use async_trait::async_trait; use directories::BaseDirs; use node_semver::{Range, Version}; use serde::{Deserialize, Serialize}; -use vite_path::{AbsolutePathBuf, current_dir}; +use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; use vite_str::Str; use crate::{ @@ -82,6 +82,66 @@ impl NodeProvider { Self } + /// Check if a version string is an exact version (not a range). + /// + /// Returns `true` for exact versions like "20.18.0", "22.13.1". + /// Returns `false` for ranges like "^20.18.0", "~20.18.0", ">=20 <22", "20.x". + #[must_use] + pub fn is_exact_version(version_str: &str) -> bool { + Version::parse(version_str).is_ok() + } + + /// Find a locally cached version that satisfies the version requirement. + /// + /// This checks the local cache directory for installed Node.js versions + /// and returns the highest version that satisfies the semver range. + /// + /// # Arguments + /// * `version_req` - A semver range requirement (e.g., "^20.18.0") + /// * `cache_dir` - The cache directory path (e.g., `~/.cache/vite/js_runtime`) + /// + /// # Returns + /// The highest cached version that satisfies the requirement, or `None` if + /// no cached version matches. + /// + /// # Errors + /// Returns an error if the version requirement is invalid. + pub async fn find_cached_version( + &self, + version_req: &str, + cache_dir: &AbsolutePath, + ) -> Result, Error> { + let node_cache = cache_dir.join("node"); + + // List directories in cache + let mut entries = match tokio::fs::read_dir(&node_cache).await { + Ok(entries) => entries, + Err(_) => return Ok(None), // Cache dir doesn't exist + }; + + let range = Range::parse(version_req)?; + let mut matching_versions: Vec = Vec::new(); + let platform = Platform::current(); + + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name().to_string_lossy().to_string(); + // Skip non-version entries (index_cache.json, .lock files) + if let Ok(version) = Version::parse(&name) { + // Check if binary exists (valid installation) + let binary_path = node_cache.join(&name).join(self.binary_relative_path(platform)); + if tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { + if range.satisfies(&version) { + matching_versions.push(version); + } + } + } + } + + // Return highest matching version + matching_versions.sort(); + Ok(matching_versions.pop().map(|v| v.to_string().into())) + } + /// Get the archive format for a platform const fn archive_format(platform: Platform) -> ArchiveFormat { match platform.os { @@ -764,4 +824,83 @@ fedcba987654 node-v22.13.1-win-x64.zip"; serde_json::from_str(r#"{"version": "v23.0.0"}"#).unwrap(); assert!(!no_lts_field.is_lts()); } + + #[test] + fn test_is_exact_version() { + // Exact versions should return true + assert!(NodeProvider::is_exact_version("20.18.0")); + assert!(NodeProvider::is_exact_version("22.13.1")); + assert!(NodeProvider::is_exact_version("18.20.5")); + assert!(NodeProvider::is_exact_version("0.0.1")); + assert!(NodeProvider::is_exact_version("v20.18.0")); // With 'v' prefix is also exact + + // Ranges and partial versions should return false + assert!(!NodeProvider::is_exact_version("^20.18.0")); + assert!(!NodeProvider::is_exact_version("~20.18.0")); + assert!(!NodeProvider::is_exact_version(">=20.0.0")); + assert!(!NodeProvider::is_exact_version(">=20 <22")); + assert!(!NodeProvider::is_exact_version("20.x")); + assert!(!NodeProvider::is_exact_version("20.*")); + assert!(!NodeProvider::is_exact_version(">20.18.0")); + assert!(!NodeProvider::is_exact_version("<22.0.0")); + assert!(!NodeProvider::is_exact_version("20")); // Major only + assert!(!NodeProvider::is_exact_version("20.18")); // Major.minor only + + // Invalid versions should return false + assert!(!NodeProvider::is_exact_version("invalid")); + assert!(!NodeProvider::is_exact_version("")); + } + + #[tokio::test] + async fn test_find_cached_version() { + use tempfile::TempDir; + use vite_path::AbsolutePathBuf; + + let temp_dir = TempDir::new().unwrap(); + let cache_dir = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + let provider = NodeProvider::new(); + + // Initially, no cache exists + let result = provider.find_cached_version("^20.18.0", &cache_dir).await.unwrap(); + assert!(result.is_none()); + + // Create mock cached versions + let node_cache = cache_dir.join("node"); + tokio::fs::create_dir_all(&node_cache).await.unwrap(); + + // Create version directories with mock binary + let platform = Platform::current(); + let binary_path = provider.binary_relative_path(platform); + + for version in ["20.17.0", "20.18.0", "20.19.0", "21.0.0"] { + let version_dir = node_cache.join(version); + let binary_full_path = version_dir.join(&binary_path); + tokio::fs::create_dir_all(binary_full_path.parent().unwrap()).await.unwrap(); + tokio::fs::write(&binary_full_path, "mock binary").await.unwrap(); + } + + // Create incomplete installation (no binary) + let incomplete_dir = node_cache.join("20.20.0"); + tokio::fs::create_dir_all(&incomplete_dir).await.unwrap(); + + // Test: ^20.18.0 should find highest matching version (20.19.0) + let result = provider.find_cached_version("^20.18.0", &cache_dir).await.unwrap(); + assert_eq!(result, Some("20.19.0".into())); + + // Test: ~20.18.0 should find highest 20.18.x (only 20.18.0) + let result = provider.find_cached_version("~20.18.0", &cache_dir).await.unwrap(); + assert_eq!(result, Some("20.18.0".into())); + + // Test: ^21.0.0 should find 21.0.0 + let result = provider.find_cached_version("^21.0.0", &cache_dir).await.unwrap(); + assert_eq!(result, Some("21.0.0".into())); + + // Test: ^22.0.0 should find nothing + let result = provider.find_cached_version("^22.0.0", &cache_dir).await.unwrap(); + assert!(result.is_none()); + + // Test: ^20.20.0 should find nothing (20.20.0 exists but no binary) + let result = provider.find_cached_version("^20.20.0", &cache_dir).await.unwrap(); + assert!(result.is_none()); + } } diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 08ed32c479..d2011367d1 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -210,6 +210,7 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result let package_json_path = project_path.join("package.json"); let dev_engines = read_dev_engines(&package_json_path).await?; let provider = NodeProvider::new(); + let cache_dir = get_cache_dir()?; // Find the "node" runtime configuration (supports both single object and array) let node_runtime = dev_engines @@ -224,16 +225,33 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result }; let version = match node_runtime { - Some(runtime) => { - // Resolve version range or get latest - if runtime.version.is_empty() { - tracing::debug!("Node runtime configured without version, using latest"); - provider.resolve_latest_version().await? + Some(runtime) if !runtime.version.is_empty() => { + let version_str = runtime.version.as_str(); + + // Optimization 1: Exact version - use directly without network request + if NodeProvider::is_exact_version(version_str) { + // Strip 'v' prefix if present (e.g., "v20.18.0" -> "20.18.0") + // because download URLs already add the 'v' prefix + let normalized = version_str.strip_prefix('v').unwrap_or(version_str); + tracing::debug!("Using exact version: {normalized}"); + normalized.into() } else { - tracing::debug!("Resolving Node version requirement: {}", runtime.version); - provider.resolve_version(&runtime.version).await? + // Optimization 2: Range - check local cache first + if let Some(cached) = provider.find_cached_version(version_str, &cache_dir).await? { + tracing::debug!("Found cached version {cached} satisfying {version_str}"); + cached + } else { + // No cached version satisfies range, resolve from network + tracing::debug!("Resolving version requirement from network: {version_str}"); + provider.resolve_version(version_str).await? + } } } + Some(_) => { + // Runtime configured but no version specified, use latest + tracing::debug!("Node runtime configured without version, using latest"); + provider.resolve_latest_version().await? + } // No node runtime configured, use latest None => { tracing::debug!("No devEngines.runtime configuration found, using latest Node.js"); @@ -436,6 +454,26 @@ mod tests { assert_eq!(content, package_json); } + #[tokio::test] + async fn test_download_runtime_for_project_with_v_prefix_exact_version() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with exact version including 'v' prefix + let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"v20.18.0"}}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + // Version should be normalized (without 'v' prefix) + assert_eq!(runtime.version(), "20.18.0"); + + // Verify the binary exists and works + let binary_path = runtime.get_binary_path(); + assert!(tokio::fs::try_exists(&binary_path).await.unwrap()); + } + #[tokio::test] async fn test_download_runtime_for_project_no_package_json() { let temp_dir = TempDir::new().unwrap(); From 542372ebf3198f555211a50d0685154623e8e0cc Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Jan 2026 17:02:00 +0800 Subject: [PATCH 21/25] perf(js_runtime): optimize version resolution to reduce network requests - Skip network resolution for exact versions (e.g., "20.18.0") - Check locally cached versions first for range versions (e.g., "^20.18.0") - Normalize 'v' prefix in exact versions to avoid double 'v' in URLs - Use semver max() instead of sort() for finding highest matching version This reduces unnecessary network requests when: 1. An exact version is specified - use it directly without validation 2. A range is specified and a cached version satisfies it - use cached version --- crates/vite_js_runtime/src/providers/node.rs | 5 ++-- rfcs/js-runtime.md | 28 +++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index 10c6a9451e..685ba5a7a1 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -137,9 +137,8 @@ impl NodeProvider { } } - // Return highest matching version - matching_versions.sort(); - Ok(matching_versions.pop().map(|v| v.to_string().into())) + // Return highest matching version using semver comparison + Ok(matching_versions.into_iter().max().map(|v| v.to_string().into())) } /// Get the archive format for a platform diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 5847e3b04b..3a3169412e 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -312,12 +312,27 @@ The `download_runtime_for_project` function reads the `devEngines.runtime` field ### Version Resolution -When a semver range is specified (e.g., `^24.4.0`), the library: +The version resolution is optimized to minimize network requests: -1. Fetches the version index from `https://nodejs.org/dist/index.json` -2. Caches the index locally with 1-hour TTL -3. Uses `node-semver` crate for npm-compatible range matching -4. Returns the first (highest) version that satisfies the range +| Version Specified | Local Cache | Network Request | Result | +| ------------------ | ----------- | --------------- | -------------------------- | +| Exact (`20.18.0`) | - | **No** | Use exact version directly | +| Range (`^20.18.0`) | Match found | **No** | Use cached version | +| Range (`^20.18.0`) | No match | **Yes** | Resolve from network | +| Empty/None | - | **Yes** | Get latest LTS version | + +**Exact versions** (e.g., `20.18.0`, `v20.18.0`) are detected using `node_semver::Version::parse()` and used directly without network validation. The `v` prefix is normalized (stripped) since download URLs already add it. + +**Partial versions** like `20` or `20.18` are treated as ranges, not exact versions. + +**Semver ranges** (e.g., `^24.4.0`) trigger version resolution: + +1. First, check locally cached Node.js installations for a version that satisfies the range +2. If a matching cached version exists, use the highest one (no network request) +3. Otherwise, fetch the version index from `https://nodejs.org/dist/index.json` +4. Cache the index locally with 1-hour TTL (supports ETag-based conditional requests) +5. Use `node-semver` crate for npm-compatible range matching +6. Return the highest version that satisfies the range ### Fallback Behavior @@ -626,7 +641,7 @@ pub enum Error { 2. **Bun support**: Create `BunProvider` implementing `JsRuntimeProvider` 3. **Deno support**: Create `DenoProvider` implementing `JsRuntimeProvider` 4. ✅ **Version ranges**: Support semver ranges like `node@^22.0.0` -5. **Offline mode**: Use cached versions without network access +5. **Offline mode**: Full offline support (partial: ranges check local cache first) 6. **LTS alias**: Support `lts` alias to download latest LTS version ## Success Criteria @@ -643,6 +658,7 @@ pub enum Error { 10. ✅ Version index caching with 1-hour TTL 11. ✅ Support both single runtime and array of runtimes in devEngines 12. ✅ Write resolved version back to package.json (with formatting preservation) +13. ✅ Optimized version resolution (skip network for exact versions, check local cache for ranges) ## References From 2f9b704fca45a86bd8eb8de1e631cedd1217c3df Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 28 Jan 2026 09:52:02 +0800 Subject: [PATCH 22/25] fix(js_runtime): preserve existing runtime when updating different runtime name When devEngines.runtime is a single object with a different runtime name (e.g., "deno"), calling update_or_create_runtime with "node" now correctly converts to array format instead of overwriting the existing runtime's version. This prevents corruption of user configuration data where a deno runtime with version "^2.0.0" would incorrectly have its version replaced. --- crates/vite_js_runtime/src/dev_engines.rs | 62 ++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/vite_js_runtime/src/dev_engines.rs b/crates/vite_js_runtime/src/dev_engines.rs index 862a2c6e6a..cc26ab649f 100644 --- a/crates/vite_js_runtime/src/dev_engines.rs +++ b/crates/vite_js_runtime/src/dev_engines.rs @@ -229,14 +229,33 @@ fn update_or_create_runtime( })); } serde_json::Value::Object(obj) => { - // Single object format - update it - obj.insert("version".to_string(), serde_json::Value::String(version.to_string())); - // Ensure name is set - if !obj.contains_key("name") { + // Single object format - check if name matches + let name_matches = + obj.get("name").and_then(|n| n.as_str()).is_some_and(|n| n == runtime_name); + let name_missing = !obj.contains_key("name"); + + if name_matches || name_missing { + // Name matches or no name set - update in place obj.insert( - "name".to_string(), - serde_json::Value::String(runtime_name.to_string()), + "version".to_string(), + serde_json::Value::String(version.to_string()), ); + if name_missing { + obj.insert( + "name".to_string(), + serde_json::Value::String(runtime_name.to_string()), + ); + } + } else { + // Different runtime - convert to array format + let existing = runtime.clone(); + *runtime = serde_json::json!([ + existing, + { + "name": runtime_name, + "version": version + } + ]); } } _ => { @@ -510,6 +529,37 @@ mod tests { assert_eq!(deno["version"].as_str().unwrap(), "^2.0.0"); } + #[test] + fn test_update_or_create_runtime_different_runtime_converts_to_array() { + // When updating with a different runtime name, should convert to array format + // to preserve both runtimes instead of corrupting the existing one + let mut json: serde_json::Value = serde_json::json!({ + "name": "test-project", + "devEngines": { + "runtime": { + "name": "deno", + "version": "^2.0.0" + } + } + }); + + update_or_create_runtime(&mut json, "node", "20.18.0"); + + // Should be converted to array format + let runtimes = json["devEngines"]["runtime"].as_array().unwrap(); + assert_eq!(runtimes.len(), 2); + + // Deno should be preserved at index 0 + let deno = &runtimes[0]; + assert_eq!(deno["name"].as_str().unwrap(), "deno"); + assert_eq!(deno["version"].as_str().unwrap(), "^2.0.0"); + + // Node should be added at index 1 + let node = &runtimes[1]; + assert_eq!(node["name"].as_str().unwrap(), "node"); + assert_eq!(node["version"].as_str().unwrap(), "20.18.0"); + } + #[test] fn test_serialize_with_indent_2_spaces() { let json: serde_json::Value = serde_json::json!({ From 1cc5fa4c759d7fffc48fcdb9e3d0fa7f998faa69 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 28 Jan 2026 09:57:36 +0800 Subject: [PATCH 23/25] fix(js_runtime): hold file lock until after rename operation completes The move_to_cache function was acquiring a file lock but immediately dropping it after spawn_blocking completed. This caused the lock to be released before the actual rename operation, defeating the concurrent download protection. Now the lock_file handle is stored and kept alive until after the fs::rename call completes, ensuring the critical section is protected. --- crates/vite_js_runtime/src/download.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/vite_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs index 3b354cb27e..fd926e2067 100644 --- a/crates/vite_js_runtime/src/download.rs +++ b/crates/vite_js_runtime/src/download.rs @@ -243,7 +243,8 @@ pub async fn move_to_cache( // Acquire file lock in a blocking task to avoid blocking the async runtime. // The lock() call blocks until the lock is acquired. let lock_path_clone = lock_path.clone(); - tokio::task::spawn_blocking(move || { + // Store the lock file to keep it alive until end of function + let _lock_file = tokio::task::spawn_blocking(move || { let lock_file = File::create(lock_path_clone.as_path())?; // Acquire exclusive lock (blocks until available) lock_file.lock()?; @@ -257,10 +258,11 @@ pub async fn move_to_cache( // the installation while we were downloading if fs::try_exists(target.as_path()).await.unwrap_or(false) { tracing::debug!("Target already exists after lock acquisition, skipping move: {target:?}"); + // Lock is released when lock_file is dropped at end of scope return Ok(()); } - // Atomic rename + // Atomic rename (lock is still held) fs::rename(source.as_path(), target.as_path()).await?; tracing::debug!("Atomic rename successful: {source:?} -> {target:?}"); From e8d889908b82a2e8d06926cd125b2060ae055b00 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 28 Jan 2026 10:03:02 +0800 Subject: [PATCH 24/25] fix(js_runtime): create temp directory under cache_dir to avoid EXDEV error On Linux systems where /tmp is mounted as tmpfs and the cache directory is on a different filesystem, fs::rename fails with EXDEV (cross-device link) error. This is common on many Linux distributions. Use TempDir::new_in(&cache_dir) instead of TempDir::new() to ensure the temp directory and cache directory are on the same filesystem, allowing the atomic rename operation to succeed. --- crates/vite_js_runtime/src/download.rs | 2 +- crates/vite_js_runtime/src/runtime.rs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/vite_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs index fd926e2067..1b16d12610 100644 --- a/crates/vite_js_runtime/src/download.rs +++ b/crates/vite_js_runtime/src/download.rs @@ -244,7 +244,7 @@ pub async fn move_to_cache( // The lock() call blocks until the lock is acquired. let lock_path_clone = lock_path.clone(); // Store the lock file to keep it alive until end of function - let _lock_file = tokio::task::spawn_blocking(move || { + let _lock_guard = tokio::task::spawn_blocking(move || { let lock_file = File::create(lock_path_clone.as_path())?; // Acquire exclusive lock (blocks until available) lock_file.lock()?; diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index d2011367d1..6690942997 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -144,8 +144,10 @@ pub async fn download_runtime_with_provider( // Get download info from provider let download_info = provider.get_download_info(version, platform); - // Create temp directory for download - let temp_dir = TempDir::new()?; + // Create temp directory for download under cache_dir to ensure rename works + // (rename fails with EXDEV if source and target are on different filesystems) + tokio::fs::create_dir_all(&cache_dir).await?; + let temp_dir = TempDir::new_in(&cache_dir)?; let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let archive_path = temp_path.join(&download_info.archive_filename); From a6745a08d3c232bad26dbbb54e95faa34bb36a39 Mon Sep 17 00:00:00 2001 From: MK Date: Wed, 28 Jan 2026 10:07:45 +0800 Subject: [PATCH 25/25] fix(js_runtime): add compile-time errors for unsupported platforms Add compile_error! for unsupported operating systems and CPU architectures to provide clear error messages during compilation instead of cryptic "missing return value" errors. Supported platforms: - OS: Linux, macOS, Windows - Arch: x86_64, aarch64 --- crates/vite_js_runtime/src/platform.rs | 29 ++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/vite_js_runtime/src/platform.rs b/crates/vite_js_runtime/src/platform.rs index 60f1cf8dd7..58e03baf37 100644 --- a/crates/vite_js_runtime/src/platform.rs +++ b/crates/vite_js_runtime/src/platform.rs @@ -37,7 +37,14 @@ impl fmt::Display for Platform { } impl Os { - /// Detect the current operating system + /// Detect the current operating system. + /// + /// # Supported platforms + /// - Linux (`target_os = "linux"`) + /// - macOS (`target_os = "macos"`) + /// - Windows (`target_os = "windows"`) + /// + /// Compilation will fail on unsupported operating systems. #[must_use] pub const fn current() -> Self { #[cfg(target_os = "linux")] @@ -52,6 +59,12 @@ impl Os { { Self::Windows } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + compile_error!( + "Unsupported operating system. vite_js_runtime only supports Linux, macOS, and Windows." + ) + } } } @@ -66,7 +79,13 @@ impl fmt::Display for Os { } impl Arch { - /// Detect the current CPU architecture + /// Detect the current CPU architecture. + /// + /// # Supported architectures + /// - x86_64 (`target_arch = "x86_64"`) + /// - ARM64/AArch64 (`target_arch = "aarch64"`) + /// + /// Compilation will fail on unsupported architectures. #[must_use] pub const fn current() -> Self { #[cfg(target_arch = "x86_64")] @@ -77,6 +96,12 @@ impl Arch { { Self::Arm64 } + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + { + compile_error!( + "Unsupported CPU architecture. vite_js_runtime only supports x86_64 and aarch64." + ) + } } }