Simon Binder

Using WebAssembly in Dart web apps

I maintain the sqlite3 Dart package which provides convenient Dart bindings to sqlite on the Dart VM with dart:ffi. To use sqlite3 on the web, I've previously used a wrapper around sql.js. While this worked fine for simple cases, I wanted a pure-Dart implementation that makes it easy to use the sqlite3 package on all platforms without any compromises. Recently, I've finally managed to port that package to the web with a custom sqlite library. In this article, I describe which changes I had to make to sqlite3 and how the compilation is set up. I'll link relevant parts of the Dart implementation on the way, so it might be helpful to others also interested in running existing C libraries on the web.

In recent versions, the LLVM toolchain supports emitting WebAssembly code for LLVM IR. So in theory, every C library can be compiled to WebAssembly through clang. In practice, this is much harder of course: The web assembly runtime has no access to any file system or the network, so most functions from the C standard library just can't be supported. An easy solution to this problem is Emscripten, which essentially implements these C functions in JavaScript. Emscripten also generates JavaScript glue code to access native APIs.

In my case, Emscripten was not the right tool though:

In my case, I was really lucky that I have a C library designed with cross-platform support in mind! sqlite3 supports Windows and POSIX platforms out-of-the-box, but is flexible enough to support custom targets too. A SQLite OS Interface, or "VFS", is a collection of methods sqlite uses to access functionality from the operating system. In sqlite3, the os_unix.c or os_win.c source files are responsible for defining the default VFS used. To make sqlite3 support the web, I just had to compile it without these files and with a os_web.c file that I wrote myself.

Implementing and compiling the VFS

To compile sqlite3 with clang, I essentially chose these options:

clang \
  --target=wasm32-unknown-wasi --sysroot $(WASI_SYSROOT) \
  -nostartfiles -Ofast \
  -Wl,--import-memory -Wl,--no-entry -Wl,--export-dynamic \
  -DSQLITE_API='__attribute__((visibility("default")))' \
  -o sqlite3.wasm
  sqlite3.c os_web.c helpers.c

Here:

With that taken care of, I still had to write the actual VFS. For this, I defined C function definitions that I then need to implement in Dart and inject into the module. Let's take the xFullPathname function which a VFS needs to support for example. Its definition is essentially int (*xFullPathname)(const char *zName, int nOut, char *zOut), where zName is the input path and nOut / zOut is an output buffer. The VFS is responsible for normalizing the zName input path (resolving ../ and whatnot). In Dart, we can use package:path for this! But how do we access that package from C? Let's see.

LLVM's linker for WebAssembly supports the import_module and import_name functions to inform it that some C functions are to be injected into the module when instantiating it. So I can define this function in a header:

#define import_dart(name) \
  __attribute__((import_module("dart"), import_name(name)))

import_dart("path_normalize") extern int dartNormalizePath(const char *zPath,
                                                           char *zOut,
                                                           int nOut);

This informs the linker that, when the dartNormalizePath function is called in C, we're actually calling an injected WebAssembly function called path_normalize in an injected module named dart. In my os_web.c implementation, I provide this function to sqlite3 for normalizing paths (source).

I used this approach to implement all VFS functions through a Dart bridge. For the file system, I've written two Dart implementations: One in-memory file system and a simple one persisting data in IndexedDB.

WebAssembly interop from Dart

Next, the compiled web assembly module needs to be loaded. I wrote a simple Dart wrapper around the WebAssembly JavaScript APIs using package:js. When instantiating a module, one needs to pass a Map<String, Map<String, Object>> for injected value. The outer map describes the modules to inject, the inner map is from symbol identifiers to the actual values.

Considering the path_normalize example, the map may look like this:

typedef Pointer = int;

final memory = Memory(MemoryDescriptor(initial: 16));

final injectedValues = {
    'env': {
      'memory': memory,
    },
    'dart': {
      'path_normalize': allowInterop((Pointer zPath, Pointer zOut, int nOut) {
          final input = memory.readString(zPath);
          final output = p.normalize(input);
          final encoded = utf8.encode(normalize);

          if (encoded.length >= length) {
            return NOMEM;
          } else {
            memory.buffer.asUint8List(zOut, encoded.length).setAll(0, encoded);
            return OK;
          }
      }),
    }
};

With the --import-memory linker flag, the compiled module will read memory from an env environment which we need to inject. The dart module with the path_normalize function is used to implement the dartNormalizePath function. Some helpers for reading strings from memory are defined here.

Once the module is loaded, exported symbols are available through Module.functions or Module.globals. For the sqlite3 implementation, all the relevant symbols are loaded as functions which are then used to implement convenient Dart sqlite3 API.

Right now, this approach in package:sqlite3 looks really promising. I can share parts of the implementation between the FFI-based native wrappers and the WASM-based web wrappers.

Future ideas

The sqlite3 package now exports three separate libraries: The common platform interface, a dart:ffi based implementation and the new WASM implementation. There's a fair bit of duplicate code between the two implementations because the C functions are essentially the same. I can't easily generalize the code to work on both platforms though, since definitions from dart:ffi aren't available in the browser.

Refactoring both implementations into a separate, very low-level wrapper around sqlite3's C APIs and shared functionality to map sqlite3 to Dart would be interesting. While we're at it, having ffigen generate JS-interop code for C libraries compiled to WebAssembly could be very helpful too.