Writing Node.js addons with .NET Native AOT - .NET Blog
Skip to main content
Dev Blogs
AI
All .NET posts
.NET MAUI<br>ASP.NET Core<br>Blazor<br>Entity Framework
C++<br>C#<br>F#<br>TypeScript
NuGet<br>Servicing<br>.NET Blog in Chinese
Microsoft for Developers<br>Agent Framework<br>Develop from the cloud<br>Xcode<br>ISE Developer<br>TypeScript<br>PowerShell<br>Python<br>Java<br>Java Blog in Chinese<br>Go<br>Microsoft Edge Dev<br>Microsoft 365 Developer<br>Microsoft Entra Identity Developer<br>Microsoft Entra PowerShell
Visual Studio<br>Visual Studio Code<br>Aspire
All things Azure<br>Azure SDK<br>Azure VM Runtime Team<br>Microsoft Azure<br>Azure Cosmos DB<br>Azure DocumentDB<br>Azure Data Studio<br>Azure SQL<br>DevOps<br>DirectX<br>Microsoft Foundry<br>Power Platform
OData<br>Unified Data Model (IDEAs)
Windows Command Line<br>#ifdef Windows<br>Inside MSIX<br>MIDI and music<br>React Native<br>The Old New Thing<br>Windows Developer
On Demand
Missed .NET Day on Agentic Modernization? Watch the coding demos and agentic workflow sessions on demand.
Watch now
Drew Noakes
Principal Software Engineer
C# Dev Kit is a VS Code extension. Like all VS Code extensions, its front end is TypeScript running in Node.js. For certain platform-specific tasks, such as reading the Windows Registry, we’ve historically used native Node.js addons written in C++, which are compiled via node-gyp during installation to the developer’s workspace.
This works, but it comes with overhead. Using node-gyp to build these particular packages requires an old version of Python to be installed on every developer’s machine. For a team that works on .NET tooling, this requirement added complexity and friction. New contributors had to set up tools they’d never touch directly, and CI pipelines needed to provision and maintain them, which slowed down builds and added yet another set of dependencies to keep up to date over time.
The C# Dev Kit team already has the .NET SDK installed, so why not use C# and Native AOT to streamline our engineering systems?
How Node.js addons work
A Node.js native addon is a shared library (.dll on Windows, .so on Linux, .dylib on macOS) that exports a specific entry point. When Node.js loads such a library, it calls the function napi_register_module_v1. The addon registers any functions it provides, and from that point on, JavaScript treats it like any other module.
The interface that makes this possible is N-API (also called Node-API) – a stable, ABI-compatible C API for building addons. N-API doesn’t care what language produced the shared library, only that it exports the right symbols and calls the right functions. This makes Native AOT a viable option because it can produce shared libraries with arbitrary native entry points, which is all N-API needs.
Throughout the rest of this post, let’s look at the key parts of a small Native AOT Node.js addon that can read a string value from the registry. To keep things simple, we’ll put all the code in one class, though you could easily factor things out to be reusable.
The project file
The project file is minimal:
net10.0<br>true<br>true
PublishAot tells the SDK to produce a shared library when the project is published. AllowUnsafeBlocks is needed because the N-API interop involves function pointers and fixed buffers.
The module entry point
Node.js expects the shared library to export napi_register_module_v1. In C#, we can do this with [UnmanagedCallersOnly]:
public static unsafe partial class RegistryAddon<br>[UnmanagedCallersOnly(<br>EntryPoint = "napi_register_module_v1",<br>CallConvs = [typeof(CallConvCdecl)])]<br>public static nint Init(nint env, nint exports)<br>Initialize();
RegisterFunction(<br>env,<br>exports,<br>"readStringValue"u8,<br>&ReadStringValue);
// Register additional functions...
return exports;<br>A few C# features are doing work here. nint is a native-sized integer — the managed equivalent of intptr_t – used to pass around N-API handles. The u8 suffix produces a ReadOnlySpan containing a UTF-8 string literal, which we pass directly to N-API without any encoding or allocation. And [UnmanagedCallersOnly] tells the AOT compiler to export the method with the specified entry point name and calling convention, making it callable from native code.
Each call to RegisterFunction attaches a C# function pointer to a named property on the JavaScript exports object, so that calling addon.readStringValue(...) in JavaScript invokes the corresponding C# method directly, in-process.
Calling N-API from .NET
N-API functions are exported by node.exe itself, so rather than linking against a separate library, we need to resolve them against the host process. We declare our P/Invoke methods using [LibraryImport] with "node" as the library name, and then register a custom resolver via NativeLibrary.SetDllImportResolver that redirects to the host process at runtime:
private static void Initialize()<br>NativeLibrary.SetDllImportResolver(<br>System.Reflection.Assembly.GetExecutingAssembly(),<br>ResolveDllImport);
static nint ResolveDllImport(<br>string libraryName,<br>Assembly assembly,<br>DllImportSearchPath?...