Package Resolution — Resolvers, the Lockfile, and Version Constraints
Prerequisites
- ›Articles 1-2
- ›Semver version ranges and constraint resolution
- ›Understanding of npm registry protocol basics
Package Resolution — Resolvers, the Lockfile, and Version Constraints
Resolution is where a yarn install becomes deterministic. Given a dependency tree of version ranges, the resolver must produce a single concrete version for every package—and do it the same way every time, on every machine. This article unpacks Yarn's resolver architecture: three categories of resolvers, the lockfile-first strategy that makes installs fast, the hand-written lockfile parser, and the BlockingQueue that keeps concurrent resolution sane.
The Resolver Architecture
Yarn organizes its resolvers into three categories, each handling a different kind of dependency specifier:
classDiagram
class BaseResolver {
+resolver: PackageResolver
+fragment: string
+registry: RegistryNames
+fork(Resolver, resolveArg): Manifest
+resolve(): Manifest
}
class RegistryResolver {
+name: string
+range: string
+resolveRequest(): Manifest
}
class NpmResolver {
+findVersionInRegistryResponse()
}
class ExoticResolver {
<<abstract>>
+static isVersion(pattern): boolean
}
class GitHubResolver {
+static isVersion(): boolean
+static getTarballUrl(): string
}
class WorkspaceResolver {
+resolve(): Manifest
}
BaseResolver <|-- RegistryResolver
BaseResolver <|-- ExoticResolver
RegistryResolver <|-- NpmResolver
ExoticResolver <|-- GitHubResolver
BaseResolver <|-- WorkspaceResolver
Registry resolvers (NpmResolver, YarnResolver) handle standard version ranges like ^4.0.0 or latest. They query the npm registry, parse dist-tags, and use semver matching to select a version.
Exotic resolvers handle non-registry sources: Git URLs, GitHub shorthands (user/repo), tarballs, local file paths, and symlinks. Each has a static isVersion() method for pattern matching.
Contextual resolvers (WorkspaceResolver) handle workspace packages that exist locally and don't need network resolution.
The BaseResolver is a minimal abstract class with an important fork() method (line 30). When a resolver needs to delegate to another resolver type—say, a git resolver that discovers the actual package name and then needs to resolve its dependencies through the npm registry—it calls fork() to create a new resolver sharing the same request context.
Exotic Resolution and Dynamic Protocol Resolvers
The getExoticResolver() function is the dispatcher. It iterates all exotic resolvers, calling each one's static isVersion() to pattern-match the input:
export function getExoticResolver(pattern: string): ?Class<$Subtype<BaseResolver>> {
for (const Resolver of exotics) {
if (Resolver.isVersion(pattern)) {
return Resolver;
}
}
return null;
}
The GitHubResolver.isVersion() shows how this works in practice: it matches either the github: protocol prefix or the user/repo shorthand pattern.
One of the cleverest patterns in the resolver system is the dynamic ExoticRegistryResolver creation:
for (const key in registries) {
const RegistryResolver = registries[key];
exotics.add(
class extends ExoticRegistryResolver {
static protocol = key;
static factory = RegistryResolver;
},
);
}
This loop dynamically creates new exotic resolver classes for each registry. The result: you can write npm:lodash@^4.0.0 to force resolution through the npm registry, even in a project that otherwise uses the Yarn registry. The ExoticRegistryResolver class strips the protocol prefix and delegates to the appropriate registry resolver.
Lockfile-First Resolution
The PackageRequest class implements the resolution strategy for a single dependency pattern. The first thing it does is check the lockfile via getLocked():
flowchart TD
A["PackageRequest.resolve()"] --> B["getLocked(pattern)"]
B -->|Lockfile hit| C["Return locked Manifest<br/>(no network needed)"]
B -->|Lockfile miss| D["findVersionOnRegistry()"]
D --> E{"Exotic range?"}
E -->|yes| F["findExoticVersionInfo()"]
E -->|no| G["new RegistryResolver().resolve()"]
F --> H["Return Manifest"]
G --> H
The getLocked() method (line 62) constructs a complete Manifest from the lockfile entry, including _remote information with the resolved URL, hash, and integrity. This means a locked dependency never touches the network during resolution—it already has everything needed to proceed to the fetch phase.
When NpmResolver does hit the registry, its findVersionInRegistryResponse() method follows a specific precedence:
- If the range matches a dist-tag name (e.g.,
latest,next), use that tag's version. - If the
latestdist-tag satisfies the range, prefer it (mimicking npm's behavior). - Fall back to
semver.maxSatisfying()viaconfig.resolveConstraints(). - If nothing matches and the session is interactive, prompt the user to pick a version.
Tip: Yarn prefers the
latestdist-tag even when other versions would satisfy the range. This is intentional NPM-compatibility behavior (see issue #3560). If you're wondering why Yarn picked a specific version, check whetherlatestsatisfied the constraint.
The Custom Lockfile Parser
Instead of using YAML or JSON, Yarn v1 uses a custom format for yarn.lock. The parser in src/lockfile/parse.js is a hand-written tokenizer using a JavaScript generator function:
function* tokenise(input: string): Iterator<Token> {
// ...
while (input.length) {
let chop = 0;
if (input[0] === '\n' || input[0] === '\r') { /* newline */ }
else if (input[0] === '#') { /* comment */ }
else if (input[0] === ' ') { /* indent */ }
else if (input[0] === '"') { /* quoted string */ }
// ... more token types
input = input.slice(chop);
}
}
The tokenizer yields typed tokens (STRING, BOOLEAN, NUMBER, COLON, NEWLINE, INDENT, etc.) that a recursive descent Parser class consumes. This approach handles several tricky requirements:
Git merge conflict detection: The parser recognizes <<<<<<<, =======, and >>>>>>> markers. When both sides of a merge are valid YAML-like blocks, it merges them (returning type 'merge'). When they can't be reconciled, it returns type 'conflict'.
Indentation-based nesting: Indentation must be exactly 2 spaces per level—odd indentation throws a TypeError (line 88).
The Lockfile class wraps the parsed data and provides two critical optimizations:
implodeEntry() (line 89): Strips redundant data before writing. If the package name can be inferred from the pattern, it's omitted. If uid equals version, it's omitted. If registry is 'npm' (the default), it's omitted. This significantly reduces lockfile size.
explodeEntry() (line 109): Restores defaults when reading. Missing dependencies become {}, missing registry becomes 'npm', missing uid falls back to version.
flowchart LR
A["Full Manifest"] -->|implodeEntry| B["Minimal lockfile entry<br/>(no defaults, no inferrable fields)"]
B -->|explodeEntry| C["Full Manifest<br/>(defaults restored)"]
The serializer in src/lockfile/stringify.js uses a priorityThenAlphaSort function to ensure deterministic field ordering: name always comes first, then version, uid, resolved, integrity, registry, and dependencies. All other fields are alphabetically sorted.
The serializer also deduplicates entries: when multiple patterns resolve to the same object reference (same remoteKey), they share a single lockfile block with comma-separated keys (line 74-84), like:
"lodash@^4.0.0", "lodash@^4.17.0":
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#..."
Concurrency Control with BlockingQueue
The PackageResolver uses a BlockingQueue named 'resolver fetching' to manage concurrent resolution. The BlockingQueue is one of Yarn's most reusable internal utilities.
Its contract is simple: operations with the same key execute serially, while operations with different keys run in parallel (up to maxConcurrency):
sequenceDiagram
participant Q as BlockingQueue
participant A as resolve("lodash")
participant B as resolve("lodash")
participant C as resolve("express")
A->>Q: push("lodash", fn)
C->>Q: push("express", fn)
Note over Q: lodash and express run in parallel
B->>Q: push("lodash", fn)
Note over Q: Second lodash queued behind first
A-->>Q: Complete
Note over Q: Second lodash starts now
The implementation at line 64-80 maintains two data structures: queue (a map of key → pending operations) and running (a map of key → boolean). When a new operation arrives for a key that's already running, it's pushed onto that key's queue. When an operation completes, shift() dequeues and runs the next operation for that key.
A global concurrencyQueue (line 132-137) enforces the maximum parallel operations across all keys. If runningCount >= maxConcurrency, new operations are held in the concurrency queue until a slot opens.
The queue also includes a stuck detection mechanism (line 54-61): if only one worker is active for 5 seconds, a debug message is emitted. This helps diagnose resolution hangs without flooding logs during normal operation.
Tip: If you encounter resolution hangs, enable debug output with
DEBUG=yarn node yarn.js install. The BlockingQueue will log which key has been stuck, pointing you to the problematic package.
What's Next
We've traced how Yarn determines exactly which version of every package to install. The resolver produces a complete dependency graph with every package's Manifest populated with _remote information. In the next article, we'll follow those manifests into the fetch phase—examining how the five fetcher types download packages, how the cache directory is organized, and how the RequestManager handles retries, DNS caching, and offline operation.