skyzen 0.1.2

A fast, ergonomic HTTP framework that works everywhere
docs.rs failed to build skyzen-0.1.2
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
Visit the last successful build: skyzen-0.1.0

Skyzen

crates.io doc.rs License Coverage

An HTTP framework for Rust that compiles to a native Tokio server or to a WinterCG fetch handler for Cloudflare Workers, from the same source.

Most Rust web frameworks assume a long-lived process with a thread pool. Most edge SDKs assume a single-threaded WASM sandbox and hand you provider types directly. Skyzen targets both: handlers are written against portable traits (Kv, Storage, Queue, Db), Send bounds are applied conditionally per target, and #[skyzen::main] expands to either a fn main() or a fetch export depending on target_arch. When you need something a portable trait can't express — D1's raw SQL, Durable Objects, alarms — the provider types are still there.

Quick start

[dependencies]
skyzen = "0.1"
use skyzen::routing::{CreateRouteNode, Route, Router};

#[skyzen::main]
fn main() -> Router {
    Route::new((
        "/".at(|| async { "Hello, World!" }),
        "/health".at(|| async { "OK" }),
    ))
    .build()
}

cargo run starts the server and logs its address. cargo run -- --port 8787 pins the port.

Routing

Routes are built from path literals. CreateRouteNode adds .at(), .get(), .post(), .put(), .delete(), .ws(), and .route() to &str, so a route tree is just a tuple of them:

use skyzen::routing::{CreateRouteNode, Params, Route, Router};

fn router() -> Router {
    Route::new((
        "/".at(home),
        "/users/{id}".at(|params: Params| async move {
            let id = params.get("id")?;
            Ok(format!("User: {id}"))
        }),
        "/posts".get(list_posts),
        "/posts".post(create_post),
        "/posts/{id}".put(update_post),
        "/posts/{id}".delete(delete_post),
        "/admin".route((
            "/stats".at(stats),
            "/flush".post(flush),
        )),
    ))
    .build()
}

Matching is done by matchit's radix tree, so a request is resolved by walking its own path rather than by testing routes one at a time.

Handlers

A handler is an async function. Its arguments are extractors, its return type is a responder — nothing needs to be registered, and tuples of either compose automatically.

use skyzen::{extract::Query, routing::Params, utils::Json, Result};

async fn create_user(Json(body): Json<CreateUser>) -> Result<Json<User>> {
    Ok(Json(User::insert(body).await?))
}

async fn search(Query(query): Query<SearchQuery>, params: Params) -> Json<Page> {
    // ...
}

Json, Form, Query, Params, Multipart, State, BearerToken, and ClientIp are extractors out of the box. String, &str, Json<T>, Response, and Result<T> are responders. Implement Extractor or Responder for your own types.

Errors

#[skyzen::error] writes the Display, Error, and HttpError impls, and maps each variant to a status code. Messages interpolate fields the same way thiserror does:

#[skyzen::error(message = "internal server error")]
enum ApiError {
    #[error("no user with id {0}", status = NOT_FOUND)]
    UserNotFound(u64),
    #[error("field {field} is invalid: {reason}", status = BAD_REQUEST)]
    Invalid { field: String, reason: String },
    #[error("upstream {service} timed out", status = GATEWAY_TIMEOUT)]
    Timeout { service: &'static str },
}

Returning Err(ApiError::UserNotFound(7)) from a handler produces 404 {"error":"no user with id 7"}. Note that 5xx responses replace the message with a generic "Internal server error" — a Display impl is for your logs, and shouldn't leak into a client's error body by accident.

Portable services

skyzen-services exposes four capability wrappers — Kv, Storage, Queue, and Db. Handlers take them as extractors and never name a provider type:

use skyzen_services::{Db, Kv, Storage};

async fn handler(kv: Kv, storage: Storage, db: Db) -> Result<Json<Data>> {
    let cached = kv.get_json::<Data>("cache:key").await?;
    let logo = storage.get("assets/logo.png").await?;
    let users = db.query("SELECT id, name FROM users").fetch_all::<User>().await?;
    Ok(Json(cached.unwrap_or_default()))
}

The backend is chosen once, at wiring time:

// Native
let kv = Kv::new(Redis::connect("redis://localhost:6379").await?);
let storage = Storage::new(S3Storage::from_env("my-bucket"));

// Cloudflare
let kv = Kv::new(CfKv::from_env(&env, "CACHE")?);
let storage = Storage::new(CfR2::from_env(&env, "UPLOADS")?);

// Tests
let kv = Kv::new(InMemoryKv::new());
let storage = Storage::new(InMemoryStorage::new());
Capability Native Cloudflare AWS Azure Test
Key-value skyzen-redis CfKv DynamoKv CosmosKv InMemoryKv
Object storage skyzen-s3 CfR2 S3Storage AzureBlob InMemoryStorage
Message queue CfQueue SqsQueue ServiceBusQueue InMemoryQueue
SQL Db via sqlx Db via D1

Native and Cloudflare are wired automatically from Skyzen.toml. The AWS and Azure crates are usable as backends, but you construct and inject them yourself; there is no automatic wiring for them yet.

The provider-specific types (CfD1, DurableKv, DurableDb, Alarm) are public and can be mixed into the same app. See the services guide and the Durable Object + SQL guide.

Running on the edge

The same router runs on Cloudflare Workers. Build a cdylib instead of a binary:

[lib]
crate-type = ["cdylib", "rlib"]
#[skyzen::main]
fn app() -> Router {
    router()
}

On wasm32, #[skyzen::main] emits a WinterCG fetch export instead of a main. Workers' queue and cron entrypoints have their own macros:

#[cfg(target_arch = "wasm32")]
#[skyzen::queue]
async fn queue(
    batch: skyzen_cloudflare::CfQueueBatch,
    env: skyzen::runtime::wasm::Env,
    ctx: skyzen_cloudflare::CfQueueContext,
) -> Result<(), skyzen_cloudflare::CfEventError> {
    batch.ack_all()?;
    Ok(())
}

#[cfg(target_arch = "wasm32")]
#[skyzen::scheduled]
async fn scheduled(
    event: skyzen_cloudflare::CfScheduledEvent,
    env: skyzen::runtime::wasm::Env,
    ctx: skyzen_cloudflare::CfScheduleContext,
) -> Result<(), skyzen_cloudflare::CfEventError> {
    Ok(())
}

Stateful workloads use #[skyzen::durable_object] with the DurableObject trait. Full setup is in the deployment guide.

WebSocket

Route::new((
    "/ws".ws(|mut socket| async move {
        while let Some(Ok(message)) = socket.next().await {
            if let Some(text) = message.into_text() {
                let _ = socket.send_text(text).await;
            }
        }
    }),
))

Native uses async-tungstenite; WASM uses WebSocketPair. Two differences worth knowing: WASM caps messages at 1 MiB, and it gives no control over ping/pong frames.

OpenAPI

Annotated handlers register themselves at compile time through linkme, so the spec is assembled from the same signatures the router uses:

/// Fetch a user by id.
#[skyzen::openapi]
async fn get_user(params: Params) -> Result<Json<User>> {
    // ...
}

fn router() -> Router {
    Route::new(("/users/{id}".at(get_user),))
        .enable_api_doc() // served at /api-docs
        .build()
}

Doc comments become descriptions. Generation is gated on debug_assertions and native targets, so neither release builds nor Workers bundles carry the schema.

#[skyzen::main]

On native targets the macro sets up a Tokio runtime and Hyper server, installs a tracing subscriber that respects RUST_LOG, parses --host / --port / --listen, and shuts down gracefully on Ctrl+C. To install your own subscriber:

#[skyzen::main(default_logger = false)]
async fn main() -> Router {
    tracing_subscriber::fmt().init();
    router()
}

If you need to drive the server yourself — embedding Skyzen in a larger process, or using a non-Tokio executor — skyzen-hyper implements the Server trait directly:

use skyzen_hyper::Hyper;

Hyper.serve(
    my_executor,
    |error| tracing::error!(%error, "connection failed"),
    my_tcp_listener(),
    router().build(),
).await;

CLI

skyzen new my-app --template api
skyzen new jobs-app --template serverless-events
skyzen new room-app --template durable-realtime
skyzen doctor                          # check toolchain
skyzen dev                             # native watch + restart
skyzen dev --provider cloudflare       # wrangler-driven dev
skyzen deploy --provider cloudflare

Bindings, queues, and databases are declared in Skyzen.toml. For Cloudflare the CLI generates .skyzen/gen/wrangler.toml — don't hand-edit wrangler.toml.

Crates

Crate What it is
skyzen Routing, extractors, responders, middleware, runtime
skyzen-core Extractor, Responder, Server traits; no_std-capable
skyzen-hyper Hyper backend for the Server trait
skyzen-macros #[skyzen::main], #[skyzen::openapi], #[skyzen::error], and friends
skyzen-services Kv, Storage, Queue, Db and the traits behind them
skyzen-test In-memory services, TestClient, assertions
skyzen-redis Redis KeyValueStore
skyzen-s3 S3-compatible ObjectStorage
skyzen-cloudflare Workers KV, R2, Queues, D1, Durable Objects (wasm32 only)
skyzen-aws DynamoDB, SQS, S3 backends
skyzen-azure Cosmos DB, Blob Storage, Service Bus backends
skyzen-cli skyzen new / dev / deploy / doctor

Guides

Runnable examples live in examples/.

License

Licensed under either of Apache-2.0 or MIT, at your option.