Skip to content

Make the ESM build resolvable in Node, and run the checks in CI - #24

Merged
panyam merged 2 commits into
masterfrom
fix/esm-build-resolvable-in-node
Sep 21, 2026
Merged

panyam merged 2 commits into
masterfrom
fix/esm-build-resolvable-in-node

Conversation

@panyam

@panyam panyam commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner

What changes

import * as N from "notations" works from plain Node now. The build rewrites the relative specifiers in lib/esm so they carry .js, and writes the type markers that tell Node which half of lib/ is which. Sources keep their extensionless imports, so jest and the two webpack configs are untouched. The require() path was already fine and stays fine.

The same PR adds the repo's first workflow file, since the check that proves the packaging fix has to run somewhere other than one developer's pre-commit hook. CodeQL was already running through GitHub's default setup, but nothing was building, linting or testing the code.

Fixes #20, fixes #25.

Prerequisite knowledge

  • Node.js integration guide, which shows import * as N from 'notations' as a supported form. That form did not work, so this PR is mostly about making the page true.
  • Node's ESM resolution rules, for why a specifier without an extension is a hard error under import and a non-issue under require.
  • Nothing in designs/ or docs/ covers how the package is built and published, which is the page this change would have been checked against if it existed.

Reviewer's guide

Two different postal systems deliver to this package. CJS is the one where you can write "the Smiths, Elm Street" on the envelope and the postman works out the rest, so require("./entity") finds entity.js by trying extensions until something sticks. ESM is the one that wants the full address including the house number, and ./entity gets returned to sender. TypeScript copies whatever the source wrote straight into the output, so both builds came out addressed the loose way, and only the half delivered by the strict service failed. The other half of it is that nothing on the parcel said which service to use. Node decides that from the nearest package.json, ours declares no type, and so lib/esm was being handed to the CJS service and only worked because Node 22 opens the parcel and guesses from the contents. Older Node does not guess, and fails for a different reason instead. Both are fixed at the point the parcel is packed rather than at the point the address is written, which is why nothing in src/ moves. The CI half of the PR is the depot check: until now every sender was trusted to inspect their own envelopes before posting, which is how a whole release went out with the wrong addresses on it.

Read in this order:

  1. scripts/finalize-esm.mjs, which writes the full address on every envelope in lib/esm and stamps both halves with their delivery service. The part worth checking is resolved(), which refuses to invent an address and fails the build instead.
  2. scripts/check-esm.mjs, the acceptance. It imports the built entry from a real Node ESM context, because a bundler resolves its own way and will happily load a tree Node rejects.
  3. package.json, the wiring. buildbase gained the finalize step, and check:esm sits in the pre-commit chain right after build.
  4. .github/workflows/ci.yml, the depot check. Same five commands the pre-commit chain runs, on three Node versions, on every push to master and every PR.

Diff groups

  • Packaging: scripts/finalize-esm.mjs, package.json
  • Acceptance: scripts/check-esm.mjs
  • CI: .github/workflows/ci.yml

How it works

finalize-esm:
    for every .js and .d.ts under lib/esm:
        for every relative specifier in `from "..."`, `import "..."`, `import("...")`:
            if a file exists at that exact path        -> leave it
            else if <spec>.js was emitted              -> rewrite to <spec>.js
            else if <spec>/index.js was emitted        -> rewrite to <spec>/index.js
            else                                       -> collect it and fail at the end
    write lib/esm/package.json  {"type": "module"}
    write lib/cjs/package.json  {"type": "commonjs"}

Resolving against the emitted tree rather than appending .js blindly is what makes the seven directory imports (./, ./carnatic, ../../carnatic) come out as ./index.js and ./carnatic/index.js. It is also what would catch a future import "./theme.css", which would resolve to no emitted file and stop the build rather than being quietly corrupted into ./theme.css.js.

check-esm then reads the marker, scans for any relative specifier still missing an extension, and does a real await import() plus a load() round trip.

Decision log

  • Post-processed lib/esm rather than writing .js into the 166 relative imports across the 49 source files. The source rewrite is the canonical TypeScript fix and it also corrects the .d.ts specifiers at the source, but it needs a moduleNameMapper entry in jest and a resolve.extensionAlias in docs/webpack.config.js, and it would have put a 170-line mechanical diff in front of a packaging change. Worth revisiting if the project ever moves to moduleResolution: nodenext, which forces it anyway.
  • Added the type markers to both halves rather than only lib/esm. Pinning lib/cjs costs nothing and means neither half depends on the root package.json staying silent about type.
  • Failed the build on an unresolvable specifier rather than warning. A specifier that resolves to nothing means the published tree would throw at import time, which is exactly the class of bug this PR is closing.
  • CI uses pnpm rather than npm ci. pnpm-lock.yaml is the tracked lockfile and package-lock.json is gitignored, so npm ci has nothing to install from on a fresh clone.
  • Installed with --ignore-scripts. The root prepare script is npm run build, so without it CI builds twice and a broken build reports as a failed install.
  • Ran lint rather than lintfix in CI, since the fixing variant rewrites files and would pass on code that is not actually clean in the tree.
  • Kept check:esm in the pre-commit chain as well as in CI. It needs a built lib/, which npm test does not produce, and the local hook is still the faster feedback loop.

Risk / blast radius

  • The packaging change affects the published package only. lib/ is gitignored and rebuilt, so nothing in the repo's own working tree changes shape.
  • Consumers on the import condition go from broken to working. Consumers on require are unaffected, and both were checked against a real installed-package layout.
  • Bundler consumers are the ones to pressure-test. lib/esm/package.json now says "type": "module", which changes how webpack, rollup and vite classify those files. They already are ESM, so this should be a no-op, and the repo's own UMD build (which bundles lib/esm/index.js through babel-loader) still produces the same dist/notations.umd.min.js. Worth a second pair of eyes anyway, since this is the one line with reach beyond Node.
  • Only Node 22.23.2 was available locally, so the 20 and 24 legs were unverified at push time. They have since run green on this PR, in 42s and 38s. fail-fast: false stays on so a future failure on one leg still reports the others.
  • pnpm install --frozen-lockfile will fail if pnpm-lock.yaml drifts from package.json. It is in sync today, checked from a clean clone, and dependabot updates both together. Worth knowing that this is now a hard failure rather than a silent resolve.
  • Related tickets: Parsing pulls in the renderer: decouple carnatic from parser.ts, and add a core export #18 wants a core export that does not pull in the renderer, which would add entries to the exports map. Those entries want the same treatment, and the finalizer covers any new file automatically since it walks the emitted tree.

Before / after

A consumer package with notations installed, "type": "module", on Node 22.23.2.

Before:

$ node esm.mjs
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../notations/lib/esm/entity'
  imported from .../notations/lib/esm/index.js
    at finalizeResolution (node:internal/modules/esm/resolve:275:11)
$ node cjs.cjs
require notations -> ok

After:

$ node esm.mjs
import  notations -> ok
$ node cjs.cjs
require notations -> ok

The same import with module-syntax sniffing turned off, which is how Node behaves before 22.12. Before the fix it fails for a different reason than it does with sniffing on, which is the argument for the matrix:

$ node --no-experimental-detect-module esm.mjs     # before
Error [ERR_REQUIRE_CYCLE_MODULE]: Cannot require() ES Module .../lib/esm/index.js in a cycle.

$ node --no-experimental-detect-module esm.mjs     # after
no-detect-module import -> ok

What the emitted entry looks like:

  // lib/esm/index.js
- export * from "./entity";
- export * from "./events";
- export * as Carnatic from "./carnatic";
+ export * from "./entity.js";
+ export * from "./events.js";
+ export * as Carnatic from "./carnatic/index.js";
flowchart LR
  subgraph after ["after"]
    A2["tsc -p tsconfig.json"] --> B2["lib/esm"]
    A3["tsc -p tsconfig-cjs.json"] --> C2["lib/cjs"]
    B2 --> F["finalize-esm.mjs"]
    C2 --> F
    F --> G["lib/esm with .js specifiers<br/>+ type markers on both halves"]
    G --> H["check-esm.mjs<br/>real Node import"]
  end
  subgraph before ["before"]
    A0["tsc -p tsconfig.json"] --> B0["lib/esm<br/>bare specifiers, no type marker"]
    A1["tsc -p tsconfig-cjs.json"] --> C0["lib/cjs"]
  end
Loading

Where the checks run, before and after:

build check:esm lint test
before, pre-commit hook yes, locally n/a yes, locally yes, locally
before, push or PR nothing nothing nothing nothing
after, pre-commit hook yes, locally yes, locally yes, locally yes, locally
after, push or PR Node 20, 22, 24 Node 20, 22, 24 Node 20, 22, 24 Node 20, 22, 24

CodeQL runs on PRs either way, through default setup rather than a workflow file, and is not affected by this change.

The workflow sequence was run end to end from a clean clone with a frozen lockfile:

pnpm install --frozen-lockfile --ignore-scripts   Done in 2.3s using pnpm v12.4.1
pnpm run build                                    exit 0
pnpm run check:esm                                check-esm: lib/esm imports and parses under Node v22.23.2
pnpm run lint                                     exit 0
pnpm test                                         16 suites, 172 passed, 4 skipped

And on this PR, which is the workflow running against itself:

verify (20)   pass   42s
verify (22)   pass   47s
verify (24)   pass   38s

Out of scope

lib/esm/index.js shipped `export * from "./entity"`, which Node's ESM
resolver rejects because it needs the full specifier. Importing the package
from plain Node failed with ERR_MODULE_NOT_FOUND. Bundlers hid it, since
they do their own resolution, so only unbundled consumers saw it: a Node
build step, a CLI, a test runner on native ESM, a server.

scripts/finalize-esm.mjs now runs after tsc and rewrites the emitted
relative specifiers, resolving each one against a file that was actually
emitted and failing the build if any of them resolves to nothing. The
sources keep their extensionless imports, so jest and the two webpack
configs are untouched.

It also writes the type markers for both halves. Without them Node
classifies lib/esm by the root package.json, which declares no type, and
the ESM half loaded only because Node 22 sniffs module syntax. Older
runtimes failed on the first `export` instead.

scripts/check-esm.mjs imports the built entry from a real Node ESM context
and runs a parse through it, and it is wired into the pre-commit chain
after build rather than left as something to remember.

Fixes #20
Comment thread scripts/finalize-esm.mjs
// type. The ESM half then loads only because Node 22 sniffs module syntax, and older runtimes
// fail on the first `export` instead.
writeFileSync(join(ESM_DIR, "package.json"), '{\n "type": "module"\n}\n');
writeFileSync(join(CJS_DIR, "package.json"), '{\n "type": "commonjs"\n}\n');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This one is not fixing a live bug, so it is fair to ask why it is here. lib/cjs is CommonJS today only because the root package.json declares no type, which makes commonjs the default. Adding "type": "module" at the root later, for any unrelated reason, would silently reclassify every file in lib/cjs and break require("notations"). Pinning both halves means neither depends on the root staying silent.

The repo had no workflows, so build, lint and test ran only in the
pre-commit hook, on the machine of whoever was committing, and only if
pre-commit was installed there. A commit made with --no-verify, or a PR
from a fresh clone, landed with nothing checked. Both bugs fixed in #23
shipped in v1.0.9 and were found downstream rather than here.

The matrix exists for check:esm. Node classifies lib/esm from its own
package.json marker on every version, but only 22.12 and later fall back
to sniffing module syntax, so a regression in that marker surfaces as a
different error either side of that line.

Install runs with --ignore-scripts so the root prepare script does not
build during install, which keeps a broken build reporting as a failed
build step rather than a failed install.

Fixes #25
@panyam panyam changed the title Emit resolvable specifiers in the ESM build Make the ESM build resolvable in Node, and run the checks in CI Sep 21, 2026
Comment thread .github/workflows/ci.yml

- uses: pnpm/action-setup@v4
with:
version: 12

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This reads as a mismatch, since pnpm-lock.yaml says lockfileVersion: '9.0'. It is not one. pnpm 9 through 12 all read and write that lockfile format, and 12 is what is installed on the dev machine this was verified on, so pinning the major here keeps CI and local on the same resolver. Dropping to version: 10would also work. What would not work isnpm ci, since package-lock.json` is gitignored.

@panyam
panyam merged commit cff2303 into master Sep 21, 2026
5 checks passed
@panyam
panyam deleted the fix/esm-build-resolvable-in-node branch September 21, 2026 09:00
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.

No CI: build, lint, test and the ESM check only run in the pre-commit hook ESM build is unusable in Node: imports have no file extensions

1 participant