scriptc 在做什麼?把 TypeScript 編成原生執行檔,但它真正賣的其實是部署形狀
Vercel Labs 的 scriptc 擺脫了傳統 JavaScript runtime 的做法,直接把 TypeScript 編成原生執行檔。它最值得看的不只是快,而是怎麼處理效能、安全性、打包輸出,以及記憶體洩漏這些被「原生執行檔」帶過的細節。
作者
Seer
日期
2026-07-30
scriptc 在做什麼?把 TypeScript 編成原生執行檔,但它真正賣的其實是部署形狀
如果你平常寫 TypeScript,對「把 TS 變成單一執行檔」這件事一定不陌生。
市面上常見做法,多半是把 JavaScript 包進去:內嵌 V8 或綁一個 Node runtime,再把整個 node_modules 塞進去。這種方式雖然能跑,但成品通常很肥,啟動慢、記憶體佔用高,部署起來像在搬移整個 Node 環境。
Vercel Labs 的 scriptc 走的是另一條路:它直接把 TypeScript 編譯成原生程式,不走把 JavaScript runtime 整包塞進執行檔的路線。
官方定位很直白:
Zero-runtime TypeScript.
No Node, no V8, no JavaScript engine in the binary.
不過,如果只看這句口號,很容易失焦。scriptc 真正值得研究的,是它如何處理這四個最棘手的細節:
- 效能到底從哪裡來
- 安全性靠什麼防守
- 打包輸出到底是不是乾淨的單一檔案
- 沒有 GC 的情況下,怎麼處理記憶體洩漏
這些實作細節,它都直接寫進了架構、runtime 與測試策略中。
scriptc 是什麼,不是什麼
先看定位。
scriptc 是一個將 TypeScript 編譯成原生執行檔的編譯器。它的 pipeline 大致如下:
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 最實用的一點,是它先承認現實,再切三層:
- Compiled statically:能靜態編譯的,直接進 native
- Runs dynamically (
--dynamic):靜態吃不下的,交給嵌入式 JS engine - 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
- 只有用到
httpserver,才會把 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.mdxdocs/src/app/dependencies/page.mdxdocs/src/app/limitations/page.mdxdocs/src/app/ffi/page.mdx- Source:
packages/cli/src/main.tspackages/compiler/src/backend/cc.tspackages/compiler/src/frontend/npm.tspackages/compiler/src/frontend/provenance.tspackages/compiler/src/ffi/profile.tspackages/runtime/src/scr_cycle.cpackages/runtime/src/scr_runtime.h
Signals
Visits
--
Waiting for Cloudflare metrics.