Node.js 服务打包遗漏 bcrypt、Swagger UI 与 worker 的排查记录
想解决的问题
我需要把一个 TypeScript 编写的 Fastify 服务整理成可发布的产物。目标平台已经确定为 Windows x64,运行环境是 Node.js,不考虑 Docker,也不强求最后只有一个文件。只要业务代码完成打包,发布目录可以直接复制到另一台机器运行就行。
项目中有几个需要留意的依赖:
bcrypt 6.0.0@fastify/swagger-ui- Fastify 日志链路使用的
pino和thread-stream
一开始试过 Bun。它很快生成了一个约 4 MB 的 server.js,node --check 正常,在原项目目录里启动服务也没有报错。这个结果看起来已经够用了,但把生成代码展开后,里面仍然保留着构建机器上 node_modules 的绝对路径。程序能够启动,是因为原项目和依赖目录还在。
这不是独立的发布产物。验证这类打包结果时,不能只在源码目录运行,必须把产物复制到一个没有源码、没有原始 node_modules 的目录。
JavaScript 依赖已经打包,运行时文件没有
打包器擅长分析静态模块引用:
import service from "./service.js";它可以从入口继续找到 service.js,再处理这个文件的依赖。问题出在模块图之外的文件访问。当前项目正好遇到了三种。
bcrypt 的原生二进制
bcrypt 6 不会直接写出某个 .node 文件的路径,而是把包目录交给 node-gyp-build:
const bindings = require("node-gyp-build")(path.resolve(__dirname));node-gyp-build 启动后才会检查平台、架构和 Node ABI,然后在下面这些位置寻找二进制:
build/Release/*.node
build/Debug/*.node
prebuilds/<platform>-<arch>/*.node当前 Windows x64 安装实际使用的是:
node_modules/bcrypt/prebuilds/win32-x64/bcrypt.node源码里没有对这个文件的静态 require(),打包器必须认识 node-gyp-build 的目录扫描规则才能自动复制它。
Swagger UI 的静态目录
@fastify/swagger-ui 会在运行时读取包内文件:
fsPromises.readFile(path.join(__dirname, "./static/logo.svg"));它还会把整个 static 目录交给 @fastify/static:
root: opts.baseDir || path.join(__dirname, "..", "static");这里包含 HTML、JavaScript、CSS、SVG 和 JSON。它们不是 JavaScript 模块依赖,只打包源码会漏掉这些文件。
模块合并后还有另一个问题:原来的 index.js 位于包根目录,lib/routes.js 位于下一层目录,两处 __dirname 的含义不同。它们被放进同一个 bundle 后,如果路径没有被正确重写,就不会再指向原来的静态目录。
thread-stream 的 worker
thread-stream 会按路径启动独立 worker:
path.join(__dirname, "lib", "worker.js");worker 是另一个执行入口,不能简单内联到主文件中。打包工具要么单独生成 worker,要么改写主程序里的定位路径。
Bun 打包为什么会产生误判
使用 Bun 生成供 Node.js 运行的 CommonJS:
bun build packages/server/src/server.ts \
--target=node \
--format=cjs \
--outfile=packages/server/dist/server.jsBun 能把普通 JavaScript 依赖放进 server.js,但不会自动追踪所有 fs 读取、目录扫描和动态原生模块加载。直接导入的 .node 或图片可以交给 loader,当前几个包却是在运行时计算路径。
--external 也不是复制命令:
bun build packages/server/src/server.ts \
--target=node \
--format=cjs \
--external bcrypt \
--external @fastify/swagger-ui \
--outfile=packages/server/dist/server.js这样只是保留 require("bcrypt") 和 require("@fastify/swagger-ui"),运行时仍然需要对应的 node_modules。
重新验证 ncc
ncc 本身有资产重定位能力,也支持把 worker 作为嵌套 JavaScript 资产继续构建。这里需要注意包名。下面的命令会下载另一个名为 ncc 的旧包:
npx ncc build packages/server/src/server.ts典型错误是:
pkgid ncc@0.3.6
error could not determine executable to runVercel 的工具名是 @vercel/ncc。不写入项目依赖时,可以明确指定:
npx --yes --package=@vercel/ncc ncc build \
packages/server/src/server.ts \
-o packages/server/dist \
-a本次使用 ncc 0.44.1 构建后得到:
12kB packages/server/dist/worker.js
4303kB packages/server/dist/index.js
4315kB - ncc 0.44.1worker.js 已经生成,说明 -a 对 thread-stream 生效了。产物中仍然没有 bcrypt.node,启动时报错:
Error: No native build was found for
platform=win32 arch=x64 runtime=node abi=137
node=24.16.0 webpack=true
loaded from: INTERNAL_PATH\dist错误里的 webpack=true 表示 node-gyp-build 知道代码经过了 Webpack,但它在 dist 中没有找到 prebuilds/win32-x64/bcrypt.node。
这也解释了为什么旧项目里可能见过 ncc 自动复制 bcrypt。旧版 bcrypt 使用 node-pre-gyp,而 ncc 的资产重定位器明确支持 node-pre-gyp 的加载模式。bcrypt 6 改用了 prebuildify 和 node-gyp-build,当前重定位规则没有识别这次目录扫描。
如果项目使用 npm workspace,还要注意 ncc 的资产边界。它的 filterAssetBase 默认是 process.cwd(),位于这个目录之外的资产不会输出。依赖被提升到仓库根目录时,应从根目录执行 ncc,或者通过编程接口把 filterAssetBase 设置为仓库根目录。
组成一个可运行的 Windows 发布目录
这次并不要求单文件,因此可以保留 ncc 已经完成的 JavaScript 打包,只补齐它无法发现的运行时文件。
Windows x64 的 bcrypt 文件可以这样复制:
New-Item -ItemType Directory -Force packages\server\dist\prebuilds\win32-x64
Copy-Item `
node_modules\bcrypt\prebuilds\win32-x64\bcrypt.node `
packages\server\dist\prebuilds\win32-x64\bcrypt.nodeSwagger UI 需要复制完整的静态目录:
Copy-Item `
node_modules\@fastify\swagger-ui\static `
packages\server\dist\static `
-Recurse注册 Swagger UI 时显式指定该目录,避免继续依赖包被打包前的目录层级:
import path from "node:path";
await fastify.register(swaggerUi, {
routePrefix: "/documentation",
baseDir: path.join(__dirname, "static"),
});最终发布目录不是单个 JS,而是:
dist/
├─ index.js
├─ worker.js
├─ static/
└─ prebuilds/
└─ win32-x64/
└─ bcrypt.node验证时要覆盖真实功能
只看到服务监听端口还不够。bcrypt 通常要等登录、注册或密码校验时才会加载,Swagger UI 的 CSS 和 JavaScript 也要等浏览器请求文档页面时才会读取。
我会把产物复制到独立目录,再检查下面几项:
node index.js可以启动。- 登录或注册接口实际执行一次 bcrypt 哈希或校验。
- Swagger JSON 可以访问。
- Swagger UI 页面及 CSS、JavaScript 请求返回 200。
- 日志输出正常,worker 路径不再指向源码目录。
如果不需要压缩 JavaScript 依赖,继续使用 TypeScript 编译并在发布目录执行 npm ci --omit=dev 会更直接。ncc 方案的价值在于缩小 JavaScript 部分,同时接受少量明确的运行时资源。
结论
这次失败不是所有依赖都无法打包。ncc 已经合并了业务代码和大部分 JavaScript 依赖,也正确生成了 thread-stream 的 worker。遗漏的是无法从静态模块图中确定的文件:
bcrypt通过node-gyp-build动态选择的 Windows 原生二进制。@fastify/swagger-ui通过文件系统读取的静态目录。
Bun 的 JavaScript bundle 和 ncc 的 index.js 都不能只凭“在原项目中启动成功”判断是否可发布。检查产物中的路径,把它移到干净目录,并触发实际使用这些资源的功能,才能确认发布目录是否完整。