1use crate::errors::{DnxError, Result};
2use bytes::Bytes;
3use futures::stream::{self, StreamExt};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::time::Duration;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct PackageMetadata {
10 pub name: String,
11 #[serde(default)]
12 pub versions: HashMap<String, VersionMetadata>,
13 #[serde(rename = "dist-tags", default)]
14 pub dist_tags: HashMap<String, String>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct VersionMetadata {
19 pub name: String,
20 pub version: String,
21 #[serde(default)]
22 pub dependencies: Option<HashMap<String, String>>,
23 #[serde(rename = "devDependencies", default)]
24 pub dev_dependencies: Option<HashMap<String, String>>,
25 #[serde(rename = "peerDependencies", default)]
26 pub peer_dependencies: Option<HashMap<String, String>>,
27 #[serde(rename = "optionalDependencies", default)]
28 pub optional_dependencies: Option<HashMap<String, String>>,
29 #[serde(default)]
30 pub dist: DistInfo,
31 #[serde(default)]
32 pub bin: Option<serde_json::Value>, #[serde(default)]
36 pub os: Option<Vec<String>>,
37 #[serde(default)]
39 pub cpu: Option<Vec<String>>,
40 #[serde(rename = "hasInstallScript", default)]
43 pub has_install_script: Option<bool>,
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct DistInfo {
48 #[serde(default)]
49 pub tarball: String,
50 #[serde(default)]
51 pub shasum: String,
52 #[serde(default)]
53 pub integrity: Option<String>,
54}
55
56pub struct RegistryClient {
57 client: reqwest::Client,
58 registry_url: String,
59 auth_token: Option<String>,
60 scoped_registries: HashMap<String, (String, Option<String>)>,
62}
63
64impl RegistryClient {
65 pub fn new(registry_url: String, auth_token: Option<String>) -> Self {
66 Self::with_config(registry_url, auth_token, None, HashMap::new())
67 }
68
69 pub fn with_config(
70 registry_url: String,
71 auth_token: Option<String>,
72 proxy: Option<String>,
73 scoped_registries: HashMap<String, (String, Option<String>)>,
74 ) -> Self {
75 let registry_url = if registry_url.starts_with("http://")
77 && !registry_url.contains("localhost")
78 && !registry_url.contains("127.0.0.1")
79 {
80 eprintln!("Warning: Upgrading insecure HTTP registry URL to HTTPS");
81 registry_url.replacen("http://", "https://", 1)
82 } else {
83 registry_url
84 };
85
86 let mut builder = reqwest::Client::builder()
87 .user_agent("dnx/0.1.1")
88 .timeout(Duration::from_secs(30))
89 .pool_max_idle_per_host(20)
90 .tcp_keepalive(Duration::from_secs(60))
91 .tcp_nodelay(true);
92
93 if let Some(ref proxy_url) = proxy {
95 if let Ok(proxy) = reqwest::Proxy::all(proxy_url) {
96 builder = builder.proxy(proxy);
97 }
98 }
99
100 let client = builder.build().expect("Failed to build HTTP client");
101
102 Self {
103 client,
104 registry_url,
105 auth_token,
106 scoped_registries,
107 }
108 }
109
110 fn registry_for_package(&self, name: &str) -> (&str, Option<&str>) {
113 if name.starts_with('@') {
114 if let Some(scope_end) = name.find('/') {
115 let scope = &name[..scope_end];
116 if let Some((url, token)) = self.scoped_registries.get(scope) {
117 return (url.as_str(), token.as_deref());
118 }
119 }
120 }
121 (&self.registry_url, self.auth_token.as_deref())
122 }
123
124 pub async fn fetch_package_metadata(&self, name: &str) -> Result<PackageMetadata> {
125 let encoded_name = if name.starts_with('@') {
127 let parts: Vec<&str> = name.splitn(2, '/').collect();
128 if parts.len() == 2 {
129 format!("{}%2f{}", parts[0], parts[1])
130 } else {
131 name.to_string()
132 }
133 } else {
134 name.to_string()
135 };
136
137 let (registry_url, scope_auth_token) = self.registry_for_package(name);
138 let url = format!("{}/{}", registry_url.trim_end_matches('/'), encoded_name);
139
140 let backoff_delays = [
142 Duration::from_millis(100),
143 Duration::from_millis(500),
144 Duration::from_millis(2000),
145 ];
146
147 let mut last_error = None;
148
149 for (attempt, &delay) in backoff_delays.iter().enumerate() {
150 let mut request = self
151 .client
152 .get(&url)
153 .header("Accept", "application/vnd.npm.install-v1+json");
154
155 let effective_token = scope_auth_token.or(self.auth_token.as_deref());
157 if let Some(token) = effective_token {
158 request = request.header("Authorization", format!("Bearer {}", token));
159 }
160
161 match request.send().await {
162 Ok(response) => {
163 let status = response.status();
164
165 if status.is_success() {
166 return response.json::<PackageMetadata>().await.map_err(|e| {
167 DnxError::Registry(format!("Failed to parse metadata: {}", e))
168 });
169 } else if status.as_u16() == 404 {
170 return Err(DnxError::PackageNotFound(name.to_string()));
171 } else {
172 last_error = Some(DnxError::Registry(format!(
173 "HTTP {} for package {}",
174 status, name
175 )));
176 }
177 }
178 Err(e) => {
179 last_error = Some(DnxError::Network(format!(
180 "Failed to fetch package {}: {}",
181 name, e
182 )));
183 }
184 }
185
186 if attempt < backoff_delays.len() - 1 {
188 tokio::time::sleep(delay).await;
189 }
190 }
191
192 Err(last_error.unwrap_or_else(|| {
193 DnxError::Registry(format!("Failed to fetch package {} after retries", name))
194 }))
195 }
196
197 pub async fn fetch_tarball(&self, url: &str) -> Result<Bytes> {
198 let backoff_delays = [
199 Duration::from_millis(100),
200 Duration::from_millis(500),
201 Duration::from_millis(2000),
202 ];
203
204 let mut last_error = None;
205
206 let should_send_auth = if let Some(ref _token) = self.auth_token {
208 url::Url::parse(url)
209 .ok()
210 .and_then(|u| u.host_str().map(|h| h.to_string()))
211 .map(|tarball_host| {
212 url::Url::parse(&self.registry_url)
213 .ok()
214 .and_then(|u| u.host_str().map(|h| h.to_string()))
215 .map(|reg_host| tarball_host == reg_host)
216 .unwrap_or(false)
217 })
218 .unwrap_or(false)
219 } else {
220 false
221 };
222
223 for (attempt, &delay) in backoff_delays.iter().enumerate() {
224 let mut request = self.client.get(url);
225
226 if should_send_auth {
227 if let Some(ref token) = self.auth_token {
228 request = request.header("Authorization", format!("Bearer {}", token));
229 }
230 }
231
232 match request.send().await {
233 Ok(response) => {
234 if response.status().is_success() {
235 return response.bytes().await.map_err(|e| {
236 DnxError::Network(format!("Failed to download tarball: {}", e))
237 });
238 } else {
239 last_error = Some(DnxError::Registry(format!(
240 "HTTP {} when fetching tarball",
241 response.status()
242 )));
243 }
244 }
245 Err(e) => {
246 last_error = Some(DnxError::Network(format!("Failed to fetch tarball: {}", e)));
247 }
248 }
249
250 if attempt < backoff_delays.len() - 1 {
251 tokio::time::sleep(delay).await;
252 }
253 }
254
255 Err(last_error.unwrap_or_else(|| {
256 DnxError::Registry("Failed to fetch tarball after retries".to_string())
257 }))
258 }
259
260 pub async fn fetch_metadata_batch(
261 &self,
262 names: Vec<String>,
263 ) -> Vec<(String, Result<PackageMetadata>)> {
264 let results: Vec<(String, Result<PackageMetadata>)> = stream::iter(names)
265 .map(|name| {
266 let name_clone = name.clone();
267 async move {
268 let result = self.fetch_package_metadata(&name_clone).await;
269 (name_clone, result)
270 }
271 })
272 .buffer_unordered(64)
273 .collect()
274 .await;
275
276 results
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn test_client_creation() {
286 let client = RegistryClient::new("https://registry.npmjs.org".to_string(), None);
287 assert_eq!(client.registry_url, "https://registry.npmjs.org");
288 assert!(client.auth_token.is_none());
289 }
290
291 #[test]
292 fn test_client_with_auth() {
293 let client = RegistryClient::new(
294 "https://registry.npmjs.org".to_string(),
295 Some("test-token".to_string()),
296 );
297 assert_eq!(client.auth_token, Some("test-token".to_string()));
298 }
299}