Zig e Node.js: Addons Nativos com N-API sem Sofrimento

Node.js resolve muito bem APIs, filas, automações, painéis internos e produtos SaaS. O problema aparece quando uma parte específica do sistema deixa de ser “código de negócio” e vira processamento pesado: parsing de arquivos grandes, compressão, validação binária, cálculo numérico, normalização de logs, criptografia, manipulação de imagens, varredura de diretórios ou integração com uma biblioteca C existente.

A saída comum é reescrever tudo em outra linguagem. Quase sempre é exagero. Uma alternativa mais pragmática é manter a aplicação em Node.js e mover apenas o núcleo quente para um addon nativo. Zig entra bem nesse espaço porque gera bibliotecas pequenas, conversa com C sem cerimônia, deixa alocação explícita e facilita cross-compilation. Você continua usando TypeScript, npm, Fastify, BullMQ ou Next.js no restante da aplicação, mas ganha um trecho nativo onde o gargalo realmente mora.

Este guia mostra quando um addon Node.js em Zig faz sentido, como desenhar uma fronteira segura com N-API, quais cuidados tomar com memória e erros, e como empacotar isso sem transformar o projeto em uma coleção de scripts frágeis.

Quando vale a pena usar Zig com Node.js

Use Zig para um componente Node.js quando pelo menos uma destas condições for verdadeira:

  • a função processa buffers grandes muitas vezes por segundo;
  • o código precisa chamar uma biblioteca C ou uma ABI já existente;
  • a latência p95/p99 importa mais do que a velocidade média;
  • o módulo precisa rodar sem instalar Python, gcc ou toolchain no servidor final;
  • o trabalho é determinístico e bem isolado, como parser, codec, hash, filtro ou validador;
  • você quer entregar binários pequenos para Linux, macOS e Windows.

Não use Zig para substituir regra de negócio comum. Se a função faz três consultas HTTP, lê uma tabela no banco e decide qual e-mail enviar, Node.js continua sendo a camada certa. Zig deve entrar como bisturi, não como martelo.

Também vale comparar com outras opções. Para extensões nativas em Python, veja Zig para extensões Python de alta performance. Para módulos WebAssembly, leia Zig e WebAssembly em 2026. Para CLIs internas que rodam fora do processo Node, comece por ferramentas internas em Zig para DevOps.

N-API em uma frase

N-API é a interface C estável do Node.js para addons nativos. Em vez de depender dos detalhes internos do V8, o addon fala com uma ABI mais estável: recebe napi_env, lê argumentos JavaScript, cria valores de retorno e registra funções exportadas.

Para Zig, isso é atraente porque a linguagem importa headers C diretamente com @cImport. O projeto pode incluir node_api.h, compilar uma biblioteca dinâmica e exportar uma função de inicialização que o Node carrega com require() ou import via um wrapper JavaScript.

O desenho mental fica assim:

TypeScript/JavaScript
  └─ wrapper pequeno: valida API pública e carrega binário certo
       └─ addon .node compilado com Zig
            ├─ ponte N-API: converte JS <-> Zig
            └─ núcleo Zig: parser, algoritmo, codec ou integração C

A fronteira N-API deve ser fina. Quanto menos lógica de produto atravessar essa camada, mais simples fica testar, versionar e debugar.

Estrutura de projeto

Uma estrutura simples para começar:

meu-addon/
  package.json
  src/
    index.ts
    loader.ts
  native/
    build.zig
    src/
      addon.zig
      core.zig
  test/
    addon.test.ts

O loader.ts escolhe o binário certo para a plataforma atual. O addon.zig conhece N-API. O core.zig não conhece Node.js; ele recebe bytes e retorna um resultado Zig normal. Essa separação permite testar o núcleo com zig build test sem iniciar Node.

Um wrapper TypeScript pode ter esta forma:

import { existsSync } from "node:fs";
import { join } from "node:path";

const platform = `${process.platform}-${process.arch}`;
const candidates = [
  join(__dirname, `../prebuilds/${platform}/meu_addon.node`),
  join(__dirname, "../native/zig-out/lib/meu_addon.node"),
];

const nativePath = candidates.find(existsSync);
if (!nativePath) {
  throw new Error(`Binário nativo não encontrado para ${platform}`);
}

export const native = require(nativePath) as {
  checksum(input: Buffer): number;
};

O wrapper é também o lugar para mensagens de erro humanas. Não deixe o usuário final encarar ERR_DLOPEN_FAILED sem contexto.

Núcleo Zig sem dependência de Node

Antes de tocar em N-API, escreva e teste o núcleo:

const std = @import("std");

test "checksum simples" {
    try std.testing.expectEqual(@as(u32, 294), checksum("abc"));
}

pub fn checksum(input: []const u8) u32 {
    var acc: u32 = 0;
    for (input) |byte| {
        acc +%= byte;
    }
    return acc;
}

Este exemplo é trivial, mas a regra é importante: parsing, validação, cálculo e acesso a bibliotecas C devem morar em funções Zig comuns. A ponte com JavaScript só transforma valores.

Para processamento maior, prefira APIs que aceitam []const u8 e escrevem em um buffer de saída fornecido pelo chamador. Isso reduz cópias e deixa a posse de memória explícita.

Ponte N-API mínima

O arquivo de ponte importa node_api.h e expõe funções no formato esperado pelo Node:

const std = @import("std");
const core = @import("core.zig");
const c = @cImport({
    @cInclude("node_api.h");
});

fn jsChecksum(env: c.napi_env, info: c.napi_callback_info) callconv(.C) c.napi_value {
    var argc: usize = 1;
    var args: [1]c.napi_value = undefined;
    _ = c.napi_get_cb_info(env, info, &argc, &args, null, null);

    var data: ?*anyopaque = null;
    var len: usize = 0;
    if (argc != 1 or c.napi_get_buffer_info(env, args[0], &data, &len) != c.napi_ok) {
        return throwTypeError(env, "checksum espera um Buffer");
    }

    const bytes = @as([*]const u8, @ptrCast(data.?))[0..len];
    const value = core.checksum(bytes);

    var result: c.napi_value = undefined;
    _ = c.napi_create_uint32(env, value, &result);
    return result;
}

O exemplo omite detalhes de registro para manter o foco. Em produção, trate todo status de N-API. Se uma chamada retornar algo diferente de napi_ok, converta para uma exceção JavaScript ou retorne null somente quando isso estiver documentado.

Memória: a regra mais importante

A maior fonte de bugs em addons nativos é posse de memória. A regra prática é simples: não retorne ponteiro para memória Zig temporária como se fosse memória JavaScript permanente.

Caminhos seguros:

  • se o retorno é pequeno, crie string ou buffer JavaScript com N-API e copie;
  • se o retorno é grande, considere napi_create_external_buffer com finalizer claro;
  • se a função recebe Buffer, trate o ponteiro como emprestado e válido só durante a chamada;
  • se guardar estado entre chamadas, exponha um handle opaco com finalizer;
  • se usar allocator, deixe o dono explícito e teste vazamentos.

Um padrão útil é manter o núcleo Zig sem alocar quando possível:

pub fn encode(input: []const u8, output: []u8) !usize {
    if (output.len < input.len * 2) return error.OutputTooSmall;
    // escreve no buffer do chamador
    return input.len * 2;
}

No lado Node, você calcula tamanho máximo, cria um Buffer, passa para o addon e depois corta para o tamanho real. Isso é menos elegante que retornar uma string mágica, mas é previsível.

Erros: converta para JavaScript cedo

Zig incentiva erros explícitos; JavaScript espera exceções ou objetos Result. Escolha uma convenção e mantenha.

Para bibliotecas internas, eu prefiro exceções para erro de uso e retorno estruturado para erro esperado de domínio:

// Erro de uso: tipo errado, argumento ausente
native.parse("texto"); // lança TypeError, esperava Buffer

// Erro esperado: arquivo inválido, payload recusado
const result = native.parsePayload(buffer);
if (!result.ok) console.error(result.reason);

No Zig, mapeie erros conhecidos para mensagens estáveis. Não vaze error.InvalidState42 como contrato público se você pretende mudar a implementação.

Build com Zig

O addon precisa ser uma biblioteca dinâmica com extensão .node. O build.zig varia conforme plataforma, mas o conceito é:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const lib = b.addSharedLibrary(.{
        .name = "meu_addon",
        .root_source_file = b.path("src/addon.zig"),
        .target = target,
        .optimize = optimize,
    });

    lib.linkLibC();
    lib.addIncludePath(b.path("../node-headers/include/node"));
    b.installArtifact(lib);
}

No empacotamento final, renomeie a saída para meu_addon.node e coloque em prebuilds/<platform>-<arch>/. Combine isso com uma matriz de CI como a do guia GitHub Actions para release multiplataforma.

Empacotamento npm

Para pacote privado ou público, evite compilar no postinstall por padrão. Compilação no computador do usuário quebra quando falta Python, compilador C, header de Node, permissões ou rede. Prefira binários pré-compilados.

Um fluxo prático:

  1. CI roda zig build test.
  2. CI compila .node para os targets suportados.
  3. CI empacota prebuilds/linux-x64, prebuilds/linux-arm64, prebuilds/darwin-arm64, prebuilds/darwin-x64 e prebuilds/win32-x64.
  4. loader.ts escolhe o binário em runtime.
  5. O pacote documenta fallback manual para quem quer compilar localmente.

Se o addon é usado apenas dentro da sua empresa, o mesmo padrão funciona em registry privado. O importante é não transformar instalação em build imprevisível.

Performance: meça a fronteira

Nem todo trecho nativo melhora performance. A chamada JS -> N-API tem custo. Copiar buffers tem custo. Serializar JSON para atravessar a fronteira pode custar mais que executar tudo em JavaScript.

Boas medições:

  • compare função JS pura, addon Zig e versão com cópia mínima;
  • rode payloads pequenos, médios e grandes;
  • separe tempo de conversão de tempo do algoritmo;
  • meça p95/p99, não só média;
  • rode em ReleaseFast e ReleaseSafe quando segurança em runtime importa;
  • inclua teste de memória e handles abertos.

Para metodologia, use o guia benchmarking em Zig e, se o serviço Node expõe API, compare também com observabilidade em Zig para definir sinais de produção.

Checklist antes de colocar em produção

Antes de depender de um addon Zig em Node.js, confira:

  • wrapper TypeScript valida argumentos antes de chamar nativo;
  • núcleo Zig tem testes sem Node;
  • ponte N-API trata todos os status relevantes;
  • nenhum ponteiro para memória temporária escapa;
  • buffers grandes evitam cópias desnecessárias;
  • erros têm mensagens estáveis;
  • CI compila os targets suportados;
  • pacote não depende de build no postinstall sem fallback claro;
  • benchmark prova ganho suficiente para justificar complexidade;
  • logs/telemetria mostram falhas de carregamento do binário.

Exemplo de bons casos de uso

Alguns módulos onde Zig pode pagar a complexidade:

Parser de log ou protocolo

Node recebe upload, fila ou stream. Zig parseia o formato binário, valida checksums e retorna offsets/erros. O JavaScript decide o fluxo de produto.

Validador de payload em lote

Um SaaS recebe arquivos CSV/JSONL grandes. Zig valida tipos, datas e limites em buffer, devolvendo lista compacta de erros. TypeScript transforma isso em relatório humano.

Codec ou compressão específica

Quando a operação aparece em todo request ou job, remover overhead de JavaScript e controlar alocação pode reduzir latência de cauda.

Wrapper de biblioteca C

Se a empresa já depende de uma biblioteca C estável, Zig pode oferecer uma camada mais segura e um pacote npm com binários pré-compilados, em vez de espalhar node-gyp pelo time.

Quando WebAssembly é melhor

Se você precisa rodar o mesmo núcleo no navegador e no servidor, ou quer sandbox forte por padrão, WebAssembly pode ser melhor que N-API. O custo é outro: ABI mais limitada, integração com arquivos/rede menos direta e tooling diferente.

Uma regra prática:

  • N-API para integração profunda com Node, buffers, bibliotecas C e máxima performance no servidor.
  • Wasm para sandbox, portabilidade entre browser/edge/server e fronteira de capacidades mais rígida.
  • Processo separado para isolamento operacional, quando crash nativo não pode derrubar o processo Node.

O melhor projeto às vezes usa os três: TypeScript para produto, Zig como CLI ou addon em pontos quentes e Wasm para plugins de terceiros.

Erros comuns

  1. Mover código cedo demais. Primeiro prove o gargalo com profiler.
  2. Passar JSON enorme pela fronteira. Prefira Buffer e formatos compactos.
  3. Compilar no postinstall sem plano B. Usuário não quer depurar toolchain.
  4. Misturar regra de negócio no addon. A ponte deve ser pequena e técnica.
  5. Ignorar ABI por plataforma. Linux glibc, musl, macOS e Windows têm diferenças reais.
  6. Esquecer finalizers. Estado nativo sem dono vira vazamento silencioso.
  7. Não testar falha de carregamento. O loader deve explicar qual binário faltou.

Conclusão

Zig não precisa substituir Node.js para ser útil em um produto Node. O uso mais forte é cirúrgico: manter TypeScript onde ele é produtivo e escrever em Zig o pedaço que processa bytes, conversa com C ou precisa de distribuição nativa previsível.

Comece com uma função pequena, benchmark claro e wrapper honesto. Se o ganho aparece mesmo depois do custo de fronteira, evolua para CI com binários pré-compilados. Se o ganho não aparece, ótimo: você provou que Node já estava bom o suficiente e evitou complexidade desnecessária.

Para continuar, leia CLI profissional em Zig, release multiplataforma com GitHub Actions e Zig vs Go para backend e infraestrutura.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.