Skip to content

Reintroduce ESM build with CJS compatibility#439

Open
wojtekmaj wants to merge 3 commits intopillarjs:masterfrom
wojtekmaj:esm
Open

Reintroduce ESM build with CJS compatibility#439
wojtekmaj wants to merge 3 commits intopillarjs:masterfrom
wojtekmaj:esm

Conversation

@wojtekmaj
Copy link
Copy Markdown

@wojtekmaj wojtekmaj commented Apr 2, 2026

Closes #346
Fixes #347

This PR reintroduces an ESM build while preserving CJS compatibility for existing consumers.

The removal of the ESM build in v7 caused tree-shaking issues and may have affected adoption.

It is done so with utmost care for CJS compatibility:

  • type remains to be implied commonjs,
  • dist/index.js, dist/index.d.ts, index.js.map are all there and remain to be CJS files,

ESM is introduced via module and exports. I leveraged tsdown, a zero-config bundler, that made this task easy and (hopefully) maintainable in the future.

Unlike #397, this does NOT make this package "ESM-first" which, I assume, was the main concern ultimately leading to the PR being closed.

@arethetypeswrong/cli reports no issues:

 No problems found 🌟

┌───────────────────┬──────────────────┐
│                   │ "path-to-regexp" │
├───────────────────┼──────────────────┤
│ node10            │ 🟢               │
├───────────────────┼──────────────────┤
│ node16 (from CJS) │ 🟢 (CJS)         │
├───────────────────┼──────────────────┤
│ node16 (from ESM) │ 🟢 (ESM)         │
├───────────────────┼──────────────────┤
│ bundler           │ 🟢               │
└───────────────────┴──────────────────┘

Copilot AI review requested due to automatic review settings April 2, 2026 09:47
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Reintroduces an ESM distribution for path-to-regexp while keeping the existing CommonJS entrypoint working for current consumers.

Changes:

  • Switches package metadata to dual ESM/CJS via conditional exports, plus module field.
  • Replaces the build pipeline with tsdown to emit both CJS and ESM builds.
  • Removes tsconfig.build.json (previously used by the prior build tooling).

Reviewed changes

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

File Description
tsconfig.build.json Removes the separate build tsconfig previously used to exclude specs/bench from compilation.
package.json Adds conditional exports for ESM/CJS, updates entrypoint/type fields, and switches the build script to tsdown.

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

Copy link
Copy Markdown

Copilot AI left a comment

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 2 out of 2 changed files in this pull request and generated 2 comments.


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

@blakeembrey
Copy link
Copy Markdown
Member

While I appreciate the PR, and in fact would prefer it to be ESM only, it can't be changed in the current major version due to it being a breaking change. Anyone importing it will suddenly be switched to different source code and while it's unlikely it'll break due to how much backward compatibility stuff all the bundlers do, it's possible. Let me know if you think I'm incorrect on this though!

Additionally this PR breaks some existing checks by removing the tsconfig.build.json file. I'd prefer to just keep using the ts-scripts and you can see where it was removed and re-introduce that directly.

Finally, we could do a major version just to re-introduce ESM, but I would prefer to avoid it for now until I can resolve the ESM discussion with the express team, that way it's only one major in the case we can adopt ESM only.

@bjohansebas
Copy link
Copy Markdown
Member

bjohansebas commented Apr 2, 2026

Adding ESM support seems fine to me for the next major version, once we only support Node.js versions that require(esm). And I’d say for Express 6 we should do the same—many are already moving in that direction, so it would make sense.

@wojtekmaj
Copy link
Copy Markdown
Author

wojtekmaj commented Apr 2, 2026

While I appreciate the PR, and in fact would prefer it to be ESM only, it can't be changed in the current major version due to it being a breaking change.

My hope is that you're incorrect. This PR just introduces ESM which basically is "opt-in":

  • It's CJS-first
  • CJS-only package.json fields remain intact
  • CJS paths remain intact

Anyone importing it will suddenly be switched to different source code

Which is exactly the point of this PR. But there are really two cases here: either whatever processes this code supports ESM or not. If it does, it's pretty straightforward and there are no traps awaiting. path-to-regexp is, architecture wise, trivial: a couple of exports bunched in a single module. I don't think there's a lot to break here.

and while it's unlikely it'll break due to how much backward compatibility stuff all the bundlers do, it's possible. Let me know if you think I'm incorrect on this though!

I think to address this I'd need to know more about your concerns. I for one can't think of a case that could break here, and even more so, in a way that's unfixable while maintaining CJS+ESM builds.

Additionally this PR breaks some existing checks by removing the tsconfig.build.json file. I'd prefer to just keep using the ts-scripts and you can see where it was removed and re-introduce that directly.

This is where I don't have an answer for you yet - looks like ts-scripts doesn't give a lot of options on how to customize stuff. While you seem to be able to define multiple "projects", this does not solve CJS+ESM on its own. Additional scripts for renaming stuff would be needed here and that seems like not very elegant solution. I checked that tests defined in scripts pass locally - I'm happy to work on remaining cases as I get to know them.

Finally, we could do a major version just to re-introduce ESM, but I would prefer to avoid it for now until I can resolve the ESM discussion with the express team, that way it's only one major in the case we can adopt ESM only.

That really depends on Node.js support. Do you support Node.js versions old enough not to support ESM at all? If yes, dual is the only option for now. If not, switching to ESM fully is frankly an implementation detail.

Nonetheless, I figured going dual is the safest, fastest way to ensure that consumers would be able to enjoy better tree shaking and restored browser support without causing too much commotion. I firmy believe we can achieve this without breaking changes.

@blakeembrey
Copy link
Copy Markdown
Member

blakeembrey commented Apr 2, 2026

This PR just introduces ESM which basically is "opt-in":

If you do import {} from "path-to-regexp" today, in a bundler or node.js, it's importing from CommonJS. This change is not opt-in because it automatically switches to ESM right? Otherwise you'd have to exclude it from exports.

Which is exactly the point of this PR.

And that's the breaking change. I understand the point of the PR and agree with it. I'm the one who introduced ESM here to begin with, and then removed it due to external requests, and am on board with adding it back in a new major, as I said, once conditions outside my control are resolved.

I for one can't think of a case that could break here

If you happen to use import on CommonJS today, it exposes the entire namespace of module.exports as the default import IIRC. That means it will break, someone might be doing import pathToRegexp from 'path-to-regexp' and using pathToRegexp.parse. It's unlikely, but not acceptable for a non-major version.

This is where I don't have an answer for you yet

Just use the git history, that's what it's there for: 9085eda

@blakeembrey
Copy link
Copy Markdown
Member

Here's the breaking change. If you use import() on CJS today, it looks like this:

> const p = await import("./dist/index.js");
undefined
> p
[Module: null prototype] {
  PathError: [class PathError extends TypeError],
  TokenData: [class TokenData],
  __esModule: true,
  compile: [Function: compile],
  default: {
    TokenData: [class TokenData],
    PathError: [class PathError extends TypeError],
    parse: [Function: parse],
    compile: [Function: compile],
    match: [Function: match],
    pathToRegexp: [Function: pathToRegexp],
    stringify: [Function: stringify]
  },
  match: [Function: match],
  'module.exports': {
    TokenData: [class TokenData],
    PathError: [class PathError extends TypeError],
    parse: [Function: parse],
    compile: [Function: compile],
    match: [Function: match],
    pathToRegexp: [Function: pathToRegexp],
    stringify: [Function: stringify]
  },
  parse: [Function: parse],
  pathToRegexp: [Function: pathToRegexp],
  stringify: [Function: stringify]
}

Note the duplication into default, and module.exports, and the __esModule tag. And while I agree it's unlikely to break anyone, someone could be using the default export of CJS and switching it to ESM does break them. When you include all the different bundlers and patterns, it's safest to make any switch in a major version.

@wojtekmaj
Copy link
Copy Markdown
Author

wojtekmaj commented Apr 2, 2026

If you do import {} from "path-to-regexp" today, in a bundler or node.js, it's importing from CommonJS. This change is not opt-in because it automatically switches to ESM right? Otherwise you'd have to exclude it from exports.

Apologies, I should have worded it better. It's "opt-in" from bundler/runtime perspective. It'd say: "I understand ESM, I see this package exposes ESM modules, so I'm gonna use it!".

And that's the breaking change. I understand the point of the PR and agree with it.

I don't think I'm on the same page here. To me, a promise we give with semver is: "For whatever documented exports, we promise it will be there and it will continue to work exactly as documented". So as long as you don't need to do any code changes on consumer side for bumped version to work, it's not a breaking change. Am I in the wrong here?

and then removed it due to external requests

👀

If you happen to use import on CommonJS today, it exposes the entire namespace of module.exports as the default import IIRC. That means it will break, someone might be doing import pathToRegexp from 'path-to-regexp' and using pathToRegexp.parse. It's unlikely, but not acceptable for a non-major version.

Ah! That's actually very valid case. I do it all the time with built-in modules. Thankfully, easily fixable. Done!

Note the duplication into default, and module.exports, and the __esModule tag. And while I agree it's unlikely to break anyone, someone could be using the default export of CJS and switching it to ESM does break them. When you include all the different bundlers and patterns, it's safest to make any switch in a major version.

Duplication into default was already there, but only in CJS - as per above, fixed that. __esModule tag is back. I'd love to cover more edge cases.

So far my test files are doing fine:

test.js

const p = require("./dist/index.js");

console.log({
  p__esModule: p.__esModule,
  pCompile: p.compile,
  pDefault: p.default,
  pDefaultCompile: p.default.compile,
});

test.mjs

import p, { compile } from "./dist/index.mjs";

console.log({
  p,
  pCompile: p.compile,
  namedCompile: compile,
});

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.

v7 and v8 are not tree-shakable Please add ESM export to v8

5 participants