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 workA 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 fileThe project file is minimal: net10.0 true 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 pointNode.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{ [UnmanagedCallersOnly( EntryPoint = "napi_register_module_v1", CallConvs = [typeof(CallConvCdecl)])] public static nint Init(nint env, nint exports) { Initialize(); RegisterFunction( env, exports, "readStringValue"u8, &ReadStringValue); // Register additional functions... return exports; }}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 .NETN-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(){ NativeLibrary.SetDllImportResolver( System.Reflection.Assembly.GetExecutingAssembly(), ResolveDllImport); static nint ResolveDllImport( string libraryName, Assembly assembly, DllImportSearchPath? searchPath) { if (libraryName is not "node") return 0; return NativeLibrary.GetMainProgramHandle(); }}With this resolver in place, the runtime knows to look up all "node" imports from the host process, and the N-API P/Invoke declarations work without any additional configuration:private static partial class NativeMethods{ [LibraryImport("node", EntryPoint = "napi_create_string_utf8")] internal static partial Status CreateStringUtf8( nint env, ReadOnlySpan str, nuint length, out nint result); [LibraryImport("node", EntryPoint = "napi_create_function")] internal static unsafe partial Status CreateFunction( nint env, ReadOnlySpan utf8name, nuint length, delegate* unmanaged[Cdecl] cb, nint data, out nint result); [LibraryImport("node", EntryPoint = "napi_get_cb_info")] internal static unsafe partial Status GetCallbackInfo( nint env, nint cbinfo, ref nuint argc, Span argv, nint* thisArg, nint* data); // ... other N-API functions as needed}For each registered function we must register a native function as a named property on the exports object:private static unsafe void RegisterFunction( nint env, nint exports, ReadOnlySpan name, delegate* unmanaged[Cdecl] callback){ NativeMethods.CreateFunction(env, name, (nuint)name.Length, callback, 0, out nint fn); NativeMethods.SetNamedProperty(env, exports, name, fn);}The source-generated [LibraryImport] handles the marshalling. ReadOnlySpan maps cleanly to const char*, function pointers are passed through directly, and the generated code is trimming-compatible out of the box.Marshalling stringsMost 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’s a helper that reads a string argument passed from JavaScript:private static unsafe string? GetStringArg(nint env, nint cbinfo, int index){ nuint argc = (nuint)(index + 1); Span argv = stackalloc nint[index + 1]; NativeMethods.GetCallbackInfo(env, cbinfo, ref argc, argv, null, null); if ((int)argc