Scriptc: VercelのTypeScriptからネイティブへのコンパイラ

Scriptc: VercelのTypeScriptからネイティブへのコンパイラ

概要

Scriptcは、通常のTypeScriptを、Node、V8、またはJavaScriptエンジンを含まない自己完結型のネイティブバイナリにコンパイルします。

インストール

npmを使用してコンパイラをグローバルにインストールします:

npm install -g scriptc

これにはclangが必要です(macOSではXcode Command Line Toolsによって提供されます)。macOS arm64が主要なプラットフォームであり、LinuxおよびWindowsのバイナリはクロスコンパイルによってビルドされます。

静的コンパイル vs 動的コンパイル

デフォルトでは、scriptcはコードをネイティブコードに静的にコンパイルします。scriptc coverageコマンドは、何が静的にコンパイル可能で、何が動的なまま残るかを表示します:

$ scriptc coverage app.ts
 statements analyzed 4481
 compile statically 4451 (99%)
 blockers:
 ×2 functions with optional parameters as values SC1090
 ×1 Promise.reject SC2020

以下の3つのティアが明示されています:

  1. 静的にコンパイル済み (Compiled statically) – ネイティブコード、エンジンなし。デフォルトモードです。
  2. 動的に実行 (--dynamic) – 組み込みのQuickJS-NGエンジン(約620 KB)が、静的にできないコード(例:npm依存関係の提供されるJS、any型コード)を実行します。静的コードに戻る値は実行時に検証され、型が不一致の場合はキャッチ可能なTypeErrorをスローします。
  3. 拒否 (Rejected) – 特定のエラーコード、コードフレーム、および通常は書き換えのヒントと共に失敗します。サイレントに誤ったコンパイルが行われることはありません。

静的にコンパイルされるもの

静的なサーフェスには以下が含まれます:

  • 言語機能: 単一継承と真の動的ディスパッチ(証明可能な場合はデバーチャライズ)を持つクラス、JSのキャプチャセマンティクスを持つクロージャ、ジェネリクス(モノモーフィズム化)、タグ付き値としての判別可能なユニオン型、スタックフルファイバー上のasync/awaitfinallyを伴う例外、デストラクト、スプレッド、オプション/デフォルト/レストパラメータ、ゲッター/セッター、文字列/配列/Map/Setのイテレータ、テンプレートリテラル、正規表現(QuickJSからのECMAScript準拠のバイトコードインタプリタ、正規表現が使用される場合にのみリンク)。
  • 標準ライブラリ: UTF-16準拠の文字列、JS準拠の順序と同一性を持つ配列/Map/Set、実行時に検証されるキャストを伴うJSONMath、型付き配列とBuffer、型付きcatchを伴うError階層。
  • Node APIサーフェス: fs(同期およびPromise)、path(バイト単位で正確)、process、パイプストリームを伴うchild_processoscryptourl/URLzlib、依存関係のないイベントループ上のタイマーとシグナルハンドラ、さらにフルサーバースタック(nethttphttps、mbedTLSを同梱したtls)、dgramdnsfs.watchreadline
  • fetchおよびWHATWG Webサブセット: ストリーム、Headers、同じネイティブnet/TLSスタック上のAbortSignal(リダイレクト、gzip、AbortSignal.timeout、Node形式のエラー原因を含む)。libcurlやシステムHTTPへの依存はありません。
  • npm依存関係 (--dynamicを使用) --dynamicを使用する場合: Nodeのアルゴリズムを通じて解決され、提供される.d.tsに対して型チェックが行われ、そのJSはビルド時にバイナリに組み込まれます。バイナリが実行時にnode_modules`を読み取ることはありません。

型チェックにはTypeScriptの実際のes2025ライブラリ(@types/nodeが存在する場合はそれも含む)が使用され、プロジェクトのtsconfig.jsonがチェッカーの厳格さを制御します。ローイング(lowering)が行われない場所に到達した場合は、正確な診断結果が返されます。

正確性

変更のたびに2つの強制メカニズムが実行されます:

  • 差分テスト (Differential testing): 800以上のプログラムのコーパスがNodeとネイティブバイナリの両方で実行されます。stdout、stderr、および終了コードはバイト単位で一致する必要があります。数値フォーマットはJSと正確に一致します(最短ラウンドトリップ、100万個の浮動小数点数に対してNodeと比較してファズ検証済み)。サーバーは、両方の実装に対してライブクライアントドライバを使用してテストされます。
  • メモリ安全性レーン (Memory-safety lane): コーパス全体がAddressSanitizerの下でリファレンスカウント監査と共に再実行されます。メモリリークやuse-after-freeはビルド失敗の原因となります。

Nodeからの意図的な相違(タイミングの内部処理やエラーオブジェクトのプロパティに関する数十個程度)は文書化され、番号が付けられています。サイレントに相違が生じることはありません。

パフォーマンス

Apple Mシリーズ上でNode、Go、Rust、Zig(バイト単位で同一の出力が検証済み)と比較して測定:

  • 起動: ~2.4 ms (Node ~47 ms; Zigと同等、Go/Rustより高速)。
  • バイナリサイズ: 静的ビルドで170–200 KB; --dynamicと組み込み依存関係を含めると~3 MB (Go ~2 MB; Node SEA 60–100 MB)。
  • メモリ (RSS): 通常1–4 MB (Node 67–116 MB)。
  • ランタイム: JSに忠実なf64セマンティクス; ほとんどのワークロードにおいてシステム言語に匹敵; 整数推論と所有権分析がロードマップにあります。

エスケープハッチ

  • comptime(() =>...): コンパイラ内の隔離されたVM内でビルド時にTypeScriptを実行し、その結果をリテラルとして焼き付けます。
  • ネイティブFFI (--ffi): シグネチャのみのTypeScript宣言を直接のC ABI呼び出しにバインドし、マニフェストで宣言されたアーカイブ、オブジェクト、システムライブラリをリンクします。境界は明示的で長さ制限があります。
  • --dynamic: npm依存関係とanyコードのためにQuickJS-NGエンジンを組み込みます。scriptc coverage --dynamicは、どのステートメントがどこで実行されるか、および残っているブロッカーを正確に報告します。静的コンパイルがデフォルトであり、バイナリがサイレントにエンジンを増大させることはありません。
  • チェック付きキャスト: JSON.parse(...) as Configは、問題のあるパスを特定するキャッチ可能なエラーをスローする実行時検証を挿入します(例: expected number at $.port, got string)。TypeScriptのasは約束であり、scriptcはそれを検証します。

アーキテクチャ

コンパイラパイプラインは以下の通りです:

TypeScript --(tsc: parse + typecheck)--> Lowering --> Typed IR --> C --> clang --> Native executable
  • packages/compiler: フロントエンド (tsc API → IR)、バリデータ/シリアライザを備えたIR、LLVMおよびCバックエンド。IRはエンド間の唯一のインターフェースであり、LLVMはデフォルトのコード生成器で、透過的なCフォールバックを備えています。
  • packages/runtime: リファレンスカウント値(サイクルコレクタ付き)、スタックフルファイバーとイベントループ (kqueue)、サーバースタック、JS準拠の数値フォーマットを提供するCランタイム。機能ユニットはリンクゲートされており、バイナリは使用するものに対してのみコストを支払います。
  • packages/cli: scriptc build | run | coverageを実装。

開発ワークフロー

pnpm install && pnpm build
pnpm test                     # 差分コーパス + 診断スナップショット
SCRIPTC_SAN=1 pnpm test       # ASan + RC監査の下での同じコーパス
pnpm scriptc build x.ts --emit-ir   #.scriptc/x.c と x.ir.json を保持

すべての機能は差分テストと共に導入されます。マージの基準を満たすには、両方のレーンがグリーンである必要があります。

コミュニティの反応 (抜粋)

  • 実用的な採用への懐疑心: “これを使うような真剣な企業やプロジェクトが思いつかない。” – @JoeDohn
  • 以前の取り組みとの比較: “Porforrはしばらくの間、同じ目標に向かって取り組んできた…Vercelがいかに早くこれほどの進歩を遂げたのか、少し疑わしい。” – @acmnrs
  • npmエコシステムへの懸念: “ほとんどのパッケージは型宣言付きの型のないJavaScriptのみを提供している…npmパッケージを使用する場合、依然としてJavaScriptエンジンが必要になるだろう。” – @sheept
  • ネイティブJSに関する歴史的視点: “90年代にGCJが存在した…GraalVM Nativeがようやくこの問題に包括的に取り当てた…それでも、既存の単純なアプリケーションをネイティブで完璧に動作させるのは大きな苦労だ。” – @weinzierl
  • 本番環境での検証への要望: “これらのプロジェクトのいくつかが本番環境で使用されるのを切実に見たい…せいぜい、これが単なる雑な宣伝活動ではないことを証明できるかもしれない。” – @notsylver
  • FFIと組み込みへの関心: “これは非常に有望に見える…これが静的ライブラリとしてコンパイルされ、Android/iOSターゲットで実行するために既存のC++アプリにリンクできるのか興味がある。” – @elendilm
  • クロスプラットフォームサポートに関する質問: “Cにコンパイルされるのか?Wasmか?…LinuxとWindowsのバイナリはクロスコンパイルでビルドされる…もしそうなら、それはかなり微妙だ…” – @MrDrMcCoy
  • サイズへの驚き: “178kb?! 中に何を詰め込んでいるんだ、JVMか?” – @aabhay
  • 熱狂: “ついに誰かがこれをやった” – @relug
  • 一般的な関心: “アイデアは好きだ。” – @casper14
  • JavaScriptサブセットに関する混乱: “JavaScriptがTypeScriptの有効なサブセットであるなら、どうやってコンパイルできるのか?混乱する。” – @xiaodai
  • ユースケースの推測: “これは面白い、これでElectronをネイティブアプリに変換できるのか?これのユースケースは何だ、私のNodeプロジェクトのリバースエンジニアリングを難しくするためか?” – @zuzululu
  • メンテナンスとハイプへの懸念: “これは彼らをHNの1ページ目に留めておくのに十分だ…これは成長戦略だ…メンテナンスはされるのか?” – @piterrro
  • 本番環境の準備状況への疑念: “以前にもこのような話を聞いたことがある…実際に本番環境で適切に動作しているものを一つでも挙げられるか?一つもない。” – @ianberdin
  • 単一実行ファイルの作成に関する問い合わせ: “これは、Goのように私のJSベースのバックエンドを単一の実行ファイルに変換して簡単に配布できるという意味か?” – @anta40
  • ポジティブな意見: “華やかではないが、動作する。” – @hnsmomdpvp
  • ターゲットが不明確: “Cにコンパイルされるのか?Wasmか?READMEの雑な記述の中に見つけられなかった。” – @khalic

これらのコメントは、技術的な成果に対する興奮、実世界での使いやすさへの懸念、npmエコシステムへの依存、および長期的なメンテナンスとクロスプラットフォームサポートに関する質問が混在していることを反映しています。

Sources