VS Code Messenger
vscode-messenger is a typed RPC library for communication between a VS Code extension host and its webviews (WebviewView and WebviewPanel). It supports request/response, notifications, broadcasts, cancellation, and sender-scoped handlers.
This skill assumes the agent is implementing or debugging messaging code on top of this library — not contributing to the library itself.
Packages
Three npm packages, one per execution context. Install only what each side needs:
| Package | Used in | Purpose |
|---|---|---|
vscode-messenger |
Extension host | Messenger class, view registration, diagnostics |
vscode-messenger-webview |
Webview script | Messenger class for webview side, createCancellationToken |
vscode-messenger-common |
Both (or shared module) | NotificationType, RequestType, MessageParticipant, HOST_EXTENSION, BROADCAST |
Recommendation: keep all NotificationType / RequestType declarations in a shared TypeScript module that both the extension and the webview import from. The same method string and parameter/result types must line up on both ends, and a shared module is the simplest way to enforce that.
Defining message types
import type { NotificationType, RequestType } from 'vscode-messenger-common';
export const ColorSelected: NotificationType<string> = { method: 'colorSelected' };
export const GetColors: RequestType<void, string[]> = { method: 'availableColors' };
export const RenameUser: RequestType<{ userId: string; name: string }, { ok: boolean }> = {
method: 'renameUser'
};
NotificationType<P> and RequestType<P, R> are pure type tags — at runtime they are just { method: string }. The generics ensure that sendRequest / onRequest and their notification counterparts type-check on both sides.
Extension side
import { Messenger } from 'vscode-messenger';
import { HOST_EXTENSION } from 'vscode-messenger-common';
const messenger = new Messenger({ debugLog: true });
class ColorsViewProvider implements vscode.WebviewViewProvider {
static readonly viewType = 'calicoColors.colorsView';
constructor(private readonly messenger: Messenger) {}
resolveWebviewView(view: vscode.WebviewView) {
view.webview.options = { enableScripts: true };
view.webview.html = getHtml();
// Register BEFORE setting handlers/sending so message events are wired.
this.messenger.registerWebviewView(view);
// Handle requests coming from the webview
this.messenger.onRequest(GetColors, () => ['020202', 'f1eeee', 'a85b20']);
// React to notifications from the webview
this.messenger.onNotification(ColorSelected, color => {
vscode.window.activeTextEditor?.insertSnippet(
new vscode.SnippetString(`#${color}`)
);
});
}
addColor() {
// Target every webview of this view type
this.messenger.sendNotification(
ColorModify,
{ type: 'webview', webviewType: ColorsViewProvider.viewType },
'add'
);
}
}
Key APIs:
new Messenger(options?)—MessengerOptions:ignoreHiddenViews(defaulttrue),uniqueHandlers(throws if a handler for the same method is registered twice — incompatible with sender-scoped handlers; see that section below),debugLog.registerWebviewView(view, options?)/registerWebviewPanel(panel, options?)— returns aWebviewIdMessageParticipantwith the assignedwebviewId. Use this returned participant to address that specific instance (vs. thewebviewTypestring which addresses all instances of that type). The library auto-unregisters ononDidDispose.onRequest(type, handler, { sender? })/onNotification(type, handler, { sender? })— return aDisposable. Extension side: multiple handlers for the same method stack and all fire (for notifications; for requests, multiple matching handlers is an error — see below). Webview side: registering a new handler for the same method replaces the previous one (last-write-wins). The optionalsender(extension side only) filters the handler so it only fires for messages from that participant.sendRequest(type, receiver, params?, cancelable?)— returnsPromise<R>. Receiver is aMessageParticipant(webview by id, webview by type, or — once supported — another extension). Cannot beBROADCAST— throws immediately on both sides.sendNotification(type, receiver, params?)— fire-and-forget. Receiver may beBROADCAST.
ViewOptions.broadcastMethods: string[] — set per registered view to opt that view in to specific broadcast notification methods. A broadcast skips any view that doesn't list the method.
Webview side
import { Messenger } from 'vscode-messenger-webview';
import { HOST_EXTENSION } from 'vscode-messenger-common';
import { ColorSelected, GetColors, ColorModify } from './shared/message-types';
const messenger = new Messenger();
messenger.onNotification(ColorModify, async action => {
if (action === 'clear') colors = [];
if (action === 'add') colors.push({ value: await getNewColor() });
render();
});
messenger.start(); // REQUIRED — registers the window 'message' listener
async function getNewColor(): Promise<string> {
const palette = await messenger.sendRequest(GetColors, HOST_EXTENSION);
return palette[Math.floor(Math.random() * palette.length)];
}
function pickColor(value: string) {
messenger.sendNotification(ColorSelected, HOST_EXTENSION, value);
}
The webview-side Messenger constructor calls acquireVsCodeApi() for you. Pass an existing instance only if your code already called acquireVsCodeApi() (it can be called only once per webview):
const vscodeApi = acquireVsCodeApi();
const messenger = new Messenger(vscodeApi);
Core patterns
Request / response
Either side can sendRequest; the other side handles it with onRequest. Errors thrown by the handler are surfaced as a rejected promise on the sender side with a ResponseError { message, data? }.
Notification
Fire-and-forget. Use onNotification to handle, sendNotification to send. No response, no awaiting.
Broadcast (notifications only)
// Extension: register the view with the methods it should receive broadcasts for
messenger.registerWebviewView(view, { broadcastMethods: [Refresh.method] });
// Either side:
messenger.sendNotification(Refresh, BROADCAST);
Sending a request to BROADCAST throws immediately on both sides. Broadcast is for notifications only.
Cancellation
Extension side accepts a vscode.CancellationToken (or any CancellationToken-shaped object) directly:
const cts = new vscode.CancellationTokenSource();
const result = await messenger.sendRequest(LongOp, target, params, cts.token);
// ...
cts.cancel(); // Sends a cancel notification to the receiver
Webview side bridges AbortSignal:
import { createCancellationToken } from 'vscode-messenger-webview';
const ctrl = new AbortController();
const result = await messenger.sendRequest(
LongOp, HOST_EXTENSION, params, createCancellationToken(ctrl.signal)
);
ctrl.abort('User cancelled');
In the handler, the third argument is the CancellationToken:
messenger.onRequest(LongOp, async (params, _sender, token) => {
while (!token.isCancellationRequested) { /* work */ }
if (token.isCancellationRequested) throw new Error('cancelled');
});
Sender-scoped handlers (extension side only)
onRequest / onNotification accept { sender: MessageParticipant }. The handler only fires when the message's sender matches. Use this to handle the same method differently per-webview, e.g. when two webviews share a method but should be served by different logic.
Important: Sender-scoped handlers require registering multiple handlers for the same method. This is only possible when uniqueHandlers is unset or false (the default). Do not set uniqueHandlers: true if you intend to use sender-scoped handlers — it throws on any duplicate method registration, even with different senders.
Targeting specific webview instances
registerWebviewView / registerWebviewPanel return a WebviewIdMessageParticipant. Save it to address that one instance:
const colorsView = messenger.registerWebviewView(view); // { type: 'webview', webviewId: '...' }
messenger.sendNotification(ColorModify, colorsView, 'clear');
If you address by webviewType instead and multiple instances of that type are registered, sendRequest sends the request to all registered instances and uses Promise.race — the first response wins, others are discarded. Hidden instances (when ignoreHiddenViews is true, the default) produce an immediate rejection that participates in the race; if any visible instance responds successfully, its result is returned. If all instances are hidden, the request is rejected. sendNotification is sent to all instances of that type (hidden ones are silently skipped when ignoreHiddenViews is true).
Common gotchas
Setup prerequisites (must be correct before any message works)
- Webview drops messages until
start()— the webview-sideMessengeronly attaches itswindow.addEventListener('message', ...)insidestart(). Forgetting to call it makes every incoming message disappear silently. Callstart()once, synchronously after allonRequest/onNotificationregistrations in the same top-level script execution (before any async work), so no incoming messages are missed. - Hidden views are skipped by default —
ignoreHiddenViews: truecausessendNotification/sendRequestto a non-visible webview to be skipped (notification) or rejected (request). Either ensure the view is visible, setignoreHiddenViews: falseinMessengerOptions, or enableretainContextWhenHiddenon the webview itself when constructing it. - Broadcast requires opt-in per view — a view receives a broadcast notification only if its
ViewOptions.broadcastMethodscontains the method string. Without that, the broadcast looks like it works but the view never sees it. - Extension may send before webview is ready — if the extension sends a request immediately after
registerWebviewView, the webview may not yet have calledmessenger.start(). Guard against this by having the webview send an initialization notification to the extension oncestart()is called, and only then begin sending from the extension side.
Runtime and targeting pitfalls
- Webview handlers are last-write-wins — on the webview side,
onRequest/onNotificationfor the same method replace the previous handler. On the extension side, notification handlers stack and all fire; request handlers also stack but having multiple matching handlers for the same request method results in an error response ("Multiple matching request handlers"). UseuniqueHandlers: trueto catch accidental duplicate registrations at registration time (throws immediately). If you re-register on the webview during HMR or re-mount, dispose the oldDisposablefirst. webviewTypewith multiple instances races requests — if you really want to broadcast a question and aggregate, you have to do it yourself (iterate instances by id andPromise.all). The library only returns the first response.extensionIdis reserved for future use —ExtensionMessageParticipant.extensionIdis in the type but cross-extension messaging isn't implemented.sendRequestto{ type: 'extension', extensionId }throws. UseHOST_EXTENSION(noextensionId) for the host extension.webviewIdchanges on every register — the id is generated fresh each timeregisterWebviewView/registerWebviewPanelis called. Don't persist it across sessions; capture the returned participant and use it for the lifetime of that view.- Don't mutate
paramsin handlers — incoming params are deserialized JSON and may be shared with diagnostic listeners. Treat them as immutable.
Debugging the message flow
The companion VS Code Messenger Developer Tool extension (typefox.vscode-messenger-devtools) visualizes live traffic — requests, responses, notifications, pending requests, registered handlers, and registered webviews — for any extension that exposes Messenger.diagnosticApi() from its activate return value. It is the fastest way to confirm whether a message is actually being sent, who received it, and what the payload looks like.
Minimum wiring — return the diagnostic API from activate:
import * as vscode from 'vscode';
import { Messenger, type MessengerDiagnostic } from 'vscode-messenger';
const messenger = new Messenger();
export function activate(context: vscode.ExtensionContext): MessengerDiagnostic {
// ... register views, handlers, etc.
return messenger.diagnosticApi();
}
To see actual params / result payloads in the devtool's event details (off by default to avoid leaking sensitive data), pass options:
return messenger.diagnosticApi({ withParameterData: true, withResponseData: true });
Open the devtool with Developer: Open vscode-messenger devtools from the Command Palette and select your extension from the list.
For deeper coverage — merging the diagnostic API into an existing public API, customizing events before they reach the devtool (for example, decorating method names with their parameters in the event timeline), the full MessengerEvent / ExtensionInfo schema, and a checklist for "the devtool shows nothing" — see references/devtools.md.
If references/devtools.md is not accessible, here is the essential "devtool shows nothing" checklist:
- Ensure
activate()returns the result ofmessenger.diagnosticApi(). - Verify the extension is listed in the devtool's extension dropdown — if not, the return value isn't being picked up.
- Confirm at least one webview is registered (
registerWebviewView/registerWebviewPanelwas called). - Check that the webview has called
messenger.start()and is visible (orignoreHiddenViewsisfalse).
Source: TypeFox/vscode-messenger — distributed by TomeVault.