Ch21.3: WebAssembly: The C++ Core in the PWA

中文(简体)

1. Why WebAssembly

The architecture from 21.1 is a portable core plus a platform-specific UI. In a PWA, the UI is HTML and CSS, and the core is WebAssembly. That gives you the same C++ code on native platforms and on the web, with one UI stack that runs on every device the browser runs on.

WebAssembly is not a replacement for native code. It is the portable target for the case where the browser is the platform. The browser is already the sandbox, so the app does not need a store, a native SDK or a platform-specific installer. The trade is a linear-memory model, a JavaScript boundary and a smaller system interface.

2. The Toolchain

Use a recent clang with the WASI sysroot. The important flags are:

clang++ --config=$HOME/cfgs/wasm32-wasip1-noeh-nomtg.cfg \
  -fherbceptions -lherbceptions \
  -nostartfiles -Wl,--no-entry \
  -Wl,--export=__wasm_call_ctors \
  -Wl,--export=__wasm_call_dtors \
  -Wl,--export=app_run \
  -Wl,--export=app_ptr \
  -Wl,--export=app_len \
  app.cpp -o app.wasm

The config targets wasm32-wasip1, uses libc++ and a no-legacy-EH sysroot, and passes -fno-exceptions. That last flag is not a contradiction: -fno-exceptions disables the legacy unwinder, while -fherbceptions enables the herbception language feature and -lherbceptions links its runtime. The WASI sysroot ships libherbceptions.a for wasm32-wasip1. Add -fno-rtti as well; the core does not need it, and it keeps the binary smaller.

-nostartfiles and --no-entry make the module a library rather than a WASI command. There is no _start; JavaScript calls the exports. You must call __wasm_call_ctors after instantiation so static constructors run, and __wasm_call_dtors if you want destructors on unload.

3. A Minimal C++ Module

The core does not print to a console. It formats into a buffer, and JavaScript reads the buffer. That is the same "device is a type" design from 20.2, with the browser as the device:

#include <cstddef>
#include <herbceptions/error>
#include <fast_io.h>
#include <fast_io_dsal/string.h>

namespace
{
::fast_io::u8string text;

void render(int value) throws
{
  if (value < 0)
  {
    throw throws ::std::errc::invalid_argument;
  }
  text.clear();
  ::fast_io::u8ostring_ref_fast_io out{__builtin_addressof(text)};
  ::fast_io::io::print(out, u8"value = ", value, u8"\n");
}
} // namespace

extern "C" int app_run(int value) noexcept
{
  try
  {
    render(value);
    return 0;
  }
  catch throws(::std::error e)
  {
    return static_cast<int>(e.to_errc());
  }
}

extern "C" const char8_t *app_ptr() noexcept { return text.c_str(); }
extern "C" ::std::size_t app_len() noexcept { return text.size(); }

Three rules are visible in that small example:

If the module uses random numbers or timestamps, fast_io will call WASI imports such as random_get and clock_time_get. You provide those from JavaScript.

4. The JavaScript Glue

Instantiate the module and provide the WASI functions it imports:

let wasm;

const imports = {
  wasi_snapshot_preview1: {
    proc_exit(code) {
      throw new Error(`wasm exited with code ${code}`);
    },
    random_get(ptr, len) {
      crypto.getRandomValues(
        new Uint8Array(wasm.exports.memory.buffer, ptr, len)
      );
      return 0;
    },
    clock_time_get(id, precision, out) {
      const ns = BigInt(Date.now()) * 1_000_000n;
      new DataView(wasm.exports.memory.buffer)
        .setBigUint64(out, ns, true);
      return 0;
    }
  }
};

const response = await fetch("/app.wasm");
const { instance } = await WebAssembly.instantiateStreaming(response, imports);
wasm = instance;
instance.exports.__wasm_call_ctors();

const status = instance.exports.app_run(42);
const ptr = instance.exports.app_ptr();
const len = instance.exports.app_len();
const text = new TextDecoder().decode(
  new Uint8Array(instance.exports.memory.buffer, ptr, len)
);
console.log(status, text);

instantiateStreaming requires the server to send Content-Type: application/wasm. If it does not, fall back to:

const bytes = await (await fetch("/app.wasm")).arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, imports);

The memory is linear and can grow. Never keep a Uint8Array across a call that may allocate; recreate the view from memory.buffer after every call that can grow it. The same rule applies to strings: the pointer is valid until the next allocation that reallocates the string, so read the bytes before calling back into the module.

5. fast_io in the Browser

The C++ side looks like ordinary fast_io code. print writes into u8string; the formatting engine does not know or care that the destination is a WebAssembly linear-memory buffer rather than a file. That is the point of the concept-based design: the device is a type, and the same formatting code instantiates for it.

The same core can be compiled natively and linked against a platform UI, or compiled to WebAssembly and linked against the web UI. The DRM, codec, crypto and networking code does not need a second implementation. That is the architecture from 21.1 made concrete.

6. Size, Speed and Debugging

7. The Reference Implementation

WasmPass is a working PWA that generates passwords, usernames and PINs with fast_io compiled to WebAssembly. It uses the same structure described here: exported functions, pointer/length getters, a wasi_snapshot_preview1 import object, and a service worker that caches the .wasm binary.

You do not need to know WasmPass's internals to use this chapter. It is the proof that the build works, not the subject. For a smaller starting point, use pwa-wasm-fast_io-demo.