* notes / bun

Cómo Zig fue bifurcado por Bun y por qué importa

Bun no usa Zig upstream: mantiene su propio fork del compilador. Investigué qué cambios introduce, por qué la inestabilidad pre-1.0 de Zig lo hace casi inevitable y qué paralelismos guarda con el ecosistema de JavaScriptCore.

November 4, 20257 min read#bun#zig#compiladores#javascriptcore#open-source

Revisando el repositorio de Bun encontré que su dependencia de compilación apunta a oven-sh/zig, no al canónico ziglang/zig. No es un mirror: Bun mantiene un fork completo del compilador de Zig, con parches propios y rebases periódicos contra upstream. Vale la pena entender por qué un runtime toma esa decisión y qué implica mantenerla.

Contexto: por qué Bun adoptó Zig

Bun, el runtime de JavaScript creado por Jarred Sumner, persigue una promesa concreta: ser drástico en latencia de arranque y en throughput de I/O. Para lograrlo, Sumner descartó tanto C++ (por su lentitud de compilación y por la verbosidad de su gestión de memoria) como Rust, cuya disciplina de borrow-checker entorpecía la cohabitación con un motor de JavaScript que asume punteros compartidos sin reglas estáticas. Zig, en cambio, ofrecía tres rasgos que el proyecto necesitaba:

  1. Sin asignador implícito: cada función que necesita memoria recibe un Allocator como parámetro. Esa transparencia encaja con runtimes que quieren arenas por petición, libres de fragmentación.
  2. comptime como metaprogramación de primera clase: permite generar tablas de despacho, validar layouts de structs en tiempo de compilación y escribir código genérico sin el ruido sintáctico de los templates de C++.
  3. Cross-compilación trivial: zig build produce binarios para macOS, Linux musl/glibc y Windows desde una sola máquina, lo cual reduce la matriz de CI a una fracción de su tamaño habitual.

No obstante, Zig vive todavía en su fase pre-1.0. Cada versión menor (0.10, 0.11, 0.12, 0.13) ha modificado el lenguaje, su biblioteca estándar y, crucialmente, el sistema de construcción. Por consiguiente, Bun se enfrentaba a un dilema: o congelaba una versión de Zig y absorbía el coste de mantenerla, o saltaba a cada upstream rompiendo su CI cada pocos meses. Optó por una vía intermedia: mantener un fork al que portar selectivamente las novedades de upstream, conservando el control sobre cuándo y cómo migrar.

Diagrama del linaje

ziglang/zig (upstream)0.110.120.13masteroven-sh/zig (fork)+ parches JSC+ build flags+ ABI fixesbun (binario)

Qué contiene el fork

Sin pretender exhaustividad (los detalles fluctúan con cada rebase), el fork de Bun acumula tres categorías de cambios:

  • Integración con JavaScriptCore: Bun embebe el motor de WebKit (no V8) y necesita generar bindings específicos entre tipos de Zig y JSCell, JSValue o JSGlobalObject. Algunos de esos bindings se beneficiaron de extensiones puntuales al sistema de tipos o a la ABI de Zig.
  • Ajustes al sistema de construcción: zig build es flexible pero, en proyectos del tamaño de Bun, su frontend de Bash y CMake combinados resultaba insuficiente. El fork incluye optimizaciones para construir bibliotecas C++, como ICU o la propia WebKit, y enlazarlas estáticamente sin sobresaltos.
  • Estabilizaciones selectivas: cuando upstream rompe una API que Bun usa intensivamente, el fork retiene la versión antigua mientras se completa la migración. Esto evita lo que en círculos de Rust se llama backporting fatigue.

Cabe destacar que mantener un fork no es gratuito. Cada rebase periódico contra upstream exige resolver conflictos, validar regresiones y, en ocasiones, reescribir parches enteros. La inversión, no obstante, es coherente con el resto de Bun: el proyecto controla su pila técnica casi en su totalidad (Zig, JavaScriptCore, parser propio, transpilador propio, gestor de paquetes propio) como condición para ofrecer la promesa de rendimiento extremo que define al runtime.

Una muestra del Zig idiomático

Para apreciar por qué Zig cautivó al proyecto, vale la pena leer un fragmento típico. El siguiente código define una caché simple, pasando el allocator de forma explícita y usando comptime para parametrizar el tipo:

const std = @import("std");
 
pub fn Cache(comptime K: type, comptime V: type) type {
    return struct {
        const Self = @This();
        allocator: std.mem.Allocator,
        map: std.AutoHashMap(K, V),
 
        pub fn init(allocator: std.mem.Allocator) Self {
            return .{
                .allocator = allocator,
                .map = std.AutoHashMap(K, V).init(allocator),
            };
        }
 
        pub fn deinit(self: *Self) void {
            self.map.deinit();
        }
 
        pub fn getOrInsert(
            self: *Self,
            key: K,
            comptime compute: fn (K) V,
        ) !V {
            const result = try self.map.getOrPut(key);
            if (!result.found_existing) {
                result.value_ptr.* = compute(key);
            }
            return result.value_ptr.*;
        }
    };
}
 
test "cache de cuadrados" {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
 
    var cache = Cache(u32, u64).init(gpa.allocator());
    defer cache.deinit();
 
    const square = struct {
        fn f(x: u32) u64 {
            return @as(u64, x) * x;
        }
    }.f;
 
    try std.testing.expectEqual(@as(u64, 49), try cache.getOrInsert(7, square));
}

Tres rasgos saltan a la vista. Primero, Cache(u32, u64) es una función ejecutada en compile time que retorna un tipo: Zig no tiene genéricos en el sentido tradicional; los simula compilando una instancia distinta de la estructura por cada combinación de parámetros. Segundo, el allocator viaja con la estructura: nada se asigna en silencio. Tercero, el defer y la idiomática inicialización mediante struct literal evitan los rituales de constructor/destructor habituales en C++. Multiplica esto por millones de líneas y se entiende por qué un proyecto orientado a rendimiento valora la transparencia del modelo.

Paralelismos: V8, JavaScriptCore y la cultura del fork

La elección de Bun no es excéntrica en su entorno. Node.js lleva un fork interno de V8 con parches; Deno depende del crate rusty_v8, que a su vez se ancla a un commit concreto de V8. JavaScriptCore, embebido por Bun, vive en el monorepo de WebKit y se compila con un sistema de construcción artesanal. En todos los casos la fórmula es idéntica: un componente upstream tan vivo que congelarlo es imprescindible, y un parche delta lo bastante reducido como para portarse cada cierto tiempo.

Esta estrategia inscribe a Bun en una tradición conocida: Brave parchea Chromium; LibreOffice parcheó OpenOffice durante años antes de divergir definitivamente. El fork explícito no es debilidad: es la forma en que un proyecto admite, y gestiona, su acoplamiento a una dependencia que evoluciona más rápido o más lento de lo que conviene a su roadmap.

Lo que me llevo del descubrimiento

Encontrar oven-sh/zig en las dependencias de Bun me forzó a reformular una intuición que considero equivocada: la de que "una dependencia upstream estable es suficiente". Para un runtime con compromisos de rendimiento y compatibilidad de varios años, la única estabilidad real es la que el propio proyecto se garantiza con sus propias manos. El fork, lejos de ser un acto de desconfianza, es una infraestructura de continuidad.

Closure

Bun no podría existir, al menos no con el perfil que conocemos hoy, sin la decisión deliberada de bifurcar Zig. Esa elección encierra una lección que trasciende al runtime concreto: cuando una dependencia central es a la vez la mejor opción y un blanco móvil, la solución profesional rara vez es esperar a que se estabilice. Es asumir el coste de gobernarla. Cada bun install ejecutado hoy reposa, silenciosamente, sobre ese coste asumido. No me parece poca cosa.