Skip to content

Commit fd5c160

Browse files
authored
fix(ext/node): re-export inner spec for module.exports = require(X).Y (#34363)
deno_ast's CJS analyzer recognizes the bare-call form `module.exports = require("./inner")` and surfaces the inner specifier as a re-export, but the very common member-access form `module.exports = require("./inner").Y` falls through unhandled. As a result, packages whose main entry has that shape (graphql-tag@2 is the canonical example) expose no named exports — `import { gql } from "npm:graphql-tag"` fails with "does not provide an export named 'gql'". When static analysis comes back with no exports and no re-exports, walk the program's top-level statements looking for the `module.exports = require(LIT)[.IDENT]*` pattern and treat the require specifier as a re-export. The existing recursive re-export machinery then resolves the inner module and picks up its named exports, which produces the same set of names Node exposes for these packages. Fixes #25311
1 parent bc6fb01 commit fd5c160

28 files changed

Lines changed: 627 additions & 12 deletions

File tree

cli/cache/node.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ mod test {
165165
let cjs_analysis = DenoCjsAnalysis::Cjs(ModuleExportsAndReExports {
166166
exports: vec!["export1".to_string()],
167167
reexports: vec!["re-export1".to_string()],
168+
member_reexports: vec![],
168169
});
169170
cache
170171
.set_cjs_analysis("file.js", CacheDBHash::new(2), &cjs_analysis)

cli/rt/node.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ impl CjsCodeAnalyzer {
7979
return Ok(CjsAnalysis::Cjs(CjsAnalysisExports {
8080
exports: vec![],
8181
reexports: vec![],
82+
member_reexports: vec![],
8283
}));
8384
}
8485

@@ -105,6 +106,7 @@ impl CjsCodeAnalyzer {
105106
CjsAnalysis::Cjs(CjsAnalysisExports {
106107
exports,
107108
reexports: Vec::new(), // already resolved
109+
member_reexports: Vec::new(),
108110
})
109111
}
110112
CjsExportAnalysisEntry::Error(err) => {
@@ -161,16 +163,31 @@ impl node_resolver::analyze::CjsCodeAnalyzer for CjsCodeAnalyzer {
161163
return Ok(CjsAnalysis::Cjs(CjsAnalysisExports {
162164
exports: vec![],
163165
reexports: vec![],
166+
member_reexports: vec![],
164167
}));
165168
}
166169
} else {
167170
return Ok(CjsAnalysis::Cjs(CjsAnalysisExports {
168171
exports: vec![],
169172
reexports: vec![],
173+
member_reexports: vec![],
170174
}));
171175
}
172176
}
173177
};
174178
self.inner_cjs_analysis(specifier, source)
175179
}
180+
181+
async fn analyze_cjs_member_props<'a>(
182+
&self,
183+
_specifier: &Url,
184+
_maybe_source: Option<Cow<'a, str>>,
185+
_member: &str,
186+
) -> Result<Option<Vec<String>>, JsErrorBox> {
187+
// The compiled runtime does not have access to swc; CJS export
188+
// analysis is precomputed at compile time. Member-shape narrowing
189+
// is therefore unavailable here, and the wrapper falls back to
190+
// advertising only the default and module.exports.
191+
Ok(None)
192+
}
176193
}

libs/node_resolver/analyze.rs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ pub enum CjsAnalysis<'a> {
4343
pub struct CjsAnalysisExports {
4444
pub exports: Vec<String>,
4545
pub reexports: Vec<String>,
46+
/// Re-exports that pin down a specific member of the inner module
47+
/// (the shape `module.exports = require(X).MEMBER`). For these, only
48+
/// names statically attached to that member in the inner module are
49+
/// surfaced as exports of the wrapper.
50+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
51+
pub member_reexports: Vec<CjsMemberReExport>,
52+
}
53+
54+
#[derive(Debug, Clone, Serialize, Deserialize)]
55+
pub struct CjsMemberReExport {
56+
pub specifier: String,
57+
pub member: String,
4658
}
4759

4860
/// What parts of an ES module should be analyzed.
@@ -68,6 +80,21 @@ pub trait CjsCodeAnalyzer {
6880
maybe_source: Option<Cow<'a, str>>,
6981
esm_analysis_mode: EsmAnalysisMode,
7082
) -> Result<CjsAnalysis<'a>, JsErrorBox>;
83+
84+
/// For `module.exports = require(X).MEMBER` shapes, return the names
85+
/// statically attached as properties of the value bound to
86+
/// `exports.MEMBER` in the module at `specifier`. Used by callers to
87+
/// narrow the wrapper's named exports to names the inner module
88+
/// actually exposes on that specific member, rather than the entire
89+
/// inner module. Returns `None` if the member's value can't be
90+
/// statically resolved to such an identifier, in which case the
91+
/// caller should advertise no names under the member shape.
92+
async fn analyze_cjs_member_props<'a>(
93+
&self,
94+
specifier: &Url,
95+
maybe_source: Option<Cow<'a, str>>,
96+
member: &str,
97+
) -> Result<Option<Vec<String>>, JsErrorBox>;
7198
}
7299

73100
pub enum ResolvedCjsAnalysis<'a> {
@@ -192,9 +219,91 @@ impl<
192219
}
193220
}
194221

222+
if !analysis.member_reexports.is_empty() {
223+
let mut errors = Vec::new();
224+
self
225+
.resolve_member_reexports(
226+
entry_specifier,
227+
&analysis.member_reexports,
228+
&mut all_exports,
229+
&mut errors,
230+
)
231+
.await;
232+
if !errors.is_empty() {
233+
errors.sort_by_cached_key(|e| e.to_string());
234+
return Err(TranslateCjsToEsmError::ExportAnalysis(errors.remove(0)));
235+
}
236+
}
237+
195238
Ok(ResolvedCjsAnalysis::Cjs(all_exports))
196239
}
197240

241+
/// For each `module.exports = require(X).MEMBER` shape recorded on
242+
/// `referrer`, resolve `X`, ask the analyzer for the property
243+
/// names attached to the value of `exports.MEMBER` inside `X`, and
244+
/// surface those (and only those) as names on the wrapper. This is
245+
/// strictly narrower than treating `X` as a wildcard re-export: only
246+
/// names the inner module statically attaches to the specific member
247+
/// are advertised, so unrelated names from `X` don't leak through.
248+
#[allow(
249+
clippy::needless_lifetimes,
250+
reason = "explicit lifetimes improve clarity"
251+
)]
252+
async fn resolve_member_reexports<'a>(
253+
&'a self,
254+
referrer: &Url,
255+
member_reexports: &[CjsMemberReExport],
256+
all_exports: &mut BTreeSet<String>,
257+
errors: &mut Vec<JsErrorBox>,
258+
) {
259+
for entry in member_reexports {
260+
let result = self
261+
.resolve(
262+
&entry.specifier,
263+
referrer,
264+
&[
265+
Cow::Borrowed("deno"),
266+
Cow::Borrowed("node"),
267+
Cow::Borrowed("require"),
268+
Cow::Borrowed("default"),
269+
],
270+
NodeResolutionKind::Execution,
271+
)
272+
.and_then(|value| {
273+
value
274+
.map(|url_or_path| url_or_path.into_url())
275+
.transpose()
276+
.map_err(JsErrorBox::from_err)
277+
});
278+
let inner_specifier = match result {
279+
Ok(Some(spec)) => spec,
280+
Ok(None) => continue,
281+
Err(err) => {
282+
errors.push(err);
283+
continue;
284+
}
285+
};
286+
let props = match self
287+
.cjs_code_analyzer
288+
.analyze_cjs_member_props(&inner_specifier, None, &entry.member)
289+
.await
290+
{
291+
Ok(Some(props)) => props,
292+
Ok(None) => continue,
293+
Err(err) => {
294+
errors.push(err);
295+
continue;
296+
}
297+
};
298+
for prop in props {
299+
if prop == "default" {
300+
continue;
301+
}
302+
all_exports.insert(prop);
303+
}
304+
}
305+
}
306+
198307
#[allow(
199308
clippy::needless_lifetimes,
200309
reason = "explicit lifetimes improve clarity"
@@ -318,6 +427,17 @@ impl<
318427
);
319428
}
320429

430+
if !analysis.member_reexports.is_empty() {
431+
self
432+
.resolve_member_reexports(
433+
&reexport_specifier,
434+
&analysis.member_reexports,
435+
all_exports,
436+
errors,
437+
)
438+
.await;
439+
}
440+
321441
all_exports.extend(
322442
analysis
323443
.exports

0 commit comments

Comments
 (0)