deno/libs/package_json/lib.rs
David Sherret f146dbe12d
Some checks are pending
ci / build libs (push) Blocked by required conditions
ci / pre-build (push) Waiting to run
ci / test debug linux-aarch64 (push) Blocked by required conditions
ci / test release linux-aarch64 (push) Blocked by required conditions
ci / test debug macos-aarch64 (push) Blocked by required conditions
ci / test release macos-aarch64 (push) Blocked by required conditions
ci / bench release linux-x86_64 (push) Blocked by required conditions
ci / lint debug linux-x86_64 (push) Blocked by required conditions
ci / lint debug macos-x86_64 (push) Blocked by required conditions
ci / lint debug windows-x86_64 (push) Blocked by required conditions
ci / test debug linux-x86_64 (push) Blocked by required conditions
ci / test release linux-x86_64 (push) Blocked by required conditions
ci / test debug macos-x86_64 (push) Blocked by required conditions
ci / test release macos-x86_64 (push) Blocked by required conditions
ci / test debug windows-x86_64 (push) Blocked by required conditions
ci / test release windows-x86_64 (push) Blocked by required conditions
ci / publish canary (push) Blocked by required conditions
feat(npm): support bundleDependencies in npm dependencies (#30521)
2025-08-26 11:58:53 -04:00

845 lines
24 KiB
Rust

// Copyright 2018-2025 the Deno authors. MIT license.
#![deny(clippy::print_stderr)]
#![deny(clippy::print_stdout)]
#![deny(clippy::unused_async)]
#![deny(clippy::unnecessary_wraps)]
use std::path::Path;
use std::path::PathBuf;
use boxed_error::Boxed;
use deno_error::JsError;
use deno_semver::StackString;
use deno_semver::VersionReq;
use deno_semver::VersionReqSpecifierParseError;
use deno_semver::npm::NpmVersionReqParseError;
use deno_semver::package::PackageReq;
use indexmap::IndexMap;
use serde::Serialize;
use serde_json::Map;
use serde_json::Value;
use sys_traits::FsRead;
use thiserror::Error;
use url::Url;
#[allow(clippy::disallowed_types)]
pub type PackageJsonRc = deno_maybe_sync::MaybeArc<PackageJson>;
#[allow(clippy::disallowed_types)]
pub type PackageJsonDepsRc = deno_maybe_sync::MaybeArc<PackageJsonDeps>;
#[allow(clippy::disallowed_types)]
type PackageJsonDepsRcCell = deno_maybe_sync::MaybeOnceLock<PackageJsonDepsRc>;
pub trait PackageJsonCache {
fn get(&self, path: &Path) -> Option<PackageJsonRc>;
fn set(&self, path: PathBuf, package_json: PackageJsonRc);
}
#[derive(Debug, Clone, JsError, PartialEq, Eq, Boxed)]
pub struct PackageJsonDepValueParseError(
pub Box<PackageJsonDepValueParseErrorKind>,
);
#[derive(Debug, Error, Clone, JsError, PartialEq, Eq)]
pub enum PackageJsonDepValueParseErrorKind {
#[class(inherit)]
#[error(transparent)]
VersionReq(#[from] NpmVersionReqParseError),
#[class(inherit)]
#[error(transparent)]
JsrVersionReq(#[from] VersionReqSpecifierParseError),
#[class(type)]
#[error("Not implemented scheme '{scheme}'")]
Unsupported { scheme: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PackageJsonDepWorkspaceReq {
/// "workspace:~"
Tilde,
/// "workspace:^"
Caret,
/// "workspace:x.y.z", "workspace:*", "workspace:^x.y.z"
VersionReq(VersionReq),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PackageJsonDepValue {
File(String),
Req(PackageReq),
Workspace(PackageJsonDepWorkspaceReq),
JsrReq(PackageReq),
}
impl PackageJsonDepValue {
pub fn parse(
key: &str,
value: &str,
) -> Result<Self, PackageJsonDepValueParseError> {
/// Gets the name and raw version constraint for a registry info or
/// package.json dependency entry taking into account npm package aliases.
fn parse_dep_entry_name_and_raw_version<'a>(
key: &'a str,
value: &'a str,
) -> (&'a str, &'a str) {
if let Some(package_and_version) = value.strip_prefix("npm:") {
if let Some((name, version)) = package_and_version.rsplit_once('@') {
// if empty, then the name was scoped and there's no version
if name.is_empty() {
(package_and_version, "*")
} else {
(name, version)
}
} else {
(package_and_version, "*")
}
} else {
(key, value)
}
}
if let Some(workspace_key) = value.strip_prefix("workspace:") {
let workspace_req = match workspace_key {
"~" => PackageJsonDepWorkspaceReq::Tilde,
"^" => PackageJsonDepWorkspaceReq::Caret,
_ => PackageJsonDepWorkspaceReq::VersionReq(
VersionReq::parse_from_npm(workspace_key)?,
),
};
return Ok(Self::Workspace(workspace_req));
} else if let Some(raw_jsr_req) = value.strip_prefix("jsr:") {
let (name, version_req) =
parse_dep_entry_name_and_raw_version(key, raw_jsr_req);
let result = VersionReq::parse_from_specifier(version_req);
match result {
Ok(version_req) => {
return Ok(Self::JsrReq(PackageReq {
name: name.into(),
version_req,
}));
}
Err(err) => {
return Err(
PackageJsonDepValueParseErrorKind::JsrVersionReq(err).into_box(),
);
}
}
}
if value.starts_with("git:")
|| value.starts_with("http:")
|| value.starts_with("https:")
{
return Err(
PackageJsonDepValueParseErrorKind::Unsupported {
scheme: value.split(':').next().unwrap().to_string(),
}
.into_box(),
);
}
if let Some(path) = value.strip_prefix("file:") {
return Ok(Self::File(path.to_string()));
}
let (name, version_req) = parse_dep_entry_name_and_raw_version(key, value);
let result = VersionReq::parse_from_npm(version_req);
match result {
Ok(version_req) => Ok(Self::Req(PackageReq {
name: name.into(),
version_req,
})),
Err(err) => {
Err(PackageJsonDepValueParseErrorKind::VersionReq(err).into_box())
}
}
}
}
pub type PackageJsonDepsMap = IndexMap<
StackString,
Result<PackageJsonDepValue, PackageJsonDepValueParseError>,
>;
#[derive(Debug, Clone)]
pub struct PackageJsonDeps {
pub dependencies: PackageJsonDepsMap,
pub dev_dependencies: PackageJsonDepsMap,
}
impl PackageJsonDeps {
/// Gets a package.json dependency entry by alias.
pub fn get(
&self,
alias: &str,
) -> Option<&Result<PackageJsonDepValue, PackageJsonDepValueParseError>> {
self
.dependencies
.get(alias)
.or_else(|| self.dev_dependencies.get(alias))
}
}
#[derive(Debug, Error, JsError)]
pub enum PackageJsonLoadError {
#[class(inherit)]
#[error("Failed reading '{}'.", .path.display())]
Io {
path: PathBuf,
#[source]
#[inherit]
source: std::io::Error,
},
#[class(inherit)]
#[error("Malformed package.json '{}'.", .path.display())]
Deserialize {
path: PathBuf,
#[source]
#[inherit]
source: serde_json::Error,
},
#[error(
"\"exports\" cannot contain some keys starting with '.' and some not.\nThe exports object must either be an object of package subpath keys\nor an object of main entry condition name keys only."
)]
#[class(type)]
InvalidExports,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PackageJson {
pub exports: Option<Map<String, Value>>,
pub imports: Option<Map<String, Value>>,
pub bin: Option<Value>,
pub main: Option<String>,
pub module: Option<String>,
pub browser: Option<String>,
pub name: Option<String>,
pub version: Option<String>,
#[serde(skip)]
pub path: PathBuf,
#[serde(rename = "type")]
pub typ: String,
pub types: Option<String>,
pub types_versions: Option<Map<String, Value>>,
pub dependencies: Option<IndexMap<String, String>>,
pub bundle_dependencies: Option<Vec<String>>,
pub dev_dependencies: Option<IndexMap<String, String>>,
pub peer_dependencies: Option<IndexMap<String, String>>,
pub peer_dependencies_meta: Option<Value>,
pub optional_dependencies: Option<IndexMap<String, String>>,
pub scripts: Option<IndexMap<String, String>>,
pub workspaces: Option<Vec<String>>,
pub os: Option<Vec<String>>,
pub cpu: Option<Vec<String>>,
#[serde(skip_serializing)]
resolved_deps: PackageJsonDepsRcCell,
}
impl PackageJson {
pub fn load_from_path(
sys: &impl FsRead,
maybe_cache: Option<&dyn PackageJsonCache>,
path: &Path,
) -> Result<PackageJsonRc, PackageJsonLoadError> {
match maybe_cache.and_then(|c| c.get(path)) {
Some(item) => Ok(item),
_ => match sys.fs_read_to_string_lossy(path) {
Ok(file_text) => {
let pkg_json =
PackageJson::load_from_string(path.to_path_buf(), &file_text)?;
let pkg_json = deno_maybe_sync::new_rc(pkg_json);
if let Some(cache) = maybe_cache {
cache.set(path.to_path_buf(), pkg_json.clone());
}
Ok(pkg_json)
}
Err(err) => Err(PackageJsonLoadError::Io {
path: path.to_path_buf(),
source: err,
}),
},
}
}
pub fn load_from_string(
path: PathBuf,
source: &str,
) -> Result<PackageJson, PackageJsonLoadError> {
if source.trim().is_empty() {
return Ok(PackageJson {
path,
main: None,
name: None,
version: None,
module: None,
browser: None,
typ: "none".to_string(),
types: None,
types_versions: None,
exports: None,
imports: None,
bin: None,
dependencies: None,
bundle_dependencies: None,
dev_dependencies: None,
peer_dependencies: None,
peer_dependencies_meta: None,
optional_dependencies: None,
scripts: None,
workspaces: None,
os: None,
cpu: None,
resolved_deps: Default::default(),
});
}
let package_json: Value = serde_json::from_str(source).map_err(|err| {
PackageJsonLoadError::Deserialize {
path: path.clone(),
source: err,
}
})?;
Self::load_from_value(path, package_json)
}
pub fn load_from_value(
path: PathBuf,
package_json: serde_json::Value,
) -> Result<PackageJson, PackageJsonLoadError> {
fn parse_string_map(
value: serde_json::Value,
) -> Option<IndexMap<String, String>> {
if let Value::Object(map) = value {
let mut result = IndexMap::with_capacity(map.len());
for (k, v) in map {
if let Some(v) = map_string(v) {
result.insert(k, v);
}
}
Some(result)
} else {
None
}
}
fn map_object(value: serde_json::Value) -> Option<Map<String, Value>> {
match value {
Value::Object(v) => Some(v),
_ => None,
}
}
fn map_string(value: serde_json::Value) -> Option<String> {
match value {
Value::String(v) => Some(v),
Value::Number(v) => Some(v.to_string()),
_ => None,
}
}
fn map_array(value: serde_json::Value) -> Option<Vec<Value>> {
match value {
Value::Array(v) => Some(v),
_ => None,
}
}
fn parse_string_array(value: serde_json::Value) -> Option<Vec<String>> {
let value = map_array(value)?;
let mut result = Vec::with_capacity(value.len());
for v in value {
if let Some(v) = map_string(v) {
result.push(v);
}
}
Some(result)
}
let mut package_json = match package_json {
Value::Object(o) => o,
_ => Default::default(),
};
let imports_val = package_json.remove("imports");
let main_val = package_json.remove("main");
let module_val = package_json.remove("module");
let browser_val = package_json.remove("browser");
let name_val = package_json.remove("name");
let version_val = package_json.remove("version");
let type_val = package_json.remove("type");
let bin = package_json.remove("bin");
let exports = package_json
.remove("exports")
.map(|exports| {
if is_conditional_exports_main_sugar(&exports)? {
let mut map = Map::new();
map.insert(".".to_string(), exports.to_owned());
Ok::<_, PackageJsonLoadError>(Some(map))
} else {
Ok(exports.as_object().map(|o| o.to_owned()))
}
})
.transpose()?
.flatten();
let imports = imports_val.and_then(map_object);
let main = main_val.and_then(map_string);
let name = name_val.and_then(map_string);
let version = version_val.and_then(map_string);
let module = module_val.and_then(map_string);
let browser = browser_val.and_then(map_string);
let dependencies = package_json
.remove("dependencies")
.and_then(parse_string_map);
let dev_dependencies = package_json
.remove("devDependencies")
.and_then(parse_string_map);
let bundle_dependencies = package_json
.remove("bundleDependencies")
.or_else(|| package_json.remove("bundledDependencies"))
.and_then(parse_string_array);
let peer_dependencies = package_json
.remove("peerDependencies")
.and_then(parse_string_map);
let peer_dependencies_meta = package_json.remove("peerDependenciesMeta");
let optional_dependencies = package_json
.remove("optionalDependencies")
.and_then(parse_string_map);
let scripts: Option<IndexMap<String, String>> =
package_json.remove("scripts").and_then(parse_string_map);
// Ignore unknown types for forwards compatibility
let typ = if let Some(t) = type_val {
if let Some(t) = t.as_str() {
if t != "module" && t != "commonjs" {
"none".to_string()
} else {
t.to_string()
}
} else {
"none".to_string()
}
} else {
"none".to_string()
};
// for typescript, it looks for "typings" first, then "types"
let types = package_json
.remove("typings")
.or_else(|| package_json.remove("types"))
.and_then(map_string);
let types_versions = package_json
.remove("typesVersions")
.and_then(|exports| exports.as_object().map(|o| o.to_owned()));
let workspaces = package_json
.remove("workspaces")
.and_then(parse_string_array);
let os = package_json.remove("os").and_then(parse_string_array);
let cpu = package_json.remove("cpu").and_then(parse_string_array);
Ok(PackageJson {
path,
main,
name,
version,
module,
browser,
typ,
types,
types_versions,
exports,
imports,
bin,
dependencies,
dev_dependencies,
bundle_dependencies,
peer_dependencies,
peer_dependencies_meta,
optional_dependencies,
scripts,
workspaces,
os,
cpu,
resolved_deps: Default::default(),
})
}
pub fn specifier(&self) -> Url {
deno_path_util::url_from_file_path(&self.path).unwrap()
}
pub fn dir_path(&self) -> &Path {
self.path.parent().unwrap()
}
/// Resolve the package.json's dependencies.
pub fn resolve_local_package_json_deps(&self) -> &PackageJsonDepsRc {
fn get_map(deps: Option<&IndexMap<String, String>>) -> PackageJsonDepsMap {
let Some(deps) = deps else {
return Default::default();
};
let mut result = IndexMap::with_capacity(deps.len());
for (key, value) in deps {
result
.entry(StackString::from(key.as_str()))
.or_insert_with(|| PackageJsonDepValue::parse(key, value));
}
result
}
self.resolved_deps.get_or_init(|| {
PackageJsonDepsRc::new(PackageJsonDeps {
dependencies: get_map(self.dependencies.as_ref()),
dev_dependencies: get_map(self.dev_dependencies.as_ref()),
})
})
}
}
fn is_conditional_exports_main_sugar(
exports: &Value,
) -> Result<bool, PackageJsonLoadError> {
if exports.is_string() || exports.is_array() {
return Ok(true);
}
if exports.is_null() || !exports.is_object() {
return Ok(false);
}
let exports_obj = exports.as_object().unwrap();
let mut is_conditional_sugar = false;
let mut i = 0;
for key in exports_obj.keys() {
let cur_is_conditional_sugar = key.is_empty() || !key.starts_with('.');
if i == 0 {
is_conditional_sugar = cur_is_conditional_sugar;
i += 1;
} else if is_conditional_sugar != cur_is_conditional_sugar {
return Err(PackageJsonLoadError::InvalidExports);
}
}
Ok(is_conditional_sugar)
}
#[cfg(test)]
mod test {
use std::error::Error;
use std::path::PathBuf;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn null_exports_should_not_crash() {
let package_json = PackageJson::load_from_string(
PathBuf::from("/package.json"),
r#"{ "exports": null }"#,
)
.unwrap();
assert!(package_json.exports.is_none());
}
fn get_local_package_json_version_reqs_for_tests(
package_json: &PackageJson,
) -> IndexMap<
String,
Result<PackageJsonDepValue, PackageJsonDepValueParseErrorKind>,
> {
let deps = package_json.resolve_local_package_json_deps();
deps
.dependencies
.clone()
.into_iter()
.chain(deps.dev_dependencies.clone())
.map(|(k, v)| {
(
k.to_string(),
match v {
Ok(v) => Ok(v),
Err(err) => Err(err.into_kind()),
},
)
})
.collect::<IndexMap<_, _>>()
}
#[test]
fn test_get_local_package_json_version_reqs() {
let mut package_json =
PackageJson::load_from_string(PathBuf::from("/package.json"), "{}")
.unwrap();
package_json.dependencies = Some(IndexMap::from([
("test".to_string(), "^1.2".to_string()),
("other".to_string(), "npm:package@~1.3".to_string()),
]));
package_json.dev_dependencies = Some(IndexMap::from([
("package_b".to_string(), "~2.2".to_string()),
("other".to_string(), "^3.2".to_string()),
]));
let deps = package_json.resolve_local_package_json_deps();
assert_eq!(
deps
.dependencies
.clone()
.into_iter()
.map(|d| (d.0, d.1.unwrap()))
.collect::<Vec<_>>(),
Vec::from([
(
"test".into(),
PackageJsonDepValue::Req(PackageReq::from_str("test@^1.2").unwrap())
),
(
"other".into(),
PackageJsonDepValue::Req(
PackageReq::from_str("package@~1.3").unwrap()
)
),
])
);
assert_eq!(
deps
.dev_dependencies
.clone()
.into_iter()
.map(|d| (d.0, d.1.unwrap()))
.collect::<Vec<_>>(),
Vec::from([
(
"package_b".into(),
PackageJsonDepValue::Req(
PackageReq::from_str("package_b@~2.2").unwrap()
)
),
(
"other".into(),
PackageJsonDepValue::Req(PackageReq::from_str("other@^3.2").unwrap())
),
])
);
}
#[test]
fn test_get_local_package_json_version_reqs_errors_non_npm_specifier() {
let mut package_json =
PackageJson::load_from_string(PathBuf::from("/package.json"), "{}")
.unwrap();
package_json.dependencies = Some(IndexMap::from([(
"test".to_string(),
"%*(#$%()".to_string(),
)]));
let map = get_local_package_json_version_reqs_for_tests(&package_json);
assert_eq!(map.len(), 1);
let err = map.get("test").unwrap().as_ref().unwrap_err();
assert_eq!(format!("{}", err), "Invalid version requirement");
assert_eq!(
format!("{}", err.source().unwrap()),
concat!("Unexpected character.\n", " %*(#$%()\n", " ~")
);
}
#[test]
fn test_get_local_package_json_version_reqs_range() {
let mut package_json =
PackageJson::load_from_string(PathBuf::from("/package.json"), "{}")
.unwrap();
package_json.dependencies = Some(IndexMap::from([(
"test".to_string(),
"1.x - 1.3".to_string(),
)]));
let map = get_local_package_json_version_reqs_for_tests(&package_json);
assert_eq!(
map,
IndexMap::from([(
"test".to_string(),
Ok(PackageJsonDepValue::Req(PackageReq {
name: "test".into(),
version_req: VersionReq::parse_from_npm("1.x - 1.3").unwrap()
}))
)])
);
}
#[test]
fn test_get_local_package_json_version_reqs_jsr() {
let mut package_json =
PackageJson::load_from_string(PathBuf::from("/package.json"), "{}")
.unwrap();
package_json.dependencies = Some(IndexMap::from([(
"@denotest/foo".to_string(),
"jsr:^1.2".to_string(),
)]));
let map = get_local_package_json_version_reqs_for_tests(&package_json);
assert_eq!(
map,
IndexMap::from([(
"@denotest/foo".to_string(),
Ok(PackageJsonDepValue::JsrReq(PackageReq {
name: "@denotest/foo".into(),
version_req: VersionReq::parse_from_specifier("^1.2").unwrap()
}))
)])
);
}
#[test]
fn test_get_local_package_json_version_reqs_skips_certain_specifiers() {
let mut package_json =
PackageJson::load_from_string(PathBuf::from("/package.json"), "{}")
.unwrap();
package_json.dependencies = Some(IndexMap::from([
("test".to_string(), "1".to_string()),
(
"work-test-version-req".to_string(),
"workspace:1.1.1".to_string(),
),
("work-test-star".to_string(), "workspace:*".to_string()),
("work-test-tilde".to_string(), "workspace:~".to_string()),
("work-test-caret".to_string(), "workspace:^".to_string()),
("file-test".to_string(), "file:something".to_string()),
("git-test".to_string(), "git:something".to_string()),
("http-test".to_string(), "http://something".to_string()),
("https-test".to_string(), "https://something".to_string()),
]));
let result = get_local_package_json_version_reqs_for_tests(&package_json);
assert_eq!(
result,
IndexMap::from([
(
"test".to_string(),
Ok(PackageJsonDepValue::Req(
PackageReq::from_str("test@1").unwrap()
))
),
(
"work-test-star".to_string(),
Ok(PackageJsonDepValue::Workspace(
PackageJsonDepWorkspaceReq::VersionReq(
VersionReq::parse_from_npm("*").unwrap()
)
))
),
(
"work-test-version-req".to_string(),
Ok(PackageJsonDepValue::Workspace(
PackageJsonDepWorkspaceReq::VersionReq(
VersionReq::parse_from_npm("1.1.1").unwrap()
)
))
),
(
"work-test-tilde".to_string(),
Ok(PackageJsonDepValue::Workspace(
PackageJsonDepWorkspaceReq::Tilde
))
),
(
"work-test-caret".to_string(),
Ok(PackageJsonDepValue::Workspace(
PackageJsonDepWorkspaceReq::Caret
))
),
(
"file-test".to_string(),
Ok(PackageJsonDepValue::File("something".to_string())),
),
(
"git-test".to_string(),
Err(PackageJsonDepValueParseErrorKind::Unsupported {
scheme: "git".to_string()
}),
),
(
"http-test".to_string(),
Err(PackageJsonDepValueParseErrorKind::Unsupported {
scheme: "http".to_string()
}),
),
(
"https-test".to_string(),
Err(PackageJsonDepValueParseErrorKind::Unsupported {
scheme: "https".to_string()
}),
),
])
);
}
#[test]
fn test_deserialize_serialize() {
let json_value = serde_json::json!({
"name": "test",
"version": "1",
"exports": {
".": "./main.js",
},
"bin": "./main.js",
"types": "./types.d.ts",
"typesVersions": {
"<4.0": { "index.d.ts": ["index.v3.d.ts"] }
},
"imports": {
"#test": "./main.js",
},
"main": "./main.js",
"module": "./module.js",
"browser": "./browser.js",
"type": "module",
"dependencies": {
"name": "1.2",
},
"devDependencies": {
"name": "1.2",
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
},
"workspaces": ["asdf", "asdf2"],
"cpu": ["x86_64"],
"os": ["win32"],
"optionalDependencies": {
"optional": "1.1"
},
"bundleDependencies": [
"name"
],
"peerDependencies": {
"peer": "1.0"
},
"peerDependenciesMeta": {
"peer": {
"optional": true
}
},
});
let package_json = PackageJson::load_from_value(
PathBuf::from("/package.json"),
json_value.clone(),
)
.unwrap();
let serialized_value = serde_json::to_value(&package_json).unwrap();
assert_eq!(serialized_value, json_value);
}
// https://github.com/denoland/deno/issues/26031
#[test]
fn test_exports_error() {
let json_value = serde_json::json!({
"name": "test",
"version": "1",
"exports": { ".": "./a", "a": "./a" },
});
assert!(matches!(
PackageJson::load_from_value(
PathBuf::from("/package.json"),
json_value.clone(),
),
Err(PackageJsonLoadError::InvalidExports)
));
}
}