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.
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();
}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;
}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.libLoad 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.nodeObserved output:
Image has the following dependencies:
KERNEL32.dll
No VCRUNTIME*.dll, no MSVCP*.dll.
The no-CRT build was about 3 KB.
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(...)Load prebuilds/win32-${process.arch}/enable-vt-input.node and call the native function.
Supported targets:
win32-x64win32-arm64- optionally
win32-ia32
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.
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: x86For each matrix job:
- Compile
src/win32.cwith MSVC. - Link with
/dll /noentry /nodefaultlib kernel32.lib. - Run
dumpbin /dependentsand fail if VC runtime DLLs appear. - 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.
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
export function enableVirtualTerminalInput(): boolean;Keep the package API intentionally tiny.
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"
}
}
}- Do not use
node-gypfor 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
.nodecan be extremely small: the no-CRT x64 build was about 3 KB and depended only onKERNEL32.dll.