TypeScriptのネイティブ実装であるtypescript-go,つまりtsgoを眺めていると,かなり自然に発想が変わる瞬間がある.

最初は「tscが速くなる」という話に見える. 実際,TypeScript 6.0のアナウンスでも,TypeScript 6.0は現在のJavaScript実装からTypeScript 7.0以降のGo実装へ移るためのbridge releaseだと説明されているし,Go実装はnative codeとshared-memory multi-threadingを使う新しいcodebaseとして説明されている.

しかしtsgoを外から利用する側のコードを書いていると,関心は少しずつ別の場所へ移る.

僕が欲しいのは,単に一回のtsgo --noEmitを速くすることだけではない. エディタ,リンター,コード生成,リファクタリング,AIエージェント,ビルドサーバーのような複数の利用者が,同じTypeScript program graphとcheckerの周りに集まり,必要な質問だけを繰り返し投げられる形である.

このとき僕には,tsgoがCLIというより,言語処理系サーバー に見えてくる.

そして僕はcorsa-bindで,この見え方を実装へ押し込んでいる. tsgoをforkして中身を書き換えるのではなく,upstreamが用意するstdio API / LSPの境界に乗り,その外側でworker,session,snapshot,transport,cacheを束ねる.

名前についても少し補足しておく. corsaは僕のプロジェクトそのものの名前というより,tsgo側のコードネームに由来する呼び名である. 僕のプロジェクト名はcorsa-bindで,READMEでも,typescript-goをRustやNode.jsから扱うためのbindings / orchestration layersとして説明している. つまりbindは,単にFFIの関数を一本生やすという意味ではなく,tsgoという処理系の外側にRust,Node,native language向けの接続面と運用層を束ねる,という意味合いを持っている.

僕はこの発想を,言語処理系オーケストレーション と呼びたい.

stdio APIは関数呼び出しではない

tsgoのstdio APIを最初に見ると,「標準入力と標準出力でrequest / responseを流すだけ」と思うかもしれない. しかしこの見方だと,かなり大事なものを落とす.

stdioの向こう側には,別プロセスとして起動した言語処理系がいる. こちらはrequestを送る. 向こうはprogramを作り,source fileを読み,型を解き,symbolを持ち,snapshotを管理し,responseを返す.

つまりこれは,

caller process
  -> transport
  -> tsgo worker process
  -> program / checker / snapshot state

という境界である.

ここで重要なのは,tsgo workerが状態を持つことだ. 一回ごとにprocessを起動し,configを読み,projectを開き,型検査して捨てるなら,それはCLIの使い方である. しかしworkerを生かし続け,initializeを一回で済ませ,snapshotを再利用し,同じsessionに対して小さなqueryを投げ続けるなら,それはserverの使い方である.

docs.rsのcorsa_bind_clientは,この層を「typescript-go stdio APIのhigh-level client bindings」として説明している. 具体的には,tsgo worker processをspawnし,一度initializeしたsessionを再利用し,snapshotを作って再利用し,type / symbol / syntaxの質問をtyped helper経由で投げる,という役割を持つ.

この時点で,僕がcorsa-bindでやっていることは単なるbindingではなくなる. FFIで関数を一本生やす,という話ではない. プロセスの寿命,transportの種類,requestの型,snapshot handleの寿命,cleanup,timeout,observabilityをまとめて扱う必要がある.

僕は言語処理系を呼ぶためだけの薄いbindingを作っているのではない. 言語処理系を運用する層を書いている.

コンパイラを速くするのではなく,仕事の形を変える

ここで誤解してはいけないことがある.

僕はcorsa-bindを,tsgoより賢いcompiler engineとして作っているわけではない. 同じprojectを開き,同じ型を解き,同じ診断を出すなら,根本的な仕事はtsgoがやる. wrapperがその中身を魔法のように速くする,という話ではない.

僕がcorsa-bindで狙っているのは,仕事の形を変えることだ.

たとえば,次の二つはかなり違う.

CLI-shaped workflow:
  run tsgo
  load config
  open project
  build program
  answer one big question
  exit

orchestrated workflow:
  spawn worker
  initialize once
  open project once
  create snapshot
  answer many small questions
  reuse worker and snapshot
  close explicitly

前者はbatch処理として自然である. CIで全体を検査するなら,この形は今でもかなり素直だ.

後者はeditorやlint ruleやagentに近い. あるnodeの型を知りたい. 次にsymbolを解決したい. 少しvirtual documentを変えてもう一度知りたい. そのたびにproject全体をCLIとして開き直すのは,分散システムで毎requestごとにdatabase serverを起動するようなものだ.

だから僕がcorsa-bindで採用しているperformance modelは,「同じ仕事をしてtsgoに勝つ」ではなく,「同じengineの状態を再利用し,余計な仕事をしない」に近い. benchmarking guideでも,engine speedとwrapper speedを分け,cold runとwarm runを分け,session reuseやtransportの違いを別々に見る,という考え方を取っている.

良い主張は「corsa-bindtsgoより速い」ではない. 良い主張は,「warmなeditor workflowでは,毎回tsgo --noEmitを走らせ直すより,live sessionを再利用する方が安くなる」である.

この違いは小さく見えて,かなり大きい. 速いコンパイラを作る話ではなく,コンパイラを含む処理系をどのように配置し,どの状態を再利用し,どのqueryをどのworkerに投げるか,という話になるからだ.

言い換えると,これはcompilerから離れる話ではない. むしろcompilerを一つの実行ファイルや一回きりの関数としてではなく,長生きするserviceとして扱う話である. parser,binder,checker,program graphはcompilerの中核だが,それらをいつ起動し,どこに置き,どのconsumerに共有し,どの単位で問い合わせるかは,compilerの外側にある設計問題になる.

僕がcorsa-bindで触りたいのは,この外側である. compiler engineの意味論はupstreamのtsgoに任せる. そのうえで,compilerを囲むtransportとlifetimeとcacheの層を設計する.

snapshotは値ではなく,遠隔の状態へのhandleである

僕がcorsa-bindで重要視している概念の一つがsnapshotである.

snapshotは,単なるJSONの値ではない. 少なくとも僕がorchestratorを書く視点では,それはworker側にある言語処理系の状態へのhandleである.

かなり雑に書くと,こういう関係になる.

ApiClient
  owns worker process
  owns transport
  initializes tsgo session

ManagedSnapshot
  points to snapshot state inside that session
  can be reused for type / symbol / syntax queries
  releases remote state when dropped or closed

この「handleである」という見方が大事だ.

普通のlibrary callでは,関数に値を渡し,戻り値を受け取る. しかしstdio越しのtsgoでは,重い状態はworker側に残る. callerはそれを識別するhandleを持ち,次のrequestでそのhandleを参照する.

これはdatabase connectionやactor systemにかなり近い. 「このsnapshotに対して,このfileのこの位置のtypeを教えてくれ」と聞く. 「このvirtual documentを反映したsnapshotを作ってくれ」と頼む. 「もう使わないのでreleaseしてくれ」と伝える.

つまり,言語処理系の内部状態を,プロセス境界の外からlifetime管理している. ここを雑にすると,速さ以前に正しさが壊れる. snapshotを解放し忘れればworker側の資源が残るし,process cleanupを軽く見るとzombie processや後続benchmarkの歪みにつながる.

だから僕はcorsa-bindで,typed clientだけでなく,timeout,graceful shutdown,observer event,queue capacityのようなoperationalな要素も同じ設計面に出している. それはbindingというより,少し小さな運用基盤である.

分散サーバーのように考える

この発想をもう一段進めると,僕にはcorsa-bindが分散サーバーに似て見えてくる.

もちろん,単一マシン上でtsgo workerを複数起動しているだけなら,本当にネットワーク越しのdistributed systemではない. しかし設計上の問題はかなり似ている.

  • どのworkerにrequestを投げるか

  • workerをいつ起動し,いつ止めるか

  • coldなworkerとwarmなworkerをどう扱うか

  • どのsnapshotを再利用できるか

  • workerが落ちたとき,どこまで復旧できるか

  • timeoutしたrequestをどう扱うか

  • transportをJSON-RPCにするかmsgpackにするか

  • 観測可能なeventをどの粒度で出すか

これは「コンパイラAPIを呼ぶ」だけの話ではない. 小さなclusterを扱っているのに近い.

たとえばApiProfileのような概念は,単にspawn configに名前を付けているだけではない. どのtsgo executableを使い,どのcwdで起動し,どのtransportを使い,どのtimeoutとobserverを付けるか,というworkerの性格を安定した名前で扱うためのものだ.

概念的には,こうなる.

profile: "default-msgpack"
  -> worker pool
    -> worker 1
      -> session
      -> snapshots
    -> worker 2
      -> session
      -> snapshots

profile: "lsp-jsonrpc"
  -> worker pool
    -> worker 1
      -> lsp session
      -> virtual documents

workerがdatabase shardのように振る舞う,とまでは言いすぎかもしれない. しかし「状態を持つ遠隔の処理主体を,client側のorchestratorが束ねる」という意味では,同じ設計語彙がかなり使える.

プロセスはnodeである. snapshotはremote stateへのhandleである. requestはmessageである. transportはwire formatである. profileはdeployment unitである. observerはtelemetryである. cleanupはcorrectnessである.

この比喩で見ると,言語処理系のAPI設計は急に面白くなる.

なぜupstreamをforkしないのか

僕はcorsa-bindのREADMEに,かなりはっきりとno forks, no patchesの方針を置いている. ref/typescript-goをexact upstream checkoutとして扱い,upstream-supportedなentry pointに乗り,ローカルpatchを持たない.

これは単なる僕の潔癖ではない. オーケストレーション層を作るなら,土台の意味論を勝手に変えないことが重要になる.

もし僕がtsgo本体にpatchを当てて速くしたなら,その速さはcorsa-bindのorchestrationによるものなのか,compiler engineの改変によるものなのかが曖昧になる. benchmarkも読みにくくなる. upstream追従も難しくなる. 別言語bindingから見た互換性も怪しくなる.

だから僕は,境界を分ける.

tsgoは言語処理系のengineとしてupstreamのまま扱う. corsa-bindではその外側で,transport,typed request / response,session reuse,snapshot lifetime,Node binding,C ABI,その他のlanguage bindingを扱う.

この分離は,かなり健全だと思っている. compilerの中へ入り込んで全てを握るのではなく,compilerを一つの専門serverとして尊重し,その周囲で使い方を設計する.

これはUnix的でもあるし,microservice的でもある. ただし,ここで扱っているserverはHTTP serviceではなく,型検査器である.

Node bindingは作者体験の入口である

僕はcorsa-bindにRust側のclient / orchestratorだけでなく,napi-rsを使ったNode bindingも入れている. これは単にJavaScriptからRustを呼べるようにするためだけのものではない.

TypeScriptの型情報を使う道具の作者は,多くの場合JS / TSの側にいる. lint rule,code mod,editor extension,framework plugin,AI agentのtool adapterなどを書く人は,Rust crateを直接触りたいとは限らない.

一方で,毎回JS側で重いprotocol handlingを書き,process管理を書き,snapshotの寿命を自分で持つのもつらい. だからRust側でhot pathとprocess orchestrationを持ち,Node側には使いやすいsurfaceを出すようにしている.

僕がcorsa-oxlintでやっている方向性は,この考え方にかなり合っている. rule authorはJS / TSでruleを書く. しかし型情報の取得やtsgo workerとの会話は,Rust-backedな層が面倒を見る.

ここでも僕の主語は「binding」だけではない. 主語は,言語処理系を共有資源としてどう配るか である.

一つのcompilerから,複数のconsumerへ

従来のCLIでは,compilerとconsumerの関係は一対一になりやすかった.

tsc command
  -> compiler
  -> diagnostics

しかしeditorやtoolchainの実際の姿は,もっと多対一に近い.

editor hover
editor completion
lint rule
code action
AI agent
test runner
build watcher
  -> shared language-processing state

もちろん全てを本当に一つのprocessに集約すればよい,という話ではない. fault isolationも必要だし,queryの性質によってworkerを分けた方がよい場合もある. read-heavyな型queryと,virtual documentを激しく差し替えるworkflowを同じsessionに押し込むべきかは,慎重に見るべきだ.

だからこそorchestratorが必要になる.

orchestratorは,compilerを隠すためのwrapperではない. compilerを中心に複数のconsumerを配置するためのcontrol planeである.

control planeという言葉を使うと少し大げさだが,やっていることは近い. どのprofileでworkerを作るか. どのworkerがwarmか. どのsnapshotがまだ使えるか. どのrequestが詰まっているか. どのtransportがそのworkflowに向いているか. どの失敗をretryし,どれをcallerに返すか.

こういう判断は,compiler engineそのものとは別の層にある.

言語処理系はこれから常駐する

僕は,今後の言語処理系はますます常駐プロセスとして扱われると思っている.

理由は単純で,開発体験がbatchからconversationへ寄っているからだ.

人間がファイルを保存したら一回検査する,というだけではない. エディタは入力中に候補を出す. リンターはASTと型を見ながらruleを走らせる. AI agentは「このnodeの型は何か」「このsymbolはどこから来たか」「この修正でdiagnosticは消えるか」を細かく聞く. frameworkはvirtual fileを生成し,元ファイルと対応付ける.

こういう世界では,言語処理系は毎回起動するcommandというより,問い合わせを受け続けるserviceになる.

そしてserviceになるなら,そこにはorchestrationが必要になる. worker pool,cache,snapshot,backpressure,timeout,observability,graceful shutdown,version pinning,compatibility policyが必要になる.

僕はcorsa-bindで,tsgoという具体的な対象を通して,この層を作っている.

言語処理系はparserとcheckerだけでは終わらない. それをどう生かし続け,どう共有し,どう壊れたときに畳み,どう別言語の作者体験へ渡すかまで含めて,これからのlanguage toolingになる.

tsgoは速いtscである. しかし,それだけでは少しもったいない.

stdio APIを通して見ると,tsgoは状態を持つ言語処理系serverでもある. そして僕がcorsa-bindで作っているのは,そのserverをRustとNodeと複数のnative bindingから扱うためのorchestration layerである.

僕が大事にしているのは,compiler engineそのものを無理に抱え込まないことだ. upstreamのtsgoを尊重し,forkせず,patchせず,その代わり外側でsessionを再利用し,snapshotを管理し,transportを選び,workerを束ねる.

これは,分散サーバーを扱うときの考え方に近い. processがいて,messageが流れ,remote stateへのhandleがあり,lifetimeがあり,失敗があり,観測があり,cleanupがある.

言語処理系をlibraryとして呼ぶだけでなく,serviceとして運用する.

それが,僕がcorsa-bindで作ろうとしている言語処理系オーケストレーションである.