From 17f4e59d720cb66ec89d8c99ece71b8ca6b14533 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Jan 2026 17:48:58 +0800 Subject: [PATCH 01/10] feat(js_runtime): add multi-source Node version reading Read Node.js version from multiple sources with priority: 1. .node-version file (highest) 2. engines.node in package.json 3. devEngines.runtime in package.json (lowest) Key behaviors: - Only write to .node-version when no version source exists - Always fetch latest LTS from network when no version specified - Warn when resolved version conflicts with lower-priority constraints - node-semver handles partial versions natively (20, 20.18, etc.) --- crates/vite_js_runtime/src/dev_engines.rs | 637 ++++------------------ crates/vite_js_runtime/src/runtime.rs | 409 ++++++++++---- rfcs/js-runtime.md | 172 ++++-- 3 files changed, 543 insertions(+), 675 deletions(-) diff --git a/crates/vite_js_runtime/src/dev_engines.rs b/crates/vite_js_runtime/src/dev_engines.rs index cc26ab649f..217824bb25 100644 --- a/crates/vite_js_runtime/src/dev_engines.rs +++ b/crates/vite_js_runtime/src/dev_engines.rs @@ -1,13 +1,9 @@ -//! Package.json devEngines.runtime parsing and updating. +//! Package.json devEngines.runtime and engines.node parsing. //! -//! This module provides structs for parsing the `devEngines.runtime` field from package.json, -//! which can be either a single runtime object or an array of runtime objects. -//! It also provides functionality to update the runtime version in package.json. +//! This module provides structs for parsing the `devEngines.runtime` and `engines.node` +//! fields from package.json. It also handles `.node-version` file reading and writing. -use std::io::Write; - -use serde::{Deserialize, Serialize}; -use serde_json::ser::{Formatter, Serializer}; +use serde::Deserialize; use vite_path::AbsolutePath; use vite_str::Str; @@ -60,266 +56,80 @@ pub struct DevEngines { pub runtime: Option, } -/// Partial package.json structure for reading devEngines. +/// The engines section of package.json. +#[derive(Deserialize, Default, Debug)] +pub struct Engines { + /// Node.js version requirement (e.g., ">=20.0.0") + #[serde(default)] + pub node: Option, +} + +/// Partial package.json structure for reading devEngines and engines. #[derive(Deserialize, Default, Debug)] #[serde(rename_all = "camelCase")] pub(crate) struct PackageJson { /// The devEngines configuration #[serde(default)] pub dev_engines: Option, + /// The engines configuration + #[serde(default)] + pub 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() +/// Parse the content of a `.node-version` file. +/// +/// # Supported Formats +/// +/// - Three-part version: `20.5.0` +/// - With `v` prefix: `v20.5.0` +/// - Two-part version: `20.5` (treated as `^20.5.0` for resolution) +/// - Single-part version: `20` (treated as `^20.0.0` for resolution) +/// +/// # Returns +/// +/// The version string with any leading `v` prefix stripped. +/// Returns `None` if the content is empty or contains only whitespace. +#[must_use] +pub fn parse_node_version_content(content: &str) -> Option { + let version = content.lines().next()?.trim(); + if version.is_empty() { + return None; + } + // Strip optional 'v' prefix + let version = version.strip_prefix('v').unwrap_or(version); + Some(version.into()) } -/// 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 - }), - ); - } +/// Read and parse a `.node-version` file from the project root. +/// +/// # Arguments +/// * `project_path` - The path to the project directory +/// +/// # Returns +/// The version string if the file exists and contains a valid version. +pub async fn read_node_version_file(project_path: &AbsolutePath) -> Option { + let path = project_path.join(".node-version"); + let content = tokio::fs::read_to_string(&path).await.ok()?; + parse_node_version_content(&content) } -/// Update devEngines.runtime in package.json with the resolved version. +/// Write a version to the `.node-version` file. /// -/// This function reads the package.json, detects the original indentation style, -/// updates or creates the devEngines.runtime field, and writes back with preserved formatting. +/// Creates the file if it doesn't exist, overwrites if it does. +/// Uses three-part version without `v` prefix and Unix line ending. /// /// # 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") +/// * `project_path` - The path to the project directory +/// * `version` - The version string (e.g., "22.13.1") /// /// # 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, +/// Returns an error if the file cannot be written. +pub async fn write_node_version_file( + project_path: &AbsolutePath, 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?; - } - + let path = project_path.join(".node-version"); + tokio::fs::write(&path, format!("{version}\n")).await?; Ok(()) } @@ -423,315 +233,102 @@ mod tests { } #[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); + fn test_parse_node_version_content_three_part() { + assert_eq!(parse_node_version_content("20.5.0\n"), Some("20.5.0".into())); + assert_eq!(parse_node_version_content("20.5.0"), Some("20.5.0".into())); + assert_eq!(parse_node_version_content("22.13.1\n"), Some("22.13.1".into())); } #[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); + fn test_parse_node_version_content_with_v_prefix() { + assert_eq!(parse_node_version_content("v20.5.0\n"), Some("20.5.0".into())); + assert_eq!(parse_node_version_content("v20.5.0"), Some("20.5.0".into())); + assert_eq!(parse_node_version_content("v22.13.1\n"), Some("22.13.1".into())); } #[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); + fn test_parse_node_version_content_two_part() { + assert_eq!(parse_node_version_content("20.5\n"), Some("20.5".into())); + assert_eq!(parse_node_version_content("v20.5\n"), Some("20.5".into())); } #[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); + fn test_parse_node_version_content_single_part() { + assert_eq!(parse_node_version_content("20\n"), Some("20".into())); + assert_eq!(parse_node_version_content("v20\n"), Some("20".into())); } #[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"); + fn test_parse_node_version_content_with_whitespace() { + assert_eq!(parse_node_version_content(" 20.5.0 \n"), Some("20.5.0".into())); + assert_eq!(parse_node_version_content("\t20.5.0\t\n"), Some("20.5.0".into())); } #[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); + fn test_parse_node_version_content_empty() { + assert!(parse_node_version_content("").is_none()); + assert!(parse_node_version_content("\n").is_none()); + assert!(parse_node_version_content(" \n").is_none()); } #[tokio::test] - async fn test_update_runtime_version_creates_dev_engines() { + async fn test_read_node_version_file() { 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; + // File doesn't exist + assert!(read_node_version_file(&temp_path).await.is_none()); - 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); + // Create .node-version file + tokio::fs::write(temp_path.join(".node-version"), "22.13.1\n").await.unwrap(); + assert_eq!(read_node_version_file(&temp_path).await, Some("22.13.1".into())); } #[tokio::test] - async fn test_update_runtime_version_preserves_tab_indent() { + async fn test_write_node_version_file() { 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(); + write_node_version_file(&temp_path, "22.13.1").await.unwrap(); - update_runtime_version(&package_json_path, "node", "20.18.0").await.unwrap(); + let content = tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(content, "22.13.1\n"); - 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); + // Verify it can be read back + assert_eq!(read_node_version_file(&temp_path).await, Some("22.13.1".into())); } - #[tokio::test] - async fn test_update_runtime_version_updates_array_format() { - use tempfile::TempDir; - use vite_path::AbsolutePathBuf; + #[test] + fn test_parse_engines_node() { + let json = r#"{"engines":{"node":">=20.0.0"}}"#; + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert_eq!(pkg.engines.unwrap().node, Some(">=20.0.0".into())); + } - 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); + #[test] + fn test_parse_engines_node_empty() { + let json = r#"{"engines":{}}"#; + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert!(pkg.engines.unwrap().node.is_none()); + } + + #[test] + fn test_parse_both_engines_and_dev_engines() { + let json = r#"{ + "engines": {"node": ">=20.0.0"}, + "devEngines": {"runtime": {"name": "node", "version": "^24.4.0"}} + }"#; + let pkg: PackageJson = serde_json::from_str(json).unwrap(); + assert_eq!(pkg.engines.unwrap().node, Some(">=20.0.0".into())); + let dev_engines = pkg.dev_engines.unwrap(); + let runtime = dev_engines.runtime.unwrap(); + let node = runtime.find_by_name("node").unwrap(); + assert_eq!(node.version, "^24.4.0"); } } diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index c3152a1861..05a648244f 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -1,10 +1,11 @@ +use node_semver::{Range, Version}; use tempfile::TempDir; use vite_path::{AbsolutePath, AbsolutePathBuf}; use vite_str::Str; use crate::{ Error, Platform, - dev_engines::{PackageJson, update_runtime_version}, + dev_engines::{PackageJson, read_node_version_file, write_node_version_file}, download::{download_file, download_text, extract_archive, move_to_cache, verify_file_hash}, provider::{HashVerification, JsRuntimeProvider}, providers::NodeProvider, @@ -114,7 +115,7 @@ pub async fn download_runtime_with_provider( let binary_relative_path = provider.binary_relative_path(platform); let bin_dir_relative_path = provider.bin_dir_relative_path(platform); - // Cache path: $CACHE_DIR/vite/js_runtime/{runtime}/{version}/ + // Cache path: $CACHE_DIR/vite-plus/js_runtime/{runtime}/{version}/ let install_dir = cache_dir.join(vite_str::format!("{}/{version}", provider.name())); // Check if already cached @@ -188,95 +189,207 @@ pub async fn download_runtime_with_provider( }) } -/// Download runtime based on project's devEngines.runtime configuration. +/// Represents the source from which a Node.js version was read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VersionSource { + /// Version from `.node-version` file (highest priority) + NodeVersionFile, + /// Version from `engines.node` in package.json + EnginesNode, + /// Version from `devEngines.runtime` in package.json (lowest priority) + DevEnginesRuntime, + /// No version source specified, will use latest installed or LTS + None, +} + +impl std::fmt::Display for VersionSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NodeVersionFile => write!(f, ".node-version"), + Self::EnginesNode => write!(f, "engines.node"), + Self::DevEnginesRuntime => write!(f, "devEngines.runtime"), + Self::None => write!(f, "none"), + } + } +} + +/// Download runtime based on project's version configuration. +/// +/// Reads Node.js version from multiple sources with the following priority: +/// 1. `.node-version` file (highest) +/// 2. `engines.node` in package.json +/// 3. `devEngines.runtime` in package.json (lowest) /// -/// 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. +/// If no version source is found, uses the latest installed version from cache, +/// or falls back to the latest LTS version from the network. +/// +/// When the resolved version from the highest priority source does NOT satisfy +/// constraints from lower priority sources, a warning is emitted. /// /// # Arguments -/// * `project_path` - The path to the project directory containing package.json +/// * `project_path` - The path to the project directory /// /// # 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. +/// Returns an error if 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. +/// Currently only supports Node.js runtime. 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 pkg = read_package_json(&package_json_path).await?; let provider = NodeProvider::new(); let cache_dir = crate::cache::get_cache_dir()?; - // Find the "node" runtime configuration (supports both single object and array) - let node_runtime = dev_engines + // 1. Read all version sources + let node_version_file = read_node_version_file(project_path).await; + let engines_node = pkg.as_ref().and_then(|p| p.engines.as_ref()).and_then(|e| e.node.clone()); + let dev_engines_runtime = pkg .as_ref() + .and_then(|p| p.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 + .and_then(|rt| rt.find_by_name("node")) + .map(|r| r.version.clone()) + .filter(|v| !v.is_empty()); + + tracing::debug!( + "Version sources - .node-version: {:?}, engines.node: {:?}, devEngines.runtime: {:?}", + node_version_file, + engines_node, + dev_engines_runtime + ); + + // 2. Select version from highest priority source that exists + let (version_req, source) = if let Some(ref v) = node_version_file { + (v.clone(), VersionSource::NodeVersionFile) + } else if let Some(ref v) = engines_node { + (v.clone(), VersionSource::EnginesNode) + } else if let Some(ref v) = dev_engines_runtime { + (v.clone(), VersionSource::DevEnginesRuntime) + } else { + (Str::default(), VersionSource::None) }; - 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::debug!("Selected version source: {source}, version_req: {version_req:?}"); + + // 3. Resolve version (if range/partial → exact) + let (version, should_write_back) = + resolve_version_for_project(&version_req, source, &provider, &cache_dir).await?; + + // 4. Check compatibility with lower priority sources + check_version_compatibility(&version, source, &engines_node, &dev_engines_runtime); 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) + // 5. Write resolved version to .node-version (if resolution occurred) 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}"); + if let Err(e) = write_node_version_file(project_path, &version).await { + tracing::warn!("Failed to write .node-version: {e}"); + } else { + tracing::info!("Using Node {version} - saved version to .node-version"); } } Ok(runtime) } -/// Read devEngines configuration from package.json. -async fn read_dev_engines( +/// Resolve version requirement to an exact version. +/// +/// Returns (resolved_version, should_write_back). +async fn resolve_version_for_project( + version_req: &str, + _source: VersionSource, + provider: &NodeProvider, + cache_dir: &AbsolutePath, +) -> Result<(Str, bool), Error> { + if version_req.is_empty() { + // No source specified - fetch latest LTS from network + tracing::debug!("No version source specified, fetching latest LTS from network"); + let version = provider.resolve_latest_version().await?; + return Ok((version, true)); + } + + // Check if it's an exact version + if NodeProvider::is_exact_version(version_req) { + let normalized = version_req.strip_prefix('v').unwrap_or(version_req); + tracing::debug!("Using exact version: {normalized}"); + // Never write back exact versions - user explicitly specified the version + return Ok((normalized.into(), false)); + } + + // Check local cache first + if let Some(cached) = provider.find_cached_version(version_req, cache_dir).await? { + tracing::debug!("Found cached version {cached} satisfying {version_req}"); + // Don't write back - user specified a version requirement + return Ok((cached, false)); + } + + // Resolve from network + tracing::debug!("Resolving version requirement from network: {version_req}"); + let version = provider.resolve_version(version_req).await?; + + // Don't write back - user specified a version requirement + Ok((version, false)) +} + +/// Check if the resolved version is compatible with lower priority sources. +/// Emit warnings if incompatible. +fn check_version_compatibility( + resolved_version: &str, + source: VersionSource, + engines_node: &Option, + dev_engines_runtime: &Option, +) { + let parsed = match Version::parse(resolved_version) { + Ok(v) => v, + Err(_) => return, // Can't check compatibility without a valid version + }; + + // Check engines.node if it's a lower priority source + if source != VersionSource::EnginesNode { + if let Some(req) = engines_node { + check_constraint(&parsed, req, "engines.node", resolved_version, source); + } + } + + // Check devEngines.runtime if it's a lower priority source + if source != VersionSource::DevEnginesRuntime { + if let Some(req) = dev_engines_runtime { + check_constraint(&parsed, req, "devEngines.runtime", resolved_version, source); + } + } +} + +/// Check if a version satisfies a constraint and warn if not. +fn check_constraint( + version: &Version, + constraint: &str, + constraint_source: &str, + resolved_version: &str, + source: VersionSource, +) { + match Range::parse(constraint) { + Ok(range) => { + if !range.satisfies(version) { + println!( + "warning: Node.js version {resolved_version} (from {source}) does not satisfy \ + {constraint_source} constraint '{constraint}'" + ); + } + } + Err(e) => { + tracing::debug!("Failed to parse {constraint_source} constraint '{constraint}': {e}"); + } + } +} + +/// Read package.json contents. +async fn read_package_json( package_json_path: &AbsolutePathBuf, -) -> Result, Error> { +) -> 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); @@ -284,7 +397,7 @@ async fn read_dev_engines( let content = tokio::fs::read_to_string(package_json_path).await?; let pkg: PackageJson = serde_json::from_str(&content)?; - Ok(pkg.dev_engines) + Ok(Some(pkg)) } #[cfg(test)] @@ -365,20 +478,14 @@ mod tests { 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); + // Should write resolved version to .node-version + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, format!("{version}\n")); + + // package.json should remain unchanged + let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert_eq!(pkg_content, package_json); } #[tokio::test] @@ -401,21 +508,14 @@ mod tests { 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); + // Should write resolved version to .node-version + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, format!("{version}\n")); + + // package.json should remain unchanged + let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert_eq!(pkg_content, package_json); } #[tokio::test] @@ -436,14 +536,17 @@ mod tests { "#; tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); - let _runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); - // 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); + // Should NOT write .node-version since a version was specified + assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); + + // package.json should remain unchanged + let pkg_content = tokio::fs::read_to_string(temp_path.join("package.json")).await.unwrap(); + assert_eq!(pkg_content, package_json); } #[tokio::test] @@ -628,4 +731,116 @@ mod tests { "Version output should contain {version}, got: {version_output}" ); } + + // ========================================== + // Multi-source version reading tests + // ========================================== + + #[tokio::test] + async fn test_node_version_file_takes_priority() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with exact version + tokio::fs::write(temp_path.join(".node-version"), "20.18.0\n").await.unwrap(); + + // Create package.json with engines.node (should be ignored) + let package_json = r#"{"engines":{"node":">=22.0.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.version(), "20.18.0"); + + // Should NOT write back since .node-version had exact version + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "20.18.0\n"); + } + + #[tokio::test] + async fn test_engines_node_takes_priority_over_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with both engines.node and devEngines.runtime + let package_json = r#"{ + "engines": {"node": "^20.18.0"}, + "devEngines": {"runtime": {"name": "node", "version": "^22.0.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 engines.node (^20.18.0), which will resolve to a 20.x version + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + } + + #[tokio::test] + async fn test_only_engines_node_source() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with only engines.node + let package_json = r#"{"engines":{"node":"^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(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + + // Should NOT write .node-version since a version was specified + assert!(!tokio::fs::try_exists(temp_path.join(".node-version")).await.unwrap()); + } + + #[tokio::test] + async fn test_node_version_file_partial_version() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with partial version (two parts) + tokio::fs::write(temp_path.join(".node-version"), "20.18\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + // Should resolve to a 20.18.x or higher version in 20.x line + assert_eq!(parsed.major, 20); + // Minor version should be at least 18 + assert!(parsed.minor >= 18, "Expected minor >= 18, got {}", parsed.minor); + + // Should NOT write back - .node-version already has a version specified + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "20.18\n"); + } + + #[tokio::test] + async fn test_node_version_file_single_part_version() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with single-part version + tokio::fs::write(temp_path.join(".node-version"), "20\n").await.unwrap(); + + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + // Should resolve to a 20.x.x version + assert_eq!(parsed.major, 20); + + // Should NOT write back - .node-version already has a version specified + let node_version_content = + tokio::fs::read_to_string(temp_path.join(".node-version")).await.unwrap(); + assert_eq!(node_version_content, "20\n"); + } + + #[test] + fn test_version_source_display() { + assert_eq!(VersionSource::NodeVersionFile.to_string(), ".node-version"); + assert_eq!(VersionSource::EnginesNode.to_string(), "engines.node"); + assert_eq!(VersionSource::DevEnginesRuntime.to_string(), "devEngines.runtime"); + assert_eq!(VersionSource::None.to_string(), "none"); + } } diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 3a3169412e..94ec27b485 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -20,7 +20,7 @@ The PackageManager implementation in `vite_install` successfully handles automat ## Non-Goals (Initial Version) -- ~~Configuration auto-detection (no reading from package.json, .nvmrc, etc.)~~ **Now supported via `devEngines.runtime`** +- ~~Configuration auto-detection (no reading from package.json, .nvmrc, etc.)~~ **Now supported via `.node-version`, `engines.node`, and `devEngines.runtime`** - Managing multiple runtime versions simultaneously - Providing a version manager CLI (like nvm/fnm) - Supporting custom/unofficial Node.js builds @@ -154,21 +154,14 @@ pub async fn download_runtime( 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 +/// Download runtime based on project's version configuration +/// Reads from .node-version, engines.node, or devEngines.runtime (in priority order) +/// Resolves semver ranges, downloads the matching version +/// Writes resolved version to .node-version for future use 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; @@ -207,7 +200,7 @@ 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):** +**Project-based download (reads from .node-version, engines.node, or devEngines.runtime):** ```rust use vite_js_runtime::download_runtime_for_project; @@ -215,7 +208,8 @@ 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 +// Version is resolved from .node-version > engines.node > devEngines.runtime +// Resolved version is saved to .node-version for future use ``` ## Cache Directory Structure @@ -269,11 +263,53 @@ Cache structure: | Windows | x64 | `win-x64` | | Windows | ARM64 | `win-arm64` | -## Project Configuration (devEngines.runtime) +## Version Source Priority + +The `download_runtime_for_project` function reads Node.js version from multiple sources with the following priority: + +| Priority | Source | File | Example | Used By | +| ----------- | -------------------- | --------------- | ------------------------------------- | ----------------------------- | +| 1 (highest) | `.node-version` | `.node-version` | `22.13.1` | fnm, nvm, Netlify, Cloudflare | +| 2 | `engines.node` | `package.json` | `">=20.0.0"` | Vercel, npm | +| 3 (lowest) | `devEngines.runtime` | `package.json` | `{"name":"node","version":"^24.4.0"}` | npm RFC | + +### `.node-version` File Format + +Reference: https://github.com/shadowspawn/node-version-usage + +**Supported Formats:** -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). +| Format | Example | Support Level | +| ------------------- | --------- | -------------------------------- | +| Three-part version | `20.5.0` | Universal | +| With `v` prefix | `v20.5.0` | Universal | +| Two-part version | `20.5` | Supported (treated as `^20.5.0`) | +| Single-part version | `20` | Supported (treated as `^20.0.0`) | -### Single Runtime +**Format Rules:** + +1. Single line with Unix line ending (`\n`) +2. Trim whitespace from both ends +3. Optional `v` prefix - normalized by stripping +4. No comments - entire line is the version + +### `engines.node` Format + +Standard npm `engines` field in package.json: + +```json +{ + "engines": { + "node": ">=20.0.0" + } +} +``` + +### `devEngines.runtime` Format + +Following the [npm devEngines RFC](https://github.com/npm/rfcs/blob/main/accepted/0048-devEngines.md): + +**Single Runtime:** ```json { @@ -287,7 +323,7 @@ The `download_runtime_for_project` function reads the `devEngines.runtime` field } ``` -### Multiple Runtimes (Array) +**Multiple Runtimes (Array):** ```json { @@ -319,11 +355,12 @@ The version resolution is optimized to minimize network requests: | 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 | +| Empty/None | Match found | **No** | Use latest cached version | +| Empty/None | No match | **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. +**Partial versions** like `20` or `20.18` are treated as ranges by the `node-semver` crate. **Semver ranges** (e.g., `^24.4.0`) trigger version resolution: @@ -334,59 +371,71 @@ The version resolution is optimized to minimize network requests: 5. Use `node-semver` crate for npm-compatible range matching 6. Return the highest version that satisfies the range +### Mismatch Detection + +When the resolved version from the highest priority source does NOT satisfy constraints from lower priority sources, a warning is emitted. + +| .node-version | engines.node | devEngines | Resolved | Warning? | +| ------------- | ------------ | ---------- | -------------------- | -------------------------------- | +| `22.13.1` | `>=20.0.0` | - | `22.13.1` | No (22.13.1 satisfies >=20) | +| `22.13.1` | `>=24.0.0` | - | `22.13.1` | **Yes** (22.13.1 < 24) | +| - | `>=20.0.0` | `^24.4.0` | latest matching >=20 | No (if resolved >= 24) | +| `20.18.0` | - | `^24.4.0` | `20.18.0` | **Yes** (20 doesn't satisfy ^24) | + ### 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 +When no version source exists: + +1. Check local cache for installed Node.js versions +2. Use the **latest installed version** (if any exist) +3. If no cached versions exist, fetch and use latest LTS from network +4. Write the used version to `.node-version` +5. Print: `Using Node {version} - saved version to .node-version` + +This optimizes for: + +- Avoiding unnecessary network requests +- Using what the user already has installed +- Establishing `.node-version` as the version source going forward ### 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. +When `download_runtime_for_project` resolves a version, it writes the resolved version to `.node-version` (not `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 +| Read From | Write To | Message | +| ------------------------------- | ---------------------- | ------------------------------------------------------- | +| `.node-version` (exact) | No write | - | +| `.node-version` (range/partial) | Update `.node-version` | - | +| `engines.node` | Create `.node-version` | "Using Node {version} - saved version to .node-version" | +| `devEngines.runtime` | Create `.node-version` | "Using Node {version} - saved version to .node-version" | +| No source | Create `.node-version` | "Using Node {version} - saved version to .node-version" | -**Write-back does NOT occur when:** +**Key behaviors:** -- A version range is already specified (e.g., `^20.18.0`) -- An exact version is already specified (e.g., `20.18.0`) +1. Always write to `.node-version` (recommended single source of truth) +2. Use three-part version without `v` prefix with Unix line ending +3. Print informational message when saving version -**Example: Before download (no version specified)** +**Example: Before download (no version source)** -```json -{ - "name": "my-project", - "devEngines": { - "runtime": { - "name": "node" - } - } -} -``` +Project structure: -**After download (version written back)** - -```json -{ - "name": "my-project", - "devEngines": { - "runtime": { - "name": "node", - "version": "24.5.0" - } - } -} ``` +my-project/ +└── package.json +``` + +**After download (.node-version created)** -**Formatting preservation:** +Project structure: -- 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 +``` +my-project/ +├── .node-version # Contains: 24.5.0 +└── package.json +``` ## Download Sources @@ -657,13 +706,20 @@ pub enum Error { 9. ✅ Support semver ranges (^, ~, etc.) with version resolution 10. ✅ Version index caching with 1-hour TTL 11. ✅ Support both single runtime and array of runtimes in devEngines -12. ✅ Write resolved version back to package.json (with formatting preservation) +12. ✅ Write resolved version to `.node-version` file 13. ✅ Optimized version resolution (skip network for exact versions, check local cache for ranges) +14. ✅ Multi-source version reading with priority: `.node-version` > `engines.node` > `devEngines.runtime` +15. ✅ Support `.node-version` file format (with/without v prefix, partial versions) +16. ✅ Support `engines.node` from package.json +17. ✅ Warn when resolved version conflicts with lower-priority source constraints +18. ✅ Use latest cached version when no source specified (avoid network request) ## References - [Node.js Releases](https://nodejs.org/en/download/releases/) - [Node.js Distribution Index](https://nodejs.org/dist/index.json) +- [.node-version file usage](https://github.com/shadowspawn/node-version-usage) +- [npm devEngines RFC](https://github.com/npm/rfcs/blob/main/accepted/0048-devEngines.md) - [Corepack (Node.js Package Manager Manager)](https://nodejs.org/api/corepack.html) - [fnm (Fast Node Manager)](https://github.com/Schniz/fnm) - [volta (JavaScript Tool Manager)](https://volta.sh/) From 3bc39f45ca2b47497ad301cf13e0b16d7ef98115 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Jan 2026 18:10:44 +0800 Subject: [PATCH 02/10] feat(js_runtime): add semver validation for version sources Invalid version strings from .node-version, engines.node, and devEngines.runtime are now ignored with a warning, allowing fallthrough to lower-priority sources or the latest LTS version. The normalize_version function trims whitespace and validates the version as either an exact semver version or a valid range. --- crates/vite_js_runtime/src/runtime.rs | 246 +++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 4 deletions(-) diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index 05a648244f..bdbabd550c 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -243,16 +243,25 @@ pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result let provider = NodeProvider::new(); let cache_dir = crate::cache::get_cache_dir()?; - // 1. Read all version sources - let node_version_file = read_node_version_file(project_path).await; - let engines_node = pkg.as_ref().and_then(|p| p.engines.as_ref()).and_then(|e| e.node.clone()); + // 1. Read all version sources (with validation) + let node_version_file = read_node_version_file(project_path) + .await + .and_then(|v| normalize_version(&v, ".node-version")); + + let engines_node = pkg + .as_ref() + .and_then(|p| p.engines.as_ref()) + .and_then(|e| e.node.clone()) + .and_then(|v| normalize_version(&v, "engines.node")); + let dev_engines_runtime = pkg .as_ref() .and_then(|p| p.dev_engines.as_ref()) .and_then(|de| de.runtime.as_ref()) .and_then(|rt| rt.find_by_name("node")) .map(|r| r.version.clone()) - .filter(|v| !v.is_empty()); + .filter(|v| !v.is_empty()) + .and_then(|v| normalize_version(&v, "devEngines.runtime")); tracing::debug!( "Version sources - .node-version: {:?}, engines.node: {:?}, devEngines.runtime: {:?}", @@ -386,6 +395,32 @@ fn check_constraint( } } +/// Normalize and validate a version string as semver (exact version or range). +/// Trims whitespace and returns the normalized version, or None with a warning if invalid. +fn normalize_version(version: &Str, source: &str) -> Option { + // Trim leading/trailing whitespace + let trimmed: Str = version.trim().into(); + + if trimmed.is_empty() { + return None; + } + + // Try parsing as exact version (strip 'v' prefix for exact version check) + let without_v = trimmed.strip_prefix('v').unwrap_or(&trimmed); + if Version::parse(without_v).is_ok() { + return Some(trimmed); + } + + // Try parsing as range + if Range::parse(&trimmed).is_ok() { + return Some(trimmed); + } + + // Invalid version + println!("warning: invalid version '{version}' in {source}, ignoring"); + None +} + /// Read package.json contents. async fn read_package_json( package_json_path: &AbsolutePathBuf, @@ -843,4 +878,207 @@ mod tests { assert_eq!(VersionSource::DevEnginesRuntime.to_string(), "devEngines.runtime"); assert_eq!(VersionSource::None.to_string(), "none"); } + + // ========================================== + // Invalid version validation tests + // ========================================== + + #[tokio::test] + async fn test_invalid_node_version_file_is_ignored() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with invalid version + tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); + + // Create package.json without any version + let package_json = r#"{"name": "test-project"}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should fall through to fetch latest LTS since .node-version is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should have a valid version (latest LTS) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 20); + } + + #[tokio::test] + async fn test_invalid_engines_node_is_ignored() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with invalid engines.node + let package_json = r#"{"engines":{"node":"invalid"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should fall through to fetch latest LTS since engines.node is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should have a valid version (latest LTS) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 20); + } + + #[tokio::test] + async fn test_invalid_dev_engines_runtime_is_ignored() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with invalid devEngines.runtime version + let package_json = r#"{"devEngines":{"runtime":{"name":"node","version":"invalid"}}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should fall through to fetch latest LTS since devEngines.runtime is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + assert_eq!(runtime.runtime_type(), JsRuntimeType::Node); + + // Should have a valid version (latest LTS) + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert!(parsed.major >= 20); + } + + #[tokio::test] + async fn test_invalid_node_version_file_falls_through_to_valid_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create .node-version with invalid version + tokio::fs::write(temp_path.join(".node-version"), "invalid\n").await.unwrap(); + + // Create package.json with valid engines.node + let package_json = r#"{"engines":{"node":"^20.18.0"}}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should use engines.node since .node-version is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + } + + #[tokio::test] + async fn test_invalid_engines_falls_through_to_valid_dev_engines() { + let temp_dir = TempDir::new().unwrap(); + let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); + + // Create package.json with invalid engines.node but valid devEngines.runtime + let package_json = r#"{ + "engines": {"node": "invalid"}, + "devEngines": {"runtime": {"name": "node", "version": "^20.18.0"}} +}"#; + tokio::fs::write(temp_path.join("package.json"), package_json).await.unwrap(); + + // Should use devEngines.runtime since engines.node is invalid + let runtime = download_runtime_for_project(&temp_path).await.unwrap(); + let version = runtime.version(); + let parsed = node_semver::Version::parse(version).unwrap(); + assert_eq!(parsed.major, 20); + } + + #[test] + fn test_normalize_version_exact() { + let version = Str::from("20.18.0"); + assert_eq!(normalize_version(&version, "test"), Some(version.clone())); + } + + #[test] + fn test_normalize_version_with_v_prefix() { + let version = Str::from("v20.18.0"); + assert_eq!(normalize_version(&version, "test"), Some(version.clone())); + } + + #[test] + fn test_normalize_version_range() { + let version = Str::from("^20.18.0"); + assert_eq!(normalize_version(&version, "test"), Some(version.clone())); + } + + #[test] + fn test_normalize_version_partial() { + // Partial versions like "20" or "20.18" should be valid as ranges + let version = Str::from("20"); + assert_eq!(normalize_version(&version, "test"), Some(version.clone())); + + let version = Str::from("20.18"); + assert_eq!(normalize_version(&version, "test"), Some(version.clone())); + } + + #[test] + fn test_normalize_version_invalid() { + let version = Str::from("invalid"); + assert_eq!(normalize_version(&version, "test"), None); + + let version = Str::from("not-a-version"); + assert_eq!(normalize_version(&version, "test"), None); + } + + #[test] + fn test_normalize_version_real_world_ranges() { + // Test various real-world version range formats + let valid_ranges = [ + ">=18", + ">=18 <21", + "^18.18.0", + "~20.11.1", + "18.x", + "20.*", + "18 || 20 || >=22", + ">=16 <=20", + ">=20.0.0-rc.0", + "*", + ]; + + for range in valid_ranges { + let version = Str::from(range); + assert_eq!( + normalize_version(&version, "test"), + Some(version.clone()), + "Expected '{range}' to be valid" + ); + } + } + + #[test] + fn test_normalize_version_with_negation() { + // node-semver crate supports negation syntax + let version = Str::from(">=18 !=19.0.0 <21"); + assert_eq!( + normalize_version(&version, "test"), + Some(version.clone()), + "Expected '>=18 !=19.0.0 <21' to be valid" + ); + } + + #[test] + fn test_normalize_version_with_whitespace() { + // Versions with leading/trailing whitespace are trimmed + let version = Str::from(" 20 "); + assert_eq!( + normalize_version(&version, "test"), + Some(Str::from("20")), + "Expected ' 20 ' to be trimmed to '20'" + ); + + let version = Str::from(" v20.2.0 "); + assert_eq!( + normalize_version(&version, "test"), + Some(Str::from("v20.2.0")), + "Expected ' v20.2.0 ' to be trimmed to 'v20.2.0'" + ); + } + + #[test] + fn test_normalize_version_empty_or_whitespace_only() { + let version = Str::from(""); + assert_eq!(normalize_version(&version, "test"), None); + + let version = Str::from(" "); + assert_eq!(normalize_version(&version, "test"), None); + } } From 551f4454dd6349be38a28eafe829c756614fe764 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 29 Jan 2026 18:21:05 +0800 Subject: [PATCH 03/10] docs(js_runtime): update RFC with version validation and write-back changes - Add Version Validation section documenting trim and semver validation - Update Version Write-Back section to reflect current behavior (only write when no version source exists) - Add success criteria #19 for invalid version fallthrough behavior --- rfcs/js-runtime.md | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/rfcs/js-runtime.md b/rfcs/js-runtime.md index 94ec27b485..f6a3470e80 100644 --- a/rfcs/js-runtime.md +++ b/rfcs/js-runtime.md @@ -346,6 +346,24 @@ Following the [npm devEngines RFC](https://github.com/npm/rfcs/blob/main/accepte **Note:** Currently only the `"node"` runtime is supported. Other runtimes are ignored. +### Version Validation + +Before using a version string from any source, it is normalized and validated: + +1. **Trim whitespace**: Leading and trailing whitespace is removed +2. **Validate as semver**: The version must be either: + - An exact version (e.g., `20.18.0`, `v20.18.0`) + - A valid semver range (e.g., `^20.0.0`, `>=18 <21`, `20.x`, `*`) +3. **Invalid versions are ignored**: If validation fails, a warning is printed and the source is skipped + +**Example warning:** + +``` +warning: invalid version 'latest' in .node-version, ignoring +``` + +This allows fallthrough to lower-priority sources when a higher-priority source contains an invalid version. + ### Version Resolution The version resolution is optimized to minimize network requests: @@ -400,21 +418,20 @@ This optimizes for: ### Version Write-Back -When `download_runtime_for_project` resolves a version, it writes the resolved version to `.node-version` (not `package.json`). This ensures subsequent executions can skip version resolution and use the cached exact version directly. +When `download_runtime_for_project` resolves a version and **no version source exists**, it writes the resolved version to `.node-version`. This establishes a version source for future use. -**Write-back occurs when:** +**Write-back only occurs when no version source exists:** -| Read From | Write To | Message | -| ------------------------------- | ---------------------- | ------------------------------------------------------- | -| `.node-version` (exact) | No write | - | -| `.node-version` (range/partial) | Update `.node-version` | - | -| `engines.node` | Create `.node-version` | "Using Node {version} - saved version to .node-version" | -| `devEngines.runtime` | Create `.node-version` | "Using Node {version} - saved version to .node-version" | -| No source | Create `.node-version` | "Using Node {version} - saved version to .node-version" | +| Read From | Write To | Message | +| -------------------- | ---------------------- | ------------------------------------------------------- | +| `.node-version` | No write | - | +| `engines.node` | No write | - | +| `devEngines.runtime` | No write | - | +| No source | Create `.node-version` | "Using Node {version} - saved version to .node-version" | **Key behaviors:** -1. Always write to `.node-version` (recommended single source of truth) +1. Only write when no version source exists (respects user's explicit version requirements) 2. Use three-part version without `v` prefix with Unix line ending 3. Print informational message when saving version @@ -713,6 +730,7 @@ pub enum Error { 16. ✅ Support `engines.node` from package.json 17. ✅ Warn when resolved version conflicts with lower-priority source constraints 18. ✅ Use latest cached version when no source specified (avoid network request) +19. ✅ Invalid version strings are ignored with warning, falling through to lower-priority sources ## References From 8b3707f241d86328c86eb9e996fa947d37a68cb8 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Jan 2026 11:08:06 +0800 Subject: [PATCH 04/10] feat(js_runtime): prefer LTS versions when resolving semver ranges When resolving semver ranges like `^20.19.0 || >=22.12.0`, now prefers LTS versions over non-LTS versions. Previously it would return v25 (non-LTS), but now returns the latest LTS that satisfies the range. Only falls back to non-LTS if no LTS version matches the range. Changes: - Updated resolve_version_from_list to prefer highest LTS version - Updated find_cached_version to cross-reference with version index for LTS status of cached versions - Added tests for LTS preference behavior --- crates/vite_js_runtime/src/providers/node.rs | 127 +++++++++++++++++-- 1 file changed, 116 insertions(+), 11 deletions(-) diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index 3184954ff3..9eb4930909 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -93,15 +93,17 @@ impl NodeProvider { /// 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. + /// and returns a version that satisfies the semver range. Prefers LTS + /// versions over non-LTS versions. /// /// # 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. + /// The highest LTS cached version that satisfies the requirement, or the + /// highest non-LTS version if no LTS version matches, or `None` if no + /// cached version matches. /// /// # Errors /// Returns an error if the version requirement is invalid. @@ -136,7 +138,29 @@ impl NodeProvider { } } - // Return highest matching version using semver comparison + if matching_versions.is_empty() { + return Ok(None); + } + + // Fetch version index to check LTS status + let version_index = self.fetch_version_index().await?; + + // Build a set of LTS versions for fast lookup + let lts_versions: std::collections::HashSet = version_index + .iter() + .filter(|e| e.is_lts()) + .map(|e| e.version.strip_prefix('v').unwrap_or(&e.version).to_string()) + .collect(); + + // Prefer LTS: find highest LTS cached version first + let lts_max = + matching_versions.iter().filter(|v| lts_versions.contains(&v.to_string())).max(); + + if let Some(version) = lts_max { + return Ok(Some(version.to_string().into())); + } + + // Fallback to highest non-LTS Ok(matching_versions.into_iter().max().map(|v| v.to_string().into())) } @@ -300,7 +324,11 @@ fn find_latest_lts_version(versions: &[NodeVersionEntry]) -> Result }) } -/// Resolve a version requirement to the highest matching version from a list. +/// Resolve a version requirement to a matching version from a list. +/// +/// Prefers LTS versions over non-LTS versions. Returns the highest LTS version +/// that satisfies the range, or falls back to the highest non-LTS version if +/// no LTS version matches. /// /// # Errors /// @@ -311,17 +339,33 @@ fn resolve_version_from_list( ) -> Result { let range = Range::parse(version_req)?; - let max_matching = versions + // Collect all matching versions with their LTS status + let matching_versions: Vec<(Version, &str, bool)> = 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)) + Version::parse(version_str) + .ok() + .filter(|v| range.satisfies(v)) + .map(|v| (v, version_str, entry.is_lts())) }) - .filter(|(version, _)| range.satisfies(version)) - .max_by(|(a, _), (b, _)| a.cmp(b)); + .collect(); - max_matching - .map(|(_, version_str)| version_str.into()) + // Prefer LTS versions: find highest LTS first + let lts_max = matching_versions + .iter() + .filter(|(_, _, is_lts)| *is_lts) + .max_by(|(a, _, _), (b, _, _)| a.cmp(b)); + + if let Some((_, version_str, _)) = lts_max { + return Ok((*version_str).into()); + } + + // Fallback to highest non-LTS version + matching_versions + .into_iter() + .max_by(|(a, _, _), (b, _, _)| a.cmp(b)) + .map(|(_, version_str, _)| version_str.into()) .ok_or_else(|| Error::NoMatchingVersion { version_req: version_req.into() }) } @@ -892,4 +936,65 @@ fedcba987654 node-v22.13.1-win-x64.zip"; let result = provider.find_cached_version("^20.20.0", &cache_dir).await.unwrap(); assert!(result.is_none()); } + + #[test] + fn test_resolve_version_from_list_prefers_lts() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { version: "v25.5.0".into(), lts: LtsInfo::NotLts }, + NodeVersionEntry { version: "v24.5.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v22.15.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v20.19.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + ]; + + // Should prefer highest LTS (v24.5.0) over non-LTS (v25.5.0) + let result = resolve_version_from_list(">=20.0.0", &versions).unwrap(); + assert_eq!(result, "24.5.0"); + } + + #[test] + fn test_resolve_version_from_list_falls_back_to_non_lts() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { version: "v25.5.0".into(), lts: LtsInfo::NotLts }, + NodeVersionEntry { version: "v25.4.0".into(), lts: LtsInfo::NotLts }, + ]; + + // No LTS matches, should return highest non-LTS + let result = resolve_version_from_list(">24.9999.0", &versions).unwrap(); + assert_eq!(result, "25.5.0"); + } + + #[test] + fn test_resolve_version_from_list_complex_range_prefers_lts() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { version: "v25.5.0".into(), lts: LtsInfo::NotLts }, + NodeVersionEntry { version: "v24.5.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v22.15.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v20.19.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + ]; + + // ^20.19.0 || >=22.12.0 should prefer v24.5.0 (highest LTS) over v25.5.0 + let result = resolve_version_from_list("^20.19.0 || >=22.12.0", &versions).unwrap(); + assert_eq!(result, "24.5.0"); + } + + #[test] + fn test_resolve_version_from_list_only_matches_in_range_lts() { + use super::resolve_version_from_list; + + let versions = vec![ + NodeVersionEntry { version: "v25.5.0".into(), lts: LtsInfo::NotLts }, + NodeVersionEntry { version: "v24.5.0".into(), lts: LtsInfo::Codename("Jod".into()) }, + NodeVersionEntry { version: "v20.19.0".into(), lts: LtsInfo::Codename("Iron".into()) }, + ]; + + // ^20.18.0 should return 20.19.0 (the only LTS in range) + let result = resolve_version_from_list("^20.18.0", &versions).unwrap(); + assert_eq!(result, "20.19.0"); + } } From cca4bfe88965a2096664bb497798e786bca1767d Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Jan 2026 11:11:58 +0800 Subject: [PATCH 05/10] fix(js_runtime): gracefully handle network errors with expired cache When fetching the version index fails due to network errors and a local cache exists (even if expired), log a warning and return the cached version instead of failing. This ensures network issues don't interrupt the normal flow when we have a usable cache fallback. --- crates/vite_js_runtime/src/providers/node.rs | 28 +++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index 9eb4930909..1aa2e29c32 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -98,7 +98,7 @@ impl NodeProvider { /// /// # Arguments /// * `version_req` - A semver range requirement (e.g., "^20.18.0") - /// * `cache_dir` - The cache directory path (e.g., `~/.cache/vite/js_runtime`) + /// * `cache_dir` - The cache directory path (e.g., `~/.cache/vite-plus/js_runtime`) /// /// # Returns /// The highest LTS cached version that satisfies the requirement, or the @@ -175,16 +175,20 @@ impl NodeProvider { /// Fetch the version index from nodejs.org/dist/index.json with HTTP caching. /// /// Uses ETag-based conditional requests to minimize bandwidth when cache expires. + /// If a network error occurs and a local cache exists (even if expired), returns + /// the cached version with a warning log instead of failing. /// /// # Errors /// - /// Returns an error if the download fails or the JSON is invalid. + /// Returns an error only if the download fails and no local cache exists. pub async fn fetch_version_index(&self) -> Result, Error> { let cache_dir = crate::cache::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 cached = load_cache(&cache_path).await; + + if let Some(ref cache) = cached { let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); // If cache is still fresh, use it @@ -193,13 +197,13 @@ impl NodeProvider { "Using cached version index (expires in {}s)", cache.expires_at - now ); - return Ok(cache.versions); + return Ok(cache.versions.clone()); } // 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 { + 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"); @@ -210,8 +214,18 @@ impl NodeProvider { } } - // Full fetch - self.fetch_and_cache(&cache_path).await + // Full fetch - if it fails and we have a cached version, use it + match self.fetch_and_cache(&cache_path).await { + Ok(versions) => Ok(versions), + Err(e) => { + if let Some(cache) = cached { + tracing::warn!("Failed to fetch version index: {e}, using expired cache"); + Ok(cache.versions) + } else { + Err(e) + } + } + } } /// Try conditional fetch with ETag, returns cached versions if 304 From a44015ca3ff5d23cae1aa5501864cc051e8d036c Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Jan 2026 11:14:14 +0800 Subject: [PATCH 06/10] fix(js_runtime): use warn level for conditional request failures --- crates/vite_js_runtime/src/providers/node.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index 1aa2e29c32..24618f6987 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -206,7 +206,7 @@ impl NodeProvider { 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"); + tracing::warn!("Conditional request failed: {e}, doing full fetch"); } } } else { From 4207fd00e09b41a989a531f7f850b10f5caf31af Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Jan 2026 11:17:39 +0800 Subject: [PATCH 07/10] fix(js_runtime): return cached version on ETag request failure instead of full fetch --- crates/vite_js_runtime/src/providers/node.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index 24618f6987..e34e6e6e6a 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -206,7 +206,9 @@ impl NodeProvider { match self.fetch_with_etag(etag, cache, &cache_path).await { Ok(versions) => return Ok(versions), Err(e) => { - tracing::warn!("Conditional request failed: {e}, doing full fetch"); + // Network error with ETag request - return cached version + tracing::warn!("Conditional request failed: {e}, using expired cache"); + return Ok(cache.versions.clone()); } } } else { From 1771e83c468f49868be992e002bfcbf274d42d26 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Jan 2026 11:19:19 +0800 Subject: [PATCH 08/10] refactor(js_runtime): avoid cloning cache.versions in fetch_version_index --- crates/vite_js_runtime/src/providers/node.rs | 57 +++++++++----------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/crates/vite_js_runtime/src/providers/node.rs b/crates/vite_js_runtime/src/providers/node.rs index e34e6e6e6a..21d65e4ce6 100644 --- a/crates/vite_js_runtime/src/providers/node.rs +++ b/crates/vite_js_runtime/src/providers/node.rs @@ -186,46 +186,39 @@ impl NodeProvider { let cache_path = cache_dir.join("node/index_cache.json"); // Try to load from cache - let cached = load_cache(&cache_path).await; - - if let Some(ref cache) = cached { - 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.clone()); - } + let Some(cache) = load_cache(&cache_path).await else { + // No cache - must fetch + return self.fetch_and_cache(&cache_path).await; + }; - // 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) => { - // Network error with ETag request - return cached version - tracing::warn!("Conditional request failed: {e}, using expired cache"); - return Ok(cache.versions.clone()); - } + 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(ref 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) => { + // Network error with ETag request - return cached version + tracing::warn!("Conditional request failed: {e}, using expired cache"); + return Ok(cache.versions); } - } else { - tracing::debug!("Cache expired, no ETag available for conditional request"); } } - // Full fetch - if it fails and we have a cached version, use it + // No ETag - try full fetch, fallback to cache + tracing::debug!("Cache expired, no ETag available for conditional request"); match self.fetch_and_cache(&cache_path).await { Ok(versions) => Ok(versions), Err(e) => { - if let Some(cache) = cached { - tracing::warn!("Failed to fetch version index: {e}, using expired cache"); - Ok(cache.versions) - } else { - Err(e) - } + tracing::warn!("Failed to fetch version index: {e}, using expired cache"); + Ok(cache.versions) } } } From a5932b9c3c806786d1dbb2640386e367a4a5e40f Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Jan 2026 11:41:08 +0800 Subject: [PATCH 09/10] feat(js_runtime): add progress bar for runtime downloads Add visual feedback when downloading JS runtimes using indicatif crate. Shows download progress with bytes, speed, and ETA in TTY environments. Automatically hidden in CI or when output is piped. --- Cargo.lock | 41 ++++++++++++++++- Cargo.toml | 1 + crates/vite_js_runtime/Cargo.toml | 1 + crates/vite_js_runtime/src/download.rs | 61 ++++++++++++++++++++++++-- 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 986a0aa5e3..832c081807 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -917,6 +917,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "const_format" version = "0.2.34" @@ -2450,6 +2463,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +dependencies = [ + "console 0.16.2", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", +] + [[package]] name = "indoc" version = "2.0.6" @@ -2500,7 +2526,7 @@ version = "1.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b66886d14d18d420ab5052cbff544fc5d34d0b2cdd35eb5976aaa10a4a472e5" dependencies = [ - "console", + "console 0.15.11", "once_cell", "similar", "tempfile", @@ -4339,6 +4365,12 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + [[package]] name = "postcard" version = "1.1.3" @@ -6760,6 +6792,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -7052,6 +7090,7 @@ dependencies = [ "flate2", "futures-util", "hex", + "indicatif", "node-semver", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 15acec7d01..228c2d2f5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ heck = "0.5.0" hex = "0.4.3" httpmock = "0.7" ignore = "0.4" +indicatif = "0.18" indexmap = "2.9.0" indoc = "2.0.5" infer = "0.19.0" diff --git a/crates/vite_js_runtime/Cargo.toml b/crates/vite_js_runtime/Cargo.toml index 1177b3064b..b3524d750e 100644 --- a/crates/vite_js_runtime/Cargo.toml +++ b/crates/vite_js_runtime/Cargo.toml @@ -12,6 +12,7 @@ async-trait = { workspace = true } backon = { workspace = true } flate2 = { workspace = true } futures-util = { workspace = true } +indicatif = { workspace = true } hex = { workspace = true } node-semver = { workspace = true } serde = { workspace = true } diff --git a/crates/vite_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs index 1b16d12610..a7f95319ef 100644 --- a/crates/vite_js_runtime/src/download.rs +++ b/crates/vite_js_runtime/src/download.rs @@ -3,10 +3,11 @@ //! This module provides platform-agnostic utilities for downloading, //! verifying, and extracting runtime archives. -use std::{fs::File, time::Duration}; +use std::{fs::File, io::IsTerminal, time::Duration}; use backon::{ExponentialBuilder, Retryable}; use futures_util::StreamExt; +use indicatif::{ProgressBar, ProgressStyle}; use sha2::{Digest, Sha256}; use tokio::{fs, io::AsyncWriteExt}; use vite_path::{AbsolutePath, AbsolutePathBuf}; @@ -27,7 +28,7 @@ pub struct CachedFetchResponse { pub not_modified: bool, } -/// Download a file with retry logic +/// Download a file with retry logic and progress bar pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), Error> { tracing::debug!("Downloading {url} to {target_path:?}"); @@ -41,16 +42,70 @@ pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), .await .map_err(|e| Error::DownloadFailed { url: url.into(), reason: vite_str::format!("{e}") })?; - // Stream to file + // Get Content-Length for progress bar + let total_size = response.content_length(); + + // Extract filename for display + let filename = target_path + .as_path() + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "file".to_string()); + + // Create progress bar (only in TTY and not in CI) + let is_ci = std::env::var("CI").is_ok(); + let progress = if std::io::stderr().is_terminal() && !is_ci { + let pb = match total_size { + Some(size) => { + let pb = ProgressBar::new(size); + pb.set_style( + ProgressStyle::default_bar() + .template( + "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \ + {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", + ) + .expect("valid progress bar template") + .progress_chars("#>-"), + ); + pb + } + None => { + let pb = ProgressBar::new_spinner(); + pb.set_style( + ProgressStyle::default_spinner() + .template( + "{spinner:.green} [{elapsed_precise}] {bytes} ({bytes_per_sec}) {msg}", + ) + .expect("valid spinner template"), + ); + pb.enable_steady_tick(Duration::from_millis(100)); + pb + } + }; + pb.set_message(format!("Downloading {filename}")); + Some(pb) + } else { + None + }; + + // Stream to file with progress updates 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?; + if let Some(ref pb) = progress { + pb.inc(chunk.len() as u64); + } file.write_all(&chunk).await?; } file.flush().await?; + + if let Some(pb) = progress { + pb.finish_with_message(format!("Downloaded {filename}")); + } + tracing::debug!("Download completed: {target_path:?}"); Ok(()) From 42c817f2fdeebfa1e49e121177c19ef614472526 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 30 Jan 2026 11:47:57 +0800 Subject: [PATCH 10/10] fix(js_runtime): show descriptive message in download progress bar Display what is being downloaded (e.g., "Downloading Node.js v22.13.1...") above the progress bar so users understand what the download is for. --- crates/vite_js_runtime/src/download.rs | 24 ++++++++++++------------ crates/vite_js_runtime/src/runtime.rs | 8 ++++---- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/vite_js_runtime/src/download.rs b/crates/vite_js_runtime/src/download.rs index a7f95319ef..42a4e9383d 100644 --- a/crates/vite_js_runtime/src/download.rs +++ b/crates/vite_js_runtime/src/download.rs @@ -29,7 +29,14 @@ pub struct CachedFetchResponse { } /// Download a file with retry logic and progress bar -pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), Error> { +/// +/// The `message` parameter is displayed to the user to indicate what is being downloaded +/// (e.g., "Downloading Node.js v22.13.1"). +pub async fn download_file( + url: &str, + target_path: &AbsolutePath, + message: &str, +) -> Result<(), Error> { tracing::debug!("Downloading {url} to {target_path:?}"); let response = (|| async { reqwest::get(url).await?.error_for_status() }) @@ -45,13 +52,6 @@ pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), // Get Content-Length for progress bar let total_size = response.content_length(); - // Extract filename for display - let filename = target_path - .as_path() - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| "file".to_string()); - // Create progress bar (only in TTY and not in CI) let is_ci = std::env::var("CI").is_ok(); let progress = if std::io::stderr().is_terminal() && !is_ci { @@ -61,7 +61,7 @@ pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), pb.set_style( ProgressStyle::default_bar() .template( - "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \ + "{msg}\n{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] \ {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", ) .expect("valid progress bar template") @@ -74,7 +74,7 @@ pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), pb.set_style( ProgressStyle::default_spinner() .template( - "{spinner:.green} [{elapsed_precise}] {bytes} ({bytes_per_sec}) {msg}", + "{msg}\n{spinner:.green} [{elapsed_precise}] {bytes} ({bytes_per_sec})", ) .expect("valid spinner template"), ); @@ -82,7 +82,7 @@ pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), pb } }; - pb.set_message(format!("Downloading {filename}")); + pb.set_message(message.to_string()); Some(pb) } else { None @@ -103,7 +103,7 @@ pub async fn download_file(url: &str, target_path: &AbsolutePath) -> Result<(), file.flush().await?; if let Some(pb) = progress { - pb.finish_with_message(format!("Downloaded {filename}")); + pb.finish_and_clear(); } tracing::debug!("Download completed: {target_path:?}"); diff --git a/crates/vite_js_runtime/src/runtime.rs b/crates/vite_js_runtime/src/runtime.rs index bdbabd550c..7e542b1d8c 100644 --- a/crates/vite_js_runtime/src/runtime.rs +++ b/crates/vite_js_runtime/src/runtime.rs @@ -111,7 +111,6 @@ pub async fn download_runtime_with_provider( let cache_dir = crate::cache::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); @@ -139,7 +138,8 @@ pub async fn download_runtime_with_provider( tokio::fs::remove_dir_all(&install_dir).await?; } - tracing::info!("Downloading {} {version} for {platform_str}...", provider.name()); + let download_message = format!("Downloading {} v{version}...", provider.name()); + tracing::info!("{download_message}"); // Get download info from provider let download_info = provider.get_download_info(version, platform); @@ -159,7 +159,7 @@ pub async fn download_runtime_with_provider( provider.parse_shasums(&shasums_content, &download_info.archive_filename)?; // Download archive - download_file(&download_info.archive_url, &archive_path).await?; + download_file(&download_info.archive_url, &archive_path, &download_message).await?; // Verify hash verify_file_hash(&archive_path, &expected_hash, &download_info.archive_filename) @@ -167,7 +167,7 @@ pub async fn download_runtime_with_provider( } HashVerification::None => { // Download archive without verification - download_file(&download_info.archive_url, &archive_path).await?; + download_file(&download_info.archive_url, &archive_path, &download_message).await?; } }