Node.js Addons in C#: .NET Native AOT Replaces C++ and node-gyp
The C# Dev Kit team swapped its C++ Node.js addon for a .NET 10 Native AOT library, using N-API, UnmanagedCallersOnly, and LibraryImport to produce a single .node file without Python or node-gyp.
Drew Noakes from the C# Dev Kit team announced on April 20, 2026 that the extension’s native Node.js addon is now written entirely in C# and compiled with .NET 10 Native AOT. That means the Windows Registry access the extension depends on ships as a plain .node file produced by dotnet publish, with no C++, no Python, and no node-gyp in the build chain.
Why This Is a Big Deal for Node Tooling
Node.js addons have historically been C or C++ projects glued together by node-gyp, which in turn needs Python, a C++ toolchain, and a compatible MSBuild on Windows. Anyone who has maintained a cross-platform Electron extension knows how brittle that chain gets on CI. Native AOT collapses the whole pipeline into a single dotnet publish, producing a platform-specific shared library (.dll, .so, or .dylib) that Node loads directly once you rename it to .node. The C# Dev Kit uses exactly this flow to read the Windows Registry, removing Python from its contributor setup.
Exporting napi_register_module_v1 from C#
The trick is that N-API (Node-API) has a stable ABI, so any language that can produce a native export with C calling conventions can implement a Node addon. In .NET 10, [UnmanagedCallersOnly] does that job: it pins an export name and calling convention into the AOT image. The entry point Node looks for is napi_register_module_v1.
public static unsafe partial class HelloAddon
{
[UnmanagedCallersOnly(
EntryPoint = "napi_register_module_v1",
CallConvs = [typeof(CallConvCdecl)])]
public static nint Init(nint env, nint exports)
{
RegisterFunction(env, exports, "hello"u8, &SayHello);
return exports;
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static nint SayHello(nint env, nint info)
{
return CreateString(env, "Hello from .NET!");
}
}
The "hello"u8 literal is a UTF-8 byte string, which is what N-API wants, and &SayHello is a function pointer that survives AOT because UnmanagedCallersOnly forbids managed-only features like generics and async on that signature.
Resolving N-API Against the Host Process
The second half of the puzzle is calling back into N-API. There is no node.dll to link against, because on many platforms the Node binary is the executable itself. The post uses [LibraryImport("node")] together with a custom NativeLibrary.SetDllImportResolver that returns the current process handle, so every N-API call resolves against the running Node executable at load time.
[LibraryImport("node", EntryPoint = "napi_create_string_utf8")]
private static partial int CreateStringUtf8(
nint env, byte[] str, nuint length, out nint result);
NativeLibrary.SetDllImportResolver(typeof(HelloAddon).Assembly,
(name, _, _) => name == "node"
? NativeLibrary.GetMainProgramHandle()
: 0);
The Project File
Enabling AOT is a two-line change. AllowUnsafeBlocks is required because N-API interop leans on function pointers and spans over native memory.
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<PublishAot>true</PublishAot>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
After dotnet publish -c Release, rename the output library to HelloAddon.node and require() it from JavaScript like any other native module.
For richer scenarios, the post also points at microsoft/node-api-dotnet, which wraps N-API in higher-level abstractions and supports full interop between JS and CLR types. But for the “ship a small, fast native addon without a C++ toolchain” case, the raw N-API plus Native AOT route is now production-proven inside Microsoft’s own VS Code extensions.