Skip to content

Commit fb1fae3

Browse files
author
Bot
committed
feat: 将 ripgrep 二进制文件通过 base64 编码嵌入到编译产物
compile.ts 构建时编码各平台 ripgrep 二进制文件为 base64, 运行时解码到临时文件(编译模式),开发模式回退 SDK 内置路径。 package.json 添加 compile 脚本。
1 parent 711927f commit fb1fae3

5 files changed

Lines changed: 339 additions & 9 deletions

File tree

compile.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { getMacroDefines } from "./scripts/defines.ts";
2+
import { exit } from "process";
3+
import { join, resolve } from "path";
4+
import { readFile, writeFile } from "fs/promises";
5+
6+
const outfile = process.platform === "win32" ? "claude.exe" : "claude";
7+
8+
// Use the currently running bun executable
9+
const bunExe = process.execPath;
10+
11+
// Collect FEATURE_* env vars from environment
12+
const features = Object.keys(process.env)
13+
.filter(k => k.startsWith("FEATURE_"))
14+
.map(k => k.replace("FEATURE_", ""));
15+
16+
// Auto-enable CHICAGO_MCP so @ant packages (computer-use-mcp, etc.)
17+
// are bundled into the standalone exe. Without this flag, the feature-gated
18+
// dynamic imports are tree-shaken and the native .node files are not embedded.
19+
if (!features.includes("CHICAGO_MCP")) {
20+
features.push("CHICAGO_MCP");
21+
}
22+
23+
const defines = getMacroDefines();
24+
25+
// Build --define flags
26+
const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
27+
"--define",
28+
`${k}:${v}`,
29+
]);
30+
31+
// Pass BUNDLED_MODE flag so ripgrepAsset.ts knows we're in compiled mode
32+
const defineArgsWithBundled = [
33+
...defineArgs,
34+
"--define",
35+
`BUNDLED_MODE:"true"`,
36+
];
37+
38+
// Build --feature flags
39+
const featureArgs = features.flatMap(f => ["--feature", f]);
40+
41+
// ─── Native module embedding ──────────────────────────────────────────────────
42+
// bun build --compile embeds .node files as assets. When the bundler sees
43+
// process.env.XXX_NODE_PATH with the var set to an absolute .node path,
44+
// it rewrites the string to the bunfs asset path. This lets the runtime
45+
// require() the embedded .node from within the compiled exe.
46+
//
47+
// Paths must use forward slashes and be absolute at compile time.
48+
const repoRoot = resolve(__dirname);
49+
50+
const nativeNodePaths: Record<string, string> = {
51+
// @ant packages — macOS only. Path is used at compile time for Bun asset embedding.
52+
// Runtime: TS files check process.platform !== "darwin" and skip native load.
53+
COMPUTER_USE_INPUT_NODE_PATH: join(repoRoot,
54+
"packages/@ant/computer-use-input/prebuilds/arm64-darwin/computer-use-input.node"),
55+
COMPUTER_USE_SWIFT_NODE_PATH: join(repoRoot,
56+
"packages/@ant/computer-use-swift/prebuilds/arm64-darwin/computer_use.node"),
57+
58+
// vendor modules — cross-platform (win32/linux/darwin)
59+
AUDIO_CAPTURE_NODE_PATH: join(repoRoot,
60+
`vendor/audio-capture/${process.arch}-${process.platform}/audio-capture.node`),
61+
IMAGE_PROCESSOR_NODE_PATH: join(repoRoot,
62+
`vendor/image-processor/${process.arch}-${process.platform}/image-processor.node`),
63+
// modifiers and url-handler are macOS only — paths point to darwin builds
64+
MODIFIERS_NODE_PATH: join(repoRoot,
65+
`vendor/modifiers-napi/${process.arch}-darwin/modifiers.node`),
66+
URL_HANDLER_NODE_PATH: join(repoRoot,
67+
`vendor/url-handler/${process.arch}-darwin/url-handler.node`),
68+
};
69+
70+
// Build env with native paths (forward slashes for Bun compatibility)
71+
const compileEnv: Record<string, string> = {
72+
...process.env,
73+
...Object.fromEntries(
74+
Object.entries(nativeNodePaths).map(([k, v]) => [k, v.replace(/\\/g, "/")]),
75+
),
76+
};
77+
78+
// ─── Step 0: Generate ripgrep base64 asset ───────────────────────────────────
79+
// Bun's bundler does not support ?url imports or arbitrary file embedding
80+
// for non-.node files. The only reliable way to embed a binary into the
81+
// compiled exe is to base64-encode it and store it as a JS string constant.
82+
// At runtime, we decode to a temp file and execute.
83+
async function generateRipgrepAsset() {
84+
const rgCache = join(repoRoot,
85+
`node_modules/.bun/@anthropic-ai+claude-agent-sdk@0.2.87+3c5d820c62823f0b/node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep`);
86+
87+
const ripgrepBinaries: Record<string, string> = {}
88+
89+
// Map platform+arch to filename
90+
const allPlatforms: Array<{ key: string; subdir: string; file: string }> = [
91+
{ key: 'windows_x64', subdir: 'x64-win32', file: 'rg.exe' },
92+
{ key: 'darwin_x64', subdir: 'x64-darwin', file: 'rg' },
93+
{ key: 'darwin_arm64', subdir: 'arm64-darwin', file: 'rg' },
94+
{ key: 'linux_x64', subdir: 'x64-linux', file: 'rg' },
95+
{ key: 'linux_arm64', subdir: 'arm64-linux', file: 'rg' },
96+
];
97+
98+
// Only embed the current platform's binary to minimize exe size.
99+
// The other platforms are available in the SDK for dev-mode fallback.
100+
const currentPlatformKey = (() => {
101+
if (process.platform === 'win32') return 'windows_x64'
102+
if (process.platform === 'darwin') return process.arch === 'arm64' ? 'darwin_arm64' : 'darwin_x64'
103+
return process.arch === 'arm64' ? 'linux_arm64' : 'linux_x64'
104+
})()
105+
106+
for (const { key, subdir, file } of allPlatforms) {
107+
if (key !== currentPlatformKey) continue // Skip other platforms
108+
const binPath = join(rgCache, subdir, file);
109+
try {
110+
const data = await readFile(binPath);
111+
ripgrepBinaries[key] = data.toString('base64');
112+
console.log(`Encoded ${key}: ${data.length} bytes -> ${Math.round(data.length * 1.37)} chars`);
113+
} catch (e) {
114+
console.warn(`Warning: could not read ${binPath}: ${e}`);
115+
}
116+
}
117+
118+
// Generate TypeScript asset file
119+
const assetFile = join(repoRoot, "src", "utils", "ripgrepAssetBase64.ts");
120+
const content = `/**
121+
* AUTO-GENERATED by compile.ts — do not edit manually.
122+
* Ripgrep binaries encoded as base64 strings.
123+
* Decoded at runtime to temp files for execution.
124+
*/
125+
export const RIPGREP_BINARIES: Record<string, string> = ${JSON.stringify(ripgrepBinaries, null, 2)};
126+
`;
127+
await writeFile(assetFile, content);
128+
console.log(`Generated ${assetFile}`);
129+
}
130+
131+
// ─── Step 1: Patch SDK ripgrep path ───────────────────────────────────────────
132+
// The SDK's cli.js computes dy_ from import.meta.url which points to B:\~BUN\root\...
133+
// in --compile mode. Patch it to use path.dirname(process.execPath) instead.
134+
async function patchRipgrepPaths() {
135+
// --- Patch bun cache SDK cli.js ---
136+
const sdkCachePath = join(repoRoot,
137+
"node_modules/.bun/@anthropic-ai+claude-agent-sdk@0.2.87+3c5d820c62823f0b/node_modules/@anthropic-ai/claude-agent-sdk/cli.js");
138+
const sdkContent = await readFile(sdkCachePath, "utf-8");
139+
const patchedSdk = sdkContent
140+
.replace(
141+
/import\{fileURLToPath as Uy_\}from"url";/,
142+
";",
143+
)
144+
.replace(
145+
/dy_=Uy_\(import\.meta\.url\),dy_=Z16\.join\(dy_,"\.\/"\)/,
146+
"dy_=Z16.dirname(process.execPath)",
147+
);
148+
if (patchedSdk === sdkContent) {
149+
console.warn("Warning: SDK patch did not match");
150+
} else {
151+
await writeFile(sdkCachePath, patchedSdk);
152+
console.log("Patched SDK cli.js (bun cache)");
153+
}
154+
}
155+
156+
// ─── Step 2: Run the compile ───────────────────────────────────────────────────
157+
async function run() {
158+
await generateRipgrepAsset();
159+
await patchRipgrepPaths();
160+
161+
console.log("\nCompiling standalone executable with native modules...");
162+
console.log(`Outfile: ${outfile}`);
163+
console.log(`Defines: ${Object.keys(defines).join(", ")}`);
164+
console.log(`Native modules:`);
165+
for (const [k, v] of Object.entries(nativeNodePaths)) {
166+
console.log(` ${k}=${v}`);
167+
}
168+
169+
// Use Bun.spawn with CLI because Bun.build({ outfile, compile: true })
170+
// does not reliably place the output file on Windows.
171+
const result = Bun.spawnSync(
172+
[
173+
bunExe,
174+
"build",
175+
"--compile",
176+
"--outfile=" + outfile,
177+
...defineArgsWithBundled,
178+
...featureArgs,
179+
"src/entrypoints/cli.tsx",
180+
],
181+
{
182+
stdio: ["inherit", "inherit", "inherit"],
183+
env: compileEnv,
184+
},
185+
);
186+
187+
if (result.exitCode !== 0) {
188+
console.error("Compile failed with exit code:", result.exitCode);
189+
exit(1);
190+
}
191+
192+
console.log(`\nCompiled standalone executable: ${outfile}`);
193+
if (features.length > 0) {
194+
console.log(`Features enabled: ${features.join(", ")}`);
195+
}
196+
}
197+
198+
run();

src/utils/bundledMode.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@ export function isRunningWithBun(): boolean {
1111

1212
/**
1313
* Detects if running as a Bun-compiled standalone executable.
14-
* This checks for embedded files which are present in compiled binaries.
14+
*
15+
* Primary check: Bun.embeddedFiles (present in compiled binaries).
16+
* Fallback: BUNDLED_MODE compile-time constant injected by compile.ts.
1517
*/
18+
// BUNDLED_MODE is injected at compile time by compile.ts --define flag.
19+
declare const BUNDLED_MODE: string | undefined
1620
export function isInBundledMode(): boolean {
21+
if (typeof BUNDLED_MODE !== 'undefined') return true
1722
return (
1823
typeof Bun !== 'undefined' &&
1924
Array.isArray(Bun.embeddedFiles) &&

src/utils/ripgrep.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as path from 'path'
66
import { logEvent } from 'src/services/analytics/index.js'
77
import { fileURLToPath } from 'url'
88
import { isInBundledMode } from './bundledMode.js'
9+
import { getRipgrepBinaryPath } from './ripgrepAsset.js'
910
import { logForDebugging } from './debug.js'
1011
import { isEnvDefinedFalsy } from './envUtils.js'
1112
import { execFileNoThrow } from './execFileNoThrow.js'
@@ -44,15 +45,11 @@ const getRipgrepConfig = memoize((): RipgrepConfig => {
4445
}
4546
}
4647

47-
// In bundled (native) mode, ripgrep is statically compiled into bun-internal
48-
// and dispatches based on argv[0]. We spawn ourselves with argv0='rg'.
48+
// In bundled mode (compiled exe), ripgrep is embedded via base64.
49+
// Extract to temp and execute from there.
4950
if (isInBundledMode()) {
50-
return {
51-
mode: 'embedded',
52-
command: process.execPath,
53-
args: ['--no-config'],
54-
argv0: 'rg',
55-
}
51+
const rgPath = getRipgrepBinaryPath()
52+
return { mode: 'builtin', command: rgPath, args: [] }
5653
}
5754

5855
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')

src/utils/ripgrepAsset.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Gets the ripgrep binary path for the current platform/arch.
3+
*
4+
* In compiled mode: decodes base64 from ripgrepAssetBase64.ts, writes to temp,
5+
* and caches on disk so subsequent starts skip the decode.
6+
*
7+
* In dev mode: falls back to SDK's bundled ripgrep path.
8+
*
9+
* BUNDLED_MODE is a compile-time constant injected by compile.ts --define flag.
10+
*/
11+
import { writeFileSync, readFileSync } from 'fs'
12+
import { mkdirSync } from 'fs'
13+
import { tmpdir } from 'os'
14+
import { join } from 'path'
15+
import { getPlatform } from './platform.js'
16+
17+
// In-memory cache: platform+arch -> absolute path to extracted temp file
18+
const extractedPaths: Record<string, string> = {}
19+
20+
// Global base64 data — loaded once on first access
21+
let globalBase64: Record<string, string> | null = null
22+
23+
// SDK's bundled ripgrep path (used as fallback in dev mode)
24+
function getSdkRipgrepPath(): string {
25+
const p = getPlatform()
26+
const arch = process.arch
27+
if (p === 'windows') return join(process.cwd(), 'node_modules/.bun/@anthropic-ai+claude-agent-sdk@0.2.87+3c5d820c62823f0b/node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep/x64-win32/rg.exe')
28+
if (p === 'macos') return join(process.cwd(), 'node_modules/.bun/@anthropic-ai+claude-agent-sdk@0.2.87+3c5d820c62823f0b/node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep', arch === 'arm64' ? 'arm64-darwin/rg' : 'x64-darwin/rg')
29+
return join(process.cwd(), 'node_modules/.bun/@anthropic-ai+claude-agent-sdk@0.2.87+3c5d820c62823f0b/node_modules/@anthropic-ai/claude-agent-sdk/vendor/ripgrep', arch === 'arm64' ? 'arm64-linux/rg' : 'x64-linux/rg')
30+
}
31+
32+
function getPlatformKey(): string {
33+
const platform = getPlatform()
34+
const arch = process.arch
35+
if (platform === 'windows') return 'windows_x64'
36+
if (platform === 'macos') return arch === 'arm64' ? 'darwin_arm64' : 'darwin_x64'
37+
return arch === 'arm64' ? 'linux_arm64' : 'linux_x64'
38+
}
39+
40+
// BUNDLED_MODE is injected at compile time by compile.ts --define flag.
41+
// In dev mode, this variable is undefined.
42+
declare const BUNDLED_MODE: string | undefined
43+
44+
/**
45+
* Load base64 data asynchronously (first call only).
46+
* Subsequent calls use the cached global.
47+
*/
48+
async function ensureBase64Loaded(): Promise<Record<string, string>> {
49+
if (globalBase64 !== null) return globalBase64
50+
// Dynamic import so the 6.9MB base64 string isn't loaded in dev mode
51+
const mod = await import('./ripgrepAssetBase64.js')
52+
globalBase64 = mod.RIPGREP_BINARIES ?? {}
53+
return globalBase64
54+
}
55+
56+
/**
57+
* Get the ripgrep binary path for the current platform/arch.
58+
* In compiled mode: decodes base64, extracts to temp, caches by version fingerprint.
59+
* In dev mode: returns SDK path directly.
60+
*/
61+
export function getRipgrepBinaryPath(): string {
62+
const key = getPlatformKey()
63+
if (extractedPaths[key]) return extractedPaths[key]
64+
65+
const tmpDir = join(tmpdir(), 'claude-code-ripgrep')
66+
const filename = key === 'windows_x64' ? 'rg.exe' : 'rg'
67+
const filePath = join(tmpDir, filename)
68+
const versionPath = join(tmpDir, `${key}.version`)
69+
70+
// Dev mode: use SDK path directly
71+
if (typeof BUNDLED_MODE === 'undefined') {
72+
const sdkPath = getSdkRipgrepPath()
73+
extractedPaths[key] = sdkPath
74+
return sdkPath
75+
}
76+
77+
// Compiled mode: must use base64 decode (synchronous path — loaded eagerly from embedded module)
78+
// In the compiled exe, require() resolves to the embedded ripgrepAssetBase64.js
79+
let base64Data: string | undefined
80+
try {
81+
// eslint-disable-next-line @typescript-eslint/no-var-requires
82+
const RIPGREP_BINARIES: Record<string, string> = require('./ripgrepAssetBase64.js').RIPGREP_BINARIES
83+
base64Data = RIPGREP_BINARIES[key]
84+
} catch {
85+
// require failed — fall back to SDK path
86+
}
87+
88+
if (!base64Data) {
89+
const sdkPath = getSdkRipgrepPath()
90+
extractedPaths[key] = sdkPath
91+
return sdkPath
92+
}
93+
94+
const versionTag = `b64:${base64Data.length}:${base64Data.slice(0, 16)}:${base64Data.slice(-16)}`
95+
96+
// Fast cache check: read only the version tag (~50 bytes)
97+
try {
98+
const storedTag = readFileSync(versionPath, 'utf8')
99+
if (storedTag === versionTag && readFileSync(filePath)) {
100+
extractedPaths[key] = filePath
101+
return filePath
102+
}
103+
} catch {
104+
// Cache miss or stale
105+
}
106+
107+
// Decode and extract
108+
mkdirSync(tmpDir, { recursive: true })
109+
const buffer = Buffer.from(base64Data, 'base64')
110+
writeFileSync(filePath, buffer, { mode: 0o755 })
111+
writeFileSync(versionPath, versionTag, 'utf8')
112+
extractedPaths[key] = filePath
113+
return filePath
114+
}
115+
116+
/**
117+
* Async version — preloads base64 data before extracting.
118+
* Call this early (e.g., during startup) to avoid decode delay on first grep.
119+
*/
120+
export async function preloadRipgrepBinary(): Promise<void> {
121+
getRipgrepBinaryPath()
122+
}

src/utils/ripgrepAssetBase64.ts

Lines changed: 8 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)