evanw / esbuild

An extremely fast bundler for the web
https://esbuild.github.io/
MIT License
38.06k stars 1.14k forks source link

[Question] Inlining worker source with shared modules deduplicated #2141

Open anandthakker opened 2 years ago

anandthakker commented 2 years ago

I'm hoping to bundle a large API that uses workers into a single bundle, with the worker source inlined. But straightforward approach results in too much code duplication in our case. (There's about 1MB of unminified shared code between the main entrypoint and the worker entrypoint.)

I've almost got a solution using code splitting. Roughly:

  1. First build with entryPoints: ['main.js', 'worker.js'] and splitting: true to factor out the common dependencies into a shared chunk.
  2. Run a second build that consumes those three outputs and, using some pretty outlandish transforms on the shared and worker chunks, produces a single bundle that looks like:

    // ./shared-chunk.js
    var __sharedChunkSource = `
    /*  code from the shared chunk, with "export {...}" transformed into a return statement like the following */
    return {
     exported_thing     
    }`
    const { exported_thing } = new Function(__sharedModuleSource)();
    
    // ./worker.js
    const __workerModuleSource = `
    // replace the import statement in worker.js with:
    const __imports = (function (){${__sharedModuleSource}})();
    const { exported_thing } = __imports;
    /* actual code from worker.js */
    doWorkerStuff(exported_thing);
    `;
    function createWorker() {
    return new Worker(URL.createObjectURL(new Blob([__workerModuleSource], { type: "application/javascript" })));
    }
    
    // main.ts
    doMainThreadStuff(exported_thing);
    createWorker();
    })();

The problem is that, in practice, the exports from the shared module can end up looking like export { init_shared, exported_thing }, where init_shared is a function wrapping the shared module code for lazy execution, which only works when exported_thing is a live binding. Since my hacky transformation changes these exports into properties on a returned object and the import of them into const variables, it breaks (exported_thing remains undefined in the consuming scope).

I guess my questions are:

  1. Would you ever consider any sort of first-class support for this (admittedly bizarre [^1]) use case?
  2. If not (or until it lands), I wonder if there are any workarounds short of "fix your transformations so that they properly simulate live bindings". Another long shot, I know, but... any ideas?

[^1]: ... though maybe not that esoteric: we're doing this at Desmos for our calculator API, and we also did a very similar thing at my previous company for mapbox-gl-js.

anandthakker commented 2 years ago

Here's an example demonstrating the technique: https://github.com/desmosinc/esbuild-worker-dedupe/tree/194e2b9384d70fd3a4fcff39794dd06c472cfb3f

This works as-is, but to see the problem I described above, change the import in main.ts into a require (but leave the one in worker.ts alone).

anandthakker commented 2 years ago

Update: okay, I guess fixing the live-binding thing isn't terrible, mostly because I found escope to take care of analyzing variable scope, which made it straightforward to replace each import variable with __shared_modules_exports['importIdentifierName'] Updated the example repo here

But this leaves the second step basically as a manual/custom bundling step that's surely slower than esbuild would be, so I'd still be interested in whether esbuild might be able to support this somehow.