OmniRoute 压缩 Worker 线程池的打包器兼容性修复:用 `pathToFileURL` 让 Webpack / Turbopack 不再解析缺失的 `compressionWorker.js`
本篇技术解读基于 OmniRoute 仓库中的一条修复记录(changelog.d/fixes/compression-worker-bundler-resolve.md),深入剖析 RTK / Caveman 压缩 Worker 线程池如何在多构建管线(Next.js 的 Webpack/Turbopack、npm 发布包的 esbuild、Electron standalone)下安全定位 worker 入口文件。通过阅读修复源码、发布打包脚本与配套测试,读者可以理解 new Worker(path) 与 new Worker(fileURL) 的本质差异、打包器静态资源解析的触发条件,以及"运行时锚点 + 动态文件 URL"这一通用修复范式在 OmniRoute 中的落地形态。
修复背景:压缩子系统的 worker 线程池是怎么来的
OmniRoute 的请求压缩引擎位于 open-sse/services/compression/,用于把同步的 RTK、Caveman 等压缩计算从 HTTP 主隔离区剥离出去。其线程池能力由特性片段 changelog.d/features/11023-compression-worker-pool.md 引入:
在一个有界的 worker 线程池中运行同步的 RTK 和 Caveman 请求压缩,把大体积
/v1/responses压缩堆留在 HTTP 隔离区之外,同时保持严格的 fail-open 行为和逐引擎遥测。
也就是说,线程池解决的核心问题是:大请求压缩时会产生显著的堆占用与 CPU 峰值,若不隔离会拖慢 HTTP 主进程事件循环。从测试用例 tests/unit/compression/compression-worker.test.ts 中也能看到该设计的验证目标——"keeps the parent event loop responsive while two workers overlap",即两个大 body 并行压缩时主事件循环仍能按时 tick。
调用链
在 strategySelector.ts 中,异步压缩入口先做资格判定,再动态加载线程池模块:
const { isCompressionWorkerEligible } = await import("./compressionWorkerProtocol.ts");
if (isCompressionWorkerEligible(body, mode, workerOptions)) {
try {
const { runCompressionInWorker } = await import("./compressionWorkerPool.ts");
return await runCompressionInWorker(body, mode, workerOptions, options?.onEngineStep);
} catch {
return { body, compressed: false, stats: null }; // fail-open
}
}
实际接收压缩任务的 worker 侧逻辑在 compressionWorker.ts:它校验 parentPort 存在(确保自己确实运行在 worker 线程里),按 stacked 与其他模式分发到 applyStackedCompression / applyCompression,再把 result、分步 step 或 error 消息回传给池。
整个机制在消费端被多个入口复用,例如 src/app/api/compression/preview/route.ts 的压缩预览接口,以及 src/app/api/internal/codex-responses-ws/compression.ts 的 WebSocket 压缩管线,它们都通过 applyCompressionAsync 汇入同一条 worker 路径。
问题本质:打包器试图把 compressionWorker.js 当作静态资源解析
仓库在同一目录下只存在 compressionWorker.ts 源码(find 结果确认),.js 产物并不在源码树中——它由发布流程中的 esbuild 单独打包生成(详见下文)。问题就出在这里:
Webpack / Turbopack 这类打包器在构建时会做静态分析。当它们发现 new Worker(...) 的入参是一个可静态识别的文件路径形态时,会默认这是"要被纳入构建图、作为资源/入口一起打包"的 worker 文件,于是尝试在构建期解析这个资源。但 compressionWorker.js 此刻并不存在(它要等 esbuild 打包阶段才会被产出),静态解析自然失败,进而中断构建。这正是修复片段所陈述的事实:
在
compressionWorkerPool中使用pathToFileURL,使打包器(Webpack / Turbopack)在构建期间不再尝试对缺失的compressionWorker.js做静态资源解析。
补充一点背景支撑:为什么 Dashboard 的 Next.js 构建会碰到这段 open-sse 代码?在 next.config.mjs 中可以看到,Next 构建(同时覆盖 webpack 与 turbopack 两条路径)会传递性地编译 **/open-sse/services/compression/** 模块树,注释里提到该模块的 ruleLoader.ts、filterLoader.ts 等动态 fs 访问模式曾触发数百次构建期诊断并被显式抑制。也就是说,压缩模块树确实被纳入 Next 的构建图,其中出现的 worker 实例化代码自然会被打包器静态分析命中。
修复实现:workerUrl() 在运行时用 pathToFileURL 构造文件 URL
修复的核心代码位于 open-sse/services/compression/compressionWorkerPool.ts,一个名为 workerUrl 的辅助函数:
function workerUrl(): URL {
const dir = dirname(fileURLToPath(import.meta.url));
for (const name of ["compressionWorker.js", "compressionWorker.ts"]) {
const candidate = join(dir, name);
if (existsSync(candidate)) return pathToFileURL(candidate);
}
return pathToFileURL(join(dir, "compressionWorker.js"));
}
随后在 spawn 中直接使用返回值:
private spawn(): PoolWorker {
const slot: PoolWorker = {
worker: new Worker(workerUrl()),
...
这个函数包含了三层关键语义:
-
运行时目录锚点:
dirname(fileURLToPath(import.meta.url))取到的是当前模块实际落盘的目录。开发态直接跑 TS 源码时该目录里有compressionWorker.ts;打包发布后(如dist/open-sse/services/compression/)则有compressionWorker.js。existsSync依次探测两个候选名,正好让同一份代码同时覆盖开发与发布两种形态。 -
pathToFileURL生成标准file://URL:new Worker的入参从"普通路径字符串"变成"URL 对象"。当传给打包器的不是可静态归结的路径字符串、而是一个在运行时才计算出的文件 URL 时,Webpack / Turbopack 的静态分析器无法把它窄化为"待打包的 worker 资源",于是不再尝试解析那个尚不存在的compressionWorker.js——构建期报错随之消失。这与仓库中另一处修复(CHANGELOG 中"resolve worker + rule/filter assets via runtime anchors"条目)属于同一类范式:此前 LLMLingua worker 依赖fileURLToPath(import.meta.url)锚点,在 standalone 打包里被冻结成构建机路径导致 worker 无法启动,当时的修法同样是"改用process.cwd()/argv[1]锚点 + 用pathToFileURL构造 worker URL"(见 open-sse/services/compression/engines/llmlingua/worker.ts)。 -
跨平台正确性:
pathToFileURL会把 Windows 的盘符路径(如C:\...)正确转成file:///C:/...,这是仓库内的一条明确约定——相关测试 tests/unit/bin-omniroute-mcp.test.ts 专门断言了pathToFileURL对 Windows、Unix 及相对路径的转换结果都能用于动态加载;tests/unit/compression/llmlingua-worker-resolution.test.ts 更进一步规定 worker 相关代码"不得 importfileURLToPath等不安全的node:urlAPI(pathToFileURL是允许的)",形成了一条仓库级的编码约束。
构建管线佐证:谁在生产 compressionWorker.js
由于仓库源码中只有 compressionWorker.ts,compressionWorker.js 由发布脚本用 esbuild 独立产出,这决定了"源码树内缺失该文件"这一前提在构建期必然成立:
- scripts/build/prepublish.ts 把
open-sse/services/compression/compressionWorker.ts以--bundle --platform=node --format=esm打出dist/open-sse/services/compression/compressionWorker.js,并且在打包前先校验源文件存在,缺失即抛错。 - scripts/build/colocate-standalone.mjs 定义了
COMPRESSION_WORKER_REL,用于把 worker 产物归拢到 standalone 分发目录。 - scripts/build/pack-artifact-policy.ts 把
open-sse/services/compression/compressionWorker.js列入受监管的打包产物清单。 - Electron 独立分发侧 scripts/build/prepare-electron-standalone.mjs 同样把
compressionWorker.js纳入打包集合。
换句话说,Dashboard 的 Next(Webpack/Turbopack)构建发生在 esbuild 产出 worker .js 之前或独立于它,因此 workerUrl() 在构建期是否被静态解析为"缺失资源"就决定了构建能否通过——这正是本次修复所防的故障面。
线程池的完整行为语义
除了 worker URL 的解析方式,CompressionWorkerPool 的其他机制值得一并说明,便于理解这条修复所处的运行环境:
- 有界并发:
size个并发 worker,超出的任务进入 FIFO 队列排队(dispatch 在有空闲 worker 或池未满时出队)。 - 每任务超时:
timeoutMs到期后调用fail,终止对应 worker 并fail-open——把请求按"未压缩原样返回"处理(compression-worker.test.ts 用timeoutMs: 1验证了这一行为),绝不因为压缩失败而弄丢请求。 - 空闲回收:worker 空闲超过
idleMs即被移除释放资源。 - 单例复用:模块级
runCompressionInWorker惰性创建全局单例池,closeCompressionWorkerPoolForTests供测试收尾。
以上参数全部支持通过环境变量覆盖,默认值与消费方出处见下表:
| 环境变量 | 默认值 | 语义 | 出处 |
|---|---|---|---|
OMNI_COMPRESSION_WORKERS |
2 |
最大并发同步 RTK/Caveman worker 数,超出按 FIFO 排队 | compressionWorkerPool.ts、ENVIRONMENT.md |
OMNI_COMPRESSION_WORKER_TIMEOUT_MS |
120000 |
单任务超时(毫秒),超时则终止 worker 并按原样 fail-open | compressionWorkerPool.ts、ENVIRONMENT.md |
OMNI_COMPRESSION_WORKER_IDLE_MS |
60000 |
空闲 worker 存活时长(毫秒),超时后回收 | compressionWorkerPool.ts、ENVIRONMENT.md |
参数解析对非法值做了防御:positiveInteger 要求正整数,否则回落默认值;构造函数还会对配置做 Math.max(1, floor(...)) 的下限归一(见 compressionWorkerPool.ts)。
回归验证与阅读指引
修复与线程池行为由一组单元测试持续守护,可在仓库中按以下路径深入:
- tests/unit/compression/compression-worker.test.ts:验证 worker 池与同步压缩结果一致(仅时长/时间戳字段不同)、Responses 形态 body 与硬预算结果一致、分步引擎遥测顺序为
["rtk", "caveman"]、超时 fail-open、双 worker 并行时不阻塞父事件循环。 - tests/unit/compression/llmlingua-worker-resolution.test.ts:对 worker 代码做源码级静态检查,确保 worker 相关文件使用
pathToFileURL而非其它可能引入歧义的node:url解析方式。 - tests/unit/bin-omniroute-mcp.test.ts:验证
pathToFileURL的跨平台转换能力,是"文件 URL 可安全用于动态加载"这一约定成立的基础。
若需从构建角度理解本修复为何必须存在,重点对照三处:发行打包器 scripts/build/prepublish.ts(worker 产物由 esbuild 单独产出)、Next 构建配置 next.config.mjs(Dashboard 构建会传递性编译 open-sse/services/compression/**,同时启用 webpack 与 turbopack 路径)、以及发布产物清单 scripts/build/pack-artifact-policy.ts。
小结
本次修复虽是一行级变更,却精确命中了一个典型的构建期与运行期资源时空错配问题:压缩 worker 的入口产物 compressionWorker.js 只在 esbuild 发布阶段生成,而 Next(Webpack/Turbopack)构建却会提前静态分析到 new Worker(...) 并试图解析这个尚不存在的文件。通过 pathToFileURL 在运行时构造 file:// URL,worker 的加载被移出打包器的静态解析范围,同时借助 existsSync 对 .js / .ts 双候选的探测,让同一套代码在开发态(TS 直跑)与发布态(esbuild 产物)都能正确定位入口。其背后的通用经验——凡是"运行期才知道路径"的文件,都应该用 pathToFileURL 之类的动态 URL 而非裸路径字符串交给 Node 运行时 API——在 OmniRoute 的 LLMLingua worker、CLI 插件加载等大量场景中被反复印证,并沉淀为测试强制的仓库约定。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00