Repository: denoland/deno
Pull request: fix(ext/node): make tty ReadStream/WriteStream callable without `new`

## Summary

- Convert `ReadStream` (in `tty.js`) and `WriteStream` (in `internal/tty.js`) from ES classes to function-style constructors so they can be called without `new`, matching Node.js behavior
- Uses `FunctionPrototypeCall` and `ObjectPrototypeIsPrototypeOf` primordials throughout
- Fixes `test-tty-stream-constructors.js` node compat test (12/16 → 13/16 passing assertions)

## Test plan

- [x] `cargo test --test node_compat -- test-tty-stream-constructors` passes
- [x] `cargo build` succeeds  
- [x] `tools/lint.js --js` passes clean
- [x] `tools/format.js` passes clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/node/polyfills/internal/tty.js
patch: @@ -8,9 +8,11 @@ import { primordials } from "ext:core/mod.js";
 const {
   ArrayPrototypeSome,
   Error,
+  FunctionPrototypeCall,
   ObjectEntries,
   ObjectPrototypeHasOwnProperty,
   ObjectPrototypeIsPrototypeOf,
+  ObjectSetPrototypeOf,
   ObjectValues,
   RegExpPrototypeExec,
   SafeMap,
@@ -289,170 +291,180 @@ function removeSigwinchListener(stream) {
   }
 }
 
-export class WriteStream extends Socket {
-  constructor(fd) {
-    if (fd >> 0 !== fd || fd < 0) {
-      throw new ERR_INVALID_FD(fd);
-    }
-
-    // We only support `stdin`, `stdout` and `stderr`.
-    if (fd > 2) throw new Error("Only fd 0, 1 and 2 are supported.");
-
-    const tty = new TTY(
-      fd === 0 ? io.stdin : fd === 1 ? io.stdout : io.stderr,
-    );
-
-    super({
-      readableHighWaterMark: 0,
-      handle: tty,
-      manualStart: true,
-    });
-
-    const { columns, rows } = Deno.consoleSize();
-    this.columns = columns;
-    this.rows = rows;
-    this.isTTY = true;
-
-    // For stdout/stderr, use synchronous writes to avoid races with
-    // console.log which writes synchronously via Deno.core.print().
-    // Without this, async writes through the Socket/TTY handle can complete
-    // after a subsequent synchronous console.log, causing output corruption
-    // (e.g. listr2 ANSI redraws overwriting later output).
-    if (fd === 1 || fd === 2) {
-      const writer = fd === 1 ? io.stdout : io.stderr;
-      this._write = function (data, enc, cb) {
-        try {
-          let buf = ObjectPrototypeIsPrototypeOf(Uint8ArrayPrototype, data)
-            ? data
-            : Buffer.from(data, enc);
+// WriteStream needs to be callable without `new` to match Node.js behavior.
+function WriteStream(fd) {
+  if (!ObjectPrototypeIsPrototypeOf(WriteStream.prototype, this)) {
+    return new WriteStream(fd);
+  }
+
+  if (fd >> 0 !== fd || fd < 0) {
+    throw new ERR_INVALID_FD(fd);
+  }
+
+  // We only support `stdin`, `stdout` and `stderr`.
+  if (fd > 2) throw new Error("Only fd 0, 1 and 2 are supported.");
+
+  const tty = new TTY(
+    fd === 0 ? io.stdin : fd === 1 ? io.stdout : io.stderr,
+  );
+
+  FunctionPrototypeCall(Socket, this, {
+    readableHighWaterMark: 0,
+    handle: tty,
+    manualStart: true,
+  });
+
+  const { columns, rows } = Deno.consoleSize();
+  this.columns = columns;
+  this.rows = rows;
+  this.isTTY = true;
+
+  // For stdout/stderr, use synchronous writes to avoid races with
+  // console.log which writes synchronously via Deno.core.print().
+  // Without this, async writes through the Socket/TTY handle can complete
+  // after a subsequent synchronous console.log, causing output corruption
+  // (e.g. listr2 ANSI redraws overwriting later output).
+  if (fd === 1 || fd === 2) {
+    const writer = fd === 1 ? io.stdout : io.stderr;
+    this._write = function (data, enc, cb) {
+      try {
+        let buf = ObjectPrototypeIsPrototypeOf(Uint8ArrayPrototype, data)
+          ? data
+          : Buffer.from(data, enc);
+        // deno-lint-ignore prefer-primordials
+        while (buf.byteLength > 0) {
+          const nwritten = writer.writeSync(buf);
           // deno-lint-ignore prefer-primordials
-          while (buf.byteLength > 0) {
-            const nwritten = writer.writeSync(buf);
-            // deno-lint-ignore prefer-primordials
-            if (nwritten >= buf.byteLength) break;
-            buf = TypedArrayPrototypeSlice(buf, nwritten);
-          }
-        } catch (e) {
-          cb(e);
-          return;
+          if (nwritten >= buf.byteLength) break;
+          buf = TypedArrayPrototypeSlice(buf, nwritten);
         }
-        cb();
-      };
-
-      // Prevent actually closing stdout/stderr. Libraries like mute-stream
-      // (used by @inquirer/prompts) call destroy()/end() on the stream,
-      // which would close the underlying resource and break subsequent
-      // console.log calls (BadResource error). Match Node.js behavior
-      // where stdio streams are indestructible.
-      //
-      // Override _destroy to prevent Socket._destroy from calling
-      // this._handle.close(), which would close the Deno resource.
-      this._destroy = function (err, cb) {
-        cb(err);
-        this._undestroy();
-      };
-
-      // Also prevent the handle's _onClose from closing the underlying
-      // io.stdout/io.stderr resource. This handles the end() path:
-      // end() -> _final -> shutdown -> _onClose -> io.stdout.close().
-      // Without this, the Deno resource (rid 1/2) is removed from the
-      // resource table and op_print (used by console.log) fails.
-      this._handle._onClose = function () {
-        return 0;
-      };
-    }
+      } catch (e) {
+        cb(e);
+        return;
+      }
+      cb();
+    };
+
+    // Prevent actually closing stdout/stderr. Libraries like mute-stream
+    // (used by @inquirer/prompts) call destroy()/end() on the stream,
+    // which would close the underlying resource and break subsequent
+    // console.log calls (BadResource error). Match Node.js behavior
+    // where stdio streams are indestructible.
+    //
+    // Override _destroy to prevent Socket._destroy from calling
+    // this._handle.close(), which would close the Deno resource.
+    this._destroy = function (err, cb) {
+      cb(err);
+      this._undestroy();
+    };
+
+    // Also prevent the handle's _onClose from closing the underlying
+    // io.stdout/io.stderr resource. This handles the end() path:
+    // end() -> _final -> shutdown -> _onClose -> io.stdout.close().
+    // Without this, the Deno resource (rid 1/2) is removed from the
+    // resource table and op_print (used by console.log) fails.
+    this._handle._onClose = function () {
+      return 0;
+    };
   }
+}
 
-  on(event, listener) {
-    super.on(event, listener);
-    if (event === "resize" && this.listenerCount("resize") === 1) {
-      addSigwinchListener(this);
-    }
-    return this;
-  }
+ObjectSetPrototypeOf(WriteStream.prototype, Socket.prototype);
+ObjectSetPrototypeOf(WriteStream, Socket);
 
-  addListener(event, listener) {
-    return this.on(event, listener);
+WriteStream.prototype.on = function on(event, listener) {
+  FunctionPrototypeCall(Socket.prototype.on, this, event, listener);
+  if (event === "resize" && this.listenerCount("resize") === 1) {
+    addSigwinchListener(this);
   }
+  return this;
+};
 
-  removeListener(event, listener) {
-    super.removeListener(event, listener);
-    if (event === "resize" && this.listenerCount("resize") === 0) {
-      removeSigwinchListener(this);
-    }
-    return this;
-  }
+WriteStream.prototype.addListener = function addListener(event, listener) {
+  return this.on(event, listener);
+};
 
-  off(event, listener) {
-    return this.removeListener(event, listener);
+WriteStream.prototype.removeListener = function removeListener(
+  event,
+  listener,
+) {
+  FunctionPrototypeCall(Socket.prototype.removeListener, this, event, listener);
+  if (event === "resize" && this.listenerCount("resize") === 0) {
+    removeSigwinchListener(this);
   }
+  return this;
+};
 
-  removeAllListeners(event) {
-    super.removeAllListeners(event);
-    if (!event || event === "resize") {
-      removeSigwinchListener(this);
-    }
-    return this;
-  }
+WriteStream.prototype.off = function off(event, listener) {
+  return this.removeListener(event, listener);
+};
 
-  _refreshSize() {
-    const oldCols = this.columns;
-    const oldRows = this.rows;
-    const { columns, rows } = Deno.consoleSize();
-    if (oldCols !== columns || oldRows !== rows) {
-      this.columns = columns;
-      this.rows = rows;
-      this.emit("resize");
-    }
+WriteStream.prototype.removeAllListeners = function removeAllListeners(event) {
+  FunctionPrototypeCall(Socket.prototype.removeAllListeners, this, event);
+  if (!event || event === "resize") {
+    removeSigwinchListener(this);
   }
+  return this;
+};
 
-  cursorTo(x, y, callback) {
-    return cursorTo(this, x, y, callback);
+WriteStream.prototype._refreshSize = function _refreshSize() {
+  const oldCols = this.columns;
+  const oldRows = this.rows;
+  const { columns, rows } = Deno.consoleSize();
+  if (oldCols !== columns || oldRows !== rows) {
+    this.columns = columns;
+    this.rows = rows;
+    this.emit("resize");
   }
+};
 
-  moveCursor(dx, dy, callback) {
-    return moveCursor(this, dx, dy, callback);
-  }
+WriteStream.prototype.cursorTo = function cursorTo_(x, y, callback) {
+  return cursorTo(this, x, y, callback);
+};
 
-  clearLine(dir, callback) {
-    return clearLine(this, dir, callback);
-  }
+WriteStream.prototype.moveCursor = function moveCursor_(dx, dy, callback) {
+  return moveCursor(this, dx, dy, callback);
+};
 
-  clearScreenDown(callback) {
-    return clearScreenDown(this, callback);
-  }
+WriteStream.prototype.clearLine = function clearLine_(dir, callback) {
+  return clearLine(this, dir, callback);
+};
 
-  getWindowSize() {
-    return ObjectValues(Deno.consoleSize());
-  }
+WriteStream.prototype.clearScreenDown = function clearScreenDown_(callback) {
+  return clearScreenDown(this, callback);
+};
 
-  /**
-   * @param {number | Record<string, string>} [count]
-   * @param {Record<string, string>} [env]
-   * @returns {boolean}
-   */
-  hasColors(count, env) {
-    if (
-      env === undefined &&
-      (count === undefined || typeof count === "object" && count !== null)
-    ) {
-      env = count;
-      count = 16;
-    } else {
-      validateInteger(count, "count", 2);
-    }
+WriteStream.prototype.getWindowSize = function getWindowSize() {
+  return ObjectValues(Deno.consoleSize());
+};
 
-    const depth = this.getColorDepth(env);
-    return count <= 2 ** depth;
+/**
+ * @param {number | Record<string, string>} [count]
+ * @param {Record<string, string>} [env]
+ * @returns {boolean}
+ */
+WriteStream.prototype.hasColors = function hasColors(count, env) {
+  if (
+    env === undefined &&
+    (count === undefined || typeof count === "object" && count !== null)
+  ) {
+    env = count;
+    count = 16;
+  } else {
+    validateInteger(count, "count", 2);
   }
 
-  /**
-   * @param {Record<string, string>} [env]
-   * @returns {1 | 4 | 8 | 24}
-   */
-  getColorDepth(env) {
-    return getColorDepth(env);
-  }
-}
+  const depth = this.getColorDepth(env);
+  return count <= 2 ** depth;
+};
+
+/**
+ * @param {Record<string, string>} [env]
+ * @returns {1 | 4 | 8 | 24}
+ */
+WriteStream.prototype.getColorDepth = function getColorDepth_(env) {
+  return getColorDepth(env);
+};
 
+export { WriteStream };
 export default WriteStream;

filename: ext/node/polyfills/tty.js
patch: @@ -2,7 +2,12 @@
 
 import { op_node_is_tty } from "ext:core/ops";
 import { primordials } from "ext:core/mod.js";
-const { Error } = primordials;
+const {
+  Error,
+  FunctionPrototypeCall,
+  ObjectPrototypeIsPrototypeOf,
+  ObjectSetPrototypeOf,
+} = primordials;
 
 import { ERR_INVALID_FD } from "ext:deno_node/internal/errors.ts";
 import { TTY } from "ext:deno_node/internal_binding/tty_wrap.ts";
@@ -19,36 +24,45 @@ function isatty(fd) {
   return op_node_is_tty(fd);
 }
 
-export class ReadStream extends Socket {
-  constructor(fd, options) {
-    if (fd >> 0 !== fd || fd < 0) {
-      throw new ERR_INVALID_FD(fd);
-    }
-
-    // We only support `stdin`.
-    if (fd != 0) throw new Error("Only fd 0 is supported.");
-
-    const tty = new TTY(io.stdin);
-    super({
-      readableHighWaterMark: 0,
-      handle: tty,
-      manualStart: true,
-      ...options,
-    });
-
-    this.isRaw = false;
-    this.isTTY = true;
+// ReadStream needs to be callable without `new` to match Node.js behavior.
+// We use a wrapper function that delegates to the actual class.
+function ReadStream(fd, options) {
+  if (!ObjectPrototypeIsPrototypeOf(ReadStream.prototype, this)) {
+    return new ReadStream(fd, options);
   }
 
-  setRawMode(flag) {
-    flag = !!flag;
-    this._handle.setRaw(flag);
-
-    this.isRaw = flag;
-    return this;
+  if (fd >> 0 !== fd || fd < 0) {
+    throw new ERR_INVALID_FD(fd);
   }
+
+  // We only support `stdin`.
+  if (fd != 0) throw new Error("Only fd 0 is supported.");
+
+  const tty = new TTY(io.stdin);
+  FunctionPrototypeCall(Socket, this, {
+    readableHighWaterMark: 0,
+    handle: tty,
+    manualStart: true,
+    ...options,
+  });
+
+  this.isRaw = false;
+  this.isTTY = true;
 }
 
+ObjectSetPrototypeOf(ReadStream.prototype, Socket.prototype);
+ObjectSetPrototypeOf(ReadStream, Socket);
+
+ReadStream.prototype.setRawMode = function setRawMode(flag) {
+  flag = !!flag;
+  this._handle.setRaw(flag);
+
+  this.isRaw = flag;
+  return this;
+};
+
+export { ReadStream };
+
 setReadStream(ReadStream);
 
 export { isatty, WriteStream };