Re-export classes through module.exports members that require them

`module.exports.Agent = require('./lib/agent')`, where agent.js ends with
`module.exports = Agent`, is the same alias plumbing as
`module.exports.Agent = Agent`: importers resolve to the class declaration
and the class registry applies, instead of storing a class value (which
classes inheriting a runtime-provided constructor, such as EventEmitter
subclasses, do not have). The statement still evaluates the required
module at its position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-19 13:18:18 -07:00
co-authored by Claude Opus 5.5
parent eb998d4f4d
commit e2d4092b16
2 changed files with 27 additions and 2 deletions
@@ -6168,7 +6168,10 @@ function lowerCjsExportStatement(
// storage — collectGlobals skipped it; importers re-resolve through
// cjsMemberExportClassSymbol): the statement lowers to nothing.
if (lowerer.cjsMemberExportClassSymbol(cjs.expr) !== null) {
return { kind: "block", body: [], loc };
// An inline require still evaluates its module here.
const req = inlineProgramRequireOf(lowerer, cjs.value);
const init = req !== null ? lowerer.requireInitStmt(req.spec, req.call) : null;
return init ?? { kind: "block", body: [], loc };
}
// The tsc VOID-INIT PREAMBLE (`exports.leaf = void 0;` with the real
// `exports.leaf = leaf;` further down — every tsc-emitted dist file
@@ -294,6 +294,7 @@ import {
import { prepareCjsModuleGraph, usesCjsTopLevelThis } from "./lower-node-module.js";
import {
cjsNamespaceModule,
cjsWholeExportValueOf,
inlineNamespaceRequireOf,
inlineProgramRequireOf,
} from "./cjs-inline-require.js";
@@ -3672,13 +3673,18 @@ export class Lowerer {
? d.parent
: null;
if (!assign || assign.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return null;
if (!ts.isIdentifier(assign.right)) return null;
const stmt = assign.parent;
if (!stmt || !ts.isExpressionStatement(stmt) || !ts.isSourceFile(stmt.parent)) return null;
if (!isJsSourceFile(stmt.parent)) return null;
const cjs = cjsExportAssignmentOf(stmt);
if (cjs?.kind !== "member" || cjs.expr !== assign) return null;
if (cjsExportDiscardReason(stmt) !== null) return null;
// `module.exports.C = require('./c')` over a module whose whole export
// is a class identifier: the same alias plumbing; the statement keeps
// the require's module evaluation at its position.
const required = this.memberExportRequiredClass(assign.right);
if (required !== undefined) return required;
if (!ts.isIdentifier(assign.right)) return null;
let value = this.checker.getSymbolAtLocation(assign.right);
if (!value) return null;
if (value.flags & ts.SymbolFlags.Alias) value = this.checker.getAliasedSymbol(value);
@@ -3686,6 +3692,22 @@ export class Lowerer {
return isClass ? value : null;
}
/** For an inline program `require("./c")`: the class symbol its module's
* kept `module.exports = <class identifier>` names, or null when the
* whole export is something else. Undefined when `expr` is not such a
* require. */
memberExportRequiredClass(expr: ts.Expression): ts.Symbol | null | undefined {
const req = inlineProgramRequireOf(this, expr);
if (req === null) return undefined;
const whole = cjsWholeExportValueOf(req.dep);
if (whole === null || !ts.isIdentifier(whole.rhs)) return null;
let value = this.checker.getSymbolAtLocation(whole.rhs);
if (!value) return null;
if (value.flags & ts.SymbolFlags.Alias) value = this.checker.getAliasedSymbol(value);
const isClass = this.checker.declarationsOf(value).some((decl) => ts.isClassDeclaration(decl));
return isClass ? value : null;
}
/** The CommonJS JS module a DEFAULT-import binding's declaration loads
* (`import d from "./lib.cjs"`), or null: Node's ESM-CJS interop binds
* the default to module.exports — exactly a require binding — so those