Files
XCOpenCodeWeb/docs/single-file-exe-build.md

411 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/)