The lack of proper web compatibility for Thermion has been bugging me for some time. This hasn't been for lack of trying - the main obstacle was that Dart doesn't have an (easy) path for direct interop with WebAsssembly libraries.
Native targets (Android, iOS, macOS, Linux and Windows) can all use dart:ffi
to invoke external functions directly and pass around native data structures and pointers to native memory. This was previously partially available when compiling to WebAssembly, but after the public API was removed in 2024, the only officially supported method for interop with WebAssembly libraries is (indirect) invocation via Javascript interop (which I'll cover in more detail below).
Dart FFI interop for native targets
First, let's recap the FFI approach. Say we have a header file my_native_func.h
:
int my_native_function(int number);
and matching implementation my_native_func.c
:
int my_native_function(int number) {
return number+1;
}
and we've already compiled this library to my_native_func.dylib
.
We now want to write a Dart program to load this library and invokes this method. In our main.dart
, we could write the following binding:
@Native<Int32 Function(Pointer<Uint8>)>(isLeaf: true)
external int my_native_function(Pointer<Uint8> addr);
and then invoke it directly in our main
function:
// I didn't explain how to compile/link the native library
// because it's not relevant to this topic,
// so just assume this loads the library
loadDynamicLibrary();
final value = my_native_function(0);
print("Value: $value");
}
Running dart run main.dart
will print Value: 1
.
(base) nickfisher@Nicks-Mac-mini % dart run main.dart
Value: 1
Dart JS interop for WebAssembly
When compiling to WebAssembly, however, this won't work. In fact, it will throw a compile-time error complaining that dart:ffi
is not available for the target platform longer.
(base) nickfisher@Nicks-Mac-mini % dart compile wasm main.dart
main.dart:1:1: Error: 'dart:ffi' can't be imported when compiling to Wasm.
import 'dart:ffi';
^
main.dart:14:37: Error: The '.address' expression can only be used as argument to a leaf native external call.
final value = my_native_function(data.address);
Luckily, Dart has very robust options for Javascript interop (package:web
and package:js_interop
). WebAssembly compilers generally don't just produce .wasm
modules, they can also generate Javascript wrappers to allow those WebAssembly modules to be loaded and executed in Javascript runtimes (like v8/Node/etc).
Assuming we have my_native_func.wasm
(the WebAssembly module) and my_native_func.wasm.js
(the corresponding Javascript wrapper), we can invoke our "native" function like so:
@JS('my_native_function')
external JSNumber my_native_function(JSNumber addr);
void main(List<String> args) {
// Similarly, just assume this loads the Javascript module
loadDynamicLibrary();
final value = my_native_function(0.toJS);
print("Value: ${value.toDart}");
}
While this looks superficially similar to the dart:ffi
approach, there are a few key differences:
- the @JS annotation indicates where to find the function in the global JS namespace
- rather than defining the argument/return types as a primitive Dart
int
, this converts the argument and return types to/from the JS equivalent type respectively. This isn't actually necessary (if you define these asint
, the Dart compiler will take care of the conversion for you), but I have kept these in to explicitly illustrate that you are passing Javascript types around - not native WebAssembly types.
The problems with JS interop
If the native library only comprises a single native function, then it's no problem to do this by hand. If/when something changes in the native library, it's trivial to edit the sole JS binding manually.
A package like Thermion, though, exposes more than 500 native function definitions. While it's possible to write every single binding by hand, it's very time-consuming and tedious.
Using native FFI, this isn't a problem; the ffigen
package auto-generates bindings practically instantly. There's no equivalent for js_interop
.
What's more, FFI interop types are slightly different from JS interop types. Take the following native function definition as an example.
void my_native_function(uint8_t* ptr);
With dart:ffi
, the binding is straightforward:
@ffi.Native<ffi.Void Function(ffi.Pointer<Uint8>)>(isLeaf: true)
external void my_native_function(
ffi.Pointer<Uint8> ptr,
);
Without dart:ffi
, there's no Pointer
or Uint8
class - so how do you know which Javascript data type will be accepted/returned by your native function? You need to roll your own, which requires at least passing familiarity with the WebAssembly ABI and calling conventions. You might then also need to maintain two separate calling libraries - one for FFI, one for JS interop WebAssembly.
jsgen to the rescue
The obvious solution to these problems is a code-generator that does all of this for you. That's exactly what I did with jsgen
- forked the ffigen
package to generate JS interop bindings from C headers (and additionally, to reimplement most dart:ffi
and package:ffi
Dart types).
Where possible, I reused the interfaces and syntax from dart:ffi
; this makes it much easier to use as a drop-in replacement for bindings generated with ffigen
. For example, we only need to slightly refactor main.dart
in the above example to use conditional imports:
import 'bindings.dart';
void main(List<String> args) {
// I didn't explain how to compile/link the native library
// because it's not relevant to this topic,
// so just assume this loads the library
loadDynamicLibrary();
final value = my_native_function(0);
print("Value: $value");
}
In bindings.dart:
export 'bindings.ffi.g.dart'
if (dart.library.io) 'bindings.ffi.g.dart'
if (dart.library.js_interop) 'bindings.js.g.dart';
In bindings.ffi.g.dart:
@Native<Int32 Function(Pointer<Uint8>)>(isLeaf: true)
external int my_native_function(Pointer<Uint8> addr);
In bindings.js.g.dart:
@Native<Int32 Function(Pointer<Uint8>)>(isLeaf: true)
external int my_native_function(Pointer<Uint8> addr);
With this approach, you only need to refactor the import paths, not every invocation of my_native_function
. This was a huge relief when migrating Thermion; I would have needed to a full day to refactor otherwise.
Shared memory
Unfortunately, Javascript interop isn't an exact substitute for FFI interop. Some FFI data structures don't have equivalents in Javascript. For example, Thermion often uses the following structure to pass byte data from Dart to native code:
@Native<Void Function(Pointer<Uint8>)>(isLeaf: true)
external void native_load(Pointer<Uint8> addr);
Future loadGltf(String assetPath) async {
var byteData = await rootBundle.load(assetPath);
var buffer = await byteData.buffer.asUint8List(byteData.offsetInBytes);
native_load(buffer.address);
}
The .address
accessor and the isLeaf: true
annotation here are key. With the latter, the Dart garbage collector is guaranteed not to run before native_load
has finished executing. This ensures the memory location of the Dart buffer
object will not move while the function is executing; the Pointer derived from the .address
accessor will therefore remain valid for the lifetime of the invocation. Similarly, the .address
accessor cannot be used if the native function being invoked is not explicitly marked as a leaf call.
js_interop has no such equivalent. .address
isn't a runtime lookup, it's actually a compiler transformation which simply isn't available when compiling Dart to Javascript or WebAssembly.
That's not all though - the whole concept doesn't make sense in the context of Javascript interop, where Dart code runs in one memory space and WebAssembly code in another. Just because we can use Javascript to communicate between the two doesn't mean that memory addresses are mutually accessible.
So conceptually, we first need to figure out how to implement some form of shared memory between Dart and WebAssembly.
Luckily, with the emscripten compiler, we can allocate memory on the stack and/or heap and pass this around as a typed Javscript array:
emscripten::val emscripten_make_uint8_buffer(int length) {
uint8_t *buffer = (uint8_t*)malloc(length);
auto v = emscripten::val(emscripten::typed_memory_view(length, buffer));
return v;
}
If you're not familiar with emscripten, here's a high level overview:
- malloc allocates length bytes of memory on the emscripten heap
- emscripten::typed_memory_view creates a view on the underlying buffer (so we can pass around a typed array without copying the underlying data)
- emscripten:val creates a Javascript object to that can be passed directly back to Dart as a JSObject
With Dart static extensions, I can create a compatible implementation of the .address
extension on dart:typed_data
classes like so:
@JS('emscripten_make_uint8_buffer);
external JSUint8Array emscripten_make_uint8_buffer(int length);
@JS('free);
external void emscripten_free(void* ptr);
final _allocations = <Uint8List, int>{};
extension Uint8ListExtension on Uint8List {
Pointer<Uint8> get address {
if (this.lengthInBytes == 0) {
return nullptr;
}
final jsTypedArray = emscripten_make_uint8_buffer(this.lengthInBytes);
jsTypedArray.toDart.setRange(0, length, this);
final ptr = Pointer<Uint8>(jsTypedArray.offsetInBytes);
_allocations[this] = ptr;
return ptr;
}
void free() {
if(_allocations.contains(this)) {
emscripten_free(_allocations[ptr]!);
_allocations.remove(this);
}
}
}
Accessing .address
on an instance of Uint8List
will create a JSUint8Array via emscripten, copy the contents of the Dart UInt8List
and return a pointer to the emscripten heap to the caller. This pointer can be passed directly back to a native function exactly as we do with FFI interop:
Future loadGltf(String assetPath) async {
var byteData = await rootBundle.load(assetPath);
var buffer = await byteData.buffer.asUint8List(byteData.offsetInBytes);
native_load(buffer.address);
buffer.free();
}
The memory copy is unfortunate, but unavoidable unless you have complete control over how the Uint8List
was constructed in the first place. You also now need to manually track the lifetime of the additional native allocation, which is painful.
Until Dart fully supports direct WebAssembly interop (rather than indirect interop via Javascript), that's what we're stuck with.