How to embed your WASM blob

Andrea Giammarchi
7 min readJun 10, 2024

--

If there’s a single thing I don’t like about WASM is its distribution channel that is hard to bundle, deliver offline or, in general, embed.

Background

The Emscripten project is likely the most deployed “PL to WASM to Web” (or NodeJS) solution out there, and it also exposes a locateFile helper to initialize WASM modules.

Without any need to blame this project though, the WASM standard itself prefers instantiateStreaming over static instantiate and the long story short here is that pretty much every WASM based project out there ended up needing to separate the core logic from its enabler: the WASM module!

It would be foolish to say that this solution to date didn’t work, but I am that kind of person that always think: “is this the best we can do?

Personal Goal

I want to be able to have a complete, single, JS file/module able to provide its own WASM related dependency to:

  • avoid the need to have any pointer to a specific CDN … true that import.meta.url can help in these cases but that’s also easily extra burden when the target WASM module mismatches the version of the WASM driver I am proposing to my users
  • the ability to actually bundle a whole project into a single file/module so that such file guarantees no network is needed, no mismatch of the expected WASM module can ever happen, you save a file at some version and you are sure that it will work as long as the WASM standard didn’t break everything that day you tried it again
  • have the ability to distribute in a single file more complex CLI related applications, which are pretty easy in the Linux world so that you have your #!/usr/bin/env node comment in top of your file and that’s portable with its own WASM related goodness, or modules

In order to do so, I started wondering what could be the best approach to have a single file that contains also my WASM related blob that fuels my WASM driver … how difficult could it be?

Well … a first awkward attempt …

I won’t “code shame” anyone in particular out there because I’m guilty myself of trying the same:

  • get the binary content of a file
  • convert that into a Uint8Array and embed that in the JS source code as:
new Uint8Array([0,1,2,3,4,5,6,7,8,9,...])

The goal was to recreate a view of a buffer I could then somehow pass to the WASM initializer (more on this later) without needing to point at external resources … and boy if this failed in terms of final JS file size!

  • the JS file itself gotta be plain text
  • each number in that huge array takes 1 up to its power of ten bytes
  • each coma takes an extra byte

The only “pro” of this approach is that the engine doesn’t need to do anything at runtime, it delegates to its parser the ability to understand and create that huge blob itself so that the engine just knows there is a huge typed array in there and nothing else needs to be done to understand its content.

The Benchmark (in terms of size)

I’ve used a pretty popular WASM module: sql.js

As of today, once built and distributed in its dist/sql-wasm.wasm file, it is 655300 bytes (~640K).

Once gzipped, that’s 321494 bytes (~50%) and only brotli, after a way slower compression operation, is able to bring it down to 277332 bytes.

Unfortunately though, brotli compression is not available as CompressionStream format, so we’ll stick with the results out of plain text and/or gzip compression.

Base64 as MUST have

We all know that base64 is the best URL safe, plain text safe, or encoding safe, format we have to represent any blob as ASCII compatible chars.

Keeping both plain and gzip as possible targets, base64 also adds its well known overhead so that we have:

  • 885233 bytes for the plain sql wasm source
  • 434301 bytes for the gzip version of the same source

Keeping this numbers in mind, here how we can embed our WASM blob out of the box.

… and boy if this failed in terms of final JS file size!

Because I’ve said that, and to whom it might concern, embedding the plain blob as Uint8Array produced a 1.8M file … and that was unbearable to me!

Solutions

So here we go, we want to add those extra bytes to our source code in a way that we can then use these to embed the WASM module we bundled before.

The sloppy solution that has been benchmarked to be the fastest, will not use any special standard except atob to return a view of the original blob and that’s how it goes: you create a base64 version of your blob via:

import { readFileSync } from 'node:fs';

const b64_blob = readFileSync(path).toString('base64');
// that's literally it in NodeJS

To unpack that, please see how I did it after various tests.

Plain Base64

If you feel like not wanting any extra overhead for a tiny decompression logic over your base64 plain blob, this is the fastest way to have it back as a view of the original buffer:

function view() {
const str = atob('{{YOUR_PLAIN_BASE64_BLOB}}');
const view = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++)
view[i] = str.charCodeAt(i);
return view;
}

Trust me when I say this is just about the fastest way to obtain that view, as code like this would take 8X more time:

function view() {
return Uint8Array.from(
atob('{{YOUR_PLAIN_BASE64_BLOB}}'),
c => c.charCodeAt(0)
);
}

Please also note any TextEncoder or TextDecoder would easily fail in the process, for reasons explained elsewhere such as:

Please note if you use TextEncoder or TextDecoder your encoded string might lose important data in the process.

However, if you find a way to make my next solution work without needing loops, please share, ’cause it’s never late to learn something new 😇

Compressed Base64

Apparently base64 is known to play very badly with compression but I couldn’t measure this claim, although compressing before producing a base64 version of that blob looks safe enough … and here how I do it:

import { encode } from 'buffer-to-base64';
import { readFileSync } from 'node:fs';

const b64_blob = await encode(readFileSync(path));

Above code would produce a base64 version of whatever path is passed along, or buffer, to then allow the other side to decompress it with the same ease:

import { decode } from 'buffer-to-base64';

async function view() {
return await decode('{{YOUR_ENCODED_BASE64_BLOB}}');
}

Yes, the difference is around sync VS async where the former method won’t need top-level await but it’s also true that WASM initialization is asynchronous anyway so that having just a tiny extra async function upfront didn’t bother me in general … you gotta wait for any WASM module to be usable anyway.

In case you haven’t guessed already, this is the module I am using to perform latter tasks 😉

Moreover, if you are worried about such module size, as dependency, here your encode and decode function in all their glory 🏆

Micro Benchmarks

If interested in raw numbers, on my machine the not compressed, yet 100% of the base64 size, took ~4ms to return the original view of the same buffer while the compressed version took ~5ms to produce the same result.

I can live with that extra ~1ms delegated to clients or consumers, as opposite of needing a bigger file to distribute, to fill my cloud storage space, and so on … but then again, you do you so if you don’t want to use a dependency, you’re good to go anyway 🥳

Me? Well I now have a way to have files smaller than 5MB that includes their own runtime or WASM ability in it, and I am sold already!

Serving the blob

Having the blob just within the file is cool and everything but it won’t work out of the box: we need to satisfy the streaming requirements imposed by WASM Web API.

async function initWASM(options) {
const url = URL.createObjectURL(
// use the previous `view` function to grab the buffer
new Blob([await view()], { type: 'application/wasm' })
);
// bootstrap your WASM module/API
const WASMModule = await initWASMThing({ locateFile: () => url });
// revoke the already streamed runtime URL
URL.revokeObjectURL(url);
// enjoy the ride 🍻
return WASMModule;
}

And that’s pretty much it and also what I’ve done to re-package the sql.js module into a fully standalone @webrefelction/sql.js package that works with ESM out of the box and doesn’t require you to care about CDN or mismatching WASM related modules 👋

Streaming while decoding

As latest update to the module, the stream exported utility is actually the fastest way to decode but it might be CSP sensible.

If you have control over the WebAssembly.initiateStreaming() utility, you can now pass directly a stream:

import { stream } from 'buffer-to-base64';

WebAssembly.initiateStream(
await stream(base64, { type: 'application/wasm' }),
{ imports: { ... } }
);

Options are type for the content type of the stream and format which is like every other utility: deflate, raw-deflate or gzip.

P.S. this journey has been brought to you by PyScript project and my personal investigation around the ability to eventually provide a single file that contains it all. We do “eat WASM runtimes for breakfast” 😁 but we’re still trying to find out a complete packaging story, so … follow us for further development around this topic 👍

--

--

Andrea Giammarchi
Andrea Giammarchi

Written by Andrea Giammarchi

Web, Mobile, IoT, and all JS things since 00's. Formerly JS engineer at @nokia, @facebook, @twitter.

No responses yet