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

Aprende a incorporar código JavaScript en tu biblioteca de 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 objetos que devuelven esas APIs, y una forma de pasar esos valores almacenados a otras APIs más adelante. En el caso de 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 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 de JavaScript asíncronos.
  • EM_ASM para incorporar fragmentos cortos y ejecutarlos intercalados, sin declarar una función.
  • --js-library para situaciones avanzadas en las que deseas declarar muchas funciones de JavaScript juntas como una sola biblioteca.

En esta publicación, aprenderás a usarlas 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, te mostramos cómo usarlo con .await() de Asyncify para recuperar y analizar algunos 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++ pasados como argumentos en algún formato intermedio.
  2. Ve a JavaScript, lee los argumentos y conviértelos en valores de JavaScript.
  3. Ejecuta la función
  4. Convertir el resultado de JavaScript a un formato intermedio
  5. Devuelve el resultado convertido a C++, y C++ lo vuelve a leer.

Cada await() también debe pausar el lado de C++ desenrollando toda la pila de llamadas del módulo de WebAssembly, volviendo a JavaScript, esperando y restableciendo la pila de WebAssembly cuando se complete la operación.

Ese código no necesita nada de C++. El código C++ solo actúa como un controlador para una serie de operaciones de JavaScript. ¿Qué pasaría si pudieras mover 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 de C/C++ implementada por un fragmento de JavaScript.

Al igual que WebAssembly, tiene la limitación de admitir solo argumentos numéricos y valores de retorno. Para pasar cualquier otro valor, debes convertirlo manualmente a través de las APIs correspondientes. Aquí tienes algunos ejemplos.

Pasar números no requiere 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 de JavaScript hacia y desde 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, el ejemplo de fetch_json se podría reescribir 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>();

Todavía 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, el motor de JavaScript ahora puede optimizarlo y solo requiere detener el lado de C++ una vez para todas las operaciones asíncronas.

Macro EM_ASYNC_JS

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

A continuación, se muestra 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 directamente como cualquier otra importación de función de JavaScript. También proporciona una buena ergonomía, ya que te permite declarar de forma explícita todos los tipos y nombres de parámetros.

Sin embargo, en algunos casos, es recomendable que insertes un fragmento rápido para una llamada a console.log, una sentencia debugger; o algo similar y no quieras declarar una función independiente por completo. 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 sencilla. Esas macros son similares a la macro EM_JS, pero ejecutan código intercalado donde se insertan, en lugar de definir una función.

Como no declaran un prototipo de función, necesitan una forma 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 resultado. Se espera que los bloques EM_ASM actúen como funciones void, que los bloques EM_ASM_INT puedan mostrar un valor entero y que los bloques EM_ASM_DOUBLE muestren números de punto flotante de manera correspondiente.

Todos los argumentos que se pasen estarán disponibles 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 puedes usar una macro EM_ASM para registrar un valor 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 de código JavaScript en un archivo independiente 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 de forma manual 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 a través de --js-library option, lo que conecta los prototipos con las implementaciones de JavaScript correspondientes.

Sin embargo, este formato de 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, analizamos varias formas de integrar código JavaScript en C++ cuando se trabaja con WebAssembly.

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