Webview Bridge : protocole gRPC-over-postMessage
Responsabilités
La webview est une application React qui tourne dans un sandbox iframe dédié. Pour communiquer avec l'hôte VSCode (extension host), elle ne dispose que de l'API nue webview.postMessage / webview.onDidReceiveMessage. Cline construit par-dessus un protocole « gRPC-over-postMessage » : côté webview, chaque appel est emballé dans un GrpcRequest (service + method + message + request_id) ; côté extension, la requête est dispatchée par service/method vers un handler spécifique, dont le résultat est ré-emballé en grpc_response et renvoyé. Ce protocole se matérialise entre apps/vscode/src/hosts/vscode/VscodeWebviewProvider.ts et apps/vscode/src/core/controller/grpc-handler.ts.
Le travail se décompose en trois couches. La plus basse gère l'injection HTML dans la webview et la CSP : en production, Cline utilise le index.js + index.css packagés ; en dev, il se connecte au Vite dev server local pour HMR. La couche du milieu s'occupe de la réception/émission des messages : setWebviewMessageListener enregistre le callback onDidReceiveMessage, postMessageToWebview pousse les ExtensionMessage vers la webview. La couche supérieure gère le dispatch : les messages envoyés par la webview ne sont plus que de deux types (grpc_request et grpc_request_cancel) ; toutes les opérations métier (askResponse, showTaskWithId, subscribeToState, etc.) sont modélisées en appels gRPC service.method, routés par la registry serviceHandlers vers le handler concret.
Motivation de conception
- Protocole unifié pour remplacer le switch-case : l'ancien Cline utilisait un switch géant sur
type: "askResponse" | "showTaskWithId" | .... Chaque nouvel appel webview imposait de modifier le typeExtensionMessage+ le switch + le handler. Avec gRPC service/method (GrpcRequest:7), ajouter un appel revient à enregistrer un handler service.method dans le code généré, et la webview l'invoque via un client typé — les types des deux côtés restent alignés automatiquement. - request_id pour corréler requête et réponse :
GrpcRequest.request_idest une chaîne (request_id:11). La webview s'en sert pour retrouver la pending Promise correspondante, l'extension l'utilise pour gérer l'annulation et l'enregistrement.GrpcResponse.request_idle reporte tel quel, et la webview dispatche par ID. - Streaming via is_streaming + sequence_number :
handleStreamingRequest:113crée un callbackresponseStream. Le handler peut l'invoquer plusieurs fois pour pousser des résultats partiels, chaque appel portantis_streaming: trueet unsequence_numbercroissant ; le dernier appel poseisLast=truepour clore le flux. Ce modèle convient aux scénarios du type « mises à jour continues de l'état d'un task » ou « flux de la liste des serveurs MCP ». - Middleware d'enregistrement :
withRecordingMiddlewareenveloppepostMessage(withRecordingMiddleware:23). Chaquegrpc_responseest confié àGrpcRecorderBuilder.recordResponse, afin de pouvoir rejouer la séquence complète requête-réponse lors du debug. - CSP stricte sans bloquer le HMR : la CSP de production utilise un nonce +
script-src 'nonce-${nonce}' 'unsafe-eval', et connect-src ne laisse passer que posthog et cline.bot (CSP meta:113). En dev, la CSP s'ouvre à localhost et ws pour laisser tourner le HMR Vite (dev CSP:200). - ExtensionMessage ne garde que grpc_response : l'interface
ExtensionMessagen'a plus qu'untype: "grpc_response"(ExtensionMessage:21). Tout ce que l'extension pousse vers la webview (update de state, message partiel, ask/say) est enveloppé dans ungrpc_response.message, via un modèle où l'extension appelle activement un streaming handler. Les anciens types de message commeplusButtonClickedont disparu.
Fichiers clés
resolveWebviewView:53— appelé la première fois que VSCode ouvre la sidebar, injecte le HTML, enregistre le listener de messages, branche les callbacks de visibilité et de destruction.html injection:62— choisit entregetHMRHtmlContentetgetHtmlContentselon dev/production.setWebviewMessageListener:145— enregistreonDidReceiveMessage, transfère les messages àhandleWebviewMessage.handleWebviewMessage:161— switch d'entrée, ne gère quegrpc_requestetgrpc_request_cancel.postMessageToWebview:189— unique sortie de l'extension vers la webview, appelle directementwebview.postMessage.handleGrpcRequest:53— entrée principale : recordRequest d'abord, puis aiguillage entre unary et streaming selonis_streaming.handleUnaryRequest:75— mode unary : appelle le handler, obtient la response, l'enveloppe engrpc_responseet la pousse.handleStreamingRequest:113— mode streaming : crée le callbackresponseStream, le handler l'appelle pour pousser des partiels.handleGrpcRequestCancel:163— annule la requête, retrouve la fonction cleanup dansGrpcRequestRegistryet l'invoque.getHandler:192— retrouve le handler dans la registryserviceHandlersparserviceName.methodName, lève une erreur si introuvable.ServiceRegistry:30— registry de services, chaque service enregistre un ensemble de methods, distinguishing unary/streaming.GrpcRequest:7— structure de requête webview→extension, porte service/method/message/request_id/is_streaming.ExtensionMessage:21— message extension→webview, il ne reste que le typetype: "grpc_response".
Flux de données
Quand la webview émet une requête, elle l'enveloppe côté client en GrpcRequest via un client typé, puis postMessage. Côté extension, onDidReceiveMessage la remet à 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 récupère le GrpcRequest, l'enregistre via recordRequest, enveloppe postMessageToWebview dans withRecordingMiddleware pour que les réponses soient également enregistrées, puis aiguille selon request.is_streaming (is_streaming branch:63). En unary, le handler est appelé une fois, son résultat est enveloppé dans grpc_response.message et poussé (unary response:86). En streaming, le callback responseStream est confié au handler ; chaque appel pousse un grpc_response portant is_streaming: !isLast et sequence_number. Côté webview, le dispatch se fait par request_id vers la pending Promise correspondante, et les messages en streaming sont fusionnés dans l'ordre des séquences. La méthode askResponse n'est qu'un cas particulier : la webview appelle la method AskResponse du service cline.askResponse. Le handler, dans askResponse handler:14, mappe la response en ClineAskResponse puis appelle task.handleWebviewAskResponse, ce qui réveille le pWaitFor à l'intérieur de Task.
Limites et échecs
- Type de message non reconnu : la branche default de
handleWebviewMessagese contente de loguer une error sans throw (default error:177). Une vieille version en cache de la webview peut encore émettre un ancien typeplusButtonClicked— l'extension le tolère. - Erreur levée par un handler : quand un handler unary lève, le catch enveloppe
error.messagedansgrpc_response.erroret le pousse (unary error:96). Pour un handler streaming en échec, le catch pousse une réponse d'erreur avecis_streaming: falsepour clore le flux (streaming error:147). - Service/method inconnu :
getHandlerlèveUnknown service: ${serviceName}quand il ne trouve pas le service ou la method (unknown service:196). L'erreur remonte au catch supérieur qui l'enveloppe en grpc_response.error. - Le streaming ne se ferme pas de lui-même : à la fin normale du handler, un commentaire indique explicitement « ne pas envoyer de message final, le flux doit rester ouvert » (
keep stream open:142). Il ne se ferme que lorsque le client envoiegrpc_request_cancelou que le serveur pose explicitementisLast=true. - postMessage avant l'initialisation de la webview :
postMessageToWebviewappelle directementthis.webview?.webview.postMessage(message). Si la webview est vide, il renvoie undefined (postMessage undefined:189). L'appelant doit tolérer cette valeur falsy. - Changement de config déclenche un state push : écoute
vscode.workspace.onDidChangeConfiguration. Dès quecline.mcpMarketplace.enabledchange, on appellepostStateToWebview(config change:102) pour que la webview reste synchronisée. - Échec du recording non fatal :
withRecordingMiddlewareetrecordRequestsont tous deux enveloppés de try/catch (recordRequest try:42). Si l'enregistrement tombe, le flux principal n'est pas impacté.
Résumé
La webview bridge unifie la communication « extension ↔ sandbox React » en gRPC-over-postMessage. Toutes les opérations métier deviennent des appels service.method : unary renvoie en une fois, streaming pousse plusieurs partiels. ExtensionMessage ne conserve plus qu'un seul type, et ajouter un appel revient à enregistrer un handler dans le code généré — les types des deux côtés s'alignent automatiquement. Pour voir comment un appel unary concret atterrit sur Task, voir /startup/controller ; pour voir comment les ask/say internes à Task deviennent des messages de chat dans la webview, voir /agent-loop/task-class.
Voir la documentation officielle : documentation Cline · README.