Skip to content

Fix ESM import resolution in dist output#5

Open
chad-loder wants to merge 2 commits into
daniellockard:mainfrom
chad-loder:fix/esm-import-extensions
Open

Fix ESM import resolution in dist output#5
chad-loder wants to merge 2 commits into
daniellockard:mainfrom
chad-loder:fix/esm-import-extensions

Conversation

@chad-loder

Copy link
Copy Markdown

Fixes #4.

Problem

The compiled dist/ files contain extensionless relative imports like from './core/ZMachine' and bare directory imports like from './core/errors'. Since the package declares "type": "module", Node's ESM loader requires explicit file extensions and rejects these at runtime:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../dist/core/ZMachine'

This doesn't surface in development or CI because Vitest (via Vite) handles extensionless resolution internally, but it breaks any consumer that loads the package through Node's native module system.

The root cause is moduleResolution: "bundler" in tsconfig.json — TypeScript allows extensionless imports under this mode (since bundlers resolve them), but tsc emits the specifiers verbatim into the JS output.

Fix

Rather than modifying every import across the source tree or changing the module resolution strategy, this adds tsc-esm-fix as a postbuild step. It rewrites the relative imports in dist/ to include proper .js extensions (and /index.js for directory barrel imports like ./core/errors./core/errors/index.js).

The change is two lines in package.json:

-    "build": "tsc",
+    "build": "tsc && tsc-esm-fix --target=dist",

Plus tsc-esm-fix as a devDependency.

About tsc-esm-fix

tsc-esm-fix is an established utility (~62K weekly downloads, 85 stars, MIT licensed, provenance-signed) that specifically addresses this gap in TypeScript's ESM output. It's been actively maintained since 2021 with 75 releases.

Testing

  • Full test suite passes (802 tests, 0 failures) after the change.
  • Verified that node -e "import('./dist/index.js')" loads successfully under Node's native ESM — the exact scenario that was previously broken.
  • Verified zero extensionless relative imports remain in dist output after a clean build.

chad-loder added 2 commits May 3, 2026 18:42
tsc emits import specifiers verbatim, so with moduleResolution: "bundler"
the compiled dist/ files contain extensionless relative imports like
from ./core/ZMachine. Node ESM loader rejects these at runtime.

Add tsc-esm-fix as a postbuild step to rewrite relative imports in dist/
with correct .js extensions (and /index.js for directory imports).

Fixes daniellockard#4
When installed as a git-hosted dependency, npm/pnpm runs the prepare
script. Previously this only ran husky, so the dist/ directory was
never built and consumers got MODULE_NOT_FOUND errors.

Run the build in prepare and guard husky so it does not fail when
the package is installed outside its own repo.
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.

ESM import resolution broken in 0.3.0 (missing .js extensions)

1 participant