mirror of
https://github.com/getnora-io/nora.git
synced 2026-04-12 13:50:31 +00:00
- Add repo_index.rs with lazy rebuild on write operations - Double-checked locking to prevent race conditions - npm optimization: count tarballs instead of parsing metadata.json - Add pagination to all registry list pages (?page=1&limit=50) - Invalidate index on PUT/proxy cache in docker/maven/npm/pypi Performance: 500-800x faster list page loads after first rebuild
127 lines
3.8 KiB
Rust
127 lines
3.8 KiB
Rust
// Copyright (c) 2026 Volkov Pavel | DevITWay
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
use crate::activity_log::{ActionType, ActivityEntry};
|
|
use crate::AppState;
|
|
use axum::{
|
|
body::Bytes,
|
|
extract::{Path, State},
|
|
http::{header, StatusCode},
|
|
response::{IntoResponse, Response},
|
|
routing::get,
|
|
Router,
|
|
};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
pub fn routes() -> Router<Arc<AppState>> {
|
|
Router::new().route("/npm/{*path}", get(handle_request))
|
|
}
|
|
|
|
async fn handle_request(State(state): State<Arc<AppState>>, Path(path): Path<String>) -> Response {
|
|
// Determine if this is a tarball request or metadata request
|
|
let is_tarball = path.contains("/-/");
|
|
|
|
let key = if is_tarball {
|
|
let parts: Vec<&str> = path.split("/-/").collect();
|
|
if parts.len() == 2 {
|
|
format!("npm/{}/tarballs/{}", parts[0], parts[1])
|
|
} else {
|
|
format!("npm/{}", path)
|
|
}
|
|
} else {
|
|
format!("npm/{}/metadata.json", path)
|
|
};
|
|
|
|
// Extract package name for logging
|
|
let package_name = if is_tarball {
|
|
path.split("/-/").next().unwrap_or(&path).to_string()
|
|
} else {
|
|
path.clone()
|
|
};
|
|
|
|
// Try local storage first
|
|
if let Ok(data) = state.storage.get(&key).await {
|
|
if is_tarball {
|
|
state.metrics.record_download("npm");
|
|
state.metrics.record_cache_hit();
|
|
state.activity.push(ActivityEntry::new(
|
|
ActionType::CacheHit,
|
|
package_name,
|
|
"npm",
|
|
"CACHE",
|
|
));
|
|
}
|
|
return with_content_type(is_tarball, data).into_response();
|
|
}
|
|
|
|
// Try proxy if configured
|
|
if let Some(proxy_url) = &state.config.npm.proxy {
|
|
let url = if is_tarball {
|
|
// Tarball URL: https://registry.npmjs.org/package/-/package-version.tgz
|
|
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
|
|
} else {
|
|
// Metadata URL: https://registry.npmjs.org/package
|
|
format!("{}/{}", proxy_url.trim_end_matches('/'), path)
|
|
};
|
|
|
|
if let Ok(data) = fetch_from_proxy(&url, state.config.npm.proxy_timeout).await {
|
|
if is_tarball {
|
|
state.metrics.record_download("npm");
|
|
state.metrics.record_cache_miss();
|
|
state.activity.push(ActivityEntry::new(
|
|
ActionType::ProxyFetch,
|
|
package_name,
|
|
"npm",
|
|
"PROXY",
|
|
));
|
|
}
|
|
|
|
// Cache in local storage (fire and forget)
|
|
let storage = state.storage.clone();
|
|
let key_clone = key.clone();
|
|
let data_clone = data.clone();
|
|
tokio::spawn(async move {
|
|
let _ = storage.put(&key_clone, &data_clone).await;
|
|
});
|
|
|
|
// Invalidate index when caching new tarball
|
|
if is_tarball {
|
|
state.repo_index.invalidate("npm");
|
|
}
|
|
|
|
return with_content_type(is_tarball, data.into()).into_response();
|
|
}
|
|
}
|
|
|
|
StatusCode::NOT_FOUND.into_response()
|
|
}
|
|
|
|
async fn fetch_from_proxy(url: &str, timeout_secs: u64) -> Result<Vec<u8>, ()> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(timeout_secs))
|
|
.build()
|
|
.map_err(|_| ())?;
|
|
|
|
let response = client.get(url).send().await.map_err(|_| ())?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(());
|
|
}
|
|
|
|
response.bytes().await.map(|b| b.to_vec()).map_err(|_| ())
|
|
}
|
|
|
|
fn with_content_type(
|
|
is_tarball: bool,
|
|
data: Bytes,
|
|
) -> (StatusCode, [(header::HeaderName, &'static str); 1], Bytes) {
|
|
let content_type = if is_tarball {
|
|
"application/octet-stream"
|
|
} else {
|
|
"application/json"
|
|
};
|
|
|
|
(StatusCode::OK, [(header::CONTENT_TYPE, content_type)], data)
|
|
}
|