Skip to main content
Version: v2

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.

info

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.

Complete example

A full working example is available as a template:

shell
wash new https://github.com/wasmCloud/wasmCloud.git \
  --name http-kv-handler \
  --subfolder templates/http-kv-handler

Step 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:

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:

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:

rust
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:

BACKENDRequired config keyExample value
in_memory(none, default in wash dev)
filesystemwasi_keyvalue_path/tmp/keyvalue-store
natswasi_keyvalue_nats_urlnats://127.0.0.1:4222
rediswasi_keyvalue_redis_urlredis://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:

rust
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:

wit
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:

rust
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

shell
wash dev

wash dev builds the component, starts an HTTP server on http://localhost:8000, and provisions the configured wasi:keyvalue backend automatically.

shell
# 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

  1. Add import lines to wit/world.wit for the interfaces you need.
  2. Add wit-bindgen to Cargo.toml if it is not already a dependency. Any 0.4x release works.
  3. Generate bindings with wit_bindgen::generate!({ generate_all }). For interfaces that share types with wasi:io (like wasi:blobstore), use the with option to point shared types at wasip2's definitions to avoid duplicates.
  4. Use the generated functions at wasi::<package>::<interface>::* (or bindings::wasi::<package>::<interface>::* if you wrap the macro in a module).
  5. Handle serialization. Most WIT interfaces use list<u8> (Vec<u8>) for binary data. Use serde_json for structured data.
  6. Stable WASI 0.2 interfaces like wasi:cli/environment and wasi:filesystem are available via the wasip2 crate without wit-bindgen. Draft interfaces like wasi:keyvalue need wit-bindgen. See Adding custom WASI interfaces for the distinction.

API reference: wasi:keyvalue/store operations used

OperationSignatureDescription
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