Viscosity
Review
Part VIII · Beyond JavaScript · Day 30

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.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • 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”.

Intuition

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].

Definition

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:

Step throughShipping the CLI
Four stages, pack, install, run and publish, each marked as working or not, above a terminal showing the real output for the current steppacknot yetinstallnot yetrunnot yetpublishnot yet# four stages between a repo and# `npm install` working for a stranger
Shipping a package means four things must work: packing it into a tarball, installing that tarball somewhere else, running the command, and publishing it to the registry. Step through what happened with the CLI as it first was, and how it was fixed.
1/8

None of the three was visible from inside the repository, because there everything works:

  1. No dependencies. cli.ts imports the core with a relative path, '../../core/src/index.ts', which only exists in the repo’s folder layout. Its package.json declared no dependencies, so npm installed one package and nothing else.

  2. TypeScript in node_modules. Node 22.18 and later strip TypeScript types by default, which is why the repo runs .ts files directly. But Node deliberately refuses to do it under node_modules, to discourage publishing TypeScript sources[3].

  3. The silent main check. cli.ts ended 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:

Try itVersions are numbers, not strings
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.

Definition

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.)

Under the hood

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

ExerciseBump a version

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';
}
Extra practiceWhich version is newer?

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, with bin, files, dependencies and engines. Viscosity is not published; the name viscosity is 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, and npm pack plus an install now works.
  • Semantic versioning: MAJOR.MINOR.PATCH, compared numerically, immutable once released, with 0.y.z promising nothing. ^0.1.0 accepts only 0.1.x, and ^0.0.1 only 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. 1.

    What happens today if someone runs npm install viscosity?

  2. 2.

    Why couldn’t the CLI be published as it first was, with bin pointing at src/cli.ts?

  3. 3.

    After bundling, npx viscosity explain a.js printed nothing and exited 0. Why?

  4. 4.

    A release fixes a bug without changing the public API. The current version is 1.9.3. What is the next?

  5. 5.

    A project depends on ^0.1.0. Which release will npm install for it?

  6. 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. [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. [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. [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. [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. [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)