mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Cuts the macOS .app from 435MB → 152MB (-65%) and the DMG from 113MB → 60MB
(-47%) by inlining src/server and src/entrypoints/cli into the bun-compiled
sidecar binaries instead of dynamic-importing them from disk at runtime.
Architectural change
====================
Before:
desktop/sidecars/server-launcher.ts → bun build --compile (≈57MB shell)
└─ at runtime: dynamic file:// import of <appRoot>/src/server/index.ts
which transitively requires ALL of src/ + the entire root node_modules/
to be shipped as Resource. tauri.conf.json copied 254M of node_modules
and 47M of src/ into Contents/Resources/app/ on every build.
After:
desktop/sidecars/server-launcher.ts → bun build --compile (≈65MB)
└─ uses `await import('../../src/server/index.ts')` with a literal
specifier so bun's bundler walks the whole graph statically and
inlines everything into the binary.
Same treatment for cli-launcher.ts → src/entrypoints/cli.tsx.
Resolver gymnastics
===================
This fork carries dozens of ant-internal feature() gated require/import
calls referencing modules that simply don't exist on disk
(cachedMicrocompact, devtools, proactive, coordinator, etc). Bun's resolver
walks the static dep graph BEFORE bun:bundle macro DCE, so even though
the dead branches never execute at runtime, they still fail to resolve
at compile time.
Two complementary mechanisms:
1. desktop/scripts/scan-missing-imports.ts walks src/, regex-greps every
relative import / require / type-import specifier, and writes a Proxy
noop stub for any target that doesn't exist on disk. Stubs are tagged
with "@generated stub from scan-missing-imports" for idempotency. Text
resources (.md / .txt / .json) get appropriate format-specific stubs.
Runs as a pre-step inside build:sidecars.
2. desktop/scripts/build-sidecars.ts adds an `external: [...]` list for
bare-package optional deps not in package.json (OTLP exporters,
@aws-sdk/*, @anthropic-ai/{bedrock,vertex,foundry,mcpb}-sdk,
@azure/identity, fflate, turndown, sharp, react-devtools-core).
These remain runtime imports, fail benignly when their gating env
var or feature flag is off.
Tauri side
==========
- desktop/src-tauri/tauri.conf.json: dropped all `resources` entries.
Was 7 entries totaling ≈301MB. Now `{}`.
- desktop/src-tauri/src/lib.rs `resolve_app_root` no longer calls
BaseDirectory::Resource (the app/ resource dir doesn't exist anymore);
instead returns the directory of the current sidecar exe. The launchers
still accept --app-root for backward compat with conversationService's
CLI subprocess spawn.
Optimisations
=============
- bun build now uses minify whitespace+identifiers+syntax. Saved another
≈16MB across both binaries (server: 72MB→65MB, cli: 75MB→66MB).
Bonus fix
=========
src/services/remoteManagedSettings/index.ts had a typo importing
'./securityCheck.jsx' instead of '.js'. Bun's runtime resolver tolerated
it; bun build didn't.
Verification
============
- Both binaries boot successfully in /tmp with no src/ or node_modules/
on disk. Verified `claude-cli --version` returns the build version,
`claude-cli --help` prints the full Commander spec, and claude-server
starts CronScheduler + listens on the requested port.
- bun test on src/ shows 358 pass / 45 fail / 2 errors vs main baseline
of 359 / 44 / 2 — net 0 new failures (1 different flake direction).
All 44 baseline failures pre-exist on main and are unrelated.
- Full DMG round-trip via build-macos-arm64.sh succeeds; new bundle
installs cleanly in /Volumes/.
Bundle size summary
===================
metric baseline after P0 delta
Resources/app/ 301 MB 0 MB -301 MB
MacOS/claude-server 57 MB 65 MB +8 MB
MacOS/claude-cli 57 MB 66 MB +9 MB
MacOS/claude-code-desktop 18 MB 18 MB —
─────────────────────────────────────────────
.app total 435 MB 152 MB -283 MB (-65%)
.dmg 113 MB 60 MB -53 MB (-47%)
Generated stub files (173 of them under src/) are committed for
reproducibility — the scanner is idempotent and will re-create them
identically on every build, but tracking them avoids dirty working trees
on first compile.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
220 lines
7.6 KiB
TypeScript
220 lines
7.6 KiB
TypeScript
/**
|
||
* scan-missing-imports.ts
|
||
*
|
||
* 在编译 sidecar 之前,扫描 src/ 里所有相对路径的 import / require / 类型 import
|
||
* specifier,找出磁盘上不存在的目标,给它们生成最小 stub 文件。
|
||
*
|
||
* 为什么需要:本 fork 的 src/ 大量使用 ant-internal 的 feature() macro 配
|
||
* dynamic require/import,gating 一堆只在 Anthropic 内部 build 才存在的源文件。
|
||
* bun build --compile 在 DCE 之前必须先把所有 import specifier 都 resolve 到
|
||
* 实际文件,找不到就直接 fail。
|
||
*
|
||
* Stub 文件内容是一个 Proxy,任何属性读、函数调用、构造调用都返回安全 noop。
|
||
* 由于这些代码路径都被 feature(...) === false 的 DCE 干掉了,stub 在运行时
|
||
* 永远不会真的被求值 —— 它只是给 resolver 的"占位符"。
|
||
*
|
||
* 生成的 stub 标记 `// @generated stub from scan-missing-imports` 让脚本可以
|
||
* 安全地覆写它们而不会动到真实代码。
|
||
*/
|
||
|
||
import { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises'
|
||
import { existsSync } from 'node:fs'
|
||
import path from 'node:path'
|
||
|
||
const repoRoot = path.resolve(import.meta.dir, '../..')
|
||
const srcRoot = path.join(repoRoot, 'src')
|
||
|
||
const STUB_MARKER_TS = '// @generated stub from scan-missing-imports'
|
||
const STUB_MARKER_TEXT = '<!-- @generated stub from scan-missing-imports -->'
|
||
|
||
const TS_STUB_CONTENT = `${STUB_MARKER_TS}
|
||
// 该文件自动生成,对应 ant-internal 的 feature() gated 模块。
|
||
// 所有外部 build 的代码路径在 DCE 后都不会真的执行这里的代码,这只是
|
||
// bun build resolver 的占位符。
|
||
const __target = function noop() {}
|
||
const __handler: ProxyHandler<any> = {
|
||
get(_t, prop) {
|
||
if (prop === '__esModule') return true
|
||
if (prop === 'default') return new Proxy(__target, __handler)
|
||
if (prop === Symbol.toPrimitive) return () => undefined
|
||
if (prop === Symbol.iterator) return function* () {}
|
||
if (prop === Symbol.asyncIterator) return async function* () {}
|
||
if (prop === 'then') return undefined
|
||
return new Proxy(__target, __handler)
|
||
},
|
||
apply() {
|
||
return new Proxy(__target, __handler)
|
||
},
|
||
construct() {
|
||
return new Proxy(__target, __handler)
|
||
},
|
||
}
|
||
const stub: any = new Proxy(__target, __handler)
|
||
export default stub
|
||
export const __stubMissing = true
|
||
// 兼容常见的命名导出 —— 没列在这里的也会通过 default Proxy 兜底
|
||
export const createCachedMCState = stub
|
||
export const isCachedMicrocompactEnabled = stub
|
||
export const isModelSupportedForCacheEditing = stub
|
||
export const getCachedMCConfig = stub
|
||
export const markToolsSentToAPI = stub
|
||
export const resetCachedMCState = stub
|
||
export const checkProtectedNamespace = stub
|
||
export const getCoordinatorUserContext = stub
|
||
`
|
||
|
||
// 文本类资源(.md / .txt / .json 等)通过 Bun 的 text/json loader 内联,
|
||
// stub 内容只要是合法的对应格式且非空即可。
|
||
const TEXT_STUB_CONTENT = `${STUB_MARKER_TEXT}\nstub\n`
|
||
const JSON_STUB_CONTENT = `{"__stubMissing": true}\n`
|
||
|
||
const TEXT_EXTS = new Set(['.md', '.markdown', '.txt'])
|
||
const JSON_EXTS = new Set(['.json', '.json5'])
|
||
|
||
const IMPORT_PATTERNS = [
|
||
// import X from './foo'
|
||
/from\s+['"](\.[^'"]+)['"]/g,
|
||
// import('./foo')
|
||
/import\s*\(\s*['"](\.[^'"]+)['"]/g,
|
||
// require('./foo')
|
||
/require\s*\(\s*['"](\.[^'"]+)['"]/g,
|
||
// import './foo' (side-effect only)
|
||
/import\s+['"](\.[^'"]+)['"]/g,
|
||
// typeof import('./foo')
|
||
/typeof\s+import\s*\(\s*['"](\.[^'"]+)['"]/g,
|
||
]
|
||
|
||
const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs'])
|
||
|
||
async function* walk(dir: string): AsyncGenerator<string> {
|
||
const entries = await readdir(dir, { withFileTypes: true })
|
||
for (const entry of entries) {
|
||
if (entry.name.startsWith('.')) continue
|
||
if (entry.name === 'node_modules') continue
|
||
if (entry.name === '__tests__') continue
|
||
const full = path.join(dir, entry.name)
|
||
if (entry.isDirectory()) {
|
||
yield* walk(full)
|
||
} else if (entry.isFile() && SOURCE_EXT.has(path.extname(entry.name))) {
|
||
yield full
|
||
}
|
||
}
|
||
}
|
||
|
||
function resolveCandidates(importer: string, spec: string): string[] {
|
||
const importerDir = path.dirname(importer)
|
||
const base = path.resolve(importerDir, spec)
|
||
return [
|
||
base,
|
||
base + '.ts',
|
||
base + '.tsx',
|
||
base + '.mts',
|
||
base + '.cts',
|
||
base + '.js',
|
||
base + '.jsx',
|
||
base + '.mjs',
|
||
base + '.cjs',
|
||
base.replace(/\.(m|c)?js$/, '.ts'),
|
||
base.replace(/\.(m|c)?js$/, '.tsx'),
|
||
path.join(base, 'index.ts'),
|
||
path.join(base, 'index.tsx'),
|
||
path.join(base, 'index.js'),
|
||
]
|
||
}
|
||
|
||
function pickStubPath(importer: string, spec: string): string {
|
||
const importerDir = path.dirname(importer)
|
||
const base = path.resolve(importerDir, spec)
|
||
// 把 .js 还原成 .ts —— TS 源里写 .js 是 ESM-on-Node 的惯例
|
||
if (base.endsWith('.js')) return base.slice(0, -3) + '.ts'
|
||
if (base.endsWith('.jsx')) return base.slice(0, -4) + '.tsx'
|
||
if (path.extname(base) === '') return base + '.ts'
|
||
return base
|
||
}
|
||
|
||
function pickStubContent(stubPath: string): { content: string; marker: string } {
|
||
const ext = path.extname(stubPath).toLowerCase()
|
||
if (TEXT_EXTS.has(ext)) {
|
||
return { content: TEXT_STUB_CONTENT, marker: STUB_MARKER_TEXT }
|
||
}
|
||
if (JSON_EXTS.has(ext)) {
|
||
return { content: JSON_STUB_CONTENT, marker: '"__stubMissing"' }
|
||
}
|
||
return { content: TS_STUB_CONTENT, marker: STUB_MARKER_TS }
|
||
}
|
||
|
||
async function main() {
|
||
const missing = new Map<string, Set<string>>() // stubPath → set of importers
|
||
let scannedFiles = 0
|
||
|
||
for await (const file of walk(srcRoot)) {
|
||
scannedFiles++
|
||
let contents: string
|
||
try {
|
||
contents = await readFile(file, 'utf8')
|
||
} catch {
|
||
continue
|
||
}
|
||
|
||
for (const pattern of IMPORT_PATTERNS) {
|
||
pattern.lastIndex = 0
|
||
let match: RegExpExecArray | null
|
||
while ((match = pattern.exec(contents)) !== null) {
|
||
const spec = match[1]!
|
||
if (!spec.startsWith('.')) continue
|
||
const candidates = resolveCandidates(file, spec)
|
||
let exists = false
|
||
for (const c of candidates) {
|
||
if (existsSync(c)) {
|
||
exists = true
|
||
break
|
||
}
|
||
}
|
||
if (exists) continue
|
||
const stubPath = pickStubPath(file, spec)
|
||
if (!missing.has(stubPath)) missing.set(stubPath, new Set())
|
||
missing.get(stubPath)!.add(path.relative(repoRoot, file))
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`[scan] scanned ${scannedFiles} source files`)
|
||
console.log(`[scan] missing ${missing.size} stub targets`)
|
||
|
||
let createdCount = 0
|
||
let skippedCount = 0
|
||
for (const [stubPath, importers] of missing) {
|
||
// 安全检查:只在 src/ 下创建,且如果文件已存在但不是 stub 就跳过
|
||
if (!stubPath.startsWith(srcRoot + path.sep)) {
|
||
console.warn(`[scan] skip out-of-src stub target: ${stubPath}`)
|
||
continue
|
||
}
|
||
const { content, marker } = pickStubContent(stubPath)
|
||
if (existsSync(stubPath)) {
|
||
try {
|
||
const existing = await readFile(stubPath, 'utf8')
|
||
if (!existing.includes(marker) && !existing.includes(STUB_MARKER_TS)) {
|
||
console.warn(
|
||
`[scan] skip non-stub existing file: ${path.relative(repoRoot, stubPath)}`,
|
||
)
|
||
skippedCount++
|
||
continue
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
await mkdir(path.dirname(stubPath), { recursive: true })
|
||
await writeFile(stubPath, content, 'utf8')
|
||
createdCount++
|
||
const rel = path.relative(repoRoot, stubPath)
|
||
const sample = [...importers].slice(0, 2).join(', ')
|
||
console.log(
|
||
`[scan] stub: ${rel} (referenced from ${sample}${importers.size > 2 ? `, +${importers.size - 2}` : ''})`,
|
||
)
|
||
}
|
||
console.log(`[scan] created ${createdCount} stubs, skipped ${skippedCount}`)
|
||
}
|
||
|
||
await main()
|