# 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/)