fix(build): make the ESM bundle loadable; add a package smoke test - #12
Conversation
`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>
| "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" | ||
| }, |
There was a problem hiding this comment.
🔴 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
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>
The bug
importfrom this package has never worked. Any ESM consumer gets:TypeScript does not rewrite relative import specifiers, so
dist/esmshipped 24 extensionless imports and Node's ESM resolver rejects every one.require()worked fine, which is why nobody hit it — butpackage.jsonadvertises"module"andexports.importpointing at a build that cannot load.This is not specific to
1.0.0-beta. I checked0.2.0-beta— the version thelatestdist-tag currently points at — and it emits the identicalexport { ZiptaxClient } from './client';. Every published version has this defect.Found while smoke-testing the
1.0.0-betatarball after publishing it. That's also whylatesthas not been moved to1.0.0-betayet.The fix
Relative imports in
src/now carry an explicit.jsextension, and directory imports nameindex.jsexplicitly — Node's ESM resolver won't resolve a bare directory either:TypeScript maps
./client.jsback to./client.tswhen 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
moduleNameMapperto strip the extension, since it does not do that substitution itself.Two smaller packaging fixes alongside:
exportsmap reordered sotypesmatches first. Conditions in an exports map match in order, andrequire/importprecededtypes. Type resolution happened to work via the top-leveltypesfield, but the map is whatnode16/nodenextreads../package.jsonis now exported. Reading it raisedERR_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:
The root
package.jsonhas no"type"field, so Node classifies every.jsfile in the package as CommonJS —dist/esmincluded. Animportresolved 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 insidedist/, so the existingfiles: ["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
SyntaxErrorverbatim.Two process notes from this:
fail-fast: falseadded 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:packagecloses that: it packs the tarball, installs it into a throwaway project, and loads it as a consumer would.The type check runs under
module: node16/moduleResolution: node16specifically, so a mis-orderedexportsmap fails there rather than silently falling back.It runs in CI on Node 18, 20, and 22, and as part of
prepublishOnlyso 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_FOUNDand a non-zero exit. A regression test that can't reproduce the defect isn't worth having.Version
1.0.0-beta→1.0.1-beta.1.0.0-betais already published and immutable, so this ships as a patch on top rather than a re-publish.Review notes
scripts/smoke-test-package.jsresolves TypeScript viarequire.resolve('typescript/package.json')rather thannode_modules/.bin/tsc. The.binpath does not exist in a git worktree and is a.cmdshim on Windows, both of which broke my first attempt..tgzit creates in the repo root, verified withgit status.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-existingno-consolewarnings insrc/utils/http.ts.After merge
Per the earlier decision to fix ESM before promoting: cut the
v1.0.1-betarelease, let it publish tobeta, then movelatest:🤖 Generated with Claude Code