scottredig / zig-javascript-bridge

Easily call Javascript from Zig wasm
MIT License
38 stars 2 forks source link
zig-package

zig-javascript-bridge

This library creates bindings for accessing Javascript from within a WASM runtime. For example:

const zjb = @import("zjb");

export fn main() void {
    zjb.global("console").call("log", .{zjb.constString("Hello from Zig")}, void);
}

Is equivalent to this Javascript:

console.log("Hello from Zig");

Project Status

ZJB is fully functional and is ready to be used in other projects. However 1.0 will not be tagged until there is significant enough usage that confidence in the API not needing further changes is high.

Why

Calling Javascript functions from Zig is a pain. WASM has restrictions on the function API surface, and how references to the runtime environment (Javascript) can be stored. So to access Javascript functionality from Zig, you must create: a function in Zig which is friendly to call, a function export, and a function in Javascript which translates into the call you actually want.

This isn't too bad so far, but the Javascript API surface is large, has a lot of variadic functions, and accepts many types. The result is that your programming loop of just wanting to write code slows down writing a large amount of ill fitting boilerplate whenever you must cross the Zig to Javascript boundary.

This package is clearly inspired by Go's solution to this problem: https://pkg.go.dev/syscall/js However, there are a few significant deviations to note if you're familiar with that library:

  1. Every call from Go's package involves string decoding, garbage creation, and reflection calls.
  2. Go has a one size fits all Javascript file, while zjb uses a generator to produce Javascript for the calls you use.
  3. Go's garbage collection and finalizers allows for automatically cleaning up references from Go to Javascript, while zjb requires manual Handle releasing.
  4. Zig has no runtime, so there's none of Go's weirdness about blocking on the main thread.

Usage

As of May 2024, zjb requires Zig 0.12.0 or greater.

The simple folder provides a good template to start from. You'll need to update to reference to zjb in build.zig.zon. There's currently no release schedule for point releases, so you should use the latest available code. Eg, copy the entire simple folder into your empty project, then run zig fetch --save=zjb https://github.com/scottredig/zig-javascript-bridge/archive/<put hash of latest commit to main here>.zip

Call into Javascript using zjb, generate the Javascript side code, and then build an html page to combine them.

Example

An example which demonstrates usage of all of the components of zjb is in the example folder. It includes:

To view the example in action, run zig build example from inside the example folder. Then host a webserver from zig-out/bin.

Details

Zjb functions which return a value from Javascript require specifying which type is returned. As arguments or return types to be passed into Javascript, zjb supports:

Zjb supports multiple ways to expose Zig functions to Javascript:

Simple Zig global variables can also be exposed to Javascript:

A few extra notes:

zjb.string([]const u8) decodes the slice of memory as a utf-8 string, returning a Handle. The string will NOT update to reflect changes in the slice in Zig.

zjb.global will be set to the value of that global variable the first time it is called. As it is intended to be used for Javascript objects or classes defined in the global scope, that usage will be safe. For example, console, document or Map. If you use it to retrieve a value or object you've defined in Javascript, ensure it's defined before your program runs and doesn't change.

The _ArrayView functions (i8ArrayView, u8ArrayView, etc) create the respective JavaScript typed array backed by the same memory as the Zig WASM instance.

dataView is similar in functionality to the ArrayView functions, but returns a DataView object. Accepts any pointer or slice.

The generated Javascript also includes a shortcut function named dataView to get an up-to-date cached DataView of the entire WebAssembly Memory.

[!CAUTION] There are three important notes about using the _ArrayView and dataView functions:

The _ArrayView and dataView functions will accept const values. If you pass one (such as []const u8), you are exposing Zig's memory to Javascript. Changing the values from Javascript may lead to undefined behavior. Zjb allows this as there are plenty of use cases which only read the data, and requiring non-const values throughout your program if they are eventually passed to Javascript isn't a desirable API. It's up to you to be safe here.

Changes to the values in either Zig or Javascript will be visible in the other. HOWEVER, if the wasm memory grows for whatever reason (either through a direct @wasmMemoryGrow call or through allocators), all _ArrayViews and DataViews are invalided, and their length will be zero. You have (roughly speaking) three choices to handle this:

  1. Always create just before using, and release immediately after use.
  2. Never allocate after using these functions.
  3. Check their length before any use, if it does not match the intended length, release and recreate the Handle.

Javascripts's DataView allows pulling out arbitrary values from offsets. This may be useful for working with Zig structs from Javascript, however remember that Zig may reorder the fields for structs. Use extern struct to be safe here.

How

To solve the general problem of referencing Javascript objects from Zig, an object on the Javascript side with integer indices is used. When passing to Javascript, the object is added to the map with a unique ID, and that ID is passed to Zig. When calling from Zig, Javascript will translate the ID into the object stored on the map before calling the intended function. To avoid building up garbage endlessly inside the object map, zjb code must call release to delete the reference from the map.

zjb works with two steps:

  1. Your Zig code calls zjb functions. Many functions use comptime to create export functions with specialized export signatures. Only methods which are actually used are in the final WASM file.
  2. Run an extract methods program on the WASM file, producing a Javascript file to use along with your WASM file. The example above produces this export, for example:
const Zjb = class {
  new_handle(value) {
    if (value === null) {
      return 0;
    }
    const result = this._next_handle;
    this._handles.set(result, value);
    this._next_handle++;
    return result;
  }
  dataView() {
    if (this._cached_data_view.buffer.byteLength !== this.instance.exports.memory.buffer.byteLength) {
      this._cached_data_view = new DataView(this.instance.exports.memory.buffer);
    }
    return this._cached_data_view;
  }
  constructor() {
    this._decoder = new TextDecoder();
    this.imports = {
      "call_o_v_log": (arg0, id) => {
        this._handles.get(id).log(this._handles.get(arg0));
      },
      "get_o_console": (id) => {
        return this.new_handle(this._handles.get(id).console);
      },
      "string": (ptr, len) => {
        return this.new_handle(this._decoder.decode(new Uint8Array(this.instance.exports.memory.buffer, ptr, len)));
      },
    };
    this.exports = {
    };
    this.instance = null;
    this._cached_data_view = null;
    this._export_reverse_handles = {};
    this._handles = new Map();
    this._handles.set(0, null);
    this._handles.set(1, window);
    this._handles.set(2, "");
    this._handles.set(3, this.exports);
    this._next_handle = 4;
  }
  setInstance(instance) {
    this.instance = instance;
    const initialView = new DataView(instance.exports.memory.buffer);
    this._cached_data_view = initialView;
  }
};