Introduction
Welcome to the second installment of our deep dive into native Node.js modules! If you’ve been following along, you’ve already learned the basics of what native modules are, how they work, and why they are such a vital part of the Node.js ecosystem. If you missed it, you might want to check out the first post in the series: "Unlocking the Power of Native Modules in JavaScript: An Introductory Guide"
Today, we’re rolling up our sleeves to get into the nitty-gritty: building a real-world native module from scratch. But we’re not just going to build any module — we’re creating a "Fast Image Resizer"
using C++
and integrating it into a Node.js
application. The primary objective here is to offer a hands-on demonstration of the performance benefits that native modules can bring to computationally intensive tasks.
Now, you might be asking, "Why image resizing?"
The answer lies in its practical applicability and the intense computation it demands, making it a perfect case study for native modules. JavaScript alone can be painfully slow for such operations, giving us an excellent backdrop against which to showcase the raw speed of a native module. By the end of this post, you’ll not only have a fully functional image resizer but also a robust understanding of node-gyp, the build tool that turns our C++ code into a native Node.js module.
Here’s what we’ll cover:
- Setting up your development environment to use node-gyp.
- Writing the C++ code that will form the core of our Fast Image Resizer.
- Crafting the essential
binding.gyp
file, the blueprint for our native module. - Building the module and integrating it into a Node.js application.
- Troubleshooting and overcoming common challenges.
So grab a cup of coffee, fire up your code editor, and let’s get building!
Setting the Stage: Tools and Environment Setup 🛠️
Before diving into the code, it’s essential to ensure that our development environment is well-equipped with the tools we’ll be using throughout this hands-on tutorial. The right tools not only make the development process smoother but also allow us to focus more on coding and less on fixing environment-related issues. Here, we’ll cover how to set up everything you need, based on the operating system you’re using.
The Tools You’ll Need 🛠️
Here’s a list of tools that you’ll require for developing native Node.js modules:
Node.js: To run your JavaScript code.
npm: For package management and distribution.
C/C++ Development Tools: For writing native code.
Python: Required by node-gyp.
Node and npm
Ensure you have Node.js and npm installed on your machine. If you haven’t, download the appropriate installer from Node.js website. It’s recommended to use the LTS (Long Term Support) version for stability. The installer will also include npm.
I will actually be using pnpm
as a personal preference. You can use npm
or yarn
as well. (Or even bun 🍞 if you are feeling adventurous!)
- C/C++ Development Tools and Python
The requirements for C/C++ development tools and Python differ based on your operating system:
Windows: Use npm to install
windows-build-tools
which includes the required C/C++ compilers and Python.Mac: Use
xcode-select
to install the necessary C/C++ tools.Python usually comes pre-installed on macOS.
Linux: The required C/C++ and Python tools are generally pre-installed. If not, consult your distribution’s package manager for installation.
- Other Tools
You’ll also need a shell program and a code editor of your choice. macOS and Linux generally come with a shell pre-installed. For Windows, you might consider using PowerShell.
Initializing Your Project 🚀
Create a new directory for your project and initialize it with pnpm:
This action will generate a package.json
file, your project’s manifest.
Setting Up node-gyp 🛠️🔧
Before we dive into the actual setup, it’s crucial to understand what node-gyp is and why it’s an essential tool for native Node.js module development. Node-gyp is a build automation tool used to compile native addon modules for Node.js. It provides a cross-platform interface for native module compilation and takes care of the heavy lifting around configuring the build environment for various operating systems. Node-gyp uses binding.gyp
files written in JSON format to describe the configuration for building your native addons.
Now that you have an idea of what node-gyp is, let’s get it set up so we can proceed with creating a native module.
Installation 💾
Installing node-gyp is straightforward. You can either install it globally on your machine or as a development dependency in your specific project.
Verifying Installation ✅
To ensure that node-gyp is correctly installed, run the following command:
If you see the version number displayed, you are good to go.
That’s it for the setup! You’re now well-equipped to tackle native Node.js module development.
Your First Native Module: Hello, World! 🌍
Before we start with the actual implementation, let’s create a basic "Hello, World!"
native module to get a taste of the process. This simple example will demonstrate the basics of setting up, compiling, and importing a native module.
The C++ Code 📝
Create a file named hello.cc
and add the following C++ code:
The Binding Configuration 📐
Create a binding.gyp
file with the following content:
Compilation 🔨
Navigate to your project folder and run:
If everything goes as planned, you should see a build/
directory containing your compiled native module.
Using the Module in Node.js 📦
Finally, let’s use this native module in a Node.js script. Create a test.js
file with the following content:
Run the script:
You should see "Hello, world from C++!"
printed to the console. Congratulations, you’ve just created and used your first native Node.js module!
Setting Up Language Server Protocol (LSP) 🧝
When developing native modules, it’s extremely beneficial to have an IDE or text editor that understands C/C++ for features like IntelliSense, refactoring, and error checking. LSP can help with this, and luckily, we can generate a compile_commands.json
file to make this happen. Below is a helper script that can automate this process for you. This script is adapted from a GitHub issue discussion.
First, create a script named lsp.sh
and paste the following code into it:
Make sure to give execute permission to the script:
Now, add the following line to your package.json
under the “scripts” section:
This way, the lsp.sh
script will run automatically when you execute npm run build
or pnpm run build
.
With this setup, you can enjoy a more interactive and responsive development environment while working on your native Node.js modules. Feel free to integrate this with your favorite text editor or IDE that supports LSP for C/C++.
This can be a boon for your productivity, especially if you’re already accustomed to such features while working in TypeScript or Rust.
This LSP setup will provide you with rich coding features such as autocompletion, go to definition, and real-time error checking, making your development process much smoother. 🌟
The Complexity of Direct Node API 😵💫
The Direct Node API, often referred to as Node-API or N-API, is robust and extremely flexible but can be verbose and intricate. It exposes a set of primitive APIs that deal directly with JavaScript objects and values. Though it allows for high customization and deep integration, it requires you to handle several underlying complexities like object lifetimes, scopes, and type conversions manually. This can be cumbersome for those accustomed to more abstracted or user-friendly interfaces, especially when you just want to get a project off the ground.
For example, the previous allegedly simple "Hello, World!"
example using the Direct Node API would look like this:
As you can see, even for a simple "Hello, World!"
, the code has a fair bit of boilerplate.
Why I Prefer node-addon-api 🌟
For better usability, we will use node-addon-api, an abstraction layer on top of the Direct Node API. It provides an object-oriented C++ API, which considerably simplifies the complexity and makes it easier to manage. It’s essentially the C++ sugar on top of the Direct Node API’s C-based interface.
To give you a contrast, let’s rewrite our "Hello, World!"
example using node-addon-api
:
As you can see, this version of "Hello, World!"
is cleaner and more straightforward. This is why we’ll proceed with node-addon-api
for our deeper exploration and for building our example application. With its simplified API and user-friendly features, node-addon-api
is an excellent choice for developers looking to create native Node.js addons without diving deep into the intricacies of the Direct Node API.
Required Packages and Adapted binding.gyp
📦
Before diving into the code, let’s first install the necessary package for node-addon-api
. Run the following command in your project directory:
Adapted binding.gyp
🛠️
You’ll also need to modify your binding.gyp
file to include node-addon-api
’s header files. Here’s how your binding.gyp
should look:
There is always cases of platform specific configurations like the following, but I wont be covering them here:
Understanding the <!@()
and <!()
Syntax in binding.gyp
🔍
In your binding.gyp
file, you might have noticed some unfamiliar syntax: <!@()
and <!()
. These are special directives that help with dynamic evaluation within the .gyp
files. Let’s break down what each does:
<!()
: This directive allows you to execute a shell command. The output of this command is then captured and used in the.gyp
file.For instance,
<!(node -p "require('node-addon-api').gyp")
executes the node command that fetches the path to the.gyp
file of thenode-addon-api
package.<!@()
: Similar to the previous one, this directive also executes a shell command. However, the output is tokenized by whitespace, allowing you to capture multiple output entries.In
<!@(node -p "require('node-addon-api').include")
, the command fetches the include directories fornode-addon-api
, and because we use<!@()
, multiple directories would be handled correctly if present.
Understanding these directives gives you the flexibility to perform more complex operations dynamically, right within your binding.gyp
configuration.
Re-Structuring the File Structure for Better Organization 🗂️
Now that we’ve got a basic "Hello World"
example up and running, let’s go a step further and reorganize our project structure for better maintainability and scalability.
Target Structure
./addon/src
: This is where all your C++.cc
files will reside../addon/include
: This folder will contain all your header.hpp
files../lib/binding.js
: A JavaScript wrapper around the native node bindings../tests
: This will contain all your test files.
Resulting in the following tree:
Updated binding.gyp
To facilitate this restructured file hierarchy, we’ll need to modify our binding.gyp
file. We’ll configure it to automatically detect all .cc
and .hpp
files in their respective directories. Here is how to do it:
Here, the find
shell command recursively looks for .cc
and .hpp
files within ./addon/src
and ./addon/include
, respectively. This way, as you add more source and header files to these folders, you won’t need to manually update binding.gyp
.
JavaScript Wrapper (./lib/binding.ts
)
Having a JavaScript wrapper around your native module can provide a cleaner and more JavaScript-friendly API. You can simply require your native addon and wrap its functionalities in this file.
Test Directory (./tests
) 🧪
To ensure the reliability of our native module, it’s essential to include automated tests. We’ll be using the Node.js Test API for this purpose.
First, add the tsx
library as a dev dependency, as we’ll use it to transpile and run our TypeScript test files.
Next, update your package.json
file to include the following test command:
Btw, I just moved lsp.sh to a
scripts
directory.
This command will transpile and execute all test files located in the tests/
directory.
Now, let’s write our test using the Node.js Test API. Create a TypeScript file in your ./tests
directory with the following code:
This test uses the describe
and it
functions from the Node.js Test API along with the assert
module to ensure that the helloWorld
function behaves as expected.
To run the tests, execute the following command from the root of your project:
The tsx --test tests/*
command will take care of transpiling the TypeScript files and running the tests. Ensure that all tests pass to verify the functionality of your native module.
Profit 📈.
Finalizing the Implementation: Image Resizing with OpenCV 🖼️🛠️
Having set up the fundamental aspects of our environment, we’re ready to tackle a more intricate task — building a native module to resize images. In this endeavor, we will employ the powerful OpenCV C++ library.
Installing OpenCV Dependency
The steps to install OpenCV may vary depending on your operating system:
Ubuntu/Linux
Note: For Arch users, installing hdf5
and vtk
may also be necessary.
macOS
- Windows Download and install from OpenCV’s website.
Update binding.gyp
Next, tweak your binding.gyp file to accommodate OpenCV headers and libraries. Take note that if you encounter issues with pkg-config --cflags
, specifying the full path to the headers might resolve the issue.
C++ Code
Start by declaring new functions in addon/include/fast-image-resizer/resize.hpp
. The Napi::Value get_file_dimensions(const Napi::CallbackInfo &info)
function will serve as a helper for testing, negating the need to worry about its JavaScript implementation.
Then, proceed to flesh out the logic in addon/src/fast-image-resizer/resize.cc
. First, we import the required headers, followed by parameter validation based on the API we’re targeting.
The API we will create expects an input path and dimensions for width and height.
The resize operation will happen in-place — no bells and whistles. If you’re looking to extend this functionality, the ball’s in your court, reader 🤠. The first order of business is to validate the number and types of arguments passed in.
You could consider a more elegant JavaScript API that takes in an object instead of individual parameters.
After validating the basics, the next step is to extract the parameters from the info object and ensure they’re correct — like confirming the file path actually exists or that the dimensions are positive numbers.
Finally, we get to the part where the actual resizing happens. This is a straightforward operation, thanks to OpenCV.
Here’s the full code for the resize
function for your reference.
In addition to resizing, it’s often necessary to gather information about an image, like its dimensions. Let’s explore how this could be implemented. For the dimensions, the approach is quite similar to the resizing logic. We use OpenCV to read the image and then extract its width and height.
Here is the full code for the getFileDimensions
function:
Now, we just need to export this functions in addon/src/fast-image-resizer/exports.cc
:
JavaScript Wrapper
Reflect these changes in the JavaScript wrapper to expose the new functionalities:
Update Tests
I just got lazy and generate a random image through convert
with convert -size 200x200 xc:skyblue dummy.png
Now in ./tests/resize.test.ts
I added a test to check if the original image was not 100x100 and if the resized image is 100x100:
Conclusion: The Power of Simplicity and What’s Next 🎉🔮
We’ve covered quite a bit of ground in this blog post. Starting from setting up your development environment to diving into the nitty-gritty details of native modules in Node.js, it’s been a rewarding journey. Let’s recap:
Tools and Environment Setup
: We discussed the tools needed and initialized the project.Setting Up node-gyp
: We went through installing and verifying node-gyp for building native addons.First Native Module
: Introduced you to the world of C++ for Node.js through a simple"Hello, World!"
example.Language Server Protocol
: Talked about the benefits of setting up LSP for a smoother development experience.Direct Node API vs node-addon-api
: We discussed why node-addon-api is the preferred way to interact with the Node.js runtime.Required Packages and Configuration
: Walked through additional setup steps, including editing binding.gyp.Project Structure
: We organized our files for better readability and maintainability.Final Implementation with OpenCV
: Demonstrated how to integrate OpenCV for image resizing.
Surprisingly, it’s not that complex to extend Node.js with native modules, especially when you’re backed by the right tools and community support.
A Glimpse into the Future: Crafting Your Own Framework 🛠️🔮
In our next blog post, “Crafting Your Own Framework: A Masterclass on node-addon-api”, I’ll introduce you to a more ergonomic way to create native modules in C++. Imagine having better tooling to manage native modules, their dependencies, and handling the packaging efficiently — sounds exciting, right?
Packaging Native Modules 📦
Packaging native modules is a topic that’s often glossed over. It’s crucial to manage dependencies and ensure that the compiled binary can be easily distributed and consumed. We’ll delve into these intricate details and show you how to package your native modules like a pro in our upcoming post.
Share Your Thoughts! 💭
We’ve covered a lot, and your opinion matters. What did you find most helpful? Are there areas you’d like more clarity on? Feel free to share your thoughts, questions, and suggestions in the comments section below. Your feedback helps make this a more comprehensive resource for everyone involved. Looking forward to hearing from you! 🌟
So, stay tuned! This journey is far from over, and the road ahead is filled with opportunities to refine and expand your skill set. See you in the next blog post!