Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
82 changes: 80 additions & 2 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { PHASE_DEVELOPMENT_SERVER } from "vinext/shims/constants";
import { normalizePageExtensions } from "../routing/file-matcher.js";
import { isExternalUrl } from "./config-matchers.js";
import { loadTsconfigPathAliasesForRoot } from "./tsconfig-paths.js";
import { maskStringsAndComments } from "../utils/mask-source.js";

/**
* Parse a body size limit value (string or number) into bytes.
Expand Down Expand Up @@ -521,6 +522,68 @@ 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. Indices stay aligned with the
// original source so the second-pass statement extraction can rely on
// positions when needed.
const masked = maskStringsAndComments(source);

// 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`.
//
// The trailing negative lookahead `(?!\s*:)` ensures we do not treat the
// *property key* of an object-pattern alias as a binding. In
// `const { __dirname: localAlias } = ...`, the actual bound identifier
// is `localAlias`; the literal text `__dirname` is the source-side key,
// not a top-level declaration that would collide with the injector.
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(?!\s*:)/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 +651,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
150 changes: 150 additions & 0 deletions packages/vinext/src/plugins/cjs-globals-shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { Plugin } from "vite";
import { maskStringsAndComments } from "../utils/mask-source.js";

/**
* 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 = maskStringsAndComments(source);

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;

// `id` is consumed by the Vite filter (the regex above admits ids
// with optional `?…` cache busters) and is not used inside the
// handler body — the transform only operates on 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.

};
},
},
};
39 changes: 39 additions & 0 deletions packages/vinext/src/utils/mask-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Source-masking helpers shared by the CJS-global shim plugins.
*
* Both the `next.config.ts` injector (`config/next-config.ts`) and the
* `node_modules` shim (`plugins/cjs-globals-shim.ts`) need to scan source
* code for bare identifier references / declarations *without* matching
* tokens that happen to appear inside string literals, template literals,
* or comments. They originally inlined the same regex; this module owns
* the canonical version so the two callers cannot drift apart.
*/

/**
* Single alternation that captures (and consumes) each kind of span we
* want to ignore. Order matters: the block-comment branch must come
* before the line-comment branch so `/* ... *\/` containing `//` is
* matched as one block, and string-literal branches must consume the
* full literal so embedded `*\/` or `//` don't terminate the wrong span.
*
* Template literals are matched segment-by-segment, stopping before
* `${` so the interpolation contents themselves remain visible to the
* scan (`__dirname` inside `${__dirname}/views` is a real reference).
*/
const MASK_REGEX =
/\/\*[\s\S]*?\*\/|\/\/[^\n]*|`(?:[^`\\$]|\\.|\$(?!\{))*`|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g;

/**
* Replace strings, template literals, and comments in `source` with an
* equal-length run of spaces. Preserves byte/character offsets so the
* returned string can be scanned with regexes whose match indices line up
* with positions in the original source — useful when callers later need
* to extract surrounding context (e.g. statement boundaries).
*
* Template-literal *expressions* (`${...}`) remain visible after masking
* because the regex stops before `${`. Identifiers used inside an
* interpolation are real references and must not be hidden.
*/
export function maskStringsAndComments(source: string): string {
return source.replace(MASK_REGEX, (m) => " ".repeat(m.length));
}
Loading
Loading