--- name: webview-trpc-messaging description: Implements tRPC-based communication between VS Code extension host and React webviews. Use when creating new webview procedures (queries, mutations, subscriptions), adding a new webview router, wiring up a webview controller, using the tRPC client from React components, applying telemetry middleware (trpcToTelemetry), or supporting AbortSignal-based cancellation in webview operations. --- # Webview tRPC Messaging Type-safe RPC communication between the VS Code extension host (server) and React webviews (client) using tRPC. ## Architecture Overview ``` React Webview (client) Extension Host (server) ───────────────────── ────────────────────── useTrpcClient() hook WebviewController └─ createTRPCClient └─ setupTrpc() └─ vscodeLink ──── postMessage ────► ├─ callerFactory(appRouter) (send/onReceive) ◄─────┤ └─ procedure(input) └─ abort/subscription.stop ``` **Key files** (read as needed for implementation details): | File | Purpose | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `src/webviews/api/extension-server/trpc.ts` | tRPC init, `publicProcedure`, `publicProcedureWithTelemetry` | | `src/webviews/api/configuration/appRouter.ts` | Root router bundling all view routers + `commonRouter` | | `src/webviews/api/extension-server/WebviewController.ts` | WebviewPanel lifecycle, tRPC message dispatcher (queries, mutations, subscriptions, abort) | | `src/webviews/api/webview-client/useTrpcClient.ts` | React hook providing the tRPC client | | `src/webviews/api/webview-client/vscodeLink.ts` | Custom tRPC link bridging `postMessage` transport | ## Creating a New Router Each webview maintains its own router. Follow this pattern: ### 1. Define the router context Extend `BaseRouterContext` with view-specific fields: ```typescript // src/webviews/documentdb/myView/myViewRouter.ts import { type BaseRouterContext } from '../../api/configuration/appRouter'; export type RouterContext = BaseRouterContext & { clusterId: string; viewId: string; databaseName: string; // add view-specific fields }; ``` ### 2. Define procedures ```typescript import { z } from 'zod'; import { publicProcedureWithTelemetry, router, type WithTelemetry } from '../../api/extension-server/trpc'; export const myViewRouter = router({ // Query with telemetry (preferred for operations that touch external services) getData: publicProcedureWithTelemetry.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => { const myCtx = ctx as WithTelemetry; // myCtx.telemetry is guaranteed present // myCtx.signal is the AbortSignal for cancellation return { data: 'result' }; }), // Mutation without telemetry (rare, use for fire-and-forget) doAction: publicProcedure.input(z.string()).mutation(({ input }) => { // lightweight operation }), }); ``` ### 3. Register in appRouter ```typescript // src/webviews/api/configuration/appRouter.ts import { myViewRouter } from '../../documentdb/myView/myViewRouter'; export const appRouter = router({ common: commonRouter, mongoClusters: { documentView: documentViewRouter, collectionView: collectionViewRouter, myView: myViewRouter, // <-- add here }, }); ``` ### 4. Create the controller ```typescript // src/webviews/documentdb/myView/myViewController.ts import { WebviewController } from '../../api/extension-server/WebviewController'; import { type RouterContext } from './myViewRouter'; export class MyViewController extends WebviewController { constructor(initialData: MyViewConfig) { super(ext.context, title, 'myViewName', initialData); const trpcContext: RouterContext = { dbExperience: API.DocumentDB, webviewName: 'myView', clusterId: initialData.clusterId, viewId: initialData.viewId, databaseName: initialData.databaseName, }; this.setupTrpc(trpcContext); } } ``` > **Important:** The `webviewName` in the `WebviewController` constructor is the **registry key** (must match a key in `WebviewRegistry`, e.g. `collectionView`). The `webviewName` in the tRPC context is a **telemetry label** used in telemetry event names (e.g. `collectionView`). These may be the same string but serve different purposes — do not confuse them. ### 5. Register in WebviewRegistry Add your React component to the registry. The key must match the `webviewName` passed to `WebviewController`'s constructor. The `WebviewName` type (exported from the same file) ensures compile-time validation of webview names. ```typescript // src/webviews/api/configuration/WebviewRegistry.ts import { MyView } from '../../documentdb/myView/MyView'; export const WebviewRegistry = { collectionView: CollectionView, documentView: DocumentView, myViewName: MyView, // <-- add your entry } as const; export type WebviewName = keyof typeof WebviewRegistry; ``` ## Telemetry: `publicProcedure` vs `publicProcedureWithTelemetry` | Base | When to use | `ctx.telemetry` | | ------------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------- | | `publicProcedure` | Fire-and-forget, no external calls, telemetry reported separately | `undefined` | | `publicProcedureWithTelemetry` | **Default choice.** Any procedure touching DB, network, or user-visible work | Guaranteed via `trpcToTelemetry` middleware | `trpcToTelemetry` (file-local, not exported) wraps the procedure in `callWithTelemetryAndErrorHandling`, auto-generating a telemetry event named `documentDB.rpc.{type}.{path}` and recording errors, duration, and abort status. Access telemetry safely: ```typescript const myCtx = ctx as WithTelemetry; myCtx.telemetry.properties.myCustomProp = 'value'; myCtx.telemetry.measurements.itemCount = items.length; ``` ## AbortSignal Support Every tRPC operation (query, mutation, subscription) receives its own `AbortController`. Cancellation flows: ``` Client (React) Server (Extension Host) ────────────── ────────────────────── // Queries/Mutations: ac = new AbortController() trpcClient.myProc.query(input, { signal: ac.signal }) ac.abort() → sends 'abort' msg → abortController.abort() → ctx.signal.aborted = true // Subscriptions: sub = trpcClient.mySub.subscribe(...) sub.unsubscribe() → 'subscription.stop' → abortController.abort() ``` ### Using abort in procedures ```typescript // Pass signal to APIs that accept it (MongoDB driver, fetch, etc.) getData: publicProcedureWithTelemetry .input(z.object({ filter: z.record(z.unknown()) })) .query(async ({ input, ctx }) => { const myCtx = ctx as WithTelemetry; // Option 1: Pass to driver (preferred) const cursor = collection.find(input.filter, { signal: myCtx.signal }); // Option 2: Manual check in loops for (const item of items) { if (myCtx.signal?.aborted) return; await processItem(item); } }), ``` ### Client-side abort ```tsx const { trpcClient } = useTrpcClient(); const abortControllerRef = useRef(); const runQuery = async () => { abortControllerRef.current?.abort(); // cancel previous const ac = new AbortController(); abortControllerRef.current = ac; const result = await trpcClient.mongoClusters.collectionView.myQuery.query(input, { signal: ac.signal }); }; ``` When `trpcToTelemetry` detects an aborted signal, it sets `telemetry.properties.aborted = 'true'` and `result = 'Canceled'` automatically. ## Subscriptions Subscriptions stream multiple values from server to client using async generators: ```typescript // Server (router) streamData: publicProcedureWithTelemetry .input(z.object({ batchSize: z.number() })) .subscription(async function* ({ input, ctx }) { const myCtx = ctx as WithTelemetry; for (let i = 0; i < total; i += input.batchSize) { if (myCtx.signal?.aborted) return; // check before each yield const batch = await fetchBatch(i, input.batchSize); yield batch; } }), // Client (React) const sub = trpcClient.mongoClusters.myView.streamData.subscribe( { batchSize: 100 }, { onData(batch) { /* handle each batch */ }, onComplete() { /* all done */ }, onError(err) { /* handle error */ }, }, ); // To stop: sub.unsubscribe(); ``` ## Client-Side Hook Usage ```tsx import { useTrpcClient } from '../api/webview-client/useTrpcClient'; import { useConfiguration } from '../api/webview-client/useConfiguration'; export const MyComponent = () => { const { trpcClient } = useTrpcClient(); const config = useConfiguration(); useEffect(() => { trpcClient.mongoClusters.myView.getData.query({ id: config.documentId }).then(setData); }, []); }; ``` `useConfiguration()` retrieves the initial config passed to `WebviewController` constructor (serialized via `encodeURIComponent(JSON.stringify(...))`). ## Common Pitfalls - **Never use `any`** in procedure context casts — use `WithTelemetry` or `RouterContext` - **Always prefer `publicProcedureWithTelemetry`** unless you have a specific reason not to - **Always check `myCtx.signal?.aborted`** in long-running loops — not checking causes wasted work after client cancels - **Do not mutate the shared `context` object** — `WebviewController` clones it per-operation already, but router code should treat `ctx` as read-only - **Input validation uses `zod`** — always define `.input(z.object({...}))` for type safety - **The `commonRouter`** handles cross-cutting concerns (error reporting, telemetry events, surveys, URL opening) — do not duplicate these in view-specific routers