wandres.dev
EL RUNTIME · workerd y Web APIs

nodejs_compat: APIs de Node cuando hacen falta

El flag que habilita un subconjunto curado de APIs de Node en Workers (node:buffer, node:crypto, node:stream, AsyncLocalStorage) implementadas de forma nativa en workerd, para que las librerías del ecosistema npm que asumen Node puedan correr en el edge.

⏱ 16 min

El runtime habla el estándar web, pero el ecosistema npm se escribió en gran parte contra Node. Cuando una librería útil —un driver de base de datos, un SDK de pagos, una utilidad de criptografía madura— importa node:crypto o usa Buffer, no tienes por qué renunciar a ella. El flag nodejs_compat habilita un subconjunto curado de APIs de Node dentro de workerd, implementadas de forma nativa, para tender un puente entre el estándar web del edge y el dialecto histórico de Node.

🎯 Al terminar esta lección sabrás
  • Entender por qué tantas librerías de npm asumen APIs de Node.
  • Activar nodejs_compat con el flag y la compatibility_date correctos.
  • Conocer las APIs que entran: node:buffer, node:crypto, node:stream, AsyncLocalStorage.
  • Saber que es un subconjunto nativo y curado, no todo Node ni un shim lento.

El desajuste con el ecosistema

Como viste en la lección 2, Workers eligió el estándar web sobre las APIs de Node. Pero millones de paquetes de npm nacieron cuando Node era el único servidor que importaba, y asumen su vocabulario: Buffer para bytes, node:crypto para hashing y firma, node:stream para flujos, EventEmitter para eventos. Ese código no es portable por defecto, y sin ayuda ni siquiera importa dentro de un Worker.

Bloquear todo ese ecosistema sería inaceptable en la práctica. Muchas librerías imprescindibles dependen de un puñado de builtins de Node, no porque sea imprescindible, sino porque se escribieron en una época en la que Node era el único destino. Reescribirlas todas contra el estándar web sería una tarea de años que nadie va a acometer.

En la práctica, los casos que empujan a activar el flag caen en unas pocas categorías reconocibles:

  • Drivers de base de datos que abren conexiones y serializan protocolos binarios.
  • SDKs oficiales de proveedores, escritos originalmente para backends de Node.
  • Librerías de criptografía que dependen de la interfaz síncrona de node:crypto.
  • Utilidades de bajo nivel que manipulan bytes apoyándose en Buffer.

El objetivo de nodejs_compat es tender el puente sin traicionar el modelo: ofrecer justo esas APIs, implementadas dentro del runtime, para que ese código corra en el edge sin reescribirse. No es una capitulación ante Node, sino un pragmatismo calculado: el valor de un runtime nuevo depende, en buena medida, de cuánto del ecosistema existente puede aprovechar desde el primer día.

Activar nodejs_compat

Se habilita con un compatibility flag, y exige una compatibility_date suficientemente reciente porque la versión moderna y unificada del soporte tiene su propia fecha por defecto:

{
  "name": "mi-worker",
  "main": "src/index.ts",
  "compatibility_date": "2024-09-23",
  "compatibility_flags": ["nodejs_compat"]
}

Con el flag activo aparecen los módulos con prefijo node: soportados y algunos globales como Buffer. La forma recomendada es siempre el prefijo node: explícito, que deja claro a la lectura que estás usando una API del runtime de Node y no un paquete de npm con el mismo nombre.

import { Buffer } from "node:buffer";
import { createHmac } from "node:crypto";

export default {
  async fetch(): Promise<Response> {
    const firma = createHmac("sha256", "secreto")
      .update("mensaje")
      .digest("hex");
    const b64 = Buffer.from("hola edge").toString("base64");
    return Response.json({ firma, b64 });
  },
};

Qué APIs entran

🧱

node:buffer

La clase Buffer, que es subclase de Uint8Array. Interopera con las APIs web de bytes y con librerías que la esperan como entrada o salida.

🔐

node:crypto

La API criptográfica de Node, con su interfaz síncrona y algoritmos que Web Crypto no expone directamente. Útil para librerías que la asumen.

🌊

node:stream

Los streams de Node, distintos de los del estándar web. Muchos parsers y transformadores del ecosistema los requieren para funcionar.

🧵

AsyncLocalStorage

De node:async_hooks. Propaga contexto —trazas, tenant, petición— a través de cadenas asíncronas sin pasarlo explícitamente por argumentos.

La lista crece con el tiempo e incluye además varios módulos que reconocerás del backend clásico:

  • node:util y node:assert para utilidades y comprobaciones.
  • node:path y node:url para manipular rutas y direcciones.
  • node:events con EventEmitter, base de innumerables librerías.
  • node:diagnostics_channel para instrumentación y observabilidad.

AsyncLocalStorage merece mención aparte: es la pieza que habilita las librerías de observabilidad y de contexto por petición en el modelo asíncrono del edge. Permite adjuntar datos a una petición y recuperarlos en cualquier punto de la cadena de llamadas asíncronas, sin ensuciar cada firma de función con un parámetro de contexto:

import { AsyncLocalStorage } from "node:async_hooks";

const contexto = new AsyncLocalStorage<{ requestId: string }>();

export default {
  async fetch(request: Request): Promise<Response> {
    const requestId = crypto.randomUUID();
    return contexto.run({ requestId }, async () => {
      // en cualquier función de aquí abajo: contexto.getStore()?.requestId
      return new Response(requestId);
    });
  },
};

Nativo, no un shim lento; y sus límites

Un matiz separa a nodejs_compat de un polyfill cualquiera: gran parte de estas APIs están implementadas en C++ dentro de workerd, no como lentas reimplementaciones en JavaScript. node:crypto llama a la misma criptografía nativa del runtime; Buffer es una vista real sobre memoria. El rendimiento es de primera clase, no un peaje que pagas por compatibilidad.

Que la implementación sea nativa tiene un efecto agradable: la interoperabilidad con las APIs web es directa, no una traducción. Buffer es subclase de Uint8Array, así que fluye entre ambos mundos sin copias ni conversiones raras:

import { Buffer } from "node:buffer";

const buf = Buffer.from("edge", "utf8");
buf instanceof Uint8Array;        // true: es un Uint8Array de pleno derecho
const vista = new Uint8Array(buf); // y de vuelta a la API web sin friccion

Para la superficie de Node que no está implementada de forma nativa pero sí tiene sentido en el edge, la cadena de build de Wrangler recurre a la capa unenv: rellena huecos con polyfills o redirige los imports hacia las implementaciones de workerd. El resultado, de cara a tu código, es una superficie unificada; por debajo hay una mezcla deliberada de implementación nativa y compatibilidad en tiempo de build.

Conviene conocer un poco de historia para no tropezar con documentación antigua. El soporte de Node pasó por varias versiones —una primera generación de nodejs_compat y una segunda que durante un tiempo convivieron con nombres distintos— hasta unificarse en el flag actual a partir de una fecha de compatibilidad reciente. Si encuentras referencias a nodejs_compat_v2 o a variantes experimentales, son vestigios de esa transición: en un proyecto nuevo, con una fecha reciente, basta con nodejs_compat a secas.

Pero es un subconjunto curado, no todo Node. Las APIs que chocan con el modelo del edge no están, y no van a estar: no hay fs con disco persistente, no hay child_process, no hay servidores que escuchen en un puerto. Esas ausencias no son huecos por rellenar, sino consecuencias del modelo, y la lección 5 las disecciona una a una.

💡
Prefiere el estándar web; reserva el flag para dependencias

Para código nuevo, elige la API web siempre que exista: crypto.subtle en vez de node:crypto, Uint8Array en vez de Buffer, los streams WHATWG en vez de los de Node. Así tu código es portable a Deno, Bun y el navegador sin cambios. Enciende nodejs_compat cuando una dependencia de terceros te obliga, no como atajo para seguir escribiendo al estilo de Node por costumbre.

Un puente hacia el ecosistema, no una rendición del modelo

nodejs_compat es fácil de malinterpretar como “al final Workers sí es Node”. No lo es, y la distinción importa. El runtime sigue siendo web-estándar en su núcleo; el flag añade un subconjunto deliberadamente acotado de APIs de Node para que el ecosistema existente no quede fuera, no para convertir el edge en un servidor Node tradicional. La elección de qué entra y qué no revela la filosofía: entran las APIs de cómputo puro —bytes, hashing, streams, contexto asíncrono— que son compatibles con un runtime efímero y multi-tenant; quedan fuera las que asumen una máquina con disco, procesos e hilos bajo tu control. Por eso la regla de oro sigue siendo preferir el estándar web cuando escribes código nuevo y reservar nodejs_compat para cuando una dependencia de terceros no te deja elegir. El flag es un puente hacia el pasado del ecosistema, valioso precisamente porque es una excepción consciente y no la norma. Cuando lo activas, deberías saber exactamente por qué: hay una librería que lo exige, no un hábito de Node que no te molestaste en abandonar. Esa disciplina es la que mantiene tu código en el lado portable de la historia, en lugar de atarlo de nuevo al dialecto del que el edge vino a liberarlo.

⚔️ Tiende el puente con criterio
  1. Activa nodejs_compat y usa createHmac de node:crypto para firmar un mensaje; compara la ergonomía con hacerlo vía crypto.subtle.
  2. Importa Buffer desde node:buffer y verifica con instanceof que es una subclase de Uint8Array.
  3. Instala una librería de npm que dependa de un builtin de Node y comprueba si arranca con el flag apagado y con él encendido.
  4. Usa AsyncLocalStorage para propagar un identificador de petición y recupéralo en una función anidada sin pasarlo por argumentos.