Incorporación de fragmentos de JavaScript en C++ con Emscripten

Aprende a incorporar código JavaScript en tu biblioteca WebAssembly para comunicarte con el mundo exterior.

Cuando trabajas en la integración de WebAssembly con la Web, necesitas una forma de llamar a APIs externas, como APIs web y bibliotecas de terceros. Luego, necesitas una forma de almacenar los valores y las instancias de objeto que muestran esas APIs, y una manera de pasar esos valores almacenados a otras APIs más adelante. Para las APIs asíncronas, es posible que también debas esperar promesas en tu código C/C++ síncrono con Asyncify y leer el resultado una vez que finalice la operación.

Emscripten proporciona varias herramientas para esas interacciones:

  • emscripten::val para almacenar y operar en valores de JavaScript en C++.
  • EM_JS para incorporar fragmentos de JavaScript y vincularlos como funciones C/C++.
  • EM_ASYNC_JS que es similar a EM_JS, pero facilita la incorporación de fragmentos asíncronos de JavaScript
  • EM_ASM para incorporar fragmentos cortos y ejecutarlos intercalados, sin declarar una función
  • --js-library para situaciones avanzadas en las que quieres declarar muchas funciones de JavaScript juntas como una sola biblioteca.

En esta publicación, aprenderás a usarlas todas para tareas similares.

clase emscripten::val

Embind proporciona la clase emcripten::val. Puede invocar APIs globales, vincular valores de JavaScript a instancias de C++ y convertir valores entre tipos de C++ y JavaScript.

A continuación, se muestra cómo usarla con el objeto .await() de Asyncify para recuperar y analizar datos de JSON:

#include <emscripten/val.h>

using namespace emscripten;

val fetch_json(const char *url) {
  // Get and cache a binding to the global `fetch` API in each thread.
  thread_local const val fetch = val::global("fetch");
  // Invoke fetch and await the returned `Promise<Response>`.
  val response = fetch(url).await();
  // Ask to read the response body as JSON and await the returned `Promise<any>`.
  val json = response.call<val>("json").await();
  // Return the JSON object.
  return json;
}

// Example URL.
val example_json = fetch_json("https://httpbin.org/json");

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

Este código funciona bien, pero realiza muchos pasos intermedios. Cada operación en val debe realizar los siguientes pasos:

  1. Convierte los valores de C++ que se pasaron como argumentos a algún formato intermedio.
  2. Ve a JavaScript, lee y convierte argumentos en valores de JavaScript.
  3. Ejecuta la función
  4. Convierte el resultado de JavaScript al formato intermedio.
  5. Devuelve el resultado convertido a C++, y C++ finalmente lo vuelve a leer.

Cada await() también debe pausar el lado de C++. Para ello, desenrolla toda la pila de llamadas del módulo WebAssembly, regresa a JavaScript, espera y restablece la pila de WebAssembly cuando se complete la operación.

Este código no necesita nada de C++, ya que actúa únicamente como controlador de una serie de operaciones de JavaScript. ¿Qué pasaría si pudieras trasladar fetch_json a JavaScript y reducir la sobrecarga de los pasos intermedios al mismo tiempo?

Macro EM_JS

EM_JS macro te permite mover fetch_json a JavaScript. EM_JS en Emscripten te permite declarar una función C/C++ que implementa un fragmento de JavaScript.

Al igual que WebAssembly, tiene la limitación de admitir solo argumentos numéricos y valores de retorno. Para pasar otros valores, debes convertirlos manualmente mediante las APIs correspondientes. Aquí tiene algunos ejemplos.

Para pasar los números, no se necesita ninguna conversión:

// Passing numbers, doesn't need any conversion.
EM_JS(int, add_one, (int x), {
  return x + 1;
});

int x = add_one(41);

Cuando pases cadenas desde y hacia JavaScript, debes usar las funciones de conversión y asignación correspondientes de preamble.js:

EM_JS(void, log_string, (const char *msg), {
  console.log(UTF8ToString(msg));
});

EM_JS(const char *, get_input, (), {
  let str = document.getElementById('myinput').value;
  // Returns heap-allocated string.
  // C/C++ code is responsible for calling `free` once unused.
  return allocate(intArrayFromString(str), 'i8', ALLOC_NORMAL);
});

Por último, para tipos de valores más complejos y arbitrarios, puedes usar la API de JavaScript para la clase val mencionada anteriormente. Con él, puedes convertir valores de JavaScript y clases de C++ en controladores intermedios y viceversa:

EM_JS(void, log_value, (EM_VAL val_handle), {
  let value = Emval.toValue(val_handle);
  console.log(value);
});

EM_JS(EM_VAL, find_myinput, (), {
  let input = document.getElementById('myinput');
  return Emval.toHandle(input);
});

val obj = val::object();
obj.set("x", 1);
obj.set("y", 2);
log_value(obj.as_handle()); // logs { x: 1, y: 2 }

val myinput = val::take_ownership(find_input());
// Now you can store the `find_myinput` DOM element for as long as you like, and access it later like:
std::string value = input["value"].as<std::string>();

Con esas APIs en mente, se podría reescribir el ejemplo de fetch_json para hacer la mayor parte del trabajo sin salir de JavaScript:

EM_JS(EM_VAL, fetch_json, (const char *url), {
  return Asyncify.handleAsync(async () => {
    url = UTF8ToString(url);
    // Invoke fetch and await the returned `Promise<Response>`.
    let response = await fetch(url);
    // Ask to read the response body as JSON and await the returned `Promise<any>`.
    let json = await response.json();
    // Convert JSON into a handle and return it.
    return Emval.toHandle(json);
  });
});

// Example URL.
val example_json = val::take_ownership(fetch_json("https://httpbin.org/json"));

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

Aún tenemos un par de conversiones explícitas en los puntos de entrada y salida de la función, pero el resto ahora es código JavaScript normal. A diferencia del equivalente de val, ahora el motor de JavaScript puede optimizarlo y solo requiere pausar el lado de C++ una vez para todas las operaciones asíncronas.

Macro EM_ASYNC_JS

El único bit restante que no se ve bien es el wrapper Asyncify.handleAsync, su único propósito es permitir la ejecución de funciones async de JavaScript con Asyncify. De hecho, este caso de uso es tan común que ahora hay una macro EM_ASYNC_JS especializada que las combina.

Aquí te mostramos cómo puedes usarlo para producir la versión final del ejemplo de fetch:

EM_ASYNC_JS(EM_VAL, fetch_json, (const char *url), {
  url = UTF8ToString(url);
  // Invoke fetch and await the returned `Promise<Response>`.
  let response = await fetch(url);
  // Ask to read the response body as JSON and await the returned `Promise<any>`.
  let json = await response.json();
  // Convert JSON into a handle and return it.
  return Emval.toHandle(json);
});

// Example URL.
val example_json = val::take_ownership(fetch_json("https://httpbin.org/json"));

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

EM_ASM

EM_JS es la forma recomendada de declarar fragmentos de JavaScript. Es eficiente porque vincula los fragmentos declarados de forma directa como cualquier otra importación de función de JavaScript. También proporciona una buena ergonomía, ya que te permite declarar explícitamente todos los tipos y nombres de parámetros.

Sin embargo, en algunos casos, es posible que quieras insertar un fragmento rápido para la llamada a console.log, una sentencia debugger; o algo similar y no te preocupes por declarar una función independiente completa. En esos casos poco frecuentes, una EM_ASM macros family (EM_ASM, EM_ASM_INT y EM_ASM_DOUBLE) podría ser una opción más simple. Esas macros son similares a la macro EM_JS, pero ejecutan el código intercalado donde se insertan, en lugar de definir una función.

Dado que no declaran un prototipo de función, necesitan una manera diferente de especificar el tipo de datos que se muestra y acceder a los argumentos.

Debes usar el nombre de macro correcto para elegir el tipo de datos que se muestra. Se espera que los bloques EM_ASM actúen como funciones void, los bloques EM_ASM_INT pueden mostrar un valor entero y los bloques EM_ASM_DOUBLE muestran números de punto flotante de manera correspondiente.

Cualquier argumento que se pase estará disponible con los nombres $0, $1, y así sucesivamente, en el cuerpo de JavaScript. Al igual que con EM_JS o WebAssembly en general, los argumentos se limitan solo a valores numéricos: números enteros, números de punto flotante, punteros y controladores.

Este es un ejemplo de cómo podrías usar una macro EM_ASM para registrar un valor de JS arbitrario en la consola:

val obj = val::object();
obj.set("x", 1);
obj.set("y", 2);
// executes inline immediately
EM_ASM({
  // convert handle passed under $0 into a JavaScript value
  let obj = Emval.fromHandle($0);
  console.log(obj); // logs { x: 1, y: 2 }
}, obj.as_handle());

--js-library

Por último, Emscripten admite la declaración del código JavaScript en un archivo separado, en un formato de biblioteca personalizado:

mergeInto(LibraryManager.library, {
  log_value: function (val_handle) {
    let value = Emval.toValue(val_handle);
    console.log(value);
  }
});

Luego, debes declarar los prototipos correspondientes manualmente en el lado de C++:

extern "C" void log_value(EM_VAL val_handle);

Una vez declarada en ambos lados, la biblioteca de JavaScript se puede vincular con el código principal por medio de --js-library option y conectar los prototipos con las implementaciones de JavaScript correspondientes.

Sin embargo, el formato de este módulo no es estándar y requiere anotaciones de dependencia cuidadosas. Por lo tanto, se reserva principalmente para situaciones avanzadas.

Conclusión

En esta publicación, observamos varias formas de integrar el código JavaScript en C++ cuando se trabaja con WebAssembly.

La inclusión de tales fragmentos te permite expresar secuencias largas de operaciones de una manera más limpia y eficiente, y aprovechar bibliotecas de terceros, nuevas APIs de JavaScript e incluso funciones de sintaxis de JavaScript que aún no se pueden expresar a través de C++ o Embind.