DEV Community

Cover image for A Simple Web App Using Vite+React, C++ / Emscripten / WebAssembly, and a Web Worker
Joy Hughes
Joy Hughes

Posted on

A Simple Web App Using Vite+React, C++ / Emscripten / WebAssembly, and a Web Worker

Rationale

Legacy or purpose-written code in C++ or other languages may be compiled into WebAssembly using Emscripten and used to create web apps. The user's device performs all the computation in a platform independent manner at near-native speed, reducing server load.

Long computations in a single thread environment can block interaction with the page, or even lock up the browser! A multithreaded approach is necessary. WebAssembly does not support native C++ multithreading, so a different approach is needed. While Emscripten offers a few different ways to perform multithreading (e.g. pthreads and WASM workers), for this example I present a method using a single-threaded WASM module running within a web worker.

Examples using the DOM

To begin with, I created two demos using the HTML Document Object Model. This is a simple way to demonstrate the advantages of using a web worker. The demos each have two canvases - the canvas on the left visualizes a solution to the traveling salesman problem (TSP), while the canvas on the right shows a circle that can be resized using a slider. The TSP canvas shows progressively improving solutions until the calculation is complete. The demo uses brute force to calculate solutions to TSP, which runs in O(n!) time - while calculating a solution for 10 points takes less than a second, a solution for 15 points can take hours!

Interface for traveling salesman demo

An example using a single thread is shown here. Note that interaction with the circle is not possible while the TSP calculation is running. In addition, the visualization of interim solutions may not display properly, and the browser may display a warning message.

Now try the multithreaded DOM demo. In this example, calculation happens in the background using a web worker, and interaction with the circle is not blocked.

Example using Vite+React

The full demo uses the React framework in combination with a web worker running Emscripten-generated WebAssembly. Try it now. Again, interaction with the circle can proceed smoothly.

Let's break down how this works:

C++ code

The top of the tsp.cpp file gives the necessary includes, defines a simple point structure, calculates the distance between two points, and creates a vector of random points with a given length:

#include <vector>
#include <algorithm>
#include <cmath>
#include <limits>
#include <random>
#include <string>
#include <emscripten/bind.h>

struct Point {
    int x;
    int y;
};

// Function to calculate the Euclidean distance between two points
double distance(const Point& p1, const Point& p2) {
    return std::sqrt((p1.x - p2.x) * (p1.x - p2.x) * 1.0 + (p1.y - p2.y) * (p1.y - p2.y) * 1.0);
}

// Function to generate n random points on a 200x200 grid
std::vector<Point> generateRandomPoints(int n) {
    std::vector<Point> points;
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> dis(0, 199);

    for (int i = 0; i < n; ++i) {
        points.push_back({dis(gen), dis(gen)});
    }

    return points;
}
Enter fullscreen mode Exit fullscreen mode

Now, generate a JSON string from a vector of points and a given total length for the path. This string will be returned to the web worker:

// Function to convert a list of points to a JSON string, including the path length
std::string pointsToJson(const std::vector<Point>& points, double pathLength) {
    std::string result = "{";
    result += "\"points\": [";
    for (size_t i = 0; i < points.size(); ++i) {
        result += "{\"x\":" + std::to_string(points[i].x) + ",\"y\":" + std::to_string(points[i].y) + "}";
        if (i != points.size() - 1) {
            result += ",";
        }
    }
    result += "], \"pathLength\": " + std::to_string(pathLength) + "}";
    return result;
}
Enter fullscreen mode Exit fullscreen mode

A sample JSON string generated by this code is shown below (formatted for readability):

{
     "points": [
          {"x":70,"y":144},{"x":48,"y":97},{"x":63,"y":90},
          {"x":69,"y":94},{"x":43,"y":50},{"x":18,"y":28}, 
          {"x":16,"y":30},{"x":64,"y":0},{"x":161,"y":30},
          {"x":162,"y":127},{"x":152,"y":145},{"x":142,"y":135},
          {"x":82,"y":196}
     ], 
     "pathLength": 591.701161
}
Enter fullscreen mode Exit fullscreen mode

Here is the code to calculate the TSP solution. It receives a callback from JavaScript which it calls with the interim solutions:

// Function to solve the Traveling Salesman Problem using brute force
std::vector<Point> tsp(const std::vector<Point>& points, emscripten::val callback) {
    int n = points.size();
    std::vector<int> indices(n);
    for (int i = 0; i < n; ++i) {
        indices[i] = i;
    }

    double min_path_length = std::numeric_limits<double>::infinity();
    std::vector<int> best_order;

    do {
        double current_path_length = 0;
        for (int i = 0; i < n - 1; ++i) {
            current_path_length += distance(points[indices[i]], points[indices[i + 1]]);
        }
        current_path_length += distance(points[indices[n - 1]], points[indices[0]]); // Return to the starting point

        if (current_path_length < min_path_length) {
            min_path_length = current_path_length;
            best_order = indices;

            // Call the callback with the improved path
            std::vector<Point> ordered_points;
            for (int idx : best_order) {
                ordered_points.push_back(points[idx]);
            }
            std::string json_result = pointsToJson(ordered_points, min_path_length);
            callback(json_result);
        }
    } while (std::next_permutation(indices.begin() + 1, indices.end())); // Fix first point to eliminate redundant permutations

    std::vector<Point> ordered_points;
    for (int idx : best_order) {
        ordered_points.push_back(points[idx]);
    }

    return ordered_points;
}

// Function to run TSP and return the result as a JSON string
std::string runTSP(int n, emscripten::val callback) {
    std::vector<Point> points = generateRandomPoints(n);
    std::vector<Point> ordered_points = tsp(points, callback);
    double path_length = 0;
    for (size_t i = 0; i < ordered_points.size() - 1; ++i) {
        path_length += distance(ordered_points[i], ordered_points[i + 1]);
    }
    path_length += distance(ordered_points.back(), ordered_points.front());
    return pointsToJson(ordered_points, path_length);
}
Enter fullscreen mode Exit fullscreen mode

Finally, Emscripten's Embind functionality is used to define a hook that can be called from JavaScript:

EMSCRIPTEN_BINDINGS(my_module) {
    emscripten::function("runTSP", &runTSP);
}
Enter fullscreen mode Exit fullscreen mode

Compiling the C++ code into WASM using Emscripten

Install Emscripten using the instructions here. This is fairly easy on a Mac or Linux, a little trickier on Windows.

I have provided a Makefile in my repo that builds all the examples. When building using Emscripten it is essential to use the proper build flags, or your web app won't work! Here is the exact command I use while buiding:

em++ -std=c++20 -Wall -Wextra -O2 -s NO_EXIT_RUNTIME=1 -s ALLOW_MEMORY_GROWTH=1 -s MODULARIZE -s SINGLE_FILE=1 -s NO_DISABLE_EXCEPTION_CATCHING -s EXPORT_ES6=1 -lembind -s ENVIRONMENT=worker src/tsp.cpp -o tsp_worker_react/src/wasm/tsp.js
Enter fullscreen mode Exit fullscreen mode

Let's go through the flags one by one and see what they do...

  • -std=c++20 -Wall -Wextra - Use the C++20 standard and give all the warnings
  • -O2 - Unoptimized Emscripten generated WASM can run slowly, so it's best to optimize. -O3 might be faster but should be used with caution as it can cause certain C++ code to be skipped over.
  • -s NO_EXIT_RUNTIME=1 - This keeps the module ready between calls and prevents premature freeing of its resources
  • -s ALLOW_MEMORY_GROWTH=1 - Allows the module to claim more memory when needed. Useful for modules with dynamically allocated memory.
  • -s MODULARIZE - Creates a module that can properly be loaded by JavaScript
  • -s SINGLE_FILE=1 - Packages all WASM code and data into a single file. Essential for loading into React.
  • -s NO_DISABLE_EXCEPTION_CATCHING - Allows module to throw exceptions. May carry a performance penalty.
  • -s EXPORT_ES6=1 - Export as an ES6 module. Essential when bundling with Vite.
  • -lembind - Use Emscripten's Embind functionality, a smoother way of calling functions in the C++ code from JavaScript.
  • -s ENVIRONMENT=worker - Used for modules loaded by web workers

Web worker

The web worker loads the compiled WASM module tsp.js - this can take some time, so the listener for messages from the main thread is initialized once the module is ready. Messages received prior to this will be ignored.

When ready, the worker receives a message from the main thread to run TSP with a given number of points. It calls the C++ code via Embind, also providing the callback function.

The callback sends a message to the main thread with an interim list of points and path length. On completion of the TSP calculation, a similar message is sent with a tag showing a result has been achieved.

import Module from '../wasm/tsp.js';

let tspModule = null;

Module().then((initializedModule) => {
  tspModule = initializedModule;
  console.log("Emscripten Module Initialized");

  self.addEventListener('message', (event) => {
    if (event.data.action === 'runTSP') {
      const numPoints = event.data.numPoints;
      try {
        const callback = (interimJson) => {
          // Send interim points to the main thread
          self.postMessage({ action: 'interimTSP', result: interimJson });
        };

        const result = tspModule.runTSP(numPoints, callback);
        // Send the final result to the main thread
        self.postMessage({ action: 'tspResult', result: result });
      } catch (error) {
        console.error("Error running TSP:", error);
        self.postMessage({ action: 'tspError', message: error.message });
      }
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

React Code

The React code is arranged in the following hierarchy:

Image description

We are going to dissect TspVisualization.jsx here. (To view the rest of the code go to the repo.)

First, we import some standard React stuff, as well as the worker. This exact syntax is necessary for Vite to bundle the worker properly:

import React, { useEffect, useRef, useState } from 'react';
import TspWorker from '../workers/tspWorker.js?worker';
Enter fullscreen mode Exit fullscreen mode

The next section of the code initializes the worker and handles messages, making calls to render the path and change the path length. (Error handling is left as an exercise for the interested collaborator.)

const TspVisualization = () => {
  const tspCanvasRef = useRef(null);
  const [worker, setWorker] = useState(null);
  const [isRunning, setIsRunning] = useState(false);
  const [numPoints, setNumPoints] = useState(13);
  const [pathLength, setPathLength] = useState(0);

  useEffect(() => {
    const createWorker = () => {
      const tspWorker = new TspWorker();
      tspWorker.onmessage = (event) => {
        if (event.data.action === 'tspResult') {
          setIsRunning(false);
          tspCanvasRef.current.style.backgroundColor = 'white';
          clearCanvas();
          console.log(event.data.result);
          const result = JSON.parse(event.data.result);
          drawTspPath(result.points, 'black');
          setPathLength(result.pathLength);
        } else if (event.data.action === 'interimTSP') {
          clearCanvas();
          console.log(event.data.result);
          const result = JSON.parse(event.data.result);
          setPathLength(result.pathLength);
          drawTspPath(result.points, 'red');
        }
      };
      return tspWorker;
    };

    const tspWorker = createWorker();
    setWorker(tspWorker);

    return () => {
      tspWorker.terminate();
    };
  }, []);
Enter fullscreen mode Exit fullscreen mode

The code below draws the visualization:

  const clearCanvas = () => {
    const tspCanvas = tspCanvasRef.current;
    const tspCtx = tspCanvas.getContext('2d');
    tspCtx.clearRect(0, 0, tspCanvas.width, tspCanvas.height);
  };

  const drawTspPath = (points, color) => {
    const tspCanvas = tspCanvasRef.current;
    const tspCtx = tspCanvas.getContext('2d');
    tspCtx.strokeStyle = color;
    tspCtx.beginPath();
    tspCtx.moveTo(points[0].x, points[0].y);
    for (let i = 1; i < points.length; i++) {
      tspCtx.lineTo(points[i].x, points[i].y);
    }
    tspCtx.closePath();
    tspCtx.stroke();
  };
Enter fullscreen mode Exit fullscreen mode

Next, handle clicks on the buttons to run and stop the TSP calculation. Stopping involves terminating the worker process and starting a new one:

  const handleRunClick = () => {
    if (worker) {
      setIsRunning(true);
      tspCanvasRef.current.style.backgroundColor = 'black';
      worker.postMessage({ action: 'runTSP', numPoints });
    }
  };

  const handleStopClick = () => {
    if (worker) {
      worker.terminate();
      const newWorker = new TspWorker();
      newWorker.onmessage = worker.onmessage;
      setWorker(newWorker);
      setIsRunning(false);
      tspCanvasRef.current.style.backgroundColor = 'white';
    }
  };
Enter fullscreen mode Exit fullscreen mode

Finally, output the JSX including the slider and export the component:

  return (
    <div>
      <canvas ref={tspCanvasRef} width={200} height={200} style={{ border: '1px solid black', backgroundColor: 'white' }}></canvas>
      <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginTop: '10px' }}>
        <label htmlFor="pointsSlider">Number of Points (2-15): </label>
        <input
          type="range"
          id="pointsSlider"
          min="2"
          max="15"
          value={numPoints}
          onChange={(e) => setNumPoints(e.target.value)}
        />
        <div>Points: {numPoints}</div>
        <div>Path Length: {pathLength.toFixed(2)}</div>
        <button onClick={handleRunClick} disabled={isRunning}>Run TSP</button>
        <button onClick={handleStopClick} disabled={!isRunning}>Stop TSP</button>
      </div>
    </div>
  );
};

export default TspVisualization;
Enter fullscreen mode Exit fullscreen mode

Configuring Vite

Install Vite and create a new app using the instructions given here.
I use the following vite.config.js:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  base: './',
  plugins: [react()],
  server: {    
      // this ensures that the browser opens upon server start
      open: true,
      // this sets a default port to 3000  
      port: 3000, 
  },
  resolve: {
      alias: {
        '@': '/src',
      },
    },
});
Enter fullscreen mode Exit fullscreen mode

Note the line base: './', - this is essential if the app is hosted in anything but the root directory. Remember this, it will save you great pain!

Repo

All code, Makefile, etc. is hosted in my repo on GitHub under the MIT License. Enjoy!

Top comments (0)