Skip to main content

osdk_core/backend/
node.rs

1//! Node.js backend: downloads official prebuilt archives (or from a mirror),
2//! verified against `SHASUMS256.txt`. This is the reference archive-based
3//! backend proving the whole M2 stack.
4
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7
8use async_trait::async_trait;
9use serde::Deserialize;
10
11use crate::backend::{Backend, Ctx, InstallCtx};
12use crate::error::Result;
13use crate::http;
14use crate::pipeline::{self, ArchiveKind, Checksum, HashAlgo, InstallPlan, PipelineCtx};
15use crate::platform::{Arch, Os};
16use crate::source::Source;
17use crate::version::{select_version, ToolRequest, ToolVersion, VersionInfo, VersionSpec};
18
19pub struct NodeBackend;
20
21#[derive(Debug, Deserialize)]
22struct NodeRelease {
23    version: String, // e.g. "v20.11.1"
24    #[serde(default)]
25    files: Vec<String>,
26    /// LTS is either `false` or a codename string.
27    #[serde(default)]
28    lts: LtsField,
29}
30
31#[derive(Debug, Default)]
32enum LtsField {
33    #[default]
34    No,
35    Named(String),
36}
37
38impl<'de> Deserialize<'de> for LtsField {
39    fn deserialize<D>(d: D) -> std::result::Result<Self, D::Error>
40    where
41        D: serde::Deserializer<'de>,
42    {
43        let v = serde_json::Value::deserialize(d)?;
44        match v {
45            serde_json::Value::String(s) => Ok(LtsField::Named(s)),
46            _ => Ok(LtsField::No),
47        }
48    }
49}
50
51impl NodeBackend {
52    /// The node file token for the current platform, e.g. `linux-x64`,
53    /// `osx-arm64-tar`, `win-x64-zip`.
54    fn target_arch(ctx: &Ctx, options: &BTreeMap<String, String>) -> Result<Arch> {
55        let Some(value) = options.get("arch") else {
56            return Ok(ctx.platform.arch);
57        };
58        Arch::parse_node(value).ok_or_else(|| {
59            crate::error::Error::config(format!(
60                "unsupported Node architecture `{value}` (expected x64|arm64|x86|arm)"
61            ))
62        })
63    }
64
65    fn file_token(ctx: &Ctx, arch: Arch) -> String {
66        let arch = arch.node_token();
67        match ctx.platform.os {
68            Os::Linux => format!("linux-{arch}"),
69            Os::Macos => format!("osx-{arch}-tar"),
70            Os::Windows => format!("win-{arch}-zip"),
71        }
72    }
73
74    /// The archive filename + kind for a version on the current platform.
75    fn archive_for(ctx: &Ctx, arch: Arch, version: &str) -> (String, ArchiveKind) {
76        let os = ctx.platform.os.node_token();
77        let arch = arch.node_token();
78        match ctx.platform.os {
79            Os::Windows => (format!("node-v{version}-{os}-{arch}.zip"), ArchiveKind::Zip),
80            _ => (
81                format!("node-v{version}-{os}-{arch}.tar.gz"),
82                ArchiveKind::TarGz,
83            ),
84        }
85    }
86
87    fn corepack_enabled(ctx: &Ctx, tv: &ToolVersion) -> Result<bool> {
88        match tv.options.get("corepack").map(String::as_str) {
89            Some("true" | "1" | "yes" | "on") => Ok(true),
90            Some("false" | "0" | "no" | "off") => Ok(false),
91            Some(value) => Err(crate::error::Error::config(format!(
92                "invalid Node corepack option `{value}` (expected true|false)"
93            ))),
94            None => Ok(ctx.config.settings.node.corepack),
95        }
96    }
97
98    fn enable_corepack(ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
99        if !Self::corepack_enabled(ctx, tv)? {
100            return Ok(());
101        }
102        let bin_dir = match ctx.platform.os {
103            Os::Windows => ctx.dirs.install_path("node", &tv.version),
104            _ => ctx.dirs.install_path("node", &tv.version).join("bin"),
105        };
106        let executable = match ctx.platform.os {
107            Os::Windows => bin_dir.join("corepack.cmd"),
108            _ => bin_dir.join("corepack"),
109        };
110        if !executable.is_file() {
111            return Err(crate::error::Error::other(format!(
112                "Node {} does not include Corepack at {}",
113                tv.version,
114                executable.display()
115            )));
116        }
117        let inherited = std::env::var_os("PATH").unwrap_or_default();
118        let mut paths = vec![bin_dir.clone()];
119        paths.extend(std::env::split_paths(&inherited));
120        let mut env = BTreeMap::new();
121        env.insert(
122            "PATH".into(),
123            std::env::join_paths(paths)
124                .map_err(|error| crate::error::Error::other(error.to_string()))?
125                .to_string_lossy()
126                .into_owned(),
127        );
128        let install_directory = bin_dir.display().to_string();
129        match ctx.platform.os {
130            Os::Windows => {
131                let script = executable.display().to_string();
132                crate::process::run(
133                    "cmd",
134                    &[
135                        "/D",
136                        "/S",
137                        "/C",
138                        &script,
139                        "enable",
140                        "--install-directory",
141                        &install_directory,
142                    ],
143                    &env,
144                    Some(&bin_dir),
145                )
146            }
147            _ => crate::process::run(
148                &executable.display().to_string(),
149                &["enable", "--install-directory", &install_directory],
150                &env,
151                Some(&bin_dir),
152            ),
153        }
154    }
155
156    fn complete_install(ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
157        if let Err(error) = Self::enable_corepack(ctx, tv) {
158            let install = ctx.dirs.install_path("node", &tv.version);
159            let _ = std::fs::remove_dir_all(install);
160            return Err(error);
161        }
162        Ok(())
163    }
164}
165
166#[async_trait]
167impl Backend for NodeBackend {
168    fn id(&self) -> &str {
169        "node"
170    }
171
172    fn aliases(&self) -> &[&str] {
173        &["nodejs"]
174    }
175
176    fn default_sources(&self) -> Vec<Source> {
177        vec![
178            Source::official("official", "https://nodejs.org/dist/")
179                .with_index("https://nodejs.org/dist/index.json"),
180            Source::mirror("npmmirror", "https://npmmirror.com/mirrors/node/", 10)
181                .with_index("https://npmmirror.com/mirrors/node/index.json"),
182            Source::mirror(
183                "tuna",
184                "https://mirrors.tuna.tsinghua.edu.cn/nodejs-release/",
185                20,
186            )
187            .with_index("https://mirrors.tuna.tsinghua.edu.cn/nodejs-release/index.json"),
188            Source::mirror("ustc", "https://mirrors.ustc.edu.cn/node/", 30)
189                .with_index("https://mirrors.ustc.edu.cn/node/index.json"),
190        ]
191    }
192
193    fn probe_url(&self, _ctx: &Ctx, source: &Source) -> Option<String> {
194        // index.json is a good representative object (~300KB).
195        source.index_url.clone()
196    }
197
198    async fn list_remote_versions(&self, ctx: &Ctx) -> Result<Vec<VersionInfo>> {
199        let sources = crate::source::select::ranked_source_list(ctx, self).await?;
200        // Only offer releases that ship an asset for the current platform.
201        let token = Self::file_token(ctx, ctx.platform.arch);
202
203        // Union versions across all reachable sources: mirrors can be stale and
204        // lag the official index, so merging avoids a fast-but-stale mirror
205        // hiding a version another source already has.
206        use std::collections::BTreeMap;
207        let mut merged: BTreeMap<String, Option<String>> = BTreeMap::new();
208        let mut any_ok = false;
209        let mut last_err: Option<crate::error::Error> = None;
210        for source in &sources {
211            let index_url = source
212                .index_url
213                .clone()
214                .unwrap_or_else(|| http::join_url(&source.download_url, "index.json"));
215            let releases: Vec<NodeRelease> = match http::get_cached_json(ctx, &index_url).await {
216                Ok(r) => r,
217                Err(e) => {
218                    tracing::warn!(source = %source.id, "{}", crate::i18n::trf("log.index_fetch_failover", &[("err", &e.to_string())]));
219                    last_err = Some(e);
220                    continue;
221                }
222            };
223            any_ok = true;
224            for r in releases {
225                if !(r.files.is_empty() || r.files.iter().any(|f| f == &token)) {
226                    continue;
227                }
228                let version = r.version.trim_start_matches('v').to_string();
229                if version.is_empty() {
230                    continue;
231                }
232                let lts = match r.lts {
233                    LtsField::Named(s) => Some(s.to_lowercase()),
234                    LtsField::No => None,
235                };
236                merged.entry(version).or_insert(lts);
237            }
238            // The fastest reachable source usually suffices; only consult more
239            // sources if it produced nothing. Stop once we have a populated set.
240            if !merged.is_empty() {
241                // Peek: does a later source add anything? We keep it cheap by
242                // continuing only when the primary is a known-laggy mirror is
243                // hard to detect, so we merge just the primary + official.
244                if source.kind == crate::source::SourceKind::Official {
245                    break;
246                }
247                // also fold in the official source (if present) for freshness
248                if let Some(official) = sources
249                    .iter()
250                    .find(|s| s.kind == crate::source::SourceKind::Official)
251                {
252                    if official.id != source.id {
253                        if let Some(idx) = &official.index_url {
254                            if let Ok(rel) =
255                                http::get_cached_json::<Vec<NodeRelease>>(ctx, idx).await
256                            {
257                                for r in rel {
258                                    if !(r.files.is_empty() || r.files.iter().any(|f| f == &token))
259                                    {
260                                        continue;
261                                    }
262                                    let v = r.version.trim_start_matches('v').to_string();
263                                    if v.is_empty() {
264                                        continue;
265                                    }
266                                    let lts = match r.lts {
267                                        LtsField::Named(s) => Some(s.to_lowercase()),
268                                        LtsField::No => None,
269                                    };
270                                    merged.entry(v).or_insert(lts);
271                                }
272                            }
273                        }
274                    }
275                }
276                break;
277            }
278        }
279
280        if !any_ok {
281            return Err(
282                last_err.unwrap_or_else(|| crate::error::Error::NoUsableSource {
283                    tool: self.id().to_string(),
284                    tried: sources.len(),
285                }),
286            );
287        }
288
289        // Sort ascending (oldest-first) by numeric components.
290        let mut out: Vec<VersionInfo> = merged
291            .into_iter()
292            .map(|(version, lts)| VersionInfo {
293                version,
294                stable: true,
295                lts,
296            })
297            .collect();
298        out.sort_by(|a, b| crate::backend::python::cmp_versions(&a.version, &b.version));
299        Ok(out)
300    }
301
302    async fn resolve_version(&self, ctx: &Ctx, req: &ToolRequest) -> Result<ToolVersion> {
303        let target_arch = Self::target_arch(ctx, &req.options)?;
304        let corepack = match req.options.get("corepack") {
305            Some(value) => match value.as_str() {
306                "true" | "1" | "yes" | "on" => true,
307                "false" | "0" | "no" | "off" => false,
308                _ => {
309                    return Err(crate::error::Error::config(format!(
310                        "invalid Node corepack option `{value}` (expected true|false)"
311                    )))
312                }
313            },
314            None => ctx.config.settings.node.corepack,
315        };
316        if let VersionSpec::Exact(version) = &req.spec {
317            let mut resolved = ToolVersion::new(self.id(), version);
318            resolved.options = req.options.clone();
319            resolved
320                .options
321                .insert("arch".into(), target_arch.node_token().into());
322            resolved
323                .options
324                .insert("corepack".into(), corepack.to_string());
325            return Ok(resolved);
326        }
327        let target_ctx = Ctx {
328            dirs: ctx.dirs.clone(),
329            platform: crate::platform::Platform {
330                arch: target_arch,
331                ..ctx.platform
332            },
333            config: ctx.config.clone(),
334            client: ctx.client.clone(),
335            cas: ctx.cas.clone(),
336            show_progress: ctx.show_progress,
337        };
338        let versions = self.list_remote_versions(&target_ctx).await?;
339        let chosen = select_version(&req.spec, &versions).ok_or_else(|| {
340            crate::error::Error::VersionResolve {
341                tool: self.id().to_string(),
342                spec: req.spec.to_string(),
343                hint: Some(format!(
344                    "no matching version ships a {} asset",
345                    target_arch.node_token()
346                )),
347            }
348        })?;
349        let mut resolved = ToolVersion::new(self.id(), &chosen.version);
350        resolved.options = req.options.clone();
351        resolved
352            .options
353            .insert("arch".into(), target_arch.node_token().into());
354        resolved
355            .options
356            .insert("corepack".into(), corepack.to_string());
357        Ok(resolved)
358    }
359
360    async fn install(&self, ictx: &InstallCtx<'_>, tv: &ToolVersion) -> Result<()> {
361        let ctx = ictx.ctx;
362        let target_arch = Self::target_arch(ctx, &tv.options)?;
363        if target_arch != ctx.platform.arch {
364            return Err(crate::error::Error::config(format!(
365                "cannot execute Node {} artifacts on host {}; cross-architecture download-only mode is not available",
366                target_arch.node_token(),
367                ctx.platform.arch.node_token()
368            )));
369        }
370        if let Some(plan) = pipeline::locked_install_plan(self.id(), tv, true)? {
371            let pctx = PipelineCtx {
372                client: &ctx.client,
373                dirs: &ctx.dirs,
374                cas: &ctx.cas,
375                link_mode: ctx.config.settings.link_mode,
376                show_progress: ctx.show_progress,
377                offline: ctx.config.settings.offline,
378                require_checksums: ctx.config.settings.require_checksums,
379            };
380            pipeline::run(&plan, &pctx).await?;
381            Self::complete_install(ctx, tv)?;
382            return Ok(());
383        }
384        let sources = crate::source::select::ranked_source_list(ctx, self).await?;
385        let version = &tv.version;
386        let (file_name, kind) = Self::archive_for(ctx, target_arch, version);
387
388        // Build a download URL from every candidate source (best-first) so the
389        // pipeline can fail over: <base>/v<version>/<file_name>.
390        let urls: Vec<String> = sources
391            .iter()
392            .map(|s| {
393                let base = http::join_url(&s.download_url, &format!("v{version}"));
394                http::join_url(&base, &file_name)
395            })
396            .collect();
397
398        // Fetch SHASUMS256.txt for verification from the best source (fall back
399        // through the others if needed).
400        let mut checksum = None;
401        for s in &sources {
402            let base = http::join_url(&s.download_url, &format!("v{version}"));
403            let shasums_url = http::join_url(&base, "SHASUMS256.txt");
404            if let Ok(body) = http::get_cached_text(ctx, &shasums_url).await {
405                if let Some(h) = pipeline::verify::find_shasum(&body, &file_name) {
406                    checksum = Some(Checksum {
407                        algo: HashAlgo::Sha256,
408                        hex: h.to_string(),
409                    });
410                    break;
411                }
412            }
413        }
414
415        let plan = InstallPlan {
416            tool: self.id().to_string(),
417            version: version.clone(),
418            urls,
419            file_name,
420            kind,
421            checksum,
422            strip_root: true,
423            subdir: None,
424        };
425        let pctx = PipelineCtx {
426            client: &ctx.client,
427            dirs: &ctx.dirs,
428            cas: &ctx.cas,
429            link_mode: ctx.config.settings.link_mode,
430            show_progress: ctx.show_progress,
431            offline: ctx.config.settings.offline,
432            require_checksums: ctx.config.settings.require_checksums,
433        };
434        pipeline::run(&plan, &pctx).await?;
435        Self::complete_install(ctx, tv)?;
436        Ok(())
437    }
438
439    fn ensure_post_install(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<()> {
440        Self::enable_corepack(ctx, tv)
441    }
442
443    fn bin_paths(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<PathBuf>> {
444        let root = ctx.dirs.install_path(self.id(), &tv.version);
445        // Unix: bin/. Windows: binaries sit at the archive root.
446        let dir = match ctx.platform.os {
447            Os::Windows => root,
448            _ => root.join("bin"),
449        };
450        Ok(vec![dir])
451    }
452
453    fn exec_env(&self, ctx: &Ctx, _tv: &ToolVersion) -> Result<BTreeMap<String, String>> {
454        Ok(crate::cache::manager_exec_env(
455            &ctx.dirs.cache,
456            &[("npm_config_cache", "npm")],
457        ))
458    }
459
460    fn bin_names(&self, ctx: &Ctx, tv: &ToolVersion) -> Result<Vec<String>> {
461        // npm/npx are managed by the independent npm backend; do not claim
462        // ownership here even when Node's archive includes bundled launchers.
463        let paths = self.bin_paths(ctx, tv)?;
464        let discovered = crate::backend::bin_names_in_dirs(&paths)
465            .into_iter()
466            .filter(|name| name != "npm" && name != "npx")
467            .collect::<Vec<_>>();
468        if discovered.is_empty() {
469            // Fallback to the canonical set if the dir isn't populated yet.
470            Ok(vec!["node".into(), "corepack".into()])
471        } else {
472            Ok(discovered)
473        }
474    }
475
476    fn idiomatic_files(&self) -> &[&str] {
477        &[".nvmrc", ".node-version"]
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use std::sync::Arc;
484
485    use super::*;
486    use crate::config::{Config, Settings, SourcesConfig};
487    use crate::dirs::Dirs;
488    use crate::platform::{Libc, Platform};
489    use crate::store::Cas;
490
491    fn test_ctx(root: &std::path::Path) -> Ctx {
492        let dirs = Dirs::resolve_from(|key| match key {
493            "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
494            "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
495            "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
496            "OSDK_STORE_DIR" => Some(root.join("store").display().to_string()),
497            "OSDK_INSTALL_DIR" => Some(root.join("installs").display().to_string()),
498            _ => None,
499        })
500        .unwrap();
501        dirs.ensure().unwrap();
502        Ctx {
503            cas: Arc::new(Cas::new(dirs.store.clone())),
504            dirs,
505            platform: Platform {
506                os: Os::Linux,
507                arch: Arch::X64,
508                libc: Libc::Glibc,
509            },
510            config: Config {
511                settings: Settings::default(),
512                sources: SourcesConfig::default(),
513                tools: Default::default(),
514                tool_configs: Default::default(),
515                global_tools: Default::default(),
516                global_tool_configs: Default::default(),
517                tool_origins: Default::default(),
518                aliases: Default::default(),
519                project_config_path: None,
520            },
521            client: reqwest::Client::new(),
522            show_progress: false,
523        }
524    }
525
526    #[tokio::test]
527    async fn resolution_records_effective_arch_and_corepack() {
528        let temp = tempfile::tempdir().unwrap();
529        let mut ctx = test_ctx(temp.path());
530        ctx.config.settings.node.corepack = true;
531        let request = ToolRequest::parse("node@20.11.1").unwrap();
532        let resolved = NodeBackend.resolve_version(&ctx, &request).await.unwrap();
533        assert_eq!(resolved.options["arch"], "x64");
534        assert_eq!(resolved.options["corepack"], "true");
535
536        let mut cross = ToolRequest::parse("node@20.11.1").unwrap();
537        cross.options.insert("arch".into(), "arm64".into());
538        let resolved = NodeBackend.resolve_version(&ctx, &cross).await.unwrap();
539        assert_eq!(resolved.options["arch"], "arm64");
540        let error = NodeBackend
541            .install(&InstallCtx { ctx: &ctx }, &resolved)
542            .await
543            .unwrap_err();
544        assert!(error.to_string().contains("cross-architecture"));
545    }
546
547    #[test]
548    fn bundled_npm_uses_the_shared_npm_cache() {
549        let temp = tempfile::tempdir().unwrap();
550        let ctx = test_ctx(temp.path());
551        let env = NodeBackend
552            .exec_env(&ctx, &ToolVersion::new("node", "20.11.1"))
553            .unwrap();
554        assert_eq!(
555            PathBuf::from(env.get("npm_config_cache").unwrap()),
556            ctx.dirs.cache.join("pkg/npm")
557        );
558    }
559
560    #[cfg(unix)]
561    #[test]
562    fn corepack_uses_managed_binary_and_failure_removes_install() {
563        use std::os::unix::fs::PermissionsExt;
564
565        let temp = tempfile::tempdir().unwrap();
566        let ctx = test_ctx(temp.path());
567        let mut version = ToolVersion::new("node", "20.11.1");
568        version.options.insert("corepack".into(), "true".into());
569        let install = ctx.dirs.install_path("node", &version.version);
570        let bin = install.join("bin");
571        std::fs::create_dir_all(&bin).unwrap();
572        let script = bin.join("corepack");
573        std::fs::write(
574            &script,
575            format!(
576                "#!/bin/sh\nprintf '%s|%s\\n' \"$PATH\" \"$*\" > '{}'\n",
577                temp.path().join("corepack.log").display()
578            ),
579        )
580        .unwrap();
581        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
582        NodeBackend::complete_install(&ctx, &version).unwrap();
583        let log = std::fs::read_to_string(temp.path().join("corepack.log")).unwrap();
584        assert!(log.starts_with(&bin.display().to_string()));
585        assert!(log.contains("enable --install-directory"));
586
587        std::fs::write(&script, "#!/bin/sh\nexit 7\n").unwrap();
588        let error = NodeBackend::complete_install(&ctx, &version).unwrap_err();
589        assert!(error.to_string().contains("failed"));
590        assert!(!install.exists());
591    }
592}