ADVANCED.md - the behind-the-scenes manual
July 15, 2026 · View on GitHub
This is the deep documentation behind the
README quick start. The README shows the one-call front door
(openWebview); this file documents the primitives underneath it and the
patterns you reach for when you outgrow the front door.
Everything here references real, shipped symbols. The public surface is split across four entry points; see Tiers below.
Table of contents
- Tiers and when to use each
- Bring your own panel:
attachTrpc - Your own tRPC instance and
createCallerFactory - Create-or-reveal (single-instance panels)
- Telemetry adapters
- The webview event channel
- Framework-agnostic client:
connectTrpc - Push events from host to webview:
TypedEventSink - The type-only router import rule
- FAQ
Tiers and when to use each
The package is three tiers stacked on a shared base. Pick the lowest tier that solves your problem; each lower tier is more flexible and more verbose.
| Tier | Subpath | Use it when |
|---|---|---|
| Front door | openWebview (./host) + ./react hooks | You want a panel, a router, and React hooks with the least ceremony. This is the README path. |
| Panel primitive | WebviewController / attachTrpc (./host) | You own the vscode.WebviewPanel lifecycle, or you need controller subclass hooks. |
| Transport primitive | connectTrpc / vscodeLink (./webview) | You use a UI framework other than React, or you need a bespoke client with custom links. |
The shared . entry (router builders, TypedEventSink, wire types) sits under
all three and imports neither vscode nor React.
Bring your own panel: attachTrpc
WebviewController is built on a single primitive: attachTrpc. When you
already own a vscode.WebviewPanel (because another part of your extension
created it, or you need custom panel options), wire tRPC onto it directly.
import { attachTrpc } from '@microsoft/vscode-ext-webview/host';
import { appRouter, trpc } from './webviews/_integration/appRouter';
const { disposable, activeOperations, activeSubscriptions } = attachTrpc(
panel, // your vscode.WebviewPanel
{ workspaceRoot: '/path' }, // the router context
appRouter,
trpc.createCallerFactory, // from your initWebviewTrpc(...) result; optional
consoleProcedureLogger, // optional dispatch logger; omit for none
);
panel.onDidDispose(() => disposable.dispose());
attachTrpc returns:
disposable- tears down the message listener; dispose it on panel disposal.activeOperations- aMapof in-flight query / mutationAbortControllers.activeSubscriptions- aMapof open subscriptions, keyed by id.
It dispatches incoming webview messages (queries, mutations, subscriptions)
against the router, threads an AbortSignal into ctx.signal, and handles
subscription.stop and abort cancellation. If you pass a ProcedureLogger,
it logs one structured entry per completed call (see
Telemetry adapters).
Your own tRPC instance and createCallerFactory
initWebviewTrpc<TContext>() returns a tRPC instance bound to your context
type. Destructure what you need:
const { router, publicProcedure, createCallerFactory, middleware } = initWebviewTrpc<RouterContext>();
routerbuilds (sub)routers.publicProcedureis the base procedure; itsctxis typed asTContext.createCallerFactorybuilds a server-side caller for a router. The host dispatcher needs it to invoke procedures with full type inference.middlewarebuilds reusable middleware bound to this instance (used by the telemetry adapters below).
With openWebview / WebviewController (recommended): pass the whole
instance. Export your initWebviewTrpc<RouterContext>() result as trpc and
hand it to the trpc option; the dispatcher reads trpc.createCallerFactory
off it. Because the factory travels with the instance that built your router it
can never be mismatched, and there is no separate createCallerFactory to
re-export:
export const trpc = initWebviewTrpc<RouterContext>();
export const appRouter = trpc.router({
/* … */
});
openWebview(ctx, { router: appRouter, trpc, context, config, sourceLayout });
With attachTrpc (bring-your-own-panel): pass the factory explicitly. The
low-level primitive takes createCallerFactory as its (optional) fourth
argument, as shown above, so embedders that construct tRPC their own way stay in
full control.
If you provide neither, the host falls back to the package's shared default
instance, which works only when your router is built with the package's default
router / publicProcedure (the ones exported from .). The old pattern of
re-exporting and passing a standalone createCallerFactory to openWebview is
deprecated in favour of the trpc option, but still honored.
Create-or-reveal (single-instance panels)
openWebview creates a new panel on every call, so opening the same logical
view twice yields two tabs. Many extensions instead want create-or-reveal: the
first call opens the panel, later calls for the same key just bring the existing
tab to the foreground. The package deliberately does not own this — a panel
registry is consumer state, not transport state — but the returned controller
handle gives you everything needed to implement it in a few lines.
Keep a Map from your own key to the live controller, reveal on a hit, and clear
the entry when the panel is disposed:
import { openWebview, type WebviewController } from '@microsoft/vscode-ext-webview/host';
const openPanels = new Map<string, WebviewController<AppRouter, MyConfig, RouterContext>>();
function openOrReveal(ctx: vscode.ExtensionContext, key: string, config: MyConfig) {
const existing = openPanels.get(key);
if (existing && !existing.isDisposed) {
existing.revealToForeground(); // already open — just focus it
return existing;
}
const controller = openWebview<AppRouter, MyConfig, RouterContext>(ctx, {
title: `My View — ${key}`,
viewType: 'myView',
router: appRouter,
trpc,
context: {
/* … */
},
config,
sourceLayout,
});
openPanels.set(key, controller);
controller.onDisposed(() => openPanels.delete(key)); // evict so a later call re-creates
return controller;
}
The key is yours to choose: a document id, a connection id, or viewType alone
for a true singleton. revealToForeground() and onDisposed() are the only
handle members this needs, and evicting on dispose keeps the map from leaking or
revealing a disposed controller. If several consumers converge on exactly this,
it becomes a candidate to promote into the package later; until then, keeping it
in consumer code keeps the package lean.
Telemetry adapters
The package separates the telemetry policy (where data goes) from the plumbing (how a call is timed and classified). Two middleware bodies cover the two common policies; both are pure functions you wire onto your own procedure.
Console logging: loggingMiddlewareBody + ProcedureLogger
consoleProcedureLogger is the zero-config default the panel uses out of the
box (it logs [tRPC] <type> <path> (<ms>) <status>). To route those entries
elsewhere, implement ProcedureLogger:
import { loggingMiddlewareBody, type ProcedureLogger } from '@microsoft/vscode-ext-webview/host';
const logger: ProcedureLogger = {
onStart(entry) {
// entry: { type, path }
myOutputChannel.appendLine(`→ ${entry.type} ${entry.path}`);
},
onEnd(entry) {
// entry: { type, path, durationMs, ok, aborted, error? }
myOutputChannel.appendLine(`${entry.type} ${entry.path} ${entry.durationMs}ms`);
},
};
const logged = publicProcedure.use((opts) => loggingMiddlewareBody(opts, logger));
Both onStart and onEnd are optional — implement only the hook you need
(onEnd alone is the common case).
You can also pass a ProcedureLogger as the telemetry option to openWebview
/ WebviewController (or the last argument to attachTrpc) to log at the
dispatch layer without touching procedure definitions. At the dispatch layer the
transport itself invokes the hooks: onStart fires exactly once before each
query, mutation, and subscription runs, and onEnd fires exactly once when it
completes (success, failure, or cancellation). The two are paired one-to-one even
when procedure setup fails — an early failure still produces its onStart and a
matching onEnd with ok: false. Because both hooks are independent and
optional, an onStart-only logger receives start events at the dispatch layer,
and dispatch onEnd entries additionally carry a concurrent in-flight gauge
(see the ProcedureLogEntry.concurrent field).
Analytics: telemetryMiddlewareBody + TelemetryRunner
For real analytics (for example Application Insights via
@microsoft/vscode-azext-utils), implement a TelemetryRunner that
establishes a telemetry scope, contributes fields to the procedure context, and
classifies the outcome. The middleware body itself is a thin delegator: it
resolves the event id (via buildEventId) and calls runner.run.
import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils';
import {
initWebviewTrpc,
// shared entry
} from '@microsoft/vscode-ext-webview';
import { getInvocationSignal, telemetryMiddlewareBody, type TelemetryRunner } from '@microsoft/vscode-ext-webview/host';
// `TEnrichment` is whatever the runner contributes to ctx. Here: the whole
// IActionContext, surfaced to procedures as `ctx.actionContext`.
const runner: TelemetryRunner<{ actionContext: IActionContext }> = {
async run(eventId, invocation, invoke) {
const result = await callWithTelemetryAndErrorHandling(eventId, async (actionContext) => {
actionContext.errorHandling.suppressDisplay = true;
const middlewareResult = await invoke({ actionContext });
// `callWithTelemetryAndErrorHandling` already records `duration` and would
// classify a *thrown* error, but tRPC returns failures as a result object
// (`{ ok: false }`) rather than a throw, so translate that here.
const aborted = getInvocationSignal(invocation.ctx)?.aborted ?? false;
if (aborted) {
actionContext.telemetry.properties.result = 'Canceled';
} else if (!middlewareResult.ok && middlewareResult.error) {
actionContext.telemetry.properties.result = 'Failed';
actionContext.telemetry.properties.error = middlewareResult.error.name ?? '';
actionContext.telemetry.properties.errorMessage = middlewareResult.error.message ?? '';
}
return middlewareResult;
});
if (!result) throw new Error(`No result for ${eventId}`);
return result;
},
};
const { publicProcedure } = initWebviewTrpc<RouterContext>();
export const trackedProcedure = publicProcedure.use(
telemetryMiddlewareBody(runner, { buildEventId: ({ type, path }) => `myExt.rpc.${type}.${path}` }),
);
The body resolves the event id, delegates to the runner, and returns the
procedure's result unchanged. All timing and outcome classification live in the
runner: callWithTelemetryAndErrorHandling records duration and dispatches to
Application Insights, and the runner translates the tRPC result object into
Canceled / Failed. Build your router from trackedProcedure instead of the
bare publicProcedure to instrument every call.
The dispatch logger and the middleware body are independent sinks: the logger reports at the transport boundary, the middleware reports inside each procedure scope. Using both does not double-count a single call into one analytics event.
Reading the runner's contributed fields
telemetryMiddlewareBody merges whatever your runner passes to
invoke(enrichment) into the procedure ctx via tRPC's next({ ctx }). Declare
that field on your router context type so procedures read it with no
telemetry-specific cast:
import type { IActionContext } from '@microsoft/vscode-azext-utils';
import type { BaseRouterContext } from '@microsoft/vscode-ext-webview';
// The runner contributes `{ actionContext }`; declare it on the context.
type RouterContext = BaseRouterContext & { db: Db; actionContext: IActionContext };
export const stats = trackedProcedure.query(({ ctx }: { ctx: RouterContext }) => {
ctx.actionContext.telemetry.properties.result = 'ok'; // typed
ctx.actionContext.telemetry.suppressIfSuccessful = true; // azext-specific field, also typed
return ctx.db.stats();
});
When a single tRPC instance is bound to a base context and serves several views,
procedures narrow ctx to their view's RouterContext (which declares
actionContext) with a single ctx as RouterContext. The root context the
controller builds does not carry actionContext — the runner injects it per
call — so type that root object as Omit<RouterContext, 'actionContext'>.
The webview event channel
By default a query or mutation that throws on the host propagates to the
call-site .catch(...) (or try / catch around await). That works, but it
forces every call site to remember to handle the error. When a single place
should always see webview-side outcomes (an ARIA announcer, a toaster,
telemetry), observe the event channel.
useRpcEvents() returns the per-webview RpcEventChannel:
import { useEffect } from 'react';
import { useRpcEvents } from '@microsoft/vscode-ext-webview/react';
function ErrorAnnouncer() {
const events = useRpcEvents();
useEffect(() => {
const off = events.onError((err, info) => announcer.announceError(`${info.path}: ${err.message}`));
return off;
}, [events]);
return null;
}
The channel exposes three observe methods, each returning an unsubscribe:
onSuccess(handler)- the call resolved;handler(info, data).onError(handler)- the call rejected;handler(error, info).onAborted(handler)- the call was canceled;handler(info).
info is a CallInfo ({ type, path }). The channel observes; it does not
swallow. Your call-site handlers still run and still receive the error.
Subscriptions are intentionally not published to the channel — observe a
subscription's outcome through its own .subscribe({ onError, onComplete })
callbacks. Only query and mutation outcomes flow through onSuccess / onError
/ onAborted, so a subscription's events are never surfaced twice.
When an observer throws: onObserverError
The channel isolates a throwing observer: if one of your onSuccess /
onError / onAborted handlers throws, the channel catches it so the throw
cannot break the tRPC call the handler was only observing (the observer-only
contract). The isolated error is routed to an onObserverError sink that
defaults to console.error. Pass your own via connectTrpc(vscodeApi, { onObserverError }) — or the onObserverError prop of WithWebviewContext — to
route observer failures to telemetry:
const { client, events } = connectTrpc<AppRouter>(vscodeApi, {
onObserverError: (error, { info, phase }) => {
// Structured: you know exactly which call and phase produced the throw.
reportEvent('webview.observerError', { path: info.path, phase, message: String(error) });
},
});
phase is 'success' | 'error' | 'aborted' and info is the same CallInfo
the observers receive. A throw from the sink itself is also swallowed — nothing
an observer (or its error sink) does can affect dispatch. The default is
deliberately quiet (console.error, no telemetry) so a generic consumer is not
opted into events it may not want; wire the sink when you want observer failures
in your telemetry.
The channel and the tRPC client are created together and shared per webview, so
useTrpcClient() and useRpcEvents() always see the same instance.
Framework-agnostic client: connectTrpc
The React hooks are sugar over a React-free factory. connectTrpc(vscodeApi, options?) from ./webview builds the same client and event channel with no
React dependency, so you can bind another UI framework on top of the transport:
import { connectTrpc } from '@microsoft/vscode-ext-webview/webview';
import type { AppRouter } from './_integration/appRouter';
const vscodeApi = acquireVsCodeApi();
const { client, events } = connectTrpc<AppRouter>(vscodeApi, {
onError: (err) => console.error(err),
});
const result = await client.hello.query({ name: 'world' });
events.onAborted((info) => console.debug('canceled', info.path));
vscodeApi only needs a postMessage(message) method (VsCodeApiLike). The
onError option is a shorthand for events.onError((err) => onError(err));
aborted calls are reported via onAborted, not onError. For full control over
link order (for example composing a third-party logging link), import
vscodeLink (and optionally errorLink / the lower-level eventLink) and
build the client yourself with createTRPCClient.
Push events from host to webview: TypedEventSink
tRPC subscriptions are async function* generators, which fit pull-style
producers (cursors, polling loops). For push-style producers (VS Code event
emitters, driver callbacks, completion notifiers) you need an adapter that the
producer can call imperatively and the subscription can iterate.
TypedEventSink<T> is that adapter. It implements AsyncIterable<T> over a
discriminated event union, with a write-only emit(event) (or emit(type, payload)) on the producer side and for await (const event of sink) on the
consumer side. Single consumer per sink; events emitted before a consumer
attaches are buffered.
Define the event union and the events router:
// _integration/myViewEventsRouter.ts
import { initWebviewTrpc, type BaseRouterContext, type TypedEventSink } from '@microsoft/vscode-ext-webview';
export type MyViewEvent = { type: 'progress'; percent: number } | { type: 'completed'; durationMs: number };
type MyViewRouterContext = BaseRouterContext & {
eventSink: TypedEventSink<MyViewEvent>;
};
const { router, publicProcedure } = initWebviewTrpc<MyViewRouterContext>();
export const myViewEventsRouter = router({
events: publicProcedure.subscription(async function* ({ ctx }) {
for await (const event of ctx.eventSink) {
if (ctx.signal?.aborted) return;
yield event;
}
}),
});
Emit from anywhere in host code that holds the sink:
sink.emit({ type: 'progress', percent: 25 });
sink.emit('completed', { durationMs: 1500 });
Consume in the webview with the standard subscription API:
useEffect(() => {
const sub = trpcClient.myView.events.subscribe(undefined, {
onData: (event) => {
if (event.type === 'progress') setPercent(event.percent);
else if (event.type === 'completed') setDoneAfter(event.durationMs);
},
});
return () => sub.unsubscribe();
}, [trpcClient]);
Recommended convention: put push-event procedures in a sibling
<view>EventsRouter.ts and merge it into the view's main router. This keeps
"things the webview calls" and "things the host pushes" in separate files, so
the entire event vocabulary of a view is discoverable at a glance.
When the producer (panel, session, task) finishes for good, call sink.close().
The framework already cleans up async iteration on unsubscribe and panel
disposal via iterator.return(), but close() is still the right signal
whenever the sink has no more events to ever emit: it lets late producers stop
without checking, and prevents reuse by accident.
The type-only router import rule
The webview needs the AppRouter type to keep useTrpcClient<AppRouter>()
type-safe end to end. This is the only thing webview code should import from the
file that defines your router.
import type { AppRouter } from '../_integration/appRouter';
import { useTrpcClient } from '@microsoft/vscode-ext-webview/react';
const trpcClient = useTrpcClient<AppRouter>();
A few rules of thumb keep the host / browser boundary honest:
- Use
import type { AppRouter } from '...'(orimport { type AppRouter }). Type-only imports are erased at compile time and never produce runtime references, so the webview bundle stays free of extension-host code even if the router module also imports Node-only APIs. - Do not import runtime values (the
appRouterconstant, procedure builders, middleware) from webview code. Those belong to the extension-host side. - Do not reach into procedure implementation files from the webview side. Router types are the only contract the webview consumes.
If your bundler ever pulls host modules into the views bundle, the most common
cause is a non-type-only import of AppRouter. Switching it to
import type { ... } resolves it without changes to the router itself.
FAQ
Why tRPC instead of raw postMessage?
Raw postMessage requires you to define message types manually, match
request / response pairs by hand, and serialise yourself. tRPC threads
TypeScript types end to end. Your host procedures and webview calls share the
same AppRouter type with zero code generation. Renaming a field on the host
shows a compile error in the webview immediately.
Why are there four entry points?
. is side-agnostic (no vscode, no React). ./host is the extension-host
surface and imports fs / path / vscode. ./webview is the browser-side
transport with no React dependency. ./react adds the React hooks. Splitting
them keeps Node / vscode out of the webview bundle, and keeps React out of a
non-React consumer's bundle.
Can I have multiple webview panels open at the same time?
Yes. Each WebviewController (or attachTrpc call) owns its own panel and its
own tRPC caller. State is not shared between panels unless you explicitly
coordinate through the host (for example a singleton service).
Can I cancel a long-running query or subscription?
Yes. Pass an AbortSignal on the client side:
const ac = new AbortController();
const result = await trpcClient.something.query(input, { signal: ac.signal });
// later
ac.abort();
On the host side, read ctx.signal inside your procedure to cooperatively stop
work. Subscriptions stop cleanly when the client unsubscribes: the framework
sends a subscription.stop message and the dispatcher both aborts the
per-operation AbortController and calls iterator.return() on the procedure's
async generator. The return() call propagates into any inner for await loop,
including loops over a TypedEventSink, releasing consumers parked on the next
event without waiting for the producer to emit or close. The same cleanup runs
when the panel is disposed.
Can I use a UI library other than React?
Yes, on top of ./webview. The React hooks (useTrpcClient, useRpcEvents,
useConfiguration, WithWebviewContext) are React-specific, but the underlying
transport (connectTrpc, vscodeLink) is framework-agnostic. Build a binding
for your framework on top of connectTrpc; the package itself does not ship one.
License
MIT. See LICENSE.md at the repository root.