Menci / vite-plugin-wasm

Add WebAssembly ESM integration (aka. Webpack's `asyncWebAssembly`) to Vite and support `wasm-pack` generated modules.
MIT License
281 stars 16 forks source link

Asynchronous module loading via top-level `await` breaks SharedWorkers "connect" event #37

Open pvh opened 1 year ago

pvh commented 1 year ago

Chrome has support for a feature called SharedWorkers. This allows you to run a process in the background shared between multiple tabs with the same origin. It's essentially a small extension over a webworker. The key difference between the two is that SharedWorkers have a "connect" event which fires when a new tab creates a reference to the shared worker.

Unfortunately, this event ("connect") is sent after the SharedWorker's code is evaluated but before the async function's promise resolves. Fixing this upstream in Chrome is the right solution... but I suspect not really an option in the mid-term.

I've been trying to think of what a good workaround would be... Possibly in a SharedWorker the "connect" event could be intercepted and forwarded on after async evaluation is resolved. I'm not entirely sure.

I am at least sure of this: without a solution, you can't really use SharedWorkers with WASM. (Except through some pretty gnarly hacks.)

Menci commented 1 year ago

Could you provide a minimal reproduce? Let me see what can I do on my side...

pvh commented 1 year ago

Thanks for the offer. I saw the README recommending an approach for other webworkers but I don't see any example/test code in the repo. Do you have something in another repo I should use as a model, or should I just write something based on my own code?

pvh commented 1 year ago

I spent a little time pulling on the thread. I'd hoped removing the top-level-await plugin would make things better but it turns out that the browser runs the async code

However! All is not lost. The less-hacky but still hacky solution I found was to use dynamic imports to load the offending module inside an async method. This could probably be improved further but looks something like this:

https://github.com/automerge/automerge-repo/blob/main/examples/automerge-repo-demo-counter/src/shared-worker.ts

I confirmed the order of operations via log-lines here, though I've removed them now.

Menci commented 1 year ago

I just understand your problem. The top-level statements executes earlier than imported async modules' loading. So your event handler depending on imported async modules won't work.

I don't think it's a issue of my plugin. In the ESM top-level await specifications, the top-level statements is executed after synchronous sub-modules loading but before asynchronous sub-modules loading. Without my plugin in a standard browser environment with TLA support the result will be the same.

pvh commented 1 year ago

Let me see if we're understanding each other:

import("module-which-might-depend-on-wasm")
self.on("connect", () => { console.log("connected") })

This code will behave differently depending on whether some module in its dependency tree loads WASM, but if there is WASM it will fail to log for both cases of native-TLA / vite-plugin-top-level-await.

But if we consider this case:

self.on("connect", () => { console.log("connected") })
await import("module-which-uses-wasm")

I believe you're saying that a browser with TLA support will console.log() successfully, but vite-plugin-top-level-await will not log successfully. (Because it wraps the entire file in one large async function().)

Menci commented 9 months ago

I still don't know what you mean. Could you please attach an example project that my plugin and native TLA have different behavior?

I tested this:

// main.mjs
const sw = new SharedWorker(new URL("./worker.mjs", import.meta.url), { type: "module" });
sw.port.addEventListener("message", e => console.log(e));
sw.port.start();

// worker.mjs
self.addEventListener("connect", e => {
  console.log(e);
  const port = e.ports[0];
  setInterval(() => port.postMessage({ qwq: "qwq" }), 1000);
});

await new Promise(r => setTimeout(r, 10000));
console.log("resolved");

The compiled worker is:

(function () {
    'use strict';

    (async ()=>{
        self.addEventListener("connect", (e)=>{
            console.log(e);
            const port = e.ports[0];
            setInterval(()=>port.postMessage({
                    qwq: "qwq"
                }), 1000);
        });
        await new Promise((r)=>setTimeout(r, 10000));
        console.log("resolved");
    })();

})();

Its behavior is expected: "connect" event listener is attached immediately and message event is printed, before the long await resolves. 10 seconds after then, the long await resolves and it prints resolved.