llhttp in Wasm, and a real Postgres in the tab
Two shorter pieces this time, connected by a theme: running Node's real source keeps paying out in places you did not plan for, and WebAssembly turns out to cover a lot of what "you need a native binary for that" used to mean.
Part one: the HTTP parser
Node's lib/http does not parse HTTP. It delegates to
internalBinding('http_parser'), which in real Node is llhttp, a C parser
compiled into the binary.
We had written a pure-JavaScript HTTP/1.1 parser to fill that binding. It
worked, in the sense that ordinary requests went in and ordinary responses came
out. It also had the failure profile every hand-written protocol parser has: the
common path is fine and the edges are a long tail of things you have not thought
about yet. Trailers. HEAD responses, which have Content-Length but no body.
204, which has neither. Chunked encoding with extensions. Pipelining. Upgrade
and CONNECT hand-off, where the parser has to stop parsing mid-stream and hand
the raw socket over.
The right answer is to run the same parser Node runs.
We did not build a toolchain to do it. Standing up wasi-sdk and clang just
to recompile llhttp would produce an artifact essentially identical to one that
already ships publicly. undici bundles a prebuilt llhttp.wasm from the same
upstream project, under the same MIT licence. So a vendoring script pins the
undici version and regenerates a binding module with the binary base64-embedded,
about 54 KB. No fetch at runtime, no build dependency.
Two details made it interesting.
It has to compile synchronously. The binding is constructed at process
bootstrap, inside the
synchronous world everything else lives in, so
the module is built with new WebAssembly.Module() rather than the async
compile. That is allowed on a worker thread, which is where guest processes
run. On the main thread there is a 4 KB size cap on synchronous compilation, and
a 54 KB module throws, which turns out to be a convenient way to detect the
environment. Exceeding the cap is precisely what trips the pure-JS fallback, so
the JS parser stays in the tree as the main-thread path rather than as dead
code. VV_HTTP_PARSER=js|wasm forces either side, with wasm failing loudly
instead of falling back, so tests can assert which one they exercised.
The bridge has to be numerically exact. llhttp reports progress through span
callbacks: on_url, on_status, on_header_field, on_header_value,
on_body, on_headers_complete, on_message_complete. Node's
lib/_http_common.js does not consume those; it expects a specific set of
numeric kOn* slots. The binding mirrors what Node's own node_http_parser.cc
does: drive llhttp's callbacks, fold them onto the exact kOn* contract, for
both requests and responses. allMethods follows llhttp's method enum, so
allMethods[llhttp_get_method()] round-trips the way callers assume.
When the Wasm backend is live it advertises process.versions.llhttp, exactly
as real Node does. Twenty offline checks guard it in CI, plus an extended HTTP
case covering HEAD, 204, chunked requests and responses, trailers and
keep-alive, run against both backends, because a fallback nobody tests is a
fallback that does not work.
You can watch it parse. The server below binds a port inside this tab and then fetches from itself:
Part two: databases
"No native database" is the limitation every in-browser runtime lists, and the documentation usually stops at suggesting workarounds.
It is worth asking why the limitation exists. The blocker is not SQL. It is that database clients are native addons, and there is no compiler in the tab. So the question becomes: what do you need in order for a Wasm-compiled engine to work, and do we already have it?
The answer was yes, and it had nothing to do with databases. A Wasm SQL engine
needs a real fs to read its data files, a working url module, and the host's
WebAssembly, all of which exist because of decisions made for other reasons.
So this was less a feature than a discovery.
SQLite via sql.js. SQLite compiled to Wasm. initSqlJs() finds its .wasm
next to itself with locateFile: (f) => require.resolve('sql.js/dist/' + f),
which resolves over the virtual filesystem like any other module path.
PostgreSQL via PGlite. This is real PostgreSQL, currently 18, compiled to
Wasm. About 16 MB of pglite.wasm and pglite.data, read out of
node_modules through the virtual filesystem: the package resolves them from
__filename, builds a new URL('./pglite.wasm', ...), and calls
fs.readFile. Every step of that is ordinary Node behaviour, which is the whole
point.
One deliberate choice: we use PGlite's CommonJS build. The ESM build relies on top-level await, and in-VM only the entry module can block on TLA. Choosing CJS avoids the problem entirely.
And one we deliberately did not ship. libSQL is not available as an in-VM
template, and the reason is worth stating rather than leaving as a gap in a
table. @libsql/client in local mode is a native N-API addon with no wasm32
build. @libsql/client/web works, but only talks to a remote Turso server,
which is a network client, not a database in the tab. Neither is
self-contained, so neither belongs in a list of things that run with no server.
sql.js remains the local SQLite path.
Both engines were confirmed end-to-end in plain Node first, against the same
fs, url and WebAssembly primitives the runtime exposes, before either was
wired into a template. Both are then gated by network spikes in CI that install
the dependency, bind a port, and assert that the API reports the right engine
version and returns seeded rows, with a longer budget for PGlite, whose install
and first-boot Wasm compile are genuinely heavy.
The pattern
Both of these landed for the same reason, and it is not cleverness.
The HTTP parser worked because Node had already defined the seam
(internalBinding('http_parser')) and someone had already compiled the C to
Wasm. The databases worked because the runtime implemented fs and url
properly rather than implementing the subset our own demos needed.
Neither was planned. Both fell out of building the layer underneath correctly and then discovering what it supported. That is a much better position to be in than the alternative, and it is most of the argument for the architecture these posts keep coming back to.