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:
10 files changed
+570
-68
No files matched your search
Generated
+2
@@ -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")
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user