emscripten-core / emscripten-core/emscripten

Findings from wrapping a WebAssembly module as a Web Component

Open
#14,107 1 comment 4 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
27.6k
Forks
3.6k
Avg merge
1d 1h
Merged PRs (30d)
105

Description

### TL;DR

Wrapping a WebAssembly module created using Emscripten as a Web Component works but there are issues when creating multiple instances or when using the shadow DOM.

### Introduction

[Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components) are nowadays sufficiently supported by most major browsers to be an actual thing. Developing web apps without any frameworks has become much more feasible due to them because they allow proper modularization and reuse of code.

This post describes my journey of wrapping WebAssembly modules created using Emscripten as Web Components. It outlines what has been achieved so far, what works and what roadblocks I hit. All explanations that follow are based on a fully working Emscripten-based Web Component. The source code of it is available [here](https://github.com/ackh/emscripten-web-component/tree/master), the working example is available [here](https://ackh.github.io/emscripten-web-component/).

I'm posting this because I hope that wrapping WebAssembly modules created using Emscripten as Web Components at some point becomes a no-brainer. Mainly because I think Web Components are a natural way of providing a clean public interface for WebAssembly modules and at the same time simplify how such modules are referenced on web pages.

I hope that this document can serve as the basis for a discussion about whether we can do something within Emscripten to simplify the process.

### What and how it works

Behind the scenes, Web Components are enabled by three different web standards: [Custom elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements) enables the definition of custom HTML elements, [shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM) enables Web Components to use an isolated DOM tree which in turn enables encapsulation and [HTML templates](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_templates_and_slots) enables flexible templates that can be used to populate the shadow DOM of a Web Component.

The sample project uses Emscripten to build C++ code that makes use of OpenGL ES 2.0 to render a [colored triangle](https://ackh.github.io/emscripten-web-component/). The resulting module provides the three JavaScript properties `red`, `green` and `blue` that allow changing the color of the triangle. By making use of the custom element standard, the public HTML interface of that module is defined to look as follows:

````html

````

This is achieved by writing the following JavaScript code:

````javascript
class ColorTriangle extends HTMLElement {
constructor() {
super();
...
}
...
}

const webComponentName = "color-triangle";
if(!customElements.get(webComponentName)) {
customElements.define(webComponentName, ColorTriangle);
}
````

That JavaScript code is merged together with the JavaScript code necessary to run the WebAssembly module by utilizing the `--extern-post-js` switch of `emcc`. The result of compiling everything are two files:

````
color-triangle.js
color-triangle.wasm
````

Because Emscripten converts the OpenGL ES 2.0 calls into the corresponding WebGL calls a `canvas` is necessary to be able to display the rendered triangle. Hence, the `ColorTriangle` class is equipped with a `canvas` element. This `canvas` is instantiated and added to the DOM in the `connectedCallback` method of the class which is a lifecycle method offered by the custom element API and executed once the component is added to the DOM.

````javascript
const canvasIdentifier = "canvas";

connectedCallback() {
this.style.display = "flex";
this.style.justifyContent = "center";
this.style.alignItems = "center";
this.canvas = document.createElement("canvas");
this.canvas.id = canvasIdentifier;
this.canvas.oncontextmenu = this.contextMenuHandler;
this.canvas.style.width = "100%";
this.canvas.style.height = "100%";
this.appendChild(this.canvas);
}
````

The `canvasIdentifier` is how I connect the canvas to the WebAssembly module. Eventually, it is passed as a C++ `std::string` to the module which then creates the WebGL context:

````cpp
void ColorTriangle::Initialize(std::string canvasIdentifier)
{
EmscriptenWebGLContextAttributes contextAttributes;
contextAttributes.alpha = EM_TRUE;
contextAttributes.depth = EM_TRUE;
contextAttributes.stencil = EM_TRUE;
contextAttributes.antialias = EM_TRUE;
contextAttributes.explicitSwapControl = EM_FALSE;
contextAttributes.majorVersion = 2;
contextAttributes.minorVersion = 0;
_context = emscripten_webgl_create_context(canvasIdentifier.c_str(), &contextAttributes);
emscripten_webgl_make_context_current(_context);
}

void ColorTriangle::Destroy()
{
emscripten_webgl_destroy_context(_context);
}
````

Note that the Web Component and the WebAssembly module each have an independent lifecycle. At the point in time where the Web Component gets instantiated the WebAssembly module might not have been loaded yet. Hence, we cannot simply instantiate classes defined in the WebAssembly module once the Web Component gets instantiated.

Instead, an event is sent to the Web Component once the WebAssembly module is loaded. To do so, I first modularize the JavaScript generated by Emscripten by passing `-s MODULARIZE=1` to `emcc`. This is not strictly necessary but gives me better control of when the WebAssembly module starts loading and allows me to send a custom event once it is loaded:

````javascript
const eventModuleReady = "onmoduleready";
var module;

createModule().then(instance => {
module = instance;
document.dispatchEvent(new CustomEvent(eventModuleReady));
});
````

The Web Component listens to that event by registering an event handler in the `connectedCallback`:

````javascript
class ColorTriangle extends HTMLElement {
constructor() {
super();
this.colorTriangle = null;
}

connectedCallback() {
document.addEventListener(eventModuleReady, this.createInstance.bind(this));
...
this.createInstance();
}

createInstance() {
if(module && !this.colorTriangle) {
...
this.colorTriangle = new module.ColorTriangle(canvasIdentifier, red, green, blue);
this.colorTriangle.startRenderingLoop();
}
}
}
````

Note that the WebAssembly module might be loaded before the Web Component has a chance to register the event handler. This is why `connectedCallback` calls `this.createInstance();` as well.

This works well for me so far. Wrapping the WebAssembly module this way makes it simple to include it on any web page with a single line of HTML code and a reference to the `color-triangle.js` JavaScript file. The mentioned [working example](https://ackh.github.io/emscripten-web-component/) reflects the state that has been outlined above. The full source code is available [here](https://github.com/ackh/emscripten-web-component/tree/master).

### What does not work yet

Ideally, as a designer of the web page that hosts this Web Component I would be free to create as many instances of it as I need. In addition, I should be protected from the internals of the Web Component so that I don't accidentally interfere with them via CSS or JavaScript.

A major obstacle to achieving this is how the canvas is connected to the WebAssembly module. Passing an identifier means that, by definition, the identifier needs to be unique. Simply hard-coding `canvasIdentifier` as done above would not work because a second Web Component instance would use the same identifier.

It might work if I simply generate a random string instead of using `const canvasIdentifier = "canvas";`. However, that approach would still make the internals visible the the users of the Web Component. By "internals" I specifically mean that it is clearly visible in the DOM that there is a nested `canvas` element under `(_red) << 16 | static_cast(_green) << 8 | static_cast(_blue);
EM_ASM_ARGS(onColorChanged($0), color);
}
````

The corresponding JavaScript callback function looks as follows:

````javascript
function onColorChanged(color) {
...
}
````

The problem here is that the `onColorChanged` JavaScript function doesn't know anything about the Web Component from which a color change event gets triggered. In order to be able to inform the Web Component from which the event originated the code can be changed to receive the C++ `this` pointer. This pointer can then be compared with the address of the `this.colorTriangle` object. The new code looks as follows:

````cpp
void ColorTriangle::ColorChangedCallback()
{
uint32_t color = static_cast(_red) << 16 | static_cast(_green) << 8 | static_cast(_blue);
EM_ASM_ARGS(onColorChanged($0, $1), this, color);
}
````

The implementation of the JavaScript callback then looks as follows:

````javascript
function onColorChanged(sender, color) {
...
}
````

Finally, comparing the two addresses reveals whether the event originated from the Web Component instance or not

````javascript
if(this.colorTriangle.$$.ptr == event.detail.sender) {
// only executed if this Web Component instance triggered the event
}
````

Note that the closure compiler automatically obfuscates `$$.ptr` which makes it necessary to use an externs file to prevent this.

### Summary

Wrapping a WebAssembly module created using Emscripten as a Web Component works well if only the custom element API is used as long as only a single instance is created. Additional issues occur with WebAssembly modules containing WebGL if the shadow DOM is used.

### Next steps

I do not know at the moment how things would best be changed to enable the shadow DOM and in general multiple instances of Web Components. At the moment this is just a summary of my findings.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.