Skip to main content

deno_npm_cache/
lib.rs

1// Copyright 2018-2026 the Deno authors. MIT license.
2
3use std::collections::HashSet;
4use std::io::ErrorKind;
5use std::path::Path;
6use std::path::PathBuf;
7use std::sync::Arc;
8
9use deno_cache_dir::file_fetcher::CacheSetting;
10use deno_cache_dir::npm::NpmCacheDir;
11use deno_error::JsErrorBox;
12use deno_npm::NpmPackageCacheFolderId;
13use deno_npmrc::RegistryConfig;
14use deno_npmrc::ResolvedNpmRc;
15use deno_path_util::fs::atomic_write_file_with_retries;
16use deno_semver::StackString;
17use deno_semver::Version;
18use deno_semver::package::PackageNv;
19use parking_lot::Mutex;
20use sys_traits::FsCanonicalize;
21use sys_traits::FsCreateDirAll;
22use sys_traits::FsHardLink;
23use sys_traits::FsMetadata;
24use sys_traits::FsOpen;
25use sys_traits::FsRead;
26use sys_traits::FsReadDir;
27use sys_traits::FsRemoveDirAll;
28use sys_traits::FsRemoveFile;
29use sys_traits::FsRename;
30use sys_traits::SystemRandom;
31use sys_traits::ThreadSleep;
32use url::Url;
33
34mod fs_util;
35mod registry_info;
36mod remote;
37mod rt;
38mod tarball;
39mod tarball_extract;
40
41pub use fs_util::hard_link_dir_recursive;
42pub use fs_util::hard_link_file;
43pub use fs_util::is_etxtbsy;
44pub use registry_info::RegistryInfoProvider;
45pub use registry_info::SerializedCachedPackageInfo;
46pub use registry_info::get_package_url;
47pub use remote::maybe_auth_header_value_for_npm_registry;
48pub use tarball::EnsurePackageError;
49pub use tarball::TarballCache;
50pub use tarball::TarballCacheReporter;
51
52use self::rt::spawn_blocking;
53
54#[derive(Debug, deno_error::JsError)]
55#[class(generic)]
56pub struct DownloadError {
57  pub status_code: Option<u16>,
58  pub error: JsErrorBox,
59}
60
61impl std::error::Error for DownloadError {
62  fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
63    self.error.source()
64  }
65}
66
67impl std::fmt::Display for DownloadError {
68  fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
69    self.error.fmt(f)
70  }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum NpmPackumentFormat {
75  /// Request the abbreviated install manifest (smaller, but omits `time` and `scripts`).
76  Abbreviated,
77  /// Request the full packument (needed when `minimumDependencyAge` is configured).
78  Full,
79}
80
81pub enum NpmCacheHttpClientResponse {
82  NotFound,
83  NotModified,
84  Bytes(NpmCacheHttpClientBytesResponse),
85}
86
87pub struct NpmCacheHttpClientBytesResponse {
88  pub bytes: Vec<u8>,
89  pub etag: Option<String>,
90}
91
92#[async_trait::async_trait(?Send)]
93pub trait NpmCacheHttpClient: std::fmt::Debug + Send + Sync + 'static {
94  async fn download_with_retries_on_any_tokio_runtime(
95    &self,
96    url: Url,
97    maybe_auth: Option<String>,
98    maybe_etag: Option<String>,
99    maybe_registry_config: Option<&RegistryConfig>,
100  ) -> Result<NpmCacheHttpClientResponse, DownloadError>;
101}
102
103/// Indicates how cached source files should be handled.
104#[derive(Debug, Clone, Eq, PartialEq)]
105pub enum NpmCacheSetting {
106  /// Only the cached files should be used. Any files not in the cache will
107  /// error. This is the equivalent of `--cached-only` in the CLI.
108  Only,
109  /// No cached source files should be used, and all files should be reloaded.
110  /// This is the equivalent of `--reload` in the CLI.
111  ReloadAll,
112  /// Only some cached resources should be used. This is the equivalent of
113  /// `--reload=npm:chalk`
114  ReloadSome { npm_package_names: Vec<String> },
115  /// The cached source files should be used for local modules. This is the
116  /// default behavior of the CLI.
117  Use,
118}
119
120impl NpmCacheSetting {
121  pub fn from_cache_setting(cache_setting: &CacheSetting) -> NpmCacheSetting {
122    match cache_setting {
123      CacheSetting::Only => NpmCacheSetting::Only,
124      CacheSetting::ReloadAll => NpmCacheSetting::ReloadAll,
125      CacheSetting::ReloadSome(values) => {
126        if values.iter().any(|v| v == "npm:") {
127          NpmCacheSetting::ReloadAll
128        } else {
129          NpmCacheSetting::ReloadSome {
130            npm_package_names: values
131              .iter()
132              .filter_map(|v| v.strip_prefix("npm:"))
133              .map(|n| n.to_string())
134              .collect(),
135          }
136        }
137      }
138      CacheSetting::RespectHeaders => panic!("not supported"),
139      CacheSetting::Use => NpmCacheSetting::Use,
140    }
141  }
142  pub fn should_use_for_npm_package(&self, package_name: &str) -> bool {
143    match self {
144      NpmCacheSetting::ReloadAll => false,
145      NpmCacheSetting::ReloadSome { npm_package_names } => {
146        !npm_package_names.iter().any(|n| n == package_name)
147      }
148      _ => true,
149    }
150  }
151}
152
153#[sys_traits::auto_impl]
154pub trait NpmCacheSys:
155  FsCanonicalize
156  + FsCreateDirAll
157  + FsHardLink
158  + FsMetadata
159  + FsOpen
160  + FsRead
161  + FsReadDir
162  + FsRemoveDirAll
163  + FsRemoveFile
164  + FsRename
165  + ThreadSleep
166  + SystemRandom
167  + Send
168  + Sync
169  + Clone
170  + std::fmt::Debug
171  + 'static
172{
173}
174
175/// Stores a single copy of npm packages in a cache.
176#[derive(Debug)]
177pub struct NpmCache<TSys: NpmCacheSys> {
178  cache_dir: Arc<NpmCacheDir>,
179  sys: TSys,
180  cache_setting: NpmCacheSetting,
181  npmrc: Arc<ResolvedNpmRc>,
182  previously_reloaded_packages: Mutex<HashSet<PackageNv>>,
183}
184
185impl<TSys: NpmCacheSys> NpmCache<TSys> {
186  pub fn new(
187    cache_dir: Arc<NpmCacheDir>,
188    sys: TSys,
189    cache_setting: NpmCacheSetting,
190    npmrc: Arc<ResolvedNpmRc>,
191  ) -> Self {
192    Self {
193      cache_dir,
194      sys,
195      cache_setting,
196      npmrc,
197      previously_reloaded_packages: Default::default(),
198    }
199  }
200
201  pub fn cache_setting(&self) -> &NpmCacheSetting {
202    &self.cache_setting
203  }
204
205  pub fn root_dir_path(&self) -> &Path {
206    self.cache_dir.root_dir()
207  }
208
209  pub fn root_dir_url(&self) -> &Url {
210    self.cache_dir.root_dir_url()
211  }
212
213  /// Checks if the cache should be used for the provided name and version.
214  /// NOTE: Subsequent calls for the same package will always return `true`
215  /// to ensure a package is only downloaded once per run of the CLI. This
216  /// prevents downloads from re-occurring when someone has `--reload` and
217  /// and imports a dynamic import that imports the same package again for example.
218  pub fn should_use_cache_for_package(&self, package: &PackageNv) -> bool {
219    self.cache_setting.should_use_for_npm_package(&package.name)
220      || !self
221        .previously_reloaded_packages
222        .lock()
223        .insert(package.clone())
224  }
225
226  /// Ensures a copy of the package exists in the global cache.
227  ///
228  /// This assumes that the original package folder being hard linked
229  /// from exists before this is called.
230  pub fn ensure_copy_package(
231    &self,
232    folder_id: &NpmPackageCacheFolderId,
233  ) -> Result<(), WithFolderSyncLockError> {
234    let registry_url = self.npmrc.get_registry_url(&folder_id.nv.name);
235    assert_ne!(folder_id.copy_index, 0);
236    let package_folder = self.cache_dir.package_folder_for_id(
237      &folder_id.nv.name,
238      &folder_id.nv.version.to_string(),
239      folder_id.copy_index,
240      registry_url,
241    );
242
243    if self.sys.fs_exists_no_err(&package_folder)
244      // if this file exists, then the package didn't successfully initialize
245      // the first time, or another process is currently extracting the zip file
246      && !self.sys.fs_exists_no_err(package_folder.join(NPM_PACKAGE_SYNC_LOCK_FILENAME))
247      && self.cache_setting.should_use_for_npm_package(&folder_id.nv.name)
248    {
249      return Ok(());
250    }
251
252    let original_package_folder = self.cache_dir.package_folder_for_id(
253      &folder_id.nv.name,
254      &folder_id.nv.version.to_string(),
255      0, // original copy index
256      registry_url,
257    );
258
259    // it seems Windows does an "AccessDenied" error when moving a
260    // directory with hard links, so that's why this solution is done
261    with_folder_sync_lock(&self.sys, &folder_id.nv, &package_folder, || {
262      hard_link_dir_recursive(
263        &self.sys,
264        &original_package_folder,
265        &package_folder,
266      )
267      .map_err(JsErrorBox::from_err)
268    })?;
269    Ok(())
270  }
271
272  pub fn package_folder_for_id(&self, id: &NpmPackageCacheFolderId) -> PathBuf {
273    let registry_url = self.npmrc.get_registry_url(&id.nv.name);
274    self.cache_dir.package_folder_for_id(
275      &id.nv.name,
276      &id.nv.version.to_string(),
277      id.copy_index,
278      registry_url,
279    )
280  }
281
282  pub fn package_folder_for_nv(&self, package: &PackageNv) -> PathBuf {
283    let registry_url = self.npmrc.get_registry_url(&package.name);
284    self.package_folder_for_nv_and_url(package, registry_url)
285  }
286
287  pub fn package_folder_for_nv_and_url(
288    &self,
289    package: &PackageNv,
290    registry_url: &Url,
291  ) -> PathBuf {
292    self.cache_dir.package_folder_for_id(
293      &package.name,
294      &package.version.to_string(),
295      0, // original copy_index
296      registry_url,
297    )
298  }
299
300  pub fn package_name_folder(&self, name: &str) -> PathBuf {
301    let registry_url = self.npmrc.get_registry_url(name);
302    self.cache_dir.package_name_folder(name, registry_url)
303  }
304
305  pub fn resolve_package_folder_id_from_specifier(
306    &self,
307    specifier: &Url,
308  ) -> Option<NpmPackageCacheFolderId> {
309    self
310      .cache_dir
311      .resolve_package_folder_id_from_specifier(specifier)
312      .and_then(|cache_id| {
313        Some(NpmPackageCacheFolderId {
314          nv: PackageNv {
315            name: StackString::from_string(cache_id.name),
316            version: Version::parse_from_npm(&cache_id.version).ok()?,
317          },
318          copy_index: cache_id.copy_index,
319        })
320      })
321  }
322
323  pub async fn load_package_info(
324    &self,
325    name: &str,
326  ) -> Result<Option<SerializedCachedPackageInfo>, serde_json::Error> {
327    let file_cache_path = self.get_registry_package_info_file_cache_path(name);
328
329    let file_bytes = match self.sys.fs_read(&file_cache_path) {
330      Ok(file_text) => file_text,
331      Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
332      Err(err) => return Err(serde_json::Error::io(err)),
333    };
334
335    spawn_blocking(move || serde_json::from_slice(&file_bytes))
336      .await
337      .unwrap()
338  }
339
340  pub fn save_package_info(
341    &self,
342    name: &str,
343    package_info: &SerializedCachedPackageInfo,
344  ) -> Result<(), JsErrorBox> {
345    let file_cache_path = self.get_registry_package_info_file_cache_path(name);
346    let file_text =
347      serde_json::to_string(&package_info).map_err(JsErrorBox::from_err)?;
348    atomic_write_file_with_retries(
349      &self.sys,
350      &file_cache_path,
351      file_text.as_bytes(),
352      0o644,
353    )
354    .map_err(JsErrorBox::from_err)?;
355    Ok(())
356  }
357
358  fn get_registry_package_info_file_cache_path(&self, name: &str) -> PathBuf {
359    let name_folder_path = self.package_name_folder(name);
360    name_folder_path.join("registry.json")
361  }
362}
363
364const NPM_PACKAGE_SYNC_LOCK_FILENAME: &str = ".deno_sync_lock";
365
366#[derive(Debug, thiserror::Error, deno_error::JsError)]
367pub enum WithFolderSyncLockError {
368  #[class(inherit)]
369  #[error("Error creating '{path}'")]
370  CreateDir {
371    path: PathBuf,
372    #[source]
373    #[inherit]
374    source: std::io::Error,
375  },
376  #[class(inherit)]
377  #[error(
378    "Error creating package sync lock file at '{path}'. Maybe try manually deleting this folder."
379  )]
380  CreateLockFile {
381    path: PathBuf,
382    #[source]
383    #[inherit]
384    source: std::io::Error,
385  },
386  #[class(inherit)]
387  #[error(transparent)]
388  Action(#[from] JsErrorBox),
389  #[class(generic)]
390  #[error(
391    "Failed setting up package cache directory for {package}, then failed cleaning it up.\n\nOriginal error:\n\n{error}\n\nRemove error:\n\n{remove_error}\n\nPlease manually delete this folder or you will run into issues using this package in the future:\n\n{output_folder}"
392  )]
393  SetUpPackageCacheDir {
394    package: Box<PackageNv>,
395    error: Box<WithFolderSyncLockError>,
396    remove_error: std::io::Error,
397    output_folder: PathBuf,
398  },
399}
400
401fn with_folder_sync_lock(
402  sys: &(impl FsCreateDirAll + FsOpen + FsRemoveDirAll + FsRemoveFile),
403  package: &PackageNv,
404  output_folder: &Path,
405  action: impl FnOnce() -> Result<(), JsErrorBox>,
406) -> Result<(), WithFolderSyncLockError> {
407  fn inner(
408    sys: &(impl FsCreateDirAll + FsOpen + FsRemoveFile),
409    output_folder: &Path,
410    action: impl FnOnce() -> Result<(), JsErrorBox>,
411  ) -> Result<(), WithFolderSyncLockError> {
412    sys.fs_create_dir_all(output_folder).map_err(|source| {
413      WithFolderSyncLockError::CreateDir {
414        path: output_folder.to_path_buf(),
415        source,
416      }
417    })?;
418
419    // This sync lock file is a way to ensure that partially created
420    // npm package directories aren't considered valid. This could maybe
421    // be a bit smarter in the future to not bother extracting here
422    // if another process has taken the lock in the past X seconds and
423    // wait for the other process to finish (it could try to create the
424    // file with `create_new(true)` then if it exists, check the metadata
425    // then wait until the other process finishes with a timeout), but
426    // for now this is good enough.
427    let sync_lock_path = output_folder.join(NPM_PACKAGE_SYNC_LOCK_FILENAME);
428    let mut open_options = sys_traits::OpenOptions::new();
429    open_options.write = true;
430    open_options.create = true;
431    open_options.truncate = false;
432    match sys.fs_open(&sync_lock_path, &open_options) {
433      Ok(_) => {
434        action()?;
435        // extraction succeeded, so only now delete this file
436        let _ignore = sys.fs_remove_file(&sync_lock_path);
437        Ok(())
438      }
439      Err(err) => Err(WithFolderSyncLockError::CreateLockFile {
440        path: output_folder.to_path_buf(),
441        source: err,
442      }),
443    }
444  }
445
446  match inner(sys, output_folder, action) {
447    Ok(()) => Ok(()),
448    Err(err) => {
449      if let Err(remove_err) = sys.fs_remove_dir_all(output_folder)
450        && remove_err.kind() != std::io::ErrorKind::NotFound
451      {
452        return Err(WithFolderSyncLockError::SetUpPackageCacheDir {
453          package: Box::new(package.clone()),
454          error: Box::new(err),
455          remove_error: remove_err,
456          output_folder: output_folder.to_path_buf(),
457        });
458      }
459      Err(err)
460    }
461  }
462}