Inspiration
Over the past year, I’ve spent a lot of time playing around with Node.js native bindings for various projects. A YouTuber by the name of MattKC released a video where he ported .NET to Windows 95 , a platform it never supported. Towards the end of the video, he made a passing comment about how “it’s pretty trivial to write C# code that calls machine code functions” and showed this code snippet from MSDN:
using System;
using System.Runtime.InteropServices;
class Example
{
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int MessageBox(IntPtr hWnd, String text, String caption, uint type);
static void Main()
{
MessageBox(new IntPtr(0), "Hello, World!", "My Message Box", 0);
}
}This got me thinking about how I’ve seen something vaguely similar in Python with the ctypes library. I remember at one point having copied and pasted some code I found on some random forum that called the EnumWindows function from user32.dll but never really understood (nor cared to understand) how it worked. It looked vaguely similar to the C/C++ code you’d write for calling EnumWindows complete with all the archaic and illegible type names like WNDENUMPROC and HWND that made me feel like I was reading ancient hieroglyphics. You could understand why a QA Engineer fresh out of college and sitting at his first job wouldn’t want to deal with understanding any of that…
Back in the present day however, I started thinking to myself;
If I can call native functions from C# and Python, why wouldn’t I be able to do it from JavaScript and Node.js too?
The Journey Begins
Well after a brief Google search, I came across node-ffi and gave it a shot. I believe my first bit of code looked something like this:
const ffi = require("node-ffi");
const user32 = ffi.Library("user32", {
MessageBoxA: ["int32", ["int32", "string", "string", "int32"]],
});
user32.MessageBoxA(0, "Hello, World!", "My Message Box", 0);And it promptly failed dumping out a segmentation fault and a whole bunch of other nonsense I had no idea what to do with. Shortly thereafter, I realized that node-ffi was no longer being maintained and had largely been replaced by node-ffi-napi. I saw that it had been a few years since the last release, but I figured it was worth a shot. The API was the same, so I swapped out the import and success! I had just created a native Windows message box from Node.js!
My mind was racing with possibilities. I immediately started thinking about the difficulty we had at work with automating Win32 application with our Python based test suite and lackluster AutoIt Python bindings. We knew JavaScript test suites like Cypress and Playwright were far superior to whatever custom test suite we put together in Python, and we were more familiar with JavaScript than Python so I decided to explore the idea of writing my own bindings for AutoIt.
Since node-ffi-napi also seemed abandoned, I did a little digging and found node-ffi-rs; a Rust based FFI library for Node. The API seemed reasonable enough so I gave it a try:
const { load, open, DataType } = require("ffi-rs");
open("user32", "user32.dll");
function MessageBoxA(hWnd, text, caption, type) {
return load({
library: "user32",
name: "MessageBoxA",
retType: DataType.I64,
paramsType: [DataType.I64, DataType.String, DataType.String, DataType.I64],
paramsValue: [hWnd, text, caption, type],
});
}
MessageBoxA(0, "Hello from ffi-rs!", "My Message Box", 0);Success! Without much thought given to anything, I decided I would start creating bindings for AutoIt using node-ffi-rs. Over the next few weeks, I was able to get nearly all the functions implemented and working mostly correctly. I don’t remember much from this period of time, but I do remember that I would often get random crashes and segmentation faults that would leave me scratching my head for hours. The documentation for node-ffi-rs wasn’t the most detailed so I was often left not knowing how to proceed; it got so bad that I eventually had to abandon the project for some time. I got so desperate in fact, that I even went as far as try to learn C++ to create native bindings myself… Oh the horror!
After I made a decent amount of progress there, I hit another roadblock with the C++ variant when I tried to implement async functions. Being a sub-novice in C++, I had no idea how to do this so yet again, I gave up. But it wasn’t long after this that I finally found my saving grace; Koffi.
Koffi, like node-ffi, node-ffi-napi, and node-ffi-rs, is another FFI library for Node.js. However, what set Koffi apart from the others was that it had, what I thought, was a much cleaner and easier to use API. Taking the same MessageBoxA example from before, here’s how it looks in Koffi:
import koffi from "koffi";
const user32 = koffi.load("user32.dll");
const MessageBoxA = user32.func("__stdcall", "MessageBoxA", "int", [
"void *",
"str",
"str",
"uint",
]);
MessageBoxA(null, "Hello from Koffi!", "My Message Box", 0);I pretty much immediately fell in love with Koffi and started rewriting my existing bindings for AutoIt using it. Took some time, but I eventually had full feature parity with the node-ffi-rs version and then I was even able to implement the async variants of each function for version 2 of AutoIt JS with very minimal friction. Koffi was working so well in fact, that I was even able to fix some of the less reliable functions from AutoIt by re-implementing them myself. Tooltip, for example, was completely broken. It didn’t crash the process or anything, but it simply just didn’t work so after loading in some functions from user32.dll and kernel32.dll, I had a working implementation in no time. PixelSearch on the other hand wasn’t broken per se, but it would randomly crash the process and I could never quite figure out why, so I figured it would be best to just re-implement it myself. This one was significantly more difficult but after a lot of trial an error (and some assistance from ChatGPT to fix the shortcomings of the MSDN documentation), I had a working implementation. As a nice little bonus, it’s several orders of magnitude faster than the original AutoIt implementation, searching three 2560x1440 displays for a specific pixel in the lower right hand corner of the right most display in under 300 milliseconds; that’s 11,059,200 pixels being searched at around 36,864,000 pixels per second!
With the library complete and published, I turned my attention back to the Windows API.
A New Challenger Appears
Still riding the high of my success with Koffi and AutoIt JS, it was time I tackled what was likely to be my most ambitious project yet; Windows API bindings for Node.js. It started off quite well and I made a fair amount of progress in a short amount of time. I had focused on some of the easier to implement, sort of “one off” functions like MessageBoxA, MessageBoxW, GetWindowRect, etc. but then I decided to increase the difficulty; I was going to get a native Windows window launching using nothing but JavaScript. For the uninitiated, here’s what it takes to get a blank window that does absolutely nothing in C/C++:
#include <windows.h>
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
switch (uMsg) {
case WM_DESTROY:
PostQuitMessage(0);
return 0;
default:
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
}
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd) {
const char CLASS_NAME[] = "Sample Window Class";
WNDCLASS wc = {};
wc.lpfnWndProc = WindowProc;
wc.hInstance = hInstance;
wc.lpszClassName = CLASS_NAME;
RegisterClass(&wc);
HWND hwnd = CreateWindowEx(
0,
CLASS_NAME,
"Learn to Program Windows",
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
NULL,
NULL,
hInstance,
NULL
);
if (hwnd == NULL) {
return 0;
}
ShowWindow(hwnd, nShowCmd);
MSG msg = {};
while (GetMessage(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return 0;
}I won’t go into all the details here, because frankly I don’t even remember them, but this took me quite a while to get right due to my lack of knowledge about the Windows API and how to use it. After several months of on and off work, I finally had a window displayed on my screen that did absolutely nothing! It was a proud moment for me. Over the following few months, I continued implementing function after function until I had enough implemented to create a simple Notepad clone. The project was coming together quite nicely but I started hitting random road blocks; some that required weird workarounds, others that were caused by Koffi features made to make life easier which conversely made my life more difficult. For example, if I wanted to pass around strings as LPARAM’s, I had to implement custom conversion functions like so:
export function wideStringToLongParam(string: string): bigint {
const buffer = Buffer.from(string + "\0", "utf16le");
const size = buffer.length / 2;
const pointer = koffi.alloc(LPARAM, size);
koffi.encode(pointer, 0, LPARAM, buffer, size);
return koffi.address(pointer);
}
export function stringToLongParam(string: string): bigint {
// pretty sure this one was wrong and should've been "latin1" but hey,
// live and learn
const buffer = Buffer.from(string + "\0", "utf8");
const pointer = koffi.alloc(LPARAM, buffer.length);
koffi.encode(pointer, 0, LPARAM, buffer);
return koffi.address(pointer);
}I had to do a similar thing for int[]’s:
export function int32ArrayToLongParam(array: number[]): bigint {
const buffer = Buffer.alloc(array.length * 4);
for (let i = 0; i < array.length; i++) {
buffer.writeInt32LE(array[i], i * 4);
}
const pointer = koffi.alloc(LPARAM, buffer.length);
koffi.encode(pointer, 0, LPARAM, buffer, buffer.length);
return koffi.address(pointer);
}This wasn’t the end of the world or anything, but it did signal to me that I would end up having a lot of what would essentially amount to framework code that would make using the Windows API from Node.js less pleasant than it could be. I was aiming for as near a 1:1 mapping as possible, but I started to wonder if that would even be possible with Koffi. Another more annoying issue I ran into was with CharNextW. This function is supposed to return a pointer to the next character in a wide string, but Koffi returns the entire string after the pointer instead. I believe this is a feature in Koffi to make working with strings a bit easier which is great, but not what I need. The workaround for this is to just return the first character from the returned string but I don’t like the idea of having to write JavaScript code to work around shortcomings in the FFI library I’m using. It was at this stage that I got really enamored with C++ again and started thinking about writing my own native bindings for Node.js using the Node API.
After a few weeks of learning C++ by creating a Minesweeper clone using SDL3 , I finally felt ready to tackle the native Windows API bindings again.
Aside: C++, An Amazing Language But a Miserable Ecosystem
Not directly related, but I need to yap and vent about C++ for a bit.
At this point in my development experience, I’ve written a very small number of C++ applications and in general, I don’t like the ecosystem around it. Sorting through the various build systems, meta build systems, half-assed package managers, and pretty obtuse documentation is a nightmare for someone coming from higher level languages like TypeScript and Java. Love ’em ‘or hate ’em, npm and Gradle are godsends when you just need to install a god damn library and get to work. In C++ you gotta figure out if you want to use CMake, Meson, Bazel or something else, then you gotta figure out if you even can use vcpkg, Conan, CPM, or something else which is dependent on what libraries you need, and if they’re not available with one package manager, you either gotta use multiple package managers (good luck with that) or build the library from source yourself by either building the library and checking it into your repo or adding it as a git submodule and building it as part of your build process. It’s a nightmare. Especially when you’re just trying to get into the language.
That said, once you get past all that bulls… *ahem* nonsense, the language itself, while complex, is really quite pleasant to work with. There are a lot of rough edges and weirdly named standard library functions (why are std::unordered_map and std::unordered_set not the default??), but once you get the hang of it, it’s a really powerful language that gives you a lot of control over how your application works. Memory management can be a bit scary when you’ve only worked in garbage collected languages, but I find that using smart pointers takes a lot of the guess work out of it. One of my favorite features is templates which allow you to create generic functions and classes that sort of act like type safe macros and generic types in TypeScript. They’re a nightmare to debug sometimes, but when they work, they work really well.
Overall, I really like C++ and I think I’ll be working on many more projects with it in the future. You know, once I figure out how to deal with the ecosystem…
Back to the Windows API
Thankfully (or maybe unfortunately for some?), when working with Node native addons, you can leverage node-gyp which is a sort of meta build system that uses gyp under the hood to generate project files for various build systems and platforms. Though it’s not super well documented, ChatGPT was able to point me in the right direction at least which allowed me to get a working GYP file:
{
"$schema": "../../gyp.schema.json",
"targets": [
{
// Library name
"target_name": "user32",
"sources": [
// Recursively adds all .cpp files in the package.
"<!@(node ../../scripts/list-source-files.js)",
],
"include_dirs": [
// Include node-addon-api headers for N-API support.
"<!(node -p \"require('node-addon-api').include\")",
],
"dependencies": [
// Link against node-addon-api for N-API support.
// This shouldn't be necessary but I couldn't get it working without it.
"<!(node -p \"require('node-addon-api').targets\"):node_addon_api",
],
// General recommendation is to disable exceptions in C++ addons it seems.
"cflags": ["-fno-exceptions"],
"cflags_cc": ["-fno-exceptions"],
// MSVC specific settings to use C++20.
"msvs_settings": {
"VCCLCompilerTool": {
"AdditionalOptions": ["/Zc:__cplusplus", "/std:c++20"],
},
},
},
],
}This represents the bare minimum configuration needed to compile libwin but we can modify it per package as needed. For example, comctl32 needs an extra libraries field to link against the comctl32.lib library:
{
"$schema": "../../gyp.schema.json",
"targets": [
{
"target_name": "comctl32",
// ...
"libraries": ["-lcomctl32"],
// ...
},
],
}With this in place, Node GYP can find your compiler, build the necessary configuration file for it, and build our project with a simple set of commands:
node-gyp clean
node-gyp configure
node-gyp build -j max
node-gyp rebuild -j maxAs much flac as Node GYP gets, it at least works so no complaints from me. The only real annoyance I had with it was a lack of IntelliSense support for it so I went ahead and created a JSON schema for it . Technically, .gyp files are supposed to be in Python dictionary format, but it doesn’t seem to have an issue loading JSON so whatever.
First Functions
With the build system in place, I then started to code the first functions. I started with MessageBoxA and MessageBoxW since they’re easy to implement and test. Here’s what my first implementation looked like:
// user32.hpp
Napi::Value MessageBoxW(const Napi::CallbackInfo& info);
Napi::Value MessageBoxA(const Napi::CallbackInfo& info);// message_box.cpp
#include "user32.hpp"
Napi::Value MessageBoxW(const Napi::CallbackInfo& info) {
const HWND hWnd = reinterpret_cast<HWND>(info[0].As<Napi::BigInt>().Uint64Value());
const LPCWSTR text = reinterpret_cast<LPCWSTR>(info[1].As<Napi::String>().Utf16Value().c_str());
const LPCWSTR caption = reinterpret_cast<LPCWSTR>(info[2].As<Napi::String>().Utf16Value().c_str());
const UINT type = info[3].As<Napi::Number>().Uint32Value();
const int result = MessageBoxW(hWnd, text, caption, type);
return Napi::Number::New(info.Env(), result);
}
Napi::Value MessageBoxA(const Napi::CallbackInfo& info) {
const HWND hWnd = reinterpret_cast<HWND>(info[0].As<Napi::BigInt>().Uint64Value());
const LPCSTR text = info[1].As<Napi::String>().Utf8Value().c_str();
const LPCSTR caption = info[2].As<Napi::String>().Utf8Value().c_str();
const UINT type = info[3].As<Napi::Number>().Uint32Value();
const int result = MessageBoxA(hWnd, text, caption, type);
return Napi::Number::New(info.Env(), result);
}A little symbol-y for my tastes, but after a quick compile, I had a .node file ready for use! Then I discovered that you can’t load Node addons with import statements, cause of course you can’t. Why would you? Anyway, I had no plans to convert the project to CJS so I did some research and came across createRequire from the node:module package which allows you to load CommonJS modules from ESM modules. With that, I was able to load the addon and call the functions like so:
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { MessageBoxW, MessageBoxA } = require("../build/user32.node");
MessageBoxW(0n, "Hello, World!", "My Message Box", 0);Success! There was of course some extra fiddling around that needed to be done, but this was the general process for implementing each function. However, after about a dozen or so functions, my Java poisoned brain started whispering horrible, horrible ideas to me…
“Abstraction. Readability. Reusability. Don’t Repeat Yourself. Keep it simple stupid.”
While my code worked, it was a bit too verbose and idealistic with no real error handling or anything. If something went wrong, you got a segfault, an illegible (to my web poisoned brain) stack trace, and no real indication of why the process just crashed. Similarly, I didn’t like having to repeat the same patterns over an over again for each function so I set out to make a library for parsing arguments and handling some basic errors; this library came to be known as QuickBind .
QuickBind
QuickBind needed to do a number of things:
- Provide a simple API for reading JavaScript arguments and converting them to C++ types.
- Handle TypeError’s when the arguments passed to a function are of the wrong type.
- Allow for optional arguments and error handling when required arguments are missing.
- Provide a simple API for converting C++ types back to JavaScript values.
- Do all this while providing usable error messages when something goes wrong.
The end result was this, only replicated a few dozen times for each type I may need to read:
#define QB_ARG(variable, expression)
auto variable = expression;
if (info.Env().IsExceptionPending()) {
return info.Env().Undefined();
}
#define QB_CHECK_NULLISH(value, required, prefix, location)
do {
if (value.IsNull() || value.IsUndefined()) {
if (required) {
qb::detail::ThrowTypeError(value.Env(), prefix, location);
}
return std::nullopt;
}
} while (0);
namespace qb {
namespace detail {
inline constexpr std::string_view EXPECTED_NUMBER = "Expected a Number ";
struct Location {
std::string in, where;
Location(std::string_view in_, std::string_view where_) : in(in_), where(where_) {}
};
struct Argument : qb::detail::Location {
explicit Argument(uint16_t index) : qb::detail::Location(AT_INDEX, std::to_string(index)) {}
};
struct Property : qb::detail::Location {
explicit Property(std::string_view key) : qb::detail::Location(FOR_PROPERTY, key) {}
};
struct ArrayIndex : qb::detail::Location {
explicit ArrayIndex(uint16_t index)
: qb::detail::Location(std::string(IN_ARRAY_AT_INDEX), std::to_string(index)) {}
};
inline void ThrowTypeError(Napi::Env env, std::string_view prefix, const qb::detail::Location &location) {
Napi::TypeError::New(env, std::string(prefix) + location.in + location.where).ThrowAsJavaScriptException();
}
[[nodiscard]] std::optional<uint32_t> inline ReadUint32(const Napi::Value &value,
const qb::detail::Location &location,
const bool required) {
QB_CHECK_NULLISH(value, required, qb::detail::EXPECTED_NUMBER, location);
if (!value.IsNumber()) {
qb::detail::ThrowTypeError(value.Env(), qb::detail::EXPECTED_NUMBER, location);
return std::nullopt;
}
const uint32_t uint32Value = value.As<Napi::Number>().Uint32Value();
return uint32Value;
};
}
[[nodiscard]] inline uint32_t ReadRequiredUint32(const Napi::CallbackInfo &info, const uint16_t index) {
return qb::detail::ReadUint32(info[index], qb::detail::Argument(index), true).value_or(0);
};
[[nodiscard]] inline uint32_t ReadRequiredUint32(const Napi::Object &object, const std::string &key) {
return qb::detail::ReadUint32(object.Get(key), qb::detail::Property(key), true).value_or(0);
};
[[nodiscard]] inline std::optional<uint32_t> ReadOptionalUint32(const Napi::CallbackInfo &info,
const uint16_t index) {
return qb::detail::ReadUint32(info[index], qb::detail::Argument(index), false);
};
[[nodiscard]] inline std::optional<uint32_t> ReadOptionalUint32(const Napi::Object &object, const std::string &key) {
return qb::detail::ReadUint32(object.Get(key), qb::detail::Property(key), false);
};
}Reading Values
That’s a lot of code for just reading uint32_t’s so let’s break it down piece by piece.
I wanted to be able to read both function arguments using indexes and object properties using keys so I created two separate overloads for each type where one takes the Napi::CallbackInfo and an index, and the other takes a Napi::Object and a key. Both of these overloads call a shared internal function that does the actual reading and error handling. This way, I can keep all the error handling logic in one place and just call the appropriate overload depending on whether I’m reading from an array of arguments or an object. Simple.
inline uint32_t ReadRequiredUint32(const Napi::CallbackInfo &info, const uint16_t index);
inline uint32_t ReadRequiredUint32(const Napi::Object &object, const std::string &key);From there, I also wanted to handle optional arguments without needing to pass true or false at the call site so I created the ReadOptional functions that pass false while the ReadRequired functions pass true. Pulled this one straight out of the Clean Code playbook. I know… Ew… But what’s special about these functions is that they return std::optional’s which allows the caller to easily check if the value was present or not:
const std::optional<uint32_t> someValue = ReadOptionalUint32(info, 0);
SomeFunction(someValue ? someValue.value() : 69);Shared Implementation
Whether you’re reading a required function argument or optional object property, the process of reading a JavaScript Number and converting it to a C++ uint32_t is the same. So in the qb::detail namespace, I created the standard reader function:
[[nodiscard]] std::optional<uint32_t> inline ReadUint32(const Napi::Value &value,
const qb::detail::Location &location,
const bool required);It takes the actual value to read, a Location struct that contains information about where the value is being read from (more on that later), and a boolean indicating whether the value is required or optional. The actual implementation does what you’d expect; check if the value is nullish (null or undefined) and throw a TypeError if it’s required, check if the value is the expected type (Number in this case) and throw a TypeError if it’s not, and finally convert the value to a uint32_t and return it wrapped in a std::optional.
Error Handling
Finally, we need to handle errors in cases where the user passes the wrong type, or passes a nullish value when a value is required. For this, I created a ThrowTypeError function that takes in a Location struct which contains information about where the error occurred (e.g. “at argument 0”, “for property ‘foo’”, etc.) and constructs a helpful error message to throw as a JavaScript exception:
inline void ThrowTypeError(Napi::Env env,
std::string_view prefix,
const qb::detail::Location &location) {
Napi::TypeError::New(env, std::string(prefix) + location.in + location.where).ThrowAsJavaScriptException();
}It’s really just a glorified string concatenation function, but it standardizes the error messages thrown by QuickBind which is nice. One gotcha though is something that I didn’t realize until much later and that’s that if you throw a JavaScript exception from a C++ function, you should immediately return from the function. If you don’t, and you continue to use the Callback::Info object, the process will hard crash. This of course manifests in a giant stack trace that isn’t particularly helpful so this needed to be resolved quickly. Now since there’s no way to “stop” execution of some parent calling function in C++, at least none that I’m aware of, and I certianly didn’t want to copy/paste:
if (info.Env().IsExceptionPending()) {
return info.Env().Undefined();
}after every call to a QuickBind function, I settled on creating a QB_ARG macro that would both read the value and check for pending exceptions in one go:
Napi::Value Add(const Napi::CallbackInfo& info) {
const QB_ARG(value1, qb::ReadRequiredUint32(info, 0));
const QB_ARG(value2, qb::ReadRequiredUint32(info, 1));
return Napi::Number::New(info.Env(), value1 + value2);
}This code then expands to:
Napi::Value Add(const Napi::CallbackInfo& info) {
const auto value1 = qb::ReadRequiredUint32(info, 0);
if (info.Env().IsExceptionPending()) {
return info.Env().Undefined();
}
const auto value2 = qb::ReadRequiredUint32(info, 1);
if (info.Env().IsExceptionPending()) {
return info.Env().Undefined();
}
return Napi::Number::New(info.Env(), value1 + value2);
}Hardly an improvement for one variable, but when you have 5+ arguments to read, as you usually do with Windows API functions, it saves a lot of boilerplate code. Thankfully we’ve got the preprocessor for this exact reason!
Putting It All Together
Now that we’ve sorted out all the finer points of reading uint32_t’s, we repeat that process about 1,000 times and nowe we can read most values from JavaScript and use them in our C++ code! Going back to our MessageBox_ functions, we can now rewrite them using QuickBind like so:
Napi::Value User32::MessageBoxW(const Napi::CallbackInfo &info) {
const QB_ARG(hWnd, qb::ReadOptionalHandle<HWND>(info, 0));
const QB_ARG(lpText, qb::ReadOptionalWideString(info, 1));
const QB_ARG(lpCaption, qb::ReadOptionalWideString(info, 2));
const QB_ARG(uType, qb::ReadRequiredUint32(info, 3));
const int result = ::MessageBoxW(hWnd ? hWnd.value() : nullptr,
lpText ? lpText->c_str() : nullptr,
lpCaption ? lpCaption->c_str() : nullptr,
uType);
return Napi::Number::New(info.Env(), result);
}
Napi::Value User32::MessageBoxA(const Napi::CallbackInfo &info) {
const QB_ARG(hWnd, qb::ReadOptionalHandle<HWND>(info, 0));
const QB_ARG(lpText, qb::ReadOptionalString(info, 1));
const QB_ARG(lpCaption, qb::ReadOptionalString(info, 2));
const QB_ARG(uType, qb::ReadRequiredUint32(info, 3));
const int result = ::MessageBoxA(hWnd ? hWnd.value() : nullptr,
lpText ? lpText->c_str() : nullptr,
lpCaption ? lpCaption->c_str() : nullptr,
uType);
return Napi::Number::New(info.Env(), result);
}This is much cleaner, easier to read, and significantly safer than our prior implementation. It kind of reads like JavaScript at this point which is a nice bonus, for me anyway. There’s still some ongoing work to read values out of an array and I’m sure there are some other edge cases I’ve missed, but I’m sure I’ll resolve that in time. One thing I think I may have goofed on is the API itself. Though it allows me to be very specific (ReadUint32, ReadInt64, etc.), I’ve so far found that I often times need to cast them to Windows types like DWORD, LONG, etc. which is really just a static_cast but kind of annoying. I think if I were to redo QuickBind, and I might, instead of having the lower level types like uint32_t and int64_t, I would just have the functions return the Windows types directly like ReadRequiredDWORD, ReadRequiredLPARAM, etc. The Java voice in my head pushed me to make it super generic so that, in theory, I could reuse this library for other projects but now all I’m hearing is:
“YAGNI. You Ain’t Gonna Need It. Don’t over-engineer things. Keep it simple stupid.”
Oh well, that’s a problem for future Kasim to deal with.
Namespacing and Organization
If you were paying attention to that last bit there, you will have noticed that my MessageBox_ functions were in a User32 namespace. I don’t remember exactly when I decided to do this, or what triggered it, but I do know that at least some of my binding functions had name collisions with their Windows counterparts. I say some because I didn’t have this namespacing setup from the begining yet I was still able to implement a number of the functions without any issues. This wasn’t a big deal however, just a matter of adding a namespace to the function declarations and definitions and updating the exports.
Exporting Functions
This is another place where I decided to do something a little slick. Normally, to export a function using the Node API, you would do something like this:
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set(Napi::String::New(env, "MessageBoxW"), Napi::Function::New(env, MessageBoxW, "MessageBoxW"));
exports.Set(Napi::String::New(env, "MessageBoxA"), Napi::Function::New(env, MessageBoxA, "MessageBoxA"));
return exports;
}
NODE_API_MODULE(user32, Init)This is fine and all, but I hate the repetition of the function name. You need to pass a string for the property name, a function pointer for the value, and then a string for the function name which is used in stack traces and whatnot. I didn’t even bother waiting around for an issue to crop up regarding mismatched names so I set out to fix it right away. Here’s what I came up with:
#define QB_EXPORT(function)
do {
constexpr std::string_view name = qb::detail::UnqualifiedName(#function);
exports.Set(Napi::String::New(env, name.data(), name.size()),
Napi::Function::New(env, function, std::string(name)));
} while (0);
namespace qb {
namespace detail {
consteval std::string_view UnqualifiedName(const std::string_view name) {
const size_t pos = name.rfind("::");
return pos == std::string_view::npos ? name : name.substr(pos + 2);
}
}
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
QB_EXPORT(User32::MessageBoxW);
QB_EXPORT(User32::MessageBoxA);
return exports;
}This was tricky to get right but the end result is a much cleaner export process that also eliminates the possibility of mismatched function names between the property name, function pointer, and stack traces. The QB_EXPORT macro takes in a function pointer, extracts the unqualified name from it (i.e. removes any namespace qualifiers), and then uses that name for both the property name and the function name in the Napi::Function::New call. It’s a small change but it makes the code much cleaner and I’m sure will make the code less error prone in the long run. Best part about it is that there’s no runtime overhead for this since all the string manipulation is done at compile time with consteval functions and std::string_view. It’s a neat little trick that I’m pretty proud of actually!
Conclusion
This has been a long journey so far and the end is still nowhere in sight. I’ve implemented a few dozen functions so far, but have well over 10,000 to go so there’s still a lot of work to be done. I’m hoping all this groundwork I’ve done with QuickBind will make the process of creating bindings for all the other functions much easier but only time will tell. If you want to help out, feel free to check out the GitHub repo and open a PR!
Sometime this year, I hope to release some sort of alpha version of the library with at least a few hundred (or maybe even a few thousand if I get some help 😉) functions implemented so that people can start using it and provide feedback. In the meantime, I’ll just keep chugging along implementing functions and improving QuickBind as I go. It’s been a fun and rewarding experience so far and I’m excited to see where this project goes in the future!
Postscript
If you’ve made it this far, thanks for reading! I know this was a bit of a ramble and I probably missed some important details here and there, but I hope it was at least somewhat informative and entertaining. If you have any questions or feedback, feel free to reach out to me on Twitter or open an issue on GitHub. I’m always happy to chat about this stuff!