Skip to content

Let constructor functions inherit from EventEmitter and the stream classes - #1512

Merged
ctate merged 3 commits into
mainfrom
opt/util-inherits-builtin
Oct 20, 2026
Merged

ctate merged 3 commits into
mainfrom
opt/util-inherits-builtin

Conversation

@cramforce

Copy link
Copy Markdown
Contributor

Stacked on #1479 (opt/builtin-base-class-values). Review that first.

Summary

Bundled npm code builds subclasses of Node's classes the pre-class way, with constructor functions:

util.inherits(Glob, EventEmitter);
function Glob(pattern, options) {
  if (!(this instanceof Glob)) return new Glob(pattern, options);
  EventEmitter.call(this);
  // …
}

glob, yazl, from2 and multistream all do this at module load. util.inherits received an identity token for the builtin class and threw Node's ERR_INVALID_ARG_TYPE ("superCtor.prototype must be of type object"), EventEmitter.call(this) and EventEmitter.prototype were refused, and this instanceof Glob was refused because the right-hand side is a function rather than a class.

#1479 made ES classes that extend these bases usable as values. Those are compiled classes with a native emitter or stream inside. A constructor function's new Glob() builds an ordinary untyped object instead, so the base has to work on such an object.

Untyped constructors for the builtin bases. A new runtime unit, scr_dyn_builtin_class.c, provides EventEmitter, the legacy Stream and the five stream classes as untyped constructor functions, one per thread. Their prototypes are chained as in Node (PassThrough → Transform → Duplex → Readable → Stream → EventEmitter, and Writable → Stream). The compiler uses them where a constructor function meets its base:

  • util.inherits(Ctor, Base) and Ctor.super_;
  • Base.call(this, …) and Base.apply(this, …);
  • Base.prototype, so Object.setPrototypeOf(Ctor.prototype, Base.prototype) works too;
  • x instanceof Base on an untyped value;
  • a JavaScript value read of EventEmitter or a stream class (a destructured require binding or stream.Readable), which used to be the identity token.

EventEmitter on an untyped object. EventEmitter.call(this) runs Node's EventEmitter.init. The prototype's methods are a port of lib/events.js that keeps the listener state where Node keeps it, in the object's own _events, _eventsCount and _maxListeners properties plus the kShapeMode and kCapture symbols. This covers on/addListener, prependListener, once, prependOnceListener, off/removeListener, removeAllListeners, emit (including the unhandled 'error' rule and ERR_UNHANDLED_ERROR), listeners, rawListeners, listenerCount, eventNames, setMaxListeners, getMaxListeners, the newListener and removeListener events, the leak warning, and the static EventEmitter.listenerCount. The legacy Stream adds Node's legacy pipe.

Stream bases. util.inherits(Ctor, stream.Readable) and the other stream classes link the prototypes, so module initialization and instanceof work. Calling Readable.call(this, opts) (or another stream class) on a constructor function's receiver throws SC2020 where it runs. Native stream state on an untyped object is the next step.

instanceof a constructor function. x instanceof Ctor in JavaScript, where Ctor is a compiled function, compares Ctor.prototype with the value's prototype chain. This is the if (!(this instanceof Ctor)) return new Ctor(…) guard.

The unit links only into programs that use these values, behind a new dynBuiltinClass runtime feature. It also needs the inspect, dyn-invoke and symbol units. Programs that don't use these values are unchanged.

Evidence

  • 6453-constructor-function-emitter.cjs: a two-level constructor-function hierarchy over EventEmitter through util.inherits and Object.setPrototypeOf, the call-without-new guard, the listener API, once and prepend ordering, unhandled 'error' with an Error and with a string, newListener/removeListener events, removeAllListeners, setMaxListeners range and listener type errors, the leak warning, and util.inspect of an instance.
  • 6454-constructor-function-stream-bases.cjs: the legacy Stream base and its pipe between two such streams (with and without end: false), plus util.inherits onto all five stream classes with super_ and instanceof along the chain.
  • Node and native agree in both lanes, including the sanitized lane's RC audit (the constructors are released at exit). The existing emitter, stream, class and util corpus programs pass. 5977-builtin-class-aliases.cjs stores EventEmitter and PassThrough in an untyped array; those values are now the untyped constructors instead of identity tokens, with the same output.
  • Zero-cost gate against Use classes that extend EventEmitter or a stream class as values #1479 (b664590): 3284 programs identical (bench, self-hosted compiler, corpus), 1 that fails on both sides, 1 difference; self-hosted compile time +0.46% (within tolerance), bench geomean −0.17% with a wide interval [−2.18%, +2.20%] (inconclusive, no change to bench programs' code). The one difference is 5977-builtin-class-aliases.cjs, which is intended: it stores EventEmitter and PassThrough in an untyped array, so it now links the new unit (+9.4% binary size for that program) and holds real constructor functions where it held identity tokens. Its output is unchanged.
  • Lint, format, test:ts7 --baselines-only, the diagnostics, npm and inheritance suites, the runtime-pack and feature unit tests, self-hosting-runtime-features.test.ts and the self-hosting IR build are clean.

Limitations

These are documented on the limitations page:

  • captureRejections and events.errorMonitor listeners are not supported on these objects.
  • Constructing over Readable, Writable, Duplex, Transform or PassThrough refuses at run time.

#1487 adds a compile-time refusal for util.inherits onto any runtime-provided class. When both land, EventEmitter and the stream classes should take this path and other runtime classes keep that refusal; the two touch the same lines in lower-inspect.ts.

The value of require("stream") itself (the legacy constructor as a module object) is unchanged. util.inherits and .call through it work, but other uses of it as a value do not.

🤖 Generated with Claude Code

…asses

Bundled npm code (glob, yazl, from2, multistream) subclasses Node's
classes with constructor functions: util.inherits(Ctor, EventEmitter) and
EventEmitter.call(this). util.inherits received an identity token and
threw ERR_INVALID_ARG_TYPE, EventEmitter.call(this) and
EventEmitter.prototype were refused, and `this instanceof Ctor` against
a function was refused.

A new runtime unit (scr_dyn_builtin_class.c, linked by the new
dynBuiltinClass feature) provides EventEmitter, the legacy Stream and
the stream classes as untyped constructor functions with Node's
prototype chain. EventEmitter's prototype ports lib/events.js over the
object's own _events, _eventsCount and _maxListeners properties; Stream
adds the legacy pipe. util.inherits, Base.call/apply(this, ...),
Base.prototype, instanceof on untyped values and JavaScript value reads
of these classes use them. Constructing over a stream class throws
SC2020 where it runs. instanceof against a compiled constructor function
compares its prototype with the value's prototype chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 20, 2026 4:29pm UTC

Comment thread packages/runtime/src/scr_dyn_builtin_class.c Outdated
Comment thread packages/compiler/src/ir/runtime-features.ts Outdated
Comment thread packages/runtime/src/scr_dyn_builtin_class.c
Base automatically changed from opt/builtin-base-class-values to main October 20, 2026 13:46
…f alone, and clean up a pipe once

- EventEmitter.defaultMaxListeners on the untyped constructor is Node's
  accessor over the default the typed EventEmitter reads: assignments
  validate as validateNumber(arg, 'defaultMaxListeners', 0) does, and
  getMaxListeners() of emitters without their own limit answers the new
  value.
- dyn.builtinInstanceOf and dyn.functionInstanceOf link the
  constructor-function unit and the units it calls (dyn-invoke, inspect,
  symbol, emitter), so a program whose only use is `this instanceof F`
  links.
- The legacy pipe's cleanup runs once: when the destination emits
  'close' from end(), the cleanup still queued on the source's 'end'
  finds the listeners removed and emit('end') answers true.
@ctate
ctate merged commit 8823556 into main Oct 20, 2026
19 of 22 checks passed
@ctate
ctate deleted the opt/util-inherits-builtin branch October 20, 2026 16:32
euphemism pushed a commit to euphemism/scriptc that referenced this pull request Oct 20, 2026
…e headers

The backend can emit scr_wasm_memory_construct, scr_wasm_table_construct,
scr_wasm_global_construct, scr_wasm_construct_without_new,
scr_wasm_module_imports, scr_wasm_module_exports,
scr_wasm_module_custom_sections and scr_wasm_value_of (vercel-labs#1410), and
scr_stream_dyn_native, scr_stream_pipe_dyn and scr_stream_unpipe_dyn
(vercel-labs#1524). Their prototypes were in scr_wasm.c or nowhere, so the runtime
ABI guard failed. They are now in scr_wasm.h and scr_stream.h, and
scr_wasm.c no longer repeats the header's declarations.

The speculative-hoisting test relied on `v instanceof Num` in a
JavaScript function failing, which constructor-function instanceof now
compiles (vercel-labs#1512). Its hoisted function reads the prototype through
Reflect.getPrototypeOf instead, which still fences.

This branch was successfully deployed

1 active deployment
Preview — 447f56e5 Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants