入口与启动
职责
openclaw 命令敲下回车后,到第一条业务命令真正开始执行前,中间的所有事都归"入口 (entry)"管:Node 版本检查、compile cache 路径选择、help/version 快速路径、信号转发、最后把控制权交给 dist/entry.js。entry.js 接过手后再做一遍环境归一化、profile/container 选项解析、respawn 判定,然后调 runMainOrRootHelp 把 argv 交给 CLI 分发。
入口分两层是有原因的:openclaw.mjs 是纯 Node ESM、无任何 TypeScript 依赖、可独立执行,它解决的是"在 TS 编译产物加载之前就要决定的事";src/entry.ts 是 TS 编译产物的入口,它假定 dist/ 已经存在,可以做更复杂的动态 import 与 trace。
设计动机
为什么把入口拆成两层、还要在第一层塞这么多 fast path?根因是冷启动延迟。OpenClaw 是个 CLI,用户对 openclaw --version 这种命令的延迟容忍度在百毫秒级,但完整加载 Commander + 插件 + 配置要几百毫秒甚至上秒。如果每次 --version 都走完整启动链路,体验会很差。
入口层的策略是:能不加载就别加载。版本号直接从 package.json 读,help 文本从一份预编译的 cli-startup-metadata.json 读,只有真正要执行命令时才 dynamic import dist/entry.js。这让 openclaw --version 在几十毫秒内就能输出。
第二层 entry.ts 也有自己的 fast path:它先尝试 tryHandleRootHelpFastPath 和 tryHandlePrecomputedCommandHelpFastPath,都不命中才 dynamic import ./cli/run-main.js。这条 import 链每一步都 await,所以前面的 fast path 命中时后面的代码根本不加载。
另一个动机是compile cache 隔离。Node 22+ 的 module.enableCompileCache() 能显著加速 ESM 启动,但源码 checkout 和 Windows 上的 Node 24 早期版本有死锁 bug。入口层专门有一段 respawn 逻辑:先判断当前进程是不是源码 checkout (isSourceCheckoutLauncher)、是不是受影响的 Node 24 版本,如果是就 respawn 一个禁用 compile cache 的子进程,把 NODE_DISABLE_COMPILE_CACHE=1 设进 env。这个判定必须在 TS 入口加载前做完,否则缓存已经污染了。
关键文件
openclaw.mjs 版本守卫:L11-L53—MIN_NODE_MAJOR=22/MIN_NODE_MINOR=19,低于就process.exit(1)。openclaw.mjs compile cache respawn:L207-L264—respawnWithoutCompileCacheIfNeeded+respawnWithPackagedCompileCacheIfNeeded决定是否需要重启子进程。openclaw.mjs help fast path:L717-L763—tryOutputBareRootHelp和tryOutputPrecomputedCommandHelp直接吐预编译 help 文本。openclaw.mjs fallback to entry.js:L765-L780— fast path 全不命中才 dynamic import./dist/entry.js。entry.ts isMainModule 守卫:L56-L79— 防止 bundler 把 entry.js 当共享依赖二次 import 时重复启动 gateway。entry.ts respawn 计划:L90-L103—buildCliRespawnPlan+runCliRespawnPlan处理需要切换运行时的场景。entry.ts 版本 fast path 与 runMainOrRootHelp 调用:L130-L131—tryHandleRootVersionFastPath不命中才走主分发。entry.ts runMainOrRootHelp:L271-L295— help fast path + dynamic import run-main。entry.ts 预编译命令 help:L208-L269—tryHandlePrecomputedCommandHelpFastPath处理browser/secrets/nodes+ 子命令 help。
数据流
第一层 openclaw.mjs 顶部的版本守卫(openclaw.mjs:L38):
const ensureSupportedNodeVersion = () => {
if (isSupportedNodeVersion(parseNodeVersion(process.versions.node))) {
return;
}
process.stderr.write(
`openclaw: Node.js v${MIN_NODE_VERSION}+ is required (current: v${process.versions.node}).\n` +
"If you use nvm, run:\n" +
` nvm install ${MIN_NODE_MAJOR}\n` +
` nvm use ${MIN_NODE_MAJOR}\n` +
` nvm alias default ${MIN_NODE_MAJOR}\n`,
);
process.exit(1);
};MIN_NODE_MAJOR=22、MIN_NODE_MINOR=19 是硬门槛,低于直接退出。版本守卫之后是 compile cache 的 respawn 决策(openclaw.mjs:L207):
const respawnWithoutCompileCacheIfNeeded = () => {
const needsDisabledCompileCacheRespawn =
isSourceCheckoutLauncher() || shouldSkipCompileCacheForWindowsNode24();
if (!needsDisabledCompileCacheRespawn) {
return false;
}
if (process.env[COMPILE_CACHE_DISABLED_RESPAWNED_ENV] === "1") {
return false;
}
// ...
return runRespawnedChild(
process.execPath,
[...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)],
env,
);
};注意 COMPILE_CACHE_DISABLED_RESPAWNED_ENV 这个自循环守卫:respawn 出来的子进程会再跑一遍这段逻辑,但环境变量已经是 "1" 了,所以最多只 respawn 一次,不会无限递归。
第一层 fallback 到 dist/entry.js(openclaw.mjs:L765):fast path 全不命中才 tryImport("./dist/entry.js"),失败再试 .mjs,都失败才 buildMissingEntryErrorMessage 给出"这是未构建的源码树,跑 pnpm install && pnpm build"的提示。tryImport 只吞"直接的 module not found",其他错误原样抛——这样 dist/entry.js 存在但其内部某段 import 失败时,错误不会被吞掉伪装成"missing dist"。
第二层 entry.ts 的入口分发的核心(entry.ts:L130):
if (!tryHandleRootVersionFastPath(process.argv)) {
await runMainOrRootHelp(process.argv);
}runMainOrRootHelp 自己也先尝试 help fast path(entry.ts:L271):
async function runMainOrRootHelp(argv: string[]): Promise<void> {
if (await tryHandleRootHelpFastPath(argv)) {
return;
}
if (await tryHandlePrecomputedCommandHelpFastPath(argv)) {
return;
}
try {
const { runCli } = await gatewayEntryStartupTrace.measure(
"run-main-import",
() => import("./cli/run-main.js"),
);
await runCli(argv);
} catch (error) {
const { formatCliFailureLines } = await import("./cli/failure-output.js");
for (const line of formatCliFailureLines({
title: "Could not start the CLI.",
error,
argv,
})) {
console.error(line);
}
process.exit(1);
}
}注意 gatewayEntryStartupTrace.measure("run-main-import", ...)——run-main.js 这一次 dynamic import 被显式计时,因为它是冷启动里最重的一步(几百毫秒)。runCli 抛错时不直接 console.error(error.stack),而是统一走 formatCliFailureLines 给出一个用户友好的标题 + trace,然后 process.exit(1)。
entry.ts 顶部还有个关键的 isMainModule 守卫(entry.ts:L56):
if (
!isMainModule({
currentFile: fileURLToPath(import.meta.url),
wrapperEntryPairs: [...ENTRY_WRAPPER_PAIRS],
})
) {
// Imported as a dependency — skip all entry-point side effects.
} else {
// ... 完整启动逻辑
}注释说出了原因:bundler 可能把 entry.js 当成共享依赖被 dist/index.js import,没有这个守卫就会启动两个 gateway,第二个抢端口失败导致崩溃。
边界与失败
- respawn 信号转发:
runRespawnedChild(openclaw.mjs:L102)给子进程挂SIGTERM/SIGINT/SIGHUP/SIGQUIT监听,收到信号后转发给子进程,并在 1 秒后兜底process.exit。这样防止子进程忽略信号导致 launcher 永远挂住。Windows 平台信号集不同(只有SIGTERM/SIGINT/SIGBREAK)。 - compile cache 路径污染:如果
NODE_COMPILE_CACHE环境变量指向一个不可写目录,module.enableCompileCache会静默失败(openclaw.mjs:L267),try/catch吞掉错误——这是有意为之,缓存失败不应该阻止启动。 - 源码 checkout 检测:
isSourceCheckoutLauncher()通过.git或src/entry.ts是否存在来判断,如果是源码树就强制禁用 compile cache(因为源码可能随时被改,缓存的编译产物会过期)。 - missing dist 报错:
buildMissingEntryErrorMessage(openclaw.mjs:L354)会检测src/entry.ts是否存在,存在就提示"这是未构建的源码树,跑pnpm install && pnpm build",而不是简单说"file not found"——这个区分能省用户几分钟排查时间。 - isMainModule 双重启动:
entry.ts顶部守卫(entry.ts:L56)的注释指出,没有这个守卫 bundler 会把 entry.js 作为共享依赖,导致runCli被调用两次,第二个实例抢 gateway 锁/端口失败崩溃。 - respawn 循环防护:
COMPILE_CACHE_DISABLED_RESPAWNED_ENV和OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED两个环境变量分别守卫两类 respawn,保证最多只 respawn 一次。
小结
入口层做的是"在 TS 加载之前就能解决的事":版本守卫、compile cache 决策、help/version fast path、信号转发。openclaw.mjs 是纯 Node 脚本,不依赖任何编译产物;entry.ts 是 TS 编译产物的入口,补上 respawn 计划、profile 解析、help fast path 兜底,最后才把 argv 交给 runCli 真正进入 CLI 命令分发。分发链路怎么把 argv 路由到具体的 subcli、怎么走 gateway hot path、怎么 lazy-register 命令,看下一页;gateway 本身的启动序列看 网关核心。
对照官方资料:OpenClaw 文档 · README。