mirror of
https://github.com/denoland/deno.git
synced 2025-09-28 13:14:48 +00:00

This adds support for using a local copy of an npm package. ```js // deno.json { "patch": [ "../path/to/local_npm_package" ], // required until Deno 2.3, but it will still be considered unstable "unstable": ["npm-patch"] } ``` 1. Requires using a node_modules folder. 2. When using `"nodeModulesDir": "auto"`, it recreates the folder in the node_modules directory on each run which will slightly increase startup time. 3. When using the default with a package.json (`"nodeModulesDir": "manual"`), updating the package requires running `deno install`. This is to get the package into the node_modules directory of the current workspace. This is necessary instead of linking because packages can have multiple "copy packages" due to peer dep resolution. Caveat: Specifying a local copy of an npm package or making changes to its dependencies will purge npm packages from the lockfile. This might cause npm resolution to resolve differently and it may end up not using the local copy of the npm package. It's very difficult to only invalidate resolution midway through the graph and then only rebuild that part of the resolution, so this is just a first pass that can be improved in the future. In practice, this probably won't be an issue for most people. Another limitation is this also requires the npm package name to exist in the registry at the moment.
418 lines
12 KiB
Rust
418 lines
12 KiB
Rust
// Copyright 2018-2025 the Deno authors. MIT license.
|
|
|
|
pub mod installer;
|
|
mod managed;
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use dashmap::DashMap;
|
|
use deno_config::workspace::Workspace;
|
|
use deno_core::serde_json;
|
|
use deno_core::url::Url;
|
|
use deno_error::JsErrorBox;
|
|
use deno_lib::version::DENO_VERSION_INFO;
|
|
use deno_npm::npm_rc::ResolvedNpmRc;
|
|
use deno_npm::registry::NpmPackageInfo;
|
|
use deno_npm::registry::NpmPackageVersionInfo;
|
|
use deno_resolver::npm::ByonmNpmResolverCreateOptions;
|
|
use deno_runtime::colors;
|
|
use deno_semver::package::PackageName;
|
|
use deno_semver::package::PackageNv;
|
|
use deno_semver::package::PackageReq;
|
|
use deno_semver::SmallStackString;
|
|
use deno_semver::StackString;
|
|
use deno_semver::Version;
|
|
use http::HeaderName;
|
|
use http::HeaderValue;
|
|
use indexmap::IndexMap;
|
|
use thiserror::Error;
|
|
|
|
pub use self::managed::CliManagedNpmResolverCreateOptions;
|
|
pub use self::managed::CliNpmResolverManagedSnapshotOption;
|
|
pub use self::managed::NpmResolutionInitializer;
|
|
pub use self::managed::ResolveSnapshotError;
|
|
use crate::file_fetcher::CliFileFetcher;
|
|
use crate::http_util::HttpClientProvider;
|
|
use crate::sys::CliSys;
|
|
use crate::util::progress_bar::ProgressBar;
|
|
|
|
pub type CliNpmTarballCache =
|
|
deno_npm_cache::TarballCache<CliNpmCacheHttpClient, CliSys>;
|
|
pub type CliNpmCache = deno_npm_cache::NpmCache<CliSys>;
|
|
pub type CliNpmRegistryInfoProvider =
|
|
deno_npm_cache::RegistryInfoProvider<CliNpmCacheHttpClient, CliSys>;
|
|
pub type CliNpmResolver = deno_resolver::npm::NpmResolver<CliSys>;
|
|
pub type CliManagedNpmResolver = deno_resolver::npm::ManagedNpmResolver<CliSys>;
|
|
pub type CliNpmResolverCreateOptions =
|
|
deno_resolver::npm::NpmResolverCreateOptions<CliSys>;
|
|
pub type CliByonmNpmResolverCreateOptions =
|
|
ByonmNpmResolverCreateOptions<CliSys>;
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct WorkspaceNpmPatchPackages(
|
|
pub HashMap<PackageName, Vec<NpmPackageVersionInfo>>,
|
|
);
|
|
|
|
impl WorkspaceNpmPatchPackages {
|
|
pub fn from_workspace(workspace: &Workspace) -> Self {
|
|
let mut entries: HashMap<PackageName, Vec<NpmPackageVersionInfo>> =
|
|
HashMap::new();
|
|
if workspace.has_unstable("npm-patch") {
|
|
for pkg_json in workspace.patch_pkg_jsons() {
|
|
let Some(name) = pkg_json.name.as_ref() else {
|
|
log::warn!(
|
|
"{} Patch package ignored because package.json was missing name field.\n at {}",
|
|
colors::yellow("Warning"),
|
|
pkg_json.path.display(),
|
|
);
|
|
continue;
|
|
};
|
|
match pkg_json_to_version_info(pkg_json) {
|
|
Ok(version_info) => {
|
|
let entry = entries.entry(PackageName::from_str(name)).or_default();
|
|
entry.push(version_info);
|
|
}
|
|
Err(err) => {
|
|
log::warn!(
|
|
"{} {}\n at {}",
|
|
colors::yellow("Warning"),
|
|
err.to_string(),
|
|
pkg_json.path.display(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else if workspace.patch_pkg_jsons().next().is_some() {
|
|
log::warn!(
|
|
"{} {}\n at {}",
|
|
colors::yellow("Warning"),
|
|
"Patching npm packages is only supported when setting \"unstable\": [\"npm-patch\"] in the root deno.json",
|
|
workspace
|
|
.root_deno_json()
|
|
.map(|d| d.specifier.to_string())
|
|
.unwrap_or_else(|| workspace.root_dir().to_string()),
|
|
);
|
|
}
|
|
Self(entries)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
enum PkgJsonToVersionInfoError {
|
|
#[error(
|
|
"Patch package ignored because package.json was missing version field."
|
|
)]
|
|
VersionMissing,
|
|
#[error("Patch package ignored because package.json version field could not be parsed.")]
|
|
VersionInvalid {
|
|
#[source]
|
|
source: deno_semver::npm::NpmVersionParseError,
|
|
},
|
|
}
|
|
|
|
fn pkg_json_to_version_info(
|
|
pkg_json: &deno_package_json::PackageJson,
|
|
) -> Result<NpmPackageVersionInfo, PkgJsonToVersionInfoError> {
|
|
fn parse_deps(
|
|
deps: Option<&IndexMap<String, String>>,
|
|
) -> HashMap<StackString, StackString> {
|
|
deps
|
|
.map(|d| {
|
|
d.into_iter()
|
|
.map(|(k, v)| (StackString::from_str(k), StackString::from_str(v)))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn parse_array(v: &[String]) -> Vec<SmallStackString> {
|
|
v.iter().map(|s| SmallStackString::from_str(s)).collect()
|
|
}
|
|
|
|
let Some(version) = &pkg_json.version else {
|
|
return Err(PkgJsonToVersionInfoError::VersionMissing);
|
|
};
|
|
|
|
let version = Version::parse_from_npm(version)
|
|
.map_err(|source| PkgJsonToVersionInfoError::VersionInvalid { source })?;
|
|
Ok(NpmPackageVersionInfo {
|
|
version,
|
|
dist: None,
|
|
bin: pkg_json
|
|
.bin
|
|
.as_ref()
|
|
.and_then(|v| serde_json::from_value(v.clone()).ok()),
|
|
dependencies: parse_deps(pkg_json.dependencies.as_ref()),
|
|
optional_dependencies: parse_deps(pkg_json.optional_dependencies.as_ref()),
|
|
peer_dependencies: parse_deps(pkg_json.peer_dependencies.as_ref()),
|
|
peer_dependencies_meta: pkg_json
|
|
.peer_dependencies_meta
|
|
.clone()
|
|
.and_then(|m| serde_json::from_value(m).ok())
|
|
.unwrap_or_default(),
|
|
os: pkg_json.os.as_deref().map(parse_array).unwrap_or_default(),
|
|
cpu: pkg_json.cpu.as_deref().map(parse_array).unwrap_or_default(),
|
|
scripts: pkg_json
|
|
.scripts
|
|
.as_ref()
|
|
.map(|scripts| {
|
|
scripts
|
|
.iter()
|
|
.map(|(k, v)| (SmallStackString::from_str(k), v.clone()))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default(),
|
|
// not worth increasing memory for showing a deprecated
|
|
// message for patched packages
|
|
deprecated: None,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct CliNpmCacheHttpClient {
|
|
http_client_provider: Arc<HttpClientProvider>,
|
|
progress_bar: ProgressBar,
|
|
}
|
|
|
|
impl CliNpmCacheHttpClient {
|
|
pub fn new(
|
|
http_client_provider: Arc<HttpClientProvider>,
|
|
progress_bar: ProgressBar,
|
|
) -> Self {
|
|
Self {
|
|
http_client_provider,
|
|
progress_bar,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait(?Send)]
|
|
impl deno_npm_cache::NpmCacheHttpClient for CliNpmCacheHttpClient {
|
|
async fn download_with_retries_on_any_tokio_runtime(
|
|
&self,
|
|
url: Url,
|
|
maybe_auth_header: Option<(HeaderName, HeaderValue)>,
|
|
) -> Result<Option<Vec<u8>>, deno_npm_cache::DownloadError> {
|
|
let guard = self.progress_bar.update(url.as_str());
|
|
let client = self.http_client_provider.get_or_create().map_err(|err| {
|
|
deno_npm_cache::DownloadError {
|
|
status_code: None,
|
|
error: err,
|
|
}
|
|
})?;
|
|
client
|
|
.download_with_progress_and_retries(url, maybe_auth_header, &guard)
|
|
.await
|
|
.map_err(|err| {
|
|
use crate::http_util::DownloadErrorKind::*;
|
|
let status_code = match err.as_kind() {
|
|
Fetch { .. }
|
|
| UrlParse { .. }
|
|
| HttpParse { .. }
|
|
| Json { .. }
|
|
| ToStr { .. }
|
|
| RedirectHeaderParse { .. }
|
|
| TooManyRedirects
|
|
| NotFound
|
|
| Other(_) => None,
|
|
BadResponse(bad_response_error) => {
|
|
Some(bad_response_error.status_code)
|
|
}
|
|
};
|
|
deno_npm_cache::DownloadError {
|
|
status_code,
|
|
error: JsErrorBox::from_err(err),
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct NpmFetchResolver {
|
|
nv_by_req: DashMap<PackageReq, Option<PackageNv>>,
|
|
info_by_name: DashMap<String, Option<Arc<NpmPackageInfo>>>,
|
|
file_fetcher: Arc<CliFileFetcher>,
|
|
npmrc: Arc<ResolvedNpmRc>,
|
|
}
|
|
|
|
impl NpmFetchResolver {
|
|
pub fn new(
|
|
file_fetcher: Arc<CliFileFetcher>,
|
|
npmrc: Arc<ResolvedNpmRc>,
|
|
) -> Self {
|
|
Self {
|
|
nv_by_req: Default::default(),
|
|
info_by_name: Default::default(),
|
|
file_fetcher,
|
|
npmrc,
|
|
}
|
|
}
|
|
|
|
pub async fn req_to_nv(&self, req: &PackageReq) -> Option<PackageNv> {
|
|
if let Some(nv) = self.nv_by_req.get(req) {
|
|
return nv.value().clone();
|
|
}
|
|
let maybe_get_nv = || async {
|
|
let name = req.name.clone();
|
|
let package_info = self.package_info(&name).await?;
|
|
if let Some(dist_tag) = req.version_req.tag() {
|
|
let version = package_info.dist_tags.get(dist_tag)?.clone();
|
|
return Some(PackageNv { name, version });
|
|
}
|
|
// Find the first matching version of the package.
|
|
let mut versions = package_info.versions.keys().collect::<Vec<_>>();
|
|
versions.sort();
|
|
let version = versions
|
|
.into_iter()
|
|
.rev()
|
|
.find(|v| req.version_req.tag().is_none() && req.version_req.matches(v))
|
|
.cloned()?;
|
|
Some(PackageNv { name, version })
|
|
};
|
|
let nv = maybe_get_nv().await;
|
|
self.nv_by_req.insert(req.clone(), nv.clone());
|
|
nv
|
|
}
|
|
|
|
pub async fn package_info(&self, name: &str) -> Option<Arc<NpmPackageInfo>> {
|
|
if let Some(info) = self.info_by_name.get(name) {
|
|
return info.value().clone();
|
|
}
|
|
// todo(#27198): use RegistryInfoProvider instead
|
|
let fetch_package_info = || async {
|
|
let info_url = deno_npm_cache::get_package_url(&self.npmrc, name);
|
|
let registry_config = self.npmrc.get_registry_config(name);
|
|
// TODO(bartlomieju): this should error out, not use `.ok()`.
|
|
let maybe_auth_header =
|
|
deno_npm_cache::maybe_auth_header_for_npm_registry(registry_config)
|
|
.ok()?;
|
|
let file = self
|
|
.file_fetcher
|
|
.fetch_bypass_permissions_with_maybe_auth(&info_url, maybe_auth_header)
|
|
.await
|
|
.ok()?;
|
|
serde_json::from_slice::<NpmPackageInfo>(&file.source).ok()
|
|
};
|
|
let info = fetch_package_info().await.map(Arc::new);
|
|
self.info_by_name.insert(name.to_string(), info.clone());
|
|
info
|
|
}
|
|
}
|
|
|
|
pub const NPM_CONFIG_USER_AGENT_ENV_VAR: &str = "npm_config_user_agent";
|
|
|
|
pub fn get_npm_config_user_agent() -> String {
|
|
format!(
|
|
"deno/{} npm/? deno/{} {} {}",
|
|
DENO_VERSION_INFO.deno,
|
|
DENO_VERSION_INFO.deno,
|
|
std::env::consts::OS,
|
|
std::env::consts::ARCH
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use std::path::PathBuf;
|
|
|
|
use deno_npm::registry::NpmPeerDependencyMeta;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_pkg_json_to_version_info() {
|
|
fn convert(
|
|
text: &str,
|
|
) -> Result<NpmPackageVersionInfo, PkgJsonToVersionInfoError> {
|
|
let pkg_json = deno_package_json::PackageJson::load_from_string(
|
|
PathBuf::from("package.json"),
|
|
text,
|
|
)
|
|
.unwrap();
|
|
pkg_json_to_version_info(&pkg_json)
|
|
}
|
|
|
|
assert_eq!(
|
|
convert(
|
|
r#"{
|
|
"name": "pkg",
|
|
"version": "1.0.0",
|
|
"bin": "./bin.js",
|
|
"dependencies": {
|
|
"my-dep": "1"
|
|
},
|
|
"optionalDependencies": {
|
|
"optional-dep": "~1"
|
|
},
|
|
"peerDependencies": {
|
|
"my-peer-dep": "^2"
|
|
},
|
|
"peerDependenciesMeta": {
|
|
"my-peer-dep": {
|
|
"optional": true
|
|
}
|
|
},
|
|
"os": ["win32"],
|
|
"cpu": ["x86_64"],
|
|
"scripts": {
|
|
"script": "testing",
|
|
"postInstall": "testing2"
|
|
},
|
|
"deprecated": "ignored for now"
|
|
}"#
|
|
)
|
|
.unwrap(),
|
|
NpmPackageVersionInfo {
|
|
version: Version::parse_from_npm("1.0.0").unwrap(),
|
|
dist: None,
|
|
bin: Some(deno_npm::registry::NpmPackageVersionBinEntry::String(
|
|
"./bin.js".to_string()
|
|
)),
|
|
dependencies: HashMap::from([(
|
|
StackString::from_static("my-dep"),
|
|
StackString::from_static("1")
|
|
)]),
|
|
optional_dependencies: HashMap::from([(
|
|
StackString::from_static("optional-dep"),
|
|
StackString::from_static("~1")
|
|
)]),
|
|
peer_dependencies: HashMap::from([(
|
|
StackString::from_static("my-peer-dep"),
|
|
StackString::from_static("^2")
|
|
)]),
|
|
peer_dependencies_meta: HashMap::from([(
|
|
StackString::from_static("my-peer-dep"),
|
|
NpmPeerDependencyMeta { optional: true }
|
|
)]),
|
|
os: vec![SmallStackString::from_static("win32")],
|
|
cpu: vec![SmallStackString::from_static("x86_64")],
|
|
scripts: HashMap::from([
|
|
(
|
|
SmallStackString::from_static("script"),
|
|
"testing".to_string(),
|
|
),
|
|
(
|
|
SmallStackString::from_static("postInstall"),
|
|
"testing2".to_string(),
|
|
)
|
|
]),
|
|
// we don't bother ever setting this because we don't store it in deno_package_json
|
|
deprecated: None,
|
|
}
|
|
);
|
|
|
|
match convert("{}").unwrap_err() {
|
|
PkgJsonToVersionInfoError::VersionMissing => {
|
|
// ok
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
match convert(r#"{ "version": "1.0.~" }"#).unwrap_err() {
|
|
PkgJsonToVersionInfoError::VersionInvalid { source: err } => {
|
|
assert_eq!(err.to_string(), "Invalid npm version");
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|