Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/docs/repositoryTypes/npm/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,16 @@ in front of public registries.
Any user with read access can install packages using the standard NPM tooling by pointing to the
repository endpoint. Cached assets are returned immediately; proxy mode automatically refreshes
missing packages from the configured upstreams.

### Yarn Classic

Yarn 1.x is supported for proxy repositories, including scoped packages. Configure the same
registry URL with a trailing slash:

```bash
yarn config set registry https://your-pkgly.example.com/repositories/storage/npm-proxy/
```

Yarn Classic requests scoped metadata using an encoded slash (for example,
`@babel%2Fcode-frame`). Pkgly rewrites the returned tarball URLs to the proxy, so both metadata
and archives are cached locally.
247 changes: 150 additions & 97 deletions pkgly/src/repository/npm/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use nr_core::{
},
storage::StoragePath,
};
use nr_storage::{DynStorage, FileContent, Storage, StorageFile};
use nr_storage::{DynStorage, FileContent, FileType, Storage, StorageFile, StorageFileMeta};
use parking_lot::{RwLock, RwLockReadGuard};
use serde::Deserialize;
use serde_json::Value;
Expand Down Expand Up @@ -100,31 +100,76 @@ async fn serve_cached_response(
storage: &DynStorage,
repository_id: Uuid,
path: &StoragePath,
cache_path: Option<&StoragePath>,
cache_path: &StoragePath,
is_metadata: bool,
) -> Result<Option<RepoResponse>, NPMRegistryError> {
if let Some(file) = storage.open_file(repository_id, path).await? {
if cache_path.is_none() {
if let Some(response) = rewrite_metadata_tarballs(parts, path, file).await? {
return Ok(Some(response));
}
if let Some(file) = storage.open_file(repository_id, path).await? {
return Ok(Some(file.into()));
}
return Ok(Some(RepoResponse::basic_text_response(
StatusCode::NOT_FOUND,
"File not found",
)));
}
let legacy_metadata_path = is_metadata.then_some(path);
let Some(file) =
open_cached_file(storage, repository_id, cache_path, legacy_metadata_path).await?
else {
return Ok(None);
};
if !is_metadata {
return Ok(Some(file.into()));
}
if let Some(response) = rewrite_metadata_tarballs(parts, path, file).await? {
return Ok(Some(response));
}

if let Some(cache_path) = cache_path {
if let Some(file) = storage.open_file(repository_id, cache_path).await? {
return Ok(Some(file.into()));
}
Ok(
open_cached_file(storage, repository_id, cache_path, legacy_metadata_path)
.await?
.map(Into::into),
)
}

async fn open_cached_file(
storage: &DynStorage,
repository_id: Uuid,
cache_path: &StoragePath,
legacy_metadata_path: Option<&StoragePath>,
) -> Result<Option<StorageFile>, NPMRegistryError> {
if let Some(file @ StorageFile::File { .. }) =
storage.open_file(repository_id, cache_path).await?
{
return Ok(Some(file));
}

Ok(None)
let Some(legacy_metadata_path) = legacy_metadata_path else {
return Ok(None);
};
Ok(storage
.open_file(repository_id, legacy_metadata_path)
.await?
.and_then(|file| match file {
file @ StorageFile::File { .. } => Some(file),
StorageFile::Directory { .. } => None,
}))
}

async fn cached_file_information(
storage: &DynStorage,
repository_id: Uuid,
cache_path: &StoragePath,
legacy_metadata_path: Option<&StoragePath>,
) -> Result<Option<StorageFileMeta<FileType>>, NPMRegistryError> {
let metadata = storage
.get_file_information(repository_id, cache_path)
.await?;
if metadata
.as_ref()
.is_some_and(|metadata| matches!(metadata.file_type(), FileType::File(_)))
{
return Ok(metadata);
}

let Some(legacy_metadata_path) = legacy_metadata_path else {
return Ok(None);
};
let metadata = storage
.get_file_information(repository_id, legacy_metadata_path)
.await?;
Ok(metadata.filter(|metadata| matches!(metadata.file_type(), FileType::File(_))))
}

impl NpmProxyRegistry {
Expand Down Expand Up @@ -212,57 +257,26 @@ impl NpmProxyRegistry {
url: url.to_string(),
error: err.to_string(),
})?;
let tarball_cache_path = cache_path_for_npm_proxy(path);
let cache_path = tarball_cache_path
.clone()
.unwrap_or_else(|| metadata_cache_path_for_npm_proxy(path));
match self
.storage()
.save_file(self.0.id, FileContent::Bytes(bytes.clone()), path)
.save_file(self.0.id, FileContent::Bytes(bytes.clone()), &cache_path)
.await
{
Ok(_) => {}
Err(nr_storage::StorageError::PathCollision(_)) => {
debug!(%url, "Skipping cache write for existing npm metadata file");
debug!(%url, ?cache_path, "Skipping cache write for existing npm resource");
}
Err(other) => return Err(other.into()),
}

let cache_path = cache_path_for_npm_proxy(path);
let canonical_path = if let Some(cache_path) = &cache_path {
if cache_path != path {
if let Err(err) = self
.storage()
.save_file(
self.0.id,
FileContent::Bytes(bytes.clone()),
cache_path,
)
.await
{
match err {
nr_storage::StorageError::PathCollision(_) => {
debug!(
?cache_path,
"Cache file already exists, skipping overwrite"
);
}
other => {
warn!(
?other,
?cache_path,
"Failed to persist npm proxy cache entry"
);
return Err(other.into());
}
}
}
}
cache_path.clone()
} else {
path.clone()
};

if cache_path.is_some() {
if tarball_cache_path.is_some() {
record_npm_proxy_cache_hit(
self.indexer().as_ref(),
&canonical_path,
&cache_path,
bytes.len() as u64,
Some(&url),
)
Expand Down Expand Up @@ -455,7 +469,11 @@ impl Repository for NpmProxyRegistry {

let path = request.path.clone();

let cache_path = cache_path_for_npm_proxy(&path);
let tarball_cache_path = cache_path_for_npm_proxy(&path);
let cache_path = tarball_cache_path
.clone()
.unwrap_or_else(|| metadata_cache_path_for_npm_proxy(&path));
let is_metadata = tarball_cache_path.is_none();
let storage = this.storage();

if path.is_directory() {
Expand All @@ -476,7 +494,8 @@ impl Repository for NpmProxyRegistry {
&storage,
this.id(),
&path,
cache_path.as_ref(),
&cache_path,
is_metadata,
)
.await?
{
Expand All @@ -489,7 +508,8 @@ impl Repository for NpmProxyRegistry {
&storage,
this.id(),
&path,
cache_path.as_ref(),
&cache_path,
is_metadata,
)
.await?
{
Expand Down Expand Up @@ -535,7 +555,10 @@ impl Repository for NpmProxyRegistry {

let path = request.path;

let cache_path = cache_path_for_npm_proxy(&path);
let tarball_cache_path = cache_path_for_npm_proxy(&path);
let is_metadata = tarball_cache_path.is_none();
let cache_path =
tarball_cache_path.unwrap_or_else(|| metadata_cache_path_for_npm_proxy(&path));

if path.is_directory() {
if let Some(response) = this
Expand All @@ -550,41 +573,24 @@ impl Repository for NpmProxyRegistry {
));
}

if let Some(meta) = this
.storage()
.get_file_information(this.id(), &path)
.await?
let storage = this.storage();
if let Some(meta) = cached_file_information(
&storage,
this.id(),
&cache_path,
is_metadata.then_some(&path),
)
.await?
{
return Ok(meta.into());
}

if let Some(cache_path) = &cache_path {
if let Some(meta) = this
.storage()
.get_file_information(this.id(), cache_path)
.await?
{
return Ok(meta.into());
}
}

if this.download_and_cache(&path, query.as_deref()).await? {
if let Some(meta) = this
.storage()
.get_file_information(this.id(), &path)
.await?
if let Some(meta) =
cached_file_information(&storage, this.id(), &cache_path, None).await?
{
return Ok(meta.into());
}
if let Some(cache_path) = &cache_path {
if let Some(meta) = this
.storage()
.get_file_information(this.id(), cache_path)
.await?
{
return Ok(meta.into());
}
}
}

if let Some(response) = this
Expand Down Expand Up @@ -635,6 +641,15 @@ fn cache_path_for_npm_proxy(path: &StoragePath) -> Option<StoragePath> {
)))
}

fn metadata_cache_path_for_npm_proxy(path: &StoragePath) -> StoragePath {
let path = path.to_string();
if path.is_empty() {
StoragePath::from("metadata/root.json")
} else {
StoragePath::from(format!("metadata/{path}"))
}
}

#[cfg(test)]
mod tests;

Expand Down Expand Up @@ -825,15 +840,11 @@ async fn rewrite_metadata_tarballs(
};
let scheme = parts.uri.scheme_str().unwrap_or("http");

// Compute repository base: full request path minus the requested package path.
let full_path = parts.uri.path();
let suffix = format!("/{}", requested_path.to_string());
let base_path = if let Some(stripped) = full_path.strip_suffix(&suffix) {
stripped
} else {
// Yarn Classic percent-encodes the slash in scoped package metadata paths.
let Some(base_path) = repository_base_path(parts.uri.path(), requested_path) else {
return Ok(None);
};
let mut base_path = base_path.to_string();
let mut base_path = base_path;
if !base_path.ends_with('/') {
base_path.push('/');
}
Expand Down Expand Up @@ -879,6 +890,48 @@ async fn rewrite_metadata_tarballs(
Ok(Some(RepoResponse::Other(builder.body(bytes))))
}

fn repository_base_path(full_path: &str, requested_path: &StoragePath) -> Option<String> {
let mut suffixes = vec![format!("/{requested_path}")];
let components: Vec<String> = requested_path
.clone()
.into_iter()
.map(String::from)
.collect();
if let [scope, package, rest @ ..] = components.as_slice()
&& scope.starts_with('@')
{
let rest = rest
.iter()
.map(|part| format!("/{part}"))
.collect::<String>();
suffixes.push(format!("/{scope}%2f{package}{rest}"));
}

let base_path = suffixes
.into_iter()
.find_map(|suffix| strip_suffix_ignore_ascii_case(full_path, &suffix))?;

// Axum strips `/repositories` before invoking the nested repository router.
// Return the canonical public endpoint so clients receive the same base they configure.
if base_path == "/repositories" || base_path.starts_with("/repositories/") {
Some(base_path.to_string())
} else {
Some(format!(
"/repositories/{}",
base_path.trim_start_matches('/')
))
}
}

fn strip_suffix_ignore_ascii_case<'a>(value: &'a str, suffix: &str) -> Option<&'a str> {
let start = value.len().checked_sub(suffix.len())?;
let prefix = value.get(..start)?;
value
.get(start..)?
.eq_ignore_ascii_case(suffix)
.then_some(prefix)
}

fn build_head_response(response: reqwest::Response) -> RepoResponse {
use http::header::{CONTENT_LENGTH, CONTENT_TYPE};

Expand Down
Loading