Read OSS

DevEnv 控制器模式:`wrangler dev` 如何编排本地开发环境

高级

前置知识

  • 本系列第 1 篇和第 2 篇文章
  • Node.js EventEmitter 模式
  • 观察者/发布-订阅模式
  • 对 esbuild 和 Miniflare 有基本了解

DevEnv 控制器模式:wrangler dev 如何编排本地开发环境

从外部看,wrangler dev 似乎简单得出奇:启动本地服务器,文件变更时自动重载,在浏览器中展示错误信息。而在这背后,它实际上是整个 SDK 中架构最为复杂的子系统——一个事件驱动的控制器总线,协调着五个独立控制器各自负责开发生命周期的不同阶段。

这套设计并非一蹴而就,而是在多重需求的驱动下逐步演进而来:用同一套代码库同时支持本地和远程开发模式、处理多 worker 配置、支持热模块替换、管理 esbuild 的增量构建,以及保持代理层与运行时层的相互独立。最终的成果,是一个构建在 Node.js EventEmitter 之上的微型 actor 系统。

DevEnv:作为控制器总线的 EventEmitter

packages/wrangler/src/api/startDevWorker/DevEnv.ts#L22-L70 中的 DevEnv 类继承自 EventEmitter 并实现了 ControllerBus 接口,内部持有四个控制器插槽:

export class DevEnv extends EventEmitter implements ControllerBus {
    config: ConfigController;
    bundler: BundlerController;
    runtimes: RuntimeController[];
    proxy: ProxyController;

注意 runtimes 是一个数组,这是有意为之的设计——它支持多 worker 本地开发,每个 worker 都拥有独立的运行时控制器。默认工厂函数会创建两个条目:LocalRuntimeControllerRemoteRuntimeController。事件会广播给所有运行时控制器,但通常在任意时刻只有一个处于活跃状态,取决于当前运行的是本地模式还是远程模式。

classDiagram
    class DevEnv {
        +config: ConfigController
        +bundler: BundlerController
        +runtimes: RuntimeController[]
        +proxy: ProxyController
        +dispatch(event): void
        +startWorker(options): Worker
        +teardown(): Promise
    }
    class ControllerBus {
        <<interface>>
        +dispatch(event): void
    }
    DevEnv --|> EventEmitter
    DevEnv ..|> ControllerBus
    DevEnv --> ConfigController
    DevEnv --> BundlerController
    DevEnv --> RuntimeController
    DevEnv --> ProxyController

工厂注入:可测试性与控制器替换

DevEnv 的构造函数并不直接实例化控制器,而是在 packages/wrangler/src/api/startDevWorker/DevEnv.ts#L45-L58 中接收工厂函数:

constructor({
    configFactory = (devEnv) => new ConfigController(devEnv),
    bundlerFactory = (devEnv) => new BundlerController(devEnv),
    runtimeFactories = [
        (devEnv) => new LocalRuntimeController(devEnv),
        (devEnv) => new RemoteRuntimeController(devEnv),
    ],
    proxyFactory = (devEnv) => new ProxyController(devEnv),
}: { /* ... typed factory signatures ... */ } = {}) {

这是构造函数层面的依赖注入。单元测试可以注入不启动真实进程的 stub 控制器;多 worker 模式下使用的 MultiworkerRuntimeController 可以替换默认的运行时工厂;当不需要代理层时,也可以注入 NoOpProxyController

每个工厂函数都接收 DevEnv 实例本身,使控制器能够通过总线派发事件。这种循环引用是刻意设计的——控制器需要能够向总线回传消息。

提示: 如果你在为涉及 DevEnv 的 wrangler 命令编写测试,建议注入 mock 控制器工厂,而不是对整个 DevEnv 进行 mock。工厂模式的设计初衷之一就是为了方便测试。

dispatch() 事件路由表

DevEnv 的核心是位于 packages/wrangler/src/api/startDevWorker/DevEnv.ts#L86-L135dispatch() 方法。它是一个简洁的 switch 语句,负责将事件路由到对应的控制器:

sequenceDiagram
    participant CC as ConfigController
    participant BC as BundlerController
    participant RC as RuntimeController(s)
    participant PC as ProxyController
    participant DE as DevEnv (dispatch)

    CC->>DE: configUpdate
    DE->>BC: onConfigUpdate
    DE->>PC: onConfigUpdate

    BC->>DE: bundleStart
    DE->>PC: onBundleStart
    DE->>RC: onBundleStart

    BC->>DE: bundleComplete
    DE->>RC: onBundleComplete

    RC->>DE: reloadStart
    DE->>PC: onReloadStart

    RC->>DE: reloadComplete
    DE->>PC: onReloadComplete

    RC->>DE: devRegistryUpdate
    DE->>CC: onDevRegistryUpdate

    PC->>DE: previewTokenExpired
    DE->>RC: onPreviewTokenExpired

路由逻辑是显式的,没有动态事件订阅,也不存在观察者模式的复杂性。阅读 dispatch() 即可直观地了解哪些事件会影响哪些控制器。default 分支利用了 TypeScript 的穷举检查(const _exhaustive: never = event),确保编译器能捕获所有未处理的事件类型。

事件类型本身在 packages/wrangler/src/api/startDevWorker/events.ts 中以可辨识联合类型(discriminated union)定义。每个事件都携带当前的 config,在适用的情况下还会携带当前的 bundle,确保控制器始终能获取最新状态,无需自行缓存可能已过期的数据。

BaseController 与 RuntimeController 抽象类

所有控制器都继承自 packages/wrangler/src/api/startDevWorker/BaseController.ts#L27-L49 中的 Controller 基类:

export abstract class Controller {
    protected bus: ControllerBus;
    #tearingDown = false;

    protected emitErrorEvent(event: ErrorEvent) {
        if (this.#tearingDown) {
            logger.debug("Suppressing error event during teardown");
            return;
        }
        this.bus.dispatch(event);
    }
}

#tearingDown 标志用于在关闭过程中抑制错误事件。若没有这个机制,dispose() 期间的竞态条件会将那些本就是被主动终止的进程所产生的错误暴露给用户。

packages/wrangler/src/api/startDevWorker/BaseController.ts#L51-L75 中的 RuntimeController 抽象子类定义了运行时实现的契约:

export abstract class RuntimeController extends Controller {
    abstract onBundleStart(_: BundleStartEvent): void;
    abstract onBundleComplete(_: BundleCompleteEvent): void;
    abstract onPreviewTokenExpired(_: PreviewTokenExpiredEvent): void;

    protected emitReloadStartEvent(data: ReloadStartEvent): void { /*...*/ }
    protected emitReloadCompleteEvent(data: ReloadCompleteEvent): void { /*...*/ }
    protected emitDevRegistryUpdateEvent(data: DevRegistryUpdateEvent): void { /*...*/ }
}

抽象事件处理器(运行时必须响应的事件)与受保护的事件发射器(运行时可以触发的事件)之间的分离,构成了一个清晰的双向契约。

五个控制器详解

每个控制器负责开发流水线的一个阶段:

ConfigControllerConfigController.ts 第 495 行)负责读取 Wrangler 配置、解析入口点、通过 chokidar 监听文件变化、解析可用端口,并发出 configUpdate 事件。它还会处理来自运行时的 devRegistryUpdate 事件,形成一个反馈闭环——运行时可以将多 worker 开发过程中发现的其他 worker 信息回传给配置层。

BundlerControllerBundlerController.ts 第 29 行)管理 esbuild。收到 configUpdate 时,它会建立或重新配置 esbuild 的增量构建。它还处理正在进行的构建的中止信号——如果 esbuild 仍在打包上一次变更时新的配置变更又到来,旧的构建将被取消。它发出 bundleStart(供代理展示加载状态)和 bundleComplete(供运行时触发重载)事件。

LocalRuntimeControllerLocalRuntimeController.ts 第 152 行)管理一个 Miniflare 实例。收到 bundleComplete 后,它将配置和 bundle 转换为 Miniflare 选项,并调用 setOptions() 重载 worker。它使用 Mutex 来串行化更新——这至关重要,因为 buildMiniflareOptions() 是异步的,没有互斥锁的保护,快速连续的更新可能会乱序应用。

RemoteRuntimeController 管理 Cloudflare 边缘网络上的预览会话。它将 Worker 代码上传到远程预览端点,并携带远程预览 URL 作为代理数据发出 reloadComplete 事件。

ProxyControllerProxyController.ts 第 46 行)运行其自身独立的 Miniflare 实例,作为面向用户的 HTTP 代理。这是整个架构中最出人意料的设计决策,值得单独展开说明。

flowchart LR
    CONFIG["ConfigController<br/>watches config files"] -->|configUpdate| BUS["DevEnv Bus"]
    BUS -->|configUpdate| BUNDLER["BundlerController<br/>manages esbuild"]
    BUNDLER -->|bundleComplete| BUS
    BUS -->|bundleComplete| RUNTIME["RuntimeController<br/>manages Miniflare"]
    RUNTIME -->|reloadComplete| BUS
    BUS -->|reloadComplete| PROXY["ProxyController<br/>HTTP proxy"]

两层代理架构

这里有一个关键细节,粗读代码时很容易忽视:ProxyController 在 packages/wrangler/src/api/startDevWorker/ProxyController.ts#L60-L85 中运行着自己的 Miniflare 实例,与 LocalRuntimeController 中的那个完全独立。

为什么要这样设计?因为代理本身就是一个 Cloudflare Worker——一个将请求转发给你的 Worker 的 Worker。这使得代理能够完整地访问 Workers API,从而实现:

  • 实时重载注入 — 代理可以拦截 HTML 响应并注入实时重载脚本
  • 请求检查 — 在请求到达你的 Worker 之前对其进行检查和修改
  • Inspector 代理 — 代理托管一个 WebSocket 端点,将 Chrome DevTools 与你的 Worker 的 V8 inspector 连接起来
  • 错误渲染 — 友好的错误页面由代理 Worker 渲染,而不是你的 Worker
flowchart LR
    BROWSER["Browser"] -->|"HTTP request"| PROXY_MF["ProxyController's Miniflare<br/>(Proxy Worker)"]
    PROXY_MF -->|"forwarded request"| RUNTIME_MF["LocalRuntimeController's Miniflare<br/>(Your Worker)"]
    RUNTIME_MF -->|"response"| PROXY_MF
    PROXY_MF -->|"modified response<br/>(+ live reload)"| BROWSER
    DEVTOOLS["Chrome DevTools"] -->|"WebSocket"| PROXY_MF
    PROXY_MF -->|"WebSocket"| RUNTIME_MF

这种两层架构意味着用户访问的端口(默认 8787)由代理 Miniflare 提供服务,而你的 Worker 运行在一个只能通过代理访问的内部端口上。当 Worker 重载时,ProxyController 会收到携带新内部 URL 的 reloadComplete 事件,并无缝更新代理的路由配置。

startDev() 与多 Worker 支持

wrangler dev 的入口是 packages/wrangler/src/dev/start-dev.ts#L31 中的 startDev() 函数。单 worker 配置下,它创建一个 DevEnv 实例;当配置中通过数组指定了多个 worker 时,则会创建多个 DevEnv 实例,并通过来自 packages/wrangler/src/api/startDevWorker/MultiworkerRuntimeController.tsMultiworkerRuntimeController 进行协调。

在多 worker 模式下,主 worker 拥有包含全部控制器的完整 DevEnv,而次级 worker 可能使用 NoOpProxyController,因为所有入站流量都由主代理统一处理。MultiworkerRuntimeController 负责串联各 worker 的 dev registry 更新,使 worker 在本地开发时能够相互发现对方的服务绑定。

提示: startDev() 中的 devEnv 变量类型为 DevEnv | DevEnv[] | undefined——这个联合类型揭示了三种状态:单 worker、多 worker,或尚未初始化。调试多 worker 问题时,请先确认你正在查看的是主 DevEnv 还是次级 DevEnv。

下一步

本文中的 LocalRuntimeController 将所有繁重工作都委托给了 Miniflare——而 Miniflare 本身是一个相当庞大的系统,拥有 28 个插件、一个 workerd 子进程管理器,以及独立的配置序列化流水线。第 4 篇文章将带我们深入 Miniflare 的内部架构。