Webview Bridge: protocolo gRPC-over-postMessage
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
switchsobretype: "askResponse" | "showTaskWithId" | .... Cada llamada nueva que se añadía desde la webview obligaba a tocar el tipoExtensionMessage+ elswitch+ 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_idcorrelaciona petición y respuesta:GrpcRequest.request_ides 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_idse devuelve tal cual; al recibirlo, la webview despacha por id.- Streaming con
is_streaming+sequence_number:handleStreamingRequest:113crea un callbackresponseStreamque el handler puede invocar varias veces para emitir resultados parciales; cada emisión llevais_streaming: truey unsequence_numbercreciente, y la última llevaisLast=truepara 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:
withRecordingMiddlewareenvuelvepostMessage(withRecordingMiddleware:23). Cada vez que se emite ungrpc_response, se entrega aGrpcRecorderBuilder.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', yconnect-srcsolo permite posthog y cline.bot (CSP meta:113). En dev, la CSP abre localhost y ws para que Vite HMR funcione (dev CSP:200). ExtensionMessagereducido agrpc_response: la interfazExtensionMessageahora solo tiene untype: "grpc_response"(ExtensionMessage:21). Todo lo que la extensión empuja a la webview (state updates, partial message, ask/say) va dentro de algúngrpc_response.message, bajo el patrón «la extensión invoca activamente un handler streaming para emitir», sin tipos de mensaje independientes como el antiguoplusButtonClicked.
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, eligegetHMRHtmlContentogetHtmlContent.setWebviewMessageListener:145— registraonDidReceiveMessagey reenvía el mensaje ahandleWebviewMessage.handleWebviewMessage:161—switchde entrada; solo procesagrpc_requestygrpc_request_cancel.postMessageToWebview:189— única salida para que la extensión empuje mensajes a la webview; invoca directamentewebview.postMessage.handleGrpcRequest:53— entrada general; primerorecordRequest, y luego bifurca a unary o streaming segúnis_streaming.handleUnaryRequest:75— modo unary: llama al handler, obtiene la respuesta y la envuelve engrpc_response.handleStreamingRequest:113— modo streaming: crea el callbackresponseStream, que el handler invoca varias veces para emitir parciales.handleGrpcRequestCancel:163— cancela la petición buscando la función de cleanup enGrpcRequestRegistry.getHandler:192— busca el handler porserviceName.methodNameen el registroserviceHandlers; 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 eltype: "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:
// 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
defaultdehandleWebviewMessagesolo registra error sin lanzar (default error:177); una caché vieja de la webview podría seguir emitiendo tipos antiguos comoplusButtonClicked, y la extensión lo tolera. - Handler que lanza: si un handler unary lanza, el
catchenvuelveerror.messageengrpc_response.errory lo devuelve (unary error:96). Si es un handler streaming el que lanza, en elcatchse empuja una respuesta de error conis_streaming: falseque cierra el flujo (streaming error:147). - Service/method desconocido: cuando
getHandlerno encuentra el service o el method, lanzaUnknown service: ${serviceName}(unknown service:196). Ese error es capturado más arriba y empaquetado comogrpc_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íagrpc_request_cancelo el servidor invocaisLast=trueexplícitamente. postMessagecon webview aún no inicializada:postMessageToWebviewdirectamente hacethis.webview?.webview.postMessage(message); si la webview no existe, devuelveundefined(postMessage undefined:189), y el llamador debe tolerar ese valorfalsy.- Cambio de configuración que dispara push de estado: se escucha
vscode.workspace.onDidChangeConfiguration; si cambiacline.mcpMarketplace.enabled, se invocapostStateToWebview(config change:102), de modo que la webview sigue el cambio de ajustes. - Grabación no letal: tanto
withRecordingMiddlewarecomorecordRequestestá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.