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.