Skip to content

fix(build): inject createRequire banner to support CJS deps in ESM bundles#166

Merged
derekmisler merged 2 commits into
mainfrom
fix/esm-dynamic-require
May 4, 2026
Merged

fix(build): inject createRequire banner to support CJS deps in ESM bundles#166
derekmisler merged 2 commits into
mainfrom
fix/esm-dynamic-require

Conversation

@docker-agent
Copy link
Copy Markdown
Contributor

Problem

The release workflow was failing at the "Setup credentials" step with:

Error: Dynamic require of "net" is not supported
    at file:///.../dist/credentials.js:11:9
    at node_modules/.pnpm/tunnel@0.0.6/node_modules/tunnel/lib/tunnel.js

Root cause

Commit 83bd0cd switched the bundler from Vite → tsup, emitting ESM output (.js with "type": "module") and bundling all npm dependencies via noExternal: [/.*/].

The dependency chain that causes the issue:

@actions/core@3.0.0
  └─ @actions/http-client@4.0.0
       └─ tunnel@0.0.6   ← CJS package, calls require('net'), require('tls'), …

tunnel@0.0.6 is a CommonJS package. When esbuild bundles it into an ESM file it wraps it in a __commonJS closure and generates a top-level __require shim:

var __require = ((x) => {
  if (typeof require !== "undefined") return require(x);
  throw Error('Dynamic require of "' + x + '" is not supported');  // ← throws
});

In ESM mode there is no global require, so the shim falls through to throw every time tunnel calls require('net'). Setting platform: 'node' in tsup does mark Node.js built-ins as external for top-level ESM imports, but it does not inject a CJS→ESM bridge for built-ins referenced inside __commonJS wrappers.

Fix

Add a banner to tsup.config.ts that injects createRequire at the very top of every output file:

banner: {
  js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);",
},

When __require evaluates typeof require !== "undefined" it now finds the createRequire-backed function and delegates to it, allowing CJS modules to successfully load any Node.js built-in (net, tls, http, https, …).

Verification

# Before fix
$ node dist/credentials.js
Error: Dynamic require of "net" is not supported   ← crash at startup

# After fix
$ node dist/credentials.js
OIDC token unavailable, skipping AWS credentials   ← gets past tunnel init;
::error::AWS Secrets Manager call failed …          ← fails only on missing CI secrets (expected)
  • biome ci . — ✅ clean
  • tsc --noEmit — ✅ clean

Fixes the failing run: https://github.com/docker/cagent-action/actions/runs/25336725173/job/74283730978

@docker-agent docker-agent requested a review from a team as a code owner May 4, 2026 18:57
@docker-agent docker-agent requested a review from derekmisler May 4, 2026 18:57
Copy link
Copy Markdown
Contributor

@docker-agent docker-agent Bot left a comment

Choose a reason for hiding this comment

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

Assessment: 🟢 APPROVE

The createRequire banner is the standard, idiomatic fix for this class of ESM/CJS interop problem and the implementation is correct. The banner syntax is valid, node:module has been stable since Node.js 12, and the fix is well-explained in the accompanying comments.

Two minor fragility observations are noted below (no action required for merge).

Comment thread tsup.config.ts Outdated
// as a top-level banner supplies a real require() before the shim runs,
// so those CJS modules can load Node.js built-ins normally.
banner: {
js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);",
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.

[LOW] const require — consider var for resilience against future collisions

The banner declares const require, which is block-scoped and non-redeclarable. Under the current esbuild version this is safe — esbuild's own CJS shim uses var __require (a different identifier). However, if a future esbuild upgrade changes the helper name to require, or if another plugin also injects a require binding, the module will throw SyntaxError: Identifier 'require' has already been declared at startup rather than silently overriding.

Using var require instead would degrade gracefully in that scenario (last-write wins) and matches the precedent set by tools like Vite and Rollup when they inject the same shim.

This is not a current bug — just a minor resilience note.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — switched to var in commit 843c614. Using var avoids any risk of a SyntaxError if esbuild ever emits a hoisted var require of its own in the same scope (where const would be a re-declaration), and it also avoids TDZ issues if the shim is somehow evaluated before the banner binding is initialised.

Comment thread tsup.config.ts
// throws "Dynamic require of 'net' is not supported". Injecting createRequire
// as a top-level banner supplies a real require() before the shim runs,
// so those CJS modules can load Node.js built-ins normally.
banner: {
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.

[LOW] Banner uses ESM-only syntax — will silently break if 'cjs' is ever added to format

The injected banner contains an import statement and import.meta.url, both of which are syntax errors in CommonJS output. The current config only emits ESM (format: ['esm']), so this is harmless today.

If a future maintainer extends format to ['esm', 'cjs'] (a common tsup pattern), the banner will be emitted verbatim into .cjs files and cause a parse failure. A short comment on the banner block noting the ESM-only constraint would guard against this:

// NOTE: this banner uses import/import.meta.url — only valid in ESM.
// If format is ever extended to include 'cjs', this must be conditioned
// or moved to a format-specific banner (banner: { esm: '...' }).
banner: {
  js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);",
},

Not a current bug; just a maintenance note.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — the three-line ESM-only warning comment has been added directly above the banner: block in commit 843c614. It calls out explicitly that the import/import.meta.url syntax is only valid in ESM output and what action to take if 'cjs' is ever added to format.

Copy link
Copy Markdown
Contributor Author

@docker-agent docker-agent left a comment

Choose a reason for hiding this comment

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

Assessment: 🟢 APPROVE

Fix is correct, well-reasoned, and empirically verified.

The createRequire banner pattern is the standard solution for this class of CJS-in-ESM bundling problem and has been verified to work correctly with tsup 8.5.1 / esbuild for this project.

Mechanism (verified empirically):

  1. Banner injects const require = createRequire(import.meta.url) at line 1 of each bundle
  2. esbuild's __require shim (line 8) immediately evaluates typeof require !== "undefined"true → returns the banner's require by reference
  3. __require becomes identical to the createRequire-backed function
  4. tunnel's __commonJS wrapper calls __require("net") → works correctly
  5. globalThis.require stays undefined — no global scope leakage

Verified with actual build output:

  • dist/credentials.js (1.84 MB) starts with the banner on line 1 ✅
  • dist/signed-commit.js (148 KB) gets the banner too, harmlessly ✅
  • node dist/credentials.js gets past the tunnel init crash ✅
  • biome ci . and tsc --noEmit pass ✅

One inline note on a minor security nuance (see below) — not a blocker.

Comment thread tsup.config.ts
// as a top-level banner supplies a real require() before the shim runs,
// so those CJS modules can load Node.js built-ins normally.
// NOTE: this banner uses import/import.meta.url — only valid in ESM output.
// If format is ever extended to include 'cjs', this must be conditioned or
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[LOW] Module-scoped require in security-sensitive bundle — acknowledged

const require = createRequire(import.meta.url) introduces a working require() into the module scope of every output bundle. In pure ESM without this banner, no require exists at all.

A few things are worth being aware of (not blockers):

  • The require is module-scoped, not globalglobalThis.require stays undefined, so only code already executing inside the bundle can call it.
  • All CJS require() calls in bundled deps route through esbuild's __require shim (which delegates to this require), so the exposure is bounded to the bundle's existing dependency graph.
  • None of the current dependencies (tunnel, @actions/core, AWS SDK) are known to pass user-controlled input to require() — but the action handles API keys and signed commits, so it's worth keeping in mind if new CJS dependencies are added.

Empirically confirmed to not cause issues in any current code path. ✅

@derekmisler derekmisler merged commit 3c760a0 into main May 4, 2026
17 checks passed
@derekmisler derekmisler deleted the fix/esm-dynamic-require branch May 4, 2026 19:04
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