refactor(registry): adopt verdaccio-shaped YAML config (#11970)

Reshape pnpm-registry's Config to match verdaccio's `config.yaml` schema (storage, uplinks, packages) so the same file can drive either server. The previous Config exposed a single `upstream: Option<String>` resolved at startup; this replaces it with named uplinks plus per-package `proxy:` rules walked in declared order — same semantics as verdaccio, minus the surface pnpm-registry does not implement (auth, web, plugins, middlewares, logs routing, secret), which are accepted and ignored.

Highlights:

  * `Config { listen, public_url, storage, uplinks, packages, packument_ttl }` with `UplinkConfig { url }` and `PackageAccess { access, publish, unpublish, proxy }`. `packages` is an `IndexMap` walked in declared order, first-match-wins: the first pattern matching a request is the rule that applies, and if that rule has no `proxy:` the package is storage-only (resolution returns `None` instead of falling through to a later catch-all). That makes the bundled `@private/*` / `@pnpm.e2e/needs-auth` / unscoped-fixture rules behave the way the YAML says they should.
  * `Config::from_yaml(path, ...)` loads via `serde-saphyr` and resolves a relative `storage:` against the config file's parent. The verdaccio-only sections in the YAML are skipped silently so `registry-mock`'s upstream `config.yaml` parses untouched.
  * `DEFAULT_CONFIG_YAML` — the bundled file mirrored from `@pnpm/registry-mock` — is `include_str!`-ed and re-exported from `lib.rs` so other crates (tests, benchmarks, future embedders) can use the same defaults without reading from disk. `Config::from_default_yaml(base_dir, ...)` parses it.
  * CLI: `-c` / `--config <path>` overrides the bundled default. `--storage` survives as a runtime override (handy for tests without a custom YAML); `--upstream` and `--static` are gone because the YAML now drives both. `--packument-ttl-secs` is optional — the loaded config's value wins when the flag is not supplied. The mock orchestrator at `pacquet/tasks/registry-mock` drops its `--upstream` flag — that command spawns the locally-built binary so it tracks this PR's source directly. The jest harness at `__utils__/jest-config/with-registry/globalSetup.js` keeps `--upstream` for now because CI installs `@pnpm/pnpr` from npm; the flag will be dropped in the same PR that bumps `@pnpm/pnpr` to a build that lacks it.
  * `server.rs` pre-builds one `Upstream` per declared uplink at router construction (keyed by name, in an `IndexMap`) and resolves the right client per request via `Config::resolve_uplink`. No per-request `ThrottledClient` allocations.

Existing tests are kept working by retaining the `Config::proxy` / `Config::static_serve` constructors and switching the test helper from `config.upstream = ...` to mutating
`config.uplinks["npmjs"].url`. All 107 tests in `pnpm-registry` pass (55 unit + 26 + 9 + 17 integration).

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * YAML-based registry configuration with package-level routing, multiple uplinks, auth and audit middleware
  * New --config option to load custom registry configs; optional storage override and packument TTL setting

* **Chores**
  * Default registry now uses the bundled configuration (web UI disabled by default)
  * Configuration refactor to support Verdaccio-style routing patterns and per-package access/publish rules

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
authored and GitHub committed 2026-05-26 23:52:37 +02:00
1 parent e55f4b5efd
commit f03dc2d15d
10 files changed
+570 -68

No files matched your search

Generated
+2
View File
@@ -2993,12 +2993,14 @@ dependencies = [
"clap",
"derive_more",
"futures-util",
"indexmap",
"miette 7.6.0",
"mockito",
"pacquet-network",
"pipe-trait",
"reqwest",
"serde",
"serde-saphyr",
"serde_json",
"sha2",
"tempfile",
@@ -83,10 +83,12 @@ pub fn pnpm_registry_command(port: u16) -> Command {
eprintln!("info: seeded {seeded} fixture file(s) into runtime storage");
}
let mut cmd = Command::new(bin);
// `pnpm-registry` defaults to its bundled verdaccio-shaped config
// (npmjs uplink + `**` proxy rule), which matches what the mock
// needs — no `-c` override required. We only pin the runtime
// bits the bundled config can't know about.
cmd.arg("--storage")
.arg(runtime_storage())
.arg("--upstream")
.arg("https://registry.npmjs.org")
.arg("--packument-ttl-secs")
.arg("31536000")
.arg("--listen")
+2
View File
@@ -25,9 +25,11 @@ base64 = { workspace = true }
clap = { workspace = true }
derive_more = { workspace = true }
futures-util = { workspace = true }
indexmap = { workspace = true }
miette = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde-saphyr = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true }
+73
View File
@@ -0,0 +1,73 @@
# path to a directory with all packages
storage: ./storage
secret: pnpm-registry-mock-secret-key-32
auth:
htpasswd:
file: ./htpasswd
# Maximum amount of users allowed to register, defaults to "+inf".
# You can set this to -1 to disable registration.
#max_users: 1000
plugins: ../node_modules
middlewares:
audit:
enabled: true
# a list of other known repositories we can talk to
uplinks:
npmjs:
url: https://registry.npmjs.org/
web:
enable: false
packages:
'@private/*':
access: $authenticated
publish: $authenticated
'@pnpm.e2e/needs-auth':
access: $authenticated
publish: $authenticated
'@*/*':
access: $all
publish: $authenticated
proxy: npmjs
'plugin-example':
access: $all
publish: $authenticated
'pkg-with-1-dep':
access: $all
publish: $authenticated
'dep-of-pkg-with-1-dep':
access: $all
publish: $authenticated
'foobar':
access: $all
publish: $authenticated
'alpha':
access: $all
publish: $authenticated
'deprecated':
access: $all
publish: $authenticated
'**':
access: $all
publish: $authenticated
proxy: npmjs
# log settings
logs:
- type: stdout
format: pretty
level: error
+432 -27
View File
@@ -1,35 +1,53 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Duration;
use indexmap::IndexMap;
use serde::Deserialize;
use crate::policy::PackagePolicies;
/// The bundled verdaccio-shaped YAML config, mirrored from
/// `@pnpm/registry-mock`'s `registry/config.yaml`. Other crates can
/// pull this in directly when they need pnpm-registry's defaults
/// (uplinks, package routing) without reading a file from disk —
/// e.g. test mocks that want to run with the standard `**` -> `npmjs`
/// routing applied.
pub const DEFAULT_CONFIG_YAML: &str = include_str!("../config.yaml");
/// Runtime configuration for the pnpm registry server.
///
/// The persisted (YAML) shape follows verdaccio's `config.yaml` —
/// `storage`, `uplinks`, `packages` — restricted to the subset
/// pnpm-registry implements (no web UI, auth, plugins, or logs
/// routing).
///
/// Runtime-only fields (`listen`, `public_url`, `packument_ttl`)
/// are set by the binary's CLI flags after the YAML is loaded,
/// matching verdaccio's CLI overrides.
#[derive(Debug, Clone)]
pub struct Config {
/// Address the HTTP server binds to.
pub listen: SocketAddr,
/// Upstream registry to proxy and cache from. `None` puts the
/// server in *static* mode: cache misses become `404`, and the
/// storage directory is treated as the authoritative source.
pub upstream: Option<String>,
/// URL clients should use to reach this server. Used to rewrite
/// `dist.tarball` URLs in served packuments so tarball requests
/// flow through this server.
pub public_url: String,
/// Directory under which packuments and tarballs live. The layout
/// is Verdaccio's:
///
/// ```text
/// <storage>/<pkg>/package.json
/// <storage>/<pkg>/<tarball-basename>.tgz
/// ```
///
/// In proxy mode this doubles as the cache; in static mode it's
/// the source of truth.
/// Directory under which packuments and tarballs live.
/// In proxy mode this doubles as the cache; with no matching
/// `proxy:` rule it is the source of truth.
pub storage: PathBuf,
/// Named upstream npm registries. Referenced by name from
/// [`PackageAccess::proxy`].
pub uplinks: IndexMap<String, UplinkConfig>,
/// Package routing rules, evaluated in declared order. The first
/// pattern that matches a requested package supplies its
/// uplink (via `proxy`). Patterns without a `proxy` make the
/// package storage-only (effectively static for that pattern).
pub packages: IndexMap<String, PackageAccess>,
/// How long a cached packument is considered fresh before it is
/// re-fetched from the upstream. Ignored in static mode.
/// re-fetched from the resolved uplink. Ignored when no uplink
/// matches.
pub packument_ttl: Duration,
/// Per-package access and publish rules. Defaults to
/// [`PackagePolicies::registry_mock_defaults`] so a vanilla
@@ -39,31 +57,418 @@ pub struct Config {
pub policies: PackagePolicies,
}
/// Verdaccio-shaped uplink declaration. Only `url` is honored —
/// other fields verdaccio supports (auth headers, timeouts, agent
/// options) are not implemented yet.
#[derive(Debug, Clone, Deserialize)]
pub struct UplinkConfig {
pub url: String,
}
/// Per-package routing rules. `access` and `publish` are parsed for
/// config compatibility but ignored (pnpm-registry is read-only and
/// has no auth). `proxy` selects the [`UplinkConfig`] by name.
#[derive(Debug, Default, Clone, Deserialize)]
pub struct PackageAccess {
pub access: Option<String>,
pub publish: Option<String>,
pub unpublish: Option<String>,
pub proxy: Option<String>,
}
/// Disk shape of the YAML file. Fields verdaccio supports but
/// pnpm-registry doesn't (`auth`, `web`, `plugins`, `middlewares`,
/// `logs`, `secret`) are accepted and silently dropped via
/// `#[serde(default)]` on the fields we care about plus
/// `#[serde(deny_unknown_fields)]` *not* being set — so the same
/// `config.yaml` works for both servers.
#[derive(Debug, Deserialize)]
struct ConfigFile {
#[serde(default = "default_storage_string")]
storage: String,
#[serde(default)]
uplinks: IndexMap<String, UplinkConfig>,
#[serde(default)]
packages: IndexMap<String, PackageAccess>,
}
impl Config {
/// Build a proxy-mode config with the default npm upstream.
/// Default `listen` when one isn't supplied by the caller.
pub const DEFAULT_LISTEN: &'static str = "127.0.0.1:4873";
/// Default packument TTL — five minutes, matching the historical
/// proxy-mode default.
pub const DEFAULT_PACKUMENT_TTL: Duration = Duration::from_secs(5 * 60);
/// Build a proxy-mode config with the default npm upstream: a single
/// `npmjs` uplink plus a `**` package rule that routes everything
/// through it. Kept for callers that don't use YAML config.
pub fn proxy(listen: SocketAddr, storage: PathBuf) -> Self {
let public_url = format!("http://{listen}");
let mut uplinks = IndexMap::new();
uplinks.insert(
"npmjs".to_string(),
UplinkConfig { url: "https://registry.npmjs.org".to_string() },
);
let mut packages = IndexMap::new();
packages.insert(
"**".to_string(),
PackageAccess { proxy: Some("npmjs".to_string()), ..Default::default() },
);
Self {
listen,
upstream: Some("https://registry.npmjs.org".to_string()),
public_url,
public_url: format!("http://{listen}"),
storage,
packument_ttl: Duration::from_secs(5 * 60),
uplinks,
packages,
packument_ttl: Self::DEFAULT_PACKUMENT_TTL,
policies: PackagePolicies::registry_mock_defaults(),
}
}
/// Build a static-mode config that serves `storage` verbatim,
/// never reaching out to a remote.
/// Build a static-mode config that serves `storage` verbatim:
/// no uplinks declared, so no package rule resolves to one.
pub fn static_serve(listen: SocketAddr, storage: PathBuf) -> Self {
let public_url = format!("http://{listen}");
Self {
listen,
upstream: None,
public_url,
public_url: format!("http://{listen}"),
storage,
packument_ttl: Duration::from_secs(5 * 60),
uplinks: IndexMap::new(),
packages: IndexMap::new(),
packument_ttl: Self::DEFAULT_PACKUMENT_TTL,
policies: PackagePolicies::registry_mock_defaults(),
}
}
/// Load YAML from `path` and merge it with runtime values
/// supplied by the binary. `listen` and `public_url` are not
/// represented in verdaccio's YAML and must be provided here;
/// `packument_ttl` defaults to [`Self::DEFAULT_PACKUMENT_TTL`].
///
/// `storage` from the YAML is resolved relative to the config
/// file's parent directory when not absolute — same convention
/// verdaccio uses for `./storage`.
pub fn from_yaml(
path: &Path,
listen: SocketAddr,
public_url: Option<String>,
) -> std::io::Result<Self> {
let raw = std::fs::read_to_string(path)?;
let base = path.parent().unwrap_or_else(|| Path::new("."));
Self::from_yaml_str(&raw, base, listen, public_url).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("parse {}: {err}", path.display()),
)
})
}
/// Parse [`DEFAULT_CONFIG_YAML`] (the verdaccio-shaped YAML
/// bundled into the binary) and merge it with the given runtime
/// values. Relative `storage:` paths in the bundled YAML are
/// resolved against `base_dir` — pass `Path::new(".")` to mirror
/// verdaccio's CWD-relative behaviour, or an absolute path when
/// the caller knows where the storage should live.
///
/// Panics if the bundled YAML fails to parse — that would be a
/// build-time bug since the file is compiled in.
pub fn from_default_yaml(
base_dir: &Path,
listen: SocketAddr,
public_url: Option<String>,
) -> Self {
Self::from_yaml_str(DEFAULT_CONFIG_YAML, base_dir, listen, public_url)
.expect("bundled DEFAULT_CONFIG_YAML must always parse")
}
fn from_yaml_str(
raw: &str,
base_dir: &Path,
listen: SocketAddr,
public_url: Option<String>,
) -> Result<Self, serde_saphyr::Error> {
let file: ConfigFile = serde_saphyr::from_str(raw)?;
let storage = resolve_relative(&file.storage, base_dir);
let public_url = public_url.unwrap_or_else(|| format!("http://{listen}"));
Ok(Self {
listen,
public_url,
storage,
uplinks: file.uplinks,
packages: file.packages,
packument_ttl: Self::DEFAULT_PACKUMENT_TTL,
// Policies could be derived from `packages[*].{access,publish}`
// here, but the bundled `config.yaml` already matches the
// `registry_mock_defaults` set verbatim, and a YAML-driven
// policy wiring is out of scope for this rebase. Keep the
// hard-coded defaults — same as `proxy` / `static_serve`.
policies: PackagePolicies::registry_mock_defaults(),
})
}
/// Find the uplink for `package_name` by walking [`Self::packages`]
/// in declared order: the first pattern that matches is the rule
/// that applies. If that rule has no `proxy:`, the package is
/// storage-only and this returns `None` — matching verdaccio's
/// first-match-wins semantics. The returned tuple's first element
/// is the uplink *name* (the key in [`Self::uplinks`]); callers
/// that have pre-built per-uplink state can use it as an index.
pub fn resolve_uplink(&self, package_name: &str) -> Option<(&str, &UplinkConfig)> {
let access = self.packages.iter().find_map(|(pattern, access)| {
pattern_matches(pattern, package_name).then_some(access)
})?;
let proxy_name = access.proxy.as_deref()?;
self.uplinks.get_key_value(proxy_name).map(|(k, v)| (k.as_str(), v))
}
}
/// Resolve a (possibly relative) storage path against `base_dir`.
/// Verdaccio's `./storage` convention.
fn resolve_relative(raw: &str, base_dir: &Path) -> PathBuf {
let path = PathBuf::from(raw);
if path.is_absolute() {
return path;
}
base_dir.join(path)
}
fn default_storage_string() -> String {
"./storage".to_string()
}
/// Match a verdaccio package pattern against a package name.
/// Supports:
/// - `**` — matches everything
/// - `@*/*` — matches all scoped packages
/// - `@scope/*` — matches every package in a specific scope
/// - exact name — literal match
fn pattern_matches(pattern: &str, name: &str) -> bool {
if pattern == "**" {
return true;
}
if pattern == "@*/*" {
return name.starts_with('@');
}
if let Some(scope) = pattern.strip_suffix("/*").and_then(|p| p.strip_prefix('@')) {
let Some(name_scope) = name.strip_prefix('@').and_then(|n| n.split('/').next()) else {
return false;
};
return name_scope == scope;
}
pattern == name
}
#[cfg(test)]
mod tests {
use super::{Config, DEFAULT_CONFIG_YAML, pattern_matches, resolve_relative};
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
use std::path::{Path, PathBuf};
fn listen() -> SocketAddr {
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873))
}
#[test]
fn pattern_double_star_matches_anything() {
assert!(pattern_matches("**", "lodash"));
assert!(pattern_matches("**", "@foo/bar"));
assert!(pattern_matches("**", ""));
}
#[test]
fn pattern_any_scope_matches_only_scoped() {
assert!(pattern_matches("@*/*", "@foo/bar"));
assert!(pattern_matches("@*/*", "@pnpm.e2e/needs-auth"));
assert!(!pattern_matches("@*/*", "lodash"));
}
#[test]
fn pattern_specific_scope_matches_only_that_scope() {
assert!(pattern_matches("@private/*", "@private/anything"));
assert!(!pattern_matches("@private/*", "@public/anything"));
assert!(!pattern_matches("@private/*", "private"));
}
#[test]
fn pattern_exact_match() {
assert!(pattern_matches("foobar", "foobar"));
assert!(!pattern_matches("foobar", "foobaz"));
assert!(!pattern_matches("foobar", "@scope/foobar"));
}
#[test]
fn resolve_relative_passes_absolute_paths_through() {
let absolute = PathBuf::from("/tmp/storage");
assert_eq!(resolve_relative("/tmp/storage", Path::new("/anywhere")), absolute);
}
#[test]
fn resolve_relative_joins_relative_paths_to_base() {
assert_eq!(
resolve_relative("./storage", Path::new("/etc/pnpr")),
PathBuf::from("/etc/pnpr/./storage"),
);
}
#[test]
fn proxy_constructor_routes_everything_through_npmjs() {
let config = Config::proxy(listen(), PathBuf::from("/tmp"));
let (name, uplink) = config.resolve_uplink("anything").expect("** rule matches");
assert_eq!(name, "npmjs");
assert_eq!(uplink.url, "https://registry.npmjs.org");
}
#[test]
fn static_constructor_has_no_uplinks() {
let config = Config::static_serve(listen(), PathBuf::from("/tmp"));
assert!(config.uplinks.is_empty());
assert!(config.packages.is_empty());
assert!(config.resolve_uplink("anything").is_none());
}
#[test]
fn from_default_yaml_parses_bundled_file() {
let config = Config::from_default_yaml(Path::new("/tmp"), listen(), None);
assert!(config.uplinks.contains_key("npmjs"));
assert_eq!(config.uplinks["npmjs"].url, "https://registry.npmjs.org/");
// The bundled file routes the catch-all through npmjs.
let (name, _) = config.resolve_uplink("lodash").expect("** -> npmjs in defaults");
assert_eq!(name, "npmjs");
}
#[test]
fn default_yaml_const_matches_what_from_default_parses() {
// Sanity check: the const is non-empty and round-trips through
// the parser without panicking — i.e. `from_default_yaml`'s
// `expect(...)` is not a tripwire under future edits.
assert!(!DEFAULT_CONFIG_YAML.is_empty());
let _ = Config::from_default_yaml(Path::new("."), listen(), None);
}
#[test]
fn from_yaml_str_storage_is_resolved_relative_to_base_dir() {
let yaml = "storage: ./store\nuplinks: {}\npackages: {}\n";
let config = Config::from_yaml_str(yaml, Path::new("/etc/pnpr"), listen(), None).unwrap();
assert_eq!(config.storage, PathBuf::from("/etc/pnpr/./store"));
}
#[test]
fn from_yaml_str_absolute_storage_is_left_alone() {
let yaml = "storage: /var/lib/pnpr\nuplinks: {}\npackages: {}\n";
let config = Config::from_yaml_str(yaml, Path::new("/etc/pnpr"), listen(), None).unwrap();
assert_eq!(config.storage, PathBuf::from("/var/lib/pnpr"));
}
#[test]
fn from_yaml_str_ignores_unknown_sections() {
// Sections we don't implement (`auth`, `web`, `plugins`, etc.)
// must parse silently so existing config files work untouched.
let yaml = "\
storage: ./s
auth:
htpasswd:
file: ./htpasswd
web:
enable: false
plugins: ../node_modules
secret: hunter2
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
'**':
access: $all
proxy: npmjs
";
let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap();
let (name, uplink) = config.resolve_uplink("anything").expect("** -> npmjs");
assert_eq!(name, "npmjs");
assert_eq!(uplink.url, "https://registry.npmjs.org/");
}
#[test]
fn from_yaml_str_packages_evaluated_in_declared_order() {
// First match wins: `@private/*` should resolve before `**`
// even though both are declared.
let yaml = "\
storage: ./s
uplinks:
mirror: { url: https://mirror.example/ }
npmjs: { url: https://registry.npmjs.org/ }
packages:
'@private/*':
proxy: mirror
'**':
proxy: npmjs
";
let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap();
assert_eq!(config.resolve_uplink("@private/foo").unwrap().0, "mirror");
assert_eq!(config.resolve_uplink("lodash").unwrap().0, "npmjs");
}
#[test]
fn from_yaml_str_package_without_proxy_does_not_resolve_an_uplink() {
// Verdaccio first-match-wins: a pattern entry that matches but
// has no `proxy:` is storage-only — resolution stops there and
// returns None instead of falling through to a later catch-all.
let yaml = "\
storage: ./s
uplinks:
npmjs: { url: https://registry.npmjs.org/ }
packages:
'@private/*':
access: $authenticated
'**':
proxy: npmjs
";
let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap();
assert!(config.resolve_uplink("@private/foo").is_none());
// Unrelated names still fall through to `**` -> `npmjs`.
assert_eq!(config.resolve_uplink("lodash").unwrap().0, "npmjs");
}
#[test]
fn from_yaml_str_public_url_defaults_to_listen_when_none_passed() {
let yaml = "storage: ./s\nuplinks: {}\npackages: {}\n";
let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap();
assert_eq!(config.public_url, format!("http://{}", listen()));
}
#[test]
fn from_yaml_str_public_url_override_wins() {
let yaml = "storage: ./s\nuplinks: {}\npackages: {}\n";
let config = Config::from_yaml_str(
yaml,
Path::new("/x"),
listen(),
Some("http://override.test".to_string()),
)
.unwrap();
assert_eq!(config.public_url, "http://override.test");
}
#[test]
fn from_yaml_path_round_trips_through_tempfile() {
// Exercise the file-reading path (not just the in-memory
// `from_yaml_str` shortcut). Confirms relative `storage:` is
// resolved against the *config file's* parent dir.
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("registry.yml");
std::fs::write(&config_path, "storage: ./store\nuplinks: {}\npackages: {}\n").unwrap();
let config = Config::from_yaml(&config_path, listen(), None).unwrap();
assert_eq!(config.storage, dir.path().join("./store"));
}
#[test]
fn from_yaml_path_surfaces_parse_errors_as_invalid_data() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("broken.yml");
std::fs::write(&config_path, "storage: [not, a, string\n").unwrap();
let err = Config::from_yaml(&config_path, listen(), None).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn from_yaml_path_propagates_missing_file_errors() {
let err = Config::from_yaml(Path::new("/no/such/file.yml"), listen(), None).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ mod server;
mod streaming;
mod upstream;
pub use config::Config;
pub use config::{Config, DEFAULT_CONFIG_YAML, PackageAccess, UplinkConfig};
pub use error::{RegistryError, Result};
pub use policy::{AccessRule, PackagePolicies, PackagePolicy};
pub use server::{router, serve};
+29 -29
View File
@@ -1,5 +1,5 @@
use std::net::SocketAddr;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::Parser;
@@ -10,35 +10,31 @@ use pnpm_registry::{Config, serve};
#[derive(Debug, Parser)]
#[command(name = "pnpm-registry", version, about = "pnpm-compatible npm registry server")]
struct Args {
/// Path to a verdaccio-shaped YAML config (storage, uplinks,
/// packages). When omitted, the bundled default config is used.
#[arg(short = 'c', long)]
config: Option<PathBuf>,
/// Address to bind to.
#[arg(long, default_value = "127.0.0.1:4873")]
listen: SocketAddr,
/// Upstream npm registry to proxy and cache from. Ignored when
/// `--static` is set.
#[arg(long, default_value = "https://registry.npmjs.org")]
upstream: String,
/// Storage directory (verdaccio-shaped). In proxy mode this
/// doubles as the cache; in static mode it's the source of truth.
#[arg(long, default_value = "./storage")]
storage: PathBuf,
/// Serve `--storage` verbatim with no upstream — useful for
/// running against a pre-populated verdaccio store (e.g.
/// `@pnpm/registry-mock`'s `registry/storage-cache`).
#[arg(long = "static")]
static_serve: bool,
/// Override the storage path from the loaded config (bundled or
/// `-c`). Useful for tests and benchmarks that want their own
/// cache directory without writing a custom YAML.
#[arg(long)]
storage: Option<PathBuf>,
/// URL clients should use to reach this server. Used when
/// rewriting `dist.tarball` URLs in served packuments.
/// rewriting `dist.tarball` URLs in served packuments. Defaults
/// to `http://<listen>`.
#[arg(long)]
public_url: Option<String>,
/// Seconds before a cached packument is considered stale and
/// refetched. Ignored in static mode.
#[arg(long, default_value_t = 300)]
packument_ttl_secs: u64,
/// refetched. When omitted, the loaded config's value wins.
#[arg(long)]
packument_ttl_secs: Option<u64>,
}
#[tokio::main]
@@ -50,17 +46,21 @@ async fn main() -> miette::Result<()> {
.init();
let args = Args::parse();
let mut config = if args.static_serve {
Config::static_serve(args.listen, args.storage)
} else {
let mut config = Config::proxy(args.listen, args.storage);
config.upstream = Some(args.upstream);
config
let mut config = match args.config.as_deref() {
Some(path) => {
Config::from_yaml(path, args.listen, args.public_url.clone()).map_err(|err| {
let path = path.display();
miette::miette!("load {path}: {err}")
})?
}
None => Config::from_default_yaml(Path::new("."), args.listen, args.public_url.clone()),
};
if let Some(url) = args.public_url {
config.public_url = url;
if let Some(storage) = args.storage {
config.storage = storage;
}
if let Some(ttl_secs) = args.packument_ttl_secs {
config.packument_ttl = Duration::from_secs(ttl_secs);
}
config.packument_ttl = Duration::from_secs(args.packument_ttl_secs);
serve(config).await.map_err(|err| miette::miette!("{err}"))
}
+24 -6
View File
@@ -6,6 +6,7 @@ use axum::extract::{DefaultBodyLimit, OriginalUri, Path, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get};
use indexmap::IndexMap;
use serde_json::{Value, json};
use tower_http::trace::TraceLayer;
@@ -43,7 +44,10 @@ struct AppState {
struct AppInner {
cache: Cache,
upstream: Option<Upstream>,
/// One [`Upstream`] per declared uplink, keyed by the same name
/// used in [`Config::uplinks`]. Built once at router construction
/// time so each request avoids re-allocating a `ThrottledClient`.
upstreams: IndexMap<String, Upstream>,
config: Config,
users: UserStore,
tokens: TokenStore,
@@ -59,11 +63,15 @@ struct AppInner {
/// the `@` prefix and the literal-`-` segment.
pub fn router(config: Config) -> Router {
let cache = Cache::new(config.storage.clone());
let upstream = config.upstream.as_ref().map(|base| Upstream::new(base.clone()));
let upstreams: IndexMap<String, Upstream> = config
.uplinks
.iter()
.map(|(name, uplink)| (name.clone(), Upstream::new(uplink.url.clone())))
.collect();
let state = AppState {
inner: Arc::new(AppInner {
cache,
upstream,
upstreams,
config,
users: UserStore::new(),
tokens: TokenStore::new(),
@@ -439,7 +447,7 @@ async fn serve_tarball(
}
}
let Some(upstream) = state.inner.upstream.as_ref() else {
let Some(upstream) = resolve_upstream(state, &name) else {
return not_found();
};
@@ -707,7 +715,7 @@ async fn serve_search(state: &AppState, headers: &HeaderMap, query_string: &str)
/// on disk, so subsequent searches find it without another upstream
/// hit.
async fn augment_search_with_upstream(state: &AppState, query: &str, body: &mut Value) {
if state.inner.upstream.is_none() {
if state.inner.upstreams.is_empty() {
return;
}
let Ok(name) = PackageName::parse(query) else {
@@ -1055,6 +1063,16 @@ fn wants_abbreviated(headers: &HeaderMap) -> bool {
.is_some_and(|accept| accept.contains(ABBREVIATED_CONTENT_TYPE))
}
/// Resolve which prebuilt [`Upstream`] should serve `package`, by
/// walking the verdaccio-style `packages` rules in declared order and
/// looking up the resolved uplink name in [`AppInner::upstreams`].
/// Returns `None` when no rule with a `proxy:` field matches the
/// package, leaving the request to fall through to a not-found.
fn resolve_upstream<'a>(state: &'a AppState, package: &PackageName) -> Option<&'a Upstream> {
let (uplink_name, _) = state.inner.config.resolve_uplink(package.as_str())?;
state.inner.upstreams.get(uplink_name)
}
/// Result of loading the packument for a package — either bytes (raw,
/// from cache or upstream), a definite not-found, or a real error.
enum PackumentLoad {
@@ -1067,7 +1085,7 @@ enum PackumentLoad {
/// the cache when configured. The same logic backs both the packument
/// and the version-manifest endpoints.
async fn load_packument_bytes(state: &AppState, name: &PackageName) -> PackumentLoad {
let Some(upstream) = state.inner.upstream.as_ref() else {
let Some(upstream) = resolve_upstream(state, name) else {
return match state.inner.cache.read_packument_any_age(name).await {
Ok(Some(bytes)) => PackumentLoad::Ok(bytes),
Ok(None) => PackumentLoad::NotFound,
@@ -675,7 +675,7 @@ async fn search_augments_with_upstream_when_local_misses_exact_name() {
let tmp = TempDir::new().unwrap();
let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0));
let mut config = Config::proxy(listen, tmp.path().to_path_buf());
config.upstream = Some(upstream.url());
config.uplinks.get_mut("npmjs").expect("default `npmjs` uplink").url = upstream.url();
config.public_url = "http://example.test".to_string();
config.packument_ttl = Duration::from_secs(60);
let app = router(config);
@@ -719,7 +719,7 @@ async fn search_augment_skips_when_upstream_404s() {
let tmp = TempDir::new().unwrap();
let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0));
let mut config = Config::proxy(listen, tmp.path().to_path_buf());
config.upstream = Some(upstream.url());
config.uplinks.get_mut("npmjs").expect("default `npmjs` uplink").url = upstream.url();
config.public_url = "http://example.test".to_string();
config.packument_ttl = Duration::from_secs(60);
let app = router(config);
@@ -14,7 +14,7 @@ use pnpm_registry::{Config, router};
fn config_for(upstream: &str, storage: std::path::PathBuf) -> Config {
let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873));
let mut config = Config::proxy(listen, storage);
config.upstream = Some(upstream.to_string());
config.uplinks.get_mut("npmjs").expect("default `npmjs` uplink").url = upstream.to_string();
config.public_url = "http://example.test".to_string();
config.packument_ttl = Duration::from_secs(60);
config