javascripttypescriptwebpackweb-workerworker-loader

How to create typescript library with webworkers using worker-loader


I try to create typescript library with web workers. When I test my code with webpack-dev-server everything looks good, all files are found, but when I make npm run build and try to use lib in another local project (npm install /local/path), I see GET http://localhost:8080/X.worker.js in browser console.

webpack.config.js:

const path = require('path');

module.exports = {
    devtool: 'inline-source-map',
    entry: {
        'mylib': './src/index.ts',
        'mylib.min': './src/index.ts',
    },
    output: {
        path: path.resolve(__dirname, '_bundles'),
        filename: '[name].js',
        libraryTarget: 'umd',
        library: 'mylib',
        umdNamedDefine: true
    },
    resolve: {
        extensions: ['.ts', '.tsx', '.js']
    },
    optimization: {
        minimize: true
    },
    module: {
        rules: [
            {
                test: /\.tsx?$/,
                loader: 'awesome-typescript-loader',
                exclude: /node_modules/,
                query: {
                    declaration: false,
                }
            },
            {
                test: /\.worker\.js$/,
                use: {
                    loader: "worker-loader"
                }
            },
        ]
    }
};

tsconfig.json

{
    "compilerOptions": {
        "target": "es5",
        "module": "es6",
        "lib": [
            "webworker",
            "es2015",
            "dom"
        ],
        "moduleResolution": "node",
        "sourceMap": true,
        "strict": true,
        "alwaysStrict": true,
        "outDir": "lib",
        "resolveJsonModule": true,
        "declaration": true,
        "skipLibCheck": true,
        "allowJs": true
    },
    "include": [
        "**/*.ts",
        "**/*.tsx"
    ],
    "exclude": [
        "node_modules",
        "lib",
    ]
}

package.json

{
  "name": "mylib",
  "version": "1.0.0",
  "description": "",
  "main": "_bundles/mylib.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "webpack": "webpack",
    "build": "rm -rf ./lib && tsc",
    "serve": "webpack-dev-server",
    "clean": "rm -rf _bundles lib lib-esm",
    "newbuild": "npm run clean && tsc && tsc -m es6 --outDir lib-esm && webpack"
  },
  "repository": {
    "type": "git",
    "url": "..."
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "..."
  },
  "homepage": "...e",
  "devDependencies": {
    "prettier": "^2.1.2",
    "tslint": "^6.1.3",
    "tslint-config-prettier": "^1.18.0",
    "worker-loader": "^3.0.5"
  },
  "dependencies": {
     ...
  }
}

Example on how I import workers:

import X from "worker-loader!./X";

Solution

  • I found myself in the exact same situation a few months back. I found a solution that worked for me, but first lets discuss why this is happening.

    The problem:

    There are 3 layers here.

    1. The development layer of your library
    2. The build layer of your library
    3. The application that consumes the build of your library

    Layer 1 is simple. In whatever file you want to create a new worker, say its index.ts, you do your import X from "worker-loader!./X". Your index.ts knows exactly where to find your worker file. That's why things work on your webpack-dev-server.

    Layer 2 is where things get weird. When you process the worker file with worker-loader, webpack outputs several files. Your config says filename: '[name].js', which would output a file for every file in your source folder, all on the same level in your _bundles folder. Webpack sees your import X from "worker-loader!./X", and it also sees your target name and location for the imported file, and the file doing the importing. It rewrites the location of the web worker file within the index.js output bundle to an absolute path relative to the rest of the bundle. You can control this more carefully by using the publicPath option in the worker-loader. But this doesn't really solve the issue, as you are only setting the publicPath as an absolute path, which leads us to step 3...

    Layer 3, where you try to consume your package, is where things go wrong. You could never anticipate where one might try to import { makeAWorker } from 'your-library' in their code. Regardless of where they import it, the build file (in the consumer app's node_modules) will be using the path that webpack wrote into the build of index.js to look for the worker file, but now the absolute path is relative to your consumer project (usually the home path, like where index.html lives), not to the node_modules folder of the build. So your consumer app has no idea where to find the worker file.

    My solution: a bit of a hack

    In my scenario, I decided that the content of my worker files was simple enough to create a worker from a string, and import it that way. For example, a worker file looked like this:

    // worker.ts
    
    // Build a worker from an anonymous function body
    export default URL.createObjectURL(
      new Blob([
        '(',
        function () {
    
          // actual worker content here
    
        }.toString(),
        ')()', ],
        { type: 'application/javascript' }
      )
    );
    

    Then in the place where I want to spawn the worker:

    // index.ts
    
    import workerScript from './worker';
    
    const myWorker = new Worker(workerScript, {worker_options});
    

    This works because now you are no longer asking webpack to create a file and write the correct import locations for you. In the end, you won't even have separate files in your bundle for your worker scripts. You can ditch worker-loader altogether. Your index.ts will create the Blob and its URL, and your consumer application will find the worker script at that URL which is dynamically generated at runtime.

    A hack indeed

    This method comes with some serious drawbacks, which led me to ask the question bundle web workers as integral part of npm package with single file webpack output. The issue is that inside your worker script, you really don't have the option to import anything. I was lucky in that my worker was relatively simple. It depended on a single node_module, which itself had no dependencies. I started out by simply including the source of that module in the script, but as I had multiple scripts that needed that same external module, I ended up passing it as a piece of data to the worker when it gets spawned:

    import { Rainbow } from './utils';
    
    var data = {
      id,
      data              
      RainbowAsString: Rainbow.toString(),
    };
    
    myWorker.postMessage(data);
    

    Then within the worker I simply convert RainbowAsString back to a function and use it. If you are curious to see more detail, you can check out the library I built with this method: leaflet-topography. Look into the src/TopoLayer.ts file to see how the workers are used, and the src//workers folder to see how I set up the worker blobs.

    Conclusion

    I think there must be a better way. One possible quick fix would be to write a copy script that would copy the worker files from node_modules/yourLibrary to the build folder of your consumer app. But this doesn't make for great portability, and other peple are going to have to do the same thing to get your library working with their app. My solution isn't perfect, but it works for simple-ish worker scripts. I am still thinking about a more robust solution that allows workers to do their own imports.

    Edit: A proper solution:

    So after writing this answer, I was inspired to join the conversation How can I use this to bundle a library? in the webpack-loader repo. Apparently, when webpack 5 was released about 2 months ago, they added support for this syntax:

    new Worker(new URL('./path/to/worker.js', import.meta.url))
    

    I have not tried this, but it looks like the solution you need (and the solution I needed 2 months ago when I was coming up with my hack). Try this out - it may be exactly what you need to tell webpack to bundle your library while stil maintaining the relationship between worker script and the script that imports it.