Simplify Visual Studio Code extension webview communication

October 6, 2022

In Visual Studio Code extension webviews you probably need to get familiar with post-messaging communication. This post-messaging communication is used when you want to start a process, request information, etc.

For this communication flow to work, the extension and webview can send and listen to incoming messages. Although this is not a complicated flow to understand, it quickly feels like a disconnected communication flow, as you cannot simply wait for a response.

To understand this “disconnected” communication flow better, let us take the following example: Once I open the webview, I want to show all the project files. The extension needs to process the data when I click on a file.

The whole communication flow looks as follows:

Communication flow
Communication flow

The disconnected experience explained

In the above example, the disconnected experience is in steps 1 and 2. You send a message, and the extension will perform an action based on the message received and sends back a message with the result.

info Documentation - Passing messages from an extension to a webview

Comparing this to an API, you wait for its response if you call an API to receive data.

In the extension/webview communication, you send a message, but you won’t get a response. You listen to messages coming back, making it a disconnected experience.

Required listeners for the communication
Required listeners for the communication

Creating this flow in code, it would look as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. send message to the extension
vscode.postMessage({ command: 'GET_DATA' });

// 2. listen for response
panel.webview.onDidReceiveMessage(message => {
  if (message.command === 'GET_DATA') {
    // do something
  },
}, undefined, context.subscriptions
);

// 3. send message to the webview
panel.webview.postMessage({ command: 'POST_DATA' });

// 4. listen for response
window.addEventListener('message', event => {
  const message = event.data;
  
  if (message.command === 'POST_DATA') {
    // do something with the data
  }
});

This disconnection between messages sending from the webview to the extension and back made me wonder how to simplify it.

Simplify the communication flow

What I wanted to achieve was to have a similar experience as to calling APIs. You request data and wait for its response.

That is how I came up with the MessageHandler, which is nothing more than a wrapper around the post-message communication.

API like communication flow
API like communication flow

When requesting data with the message handler, it creates a callback function that returns a promise and identifies it with a requestId. The message sends this ID with the message command and payload to the extension listener.

See here a snippet of the class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public request<T>(message: string, payload?: any): Promise<T> {
  const requestId = v4();

  return new Promise((resolve, reject) => {
    MessageHandler.listeners[requestId] = (payload: T, error: any) => {
      if (error) {
        reject(error);
      } else {
        resolve(payload);
      }

      if (MessageHandler.listeners[requestId]) {
        delete MessageHandler.listeners[requestId];
      }
    };

    Messenger.sendWithReqId(message, requestId, payload);
  });
}

info The above code uses the @estruyf/vscode npm dependency, of which you can find the whole implementation here: MessageHandler.ts

In return, the message handler must wait until the extension sends a message with the same request ID.

When a message with the same request ID is received, the callback function gets executed, and the promise resolves (or gets rejected in case of an issue).

Using the message handler in the webview

With the message handler, sending or requesting data from the webview is straightforward.

1
2
3
4
5
messageHandler.send('<command id>', { msg: 'Hello from the webview' });

messageHandler.request<string>('<command id>').then((msg) => {
  setMessage(msg);
});

Changes on the extension level

On the extension level, you do not have to change much. All you need to do is add the requestId property with the ID that was passed with the message and post the new message.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
panel.webview.onDidReceiveMessage(message => {
  const { command, requestId, payload } = message;
  
  if (command === "<command id>") {
    // Do something with the payload

    // Send a response back to the webview
    panel.webview.postMessage({
      command,
      requestId, // The requestId is used to identify the response
      payload: `Hello from the extension!`
    } as MessageHandlerData<string>);
  }
}, undefined, context.subscriptions);

The requestId property is what the message handler uses as the identifier returning the response data.

1
2
3
4
5
6
7
Messenger.listen((message: MessageEvent<MessageHandlerData<any>>) => {
  const { requestId, payload, error } = message.data;

  if (requestId && MessageHandler.listeners[requestId]) {
    MessageHandler.listeners[requestId](payload, error);
  }
});

Sample project

In the Visual Studio Code Extension - React Webview Starter repository, you can find an example of how this whole message handling system works.

Comments