Bun Worker:TypeScript 服务端的本地调试与生产运行
最初使用 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=truemain.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.js 和 worker.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.ts和worker.ts; - 容器运行时明确设置
NODE_ENV=production。
真正容易出错的地方不是 Worker API 本身,而是开发源码路径、生产产物路径和模块副作用混在一起。把业务入口固定下来,再明确区分“谁启动循环”和“谁发布 WebSocket 消息”,本地测试与正式部署就可以长期使用同一套代码。