Skip to content
academia.sh

Lesson 05 / 14

Package Registries and Dependencies

The fields of the manifest file, resolving a bare specifier by walking the directory tree, encapsulation through the exports map, dependency types, and what the lock file does.

Contents

Every specifier in the module systems topic was a relative file path. In real projects, most imports are bare names like format-helper: no extension, no directory information.

What links this name to a file is a filesystem convention and the package’s own declaration. This lesson first defines what a package is, then follows step by step how a bare name is searched for.

The Manifest File

A package is a directory that carries its own declaration. The manifest file (manifest) defines the package’s identity, entry points, and dependencies. This is the language-level counterpart of the metadata concept introduced in the package manager lesson of the Introduction to Linux course; the role of the fields is similar too.

The measurement library’s manifest — package.json at the project root:

{
  "name": "text-measurer",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    ".": "./src/index.mjs",
    "./segmentation": "./src/segmentation.mjs"
  },
  "files": ["src"],
  "dependencies": {
    "format-helper": "^1.4.0"
  }
}

The decisions carried by the fields:

Field Decision
name The package’s unique identity within the registry
version The version of this release; the second part of the identity
type Which module system files with a .js extension are read as
exports Which paths can be imported from outside
files Which files go into the published archive
dependencies The packages needed to run, and the accepted version ranges

The last three fields are design decisions, not technical requirements. It is the package’s author who decides which file is visible from outside, which is published, and which versions are accepted.

Adding a Dependency

The measurement library depends on a small helper to print the average length with a fixed number of digits. The helper package is a directory carrying its own manifest — node_modules/format-helper/package.json:

{
  "name": "format-helper",
  "version": "1.4.0",
  "type": "module",
  "main": "index.mjs"
}
// file: node_modules/format-helper/index.mjs
export function decimal(value, digits = 2) {
  return value.toFixed(digits);
}

The report module added to the library imports this package by its bare name. src/index.mjs below, along with src/segmentation.mjs and src/statistics.mjs that it uses, was written in this topic’s ES Modules lesson; the library keeps growing on the same tree:

// file: src/report.mjs -- src/index.mjs is the entry point that exports the measure function
import { decimal } from 'format-helper';
import { measure } from './index.mjs';

export function report(text) {
  const measurement = measure(text);
  return `${measurement.wordCount} words, average ${decimal(measurement.averageLength)} characters`;
}
// file: try.mjs
import { report } from './src/report.mjs';

console.log(report('A module carries its own scope. A script runs in global scope.'));
console.log(import.meta.resolve('format-helper').split('/node_modules/')[1]);
$ node try.mjs
12 words, average 4.08 characters
format-helper/index.mjs

There are two separate facts here that do not require each other: the package being written in the manifest and the package being present in the directory. A package written in the manifest but not present in the directory produces a resolution error:

// file: missing-package.mjs
import { decimal } from 'nonexistent-package';

console.log(decimal(1.5));
$ node missing-package.mjs 2>&1 | grep '^Error' | sed "s|$(pwd -P)/||g"
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'nonexistent-package' imported from missing-package.mjs

The reverse case is sneakier: a package present in the directory but not written in the manifest runs without a hitch locally. Resolution only looks at the filesystem, not the manifest. Installed somewhere else, that file may not be there, and the error only surfaces on first use. Every used dependency has to be written in the manifest too — the assumption underlying the tree arrangements covered later.

How a Bare Specifier Is Resolved

A bare name is not a file path; it is a name that has to be searched for. The rule fits in one sentence: start from the directory of the file doing the importing, look for this name inside a node_modules subdirectory at every level, and if not found, move up one directory.

A script that applies the rule directly shows that the search really does proceed this way:

// file: src/deep/search.mjs
import { dirname, join } from 'node:path';
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

// Directories searched for the bare specifier: from the current directory toward the root.
let dir = dirname(fileURLToPath(import.meta.url));
while (true) {
  const candidate = join(dir, 'node_modules', 'format-helper');
  console.log(`${existsSync(candidate) ? 'found  ' : 'missing'} ${candidate}`);
  if (existsSync(candidate) || dir === '/') break;
  dir = dirname(dir);
}

console.log('resolved by the runtime:', import.meta.resolve('format-helper'));
$ node src/deep/search.mjs | sed "s|$(pwd -P)|.|"
missing ./src/deep/node_modules/format-helper
missing ./src/node_modules/format-helper
found   ./node_modules/format-helper
resolved by the runtime: file://./node_modules/format-helper/index.mjs

The sed call shortens the absolute path in the output; the last line gets the same shortening. The search itself reveals four properties:

It is location-dependent. The same name can resolve to a different package for two files in different directories. This is the basis of the version-conflict resolution covered in a later lesson.

It requires no registry. Resolution is entirely local; it never reaches the network. The network is used while packages are being placed into the directory, not during resolution.

The package itself speaks. Once the directory is found, it is the package’s own manifest that says which file gets loaded.

It is deterministic. The same specifier in the same file tree always resolves to the same file. This is the third guarantee listed in the first lesson of module systems.

The Exports Map

Once the package directory is found, it is the map in the manifest that determines which paths are accessible from outside. A path not listed in the map cannot be imported even if it exists in the directory.

The following two files sit in a separate consumer project that installs the text-measurer package, and run inside that project’s directory. The consumer’s own manifest — consumer/package.json:

{
  "name": "consumer",
  "version": "1.0.0",
  "type": "module",
  "dependencies": { "text-measurer": "^0.1.0" }
}
// file: consumer/use.mjs
import { measure } from 'text-measurer';
import { splitWords } from 'text-measurer/segmentation';

console.log(measure('One sentence. Two sentences.').wordCount);
console.log(splitWords('one two three').length);
$ node use.mjs
4
3
// file: consumer/deep.mjs -- tries to reach an inner file not listed in the map
import { averageLength } from 'text-measurer/src/statistics.mjs';

console.log(averageLength(['one', 'two']));
$ node deep.mjs 2>&1 | grep -E '^Error' | sed "s|$(pwd -P)/||g"
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './src/statistics.mjs' is not defined by "exports" in node_modules/text-measurer/package.json imported from deep.mjs

This is encapsulation at the package level. With no map, every inner file is open to the outside; consumers bind themselves to inner files, and reorganizing them turns into a breaking change. The map separates which paths are contract and which files can be freely changed.

Dependency Types

In the manifest, dependencies are not a single list; each list answers a different question.

Runtime dependencies. Packages needed while the package runs. Everyone who installs the package installs these too.

Development dependencies. Needed only while developing the package: a test runner, a linter, a build tool. The party consuming the package does not install these. The point of the split is to keep the tree the consumer downloads small.

Peer dependencies. Dependencies the package needs to run, but which the package itself must not install. If a plugin has to use the same instance of the library it extends, it is written to this list; that way the party installing the library is the application itself, and there is a single copy in the tree. Why a duplicate copy is a problem is shown in the dependency tree lesson.

Optional dependencies. Dependencies whose failed installation does not stop the process. The package has to carry a fallback path against their absence; if it does not, this list has been used incorrectly.

The Package Registry and the Lock File

A package registry is a directory that maps a name–version pair to an archive. The content of a published name–version pair is considered immutable: the same pair should always give the same bytes. This immutability is the foundation all later guarantees rest on.

A range like ^1.4.0 in the manifest denotes not a single version but a set. A choice is made from the set when install time comes. Two installs done at two different times can make different choices as the set grows; the result is two trees that run differently from the same source.

The lock file removes this uncertainty. It records three pieces of information:

  1. The exact version chosen. Not a range, a single version number.
  2. The source address. Where the archive was fetched from.
  3. The integrity digest. A checksum of the archive’s content.

The shape of the tree — which package sits at which level — is also recorded. Once a lock file exists, installing is not a search, it is a reproduction: the written tree is installed exactly as is.

This split comes from the same place as the Introduction to Linux course’s framing of dependency resolution as a constraint problem. The solution the resolver produces has to be storable without recomputing it; the lock file is that record. This is why it is added to version control: a second developer cloning a repository, and a build server, both depend on installing the same tree.

Summary

  • A package is a directory carrying a manifest file; the manifest defines its identity, entry points, files to publish, and dependencies.
  • A bare specifier is searched for in the node_modules directory at every level, from the importing file’s directory toward the root; resolution is local, location- dependent, and deterministic.
  • The exports map provides encapsulation at the package level; inner files not in the map cannot be imported from outside.
  • Dependency lists answer different questions: runtime, development, peer, and optional.
  • A version range denotes a set; the lock file makes installation reproducible by recording the choice made from the set, the source, and the integrity digest.

Next Step

It was said that ranges denote a set, but not what draws the set’s boundaries. Behind a manifest that writes ^1.4.0 accepting version 1.9.0 and rejecting 2.0.0 is a contract that assigns meaning to the parts of a version number. The next lesson builds that contract and its comparison rules with running code.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close