scriptc: ランタイム不要でTypeScriptをネイティブ実行可能ファイルにコンパイル

Vercel Labsのscriptcは、通常のTypeScriptをNodeやV8なしで小型で高速なネイティブバイナリにコンパイルします。その仕組み、コンパイル可能なもの、GoやRustとの比較について学びましょう。

scriptc: ランタイム不要でTypeScriptをネイティブ実行可能ファイルにコンパイル

Vercel Labsはscriptcをオープンソース化しました。これはTypeScriptをネイティブコンパイラで変換し、スタンドアロンのネイティブ実行可能ファイルを生成します。最終的なバイナリにNode.js、V8、JavaScriptエンジンは不要です。その結果、約2msで起動し、170~200KBを占有し、1~4MBのメモリを消費するバイナリが得られます。

問題: TypeScriptのランタイム負荷

TypeScript開発者は言語の型安全性とツールを愛していますが、Node.jsアプリケーションを出荷するには重いランタイムをバンドルする必要があります。Node.jsのSingle Executable Applications(SEA)を使用しても、バイナリは60~100MBに膨れ上がることがあります。起動時間は最新のハードウェアで約47ms、メモリ使用量は100MB RSSを超えることがよくあります。

scriptcは異なるアプローチを取ります。JavaScriptエンジンを出荷する代わりに、LLVMまたはCバックエンドを介してTypeScriptを直接ネイティブコードにコンパイルします。結果は、Node.jsとバイト単位で同じ動作をしますが、オーバーヘッドはありません。

仕組み

scriptcは3層のコンパイルモデルを使用します:

  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自身の絞り込みによって駆動されるタグ付き値としての判別共用体
  • JavaScriptと正確なスケジューリングを持つスタックフルファイバー上のasync/await
  • finally付きの例外
  • 分割代入、スプレッド、オプション/デフォルト/レストパラメータ
  • ゲッター/セッター
  • 文字列、配列、Map、Setに対するイテレータ
  • テンプレートリテラル
  • 正規表現(QuickJSと同じECMAScript正確なバイトコードインタプリタを使用)

標準ライブラリ

  • UTF-16正確なセマンティクスを持つ文字列
  • JavaScript正確な順序と同一性を持つ配列、Map、Set
  • ランタイム検証キャスト付きJSON
  • Math、型付き配列、Buffer
  • 型付きキャッチ付きエラー階層

Node.js APIサーフェス

  • fs(同期とプロミス)
  • path(バイト正確な移植)
  • process、パイプストリーム付きchild_process
  • oscryptourl/URLzlib
  • 依存関係のないイベントループ上のタイマーとシグナルハンドラ
  • サーバースタック: nethttphttpstls(ベンダー提供のmbedTLS)、dgramdnsfs.watchreadline

Web API

  • 同じネイティブネット/TLSスタック上のfetchとWHATWGウェブサブセット(ストリーム、Headers、AbortSignal)
  • リダイレクト、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(静的)、~3MB(--dynamic付き) Go: ~2MB; Node SEA: 60~100MB
メモリ(RSS) 通常1~4MB Node: 67~116MB
ランタイム JS忠実なf64セマンティクス; システム言語と競争力あり 整数推論と所有権分析はロードマップ上

正確性の保証

scriptcはすべての変更に対して2つの強制メカニズムを実行します:

  1. 差分テスト — すべてのコーパスプログラム(800以上のテスト)がNodeとネイティブバイナリの両方で実行されます。stdout、stderr、終了コードはバイト単位で一致する必要があります。数値フォーマットはJS正確です(最短ラウンドトリップ、100万の倍精度浮動小数点数でNodeに対してファズ検証済み)。サーバーは、両方の実装に対してライブクライアントドライバでテストされます。

  2. メモリ安全性レーン — コーパス全体がAddressSanitizerと参照カウント監査の下で再実行されます。リークとuse-after-freeはビルド失敗と見なされます。

エスケープハッチ

scriptcは、静的モデルから脱出する必要がある場合にいくつかのエスケープハッチを提供します:

  • comptime(() => ...) — ビルド時にTypeScriptを実行し(コンパイラ内の隔離されたVMで)、結果をリテラルとしてバイナリに焼き付けます。
  • ネイティブ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 Command Line Toolsでプリインストール)。macOS arm64が主要プラットフォームです。LinuxとWindowsのバイナリはクロスコンパイルでビルドされます。

アーキテクチャ

プロジェクトは3つのパッケージで構成されています:

  • packages/compiler — フロントエンド(tsc API → IR)、バリデータ/シリアライザ付きIR、LLVMおよびCバックエンド。IRはエンド間の唯一のインターフェースです。LLVMがデフォルトのコードジェネレータで、Cがリファレンスバックエンドです。
  • packages/runtime — Cランタイム: サイクルコレクタ付き参照カウント値、スタックフルファイバーとイベントループ(kqueue)、サーバースタック、JS正確な数値フォーマット。機能ユニットはリンクゲートされています。バイナリは使用するものだけにコストを支払います。
  • packages/cliscriptc build | run | coverage

これが重要な理由

scriptcは、TypeScriptのデプロイ方法についての考え方に大きな変化をもたらします。Node.jsランタイムの負荷を受け入れる代わりに、TypeScriptをミリ秒で起動し、最小限のメモリを使用し、単一ファイルとして配布できるほど小さいネイティブバイナリにコンパイルできるようになりました。サーバーレス関数、CLIツール、エッジコンピューティングにとって、これは変革的かもしれません。

このプロジェクトはまだ初期段階ですが(執筆時点でv0.0.17)、基盤はしっかりしています。800以上の差分テスト、メモリ安全性検証、そして整数推論や所有権分析などの将来の改善のための明確なロードマップがあります。

起動時間、メモリ使用量、バイナリサイズが重要なTypeScriptアプリケーションを構築しているなら、scriptcは真剣に検討する価値があります。

ソース

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