A while back I wrote about
go-catdoc, which reads
legacy .doc files from Go by running the C catdoc tool as WebAssembly
through wazero. That library also powers a free
in-browser .doc extractor on this site: drop an old
Word file, read its text and hidden metadata, with nothing uploaded.
The first version of that browser tool worked, but it was embarrassingly heavy: 7.2 MB, and almost none of it was the parser. Cutting it down came from one realization: the browser was already doing the job I was shipping a whole runtime to do.
A WebAssembly runtime inside a WebAssembly runtime
go-catdoc is a Go library, so the obvious way into a browser is Go’s own
GOOS=js GOARCH=wasm target: compile the package, load it with Go’s
wasm_exec.js glue, call it from JavaScript. That is what the first version
did, and it worked on the first try.
It also made no sense once I looked at what was actually running. catdoc is compiled to a roughly 90 KB WebAssembly module. go-catdoc runs that module with wazero, a WebAssembly runtime written in Go. Compile all of that to wasm for the browser and you get the browser’s WebAssembly engine, running the Go runtime as wasm, running wazero (now also wasm), interpreting the 90 KB catdoc module. A WebAssembly runtime, inside a WebAssembly runtime. The download came to 7.2 MB, most of it the Go runtime and wazero, there to host a module the browser could run on its own.
The browser is a WebAssembly runtime. Hand it the catdoc module directly and both wazero and the Go runtime disappear.
What “directly” costs you
catdoc’s module is a standalone WASI reactor. Unlike a command, which runs
main once and exits, a reactor is initialized once and then kept around to
have its exported functions called, which is exactly what a page wants.
Running it without wazero means supplying what wazero used to: the WASI system
calls the module imports, and a filesystem for it to read.
The module imports sixteen WASI preview1 functions (path_open, fd_read and
fd_seek, fd_write for stdout, clock_time_get, plus the usual directory
and environment stubs) and one Emscripten memory-growth hook. wazero
implements all of WASI; I only needed a read-only slice of it, which is a
couple hundred lines of JavaScript. It goes in as a plain import object:
const wasi = {
fd_read(fd, iovs, n, nreadPtr) { /* ... */ },
fd_seek(fd, offset, whence, newPtr) { /* ... */ },
path_open(dirfd, /* ... */ fdPtr) { /* ... */ },
fd_write(fd, iovs, n, nwrittenPtr) { /* capture stdout */ },
// twelve more
};
// module is already compiled, so instantiate resolves to the instance itself
const instance = await WebAssembly.instantiate(module, {
wasi_snapshot_preview1: wasi,
env: { emscripten_notify_memory_growth() {} },
});
instance.exports._initialize();
instance.exports.get_text(); // output arrives on stdout
Faking a filesystem, again
The Go library solved file access with a fake fs.FS. In the browser I do the
same thing with a Map. catdoc opens its input by path and loads
character-set tables from files, so the shim mounts the picked file at a fixed
path and the charset tables under /charsets/, all in memory. path_open
looks the path up in the map, fd_read and fd_seek walk an offset through
the bytes, and fd_write to file descriptor 1 collects stdout. catdoc opens
what it believes are files on disk and reads from JavaScript objects that never
touch one.
The charset tables are the one bulky part. catdoc ships a directory of them for converting between code pages, and text extraction needs them (the metadata functions convert nothing, so they work without a single table). Rather than fetch dozens of small files, I pack them into one bundle at build time and unpack it into the map once, on first use.
The bug that only broke the text
The first end-to-end run instantiated cleanly and returned every metadata field correctly: title, author, subject, keywords. The extracted text came back empty.
Metadata working while text failed was the clue. Text extraction is the part
that reads the charset tables, so the tables were the suspect, and they were:
every read of a charset file returned zero bytes. The cause is a detail of how
wasi-libc does buffered input. When catdoc’s fscanf reads a table, wasi-libc
hands fd_read two buffers in one call: a leading zero-length one, then the
real one. My first fd_read saw the zero-length buffer, read zero bytes into
it, and treated that as end-of-file:
// wrong: a zero-length buffer is not EOF
if (bytesRead === 0) break;
So it stopped before the real buffer ever got filled. Every table came back empty, text conversion had nothing to work with, and the metadata path, which never reads a table, was unaffected. The fix is to skip empty buffers instead of ending on them, and stop only at a genuine end of file:
for (let i = 0; i < n; i++) {
const len = view.getUint32(iovs + i * 8 + 4, true);
if (len === 0) continue; // skip, don't treat as EOF
const remaining = file.bytes.length - file.off;
if (remaining <= 0) break; // real EOF
// copy min(len, remaining) bytes into the buffer
}
After that, all nine exported functions matched the go-catdoc reference output exactly.
The result
The tool now fetches a 90 KB catdoc module and a 480 KB charset bundle, about
570 KB against the old 7.2 MB. More than ten times smaller, and the Go runtime,
wazero, and wasm_exec.js are gone. It
runs under the site’s strict Content Security Policy with only
wasm-unsafe-eval added on the tool pages, which permits compiling and
instantiating WebAssembly but not eval or new Function. The parsing still
happens entirely on the visitor’s machine.
When to skip the runtime
Shipping a language’s wasm runtime to the browser is the right call when you are moving real application code there, Go or otherwise, and want its standard library and ecosystem to come along. It is the wrong call when the thing you actually need is one standalone WASI module, and the language runtime is only there to host it. The browser already hosts WebAssembly. A reactor module with a handful of WASI imports can run on a page directly, and you write the imports it needs rather than shipping a runtime that implements all of them.
It is the same instinct as the Go version: keep the proven C, put a boundary you can test around it, and carry nothing more than that boundary needs.