mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
experiment(desktop): merge server + cli into one sidecar binary
Replaces the two ~65MB bun-compiled sidecar binaries with a single
~66MB merged binary. The bun runtime + shared dependency code (anthropic
SDK, MCP SDK, ws, undici, etc) was previously duplicated across both —
merging eliminates that duplication entirely.
Combined with the previous P0 commit (static-import inlining + drop
src/ + node_modules/ from Resources), this brings the macOS .app from
the original 435MB baseline down to 87MB (-80%), and the DMG from 113MB
to 37MB (-67%).
Final breakdown of the 87MB .app:
Contents/MacOS/claude-sidecar 66MB (was 57+57=114MB)
Contents/MacOS/claude-code-desktop 18MB (Tauri Rust main)
Contents/Resources/icon.icns 2MB
+ plist + frameworks ~1MB
This is essentially the floor — bun runtime + Tauri main + minimum
overhead. Going lower would require swapping toolchains.
Implementation
==============
* desktop/sidecars/claude-sidecar.ts (new): single entrypoint that
takes a positional mode argument ("server" or "cli") then dispatches
via `await import('../../src/server/index.ts').startServer()` or
`await import('../../src/entrypoints/cli.tsx')`. Same env / argv setup
pattern as the old launchers.
* desktop/sidecars/server-launcher.ts + cli-launcher.ts: deleted.
* desktop/scripts/build-sidecars.ts: compiles only claude-sidecar now.
* desktop/src-tauri/tauri.conf.json: externalBin → ["binaries/claude-sidecar"]
* desktop/src-tauri/src/lib.rs: spawns sidecar with leading "server"
positional arg.
* src/server/services/conversationService.ts resolveBundledCliPath /
resolveCliArgs: when current process is claude-sidecar, reuses the
same exe and spawns it with leading "cli" positional arg. Backward
compat path for old claude-server / claude-cli pair preserved for the
bin/claude-haha dev mode.
Verification
============
* claude-sidecar cli --version → 999.0.0-local (Claude Code) ✓
* claude-sidecar cli --help → full Commander spec ✓
* claude-sidecar server --port N → HTTP listening, CronScheduler running ✓
* All three above run in /tmp with no src/ or node_modules/ on disk
* bun test on src/ → 358 pass / 45 fail / 1 error, identical to baseline
(44 fails are pre-existing on main, unrelated to this change)
* Full DMG round-trip via build-macos-arm64.sh succeeds; new .app
installs cleanly in /Volumes/
Bundle size summary (vs original baseline)
==========================================
metric baseline final delta
.app total 435 MB 87 MB -348 MB (-80%)
.dmg 113 MB 37 MB -76 MB (-67%)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
662485c3eb
commit
3c5549e221
@ -26,21 +26,17 @@ if (scanExit !== 0) {
|
||||
|
||||
await mkdir(binariesDir, { recursive: true })
|
||||
|
||||
// 单一合并 sidecar:server / cli 共享一份 bun runtime + 共享依赖代码。
|
||||
// 调用方(Tauri lib.rs / conversationService)通过第一个 positional 参数
|
||||
// 选择 'server' 或 'cli' 模式,详见 desktop/sidecars/claude-sidecar.ts。
|
||||
await compileExecutable({
|
||||
entrypoint: path.join(desktopRoot, 'sidecars/server-launcher.ts'),
|
||||
outfileBase: path.join(binariesDir, `claude-server-${targetTriple}`),
|
||||
productName: 'Claude Code Server',
|
||||
entrypoint: path.join(desktopRoot, 'sidecars/claude-sidecar.ts'),
|
||||
outfileBase: path.join(binariesDir, `claude-sidecar-${targetTriple}`),
|
||||
productName: 'Claude Code Sidecar',
|
||||
bunTarget,
|
||||
})
|
||||
|
||||
await compileExecutable({
|
||||
entrypoint: path.join(desktopRoot, 'sidecars/cli-launcher.ts'),
|
||||
outfileBase: path.join(binariesDir, `claude-cli-${targetTriple}`),
|
||||
productName: 'Claude Code CLI',
|
||||
bunTarget,
|
||||
})
|
||||
|
||||
console.log(`[build-sidecars] Built desktop sidecars for ${targetTriple} (${bunTarget})`)
|
||||
console.log(`[build-sidecars] Built desktop sidecar for ${targetTriple} (${bunTarget})`)
|
||||
|
||||
async function detectHostTriple() {
|
||||
const proc = Bun.spawn(['rustc', '-vV'], {
|
||||
|
||||
64
desktop/sidecars/claude-sidecar.ts
Normal file
64
desktop/sidecars/claude-sidecar.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Claude Code 桌面端合并 sidecar 入口。
|
||||
*
|
||||
* 历史上 server 和 cli 是两个独立的 bun-compile 二进制,各自携带一份
|
||||
* ~55MB 的 bun runtime。把两份合并成一个二进制只保留一份 runtime,
|
||||
* 是 P0 之后剩余空间的最大优化点。
|
||||
*
|
||||
* 调用约定(必须由调用方传第一个 positional 参数选择模式):
|
||||
*
|
||||
* claude-sidecar server --app-root <path> --host 127.0.0.1 --port 12345
|
||||
* claude-sidecar cli --app-root <path> [其它 CLI 参数...]
|
||||
*
|
||||
* 任何模式都必须先做 process.env / process.argv 设置,再 await 进入相应的
|
||||
* 子模块树,原因和 server-launcher.ts / cli-launcher.ts 注释一致 ——
|
||||
* src/server/index.ts 顶层立刻读 process.argv,必须在它求值前 splice 掉
|
||||
* --app-root 和 mode 这些 launcher-only 参数。
|
||||
*/
|
||||
|
||||
const rawArgs = process.argv.slice(2)
|
||||
if (rawArgs.length === 0) {
|
||||
console.error('claude-sidecar: missing mode argument (expected "server" or "cli")')
|
||||
process.exit(2)
|
||||
}
|
||||
const mode = rawArgs[0]!
|
||||
const restArgs = rawArgs.slice(1)
|
||||
|
||||
const { appRoot, args } = parseLauncherArgs(restArgs)
|
||||
|
||||
process.env.CLAUDE_APP_ROOT = appRoot
|
||||
process.env.CALLER_DIR ||= process.cwd()
|
||||
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
|
||||
|
||||
await import('../../preload.ts')
|
||||
|
||||
if (mode === 'server') {
|
||||
const { startServer } = await import('../../src/server/index.ts')
|
||||
startServer()
|
||||
} else if (mode === 'cli') {
|
||||
await import('../../src/entrypoints/cli.tsx')
|
||||
} else {
|
||||
console.error(`claude-sidecar: unknown mode "${mode}" (expected "server" or "cli")`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
function parseLauncherArgs(rawArgs: string[]): { appRoot: string; args: string[] } {
|
||||
const nextArgs: string[] = []
|
||||
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
|
||||
|
||||
for (let index = 0; index < rawArgs.length; index++) {
|
||||
const arg = rawArgs[index]
|
||||
if (arg === '--app-root') {
|
||||
appRoot = rawArgs[index + 1] ?? null
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
nextArgs.push(arg!)
|
||||
}
|
||||
|
||||
if (!appRoot) {
|
||||
throw new Error('Missing --app-root for claude-sidecar')
|
||||
}
|
||||
|
||||
return { appRoot, args: nextArgs }
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Claude Code 桌面端 CLI sidecar 入口。
|
||||
*
|
||||
* 与 server-launcher.ts 同理:通过字面量 specifier 的 dynamic import
|
||||
* 让 `bun build --compile` 把整棵 src/entrypoints/cli.tsx 依赖图静态
|
||||
* 内联进二进制。运行时不再依赖磁盘上的 src/ 或 node_modules/。
|
||||
*/
|
||||
|
||||
const { appRoot, args } = parseLauncherArgs()
|
||||
|
||||
process.env.CLAUDE_APP_ROOT = appRoot
|
||||
process.env.CALLER_DIR ||= process.cwd()
|
||||
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
|
||||
|
||||
await import('../../preload.ts')
|
||||
await import('../../src/entrypoints/cli.tsx')
|
||||
|
||||
function parseLauncherArgs() {
|
||||
const rawArgs = process.argv.slice(2)
|
||||
const nextArgs: string[] = []
|
||||
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
|
||||
|
||||
for (let index = 0; index < rawArgs.length; index++) {
|
||||
const arg = rawArgs[index]
|
||||
if (arg === '--app-root') {
|
||||
appRoot = rawArgs[index + 1] ?? null
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
nextArgs.push(arg)
|
||||
}
|
||||
|
||||
if (!appRoot) {
|
||||
throw new Error('Missing --app-root for claude-cli sidecar')
|
||||
}
|
||||
|
||||
return { appRoot, args: nextArgs }
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Claude Code 桌面端 server sidecar 入口。
|
||||
*
|
||||
* 这里把 src/server 这棵树通过字面量 specifier 的 dynamic import 拉进来,
|
||||
* `bun build --compile` 会把它们作为静态依赖完整内联进单个二进制 ——
|
||||
* 编译后运行时不再需要磁盘上的 src/ 或 node_modules/。
|
||||
*
|
||||
* 注意:先做 process.env / process.argv 设置,再 await import,这样
|
||||
* - src/server/index.ts 顶层读 process.argv 时拿到的是被剥过 --app-root 的值;
|
||||
* - preload.ts 设置的 MACRO 全局在 server 模块求值前到位。
|
||||
*/
|
||||
|
||||
const { appRoot, args } = parseLauncherArgs()
|
||||
|
||||
// 维持 conversationService → CLI 子进程的兼容契约:CLI sidecar 仍然
|
||||
// 接受 --app-root 参数,所以 server 这边把它原样透传到环境变量里。
|
||||
process.env.CLAUDE_APP_ROOT = appRoot
|
||||
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
|
||||
|
||||
await import('../../preload.ts')
|
||||
const { startServer } = await import('../../src/server/index.ts')
|
||||
startServer()
|
||||
|
||||
function parseLauncherArgs() {
|
||||
const rawArgs = process.argv.slice(2)
|
||||
const nextArgs: string[] = []
|
||||
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
|
||||
|
||||
for (let index = 0; index < rawArgs.length; index++) {
|
||||
const arg = rawArgs[index]
|
||||
if (arg === '--app-root') {
|
||||
appRoot = rawArgs[index + 1] ?? null
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
nextArgs.push(arg)
|
||||
}
|
||||
|
||||
if (!appRoot) {
|
||||
throw new Error('Missing --app-root for claude-server sidecar')
|
||||
}
|
||||
|
||||
return { appRoot, args: nextArgs }
|
||||
}
|
||||
@ -105,11 +105,13 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
|
||||
let app_root = resolve_app_root(app)?;
|
||||
let app_root_arg = app_root.to_string_lossy().to_string();
|
||||
|
||||
// 单一合并 sidecar:第一个参数选 server / cli 模式。
|
||||
let sidecar = app
|
||||
.shell()
|
||||
.sidecar("claude-server")
|
||||
.map_err(|err| format!("resolve server sidecar: {err}"))?
|
||||
.sidecar("claude-sidecar")
|
||||
.map_err(|err| format!("resolve sidecar: {err}"))?
|
||||
.args([
|
||||
"server",
|
||||
"--app-root",
|
||||
&app_root_arg,
|
||||
"--host",
|
||||
|
||||
@ -44,8 +44,7 @@
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"externalBin": [
|
||||
"binaries/claude-server",
|
||||
"binaries/claude-cli"
|
||||
"binaries/claude-sidecar"
|
||||
],
|
||||
"resources": {},
|
||||
"icon": [
|
||||
|
||||
@ -448,19 +448,29 @@ export class ConversationService {
|
||||
}
|
||||
|
||||
private resolveBundledCliPath(): string | null {
|
||||
// 桌面端 P0+P2 之后只有一个合并的 sidecar 二进制 —— `claude-sidecar`,
|
||||
// 它通过第一个 positional 参数 (server / cli) 选模式。当前进程要么
|
||||
// 已经是这个 sidecar 自己(spawn 子 CLI 时复用同一个文件),要么是
|
||||
// 旧 dev 模式下走 bin/claude-haha。这里支持两种命名:
|
||||
// - 桌面端 prod build:进程名 claude-sidecar*
|
||||
// - 旧 server-only 二进制(向后兼容):claude-server*
|
||||
const execPath = process.execPath
|
||||
const execName = path.basename(execPath)
|
||||
|
||||
if (!execName.startsWith('claude-server')) {
|
||||
return null
|
||||
if (execName.startsWith('claude-sidecar')) {
|
||||
// 复用同一个二进制,调用 cli 模式
|
||||
return execPath
|
||||
}
|
||||
|
||||
const bundledCliPath = path.join(
|
||||
path.dirname(execPath),
|
||||
execName.replace(/^claude-server/, 'claude-cli'),
|
||||
)
|
||||
if (execName.startsWith('claude-server')) {
|
||||
const bundledCliPath = path.join(
|
||||
path.dirname(execPath),
|
||||
execName.replace(/^claude-server/, 'claude-cli'),
|
||||
)
|
||||
return fs.existsSync(bundledCliPath) ? bundledCliPath : null
|
||||
}
|
||||
|
||||
return fs.existsSync(bundledCliPath) ? bundledCliPath : null
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveCliArgs(baseArgs: string[]): string[] {
|
||||
@ -473,9 +483,21 @@ export class ConversationService {
|
||||
return ['bun', cliCommand, ...baseArgs]
|
||||
}
|
||||
|
||||
const cliBaseName = path.basename(cliCommand)
|
||||
|
||||
// 合并 sidecar 模式:第一个参数必须是 'cli',后面跟 --app-root 透传
|
||||
if (cliBaseName.startsWith('claude-sidecar')) {
|
||||
const args = ['cli', ...baseArgs]
|
||||
if (process.env.CLAUDE_APP_ROOT) {
|
||||
return [cliCommand, 'cli', '--app-root', process.env.CLAUDE_APP_ROOT, ...baseArgs]
|
||||
}
|
||||
return [cliCommand, ...args]
|
||||
}
|
||||
|
||||
// 旧两段式 sidecar:claude-cli 二进制需要 --app-root
|
||||
if (
|
||||
process.env.CLAUDE_APP_ROOT &&
|
||||
path.basename(cliCommand).startsWith('claude-cli')
|
||||
cliBaseName.startsWith('claude-cli')
|
||||
) {
|
||||
return [
|
||||
cliCommand,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user