---
slug: scriptc-typescript-native-compiler-analysis
title: scriptc 在做什麼？把 TypeScript 編成原生執行檔，但它真正賣的其實是部署形狀
status: published
excerpt: Vercel Labs 的 scriptc 擺脫了傳統 JavaScript runtime 的做法，直接把 TypeScript 編成原生執行檔。它最值得看的不只是快，而是怎麼處理效能、安全性、打包輸出，以及記憶體洩漏這些被「原生執行檔」帶過的細節。
category: Development
tags:
  - TypeScript
  - Compiler
  - Vercel
  - Native Binary
  - Security
  - Performance
author: Seer
author_role: Author
read_time: 10 min
cover: "/static/scriptc-typescript-native-compiler-analysis-cover.png"
published_at: "2026-07-30T09:20:47Z"
updated_at: "2026-07-30T09:20:47Z"
---

# scriptc 在做什麼？把 TypeScript 編成原生執行檔，但它真正賣的其實是部署形狀

如果你平常寫 TypeScript，對「把 TS 變成單一執行檔」這件事一定不陌生。

市面上常見做法，多半是把 JavaScript 包進去：內嵌 V8 或綁一個 Node runtime，再把整個 `node_modules` 塞進去。這種方式雖然能跑，但成品通常很肥，啟動慢、記憶體佔用高，部署起來像在搬移整個 Node 環境。

[Vercel Labs 的 `scriptc`](https://github.com/vercel-labs/scriptc) 走的是另一條路：它直接把 TypeScript 編譯成原生程式，不走把 JavaScript runtime 整包塞進執行檔的路線。

官方定位很直白：

> Zero-runtime TypeScript.  
> No Node, no V8, no JavaScript engine in the binary.

不過，如果只看這句口號，很容易失焦。`scriptc` 真正值得研究的，是它如何處理這四個最棘手的細節：

1. **效能到底從哪裡來**
2. **安全性靠什麼防守**
3. **打包輸出到底是不是乾淨的單一檔案**
4. **沒有 GC 的情況下，怎麼處理記憶體洩漏**

這些實作細節，它都直接寫進了架構、runtime 與測試策略中。

## scriptc 是什麼，不是什麼

先看定位。

`scriptc` 是一個將 TypeScript 編譯成原生執行檔的編譯器。它的 pipeline 大致如下：

```mermaid
flowchart TB
  T["TypeScript source"] --> F["TypeScript compiler: parse + typecheck"]
  F --> I["Typed IR"]
  I --> B{"Backend path"}
  B -->|default| L["LLVM IR"]
  B -->|fallback| C["C backend"]
  L --> K["clang"]
  C --> K
  K --> N["Native executable"]
```

這裡的重點不在 LLVM 這個關鍵字，而是它明確把系統拆成三層：

- **frontend**：用官方的 TypeScript compiler 做解析與型別檢查
- **IR**：typed IR，是前後端唯一的溝通介面
- **backend**：預設走 LLVM，需要時能透明 fallback 到 C backend

這代表它不依賴特製的 JS runtime 來執行 TS，而是把 TS lowering 成可控的中介表示法，再生出原生碼。

因此，`scriptc` 的位置比較接近「把常見 TypeScript / Node surface 收編成可靜態編譯的系統」。它跟 Bun 或 Deno 這種 runtime 不同，也不同於 `pkg` 或 `nexe` 這類打包 Node 的工具，更不是單純把 TS 轉成 C 而已。

## 核心設計：靜態為主，動態為輔

`scriptc` 最實用的一點，是它先承認現實，再切三層：

1. **Compiled statically**：能靜態編譯的，直接進 native
2. **Runs dynamically (`--dynamic`)**：靜態吃不下的，交給嵌入式 JS engine
3. **Rejected**：兩者都不行的，直接報錯拒絕

這個設計很誠實。它沒有假設 TypeScript 生態天生就很靜態。它承認 npm 套件、`any` 與某些動態語意本來就很髒，然後把這些東西集中關進 **dynamic island**。

這個 dynamic island 目前採用 **quickjs-ng**：

- **靜態部分**：直接編成原生碼
- **動態部分**：例如 npm 依賴、`any`，必要時交給嵌入式引擎
- **兩者交界處**：每次跨界都做驗證

所以它不是在賭「全世界 TS 都能純編譯」，而是在賭：

> 大部分你自己寫的 TS，可以靜態化；  
> 真正髒的那層，縮到最小，再用明確邊界包住。

這個策略，直接決定了它在後面四個面向的表現。

## 一、效能：重點在啟動速度、RSS 與部署成本，不是 CPU 神話

README 的 benchmark 先給了幾個很直觀的數字：

- 啟動時間：約 **2.4ms**
- 靜態 binary 大小：約 **170–200KB**
- 啟用 `--dynamic` 並嵌入相依套件：約 **3MB**
- 記憶體 RSS：典型值約 **1–4MB**

對比 Node、Go、Rust、Zig，它的賣點很明顯：**`scriptc` 的第一優勢不是所有 workload 都跑贏，而是它的程式形狀非常輕。**

### 為什麼能做到這麼輕？

參考 docs 與 `packages/compiler/src/backend/cc.ts`，主要有三個原因。

### 1. 它真的把 Node 與 V8 拔掉了

在靜態模式下，執行檔內不包含 Node、V8，也沒有完整的 JS engine。

這跟很多單檔打包方案差很多。很多工具雖然最後也給你一個檔案，但底層仍然是：

- JS 程式
- + 一個 runtime
- + 一坨相依

`scriptc` 的靜態模式不是這種包裝，它是真的把 runtime 那層拿掉了。

### 2. runtime 是 link-gated 的

`docs/how-it-works` 講得很清楚：runtime 是一套 **C library of link-gated feature units**。

意思是：

- Hello world 不會順便把整個網路堆疊連進去
- 只有用到 regex，才會連 regex engine
- 只有用到 `http` server，才會把 net、http、tls 拉進去

這是它能把 binary 壓到 170–200KB 等級的根本原因之一。不是因為 C 比 JS 神，而是因為它把「功能要不要進 binary」切得很細。

### 3. npm 相依套件是 build-time embed，不是 runtime 去讀 `node_modules`

在 `--dynamic` 模式下，`scriptc` 仍然用 Node 的解析規則處理 npm package，但它做的是：

- **build time** 掃描並收攏相依 JS
- 直接 embed 到 binary
- runtime 不再讀取 `node_modules`

這不只讓啟動更快，也少掉部署時的 I/O 成本、runtime path lookup 問題，對 CLI、edge function 或 utility 類 workload 特別有利。

### 它不是全方位的效能神話

這裡要冷靜。

docs 自己也寫得很保守：

- dynamic island 用的是 **quickjs-ng，不是 V8**
- 所以 **CPU-bound 的 dependency code 會比較慢**
- 目前仍是 **JS-exact f64 semantics**
- `integer inference`、`ownership analysis` 還在 roadmap，還沒落地

所以現在的 `scriptc` 還不能直接理解成「TypeScript 版 Rust」。它目前最強的，是啟動速度、記憶體佔用與部署體積。

換句話說，它比較適合：

- CLI
- 小型工具
- 自帶 server 的單檔應用
- 想砍掉 Node 部署成本的 utility
- 對 cold start 很敏感的場景

而不是把任何 Node 專案直接丟進去，就期待全面提速。

## 二、安全性：靠邊界驗證與拒絕機制，不靠 sandbox 神話

`scriptc` 的安全設計，重點不是「native 比較安全」，而是它**不信任邊界另一側的資料**。這個態度在 repo 裡幾乎到處都看得到。

### 1. `any` 與 dynamic 邊界不是直接放過，而是 runtime 驗證

文件反覆強調一句話：

> Every value crossing back into static code is validated at runtime.

這句話很重。

在一般 TypeScript 裡，`as Config` 很多時候只是自我安慰。  
但在 `scriptc` 裡，這件事變成 runtime contract：

- 若 `JSON.parse(...) as Config` 不符合
- 它不是靜默給你一個錯值
- 而是丟出可捕獲的 **TypeError**
- 還會指出是哪一條 path 出錯

也就是說，它把 TypeScript 常見那種「型別只存在編譯期」的破口，硬補成 runtime fence。

這對 native 編譯特別重要。因為在 JS 世界裡，型別說謊通常是邏輯 bug；到了 native world，如果你還照單全收，代價可能就是記憶體破壞。

`scriptc` 在這裡的態度很清楚：  
**寧可丟 TypeError，也不要讓錯誤型別混進 native heap。**

### 2. npm 相依套件不在 runtime 現場外掛讀取

因為相依 JS 是 **build time 就嵌入**，執行檔不再依賴現場的 `node_modules`。這直接避掉一整類問題：

- 執行環境裝到不同版本套件
- 工作目錄切換後解析到別的模組
- deployment machine 缺 package
- runtime 才發現路徑與解析行為不一致

它不是完全消滅供應鏈風險，但至少把風險收回到 build time，讓 binary 內容固定下來。

### 3. provenance-sources 很有野心，但現在還不能當成熟供應鏈驗證

`--provenance-sources` 的想法其實很漂亮：

- 去 npm Attestation API 拿 provenance
- 找到 package 對應的 repo 與 commit
- 把 attested source 抓下來
- 直接編 source，而不是編 package 發佈後的 JS

如果這條線成熟，會是很強的供應鏈 story。因為它不只是在吃 npm tarball，而是在試著把「發佈物 ← 原始碼」這條鏈路接起來。

但 `packages/compiler/src/frontend/provenance.ts` 自己也把缺口寫得很白：

- 還沒有 sigstore bundle verification
- 還沒有 dist tarball ↔ source build reproduction
- source-entry mapping 目前是 heuristic
- 遞迴相依版本也還不是完全按 package 自己的 lockfile 還原

所以現在比較準確的說法不是「scriptc 已經解決 supply-chain security」，而是：

> 它已經把 provenance 編譯這條路開出來了，但目前還是 experimental prototype。

這個邊界一定要講清楚，因為 provenance 這個詞很容易讓人自動腦補成「安全已完成」。

### 4. FFI 很明確，但本質還是危險邊界

`scriptc` 支援 `--ffi`，能把 TypeScript 宣告綁到 C ABI symbol。

這很強，但它的 docs 也寫得很老實：

- 沒有 runtime symbol lookup
- 沒有 JS engine 在邊界幫你兜底
- native code 不受它的 exception、RC 或 sanitizer contract 保護
- 錯的 pointer 或錯的 signature，還是可以直接把 process 打爛

它做的保護主要是：

- manifest schema 很嚴
- symbol、name、ABI class 都會驗
- string / bytes 都走 **length-delimited** 邊界
- 不允許 pointer / string / bytes return，避免所有權不清

這才是對的。FFI 本來就不該被包裝成「安全互通」。它能做的是把 unsafe surface 收窄、顯式化，不是把它變魔法。

## 三、打包輸出：這個 repo 真正厲害的，是把「單檔可部署」做得很乾淨

如果你只是想把 JS 變成一個檔，其實現在很多工具都能做到。  
但 `scriptc` 比較厲害的地方，是它對輸出物的定義很乾淨。

### 1. 輸出的是原生執行檔，不是附帶 runtime 的壓縮包

最基本的一條：

- `scriptc build`
- 產出的是 native executable
- 不要求目標機器先裝 Node
- 也不是 runtime 再回頭找 `node_modules`

這個差別對 CLI、單用途 server、內部工具都很有感。

### 2. 它會把中間產物留給你看

CLI 提供了：

- `--emit-ir`
- `--backend c`
- `--keep-c`

也就是說，你不只拿到 binary，還能直接看：

- `.ir.json`
- `.c`
- `.ll`

這件事其實很重要，因為它代表 `scriptc` 沒把自己做成黑盒。

很多編譯工具都喜歡把「能跑」當完成；但 `scriptc` 願意把 IR、C backend、fallback path 都攤出來，表示它知道這種工具如果不能 inspect，後面很難真的進工程鏈。

### 3. `--dynamic` 不是偷偷幫你長胖

這一點很好。

很多工具會幫你自動 fallback，最後 binary 偷偷變胖、行為偷偷改掉。  
`scriptc` 這裡的態度比較硬：

- `--dynamic` 是顯式 opt-in
- coverage 會告訴你哪些部分是 static，哪些跑進 island
- 預設仍是靜態編譯
- binary 不會偷偷長出 engine

這讓打包輸出變得比較可預測。你不會以為自己產的是純靜態 native binary，結果其實塞了一個完整 JS runtime 卻沒發現。

### 4. library mode 也不是隨便打一包

CLI 其實還有 `build --lib --profile` 模式，可以編成 linkable static archive。

而且從 `packages/compiler/src/library/sidecar-validate.ts` 看得出來，這條路線連 sidecar schema、type table、ABI export 都有在驗。

這表示他們不是只想做「跑一個 demo binary」，而是已經在往這幾個方向擴：

- executable
- library artifact
- 可被其他 host 集成的輸出

當然，這條路也還早，但方向很清楚。

## 四、記憶體洩漏：這可能是 scriptc 最不像 TypeScript 工具、最像系統工具的地方

很多人看到「沒有 GC」會先興奮，但真正該問的是：

> 那你怎麼處理 leak？

`scriptc` 在這塊反而是我最想多看兩眼的地方，因為它沒有逃避。

### 1. 它用的是 refcount + cycle collector，不是 tracing GC

`docs/how-it-works` 和 runtime header 都寫得很明白：

- acyclic value：最後一個 reference drop 就立即釋放
- cycle：交給 deterministic cycle collector
- 不是 concurrent GC
- 也不是 tracing heap

這會帶來兩面：

#### 好處
- 釋放時機更可預測
- 沒有傳統 GC pause
- RSS 與記憶體輪廓比較容易壓低

#### 代價
- 你得面對 cycle
- 你得面對跨 heap 邊界的 ownership 問題
- 你得誠實處理那些「理論上可接受、實務上會 leak」的角落

### 2. 它不是只說有 cycle collector，連收集時機都寫出來

`packages/runtime/src/scr_cycle.c` 直接寫了：

- collection point 包含：
  - program exit
  - event-loop quiescence
  - root-buffer threshold
- `SCR_CYCLE_THRESHOLD` 預設是 **256**
- collector 用的是 **synchronous Bacon–Rajan trial deletion**

這很少見。通常工具只會說「我們有 memory management」，不太會把 collector 策略直接攤在 repo 第一線。

這代表 `scriptc` 對記憶體行為不是走玄學，而是真的把它當成設計面的一部分。

### 3. 它有專門的 memory-safety lane，不只是一般測試

README 和 docs 都提到：

- differential corpus：Node vs native binary byte-for-byte 對比
- memory-safety lane：整個 corpus 在 **AddressSanitizer + RC audit** 下重跑
- leak / use-after-free 直接算 build failure
- 使用者自己也可以用 `scriptc build --sanitize`

這點很關鍵。

因為「我們有 RC」不代表你就不會漏；真正有說服力的是：**repo 把 leak 和 UAF 當成 merge gate。**

### 4. 它也明講幾個還沒完全漂亮的角落

這是我最欣賞的地方。它沒有硬說自己記憶體 story 已經完美。

#### 跨 static / island 邊界的 cycle 是 uncollectable

`docs/limitations` 與 `scr_runtime.h` 都寫得很白：

- static heap 與 dynamic island 是兩個不同的記憶體世界
- **跨邊界 cycle 不可收**
- 這是 documented divergence

這不是小事。因為一旦有 host closure、engine object、dyn box 互相咬住，很容易形成「兩邊都看不完整」的 cycle。

`scriptc` 的作法不是假裝處理掉，而是直接承認：

> 這類 cycle 目前就是不收。

#### SUSPENDED generator 被放棄時，會故意 leak fiber

這個更猛。

在 `scr_runtime.h` 裡，generator lifecycle 的註解直接寫：

> releasing a SUSPENDED generator leaks its fiber deliberately

原因也很務實：

- 如果強行 unwind
- 可能會跑出 Node GC 本來不會跑的 `finally`
- 行為反而更錯

所以他們選擇：

- 不去假裝 clean teardown
- 讓它留在 live count
- 再透過 RC audit 給你 note

這個決策很工程。不是最漂亮，但比偷偷做錯好太多。

#### EventEmitter 也有 leak warning

runtime 裡甚至連 `EventEmitter` listener 超過預設 max listener 時的 leak warning 都保留著。

這件事很小，但也說明他們不是只盯底層 heap，連 Node 開發者熟悉的 leak signal 都盡量補齊。

## 結論

如果只用一句話總結 `scriptc`，我會這樣講：

> 它不是在把 TypeScript 變成「更快的 JavaScript」，而是在把 TypeScript 變成「更像系統程式的可部署產物」。

它的重點不是單純 benchmark，而是四件事一起成立：

- **效能**：啟動、RSS、包體積都很有吸引力
- **安全性**：dynamic boundary 驗證、explicit refusal、FFI 明確收窄
- **打包輸出**：真正單檔、自帶依賴、不吃 runtime `node_modules`
- **記憶體洩漏**：不是假裝沒事，而是把 collector、ASan、RC audit、已知 leak 邊界都攤開來講

但反過來說，也要很清楚它現在還不是什麼：

- 還很新，repo 是 2026 年 7 月才開
- package 版本目前還在 `0.0.17`
- `--dynamic` 不是 V8，npm-heavy CPU workload 不一定好看
- `--provenance-sources` 很有前景，但現在還是 prototype
- FFI 仍然是 unsafe surface
- 跨邊界 cycle、某些 generator / fiber 情境，還有明確限制

所以現在最合理的看法不是「Node 終於被取代」，而是：

> `scriptc` 把一條以前很常被講成夢想的路，第一次做成了有工程誠意的 prototype。

而且它最厲害的地方，不是說自己什麼都做到了；而是它知道哪裡做到、哪裡沒做到，還把代價寫進原始碼和測試規則裡。

這點，比很多 demo 跑得快更重要。

## 適用場景建議

目前比較適合先追的場景包括：

### 很適合
- CLI 工具
- 單用途 internal utility
- 想把 Node 部署成本砍掉的小型 server
- 對 cold start、RSS、單檔交付很敏感的場景
- 想逐步把 TS 拉向可分析、可裁切、可靜態化工程鏈的團隊

### 暫時不要想太滿
- 極重 npm、生態很髒的大型 app
- 高度依賴 V8 行為或完整 Node runtime 細節的專案
- 很多 native addon / FFI / callback ABI 的系統
- 想把 provenance 當成已成熟供應鏈驗證的人

## 參考來源

- GitHub repo: https://github.com/vercel-labs/scriptc
- README: `vercel-labs/scriptc/README.md`
- Docs:
  - `docs/src/app/how-it-works/page.mdx`
  - `docs/src/app/dependencies/page.mdx`
  - `docs/src/app/limitations/page.mdx`
  - `docs/src/app/ffi/page.mdx`
- Source:
  - `packages/cli/src/main.ts`
  - `packages/compiler/src/backend/cc.ts`
  - `packages/compiler/src/frontend/npm.ts`
  - `packages/compiler/src/frontend/provenance.ts`
  - `packages/compiler/src/ffi/profile.ts`
  - `packages/runtime/src/scr_cycle.c`
  - `packages/runtime/src/scr_runtime.h`
