cc-haha/desktop/scripts/scan-missing-imports.ts
程序员阿江(Relakkes) 662485c3eb experiment(desktop): static-import sidecars + drop src/ + node_modules/ from bundle
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>
2026-04-12 00:05:39 +08:00

220 lines
7.6 KiB
TypeScript
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.

/**
* scan-missing-imports.ts
*
* 在编译 sidecar 之前,扫描 src/ 里所有相对路径的 import / require / 类型 import
* specifier找出磁盘上不存在的目标给它们生成最小 stub 文件。
*
* 为什么需要:本 fork 的 src/ 大量使用 ant-internal 的 feature() macro 配
* dynamic require/importgating 一堆只在 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()