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

11 KiB
Raw Blame History

OpenChamber 单文件可执行文件构建指南

概述

本文档详细记录了如何将 OpenChamber Web 应用打包成一个独立的单文件可执行文件exe用户无需安装 Node.js、Bun 或任何依赖,只需双击 exe 即可运行。

最终产物:XCOpenCodeWeb.exe(约 150MB),包含完整的服务器代码、依赖和静态文件。


目录

  1. 背景与需求
  2. 技术选型
  3. 尝试过程与问题
  4. 最终方案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 打包,配置如下:

// 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 工具:

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
  • 单文件输出

初步尝试:

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)

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)

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. 服务器适配

服务器需要支持从环境变量读取静态文件目录:

// 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 配置

{
  "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"
  }
}

构建命令

完整构建流程

cd web
bun run build:exe

分步执行

# 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 最终产物

参考资料