Skip to content

Instantly share code, notes, and snippets.

@Tythos
Last active January 15, 2022 13:01
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Tythos/376d88e51e80b620135e5cc9790b321e to your computer and use it in GitHub Desktop.
Save Tythos/376d88e51e80b620135e5cc9790b321e to your computer and use it in GitHub Desktop.
WasmThread-v1.0.0.js
/**
* @file
*
* Single-file JavaScript module that defines a developer-friendly interface
* for adapting, and calling, WASM modules in a threaded manner using
* WebWorkers. There are two components merged within this single file:
*
* 1. The first "if" block is evaluated by the WebWorker (child thread). This
* block is responsible for instantating the WASM module referenced by the
* main thread, and then managing the individual calls. This includes
* universal object interface ("UOI") mapping/encoding of argument objects.
*
* 2. The second ("else") block is a normal AMD-compatible module that defines
* a WasmThread class for the front end to use. A WasmThread object is
* instantiated with the path to the desired WASM module. Once instantiated,
* the "dispatch()" method can be used to identify a specific export from
* the WASM module to call; what argument object to pass; and what callback
* will be invoked when the call has completed.
*
* Key advantages of this approach, which effectively wraps the (Web)Worker
* interface with a managed call-and-callback infrastructure on both ends,
* include:
*
* * Minimal binding/interface for JS-to-WASM calls, agnostic to the WASM
* interface itself (assuming something like the Rust source for WASM has
* implemented JSON-based UOI unpackaging of inputs and packaging of
* outputs).
*
* * Instantiate-once, call-many-times behavior without directly interacting
* with (Web)Worker message handling/unpacking.
*
* * Backwards-compatible asynchronous calls to computationally-heavy (e.g.,
* numerical calculations) routines that can be enacapsulated within a single
* module, which splices nicely with typically callback-based asynch code.
*
* This produces a very elegant interface for the caller/client:
* ```
* > let wt = new WasmThread("path/to/wasm/module.wasm");
*
* > wt.dispatch("myfunc", {inp1}, function({out1}) { });
*
* > wt.dispatch("myfunc", {inp2}, function({out2}) { });
* ```
*/
if (typeof define == "undefined") {
/* --- THIS BLOCK IS EVALUATED WITHIN THE WORKER --- */
let wasmInstance = null;
let ncdr = new TextEncoder("utf-8");
let dcdr = new TextDecoder("utf-8", {
"ignoreBOM": true,
"fatal": true
});
/**
* Once the Worker is instantiated on the child thread, we use this
* method to load and instantiate the WASM module from the path String.
* Once instantiated, the module is stored under the Worker-scope variable
* "wasmInstance" and the "onWasmInstantiated()" event is called.
*
* @param {String} wasmPath - Path to WASM module (relative to this file)
*/
function onWorkerInstantiated(wasmPath) {
fetch(wasmPath)
.then(response => response.arrayBuffer())
.then(bytes => WebAssembly.instantiate(bytes, {}))
.then(instantiation => onWasmInstantiated(instantiation));
}
/**
* We now have a WASM module instance; let the main thread know. This
* passes a unique message with no identifier. The instance is first
* assigned to the Worker-scope variable "wasmInstance".
*
* @param {WebAssembly.Instance} instantiation - WASM module instance
*/
function onWasmInstantiated(instantiation) {
wasmInstance = instantiation.instance;
postMessage([null, "WasmThread initialized"]);
}
/**
* Invoked when the main thread dispatches a call intended for the WASM
* module. After unpacking arguments from the event data, invokes the UOI
* caller and returns the output with the unique call ID via message to the
* main thread. The Worker message object "event" has a "data" property
* that includes an Array of three key variables:
*
* 0. {String} - A unique hash for this dispatch
*
* 1. {String} - What endpoint are we calling within the WASM module?
*
* 2. {Object} - Object of dispatch arguments for UOI packaging
*
* @param {Object} event - Worker message object
*/
function onMainMessage(event) {
let id = event.data[0];
let export_name = event.data[1];
let input = event.data[2];
let output = onUoiCall(export_name, input);
postMessage([id, output]);
}
/**
* UOI, or universal object interface, is a way to utilize agnostic
* interfaces to (for example) Rust-based WASM modules. UOI uses Objects
* for input and output structures, and serializes/deserializes those
* structures (respectively) to JSON strings that are then encoded to (and
* decoded from) the WASM module memory buffer before and after each call.
*
* @param {String} export_name - Identifies a specific export within the WASM module to call
* @param {Object} input - Arguments to package and pass to the WASM module via JSON-encoded memory buffer entry
* @returns {Object} - Results decoded from the WASM module memory buffer
*/
function onUoiCall(export_name, input) {
// encode input object to string in buffer
let arg = JSON.stringify(input);
let len = arg.length;
let ptrIn = wasmInstance.exports.__wbindgen_malloc(len);
let cache = new Uint8Array(wasmInstance.exports.memory.buffer);
ncdr.encodeInto(arg, cache.subarray(ptrIn, ptrIn + len));
// set up string pointer for output, then wrap call to prevent stack corruption
let ptrOut = wasmInstance.exports.__wbindgen_add_to_stack_pointer(-16);
let output = {};
try {
wasmInstance.exports[export_name](ptrOut, ptrIn, len);
let start = new Int32Array(wasmInstance.exports.memory.buffer.slice(ptrOut, ptrOut + 4))[0];
let length = new Int32Array(wasmInstance.exports.memory.buffer.slice(ptrOut + 4, ptrOut + 4 + 4))[0];
let str = dcdr.decode(wasmInstance.exports.memory.buffer.slice(start, start + length));
output = JSON.parse(str);
wasmInstance.exports.__wbindgen_free(start, length);
} finally {}
// finally, no matter what happened, pop the two 8-byte values back off the stack and return output
wasmInstance.exports.__wbindgen_add_to_stack_pointer(16);
return output;
}
// evaluation leads to WASM instantiation
onmessage = onMainMessage;
onWorkerInstantiated(self.name);
} else {
/* --- THIS BLOCK IS EVALUATED WITHIN THE MAIN THREAD --- */
define(function(require, exports, module) {
/**
* Returns remainder of tgtPath that does not intersect with refPath.
* For example, if refPath is "/path/to/script.js", and tgtPath is
* "/path/to/another/script.js", the function will return the String
* "another/script.js".
*
* @param {String} refPath
* @param {String} tgtPath
* @returns {String} x
*/
function getPathJoin(refPath, tgtPath) {
let refPaths = refPath.split("/");
let tgtPaths = tgtPath.split("/");
let i = 0;
while (i < refPaths.length && i < tgtPaths.length && refPaths[0] == tgtPaths[0]) {
refPaths.shift();
tgtPaths.shift();
}
return tgtPaths.join("/");
}
/**
* The WasmThread object is the primary export from this JavaScript
* module and defines an object wrapping for a specific child thread.
* The child thread manages the WASM module instantiation and
* call-wrapping, whereas the WasmThread object behaviors ensure
* uniqueness of asynchronous calls and mapping to the appropriate
* callbacks.
*
* @property {String} wasmPath - Normalized (relative to this module) path to WASM
* @property {Object} calls - Mapping of unique hash to callbacks
* @property {Boolean} isInitialized - Flag used to queue dispatches when instantiation of child thread (Worker and WASM instance) has not yet finished
* @property {Array} dispatchQueue - Stores argument triplets (export name, input object, and callback) for dispatches requested before instantiation has completed
* @property {Worker} worker - Specific Worker instance, using this module to bootstrap the interface to a specific WASM module
*/
class WasmThread {
constructor(wasmPath) {
// WASM fetch will be relative to THIS module path, whereas original caller (WasmThread constructor) will likely assume relative to data-main; constructot
if (!wasmPath.startsWith(".") && !wasmPath.startsWith("/")) {
wasmPath = "./" + wasmPath;
}
this.wasmPath = getPathJoin(module.uri, wasmPath);
this.calls = {};
// waiting pool of calls while we wait for intialization
this.isInitialized = false;
this.dispatchQueue = [];
// bootstrap worker w/ this file, using "name" option for WASM path
this.worker = new Worker(module.uri, {
"name": this.wasmPath
});
/// set up event bindings and callback map
this.worker.onerror = this.onWorkerError.bind(this);
this.worker.onmessage = this.onWorkerMessage.bind(this);
this.worker.onmessageerror = this.onWorkerMessageerror.bind(this);
}
/**
* Relays errors from Worker to console.error. Attached to worker's
* "onerror" property by WasmThread constructor.
*
* @param {Event} event - Error event from Worker instance
*/
onWorkerError(event) {
console.error(event);
}
/**
* Invoked when initialization message is intercepted by
* "onWorkerMessage()" listener. Iterates through any queued
* dispatches that may be waiting; passes them as normal
* invocations, then empties queue.
*
* @param {Event} event - Unused Event object
*/
onWorkerInitialized(event) {
if (0 < this.dispatchQueue.length) {
this.dispatchQueue.forEach(function(dispatch) {
this.dispatch(dispatch[0], dispatch[1], dispatch[2]);
}, this);
this.dispatchQueue = [];
}
}
/**
* Assigned to "onmessage" listener property of Worker instance by
* the WasmThread constructor. Invoked whenever a message is posted
* by the child thread. This occurs in one of two situations:
*
* #. The child thread has finished initialization, including
* instantiation of the target WASM module, in which case the
* "onWorkerInitialized()" method is called.
*
* #. A dispatch has completed, in which case the appropriate
* callback is retrieved using the unique hash. The callback is
* passed the associated output object, and popped from the
* "calls" Object property.
*
* @param {Event} event
*/
onWorkerMessage(event) {
let id = event.data[0];
if (!id && !this.isInitialized) {
// one-time init message (worker and wasm loaded)
this.isInitialized = true;
this.onWorkerInitialized(event.data[1]);
} else {
let result = event.data[1];
this.calls[id](result);
delete this.calls[id];
}
}
/**
* Unlike onWorkerError, which is triggered when the Worker itself
* experiences an error, a onWorkerMessageerror is triggered when
* the browser is unable to serialize/deserialize data to and/or
* from the Worker. In this case, it too is simply relayed to the
* "console.error()" method.
*
* @param {Event} event
*/
onWorkerMessageerror(event) {
console.error(event);
}
/**
* The primary user point for a WasmThread instance, once
* constructed. This method sets up the unique hash to identify
* this dispatch and callback, which is then passed (along with the
* specific export name to call, and the input object to use) to
* the Worker as a message. If the Worker has not yet initialized,
* the arguments are pushed to the *dispatchQueue* property, where
* they will be popped for another dispatch call by the
* "onWorkerInitialized()" method.
*
* @param {String} export_name - Identifies which endpoint within the WASM module we will eventually invoke (there can be more than one export!)
* @param {Object} input - Set of input arguments that will be unpacked (deserialized) from memory buffer via JSON decoding by the export function
* @param {Function} callback - Accepts an Object containing results of WASM call, deserialized from memory buffer.
*/
dispatch(export_name, input, callback) {
if (this.isInitialized) {
let id = (Math.floor(Math.random() * Math.pow(2, 32))).toString(16);
while (this.calls.hasOwnProperty(id)) {
id = (Math.floor(Math.random() * Math.pow(2, 32))).toString(16);
}
this.calls[id] = callback;
this.worker.postMessage([id, export_name, input]);
} else {
this.dispatchQueue.push([export_name, input, callback]);
}
}
/**
* Strictly speaking, the garbage collector should handle Worker
* termination. But, since there IS a specific "terminate()" method
* of the Worker instance, this is just responsible coding.
*/
close() {
this.worker.terminate();
}
}
return Object.assign(WasmThread, {
"__url__": "https://gist.github.com/Tythos/376d88e51e80b620135e5cc9790b321e",
"__semver__": "1.0.0",
"__license__": "MIT"
});
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment