fix(server): define CJS path globals in bundled modules - #1740
Conversation
Server-side user modules that referenced __filename or __dirname ran inside vinext's ESM bundle without CommonJS path globals. Proxy files that used __filename, including Next.js's proxy-nfc-traced fixture, threw before redirecting or continuing. The missing compatibility boundary was the source-identity transform: it preserved import.meta.url but did not provide Next-compatible CJS path globals for server modules. The transform now rewrites free server-side __filename and __dirname reads to canonical source paths while preserving local bindings, object keys, and client modules. Regression coverage ports the upstream proxy-nfc-traced scenario at the transform boundary and through a production App Router build.
commit: |
…r __filename/__dirname Replaces the custom free-identifier AST walker with a simpler top-level var injection approach. This fixes two review blockers: 1. Var hoisting bug: the old walker discovered nested var declarations only after entering their block, so reads before the block could be incorrectly rewritten even though they were shadowed by a hoisted local var. 2. TS type-space awareness: by injecting module-level bindings instead of rewriting individual identifiers, we no longer need to distinguish type-space from value-space usage. JavaScript scope rules naturally handle params, nested locals, object shorthand, assignment targets, etc. We only need a lightweight top-level binding scan to avoid duplicate declarations with let/const/class/function/value imports. All other shadowing is handled by JS semantics automatically. Per-review feedback on PR cloudflare#1740.
…n top-level binding scan
Adds hasBindingInPattern() to recurse through ObjectPattern, ArrayPattern, RestElement, and AssignmentPattern so destructuring declarations like const { __filename } = source correctly block injection.
Also handles top-level ForStatement/ForInStatement/ForOfStatement with var declarations, which create top-level bindings that must block injection. let/const in for-loop init are block-scoped and correctly ignored.
Changes output.prepend to output.appendLeft(findDirectivePrologueEnd(ast)) so that use server, use client, and use strict directives remain the first statement in the program body. Adds findDirectivePrologueEnd() helper that scans leading ExpressionStatement(Literal<string>) nodes and returns the end position of the last directive.
|
/bigbonk review |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
Keep PR cloudflare#1740's baked-literal value strategy (var __filename/__dirname computed in the plugin) but adopt the condensed binding-collision helpers. Drops ~31% of the added lines vs the original PR with byte-identical generated output and no new runtime surface.
… branches - rewriteServerCjsGlobals now delegates to rewriteCanonicalSourceIdentity (the same function the plugin runs), removing the parallel rewriteCanonicalServerCjsGlobals implementation so the unit tests exercise the production code path. - Drop the TSEnumDeclaration/TSModuleDeclaration/TSImportEqualsDeclaration branches and importKind/declare guards from declaresBinding: Vite's parseAst (rolldown/oxc) rejects TS syntax and the plugin runs after TS is stripped, so those nodes/fields are unreachable.
Replace the substring `code.includes(name)` injection gate with a syntactic read-reference check (hasReadReference): an Identifier in value position, excluding non-computed member properties (obj.__filename), non-computed object/class keys, and lookalikes (__filenameFoo). Object shorthand, computed keys/members, default values, and assignment targets still count as reads. Collision safety is unchanged (hasTopLevelBinding still blocks a `var` that would redeclare a top-level let/const/class/import). This fixes baking an unused `var __filename = "/abs/path"` into modules that only mention the name in a member access, key, comment, or a lookalike identifier. Adds 6 false-positive/read tests.
|
/bigbonk review |
Replaces the two name-parameterised predicates (hasReadReference and
hasTopLevelBinding) with a single analyzeServerCjsGlobals() function that
walks the AST once and returns { reads, topLevelBindings }.
injectServerCjsGlobals() now reads from the analysis instead of asking
the same questions in two separate functions. The injection rule stays
the same:
inject iff reads.has(name) and not topLevelBindings.has(name)
Behaviour is unchanged: all 30 import-meta-url tests and the proxy
build integration test pass.
|
/bigbonk review |
findDirectivePrologueEnd returned 0 for a module with no directive prologue, so appendLeft(0, ...) inserted the var before a leading `#!` shebang (which oxc stores in ast.hashbang, outside ast.body), producing output like `\nvar __filename=...;#!/usr/bin/env node` that fails to parse. Start the injection floor after the hashbang.
…reads
recordReads' generic walk visited ImportSpecifier.imported,
ExportSpecifier.exported, and ExportAllDeclaration.exported identifiers,
so `export * as __filename from`, `export { foo as __filename }`, and
`import { __filename as foo }` each recorded __filename as a read with no
module binding, injecting a spurious unused `var __filename = "<path>"`.
Add explicit cases: imports read nothing; export-namespace aliases read
nothing; named-export specifiers reference only `local` (and only without
a re-export source).
… production gate The test-only entry re-implemented a subset of eligibility (within-root + build-output exclusion) but omitted the node_modules and script-extension guards that the plugin applies via transformableModuleCanonicalId, so tests could pass while the real plugin path behaved differently. Delegate to transformableModuleCanonicalId so tests exercise the same boundary.
rewriteCanonicalImportMetaUrl re-implemented the import.meta.url branch already in rewriteCanonicalSourceIdentity (parse, collect ranges, overwrite, generateMap) and was reached only by the test-only rewriteImportMetaUrl, so the test path could drift from the production path. Delegate rewriteImportMetaUrl to rewriteCanonicalSourceIdentity and drop the duplicate.
The CJS-global gate matches __filename/__dirname anywhere, admitting many modules with no import.meta.url. collectImportMetaUrlRanges walked the full AST for all of them; guard it behind the cheap mayContainImportMetaUrl substring check (a range can only exist when the substring is present).
The single-line delegation introduced in ad0000a was 104 chars, over the 100-col print width, failing `vp check`. Wrap the call.
|
/bigbonk review |
1 similar comment
|
/bigbonk review |
There was a problem hiding this comment.
Review: CJS path globals in bundled server modules
Reviewed the transform, the binding/read analysis, and the test coverage. This is a well-designed change and I'm comfortable with the approach.
What's good:
- Binding injection over free-identifier replacement is the right call. Injecting
var __filename/__dirnameat module top-level and letting JS scope rules handle params, shadowing, shorthand, and nestedvarhoisting is much more robust than a scope-modeling rewrite walker. The class-expression and pattern-default tests demonstrate this pays off. - Server-only gating is correct.
this.environment?.name === "client"keeps client output unchanged, and the CJS injection only runs in theserverbranch ofrewriteCanonicalSourceIdentity. Since this is a build-time Vite transform (not request-handling logic), it applies uniformly to devssrLoadModuleand the prod bundle, so there's no dev/prod parity gap to worry about here. - Binding-detection completeness. I audited
recordDirectTopLevelBindings+recordModuleScopedVarBindingsagainst the top-level binding forms (import default/named/namespace, const/let/var, function/class decls, exported decls,export default class Named, destructuring, top-levelfor (var ...)/for-in/for-of). The dangerous failure mode would be a missed module binding causing an injectedvarto collide with an existinglet/const/class/import(a SyntaxError) — I couldn't find a missed form. Catch-clause params are correctly treated as block-scoped (not module bindings). enforce: "post"legitimately removes TS-only binding forms from the surface, so the comment about not needing to handleenum/namespace/import typeholds.canonicalizePathrefactor totryRealpathSync(value) ?? path.resolve(value)preserves the priorrealpathSync.native→path.resolvefallback behavior.- Test coverage is excellent — 42 passing focused tests plus a production-build regression ported from Next.js
proxy-nfc-traced. All pass locally.
Minor (non-blocking) observations below. None of these are correctness bugs; they're about the read walker over-reporting in harmless ways. No changes required to merge.
| case "Property": | ||
| if (value.computed) recordReads(value.key); | ||
| recordReads(value.value); | ||
| return; |
There was a problem hiding this comment.
Minor / non-blocking: the read walker can over-report reads for names that only appear as binding targets rather than value reads. Two cases:
- Function/arrow params:
function f(__filename) { ... }where nothing else reads the global — the param Identifier is visited via the default case and recorded as a read. - Function-scoped shorthand destructuring:
function f() { const { __dirname } = x; }— thePropertycase recurses intovalue.value(the binding-target Identifier) and records it as a read.
In both cases moduleBindings is empty (params and function-locals aren't module bindings), so an unnecessary var __filename/var __dirname gets injected at module scope. This is harmless — the injected var is unused and the inner binding shadows it — but it does mean we emit dead module-scoped declarations for modules that never actually read the CJS global at module scope. Not worth complicating the walker to fix; flagging only so it's a known, intentional tradeoff. A short comment near the Property/default cases noting that param/local binding targets may be counted as reads (and why that's safe) would help future readers.
| } | ||
| } finally { | ||
| fs.rmSync(tmpDir, { recursive: true, force: true }); | ||
| } |
There was a problem hiding this comment.
Nice regression test. One coverage gap worth a follow-up (not blocking): this only exercises the production build path. The transform also runs in dev via ssrLoadModule, and dev is where users hit the proxy/middleware first. Consider adding a dev-server case (or a pages-router proxy variant) that loads a proxy.ts reading __filename through the dev server, to lock in dev/prod parity for this behavior.
|
Review posted successfully. Here's a summary of my review of PR #1740: SummaryVerdict: Approve-quality change. The PR fixes a real Next.js compatibility gap (server modules reading What I verified
Non-blocking observations posted inline
|
Overview
__filenameor__dirname.packages/vinext/src/plugins/import-meta-url.ts,tests/import-meta-url.test.ts,tests/app-router.test.ts__filename/__dirnameafter ESM bundling without throwing, while preserving source-module identity semantics.Why
Vinext already rewrites direct
import.meta.urlreads so module identity survives bundling. The same compatibility boundary did not define CommonJS path globals, so server-side user modules that read__filenameor__dirnamecould throw in the bundled ESM server output before user code returned a response.Next.js server artifacts run with CommonJS-compatible path globals available. This PR closes that compatibility gap for Vinext server modules without changing client output.
What changed
__filename/__dirnamereads could throw after bundling.var __filename/var __dirnamebindings pointing at canonical source paths.import.meta.urlimport.meta.urland server CJS path globals in one source-identity pass.Implementation notes
This PR uses binding injection rather than free-identifier replacement. For eligible server modules, the transform inserts top-level
varbindings for the missing CJS path globals and lets JavaScript scope rules handle params, nested locals, object shorthand, assignment/update behaviour, class expression names, and nestedvarhoisting naturally.Injection is gated by a syntactic read check so we only inject when the module actually references the global as a value, skipping member names, object/class keys, and lookalikes.
Bindings are inserted after the directive prologue, so file-level directives such as
"use server"and"use strict"remain directives.Binding / syntax safeguards
The transform skips injection for a global name when:
for (var ...)forms (includingfor-inandfor-of).The plugin runs
enforce: "post"after Vite's TypeScript transforms have stripped type-only syntax, so the parsed AST is always plain JavaScript. TypeScript-only binding forms (import type,declare,enum,namespace,import =) are therefore not part of the binding-detection surface.Regression coverage
for (var ...)conflicts, object shorthand, pattern defaults, read-reference gating, directive-prologue preservation, build-output exclusion.proxy.tsthat reads__filename, redirects/home, and continues normally for/.proxy-nfc-tracedbehaviour.Validation
vp test run tests/import-meta-url.test.tsvp test run tests/app-router.test.ts -t "builds proxy.ts that reads __filename before redirecting"vp checkRisk / compatibility
__filename/__dirnamenow receive source-path bindings.References
/homeand reads__filenamebefore continuing.proxyfile convention and runtime expectations.