Skip to content

Instantly share code, notes, and snippets.

@mitsuhiko
Last active May 19, 2026 23:12
Show Gist options
  • Select an option

  • Save mitsuhiko/6ab209de5f5662de283289e46daee0a9 to your computer and use it in GitHub Desktop.

Select an option

Save mitsuhiko/6ab209de5f5662de283289e46daee0a9 to your computer and use it in GitHub Desktop.
Plan for a tiny Node package that enables Windows ENABLE_VIRTUAL_TERMINAL_INPUT

Plan: tiny Node package for enabling Windows VT input

Goal

Publish one npm package that exposes a single function:

import { enableVirtualTerminalInput } from "enable-vt-input";

const ok = enableVirtualTerminalInput();

On Windows it enables ENABLE_VIRTUAL_TERMINAL_INPUT on STD_INPUT_HANDLE and returns true/false. On non-Windows platforms it should be a harmless no-op returning true, because VT-style input is already the terminal default and there is no Windows console mode to flip.

Package shape

enable-vt-input/
  package.json
  index.js
  index.d.ts
  prebuilds/
    win32-x64/enable-vt-input.node
    win32-arm64/enable-vt-input.node
    win32-ia32/enable-vt-input.node        # optional, only if still needed
  src/
    win32.c

index.js chooses the native addon only on Windows:

import { createRequire } from "node:module";

const require = createRequire(import.meta.url);

let binding = null;

if (process.platform === "win32") {
  const arch = process.arch;
  if (arch !== "x64" && arch !== "arm64" && arch !== "ia32") {
    throw new Error(`Unsupported Windows architecture: ${arch}`);
  }
  binding = require(`./prebuilds/win32-${arch}/enable-vt-input.node`);
}

export function enableVirtualTerminalInput() {
  if (process.platform !== "win32") return true;
  return binding.enableVirtualTerminalInput();
}

Exact native code we implemented

This is the no-CRT N-API addon source. It deliberately avoids Node headers, node.lib, and the MSVC CRT. It dynamically resolves the small set of N-API functions from the running Node executable or node.dll.

src/win32.c:

#include <windows.h>

#define ENABLE_VIRTUAL_TERMINAL_INPUT 0x0200
#define NAPI_AUTO_LENGTH ((unsigned long long)-1)

typedef void* napi_env;
typedef void* napi_value;
typedef void* napi_callback_info;
typedef napi_value (__cdecl *napi_callback)(napi_env, napi_callback_info);
typedef int (__cdecl *napi_create_function_fn)(napi_env, const char*, unsigned long long, napi_callback, void*, napi_value*);
typedef int (__cdecl *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value);
typedef int (__cdecl *napi_get_boolean_fn)(napi_env, int, napi_value*);

static void* node_symbol(const char* name) {
    HMODULE module = GetModuleHandleA(0);
    void* proc = module ? (void*)GetProcAddress(module, name) : 0;
    if (proc) return proc;

    module = GetModuleHandleA("node.dll");
    return module ? (void*)GetProcAddress(module, name) : 0;
}

static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) {
    (void)info;

    HANDLE handle = GetStdHandle(STD_INPUT_HANDLE);
    DWORD mode = 0;
    int enabled = handle != INVALID_HANDLE_VALUE &&
        GetConsoleMode(handle, &mode) &&
        SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_INPUT);

    napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
    napi_value result = 0;
    if (napi_get_boolean) napi_get_boolean(env, enabled, &result);
    return result;
}

__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) {
    napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function");
    napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property");

    napi_value fn = 0;
    if (napi_create_function &&
        napi_set_named_property &&
        napi_create_function(env, "enableVirtualTerminalInput", NAPI_AUTO_LENGTH, enable_virtual_terminal_input, 0, &fn) == 0) {
        napi_set_named_property(env, exports, "enableVirtualTerminalInput", fn);
    }

    return exports;
}

Exact compile command we used

From Git Bash/MSYS, calling MSVC through vcvars64.bat:

mkdir -p x
VS_PATH=$("/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath | tr -d '\r')
SRC_WIN=$(cygpath -w "C:/Users/armin/AppData/Local/Temp/pi-nocrt-foo.c")
OBJ_WIN=$(cygpath -w "C:/Users/armin/AppData/Local/Temp/pi-nocrt-foo.obj")
OUT_WIN=$(cygpath -w "$PWD/x/foo.node")
VCVARS="$VS_PATH\\VC\\Auxiliary\\Build\\vcvars64.bat"
MSYS2_ARG_CONV_EXCL='*' cmd.exe /C "call \"$VCVARS\" >nul && cl /nologo /c /O1 /GS- /TC \"$SRC_WIN\" /Fo:\"$OBJ_WIN\" && link /nologo /dll /noentry /nodefaultlib /incremental:no /opt:ref /opt:icf /out:\"$OUT_WIN\" \"$OBJ_WIN\" kernel32.lib"

For the package, replace foo.node with enable-vt-input.node and write the output into the matching prebuild directory, for example:

cl /nologo /c /O1 /GS- /TC src\win32.c /Fo:win32.obj

link /nologo /dll /noentry /nodefaultlib ^
  /incremental:no /opt:ref /opt:icf ^
  /out:prebuilds\win32-x64\enable-vt-input.node win32.obj kernel32.lib

Verification we ran

Load check:

node -e "const path = require('node:path'); const m = { exports: {} }; process.dlopen(m, path.resolve('x/foo.node')); console.log(Object.keys(m.exports)); console.log(typeof m.exports.enableVirtualTerminalInput); console.log(m.exports.enableVirtualTerminalInput());"

Observed output in this non-console harness:

[ 'enableVirtualTerminalInput' ]
function
false

false is expected when stdin is not a real Windows console. The important part is that the addon loads and returns a boolean.

Dependency check:

dumpbin /dependents x\foo.node

Observed output:

Image has the following dependencies:

  KERNEL32.dll

No VCRUNTIME*.dll, no MSVCP*.dll.

The no-CRT build was about 3 KB.

Native implementation strategy

Use N-API, not the V8 API. That makes the binary independent of the Node module ABI version and usable across Node versions.

For the Windows addon, avoid Node headers and avoid linking against node.lib by resolving N-API symbols from the running Node process:

  • GetModuleHandleA(0) / GetProcAddress(...)
  • fallback to GetModuleHandleA("node.dll")

Export only:

__declspec(dllexport) napi_value __cdecl napi_register_module_v1(...)

Platform behavior

Windows

Load prebuilds/win32-${process.arch}/enable-vt-input.node and call the native function.

Supported targets:

  • win32-x64
  • win32-arm64
  • optionally win32-ia32

macOS / Linux / BSD

Do not ship native binaries unless a future feature requires them. Export the same JS function as a no-op returning true.

Rationale: ENABLE_VIRTUAL_TERMINAL_INPUT is Windows-specific. POSIX terminals use termios/raw mode rather than this console flag. Node callers still need process.stdin.setRawMode(true) if they want raw key bytes.

Build and release workflow

Use GitHub Actions to build the Windows binaries and commit/package them into the npm tarball.

Matrix:

strategy:
  matrix:
    include:
      - os: windows-latest
        arch: x64
        vcvars: x64
      - os: windows-latest
        arch: arm64
        vcvars: amd64_arm64
      - os: windows-latest
        arch: ia32
        vcvars: x86

For each matrix job:

  1. Compile src/win32.c with MSVC.
  2. Link with /dll /noentry /nodefaultlib kernel32.lib.
  3. Run dumpbin /dependents and fail if VC runtime DLLs appear.
  4. Upload artifact under prebuilds/win32-${arch}/enable-vt-input.node.

A publish job downloads all artifacts, runs smoke tests, and publishes one npm package containing all prebuilt .node files.

Smoke tests

For Windows CI:

import { enableVirtualTerminalInput } from "./index.js";

if (typeof enableVirtualTerminalInput() !== "boolean") {
  throw new Error("expected boolean");
}

Also test explicit process.dlopen load of each produced .node where architecture matches the runner.

The return value may be false in non-console CI. That is fine. The important checks are:

  • the addon loads
  • the function exists
  • the function returns a boolean
  • dependency check shows no VC redist requirement

TypeScript API

export function enableVirtualTerminalInput(): boolean;

Keep the package API intentionally tiny.

Package metadata

Suggested package.json fields:

{
  "name": "enable-vt-input",
  "version": "0.1.0",
  "type": "module",
  "main": "./index.js",
  "types": "./index.d.ts",
  "files": [
    "index.js",
    "index.d.ts",
    "prebuilds/**/*.node"
  ],
  "exports": {
    ".": {
      "types": "./index.d.ts",
      "default": "./index.js"
    }
  }
}

Notes

  • Do not use node-gyp for consumers. Consumers should not compile anything.
  • Do not use optional platform packages unless package size becomes a problem.
  • N-API plus dynamic symbol lookup lets one binary per Windows architecture work across supported Node versions.
  • The .node can be extremely small: the no-CRT x64 build was about 3 KB and depended only on KERNEL32.dll.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment