Two weeks before v0.8.7 shipped, we added a dist-check
workflow to the repo. The job was simple: rebuild the MCP server
from source on Linux, diff the result against the committed
mcp-server/dist/ directory, and fail the build if they
didn't match. The intent was to catch the case where someone updated
the source but forgot to run npm run build before
committing.
The check went red on the very first push. We rebuilt, recommitted,
pushed again. Still red. We diffed locally on the developer
machine — a darwin-arm64 MacBook — against what CI had built on
ubuntu-latest. The diffs were tiny. A few hundred bytes
here. A whitespace shift there. About 3,700 bytes total across a
few files, on inputs that were byte-identical: same source, same
package-lock.json, same esbuild version
pinned in devDependencies.
For two weeks every PR went red. We ignored the check, then considered deleting it. Eventually we did the thing we should have done on day one: read what the diff actually was.
What esbuild was doing.
esbuild is a fantastic bundler. It's fast, it has good
defaults, it handles TypeScript natively, it tree-shakes
aggressively. We had been using it to compile
mcp-server/src/**/*.ts into a small set of bundled
.js files in dist/. Around 4 megafiles
total, with the runtime native deps
(@imgly/background-removal-node, sharp,
onnxruntime-node) marked --external so
they'd resolve from node_modules at runtime.
The bundled output was efficient and self-contained. It was also, we discovered, not byte-identical across operating systems. esbuild does a small amount of platform-specific work during code generation. Tree-shaking decisions can depend on dependency-graph traversal order, which depends on filesystem enumeration order, which is OS-specific. Source-map encoding has minor variants. None of this affects runtime behavior — the output is functionally identical — but the bytes differ.
For a project with a "the dist in git matches what CI builds" check, "functionally identical" is not the same as "byte-identical." The check failed for a real reason: we had no guarantee that the dist committed by a Mac developer would match what an Ubuntu CI runner produced.
The first instinct was wrong.
The obvious fix is to move the build into CI. Don't commit the dist; let CI build it on every release. We considered this and rejected it. The plugin gets fetched by Claude Code's marketplace mechanism from the GitHub raw URL, and the dist has to be in the repo at install time. We could publish a separate release artifact, but that adds complexity for a small project.
The right fix isn't a smarter check. It's a builder whose output doesn't depend on which laptop ran it. — commit message, v0.8.7
What we wanted was a build tool whose output is deterministic
across operating systems on identical inputs. That ruled out
bundlers in general, since most of them make platform-influenced
decisions during code generation. It pointed at the most basic
thing on the shelf: tsc.
What we lost, what we gained.
Switching from esbuild to tsc meant giving up bundling
entirely. tsc compiles each .ts file to
one .js file. No tree-shaking, no inlining, no minification.
Our dist went from 4 bundled megafiles to ~50 small files mirroring
the source tree.
Counterintuitively, the total dist size went down. Way down.
The reason: esbuild was inlining the dependency tree into the
bundle, even though most of those deps were already being shipped
via node_modules at install time (the bootstrap shim
runs npm ci --omit=dev on first launch since v0.8.3).
The bundle was duplicating code that the runtime already had on
disk.
tsc just emits import {...} from '@imgly/...'
verbatim. Node's runtime resolution finds the dep in
node_modules at first call, exactly as it did before.
No behavior change. No size penalty. The bundle was solving a
problem that didn't exist for our deployment shape.
The postbuild step.
tsc doesn't do everything esbuild did. We needed three
small things that the bundler had handled automatically:
-
Copy
pricing.jsontodist/pricing/so the compiledload.jscan find it viareadFileSync. -
Prepend
#!/usr/bin/env nodeshebangs to the entry files so they're directly executable. -
chmod +xthe entry files.
All three got shoved into a tiny scripts/postbuild.mjs:
// scripts/postbuild.mjs — runs after `tsc` import { readFileSync, writeFileSync, copyFileSync, chmodSync, mkdirSync } from 'node:fs'; // 1. copy pricing.json next to its loader mkdirSync('dist/pricing', { recursive: true }); copyFileSync('src/pricing/pricing.json', 'dist/pricing/pricing.json'); // 2. + 3. prepend shebangs to entry points and chmod for (const entry of ['dist/server.js', 'dist/cli.js']) { const body = readFileSync(entry, 'utf8'); if (!body.startsWith('#!')) { writeFileSync(entry, '#!/usr/bin/env node\n' + body); } chmodSync(entry, 0o755); }
Cross-platform. Node fs API only. No npm dep. The whole thing is shorter than this paragraph.
One more thing: JSON imports.
The pricing module had been importing its JSON via the modern syntax:
import pricing from './pricing.json' with { type: 'json' };
Import attributes (the with clause) became stable in
Node 22. esbuild handled this fine because it inlined the JSON
directly into the bundle. tsc compiles the import
statement through to the output, and the result needs to actually
run on whatever Node version users have. Our minimum supported
version is Node 18, where import attributes are still flagged.
We swapped to readFileSync + JSON.parse —
universal across every Node version we'd ever target — and called
it done. The cost is a few microseconds at startup. The benefit is
that nothing breaks for users on the LTS line.
Lessons.
Read the diff before changing tools
We lost two weeks because we kept retrying the build instead of asking what specifically is different. The diff was small, readable, and would have pointed at the answer in fifteen minutes. "It's flaky" is almost never true. There's a cause, and the diff is usually showing you part of it.
Bundling is a deployment choice, not a default
We had reached for esbuild because that's what modern TypeScript projects do. But our deployment shape — node modules already on disk via a bootstrap install — meant the bundle was duplicating work the runtime was already doing. The smaller, simpler tool produced a smaller, simpler artifact. Defaults are defaults because they fit a common case, not your case.
Determinism is a property of the build pipeline, not a flag
You don't get reproducible builds by adding
--reproducible. You get them by choosing tools that
don't make platform-influenced decisions, and by treating the
output bytes as a contract. The whole pipeline has to cooperate.
tsc isn't reproducible because it tries hard; it's
reproducible because the work it does is straightforward enough
that the same inputs reliably produce the same outputs.
Postbuild scripts are fine
A 12-line scripts/postbuild.mjs using only Node
built-ins is worth more than a plugin ecosystem. There was a
moment, mid-migration, where we considered adding a build-tool
plugin to handle the shebang and the asset copy. We didn't. The
script is shorter than the plugin's README would have been.
The dist-check has been green on every push since v0.8.7 landed. The changelog entry has the one-line summary. The full migration was a single commit. The total time spent on it — once we read the diff — was an afternoon.
Read the diff.