{"id":3883,"date":"2026-04-20T20:14:04","date_gmt":"2026-04-20T20:14:04","guid":{"rendered":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/2026\/04\/20\/writing-node-js-addons-with-net-native-aot\/"},"modified":"2026-04-20T20:14:04","modified_gmt":"2026-04-20T20:14:04","slug":"writing-node-js-addons-with-net-native-aot","status":"publish","type":"post","link":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/2026\/04\/20\/writing-node-js-addons-with-net-native-aot\/","title":{"rendered":"Writing Node.js addons with .NET Native AOT"},"content":{"rendered":"<p><a href=\"https:\/\/marketplace.visualstudio.com\/items?itemName=ms-dotnettools.csdevkit\">C# Dev Kit<\/a> 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\u2019ve historically used native Node.js addons written in C++, which are compiled via <a href=\"https:\/\/github.com\/nodejs\/node-gyp\">node-gyp<\/a> during installation to the developer\u2019s workspace.<\/p>\n<p>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\u2019s machine. For a team that works on .NET tooling, this requirement added complexity and friction. New contributors had to set up tools they\u2019d 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.<\/p>\n<p>The C# Dev Kit team already has the .NET SDK installed, so why not use C# and Native AOT to streamline our engineering systems?<\/p>\n<h2>How Node.js addons work<\/h2>\n<p>A Node.js native addon is a shared library (<code>.dll<\/code> on Windows, <code>.so<\/code> on Linux, <code>.dylib<\/code> on macOS) that exports a specific entry point. When Node.js loads such a library, it calls the function <code>napi_register_module_v1<\/code>. The addon registers any functions it provides, and from that point on, JavaScript treats it like any other module.<\/p>\n<p>The interface that makes this possible is <a href=\"https:\/\/nodejs.org\/api\/n-api.html\">N-API<\/a> (also called Node-API) \u2013 a stable, ABI-compatible C API for building addons. N-API doesn\u2019t 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.<\/p>\n<p>Throughout the rest of this post, let\u2019s 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\u2019ll put all the code in one class, though you could easily factor things out to be reusable.<\/p>\n<h2>The project file<\/h2>\n<p>The project file is minimal:<\/p>\n<pre><code class=\"language-xml\">&lt;Project Sdk=\"Microsoft.NET.Sdk\"&gt;\r\n  &lt;PropertyGroup&gt;\r\n    &lt;TargetFramework&gt;net10.0&lt;\/TargetFramework&gt;\r\n    &lt;PublishAot&gt;true&lt;\/PublishAot&gt;\r\n    &lt;AllowUnsafeBlocks&gt;true&lt;\/AllowUnsafeBlocks&gt;\r\n  &lt;\/PropertyGroup&gt;\r\n&lt;\/Project&gt;<\/code><\/pre>\n<p><a href=\"https:\/\/learn.microsoft.com\/dotnet\/core\/deploying\/native-aot\/\"><code>PublishAot<\/code><\/a> tells the SDK to produce a shared library when the project is published. <code>AllowUnsafeBlocks<\/code> is needed because the N-API interop involves function pointers and fixed buffers.<\/p>\n<h2>The module entry point<\/h2>\n<p>Node.js expects the shared library to export <code>napi_register_module_v1<\/code>. In C#, we can do this with <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.runtime.interopservices.unmanagedcallersonlyattribute\"><code>[UnmanagedCallersOnly]<\/code><\/a>:<\/p>\n<pre><code class=\"language-csharp\">public static unsafe partial class RegistryAddon\r\n{\r\n    [UnmanagedCallersOnly(\r\n        EntryPoint = \"napi_register_module_v1\",\r\n        CallConvs = [typeof(CallConvCdecl)])]\r\n    public static nint Init(nint env, nint exports)\r\n    {\r\n        Initialize();\r\n\r\n        RegisterFunction(\r\n            env,\r\n            exports,\r\n            \"readStringValue\"u8,\r\n            &amp;ReadStringValue);\r\n\r\n        \/\/ Register additional functions...\r\n\r\n        return exports;\r\n    }\r\n}<\/code><\/pre>\n<p>A few C# features are doing work here. <a href=\"https:\/\/learn.microsoft.com\/dotnet\/csharp\/language-reference\/builtin-types\/nint-nuint\"><code>nint<\/code><\/a> is a native-sized integer \u2014 the managed equivalent of <code>intptr_t<\/code> \u2013 used to pass around N-API handles. The <a href=\"https:\/\/learn.microsoft.com\/dotnet\/csharp\/language-reference\/builtin-types\/reference-types#utf-8-string-literals\"><code>u8<\/code> suffix<\/a> produces a <code>ReadOnlySpan&lt;byte&gt;<\/code> containing a UTF-8 string literal, which we pass directly to N-API without any encoding or allocation. And <code>[UnmanagedCallersOnly]<\/code> tells the AOT compiler to export the method with the specified entry point name and calling convention, making it callable from native code.<\/p>\n<p>Each call to <code>RegisterFunction<\/code> attaches a C# function pointer to a named property on the JavaScript <code>exports<\/code> object, so that calling <code>addon.readStringValue(...)<\/code> in JavaScript invokes the corresponding C# method directly, in-process.<\/p>\n<h2>Calling N-API from .NET<\/h2>\n<p>N-API functions are exported by <code>node.exe<\/code> 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 <a href=\"https:\/\/learn.microsoft.com\/dotnet\/standard\/native-interop\/pinvoke-source-generation\"><code>[LibraryImport]<\/code><\/a> with <code>\"node\"<\/code> as the library name, and then register a custom resolver via <a href=\"https:\/\/learn.microsoft.com\/dotnet\/api\/system.runtime.interopservices.nativelibrary.setdllimportresolver\"><code>NativeLibrary.SetDllImportResolver<\/code><\/a> that redirects to the host process at runtime:<\/p>\n<pre><code class=\"language-csharp\">private static void Initialize()\r\n{\r\n    NativeLibrary.SetDllImportResolver(\r\n        System.Reflection.Assembly.GetExecutingAssembly(),\r\n        ResolveDllImport);\r\n\r\n    static nint ResolveDllImport(\r\n        string libraryName,\r\n        Assembly assembly,\r\n        DllImportSearchPath? searchPath)\r\n    {\r\n        if (libraryName is not \"node\")\r\n            return 0;\r\n\r\n        return NativeLibrary.GetMainProgramHandle();\r\n    }\r\n}<\/code><\/pre>\n<p>With this resolver in place, the runtime knows to look up all <code>\"node\"<\/code> imports from the host process, and the N-API P\/Invoke declarations work without any additional configuration:<\/p>\n<pre><code class=\"language-csharp\">private static partial class NativeMethods\r\n{\r\n    [LibraryImport(\"node\", EntryPoint = \"napi_create_string_utf8\")]\r\n    internal static partial Status CreateStringUtf8(\r\n        nint env, ReadOnlySpan&lt;byte&gt; str, nuint length, out nint result);\r\n\r\n    [LibraryImport(\"node\", EntryPoint = \"napi_create_function\")]\r\n    internal static unsafe partial Status CreateFunction(\r\n        nint env, ReadOnlySpan&lt;byte&gt; utf8name, nuint length,\r\n        delegate* unmanaged[Cdecl]&lt;nint, nint, nint&gt; cb,\r\n        nint data, out nint result);\r\n\r\n    [LibraryImport(\"node\", EntryPoint = \"napi_get_cb_info\")]\r\n    internal static unsafe partial Status GetCallbackInfo(\r\n        nint env, nint cbinfo, ref nuint argc,\r\n        Span&lt;nint&gt; argv, nint* thisArg, nint* data);\r\n\r\n    \/\/ ... other N-API functions as needed\r\n}<\/code><\/pre>\n<p>For each registered function we must register a native function as a named property on the exports object:<\/p>\n<pre><code class=\"language-csharp\">private static unsafe void RegisterFunction(\r\n    nint env, nint exports, ReadOnlySpan&lt;byte&gt; name,\r\n    delegate* unmanaged[Cdecl]&lt;nint, nint, nint&gt; callback)\r\n{\r\n    NativeMethods.CreateFunction(env, name, (nuint)name.Length, callback, 0, out nint fn);\r\n    NativeMethods.SetNamedProperty(env, exports, name, fn);\r\n}<\/code><\/pre>\n<p>The source-generated <code>[LibraryImport]<\/code> handles the marshalling. <code>ReadOnlySpan&lt;byte&gt;<\/code> maps cleanly to <code>const char*<\/code>, function pointers are passed through directly, and the generated code is trimming-compatible out of the box.<\/p>\n<h2>Marshalling strings<\/h2>\n<p>Most of the interop work comes down to moving strings between JavaScript and .NET. N-API uses UTF-8, so the conversion is straightforward, though it does require a buffer. Here\u2019s a helper that reads a string argument passed from JavaScript:<\/p>\n<pre><code class=\"language-csharp\">private static unsafe string? GetStringArg(nint env, nint cbinfo, int index)\r\n{\r\n    nuint argc = (nuint)(index + 1);\r\n    Span&lt;nint&gt; argv = stackalloc nint[index + 1];\r\n    NativeMethods.GetCallbackInfo(env, cbinfo, ref argc, argv, null, null);\r\n\r\n    if ((int)argc &lt;= index)\r\n        return null;\r\n\r\n    \/\/ Ask N-API for the UTF-8 byte length\r\n    NativeMethods.GetValueStringUtf8(env, argv[index], null, 0, out nuint len);\r\n\r\n    \/\/ Allocate a buffer\r\n    int bufLen = (int)len + 1;\r\n    byte[]? rented = null;\r\n    Span&lt;byte&gt; buf = bufLen &lt;= 512\r\n        ? stackalloc byte[bufLen]\r\n        : (rented = ArrayPool&lt;byte&gt;.Shared.Rent(bufLen));\r\n\r\n    try\r\n    {\r\n        fixed (byte* pBuf = buf)\r\n            NativeMethods.GetValueStringUtf8(env, argv[index], pBuf, len + 1, out _);\r\n\r\n        return Encoding.UTF8.GetString(buf[..(int)len]);\r\n    }\r\n    finally\r\n    {\r\n        if (rented is not null)\r\n            ArrayPool&lt;byte&gt;.Shared.Return(rented);\r\n    }\r\n}<\/code><\/pre>\n<p>This code asks N-API for the byte length, allocates a buffer (on the stack for small strings, from the pool for larger ones), reads the bytes, then decodes to a .NET <code>string<\/code>.<\/p>\n<p>Returning a string to JavaScript is the same process in reverse. We encode a .NET string into a UTF-8 buffer and pass it to <code>napi_create_string_utf8<\/code>:<\/p>\n<pre><code class=\"language-csharp\">private static nint CreateString(nint env, string value)\r\n{\r\n    int byteCount = Encoding.UTF8.GetByteCount(value);\r\n\r\n    byte[]? rented = null;\r\n    Span&lt;byte&gt; buf = byteCount &lt;= 512\r\n        ? stackalloc byte[byteCount]\r\n        : (rented = ArrayPool&lt;byte&gt;.Shared.Rent(byteCount));\r\n\r\n    try\r\n    {\r\n        Encoding.UTF8.GetBytes(value, buf);\r\n        NativeMethods.CreateStringUtf8(\r\n            env, buf[..byteCount], (nuint)byteCount, out nint result);\r\n        return result;\r\n    }\r\n    finally\r\n    {\r\n        if (rented is not null)\r\n            ArrayPool&lt;byte&gt;.Shared.Return(rented);\r\n    }\r\n}<\/code><\/pre>\n<p>Both directions use <code>Span&lt;T&gt;<\/code>, <code>stackalloc<\/code>, and <code>ArrayPool<\/code> to avoid heap allocations for typical string sizes. Once you have these helpers in place, you can write exported functions without thinking much about marshalling values.<\/p>\n<h2>Implementing an exported function<\/h2>\n<p>With the N-API plumbing in place, implementing an actual exported function is straightforward. Here\u2019s one that reads a value from the Windows Registry and returns it to JavaScript as a string:<\/p>\n<pre><code class=\"language-csharp\">[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]\r\nprivate static nint ReadStringValue(nint env, nint info)\r\n{\r\n    try\r\n    {\r\n        var keyPath = GetStringArg(env, info, 0);\r\n        var valueName = GetStringArg(env, info, 1);\r\n\r\n        if (keyPath is null || valueName is null)\r\n        {\r\n            ThrowError(env, \"Expected two string arguments: keyPath, valueName\");\r\n            return 0;\r\n        }\r\n\r\n        using var key = Registry.CurrentUser.OpenSubKey(\r\n            keyPath,\r\n            writable: false);\r\n\r\n        return key?.GetValue(valueName) is string value\r\n            ? CreateString(env, value)\r\n            : GetUndefined(env);\r\n    }\r\n    catch (Exception ex)\r\n    {\r\n        ThrowError(env, $\"Registry read failed: {ex.Message}\");\r\n        return 0;\r\n    }\r\n}<\/code><\/pre>\n<p>The structure is the same for every exported function. Read any arguments to the function first. Here we read string arguments with <code>GetStringArg<\/code>. Then, do the work using normal .NET APIs, and finally return a result via <code>CreateString<\/code> or similar. One thing to be careful about is exception handling \u2013 an unhandled exception in an <code>[UnmanagedCallersOnly]<\/code> method will crash the host process. We catch exceptions and forward them to JavaScript via <code>ThrowError<\/code>, which causes a standard JavaScript <code>Error<\/code> to be thrown on the calling side.<\/p>\n<p>This example also shows why native addons are useful in the first place. Node.js doesn\u2019t have built-in access to the Windows Registry, so a native addon lets us use <code>Microsoft.Win32.Registry<\/code> from .NET and expose the result to JavaScript with minimal ceremony.<\/p>\n<h2>Calling our function from TypeScript<\/h2>\n<p>First, we must produce a platform-specific shared library. Running <code>dotnet publish<\/code> produces a native library appropriate for your operating system (for example, <code>RegistryAddon.dll<\/code> on Windows, <code>libRegistryAddon.so<\/code> on Linux, or <code>libRegistryAddon.dylib<\/code> on macOS). By convention, Node.js treats paths ending with <code>.node<\/code> as native addons, so we rename this output file to <code>MyNativeAddon.node<\/code>.<\/p>\n<p>We declare a TypeScript interface for our module, through which we expose type-safe access to our module\u2019s functions:<\/p>\n<pre><code class=\"language-typescript\">interface RegistryAddon {\r\n    readStringValue(keyPath: string, valueName: string): string | undefined;\r\n\r\n    \/\/ Declare additional functions...\r\n}<\/code><\/pre>\n<p>From there, loading it in TypeScript is a standard <code>require()<\/code> call:<\/p>\n<pre><code class=\"language-typescript\">\/\/ Load our native module\r\nconst registry = require('.\/native\/win32-x64\/RegistryAddon.node') as RegistryAddon\r\n\r\n\/\/ Call our native function\r\nconst sdkPath = registry.readStringValue(\r\n    'SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x64\\sdk', 'InstallLocation')<\/code><\/pre>\n<p>And with that, we\u2019re done! We can call from TypeScript into native code that was written in C#. While this particular registry addon is Windows-only, the same Native AOT and N-API approach works equally well on Windows, Linux, and macOS.<\/p>\n<h2>What about existing libraries?<\/h2>\n<p>There is an existing project, <a href=\"https:\/\/github.com\/microsoft\/node-api-dotnet\">node-api-dotnet<\/a>, that provides a higher-level framework for .NET\/JavaScript interop. It handles a lot of the boilerplate and supports richer scenarios. For our use case, we only needed a handful of functions, and the thin N-API wrapper gave us full control over the interop layer without bringing in additional dependencies. If you need to expose entire .NET classes or handle callbacks from JavaScript into .NET, a library like that is worth considering.<\/p>\n<h2>What we gained<\/h2>\n<p>The immediate, practical benefit was simplifying our contributor experience. Anyone who wants to develop in our repo no longer needs a specific Python version. <code>yarn install<\/code> works with just Node.js, C++ tooling and the .NET SDK, which are tools we already require for development. Our CI pipelines are simpler as well.<\/p>\n<p>Performance has been comparable to the C++ implementation. Native AOT produces optimized native code, and for the kind of work these functions do \u2013 string marshalling, registry access \u2013 there\u2019s no meaningful difference in practice. The .NET runtime does bring a garbage collector and a slightly larger memory footprint, but in a long-running VS Code extension process this is negligible.<\/p>\n<p>Looking ahead, this opens up some interesting possibilities. We currently run substantial .NET workloads in a separate process, communicating over a pipe. With Native AOT producing shared libraries that load directly into the Node.js process, we could potentially host some of that logic in-process, avoiding the serialization and process-management overhead. That\u2019s a longer-term exploration, but the foundation is now in place.<\/p>\n<h2>A footnote<\/h2>\n<p>When the idea of using Native AOT first arose, no one on the team had direct experience of integrating native code with Node.js. Even though we have experience with Native AOT, the prospect of learning N-API\u2019s C calling conventions and wiring up the interop might have seemed daunting enough to put the whole idea on the back burner. GitHub Copilot allowed us to get a working proof-of-concept running very quickly, at which point the idea seemed promising enough to pursue. It\u2019s been a fantastic tool for exploring ideas that we wouldn\u2019t previously have had the time for. It\u2019s improving our products, and the team\u2019s quality of life.<\/p>\n<h2>Summary<\/h2>\n<p>Native AOT increases the number of places you can run your .NET code. In this case, it allowed us to consolidate our tooling around fewer technologies and streamline our developer experience, particularly for onboarding new developers to the codebase.<\/p>\n<p>If you\u2019re running in Node.js, or in any other environment that can load native code, consider using Native AOT to produce that code. It allows you to write your native code in a language with memory safety, a rich standard library, and modern tooling. And if you\u2019re not very familiar with native coding, you might be surprised to learn just how simple it can be to wire this all up (especially if you have a Copilot to help).<\/p>\n<p>The post <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\/writing-nodejs-addons-with-dotnet-native-aot\/\">Writing Node.js addons with .NET Native AOT<\/a> appeared first on <a href=\"https:\/\/devblogs.microsoft.com\/dotnet\">.NET Blog<\/a>.<\/p>","protected":false},"excerpt":{"rendered":"<p>C# Dev Kit is a VS Code extension. Like all VS Code extensions, its front end is TypeScript running in [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":94,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"default","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"footnotes":""},"categories":[7],"tags":[],"class_list":["post-3883","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-dotnet"],"_links":{"self":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts\/3883","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/comments?post=3883"}],"version-history":[{"count":0,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts\/3883\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/media\/94"}],"wp:attachment":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/media?parent=3883"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/categories?post=3883"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/tags?post=3883"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}