Skip to content

fix(build): avoid __dirname shim conflicts and cover node_modules#1385

Open
james-elicx wants to merge 2 commits into
mainfrom
fix/issue-1345-dirname-shim
Open

fix(build): avoid __dirname shim conflicts and cover node_modules#1385
james-elicx wants to merge 2 commits into
mainfrom
fix/issue-1345-dirname-shim

Conversation

@james-elicx
Copy link
Copy Markdown
Member

Summary

Two related fixes to vinext's CommonJS-global shim injection that surfaced on the Next.js v16 deploy suite:

  • next.config.ts. When the user's config already declared const __dirname = fileURLToPath(import.meta.url) (a real pattern in Next.js v16-style configs), the injector still emitted its own const __dirname = ... and OXC rejected the duplicate const. New findUserDeclaredCjsGlobals helper masks strings/comments and detects existing const/let/var __dirname / __filename bindings (plain, comma-continued, or object-destructure); the injector skips those names.
  • node_modules ReferenceError. Bundled third-party packages reference __dirname / __filename, but the server entry is emitted as ESM (type: module) where those CJS globals do not exist. New vinext:cjs-globals-shim plugin runs in the ssr / rsc environments only, targets node_modules .js/.mjs/.cjs files containing bare references, and prepends a per-module preamble that declares both bindings via import.meta.url + fileURLToPath + dirname (helpers imported under __vinext_* aliases to avoid colliding with user imports).

The shim only prevents the ReferenceError. Libraries that load sibling on-disk assets through __dirname (native addons, embedded wasm) still need to be externalized via serverExternalPackagesimport.meta.url after bundling points at the bundle, not the original module location.

Test plan

  • pnpm test tests/next-config.test.ts (125 tests pass, including new regressions for user-declared __dirname / __filename and helper-level coverage of findUserDeclaredCjsGlobals)
  • pnpm test tests/cjs-globals-shim.test.ts (18 new tests covering detectCjsGlobalReferences, buildCjsGlobalsShimPreamble, plugin filter, environment gating, and full transform output)
  • pnpm test tests/build-optimization.test.ts tests/deploy.test.ts tests/standalone-build.test.ts (304 pass, no regressions)
  • pnpm run check clean (format + lint + types)

Closes #1345

Two related issues with CommonJS-global shim injection were causing
build failures and runtime errors on the Next.js deploy suite:

1. **next.config.ts redeclaration.** When a config file already declared
   its own `const __dirname = fileURLToPath(import.meta.url)` polyfill,
   the injector emitted a second `const __dirname = ...` shim and OXC
   rejected the duplicate `const` with
   `Identifier __dirname has already been declared`.

   Fix: add `findUserDeclaredCjsGlobals` to scan masked source for
   user-owned bindings of `__dirname` / `__filename` (plain, comma-
   continued, or object-destructure forms; outside strings, template
   literals, and comments) and skip injection for the names already
   declared.

2. **node_modules ReferenceError.** Third-party packages bundled into
   the ESM server entry (`type: module`) reference `__dirname` /
   `__filename`, but the previous shim only ran for `next.config.ts`.

   Fix: add a per-module `vinext:cjs-globals-shim` plugin that runs in
   the `ssr` / `rsc` environments, targets `node_modules` `.js/.mjs/
   .cjs` files containing bare references (skipping strings, templates,
   and comments), and prepends a local
   `const __filename = fileURLToPath(import.meta.url);
    const __dirname = dirname(__filename);`
   preamble. Helper imports are aliased so they do not collide with
   user code already importing `dirname` or `fileURLToPath`.

   The shim only prevents `ReferenceError`; libraries that load sibling
   on-disk assets through `__dirname` still need to be externalised via
   `serverExternalPackages`.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 20, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1385

commit: d790d6b

@github-actions
Copy link
Copy Markdown

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Good PR. Two focused fixes with solid test coverage and thorough documentation. A few observations below — one potential correctness issue worth discussing, and some minor nits.

Overall assessment: The approach is sound. The findUserDeclaredCjsGlobals helper for the config injector and the new cjsGlobalsShimPlugin for node_modules are well-scoped and correctly layered. The limitation about import.meta.url pointing at the bundle rather than the original module is clearly documented, which is the right call.

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Detailed line comments below.

const masked = source.replace(
/\/\*[\s\S]*?\*\/|\/\/[^\n]*|`(?:[^`\\$]|\\.|\$(?!\{))*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g,
(m) => " ".repeat(m.length),
);
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.

The masking regex here is duplicated verbatim from findUserDeclaredCjsGlobals in next-config.ts.

Consider extracting this into a shared helper (e.g., maskStringsAndComments(source: string): string) to avoid the two copies drifting apart. Not a blocker — the regex is stable and well-tested — but it would be nice for maintainability.

// Strip any query suffix before the filter recheck. Vite passes ids
// with `?v=…` cache busters that the filter regex already allows
// through, but downstream logic only cares about the source text.
void id;
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.

void id; is a clear "intentionally unused" signal, but on its own it looks a bit orphaned. The comment above it discusses query suffixes but doesn't explicitly say "id is only used by the Vite filter".

nit: consider void id; // consumed by the Vite filter; transform only uses code`` or similar.

// keyword itself, a comma, an opening brace, or a colon (object-pattern
// alias). Anything preceded by `=` is the initializer side and ignored,
// so `const x = __dirname;` does NOT count as declaring `__dirname`.
const declStmt = /\b(const|let|var)\b([^;]*)/g;
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.

Minor note on the declStmt regex. /\b(const|let|var)\b([^;]*)/g captures everything up to the next ;. This works for the common case, but relies on semicolons as statement boundaries. Two observations:

  1. ASI-eligible code without semicolons — if the user writes const __dirname = dirname(...) without a trailing ;, the [^;]* consumes past it into subsequent lines. This is actually still correct (it just captures more text than one statement), so no bug.

  2. for-loop initializers like for (let __dirname = 0; ...) — the [^;]* stops at the first ; in the for-header, capturing let __dirname = 0 and reporting a false positive. In practice nobody writes for (let __dirname ...) so this is purely academic.

No action needed — failure modes are safe or unrealistic. The docstring already covers the important invariant (false negatives surface the collision error, false positives skip the shim → ReferenceError). Just flagging for completeness.

enforce: "pre",

transform: {
filter: { id: /\/node_modules\/.+\.(?:m?js|cjs)(?:\?.*)?$/ },
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.

Plugin ordering confirmation: This plugin runs with enforce: "pre", so it executes before the top-level commonjs() plugin (which runs in "normal" phase). That means for a node_modules file using both __dirname and require(), the shim's import statements are prepended first, then commonjs() converts the CJS patterns. The ordering is correct — the prepended ESM import statements won't confuse the CJS-to-ESM transform.

Also worth noting: commonjs() at the top level in index.ts doesn't have a node_modules filter exclusion (unlike the one used in loadNextConfig), so CJS code in node_modules still gets converted to ESM as expected.

// overhead in the hot transform path. Stack traces still resolve to
// the original source via the bundler's combined sourcemap once the
// module is concatenated.
map: null,
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.

Source map trade-off is reasonable but worth noting: when the preamble is multi-line (e.g., 4 lines for both __dirname + __filename), every line in the original source shifts by that offset. Stack traces during development will be off by 4 lines for any error originating from a shimmed node_modules file. Since this only applies to server-side node_modules code and the bundler's combined sourcemap corrects it in production, this is acceptable.

If this ever becomes a debugging pain point, MagicString with prepend() + generateMap() would fix it without changing the architecture.

// (`{ filter, handler }`) on Vite 8+. Resolve either shape to the
// underlying function so the tests can invoke it directly with a fake
// plugin `this` context.
function getTransformHandler(): (
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.

Nice defensive handling of both the function-style and object-style ({ filter, handler }) transform hook shapes. This future-proofs the test against Vite API evolution.

Comment thread tests/next-config.test.ts
expect(typeof config?.env?.OWN_DIR).toBe("string");
expect(ownFile?.endsWith("next.config.ts")).toBe(true);
});

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.

Good regression test. One additional edge case worth considering for a follow-up: what about const { __dirname: localAlias } = something;? The bindingRegex in findUserDeclaredCjsGlobals uses [,{:] as preceding context, so { __dirname: localAlias } would match __dirname after { — which is a false positive (the binding is localAlias, not __dirname). But the failure mode is safe (skipping the shim when it's not needed, since the user has a local alias), so this is fine in practice.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 20, 2026

Review posted successfully. Here's a summary of what I reviewed and commented on:

PR #1385 Review Summary

The PR fixes two related issues with CJS-global shim injection that surfaced in the Next.js v16 deploy suite:

  1. Duplicate __dirname declarations in next.config.ts — New findUserDeclaredCjsGlobals helper detects when the user already declares const __dirname / const __filename and skips injecting duplicates that would cause OXC parse errors.

  2. ReferenceError in bundled node_modules — New vinext:cjs-globals-shim plugin injects per-module __dirname/__filename preambles for node_modules code in SSR/RSC environments only.

Line comments posted (7 total):

  • Duplicated masking regex — The string/comment masking regex is identical in both detectCjsGlobalReferences and findUserDeclaredCjsGlobals. Suggested extracting to a shared helper for maintainability.
  • void id readability — Minor nit suggesting a clarifying inline comment.
  • declStmt regex edge cases — Noted the semicolon-based statement boundary approach has theoretical edge cases (ASI, for-loops) but all failure modes are safe.
  • Plugin ordering confirmation — Verified enforce: "pre" ordering with the commonjs() plugin is correct.
  • Source map trade-off — Acknowledged the map: null decision is reasonable, noted MagicString as a future option if debugging becomes painful.
  • Test helper robustness — Positive note on the getTransformHandler utility handling both function and object-style hook shapes.
  • Destructuring false positive — Flagged a theoretical false positive in findUserDeclaredCjsGlobals for const { __dirname: alias } patterns, but confirmed the failure mode is safe.

github run

Address Bonk review feedback on #1345:

- Extract the duplicated strings/comments/template-literal masking regex
  from `findUserDeclaredCjsGlobals` and `detectCjsGlobalReferences` into
  a shared `maskStringsAndComments` helper in `utils/mask-source.ts`.
- Avoid false positives in `findUserDeclaredCjsGlobals` for object-key
  uses of `__dirname` / `__filename`. The trailing `(?!\\s*:)` lookahead
  excludes property keys (`{ __dirname: ... }`, `{ __dirname: alias }`)
  from binding matches so the injector keeps the shim in place when no
  real top-level declaration exists.
- Clarify the `void id` rationale in `cjsGlobalsShimPlugin.transform`.
@james-elicx
Copy link
Copy Markdown
Member Author

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Good PR. Two well-scoped fixes with solid test coverage. The shared mask-source.ts extraction addresses the previous review's duplication concern, and the (?!\s*:) lookahead correctly handles the const { __dirname: alias } destructuring edge case.

A few fresh observations not covered by the previous review:

Correctness — looks solid. The two-pass scan approach (quick reject via raw regex, then masked scan) is the right architecture. The findUserDeclaredCjsGlobals false-positive/false-negative analysis in the docstring is correct and well-reasoned. The cjsGlobalsShimPlugin scoping (node_modules only, server envs only) is correctly restrictive.

Dev/prod parity. The cjsGlobalsShimPlugin is registered as a top-level Vite plugin via index.ts, so it runs in both dev and prod for SSR/RSC environments. The next.config injector fix (findUserDeclaredCjsGlobals) runs through the cjsGlobalsInjectorPlugin which applies to .ts config files loaded via Vite's module runner — also runs in both dev and build. No parity gap here.

Performance. The quick-reject !/__dirname|__filename/.test(source) before maskStringsAndComments is critical — maskStringsAndComments allocates a new string via .replace() for every call, and this hot path sees every node_modules .js/.mjs/.cjs file. The vast majority of modules won't contain either identifier, so the fast path is almost always taken. This is well-designed.

One note for future consideration (not blocking): the MASK_REGEX uses [\s\S]*? for block comments which is correct but non-greedy — on pathological inputs with many /* openers and no closers it degrades gracefully (each unmatched /* just doesn't match). Template literal handling correctly stops at ${ to preserve interpolation visibility. The regex is sound.

Clean implementation, thorough tests, clear documentation. Ship it.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 20, 2026

Review posted successfully. Here's a summary:

PR #1385 Review — APPROVED

Two well-scoped fixes with solid test coverage:

  1. findUserDeclaredCjsGlobals — Correctly detects user-declared __dirname/__filename bindings and skips injecting duplicate declarations that would cause OXC parse errors. The (?!\s*:) negative lookahead properly handles destructuring aliases.

  2. cjsGlobalsShimPlugin — New Vite plugin correctly scoped to node_modules files in ssr/rsc environments only. The quick-reject fast path (!/__dirname|__filename/.test(source)) avoids the expensive maskStringsAndComments allocation for the vast majority of modules.

  3. Shared mask-source.ts — Clean extraction of the masking regex into a single canonical location, addressing the duplication concern from the previous review.

Key verification points:

  • Dev/prod parity: No gap — both fixes apply in both dev and build
  • Plugin ordering: enforce: "pre" runs before commonjs() — correct
  • Performance: Fast path optimization is sufficient for the hot transform path
  • Test coverage: 18 new tests for the shim plugin + 150 lines of new tests for config detection, including edge cases for destructuring, comments, strings, and comma-continued declarators

github run

@james-elicx james-elicx marked this pull request as ready for review May 20, 2026 20:20
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.

Build failure: __dirname/__filename shim injection conflicts and runtime errors

1 participant