Skip to content

Conversation

@fc-anjos
Copy link

@fc-anjos fc-anjos commented Aug 15, 2025

Node: Standardize dual-format packaging to match established codebase patterns

This Pull Request fixes the packaging for CJS consumers of the Node package.
Previously, the CommonJS entry failed due to a Rollup bundling configuration issue in @herb-tools/node. The package used separate .cts/.mts source files, but Rollup wasn't properly bundling local dependencies for the CJS build, leaving runtime requires that failed.
This change adopts the established pattern: a single src/index.ts source file that Rollup transforms into both ESM and CJS outputs. This matches the proven strategy used by @herb-tools/core, @herb-tools/formatter, @herb-tools/highlighter, and @herb-tools/linter.

This change adopts the established pattern: a single src/index.ts source file that Rollup transforms into both ESM and CJS outputs. This matches the strategy used by @herb-tools/core, @herb-tools/formatter, @herb-tools/highlighter, and @herb-tools/linter.

No public API changes.

Expected vs Actual

  • Expected
    • Both ESM and CJS consumers can import/require @herb-tools/node, call await Herb.load(), and use the API.
  • Actual (before)
    • ESM import works.
    • CJS import fails at runtime when using the previously split sources.

Reproduction (before)

Using the built artifacts, with the previous split .mts/.cts sources:

  1. ESM import (works)
// test-esm.mjs
import { Herb } from "@herb-tools/node"

await Herb.load()
console.log("ESM works:", Herb.version)

Run:

node test-esm.mjs
# ✓ Loads and runs successfully
  1. CJS import (fails)
// test-cjs.cjs
const { Herb } = require("@herb-tools/node")

Run:

node test-cjs.cjs
# ✗ Error: Cannot find module './node-backend' (from dist/herb-node.cjs)

Even with the path changed, there'd still be the error:

TypeError: this.backendPromise is not a function
    at HerbBackendNode.load (.../node_modules/@herb-tools/core/src/herb-backend.ts:...)

Diagnosis (before)

CJS build problem:

  • src/index-cjs.cts contained require("./node-backend.js")
  • Rollup processed this as a runtime dependency, not a bundled dependency
  • The built dist/herb-node.cjs expected ./node-backend.js to exist at runtime, but it wasn't emitted
  • Additionally, the CJS version passed new Promise(...) (Promise instance) instead of () => new Promise(...) (Promise factory)

ESM build worked because:

  • Rollup properly resolved and bundled the local node-backend.ts dependency
  • It used the correct Promise factory pattern: () => new Promise(...)

Fix

Adopt the standard, project-wide packaging pattern:

  1. Use a single source file: src/index.ts for both formats.
  2. Update Rollup to emit both ESM and CJS outputs from that single source.
  3. Remove the separate .cts/.mts sources to avoid divergence and bundling issues.
  4. Update package.json type entries to the standard ./dist/types/index.d.ts.

This mirrors the approach in other packages and eliminates the non-working CJS path.


Files Changed

  • src/index-esm.mtssrc/index.ts (renamed, now single source)
  • src/index-cjs.cts (deleted, was non-working code path)
  • rollup.config.mjs (updated to use standard pattern)
  • package.json (updated type declarations)
  • node.test.ts (update import path)

Fixes failures when loading the CommonJS entry of @herb-tools/node while keeping the ESM entry working correctly.

Fix CommonJS entry by standardizing dual-format packaging approach

Adopt the single-source pattern used consistently across @herb-tools/core, @herb-tools/formatter, @herb-tools/highlighter, and @herb-tools/linter. Replace separate .cts/.mts source files with a single src/index.ts that Rollup transforms into both ESM and CJS outputs.

Align @herb-tools/node with the established monorepo pattern to ensure consistency, maintainability, and reliability. Preserve the existing single-entry approach with no public API changes.
@fc-anjos fc-anjos force-pushed the fix/node-cjs-packaging branch from 1ad4f6b to 1c7f6d3 Compare August 15, 2025 13:12
@marcoroth
Copy link
Owner

Hey @fc-anjos, thanks for this pull request! It's something that annoyed me too but that I ended up having to do so I could bundle it the right way in the dependent packages.

Sadly, I'll have to sit on this one for a bit before I get to play with changing the bundling foundation again! Thank you!

@fc-anjos
Copy link
Author

Hey @marcoroth! Of course, thank you for the important work done on this and the other packages.
I'd be happy to further explore and propose other solutions if you could kindly give me any pointer on the requirements of the dependent packages and their bundling.

What I am considering a bug:

This PR is based on the assumption that this is a bug: const { Herb } = require("@herb-tools/node") fails at runtime with Error: Cannot find module './node-backend'.

  1. Inconsistent Behavior: ESM imports work perfectly while CJS imports fail
  2. Package.json configuration: The package explicitly declares both "main" (CJS) and "module" (ESM) entry points
  3. Expected Functionality: The package should work in both formats

Evidence

# Build the package
npm run build

# Test CJS import (fails)
node -e "const { Herb } = require('./dist/herb-node.cjs'); console.log('CJS works')"
# Error: Cannot find module './node-backend'

# Test ESM import (works)
node -e "import('./dist/herb-node.esm.js').then(m => console.log('ESM works'))"

...but I'm not sure how changing this file could break things downstream

Root Cause, as discussed earlier

The CJS build fails because:

  1. Unbundled Dependency: require("./node-backend.js") expects a file that doesn't exist at runtime
  2. Promise Factory Mismatch: The CJS version passes new Promise(...) instead of () => new Promise(...)

Previous Solution and your concerns

The original PR proposed consolidating to a single source file, which would fix the CJS issue but you mentioned concerns about bundling it the right way in the dependent packages. This suggests there are specific bundling requirements that need to be preserved.

Alternative Solution: Minimal CJS Fix

Instead of changing the bundling foundation, as an alternative, perhaps a minimal fix that would maintain the separate file structure is more adequate:

// src/index-cjs.cts
- const { HerbBackendNode } = require("./node-backend.js")
+ const { Visitor, HerbBackend } = require("@herb-tools/core")
+
+ // Inline the HerbBackendNode class to avoid bundling issues
+ const packageJSON = require("../package.json")
+
+ class HerbBackendNode extends HerbBackend {
+   constructor(backendPromise: any) {
+     super(backendPromise)
+   }
+
+   backendVersion() {
+     return `${packageJSON.name}@${packageJSON.version}`
+   }
+ }
+
+ - const Herb = new HerbBackendNode(
+ -   new Promise((resolve, _reject) => resolve(libHerbBinary)),
+ - )
+ + const Herb = new HerbBackendNode(
+ +   () => new Promise((resolve, _reject) => resolve(libHerbBinary)),
+ + )

This approach maintains the separate file structure while fixing the CJS bundling issue, although it introduces a duplication for the HerbBackendNode function.

Bundling Concerns

You mentioned concerns about bundling it the right way in the dependent packages. If you're open to, I'd be happy to properly address these.

  1. What bundling behavior do the dependent packages expect?
  2. Would you be open to adding integration tests that verify the dependent package works correctly when bundled?
  3. Should the bundling expectations be documented to clearly specify how the package should behave in different bundling scenarios?

This would allow for safer refactoring and clearer expectations about how the package integrates with various build systems, and I can work on those if needed.

This would help ensure the CJS issue is fixed without disrupting the existing bundling setup.

Let me know and I can push a commit with this focused fix on the index-cjs.cts file, or investigate a different approach.

Thank you!

@marcoroth marcoroth added the node label Aug 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants