visual-studiovisual-studio-codewebpackwebviewvscode-extensions

VS Code extension with webpack and link to node_modules


I created a VS Code extension which uses a Webview. In the Webview I have a link to a file in the node_modules folder which I added to the html as recommended by various sources (e.g. vscode-codicons-sample):

const codiconsUri = webview.asWebviewUri(
  Uri.joinPath(this.context.extensionUri, 'node_modules', '@vscode/codicons', 'dist', 'codicon.css')
);

and in the html:

<link href="${codiconsUri}" rel="stylesheet" />

Now I'd like to use webpack for bundling. I followed the instructions from Visual Studio Code here.

In the .vscodeignore file I exclude the node_modules folder as instructed. (That's the main reason for bundling)

And when I package the project now, of course, the node_modules is not present in the .vsix file and thus the Uri is invalid.

How would you solve that? (I use this method to insert many more links besides codicons)

Thanks in advance!


Solution

  • I decided to go with @MikeLischke's suggestion and copy the required files into the dist folder, which is packaged. But I didn't want to do it manually, so I did the following.

    NodeModulesAccessor

    Firstly, I created a class which does the mapping between the files in the node_modules folder and the destination in the packaged dist folder.

    import { NodeModulesKeys } from './nodeModulesKeys';
    import { NodeModulesValue } from './nodeModulesValue';
    
    export class NodeModulesAccessor {
      static readonly outputPath = 'dist';
    
      private static readonly pathMapping = new Map<NodeModulesKeys, NodeModulesValue>([
        [
          NodeModulesKeys.ffmpegMinJs,
          {
            sourcePath: ['node_modules', '@ffmpeg', 'ffmpeg', 'dist'],
            destinationPath: ['libs', '@ffmpeg', 'ffmpeg', 'dist'],
            fileName: 'ffmpeg.min.js',
          },
        ],
        [
          NodeModulesKeys.ffmpegCoreJs,
          {
            sourcePath: ['node_modules', '@ffmpeg', 'core', 'dist'],
            destinationPath: ['libs', '@ffmpeg', 'core', 'dist'],
            fileName: 'ffmpeg-core.js',
            includeFolder: true,
          },
        ],
        [
          NodeModulesKeys.codiconCss,
          {
            sourcePath: ['node_modules', '@vscode', 'codicons', 'dist'],
            destinationPath: ['libs', '@vscode', 'codicons', 'dist'],
            fileName: 'codicon.css',
            includeFolder: true,
          },
        ],
      ]);
    
      static getPathToOutputFile(key: NodeModulesKeys): string[] {
        const path = this.getMappedValue(key);
        return [this.outputPath, ...path.destinationPath, path.fileName];
      }
    
      static getPathToNodeModulesFile(key: NodeModulesKeys): NodeModulesValue {
        return this.getMappedValue(key);
      }
    
      private static getMappedValue(key: NodeModulesKeys): NodeModulesValue {
        const value = this.pathMapping.get(key);
        if (!value) {
          throw Error(`Path to "${key}" is not mapped.`);
        }
        return value;
      }
    }
    

    NodeModulesKeys is a simple enum of all the files I want to use:

    export enum NodeModulesKeys {
      ffmpegMinJs,
      ffmpegCoreJs,
      codiconCss,
    }
    

    and NodeModulesValue is an interface:

    export interface NodeModulesValue {
      sourcePath: string[];
      destinationPath: string[];
      fileName: string;
      includeFolder?: boolean;
    }
    

    Some libraries (e.g. codicons) require multiple files inside the folder. That is why NodeModulesValue has an optional field includeFolder.

    webpack.config.ts

    Here is where the magic happens (don't worry, it's not that complicated).

    You can use the CopyWebpackPlugin to copy files while bundling:

    import * as path from 'path';
    import { NodeModulesAccessor } from './src/node-modules-accessor/nodeModulesAccessor';
    import { NodeModulesKeys } from './src/node-modules-accessor/nodeModulesKeys';
    import { Configuration } from 'webpack';
    import CopyPlugin = require('copy-webpack-plugin');
    
    const config: Configuration = {
      // omitted, nothing special here
      plugins: [copyNodeModulesFiles()],
    };
    
    function copyNodeModulesFiles(): CopyPlugin {
      const files: NodeModulesKeys[] = Object.keys(NodeModulesKeys)
        .filter((key) => !isNaN(Number(key)))
        .map((key) => Number(key));
      const copies: CopyPlugin.ObjectPattern[] = files.map((file) => {
        const value = NodeModulesAccessor.getPathToNodeModulesFile(file);
        let sourcePath;
        let destinationPath;
        if (value.includeFolder) {
          sourcePath = path.join(...value.sourcePath);
          destinationPath = path.join(...value.destinationPath);
        } else {
          sourcePath = path.join(...value.sourcePath, value.fileName);
          destinationPath = path.join(...value.destinationPath, value.fileName);
        }
        return {
          from: sourcePath,
          to: destinationPath,
        };
      });
      return new CopyPlugin({
        patterns: copies,
      });
    }
    
    module.exports = config;
    

    Here we iterate over all the values of the NodeModulesKeys enum. (How to iterate over enum values) and add a copy instruction for each of them.

    We can also copy entire folders if we need to.

    Webview integration

    To get the Uris and use them in the webview html, we use the NodeModulesAccessor again.

    const codiconsUri = webview.asWebviewUri(
      Uri.joinPath(this.context.extensionUri, ...NodeModulesAccessor.getPathToOutputFile(NodeModulesKeys.codiconCss))
    );
    

    Important

    For the webview to be able to access the dist/libs directory, you have define the directory as a localResourceRoot when you create the webview:

    this.viewPanel = window.createWebviewPanel('sampleId', 'Sample Webview', ViewColumn.Beside, {
      enableScripts: true,
      retainContextWhenHidden: true,
      localResourceRoots: [
        Uri.joinPath(this.context.extensionUri, NodeModulesAccessor.outputPath, 'libs'), // <--- Important
        Uri.joinPath(this.context.extensionUri, 'media'),
      ],
    });
    

    I hope that will come in handy for someone else.