Key-Value Storage
You can add key-value capabilities to a Rust component with the wasi:keyvalue interface.
This guide walks through adding wasi:keyvalue to a Rust component using wit-bindgen alongside wstd. You can use it as a model for adding any WASI interface that isn't bundled into wstd directly.
Overview
wasi:keyvalue is a draft WASI interface that gives a component access to a bucket-based key-value store: open a bucket, then get, set, delete, exists, and list-keys against it. The underlying storage is provided by the host runtime, so the same component code runs against any supported backend (in-memory, filesystem, NATS, Redis, and others) without modification.
By default, a wasmCloud deployment uses NATS JetStream for storage via the built-in WASI Key-Value NATS plugin. You can use host plugins to back wasi:keyvalue with a different storage solution.
wasi:keyvalue is a draft interface and is not bundled into wstd. To use it, your component generates Rust bindings with wit-bindgen while still relying on wstd to drive the HTTP export. The two coexist because draft interfaces like wasi:keyvalue are self-contained: they don't share types with wasi:io or other standard interfaces, so there are no type conflicts between what wasip2 (the crate wstd is built on) provides and what wit-bindgen generates.
A full working example is available as a template:
wash new https://github.com/wasmCloud/wasmCloud.git \
--name http-kv-handler \
--subfolder templates/http-kv-handlerStep 1: Declare the WIT import
Every capability your component uses must be declared in its WIT world. The wstd::http_server macro provides the wasi:http/incoming-handler export, so the world only needs to declare the wasi:keyvalue/store import.
In wit/world.wit:
package wasmcloud:http-kv-handler;
// wasi:http/incoming-handler is exported via wstd's #[http_server] proc macro.
world http-kv-handler {
import wasi:keyvalue/store@0.2.0-draft;
}If you also need atomic operations like increment, add wasi:keyvalue/atomics@0.2.0-draft as a second import.
When you run wash build, the build pipeline calls wash wit fetch, which resolves the WIT package references and downloads their definitions into wit/deps/. If you have not run wash wit fetch before, you may need to run it manually the first time before cargo build works.
Step 2: Add the dependencies
In Cargo.toml:
[package]
name = "http-kv-handler"
edition = "2024"
version = "0.1.0"
[lib]
crate-type = ["cdylib"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wit-bindgen = "0.46"
wstd = "0.6"Any wit-bindgen 0.4x release works; 0.46 is what current wstd releases pair cleanly with.
Step 3: Generate bindings and use the interface
In src/lib.rs, generate bindings for the wasi:keyvalue/store import and call them from inside a #[wstd::http_server] handler:
wit_bindgen::generate!({
world: "http-kv-handler",
path: "wit",
generate_all,
});
use serde::Deserialize;
use wasi::keyvalue::store::open;
use wstd::http::{Body, Method, Request, Response, StatusCode};
const BACKEND: &str = "in_memory";
#[derive(Deserialize)]
struct KvPayload {
key: String,
value: String,
}
#[wstd::http_server]
async fn main(mut req: Request<Body>) -> Result<Response<Body>, wstd::http::Error> {
match *req.method() {
Method::POST => post(&mut req).await,
Method::GET => get(&req).await,
_ => Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(Body::from("Method Not Allowed\n"))
.map_err(Into::into),
}
}
async fn post(req: &mut Request<Body>) -> Result<Response<Body>, wstd::http::Error> {
let body_bytes = req.body_mut().contents().await?.to_vec();
let payload: KvPayload = serde_json::from_slice(&body_bytes)
.map_err(|e| wstd::http::Error::msg(format!("invalid JSON: {e}")))?;
let bucket = open(BACKEND)
.map_err(|e| wstd::http::Error::msg(format!("open bucket: {e:?}")))?;
bucket
.set(&payload.key, payload.value.as_bytes())
.map_err(|e| wstd::http::Error::msg(format!("set: {e:?}")))?;
Ok(Response::new(Body::from(format!("[{BACKEND}] Stored '{}'\n", payload.key))))
}
async fn get(req: &Request<Body>) -> Result<Response<Body>, wstd::http::Error> {
let path_and_query = req.uri().path_and_query().map(|p| p.as_str()).unwrap_or("");
let Some(key) = parse_query_param(path_and_query, "key") else {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from("Missing required query parameter: key\n"))
.map_err(Into::into);
};
let bucket = open(BACKEND)
.map_err(|e| wstd::http::Error::msg(format!("open bucket: {e:?}")))?;
match bucket
.get(&key)
.map_err(|e| wstd::http::Error::msg(format!("get: {e:?}")))?
{
Some(bytes) => Ok(Response::new(Body::from(format!(
"[{BACKEND}] {}\n",
String::from_utf8_lossy(&bytes)
)))),
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from(format!("[{BACKEND}] Key '{key}' not found\n")))
.map_err(Into::into),
}
}
fn parse_query_param(path_and_query: &str, param: &str) -> Option<String> {
let query = path_and_query.splitn(2, '?').nth(1)?;
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
if parts.next()? == param {
return parts.next().map(|v| v.to_string());
}
}
None
}The generate_all option tells wit-bindgen to generate bindings for every interface in the world. The wasi:keyvalue/store interface lands at wasi::keyvalue::store. The HTTP export is still owned by wstd's #[http_server] macro, not by wit-bindgen.
Opening a bucket and choosing a backend
Every keyvalue operation goes through a Bucket resource returned by store::open. The string you pass to open is a logical bucket name. The wasmCloud runtime maps it to a physical store based on .wash/config.yaml (or the host's wasi:keyvalue plugin configuration in production), so the same component code runs against any supported backend without modification.
The shipped http-kv-handler template uses the bucket name as a logical "backend" identifier. Set BACKEND in src/lib.rs to one of the values below and uncomment the matching block in .wash/config.yaml:
BACKEND | Required config key | Example value |
|---|---|---|
in_memory | (none, default in wash dev) | — |
filesystem | wasi_keyvalue_path | /tmp/keyvalue-store |
nats | wasi_keyvalue_nats_url | nats://127.0.0.1:4222 |
redis | wasi_keyvalue_redis_url | redis://127.0.0.1:6379 |
The component code is unchanged across backends.
Treat the bucket as a per-request resource and open it inside each handler rather than caching it at module scope.
Serialization: values are list<u8>
wasi:keyvalue/store uses list<u8> (Vec<u8> in Rust) for values. To store structured data, serialize before set and deserialize after get:
let item_bytes = serde_json::to_vec(&item).map_err(|e| format!("serialize: {e}"))?;
bucket.set(key, &item_bytes).map_err(|e| format!("set: {e:?}"))?;
if let Some(bytes) = bucket.get(key).map_err(|e| format!("get: {e:?}"))? {
let item: Item = serde_json::from_slice(&bytes).map_err(|e| format!("deserialize: {e}"))?;
}Synchronous API
All wasi:keyvalue/store calls (open, get, set, delete, exists, list-keys) are synchronous in WIT. The wit-bindgen-generated bindings expose them as plain functions, not async fn. They work just as well from a synchronous handler if your component does not need async.
Alternative: wit-bindgen-only (no wstd)
If you want full control over the HTTP export and prefer not to bring in wstd, you can let wit-bindgen generate the wasi:http/incoming-handler export too. Declare both the keyvalue import and the HTTP export in your WIT world:
package wasmcloud:http-kv-handler;
world http-kv-handler {
import wasi:keyvalue/store@0.2.0-draft;
export wasi:http/incoming-handler@0.2.2;
}Then implement the Guest trait directly:
mod bindings {
wit_bindgen::generate!({
generate_all,
});
}
use bindings::{
exports::wasi::http::incoming_handler::Guest,
wasi::{
http::types::{Fields, IncomingRequest, OutgoingBody, OutgoingResponse, ResponseOutparam},
keyvalue::store::open,
},
};
struct Component;
impl Guest for Component {
fn handle(request: IncomingRequest, response_out: ResponseOutparam) {
// ... assemble the response by hand
}
}
bindings::export!(Component with_types_in bindings);This gives you the lowest-level WIT API at the cost of more boilerplate. See Adding custom WASI interfaces in the language guide for the full pattern, including handling type conflicts when imports overlap with wasi:io.
Build and verify
wash devwash dev builds the component, starts an HTTP server on http://localhost:8000, and provisions the configured wasi:keyvalue backend automatically.
# Store a value
curl -X POST http://localhost:8000 \
-H "Content-Type: application/json" \
-d '{"key":"mykey","value":"myvalue"}'
# Retrieve it
curl "http://localhost:8000?key=mykey"
# Missing key returns 404
curl "http://localhost:8000?key=missing"To verify persistence with a non-default backend, pick a backend in .wash/config.yaml (filesystem, NATS, Redis), restart the component, and confirm previously stored keys are still readable.
Summary: checklist for adding any WIT interface
- Add
importlines towit/world.witfor the interfaces you need. - Add
wit-bindgentoCargo.tomlif it is not already a dependency. Any 0.4x release works. - Generate bindings with
wit_bindgen::generate!({ generate_all }). For interfaces that share types withwasi:io(likewasi:blobstore), use thewithoption to point shared types atwasip2's definitions to avoid duplicates. - Use the generated functions at
wasi::<package>::<interface>::*(orbindings::wasi::<package>::<interface>::*if you wrap the macro in a module). - Handle serialization. Most WIT interfaces use
list<u8>(Vec<u8>) for binary data. Useserde_jsonfor structured data. - Stable WASI 0.2 interfaces like
wasi:cli/environmentandwasi:filesystemare available via thewasip2crate withoutwit-bindgen. Draft interfaces likewasi:keyvalueneedwit-bindgen. See Adding custom WASI interfaces for the distinction.
API reference: wasi:keyvalue/store operations used
| Operation | Signature | Description |
|---|---|---|
open(name) | fn(&str) -> Result<Bucket, Error> | Open a named bucket |
bucket.get(key) | fn(&str) -> Result<Option<Vec<u8>>, Error> | Get a value by key, None if missing |
bucket.set(key, value) | fn(&str, &[u8]) -> Result<(), Error> | Set a key to a value |
bucket.delete(key) | fn(&str) -> Result<(), Error> | Delete a key |
bucket.exists(key) | fn(&str) -> Result<bool, Error> | Check if a key exists |
bucket.list_keys(cursor) | fn(Option<String>) -> Result<KeyResponse, Error> | List keys with pagination cursor |
Further reading
- Rust Language Guide — toolchain overview, HTTP patterns, framework integration, and library compatibility
- Configuration — read configuration values from ConfigMaps, Secrets, and inline environment variables
- Filesystem — read and write files from preopened directories
- Language Support overview — summary of all supported languages and toolchains