Skip to content

Convert TypeScript package to ESM - #261

Open
Arechii wants to merge 5 commits into
masterfrom
typescript-esm-only
Open

Convert TypeScript package to ESM#261
Arechii wants to merge 5 commits into
masterfrom
typescript-esm-only

Conversation

@Arechii

@Arechii Arechii commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Publishes @blueyerobotics/protocol-definitions as an ESM package, while keeping CommonJS consumers working on modern Node.

  • tsconfig.json: module/moduleResolution set to NodeNext, target raised to ES2022. This makes TypeScript's resolution match what Node actually does at runtime.
  • package.json: adds "type": "module" and an exports map; drops main. Version bumped to 4.0.0.
  • Generation: the protoc invocation moved into a generate npm script, with esModuleInterop=true and importSuffix=.js so ts-proto emits the explicit .js extensions NodeNext requires.
  • CI: new step packs the tarball and verifies both entrypoints against the real exports map.

Compatibility

The . entry uses a default condition rather than import:

"exports": {
  ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
  "./dist/*": "./dist/*"
}

import matches ESM callers only, so require() would fall through the map and fail with ERR_PACKAGE_PATH_NOT_EXPORTED. default matches any loader, and Node 22.12+ / 20.19+ can require() an ES module (require(esm)) as long as the graph has no top-level await — generated protobuf code has none. Verified locally:

  • import { blueye } from "@blueyerobotics/protocol-definitions" — works
  • require("@blueyerobotics/protocol-definitions") — returns ['blueye', 'google']
  • CJS consumer type-checking under module: nodenext (TS 5.9) — clean

On Node older than 22.12, require() fails with ERR_REQUIRE_ESM.

The ./dist/* subpath keeps deep imports working with the same specifiers as before the exports map existed.

Why 4.0.0

This does not mean protocol v4. The package still implements protocol v3; the npm version tracks releases of this package, not the protocol generation. The two were never coupled — the NuGet package is at 5.4.0 for the same protocol v3, and the CMake project at 3.0.0. Since the published npm README never actually stated the protocol version, this PR adds it to README.npm.md explicitly, which is a firmer signal than a leading digit.

CI publishes 3.2.0-<sha> on every master push under the latest tag. A consumer who ran npm install has ^3.2.0-<sha> in their manifest, and that caret range does match other 3.2.0-* prereleases, since prerelease identifiers compare lexically. Without a major bump, npm update could walk a consumer onto the ESM build with no signal. 4.0.0 breaks the range and forces an explicit upgrade.

Notes

  • Nothing forced this migration — @bufbuild/protobuf dual-publishes, and the previous CJS build already worked for ESM consumers via cjs-module-lexer named-export detection. The wins here are correct NodeNext type resolution and roughly 12% smaller bundles (583.7 KB → 515.8 KB for a one-message esbuild bundle); tree-shaking is limited because ts-proto's encode/decode closures keep most of the graph reachable.
  • Shipping one file rather than dual-publishing also avoids the dual package hazard, where a consumer reaching the package both ways gets two module instances with instanceof checks that silently fail across the boundary.
  • CLAUDE.md documented a protoc command without the new flags, which produced TS2835 errors on every file under the new tsconfig. Folding the flags into the generate script keeps CI and the docs in sync.

🤖 Generated with Claude Code

Switches the npm package from CommonJS to ESM-only output.

- tsconfig.json: module/moduleResolution NodeNext, target ES2022
- package.json: adds "type": "module" and "exports" map; drops "main"
- CI Generate: adds esModuleInterop=true and importSuffix=.js so
  ts-proto emits relative imports with .js extensions for NodeNext

Breaking change for CommonJS consumers (require() is blocked by the
exports map); a major version bump should accompany this before
publishing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@follesoe
follesoe requested review from jp-pino and a lite review from Copilot May 22, 2026 06:14

Copilot AI 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.

Pull request overview

This PR migrates the @blueyerobotics/protocol-definitions npm package build output from CommonJS to ESM-only, aligning the TypeScript compiler and ts-proto generation with Node’s ESM/NodeNext requirements.

Changes:

  • Update TypeScript compilation to module/moduleResolution: NodeNext and raise target to ES2022.
  • Convert the npm package to ESM-only via "type": "module" and an exports map (dropping the previous main entrypoint).
  • Adjust CI generation to emit .js-suffixed relative imports and enable esModuleInterop in ts-proto output.

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated 2 comments.

File Description
tsconfig.json Switches TS compiler settings to NodeNext ESM output and ES2022 target.
package.json Declares ESM-only package semantics and defines an exports map for the entrypoint/types.
.github/workflows/ci-typescript.yaml Updates ts-proto generation options to be compatible with NodeNext ESM resolution.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread package.json Outdated
Comment thread package.json Outdated
- Bump to 4.0.0: the output format change is breaking, and the
  `3.2.0-<sha>` prerelease scheme means a `^3.2.0-<sha>` range would
  otherwise let `npm update` move consumers onto the ESM build silently.
- Use a `default` export condition instead of `import`, so `require()`
  still resolves on Node 22.12+ via `require(esm)` instead of failing
  with ERR_PACKAGE_PATH_NOT_EXPORTED.
- Restore deep imports via a `./dist/*` subpath export, preserving the
  specifiers that worked before the `exports` map was introduced.
- Move the protoc invocation into a `generate` npm script so CI and the
  docs cannot drift; CLAUDE.md documented flags that no longer compiled
  under NodeNext.
- Add a CI step that packs the tarball and verifies both the ESM import
  and the CJS require against the real `exports` map.
- Document the module format in README.npm.md and ignore `dist/`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Arechii Arechii changed the title Convert TypeScript package to ESM-only Convert TypeScript package to ESM Aug 18, 2026
Arechii and others added 3 commits August 18, 2026 21:04
"ESM only" reads as "you cannot require this", which is not true here —
require() resolves on Node 22.12+ via require(esm). Lead with what both
loaders actually do instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Match the README wording — "ESM-only" implies require() is unavailable,
which it is not. Also record why the exports map uses a `default`
condition, so it does not get "corrected" back to `import` later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The published README (README.npm.md, copied over README.md at publish time)
never mentioned that this implements protocol v3, so npm consumers had no
signal beyond inferring it from the leading version digit. That inference was
never reliable — the NuGet package sits at 5.4.0 for the same protocol v3 —
so state it explicitly and note that the package version is a separate axis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 21, 2026 10:03

Copilot AI 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.

Pull request overview

Copilot reviewed 4 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (1)

package.json:16

  • This wildcard maps @blueyerobotics/protocol-definitions/dist/telemetry literally to ./dist/telemetry; unlike the old extensionless CommonJS path resolution, package export targets do not search for .js, so that previously valid deep import now fails. If preserving deep-import compatibility is intended, add an extensionless compatibility mapping (while retaining the .js form), or document this additional breaking change instead of claiming the old specifiers remain supported.
    "./dist/*": "./dist/*"

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.

2 participants