Recently I was writing a VSCode extension that needed to render some web content through a Webview. As a front-end configuration engineer, I couldn’t bear web development without hot updates. After a bit of a struggle, we’ve finally implemented a hot update that seamlessly integrates VSCode Webview with webpack.

The development environment

Before getting into the subject, let’s introduce the project development environment for the demonstration. Vscode – webView -webpack-hmr-example

Technology stack

  • The VSCode extension itself is developed in typescript, compiled directly in TSC and not packaged with webpack. Currently, only one command is provided to open the test WebView, with the code stored in the SRC directory
  • The front-end code is stored in a Web directory and packaged with WebPack
  • Front-end framework: React
  • Front-end development language: typescript
  • The React component hot update mode is react-refresh
  • Configure webpack-dev-server: node API

Version information

  • VSCode: 1.66.0 – insider
  • OS: MacOS 12.3
  • Webpack: 5.70.0
  • Webpack dev – server: 4.7.4
  • React: 17.0.2
  • The react – refresh: 0.11.0
  • @ PMMMWH/react – refresh – webpack – plugin: 0.5.4

Demonstrate the start of the project

Up to first commit: Implementation loads webPack packaged content. WebView can load js bundles packed by Webpack. If you follow the project instructions to launch the project correctly and open the WebView, unsurprisingly you should see something like this:

How to load Webview content in VSCode extension:

// src/MyWebview.ts
private setupHtmlForWebview() {
        const webview = this.panel.webview;
        const localPort = 3000;
        const localServerUrl = `localhost:${localPort}`;
        const scriptRelativePath = 'webview.js';
        const scriptUri = `http://${localServerUrl}/${scriptRelativePath}`;
        const nonce = getNonce()

        this.html = ` <! DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, Initial scale = 1.0 "/ > < title >${MyWebview.viewType}</title>
    </head>
    <body>
        <div id="root"></div>
        <script nonce="${nonce}" src="${scriptUri}"></script>
    </body>
</html>`;
        webview.html = this.html;
    }
Copy the code

As you can see, we load the bundle directly by setting the script SCR to the js bundle address hosted by webpack-dev-server.

We use the Node API to configure webpack-dev-server:

// scripts/webpack.config.js
const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

/ * *@type {import('webpack').Configuration}* /
module.exports = {
  mode: 'development'.entry: [resolve(__dirname, '.. /web/index.tsx')].output: {
    path: resolve(__dirname, '.. /dist/web'),
    filename: 'webview.js',},resolve: {
    extensions: ['.js'.'.ts'.'.tsx'.'.json'],},module: {
    rules: [{test: /\.(js|ts|tsx)$/,
        loader: 'babel-loader'.options: { cacheDirectory: true },
        exclude: /node_modules/}, {test: [/\.bmp$/./\.gif$/./\.jpe?g$/./\.png$/./\.svg$/].type: 'asset'.parser: {
          dataUrlCondition: {
            maxSize: 10 * 1024,}},generator: {
          filename: 'images/[hash]-[name][ext][query]',},},],},devtool: 'eval-source-map'.plugins: [
    new HtmlWebpackPlugin({
      template: resolve(__dirname, '.. /web/index.html'),})]};Copy the code

The code to start webpack-dev-server:

// scripts/start.js
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');

const devConfig = require('./webpack.config');

function start() {
  const compiler = webpack(devConfig);
  const devServerOptions = {
    hot: false.client: false.liveReload: false.host: 'localhost'.port: 3000.open: false.devMiddleware: {
      stats: 'minimal',}};const server = new WebpackDevServer(devServerOptions, compiler);
  server.start();
}

start();
Copy the code

As of now, no hot update code has been added to the WebPack configuration. Let’s take a look at the various issues you might encounter integrating webPack hot updates in the current situation.

The WebSocket URL is invalid

React-refresh-webpack-plugin/react-refresh/react-refresh/react-refresh/react-refresh/react-refresh/react-refresh

Uncaught DOMException: Failed to construct ‘WebSocket’: The URL’s scheme must be either ‘ws’ or ‘wss’. ‘vscode-webview’ is not allowed

The hint already tells us that when new WebScoket(), the URL protocol must be ws or WSS, but you’re using vscode-webview. VSCode webView is implemented using iframe. The protocol is VSCode – webView:

WebSocket URL = webPack-dev-server; WebSocket URL = WebSocket URL = webpack-dev-server; Node_modules/webpack – dev – server/client/utils/createSocketURL. Js. In short, since we did not specify the webSocket link protocol manually, webpack-dev-server assumes that the URL protocol for new WebSocket(URL) is also vscode-webView, based on the current protocol vscode-webView. WebSocket objects are not allowed to accept only THE WS or WSS protocols.

When configuring webpack-dev-server using the Node API, you can configure various options for webpack-dev-server to create WebSocket clients in entry when integrating hot updates.

To solve this problem, we just need to manually specify that the protocol we are using to establish WebSocket links is WS.

const devServerClientOptions = {
  hot: true./ /! : specifies that the protocol for constructing websockets is WS
  protocol: 'ws'.hostname: 'localhost'.port: 3000.path: 'ws'};const devServerClientQuery = Object.entries(devServerClientOptions)
  .map(([k, v]) = > `${k}=${v}`)
  .join('&');
const devEntries = [
  'webpack/hot/dev-server.js'.`webpack-dev-server/client/index.js?${devServerClientQuery}`,];/ * *@type {import('webpack').Configuration}* /
module.exports = {
  mode: 'development'.entry: [...devEntries, resolve(__dirname, '.. /web/index.tsx')].output: {
    publicPath: 'http://localhost:3000/'.path: resolve(__dirname, '.. /dist/web'),
    filename: 'webview.js',},resolve: {
    extensions: ['.js'.'.ts'.'.tsx'.'.json'],}};Copy the code

In addition to the need to specify the protocol, including hostname, port need to be specified, otherwise there will be a variety of link errors.

Invalid origin host

The host in the origin request header of the WebSocket is invalid.

Open the Network panel and look at the request header sent by ws when we set up a link:

You can see that the origin request header value is: Vscode webview: / / 180 k16ne6bgriaem9878j8lt8el0qnj9uc9uodq31ah3fdgvvea8, The vscode-webview host is invalid for the default policy of webpack-dev-server. What is the purpose of webpack-dev-server’s allowedHosts security mechanism?

The solution is also simple, configure devServer’s allowedHosts option:

function start() {
  const compiler = webpack(devConfig);
  const devServerOptions = {
    hot: false.client: false.liveReload: false.host: 'localhost'.port: 3000.open: false.devMiddleware: {
      stats: 'minimal',},// Allow any host
    allowedHosts: 'all'}; } start();Copy the code

Cross-domain problem

So far, we can say that the client of webpack-dev-server in VSCode Webview has successfully established a link with the server:

But when we modify the web code, such as the Hello World text in the App component:

// web/App.tsx
import imgSrc from './xiaomai.gif';

export default function App() {
    return (
        <div className="app">
            <img
                src={imgSrc}
                style={{
                    display: 'block',
                    marginBottom: 20,
                }}
            />
            <button>Hello World</button>
        </div>
    );
}
Copy the code

The console can see the cross-domain error:

Solving the CORS problem is a small case for us front-end students:

// scripts/start.js
function start() {
  const compiler = webpack(devConfig);
  const devServerOptions = {
    // ...
    allowedHosts: 'all'.// Allow any domain name access
    headers: {
      'Access-Control-Allow-Origin': The '*',}}; }Copy the code

VSCode WebView Reload limit

So far, if you change the front-end code to not trigger page reload then everything looks fine:

Once Relaod is triggered, such as when we delete an import statement, WebPack defaults to the Relaod page when it cannot apply a hot update, causing the WebView content to go blank.

To do a simpler test, add the following code directly to index. TSX:

import ReactDOM from 'react-dom'; import App from './App'; ReactDOM.render(<App />, document.querySelector('#root')); SetTimeout (() => {console.log('ready to reload'); window.location.reload(); }, 3000);Copy the code

In fact, any time you call window.location.reload in VSCode’s Webview, the Webview will be blank. What the hell, writing front-end code will still trigger reload every now and then, even though there are hot updates.

To solve this problem, we need to do some dirty work.

Talk about Webpack and webpack-dev-server

Some students who are new to Webpack may not have a clear understanding of their respective responsibilities.

The Webpack package is positioned as a packer and provides a hot update interface for external plug-ins to implement specific hot update logic. A bundle can be packaged through Webpack.

Webpack-dev-server is positioned as a static server that uses an in-memory file system to host bundles packaged by WebPack. At the same time. It is also a WebSocket server that communicates webpack and bundle code.

When we visit a WEBpack-dev-server hosted SPA and modify the web code, relaod is sometimes triggered. Is this part of relaod related source code in Webpack or webpack-dev-server?

Since WebPack is responsible for providing the interface for hot updates, the source code injected into the bundle by WebPack triggers Relaod when hot updates cannot be applied.

Remember earlier when we configured hot updates we needed to configure additional entries?

const devEntries = [
  'webpack/hot/dev-server.js'.`webpack-dev-server/client/index.js?${devServerClientQuery}`,];Copy the code

Webpack /hot/dev-server.js:

/* MIT License http://www.opensource.org/licenses/mit-license.php Author Tobias Koppers @sokra */
/* globals __webpack_hash__ */
if (module.hot) {
  var lastHash;
  var upToDate = function upToDate() {
    return lastHash.indexOf(__webpack_hash__) >= 0;
  };
  var log = require('./log');
  var check = function check() {
    module.hot
      .check(true)
      .then(function (updatedModules) {
        if(! updatedModules) { log('warning'.'[HMR] Cannot find update. Need to do a full reload! ');
          log('warning'.'[HMR] (Probably because of restarting the webpack-dev-server)');
          window.location.reload();
          return;
        }

        if(! upToDate()) { check(); }require('./log-apply-result')(updatedModules, updatedModules);

        if (upToDate()) {
          log('info'.'[HMR] App is up to date.');
        }
      })
      .catch(function (err) {
        var status = module.hot.status();
        if (['abort'.'fail'].indexOf(status) >= 0) {
          log('warning'.'[HMR] Cannot apply update. Need to do a full reload! ');
          log('warning'.'[HMR] ' + log.formatError(err));
          window.location.reload();
        } else {
          log('warning'.'[HMR] Update failed: '+ log.formatError(err)); }}); };var hotEmitter = require('./emitter');
  hotEmitter.on('webpackHotUpdate'.function (currentHash) {
    lastHash = currentHash;
    if(! upToDate() &&module.hot.status() === 'idle') {
      log('info'.'[HMR] Checking for updates on the server... '); check(); }}); log('info'.'[HMR] Waiting for update signal from WDS... ');
} else {
  throw new Error('[HMR] Hot Module Replacement is disabled.');
}
Copy the code

You can see that window.location.reload() is called when hot update code is not found or hot update fails; .

SAO operation

To solve the problem that Webpack automatically relaod causes the page to be blank.

/hot/dev-server.js/webpack/hot/dev-server.js/window.location.reload(); Will do. Change the relative path of require to the path of webpack-dev-server.

// scripts/webpack.config.js
const webpackHotDevServer = resolvePath(__dirname, './webpack-hot-dev-server.js');
const devEntries = [
  // Replace with a modified file
  webpackHotDevServer,
  `webpack-dev-server/client/index.js?${devServerClientQuery}`,];Copy the code

But not without Relaod! Since we can’t reload ourselves, we can have VSCode reload.

Specifically, we can modify webpack/hot/dev-server.js to change the reload operation to extend communication to our VSCode and let it go to relaod Webview.

Change webpack/hot/dev-server.js to add the following code to make window.__reload__ the real reload available.

if (!window.__vscode__) {
  window.__vscode__ = acquireVsCodeApi();
  window.__reload__ = function () {
    console.log('post message to vscode to reload! ');
    window.__vscode__.postMessage({
      command: 'reload'.text: 'from web view'}); }; }Copy the code

Then replace the relaod code with our own implementation of window.__reload__ perfect integration webpack hot update!

Oh, and there’s a reload event to handle in the extension:

// src/MyWebView.ts
private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) {
        this.panel = panel;
        this.extensionUri = extensionUri;

        this.setupHtmlForWebview();

        this.panel.onDidDispose(() = > this.dispose(), null.this.disposables);

        // Handle messages from the webview
        this.panel.webview.onDidReceiveMessage(
            (message) = > {
                switch (message.command) {
                // Handle relaod implementation
                    case 'reload':
                    // The HTML content needs to be modified to relaod, so the script nonce is replaced with a random string each time
                        this.html = this.html.replace(/nonce="\w+?" /.`nonce="${getNonce()}"`);
                        this.panel.webview.html = this.html;
                        return; }},null.this.disposables,
        );
    }
Copy the code

The final result

conclusion

I debug a lot of code during the hot update of VSCode Webview and Webpack, and also read a lot of source code for Webpack and webpack-dev-servr. Can obviously feel and just entered the front end of the line is not the same, at that time debug is not quick, the source code is unable to start. In fact, reading source code is a technical work, I also looked at a lot of open source project source code to become now encounter problems to see the source code, debug analysis. When I first entered the line, I had a headache when I looked at the source code. I didn’t know how to start when I looked at the code I didn’t write.

This is my first public blog post in two years, and I will share my experiences and thoughts in my work and open source projects in the future. Currently, I also want to share some topics about ts gymnastics and VSCode. I feel that it is very difficult to write one article a week. I have to study and write open source projects when I have time. Recently, I delayed learning rust in order to write a VSCode extension.

Part of blogging is to give yourself time to think more comprehensively and clearly about the problems you encounter while blogging. In fact, there are a lot of things I can share after working for two years, but I didn’t record them. I worked on flutter for half a year before, but now I can’t write a code clearly in my mind when I think of writing a Hello world. It’s not that I can’t write, but IF I write a flutter project now, I may repeat many mistakes I made before.

Another reason I’ve become more interested in blogging lately is that I feel like I’ve learned so much from someone else’s blog that I even gave him $66 for his blog. And some of my previous blogs still receive a little thanks from time to time. It is also possible that they have been single for a long time and want to enhance their presence on the Internet through blogging and relieve their sense of emptiness through communication.

The full text.