We can rebuild them
— Imperial maxim, Warhammer 40,000: Compendium, p. 6
Our HTTP client once compiled for four hosts: a native service, a browser, a Wasmtime sandbox and a Cloudflare Worker. Only the native adapter honoured a request’s response-size override. The other three would have applied their default limit and rejected a larger response. Every build passed.
A code review caught it before the affected client was used. The fix was not a patch in three adapters. We moved the decision out of the adapters and into the shared code, so that no adapter can skip it. Since then, we have deleted two of those four adapters, and part 2 explains why.
That bug is the subject of this series. Rust can run in very different places. The useful question is which parts of a program must behave the same everywhere, and which parts belong to the place where it runs.
Keep the behaviour in shared Rust. Give each host an adapter for the work it must do itself.
This is part 1 of 3:
- One pattern, many deployments. The method, and what each of the four hosts gives the code.
- One signal, every host. One carrier client, the adapters we kept, the two we deleted, and the failures you must not retry.
- The decision and the duty. A speech detector that shares its rules but not its model, and how one crate becomes several builds.
One implementation, several builds. Each host still needs its own build and packaging.
Start with what must not change¶
“Run on a VPS and on Cloudflare” sounds like one requirement. It is really three questions: which behaviour must match, where each build runs, and what each host supplies. I answer them in six steps. Every example in parts 2 and 3 follows the same steps, with the same labels.
Where should the boundary go?¶
| The core needs… | Structure it as… | Example |
|---|---|---|
| I/O while it runs a protocol | A shared client parameterized by a host interface | Transport (part 2) |
| Results from a platform-specific engine | A state machine fed by host-produced results | VAD (part 3) |
Start from the operation you need. Extract a trait where the host adapters differ, and keep ordinary Rust function calls where they are enough. Pass time and external results into the core, instead of letting the core discover them from the host.
Put the shared code below the apps¶
The transport code separates shared libraries from applications:
Cargo workspace
├── platform HTTP contract, host adapters, Wasmtime host
│ └── src/
│ ├── http/native.rs reqwest
│ ├── http/cloudflare.rs worker::Fetch
│ └── wasmtime/ what a Wasm Component may call
├── transport-contracts carrier session, webhook and media interfaces
├── transport carrier clients
│ └── src/telephony/twilio builds and decodes Twilio requests
└── command native service: src/main.rs → executableThe application depends on transport and picks its adapter from platform. transport depends on transport-contracts and platform. No library imports an application. Every snippet in this series is an excerpt from a larger program.
Meet the four hosts¶
Three of the four hosts run WebAssembly, and that is where most of the confusion starts. The browser, Wasmtime and Cloudflare all load a .wasm file. Each one loads it differently, gives it different powers and needs different tools. So this series names a host by what runs the code, not by the file format. “Wasm” alone never tells you enough.
| Native | Browser | Wasmtime | Cloudflare | |
|---|---|---|---|---|
| Target | x86_64-unknown-linux-gnu | wasm32-unknown-unknown | wasm32-unknown-unknown* | wasm32-unknown-unknown |
| Runs in | An OS process | A page or a Web Worker | Wasmtime, inside a native host | A V8 isolate |
| Build | cargo build | Cargo, wasm-bindgen, wasm-opt, Vite | Cargo, wasm-tools component new | worker-build |
| Bindings | None | wasm-bindgen | wit-bindgen and Wasmtime’s bindgen! | worker and a generated shim |
| HTTP | reqwest | Global fetch | A host import | worker::Fetch |
| Secrets | On the server | None | Supplied by the host | Cloudflare Secrets |
| Threads | Many, so Send | One | One, in the guest | One |
| In this series | Transport, VAD | VAD | The host side, part 2 | Transport |
* The same target as the browser and Cloudflare. The Component is a packaging step after Cargo: wasm-tools component new wraps the compiled module.
Native: full access, strict thread rules¶
The native host is an ordinary Linux process with real sockets, files and threads. It is the only host that runs part of every example in this series: it dials Twilio, runs native VAD and embeds Wasmtime to run plugins. The price is the thread rule. Tokio can move a task to another thread, so anything held across an .await must be safe to move.
Browser: close to the user, never trusted¶
The browser runs the Rust module next to JavaScript, in a page or a Web Worker. Everything in it belongs to the user, so it never holds credentials. wasm-bindgen writes the glue between JavaScript and Rust, and the JavaScript side must initialize that glue once before the first call. Requests from a page also face CORS rules.
Wasmtime: a sandbox inside a host¶
Wasmtime is a host inside a host. A native Rust application embeds it and loads a Wasm Component, the guest. The Component starts with no sockets and no files. It can call only the functions that the host supplies, so the host decides every capability, limit and credential. In this series, Wasmtime appears once: in part 2, as the host that checks a plugin’s HTTP requests.
Cloudflare: server-side, single-threaded¶
The Worker runs in a V8 isolate on Cloudflare’s network. Like the browser, it has one thread. Unlike the browser, it runs on the server side and can read Secrets. worker-build compiles the Rust to Wasm and generates the JavaScript entry. Storage and other services arrive as Env bindings, which are configuration, not code, and each request has a CPU time limit.
Next: one request, and the adapters we kept¶
The method is easy to state and easy to get wrong. A build that passes on every host proves only that every adapter compiles. Part 2 follows one carrier request through its adapters, shows where the response-limit fix now lives, and explains why we deleted two adapters.