119. Shipping Viscosity
A compiler that only runs inside its own repository is a prototype. Packing it for strangers turned up three bugs that no test in the repo could see, and version numbers turn out to be promises about what you will and won’t break.
- Describe what an npm package is, and how name and version identify it.
- Explain why the CLI, as first written, couldn’t be installed and run from npm, and what fixed it.
- Apply semantic versioning’s rules, including major version zero and caret ranges.
- Decide what counts as Viscosity’s public API, and so what a breaking change is.
Can I install it?
Somebody reads this book, likes Viscosity, and types
npm install viscosity. What do they get?
A library, version 0.3.0, described as “a sass library for fluid
containers that handle aspect ratios across device sizes”. The name
viscosity on npm belongs to someone else. And @viscosity/cli, the
name in the repo’s package.json? The registry answers 404. Viscosity
isn’t published, and its own documentation says so: viscosity.dev’s
install page tells you to clone the repository and run the CLI from
source.
This chapter is about the last step, from “works on my machine” to “works on yours”.
A package is a sealed box on a warehouse shelf, with a label that says exactly what is inside: a name and a version. Once a box with a label is on the shelf it never changes. A fix goes into a new box with a new label, and the label’s numbers tell the customer how different the new box is.
What a package is
Here is the CLI’s first package.json, in
engine/ts/packages/cli:
{
"name": "@viscosity/cli",
"version": "0.0.1",
"description": "The viscosity command:
build, explain and profile.",
"author": "Tariq Shams",
"license": "MIT",
"type": "module",
"bin": { "viscosity": "src/cli.ts" }
}
(The description is wrapped here to fit.) The name and version are the
only fields npm requires to publish, and together they identify the
package. @viscosity/ is a scope, a namespace that belongs to a
user or organization, where only they can add
packages[2]. bin asks npm to install a viscosity command
that runs src/cli.ts[1].
npm pack builds the tarball that npm publish would upload, and
npm pack --dry-run lists what would go in it without writing
anything[2].
An npm package is a folder described by a package.json, packed
into a tarball and published to a registry. Its name (possibly
scoped, @scope/name) and version identify it. bin installs
commands, files limits what is packed, dependencies lists other
packages it needs with version ranges, and engines says which Node
versions it supports.
Three bugs nobody could see
To find out what would happen, the author packed that CLI and installed the tarball in an empty scratch folder, the way a stranger would. Step through it:
None of the three was visible from inside the repository, because there everything works:
-
No dependencies.
cli.tsimports the core with a relative path,'../../core/src/index.ts', which only exists in the repo’s folder layout. Itspackage.jsondeclared no dependencies, so npm installed one package and nothing else. -
TypeScript in
node_modules. Node 22.18 and later strip TypeScript types by default, which is why the repo runs.tsfiles directly. But Node deliberately refuses to do it undernode_modules, to discourage publishing TypeScript sources[3]. -
The silent main check.
cli.tsended with a check, so that importing it in a test doesn’t run it:if (import.meta.url === `file://${process.argv[1]}`) process.exit(main(argv.slice(2)));(simplified). Run through npm’s link in
node_modules/.bin,argv[1]is the link’s path, not the file’s, so the check was false and the command exited with status 0 having done nothing.
The fixes were a build step and one small file, and they are in the
repository now. build.mjs runs esbuild, which compiles the TypeScript
and pulls the core, the front-ends and their parsers (acorn, acorn-walk,
sucrase) into one file, dist/viscosity.mjs. The package has no
runtime dependencies and needs only Node 18. A bundle contains other
people’s code, so their MIT licence notices have to ship with it too:
the build also writes LICENSE and THIRD_PARTY_LICENSES.txt, holding
the full licence text of each bundled package.
And a separate entry file, bin/viscosity.mjs, calls main without
any check:
#!/usr/bin/env node
import { main } from
'../dist/viscosity.mjs';
process.exit(main(process.argv.slice(2)));
The #! line matters: npm’s documentation asks that every bin file
start with it, or the script isn’t run by Node[1].
The package.json gained the rest: bin points at that file,
"files" limits the tarball to the bin file, the bundle and the two
licence files, engines says
>=18, and a prepack script runs the build, so npm pack always
packs a fresh bundle. (The check in cli.ts now compares real paths,
so even running the source through a link works.) Packed again and
installed in an empty folder:
$ npm pack
npm notice total files: 5
$ npm install viscosity-cli-0.0.1.tgz
$ npx viscosity --version
viscosity 0.0.1
$ npx viscosity explain p.js
ps (line 1) {x, vx} → struct of arrays
…
Viscosity still isn’t on the registry, but everything up to
npm publish has now been walked.
Version numbers that mean something
The version 0.0.1 isn’t decoration. npm works in Semantic
Versioning: it requires versions that its semver library can
parse[1]. The rules fit in a
sentence: given MAJOR.MINOR.PATCH, bump MAJOR for incompatible API
changes, MINOR for backward compatible new features, PATCH for backward
compatible bug fixes, and reset the numbers to the right of the one you
bumped[4].
Three details catch people out. The parts are compared as numbers, so
1.10.0 is newer than 1.9.0, even though as strings it sorts first:
const a = '1.10.0';
const b = '1.9.0';
// strings: character by character
console.log(a > b);
function parse(v) {
return v.split('.').map(Number);
}
console.log(parse(a), parse(b));
// numbers compare by value
console.log(parse(a)[1] > parse(b)[1]);Second, a released version must never change. npm enforces it: once a
name and version have been published, that pair can never be used
again, even after unpublishing[2]. Third,
major version zero is for initial development, and “anything MAY
change at any time”[4]. npm’s caret ranges follow
that: a dependency on ^1.2.3 accepts anything below 2.0.0, but
^0.1.0 accepts only 0.1.x, because in 0.x the minor number is
treated as the breaking one, and ^0.0.1 accepts no other version at
all[5]. That is why Viscosity starts at
0.0.1: no project that depends on it gets a newer release without
asking for it.
Semantic versioning (semver) numbers releases MAJOR.MINOR.PATCH.
MAJOR changes when the public API breaks, MINOR when it gains
backward compatible features, PATCH for backward compatible fixes.
Lower parts reset to 0. Released versions are immutable, and 0.y.z
promises nothing. Pre-release tags (1.0.0-beta.1) sort before the
release.
What is Viscosity’s public API?
Semver starts with a demand: declare your public API, because the numbers only mean something relative to it[4]. For a library that is its exported functions. For Viscosity it is more:
- The CLI: commands and options. viscosity.dev’s CLI reference is
generated from
--help, so the documentation can’t drift from it. - The plugin API from chapter 90, if other people write plugins.
- The explain text. Chapter 110 argued that if people read the reports, they are part of the interface. Scripts will grep them.
- The decisions. A release that suddenly keeps a collection it used to transform makes someone’s program slower without changing its output.
Is that last one a breaking change? The output is identical, which
the differential tests check, so by semver’s letter it is a fix or a
feature. A user who measured a speed-up may disagree. Deciding, and
writing the decision down, is part of shipping. (One small thing was
fixed on the way: viscosity --version used to print a hard-coded
version, a second copy of the number in package.json that would one
day disagree with it. Now it reads package.json.)
Node’s type stripping replaces type annotations with whitespace and
checks nothing. By default it runs only TypeScript whose features need
no code generation (an enum does), so it needs no source maps: line
numbers stay the same[3]. That is why the repository
can run its .ts sources directly on Node 22.18 or later, the version
its own engines field asks for, and also why that convenience stops at
the edge of node_modules: the published CLI is plain JavaScript.
Your turn
Write bump(v, kind). v is a version like '1.4.2' and kind is 'major', 'minor' or 'patch'. Return the next version as a string, following semver: add 1 to that part and reset every part to its right to 0.
function bump(v, kind) {
return v + '.1';
}Write newer(a, b) for plain versions like '1.10.0' (no pre-release tags). Return true if a is newer than b, otherwise false. Compare major, then minor, then patch, as numbers; the first part that differs decides.
function newer(a, b) {
return a > b;
}What you learned
- An npm package is a tarball described by
package.json, identified by name and version, withbin,files,dependenciesandengines. Viscosity is not published; the nameviscosityis taken. - Packing and installing the CLI found three bugs: undeclared
dependencies, TypeScript under
node_modules, and a main check that failed through npm’s link. A bundle and a bin entry fixed them, andnpm packplus an install now works. - Semantic versioning: MAJOR.MINOR.PATCH, compared numerically,
immutable once released, with 0.y.z promising nothing.
^0.1.0accepts only0.1.x, and^0.0.1only 0.0.1. - Versions are only meaningful against a declared public API, which for Viscosity includes its options, reports and decisions.
Check your understanding
0/6- 1.
What happens today if someone runs
npm install viscosity? - 2.
Why couldn’t the CLI be published as it first was, with
binpointing atsrc/cli.ts? - 3.
After bundling,
npx viscosity explain a.jsprinted nothing and exited 0. Why? - 4.
A release fixes a bug without changing the public API. The current version is 1.9.3. What is the next?
- 5.
A project depends on
^0.1.0. Which release will npm install for it? - 6.
A new Viscosity release keeps a collection it used to transform. Output is identical, but the program is slower. What does the chapter conclude?
References
- [1] npm, Inc. (GitHub) and the npm CLI contributors. package.json (npm CLI documentation). docs.npmjs.com (source in the npm/cli repository on GitHub). docs.npmjs.com/cli/configuring-npm/package-json (checked 2026-10-31)
- [2] npm, Inc. (GitHub) and the npm CLI contributors. npm-publish and Scope (npm CLI documentation). docs.npmjs.com (source in the npm/cli repository on GitHub). docs.npmjs.com/cli/commands/npm-publish (checked 2026-10-31)
- [3] The Node.js project contributors. Modules: TypeScript (Node.js v22 documentation). nodejs.org documentation (source in the nodejs/node repository on GitHub). nodejs.org/docs/latest-v22.x/api/typescript.html (checked 2026-10-31)
- [4] Tom Preston-Werner (original author) and the semver contributors. Semantic Versioning 2.0.0. semver.org (source in the semver/semver repository on GitHub), CC BY 3.0. semver.org/spec/v2.0.0.html (checked 2026-10-31)
- [5] npm, Inc. and the node-semver contributors. node-semver README: Caret Ranges and Tilde Ranges. npm/node-semver repository on GitHub. github.com/npm/node-semver#caret-ranges-123-025-004 (checked 2026-10-31)