Recommendations when publishing a Wasm library

Interested in seeing a library employing recommendations that will be laid out in this article? Check out jomini. The highwayhasher library takes this one step further by juggling an implementation that uses native code too!

Don’t like reading a life story on recipes? Skip to the next section.

With Wasm support in nearly all JS environments, I’ve become a large proponent of leveraging Wasm when writing libraries for JS environments for two main reasons. The first and most important is the ability to reuse code. One does not need to create a port of a Rust library (or any other library in a language that can target Wasm) to JS, or restrict functionality to server-side. The second, and to a lesser extent, important, reason is that Wasm can have better performance. I emphasize “can”, as JS is plenty optimized by browsers (see: Surma’s article Is WebAssembly magic performance pixie dust?), so while Wasm may be faster especially if SIMD or threads are used, it shouldn’t be assumed.

The journey to distributing a JS library that wraps a Wasm core has been long in the making (in as far as Wasm’s lifetime is concerned). A little under two years ago, I talked about encoding Wasm as a base64 inlined string embedded inside a JS wrapper so that Wasm could be just an implementation detail to downstream users and developers, or so I claimed in Results of Authoring a JS Library with Rust and Wasm.

I harped upon this base64 inline idea in subsequent articles, like in Authoring a SIMD enhanced Wasm library with Rust

There’s no need for downstream users to figure out how to fetch the Wasm code at runtime, nor worry about if the CDN can properly cache it due to the relatively esoteric .wasm extension

And I felt the need to mention it once again in Leveraging Rust to Bundle Node Native Modules and Wasm into an Isomorphic NPM Package

I always inline Wasm in browser libraries so that it doesn’t require a fetch at runtime, causing the use of Wasm to nearly become an implementation detail.

Not to spoil the current article you’re reading, but Wasm in JS is a little more complicated, so I wanted to take the time to shed light on difficulties with Wasm.

Initialization

The first hurdle for developers using a library that references Wasm will be just trying to load the library properly. Wasm is not Javascript. Wasm must be compiled for it to be useful. For the best developer experience, I recommend providing the main bundle with Wasm inlined as a base64 encoded string, while also providing an API where developers can customize this initialization for their environment (and not need to pay any cost of an inlined payload). So let’s see how we can accomplish this goal.

inlined base64 Wasm

The main entrypoint of a library should inline the Wasm payload as a base64 encoded string. Inlining the Wasm allows one to produce a single file, and for easier integration for developers using the library, as a self contained distribution means developers don’t need to figure out how to configure their bundler (or even use a bundler at all) to ingest the Wasm. One can just insert a <script> tag in their HTML referencing the library on unpkg or jsdelivr and forget about it:

<body>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/index.min.js"></script>
  <script>
    jomini.Jomini.initialize().then((parser) => {
      const out = parser.parseText("foo=bar");
      alert(`the value of foo is ${out.foo}`);
    });
  </script>
</body>

Do not underestimate this use case (hence why I am talking about this first) – it’s immensely powerful to beginners who may be unfamiliar with the ecosystem and don’t particularly know or care about Wasm. They may just copy and paste the distributed library off a CDN into their project and then reference it locally. If it works, great. If not, they may seek alternative solutions or worse, become dismayed and give up on their project.

I sometimes need to be reminded that people tinkering around with JS aren’t full time web developers with several years worth of experience. Still, I can sympathize with their plight. I know I try to avoid bundler configurations too, as I find nothing more opaque and brittle. Unfortunately, in preparation of this post I had to fully submerge myself into the bundler world.

The best way I know how to accomplish this base64 inlining is by using the Wasm rollup plugin:

import { wasm } from "@rollup/plugin-wasm";

export default {
  /* snip */
  plugins: [
    wasm({ maxFileSize: 10000000 }),
  ]
};

The above rollup configuration instructs the Wasm plugin to (essentially) always inline the Wasm as base64.

There’s some deficiencies with the above config, which we’ll circle back to later in the article. But we’ll be keeping with rollup as I haven’t found a JS bundler dedicated to producing libraries that surpasses Rollup.

Allow initialization customization

The base64 approach won’t be for everyone, as sometimes flexibility is desired or even necessary and one will need to provide an API to customize how Wasm is instantiated. It could be a parameter that signifies the URL of where the Wasm is hosted or it could be an already initialized WebAssembly.Module. A good portion of Wasm libraries I’ve seen tend to assume control of loading and compiling Wasm, so allowing the user to customize initialization will open the library to additional use cases.

Let’s unpack that last paragraph.

Base64 costs

Instantiating Wasm from a base64 encoded string is typically inexpensive, but it’s not free, so developers should have the option of not paying the base64 tax.

One cost is file size. I measured the file size cost of base64 before and after compression using a Wasm project of mine.

  • myfile.wasm: 23 KB
  • myfile.wasm (base64 encoded): 31 KB (a 33.3% increase)
  • myfile.wasm (brotli): 8.3 KB
  • myfile.wasm (base64 + brotli): 13 KB (a 47.6% increase)

It’s the same story if we swap brotli compression with gzip.

A few KB in difference doesn’t seem like much but on another project this difference balloons to several hundred KBs with nearly an 80% increase in file size after compression. This could mean precious user time and bandwidth is being taken for granted. Or it could cause the script to exceed the max size allowed in an environment, like how Cloudflare Workers restrict script size to 1 MB after compression.

But it’s not just added network costs, but also compute costs. If we inspect how rollup plugin initializes the Wasm from the base64 encoded string, we’ll see:

var src = "aGVsbG8K"; // (base64 wasm payload)
var raw = globalThis.atob(src);
var rawLength = raw.length;
buf = new Uint8Array(new ArrayBuffer(rawLength));
for(var i = 0; i < rawLength; i++) {
    buf[i] = raw.charCodeAt(i);
}

The above code shows that we’re allocating strings larger than if the Wasm was transmitted in binary form, and we’re making two passes over the payload. At least we can use the browser optimized atob.

Continuing with our example of a 31 KB payload, how fast does it take to decode base64 and recreate the binary payload:

  • Fast desktop: 0.2 ms
  • Slower laptop: 1 ms
  • Slower phone: 1.5 ms

These are great numbers and should reassure developers that the above decoding snippet can be cheap. However on large base64 payloads (think 2 MB), it will take hundreds of milliseconds to decode them on older hardware.

URL fetching

In addition to initializing the library transparently with a base64 inlined payload, we should allow developers to pass in a URL of where the Wasm is stored. Our library should then load the Wasm efficiently and resume initialization. This use case is already covered for Rust developers who ask wasm-bindgen (or wasm-pack) to output “web” targeted Wasm, as the emitted helper module can load the Wasm from a URL.

In the simplest case, it can look like:

<script type="module">
  import { Jomini } from 'https://cdn.jsdelivr.net/npm/[email protected]/dist/es/index.min.js';

  const wasmUrl = "https://cdn.jsdelivr.net/npm/[email protected]/dist/jomini.wasm";
  Jomini.initialize({ wasm: wasmUrl })
    .then((parser) => {
      const out = parser.parseText('foo=bar');
      alert(`the value of foo is ${out.foo}`);
    });
</script>

Several notes from the above example:

  • It uses script tag modules as browser support for them is about the same as Wasm
  • Since we’re using modules, we can save a few bytes by using the module entrypoint. For the uninitiated, it’s the dist/es in the URL. We’ll talk more about what this means in the entrypoints section.
  • We no longer need to spend cycles on base64 decoding the Wasm every time the site is refreshed
  • At least for Chrome, using streaming API calls behind the scenes to compile the Wasm (instead of decoding base64) allows the compiled Wasm to be cached for subsequent requests.
  • Despite fetching the Wasm directly, the default module entrypoint still contains the base64 encoded Wasm – it just goes unused. Don’t worry we’ll get to a solution to avoid wasting this bandwidth.

A good portion of the library users probably won’t use the CDN version of the library and prefer to reference it within their own application and write code like:

import { Jomini } from "jomini";
import wasm from "jomini/jomini.wasm";
Jomini.initialize({ wasm })
  .then((parser) => {
    const out = parser.parseText('foo=bar');
    alert(`the value of foo is ${out.foo}`);
  });

Importing from a Wasm file may catch some developers off guard. Or, could be like in a recent interview, where the candidate was coding a solution and assumed importing from different file types was a baked in JS feature, which seems like an ok viewpoint as long as the shield that metaframeworks create between us and bundler configuration holds.

But metaframeworks don’t tend to make Wasm seamless so there’s some work to do.

First, our typescript friends will want to declare the following types to satisfy the type checker.

declare module "*.wasm" {
  const content: string;
  export default content;
}

Then, the hardest part for developers will probably be configuring their bundler to copy the Wasm to the build directory with a digested name and replace the import with the unique path.

In an effort to not let this article get too long, and to avoid pasting opaque bundler configurations, here is how you’d accomplish the above:

Optimizing Wasm initialization is detrimental to developers in favor of user experience. In the end this burden is very much worth it.

External compilation

In addition to accepting a URL for Wasm, one should also accept a WebAssembly.Module (Wasm code that has already been compiled) in order to be eligible for Cloudflare Workers, which allows the execution and validation of Wasm, but not compilation.

To phrase it one more time: if one wants to allow their library to run on Cloudflare workers, one must create an API where one can pass a WebAssembly.Module into the initialization function.

Rust users are in luck again, as the helper module emitted by wasm-bindgen and wasm-pack through the “web” target can be initialized from a WebAssembly.Module.

Our Cloudflare Worker code will look almost identical to the URL snippet:

import { Jomini } from "jomini";
import wasm from "jomini/jomini.wasm";

const fetch = async (req) => {
  const jomini = await Jomini.initialize({ wasm });

  const data = new Uint8Array(await req.arrayBuffer());
  const out = jomini.parseText(data, {}, (q) => q.json());
  /* ... */
}

export default { fetch };

Like in a URL initialization, our Typescript friends will want to declare the module export to be a different type:

declare module "*.wasm" {
  const content: WebAssembly.Module;
  export default content;
}

Then we communicate to Cloudflare via a wrangler upload rule that our Wasm should be compiled:

[[build.upload.rules]]
globs = ["**/*.wasm"]
type  = "CompiledWasm"

Last but not least, before the code is uploaded to Cloudflare, it needs to be packaged for deployment, which for Cloudflare workers entails:

  1. Copy all JS and all JS in dependencies (recursively) to an output directory
  2. Copying all referenced Wasm files to an output directory and rewriting imports so they are relative.

The first task is easy and most bundlers like webpack and esbuild can accomplish this natively by outputting a single file with everything in it.

It’s the second task that proves tricky as we basically need to communicate to the bundler to turn:

import wasm from "jomini/jomini.wasm";

into

import wasm from "./jomini/jomini.wasm";

Spot the difference? It’s a relative import path as we need to also copy the Wasm file to the output directory.

This seems like such a simple task, but I have failed to find it built into a bundler or a plugin.

Realizing I’d need to write my own bundler plugin, I came up with the following esbuild plugin. I believe it came out good enough that I think it can be used generically when targeting other environments that need Wasm imports preserved.

const passThroughWasmPlugin = {
  name: "pass-through-wasm",
  setup(build) {
    const path = require("path");
    const fs = require("fs/promises");
    const opts = build.initialOptions;
    const outdir = opts.outfile ? path.dirname(opts.outfile) : opts.outdir;

    // Whenever esbuild encounters a `import x from "abc/foo.wasm"`
    build.onResolve({ filter: /\.wasm$/ }, async (args) => {
      // Copy "abc/foo.wasm" into the output directory
      const pathDir = path.dirname(args.path);
      await fs.mkdir(path.resolve(outdir, pathDir), { recursive: true });
      await fs.copyFile(
        require.resolve(args.path),
        path.join(outdir, args.path)
      );

      // Mark the path as external so that esbuild preserves the import
      return { path: args.path, external: true };
    });
  },
};

I wrote the plugin with esbuild as it seemed like the shortest amount of code. I don’t expect readers to understand it, rather I want to keep demonstrating the issues I’ve come across trying to propagate libraries that use Wasm.

Slim Entrypoint

There’s a major problem with the URL and external compilation initialization methods that I only briefly touched upon earlier. Despite not using the inlined base64 Wasm in any way shape or form, the default module entrypoint still contains it.

This deficiency can incur heavy bandwidth costs or, in the case of Cloudflare workers, cause failure due to exceeding the max script size.

The solution I’ve found is to use package exports to allow downstream developers to decide whether they want the base64 payload included or omitted whenever the dependency is resolved, which for web apps tends to be at bundle time.

So those who care about trimming the bloat, will source the library from a /slim entrypoint and look like:

import { Jomini } from "jomini/slim";
import wasm from "jomini/jomini.wasm";
Jomini.initialize({ wasm })
  .then((parser) => {
    const out = parser.parseText('foo=bar');
    alert(`the value of foo is ${out.foo}`);
  });

The /slim entrypoint is the same exact library sans inline Wasm payload.

The package exports for the library in question looks like:

{
  "main": "./dist/standard/umd/index.js",
  "exports": {
    ".": {
      "node": "./dist/node/cjs/index.cjs",
      "import": "./dist/standard/es/index.js",
      "default": "./dist/standard/cjs/index.cjs"
    },
    "./slim": {
      "node": "./dist/node/cjs/index.cjs",
      "import": "./dist/slim/es/index_slim.js",
      "default": "./dist/slim/cjs/index_slim.cjs"
    },
    "./jomini.wasm": "./dist/jomini.wasm",
    "./package.json": "./package.json"
  },
}

I’ll try and breakdown the configuration as succinctly as possible:

  • Package exports allow for cleaner imports so that we can write:
    import wasm from "jomini/jomini.wasm";
    
    Instead of
    import wasm from "jomini/dist/jomini.wasm";
    
  • Different entrypoints are used for the main import. For instance, we produce a build of the library for node users that is shared between the standard and slim entrypoints that omits inlined Wasm as we can just read the Wasm file from the file system.
  • For compatibility, a UMD file is exposed as the default main entrypoint for clients that don’t understand package exports. It includes the inlined base64 Wasm
  • A import "jomini" will not resolve to the same code as require("jomini"). This can run us afoul of the dual package hazard if both resolution styles are used interchangeably, however trying to publish only a CJS distribution with property exports made it inordinately difficult for a downstream webpack to tree shake dead code, so I continue to live with the dual package hazard.
  • Since we are exposing the same library API in both the standard and slim entrypoints we don’t need to declare package export "types" fields for each one.

Phew. We haven’t even gotten to how to generate the entrypoints!

What I’ve found works best is to have two index files: standard and slim. They both are exactly the same except the standard index file references the desired Wasm file:

import jominiWasm from "./pkg/jomini_js_bg.wasm";
import { setWasmInit } from "./jomini";
setWasmInit(() => jominiWasm());

For this to work, the Wasm file reference in the standard index file should be the only one in the library.

The setWasmInit function merely sets an internal variable that is used for initialization if defined.

We then feed both index files back to rollup so it can spit out all the entrypoints.

const rolls = (fmt, env) => ({
  input: env !== "slim" ? "src/index.ts" : "src/index_slim.ts",
  output: {
    dir: `dist/${env}/${fmt}`,
    format: fmt,
  },
  /* We'll get to the plugins later */
  plugins: [],
});

export default [
  rolls("umd", "standard"),
  rolls("es", "standard"),
  rolls("cjs", "standard"),
  rolls("cjs", "node"),
  rolls("es", "slim"),
  rolls("cjs", "slim"),
];

Ok that’s most of the hard stuff behind us.

Readers who have been paying close attention may point out a slight discrepancy in the API between the standard and slim entrypoints: what happens when the slim entrypoint isn’t provided with an initialization parameter like the URL to the Wasm or an already compiled Wasm module? In order to provide the same API, what should it fallback to? A decent option is to code the use of a CDN like jsdelivr into the library to reference the Wasm file pinned to the version like:

const PKG_V = __jomini__build__version__;
const CDN_URL =
  `https://cdn.jsdelivr.net/npm/jomini@${PKG_V}/dist/jomini.wasm`;

Then one could use rollup’s plugin replace to substitute in the correct value at build time.

Consolidating Entrypoints

I know that it’s desirable to consolidate the main and slim entrypoints into one, as having one entrypoint without any downsides is the ultimate goal. I attempted to achieve this goal by dynamically importing the base64 encoded Wasm if no other option is provided.

Something like:

  const loadModule =
    options?.wasm ?? (await import("./pkg/jomini_js_bg.wasm"));
  initialized = init(loadModule).then(() => void 0);

In order for the above to build correctly, we need to inline dynamic imports on the UMD build for users who may need a single file bundle. So we tweak the rollup config:

export {
  output: {
    format: fmt,
    inlineDynamicImports: fmt === "umd",
    name: "jomini",
  }
  // ...
}

This works. Our UMD build will be a single file while the js containing the base64 Wasm will be split off into a new file for our modular build.

But there’s a finicky issue for developers that are bundling the library in a larger application: the base64 Wasm may still be included in the deployment even if never used. Dynamic imports are tough to statically analyze. Bundlers are unable to determine if the import is actually used, so they include it. Even an unused inclusion could have a bandwidth cost or, as mentioned earlier, cause a script to exceed a max size.

Until dynamic imports become trivial enough for bundlers to treeshake conditional imports, which doesn’t seem to be coming anytime soon, having a dedicated slim entrypoint is required.

We could alternatively consolidate the entrypoints if we necessitated bundler configuration for loading Wasm for developers that would import our library. However, I want Wasm libraries to remain accessible, so we can’t demand bundler configuration.

But perhaps most frustratingly was the attempt to simply expose the standard and slim endpoints as just two different classes in the same entrypoint. The standard class would import the Wasm while the slim wouldn’t and the thought was that downstream bundlers would be able to observe when the standard class wasn’t being imported and eliminate the inlined base64 Wasm. I am not sure why, but the resulting behavior didn’t match the prior thought. It could have been due to the specific bundler (esbuild in this case) or how the library is bundled or if it had something to do with helper code emitted by the rollup Wasm plugin and wasm-bindgen. All I know is that the promised tree shaking never occurred, so I sleep better at night knowing that when one references the /slim entrypoint it is guaranteed that resulting output won’t contain the inlined base64 Wasm.

Pitfalls

The road to publishing a Wasm library that is both easy to maintain and easy to use has not been easy.

Typescript package export

Typescript does not currently support package exports, but will soon in Typescript 4.7. It’s been a long standing issue, so I’m excited for the release of 4.7, which is slated for May 24th.

Until then, Typescript will complain about our slim entrypoint.

A workaround that I’ve found sufficient is for downstream projects to essentially redirect type queries from the to the main entrypoint:

declare module "jomini/slim" {
  export const Jomini: typeof import("jomini").Jomini;
}

If Typescript wasn’t planning on supporting package exports any time soon, I would distribute the type redirection within the main npm package.

Jest package export

I tend to default to Jest for writing unit tests, not because I like it, but more so due to its sustained popularity throughout the years.

Then once I discovered that Jest 27 doesn’t resolve package exports correctly and Jest 28 (alpha at the time) failed to resolve the package at all, I decided to ditch jest once I found that vitest was a drop in replacement.

Though my exposure to vitest has been limited, I’m excited at the possibility that I can write tests in Typescript with minimal dependencies and configuration.

Wasm rollup plugin

Update: April 29th: The PR fixing this issue in Rollup’s Wasm plugin has been merged. Skip to next section if not interested.

The Wasm plugin for rollup has a serious flaw. It will emit code that requires builtin Node.js modules:

var fs = require("fs") 
var path = require("path") 

These statements will cause downstream bundlers to fail to package a browser application as they won’t be able to tell if the modules are unused and thus can be eliminated. As we continue to see in this article, bundlers lack sufficient flow analysis to eliminate dead code.

Furthermore, the fs and path modules were included by the rollup plugin to allow downstream developers to pass an argument to the Wasm initialization in order to read Wasm from disk or fetch it, which is a use case that is rendered unnecessary when the library is structured such that it is known at build time if either the Wasm is inlined or initialized externally using the slim package entrypoint.

So I created a PR for the plugin that will allow the following situations:

  • When using the slim package, we don’t have to worry about the Wasm at all as the downstream developer will be handling that.
  • If we’re targeting a node environment, copy the Wasm to a separate file and omit browser specific code (like fetch) from the Wasm helper module.
  • Else we’re inlining the Wasm as base64, so decode it using environment specific code (which doesn’t use Node.js builtin modules). I called this method auto-inline, as it AUTOmatically decodes INLINE Wasm based on if we’re in a node environment or not (since it should still be possible to run the UMD or any other importable build with Wasm inlined in node).

The PR remains unmerged, which is ok as rollup plugin maintainers don’t owe anyone anything, so I forked the project and released the enhanced version:

import { wasm } from "@nickbabcock/plugin-wasm";
export {
  plugins: [
    env != "slim" &&
      wasm(
        env == "node"
          ? { maxFileSize: 0, targetEnv: "node" }
          : { targetEnv: "auto-inline" }
      ),
  ]
  // ...
}

Ideally this configuration will be upstreamed into the proper plugin, but until then I’ll rely on the forked project.

Entrypoint coloring

Having multiple entrypoints and ways for initialization comes with some drawbacks that can be demonstrated by a bit of contrived code:

import { Jomini } from "jomini/slim";
import { Jomini as Jomini2 } from "jomini";
const { Jomini: Jomini3 } = require("jomini");
const { Jomini: Jomini4 } = require("jomini/slim");

The problem with the above is that all four versions of the library will be bundled together in their entirety! So the base64 inline Wasm is included twice.

Ideally, one shouldn’t be drawn to writing multiple imports of the same library in different styles, but one could imagine this situation arising somewhere from a deep dependency chain.

Speaking of dependency chains, how is an enterprising developer supposed to wrap the Wasm library into a library of their own? The answer is they would expose a standard and slim version of their library and copy all the bundler configurations necessary to make it happen. This pattern would continue ad nauseam until the application developer decides the best way to handle the initialization.

In effect, usage of Wasm in a library is infectious as it forces all intermediate packages to also have standard and slim entrypoints. I find it analogous to a function’s color as the choice to use Wasm has ripple effects. This is what is meant when I titled this section entrypoint coloring, and is definitely a less than savory aspect of developing Wasm libraries.

While it is commendable all the strides we are taking to make the library accessible to those with bundler inexperience creating applications, it can sometimes be hard to stomach the corresponding increase in difficulty creating wrapper libraries. Still, I find this tradeoff the better of the two options.

Removing import.meta.url from the distribution

This section is mainly for Rust developers that use wasm-bindgen and wasm-pack configured with a “web” target, as the helper module emitted contains this snippet of code:

async function init(input) {
  if (typeof input === 'undefined') {
    input = new URL('jomini_js_bg.wasm', import.meta.url);
  }

  // ...
}

The conditional is saying that if we’re not initializing the Wasm with any input, to fetch a Wasm payload at a URL that is relative to the current file.

We don’t need this conditional as we will always be providing an input parameter, either it’ll be the inlined base64 Wasm, a URL for the Wasm, or an already compiled Wasm module.

Despite the conditional never being taken, it will cause webpack 4 to fail as it doesn’t understand import.meta.url. Even though webpack 5 is the latest version, webpack 4 is still seeing over 2x the number of weekly downloads as version 5, at the time of writing. Another bundler hiccup.

The solution is satisfyingly short. Append a rollup plugin to resolve import.meta.url to the empty string:

export default {
  plugins: [{
    name: "resolve-to-empty-string",
    resolveImportMeta: () => `""`,
  }]
}

A little heavy handed and definitely opaque, but this is the shortest way to resolve the issue so that webpack 4 users can import our library.

Avoid deceptive async methods

Do not create methods with an async signature that contain a significant amount of blocking code.

This recommendation is not unique to Wasm, but Wasm often exacerbates the problem, as Wasm should initialize asynchronously and many use cases for Wasm are computationally intense.

This problem can be seen in the hash-wasm package:

import { sha1 } from 'hash-wasm';

async function run() {
  const result = await sha1('demo');
  console.log('sha1:', result);
}

The await sha1 is deceptive as the actual computation of SHA1 is performed on the main thread and is blocking. Only the Wasm initialization step is asynchronous. So computing the result of a large payload can significantly impact user experience.

A better idea is to make the API more intuitive as to its behavior, which hash-wasm nicely demonstrates for us:

import { createSHA1 } from 'hash-wasm';

async function run() {
  const sha1 = await createSHA1();
  sha1.init();
  sha1.update('demo');
  const hash = sha1.digest('hex');
  console.log('sha1:', hash);
}

The above is more verbose but significantly more clear as to what is happening and is what I recommend for API design for Wasm libraries. Avoid deceptive shorthand methods in an attempt to seem more ergonomic and save a couple lines of code.

And for those who are curious, hash-wasm is only distributed with the Wasm inlined as base64.

Off main thread computation

There is an alternative, an alternative that could allow for the best of both worlds: a library that exposes an async signature and computes everything off the main thread while keeping actual computation synchronous. I’m talking about the library internally using Web workers, where one sets up a class on the main thread that communicates with the worker.

I’m not going to go too in-depth on this solution as it gets complicated quickly. For instance, one would have to use worker_threads on node js instead of web workers, and it could be desirable to use N-API to schedule tasks for native code when node-addons is detected. And one would still need to expose a synchronous web solution to work in environments that lack Web workers and aren’t node js like Cloudflare workers.

I’ll save it for another post once I figure out the sweet spot.

In the meantime, see PapaParse for an interesting take on exposing both synchronous and asynchronous API using one function by making the return type dependent on a configuration parameter. It doesn’t take node js worker_threads into consideration, but hey we can fix that, as that’s what package exports are for, am I right?

Conclusion

Crazy to think that I’ve written this much about authoring a Wasm library and there are still TODOs left, but we’ve covered an immense amount of ground in this article and it is time to review what was covered.

One should not omit the mention of Wasm from a project’s description in an attempt to paper over its use as an implementation detail. No matter how it is distributed, it may not behave exactly like a JS library and developers have a right to be aware of its presence. No need to include wasm in the library’s name, just maybe describe the use of Wasm in the readme.

The standard distribution of the library should contain the Wasm as a base64 inlined string to make the library more accessible to developers who may be unfamiliar with Wasm and bundlers, and just want the library’s functionality without worrying about the inner workings. Two exceptions to inlining the Wasm is for builds for node js where the Wasm file can be read from the filesystem and for extraordinarily large libraries like sql.js and ffmpeg.wasm that are the centerpiece of an application.

The slim distribution of the library should omit Wasm and expect it to be user supplied, or worst case, fallback to a globally available CDN that contains the Wasm pinned to the same version. The slim distribution is communicated through package exports. There’s some rough edges with package exports with Typescript, Jest, and its viral existence.

The Wasm file needs to be included in the package exports.

I needed to fork rollup’s wasm plugin in order remove references to node js builtin modules that would cause heartache for downstream bundlers.

The library should accept Wasm initialization customization via at least two mechanisms: a URL and an already compiled Wasm module. If a downstream developer opts for one of these customization methods, they will most likely need custom bundler and Typescript configuration. Also Wasm libraries originating from Rust wasm-bindgen users that output “web” targeted code will get this customization included in the output, but must remove the mention of import.meta.url to be webpack 4 compatible.

A custom bundler plugin is needed to preserve but rewrite ES imports into relative paths pointing to referenced Wasm files in order to deploy to Cloudflare Workers.

Avoid consolidating initialization and compute into a single asynchronous function, as users may think the compute portion is non-blocking based on the async signature.

That’s about it! My hope is that one day, library bundlers like microbundle, tsup, and unbuild tackle this problem of appropriately bundling Wasm so that library developers don’t need to constantly reinvent bundler configurations, as way too much of this article was dedicated to bundler configuration for it to be healthy.

Discuss on Hacker News

Comments

If you'd like to leave a comment, please email [email protected]