Node.js 服务打包遗漏 bcrypt、Swagger UI 与 worker 的排查记录
太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处

Node.js 服务打包遗漏 bcrypt、Swagger UI 与 worker 的排查记录

想解决的问题

我需要把一个 TypeScript 编写的 Fastify 服务整理成可发布的产物。目标平台已经确定为 Windows x64,运行环境是 Node.js,不考虑 Docker,也不强求最后只有一个文件。只要业务代码完成打包,发布目录可以直接复制到另一台机器运行就行。

项目中有几个需要留意的依赖:

  • bcrypt 6.0.0
  • @fastify/swagger-ui
  • Fastify 日志链路使用的 pinothread-stream

一开始试过 Bun。它很快生成了一个约 4 MB 的 server.jsnode --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.js

Bun 能把普通 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 run

Vercel 的工具名是 @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.1

worker.js 已经生成,说明 -athread-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 改用了 prebuildifynode-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.node

Swagger 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 也要等浏览器请求文档页面时才会读取。

我会把产物复制到独立目录,再检查下面几项:

  1. node index.js 可以启动。
  2. 登录或注册接口实际执行一次 bcrypt 哈希或校验。
  3. Swagger JSON 可以访问。
  4. Swagger UI 页面及 CSS、JavaScript 请求返回 200。
  5. 日志输出正常,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 都不能只凭“在原项目中启动成功”判断是否可发布。检查产物中的路径,把它移到干净目录,并触发实际使用这些资源的功能,才能确认发布目录是否完整。

参考资料