ttsx 运行时钩子
ttsx 是 ttsc 的"带类型检查的 TypeScript 执行器"。它在真正类型检查之后再执行入口,给了 tsx/ts-node 那样的全图源码执行,但不削弱编译门。本页讲它两阶段的运行时机制:父进程的项目构建 + 虚拟布局,子进程的同步模块钩子。源码 packages/ttsc/src/launcher/internal/runTtsx.ts、prepareExecution.ts、runtimeHooks.ts、registerRuntimeHooks.ts。
两阶段总览
阶段一:父进程构建 + 虚拟布局
prepareExecution(prepareExecution.ts:21):
readProjectConfig找入口的所属 tsconfig。runBuild({ emit: true, ... })把项目(含 typia 等 transform 插件)emit 到 PID 隔离的临时目录(processDir = cacheDir/project/<pid>)。linkVirtualProjectLayout(prepareExecution.ts:182)建一个虚拟文件系统布局:目录软链、文件硬链(跨设备回退到拷贝),让node_modules软链与import.meta.url仍指向源码树。resolveEmittedJavaScript定位 emit 出的入口 JS。
为什么要虚拟布局:tsgo emit 把源 rootDir 前缀剥掉,产物落在 emitDir。但运行时要让 __dirname/import.meta.url 看起来仍在源码树,且 node_modules 要能解析。虚拟布局用软/硬链把真实 node_modules 等映射进虚拟根,collectLinkDirectories(prepareExecution.ts:250)从项目根向上最多 3 层(停在 workspace 根 pnpm-workspace.yaml/.git),覆盖常见 monorepo 布局(workspace-root → packages → package-root)。
virtualPath(prepareExecution.ts:285)把绝对路径映射进虚拟根下稳定、文件系统安全的子树(POSIX 用 "posix" 前缀,Windows 给每个盘符/UNC 根一个 sanitized 标签防碰撞)。
阶段二:子进程同步钩子
runPreparedEntry(runTtsx.ts:260)写一个 runtime-manifest.json(指向已构建 emit、emitDir、rootDir、moduleOption、depCacheDir),然后 spawn:
bootstrap 作为主模块运行(不是 --import),这样 CommonJS require 链能触达钩子。NODE_OPTIONS 注入 runtimeHookPreload.js 让程序 spawn 的 worker(node worker.ts)也继承源加载器。TTSC_TSGO_BINARY 让依赖构建找得到 tsgo。
runtimeHooks.ts(~1300+ LOC)安装的同步模块钩子(经 module.registerHooks)有三条加载路径(runtimeHooks.ts:14 注释):
为什么路径 2 要真正构建而非类型剥离:Node 的类型剥离做不了跨文件 type-only elision——比如一个 type+namespace 合并的 value-shaped import 在剥离后存活、运行时悬空(runtimeHooks.ts:29 注释)。
钩子是同步、跑在主线程(不是 loader worker),这正是让 CommonJS require("./x") 链能触达它们、且让 runBuild 插件加载器里的 require.resolve(..., { paths }) 行为正确的原因(runtimeHooks.ts:39 注释)。
--no-plugins:config 加载器的关键
--no-plugins(runTtsx.ts:55 注释)让入口的所属项目在禁用插件发现与加载下构建。ttsc 自己的 *.config.ts 加载器用它:那次构建只需类型检查并运行 config 文件,加载宿主项目的 transform/check 插件(@nestia/core、typia)既浪费又错——那些插件强加 config 加载器 tsconfig 故意不满足的项目要求(如 strict 模式)。
参数路由的微妙处
parseCLI(runTtsx.ts:83)有几个棘手点:
-P是--project别名(大写,因为 schema 用-p给 ttsc 的--tsconfig,会冲突)——手工 rewrite。forwardAfterFirstPositional: true:入口文件后的 token 是用户程序的 argv(ttsx typia.ts generate --input src里的generate --input src),绝不到 tsgo;入口前的 flag 转发给 tsgo。--require/-r接受重复值;schema 引擎只记最后一个,所以 rescue 扫描重建完整列表,但在入口/--边界停止(防ttsx entry.ts -r preload.cjs既 preload 又转发给程序 argv 的双重效应)。
PID 隔离与清理
整个临时输出在 cacheDir/project/<pid> 下,进程退出(成功或失败)都 removeRuntimeOutput 清理(runTtsx.ts:321 finally)。并发 ttsx 进程靠 PID 隔离互不干扰。
不变量
- 入口项目类型检查门不被削弱(先构建、失败即停)。
- 钩子同步、主线程,让 CJS require 链与 require.resolve 正确。
- 入口项目的
.ts服务预构建 emit(插件已应用),其他依赖按需构建各自 tsconfig。 --no-plugins让 config 加载不触发宿主项目插件。