Webview Bridge: gRPC-over-postMessage プロトコル
役割
webview は React アプリで、独立した iframe サンドボックスの中で動き、拡張ホスト (extension host) との間は webview.postMessage / webview.onDidReceiveMessage でしか通信できない。Cline はこの生の API の上に「gRPC-over-postMessage」プロトコルを構築する。webview 側は毎回の呼び出しを GrpcRequest (service + method + message + request_id) に包み、拡張側は受け取った後に service/method ごとに具体的な handler に振り分け、handler が返した結果を grpc_response に包んで送り返す。このプロトコルは apps/vscode/src/hosts/vscode/VscodeWebviewProvider.ts と apps/vscode/src/core/controller/grpc-handler.ts の間に実装されている。
行っていることは三層に分けられる。一番下は webview の HTML 注入と CSP で、Cline は本番ではバンドル済みの index.js + index.css を使い、dev モードではローカルの Vite dev server に繋いで HMR を走らせる。中間層はメッセージの送受信で、setWebviewMessageListener が onDidReceiveMessage コールバックを登録し、postMessageToWebview が ExtensionMessage を webview に送り返す。一番上はプロトコルのディスパッチで、webview から来るメッセージは二種類 (grpc_request と grpc_request_cancel) しかなく、具体的な業務 (askResponse、showTaskWithId、subscribeToState 等) はすべて gRPC の service.method 呼び出しとしてモデリングされ、serviceHandlers レジストリ経由で具体的な handler にルーティングされる。
設計動機
- switch-case の代わりに統一プロトコル:旧 Cline は
type: "askResponse" | "showTaskWithId" | ...の巨大な switch でディスパッチしており、webview 呼び出しを追加するたびにExtensionMessage型 + switch + handler を書き換える必要があった。gRPC の service/method に変えてからは (GrpcRequest:7)、新規呼び出しの追加は生成コードの中に service.method handler を一つ登録するだけで済む。webview 側は typed client で呼び、両端の型が揃う。 - request_id でリクエストとレスポンスを対応付:
GrpcRequest.request_idは文字列 (request_id:11)。webview はこれで対応する pending Promise を探し、拡張側はこれでキャンセルと記録を行う。GrpcResponse.request_idがそのまま返り、webview は受信後に ID で振り分ける。 - ストリーミングは is_streaming + sequence_number:
handleStreamingRequest:113がresponseStreamコールバックを作り、handler は何度でも呼んで partial 結果を送れる。毎回is_streaming: trueとインクリメンタルなsequence_numberを付け、最後はisLast=trueでストリームを閉じる。これは「タスク状態の継続的更新」「mcp server リストのストリーム」のような場面に適している。 - 記録ミドルウェア:
withRecordingMiddlewareがpostMessageを包む (withRecordingMiddleware:23)。grpc_responseが発生するたびにGrpcRecorderBuilder.recordResponseに渡され、デバッグ時にリクエスト-レスポンスの完全なシーケンスを再生するのに使われる。 - CSP は厳しく、ただし HMR は遮らない:本番 CSP は nonce +
script-src 'nonce-${nonce}' 'unsafe-eval'を使い、connect-src は posthog と cline.bot だけを許可する (CSP meta:113)。dev モードでは localhost と ws を許可し、Vite HMR が動くようにする (dev CSP:200)。 - ExtensionMessage は grpc_response だけを残す:
ExtensionMessageインターフェースは現在type: "grpc_response"一つだけ (ExtensionMessage:21)。拡張から webview に送るすべての内容 (state 更新、partial message、ask/say) はある grpc_response.message の中に包まれ、「拡張が能動的に streaming handler を呼んで送る」モデルで動く。独立したplusButtonClickedのようなメッセージ型はもうない。
主要ファイル
resolveWebviewView:53— VSCode が初めてサイドバーを開いた時に呼ばれ、HTML を注入し、メッセージリスナを登録し、可視性/破棄コールバックを紐付ける。html injection:62— dev/production に応じてgetHMRHtmlContentまたはgetHtmlContentを選ぶ。setWebviewMessageListener:145—onDidReceiveMessageを登録し、メッセージをhandleWebviewMessageに転送する。handleWebviewMessage:161— 入口の switch で、grpc_requestとgrpc_request_cancelだけを処理する。postMessageToWebview:189— 拡張から webview へメッセージを送る唯一の出口で、直接webview.postMessageを呼ぶ。handleGrpcRequest:53— 総入口。まず recordRequest を行い、その後is_streamingで unary または streaming に振り分ける。handleUnaryRequest:75— unary モード。handler を呼んで response を取得し、grpc_responseに包んで返す。handleStreamingRequest:113— streaming モード。responseStreamコールバックを作り、handler が複数回呼んで partial を送れるようにする。handleGrpcRequestCancel:163— リクエストのキャンセル。GrpcRequestRegistryから cleanup 関数を探して呼ぶ。getHandler:192—serviceName.methodNameでserviceHandlersレジストリから handler を探す。見つからなければ例外を投げる。ServiceRegistry:30— サービス登録レジストリ。各 service に method の組を登録し、unary/streaming を区別する。GrpcRequest:7— webview→拡張 のリクエスト構造。service/method/message/request_id/is_streaming を持つ。ExtensionMessage:21— 拡張→webview のメッセージ。type: "grpc_response"一種だけが残っている。
データフロー
webview がリクエストを送る時、まずクライアント側で typed client を使い GrpcRequest に包んでから postMessage する。拡張側は onDidReceiveMessage で受け取り handleWebviewMessage に入る:
// apps/vscode/src/hosts/vscode/VscodeWebviewProvider.ts
async handleWebviewMessage(message: WebviewMessage) {
const postMessageToWebview = (response: ExtensionMessage) => this.postMessageToWebview(response)
switch (message.type) {
case "grpc_request": {
if (message.grpc_request) {
await handleGrpcRequest(this.controller, postMessageToWebview, message.grpc_request)
}
break
}
case "grpc_request_cancel": {
if (message.grpc_request_cancel) {
await handleGrpcRequestCancel(postMessageToWebview, message.grpc_request_cancel)
}
break
}
default: {
Logger.error("Received unhandled WebviewMessage type:", JSON.stringify(message))
}
}
}handleGrpcRequest は GrpcRequest を受け取った後、まず recordRequest で記録し、withRecordingMiddleware で postMessageToWebview を包んでレスポンスも記録に流し、その後 request.is_streaming で分岐する (is_streaming branch:63)。unary は handler を一度呼び、結果を grpc_response.message に包んで返す (unary response:86)。streaming は responseStream コールバックを handler に渡し、handler がこのコールバックを呼ぶたびに is_streaming: !isLast と sequence_number を付けた grpc_response を送る。webview 側は request_id で対応する pending Promise に振り分け、ストリームメッセージは sequence 順にマージする。askResponse という具体的な業務は、webview が cline.askResponse service の AskResponse method を呼ぶ形で実装され、handler は askResponse handler:14 で response を ClineAskResponse にマップしてから task.handleWebviewAskResponse を呼び、Task 内の pWaitFor が起床する。
境界と失敗
- 未知のメッセージ型:
handleWebviewMessageの default 分岐は error を記録するだけで例外を投げない (default error:177)。旧バージョンの webview キャッシュがまだ古いplusButtonClicked型を送ってくる可能性があり、拡張はこれを許容する。 - handler が例外を投げた:unary handler が例外を投げた時、catch が
error.messageをgrpc_response.errorに包んで返す (unary error:96)。streaming handler が例外を投げた時は、catch でis_streaming: falseの error レスポンスを送ってストリームを閉じる (streaming error:147)。 - 未知の service/method:
getHandlerが service または method を見つけられない時はUnknown service: ${serviceName}を投げ (unknown service:196)、この例外は上位の catch で grpc_response.error に包まれる。 - streaming は能動的に閉じない:handler が正常に終了した後、コードコメントは「最終メッセージを送らず、ストリームは開いたままにする」と明示する (
keep stream open:142)。クライアントがgrpc_request_cancelを送るか、サーバーが明示的にisLast=trueを呼ぶまでストリームは閉じない。 - webview 未初期化時の postMessage:
postMessageToWebviewは直接this.webview?.webview.postMessage(message)を呼び、webview が空の時は undefined を返す (postMessage undefined:189)。呼び出し側はこの falsy を許容する必要がある。 - 設定変更による state push:
vscode.workspace.onDidChangeConfigurationを監視し、cline.mcpMarketplace.enabledが変化したらpostStateToWebviewを呼ぶ (config change:102)。webview が設定変更に追従するようにする。 - 記録失敗は致命的でない:
withRecordingMiddlewareとrecordRequestはどちらも try/catch で包まれており (recordRequest try:42)、記録が壊れてもメインフローには影響しない。
まとめ
webview bridge は「拡張↔React サンドボックス」の通信を gRPC-over-postMessage に統一し、すべての具体的業務は service.method 呼び出しとなる。unary は一度に返し、streaming は複数回に分けて partial を送る。これにより ExtensionMessage は一種類の type だけとなり、新規呼び出しの追加は生成コードに handler を一つ登録するだけで済み、両端の型は自動で揃う。具体的な unary 呼び出しがどう Task に落ちるかは /startup/controller に、Task 内部の ask/say がどう webview のチャットメッセージになるかは /agent-loop/task-class に続く。
公式資料: Cline ドキュメント · README