灵矶是一个支持插件化、模块动态加载、运行时行为高度灵活的 Node.js 应用平台。在开发环境下,灵矶依赖于 Node 的模块解析机制进行插件动态加载;但是作为以易用为目标的灵矶,我们希望它能作为一个独立可执行程序部署和运行。
本文将介绍我们是如何使用 @yao-pkg/pkg
工具将灵矶打包为单文件可执行程序的,并详细讲解我们如何绕过 pkg
的限制,实现灵活而稳定的模块加载机制。
可以从这里获取我们已经构建的产物,github.com/tachybase/t... ,我们已经用于交付给客户的系统中,但是泛用性、启动效率等目前还在优化中,使用方式和官方文档中使用 @tachybase/engine
的方式一致,我们也在将来提供图形化启动的方式。
为什么选择 @yao-pkg/pkg
pkg
可以将 Node.js 项目打包成单一的可执行文件,便于在生产环境部署,不需要依赖 node_modules
或 Node.js 本身。 @yao-pkg/pkg
本身是 fork 自 vercel/pkg
,但是 node
官方支持打包之后就不再维护了,但是 node
使用的 sea
对于灵矶不适配,所以我们选用了 @yao-pkg/pkg
。
但它也有一些限制:
- 所有动态
require
的路径必须能在打包阶段静态解析; require.resolve()
的路径必须在 snapshot 中;- 动态路径加载、插件目录、外部依赖都不被自动包含。
这些限制与灵矶的"插件化架构"天然冲突,因此我们对模块加载器进行了定制。
灵矶在使用 @yao-pkg/pkg
打包时的模块加载器定制
问题背景
灵矶支持在运行时动态加载第三方模块,尤其是形如 @tachybase/plugin-xxx
的插件模块。在打包为可执行程序后,require.resolve()
无法在 snapshot 外查找路径,这会导致插件加载失败。
定制目标
我们定制了 Node.js 内部模块加载器 Module._load
,实现目标包括:
- 允许打包后的应用仍然可以动态加载部分模块;
- 支持白名单机制和特定命名空间模块(如
@tachybase/
)的加载; - 对可能的加载失败提供容错和回退机制,保持原有行为一致;
- 避免影响 Node.js 原有的相对路径/绝对路径加载行为。
实现方案
ts
// 重写 Node.js 默认模块加载器
Module._load = function (request: string, parent: NodeModule | null, isMain: boolean) {
// 拦截白名单模块或以 @tachybase/ 开头的模块
if (whitelists.has(request) || request.startsWith('@tachybase/')) {
try {
// 尝试从指定路径中解析模块路径
const resolvedFromApp = require.resolve(request, { paths: lookingPaths });
// 使用解析后的路径加载模块
return originalLoad(resolvedFromApp, parent, isMain);
} catch (err) {
// 如果模块未找到,回退使用默认加载方式
if (err.code === 'MODULE_NOT_FOUND') {
return originalLoad(request, parent, isMain);
}
}
}
// 其他模块保持默认行为
return originalLoad(request, parent, isMain);
};
获取插件的 package.json
路径
在插件系统中,我们常常需要访问插件的 package.json
来获取元信息,比如版本号、配置字段等。但在打包后,直接拼接路径的方式并不可靠。因此我们封装了一个兼容 pkg
的版本路径解析逻辑:
ts
export function getDepPkgPath(packageName: string, cwd?: string) {
const basePaths = cwd ? [cwd] : [process.env.NODE_MODULES_PATH || process.cwd()];
try {
return require.resolve(`${packageName}/package.json`, { paths: basePaths });
} catch {
const mainFile = require.resolve(packageName, { paths: basePaths });
const packageDir = mainFile.slice(
0,
mainFile.indexOf(packageName.replace('/', path.sep)) + packageName.length
);
const result = path.join(packageDir, 'package.json');
if (!fs.existsSync(result)) {
return path.join(findPackageJson(mainFile), 'package.json');
}
return result;
}
}
该函数优先使用 require.resolve
定位 package.json
,如果失败则退回主模块路径向上查找。这样可以兼容开发环境与打包后路径结构不同的情况。
多语言资源加载的动态路径处理
灵矶支持国际化语言包,但语言资源文件通常分布在 lib/locale
或 src/locale
等目录下,路径结构在不同环境下可能不一致,因此我们也对其加载逻辑进行了封装:
ts
export const getResource = (packageName: string, lang: string, isPlugin = true) => {
const resources = [];
const prefixes = [isPlugin ? 'dist' : 'lib'];
if (process.env.APP_ENV !== 'production') {
try {
require.resolve('@tachybase/client/src');
if (packageName === '@tachybase/module-web') {
packageName = '@tachybase/client';
}
} catch {
// fallback to default path
}
prefixes.unshift('src');
}
for (const prefix of prefixes) {
try {
const file = `${packageName}/${prefix}/locale/${lang}`;
const resolved = require.resolve(file, {
paths: [process.cwd(), process.env.NODE_MODULES_PATH].filter(Boolean),
});
if (process.env.APP_ENV !== 'production') {
delete require.cache[resolved];
}
const resource = require(resolved);
resources.push(resource);
} catch {
// fallback
}
if (resources.length) break;
}
if (resources.length === 0 && lang.includes('-')) {
return getResource(packageName, lang.replace('-', '_'));
}
return arr2obj(resources);
};
这段逻辑优雅地支持了:
src
和lib
双模式加载(用于开发 vs 生产);- 多语言回退(如
zh-CN
→zh_CN
); - 可插拔模块语言包路径一致性;
- 开发环境下热更新(清除 require 缓存)。
总结
灵矶是一套高度插件化、模块化的系统,传统的打包方式在处理其动态特性时会遇到障碍。通过定制 Node 加载器、重写 require.resolve
的行为、构建路径容错机制以及语言资源管理策略,我们成功将灵矶打包成了一个可独立部署运行的单文件程序。
这让灵矶既保留了开发时的灵活性,又具备了部署时的极致简洁性。如果你也在构建一个可扩展的 Node 平台,希望部署更轻量,不妨尝试类似的方式打包你的系统。
如果你对灵矶的打包方式感兴趣,或者希望进一步了解插件化架构与部署优化,欢迎留言交流 🙌 📚 更多信息请访问:
官网 👉 tachybase.org
GitHub 👉 tachybase/tachybase