Skip to content

Webview Bridge : protocole gRPC-over-postMessage ​

源码版本v4.0.10

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 type ExtensionMessage + 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_id est 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_id le reporte tel quel, et la webview dispatche par ID.
  • Streaming via is_streaming + sequence_number : handleStreamingRequest:113 crée un callback responseStream. Le handler peut l'invoquer plusieurs fois pour pousser des résultats partiels, chaque appel portant is_streaming: true et un sequence_number croissant ; le dernier appel pose isLast=true pour 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 : withRecordingMiddleware enveloppe postMessage (withRecordingMiddleware:23). Chaque grpc_response est 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 ExtensionMessage n'a plus qu'un type: "grpc_response" (ExtensionMessage:21). Tout ce que l'extension pousse vers la webview (update de state, message partiel, ask/say) est enveloppé dans un grpc_response.message, via un modèle où l'extension appelle activement un streaming handler. Les anciens types de message comme plusButtonClicked ont 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 entre getHMRHtmlContent et getHtmlContent selon dev/production.
  • setWebviewMessageListener:145 — enregistre onDidReceiveMessage, transfère les messages à handleWebviewMessage.
  • handleWebviewMessage:161 — switch d'entrée, ne gère que grpc_request et grpc_request_cancel.
  • postMessageToWebview:189 — unique sortie de l'extension vers la webview, appelle directement webview.postMessage.
  • handleGrpcRequest:53 — entrée principale : recordRequest d'abord, puis aiguillage entre unary et streaming selon is_streaming.
  • handleUnaryRequest:75 — mode unary : appelle le handler, obtient la response, l'enveloppe en grpc_response et la pousse.
  • handleStreamingRequest:113 — mode streaming : crée le callback responseStream, le handler l'appelle pour pousser des partiels.
  • handleGrpcRequestCancel:163 — annule la requête, retrouve la fonction cleanup dans GrpcRequestRegistry et l'invoque.
  • getHandler:192 — retrouve le handler dans la registry serviceHandlers par serviceName.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 type type: "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 :

typescript
// 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 handleWebviewMessage se contente de loguer une error sans throw (default error:177). Une vieille version en cache de la webview peut encore émettre un ancien type plusButtonClicked — l'extension le tolère.
  • Erreur levée par un handler : quand un handler unary lève, le catch enveloppe error.message dans grpc_response.error et le pousse (unary error:96). Pour un handler streaming en échec, le catch pousse une réponse d'erreur avec is_streaming: false pour clore le flux (streaming error:147).
  • Service/method inconnu : getHandler lève Unknown 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 envoie grpc_request_cancel ou que le serveur pose explicitement isLast=true.
  • postMessage avant l'initialisation de la webview : postMessageToWebview appelle directement this.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 que cline.mcpMarketplace.enabled change, on appelle postStateToWebview (config change:102) pour que la webview reste synchronisée.
  • Échec du recording non fatal : withRecordingMiddleware et recordRequest sont 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.