microsoft/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.
npx skills add https://github.com/microsoft/vscode-documentdb --skill webview-trpc-messaging
Type-safe RPC communication between the VS Code extension host (server) and React webviews (client) using tRPC.
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 |
Each webview maintains its own router. Follow this pattern:
Extend BaseRouterContext with view-specific fields:
// 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
};
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
}),
});
// 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
},
});
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):
// 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.
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.
// 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;
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:
const myCtx = ctx as WithTelemetry<RouterContext>;
myCtx.actionContext.telemetry.properties.myCustomProp = 'value';
myCtx.actionContext.telemetry.measurements.itemCount = items.length;
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()
// 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);
}
}),
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 stream multiple values from server to client using async generators:
// 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();
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(...))).
any in procedure context casts — narrow with ctx as WithTelemetry<RouterContext> when the procedure reads telemetry (ctx.actionContext.telemetry), or ctx as RouterContext otherwisepublicProcedureWithTelemetry unless you have a specific reason not tomyCtx.signal?.aborted in long-running loops — not checking causes wasted work after client cancelscontext object — WebviewController clones it per-operation already, but router code should treat ctx as read-onlyzod — always define .input(z.object({...})) for type safetycommonRouter handles cross-cutting concerns (error reporting, telemetry events, surveys, URL opening) — do not duplicate these in view-specific routersTake microsoft/webview-trpc-messaging from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.