Skip to content

Commit 2e6555c

Browse files
committed
feat(migrate): upgrade below-range Node versions to the latest supported
A project pinning a Node version below Vite+'s supported range (package.json engines.node: ^20.19.0 || ^22.18.0 || >=24.11.0), e.g. .node-version 24.3.0, made engine-strict skip the native binding optional dependency ("Cannot find native binding"). During migrate, detect the pinned Node version (.node-version, .nvmrc, Volta, devEngines.runtime, engines.node) and rewrite an exact below-range version to the concrete latest release of that major (24.3.0 -> 24.18.0). Adds a resolveSupportedNodeVersion NAPI that reuses the vite_js_runtime resolver and verifies the result against the range; the range is sourced from package.json engines.node (no hardcoded range or per-major floors). The two volta/nvmrc snap fixtures move to supported versions to stay deterministic (the upgrade resolves over the network); the upgrade logic is covered by mocked unit tests. Claude-Session: https://claude.ai/code/session_01DQhS6o1fyQd1yjiee6W8jR
1 parent 823decd commit 2e6555c

17 files changed

Lines changed: 601 additions & 11 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_js_runtime/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ pub use platform::{Arch, Os, Platform};
6161
pub use provider::{
6262
ArchiveFormat, DownloadInfo, HashVerification, JsRuntimeProvider, ShasumsSignature,
6363
};
64-
pub use providers::{LtsInfo, NodeProvider, NodeVersionEntry};
64+
pub use providers::{LtsInfo, NodeProvider, NodeVersionEntry, resolve_version_from_list};
6565
pub use runtime::{
6666
JsRuntime, JsRuntimeType, VersionResolution, VersionSource, download_runtime,
6767
download_runtime_for_project, download_runtime_with_provider, is_valid_version,

crates/vite_js_runtime/src/providers/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@
55
66
mod node;
77

8-
pub use node::{LtsInfo, NodeProvider, NodeVersionEntry};
8+
pub use node::{LtsInfo, NodeProvider, NodeVersionEntry, resolve_version_from_list};

crates/vite_js_runtime/src/providers/node.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ fn find_absolute_latest_version(versions: &[NodeVersionEntry]) -> Result<Str, Er
497497
/// # Errors
498498
///
499499
/// Returns an error if no matching version is found or if the version requirement is invalid.
500-
fn resolve_version_from_list(
500+
pub fn resolve_version_from_list(
501501
version_req: &str,
502502
versions: &[NodeVersionEntry],
503503
) -> Result<Str, Error> {

packages/cli/binding/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ async-trait = { workspace = true }
1616
clap = { workspace = true, features = ["derive"] }
1717
cow-utils = { workspace = true }
1818
fspy = { workspace = true }
19+
node-semver = { workspace = true }
1920
rustc-hash = { workspace = true }
2021
napi = { workspace = true }
2122
napi-derive = { workspace = true }
@@ -28,6 +29,7 @@ tracing = { workspace = true }
2829
vite_command = { workspace = true }
2930
vite_error = { workspace = true }
3031
vite_install = { workspace = true }
32+
vite_js_runtime = { workspace = true }
3133
vite_migration = { workspace = true }
3234
vite_pm_cli = { workspace = true }
3335
vite_path = { workspace = true }

packages/cli/binding/index.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,7 @@ module.exports.downloadPackageManager = nativeBinding.downloadPackageManager;
853853
module.exports.hasConfigKey = nativeBinding.hasConfigKey;
854854
module.exports.mergeJsonConfig = nativeBinding.mergeJsonConfig;
855855
module.exports.mergeTsdownConfig = nativeBinding.mergeTsdownConfig;
856+
module.exports.resolveSupportedNodeVersion = nativeBinding.resolveSupportedNodeVersion;
856857
module.exports.rewriteEslint = nativeBinding.rewriteEslint;
857858
module.exports.rewriteImportsInDirectory = nativeBinding.rewriteImportsInDirectory;
858859
module.exports.rewritePrettier = nativeBinding.rewritePrettier;

packages/cli/binding/index.d.cts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3496,6 +3496,42 @@ export interface PathAccess {
34963496
readDir: boolean;
34973497
}
34983498

3499+
/**
3500+
* Resolve a Node.js version that is below Vite+'s supported range to the
3501+
* concrete latest release of the same major.
3502+
*
3503+
* Engine-strict installers skip the native optional dependency under an
3504+
* unsupported Node.js version (causing "Cannot find native binding"), so
3505+
* `vp migrate` uses this to bump a too-old pin up to a supported release of the
3506+
* same major line.
3507+
*
3508+
* # Arguments
3509+
*
3510+
* * `current` - The pinned Node.js version (e.g. `24.3.0`, optionally `v`-prefixed)
3511+
* * `supported_range` - The Vite+-supported Node.js range, sourced from the
3512+
* `engines.node` field in `package.json` (e.g.
3513+
* `^20.19.0 || ^22.18.0 || >=24.11.0`). This is the only source of truth for
3514+
* what is supported.
3515+
*
3516+
* # Returns
3517+
*
3518+
* * `Some(latest)` - The concrete latest supported release of `current`'s major
3519+
* (e.g. `24.18.0`) when `current` is an exact, below-range, supported-major version
3520+
* * `None` - When `current` is already supported, is not an exact version, or
3521+
* belongs to an unsupported major (e.g. 21, 23)
3522+
*
3523+
* # Example
3524+
*
3525+
* ```javascript
3526+
* const upgraded = await resolveSupportedNodeVersion('24.3.0', '^20.19.0 || ^22.18.0 || >=24.11.0');
3527+
* // upgraded === '24.18.0' (latest 24.x at the time of resolution)
3528+
* ```
3529+
*/
3530+
export declare function resolveSupportedNodeVersion(
3531+
current: string,
3532+
supportedRange: string,
3533+
): Promise<string | null>;
3534+
34993535
/**
35003536
* Rewrite ESLint scripts: rename `eslint` → `vp lint` and strip ESLint-only flags.
35013537
*

packages/cli/binding/src/migration.rs

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,110 @@ use std::path::Path;
22

33
use napi::{anyhow, bindgen_prelude::*};
44
use napi_derive::napi;
5+
use node_semver::{Range, Version};
6+
use vite_js_runtime::NodeProvider;
7+
8+
/// Compute the semver requirement that selects the latest release of the same
9+
/// major as `current`.
10+
///
11+
/// `supported_range` is the Vite+-supported Node.js range, sourced from the
12+
/// `engines.node` field in `package.json` (e.g.
13+
/// `^20.19.0 || ^22.18.0 || >=24.11.0`). It is the only source of truth: there
14+
/// are no hardcoded per-major floors here.
15+
///
16+
/// Returns `None` when:
17+
/// - `current` is not an exact semver version (a leading `v` is tolerated), or
18+
/// - `current` already satisfies `supported_range` (no upgrade needed).
19+
///
20+
/// Otherwise returns a constrained range like `>=24.0.0 <25.0.0` that, when
21+
/// resolved against the Node.js release index, yields the latest release of that
22+
/// major. The resolved version is verified against `supported_range` separately,
23+
/// which is what rejects unsupported majors (e.g. 21 or 23).
24+
fn supported_node_requirement(current: &str, supported_range: &Range) -> Option<String> {
25+
let normalized = current.strip_prefix('v').unwrap_or(current);
26+
27+
// Exact versions only: a range/alias/partial version is left untouched.
28+
let version = Version::parse(normalized).ok()?;
29+
30+
// Already supported — nothing to upgrade (and never hits the network).
31+
if supported_range.satisfies(&version) {
32+
return None;
33+
}
34+
35+
let major = version.major;
36+
Some(format!(">={major}.0.0 <{}.0.0", major + 1))
37+
}
38+
39+
/// Resolve the latest supported Node.js release matching `current`'s major from
40+
/// an explicit version list, verifying the result against `supported_range`.
41+
/// Shared by the NAPI entry point and unit tests.
42+
#[cfg(test)]
43+
fn resolve_supported_node_version_from_list(
44+
current: &str,
45+
supported_range: &str,
46+
versions: &[vite_js_runtime::NodeVersionEntry],
47+
) -> Option<String> {
48+
let supported = Range::parse(supported_range).ok()?;
49+
let requirement = supported_node_requirement(current, &supported)?;
50+
let resolved = vite_js_runtime::resolve_version_from_list(&requirement, versions).ok()?.to_string();
51+
// Verify the resolved version actually satisfies the supported range. An
52+
// unsupported major (e.g. 21 or 23) resolves to a concrete release but must
53+
// not be returned.
54+
Version::parse(resolved.as_str()).ok().filter(|v| supported.satisfies(v)).map(|_| resolved)
55+
}
56+
57+
/// Resolve a Node.js version that is below Vite+'s supported range to the
58+
/// concrete latest release of the same major.
59+
///
60+
/// Engine-strict installers skip the native optional dependency under an
61+
/// unsupported Node.js version (causing "Cannot find native binding"), so
62+
/// `vp migrate` uses this to bump a too-old pin up to a supported release of the
63+
/// same major line.
64+
///
65+
/// # Arguments
66+
///
67+
/// * `current` - The pinned Node.js version (e.g. `24.3.0`, optionally `v`-prefixed)
68+
/// * `supported_range` - The Vite+-supported Node.js range, sourced from the
69+
/// `engines.node` field in `package.json` (e.g.
70+
/// `^20.19.0 || ^22.18.0 || >=24.11.0`). This is the only source of truth for
71+
/// what is supported.
72+
///
73+
/// # Returns
74+
///
75+
/// * `Some(latest)` - The concrete latest supported release of `current`'s major
76+
/// (e.g. `24.18.0`) when `current` is an exact, below-range, supported-major version
77+
/// * `None` - When `current` is already supported, is not an exact version, or
78+
/// belongs to an unsupported major (e.g. 21, 23)
79+
///
80+
/// # Example
81+
///
82+
/// ```javascript
83+
/// const upgraded = await resolveSupportedNodeVersion('24.3.0', '^20.19.0 || ^22.18.0 || >=24.11.0');
84+
/// // upgraded === '24.18.0' (latest 24.x at the time of resolution)
85+
/// ```
86+
#[napi]
87+
pub async fn resolve_supported_node_version(
88+
current: String,
89+
supported_range: String,
90+
) -> Result<Option<String>> {
91+
let Ok(supported) = Range::parse(&supported_range) else {
92+
return Ok(None);
93+
};
94+
let Some(requirement) = supported_node_requirement(&current, &supported) else {
95+
return Ok(None);
96+
};
97+
98+
let provider = NodeProvider::new();
99+
let latest = provider.resolve_version(&requirement).await.map_err(anyhow::Error::from)?;
100+
let latest = latest.to_string();
101+
102+
// Verify the resolved version is actually supported. An unsupported major
103+
// (e.g. 21 or 23) resolves to a concrete release but must not be returned.
104+
match Version::parse(latest.as_str()) {
105+
Ok(version) if supported.satisfies(&version) => Ok(Some(latest)),
106+
_ => Ok(None),
107+
}
108+
}
5109

6110
/// Rewrite scripts json content using rules from rules_yaml
7111
///
@@ -320,3 +424,145 @@ pub fn rewrite_imports_in_directory(
320424
.collect(),
321425
})
322426
}
427+
428+
#[cfg(test)]
429+
mod tests {
430+
use vite_js_runtime::{LtsInfo, NodeVersionEntry};
431+
432+
use super::*;
433+
434+
/// The Vite+-supported Node.js range used as test input. Mirrors the
435+
/// `engines.node` field shipped in `packages/cli/package.json`.
436+
const SUPPORTED_RANGE: &str = "^20.19.0 || ^22.18.0 || >=24.11.0";
437+
438+
/// A mock Node.js release index spanning several majors, mirroring the
439+
/// shape used in `vite_js_runtime`'s own `resolve_version_from_list` tests.
440+
fn mock_versions() -> Vec<NodeVersionEntry> {
441+
vec![
442+
NodeVersionEntry { version: "v24.18.0".into(), lts: LtsInfo::Codename("Krypton".into()) },
443+
NodeVersionEntry { version: "v24.11.0".into(), lts: LtsInfo::Codename("Krypton".into()) },
444+
NodeVersionEntry { version: "v24.3.0".into(), lts: LtsInfo::Boolean(false) },
445+
NodeVersionEntry { version: "v23.5.0".into(), lts: LtsInfo::Boolean(false) },
446+
NodeVersionEntry { version: "v22.18.0".into(), lts: LtsInfo::Codename("Jod".into()) },
447+
NodeVersionEntry { version: "v22.10.0".into(), lts: LtsInfo::Codename("Jod".into()) },
448+
NodeVersionEntry { version: "v21.5.0".into(), lts: LtsInfo::Boolean(false) },
449+
NodeVersionEntry { version: "v20.19.0".into(), lts: LtsInfo::Codename("Iron".into()) },
450+
NodeVersionEntry { version: "v20.11.0".into(), lts: LtsInfo::Codename("Iron".into()) },
451+
]
452+
}
453+
454+
#[test]
455+
fn upgrades_below_range_major_24() {
456+
// 24.3.0 is below the 24.11.0 floor → latest 24.x (24.18.0).
457+
let result =
458+
resolve_supported_node_version_from_list("24.3.0", SUPPORTED_RANGE, &mock_versions());
459+
assert_eq!(result.as_deref(), Some("24.18.0"));
460+
}
461+
462+
#[test]
463+
fn leaves_supported_major_24_unchanged() {
464+
// 24.11.0 already satisfies `>=24.11.0`.
465+
let result =
466+
resolve_supported_node_version_from_list("24.11.0", SUPPORTED_RANGE, &mock_versions());
467+
assert_eq!(result, None);
468+
}
469+
470+
#[test]
471+
fn leaves_supported_major_22_unchanged() {
472+
// 22.18.0 already satisfies `^22.18.0`.
473+
let result =
474+
resolve_supported_node_version_from_list("22.18.0", SUPPORTED_RANGE, &mock_versions());
475+
assert_eq!(result, None);
476+
}
477+
478+
#[test]
479+
fn upgrades_below_range_major_20() {
480+
// 20.10.0 is below the 20.19.0 floor → latest 20.x (20.19.0).
481+
let result =
482+
resolve_supported_node_version_from_list("20.10.0", SUPPORTED_RANGE, &mock_versions());
483+
assert_eq!(result.as_deref(), Some("20.19.0"));
484+
}
485+
486+
#[test]
487+
fn skips_unsupported_major_21() {
488+
// Major 21 is not part of the supported range; the resolved release
489+
// fails the verify-against-range step, so it is never upgraded.
490+
let result =
491+
resolve_supported_node_version_from_list("21.5.0", SUPPORTED_RANGE, &mock_versions());
492+
assert_eq!(result, None);
493+
}
494+
495+
#[test]
496+
fn skips_unsupported_major_23() {
497+
// Major 23 is not part of the supported range; the resolved release
498+
// fails the verify-against-range step, so it is never upgraded.
499+
let result =
500+
resolve_supported_node_version_from_list("23.5.0", SUPPORTED_RANGE, &mock_versions());
501+
assert_eq!(result, None);
502+
}
503+
504+
#[test]
505+
fn skips_non_semver_input() {
506+
assert_eq!(
507+
resolve_supported_node_version_from_list("lts/*", SUPPORTED_RANGE, &mock_versions()),
508+
None
509+
);
510+
assert_eq!(
511+
resolve_supported_node_version_from_list("^24.3.0", SUPPORTED_RANGE, &mock_versions()),
512+
None
513+
);
514+
assert_eq!(
515+
resolve_supported_node_version_from_list(
516+
"not-a-version",
517+
SUPPORTED_RANGE,
518+
&mock_versions()
519+
),
520+
None
521+
);
522+
assert_eq!(
523+
resolve_supported_node_version_from_list("", SUPPORTED_RANGE, &mock_versions()),
524+
None
525+
);
526+
}
527+
528+
#[test]
529+
fn tolerates_leading_v_prefix() {
530+
// A `v`-prefixed exact version is normalized before resolving.
531+
let result =
532+
resolve_supported_node_version_from_list("v24.3.0", SUPPORTED_RANGE, &mock_versions());
533+
assert_eq!(result.as_deref(), Some("24.18.0"));
534+
}
535+
536+
#[test]
537+
fn requirement_targets_same_major_bracket() {
538+
let range = Range::parse(SUPPORTED_RANGE).unwrap();
539+
// The requirement brackets the whole major; verification against the
540+
// range happens after resolution, not here.
541+
assert_eq!(
542+
supported_node_requirement("24.3.0", &range).as_deref(),
543+
Some(">=24.0.0 <25.0.0")
544+
);
545+
assert_eq!(
546+
supported_node_requirement("20.10.0", &range).as_deref(),
547+
Some(">=20.0.0 <21.0.0")
548+
);
549+
assert_eq!(
550+
supported_node_requirement("22.5.0", &range).as_deref(),
551+
Some(">=22.0.0 <23.0.0")
552+
);
553+
// Unsupported majors still produce a major bracket; only the later
554+
// verify-against-range step rejects them.
555+
assert_eq!(
556+
supported_node_requirement("21.5.0", &range).as_deref(),
557+
Some(">=21.0.0 <22.0.0")
558+
);
559+
assert_eq!(
560+
supported_node_requirement("23.5.0", &range).as_deref(),
561+
Some(">=23.0.0 <24.0.0")
562+
);
563+
// Majors above 24 already satisfy `>=24.11.0`, so they are reported as
564+
// supported (no upgrade) before a requirement is computed.
565+
assert_eq!(supported_node_requirement("26.0.0", &range), None);
566+
assert_eq!(supported_node_requirement("25.0.0", &range), None);
567+
}
568+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v20.5.0
1+
v20.19.0

packages/cli/snap-tests-global/migration-volta-with-nvmrc/snap.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
→ Manual follow-up:
77
- Remove the "volta" field from package.json
88

9-
> cat .node-version # check .node-version comes from .nvmrc (v20.5.0), not volta.node (18.0.0)
10-
20.5.0
9+
> cat .node-version # check .node-version comes from .nvmrc (v20.19.0), not volta.node (18.0.0)
10+
20.19.0
1111

1212
> test ! -f .nvmrc # check .nvmrc is removed
1313
> grep '"volta"' package.json # volta field must remain intact

0 commit comments

Comments
 (0)