scriptc:将TypeScript编译为零运行时的原生可执行文件

Vercel Labs的scriptc将普通TypeScript编译成小巧、快速的原生二进制文件,无需Node或V8。了解其工作原理、可编译内容以及与Go和Rust的对比。

scriptc:将TypeScript编译为零运行时的原生可执行文件

Vercel Labs 开源了 scriptc,一个 TypeScript 到原生的编译器,可将普通 TypeScript 转换为独立的原生可执行文件——最终二进制文件中无需 Node.js、V8 或任何 JavaScript 引擎。结果如何?二进制文件启动时间约 2 毫秒,占用 170–200KB 空间,内存消耗仅 1–4MB。

问题:TypeScript 的运行时开销

TypeScript 开发者喜爱该语言的类型安全性和工具链,但部署 Node.js 应用意味着要捆绑一个庞大的运行时。即使使用 Node.js 单可执行应用程序(SEA),二进制文件也可能膨胀到 60–100MB。在现代硬件上启动时间约为 47 毫秒,内存使用通常超过 100MB RSS。

scriptc 采取了不同的方法:它不提供 JavaScript 引擎,而是通过 LLVM 或 C 后端直接将 TypeScript 编译为原生代码。结果是一个行为与 Node.js 逐字节一致但无额外开销的二进制文件。

工作原理

scriptc 采用三层编译模型:

  1. 静态编译(默认)—— 可直接编译为原生代码的 TypeScript 结构被直接编译,无需引擎参与。
  2. 动态运行(--dynamic—— 嵌入式 QuickJS-ng 引擎(约 620KB)执行无法静态编译的代码,例如 npm 依赖项附带的 JavaScript 或 any 类型的代码。跨越回静态代码的值会在运行时进行验证。
  3. 拒绝—— 其他所有情况都会失败,并给出特定的错误代码、代码帧以及通常的重写提示。不会静默地错误编译任何内容。
$ scriptc coverage app.ts

statements analyzed 4481
compile statically 4451 (99%)

blockers:
×2 functions with optional parameters as values SC1090
×1 Promise.reject SC2020

可编译为原生代码的内容

scriptc 的静态编译覆盖了实际程序使用的语言特性和标准库:

语言特性

  • 支持单继承和真正动态分派的类(在可证明安全时去虚拟化)
  • 具有 JavaScript 捕获语义的闭包
  • 泛型(单态化)
  • 由 TypeScript 自身窄化驱动的标记值判别联合
  • 基于栈式纤程的 async/await,具有与 JavaScript 精确的调度
  • finally 的异常
  • 解构、展开、可选/默认/剩余参数
  • getter/setter
  • 字符串、数组、Map、Set 上的迭代器
  • 模板字面量
  • 正则表达式(使用与 QuickJS 相同的 ECMAScript 精确字节码解释器)

标准库

  • 具有 UTF-16 精确语义的字符串
  • 具有 JavaScript 精确顺序和同一性的数组、Map、Set
  • 带运行时验证转换的 JSON
  • Math、类型化数组、Buffer
  • 带类型化 catch 的错误层次结构

Node.js API 接口

  • fs(同步和 Promise)
  • path(逐字节精确移植)
  • process、带管道流的 child_process
  • oscryptourl/URLzlib
  • 基于无依赖事件循环的定时器和信号处理器
  • 服务器栈:nethttphttpstls(内置 mbedTLS)、dgramdnsfs.watchreadline

Web API

  • fetch 和 WHATWG 网络子集(流、Headers、AbortSignal),基于相同的原生网络/TLS 栈
  • 重定向、gzip、AbortSignal.timeout、Node 形状的错误原因
  • 无 libcurl,无系统 HTTP 依赖

npm 依赖项(使用 --dynamic

  • 包使用 Node 自身的算法解析
  • 根据其附带的 .d.ts 进行类型检查
  • JavaScript 在构建时嵌入到二进制文件中
  • 二进制文件在运行时从不读取 node_modules

性能对比

在 Apple M 系列上针对逐字节相同的工作负载进行测量:

维度 scriptc 上下文
启动时间 ~2.4ms Node:~47ms;与 Zig 相当,领先于 Go/Rust
二进制大小 静态 170–200KB,--dynamic 约 3MB Go:约 2MB;Node SEA:60–100MB
内存(RSS) 典型 1–4MB Node:67–116MB
运行时 与 JS 一致的 f64 语义;与系统语言竞争 整数推断和所有权分析已在路线图中

正确性保证

scriptc 对每次更改运行两种强制机制:

  1. 差异测试—— 每个语料库程序(800+ 测试)在 Node 和原生二进制文件下运行;stdout、stderr 和退出码必须逐字节匹配。数字格式与 JS 精确一致(最短往返,在百万个双精度浮点数上通过模糊测试与 Node 验证)。服务器使用实时客户端驱动程序针对两种实现进行测试。

  2. 内存安全通道—— 整个语料库在 AddressSanitizer 下重新运行,并进行引用计数审计;内存泄漏和释放后使用被视为构建失败。

逃生舱口

scriptc 提供了多个逃生舱口,用于需要突破静态模型的情况:

  • comptime(() => ...)—— 在构建时(在编译器内的隔离 VM 中)运行 TypeScript,并将结果作为字面量烘焙到二进制文件中。
  • 原生 FFI(--ffi—— 将仅签名的 TypeScript 声明绑定到直接的 C ABI 调用,并链接清单声明的存档、对象和系统库。
  • --dynamic—— 为 npm 依赖项和任何代码嵌入引擎。scriptc coverage --dynamic 精确报告哪些语句在何处运行。
  • 检查转换—— JSON.parse(...) as Config 插入一个运行时验证,如果失败则抛出一个可捕获的错误,并指出有问题的路径。

入门指南

# 安装
npm install -g scriptc

# 直接运行 TypeScript 文件
$ cat fib.ts
function fib(n: number): number {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
console.log(fib(30));

$ scriptc run fib.ts
832040

# 构建原生二进制文件
$ scriptc build fib.ts && ls -la fib
-rwxr-xr-x 178K fib  # 一个自包含的原生二进制文件,启动时间约 2ms

要求:clang(预装在 Xcode 命令行工具中)。macOS arm64 是主要平台;Linux 和 Windows 二进制文件通过交叉编译构建。

架构

该项目分为三个包:

  • packages/compiler—— 前端(tsc API → IR)、带验证器/序列化器的 IR,以及 LLVM 和 C 后端。IR 是两端之间的唯一接口;LLVM 是默认代码生成器,C 是参考后端。
  • packages/runtime—— C 运行时:带循环收集器的引用计数值、栈式纤程和事件循环(kqueue)、服务器栈、与 JS 精确的数字格式。功能单元通过链接门控:二进制文件仅为其使用的功能付费。
  • packages/cli—— scriptc build | run | coverage

为何重要

scriptc 代表了我们对 TypeScript 部署方式的重大转变。无需接受 Node.js 运行时开销,您现在可以将 TypeScript 编译为原生二进制文件,这些文件在毫秒内启动,使用最少的内存,并且小到可以作为单个文件分发。对于无服务器函数、CLI 工具和边缘计算,这可能是变革性的。

该项目仍处于早期阶段(撰写时为 v0.0.17),但基础扎实:800+ 差异测试、内存安全验证,以及清晰的未来改进路线图,如整数推断和所有权分析。

如果您正在构建启动时间、内存使用或二进制大小至关重要的 TypeScript 应用程序,scriptc 值得认真考虑。

来源

vercel-labs/scriptc: TypeScript-to-Native Compiler