Lesson 20 / 20
Package Publishing
The export contract of the package manifest, conditional entries, hiding internal paths, the executable entry point, and pre-publish checks.
Contents
The previous lesson made the measurement collector run and shut down cleanly. One question remains: how does the reusable part of this code — the summarizer, the log, the error classes — get carried into other projects?
The answer is wrapping the code in a package contract. The Modules, Tooling and the Ecosystem course explained packages from the consuming side; this lesson moves to the publishing side.
The Package Manifest Is a Contract
The manifest file does more than carry the package’s name and version: it defines what the package promises to the outside. The Module Resolution lesson showed this map’s effect from the consumer’s side; here, the author’s decisions are taken up.
package/
package.json
src/
index.mjs
index.cjs
summary.mjs
internal-format.mjs
// package/package.json { "name": "measurement-core", "version": "1.2.0", "type": "module", "engines": { "node": ">=20" }, "exports": { ".": { "import": "./src/index.mjs", "require": "./src/index.cjs" }, "./summary": "./src/summary.mjs" }, "imports": { "#internal/format": "./src/internal-format.mjs" }, "files": ["src/"] }
// package/src/internal-format.mjs export const format = (n) => Number(n.toFixed(2));
// package/src/summary.mjs import { format } from '#internal/format'; export const average = (values) => format(values.reduce((t, v) => t + v, 0) / values.length);
// package/src/index.mjs export const entry = 'ES module entry';
// package/src/index.cjs module.exports = { entry: 'CommonJS entry' };
Each field makes a separate promise.
exports fixes which paths can be imported from outside. A file not in the map
is unreachable, even if it is right where it is. This gives the freedom to
reorganize the package’s internal layout: as long as the map stays the same,
files can move.
imports are the package’s own internal aliases. Names starting with #
resolve only from the package’s own files and are invisible from outside — a
way to shorten long relative paths that carries no risk of opening them to the
outside.
type decides which module form files with a .js extension are interpreted
as. This field is read from the package’s own manifest; the manifest of
whoever uses it has no effect.
engines declares which runtime versions the package is tested against. The
runtime does not enforce this field; the package manager warns or refuses at
install time. The field is not documentation, it is a promise: if an interface
used is present from a certain version onward, the lower bound is written to
match.
files decides which paths go into the published archive. Tests, examples,
and development configuration are left out of the archive; the user never
downloads them.
Conditional Entries
The root entry has two counterparts: one file resolves when reached through
import, another when reached through require. This makes the same package
usable from either module form.
A directory that consumes the package is set up next. A package manager places a local package in the dependency directory as a link; the same layout can be set up by hand:
mkdir -p package/consumer/node_modules cd package/consumer && ln -s ../.. node_modules/measurement-core printf '{ "name": "consumer", "type": "module" }\n' > package.json
// package/consumer/try.mjs import { entry } from 'measurement-core'; import { average } from 'measurement-core/summary'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); console.log('import condition :', entry); console.log('require condition:', require('measurement-core').entry); console.log('subpath :', average([21.4, 21.9, 22.3]));
cd package/consumer && node try.mjs
import condition : ES module entry require condition: CommonJS entry subpath : 21.87
The same name resolved to two different files. The third line shows the subpath working, and that module using another file through its own internal alias.
Offering both forms together has one trap: a module can be evaluated twice in the same process; as explained in the Module Resolution lesson, singularity depends on the resolved address, and two files are two separate addresses. If the package holds state — a counter, a registry — two copies mean two separate states. For packages that hold state, offering a single form is safer.
Paths not in the map are closed to access:
cd package/consumer && node -e "import('measurement-core/src/internal-format.mjs').catch(e => console.log(e.code))"
ERR_PACKAGE_PATH_NOT_EXPORTED
Internal aliases cannot be resolved from outside either:
cd package/consumer && node -e "import('#internal/format').catch(e => console.log(e.code))"
ERR_PACKAGE_IMPORT_NOT_DEFINED
The second error says the alias is not defined in the consuming package’s own
manifest. Names starting with # are defined separately for each package;
another package’s internal names cannot be borrowed.
Executable Entry
The tool from the Command-Line Applications lesson can be placed in a package.
The bin field in the manifest says which name links to which file when the
package is installed:
{ "name": "measurement-tools", "version": "1.0.0", "type": "module", "bin": { "measurement-collect": "./src/collect.mjs" }, "files": ["src/"] }
Two conditions are required. The file’s first line has to carry the
interpreter declaration — the #!/usr/bin/env node line tells the kernel
which program runs the file. Second, the file has to have execute permission;
the package manager usually sets this at install time, but committing it to
the repository with the right permission is safer.
Combining the library and the tool in the same package is a choice. Separate packages keep those using only the library from downloading the tool’s dependencies; a single package guarantees version compatibility on its own.
Pre-Publish Checks
Publishing is an irreversible operation: a version number gets published once, and its content cannot be changed. This is why four checks happen before publishing.
The archive’s contents. A wrongly written files field fails in two
directions: a required file is left out and the package does not work once
installed, or unneeded files get in and the archive bloats. Package managers
offer a dry-run mode that lists the archive’s contents without producing it;
this list is read before publishing.
That entry points work. Setting up a small directory that consumes the package — like above — and importing every entry catches a typo in the map before publishing. A published package with a broken entry stays in users’ caches even if the version is pulled.
The meaning of the version number. Semantic versioning rules were explained in the Modules, Tooling and the Ecosystem course. For the publisher, the rule reduces to one question: does a user’s code break by moving to this version? If it does, the major version increments. Removing an entry from the exports map, changing a field’s type, and raising the minimum supported runtime version are all breaking changes.
Secret leaks. The env file from the Configuration Management lesson must
never enter the archive. The ignore list from the Introduction to Version
Control course keeps that file out of the repository; the files field keeps
it out of the archive. The two checks do not substitute for each other — a file
absent from the repository can still sit in the local working directory and
get into the archive.
The publish action itself is a package manager command and requires access to a registry account. Protecting that access with a second factor, limiting publish rights to the smallest possible set of people, and doing the publish from an automation step triggered by a version tag rather than by hand — these are common practices.
Summary
- The package manifest defines the promise the package makes outward; the exports map fixes the public interface and gives freedom to reorganize the internal layout.
- Conditional entries make the same package usable from both module forms; for packages that hold state, two copies mean two separate states.
- Internal aliases starting with
#resolve only from their own package; they cannot be reached from another package. - An executable entry depends on three conditions: a name-to-file mapping, the interpreter declaration, and execute permission.
- Publishing cannot be undone; archive contents, entry points, the meaning of the version number, and secret leaks are all checked before publishing.
Course Wrap-Up
The course began with a single question: when the same language runs in a server process instead of a browser, what changes? The answer was built in three layers.
The runtime model. The host environment concept separated the language core from access to the outside world. The event loop’s phases were measured; timer delay was shown to be a lower bound, and blocking work was shown to extend that bound by its own duration. The process object put arguments, environment, streams, and exit code into the program’s hands. Module resolution settled which file a name corresponds to.
Built-in modules. The file system was taken up through its three interfaces, and the choice between them was tied back to the event loop. Paths were treated as data; a check was written to prevent escaping the root directory. Streams were built on the observation that a chunk boundary differs from a record boundary, and backpressure was measured: a producer ignoring the return value ran the buffer up to fifty records, while one that listened never exceeded four. Buffers brought raw bytes, the event emitter the observer pattern, the HTTP server the streaming nature of request and response. Child processes and multi-core use separated two ways of moving work outside the process.
Writing applications. The command-line interface, configuration, error classification, structured logging, testing, profiling, graceful shutdown, and packaging — all built on one concrete piece of work. The measurement collector started as a script reading a twelve-line file and turned into a service that reads through a stream, serves over HTTP, is tested, is observable, and shuts down on signal by finishing its running requests.
This is the last course in the JavaScript and TypeScript curriculum. Two directions open up from here.
The Frontend Development curriculum returns to the same language’s other host environment: document structure, rendering strategies, component architecture, accessibility, and performance. The module resolution, packaging, and event model learned in this course are the foundation of the build tools there.
The Backend Development curriculum continues from where this course
leaves off: the request dispatching, configuration, logging, and error layer
built by hand here get taken up there as an application architecture, with
layers for API contracts, data access, authentication, asynchronous
processing, and observability added on top. The measurement collector’s
/summary endpoint is the first example of a resource model whose design
gets justified there.
In both directions, the model built in this course stays valid: an event-driven, single-threaded runtime that works with streams. Frameworks and libraries hide this model, they do not change it — and a hidden model’s failure can only be solved by someone who knows the model.
To keep your progress and take notes, Log in
My notes
Log in to take notes.