Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,65 @@ export function reassignsModuleExports(source: string): boolean {
return /\bmodule\s*\.\s*exports\b\s*(?:=(?!=)|\.\s*[A-Za-z_$][\w$]*\s*=(?!=)|\[)/.test(source);
}

/**
* Detect which of the CJS-style identifiers (`__filename`, `__dirname`) the
* user already declares themselves with `const`, `let`, or `var`. Used by the
* injector to skip duplicate declarations that would otherwise fail with
* "Identifier `__dirname` has already been declared" under OXC/Rolldown.
*
* Skips identifiers that appear inside strings, template literals, and
* comments so a docstring like `/* sets __dirname *\/` doesn't suppress the
* shim. Recognizes plain bindings (`const __dirname = ...`), comma-continued
* declarators (`const x = 1, __dirname = ...`), and identifier destructuring
* (`const { __dirname } = ...` / `const { foo: __dirname } = ...`). Bare
* declarations without initializer (`let __dirname;`) are also caught — they
* still create a binding that collides with the injected `const`.
*
* False negatives are the safe failure mode: if we fail to detect a
* declaration, the existing collision error surfaces (no silent miscompile).
* False positives would skip the shim and turn an injected `const` into a
* ReferenceError, so the regex deliberately requires a binding keyword and
* skips string/template/comment contexts.
*/
export function findUserDeclaredCjsGlobals(source: string): {
__dirname: boolean;
__filename: boolean;
} {
const declared = { __dirname: false, __filename: false };

// First pass: blank out comments / strings / template literals so
// identifiers inside them aren't matched. Replace each masked span with
// an equal number of spaces so indices stay aligned with the original
// source — useful if this function later returns positions.
const masked = source.replace(
/\/\*[\s\S]*?\*\/|\/\/[^\n]*|`(?:[^`\\$]|\\.|\$(?!\{))*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g,
(m) => " ".repeat(m.length),
);

// Second pass: for each `const|let|var` declarator statement, look at
// the head of the statement (up to the next `;` or top-level statement
// boundary) for `__dirname`/`__filename` appearing in a binding position.
//
// Binding position = preceded (after whitespace) by the declarator
// 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.

let stmt: RegExpExecArray | null;
while ((stmt = declStmt.exec(masked)) !== null) {
const body = stmt[2];
const bindingRegex = /(?:^|[,{:]|\b(?:const|let|var)\b)\s*(__dirname|__filename)\b/g;
let bind: RegExpExecArray | null;
while ((bind = bindingRegex.exec(body)) !== null) {
const name = bind[1];
if (name === "__dirname") declared.__dirname = true;
else if (name === "__filename") declared.__filename = true;
}
if (declared.__dirname && declared.__filename) break;
}
return declared;
}

/**
* Vite plugin that prepends CJS-style globals (`__filename`, `__dirname`,
* `module`, `exports`, `require`) to the next.config.* source before
Expand Down Expand Up @@ -588,12 +647,27 @@ function cjsGlobalsInjectorPlugin(configPath: string): {
`export const ${VINEXT_CJS_INITIAL_KEY} = __vinextInitialExports;\n`
: "";

// Skip injecting `__dirname` / `__filename` shims when the user has
// already declared the same identifier with `const` / `let` / `var`.
// OXC/Rolldown reject a duplicate `const` declaration with
// "Identifier `__dirname` has already been declared", which is a real
// pattern in next.config.ts files written for Next.js v16 (Next.js
// sometimes ships configs containing `const __dirname =
// fileURLToPath(import.meta.url)` to polyfill the ESM module scope).
// The user's own declaration carries the same value semantics we'd
// inject, so dropping our line is correct, not a behavioural change.
const userDeclared = findUserDeclaredCjsGlobals(code);

// Preamble runs after ESM imports are hoisted; the const bindings shadow
// any global lookups the source would otherwise perform.
const filenameLine = userDeclared.__filename
? ""
: `const __filename = ${filenameLiteral};\n`;
const dirnameLine = userDeclared.__dirname ? "" : `const __dirname = ${dirnameLiteral};\n`;
const preamble =
`import { createRequire as __vinextCreateRequire } from "node:module";\n` +
`const __filename = ${filenameLiteral};\n` +
`const __dirname = ${dirnameLiteral};\n` +
filenameLine +
dirnameLine +
`const require = __vinextCreateRequire(${requireBaseLiteral});\n` +
moduleLines;

Expand Down
5 changes: 5 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ import {
type BundleBackfillChunk,
} from "./build/ssr-manifest.js";
import { stripServerExports } from "./plugins/strip-server-exports.js";
import { cjsGlobalsShimPlugin } from "./plugins/cjs-globals-shim.js";
import { hasMdxFiles } from "./utils/mdx-scan.js";
import { scanPublicFileRoutes } from "./utils/public-routes.js";
import tsconfigPaths from "vite-tsconfig-paths";
Expand Down Expand Up @@ -2269,6 +2270,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
},
// Stub node:async_hooks in client builds — see src/plugins/async-hooks-stub.ts
asyncHooksStubPlugin,
// Inject __dirname / __filename shims into bundled node_modules code that
// touches the CJS globals in ESM server output —
// see src/plugins/cjs-globals-shim.ts
cjsGlobalsShimPlugin,
createInstrumentationClientTransformPlugin(() => instrumentationClientPath),
// Dedup client references from RSC proxy modules — see src/plugins/client-reference-dedup.ts
...(options.experimental?.clientReferenceDedup ? [clientReferenceDedupPlugin()] : []),
Expand Down
152 changes: 152 additions & 0 deletions packages/vinext/src/plugins/cjs-globals-shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { Plugin } from "vite";

/**
* Detect whether the source contains a bare reference to `__dirname` or
* `__filename` outside of strings, template literals, and comments. Mirrors
* the conservative "skip masked spans first" strategy used by
* {@link findUserDeclaredCjsGlobals} so a docstring like
* `/* uses __dirname *\/` doesn't force the shim into modules that don't
* actually need it.
*
* The return value reports each identifier independently so the injected
* preamble can hoist only the bindings actually used by the module — keeping
* the per-module patch size minimal and avoiding spurious imports.
*/
export function detectCjsGlobalReferences(source: string): {
__dirname: boolean;
__filename: boolean;
} {
// Quick reject: the strict masked scan below is comparatively expensive on
// hot paths (every node_modules .js/.mjs/.cjs goes through the transform
// pipeline), so first verify the source contains the identifier at all.
if (!/__dirname|__filename/.test(source)) {
return { __dirname: false, __filename: false };
}

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.


return {
__dirname: /\b__dirname\b/.test(masked),
__filename: /\b__filename\b/.test(masked),
};
}

/**
* Per-module preamble that defines `__dirname` / `__filename` for ESM-bundled
* CommonJS code. Used as the body of the {@link cjsGlobalsShimPlugin}
* transform output.
*
* The bindings are scoped to the transformed module (Vite's `transform`
* runs before bundling, so each module's source is patched independently).
* After Rolldown concatenates modules, the bindings become unique top-level
* `const`s in the bundle thanks to the bundler's identifier renaming.
*
* `import.meta.url` resolves to the *bundle's* output URL at runtime, not
* the original `node_modules` location. That is intentional: the goal of
* this shim is to prevent `ReferenceError: __dirname is not defined` for
* libraries that touch these identifiers defensively (e.g. `typeof
* __dirname === "string"`, log paths, debug strings). Libraries that load
* sibling assets through `__dirname` need to be externalized instead — a
* shim cannot recreate the original on-disk layout post-bundle.
*
* The helper imports are aliased so they cannot collide with user-declared
* names like `dirname` or `fileURLToPath` already imported by the module.
*/
export function buildCjsGlobalsShimPreamble(needs: {
__dirname: boolean;
__filename: boolean;
}): string {
if (!needs.__dirname && !needs.__filename) return "";

const lines: string[] = [`import { fileURLToPath as __vinext_fileURLToPath } from "node:url";`];
if (needs.__dirname) {
lines.push(`import { dirname as __vinext_dirname } from "node:path";`);
}
if (needs.__filename) {
lines.push(`const __filename = __vinext_fileURLToPath(import.meta.url);`);
}
if (needs.__dirname) {
// Reuse the local __filename when we already declared it, otherwise
// compute it inline. Avoids declaring an unused __filename binding.
if (needs.__filename) {
lines.push(`const __dirname = __vinext_dirname(__filename);`);
} else {
lines.push(`const __dirname = __vinext_dirname(__vinext_fileURLToPath(import.meta.url));`);
}
}
return lines.join("\n") + "\n";
}

/**
* Vite plugin that injects local `__dirname` / `__filename` shims into
* third-party `node_modules` code that references those CJS globals but is
* bundled into an ESM server output (`"type": "module"`).
*
* Why: vinext's production server build emits ESM (`output.format: "esm"`,
* `package.json#type === "module"`). The Node ESM module scope does not
* expose the CJS-style `__dirname` / `__filename` globals, so any
* `node_modules` dependency that touches them — even defensively, like
* `typeof __dirname === "string"` or for log messages — crashes the bundled
* worker with `ReferenceError: __dirname is not defined in ES module scope`.
*
* vinext already injects these shims into the user's `next.config.ts`
* (see `config/next-config.ts`), but that transform is keyed to the config
* file path. Third-party packages bundled into the server entry need the
* same treatment, scoped per-module so user code declaring its own
* `__dirname` is unaffected.
*
* Scope:
* - Only `node_modules` files (user code stays untouched — user-source
* `__dirname` is a real bug we want to surface, not silently shim).
* - Only `.js` / `.mjs` / `.cjs` extensions (TypeScript files inside
* `node_modules` are unusual and typically pass through their own
* compiler first).
* - Only server-side environments (`ssr`, `rsc`). The browser environment
* doesn't ship CJS globals at all and clients almost never reference
* them — adding a shim there would inject `node:url` imports that the
* browser-target build would have to externalize.
*
* Limitation: `import.meta.url` after bundling points at the *bundle*, not
* the original module location. Libraries that load sibling files through
* `__dirname` (native `.node` addons, embedded `.wasm`, etc.) should be
* externalized via `next.config.serverExternalPackages` instead. This shim
* only prevents the `ReferenceError`; it does not preserve filesystem
* semantics.
*/
export const cjsGlobalsShimPlugin: Plugin = {
name: "vinext:cjs-globals-shim",
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.

handler(code, id) {
// Server-only: see plugin docstring. The Vite environment API exposes
// the current environment name to plugin handlers via `this.environment`.
const envName = this.environment?.name;
if (envName !== "ssr" && envName !== "rsc") return null;

// 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.


const needs = detectCjsGlobalReferences(code);
if (!needs.__dirname && !needs.__filename) return null;

const preamble = buildCjsGlobalsShimPreamble(needs);
return {
code: preamble + code,
// Source map is intentionally null: the preamble is prepended without
// line offsets in the original source map. A precise map would
// require MagicString plumbing; the trade-off here favours zero
// 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.

};
},
},
};
Loading
Loading