Bun Worker:TypeScript 服务端的本地调试与生产运行

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

最初使用 Bun Worker 运行后台任务时,真正卡住我的不是线程通信,而是调试:主线程中的 TypeScript 可以在 VS Code 里打断点,进入 Worker 后却无法正常命中。后台时间线又包含大量状态切换,只靠日志很难看清一次执行中变量究竟怎样变化。

RUN_WORKER_IN_MAIN 正是为这个问题增加的。调试时让 worker.ts 的业务循环暂时回到主线程,正式环境仍使用独立 Worker。为了让两种模式不维护两套逻辑,启动方式、线程判断、消息发布和构建入口随后又经历了多次修改。

最终可用的结构只有一套 Worker 业务逻辑,但保留两条运行路径:本地测试时在主线程显式启动,正式环境由 Bun Worker 加载独立产物。两条路径的区别只在消息如何交给 WebSocket,调度和业务代码不会分叉。

RUN_WORKER_IN_MAIN 解决的不是性能问题

这个变量的命名容易让人误以为它只是普通的测试开关。它真正解决的是断点调试入口:

RUN_WORKER_IN_MAIN=true
  → 不创建子 Worker
  → 主线程直接调用 startWorkerLoop()
  → VS Code 调试器可以沿着主服务进入后台任务代码

正式环境不启用它,后台循环仍由独立线程执行。换句话说,它不会改变业务规则,也不是让生产环境在两种并发模型之间随机切换,而是把同一套 Worker 逻辑临时放回当前可调试的执行上下文。

如果只为测试另写一个简化版循环,断点虽然能打上,测试的却不再是生产代码。保留同一个 startWorkerLoop(),只注入不同的消息回调,才是这套结构最后能够稳定下来的原因。

目标结构

项目中与 Worker 有关的文件不多:

server/
├─ src/
│  ├─ main.ts
│  ├─ worker.ts
│  └─ generated/
│     └─ realtime.ts
├─ package.json
└─ Dockerfile

运行关系如下:

本地测试:main.ts → startWorkerLoop(callback) → server.publish

正式环境:main.js → Worker(worker.js) → postMessage
          → main.js 编码消息 → server.publish

这里的“本地测试”更准确地说是断点调试和集成调试入口:HTTP、WebSocket 和后台循环在同一个进程中运行,VS Code 可以进入原本位于 Worker 中的业务代码。它不等同于 worker.test.ts 单元测试,也不应该让自动化测试直接启动一个不会自行结束的无限循环。

Worker 不直接持有 WebSocket Server

Bun.serve() 返回的 server 属于主线程,不能把它当成普通对象传给 Worker。worker.ts 只负责产生业务消息,通过回调交给外部:

import { WsMessage } from './generated/realtime'

type MessageCallback = (
  channel: string,
  message: WsMessage,
) => Promise<void>

export async function handleScheduledTask(
  callback: MessageCallback,
) {
  const updates = await findScheduledUpdates()

  for (const update of updates) {
    await callback(`topic:${update.topicId}`, {
      notification: {
        id: update.id,
        content: update.content,
      },
    })
  }
}

调度逻辑只知道频道名称和消息内容,不知道消息最终是在当前线程直接发布,还是先经过 postMessage。这层回调是两种运行方式能够共用业务代码的关键。

如果 Worker 直接导入 main.ts 或持有 WebSocket server,模块之间会形成反向依赖;如果 Worker 自己建立一套 WebSocket 服务,连接和频道又会被拆到两个线程中。把发布动作留在主线程,职责会清楚很多。

Protobuf 消息如何生成、编码和解码,单独记录在 Protobuf 与 Buf 实战:TypeScript WebSocket 服务端发送与客户端接收。本文只讨论 Worker 如何把结构化消息交回主线程。

后台循环只有一个入口

后台任务需要启动后立即执行一次,随后按固定边界继续调度。当前入口使用一个异步循环:

export async function startWorkerLoop(
  callback?: MessageCallback,
) {
  const safeCallback = callback ?? (() => Promise.resolve())

  while (true) {
    await handleScheduledTask(safeCallback)
    await sleep(getDelayToNextMinute())
  }
}

使用 getDelayToNextMinute() 而不是固定 sleep(60_000),可以让每轮任务重新对齐分钟边界。任务执行本身消耗的时间不会不断累积到下一轮。

这里保留默认空回调,是为了让调度逻辑在不需要实时广播的场景下仍可运行。不过正式入口和本地测试入口都应该传入真实回调,否则数据库状态会更新,WebSocket 客户端却收不到消息。

长时间任务还要在业务层阻止重复启动。例如分钟调度每次都会扫描活动任务,但同一个任务不能每分钟再启动一遍:

const activeTasks = new Set<number>()

async function startTask(
  callback: MessageCallback,
  taskId: number,
) {
  if (activeTasks.has(taskId)) return
  activeTasks.add(taskId)

  try {
    await runTaskTimeline(callback, taskId)
  } finally {
    activeTasks.delete(taskId)
  }
}

线程隔离不能代替业务重入保护。只要同一个 Worker 会周期扫描任务,就仍然需要明确的执行中状态。

本地测试:在主线程显式启动

本地环境通过变量选择同线程模式:

RUN_WORKER_IN_MAIN=true

main.ts 动态导入 Worker 模块,显式调用 startWorkerLoop()。消息在回调里编码,再由当前 server 发布:

if (process.env.RUN_WORKER_IN_MAIN === 'true') {
  logger.info('[Main] 任务循环启动')

  import('./worker').then((worker) => {
    worker.startWorkerLoop(async (channel, message) => {
      const encoded = WsMessage.encode(message).finish()
      server.publish(channel, encoded)
    })
  })
}

然后正常启动开发服务:

bun run dev

这条路径首先解决的是断点无法进入 Worker 的问题,其次才是日志集中。路由、WebSocket 和后台任务运行在同一个可调试进程中,可以直接观察调度、截止时间和状态切换时的局部变量。代价是后台任务与请求处理共享事件循环,所以它只用于本地调试,不是正式部署方案。

为什么不能用 postMessage 判断线程

早期实现曾通过 typeof postMessage !== 'undefined' 判断当前是不是 Worker。这个条件看起来直观,却没有准确表达真正的问题:当前模块究竟运行在主线程还是 Worker 线程。

一旦运行时在主线程也提供了相关全局对象,普通的动态导入就可能触发 Worker 自启动。结果是 main.ts 显式启动一次,worker.ts 的模块副作用又启动一次,同一批任务被两条循环处理。

现在直接使用 isMainThread

import { isMainThread } from 'node:worker_threads'

if (!isMainThread) {
  startWorkerLoop(async (channel, message) => {
    if (typeof postMessage !== 'undefined') {
      postMessage({ channel, message })
    }
  })
}

判断和动作由此分开:

  • isMainThread 决定是否应该自动启动;
  • postMessage 只负责把消息发给主线程;
  • 主线程导入 worker.ts 时只取得导出的函数,不触发模块自启动。

这比检查某个全局函数是否存在更可靠,也让代码意图可以直接从条件本身读出来。

现在 Bun Worker 能直接在 VS Code 调试了吗

截至 2026 年 8 月,本地使用的 Bun 版本是 1.3.14。Bun 已经支持通过 --inspect--inspect-brk--inspect-wait 调试普通 JavaScript 或 TypeScript 进程,也提供基于 WebKit Inspector Protocol 的网页调试器。

不过,当前官方文档仍将 VS Code 调试支持标记为 experimental;Bun 的 VS Code 调试指南还直接提示扩展存在问题,并推荐优先使用 Web Debugger。Worker API 本身同样仍被标记为 experimental。

更关键的是,官方 Worker 文档只说明了线程创建、postMessage、生命周期和 Bun.isMainThread,没有给出为子 Worker 单独开启 Inspector 或从 VS Code 附加到子 Worker 的方法。因此,目前不能根据官方资料得出“VS Code 已经可以稳定调试 Bun 子 Worker”的结论。

这并不表示所有环境下一定无法附加,也不排除新版本或特定配置已经改善。能确认的是:官方仍未把这条路径描述为稳定能力。对于需要可靠命中断点的后台时间线,保留 RUN_WORKER_IN_MAIN 仍然合理。它让 VS Code 只调试主 Bun 进程,同时执行与生产 Worker 完全相同的业务函数。

如果以后 Bun 明确支持对子 Worker 独立附加调试器,可以重新评估是否删除这条分支。在此之前,不应仅因为主进程已经支持 --inspect,就推断它创建的所有 Worker 也会自动进入同一个调试会话。

相关官方说明:

正式环境:使用独立 Worker 线程

正式环境不设置 RUN_WORKER_IN_MAIN=true。主线程根据运行环境选择 Worker 文件:

const workerFile = process.env.NODE_ENV === 'production'
  ? './worker.js'
  : './worker.ts'

const worker = new Worker(
  new URL(workerFile, import.meta.url).href,
)

开发源码位于 src,可以直接加载 worker.ts;生产容器只复制构建后的 dist,因此必须加载 worker.js

Worker 发回的是可结构化克隆的普通数据。主线程收到后再编码 Protobuf 并发布到 WebSocket 频道:

worker.onmessage = (event) => {
  const { channel, message } = event.data

  if (!server) return

  const encoded = WsMessage.encode(message).finish()
  server.publish(channel, encoded)
}

worker.onerror = (error) => {
  logger.error(`[Worker Error] ${error.message}`)
}

这条路径中只有主线程接触 WebSocket server。Worker 负责数据库查询、定时任务和业务状态推进,线程间只传递频道和消息对象。

构建时必须包含两个入口

生产环境加载 worker.js,意味着构建命令不能只编译 main.ts

{
  "scripts": {
    "build": "cross-env NODE_ENV=production bun build src/main.ts src/worker.ts --outdir ./dist --target bun --no-env-inlining",
    "build:prod": "cross-env NODE_ENV=production bun build src/main.ts src/worker.ts --outdir ./dist --target bun --minify --no-env-inlining",
    "start": "bun dist/main.js"
  }
}

构建成功后应当同时看到两个入口文件:

dist/
├─ main.js
└─ worker.js

只输出 main.js 是一个很隐蔽的问题。本地开发一直读取 worker.ts,不会暴露错误;直到镜像只复制 dist,正式服务创建 Worker 时才会找不到文件。

可以在部署前做一个最小检查:

bun run build:prod

不要只看命令退出码,还要确认构建输出中同时列出了 main.jsworker.js

运行时也要设置 production

构建阶段设置 NODE_ENV=production,不代表运行阶段一定还能读取到这个值,尤其是在使用 --no-env-inlining 时。正式容器需要明确提供运行时环境变量:

FROM oven/bun:1-alpine

COPY ./dist /app/dist
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3001

CMD ["bun", "./dist/main.js"]

如果运行时的 NODE_ENV 仍是 development,已经打包好的 dist/main.js 会尝试寻找同目录下的 worker.ts。构建产物明明包含 worker.js,服务仍然会启动失败。

因此正式流程有两个缺一不可的条件:

构建阶段:同时生成 dist/main.js 与 dist/worker.js
运行阶段:NODE_ENV=production,加载 ./worker.js

这套结构解决了哪些问题

经过多次修改后,最后保留下来的边界并不复杂:

  • Worker 业务通过回调输出消息,不依赖 WebSocket server;
  • 本地测试由 main.ts 显式调用同一个 startWorkerLoop()
  • worker.ts 只在 !isMainThread 时自动启动;
  • 正式环境用 postMessage 把结构化消息交回主线程;
  • 主线程统一完成 Protobuf 编码和 server.publish()
  • Bun 构建同时包含 main.tsworker.ts
  • 容器运行时明确设置 NODE_ENV=production

真正容易出错的地方不是 Worker API 本身,而是开发源码路径、生产产物路径和模块副作用混在一起。把业务入口固定下来,再明确区分“谁启动循环”和“谁发布 WebSocket 消息”,本地测试与正式部署就可以长期使用同一套代码。