Da sempre, creare addon nativi per Node.js significava entrare nel mondo di C++ e node-gyp, con la necessità di installare Python, Visual Studio Build Tools e una serie di dipendenze che trasformavano il setup dell’ambiente in un’impresa. Il team di C# Dev Kit di Microsoft ha trovato una soluzione elegante: usare .NET Native AOT per produrre librerie condivise compatibili con l’interfaccia N-API di Node.js, scritte interamente in C#.
In questo articolo vediamo come funziona questa tecnica, analizzando la struttura del progetto, il meccanismo di interop e i punti critici da tenere d’occhio in produzione.
Perché Node.js supporta addon scritti in qualsiasi linguaggio
Un addon nativo per Node.js è semplicemente una libreria condivisa (.dll su Windows, .so su Linux, .dylib su macOS) che esporta un punto di ingresso preciso: la funzione napi_register_module_v1. Node.js carica la libreria, chiama questa funzione e da quel momento il modulo è disponibile per JavaScript.
L’interfaccia che rende tutto questo possibile è N-API (Node-API), una API C stabile e ABI-compatibile tra le versioni di Node.js. Questo significa che qualsiasi linguaggio in grado di produrre una shared library ed esportare una funzione C può diventare un addon Node.js — incluso C# compilato con Native AOT.
Configurazione del progetto .NET
Il file di progetto è sorprendentemente minimale:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<PublishAot>true</PublishAot>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
Due impostazioni chiave:
PublishAot: abilita la compilazione Ahead-of-Time, producendo una shared library nativa invece di un assembly IL.AllowUnsafeBlocks: necessario per l’interop con N-API tramite function pointer e tipi non gestiti.
Il punto di ingresso del modulo
L’entry point usa l’attributo [UnmanagedCallersOnly], che istruisce il compilatore a generare una funzione C-callable con la firma esatta attesa da Node.js:
[UnmanagedCallersOnly(
EntryPoint = "napi_register_module_v1",
CallConvs = [typeof(CallConvCdecl)])]
public static nint Init(nint env, nint exports)
{
// Registrazione delle funzioni esposte
return exports;
}
Il tipo nint (native-sized integer) rappresenta gli handle opachi che N-API usa per riferirsi agli oggetti JavaScript. Non si tratta di puntatori diretti a memoria, ma di token gestiti dall’engine V8 tramite N-API.
Risoluzione delle funzioni N-API a runtime
Le funzioni N-API (come napi_create_string_utf8 o napi_get_cb_info) sono esportate direttamente da node.exe, non da una DLL separata. Per fare in modo che P/Invoke le risolva correttamente, si registra un custom resolver:
private static void Initialize()
{
NativeLibrary.SetDllImportResolver(
System.Reflection.Assembly.GetExecutingAssembly(),
ResolveDllImport);
}
private static nint ResolveDllImport(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
if (libraryName == "node")
return NativeLibrary.GetMainProgramHandle();
return IntPtr.Zero;
}
Questo permette di dichiarare le importazioni P/Invoke con [LibraryImport("node")] e averle risolte contro il processo host a runtime.
Marshalling delle stringhe UTF-8
Uno dei punti più delicati è la conversione tra stringhe JavaScript (UTF-16 internamente in V8, UTF-8 via N-API) e stringhe .NET. La strategia ottimale prevede:
- Uso dello stack per stringhe piccole (≤512 byte) tramite
stackalloc - Uso di
ArrayPool<byte>per stringhe più grandi, evitando allocazioni sull’heap
private static string GetStringArg(nint env, nint info, int argIndex)
{
// Recupera l'handle dell'argomento
nint value = GetArgument(env, info, argIndex);
// Prima chiamata: ottieni la dimensione necessaria
nuint byteCount;
napi_get_value_string_utf8(env, value, null, 0, out byteCount);
// Allocazione efficiente in base alla dimensione
if (byteCount <= 512)
{
Span<byte> buffer = stackalloc byte[(int)byteCount + 1];
napi_get_value_string_utf8(env, value, buffer, (nuint)buffer.Length, out _);
return Encoding.UTF8.GetString(buffer[..^1]);
}
else
{
byte[] buffer = ArrayPool<byte>.Shared.Rent((int)byteCount + 1);
try
{
napi_get_value_string_utf8(env, value, buffer, (nuint)buffer.Length, out _);
return Encoding.UTF8.GetString(buffer, 0, (int)byteCount);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
Implementazione di una funzione reale: lettura dal Registry
L’esempio concreto mostrato dal team di Microsoft è un lettore del Windows Registry, che sostituisce il precedente addon C++:
private static nint ReadStringValue(nint env, nint info)
{
try
{
var keyPath = GetStringArg(env, info, 0);
var valueName = GetStringArg(env, info, 1);
using var key = Registry.CurrentUser.OpenSubKey(keyPath, writable: false);
return key?.GetValue(valueName) is string value
? CreateString(env, value)
: GetUndefined(env);
}
catch (Exception ex)
{
// CRITICO: le eccezioni non gestite in [UnmanagedCallersOnly] crashano il processo
ThrowError(env, $"Registry read failed: {ex.Message}");
return 0;
}
}
Attenzione: in un metodo [UnmanagedCallersOnly], le eccezioni non gestite provocano il crash dell’intero processo Node.js. Il pattern try/catch con ThrowError trasforma l’eccezione .NET in un errore JavaScript, mantenendo stabile il runtime.
Integrazione con TypeScript
Dopo dotnet publish, il file prodotto viene rinominato con estensione .node (convenzione Node.js) e caricato normalmente da TypeScript:
interface RegistryAddon {
readStringValue(keyPath: string, valueName: string): string | undefined;
}
const registry = require('./native/win32-x64/RegistryAddon.node') as RegistryAddon;
const sdkPath = registry.readStringValue(
'SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x64\\sdk',
'InstallLocation'
);
console.log(`SDK installato in: ${sdkPath}`);
Limiti e considerazioni
Questa tecnica ha un limite importante: Native AOT non supporta la cross-compilazione. Per ogni piattaforma target (Windows x64, Linux x64, macOS ARM64…) è necessario un ambiente di build separato. In pratica, questo si risolve con pipeline CI che eseguono la build su runner del sistema operativo corrispondente.
Esiste anche un’alternativa di più alto livello, node-api-dotnet, che astrae molti dei dettagli mostrati qui e supporta scenari più complessi come l’esposizione di interi namespace .NET a JavaScript. L’approccio “thin wrapper” descritto in questo articolo è preferibile quando si vuole controllo totale e dipendenze minime.
Conclusioni
L’integrazione tra .NET Native AOT e N-API apre uno scenario interessante per i team che già lavorano con C# e devono interfacciarsi con l’ecosistema Node.js. Eliminare Python e node-gyp dal setup semplifica notevolmente l’ambiente di sviluppo e unifica le competenze necessarie intorno a un unico SDK.
Il risultato è codice nativo con prestazioni paragonabili al C++, scritto con la produttività e la type safety di C# moderno, deployabile su Windows, Linux e macOS.
Fonte: Writing Node.js addons with .NET Native AOT — Microsoft .NET Blog, Drew Noakes