2

WebAssembly.instantiateStreaming is the fastest way to download and instantiate a .wasm module however for large .wasm files it can still take a long time. Simply displaying a spinner does not provide enough user feedback in this case.

Is there a way to use the WebAssembly.instantiateStreaming api and get some form of progress event so that an eta can displayed to the user? Ideally I would like to be able to display a percentage progress bar / estimated time left indicator so user's know how long they will have to wait.

haikü
  • 116
  • 1
  • 5
  • Does this answer your question? [Fetch API Download Progress Indicator?](https://stackoverflow.com/questions/47285198/fetch-api-download-progress-indicator) – tevemadar Dec 29 '20 at 13:12
  • Just as the article suggests, you are probably waiting for the download, not for the compilation. – tevemadar Dec 29 '20 at 13:13
  • @tevemadar thanks that works, wrapping the Fetch response in a new response with a custom ReadableStream which implements it's own controller gives me what I need. Basing the total size on the content-length doesn't take into account compression but that should be easy to work around. – haikü Dec 29 '20 at 16:03

2 Answers2

3

Building off the answer here.

To get the progress of WebAssembly.instantiateStreaming / WebAssembly.compileStreaming create a new Fetch Response with a custom ReadableStream which implements it's own controller.

Example:

// Get your normal fetch response
var response = await fetch('https://www.example.com/example.wasm'); 

// Note - If you are compressing your .wasm file the Content-Length will be incorrect
// One workaround is to use a custom http header to manually specify the uncompressed size 
var contentLength = response.headers.get('Content-Length');

var total = parseInt(contentLength, 10);
var loaded = 0;

function progressHandler(bytesLoaded, totalBytes)
{
    // Do what you want with this info...
}

var res = new Response(new ReadableStream({
        async start(controller) {
            var reader = response.body.getReader();
            for (;;) {
                var {done, value} = await reader.read();

                if (done)
                {
                    progressHandler(total, total)
                    break
                }

                loaded += value.byteLength;
                progressHandler(loaded, total)
                controller.enqueue(value);
            }
            controller.close();
        },
    }, {
        "status" : response.status,
        "statusText" : response.statusText
    }));

// Make sure to copy the headers!
// Wasm is very picky with it's headers and it will fail to compile if they are not
// specified correctly.
for (var pair of response.headers.entries()) {
    res.headers.set(pair[0], pair[1]);
}

// The response (res) can now be passed to any of the streaming methods as normal
var promise = WebAssembly.instantiateStreaming(res)
haikü
  • 116
  • 1
  • 5
  • 1
    Note, the above does not appear to work in Firefox (works in Chrome): https://bugzilla.mozilla.org/show_bug.cgi?id=1684634 – haikü Jan 01 '21 at 13:54
  • Perhaps the bug is related to passing the "init" object to the wrong constructor (it goes with Response, but here it is given to ReadableStream) – Connor Clark Apr 16 '22 at 03:24
  • In Chrome (I did not test in other browsers) this pattern will _prevent_ the v8 wasm cache from working. See: https://bugs.chromium.org/p/chromium/issues/detail?id=719172#c102 – Connor Clark Jan 22 '23 at 22:25
2

Building off of various other SO answers, here is what I ended up with.

My solution also has decent fallback for Firefox, which doesn't yet have proper stream support. I opted for falling back to a good old XHR and WebAssembly.Instantiate there, as I really do want to show a loading bar, even if it means slightly slower startup just on FF.

  async function fetchWithProgress(path, progress) {
    const response = await fetch(path);
    // May be incorrect if compressed
    const contentLength = response.headers.get("Content-Length");
    const total = parseInt(contentLength, 10);

    let bytesLoaded = 0;
    const ts = new TransformStream({
      transform (chunk, ctrl) {
        bytesLoaded += chunk.byteLength;
        progress(bytesLoaded / total);
        ctrl.enqueue(chunk)
      }
    });

    return new Response(response.body.pipeThrough(ts), response);
  }

  async function initWasmWithProgress(wasmFile, importObject, progress) {
    if (typeof TransformStream === "function" && ReadableStream.prototype.pipeThrough) {
      let done = false;
      const response = await fetchWithProgress(wasmFile, function() {
        if (!done) {
          progress.apply(null, arguments);
        }
      });
      await WebAssembly.InstantiateStreaming(response, importObject);
      done = true;
      progress(1);
    } else {
      // xhr fallback, this is slower and doesn't use WebAssembly.InstantiateStreaming,
      // but it's only happening on Firefox, and we can probably live with the game
      // starting slightly slower there...
      const xhr = new XMLHttpRequest();
      await new Promise(function(resolve, reject) {
        xhr.open("GET", wasmFile);
        xhr.responseType = "arraybuffer";
        xhr.onload = resolve;
        xhr.onerror = reject;
        xhr.onprogress = e => progress(e.loaded / e.total);
        xhr.send();
      });

      await WebAssembly.Instantiate(xhr.response, importObject);
      progress(1);
    }
  }

  const wasmFile = "./wasm.wasm";
  await initWasmWithProgress(wasmFile, importObject, p => console.log(`progress: ${p*100}%`));
  console.log("Initialized wasm");
bobbaluba
  • 3,584
  • 2
  • 31
  • 45