unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
api-designbackwards-compatibilitytesting-strategy

tty: add raw-vt and io raw modes

Project: nodejs/node | Language: JavaScript, C++ | Concepts: api-design, backwards-compatibility, testing-strategy | Difficulty: mid
Link: https://github.com/nodejs/node/pull/64140

The problem

Node.js's tty.ReadStream#setRawMode() previously only supported a boolean to enable or disable "raw" mode, which did not distinguish between different low-level terminal modes. There was no way to explicitly request libuv's binary-safe I/O mode (UV_TTY_MODE_IO), and the API did not allow introspection of the exact current raw mode. This limited the ability to control or reason about terminal behavior, especially for advanced applications needing precise terminal handling.

Why it's non-trivial

A naive fix might just add another boolean or overload, but that would break compatibility or introduce ambiguity. The solution must:

  • Maintain existing behavior for setRawMode(true/false) for compatibility.
  • Allow explicit selection of different raw modes, matching libuv's capabilities.
  • Expose the current mode in a way that doesn't break existing consumers.
  • Properly validate arguments and handle unsupported modes.
  • Update documentation and tests to cover the new cases.

The approach

1. Extend the API to accept string modes and expose the current mode:

-ReadStream.prototype.setRawMode = function(flag) {
-  flag = !!flag;
-  const err = this._handle?.setRawMode(flag);
+ReadStream.prototype.setRawMode = function(mode) {
+  let rawMode;
+  if (mode === 'io' || mode === 'raw') {
+    rawMode = mode;
+  } else if (typeof mode === 'string') {
+    throw new ERR_INVALID_ARG_VALUE(
+      'mode', mode, "must be true, false, 'raw', or 'io'");
+  } else {
+    rawMode = mode ? 'raw' : false;
+  }
+  let ttyMode = UV_TTY_MODE_NORMAL;
+  if (rawMode === 'io') {
+    ttyMode = UV_TTY_MODE_IO;
+  } else if (rawMode === 'raw') {
+    ttyMode = UV_TTY_MODE_RAW_VT;
+  }
+  const err = this._handle?.setRawMode(ttyMode);
   if (err) {
     this.emit('error', new ErrnoException(err, 'setRawMode'));
     return this;
   }
-  this.isRaw = flag;
+  this.isRaw = rawMode !== false;
+  this.rawMode = rawMode;
   return this;
 };

This change allows setRawMode('raw') and setRawMode('io') as explicit string arguments, mapping to the appropriate libuv constants. It also introduces the rawMode property to reflect the current mode, while keeping isRaw as a boolean for compatibility.

2. Validate and reject unknown string modes:

+  if (mode === 'io' || mode === 'raw') {
+    rawMode = mode;
+  } else if (typeof mode === 'string') {
+    throw new ERR_INVALID_ARG_VALUE(
+      'mode', mode, "must be true, false, 'raw', or 'io'");
+  }

This ensures that only supported string modes are accepted, and unknown strings throw a clear error.

3. Expose the underlying libuv constants to JS:

+  NODE_DEFINE_CONSTANT(target, UV_TTY_MODE_NORMAL);
+  NODE_DEFINE_CONSTANT(target, UV_TTY_MODE_IO);
+  NODE_DEFINE_CONSTANT(target, UV_TTY_MODE_RAW_VT);

These lines make the C++ constants available to JS, so the mapping in JS code is robust and not hard-coded.

4. Update the C++ binding to accept the mode integer:

-  int err = uv_tty_set_mode(
-      &wrap->handle_,
-      args[0]->IsTrue() ? UV_TTY_MODE_RAW_VT : UV_TTY_MODE_NORMAL);
+  Environment* env = Environment::GetCurrent(args);
+  int mode;
+  if (!args[0]->Int32Value(env->context()).To(&mode)) return;
+  int err = uv_tty_set_mode(&wrap->handle_, static_cast<uv_tty_mode_t>(mode));

This change allows the JS layer to pass an explicit integer mode, supporting all libuv tty modes.

5. Document the new API and add a test:

+* `mode` {boolean|string} If `true` or `'raw'`, configures the
+  `tty.ReadStream` to operate as a raw device. If `'io'`, configures the
+  `tty.ReadStream` to operate in binary-safe I/O mode. If `false`, configures
+  the `tty.ReadStream` to operate in its default mode. The `readStream.isRaw`
+  property will be set to whether the stream is in raw mode, and the
+  `readStream.rawMode` property will be set to the resulting mode.

The documentation now reflects the new string options and the rawMode property.

+process.stdin.setRawMode('raw');
+assert.strictEqual(process.stdin.isRaw, true);
+assert.strictEqual(process.stdin.rawMode, 'raw');
+
+process.stdin.setRawMode('io');
+assert.strictEqual(process.stdin.isRaw, true);
+assert.strictEqual(process.stdin.rawMode, 'io');
+
+assert.throws(
+  () => process.stdin.setRawMode('raw-vt'),
+  {
+    code: 'ERR_INVALID_ARG_VALUE',
+    name: 'TypeError',
+  });

The new test covers all supported and unsupported modes, verifying the API and error handling.

From the review thread

trivikr: This PR needs to a rebase to resolve conflicts in doc/api/tty.md
samuel-williams-shopify: @trivikr done.


addaleax:
Not sure what's different on the Alpine runners, I guess if there's no obvious reason for this it might be fine to silence stderr for stty instead of specifying inherit there

samuel-williams-shopify:
Pushed a test-only update in 7af5c595 to capture stty stderr in test-set-raw-mode-modes instead of inheriting it. This should prevent Alpine's stty: standard input: Not a tty message from polluting the pseudo-tty expected-output comparison while still surfacing stderr if stty exits non-zero.

Takeaways

  • When extending an API, preserve existing behavior for legacy argument types to avoid breaking current users.
  • Explicitly validate and reject unsupported argument values to prevent silent bugs and improve developer experience.
  • When adding new modes or options, expose the current state in a way that is both backward-compatible (e.g., keep isRaw as a boolean) and forward-looking (e.g., add rawMode for richer introspection).
  • Update tests and documentation together with code changes to ensure new behaviors are covered and discoverable.