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/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/Cargo.lock b/Cargo.lock index 64745f0bd6..163cdcb8b0 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" @@ -2791,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" @@ -2975,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" @@ -3333,7 +3472,7 @@ dependencies = [ "textwrap", "thiserror 2.0.17", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -3911,6 +4050,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 +4353,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" @@ -4659,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", ] @@ -6136,7 +6291,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -6195,6 +6350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -6519,6 +6675,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" @@ -6564,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" @@ -6859,6 +7027,31 @@ dependencies = [ "vite_workspace", ] +[[package]] +name = "vite_js_runtime" +version = "0.0.0" +dependencies = [ + "async-trait", + "backon", + "directories", + "flate2", + "futures-util", + "hex", + "node-semver", + "reqwest", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "thiserror 2.0.17", + "tokio", + "tracing", + "vite_path", + "vite_str", + "zip", +] + [[package]] name = "vite_migration" version = "0.0.0" @@ -7668,6 +7861,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 +7909,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..3528196f41 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" @@ -115,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" @@ -149,6 +154,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 +166,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..8c502af052 --- /dev/null +++ b/crates/vite_js_runtime/Cargo.toml @@ -0,0 +1,40 @@ +[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] +async-trait = { workspace = true } +backon = { workspace = true } +directories = { workspace = true } +flate2 = { workspace = true } +futures-util = { workspace = true } +hex = { workspace = true } +node-semver = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true, features = ["preserve_order"] } +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 } +zip = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +reqwest = { workspace = true, features = ["stream", "native-tls-vendored"] } + +[target.'cfg(not(target_os = "windows"))'.dependencies] +reqwest = { workspace = true, features = ["stream", "rustls-tls"] } + +[dev-dependencies] +tempfile = { workspace = true } + +[lints] +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..cc26ab649f --- /dev/null +++ b/crates/vite_js_runtime/src/dev_engines.rs @@ -0,0 +1,737 @@ +//! 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 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")] +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, +} + +/// 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 - 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( + "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 + } + ]); + } + } + _ => { + // 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::*; + + #[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()); + } + + #[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_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!({ + "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/download.rs b/crates/vite_js_runtime/src/download.rs new file mode 100644 index 0000000000..1b16d12610 --- /dev/null +++ b/crates/vite_js_runtime/src/download.rs @@ -0,0 +1,285 @@ +//! 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}; + +/// 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:?}"); + + 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) +} + +/// 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, + 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(); + // Store the lock file to keep it alive until end of function + 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()?; + 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:?}"); + // Lock is released when lock_file is dropped at end of scope + return Ok(()); + } + + // Atomic rename (lock is still held) + fs::rename(source.as_path(), target.as_path()).await?; + tracing::debug!("Atomic rename successful: {source:?} -> {target:?}"); + + 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/error.rs b/crates/vite_js_runtime/src/error.rs new file mode 100644 index 0000000000..46230a9409 --- /dev/null +++ b/crates/vite_js_runtime/src/error.rs @@ -0,0 +1,62 @@ +use thiserror::Error; +use vite_str::Str; + +/// Errors that can occur during JavaScript runtime management +#[derive(Error, Debug)] +pub enum Error { + /// 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 }, + + /// 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), + + /// HTTP request error + #[error(transparent)] + Reqwest(#[from] reqwest::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 new file mode 100644 index 0000000000..94d654bae9 --- /dev/null +++ b/crates/vite_js_runtime/src/lib.rs @@ -0,0 +1,51 @@ +//! 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}; +//! +//! let runtime = download_runtime(JsRuntimeType::Node, "22.13.1").await?; +//! 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): +//! +//! 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 dev_engines; +mod download; +mod error; +mod platform; +mod provider; +mod providers; +mod runtime; + +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_for_project, + download_runtime_with_provider, +}; diff --git a/crates/vite_js_runtime/src/platform.rs b/crates/vite_js_runtime/src/platform.rs new file mode 100644 index 0000000000..58e03baf37 --- /dev/null +++ b/crates/vite_js_runtime/src/platform.rs @@ -0,0 +1,162 @@ +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() } + } +} + +impl fmt::Display for Platform { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}-{}", self.os, self.arch) + } +} + +impl Os { + /// 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")] + { + Self::Linux + } + #[cfg(target_os = "macos")] + { + Self::Darwin + } + #[cfg(target_os = "windows")] + { + 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." + ) + } + } +} + +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. + /// + /// # 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")] + { + Self::X64 + } + #[cfg(target_arch = "aarch64")] + { + 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." + ) + } + } +} + +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::*; + + #[test] + fn test_platform_detection() { + let platform = Platform::current(); + + // Just verify it doesn't panic and returns a valid platform + let platform_str = platform.to_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_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 }, "windows-x64"), + (Platform { os: Os::Windows, arch: Arch::Arm64 }, "windows-arm64"), + ]; + + for (platform, expected) in cases { + assert_eq!(platform.to_string(), expected); + } + } + + #[test] + 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..685ba5a7a1 --- /dev/null +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -0,0 +1,905 @@ +//! Node.js runtime provider implementation. + +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::{AbsolutePath, AbsolutePathBuf, current_dir}; +use vite_str::Str; + +use crate::{ + Error, Platform, + download::fetch_with_cache_headers, + 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"; + +/// 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, +} + +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)] +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; + +impl NodeProvider { + /// Create a new `NodeProvider` + #[must_use] + pub const fn new() -> Self { + 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 using semver comparison + Ok(matching_versions.into_iter().max().map(|v| v.to_string().into())) + } + + /// 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, + } + } + + /// 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. + 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 cache is still fresh, use it + if now < cache.expires_at { + tracing::debug!( + "Using cached version index (expires in {}s)", + cache.expires_at - now + ); + return Ok(cache.versions); + } + + // 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"); + } + } + + // Full fetch + self.fetch_and_cache(&cache_path).await + } + + /// 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"); + + 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; + + Ok(versions) + } + + /// 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 response = fetch_with_cache_headers(&index_url, None).await?; + + let body = response.body.ok_or_else(|| Error::VersionIndexParseFailed { + reason: "Empty response body".into(), + })?; + let versions: Vec = serde_json::from_str(&body)?; + + 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 versions = self.fetch_version_index().await?; + resolve_version_from_list(version_req, &versions) + } + + /// Get the latest LTS version with the highest version number. + /// + /// # Errors + /// + /// 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?; + 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() { + 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 +/// +/// 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) }; + } + + #[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"); + } + + #[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()) }, + ]; + + // ^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] + 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"); + } + + #[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()) }, + ]; + + // Exact version should return that specific version + let result = resolve_version_from_list("20.18.0", &versions).unwrap(); + assert_eq!(result, "20.18.0"); + } + + #[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()) }, + ]; + + // >=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()); + } + + #[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 result = find_latest_lts_version(&versions).unwrap(); + + // 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()); + } + + #[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 new file mode 100644 index 0000000000..6690942997 --- /dev/null +++ b/crates/vite_js_runtime/src/runtime.rs @@ -0,0 +1,641 @@ +use directories::BaseDirs; +use tempfile::TempDir; +use vite_path::{AbsolutePath, AbsolutePathBuf, current_dir}; +use vite_str::Str; + +use crate::{ + Error, Platform, + dev_engines::{PackageJson, update_runtime_version}, + 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)] +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, + /// 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 { + self.install_dir.join(&self.binary_relative_path) + } + + /// Get the bin directory containing the runtime + #[must_use] + pub fn get_bin_prefix(&self) -> AbsolutePathBuf { + if self.bin_dir_relative_path.is_empty() { + self.install_dir.clone() + } else { + self.install_dir.join(&self.bin_dir_relative_path) + } + } + + /// 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 + } +} + +/// 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 => { + let provider = NodeProvider::new(); + download_runtime_with_provider(&provider, JsRuntimeType::Node, version).await + } + } +} + +/// 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()?; + + // 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}/ + 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); + if tokio::fs::try_exists(&binary_path).await.unwrap_or(false) { + tracing::debug!("{} {version} already cached at {install_dir:?}", provider.name()); + return Ok(JsRuntime { + runtime_type, + version: version.into(), + install_dir, + binary_relative_path, + bin_dir_relative_path, + }); + } + + // 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 + let download_info = provider.get_download_info(version, platform); + + // 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); + + // 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_info.archive_url, &archive_path).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 + extract_archive(&archive_path, &temp_path, download_info.archive_format).await?; + + // 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!("{} {version} installed at {install_dir:?}", provider.name()); + + Ok(JsRuntime { + runtime_type, + version: version.into(), + install_dir, + binary_relative_path, + bin_dir_relative_path, + }) +} + +/// 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(); + let cache_dir = get_cache_dir()?; + + // 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")); + + // 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) 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 { + // 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"); + provider.resolve_latest_version().await? + } + }; + + tracing::info!("Resolved Node.js version: {version}"); + 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. +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() { + 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)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_js_runtime_type_display() { + 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 (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(); + + 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); + + // 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] + 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(); + 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() { + // 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] + 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); + } + + /// 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"; + + // 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] + #[ignore] + 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(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(); + } + + // 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}" + ); + } +} diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md new file mode 100644 index 0000000000..3a3169412e --- /dev/null +++ b/rfcs/js-runtime.md @@ -0,0 +1,669 @@ +# 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.)~~ **Now supported via `devEngines.runtime`** +- 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` | + +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 + +### Crate Structure + +``` +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 with version resolution + ├── download.rs # Generic download utilities + └── runtime.rs # JsRuntime struct and download orchestration +``` + +### 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, + 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 +/// 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 +/// 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; + + /// 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; +} + +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 Examples + +**Direct version download:** + +```rust +use vite_js_runtime::{JsRuntimeType, download_runtime}; + +let runtime = download_runtime(JsRuntimeType::Node, "22.13.1").await?; +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: + +``` +$CACHE_DIR/vite/js_runtime/{runtime}/{version}/ +``` + +Examples: + +- 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\` + +### 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 | +| ------- | ------------ | --------------- | +| Linux | x64 | `linux-x64` | +| Linux | ARM64 | `linux-arm64` | +| macOS | x64 | `darwin-x64` | +| macOS | ARM64 | `darwin-arm64` | +| 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 + +The version resolution is optimized to minimize network requests: + +| 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 + +- 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 + +### 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 + +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` | + +### 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: + +``` +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. Select the appropriate JsRuntimeProvider + └── e.g., NodeProvider for JsRuntimeType::Node + +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 + +5. Download with atomic operations + ├── Create temp directory + ├── Download SHASUMS file and parse expected hash (via provider) + ├── Download archive with retry logic + ├── Verify archive hash + ├── Extract archive (tar.gz or zip based on format) + ├── Acquire file lock (prevent concurrent installs) + └── Atomic rename to final location + +6. Return JsRuntime with install path and relative paths +``` + +### 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 + +Error variants in `vite_js_runtime::Error`: + +```rust +pub enum Error { + /// Version not found in official releases + VersionNotFound { runtime: Str, version: Str }, + + /// Platform not supported for this runtime + UnsupportedPlatform { platform: Str, runtime: Str }, + + /// Download failed after retries + DownloadFailed { url: Str, reason: Str }, + + /// Hash verification failed (download corrupted) + HashMismatch { filename: Str, expected: Str, actual: Str }, + + /// Archive extraction failed + 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), +} +``` + +## Testing Strategy + +### Unit Tests + +1. **Platform detection** + - Test all supported platform/arch combinations + - Test mapping to Node.js distribution names + +2. **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**: Support both exact versions and semver ranges. + +**Rationale**: + +- 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 + +**Decision**: Support only Node.js in the initial version. + +**Rationale**: + +- Node.js is the most widely used runtime +- Allows focused, well-tested implementation +- 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` 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` +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 + +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. ✅ 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 +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 + +- [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/)