Last active
January 15, 2022 13:01
-
-
Save Tythos/376d88e51e80b620135e5cc9790b321e to your computer and use it in GitHub Desktop.
WasmThread-v1.0.0.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @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