Escribiendo complementos de Node.js con .NET Native AOT

Escribiendo complementos de Node.js con .NET Native AOT

El equipo de C# Dev Kit en VS Code ha migrado sus complementos nativos de Node.js de C++ a C# utilizando Native AOT. Esta decisión elimina la dependencia de una versión antigua de Python, simplificando significativamente la experiencia del desarrollador y las pipelines de CI.

Simplificando la Interoperabilidad con Native AOT

Originalmente, para tareas como leer el Registro de Windows, la extensión C# Dev Kit empleaba complementos nativos de C++ compilados con node-gyp. Este enfoque introducía una fricción considerable, requiriendo la instalación de una versión específica de Python en las máquinas de los desarrolladores y en los entornos de CI. Dada la presencia del SDK de .NET, el equipo optó por aprovechar C# y Native AOT para optimizar sus sistemas.

¿Cómo Funcionan los Complementos de Node.js?

Un complemento nativo de Node.js es una biblioteca compartida (.dll, .so, .dylib) que exporta un punto de entrada específico. Node.js invoca la función napi_register_module_v1 al cargar la biblioteca. La interfaz subyacente es N-API (Node-API), una API C estable y compatible con ABI que permite la creación de complementos sin importar el lenguaje de origen, siempre que exporte los símbolos y funciones correctos. Esta característica hace que Native AOT sea una opción ideal, ya que puede producir bibliotecas compartidas con puntos de entrada nativos arbitrarios.

Configuración del Proyecto y Punto de Entrada

El archivo de proyecto para un complemento Native AOT es minimalista:


  
    net10.0
    true
    true
  

PublishAot indica al SDK que genere una biblioteca compartida. AllowUnsafeBlocks es necesaria para la interoperabilidad con N-API, que involucra punteros de función y búferes fijos.

Node.js espera que la biblioteca exporte napi_register_module_v1. En C#, esto se logra con [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);
        return exports;
    }
}

nint se usa para handles de N-API, el sufijo u8 para literales de cadena UTF-8 eficientes, y [UnmanagedCallersOnly] exporta el método para código nativo. Cada RegisterFunction adjunta un puntero de función C# a una propiedad del objeto exports de JavaScript, permitiendo la invocación directa en proceso.

Llamando a N-API desde .NET

Las funciones de N-API son exportadas por node.exe. Para resolverlas, se utilizan P/Invoke con [LibraryImport] y un resolvedor personalizado NativeLibrary.SetDllImportResolver que redirige a NativeLibrary.GetMainProgramHandle():

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();
    }
}

Esto permite que las declaraciones de P/Invoke funcionen sin configuración adicional, con [LibraryImport] manejando el marshalling de ReadOnlySpan<byte> a const char* y los punteros de función.

Marshalling Eficiente de Cadenas

La transferencia de cadenas entre JavaScript (UTF-8) y .NET requiere búferes. Se usan funciones auxiliares para esto, optimizadas con Span<T>, stackalloc y ArrayPool para evitar asignaciones en el heap para cadenas típicas.

Para leer una cadena desde JavaScript:

private static unsafe string? GetStringArg(nint env, nint cbinfo, int index)
{
    // ... (código que obtiene longitud, asigna búfer (stack/pool), lee y decodifica UTF-8 a string .NET)
}

Para devolver una cadena a JavaScript:

private static nint CreateString(nint env, string value)
{
    // ... (código que codifica string .NET a UTF-8, asigna búfer (stack/pool), y crea cadena N-API)
}

Estas utilidades simplifican el desarrollo de funciones exportadas al abstraer los detalles del marshalling.

Implementación de una Función Exportada: Lectura del Registro

Con el _plumbing_ de N-API listo, la implementación de una función es directa. Un ejemplo es ReadStringValue para leer del Registro de Windows:

[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static nint ReadStringValue(nint env, nint info)
{
    try
    {
        var keyPath = GetStringArg(env, info, 0);
        var valueName = GetStringArg(env, info, 1);
        // ... Lógica de lectura de registro con Microsoft.Win32.Registry ...
        return key?.GetValue(valueName) is string value
            ? CreateString(env, value)
            : GetUndefined(env);
    }
    catch (Exception ex)
    {
        ThrowError(env, $"Registry read failed: {ex.Message}");
        return 0;
    }
}

Se leen los argumentos, se utiliza la API de .NET (ej. Microsoft.Win32.Registry), y se devuelve el resultado. El manejo de excepciones es crucial para evitar crasheos del proceso host, utilizando ThrowError para lanzar errores JavaScript estándar. Este patrón resalta la capacidad de los complementos nativos para extender Node.js con funcionalidades específicas de la plataforma.

Invocación desde TypeScript

Después de dotnet publish y renombrar la biblioteca (RegistryAddon.dll a MyNativeAddon.node), se define una interfaz TypeScript:

interface RegistryAddon {
    readStringValue(keyPath: string, valueName: string): string | undefined;
}

La carga e invocación en TypeScript es un require() estándar:

const registry = require('./native/win32-x64/RegistryAddon.node') as RegistryAddon
const sdkPath = registry.readStringValue(
    'SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x64\\sdk', 'InstallLocation')

Este enfoque con Native AOT y N-API funciona universalmente en Windows, Linux y macOS, a pesar del ejemplo específico de registro en Windows.

Ventajas y Futuro

Los beneficios inmediatos incluyen una simplificación drástica de la experiencia del colaborador, eliminando la necesidad de Python y haciendo más sencillas las pipelines de CI. El rendimiento es comparable al de las implementaciones en C++, gracias al código nativo optimizado de Native AOT. La sobrecarga de .NET (GC, memoria) es insignificante en extensiones de VS Code de larga duración.

A futuro, esta capacidad de cargar bibliotecas Native AOT directamente en el proceso de Node.js abre la puerta a alojar lógicas .NET sustanciales en proceso, evitando la sobrecarga de serialización y gestión de procesos que implica la comunicación entre procesos separados.

El Impulso de GitHub Copilot

La exploración de esta idea, que inicialmente parecía desalentadora por la falta de experiencia con N-API, fue acelerada significativamente por GitHub Copilot. Permitió al equipo generar una prueba de concepto funcional muy rápidamente, demostrando la viabilidad del enfoque y ahorrando tiempo valioso.

Conclusión: .NET Native AOT Amplía Horizontes

Native AOT no solo expande dónde se puede ejecutar código .NET, sino que también permite consolidar herramientas y optimizar la experiencia de desarrollo. Al escribir código nativo en C#, los desarrolladores se benefician de la seguridad de memoria, una rica biblioteca estándar y herramientas modernas. Incluso para aquellos menos familiarizados con la programación nativa, la configuración es sorprendentemente accesible, especialmente con la asistencia de IA como Copilot.

Author: Enagora

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *