denoland/deno · #36883
fix(check): pin @types/node and resolve it through the configured registry
cli/tools/installer/local.rs1 + / 0 −
@@ -466,6 +466,7 @@ pub async fn sync_types_command( &graph_specifiers, &local_wasm_modules, &npm_resolver,+ factory.npmrc()?, resolved_compiler_options.as_ref(), manage_root_tsconfig, type_check_remote,cli/tools/installer/npm_compat.rs131 + / 19 −
@@ -23,6 +23,7 @@ use deno_core::serde_json; use deno_core::serde_json::Value; use deno_core::serde_json::json; use deno_core::url::Url;+use deno_npmrc::ResolvedNpmRc; use deno_runtime::deno_permissions::PermissionsContainer; use deno_semver::Version; use deno_semver::VersionReq;@@ -32,6 +33,15 @@ use crate::file_fetcher::CliFileFetcher; use crate::http_util::HttpClient; use crate::npm::CliNpmResolver; +/// The `@types/node` version installed for projects that don't bring their own.+///+/// Pinned, and kept equal to the version `cli/tsc/dts/node` is generated from+/// (`tools/update_types_node.ts` reads this constant), so a project type-checks+/// the same way whether it uses the downloaded typings or the built-in ones,+/// and so a `@types/node` release doesn't silently change anyone's diagnostics.+/// Bump both by running `./tools/update_types_node.ts` after editing this.+pub const TYPES_NODE_VERSION: &str = "24.2.0";+ /// Installed JSR package info for reporting. pub struct InstalledJsrPackage { /// e.g. "@jsr/std__assert"@@ -285,6 +295,7 @@ pub async fn setup_npm_compat( graph_specifiers: &[String], local_wasm_modules: &[(Url, String)], npm_resolver: &CliNpmResolver,+ npmrc: &ResolvedNpmRc, resolved_compiler_options: Option<&Value>, manage_root_tsconfig: bool, type_check_remote: bool,@@ -444,8 +455,13 @@ pub async fn setup_npm_compat( // Ensure @types/node is available so Node globals (timers, node: builtins, // Buffer, URLPattern, ...) resolve under stock tooling.- let node_types =- ensure_types_node(project_root, http_client, use_global_cache_layout).await;+ let node_types = ensure_types_node(+ project_root,+ http_client,+ npmrc,+ use_global_cache_layout,+ )+ .await; if let Some(undici_types_dir) = &node_types.undici_types_dir { member_paths.insert( "undici-types".to_string(),@@ -548,6 +564,7 @@ fn resolve_bare_against_import_map( async fn ensure_types_node( project_root: &Path, http_client: &HttpClient,+ npmrc: &ResolvedNpmRc, use_global_cache_layout: bool, ) -> NodeTypesSetup { // Reuse an @types/node the project already installed under node_modules.@@ -569,15 +586,24 @@ async fn ensure_types_node( let type_root = "./npm-compat/@types".to_string(); let node_dir = modules_dir.join("@types/node"); let undici_types_dir = modules_dir.join("undici-types");- if is_materialized_package(&node_dir) {+ // Only reuse a copy at the pinned version: one left behind by an earlier+ // Deno (back when this followed the registry's `latest`) would otherwise keep+ // being type-checked against forever.+ if is_materialized_package_at_version(&node_dir, TYPES_NODE_VERSION) { return NodeTypesSetup { type_root: Some(type_root), undici_types_dir: is_materialized_package(&undici_types_dir) .then_some(undici_types_dir), }; }- match download_npm_package(&modules_dir, "@types/node", None, http_client)- .await+ match download_npm_package(+ &modules_dir,+ "@types/node",+ Some(TYPES_NODE_VERSION),+ http_client,+ npmrc,+ )+ .await { Ok(Some((_version, deps))) => { if let Some(req) = deps.get("undici-types").and_then(|v| v.as_str()) {@@ -586,6 +612,7 @@ async fn ensure_types_node( "undici-types", Some(req), http_client,+ npmrc, ) .await; }@@ -610,10 +637,13 @@ async fn download_npm_package( pkg: &str, req: Option<&str>, http_client: &HttpClient,+ npmrc: &ResolvedNpmRc, ) -> Result<Option<(String, serde_json::Map<String, Value>)>, AnyError> {- let meta_url =- format!("https://registry.npmjs.org/{}", pkg.replace('/', "%2f"));- let bytes = match http_client.download(Url::parse(&meta_url)?).await {+ // Go through the registry the project configured (`.npmrc`,+ // `NPM_CONFIG_REGISTRY`) rather than hardcoding npmjs.org, so a private+ // registry, a mirror or an offline setup is honored here too.+ let meta_url = deno_npm_cache::get_package_url(npmrc, pkg);+ let bytes = match http_client.download(meta_url).await { Ok(b) => b, Err(e) => { log::debug!("Failed to fetch metadata for {pkg}: {e}");@@ -642,7 +672,7 @@ async fn download_npm_package( }; let tb = http_client.download(Url::parse(tarball)?).await?; let dest = modules_dir.join(pkg);- if let Err(e) = extract_tarball_gz_atomic(&tb, &dest) {+ if let Err(e) = extract_tarball_gz_atomic(&tb, &dest, Some(&version)) { log::debug!("Failed to extract {pkg}: {e}"); return Ok(None); }@@ -985,7 +1015,7 @@ async fn install_jsr_packages( } }; - if let Err(e) = extract_tarball_gz_atomic(&tarball_bytes, &pkg_dir) {+ if let Err(e) = extract_tarball_gz_atomic(&tarball_bytes, &pkg_dir, None) { log::debug!("Failed to extract {registry_name}: {e}"); continue; }@@ -1016,6 +1046,28 @@ fn is_materialized_package(dir: &Path) -> bool { dir.join("package.json").exists() } +/// Whether an existing `dest` tree can stand in for the one being extracted:+/// complete, and at the version we asked for when we pinned one.+fn published_copy_is_current(+ dest: &Path,+ expected_version: Option<&str>,+) -> bool {+ match expected_version {+ Some(version) => is_materialized_package_at_version(dest, version),+ None => is_materialized_package(dest),+ }+}++fn is_materialized_package_at_version(dir: &Path, version: &str) -> bool {+ let Ok(contents) = std::fs::read_to_string(dir.join("package.json")) else {+ return false;+ };+ let Ok(manifest) = serde_json::from_str::<Value>(&contents) else {+ return false;+ };+ manifest.get("version").and_then(|v| v.as_str()) == Some(version)+}+ fn cleanup_stale_staging_dirs( parent: &Path, staging_prefix: &str,@@ -1052,6 +1104,7 @@ fn cleanup_stale_staging_dirs( fn extract_tarball_gz_atomic( gz_bytes: &[u8], dest: &Path,+ expected_version: Option<&str>, ) -> Result<(), AnyError> { let parent = dest.parent().ok_or_else(|| { anyhow!("Invalid extract destination: {}", dest.display())@@ -1098,23 +1151,25 @@ fn extract_tarball_gz_atomic( // `dest` is in the way. On Windows a rename onto *any* existing directory // fails; on Unix only onto a non-empty one - so this is the normal path // for a lost race, not just an error path.- if is_materialized_package(dest) {+ if published_copy_is_current(dest, expected_version) { // Another process published a complete copy first. Its tree is as good // as ours, so keep it and drop the staging dir. let _ = std::fs::remove_dir_all(&tmp_dir); return Ok(()); }- // `dest` is stale: half-extracted by a killed process, or written- // non-atomically by an older Deno. Clearing it is what heals a cache that- // is already poisoned - otherwise every later run skips the download and- // keeps type checking against an incomplete tree.+ // `dest` is stale: half-extracted by a killed process, written+ // non-atomically by an older Deno, or a complete copy of a version we no+ // longer want (an older pin, or whatever `latest` was when Deno still+ // followed it). Clearing it is what heals a cache that is already+ // poisoned - otherwise every later run skips the download and keeps type+ // checking against the wrong tree. let _ = std::fs::remove_dir_all(dest); match std::fs::rename(&tmp_dir, dest) { Ok(()) => Ok(()), Err(e) => { let _ = std::fs::remove_dir_all(&tmp_dir); // A third process may have published `dest` in between.- if is_materialized_package(dest) {+ if published_copy_is_current(dest, expected_version) { return Ok(()); } Err(anyhow!(@@ -1803,7 +1858,7 @@ mod tests { ("index.d.ts", "declare const x: number;"), ]); - extract_tarball_gz_atomic(&gz, &dest).unwrap();+ extract_tarball_gz_atomic(&gz, &dest, None).unwrap(); assert!(is_materialized_package(&dest)); assert_eq!(@@ -1834,7 +1889,7 @@ mod tests { ("package.json", r#"{"name":"@types/node"}"#), ("index.d.ts", "fresh"), ]);- extract_tarball_gz_atomic(&gz, &dest).unwrap();+ extract_tarball_gz_atomic(&gz, &dest, None).unwrap(); assert!(is_materialized_package(&dest)); assert_eq!(@@ -1858,7 +1913,7 @@ mod tests { ("index.d.ts", "loser"), ]); // Losing the race is not an error, and must not clobber the winner.- extract_tarball_gz_atomic(&gz, &dest).unwrap();+ extract_tarball_gz_atomic(&gz, &dest, None).unwrap(); assert_eq!( std::fs::read_to_string(dest.join("index.d.ts")).unwrap(),@@ -1872,6 +1927,63 @@ mod tests { assert!(leftovers.is_empty(), "leftover staging dirs: {leftovers:?}"); } + #[test]+ fn test_extract_tarball_gz_atomic_replaces_other_version() {+ let dir = tempfile::tempdir().unwrap();+ let dest = dir.path().join("node");+ // A complete copy of a version we no longer want: what an older Deno left+ // behind when this followed the registry's `latest` instead of a pin.+ std::fs::create_dir_all(&dest).unwrap();+ std::fs::write(+ dest.join("package.json"),+ r#"{"name":"@types/node","version":"26.6.2"}"#,+ )+ .unwrap();+ std::fs::write(dest.join("index.d.ts"), "stale").unwrap();++ let gz = make_tarball_gz(&[+ (+ "package.json",+ r#"{"name":"@types/node","version":"24.2.0"}"#,+ ),+ ("index.d.ts", "pinned"),+ ]);+ extract_tarball_gz_atomic(&gz, &dest, Some("24.2.0")).unwrap();++ assert_eq!(+ std::fs::read_to_string(dest.join("index.d.ts")).unwrap(),+ "pinned"+ );+ }++ #[test]+ fn test_extract_tarball_gz_atomic_keeps_same_version() {+ let dir = tempfile::tempdir().unwrap();+ let dest = dir.path().join("node");+ // A concurrent process published the version we wanted; keep its tree.+ std::fs::create_dir_all(&dest).unwrap();+ std::fs::write(+ dest.join("package.json"),+ r#"{"name":"@types/node","version":"24.2.0"}"#,+ )+ .unwrap();+ std::fs::write(dest.join("index.d.ts"), "winner").unwrap();++ let gz = make_tarball_gz(&[+ (+ "package.json",+ r#"{"name":"@types/node","version":"24.2.0"}"#,+ ),+ ("index.d.ts", "loser"),+ ]);+ extract_tarball_gz_atomic(&gz, &dest, Some("24.2.0")).unwrap();++ assert_eq!(+ std::fs::read_to_string(dest.join("index.d.ts")).unwrap(),+ "winner"+ );+ }+ #[test] fn test_url_to_mirror_rel_disambiguates_query_and_port() { let rel = |u: &str| url_to_mirror_rel(&Url::parse(u).unwrap()).unwrap();tests/specs/node/node_test_mock_timers/test.js3 + / 1 −
@@ -199,7 +199,9 @@ test("Date is mocked and tracks tick", () => { assert.strictEqual(new Date().getTime(), 5000); assert.strictEqual(Date.isMock, true); assert.strictEqual(Date.toString(), "function Date() { [native code] }");- assert.match(Date(), /1970/);+ // Called as a function, `Date()` stringifies the mocked now. Compare with an+ // explicit timestamp rather than a year: the year depends on the timezone.+ assert.strictEqual(Date(), new Date(5000).toString()); // explicit timestamp still works assert.strictEqual(new Date(0).getTime(), 0); mock.timers.reset();tools/update_types_node.ts14 + / 1 −
@@ -19,8 +19,10 @@ import { VariableDeclaration, } from "jsr:@ts-morph/ts-morph@27.0.2"; -const typesNodeVersion = "24.2.0"; const rootDir = $.path(import.meta.url).parentOrThrow().parentOrThrow();+// Single source of truth: the same pin `deno check` installs for projects that+// don't bring their own @types/node.+const typesNodeVersion = readPinnedTypesNodeVersion(); const dtsDir = rootDir.join("cli/tsc/dts/"); const nodeTypesDir = dtsDir.join("node"); const undiciTypesDir = nodeTypesDir.join("undici");@@ -526,3 +528,14 @@ function isKnownModuleSpecifier(text: string) { return text.startsWith("node:"); } }++function readPinnedTypesNodeVersion(): string {+ const path = rootDir.join("cli/tools/installer/npm_compat.rs");+ const match = /TYPES_NODE_VERSION: &str = "([^"]+)"/.exec(+ path.readTextSync(),+ );+ if (match == null) {+ throw new Error(`Could not find TYPES_NODE_VERSION in ${path}`);+ }+ return match[1];+}