Skip to content

fix(build): make the ESM bundle loadable; add a package smoke test - #12

Merged
ericlakich merged 2 commits into
mainfrom
fix/esm-build-extensions
Aug 7, 2026
Merged

fix(build): make the ESM bundle loadable; add a package smoke test#12
ericlakich merged 2 commits into
mainfrom
fix/esm-build-extensions

Conversation

@ericlakich

@ericlakich ericlakich commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The bug

import from this package has never worked. Any ESM consumer gets:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../dist/esm/client'
imported from '.../dist/esm/index.js'

TypeScript does not rewrite relative import specifiers, so dist/esm shipped 24 extensionless imports and Node's ESM resolver rejects every one. require() worked fine, which is why nobody hit it — but package.json advertises "module" and exports.import pointing at a build that cannot load.

This is not specific to 1.0.0-beta. I checked 0.2.0-beta — the version the latest dist-tag currently points at — and it emits the identical export { ZiptaxClient } from './client';. Every published version has this defect.

Found while smoke-testing the 1.0.0-beta tarball after publishing it. That's also why latest has not been moved to 1.0.0-beta yet.

The fix

Relative imports in src/ now carry an explicit .js extension, and directory imports name index.js explicitly — Node's ESM resolver won't resolve a bare directory either:

- export { ZiptaxClient } from './client';
- export { isTaxCloudCartResponse } from './models';
+ export { ZiptaxClient } from './client.js';
+ export { isTaxCloudCartResponse } from './models/index.js';

TypeScript maps ./client.js back to ./client.ts when compiling, so the CommonJS build is unchanged. This is the documented way to author ESM-compatible TypeScript, and it avoids adding a bundler.

Jest needs a moduleNameMapper to strip the extension, since it does not do that substitution itself.

Two smaller packaging fixes alongside:

  • exports map reordered so types matches first. Conditions in an exports map match in order, and require/import preceded types. Type resolution happened to work via the top-level types field, but the map is what node16/nodenext reads.
  • ./package.json is now exported. Reading it raised ERR_PACKAGE_PATH_NOT_EXPORTED; some tooling expects it reachable.

A second bug, caught by the matrix

Loadable specifiers turned out to be necessary but not sufficient. With them in place the smoke test still failed on Node 18 and 20, while passing on 22 and on my local Node 26:

SyntaxError: Named export 'NO_RETRY' not found. The requested module
'@ziptax/node-sdk' is a CommonJS module...

The root package.json has no "type" field, so Node classifies every .js file in the package as CommonJS — dist/esm included. An import resolved through the exports map to the ESM build and was then read as CJS, where Node's named-export detection found some bindings and missed others.

Fixed by writing per-directory module markers during the build, the standard dual-package layout:

  • dist/esm/package.json{"type": "module"}
  • dist/cjs/package.json{"type": "commonjs"}

Marking the CommonJS side explicitly costs nothing and keeps it correct if the root package ever adopts "type": "module". Both land inside dist/, so the existing files: ["dist"] ships them.

I verified this against Node 18.20.8 specifically rather than trusting CI: with the marker the named imports resolve, and deleting it reproduces the CI SyntaxError verbatim.

Two process notes from this:

  • This is exactly why the version matrix is there. A single-version smoke test on a modern Node would have passed and shipped the bug again.
  • fail-fast: false added to the smoke matrix. The first run canceled the Node 20 leg before it ran, so I couldn't tell whether the failure was version-specific or universal. The value of a matrix is all of its legs reporting.

Why this shipped, and the guard

The unit suite runs ts-jest against src/. Nothing ever loaded the built output, so a completely unloadable entry point passed 278 tests, lint, type-check, and four releases.

npm run smoke:package closes that: it packs the tarball, installs it into a throwaway project, and loads it as a consumer would.

Packing tarball... ziptax-node-sdk-1.0.1-beta.tgz
Installing into a clean project... done
  require() ... ok (17 exports)
  import ..... ok
  types ...... ok

Package smoke test passed.

The type check runs under module: node16 / moduleResolution: node16 specifically, so a mis-ordered exports map fails there rather than silently falling back.

It runs in CI on Node 18, 20, and 22, and as part of prepublishOnly so a broken artifact cannot be published.

I verified the guard actually catches the bug rather than assuming it does — reintroducing a single extensionless import makes it fail with ERR_MODULE_NOT_FOUND and a non-zero exit. A regression test that can't reproduce the defect isn't worth having.

Version

1.0.0-beta1.0.1-beta.

1.0.0-beta is already published and immutable, so this ships as a patch on top rather than a re-publish.

Review notes

  • scripts/smoke-test-package.js resolves TypeScript via require.resolve('typescript/package.json') rather than node_modules/.bin/tsc. The .bin path does not exist in a git worktree and is a .cmd shim on Windows, both of which broke my first attempt.
  • The script cleans up after itself — it removes the temp project and the .tgz it creates in the repo root, verified with git status.
  • Also fixed 9 lint warnings in tests/retry-policy.test.ts (missing return types) that I introduced in feat!: realign SDK with current Ziptax API surface (v1.0.0-beta) #10. Lint is back to the 2 pre-existing no-console warnings in src/utils/http.ts.

After merge

Per the earlier decision to fix ESM before promoting: cut the v1.0.1-beta release, let it publish to beta, then move latest:

npm dist-tag add @ziptax/node-sdk@1.0.1-beta latest

🤖 Generated with Claude Code


Open in Devin Review

`import { ZiptaxClient } from '@ziptax/node-sdk'` failed outright with
ERR_MODULE_NOT_FOUND. TypeScript does not rewrite relative import
specifiers, so dist/esm shipped 24 extensionless imports that Node's ESM
resolver rejects. Only require() worked, despite package.json advertising
"module" and exports.import.

Not specific to 1.0.0-beta: every published version has this, including
the 0.2.0-beta that the `latest` dist-tag points at. `import` has never
worked for this package.

Sources now write relative imports with an explicit .js extension, and
directory imports name index.js explicitly, which Node's ESM resolver also
requires. TypeScript maps './client.js' back to './client.ts' when
compiling, so the CommonJS build is unaffected. Jest gets a
moduleNameMapper to strip the extension, since it does not do that
substitution itself.

Two smaller packaging fixes alongside:

- exports map reordered so `types` matches first. Conditions match in
  order, and require/import preceded it. Resolution happened to work via
  the top-level "types" field, but the map is what node16/nodenext read.
- ./package.json is exported; reading it raised
  ERR_PACKAGE_PATH_NOT_EXPORTED.

Adds `npm run smoke:package`, which packs the tarball, installs it into a
throwaway project, and loads it through require(), import, and tsc under
node16 resolution. It runs in CI on Node 18, 20, and 22, and as part of
prepublishOnly.

That gap is the reason this shipped: the unit suite runs ts-jest against
src/, so nothing ever exercised the built output. Confirmed the new check
fails on the original defect by reintroducing it before relying on it.

Also adds the missing return types that the new retry-policy tests were
warning on, taking lint back to the two pre-existing console warnings.

278 tests passing, 99.69% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

Open in Devin Review

Comment thread package.json
Comment on lines +10 to +13
"types": "./dist/types/index.d.ts",
"require": "./dist/cjs/index.js",
"import": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts"
}
"import": "./dist/esm/index.js"
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Importing the package from modern JavaScript projects still fails

The modern-import build is published without the marker that tells Node it contains modern-style code (no "type": "module" next to dist/esm, and the root package.json:3-14 declares none), so importing the package still fails on Node 18 and 20 even after the extensions fix.

Impact: Consumers who use import still cannot load the SDK, and the new packaging check will fail in CI on Node 18 and 20.

Why Node treats dist/esm as CommonJS despite the .js-extension fix

tsconfig.esm.json emits module: ES2020 output into dist/esm/*.js. Node determines module format from the nearest package.json; that is the package root manifest, which has no "type": "module", and the build (package.json build script: clean → version → cjs → esm → types) never writes a dist/esm/package.json. Node therefore parses dist/esm/index.js as CommonJS.

Reproduced locally with a minimal package having the same exports map and an ESM-syntax dist/esm/index.js on Node 20:

SyntaxError: Named export 'x' not found. The requested module 'testpkg' is a CommonJS module...

Adding .js extensions (e.g. src/index.ts:7) fixes specifier resolution but not the format determination. Node 22.7+ enables module-syntax detection by default, which likely explains why the author's local run of scripts/smoke-test-package.js passed while Node 18/20 will not.

Fix: emit dist/esm/package.json containing {"type": "module"} (and optionally dist/cjs/package.json with {"type": "commonjs"}) as part of the build, or emit the ESM output with .mjs extensions.

Prompt for agents
The ESM output in dist/esm is emitted as ES2020 module syntax into .js files, but nothing marks that directory as ESM: the root package.json has no "type": "module" and the build pipeline (build:clean, build:version, build:cjs, build:esm, build:types in package.json) never writes a dist/esm/package.json. Node determines module format from the nearest package.json, so it parses dist/esm/index.js as CommonJS and an `import` of the package fails on Node 18 and 20 (Node 22.7+ hides this via default module-syntax detection, which is likely why the new smoke test passed locally). Fix by adding a build step that writes {"type": "module"} into dist/esm (and, for safety, {"type": "commonjs"} into dist/cjs), or by emitting the ESM build with .mjs extensions and pointing exports.import at it. Make sure the packed tarball includes the generated marker file and that scripts/smoke-test-package.js is run on Node 18 or 20 to confirm the fix.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread scripts/smoke-test-package.js
Comment thread package.json
Comment thread jest.config.js
Follow-up to the specifier fix in this branch, caught by the new smoke
test's Node version matrix.

Loadable specifiers were necessary but not sufficient. The root
package.json has no "type" field, so Node classifies every .js file in the
package as CommonJS — including dist/esm. An `import` resolved through the
exports map to the ESM build and was then read as CJS, and Node's
named-export detection found some bindings but not others:

  SyntaxError: Named export 'NO_RETRY' not found. The requested module
  '@ziptax/node-sdk' is a CommonJS module...

The build now writes dist/esm/package.json with {"type":"module"} and
dist/cjs/package.json with {"type":"commonjs"}, the standard dual-package
layout, scoping the module type per directory. Marking the CommonJS side
explicitly keeps it correct if the root ever adopts "type": "module".
Both markers land inside dist/, so the existing `files: ["dist"]` ships
them.

Node 22 tolerated the missing marker and Node 18 did not, so this passed
locally on Node 26 and only failed in CI. Verified both directions against
Node 18.20.8 specifically: with the marker the named imports resolve, and
deleting it reproduces the CI SyntaxError exactly.

Also sets fail-fast: false on the smoke matrix. The first run canceled the
Node 20 leg before it executed, which hid whether the failure was
version-specific or universal — the useful signal from a matrix is all of
its legs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericlakich
ericlakich merged commit ac6cc26 into main Aug 7, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant