Fermyon Spin
Run EdgeZero applications on Fermyon Spin, a WebAssembly-first application platform with a wasm32-wasip2 target and component-scoped KV / variable stores.
Prerequisites
Rust toolchain with
wasm32-wasip2target (rustup target add wasm32-wasip2)Spin CLI 4.1 or newer (install). The workspace's
spin-sdkimportswasi:http/types@0.3.0, which Spin 4.0.x does not provide. On an older runtime the component builds and its tests pass, and onlyspin upfails:Error: component imports instance `wasi:http/types@0.3.0`, but a matching implementation was not found in the linker
Project Setup
When scaffolding with edgezero new my-app, the Spin adapter includes:
crates/my-app-adapter-spin/
├── Cargo.toml
├── spin.toml
└── src/
└── lib.rsEntrypoint
The Spin entrypoint wires the adapter via #[http_service]:
use spin_sdk::{http::IntoResponse, http::Request, http_service};
use my_app_core::App;
#[http_service]
async fn handle(req: Request) -> anyhow::Result<impl IntoResponse> {
edgezero_adapter_spin::run_app::<App>(req).await
}run_app reads the portable store metadata baked into App by the app! macro plus EDGEZERO__* environment variables; it does not require an edgezero.toml to be present at runtime.
Building
Build the Spin component:
# Using the CLI
edgezero build --adapter spin
# Or directly
cargo build --target wasm32-wasip2 --release -p my-app-adapter-spinLocal Development
# Using the CLI
edgezero serve --adapter spin
# Or directly
spin up --from crates/my-app-adapter-spinDeployment
# Using the CLI
edgezero deploy --adapter spin
# Or directly
spin deploy --from crates/my-app-adapter-spinKV Storage
Spin KV is label-backed and multi-store — each logical id in [stores.kv].ids maps to a Spin store label declared in spin.toml. Override the label per id with EDGEZERO__STORES__KV__<ID>__NAME; with the variable unset the label defaults to the logical id.
# edgezero.toml
[stores.kv]
ids = ["sessions", "cache"]
default = "sessions"# spin.toml
[component.my-app]
key_value_stores = ["sessions", "cache"]Two Spin-specific KV constraints (see §6.7 of the design spec for the full rationale):
- TTL is unsupported. Spin's
key_value::Store::setaccepts no expiry.put_bytes_with_ttlreturnsKvError::Unsupported { operation: "put_bytes_with_ttl" }(mapped to HTTP 501); never silently strips the TTL. - Listing is capped.
Store::get_keys()is unbounded, so the adapter materialises the key list, filters by prefix, sorts, and pages client-side. Amax_list_keyscap (default1000, override viaEDGEZERO__STORES__KV__<ID>__MAX_LIST_KEYS) guards against runaway lists and yieldsKvError::LimitExceeded(HTTP 503) when exceeded.
Config Store
Spin config is KV-backed and multi-store — each logical id in [stores.config].ids opens a separate spin_sdk::key_value::Store at runtime. The store accepts arbitrary UTF-8 keys, so the canonical dotted key (service.timeout_ms) is read back verbatim — no key translation. Override the label per id with EDGEZERO__STORES__CONFIG__<ID>__NAME; with the variable unset the label defaults to the logical id.
# edgezero.toml
[stores.config]
ids = ["app_config", "feature_flags"]
default = "app_config"# spin.toml — declare every label in the component's `key_value_stores`
[component.my-app]
key_value_stores = ["app_config", "feature_flags"]# runtime-config.toml — register each custom label with a backend
# (the default `default` label is auto-provided by Spin; everything
# else needs an entry here, or `spin up` errors with
# "unknown key_value_stores label <name>").
[key_value_store.app_config]
type = "spin"
[key_value_store.feature_flags]
type = "spin"edgezero new --adapter spin scaffolds both files; edgezero serve --adapter spin runs spin up --runtime-config-file runtime-config.toml so locally-declared labels resolve to the SQLite-backed Spin KV implementation. For production, swap type = "spin" for a managed backend (type = "azure_cosmos", type = "redis", …) per the Spin runtime-config docs.
provision writes the [component.<id>].key_value_stores array for you (it does NOT touch runtime-config.toml — keep that one hand-edited).
Seeding the store
<your-cli> config push --adapter spin reads runtime-config.toml and dispatches to the right per-backend writer — no embedded HTTP endpoint, no token to manage. (Typed push runs from your downstream app CLI; the bundled edgezero config push errors with a pointer to it, since the blob model needs the typed AppConfig<C>.) Resolution order:
--localset: forces SQLite-direct against<spin.toml dir>/.spin/sqlite_key_value.db(Spin's local KV file). Useful for poking values in your local dev loop without authenticating against Fermyon Cloud. Even under--local, every non-defaultlabel MUST be declared inruntime-config.toml(see point 4 below) — without the stanza,spin uperrors withunknown key_value_stores label <name>and the file you just wrote is unreadable from the running app, so the dispatcher refuses the push and tells you exactly which stanza to add.- Manifest's
deploycommand targets Fermyon Cloud (auto-detected from[adapters.spin.commands].deploycontainingspin deployorspin cloud deploy): one batched shellout per ≤96 KiB chunk ofspin cloud key-value set --app <APP> --label <LABEL> KEY=VALUE [KEY=VALUE …].<APP>comes from[application].nameinspin.toml;<LABEL>is the env-resolved platform label (your[stores.config].idafter theEDGEZERO__STORES__CONFIG__<ID>__NAMEoverlay). EdgeZero uses Fermyon's app-scoped label model, so the operator pre-links the label to a cloud KV store viaspin cloud link key-value(or the Fermyon dashboard) before the first push; an unlinked label produces an actionable error suggesting the link command. Authentication comes fromspin cloud login— EdgeZero does not store cloud credentials. runtime-config.tomldeclares this label's backend:type = "spin"→ SQLite-direct (honours an explicitpathfield if set; otherwise the default.spin/sqlite_key_value.db).type = "redis"/azure_cosmos/ unknown → clear error pointing at the backend's native CLI (e.g.redis-cli -u <url> SET <key> <value>). Native-CLI dispatch for redis / azure is planned for a follow-up release.
- Default: only the
defaultlabel falls through to SQLite without a runtime-config stanza (Spin auto-providesdefault). Any other label MUST have a[key_value_store.<label>]entry — absent it, the dispatcher errors before any write.
Schema-coupling note: the SQLite writer uses the exact spin_key_value schema and INSERT … ON CONFLICT DO UPDATE statement vendored from spinframework/spin's crates/key-value-spin/src/store.rs. A contract test in edgezero-adapter-spin/src/cli/push_sqlite.rs asserts byte-equality against the upstream string, and the workspace's spin-sdk = "~7.0" pin blocks any Spin minor bump that would change the schema until the operator opts in.
# Local dev: writes through to .spin/sqlite_key_value.db.
<your-cli> config push --adapter spin --local
# Production (Fermyon Cloud): shells `spin cloud key-value set`.
spin cloud login # one-time
<your-cli> config push --adapter spinSecret Store
Spin secrets use spin_sdk::variables, which exposes a single flat variable namespace per component (no notion of multiple named secret stores). [stores.secrets].ids.len() > 1 while targeting Spin is caught by config validate --strict. Secret variables are declared manually in spin.toml with secret = true:
# spin.toml
[variables]
api_token = { required = true, secret = true }
[component.my-app.variables]
api_token = "{{ api_token }}"required = true blocks spin up until the variable is supplied by a provider or SPIN_VARIABLE_<NAME>. For local development, declare default = "" with secret = true instead so the component starts without one; that is the form the demo's spin.toml uses.
config validate runs a within-secrets canonicalisation check: each #[secret] field value is lowercased to mirror the runtime SpinSecretStore::get_bytes lookup, must be a valid Spin variable name (^[a-z][a-z0-9_]*$), and must not collide with another #[secret] value that lowercases to the same form.
Spin component discovery
provision and config push (Stages 6 and 7) write [component.<id>.*] blocks to spin.toml, which requires knowing the component id. Resolution:
- The CLI parses
spin.tomland enumerates[component.*]ids. - If exactly one component exists, it is used.
- If more than one exists,
[adapters.spin.adapter]must carry an explicitcomponent = "<id>"field; otherwise the command errors.
config validate --strict performs this resolution as part of its adapter-set checks when spin is in the target list, so the failure surfaces before provision / config push run.
Logging
edgezero_adapter_spin::init_logger() is a no-op today because Spin manages its own logging internally; run_app calls it unless your app sets owns_logging = true. An [adapters.spin.logging] table in edgezero.toml is not consumed by the Spin runtime. View output through spin up or your Spin host's log files.
Logging status
Spin logging is handled by the runtime; install your own log implementation in the entrypoint if you need structured output.
Proxy Client
The Spin adapter forwards outbound requests through spin_sdk::http::send:
use edgezero_adapter_spin::proxy::SpinProxyClient;
use edgezero_core::proxy::ProxyService;
let response = ProxyService::new(SpinProxyClient).forward(request).await?;Proxied responses carry x-edgezero-proxy: spin. Every upstream host must be listed in the component's allowed_outbound_hosts in spin.toml or the send fails at runtime.
Context Access
Access Spin-specific request metadata via the request context extensions:
use edgezero_core::context::RequestContext;
use edgezero_adapter_spin::context::SpinRequestContext;
async fn handler(ctx: RequestContext) -> Result<Response, EdgeError> {
if let Some(spin_ctx) = SpinRequestContext::get(ctx.request()) {
let client_addr = spin_ctx.client_addr;
let full_url = spin_ctx.full_url.as_deref();
// ...
}
// ...
}Spin exposes this data through the spin-client-addr and spin-full-url headers rather than a separate runtime object.
Testing
Run contract tests for the Spin adapter:
rustup target add wasm32-wasip2
export CARGO_TARGET_WASM32_WASIP2_RUNNER="wasmtime run"
cargo test -p edgezero-adapter-spin --features spin --target wasm32-wasip2 --test contractThe tests execute the adapter's real wasm32-wasip2 request path under Wasmtime. The wasmtime version CI uses is pinned in .tool-versions.
Manifest Configuration
Configure the Spin adapter in edgezero.toml. See Configuration for the full manifest reference.
Retaining an application
Use a concrete application-owned OnceLock<App> and call edgezero_adapter_spin::dispatch_app::<MyApp>(app, req).await on each invocation. This resolves fresh request resources from MyApp::stores() and does not install logging, construct an app, or cache native resources. Initialize any application logging before the cache's synchronous app constructor. Do not use a generic unkeyed static or hold a lock across an await.
use edgezero_core::app::{App, Hooks};
use spin_sdk::{http::{IntoResponse, Request}, http_service};
use std::sync::OnceLock;
static APP: OnceLock<App> = OnceLock::new();
#[http_service]
async fn handle(req: Request) -> anyhow::Result<impl IntoResponse> {
let app = APP.get_or_init(MyApp::build_app);
edgezero_adapter_spin::dispatch_app::<MyApp>(app, req).await
}The pinned SDK 6 macro exports a P3 HTTP interface. The wasm32-wasip2 Rust target name does not establish the component's HTTP lifecycle. Verify the emitted interface and host together. spin-sdk 6 requires Spin 3.7 or later. See Spin's instance-reuse documentation for the reuse and concurrency controls your host version supports.
Test both sequential reuse and overlapping invocations in the same instance. Different guest instances are not evidence of concurrent isolation. Keep request/response bodies and pending operations invocation-local. Existing response buffering and stream collection limits still apply. Restore run_app and remove the cache to roll back; default generated entry points are unchanged.
Next Steps
- Migration guide — moving from the pre-rewrite store schema
- Adapters overview — cross-adapter contracts
- Configuration — full manifest reference