Lesson 2: Bridging Wasm with JavaScript and Browser Web APIs
In this lesson, we start by diving deep into the symbiotic relationship between Wasm and JavaScript. As we progress, you’ll understand how the two can work together to craft responsive and powerful web applications.
Instantiating and Loading Wasm Modules in JavaScript
Once you’ve compiled your Wasm module, the next step is to load and use it in a JavaScript environment.
The following examples demonstrated how to achieve this in Node.js and in the browser, respectively.
The key distinction is the method of loading the Wasm binary: in Node.js, we can read it directly from the file system, while in browsers, we rely on the fetch
API.
const fs = require('fs');
const wasmBuffer = fs.readFileSync('sample.wasm');
WebAssembly.instantiate(wasmBuffer).then(result => {
const { add } = result.instance.exports;
console.log(add(2, 3)); // Outputs: 5
});
fetch('sample.wasm').then(response =>
response.arrayBuffer()
).then(bytes =>
WebAssembly.instantiate(bytes)
).then(result => {
const { add } = result.instance.exports;
console.log(add(2, 3)); // Outputs: 5
});
Data Types and Conversions
One of the challenges when working with Wasm and JavaScript together is the difference in data types. While JavaScript is dynamically typed, Wasm is statically typed. This difference mandates conversions when data is transferred between them.
For instance, when you pass a string from JavaScript to Wasm, it’s not transferred as a string but as a pointer to a memory location. Hence, understanding these conversions is paramount.
Suppose you have a Wasm function that accepts a string. The process usually involves two steps: passing the pointer of the string and its length.
const stringToPass = "Hello Wasm";
const pointer = wasmModule._malloc(stringToPass.length + 1); // allocate memory
const buffer = new Uint8Array(wasmModule.HEAPU8.buffer, pointer, stringToPass.length + 1);
for (let i = 0; i < stringToPass.length; i++) {
buffer[i] = stringToPass.charCodeAt(i);
}
buffer[stringToPass.length] = 0; // Null-terminate the string
wasmFunction(pointer, stringToPass.length);
wasmModule._free(pointer); // free allocated memory
Here, we’re using Wasm’s memory methods (_malloc
and _free
) to manage memory. The string is written into Wasm’s memory, and its pointer is passed to the Wasm function.
Asynchronous Communication Using Web Workers
In modern web development, responsiveness is king. We don’t want our applications to be sluggish or freeze during heavy computations. This is where Web Workers come into play, and integrating them with Wasm can lead to some impressive results.
Introduction to Web Workers
Web Workers allow web applications to run scripts in the background, separate from the main execution thread. This leads to smoother performance, especially during tasks that might otherwise bog down the main thread.
Think of it this way: while your main application thread is busy managing UI interactions, Web Workers can process data, handle computations, or interact with WebAssembly modules, all without interrupting the main thread.
Setting Up Web Workers with Wasm
Let’s think of a Wasm module sample.wasm
that exports a function add
that adds two numbers.
Node.js Example
Node.js does not have Web Workers since it’s not a browser environment. However, it has a similar concept with worker threads.
Here’s a simple example using worker_threads
:
const { Worker, isMainThread, parentPort } = require('worker_threads');
const fs = require('fs');
if (isMainThread) {
const worker = new Worker(__filename);
worker.on('message', (msg) => {
console.log(msg); // Outputs: 'Result from Wasm: 5'
});
worker.postMessage('Run Wasm');
} else {
parentPort.on('message', async (msg) => {
if (msg === 'Run Wasm') {
const wasmBuffer = fs.readFileSync('sample.wasm');
try {
const wasmModule = await WebAssembly.compile(wasmBuffer);
const instance = await WebAssembly.instantiate(wasmModule);
const resultFromWasm = instance.exports.add(2, 3);
parentPort.postMessage(`Result from Wasm: ${resultFromWasm}`);
} catch (error) {
parentPort.postMessage(`Error in Wasm: ${error.message}`);
}
}
});
}
Browser Example
Here’s how you might use a Web Worker with Wasm in a browser setting.
You need to create two separate files: one for the worker (wasmWorker.js
) and one for the main thread (main.js
).
self.onmessage = function(event) {
if (event.data === 'Run Wasm') {
fetch('sample.wasm').then(response =>
response.arrayBuffer()
).then(bytes =>
WebAssembly.instantiate(bytes)
).then(result => {
const { add } = result.instance.exports;
self.postMessage(`Result from Wasm: ${add(2, 3)}`);
});
}
};
const worker = new Worker('wasmWorker.js');
worker.onmessage = function(event) {
alert(event.data); // Alerts: 'Result from Wasm: 5'
};
worker.postMessage('Run Wasm');
Benefits of Asynchronous Operations
By running Wasm modules within Web Workers, you free up the main thread, ensuring your website remains responsive. Heavy computations or data processing can occur in the background, providing users with a seamless experience. This synergy between Web Workers and Wasm taps into the full potential of modern web performance.
How to Import and Use Web API Functions in Wasm
WebAssembly is not just about performance; it’s also about integration. Thanks to the Web APIs, Wasm can seamlessly communicate with the web environment. In this section, we’ll explore how Wasm can tap into these APIs.
The Import Object in WebAssembly
When instantiating a Wasm module, we can pass an ‘import object’. This object can define functions, values, and memory that WebAssembly can use. Essentially, it serves as a bridge between the JavaScript (or host) environment and the Wasm module.
Importing Web API Functions to Wasm
Node.js Example
Suppose you have a Wasm function that requires the current time in milliseconds since the UNIX epoch (akin to JavaScript’s Date.now()
). Here’s how you might provide that functionality:
const fs = require('fs');
const wasmBuffer = fs.readFileSync('sample.wasm');
WebAssembly.instantiate(wasmBuffer, {
jsFunctions: {
currentTimeMillis: () => Date.now()
}
}).then(result => {
console.log(`Time from Wasm: ${result.instance.exports.getTime()}`);
});
In this example, the Wasm module uses the getTime
function, which internally calls the provided currentTimeMillis
JavaScript function to get the current time.
Browser Example
Let’s imagine a scenario where our Wasm module wants to log messages to the browser console:
fetch('sample.wasm').then(response =>
response.arrayBuffer()
).then(bytes =>
WebAssembly.instantiate(bytes, {
jsFunctions: {
logMessage: (ptr, length) => {
const message = new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, length));
console.log(message);
}
}
})
).then(result => {
const memory = result.instance.exports.memory;
result.instance.exports.logFromWasm();
});
In this example, the Wasm module uses a function, say logFromWasm
, which calls the provided logMessage
JavaScript function to log a message to the console.
Using the Imported Object within WebAssembly Modules
When writing the WebAssembly module in either C or Rust, it’s necessary to declare and use the imported JavaScript functions. Here’s how you can achieve that:
Using C with Emscripten
In C, you declare an external function using the extern
keyword.
#include <emscripten.h>
extern void logMessage(const char *msg);
EMSCRIPTEN_KEEPALIVE
void sendMessageToJS() {
logMessage("Hello from WebAssembly!");
}
When compiled with Emscripten, the logMessage
function will be linked to the corresponding function provided in the JavaScript import object during instantiation.
Using Rust with wasm-bindgen
In Rust, you use the wasm-bindgen
crate to seamlessly bridge the gap between Rust and JS.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
fn logMessage(msg: &str);
}
#[wasm_bindgen]
pub fn sendMessageToJS() {
logMessage("Hello from WebAssembly!");
}
With wasm-bindgen
, not only can you call JS functions from Rust, but you can also expose Rust functions to be called from JS, as seen with the sendMessageToJS
function.
Memory Management Considerations
When bridging JavaScript and Wasm, you’ll often deal with memory directly, especially when passing strings or arrays. Always remember to manage this memory appropriately to prevent leaks or unintended behavior. WebAssembly provides its own memory space, and accessing it requires careful decoding, as demonstrated in the examples.
Best Practices
-
Minimize Overhead: Each call from Wasm to JS (or vice versa) introduces a small overhead. Aim for larger, more meaningful interactions rather than numerous tiny calls.
-
Memory Care: When working with memory directly, ensure you allocate and deallocate appropriately to avoid memory leaks.
-
Trust, but Verify: Always validate data passed between JS and Wasm. Though Wasm is sandboxed, ensuring data integrity is crucial for application reliability.
Using Wasm to Manipulate the DOM and Handle Events
WebAssembly’s primary purpose isn’t to replace JavaScript for tasks like DOM manipulation. However, through the integration between Wasm and JS, it’s feasible for WebAssembly to impact the DOM indirectly. Let’s explore how.
Understanding DOM Manipulation via Wasm
Wasm does not possess native capabilities to manipulate the DOM. Instead, it relies on JavaScript functions (that we provide) to perform DOM operations. The primary sequence involves Wasm computing some result and then calling a JS function to make the actual DOM changes.
Practical DOM Manipulation and Event Handling
Node.js Example
Node.js doesn’t have a DOM, but let’s simulate this with a mock DOM library, showing the interaction pattern:
const fs = require('fs');
const wasmBuffer = fs.readFileSync('sample.wasm');
const mockDOM = {
changeText: (id, text) => {
// Pretend this changes a DOM element's text
console.log(`Changed text of element ${id} to "${text}"`);
}
};
WebAssembly.instantiate(wasmBuffer, {
domFunctions: {
updateDOM: (ptr, length) => {
const message = new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, length));
mockDOM.changeText("exampleElement", message);
}
}
}).then(result => {
const memory = result.instance.exports.memory;
result.instance.exports.changeDOMContent();
});
Browser Example
In a browser environment, we can work with the real DOM. Let’s see how Wasm can indirectly manipulate the DOM:
fetch('sample.wasm').then(response =>
response.arrayBuffer()
).then(bytes =>
WebAssembly.instantiate(bytes, {
domFunctions: {
setElementText: (ptr, length, elementIdPtr, elementIdLength) => {
const text = new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, length));
const elementId = new TextDecoder().decode(new Uint8Array(memory.buffer, elementIdPtr, elementIdLength));
document.getElementById(elementId).textContent = text;
}
}
})
).then(result => {
const memory = result.instance.exports.memory;
result.instance.exports.updateDOM();
});
In this example, we have a Wasm function updateDOM
that intends to change the content of a DOM element. It does this by calling setElementText
, a JS function, passing it the text and the element ID.
Handling Events
For event handling, you’d typically set up your event listeners in JS, then call into Wasm when the event occurs.
// After instantiating the Wasm module
const button = document.getElementById('myButton');
button.addEventListener('click', () => {
wasmInstance.exports.onButtonClick();
});
In this simple example, when the button with ID ‘myButton’ is clicked, the onButtonClick
function within the Wasm module is called, allowing Wasm to then perform some logic based on the event.
Best Practices
-
Use Wasm Appropriately: While it’s exciting to manipulate the DOM through Wasm, remember that direct JS manipulation will be more efficient for many tasks. Use Wasm for compute-intensive operations, and then reflect those results in the DOM.
-
Keep Interactions Coherent: When integrating Wasm and JS, make interactions as clear and intuitive as possible. Avoid complex chains of calls between JS and Wasm which can become hard to debug and maintain.