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 派發。 - streaming 用 is_streaming + sequence_number:
handleStreamingRequest:113建立responseStream回呼,handler 可以多次調它推 partial 結果,每次帶is_streaming: true和遞增的sequence_number,最後一次isLast=true關閉流。這適合「task 狀態持續更新」「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 模式 CSP 放開 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 首次打開 sidebar 時調,注入 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 每調一次回呼就推一條 grpc_response 帶 is_streaming: !isLast 和 sequence_number。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)。 - unknown 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。