Skip to content

Webview Bridge: protocolo gRPC-over-postMessage

源码版本v4.0.10

Responsabilidades

La webview es una aplicación React que corre dentro de un sandbox iframe independiente, y solo puede comunicarse con el host de la extensión (extension host) a través de webview.postMessage / webview.onDidReceiveMessage. Sobre esta API desnuda, Cline monta un protocolo «gRPC-over-postMessage»: la webview envuelve cada llamada en un GrpcRequest (service + method + message + request_id), la extensión lo recibe y lo despacha por service/method al handler correspondiente, y el resultado devuelto por el handler se reenvuelve en un grpc_response. Este protocolo se materializa entre apps/vscode/src/hosts/vscode/VscodeWebviewProvider.ts y apps/vscode/src/core/controller/grpc-handler.ts.

Puede descomponerse en tres capas. La más baja es la inyección de HTML en la webview y la CSP: en producción, Cline usa el index.js + index.css empaquetados; en dev, se conecta al Vite dev server local con HMR. La capa intermedia es la recepción y envío de mensajes: setWebviewMessageListener registra el callback onDidReceiveMessage, y postMessageToWebview empuja los ExtensionMessage hacia la webview. La capa superior es el despacho por protocolo: los mensajes que llegan de la webview son solo de dos tipos (grpc_request y grpc_request_cancel), y todo el negocio concreto (askResponse, showTaskWithId, subscribeToState, etc.) se modela como llamadas gRPC service.method, enrutadas por el registro serviceHandlers a un handler específico.

Motivación de diseño

  • Protocolo unificado en lugar de switch-case: el viejo Cline despachaba con un switch sobre type: "askResponse" | "showTaskWithId" | .... Cada llamada nueva que se añadía desde la webview obligaba a tocar el tipo ExtensionMessage + el switch + el handler. Tras migrar a service/method gRPC (GrpcRequest:7), añadir una llamada solo requiere registrar un handler service.method en el código generado; la webview la invoca con un client tipado, y los tipos de ambos extremos quedan alineados.
  • request_id correlaciona petición y respuesta: GrpcRequest.request_id es una cadena (request_id:11) que la webview usa para localizar la Promise pendiente correspondiente, y la extensión para cancelación y grabación. GrpcResponse.request_id se devuelve tal cual; al recibirlo, la webview despacha por id.
  • Streaming con is_streaming + sequence_number: handleStreamingRequest:113 crea un callback responseStream que el handler puede invocar varias veces para emitir resultados parciales; cada emisión lleva is_streaming: true y un sequence_number creciente, y la última lleva isLast=true para cerrar el flujo. Encaja bien con escenarios como «estado de task que se actualiza continuamente» o «flujo de lista de MCP servers».
  • Middleware de grabación: withRecordingMiddleware envuelve postMessage (withRecordingMiddleware:23). Cada vez que se emite un grpc_response, se entrega a GrpcRecorderBuilder.recordResponse, lo que permite reproducir secuencias completas de petición-respuesta al depurar.
  • CSP estricta pero sin bloquear HMR: en producción, la CSP usa nonce + script-src 'nonce-${nonce}' 'unsafe-eval', y connect-src solo permite posthog y cline.bot (CSP meta:113). En dev, la CSP abre localhost y ws para que Vite HMR funcione (dev CSP:200).
  • ExtensionMessage reducido a grpc_response: la interfaz ExtensionMessage ahora solo tiene un type: "grpc_response" (ExtensionMessage:21). Todo lo que la extensión empuja a la webview (state updates, partial message, ask/say) va dentro de algún grpc_response.message, bajo el patrón «la extensión invoca activamente un handler streaming para emitir», sin tipos de mensaje independientes como el antiguo plusButtonClicked.

Archivos clave

  • resolveWebviewView:53 — invocado la primera vez que VSCode abre el sidebar; inyecta HTML, registra el listener de mensajes y asocia los callbacks de visibilidad y destrucción.
  • html injection:62 — según dev/production, elige getHMRHtmlContent o getHtmlContent.
  • setWebviewMessageListener:145 — registra onDidReceiveMessage y reenvía el mensaje a handleWebviewMessage.
  • handleWebviewMessage:161switch de entrada; solo procesa grpc_request y grpc_request_cancel.
  • postMessageToWebview:189 — única salida para que la extensión empuje mensajes a la webview; invoca directamente webview.postMessage.
  • handleGrpcRequest:53 — entrada general; primero recordRequest, y luego bifurca a unary o streaming según is_streaming.
  • handleUnaryRequest:75 — modo unary: llama al handler, obtiene la respuesta y la envuelve en grpc_response.
  • handleStreamingRequest:113 — modo streaming: crea el callback responseStream, que el handler invoca varias veces para emitir parciales.
  • handleGrpcRequestCancel:163 — cancela la petición buscando la función de cleanup en GrpcRequestRegistry.
  • getHandler:192 — busca el handler por serviceName.methodName en el registro serviceHandlers; si no lo encuentra, lanza.
  • ServiceRegistry:30 — registro de servicios; cada servicio registra un conjunto de methods, distinguiendo unary/streaming.
  • GrpcRequest:7 — estructura de petición webview→extensión; lleva service/method/message/request_id/is_streaming.
  • ExtensionMessage:21 — mensaje extensión→webview; solo queda el type: "grpc_response".

Flujo de datos

Cuando la webview quiere emitir una petición, primero la envuelve con un client tipado en un GrpcRequest y luego postMessage; la extensión la recibe en onDidReceiveMessage y la pasa a 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))
        }
    }
}

Cuando handleGrpcRequest recibe el GrpcRequest, primero recordRequest para grabar, luego envuelve postMessageToWebview con withRecordingMiddleware para que las respuestas también pasen por la grabación, y finalmente bifurca por request.is_streaming (is_streaming branch:63). El modo unary invoca al handler una vez, envuelve el resultado en grpc_response.message y lo devuelve (unary response:86). El modo streaming entrega el callback responseStream al handler; cada vez que el handler lo invoca, se empuja un grpc_response con is_streaming: !isLast y sequence_number. En la webview, se despacha por request_id a la Promise pendiente correspondiente; los mensajes streaming se combinan por orden de secuencia. El negocio concreto askResponse es simplemente la webview llamando al método AskResponse del service cline.askResponse; el handler, en askResponse handler:14, mapea la respuesta a ClineAskResponse y luego invoca task.handleWebviewAskResponse, lo que despierta el pWaitFor dentro de Task.

Límites y fallos

  • Tipos de mensaje no reconocidos: la rama default de handleWebviewMessage solo registra error sin lanzar (default error:177); una caché vieja de la webview podría seguir emitiendo tipos antiguos como plusButtonClicked, y la extensión lo tolera.
  • Handler que lanza: si un handler unary lanza, el catch envuelve error.message en grpc_response.error y lo devuelve (unary error:96). Si es un handler streaming el que lanza, en el catch se empuja una respuesta de error con is_streaming: false que cierra el flujo (streaming error:147).
  • Service/method desconocido: cuando getHandler no encuentra el service o el method, lanza Unknown service: ${serviceName} (unknown service:196). Ese error es capturado más arriba y empaquetado como grpc_response.error.
  • Streaming que no cierra el flujo por sí mismo: cuando el handler termina con normalidad, un comentario del código deja claro que «no se envía un mensaje final, el flujo se mantiene abierto» (keep stream open:142); el flujo solo se cierra cuando el cliente envía grpc_request_cancel o el servidor invoca isLast=true explícitamente.
  • postMessage con webview aún no inicializada: postMessageToWebview directamente hace this.webview?.webview.postMessage(message); si la webview no existe, devuelve undefined (postMessage undefined:189), y el llamador debe tolerar ese valor falsy.
  • Cambio de configuración que dispara push de estado: se escucha vscode.workspace.onDidChangeConfiguration; si cambia cline.mcpMarketplace.enabled, se invoca postStateToWebview (config change:102), de modo que la webview sigue el cambio de ajustes.
  • Grabación no letal: tanto withRecordingMiddleware como recordRequest están envueltos en try/catch (recordRequest try:42). Si la grabación se cae, el flujo principal no se ve afectado.

Resumen

El webview bridge unifica la comunicación entre «extensión ↔ sandbox de React» como gRPC-over-postMessage: todo el negocio concreto es una llamada service.method; unary devuelve una sola vez, streaming emite parciales varias veces. Así, ExtensionMessage se reduce a un único type, y añadir llamadas solo requiere registrar un handler en el código generado, con tipos alineados a ambos lados. Para ver cómo una llamada unary concreta aterriza en Task, ver /startup/controller; para ver cómo el ask/say dentro de Task se convierte en mensajes de chat en la webview, ver /agent-loop/task-class.

Véase la documentación oficial: Cline 文档 · README