# Webview Trpc Messaging

> 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 (telemetryMiddlewareBody), or supporting AbortSignal-based cancellation in webview operations.

- Skill: `microsoft/webview-trpc-messaging` (Agent Skill)
- Install (CLI): `npx skillmds@latest add microsoft/webview-trpc-messaging`
- Raw SKILL.md: https://api.skillmd.com/api/skills/microsoft/webview-trpc-messaging/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Microsoft (https://skillmd.com/u/microsoft)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/microsoft/webview-trpc-messaging

---


# 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                                                                                                                                                       |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@microsoft/vscode-ext-webview` (shared)                 | tRPC init via `initWebviewTrpc`, `publicProcedure`, `router`, `BaseRouterContext`                                                                             |
| `@microsoft/vscode-ext-webview/host` (telemetry)         | `telemetryMiddlewareBody`, `ProcedureLogger`, `TelemetryRunner` (consumer builds `publicProcedureWithTelemetry`)                                              |
| `src/webviews/_integration/trpc.ts`                      | Consumer tRPC instance: `publicProcedureWithTelemetry`, the DocumentDB `TelemetryRunner`, and the `RpcEnrichment` shape it contributes to `ctx.actionContext` |
| `src/webviews/_integration/appRouter.ts`                 | Root router + `publicProcedureWithTelemetry` wiring + DocumentDB `BaseRouterContext`                                                                          |
| `src/webviews/_integration/configuration.ts`             | Consumer-owned knobs (telemetry namespace, bundle layout, dev-server host)                                                                                    |
| `@microsoft/vscode-ext-webview/host` (WebviewController) | `WebviewController` + `openWebview` factory: WebviewPanel lifecycle, tRPC dispatcher (queries, mutations, subscriptions, abort)                               |
| `src/webviews/_integration/openAppWebview.ts`            | DocumentDB factory preset that pre-fills router + bundle layout (`openAppWebview`)                                                                            |
| `src/webviews/_integration/useTrpcClient.ts`             | React hook providing the tRPC client (pre-typed against `AppRouter`)                                                                                          |
| `@microsoft/vscode-ext-webview/webview` (vscodeLink)     | 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 '../../_integration/appRouter';

export type RouterContext = BaseRouterContext & {
  clusterId: string;
  viewId: string;
  databaseName: string;
  // add view-specific fields
};
```

### 2. Define procedures

```typescript
import { z } from 'zod';
import {
  publicProcedure,
  publicProcedureWithTelemetry,
  router,
  type WithTelemetry,
} from '../../_integration/appRouter';
import { type RouterContext } from './myViewRouter';

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<RouterContext>;
    // Instrumented procedure: myCtx.actionContext (the full IActionContext) is present.
    myCtx.actionContext.telemetry.properties.itemId = input.id;
    // 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/_integration/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

Construction-only panels are opened with a factory function that builds the
config + router context and calls the `openAppWebview` preset (which pre-fills
the app router, caller factory, and bundle layout):

```typescript
// src/webviews/documentdb/myView/myViewController.ts
import * as vscode from 'vscode';
import { API } from '../../../DocumentDBExperiences';
import { type AppWebviewController, openAppWebview } from '../../_integration/openAppWebview';
import { type RouterContext } from './myViewRouter';

export function openMyViewPanel(initialData: MyViewConfig): AppWebviewController<MyViewConfig> {
  const title = `${initialData.databaseName}`;

  const trpcContext: RouterContext = {
    dbExperience: API.DocumentDB,
    webviewName: 'myView',
    clusterId: initialData.clusterId,
    viewId: initialData.viewId,
    databaseName: initialData.databaseName,
  };

  return openAppWebview({
    title,
    webviewName: 'myView',
    config: initialData,
    context: trpcContext,
  });
}
```

The returned `AppWebviewController` handle exposes `panel`, `onDisposed`,
`revealToForeground`, `isDisposed`, and `dispose`. Genuinely stateful panels may
still extend `WebviewController` from `@microsoft/vscode-ext-webview/host`
directly instead of using the factory.

> **Important:** The `webviewName` field passed to `openAppWebview` is the
> **registry key** (`viewType`, must match a key in `WebviewRegistry`, e.g.
> `collectionView`). The `webviewName` in the tRPC context is a **telemetry
> label** used in telemetry event names. 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 `openAppWebview` (`viewType`). The `WebviewName` type (exported from
the same file) ensures compile-time validation of webview names.

```typescript
// src/webviews/_integration/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.actionContext`                                      |
| ------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------- |
| `publicProcedure`              | Fire-and-forget, no external calls, telemetry reported separately            | absent (do not read it)                                  |
| `publicProcedureWithTelemetry` | **Default choice.** Any procedure touching DB, network, or user-visible work | Guaranteed, injected by the DocumentDB `TelemetryRunner` |

`publicProcedureWithTelemetry` is `publicProcedure.use(telemetryMiddlewareBody(documentDbTelemetryRunner, ...))` (built in `trpc.ts`). The framework's `telemetryMiddlewareBody` delegates to the DocumentDB `TelemetryRunner`, which wraps the call in `callWithTelemetryAndErrorHandling`, contributes the full `IActionContext` to `ctx.actionContext`, auto-generates a telemetry event named `documentDB.rpc.{type}.{path}`, and records errors, duration, and abort status.

`actionContext` is **not** a field on the base `RouterContext` — it is an additive enrichment. Narrow to `WithTelemetry<RouterContext>` (`= RouterContext & { actionContext }`) in an instrumented procedure to read it; a plain `publicProcedure` procedure narrows to bare `RouterContext`, so reading `actionContext` there is a compile error instead of a runtime `undefined`.

Access telemetry safely:

```typescript
const myCtx = ctx as WithTelemetry<RouterContext>;
myCtx.actionContext.telemetry.properties.myCustomProp = 'value';
myCtx.actionContext.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 RouterContext;

        // 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<AbortController>();

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 `publicProcedureWithTelemetry` detects an aborted signal, the DocumentDB `TelemetryRunner` 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 RouterContext;

        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 '../_integration/useTrpcClient';
import { useConfiguration } from '@microsoft/vscode-ext-webview/react';

export const MyComponent = () => {
  const trpcClient = useTrpcClient();
  const config = useConfiguration<MyViewConfig>();

  useEffect(() => {
    trpcClient.mongoClusters.myView.getData.query({ id: config.documentId }).then(setData);
  }, []);
};
```

`useConfiguration<T>()` retrieves the initial config passed to `WebviewController` constructor (serialized via `encodeURIComponent(JSON.stringify(...))`).

## Common Pitfalls

- **Never use `any`** in procedure context casts — narrow with `ctx as WithTelemetry<RouterContext>` when the procedure reads telemetry (`ctx.actionContext.telemetry`), or `ctx as RouterContext` otherwise
- **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

