packall

@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> | undefined

connectTimeoutFor

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) => number

credentialKeys

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) => Credentials

defaultRegistry

The default registry, unless a scope overrides it.

const defaultRegistry: (config: NpmrcConfig, fallback?: string) => string

describeTransport

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) => string

encodePackageName

@scope/name -> @scope%2fname, which is how the registry addresses it.

const encodePackageName: (name: string) => string

isTransient

Which failures are worth another attempt.

const isTransient: (error: BundlerError) => boolean

layer

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[]) => NpmrcConfig

nerfDart

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) => string

normalizeRegistry

Ensures a registry URL ends in exactly one slash.

const normalizeRegistry: (registry: string) => string

parseNpmrc

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>>) => NpmrcConfig

parsePackument

const parsePackument: (name: string, body: unknown) => Packument | null

preflightTimeoutFor

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) => number

registryForPackage

The registry for a package, honouring @scope:registry entries.

const registryForPackage: (config: NpmrcConfig, packageName: string, fallback?: string | undefined) => string

retrying

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-host means, 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) => string

tlsConfigFor

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) => TlsConfig

transportHint

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 | undefined

Constants

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: 10000

DEFAULT_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: 10000

defaultRetryPolicy

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[]; }

On this page