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:
-
No exception crosses the boundary. The exported function is
noexceptand catches herbceptions, returning an error code. JavaScript has no C++ unwinder and should not see one. -
The boundary is bytes and integers. Export a pointer and a length; the JS
side decodes UTF-8. Do not pass
std::string,std::vectoror pointers to objects with unclear lifetime. -
fast_io does the formatting. The core does not use
std::cout,printforFILE*; it writes intou8string, which is memory the module owns.
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
-
Compile with
-O2or-Os, enable LTO, use--gc-sections, and runwasm-opt -Ozon the result. -
Keep
-fno-rttiand avoid<iostream>,<locale>and other hosted-only weight. The WasmPass reference binary is about 26 KB, which is a realistic target for a focused module. -
Build with
-gwhen debugging. Inspect the import and export tables first withwasm-objdump -x; most "it does not work" cases are a missing import, a missing export, or a constructor that was never called. - Test in the browser's DevTools, not only in Node. The WebAssembly memory, the JS boundary and the service worker only exist in the browser.
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.