利用 Zig 构建 WASI 插件系统并以 Event Sourcing 模式重构 esbuild 构建内核


esbuild 的性能毋庸置疑,但其插件系统在处理极端性能敏感或需要系统级能力的场景时,会触碰到天花板。基于 Go 的原生插件 API 无法在 Node.js 环境直接使用,而 Node.js 插件 API 则受限于 JS 引擎的性能和 V8 与 Go 之间上下文切换的开销。一个常见的痛点是,当一个插件需要执行 CPU 密集型任务(如图像优化、代码混淆、AST 深度分析)时,JavaScript 的性能短板就暴露无遗。

我们的目标是设计一个构建系统内核,它不仅要快,而且其扩展性要能突破语言的界限。初步构想是,插件不应局限于 JavaScript,而应能用像 C++、Rust 或 Zig 这样的系统级语言编写,并以一种安全、可移植的方式运行。WebAssembly (WASM) 及其系统接口 (WASI) 是实现这一目标的理想技术。Zig 语言因其对 C ABI 的无缝兼容、手动内存管理带来的极致控制以及卓越的交叉编译能力,成为编写高性能 WASM 插件的首选。

但仅仅替换插件执行引擎还不够。构建过程本身就是一个复杂的状态转换序列。文件的读取、依赖解析、代码转换、代码合并……每一步都改变着最终的产出。传统的状态管理方式,即维护一个巨大的、可变的状态对象(如构建图),在调试、缓存和并发处理上都面临挑战。如果构建失败,我们很难追溯是哪一步的转换引入了问题。Event Sourcing 提供了一种截然不同的思路:将构建过程中的每一步都建模为不可变事件。整个构建过程的状态,就是所有事件依次应用的最终结果。

这个方案的核心技术选型决策如下:

  1. Orchestrator (宿主环境): Node.js/TypeScript。保持与前端生态的兼容性,负责流程编排和事件分发。
  2. Plugin Runtime: WASI。提供一个标准的、沙箱化的执行环境,解耦插件与宿主。
  3. Plugin Language: Zig。用于编写需要极致性能的插件,编译成轻量的 WASI 模块。
  4. State Management: Event Sourcing。将构建流程建模为事件流,提升可追溯性、可测试性和潜在的并发能力。

第一步:用 Event Sourcing 定义构建流程

我们不再维护一个庞大可变的 buildContext 对象,而是定义一系列命令 (Commands) 和事件 (Events)。

  • Commands: 代表执行构建操作的意图。例如 StartBuildCommand, ApplyTransformCommand
  • Events: 代表已经发生的事实。例如 BuildStartedEvent, FileReadEvent, TransformAppliedEvent, DependencyResolvedEvent

一个聚合 (Aggregate),在这里就是 BuildProcess,负责处理命令并生成事件。它的状态完全由历史事件推导而来。

// src/events.ts

// 定义不同类型的事件
export type BuildEvent =
  | { type: 'BUILD_STARTED'; payload: { entryPoint: string; timestamp: number } }
  | { type: 'FILE_READ'; payload: { path: string; content: string } }
  | { type: 'DEPENDENCY_RESOLVED'; payload: { from: string; to: string } }
  | { type: 'TRANSFORM_APPLIED'; payload: { path: string; transformer: string; content: string } }
  | { type: 'BUILD_FAILED'; payload: { reason: string } }
  | { type: 'BUILD_COMPLETED'; payload: { output: Map<string, string> } };

// 一个简单的内存事件存储
export class InMemoryEventStore {
  private events: BuildEvent[] = [];

  public save(event: BuildEvent): void {
    // 在真实项目中,这里会是数据库写入、消息队列发送等操作
    console.log(`[EventStore] Saving event: ${event.type}`);
    this.events.push(event);
  }

  public getEvents(): readonly BuildEvent[] {
    return this.events;
  }
}

// BuildProcess 聚合,是核心的状态机
export class BuildProcessAggregate {
  private state: {
    id: string;
    entryPoint: string;
    files: Map<string, string>; // path -> content
    dependencyGraph: Map<string, string[]>; // file -> dependencies
  };

  constructor(buildId: string) {
    this.state = {
      id: buildId,
      entryPoint: '',
      files: new Map(),
      dependencyGraph: new Map(),
    };
  }

  // 核心方法:根据事件更新状态
  public apply(event: BuildEvent): void {
    switch (event.type) {
      case 'BUILD_STARTED':
        this.state.entryPoint = event.payload.entryPoint;
        break;
      case 'FILE_READ':
        this.state.files.set(event.payload.path, event.payload.content);
        break;
      case 'DEPENDENCY_RESOLVED':
        const deps = this.state.dependencyGraph.get(event.payload.from) || [];
        deps.push(event.payload.to);
        this.state.dependencyGraph.set(event.payload.from, deps);
        break;
      case 'TRANSFORM_APPLIED':
        // 应用转换后的内容
        this.state.files.set(event.payload.path, event.payload.content);
        break;
    }
  }

  public getState() {
    return this.state;
  }

  // 从事件历史中重建状态
  public static replay(buildId: string, events: BuildEvent[]): BuildProcessAggregate {
    const aggregate = new BuildProcessAggregate(buildId);
    for (const event of events) {
      aggregate.apply(event);
    }
    return aggregate;
  }
}

这种结构的好处是显而易见的。任何时刻的构建状态都可以通过重放事件来精确复现,这对于调试复杂构建问题是无价的。

第二步:设计 Zig 与宿主的 WASI 插件接口

为了让宿主(TypeScript)能调用 Zig 编写的 WASM 插件,必须定义一个清晰的 ABI (Application Binary Interface)。WASI 自身提供了文件、网络等系统调用,但我们需要定义应用层面的函数。

我们的插件需要实现一个 transform 函数。它接收文件内容作为输入,返回转换后的内容。由于 WASM 的内存是隔离的,数据交换必须通过内存指针和长度来完成。

约定:

  1. 宿主分配一块内存,将源代码写入 WASM 实例。
  2. 宿主调用插件的 transform(ptr, len) 函数,传入内存地址和长度。
  3. 插件在自己的内存中处理数据,并分配一块新的内存存放结果。
  4. 插件返回一个 64 位整数,高 32 位是结果的内存地址,低 32 位是结果的长度。
  5. 宿主读取结果内存,并负责调用插件导出的 free_memory(ptr, len) 函数来释放插件分配的内存,避免内存泄漏。

下面是一个 Zig 插件的实现,它将所有 “esbuild” 字符串替换为 “ZIGBUILD”。

// plugins/replacer.zig
const std = @import("std");

// 定义一个分配器,WASI 环境下需要自己管理内存
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();

// 导出给宿主调用的 transform 函数
export fn transform(source_ptr: *const u8, source_len: usize) u64 {
    const source_slice = source_ptr[0..source_len];
    const target = "esbuild";
    const replacement = "ZIGBUILD";

    // 使用 ArrayList 来动态构建结果字符串
    var result_list = std.ArrayList(u8).init(allocator);
    defer result_list.deinit();

    var last_index: usize = 0;
    while (std.mem.indexOf(u8, source_slice[last_index..], target)) |found_index_relative| {
        const found_index = last_index + found_index_relative;
        // 将匹配之前的部分写入结果
        result_list.appendSlice(source_slice[last_index..found_index]) catch |err| {
            // 在真实项目中,错误处理应该更完善,例如返回一个特殊的错误码
            std.debug.print("Allocation failed: {}\n", .{err});
            return 0;
        };
        // 写入替换字符串
        result_list.appendSlice(replacement) catch |err| {
            std.debug.print("Allocation failed: {}\n", .{err});
            return 0;
        };
        last_index = found_index + target.len;
    }

    // 写入剩余的部分
    result_list.appendSlice(source_slice[last_index..]) catch |err| {
        std.debug.print("Allocation failed: {}\n", .{err});
        return 0;
    };

    const result_slice = result_list.toOwnedSlice() catch |err| {
        std.debug.print("Allocation failed: {}\n", .{err});
        return 0;
    };

    // 返回结果的指针和长度,打包成一个 u64
    const result_ptr_val: u64 = @intFromPtr(result_slice.ptr);
    const result_len_val: u64 = result_slice.len;
    
    return (result_ptr_val << 32) | result_len_val;
}

// 导出给宿主调用的内存释放函数
export fn free_memory(ptr: *u8, len: usize) void {
    const slice = ptr[0..len];
    allocator.free(slice);
}

// 这是 WASI 的入口点,虽然我们不直接用它,但它是必须的
pub fn main() !void {}

编译这个 Zig 文件为 WASI 模块:
zig build-exe -target wasm32-wasi -O ReleaseSmall plugins/replacer.zig

第三步:在 TypeScript 中实现 WASI 插件加载与执行器

宿主需要一个通用的 WasiPluginRunner 来加载和执行这些 WASM 插件。这里我们使用 node:wasi@wasmer/sdk 库来简化操作。

// src/wasi-runner.ts
import fs from 'fs/promises';
import { WASI } from 'wasi';
import { WasmFs } from '@wasmer/fs';
import { lowerI64Imports } from "@wasmer/wasm-transformer";

// WASI 插件运行器
export class WasiPluginRunner {
  private instance!: WebAssembly.Instance;
  private wasi!: WASI;
  private wasmFs: WasmFs;

  constructor() {
    this.wasmFs = new WasmFs();
  }

  // 加载 WASM 插件
  async load(pluginPath: string): Promise<void> {
    const wasmBytes = await fs.readFile(pluginPath);
    // wasm-transformer 用于处理 64 位整数的兼容性问题
    const loweredWasm = await lowerI64Imports(wasmBytes);
    
    this.wasi = new WASI({
      args: [],
      env: {},
      preopens: {},
      fs: this.wasmFs as any, // 强类型转换,以适配 WASI 构造函数
    });

    const module = await WebAssembly.compile(loweredWasm);
    this.instance = await WebAssembly.instantiate(module, this.wasi.getImports(module));
    this.wasi.start(this.instance);
  }

  // 执行 transform 函数
  public transform(source: string): string {
    const exports = this.instance.exports as any;
    if (!exports.transform || !exports.free_memory || !exports.memory) {
      throw new Error('Plugin does not export required functions: transform, free_memory, memory');
    }

    const sourceBuffer = Buffer.from(source, 'utf8');
    
    // 1. 在 WASM 内存中分配空间并写入源数据
    // 注意:这里简单起见直接在内存起始位置写,实际应用需要一个分配器
    // 一个更健壮的实现是插件导出一个 allocate 函数
    const sourcePtr = 0; // 假设从地址0开始
    const wasmMemory = new Uint8Array(exports.memory.buffer);
    wasmMemory.set(sourceBuffer, sourcePtr);
    
    // 2. 调用插件的 transform 函数
    const resultHandle: bigint = exports.transform(sourcePtr, sourceBuffer.length);
    if (resultHandle === 0n) {
        throw new Error("WASM plugin execution failed.");
    }

    // 3. 解析返回的指针和长度
    const resultPtr = Number(resultHandle >> 32n);
    const resultLen = Number(resultHandle & 0xFFFFFFFFn);

    // 4. 从 WASM 内存中读取结果
    const resultBuffer = Buffer.from(wasmMemory.slice(resultPtr, resultPtr + resultLen));
    const result = resultBuffer.toString('utf8');

    // 5. 调用插件的 free_memory 释放内存
    exports.free_memory(resultPtr, resultLen);

    return result;
  }
}

这里的内存交互是关键也是最容易出错的地方。生产级的实现需要一个更复杂的内存管理策略,例如在宿主和 WASM 之间共享一个由 WASM 模块自己管理的分配器(通过导出 allocatedeallocate 函数)。

第四步:将所有部分整合到构建流程中

现在,我们可以创建一个编排器,它使用 Event Sourcing 模型来驱动整个构建过程,并在需要时调用 WasiPluginRunner

graph TD
    A[StartBuildCommand] --> B{Build Orchestrator};
    B --> C[Create BuildStartedEvent];
    C --> D[EventStore.save];
    D --> E{State Projection};
    E --> F[BuildProcessAggregate State];
    
    subgraph Build Loop
        B -- 1. Read Entry File --> G[Create FileReadEvent];
        G --> D;
        B -- 2. Apply Transformer --> H(Invoke WasiPluginRunner);
        H -- Zig Plugin --> I[Get Transformed Content];
        I --> J[Create TransformAppliedEvent];
        J --> D;
        B -- 3. Resolve Dependencies --> K[Create DependencyResolvedEvent];
        K --> D;
    end
    
    B --> L[Create BuildCompletedEvent];
    L --> D;

这是主流程的实现:

// src/main.ts
import { BuildEvent, InMemoryEventStore, BuildProcessAggregate } from './events';
import { WasiPluginRunner } from './wasi-runner';
import path from 'path';
import fs from 'fs/promises';
import { randomUUID } from 'crypto';

class BuildOrchestrator {
  private eventStore = new InMemoryEventStore();
  private wasiRunner = new WasiPluginRunner();

  async run(entryPoint: string): Promise<BuildProcessAggregate> {
    const buildId = randomUUID();
    
    // 1. 加载 WASI 插件
    await this.wasiRunner.load(path.resolve(__dirname, '../plugins/replacer.wasm'));

    // 2. 发出构建开始事件
    this.dispatch({
      type: 'BUILD_STARTED',
      payload: { entryPoint, timestamp: Date.now() },
    });

    // 3. 递归处理文件
    await this.processFile(entryPoint);
    
    // 4. ... 此处应有代码合并 (bundling) 逻辑 ...
    
    // 5. 最终生成聚合状态
    const finalAggregate = BuildProcessAggregate.replay(buildId, this.eventStore.getEvents());

    // 6. 发出构建完成事件 (简化)
    this.dispatch({
        type: 'BUILD_COMPLETED',
        payload: { output: finalAggregate.getState().files }
    });
    
    console.log('\n--- Final Build State ---');
    console.log(finalAggregate.getState());
    
    return finalAggregate;
  }
  
  private async processFile(filePath: string) {
    try {
      const content = await fs.readFile(filePath, 'utf-8');
      this.dispatch({ type: 'FILE_READ', payload: { path: filePath, content } });
      
      // 应用 WASI 插件转换
      const transformedContent = this.wasiRunner.transform(content);
      this.dispatch({
        type: 'TRANSFORM_APPLIED',
        payload: { path: filePath, transformer: 'zig-replacer', content: transformedContent },
      });
      
      // 此处应有依赖解析逻辑,并为每个依赖递归调用 processFile
      // 例如,解析 import/require 语句,然后 dispatch 'DEPENDENCY_RESOLVED' 事件

    } catch (error) {
      this.dispatch({ type: 'BUILD_FAILED', payload: { reason: (error as Error).message } });
      throw error;
    }
  }

  private dispatch(event: BuildEvent) {
    this.eventStore.save(event);
  }
}

// --- 执行 ---
async function main() {
  // 创建一个示例文件
  const entryFile = path.resolve(__dirname, 'example.js');
  await fs.writeFile(entryFile, `
    console.log("Hello from esbuild!");
    // This comment about esbuild should be replaced.
  `);

  const orchestrator = new BuildOrchestrator();
  await orchestrator.run(entryFile);

  await fs.unlink(entryFile); // 清理
}

main().catch(console.error);

运行这个程序,你会看到控制台清晰地打印出每一步的事件,从 BUILD_STARTEDFILE_READ,再到 TRANSFORM_APPLIED,最后看到 BuildProcessAggregate 的最终状态,其中 example.js 的内容已经被 Zig 插件修改。

当前方案的局限性与未来展望

这个原型验证了核心思想的可行性,但在生产环境中还存在一些问题。

  1. 性能瓶颈: 最大的瓶颈在于宿主与 WASM 之间的数据拷贝。每次调用 transform 都涉及至少两次完整的内存拷贝。WebAssembly 的共享内存提案(Wasm Threads)将是解决这个问题的关键,它允许宿主和 WASM 实例直接在同一块内存上操作,从而消除拷贝开销。

  2. 插件 ABI 的健壮性: 目前的 ABI 非常简单。一个成熟的系统需要更复杂的接口,例如传递结构化数据(AST)、提供日志、文件系统访问等宿主能力的 API 回调,以及更精细的错误处理机制。这需要一套类似 Protocol Buffers 或 Cap’n Proto 的序列化方案在边界上工作。

  3. 事件存储: 内存中的 EventStore 仅用于演示。在真实系统中,可以替换为持久化存储如 PostgreSQL、或者流式平台如 Kafka。使用 Kafka 还能将构建的不同阶段解耦为独立的微服务,例如一个服务负责文件读取,另一个服务负责代码转换,从而实现分布式并行构建。Event Sourcing 的模型天然地支持这种架构。

  4. Zig 的生态: 尽管 Zig 语言本身非常强大,但其生态系统与 Rust 或 Go 相比还处于早期阶段。在编写复杂插件时,可能会缺少现成的库。

尽管存在这些挑战,但将 Zig/WASI 的极致性能、Event Sourcing 的架构清晰度与 esbuild 的核心理念相结合,为构建下一代高性能、高可扩展性的开发工具链开辟了一条充满可能性的道路。


  目录