DEV Community

mengyuxuan
mengyuxuan

Posted on

Three things that surprised me shipping a 200-line npm package

I published a small thing last week — video-bitrate-calculator, a zero-dependency library that works out what video bitrate fits under a target file size. About 200 lines of arithmetic.

The code was the easy part. Shipping it surfaced three failures that all had the same shape: the tool told me something, the something was misleading, and the correct response was the opposite of the obvious one.

1. npm said my bin was "invalid and removed". It was neither.

npm publish, and this scrolls past:

npm warn publish "bin[video-bitrate-calculator]" script name src/cli.js was invalid and removed
npm warn publish "bin[vbc]" script name src/cli.js was invalid and removed
Enter fullscreen mode Exit fullscreen mode

Both CLI entry points, gone. That's the headline feature of the package — npx video-bitrate-calculator discord 90s is the first line of the README. I hit Ctrl+C.

My package.json had:

"bin": {
  "video-bitrate-calculator": "./src/cli.js",
  "vbc": "./src/cli.js"
}
Enter fullscreen mode Exit fullscreen mode

Nothing wrong with that. ./ prefixes are normal. So I went and read npm's own normalizer, @npmcli/package-json/lib/normalize.js:

const binTarget = secureAndUnixifyPath(pkg.bin[binKey])   // "./src/cli.js" → "src/cli.js"

if (binTarget !== pkg.bin[binKey]) {
  changes?.push(`"bin[${base}]" script name ${binTarget} was invalid and removed`)
}
pkg.bin[base] = binTarget     // ← assigned anyway
Enter fullscreen mode Exit fullscreen mode

The assignment runs unconditionally. The warning fires whenever the path is normalized, and describes a deletion that never happens. Stripping a leading ./ is enough to trigger it.

I confirmed it by running npm's own normalizer over my package:

bin after normalize: {"video-bitrate-calculator":"src/cli.js","vbc":"src/cli.js"}
Enter fullscreen mode Exit fullscreen mode

Both entries alive. The publish would have been fine.

The fix is to write the normalized form yourself — "src/cli.js", no ./ — so the warning never appears. Not because the warning was right, but because nobody should have to read npm's source to dismiss it.

The lesson isn't "ignore npm warnings". It's that a warning claiming destructive behaviour deserves 60 seconds of verification before you trust or dismiss it. I nearly published without checking; I also nearly rewrote half my package layout to appease something that wasn't broken.

2. My tests silently didn't run on two Node versions

I used Node's built-in test runner, no framework:

"scripts": {
  "test": "node --test \"test/*.test.js\""
}
Enter fullscreen mode Exit fullscreen mode

Green locally. First CI run, Node 22 passed and Node 18 and 20 failed:

Could not find '/home/runner/work/.../test/*.test.js'
Enter fullscreen mode Exit fullscreen mode

The quotes are the bug. They stop the shell from expanding the glob, so Node receives the literal string test/*.test.js. Newer Node expands that itself. Node 18 and 20 don't — they look for a file with an asterisk in its name, find nothing, and exit.

What makes this worth writing down is the failure mode one Node version over. On Node 22+, the runner expands the pattern itself — and a pattern that matches nothing is not an error. node --test "test/*.test.js" in a tree where that matches zero files prints tests 0 and exits 0. Green CI, no coverage, no signal. Node 18 and 20 were doing me a favour by failing loudly.

The fix was not better quoting:

"scripts": { "test": "node --test" }
Enter fullscreen mode Exit fullscreen mode

Bare node --test discovers the test directory itself, consistently, on every version that still gets security patches. I also dropped Node 18 from the matrix — it went EOL in April 2025, and keeping it was buying compatibility debt with no user attached to it.

Never let your test command's meaning depend on which shell expands it. Hand the discovery to the runner.

3. A registry mirror redirected my publish, quietly

npm login opened a signup page for a registry I'd never heard of, showing:

Public registration is not allowed

My ~/.npmrc, set up years ago for faster installs in China:

registry=https://registry.npmmirror.com
Enter fullscreen mode Exit fullscreen mode

That mirror is read-only. Installs work beautifully. Publishing silently goes to a host that will never accept it — and the failure surfaces as a confusing signup wall, not as "you cannot publish here".

The --registry flag fixes the immediate problem, but it fixes it on my machine, for this invocation. The durable fix belongs in the package:

"publishConfig": {
  "registry": "https://registry.npmjs.org",
  "access": "public"
}
Enter fullscreen mode Exit fullscreen mode

The publish destination is a property of the package, not of whoever runs the command. With that in place, a contributor behind any mirror — or CI with an inherited .npmrc — publishes to the right place without knowing this trap exists. If you maintain a public package and haven't set publishConfig, you're relying on every future publisher having a clean config.

The pattern

All three were the same mistake in different clothes: I read a message instead of verifying a state.

  • npm said "removed" → I should have checked whether bin was actually gone. It wasn't.
  • CI said "passed" on one version → I should have checked whether tests ran on the others. They didn't.
  • npm login showed a signup page → I should have checked which registry I was talking to. Not the one I thought.

Each check took under a minute. Each message, taken at face value, pointed the wrong way — one toward unnecessary work, one toward false confidence, one toward confusion.

The tooling is not lying to you. It is describing its own internals in language that sounds like it's describing yours.


The package that surfaced all this: video-bitrate-calculatorsize = bitrate × duration, plus the safety margins that stop your file from missing a hard upload limit by 40 KB. Zero dependencies, MIT.

It came out of videocompress.dev, a video compressor that runs entirely in the browser. Same arithmetic, extracted and made standalone.

Top comments (0)