From fec779abbb14e96d28cefcec21a4cd9cfdbc813e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 11:13:09 -0700 Subject: [PATCH] Use the events module and EventEmitter as untyped constructor values In JavaScript, `ReleaseEmitter = EventEmitter` over `const EventEmitter = require("events")` was refused with SC1090 ("a runtime-provided class value as an untyped value"), and `class Channel extends require("events")` failed on the require call as a value. Bundled npm code does both, for example a small semaphore that aliases EventEmitter and constructs its release channel through the alias. - The events module binding and the EventEmitter or stream class itself turn untyped as the runtime's untyped constructor that constructor functions already use (scr_dyn_builtin_class.c). `new` through it builds an emitter object with Node's listener API, and `===` against the class compares identity. - `x instanceof EventEmitter` on an untyped value also recognizes a boxed compiled emitter (an instance of a program class extending EventEmitter), which the prototype test alone answered false for. The same holds through an untyped binding narrowed to the class. - `class X extends require("events")` extends the runtime emitter, as through a require binding. - A typed array of the class built from such values (`[EventEmitter]`) is refused with SC1090 rather than failing its class check at run time. Co-Authored-By: Claude Opus 5.5 --- .../limitations/builtin-function-values.mdx | 2 + .../lowering/builtins/builtin-class-dyn.ts | 64 +++++++++++++++- .../frontend/lowering/expressions/binary.ts | 20 +++++ .../lowering/expressions/relational.ts | 18 ++++- .../src/frontend/lowering/lower-classes.ts | 23 +++++- .../src/frontend/lowering/lower-modules.ts | 4 +- .../compiler/src/frontend/lowering/lowerer.ts | 51 ++++++++++++- .../6571-events-module-class-value.cjs.json | 6 ++ .../6572-extends-require-events.cjs.json | 6 ++ .../6573-semaphore-release-emitter.cjs.json | 6 ++ .../2595-bundled-chunk-emitter/main.ts.json | 6 ++ .../corpus/6571-events-module-class-value.cjs | 30 ++++++++ tests/corpus/6572-extends-require-events.cjs | 27 +++++++ .../corpus/6573-semaphore-release-emitter.cjs | 62 +++++++++++++++ .../cases/2595-bundled-chunk-emitter/main.ts | 11 +++ .../node_modules/gtchunkee/dist/index.d.ts | 2 + .../npm/node_modules/gtchunkee/dist/index.js | 75 +++++++++++++++++++ .../npm/node_modules/gtchunkee/package.json | 7 ++ tests/harness/npm-cases.ts | 5 +- tests/harness/npm-static.test.ts | 9 +++ 20 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6571-events-module-class-value.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6572-extends-require-events.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6573-semaphore-release-emitter.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2595-bundled-chunk-emitter/main.ts.json create mode 100644 tests/corpus/6571-events-module-class-value.cjs create mode 100644 tests/corpus/6572-extends-require-events.cjs create mode 100644 tests/corpus/6573-semaphore-release-emitter.cjs create mode 100644 tests/fixtures/npm/cases/2595-bundled-chunk-emitter/main.ts create mode 100644 tests/fixtures/npm/node_modules/gtchunkee/dist/index.d.ts create mode 100644 tests/fixtures/npm/node_modules/gtchunkee/dist/index.js create mode 100644 tests/fixtures/npm/node_modules/gtchunkee/package.json diff --git a/docs/content/limitations/builtin-function-values.mdx b/docs/content/limitations/builtin-function-values.mdx index 541c9fb2b7..a99f0d65c0 100644 --- a/docs/content/limitations/builtin-function-values.mdx +++ b/docs/content/limitations/builtin-function-values.mdx @@ -8,6 +8,8 @@ Some builtins can be stored and passed as functions: In JavaScript, `setTimeout`, `setInterval`, `setImmediate`, `clearTimeout`, `clearInterval`, `clearImmediate`, `queueMicrotask`, and `process.nextTick` are function values wherever they are stored, including conditional and logical expressions and assignments: `typeof` answers `"function"`, identity holds across spellings, and calls schedule as the direct calls do, returning the same numeric timer handles. Calls through these values bypass `node:test` mock timers. Other builtin globals stored in JavaScript remain opaque identity values. +In JavaScript, `require("events")` and the `EventEmitter` class assigned to an untyped binding or stored in an object (`ReleaseEmitter = EventEmitter`) are the untyped `EventEmitter` constructor that constructor functions inherit from (see below): `new` through it builds such an emitter object, and identity and `instanceof` agree with the class. +A class can extend `require("events")` directly. Other immutable aliases of table-backed Node builtins support direct calls, but cannot be passed elsewhere. `util.promisify` supports compile-time conversions of `child_process.execFile`, `child_process.exec`, `setTimeout` and `setImmediate` (to the `timers/promises` functions, as in Node), `stream.finished` and `stream.pipeline` (to the `stream/promises` functions, as in Node), the `zlib` callback one-shots `deflate`, `inflate`, `deflateRaw`, `inflateRaw`, `gzip`, `gunzip` and `unzip` (with or without options), the `crypto` callback functions `randomBytes`, `pbkdf2`, `scrypt`, `hkdf`, `randomFill`, `randomInt` and `generateKey`, `dns.lookup` (settling with `{ address, family }`, or the address array with `all: true`) and `dns.lookupService` (settling with `{ hostname, service }`), and the `fs` callback functions that have an `fs/promises` counterpart (`access`, `appendFile`, `chmod`, `chown`, `copyFile`, `lchown`, `link`, `lstat`, `lutimes`, `mkdir`, `mkdtemp`, `readFile`, `readdir`, `readlink`, `realpath`, `rename`, `rm`, `rmdir`, `stat`, `statfs`, `symlink`, `truncate`, `unlink`, `utimes`, and `writeFile`); calls through the converted function lower as the `fs/promises` call. diff --git a/packages/compiler/src/frontend/lowering/builtins/builtin-class-dyn.ts b/packages/compiler/src/frontend/lowering/builtins/builtin-class-dyn.ts index ac73924ad1..6242f3481d 100644 --- a/packages/compiler/src/frontend/lowering/builtins/builtin-class-dyn.ts +++ b/packages/compiler/src/frontend/lowering/builtins/builtin-class-dyn.ts @@ -1,8 +1,18 @@ import { numLit } from "../../../ir/build.js"; -import { BOOL, DYN, RUNTIME_EMITTER_CLASS, type IrExpr, type SrcLoc } from "../../../ir/ir.js"; +import { + BOOL, + DYN, + RUNTIME_EMITTER_CLASS, + type IrExpr, + type IrStmt, + type SrcLoc, +} from "../../../ir/ir.js"; import * as ts from "../../ts7/adapter.js"; import { locOf } from "../../program.js"; import type { Lowerer } from "../lowerer.js"; +import type { ClassInfo } from "../lower-classes.js"; +import { classInstanceOf } from "../class-dynamic-dispatch.js"; +import { isSafeToRepeat } from "../expressions/evaluation-safety.js"; /* EventEmitter and the stream classes as untyped constructor values, the * bases of JavaScript constructor functions: @@ -147,3 +157,55 @@ export function builtinClassInstanceOf(value: IrExpr, kind: number, loc: SrcLoc) loc, }; } + +/** `value instanceof Base` for an untyped value that may also hold a + * compiled instance: an object built by a constructor function inherits + * the untyped constructor's prototype, and an instance of a program class + * extending Base boxes as a typed reference the class test recognizes. + * With `callee`, the right-hand side is an untyped value and the class + * test applies only when it is Base's untyped constructor. */ +export function builtinClassInstanceOfAny( + lowerer: Lowerer, + value: IrExpr, + kind: number, + info: ClassInfo, + loc: SrcLoc, + callee?: IrExpr, +): IrExpr { + const stmts: IrStmt[] = []; + const once = (expr: IrExpr, name: string): IrExpr => { + if (isSafeToRepeat(expr)) return expr; + const local = lowerer.declareHiddenLocal(name, DYN); + stmts.push({ kind: "varDecl", localId: local.id, init: expr, loc }); + return { kind: "varRef", localId: local.id, type: DYN, loc }; + }; + const tested = once(value, "%instanceValue"); + const ctor = callee ? once(callee, "%instanceCallee") : null; + const compiled = classInstanceOf(lowerer, tested, info, loc); + const result: IrExpr = { + kind: "logical", + op: "||", + left: ctor + ? { kind: "libCall", fn: "dyn.functionInstanceOf", args: [tested, ctor], type: BOOL, loc } + : builtinClassInstanceOf(tested, kind, loc), + right: ctor + ? { + kind: "logical", + op: "&&", + left: { + kind: "dynScalarEq", + left: ctor, + right: builtinClassValue(kind, loc), + type: BOOL, + loc, + }, + right: compiled, + type: BOOL, + loc, + } + : compiled, + type: BOOL, + loc, + }; + return stmts.length ? { kind: "seqExpr", stmts, result, type: BOOL, loc } : result; +} diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 8ef631f601..d556245b97 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -45,6 +45,7 @@ import { lowerElementCompoundParts } from "./element-access.js"; import { InternalCompilerError } from "../../../errors.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { storedClassValueType } from "../lower-classes.js"; +import { builtinClassKind } from "../builtins/builtin-class-dyn.js"; import { hasRuntimeStatics } from "../class-runtime-statics.js"; import { tryLowerExpression } from "./try-lower-expression.js"; import { fenceNodeModuleMutation, lowerRequireCacheHas } from "../lower-node-module.js"; @@ -1486,6 +1487,25 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr loc, }; } + // EventEmitter or a stream class against an untyped value in + // JavaScript: the untyped value can hold the class's untyped + // constructor (coerceToExpected), which compares by identity. + if ( + dynSide.type.kind === "dyn" && + isJsSourceFile(expr.getSourceFile()) && + ((scalarSide.type.kind === "moduleNs" && scalarSide.type.moduleId === "builtin:events") || + (scalarSide.kind === "classRef" && builtinClassKind(scalarSide.className) !== null)) + ) { + const boxed = lowerer.coerceToExpected(scalarSide, DYN); + return { + kind: "dynScalarEq", + left: scalarSide === left ? boxed : left, + right: scalarSide === right ? boxed : right, + ...(negated ? { negated: true as const } : {}), + type: BOOL, + loc, + }; + } if ( dynSide.type.kind === "dyn" && (scalarSide.type.kind === "f64" || diff --git a/packages/compiler/src/frontend/lowering/expressions/relational.ts b/packages/compiler/src/frontend/lowering/expressions/relational.ts index 91d1468c27..b29b67f8b8 100644 --- a/packages/compiler/src/frontend/lowering/expressions/relational.ts +++ b/packages/compiler/src/frontend/lowering/expressions/relational.ts @@ -1,6 +1,6 @@ /* instanceof and in, including private-name and runtime-key in. */ import * as ts from "../../ts7/adapter.js"; -import { builtinClassInstanceOf, builtinClassKind } from "../builtins/builtin-class-dyn.js"; +import { builtinClassInstanceOfAny, builtinClassKind } from "../builtins/builtin-class-dyn.js"; import { checkedPrivateIn, localPrivateBrandClass, @@ -401,6 +401,16 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc } const left = lowerer.lowerExpr(expr.left); if (left.type.kind === "dyn") { + // An untyped binding the checker narrowed to EventEmitter or a + // stream class (`ReleaseEmitter = EventEmitter` in JavaScript): + // the binding holds an untyped constructor, so the test is + // Function.prototype[Symbol.hasInstance] over it. + const builtinKind = targetInfo.def.runtime ? builtinClassKind(targetInfo.def.name) : null; + if (builtinKind !== null) { + const right = lowerer.lowerExpr(expr.right); + if (right.type.kind === "dyn") + return builtinClassInstanceOfAny(lowerer, left, builtinKind, targetInfo, loc, right); + } if (targetInfo.def.runtime) { lowerer.unsupported( "SC1090", @@ -600,11 +610,11 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc } // `u instanceof EventEmitter` (or a stream class): an untyped object // built by a constructor function inherits the untyped constructor's - // prototype (builtin-class-dyn.ts). Compiled emitters and streams have - // no untyped form, so the prototype chain answers for every value. + // prototype (builtin-class-dyn.ts), and a compiled emitter or stream + // boxes as a typed reference the class test answers for. const builtinKind = builtinClassKind(target.def.name); if (left.type.kind === "dyn" && builtinKind !== null) { - return builtinClassInstanceOf(left, builtinKind, loc); + return builtinClassInstanceOfAny(lowerer, left, builtinKind, target, loc); } if (left.type.kind === "dyn") { lowerer.unsupported( diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 99d854a6ae..f638515469 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -89,14 +89,17 @@ import { withUnitArm, } from "../type-mapper.js"; import { + canonicalBuiltinModule, cjsClassExprWholeExportOf, isCjsJsFile, isJsSourceFile, isModuleExportsAccess, locOf, + requireSpecOf, } from "../program.js"; import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./lowerer.js"; import { isAssertClassNew } from "./assert-instances.js"; +import { isCjsRequireGlobal } from "./lower-node-module.js"; import { lowerArrayConstructor } from "./containers/array-construction.js"; import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js"; import { lowerObjectConstructor } from "./containers/indexed-objects.js"; @@ -978,6 +981,19 @@ export function registerBuiltinEmitterClass(lowerer: Lowerer): void { lowerer.classes.set(RUNTIME_EMITTER_CLASS, info); } +/** `class Bus extends require("events") {}` in a CommonJS module: the + * events module is the EventEmitter class itself, so the base is the + * runtime emitter, exactly as through a `require("events")` binding. */ +export function builtinRequireBaseOf(lowerer: Lowerer, node: ts.Expression): ClassInfo | null { + let expr = node; + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (!ts.isCallExpression(expr) || !isJsSourceFile(expr.getSourceFile())) return null; + const spec = requireSpecOf(expr); + if (spec === null || canonicalBuiltinModule(spec) !== "events") return null; + if (!isCjsRequireGlobal(lowerer, expr.expression)) return null; + return lowerer.classes.get(RUNTIME_EMITTER_CLASS) ?? null; +} + /** The runtime-provided node:stream classes as eagerly-registered * ClassInfos (the emitter story): mapType names `%Readable` et al the * moment a stream type appears, so the infos must exist before any @@ -2092,6 +2108,11 @@ export function collectClassShapeInner( // callee is NOT a mixin function keeps the computed-expression // fence below. if (t && ts.isCallExpression(t.expression) && !t.typeArguments?.nodes) { + const requiredBase = builtinRequireBaseOf(lowerer, t.expression); + if (requiredBase) { + base = requiredBase; + continue; + } const mixinBase = lowerer.mixinCallClassInfoOf(t.expression); if (mixinBase) { base = mixinBase; @@ -5724,7 +5745,7 @@ export function lowerClassExpressionInfo( )?.types?.nodes[0]?.expression; if ( heritage && - (ts.isCallExpression(heritage) || + ((ts.isCallExpression(heritage) && !builtinRequireBaseOf(lowerer, heritage)) || (ts.isIdentifier(heritage) && (lowerer.peekLocal(heritage) ?? lowerer.globalOf(heritage)) != null)) ) { diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 12c7d32ffc..b0b07235c1 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -132,6 +132,7 @@ import { recordTextCodecClass } from "../../ir/ir.js"; import { isUnitOnlyTsType, unitOnlyUnion } from "../type-mapper.js"; import type { ClassInfo } from "./lower-classes.js"; import { + builtinRequireBaseOf, collectInheritedJsFieldWrites, collectVirtualJsMethods, decoratorNodesOf, @@ -373,7 +374,8 @@ export function collectProgram(lowerer: Lowerer, parts: FileParts[]): void { const heritage = decl.heritageClauses?.nodes.find( (clause) => clause.token === ts.SyntaxKind.ExtendsKeyword, )?.types?.nodes[0]?.expression; - if (heritage && ts.isCallExpression(heritage)) computed.add(decl); + if (heritage && ts.isCallExpression(heritage) && !builtinRequireBaseOf(lowerer, heritage)) + computed.add(decl); } // Descendants need the computed ancestor's completed layout before // their own fields and inherited methods can be collected. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index a5f376fc04..069eddd6c7 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -6,7 +6,12 @@ import { } from "./general-streams.js"; import { extendAssertedRecordMembers } from "./assertion-members.js"; import { jsDynamicDeclarations } from "./js-dynamic-objects.js"; -import { builtinClassValue, builtinMemberClassKind } from "./builtins/builtin-class-dyn.js"; +import { isJsSourceFileName } from "../tsc-codes.js"; +import { + builtinClassKind, + builtinClassValue, + builtinMemberClassKind, +} from "./builtins/builtin-class-dyn.js"; import { lifetimeRegionType, lowerLifetimeRegionCall, @@ -8925,7 +8930,33 @@ export class Lowerer { } // A runtime-provided class value has no checked-dynamic form that // inspects as Node's does (`[Function: EventEmitter] { ... }`). + // JavaScript sources already read EventEmitter and the stream + // classes as the runtime's untyped constructors (builtin-class-dyn.ts), + // so one of those classes turning untyped there becomes the same + // constructor. `require("events")` is the EventEmitter class itself, + // so its module binding is too (`ReleaseEmitter = EventEmitter` over + // `const EventEmitter = require("events")`). + if ( + expr.type.kind === "moduleNs" && + expr.type.moduleId === "builtin:events" && + isJsSourceFileName(expr.loc.file) + ) { + const value = builtinClassValue(0, expr.loc); + if (expr.kind === "moduleNsRef") return value; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr, loc: expr.loc }], + result: value, + type: DYN, + loc: expr.loc, + }; + } if (expr.type.kind === "classval" && this.classes.get(expr.type.className)?.def.runtime) { + // Only the class itself: a slot of the class's type may hold a + // program subclass, which this constructor would misidentify. + const kind = builtinClassKind(expr.type.className); + if (kind !== null && expr.kind === "classRef" && isJsSourceFileName(expr.loc.file)) + return builtinClassValue(kind, expr.loc); this.pushDiag( unsupportedDiag( "SC1090", @@ -9005,6 +9036,24 @@ export class Lowerer { ); const handleOk = DYN_HANDLE_KINDS.has(expected.kind); noteUntypedStreamFlow(this, expected); + // `[EventEmitter]` in JavaScript builds an untyped array of the + // untyped constructor, which a typed array of the class cannot + // hold: no class check admits that constructor. + if ( + expr.kind === "dynArrLit" && + expected.kind === "array" && + expected.elem.kind === "classval" && + expr.elems.some((elem) => elem.kind === "libCall" && elem.fn === "dyn.builtinClass") + ) { + this.pushDiag( + unsupportedDiag( + "SC1090", + expr.loc, + `holding runtime-provided class values ('${expected.elem.className.slice(1)}') in a typed array`, + ), + ); + throw new PoisonError(); + } if ( canDynCheckTo( expected, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6571-events-module-class-value.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6571-events-module-class-value.cjs.json new file mode 100644 index 0000000000..c8eaf14515 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6571-events-module-class-value.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6571-events-module-class-value.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6572-extends-require-events.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6572-extends-require-events.cjs.json new file mode 100644 index 0000000000..2fc3d1039c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6572-extends-require-events.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6572-extends-require-events.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6573-semaphore-release-emitter.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6573-semaphore-release-emitter.cjs.json new file mode 100644 index 0000000000..f1dd30da93 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6573-semaphore-release-emitter.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6573-semaphore-release-emitter.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2595-bundled-chunk-emitter/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2595-bundled-chunk-emitter/main.ts.json new file mode 100644 index 0000000000..ee60a3f466 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/2595-bundled-chunk-emitter/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/2595-bundled-chunk-emitter/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6571-events-module-class-value.cjs b/tests/corpus/6571-events-module-class-value.cjs new file mode 100644 index 0000000000..9df037a6d2 --- /dev/null +++ b/tests/corpus/6571-events-module-class-value.cjs @@ -0,0 +1,30 @@ +// require("events") is the EventEmitter class. Assigned to an untyped +// binding or stored in an object, it stays a constructor: construction +// through it builds an emitter with the whole listener API, and identity, +// instanceof, typeof and .name agree with the class. +var EventEmitter = require("events"), ReleaseEmitter; +ReleaseEmitter = EventEmitter; + +const e = new ReleaseEmitter(); +e.on("x", (v) => console.log("x", v)); +e.prependListener("x", (v) => console.log("first x", v)); +e.once("x", (v) => console.log("once x", v)); +console.log("emit", e.emit("x", 1), e.emit("x", 2), e.emit("missing")); +console.log("count", e.listenerCount("x"), e.eventNames(), e.getMaxListeners()); +console.log("instanceof", e instanceof EventEmitter, e instanceof ReleaseEmitter); +console.log("identity", ReleaseEmitter === EventEmitter, ReleaseEmitter !== EventEmitter); +console.log("typeof", typeof ReleaseEmitter, ReleaseEmitter.name); + +const holder = { E: EventEmitter }; +const h = new holder.E(); +h.on("error", (err) => console.log("error", err.message)); +h.emit("error", new Error("boom")); +console.log("holder", h instanceof EventEmitter, h instanceof holder.E, holder.E === EventEmitter); + +try { + new ReleaseEmitter().emit("error", new Error("unhandled")); +} catch (err) { + console.log("caught", err.message); +} +e.removeAllListeners("x"); +console.log("removed", e.listenerCount("x"), e.emit("x", 3)); diff --git a/tests/corpus/6572-extends-require-events.cjs b/tests/corpus/6572-extends-require-events.cjs new file mode 100644 index 0000000000..06e2f1c9a9 --- /dev/null +++ b/tests/corpus/6572-extends-require-events.cjs @@ -0,0 +1,27 @@ +// A class whose heritage is the require call itself extends EventEmitter, +// as through a require binding. The class is a value: stored in an untyped +// binding, construction through it runs the subclass constructor. +class Channel extends require("events") { + constructor(name) { + super(); + this.name = name; + } + send(text) { + return this.emit("message", this.name, text); + } +} + +const a = new Channel("a"); +a.on("message", (from, text) => console.log("message", from, text)); +console.log("sent", a.send("hello"), new Channel("b").send("nobody")); + +let Ctor; +Ctor = Channel; +const c = new Ctor("c"); +c.once("message", (from, text) => console.log("once", from, text)); +c.send("one"); +c.send("two"); + +const EventEmitter = require("events"); +console.log("instanceof", a instanceof EventEmitter, c instanceof EventEmitter, c instanceof Channel); +console.log("class", typeof Ctor, Ctor.name, Ctor === Channel, a.listenerCount("message")); diff --git a/tests/corpus/6573-semaphore-release-emitter.cjs b/tests/corpus/6573-semaphore-release-emitter.cjs new file mode 100644 index 0000000000..fea3e284d0 --- /dev/null +++ b/tests/corpus/6573-semaphore-release-emitter.cjs @@ -0,0 +1,62 @@ +// A token semaphore whose constructor builds its release channel from the +// events module, the way small npm semaphores do: once through a subclass +// declared beside the require, once through an untyped alias of the class. +// acquire() hands out free tokens and queues the rest; release() emits, and +// the listener wakes the oldest waiter or returns the token. +var EventEmitter = require("events"), Subclassed = class extends EventEmitter {}, Aliased; +Aliased = EventEmitter; + +function defaultInit() { + return "1"; +} + +class Semaphore { + constructor(nr, { initFn = defaultInit, aliased = false } = {}) { + this.nrTokens = nr; + this.free = []; + this.waiting = []; + this.releaseEmitter = aliased ? new Aliased() : new Subclassed(); + this.noTokens = initFn === defaultInit; + this.releaseEmitter.on("release", (token) => { + const p = this.waiting.shift(); + if (p) p.resolve(token); + else this.free.push(token); + }); + for (let i = 0; i < nr; i++) this.free.push(initFn()); + } + async acquire() { + const token = this.free.pop(); + if (token !== undefined) return token; + return new Promise((resolve, reject) => { + this.waiting.push({ resolve, reject }); + }); + } + release(token) { + this.releaseEmitter.emit("release", this.noTokens ? "1" : token); + } + nrWaiting() { + return this.waiting.length; + } +} + +async function run(label, sema) { + const order = []; + const tasks = [1, 2, 3, 4].map(async (n) => { + const token = await sema.acquire(); + order.push(`${n}:${token}`); + await new Promise((resolve) => setTimeout(resolve, 2)); + sema.release(token); + }); + console.log(label, "waiting", sema.nrWaiting()); + await Promise.all(tasks); + console.log(label, order.join(" "), "free", sema.free.length, "waiting", sema.nrWaiting()); +} + +(async () => { + await run("plain", new Semaphore(2)); + let next = 0; + await run("tokens", new Semaphore(2, { initFn: () => `t${next++}` })); + await run("aliased", new Semaphore(1, { aliased: true })); + const s = new Semaphore(1); + console.log("channel", s.releaseEmitter instanceof EventEmitter, s.releaseEmitter instanceof Subclassed); +})(); diff --git a/tests/fixtures/npm/cases/2595-bundled-chunk-emitter/main.ts b/tests/fixtures/npm/cases/2595-bundled-chunk-emitter/main.ts new file mode 100644 index 0000000000..e684af3948 --- /dev/null +++ b/tests/fixtures/npm/cases/2595-bundled-chunk-emitter/main.ts @@ -0,0 +1,11 @@ +// A bundled CommonJS chunk whose top-level var statement mixes +// require("events"), require("util"), a sibling chunk's require and a class +// expression extending the events binding, followed by an untyped alias of +// the events module. The require bindings own no storage: the semaphore's +// constructor builds its release channel through them, its queue through +// the sibling chunk's constructor function, and both channels emit. +import { aliased, limited } from "gtchunkee"; + +console.log(aliased()); +console.log(await limited("two slots", 4, 2)); +console.log(await limited("one slot", 3, 1)); diff --git a/tests/fixtures/npm/node_modules/gtchunkee/dist/index.d.ts b/tests/fixtures/npm/node_modules/gtchunkee/dist/index.d.ts new file mode 100644 index 0000000000..f9f2d15351 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkee/dist/index.d.ts @@ -0,0 +1,2 @@ +export declare function limited(label: string, jobs: number, slots: number): Promise; +export declare function aliased(): string; diff --git a/tests/fixtures/npm/node_modules/gtchunkee/dist/index.js b/tests/fixtures/npm/node_modules/gtchunkee/dist/index.js new file mode 100644 index 0000000000..59040f2386 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkee/dist/index.js @@ -0,0 +1,75 @@ +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// node_modules/queue/index.js +var require_queue = __commonJS({ + "node_modules/queue/index.js"(exports2, module2) { + "use strict";function Queue(capacity){this._items=[],this._capacity=capacity||16}Queue.prototype.push=function(item){return this._items.push(item)};Queue.prototype.shift=function(){return this._items.shift()};Queue.prototype.pop=function(){return this._items.pop()};Object.defineProperty(Queue.prototype,"length",{get:function(){return this._items.length}});module2.exports=Queue; + } +}); + +// node_modules/sema/index.js +var require_sema = __commonJS({ + "node_modules/sema/index.js"(exports2) { + var EventEmitter=require("events"),util=require("util"),Queue=require_queue(),ReleaseEmitter=class extends EventEmitter{},AliasedEmitter;AliasedEmitter=EventEmitter;function defaultInit(){return"1"}class Sema{constructor(nr,{initFn=defaultInit,aliased=!1}={}){this.nrTokens=nr,this.free=new Queue(nr),this.waiting=new Queue(10),this.releaseEmitter=aliased?new AliasedEmitter:new ReleaseEmitter,this.noTokens=initFn===defaultInit,this.releaseEmitter.on("release",token=>{let p=this.waiting.shift();p?p.resolve(token):this.free.push(token)});for(let i=0;i{this.waiting.push({resolve,reject})})}release(token){this.releaseEmitter.emit("release",this.noTokens?"1":token)}nrWaiting(){return this.waiting.length}describe(){return util.format("%d tokens, %s",this.nrTokens,this.releaseEmitter instanceof EventEmitter?"emitter":"other")}}exports2.create=function(nr,options){return new Sema(nr,options)}; + } +}); + +// src/index.ts +var index_exports = {}; +__export(index_exports, { + aliased: () => aliased, + limited: () => limited +}); +module.exports = __toCommonJS(index_exports); +var import_sema = __toESM(require_sema()); +async function limited(label, jobs, slots) { + let next = 0; + const sema = (0, import_sema.create)(slots, { initFn: () => "t" + next++ }); + const order = []; + const tasks = []; + for (let i = 0; i < jobs; i++) { + tasks.push((async () => { + const token = await sema.acquire(); + order.push(i + ":" + token); + await new Promise((resolve) => setTimeout(resolve, 2)); + sema.release(token); + })()); + } + const queued = sema.nrWaiting(); + await Promise.all(tasks); + return label + " queued " + queued + " order " + order.join(" ") + " (" + sema.describe() + ")"; +} +function aliased() { + const sema = (0, import_sema.create)(1, { aliased: true }); + return sema.describe() + " waiting " + sema.nrWaiting(); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + aliased, + limited +}); diff --git a/tests/fixtures/npm/node_modules/gtchunkee/package.json b/tests/fixtures/npm/node_modules/gtchunkee/package.json new file mode 100644 index 0000000000..e47378c0e2 --- /dev/null +++ b/tests/fixtures/npm/node_modules/gtchunkee/package.json @@ -0,0 +1,7 @@ +{ + "name": "gtchunkee", + "version": "1.0.0", + "description": "esbuild-style CommonJS bundle (--bundle --platform=node --format=cjs, whitespace+syntax minified chunk bodies): a semaphore chunk whose top-level var statement mixes require(\"events\"), require(\"util\"), a sibling chunk require and a class extending the events binding", + "main": "./dist/index.js", + "types": "./dist/index.d.ts" +} diff --git a/tests/harness/npm-cases.ts b/tests/harness/npm-cases.ts index 01718abf9f..43044ee607 100644 --- a/tests/harness/npm-cases.ts +++ b/tests/harness/npm-cases.ts @@ -19,13 +19,14 @@ export function npmCases(fixturesRoot: string): NpmCase[] { return [ ...globSync(join(fixturesRoot, "npm/cases/*/main.ts")) .sort() - // 2465-2469, 2556-2567, 2571 and 2580-2584 are the --npm-static bundler-emitted-CJS + // 2465-2469, 2556-2567, 2571, 2580-2584 and 2595 are the --npm-static bundler-emitted-CJS // cases (npm-static.test.ts drives them with the opt-in): their // consumers exercise the static rewrite's surface (lexer-visible // names the shipped .d.ts never declares, the __toESM interop // family), so they stay out of the flagless island lane by design. .filter( - (entry) => !/\/(246[5-9]|255[6-9]|256[0-7]|2571|258[0-4])-[^/]+\/main\.ts$/.test(entry), + (entry) => + !/\/(246[5-9]|255[6-9]|256[0-7]|2571|258[0-4]|2595)-[^/]+\/main\.ts$/.test(entry), ) .map((entry) => ({ name: entry.split("/").at(-2)!, diff --git a/tests/harness/npm-static.test.ts b/tests/harness/npm-static.test.ts index c16567f47c..499c50f179 100644 --- a/tests/harness/npm-static.test.ts +++ b/tests/harness/npm-static.test.ts @@ -1438,6 +1438,15 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => { ]); }, 180_000); + // 2595: a minified chunk whose top-level var statement mixes require + // bindings with a class extending the events binding; the bindings own no + // storage, so the constructor reaches the modules through them. + test("bundled chunk 2595-bundled-chunk-emitter builds its emitters through require bindings", async () => { + await expectNodeParity(join(fixturesRoot, "npm/cases/2595-bundled-chunk-emitter/main.ts"), [ + "gtchunkee", + ]); + }, 180_000); + // A whitespace/syntax-minified bundle whose helpers keep their names is // judged by the recognizers, not refused up front as minified. test("--npm-static=auto opts a minified bundle with named helpers in", () => {