@packall/registry-npm
The npm-protocol backend, plus .npmrc parsing, credentials and retries.
import { … } from "@packall/registry-npm";Functions
authorizationHeader
Renders credentials as an Authorization header value, if any.
Stays Redacted on the way out: the caller unwraps it at the exact point it
becomes a header, and from there Effect's own Headers redaction takes over
— authorization is in its default redacted-name list, so the value does not
reappear in logs or traces.
const authorizationHeader: (credentials: Credentials) => Redacted<string> | undefinedconnectTimeoutFor
How long establishing a connection may take.
This is a transport setting — the caller wires it into the HTTP client's dispatcher — but the value belongs here, with the other deadlines it has to stay consistent with.
It matters that this is not left to undici's own default: an unanswered SYN keeps the socket, and therefore Node's event loop, alive past the point where the run has already reported the registry down — which is indistinguishable from a hang. Tying it to the same ceiling as preflight means the socket dies exactly when the run says it has.
const connectTimeoutFor: (requestTimeoutMs: number) => numbercredentialKeys
Every credential key to try, most specific first.
npm allows credentials to be configured against a path prefix, so a token
set on //host/ covers //host/api/npm/repo/. Walking the path outwards is
what implements that.
const credentialKeys: (registry: string) => readonly string[]credentialsFor
Resolves credentials for a registry.
Precedence follows npm: _authToken (bearer), then _auth (pre-encoded
basic), then username + _password (base64-encoded password).
const credentialsFor: (config: NpmrcConfig, registry: string) => CredentialsdefaultRegistry
The default registry, unless a scope overrides it.
const defaultRegistry: (config: NpmrcConfig, fallback?: string) => stringdescribeTransport
Describes a transport failure as its innermost cause does.
The code and the message together where both exist, because neither is
sufficient alone: ENOTFOUND does not say which host, and "unable to get
local issuer certificate" is not what anybody types into a search box.
const describeTransport: (cause: unknown) => stringencodePackageName
@scope/name -> @scope%2fname, which is how the registry addresses it.
const encodePackageName: (name: string) => stringisTransient
Which failures are worth another attempt.
const isTransient: (error: BundlerError) => booleanlayer
Provides Registry backed by the npm protocol.
Requires an HttpClient; the CLI supplies the Undici-backed Node one.
const layer: (options?: NpmRegistryOptions) => Layer<Registry, never, HttpClient>make
Builds the registry service.
const make: (options?: NpmRegistryOptions) => Effect<Service, never, HttpClient>mergeNpmrc
Merges configs, earliest-wins.
Call with the most specific source first: project .npmrc, then user, then
global. That ordering matches npm's precedence.
const mergeNpmrc: (...configs: readonly NpmrcConfig[]) => NpmrcConfignerfDart
Turns a registry URL into npm's credential key prefix — the "nerf dart".
https://artifactory.corp/api/npm/npm-remote/ becomes
//artifactory.corp/api/npm/npm-remote/.
const nerfDart: (registry: string) => stringnormalizeRegistry
Ensures a registry URL ends in exactly one slash.
const normalizeRegistry: (registry: string) => stringparseNpmrc
Parses .npmrc content.
Handles the subset that matters: key=value, #/; comments, quoted
values, and ${VAR} expansion from the environment. Section headers
([section]) are ignored rather than rejected — npm does not use them, but
hand-edited files occasionally contain them.
env is required rather than defaulting to process.env. A default would
read as harmless and then, somewhere without a process, silently expand
every ${NPM_TOKEN} to nothing — producing an Authorization header built
from an empty string and a 401 that explains none of it.
const parseNpmrc: (content: string, env: Readonly<Record<string, string | undefined>>) => NpmrcConfigparsePackument
const parsePackument: (name: string, body: unknown) => Packument | nullpreflightTimeoutFor
The ceiling on the preflight check.
Preflight exists so a dead network is reported before any real work starts,
which means it has to respect a caller asking for something tighter than the
default: without the min, --timeout 3000 would still sit for the full
ten seconds before reporting the registry down.
const preflightTimeoutFor: (options: {
readonly preflightTimeoutMs?: number | undefined;
}, requestTimeout: number) => numberregistryForPackage
The registry for a package, honouring @scope:registry entries.
const registryForPackage: (config: NpmrcConfig, packageName: string, fallback?: string | undefined) => stringretrying
Retries effect while isTransient says the failure is worth another
attempt.
onRetry is called before each delay so the CLI can report "retrying 2/3"
rather than appearing to hang. It hangs off Schedule.tap, which sees the
decision the schedule just made — the failure, the attempt number and the
delay about to be slept — so the reported numbers are the ones actually used.
const retrying: <A, E, R>(effect: Effect<A, E, R>, options: {
readonly policy: RetryPolicy;
readonly isTransient: (error: E) => boolean;
readonly onRetry?: ((error: E, attempt: number, delayMs: number) => Effect<void, never, R>) | undefined;
}) => Effect<A, E, R>scheduleFor
The backoff schedule a policy describes: capped exponential, full jitter.
Schedule.jittered is deliberately not used. It scales each delay by a
factor between 0.8 and 1.2, which spreads a herd a little; full jitter — a
random delay in [0, backoff] — decorrelates it. That matters more than it
looks here. Without it, a hundred concurrent downloads that hit the same rate
limit back off in lockstep and retry in the same millisecond, reproducing the
overload they were backing off from.
Taking the randomness from Random rather than Math.random also means a
test can pin it by providing its own Random service.
const scheduleFor: <E>(policy: RetryPolicy) => Schedule<Duration, E, never, never>tarballUrl
Works out where to fetch a tarball from.
Two modes, and the rule is total — there are no special cases:
- off (default): use the URL the registry gave us, verbatim. It is normally right, and it is the only thing that knows about non-standard file names.
- on: derive the URL from the configured registry and the package's own
identity, ignoring what the registry returned. This is what
--rewrite-tarball-hostmeans, and it is the whole reason the flag exists.
An earlier version short-circuited when the returned URL shared an origin
with the registry, on the theory that it was "already pointing at us". That
was backwards: same host with a different path — Artifactory handing back
/npm-remote-cache/… when you configured /npm-remote/ — is precisely the
case somebody turns this flag on to fix, and it was the one case the
short-circuit silently skipped.
Rewriting is idempotent: a URL that is already canonical maps to itself.
const tarballUrl: (manifest: PackageManifest, registry: string, rewriteHost: boolean) => stringtlsConfigFor
Reads the TLS keys out of an .npmrc.
strict-ssl defaults to on: a missing key must never quietly weaken a
connection, so only the literal false turns verification off.
const tlsConfigFor: (config: NpmrcConfig) => TlsConfigtransportHint
Advice that fits the failure, where the failure says enough to earn some.
undefined leaves the general network/VPN/proxy line in place. A certificate
rejection is the one case worth splitting out: the generic advice is actively
misleading there — the network is fine, and no amount of looking at the VPN
will show otherwise.
const transportHint: (detail: string) => string | undefinedConstants
DEFAULT_PREFLIGHT_TIMEOUT
The ceiling on waiting for a network that may not be there.
Deliberately the same as the request default, so the tool has one deadline
rather than a family of them. An earlier split — 8s here against a 30s
request timeout — bought a faster failure when the request ceiling was
generous, but with both at ten seconds the gap only bought a second number
to explain, and a progress line counting towards a deadline that was not the
one documented in --help.
const DEFAULT_PREFLIGHT_TIMEOUT: 10000DEFAULT_REQUEST_TIMEOUT
How long a single request may take to answer.
Not how long a download may take: get is timed out around the exchange up
to the response head, and the body is read afterwards, outside this ceiling.
So this bounds silence from the registry, not transfer time — a 200MB tarball
over a slow VPN is unaffected by it, which is what makes ten seconds a
reasonable default rather than a tight one. A registry that has sent nothing
at all in ten seconds is not about to.
const DEFAULT_REQUEST_TIMEOUT: 10000defaultRetryPolicy
const defaultRetryPolicy: {
readonly maxRetries: number;
readonly baseDelayMs: number;
readonly maxDelayMs: number;
}emptyNpmrc
const emptyNpmrc: { readonly entries: ReadonlyMap<string, string>; }noRetry
const noRetry: {
readonly maxRetries: number;
readonly baseDelayMs: number;
readonly maxDelayMs: number;
}strictTls
Verify against the system store and nothing else — npm's default, and ours.
const strictTls: { readonly rejectUnauthorized: boolean; readonly ca: readonly string[]; }Types
Credentials
Credentials for one registry.
The secrets are Redacted, so an accidental console.log, a serialized
error, or a state object captured by a crash reporter prints <redacted>
rather than somebody's registry token. They are unwrapped exactly once, at
the point the Authorization header is built.
type Credentials = {
readonly _tag: "Bearer";
readonly token: Redacted<string>;
} | {
readonly _tag: "Basic";
readonly encoded: Redacted<string>;
} | {
readonly _tag: "None";
}NpmrcConfig
A parsed .npmrc, flattened to key/value pairs in precedence order.
type NpmrcConfig = { readonly entries: ReadonlyMap<string, string>; }NpmRegistryOptions
Configuration for the npm registry backend.
type NpmRegistryOptions = {
readonly registry?: string | undefined;
readonly npmrc?: NpmrcConfig | undefined;
readonly requestTimeoutMs?: number | undefined;
readonly preflightTimeoutMs?: number | undefined;
readonly retry?: RetryPolicy | undefined;
readonly headers?: Readonly<Record<string, string>> | undefined;
readonly rewriteTarballHost?: boolean | undefined;
readonly skipPreflight?: boolean | undefined;
}RetryPolicy
type RetryPolicy = {
readonly maxRetries: number;
readonly baseDelayMs: number;
readonly maxDelayMs: number;
}TlsConfig
The same settings as configured, with cafile still an unread path.
type TlsConfig = TlsSettings & { readonly caFile: string | undefined; }TlsSettings
TLS as a transport needs it: every CA already resolved to PEM text.
ca is additional trust rather than a replacement for the system store,
which is what a corporate CA almost always means in practice.
type TlsSettings = { readonly rejectUnauthorized: boolean; readonly ca: readonly string[]; }