411 lines
11 KiB
Markdown
411 lines
11 KiB
Markdown
# OpenChamber 单文件可执行文件构建指南
|
||
|
||
## 概述
|
||
|
||
本文档详细记录了如何将 OpenChamber Web 应用打包成一个独立的单文件可执行文件(exe),用户无需安装 Node.js、Bun 或任何依赖,只需双击 exe 即可运行。
|
||
|
||
最终产物:`OpenChamber.exe`(约320MB),包含完整的服务器代码、依赖和静态文件。
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
1. [背景与需求](#背景与需求)
|
||
2. [技术选型](#技术选型)
|
||
3. [尝试过程与问题](#尝试过程与问题)
|
||
4. [最终方案:Bun Compile](#最终方案bun-compile)
|
||
5. [实现细节](#实现细节)
|
||
6. [构建命令](#构建命令)
|
||
7. [已知限制](#已知限制)
|
||
|
||
---
|
||
|
||
## 背景与需求
|
||
|
||
### 需求
|
||
|
||
用户希望将 OpenChamber 打包成类似 Gitea 的单文件可执行文件:
|
||
- 无需安装 Node.js/Bun 运行时
|
||
- 无需解压即可运行
|
||
- 单个 exe 文件,便于分发
|
||
- 双击启动,访问 Web UI
|
||
|
||
### 项目现状
|
||
|
||
| 组件 | 技术 | 代码量 |
|
||
|------|------|--------|
|
||
| 前端 | React + TypeScript + Vite | ~10,000 行 |
|
||
| 后端 | Express.js + Node.js | ~12,000 行 |
|
||
| 静态文件 | HTML/CSS/JS/字体/图片 | ~150MB |
|
||
|
||
---
|
||
|
||
## 技术选型
|
||
|
||
### 方案对比
|
||
|
||
| 方案 | 单文件 | ESM支持 | 静态文件嵌入 | 结果 |
|
||
|------|--------|---------|--------------|------|
|
||
| Electron portable | 自解压 | ✅ | ❌ 需外部文件 | 失败 |
|
||
| pkg (Node.js) | ✅ | ❌ 仅CJS | ❌ 需外部文件 | 失败 |
|
||
| **Bun compile** | ✅ | ✅ | ✅ 需手动嵌入 | **成功** |
|
||
| nexe | ✅ | ❌ 仅CJS | ❌ | 未尝试 |
|
||
| Tauri | ✅ | - | ✅ | 工作量大,未尝试 |
|
||
|
||
---
|
||
|
||
## 尝试过程与问题
|
||
|
||
### 方案一:Electron Portable
|
||
|
||
最初使用 Electron 打包,配置如下:
|
||
|
||
```json
|
||
// web/electron/package.json
|
||
{
|
||
"build": {
|
||
"win": { "target": "portable" },
|
||
"extraResources": [
|
||
{ "from": "../dist", "to": "dist" },
|
||
{ "from": "../server", "to": "server" },
|
||
{ "from": "D:\\Program Files\\nodejs", "to": "nodejs", "filter": ["node.exe"] }
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
**问题:**
|
||
|
||
1. **不是真正的单文件** - Electron portable 本质是自解压包,运行时解压到 `%TEMP%` 目录
|
||
2. **中文路径问题** - 路径包含中文字符导致 spawn 失败
|
||
3. **node-pty 原生模块** - 需要重新编译,兼容性复杂
|
||
4. **文件体积大** - 约 300MB,且需要外部静态文件目录
|
||
|
||
**结论:** Electron portable 并非真正的单文件 exe,用户体验不佳。
|
||
|
||
### 方案二:pkg
|
||
|
||
尝试使用 Node.js 官方的 pkg 工具:
|
||
|
||
```bash
|
||
pkg bin/cli.js --targets node18-win-x64 --output OpenChamber.exe
|
||
```
|
||
|
||
**问题:**
|
||
|
||
1. **不支持 ESM** - pkg 只支持 CommonJS,而项目使用 `"type": "module"`
|
||
```
|
||
Warning Babel parse has failed: import.meta may appear only with 'sourceType: "module"'
|
||
```
|
||
|
||
2. **尝试创建 CJS 入口** - 仍然失败
|
||
```
|
||
Error: Cannot find module 'C:\snapshot\bin\cli.js'
|
||
```
|
||
|
||
3. **中文路径问题** - pkg 的 snapshot 路径在中文目录下无法正确解析
|
||
|
||
**结论:** pkg 无法处理 ESM 项目,且对中文路径支持不佳。
|
||
|
||
### 方案三:Bun Compile
|
||
|
||
Bun 1.0+ 提供了 `bun build --compile` 功能,可以将 JavaScript 应用编译成独立的可执行文件。
|
||
|
||
**优势:**
|
||
- 原生支持 ESM
|
||
- 编译速度快
|
||
- 支持所有 Bun API
|
||
- 单文件输出
|
||
|
||
**初步尝试:**
|
||
|
||
```bash
|
||
bun build server/index.js --compile --outfile OpenChamber.exe
|
||
```
|
||
|
||
**成功生成 113MB 的 exe!** 但运行时报错:
|
||
|
||
```
|
||
Error: Static files not found at dist/
|
||
```
|
||
|
||
**问题:** 静态文件(dist/、public/)没有被嵌入 exe。
|
||
|
||
---
|
||
|
||
## 最终方案:Bun Compile + 静态文件嵌入
|
||
|
||
### 核心思路
|
||
|
||
1. 将静态文件(dist/)转换为 base64 编码的 JS 模块
|
||
2. 在入口文件中导入该模块
|
||
3. 运行时解压到临时目录
|
||
4. 服务器从临时目录读取静态文件
|
||
|
||
### 架构图
|
||
|
||
```
|
||
OpenChamber.exe
|
||
├── Bun Runtime (~80MB)
|
||
├── Server Code (~20MB)
|
||
├── Dependencies (~50MB)
|
||
└── Embedded Static Files (base64, ~150MB compressed)
|
||
|
||
启动时:
|
||
┌─────────────────┐
|
||
│ OpenChamber.exe │
|
||
└────────┬────────┘
|
||
│ 启动
|
||
▼
|
||
┌─────────────────────────────────┐
|
||
│ 解压静态文件到 %TEMP%\openchamber-static-{PID}\ │
|
||
└────────┬────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────┐
|
||
│ 启动 Express 服务器 │
|
||
│ 监听 localhost:3000 │
|
||
│ 从临时目录读取静态文件 │
|
||
└─────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 实现细节
|
||
|
||
### 1. 静态文件嵌入脚本 (`embed-static.js`)
|
||
|
||
```javascript
|
||
import { readdirSync, statSync, readFileSync, writeFileSync } from 'fs';
|
||
import { join, relative } from 'path';
|
||
|
||
const distDir = './dist';
|
||
const outputFile = './embedded-static.js';
|
||
|
||
function walkDir(dir, baseDir, result = {}) {
|
||
const items = readdirSync(dir);
|
||
for (const item of items) {
|
||
const fullPath = join(dir, item);
|
||
const stat = statSync(fullPath);
|
||
if (stat.isDirectory()) {
|
||
walkDir(fullPath, baseDir, result);
|
||
} else {
|
||
const relPath = relative(baseDir, fullPath).replace(/\\/g, '/');
|
||
const content = readFileSync(fullPath);
|
||
result[relPath] = content.toString('base64');
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
console.log('Embedding static files...');
|
||
const files = walkDir(distDir, distDir);
|
||
console.log('Found', Object.keys(files).length, 'files in dist');
|
||
|
||
const jsContent = `// Auto-generated - embedded static files
|
||
export const staticFiles = ${JSON.stringify(files)};
|
||
export const fileCount = ${Object.keys(files).length};
|
||
`;
|
||
|
||
writeFileSync(outputFile, jsContent);
|
||
console.log('Written to', outputFile);
|
||
```
|
||
|
||
**说明:**
|
||
- 遍历 `dist/` 目录下的所有文件
|
||
- 将每个文件内容转换为 base64 字符串
|
||
- 生成一个 JS 模块导出文件映射
|
||
|
||
### 2. 入口文件 (`entry-singlefile.js`)
|
||
|
||
```javascript
|
||
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
||
import { join, dirname } from 'path';
|
||
import { tmpdir } from 'os';
|
||
|
||
// 导入嵌入的静态文件
|
||
import { staticFiles } from './embedded-static.js';
|
||
|
||
// 导入服务器模块
|
||
import * as serverModule from './server/index.js';
|
||
|
||
// 解析命令行参数
|
||
const args = process.argv.slice(2);
|
||
let port = 3000;
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === '--port' || args[i] === '-p') {
|
||
port = parseInt(args[++i]) || 3000;
|
||
} else if (args[i].startsWith('--port=')) {
|
||
port = parseInt(args[i].split('=')[1]) || 3000;
|
||
} else if (args[i] === '--help' || args[i] === '-h') {
|
||
console.log(`OpenChamber - Web UI for OpenCode
|
||
Usage: OpenChamber [options]
|
||
Options:
|
||
--port, -p Port (default: 3000)
|
||
--help, -h Show help`);
|
||
process.exit(0);
|
||
}
|
||
}
|
||
|
||
// 解压静态文件到临时目录
|
||
const extractDir = join(tmpdir(), 'openchamber-static-' + process.pid);
|
||
const distPath = join(extractDir, 'dist');
|
||
|
||
console.log('Extracting static files...');
|
||
mkdirSync(distPath, { recursive: true });
|
||
|
||
let extractedCount = 0;
|
||
for (const [relPath, base64Content] of Object.entries(staticFiles)) {
|
||
const fullPath = join(distPath, relPath);
|
||
const dir = dirname(fullPath);
|
||
if (!existsSync(dir)) {
|
||
mkdirSync(dir, { recursive: true });
|
||
}
|
||
const content = Buffer.from(base64Content, 'base64');
|
||
writeFileSync(fullPath, content);
|
||
extractedCount++;
|
||
}
|
||
console.log(`Extracted ${extractedCount} files to ${distPath}`);
|
||
|
||
// 设置环境变量
|
||
process.env.OPENCHAMBER_DIST_DIR = distPath;
|
||
process.env.OPENCHAMBER_PORT = String(port);
|
||
|
||
console.log(`Starting OpenChamber on port ${port}...`);
|
||
|
||
// 启动服务器
|
||
if (typeof serverModule.startWebUiServer === 'function') {
|
||
await serverModule.startWebUiServer({ port, attachSignals: true, exitOnShutdown: true });
|
||
} else {
|
||
console.error('Error: startWebUiServer not found in server module');
|
||
process.exit(1);
|
||
}
|
||
```
|
||
|
||
**关键点:**
|
||
- 使用 `import` 静态导入 `embedded-static.js`
|
||
- 运行时将 base64 解码并写入临时目录
|
||
- 设置 `OPENCHAMBER_DIST_DIR` 让服务器知道静态文件位置
|
||
|
||
### 3. 服务器适配
|
||
|
||
服务器需要支持从环境变量读取静态文件目录:
|
||
|
||
```javascript
|
||
// web/server/index.js
|
||
const distPath = (() => {
|
||
const env = typeof process.env.OPENCHAMBER_DIST_DIR === 'string'
|
||
? process.env.OPENCHAMBER_DIST_DIR.trim() : '';
|
||
if (env) {
|
||
return path.resolve(env);
|
||
}
|
||
return path.join(__dirname, '..', 'dist');
|
||
})();
|
||
```
|
||
|
||
### 4. package.json 配置
|
||
|
||
```json
|
||
{
|
||
"scripts": {
|
||
"build": "vite build",
|
||
"embed:static": "node embed-static.js",
|
||
"build:exe": "vite build && node embed-static.js && bun build entry-singlefile.js --compile --outfile OpenChamber.exe"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 构建命令
|
||
|
||
### 完整构建流程
|
||
|
||
```bash
|
||
cd web
|
||
bun run build:exe
|
||
```
|
||
|
||
### 分步执行
|
||
|
||
```bash
|
||
# 1. 构建前端静态文件
|
||
bun run build
|
||
|
||
# 2. 嵌入静态文件到 JS 模块
|
||
node embed-static.js
|
||
|
||
# 3. 编译成单文件 exe
|
||
bun build entry-singlefile.js --compile --outfile OpenChamber.exe
|
||
```
|
||
|
||
### 输出
|
||
|
||
```
|
||
web/
|
||
├── OpenChamber.exe # 最终产物 (~320MB)
|
||
├── embedded-static.js # 临时生成,可删除
|
||
├── entry-singlefile.js # 入口文件
|
||
└── embed-static.js # 嵌入脚本
|
||
```
|
||
|
||
---
|
||
|
||
## 已知限制
|
||
|
||
### 1. 文件体积
|
||
|
||
- 最终 exe 约 320MB
|
||
- 原因:Bun 运行时 (~80MB) + 依赖 + 静态文件
|
||
|
||
### 2. 启动时间
|
||
|
||
- 首次启动需要 2-3 秒解压静态文件
|
||
- 后续启动仍需解压(每次使用新的临时目录)
|
||
|
||
### 3. 临时文件
|
||
|
||
- 静态文件解压到 `%TEMP%\openchamber-static-{PID}\`
|
||
- 关闭程序后不会自动清理
|
||
- 可手动清理 `%TEMP%\openchamber-static-*`
|
||
|
||
### 4. 平台限制
|
||
|
||
- 需要在目标平台编译(Windows exe 需在 Windows 上编译)
|
||
- Bun 支持跨平台编译,但需要对应平台的 Bun 二进制
|
||
|
||
### 5. 原生模块
|
||
|
||
- `node-pty` 等原生模块需要特殊处理
|
||
- 当前方案依赖 Bun 的原生模块支持
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
通过 Bun compile + 静态文件嵌入,我们成功实现了:
|
||
|
||
1. **真正的单文件 exe** - 无需外部依赖
|
||
2. **简单分发** - 用户只需下载一个文件
|
||
3. **保留完整功能** - 所有 Web UI 功能正常
|
||
|
||
关键难点:
|
||
- pkg 不支持 ESM → 改用 Bun
|
||
- 静态文件无法嵌入 → base64 编码嵌入
|
||
- 运行时路径问题 → 使用临时目录
|
||
|
||
---
|
||
|
||
## 附录:相关文件
|
||
|
||
| 文件 | 用途 |
|
||
|------|------|
|
||
| `web/entry-singlefile.js` | 编译入口,处理启动逻辑 |
|
||
| `web/embed-static.js` | 将 dist/ 转换为 JS 模块 |
|
||
| `web/OpenChamber.exe` | 最终产物 |
|
||
|
||
## 参考资料
|
||
|
||
- [Bun Documentation - Compile](https://bun.sh/docs/bundler#compile)
|
||
- [pkg - Node.js 单文件打包](https://github.com/vercel/pkg)
|
||
- [Electron Builder](https://www.electron.build/) |