Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Vyuh

Vyuh is a Rust application framework for building typed runtime paths in one place: routes, commands, tasks, signals, emitters, services, validation, assets, templates, and OpenAPI.

The framework keeps application structure visible in ordinary Rust code. Handler signatures describe what each runtime path consumes, Site owns the runtime, and Data<T> gives typed application data a common shape across subsystems.

What Vyuh Optimizes For

  • A single application model across web and operational code.
  • Explicit validation and error behavior.
  • Bundle-owned routes, services, tasks, commands, assets, and templates.
  • Postgres-first production behavior with MySQL and SQLite support where available.

Current Status

Vyuh is usable, but not API-stable yet. Expect breaking changes before a stable release.

Overview

Vyuh applications are built from ordinary Rust functions and a small runtime model:

Handler + Services + Assets -> Bundle
Bundle + Conf -> Site
Site -> Application + OpenAPI + Console

The goal is not to make every subsystem identical. The goal is to keep the shape of the application visible: what enters, what runs, what it depends on, and what the framework can expose for documentation and operations.

Handlers

Handlers are the entrypoints into a Vyuh application. A route handles HTTP, a command handles CLI or admin work, a task handles durable background work, and a signal handler reacts to typed in-process events. Emitters produce scheduled or external events and feed them back into the same handler model.

The common mental model is:

#![allow(unused)]
fn main() {
async fn handler(...context, Data<Input>) -> Result<Data<Output>, Error>
}

That is a guide, not a rigid signature. A handler can omit input, return (), use auth or validation extractors, read path/query/json/form data, use ServiceRef<T>, or return a lifecycle-specific type such as task state when that runtime path needs it.

The important part is that handlers remain typed async functions. Their signatures show the data and context they need.

Services

Services are dependencies that handlers use. They are constructed once when the site is built, stored for the lifetime of the site, and accessed through ServiceRef<T> or site.service::<T>().

A service is not an entrypoint. It is a reusable capability: a client, cache, coordinator, repository, search index, mailer, or any other site-lifetime component. Service methods can still follow the same typed input and output style, but service construction belongs to the site lifecycle.

Bundles

Bundles are the composition unit. A feature can own its routes, commands, tasks, signals, emitters, services, templates, assets, and metadata together.

This keeps feature boundaries explicit. A bundle can be nested, reused, prefixed, or published as a separate crate without scattering its runtime paths and resources across unrelated configuration files.

Because a bundle is self-contained, applications can compose functionality by importing bundles from other crates just as they import ordinary Rust libraries. A blog, chat system, authentication module, or admin interface can be packaged once and reused across multiple applications while remaining fully integrated with routing, OpenAPI, the console, and the rest of the site.

Site

Site = Bundle + SiteConf.

The site is the running application. It owns configuration, routing, services, database access, tasks, signals, emitters, commands, templates, assets, logging, console, OpenAPI, and shutdown coordination.

Because runtime paths are registered through bundles and typed handlers, Vyuh can derive useful operational surfaces without extra per-feature wiring. OpenAPI and console support come from the same application metadata that builds the running site.

The practical result is one typed application core. HTTP APIs, background work, commands, events, services, assets, docs, and operations stay connected instead of becoming separate side systems.

Getting Started

This chapter walks through a small Vyuh application that touches the framework surfaces most projects care about first: routes, JWT auth, a task, a command, a cron emitter, a signal handler, and OpenAPI.

It is not a production-ready application, and it is not trying to hide that. The goal is simpler: show how Vyuh keeps HTTP, background work, scheduled work, and operations in one model without making them feel like separate systems.

Data Types

Start with ordinary Rust types.

Data<T> is the main wrapper Vyuh moves through handlers. Add Validate when input should be checked at the boundary. Add JsonSchema when a type should appear in generated OpenAPI.

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Validate)]
struct Signup {
    #[validate(email)]
    email: String,

    #[validate(min_length = 3, max_length = 80)]
    name: String,
}

#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
struct UserCreated {
    id: i64,
    email: String,
    name: String,
}

#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
struct LoginOut {
    access: String,
    refresh: String,
}

#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
struct BuildReportJob {
    account_id: i64,
}

#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
struct ReportBuilt {
    location: String,
}

#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
struct Tick {
    source: String,
}

#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
struct ReindexArgs {
    scope: String,
}
}

These types are intentionally plain. Vyuh does not ask you to declare a second schema layer just to move data through the framework.

Routes

Routes are ordinary async functions with typed inputs and typed outputs. The important thing to notice is not the macro. It is the function signature.

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::auth::{AuthUser, TokenPair};

#[bundles::route(path = "/users", method = "POST")]
async fn signup(Valid(Data(input)): Valid<Data<Signup>>) -> Result<Data<UserCreated>, Error> {
    Ok(Data::new(UserCreated {
        id: 1,
        email: input.email.clone(),
        name: input.name.clone(),
    }))
}

#[bundles::route(path = "/login", method = "POST")]
async fn login(site: Site) -> Result<Data<LoginOut>, Error> {
    let tokens: TokenPair = site
        .auth()
        .create_token_pair(AuthUser::new("user-123", 0), &["web"])?;

    Ok(Data::new(LoginOut {
        access: tokens.access,
        refresh: tokens.refresh,
    }))
}

#[bundles::route(path = "/me", method = "GET")]
async fn me(user: AuthUser) -> Result<Data<String>, Error> {
    Ok(Data::new(user.key.to_string()))
}
}

signup is a validated JSON route. login and me show the normal JWT path with very little ceremony.

That combination is already most of what a real API does: parse input, validate it, authenticate some endpoints, and issue tokens without dropping into untyped request plumbing.

Task, Cron, And Command

Tasks, cron emitters, signals, and commands are different runtime paths, but Vyuh does not make them feel alien. They use the same handler style and the same bundle composition model.

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[bundles::task]
async fn build_report(Data(job): Data<BuildReportJob>) -> Data<ReportBuilt> {
    Data::new(ReportBuilt {
        location: format!("reports/{}.json", job.account_id),
    })
}

#[bundles::cron(expr = "0 */5 * * * *")]
async fn heartbeat() -> Data<Tick> {
    Data::new(Tick {
        source: "docs-example".into(),
    })
}

#[bundles::signal]
async fn record_tick(Data(tick): Data<Tick>) -> Result<(), Error> {
    println!("tick from {}", tick.source);
    Ok(())
}

async fn rebuild_index(Data(args): Data<ReindexArgs>) -> Result<(), Error> {
    println!("reindex {}", args.scope);
    Ok(())
}
}

This is where Vyuh starts to feel different. The route, task, cron emitter, signal handler, and command are not the same feature, but they are close enough in shape that you can move between them without switching mental models.

  • The route handles HTTP input.
  • The task handles durable background input.
  • The cron handler runs on a schedule and emits typed data.
  • The signal handler receives that emitted data in-process.
  • The command handles CLI input.

All of them are ordinary async functions over typed data. That is the uniformity argument in practice, not as a slogan.

Auth And OpenAPI

Auth stays explicit. If a handler does not extract auth, Vyuh does no auth work for it. For a first application, JWT is the path with the least ceremony: configure auth once, issue a token pair, and extract AuthUser where a route should require an access token.

OpenAPI works the same way Vyuh usually works: attach it once at the bundle, and let it follow the routes that bundle already owns. That means prefixes, nesting, and route metadata stay aligned without a parallel documentation tree.

#![allow(unused)]
fn main() {
use vyuh::auth::AuthConf;

let auth = AuthConf::default();

fn api_bundle() -> bundles::Bundle {
    bundles::bundle! {
        signup,
        login,
        me,
        build_report,
        heartbeat,
        record_tick,
    }
    .merge(command_bundle())
    .with_openapi(
        bundles::OpenApiConf::default()
            .title("Vyuh Getting Started")
            .version("0.1.0")
            .description("Routes, auth, tasks, commands, and cron.")
            .spec("/openapi.json")
            .viewer("/docs"),
    )
    .with_prefix("/api")
}
}

OpenAPI and the docs viewer need no per-route schema file, no separate route table, and no duplicate metadata layer. They come from the handlers and bundle declaration you already had to write anyway.

Command Bundle

Commands are registered directly and then merged like any other bundle part. That matters because commands are operational code, but they still belong to the same application.

#![allow(unused)]
fn main() {
use vyuh::bundles;
use vyuh::commands::CommandConf;

fn command_bundle() -> bundles::Bundle {
    bundles::bundle([bundles::command(
        rebuild_index,
        CommandConf::new("search:rebuild").description("Rebuild the search index."),
    )])
}
}

The result is modest but useful: CLI work stays close to the feature that owns it instead of drifting into a separate operational codebase.

Main Function

Put the bundle and site configuration together in main. This is where the different surfaces stop being examples and become one application.

use vyuh::prelude::*;
use vyuh::auth::AuthConf;

#[tokio::main]
async fn main() -> Result<(), SiteError> {
    let auth = AuthConf::default();

    Site::run(
        SiteConf::from_env_with_files()?
            .auth(auth),
        api_bundle(),
    )
    .await
}

The bundle is where the feature surface comes together: routes, a task, a cron emitter, a signal handler, command registration, JWT-protected handlers, and OpenAPI. main stays small because the feature wiring already lives with the feature.

What To Notice

  • One bundle holds the feature surface instead of scattering setup across unrelated registries.
  • Data<T> keeps the route, task, signal, command, and cron shapes recognizably close to each other.
  • Validation is explicit through Valid<Data<T>>, not inferred from derives alone.
  • JWT auth is explicit through AuthUser, while setup stays small with AuthConf::default().
  • OpenAPI is attached once and follows the bundle tree automatically.

From here, the next useful pages are Bundles, Routes, OpenAPI, Tasks, and Auth.

Site

Site is the built Vyuh application. It owns configuration, the router, database pool, authenticator, template engine, task dispatcher, signal and emitter engines, services, commands, logging, and shutdown coordination.

Most applications interact with Site in two places:

  • At startup, through Site::build, Site::run, Site::serve, or Site::test.
  • Inside handlers and workers, where Site or subsystem handles can be extracted when framework access is needed.

Overview

The main public pieces are:

  • SiteConf for application configuration.
  • Site::build(conf, bundle) for building a site without serving it.
  • Site::run(conf, bundle) for command-aware application entrypoints.
  • Site::serve(conf, bundle) for directly building and serving HTTP.
  • Site::test(conf, bundle, pool) for tests with an explicit SQLx pool.
  • site.start() for serving an already-built site.
  • Site accessors such as db(), tasks(), templates(), service(), auth(), signals(), and reverse().
  • vyuh::testing::router(&site) for tests or Axum interop.
  • SiteConf::http(...) for global HTTP middleware and slash behavior.
  • SiteConf::templates(...) for Minijinja environment behavior.

Site is cheap to clone. Clones share the same underlying application state.

Configuration

Start from SiteConf::default() and set only what the application needs:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::db::DbConf;
use vyuh::console::ConsoleConf;
use vyuh::file_storage::UploadConf;
use vyuh::middlewares::{HttpConf, TraceConf};
use vyuh::templates::{TemplateConf, TemplateDateFormats};

let conf = SiteConf::default()
    .host("127.0.0.1")
    .port(8080)
    .project_dir(".")
    .database(DbConf::from_url("sqlite://app.db?max=5")?)
    .secret_key("replace-with-a-long-random-secret")
    .templates(TemplateConf {
        date_formats: TemplateDateFormats {
            date: "%d %b %Y".into(),
            time: "%H:%M".into(),
            datetime: "%d %b %Y, %H:%M".into(),
        },
        ..TemplateConf::default()
    })
    .http(HttpConf {
        trace: TraceConf { enabled: true },
        ..HttpConf::default()
    })
    .uploads(UploadConf {
        dir: "media/uploads".into(),
        base_url: Some("/media/uploads".into()),
        ..UploadConf::default()
    })
    .console(ConsoleConf::default())
    .timezone("UTC");
}

project_dir is the base for relative media, upload, reload, auth key, and log paths. Static files and templates belong to bundles through asset dirs. SiteConf::validate() checks required fields and path readability before the site is built.

With no database backend feature enabled, SiteConf::default() uses a shared in-memory SQLite database URL and tasks use MemoryTaskStore. This is intended for quick starts, docs, local experiments, and tests. Production applications should enable exactly one backend feature (postgres, mysql, or sqlite) and configure a durable database.

For global HTTP behavior, see Middlewares. For Minijinja environment behavior and formatting helpers, see Templates. For upload storage, see Uploads. For optional operational inspection, see Console.

Environment helpers are available when configuration should come from the process environment:

#![allow(unused)]
fn main() {
let conf = vyuh::SiteConf::from_env_with_files()?;
}

from_env_with_files() loads .env, then .env.test, .env.dev, or .env.prod depending on the build mode. Environment variables currently patch common deployment fields such as DATABASE_URL, SECRET_KEY, HOST, PORT, TZ, and LOG_INIT.

Lifecycle

Vyuh keeps lifecycle on Site:

MethodPurpose
Site::buildbuild the site object without starting HTTP
Site::runcommand-aware application entrypoint; no args defaults to serve
Site::servebuild and directly serve HTTP, ignoring commands
Site::testbuild a test site with an explicit SQLx pool
site.startserve an already-built site

Use Site::run for ordinary application binaries:

use vyuh::prelude::*;

#[tokio::main]
async fn main() -> Result<(), vyuh::SiteError> {
    let bundle = bundles::bundle! {
        // routes, services, tasks, signals, assets, commands
    };

    vyuh::Site::run(SiteConf::from_env_with_files()?, bundle).await
}

Use Site::serve when a binary should ignore commands and only serve HTTP:

#![allow(unused)]
fn main() {
vyuh::Site::serve(SiteConf::from_env_with_files()?, app_bundle()).await?;
}

Use Site::build when the caller needs the site before serving, for example to inspect configuration, run setup code, or pass the built site to another runtime:

#![allow(unused)]
fn main() {
let site = vyuh::Site::build(conf, bundle).await?;
site.start().await?;
}

When arguments are supplied, Site::run executes the requested command:

#[tokio::main]
async fn main() -> Result<(), vyuh::SiteError> {
    vyuh::Site::run(vyuh::SiteConf::from_env_with_files()?, app_bundle()).await
}

During build, Vyuh validates configuration and bundles, builds the router, creates the database pool, loads templates, initializes services, registers OpenAPI endpoints, prepares task stores when tasks are present, and starts background engines.

Using Site In Handlers

Handlers can extract Site directly:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[bundles::route(path = "/health")]
async fn health(site: Site) -> Json<String> {
    Json(site.timezone().to_string())
}
}

Prefer subsystem handles for subsystem-specific work:

#![allow(unused)]
fn main() {
let db = site.db();
let templates = site.templates();
let tasks = site.tasks();
let auth = site.auth();
let counter = site.service::<CounterService>()?;
}

Task submission should go through site.tasks().submit(...) or site.tasks().submit_with(...). Template rendering should usually go through site.templates().render(...) or the Templates route extractor.

Error Rendering

Route parse errors, validation errors, auth failures, database errors, template errors, and application vyuh::Error values are normalized into ErrorReport before they are rendered. The default response is JSON-first. See Errors for the application/subsystem/rendered error model.

Applications can replace error rendering with SiteConf::errors(...):

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::errors::ErrorConf;

let conf = SiteConf::default().errors(
    ErrorConf::default().handler(|ctx, report| async move {
        (
            report.status,
            [("content-type", "application/json")],
            serde_json::json!({
                "path": ctx.path,
                "code": report.code,
                "detail": report.detail,
            })
            .to_string(),
        )
            .into_response()
    }),
);
}

The handler is async and receives request context plus the normalized report, so applications can render templates, add headers, or choose a different content type.

Routing And Reverse URLs

Raw Axum router access is intentionally not part of the normal application lifecycle. Use Site::serve or site.start() for serving. Use vyuh::testing::router(&site) only for tests or interop that truly needs an Axum Router.

Named routes can be reversed through Site::reverse:

#![allow(unused)]
fn main() {
let url = site.reverse("user_detail", &[("id", "42")]);
}

reverse returns None when the route name or required parameters do not match a registered route.

Testing

Use Site::test when a test should build the real site with a caller-provided SQLx pool:

#![allow(unused)]
fn main() {
#[sqlx::test]
async fn route_works(pool: vyuh::db::Pool) -> Result<(), vyuh::SiteError> {
    let site = vyuh::Site::test(vyuh::SiteConf::default(), app_bundle(), pool).await?;
    let app = vyuh::testing::router(&site);
    Ok(())
}
}

For route-level tests, build a site and send requests through vyuh::testing::TestClient or vyuh::testing::router(&site). Use .log_init(false) in tests when test output should stay quiet.

Shutdown

Site owns a shared shutdown notifier. Long-lived service workers and other background loops should observe site.shutdown_notifier() and exit when it is notified.

#![allow(unused)]
fn main() {
let shutdown = site.shutdown_notifier();
tokio::select! {
    _ = shutdown.notified() => {}
    _ = do_work() => {}
}
}

Site::serve and site.start() install bounded graceful server shutdown. The first Ctrl+C starts graceful shutdown and prints a message explaining that a second Ctrl+C will force shutdown. SIGTERM, touch-reload, and site.shutdown() also start graceful shutdown. If active requests do not drain within conf.http.shutdown.grace_period_ms, Vyuh forces server shutdown and returns from Site::serve or site.start().

Channel transports are shutdown-aware: SSE streams end, WebSockets close, and long-poll requests return promptly when shutdown starts. Long-lived service workers should still select on site.shutdown_notifier() so they can stop their own work cleanly before the grace period expires.

shutdown_and_wait() can be used by tests or embedding code that needs to notify background tasks and abort remaining join handles.

Failure Modes

  • Invalid configuration returns SiteError::ConfError.
  • Database pool setup returns SiteError::DatabaseError.
  • Bundle validation and duplicate registration errors return SiteError::BundleError.
  • Template loading errors return SiteError::TemplateError.
  • Service construction errors return SiteError::ServiceError.
  • Task store migration errors return SiteError::TaskMigrationError.
  • Server bind or runtime errors return SiteError::IOError or SiteError::ServeError.

Current Limitations

  • Site is an in-process application handle, not a distributed coordinator.
  • Background engines are tied to the process that built the site.
  • Site::test uses the supplied pool but does not replace application-level schema setup; tests still need the schema their routes and services expect.

Bundles

Bundles are Vyuh’s composition API. A Bundle collects routes, signals, emitters, tasks, services, commands, asset directories, migrations, schema contributors, and OpenAPI configuration into one value that can be mounted, merged, prefixed, layered, validated, and passed to site startup.

Every registerable item enters a bundle as a BundlePart. Macros create BundlePart values for ergonomic static registration; the direct APIs create the same parts explicitly.

Overview

A bundle has three jobs:

  • collect runtime registrations for each subsystem,
  • collect operation metadata for docs, diagnostic surfaces, and reverse routing,
  • accumulate registration errors until Bundle::validate() or site build.

Invalid paths, duplicate route names, duplicate route method/path collisions, duplicate migrations, and subsystem registration errors are stored on the bundle. Site construction validates the final composed bundle before startup.

BundlePart

BundlePart is the common registration unit for framework features. Route, signal, emitter, task, service, command, asset, migration, and schema helpers all return a BundlePart.

The direct constructor is bundles::bundle([...]):

#![allow(unused)]
fn main() {
let bundle = bundles::bundle([
    bundles::route(
        list_notes,
        RouteConf {
            name: Cow::Borrowed("list_notes"),
            path: Cow::Borrowed("/notes"),
            methods: Methods::GET,
            slash: None,
        },
    ),
    bundles::signal::<NoteChanged, _, _>(
        index_note_change,
        signals::SignalConf::default(),
    ),
]);
}

Some bundle parts expose operation metadata. BundlePart::patch(PatchOp) amends that metadata before the part is registered into a bundle.

Patch API

BundlePart::patch(PatchOp) is the general metadata override API. It can adjust operation names, descriptions, argument metadata, return metadata, status codes, and additional responses when the part has operation metadata.

#![allow(unused)]
fn main() {
let route = bundles::route(create_note, conf).patch(
    PatchOp::new()
        .ret()
        .status(201)
        .doc("Created note")
        .done(),
);
}

Patches affect metadata only. They do not change runtime extraction, routing, or handler behavior. Routes and OpenAPI are the main v0 use case for patching; other subsystem docs mention patching only when there is a feature-specific use case.

Macro Sugar

Feature macros such as #[bundles::route] and #[bundles::signal] generate a hidden bundle-part helper next to the annotated item. bundle! calls those helpers and passes the resulting parts to bundles::bundle([...]).

#![allow(unused)]
fn main() {
#[bundles::route(path = "/notes")]
async fn list_notes() -> Json<Vec<Note>> {
    Json(Vec::new())
}

#[bundles::signal]
async fn index_note_change(Data(event): Data<NoteChanged>) {
    tracing::info!("note {} changed", event.id);
}

let bundle = bundles::bundle! {
    list_notes,
    index_note_change,
};
}

The macro path does not add a unique runtime capability. Use direct registration when bundle parts are generated, conditional, feature-gated, or assembled from tables.

Module Organization

bundle! members must be macro-registered items visible in the module where bundle! is invoked. The macro expands each member to an unqualified helper call named like __bundle_part_<item>().

For cross-module organization, let each module expose a bundle function and compose those bundles in the parent module:

#![allow(unused)]
fn main() {
mod notes {
    use vyuh::bundles;

    #[bundles::route(path = "/notes")]
    async fn list_notes() -> Json<Vec<Note>> {
        Json(Vec::new())
    }

    pub fn bundle() -> bundles::Bundle {
        bundles::bundle! {
            list_notes,
        }
    }
}

mod ops {
    pub fn bundle() -> vyuh::bundles::Bundle {
        vyuh::bundles::Bundle::new()
    }
}

let app = notes::bundle().merge(ops::bundle());
}

Do not rely on bundle! to reach into another module’s generated helper. Merge the other module’s Bundle instead.

Bundles From Other Crates

Bundles are not limited to local modules. A Rust crate can export a function that returns a Bundle, and an application can import and merge that bundle like any other dependency:

#![allow(unused)]
fn main() {
let app = bundles::bundle! {
    local_health_check,
}
.merge(blog_feature::bundle())
.merge(admin_feature::bundle());
}

This is useful when a feature should ship with more than routes. An imported bundle can bring its handlers, services, templates, assets, OpenAPI metadata, commands, signals, tasks, and emitters together. After it is merged, it remains part of the same site: prefixes, OpenAPI generation, console inspection, middleware layers, and validation all see it as normal bundle content.

Use this boundary for reusable application capabilities: a blog, chat system, auth module, internal admin area, or shared operational tooling. The exporting crate should expose a small bundle() function and keep feature-owned assets and templates inside the crate’s bundle resources.

Composition

merge combines two bundles, including routes, operations, services, signals, emitters, tasks, commands, migrations, schema contributors, assets, and doc configuration.

#![allow(unused)]
fn main() {
let api = notes::bundle()
    .merge(users::bundle())
    .with_prefix("/v1")
    .with_tags(["api", "v1"]);
}

with_prefix prefixes route paths and operation metadata. Prefixes must start with /, must not be /, and must not end with /.

with_tags adds tags to all current operations in the bundle. layer applies middleware to all routes in the bundle; middleware that exposes metadata also updates operations for documentation.

reverse(name, args) resolves a named route to its final path. Missing path arguments return None; extra arguments are ignored; substituted values are percent-encoded.

iter_operations() exposes the collected operation metadata. Callers that show operations should filter hidden entries.

OpenAPI Order

with_openapi snapshots route operations already registered in the bundle. Routes added or merged after with_openapi do not appear in that generated spec.

Call with_openapi after route registration and merge steps for the API surface being documented:

#![allow(unused)]
fn main() {
let api = notes::bundle()
    .merge(users::bundle())
    .with_prefix("/v1")
    .with_openapi(
        bundles::OpenApiConf::default()
            .title("Notes API")
            .spec("/openapi.json"),
    );
}

Prefixes and metadata applied to already captured operations still affect final paths and operation metadata.

Examples

Bundles are exercised by the subsystem examples:

cargo run -p vyuh --no-default-features --features sqlite --example routes_json_post
cargo run -p vyuh --no-default-features --features sqlite --example routes_macroless
cargo run -p vyuh --no-default-features --features sqlite --example routes_reverse
cargo run -p vyuh --no-default-features --features sqlite --example openapi_basic
cargo run -p vyuh --no-default-features --features sqlite --example signals_simple
cargo run -p vyuh --no-default-features --features sqlite --example signals_macroless
  • routes_json_post: basic macro route registration and typed JSON handling.
  • routes_macroless: direct bundles::bundle([...]) route construction.
  • routes_reverse: prefixing, multi-method routes, and reverse routing.
  • openapi_basic: OpenAPI registration on a composed bundle.
  • signals_simple: signal handlers as macro-generated bundle parts.
  • signals_macroless: signal handlers as direct bundle parts.

Best Practices

  • Use bundle! for ordinary static, same-module macro registrations.
  • Use bundles::bundle([...]) for generated or conditional bundle parts.
  • Return Bundle values from feature modules and compose them with merge.
  • Apply with_prefix, with_tags, and layer at clear composition boundaries.
  • Call with_openapi after the routes for that spec have been registered and merged.

Failure Modes

  • Invalid route paths, prefixes, slash rules, and operation metadata are collected on the bundle and reported during validation or site build.
  • Duplicate route names or method/path pairs fail site build.
  • Duplicate subsystem registrations, such as services or commands with the same identity, fail before the site starts.
  • OpenAPI captures only routes registered before with_openapi.

Current Limitations

  • bundle! only works with macro-registered items visible in the current module.
  • Bundle composition is order-sensitive for APIs that snapshot metadata.
  • Macros are convenience only; direct registration is the canonical escape hatch for generated or conditional registrations.

Routes

Routes are Vyuh’s HTTP boundary. A route connects an async handler to a path, one or more HTTP methods, a stable operation name, and Bundle metadata used by reverse routing and OpenAPI.

Route macros are convenience syntax over direct registration APIs. The macro path and the direct API path create the same kind of BundlePart.

Use routes for HTTP APIs, pages, webhooks, and browser-facing endpoints. Do not use routes for maintenance scripts, durable background work, or site-lifetime workers; use commands, tasks, or services for those.

Overview

The route pipeline has three parts:

  • A handler function defines runtime behavior and typed input/output metadata.
  • RouteConf defines the route name, path, accepted methods, and optional slash behavior.
  • A Bundle collects routes and composes them with prefixes, middleware, tags, and other subsystem registrations.

The vyuh::routes module provides Vyuh-owned request wrappers such as Json<T>, Query<T>, Path<T>, Form<T>, and MultipartForm<T>. These wrappers are the recommended route API; Axum remains an internal implementation detail unless an application explicitly imports Axum types as an escape hatch. See Request for wrapper behavior and Validation for Valid<E>.

Macro Sugar And Direct API

The ergonomic path is the route macro:

#![allow(unused)]
fn main() {
#[bundles::route(path = "/notes")]
async fn list_notes() -> Json<Vec<Note>> {
    Json(Vec::new())
}
}

The equivalent direct API is bundles::route(handler, RouteConf) inside bundles::bundle(...):

#![allow(unused)]
fn main() {
let bundle = bundles::bundle([bundles::route(
    list_notes,
    RouteConf {
        name: Cow::Borrowed("list_notes"),
        path: Cow::Borrowed("/notes"),
        methods: Methods::GET,
        slash: None,
    },
)]);
}

Use the macro for ordinary static routes. Use the direct API when routes are generated, feature-gated, or assembled conditionally.

Route Registration

RouteConf has four fields:

  • name: logical route name used by reverse(), operation IDs, and diagnostic metadata. Macro routes default to the function name.
  • path: Axum-style absolute path, such as /notes or /notes/{id}.
  • methods: a Methods filter. Macro routes default to GET.
  • slash: optional route-level trailing-slash behavior.

Paths must start with /, must not be empty, and must not contain //. Bundle prefixes follow the same rule and also must not end in /.

Multiple HTTP methods can be registered on one handler by repeating method = "...".

Trailing-slash behavior defaults to the site’s HttpConf. Override it on a route when a specific page or API endpoint needs canonical behavior:

#![allow(unused)]
fn main() {
#[bundles::route(path = "/docs/", slash = "redirect_append")]
async fn docs() -> Html<&'static str> {
    Html("docs")
}
}

See Middlewares for SlashPolicy, site defaults, bundle overrides, and API vs HTML behavior.

Methods supports GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, TRACE, and CONNECT. CONNECT routes can be served, but OpenAPI 3 does not represent them as operations.

Handlers

Route handlers are normal async Axum handlers with additional Vyuh metadata derived from the signature.

Common inputs:

  • Site is runtime state and is not emitted as an OpenAPI parameter.
  • Path<T> parses path parameters and contributes path parameter metadata.
  • Query<T> parses query parameters and contributes query parameter metadata.
  • Json<T> parses a JSON request body and contributes JSON request-body metadata.
  • Form<T> parses a form request body and contributes form request-body metadata.
  • MultipartForm<T> parses file uploads and contributes multipart/form-data metadata. See Uploads.
  • Valid<E> wraps a request extractor and runs Validate after parsing.
  • AuthUser, permit!(Role, Variant), and ApiKey contribute security metadata.

Common outputs:

  • Json<T> becomes an application/json response.
  • Html<String> becomes a text/html response.
  • StatusCode and () become empty responses.
  • Raw Response is allowed but has unknown response metadata unless patched.

For the full response API, see Response.

Doc comments become operation text. The first paragraph is the summary; remaining paragraphs become the description.

Parsing And Validation

Request wrappers parse only by default:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn create(Json(input): Json<CreateUser>) {
    // JSON was parsed, but Validate was not run.
}
}

Validation is an explicit route-boundary choice:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Deserialize, JsonSchema, Validate)]
struct CreateUser {
    #[validate(email)]
    email: String,

    #[validate(min_length = 3)]
    name: String,
}

async fn create(Valid(Json(input)): Valid<Json<CreateUser>>) {
    // JSON was parsed, then CreateUser::validate() was run.
}
}

Parse failures return 400 through ErrorReport. Validation failures return 422 through ErrorReport with field-oriented code, message, and params entries.

For the full request API, see Request. For validation rules, nested validation, runtime-only rules, and OpenAPI behavior, see Validation. For application errors and HTTP rendering, see Errors.

Bundles

Routes are registered as BundlePart values. Macro routes and direct bundles::route(handler, RouteConf) registration produce the same kind of bundle part.

Route names must be unique across a composed bundle. A bundle also rejects two routes with the same path and overlapping HTTP methods.

Reverse routing resolves a registered route name to its final path. Path parameters are percent-encoded. Missing path arguments return None; extra arguments are ignored.

See Bundles for BundlePart, bundle!, cross-module bundle organization, validation, composition behavior, and the general patch API.

Middleware Metadata

Middleware can add runtime behavior and OpenAPI metadata when it implements routes::Middleware and returns a LayerSpec. Plain Tower layers can be wrapped with routes::layer_from(layer) when they should not affect OpenAPI metadata.

Site-wide transport middleware such as request IDs, panic catching, tracing, compression, CORS, timeouts, body limits, security headers, and slash policy is configured through SiteConf::http(...); see Middlewares.

Examples

Run the route examples in increasing complexity:

cargo run -p vyuh --no-default-features --features sqlite --example routes_json_post
cargo run -p vyuh --no-default-features --features sqlite --example routes_macroless
cargo run -p vyuh --no-default-features --features sqlite --example routes_reverse
  • routes_json_post: JSON body parsing and response rendering.
  • routes_macroless: equivalent direct bundles::route(..., RouteConf) registration.
  • routes_reverse: named routes, path parameters, multi-method registration, prefixing, and reverse().

Failure Modes

Route failures are reported during site build:

  • Invalid route paths or prefixes.
  • Empty route names.
  • Duplicate route names.
  • Duplicate path plus overlapping methods.

The macro catches invalid static path and method values at compile time. The direct API uses the same runtime bundle validation path.

Best Practices

  • Give public routes stable names when callers use reverse().
  • Keep route doc comments user-facing because OpenAPI uses them.
  • Use direct registration for generated routes, conditional routes, or feature-gated route lists.
  • Apply with_prefix at bundle composition boundaries.

Current Limitations

  • Route registration is explicit; Vyuh does not auto-discover handlers.
  • Raw Axum router access is reserved for tests and interop.
  • OpenAPI metadata is inferred from handler types unless patched.

Request

Vyuh request data wrappers are the normal way to receive route input. They keep Axum extractor details behind Vyuh’s API boundary and make parsing, errors, and OpenAPI metadata consistent across routes.

Vyuh wrappers are intentionally thin. They delegate parsing behavior to Axum internally, then convert failures into Vyuh’s ErrorReport shape. Wrapping the extractors gives Vyuh consistent errors, first-class OpenAPI integration, and a stable public API even if the internal Axum integration changes.

All parse failures become ErrorReport values and are rendered through the site’s configured error handler.

Mental Model

  • Request wrappers parse incoming data.
  • Request wrappers never validate.
  • Valid<E> adds validation.
  • Parse failures return 400.
  • Validation failures return 422.

The route lifecycle is:

Request -> Wrapper Parse -> Valid (optional) -> Handler -> Response

Use these from vyuh::routes:

WrapperSourceParse failureOpenAPI behavior
Data<T>JSON body400JSON request body or response body
Json<T>JSON body400JSON request body or response body
Query<T>query string400query parameters
Path<T>path captures400path parameters
Form<T>URL-encoded form body400form request body
MultipartForm<T>multipart form body400, 413, 415multipart form request body
BodyBytesraw body bytes400opaque binary request body

Wrappers parse only, even when the DTO derives Validate. Validation runs only when a wrapper is wrapped in Valid<E>. See Validation.

Use Data<T> when the input is application data rather than explicitly HTTP-shaped JSON. It behaves like Json<T> for routes, but the same wrapper is also used by commands, tasks, signals, and emitters.

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Deserialize)]
struct CreateNote {
    title: String,
}

async fn create(Data(input): Data<CreateNote>) {
    // input is Arc<CreateNote>; field access works through Deref.
}
}

JSON

Json<T> parses an application/json request body:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Deserialize)]
struct CreateNote {
    title: String,
}

async fn create(Json(input): Json<CreateNote>) {
    // input is parsed, but not validated.
}
}

Json<T> can also be returned from handlers when T: Serialize:

#![allow(unused)]
fn main() {
#[derive(Serialize)]
struct NoteOut {
    id: u64,
}

async fn show() -> Json<NoteOut> {
    Json(NoteOut { id: 1 })
}
}

Invalid JSON or a body that cannot deserialize into T returns 400.

Query

Query<T> parses query strings:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Deserialize)]
struct SearchParams {
    q: String,
    page: Option<u32>,
}

async fn search(Query(params): Query<SearchParams>) {
    // /search?q=vyuh&page=2
}
}

Malformed query strings or failed deserialization return 400.

Unknown query parameters follow Serde deserialization behavior. They are ignored for ordinary structs, and rejected when the target type opts into #[serde(deny_unknown_fields)].

Path

Path<T> parses path captures. Use a struct when names matter:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Deserialize)]
struct UserPath {
    id: uuid::Uuid,
}

#[vyuh::bundles::route(path = "/users/{id}")]
async fn user_detail(Path(path): Path<UserPath>) {
    let id = path.id;
}
}

Use a tuple for positional captures:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[vyuh::bundles::route(path = "/orgs/{org}/users/{id}")]
async fn user_in_org(Path((org, id)): Path<(String, u64)>) {
    // org and id are parsed from the path.
}
}

Path parse failures return 400.

Form

Form<T> parses application/x-www-form-urlencoded request bodies:

#![allow(unused)]
fn main() {
use vyuh::routes::Form;

#[derive(Deserialize)]
struct LoginForm {
    email: String,
    password: String,
}

async fn login(Form(form): Form<LoginForm>) {
    // form.email, form.password
}
}

Form parse failures return 400.

Multipart

MultipartForm<T> parses multipart/form-data upload requests. It is route only because multipart is an HTTP request shape with file streaming, temporary files, content types, and request limits.

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::routes::{MultipartForm, UploadedFile};
use vyuh::MultipartData;

#[derive(JsonSchema, MultipartData)]
struct AvatarUpload {
    #[upload(content_types = ["image/png"], extensions = ["png"], sniff = "image")]
    avatar: UploadedFile,
}

async fn upload(MultipartForm(input): MultipartForm<AvatarUpload>) {
    // input.avatar is parsed and screened, but not validated by Validate.
}
}

Use MultipartMap and MultipartSpec directly when macro-less dynamic upload handling is a better fit. MIME screening uses the infer crate to check a bounded prefix of uploaded bytes, so invalid files can be rejected before Vyuh accepts or stores the full upload. Large files stream to memory or temporary files according to SiteConf::uploads(...).

Save accepted files through site.file_storage(). Uploaded/runtime files are separate from bundled assets and collect_static.

See Uploads for typed uploads, macro-less uploads, large-file behavior, MIME sniffing, and LocalStorage.

Raw Body

BodyBytes reads the request body as bytes. It is useful for webhooks because signature verification usually needs the exact raw payload:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn webhook(BodyBytes(bytes): BodyBytes) {
    // Verify the provider signature against the raw bytes before decoding.
}
}

Use it for custom protocols, signed payloads, or cases where deserialization is not appropriate. BodyBytes is intentionally excluded from the validation system because it represents raw request data.

BodyBytes does not generate a JSON schema. In OpenAPI it is documented as an opaque binary request body.

Ownership Helpers

Wrappers support pattern matching, Deref, AsRef, and into_inner():

#![allow(unused)]
fn main() {
async fn create(json: Json<CreateNote>) {
    let input = json.into_inner();
}
}

Valid<E> supports the same ownership pattern around the wrapped extractor.

Validation

Use Valid<E> when parsed input should be validated:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn create(Valid(Json(input)): Valid<Json<CreateNote>>) {
    // input is parsed and validated.
}

async fn create_data(Valid(Data(input)): Valid<Data<CreateNote>>) {
    // same validation model for application data.
}
}

Parse failures and validation failures are different:

  • A parse failure means Vyuh could not read the request into the wrapper type.
  • A validation failure means parsing succeeded, but Validate rejected the parsed value. Validation errors return 422 with field entries containing code, message, and params.

Plain wrappers never publish validation constraints to OpenAPI. Validation metadata is published only when Valid<E> is used. The full model is described in Validation.

OpenAPI

Wrappers contribute request metadata:

  • Json<T> becomes a JSON request body.
  • Data<T> becomes a JSON request body.
  • Query<T> becomes query parameters.
  • Path<T> becomes path parameters.
  • Form<T> becomes a form request body.
  • MultipartForm<T> becomes a multipart/form-data request body.
  • BodyBytes becomes an opaque binary request body.

Given a DTO that derives Validate:

#![allow(unused)]
fn main() {
#[derive(Deserialize, JsonSchema, Validate)]
struct CreateNote {
    #[validate(min_length = 3)]
    title: String,
}
}

Json<CreateNote> documents only the parse shape:

requestBody:
  content:
    application/json:
      schema:
        $ref: '#/components/schemas/CreateNote'

Valid<Json<CreateNote>> documents the parse shape plus supported validation constraints:

requestBody:
  content:
    application/json:
      schema:
        type: object
        properties:
          title:
            type: string
            minLength: 3

See Validation for supported constraints, structured validation errors, runtime-only rules, and explicit custom_schema hints.

Axum Escape Hatch

Vyuh wrappers are the recommended API. Direct Axum extractors remain possible through explicit Axum imports, or through vyuh::routes::axum_extractors when a route needs behavior Vyuh does not wrap yet.

Failure Modes

  • Parse and deserialization failures return 400.
  • Validation failures return 422 only when Valid<E> is used.
  • Multipart type, size, and sniffing failures use the upload error mapping described in Uploads.

Current Limitations

  • Request wrappers are route-only.
  • Direct Axum extractors are available, but Vyuh cannot infer custom metadata unless the wrapper or route patch provides it.
  • Validation metadata is intentionally opt-in through Valid<E>.

Response

Vyuh handlers return ordinary Rust values that implement IntoResponse. Prefer the response types re-exported from vyuh::routes so response behavior and OpenAPI metadata stay close to the handler signature.

Mental Model

  • Request wrappers parse input.
  • Response wrappers describe output.
  • Data<T> and Json<T> return JSON when T: Serialize.
  • Html<T> returns HTML.
  • StatusCode, NoContent, redirects, and raw Response cover lower-level cases.
  • OpenAPI response metadata comes from the handler return type unless a route patch overrides it.

JSON

Use Data<T> when the response is application data shared with other Vyuh subsystems:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Serialize, JsonSchema)]
struct NoteOut {
    id: u64,
    title: String,
}

async fn show_data() -> Data<NoteOut> {
    Data::new(NoteOut {
        id: 1,
        title: "Vyuh".into(),
    })
}
}

Use Json<T> for JSON responses:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Serialize, JsonSchema)]
struct NoteOut {
    id: u64,
    title: String,
}

async fn show() -> Json<NoteOut> {
    Json(NoteOut {
        id: 1,
        title: "Vyuh".into(),
    })
}
}

When T: JsonSchema, OpenAPI documents the response body as application/json.

Use JsonStr only when the body is already serialized JSON:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::routes::JsonStr;

async fn raw_json() -> JsonStr {
    JsonStr::from(r#"{"ok":true}"#)
}
}

JsonStr does not validate or serialize the string.

HTML

Use Html<T> for HTML responses:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn page() -> Html<&'static str> {
    Html("<h1>Dashboard</h1>")
}
}

For server-side templates, prefer Templates::html(...):

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::templates::{TemplateError, Templates};

async fn dashboard(templates: Templates) -> Result<Html<String>, TemplateError> {
    templates.html("dashboard.html", &serde_json::json!({ "title": "Dashboard" }))
}
}

HTML return metadata is also used by slash policy Auto to distinguish page routes from API routes.

Status And Empty Responses

Return StatusCode when the status is the whole response:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn accepted() -> StatusCode {
    StatusCode::ACCEPTED
}
}

Use NoContent or () for empty success responses:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn delete_note() -> NoContent {
    NoContent
}
}

Redirects And Headers

Use Redirect for HTTP redirects:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn old_path() -> Redirect {
    Redirect::permanent("/new-path")
}
}

Use AppendHeaders or tuple responses when a handler needs custom headers:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn with_headers() -> (AppendHeaders<[(&'static str, &'static str); 1]>, Json<&'static str>) {
    (AppendHeaders([("cache-control", "no-store")]), Json("ok"))
}
}

Errors

Handlers can return Result<T, vyuh::Error> for ordinary application failures:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn show() -> Result<Json<String>, Error> {
    Err(Error::not_found("item not found"))
}
}

Framework errors such as auth, database, template, validation, and extractor errors, plus application vyuh::Error values, normalize into ErrorReport before they are rendered. The site error handler can replace the final body, status, headers, and content type. Validation ErrorReport bodies include field-oriented code, message, and params entries. See Errors, Site, and Validation.

Raw Responses

Use Response when a route needs full control:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::routes::Response;

async fn raw() -> Response {
    (StatusCode::CREATED, "created").into_response()
}
}

Raw responses are an escape hatch. Vyuh cannot infer precise OpenAPI response metadata from an opaque Response, so document it with route OpenAPI overrides when the endpoint is part of a public API.

OpenAPI

Vyuh infers the primary response from the return type:

Return typeOpenAPI metadata
Data<T>JSON response body when T: JsonSchema
Json<T>JSON response body when T: JsonSchema
Html<String>text/html response
StatusCodeempty response; use overrides for exact status docs
NoContent or ()empty success response
Responseunknown unless patched

Use OpenAPI response overrides for non-200 success responses, additional error responses, custom descriptions, or raw responses.

Examples

Failure Modes

  • Serialization failure for Data<T> or Json<T> becomes an application error.
  • Template rendering failure returns TemplateError and flows through the error pipeline.
  • Raw Response values are allowed, but Vyuh cannot infer precise OpenAPI metadata from them.

Current Limitations

  • Response metadata is inferred from the primary return type unless explicitly patched.
  • Raw responses require manual OpenAPI metadata for public APIs.
  • Content negotiation is application-owned; wrappers choose their content type directly.

Validation

Vyuh validation is explicit at the route boundary. Parsing and validation are separate steps:

  • Json<T>, Query<T>, Path<T>, and Form<T> parse request data.
  • Valid<E> opts a parsed extractor into runtime validation.
  • #[derive(Validate)] implements the validation rules.
  • ValidationSchema exposes accurately representable rules to OpenAPI.

This keeps API behavior visible in the handler signature:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn parse_only(Json(input): Json<CreateUser>) {
    // parsed only
}

async fn parse_and_validate(Valid(Json(input)): Valid<Json<CreateUser>>) {
    // parsed and validated
}
}

Mental Model

  • Request wrappers parse.
  • Valid<E> validates.
  • Validate decides runtime correctness.
  • ValidationSchema describes supported constraints for OpenAPI.
  • Unsupported or business-specific rules never appear in OpenAPI unless they opt in with explicit custom_schema vendor metadata.

Defining Rules

Derive Validate on request DTOs and add #[validate(...)] attributes:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Deserialize, JsonSchema, Validate)]
struct CreateUser {
    #[validate(email)]
    email: String,

    #[validate(min_length = 3, max_length = 80)]
    name: String,

    #[validate(min = 18)]
    age: u8,
}
}

Deriving Validate alone does not change route behavior. A route validates only when the argument uses Valid<E>.

Supported derive rules:

CategoryRules
Stringsmin_length, max_length, exact_length, pattern
Formatsemail, url, uuid, phone_e164, ipv4, ipv6, date, datetime
Numbersmin, max, exclusive_min, exclusive_max, multiple_of
Collectionsmin_items, max_items, unique_items
Choicesenum_values(...)
Nested datadelegate
Custom logiccustom = "path", optional custom_schema = "name"

min_length, max_length, and exact_length count Unicode characters. They match JSON Schema minLength and maxLength semantics, not UTF-8 byte length.

Using Valid

Valid<E> is generic over request wrappers:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn create(Valid(Json(input)): Valid<Json<CreateUser>>) {}
async fn search(Valid(Query(input)): Valid<Query<SearchParams>>) {}
async fn update(Valid(Path(input)): Valid<Path<UserPath>>) {}
async fn login(Valid(Form(input)): Valid<Form<LoginForm>>) {}
}

The wrapped type must implement Validate. If it does not, Rust reports a compile-time trait-bound error at the route signature.

Valid<E> supports Deref and into_inner():

#![allow(unused)]
fn main() {
async fn create(valid: Valid<Json<CreateUser>>) {
    let Json(input) = valid.into_inner();
}
}

Standalone Validation

Validation is not tied to HTTP. Any value whose type implements Validate can be checked directly from tests, services, commands, tasks, or ordinary application code:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Validate)]
struct CreateUser {
    #[validate(email)]
    email: String,

    #[validate(min_length = 3)]
    name: String,
}

let input = CreateUser {
    email: "bad-email".into(),
    name: "Al".into(),
};

let report = input.validate().expect_err("input should be invalid");
assert!(report.has_error("email"));
assert!(report.has_error("name"));
}

Use validate() directly when a test needs to assert the validation rules without building a site or sending an HTTP request. Valid<E> is the route extractor layer; Validate is the underlying runtime rule implementation.

For field-oriented assertions, inspect the ValidationReport:

#![allow(unused)]
fn main() {
let flat = report.to_field_map_flat();
assert_eq!(flat["name"][0], "Must be at least 3 characters");
}

For structured assertions, use the nested helpers:

#![allow(unused)]
fn main() {
let report = input.validate().expect_err("invalid");
let errors = report.to_nested_errors();
assert_eq!(errors["name"][0]["code"], "min_length");
assert_eq!(errors["name"][0]["params"]["min"], "3");
}

Use to_nested_errors() when tests should assert the same code, message, and params shape returned by ErrorReport. Use to_nested_messages() or to_field_map_flat() for simpler message-only assertions.

Errors

Vyuh preserves the distinction between parsing and validation:

  • Parse or deserialization failure returns 400.
  • Validation failure returns 422.

Validation failures are field-oriented and flow through ErrorReport and the site-wide error handler:

{
  "source": "validation",
  "code": "validation_error",
  "detail": "Validation failed.",
  "errors": {
    "email": [
      {
        "code": "email",
        "message": "Enter a valid email address.",
        "params": {}
      }
    ],
    "non_field_errors": [
      {
        "code": "invalid",
        "message": "The submitted data is invalid.",
        "params": {}
      }
    ],
    "name": [
      {
        "code": "min_length",
        "message": "Ensure this field has at least 3 characters.",
        "params": {
          "min": "3"
        }
      }
    ]
  }
}

For the larger error model, including vyuh::Error, command rendering, and task retry behavior, see Errors.

Applications can replace the rendered response with SiteConf::errors(...). The validation report remains the normalized transport object before rendering. Root-level errors are emitted under non_field_errors.

Nested Validation

Use delegate when a field’s type has its own validation rules:

#![allow(unused)]
fn main() {
#[derive(Deserialize, JsonSchema, Validate)]
struct Address {
    #[validate(min_length = 2)]
    city: String,
}

#[derive(Deserialize, JsonSchema, Validate)]
struct Signup {
    #[validate(delegate)]
    address: Address,
}
}

Nested errors preserve field paths so clients can bind failures to form fields or JSON paths.

Runtime-Only Rules

Some validation belongs only at runtime:

  • custom functions,
  • database checks,
  • authorization checks,
  • cross-record or business rules,
  • rules that cannot be represented exactly in JSON Schema.

Runtime validation is authoritative. OpenAPI is documentation only, so Vyuh emits schema constraints only when they can be represented accurately.

Custom validators are runtime-only by default:

#![allow(unused)]
fn main() {
#[validate(custom = "validate_slug")]
slug: String,
}

A custom validator returns Result<(), ValidationError>:

#![allow(unused)]
fn main() {
use vyuh::ValidationError;

fn validate_slug(value: &String) -> Result<(), ValidationError> {
    if value.chars().all(|ch| ch.is_ascii_lowercase() || ch == '-') {
        Ok(())
    } else {
        Err(ValidationError::new("slug", "Enter a valid slug.")
            .with_param("allowed", "lowercase letters, digits, and dashes"))
    }
}
}

Expose a custom validator name to OpenAPI clients only when the frontend should know about it:

#![allow(unused)]
fn main() {
#[validate(custom = "validate_slug", custom_schema = "slug")]
slug: String,
}

This emits Vyuh vendor metadata:

{
  "x-vyuh-validators": ["slug"]
}

custom_schema is not JSON Schema. It is a client hint for application-specific rules, and it is emitted only when the route uses Valid<E>. It requires custom, accepts a string literal, and does not affect runtime validation.

OpenAPI

OpenAPI validation metadata appears only for Valid<E>:

#![allow(unused)]
fn main() {
async fn parse_only(Json(input): Json<CreateUser>) {}
async fn validated(Valid(Json(input)): Valid<Json<CreateUser>>) {}
}

The first route documents the request shape only. The second route documents the request shape plus supported constraints such as string length, numeric minimum and maximum, patterns, formats, collection limits, enum values, and explicit custom hints from custom_schema.

ValidationSchema is the low-level trait generated by #[derive(Validate)]. Most applications do not need to implement it manually; it exists so advanced types can expose schema constraints in the same way as derive-generated types.

Current Limitations

  • Validation runs only where code explicitly calls validate() or uses Valid<E>.
  • OpenAPI contains only constraints that Vyuh can represent accurately, plus explicit custom_schema vendor hints.
  • Custom validators are ordinary Rust functions; Vyuh does not add a runtime validator registry in this pass.

Errors

Vyuh uses different error shapes for different jobs. Application handlers should usually return vyuh::Error. Subsystems still keep their own error types for framework machinery, and rendered output is transport-specific.

Mental Model

LayerTypeUse
Application errorvyuh::Errornormal handler failure from routes, commands, tasks, signals, and emitters
Subsystem errorCommandError, TaskError, SignalError, EmitterError, SiteErrorparsing, registration, storage, dispatch, startup, and other framework machinery
Render inputErrorViewtransport-neutral error data passed to JSON, HTML, and command renderers
HTTP JSON bodyErrorReportdefault JSON response body for routes and middleware
CLI outputcommand rendererhuman-readable stderr output for command failures

The important boundary is this: users return Error from handler logic, while Vyuh converts that error into ErrorView before rendering. ErrorReport is the default HTTP JSON body, not the universal error abstraction.

Rendering Pipeline

All rendered errors follow the same normalization path:

vyuh::Error / ValidationReport / subsystem error
        -> ErrorView
        -> JSON renderer / HTML renderer / command renderer

ErrorView is the shared renderer input:

#![allow(unused)]
fn main() {
pub struct ErrorView {
    pub status: StatusCode,
    pub source: ErrorSourceKind,
    pub kind: ErrorKind,
    pub code: Cow<'static, str>,
    pub message: Cow<'static, str>,
    pub errors: Option<serde_json::Value>,
    pub validation: Option<ValidationReport>,
}
}

Use ErrorView when deciding what message to show. Use ErrorReport only when you want Vyuh’s default HTTP JSON envelope.

Application Errors

Use vyuh::Error for ordinary handler failures:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn show_user(Data(input): Data<UserLookup>) -> Result<Json<UserOut>, Error> {
    let user = find_user(input.id)
        .await
        .ok_or_else(|| Error::not_found("user not found"))?;
    Ok(Json(user))
}
}

Convenience constructors cover common cases:

#![allow(unused)]
fn main() {
Error::bad_request("invalid input")
Error::not_found("record not found")
Error::invalid("business rule failed")
Error::conflict("version conflict")
Error::unavailable("upstream unavailable")
Error::other(err)
Error::wrap(ErrorKind::Unavailable, err)
}

Use with_context(...) to add operator-facing detail while preserving the machine-readable kind.

Subsystem Errors

Subsystem errors describe framework mechanics:

  • CommandError: unknown commands, unknown flags, help rendering, unsupported command schemas, command argument parsing, and command rendering.
  • TaskError: task store, lease, migration, serialization, and retry machinery.
  • SignalError and EmitterError: registration, dispatch, source setup, and source execution machinery.
  • SiteError: configuration, build, startup, and shutdown lifecycle failures.

Application code inside those handlers should still return Error:

#![allow(unused)]
fn main() {
async fn command(Data(args): Data<ReindexArgs>) -> Result<(), vyuh::Error> {
    reindex(args.full).await.map_err(Error::other)
}
}

Validation

Validation failures are structured reports. Valid<E> runs Validate after the inner data wrapper parses or extracts successfully:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn create(Valid(Data(input)): Valid<Data<CreateUser>>) -> Result<(), Error> {
    Ok(())
}
}

Routes convert validation failures into a 422 ErrorView, then render the view as JSON, HTML, or any custom HTTP response. Commands render the same field-oriented report as CLI output:

Validation failed for command 'create-user':

  --email
    Enter a valid email address.

Use 'create-user --help' for usage.

See Validation for the full code, message, and params error object.

HTTP Errors

Routes, middleware, extractors, auth, database, template, validation, and application failures normalize into ErrorView before a response is rendered. Multipart upload parse, MIME screening, and size-limit failures use the same pipeline. The default JSON renderer turns that view into ErrorReport.

Upload-specific status codes follow the same model: malformed multipart returns 400, unsupported declared or sniffed file type returns 415, oversized uploads return 413, and upload validation returns 422.

Applications can customize JSON and HTML rendering separately:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::errors::{ErrorConf, HttpErrorRenderMode};

let conf = SiteConf::default().errors(
    ErrorConf::default()
        .json(|ctx, view| async move {
            (
                view.status,
                Json(serde_json::json!({
                    "code": view.code,
                    "message": "Custom JSON message",
                    "path": ctx.path,
                    "errors": view.errors,
                })),
            )
                .into_response()
        })
        .html(|ctx, view| async move {
            (
                view.status,
                Html(format!("<h1>{}</h1><p>{}</p>", view.status, view.message)),
            )
                .into_response()
        })
        .http_mode(HttpErrorRenderMode::Auto),
);
}

HttpErrorRenderMode::Auto uses JSON by default and HTML when the request accepts text/html. Use Json or Html to force one renderer for all HTTP errors.

Renderer inputs are request-aware. JSON and HTML renderers receive ErrorRequestContext, which includes method, URI, path, and headers:

#![allow(unused)]
fn main() {
ErrorConf::default().json(|ctx, view| async move {
    (
        view.status,
        Json(serde_json::json!({
            "code": view.code,
            "message": view.message,
            "path": ctx.path,
        })),
    )
        .into_response()
})
}

The lower-level handler(|ctx, report| ...) hook remains available when an application wants to replace the final HTTP response from the default ErrorReport directly.

Command Errors

Site::run builds the site, runs a command when one is supplied, and renders command failures for a terminal. With no command arguments it runs the built-in serve command:

  • help output goes to stdout and succeeds;
  • unknown command, unknown flag, missing argument, parse failure, validation failure, and handler failure are rendered to stderr and return non-zero;
  • handler Error values are normalized into ErrorView.

Command parsing and help generation remain CommandError concerns. Handler logic should return Error.

Customize command output separately from HTTP rendering:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::errors::ErrorConf;

let conf = SiteConf::default().errors(
    ErrorConf::default().command(|ctx, view| {
        if view.validation.is_some() {
            format!("{} failed validation. Run '{} --help'.", ctx.command, ctx.command)
        } else {
            format!("{} failed: {}", ctx.command, view.message)
        }
    }),
);
}

Command renderers receive ErrorCommandContext, which includes the command name and raw command arguments. They return a string that is written to stderr for failures.

Commands do not render ErrorReport; command output is terminal text.

Task Errors And Retry

Task retry is explicit. A task handler returning Err(Error) marks the task as failed terminally. Vyuh does not infer retry behavior from ErrorKind.

Return TaskState::retry(...) when work should be retried:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn send_email(Data(job): Data<EmailJob>) -> Result<TaskState<String>, Error> {
    match deliver(&job).await {
        Ok(()) => Ok(TaskState::complete("sent".to_string())?),
        Err(err) if err.is_transient() => Ok(TaskState::retry(
            Some(std::time::Duration::from_secs(60)),
            err.to_string(),
        )),
        Err(err) => Err(Error::unavailable(err.to_string())),
    }
}
}

This keeps retry policy visible in task code instead of hiding it in a broad error classification.

ErrorKind Mapping

ErrorKind carries the broad class of failure. HTTP routes map it to status codes when converting into ErrorReport:

KindHTTP Status
BadRequest400
Unauthorized401
Forbidden403
NotFound404
Conflict409
Integrity409
Invalid422
RateLimited429
Unavailable503
Other500

Commands do not render ErrorReport; they render terminal text. Tasks do not retry from ErrorKind; they use TaskState::retry(...).

Auth

Vyuh auth is opt-in and handler-signature driven. If a route does not extract AuthUser, permit!(...), or ApiKey, Vyuh does no authentication work for that route.

Vyuh does not provide user models, registration, password reset, API-key tables, session tables, or account management flows. Applications own identity storage and decide how users or API keys are verified.

Vyuh auth deliberately stops at verified principals. Applications own user records, account lifecycle, and domain permissions.

Mental Model

NeedUse
Public routeno auth type
Authenticated JWT userAuthUser
Static role maskpermit!(Role, Admin)
Dynamic permissionhandler or service logic
Machine-to-machine authApiKey
Issue or refresh JWTssite.auth()
Django password hashesmake_password, check_password

Roles are optional. The permit! macro is a static role-mask convenience, not a general authorization framework. Omit roles entirely when an application does not need them.

Console roles live under vyuh::console and are separate from application roles. They only control access to Vyuh’s optional read-only console APIs.

Configuration

Auth configuration lives on SiteConf. Cookies are disabled by default:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::auth::{AuthConf, CookieConf};

let conf = SiteConf::default()
    .secret_key("replace-with-a-long-random-secret")
    .auth(
        AuthConf::default()
            .access_ttl(3600)
            .refresh_ttl(604800)
            .access_cookie(CookieConf::new("access_token"))
            .refresh_cookie(CookieConf::new("refresh_token")),
    );
}

By default, JWTs are signed with HS256 using SiteConf.secret_key. SiteConf::validate() checks the configured minimum length for HMAC signing keys.

Useful AuthConf options:

  • access_ttl(seconds) and refresh_ttl(seconds).
  • issuer(value) to require and emit an issuer claim.
  • audience(policy) for optional, required, or disabled audience checks.
  • leeway_seconds(seconds) for clock skew.
  • min_secret_len(len) for signing-secret validation.
  • jwt(...) for algorithm and key configuration.
  • access_cookie(...) and refresh_cookie(...) for opt-in cookies.
  • api_keys(...) for API-key verification.

JWT Users

Use site.auth() to issue tokens:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::auth::{AuthError, AuthUser, TokenPair};

async fn login(site: Site) -> Result<Json<TokenPair>, AuthError> {
    let user = AuthUser::new("user-123", 0);
    let tokens = site.auth().create_token_pair(user, &["web"])?;
    Ok(Json(tokens))
}
}

Extract AuthUser to require an access token:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::auth::AuthUser;

async fn me(user: AuthUser) -> Json<String> {
    Json(user.key.to_string())
}
}

AuthUser contains:

  • key: the authenticated subject, stored as JWT sub.
  • roles: a u64 static role mask.

Access and refresh tokens are distinct. AuthUser accepts access tokens only, and site.auth().refresh(...) accepts refresh tokens only.

Access tokens can be sent with:

Authorization: Bearer <jwt>

The older Authorization: JWT <jwt> form is also accepted.

JWT Algorithms

Vyuh defaults to HS256 with SiteConf.secret_key. That matches the common Django Simple JWT default of HS256 with Django’s SECRET_KEY. Django core itself does not use JWT for its built-in sessions; its signing utilities use SHA-256 signed values and cookies.

Use JwtConf when deployments need a different symmetric algorithm or an asymmetric key pair:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::auth::{AuthConf, JwtConf, JwtKeySource};

let auth = AuthConf::default().jwt(JwtConf::hs512(JwtKeySource::Env(
    "JWT_SECRET".into(),
)));
}

Asymmetric deployments can sign with a private PEM and verify with a public PEM. Relative file paths are resolved from SiteConf.project_dir:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::auth::{AuthConf, JwtConf, JwtKeySource};

let auth = AuthConf::default().jwt(JwtConf::rs256(
    JwtKeySource::File("secrets/jwt-private.pem".into()),
    JwtKeySource::File("secrets/jwt-public.pem".into()),
));
}

Supported algorithms are HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, and EdDSA. HMAC algorithms use one symmetric key and reject a separate verifying key. RSA, ECDSA, and EdDSA configurations require both signing and verifying keys. Token decoding accepts only the configured algorithm.

Cookies

Cookies are opt-in. When configured, login_user(...) writes access and refresh cookies, and logout(...) clears them:

#![allow(unused)]
fn main() {
let conf = SiteConf::default().auth(
    AuthConf::cookie_pair("access_token", "refresh_token")
);
}

CookieConf uses typed CookieSameSite values. Invalid SameSite strings are not silently accepted.

Static Roles

Static role checks are useful for simple route gates:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::auth::{AuthUser, BitRole, permit};

#[derive(BitRole)]
enum AppRole {
    Manager,
    Editor,
    Viewer,
}

async fn managers_only(_permit: permit!(AppRole, Manager)) {}
}

Role expressions support:

  • permit!(AppRole, Manager) - requires one role.
  • permit!(AppRole, Manager | Editor) - requires any listed role.
  • permit!(AppRole, Manager & Editor) - requires all listed roles.

permit! returns 401 for missing or invalid tokens and 403 when the token is valid but lacks the required role mask. It also contributes role metadata to OpenAPI.

Use handler or service logic for dynamic authorization:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::auth::AuthUser;

async fn edit_post(
    site: Site,
    user: AuthUser,
    Path(id): Path<i64>,
) -> Result<Json<PostOut>, Error> {
    let post = load_post(site.db(), id).await?;
    if post.owner_id != user.key.as_ref() {
        return Err(Error::new(vyuh::ErrorKind::Forbidden).with_context("not allowed"));
    }
    Ok(Json(post.into()))
}
}

API Keys

API keys are for machine-to-machine authentication. Vyuh extracts keys, but the application verifies them through a hook. Vyuh does not store plaintext keys or own API-key tables.

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::auth::{ApiKey, ApiKeyPrincipal, ApiKeyVerifier, AuthError};

struct MyVerifier;

impl ApiKeyVerifier for MyVerifier {
    async fn verify(&self, presented: &str) -> Result<ApiKeyPrincipal, AuthError> {
        if presented == "secret" {
            Ok(ApiKeyPrincipal::new("key-1").subject("service-1"))
        } else {
            Err(AuthError::InvalidApiKey)
        }
    }
}

async fn ingest(key: ApiKey) {
    let key_id = key.key_id.to_string();
}
}

Configure the verifier:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::auth::{ApiKeyConf, AuthConf};

let auth = AuthConf::default().api_keys(
    ApiKeyConf::default().verifier(MyVerifier)
);
}

By default, API keys are read from X-API-Key and Authorization: ApiKey <key>. Query-string API keys are disabled by default because URLs are commonly logged and copied. Enable them only when the protocol requires it.

OpenAPI

Auth is reflected in generated OpenAPI specs from handler arguments:

  • AuthUser adds bearerAuth.
  • permit!(Role, ...) adds bearerAuth with role scopes.
  • ApiKey adds apiKeyAuth.

Vyuh emits standard security schemes:

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key

OpenAPI spec endpoints can be protected independently with OpenApiConf::auth(...), which receives the extracted AuthUser.

Password Utilities

Vyuh includes small PBKDF2 password helpers:

  • make_password(password, salt, algorithm)
  • check_password(password, encoded)
  • unusable_password()

The encoded hash format is compatible with Django PBKDF2 hashes. Django compatibility means password-hash compatibility and migration friendliness, not Django sessions, permissions, groups, or user tables.

Examples

Failure Modes

  • Missing JWTs return AuthError::MissingToken and HTTP 401.
  • Invalid JWTs return AuthError::InvalidToken and HTTP 401.
  • Expired JWTs return AuthError::ExpiredToken and HTTP 401.
  • Access/refresh token-kind mismatch returns AuthError::WrongTokenKind and HTTP 401.
  • Missing API keys return AuthError::MissingApiKey and HTTP 401.
  • Invalid API keys return AuthError::InvalidApiKey and HTTP 401.
  • Failed audience, role, or permission checks return AuthError::Forbidden and HTTP 403.

Current Limitations

  • Auth is stateless unless the application stores extra session or token data.
  • API-key storage and revocation are application responsibilities.
  • JWK and JWKS fetching are not included in this pass.
  • Vyuh does not ship user models, registration, password reset, or account management flows.
  • Role masks are limited to 64 bit positions.

Middlewares

Vyuh separates global HTTP transport policy from feature-level route composition. Site-wide middleware is configured with SiteConf::http(...). Bundle and route middleware remain available for feature-specific behavior.

Overview

The main public pieces are:

  • SiteConf::http(HttpConf) for global middleware configuration.
  • SlashPolicy for deterministic trailing-slash behavior.
  • Bundle::with_slash_policy(...) for bundle-level slash policy.
  • RouteConf { slash: Some(...), .. } and #[bundles::route(..., slash = "...")] for route-level slash policy.
  • routes::Middleware and routes::layer_from(...) for route or bundle middleware.

Site-wide middleware is applied through the shared internal router path used by Site::serve, site.start(), and test router construction.

Site HTTP Configuration

Start from defaults and enable only the transport behavior the application needs:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::middlewares::{BodyLimitConf, CompressionConf, HttpConf, TraceConf};

let conf = SiteConf::default().http(HttpConf {
    trace: TraceConf { enabled: true },
    compression: CompressionConf { enabled: true },
    body_limit: BodyLimitConf {
        enabled: true,
        max_bytes: 1024 * 1024,
    },
    ..HttpConf::default()
});
}

Default behavior:

OptionDefault
panic catchingenabled
request idenabled, x-request-id
slash policyAuto
tracedisabled
compressiondisabled
CORSdisabled
timeoutdisabled
body limitdisabled
security headersdisabled
shutdown grace period10000 ms

Request Ids And Panics

Request IDs are enabled by default. Vyuh reads the configured header when it is present, otherwise it generates a new ID and writes it to the response:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::middlewares::{HttpConf, RequestIdConf};

let conf = SiteConf::default().http(HttpConf {
    request_id: RequestIdConf {
        enabled: true,
        header: "x-request-id".into(),
    },
    ..HttpConf::default()
});
}

Panic catching is also enabled by default so panics are converted into framework errors instead of tearing down the server task.

Trace, Compression, CORS, Timeout, And Limits

Trace, compression, CORS, timeout, and body limit are opt-in:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::middlewares::{CorsConf, HttpConf, TimeoutConf};

let conf = SiteConf::default().http(HttpConf {
    cors: CorsConf {
        enabled: true,
        permissive: true,
    },
    timeout: TimeoutConf {
        enabled: true,
        timeout_ms: 10_000,
    },
    ..HttpConf::default()
});
}

Timeout and body-limit failures flow through ErrorReport and the site error handler, so custom API or HTML error pages can render them consistently.

Shutdown

Vyuh starts graceful shutdown on the first Ctrl+C, SIGTERM, touch-reload event, or programmatic site.shutdown(). The default grace period is 10 seconds; after that Vyuh forces server shutdown so long-lived HTTP connections cannot keep the process alive forever.

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::middlewares::{HttpConf, ShutdownConf};

let conf = SiteConf::default().http(HttpConf {
    shutdown: ShutdownConf {
        grace_period_ms: 5_000,
    },
    ..HttpConf::default()
});
}

During graceful shutdown, channel transports close themselves: SSE streams end, WebSockets close, and long-poll requests return promptly.

Security Headers

Security headers are disabled by default because applications often need deployment-specific policy. Enable the built-in defaults when they fit:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::middlewares::{HttpConf, SecurityHeadersConf};

let conf = SiteConf::default().http(HttpConf {
    security_headers: SecurityHeadersConf {
        enabled: true,
        ..SecurityHeadersConf::default()
    },
    ..HttpConf::default()
});
}

The default header policy includes x-content-type-options: nosniff, x-frame-options: DENY, and referrer-policy: same-origin.

Slash Policy

Vyuh does not silently hard-code one trailing-slash rule for the whole server. Slash behavior is route metadata:

PolicyBehavior
Exactonly the declared path matches
Trimalternate trailing slash rewrites internally
RedirectAppendmissing slash redirects to slash form with 308
RedirectRemovetrailing slash redirects to non-slash form with 308
AutoHTML routes redirect to the declared path shape; API/unknown routes trim

Site default:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::middlewares::{HttpConf, SlashConf, SlashPolicy};

let conf = SiteConf::default().http(HttpConf {
    slash: SlashConf {
        policy: SlashPolicy::Auto,
    },
    ..HttpConf::default()
});
}

Bundle override:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::middlewares::SlashPolicy;

let bundle = app_bundle().with_slash_policy(SlashPolicy::RedirectAppend);
}

Route override with the macro:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::routes::Html;

#[bundles::route(path = "/docs/", slash = "redirect_append")]
async fn docs() -> Html<&'static str> {
    Html("docs")
}
}

Route override with direct registration:

#![allow(unused)]
fn main() {
use std::borrow::Cow;
use vyuh::prelude::*;
use vyuh::bundles;
use vyuh::middlewares::SlashPolicy;
use vyuh::routes::{Methods, RouteConf};

let route = bundles::route(
    docs,
    RouteConf {
        name: Cow::Borrowed("docs"),
        path: Cow::Borrowed("/docs/"),
        methods: Methods::GET,
        slash: Some(SlashPolicy::RedirectAppend),
    },
);
}

Slash aliases and redirects are validated at site build. Conflicting generated rules fail build instead of producing ambiguous runtime behavior.

API And HTML Defaults

Auto is designed for mixed applications:

  • API and unknown-response routes trim, so /api/items/ can serve /api/items.
  • HTML routes canonicalize to the declared path shape. A declared /docs/ redirects /docs to /docs/; a declared /about redirects /about/ to /about.

HTML detection uses route return metadata with text/html. Vyuh does not infer slash policy from request Accept headers.

Route And Bundle Middleware

Use site-wide middleware for global transport policy. Use bundle or route middleware for feature-specific behavior and OpenAPI metadata:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::bundles;
use vyuh::routes::layer_from;

let bundle = bundles::bundle! {
    // routes
}
.with_middleware(layer_from(my_tower_layer));
}

Direct Tower or Axum layers remain escape hatches for behavior Vyuh does not wrap yet. Prefer Vyuh’s config and wrapper APIs when they cover the use case so errors, OpenAPI metadata, and future compatibility remain consistent.

Examples

Failure Modes

  • Invalid slash policies or generated slash aliases fail during site build.
  • Timeout and body-limit failures are rendered through the normal error pipeline.
  • Panics are converted to framework errors when panic catching is enabled.

Current Limitations

  • Built-in middleware configuration covers common transport policy, not every Tower layer.
  • Direct Tower layers remain available, but they do not automatically provide Vyuh OpenAPI metadata.
  • Slash policy is based on route metadata, not request Accept headers.

OpenAPI

OpenAPI is generated from Vyuh route metadata. Route configuration, handler signatures, doc comments, middleware metadata, and explicit patches are combined into an OpenAPI 3 spec at site build time.

OpenAPI is a first-class subsystem. It is commonly used with routes, but it has its own configuration, schema conversion, response metadata, and override APIs.

Overview

OpenAPI generation uses these inputs:

  • RouteConf supplies the path, route name, and HTTP methods.
  • Handler arguments supply path, query, body, and ignored state metadata.
  • Handler return types supply response body, content type, and default status metadata.
  • Doc comments supply operation summary and description.
  • PatchOp overrides names, descriptions, argument metadata, response metadata, status codes, and extra responses.
  • OpenApiConf controls the generated spec endpoint and API metadata.

Registration

OpenAPI is registered on a Bundle with with_openapi. By default, Vyuh serves only the JSON spec:

#![allow(unused)]
fn main() {
let bundle = routes.with_openapi(
    bundles::OpenApiConf::default()
        .title("Notes API")
        .version("0.1.0"),
);
}

Use .spec(...) to place the JSON spec under the same prefix as the API it describes:

#![allow(unused)]
fn main() {
let bundle = routes.with_openapi(
    bundles::OpenApiConf::default()
        .title("Notes API")
        .version("0.1.0")
        .description("Notes service API")
        .spec("/api/openapi.json"),
);
}

Add .viewer(...) when the site should also serve an HTML documentation UI. Swagger UI is the default viewer:

#![allow(unused)]
fn main() {
let bundle = routes.with_openapi(
    bundles::OpenApiConf::default()
        .spec("/api/openapi.json")
        .viewer("/api/docs"),
);
}

Use .viewer_with(...) to choose another built-in viewer:

#![allow(unused)]
fn main() {
let bundle = routes.with_openapi(
    bundles::OpenApiConf::default()
        .spec("/api/openapi.json")
        .viewer_with("/api/docs", bundles::DocViewer::Redoc),
);
}

The JSON spec is generated during site build. Schema conversion or serialization errors fail startup instead of failing on the first documentation request.

Order Sensitivity

with_openapi snapshots the route operations that are already registered in the bundle. Routes added or merged after with_openapi will not appear in that generated spec.

Register OpenAPI after all route registration and bundle merge steps for the API surface the spec should describe. Prefixes and metadata applied to already captured routes still affect the final generated paths and operation metadata.

See Bundles for bundle composition rules and the general order-sensitive behavior of bundle-level APIs.

Schemas

Request and response schemas come from JsonSchema types used in extractors and returns:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Deserialize, JsonSchema)]
struct CreateNote {
    title: String,
}

#[derive(Serialize, JsonSchema)]
struct Note {
    id: i64,
    title: String,
}
}

Json<CreateNote> is emitted as an application/json request body. Json<Note> is emitted as an application/json response body. Shared schemas are emitted into OpenAPI components when schemars produces reusable definitions.

Validation Metadata

Validation metadata is opt-in at the route boundary. Deriving Validate on a type does not automatically add validation constraints to every route that uses that type.

Plain wrappers document parse shape only:

#![allow(unused)]
fn main() {
async fn create(Json(input): Json<CreateNote>) {
    // OpenAPI uses the plain JsonSchema for CreateNote.
}
}

Valid<E> documents supported validation constraints and runs runtime validation:

#![allow(unused)]
fn main() {
#[derive(Deserialize, JsonSchema, Validate)]
struct CreateNote {
    #[validate(min_length = 3)]
    title: String,
}

async fn create(Valid(Json(input)): Valid<Json<CreateNote>>) {
    // OpenAPI includes minLength for title, and runtime validation returns 422.
}
}

Vyuh emits only constraints that can be represented accurately in OpenAPI, such as string length, numeric ranges, formats, patterns, collection sizes, enum values, and explicit custom validator hints. Runtime-only validators such as custom remain enforcement logic only unless they opt in with custom_schema = "name", which emits x-vyuh-validators vendor metadata for clients.

See Validation for the full validation model.

Response Metadata

Vyuh infers the primary response from the handler return type. PatchOp can override the inferred response status and description through the direct API:

#![allow(unused)]
fn main() {
bundles::route(create_note, conf).patch(
    PatchOp::new()
        .ret()
        .status(201)
        .doc("Created note")
        .done(),
)
}

The same response override can be written on the route macro:

#![allow(unused)]
fn main() {
#[bundles::route(
    path = "/notes",
    method = "POST",
    returns(status = 201, description = "Created note")
)]
async fn create_note(Json(input): Json<CreateNote>) -> Json<Note> {
    Json(Note {
        id: 1,
        title: input.title,
    })
}
}

Additional responses are appended with PatchOp::append():

#![allow(unused)]
fn main() {
PatchOp::new()
    .append()
    .status(409)
    .typed::<Json<ApiError>>()
    .doc("Title already exists")
    .done()
}

Equivalent macro syntax uses returns(ty = "...") for appended response metadata:

#![allow(unused)]
fn main() {
#[bundles::route(
    path = "/notes",
    method = "POST",
    returns(status = 201, description = "Created note"),
    returns(ty = "Json<ApiError>", status = 409, description = "Title already exists")
)]
async fn create_note(Json(input): Json<CreateNote>) -> Json<Note> {
    Json(Note {
        id: 1,
        title: input.title,
    })
}
}

This is useful for documented error responses, alternate success responses, and handlers returning raw Response.

Argument Overrides

Argument names and descriptions are usually extracted from the handler. PatchOp can adjust argument metadata by position through the direct API:

#![allow(unused)]
fn main() {
PatchOp::new()
    .arg(0)
    .name("id")
    .doc("Note id")
    .done()
}

The same override can be written on the route macro:

#![allow(unused)]
fn main() {
#[bundles::route(
    path = "/notes/{id}",
    arg(pos = 0, name = "id", ty = "i64", description = "Note id")
)]
async fn get_note(Path(id): Path<i64>) -> Json<Note> {
    Json(Note {
        id,
        title: "example".to_string(),
    })
}
}

The patch applies only to metadata. Runtime extraction still follows the handler signature.

Middleware Metadata

Middleware that implements routes::Middleware can return a LayerSpec. Layer parts contribute OpenAPI parameters to every operation in the layered bundle. routes::layer_from(layer) applies a Tower layer without OpenAPI metadata.

Examples

Run the OpenAPI examples in increasing complexity:

cargo run -p vyuh --no-default-features --features sqlite --example openapi_basic
cargo run -p vyuh --no-default-features --features sqlite --example openapi_responses
  • openapi_basic: spec registration for a route bundle.
  • openapi_responses: macro and PatchOp response overrides, documented error responses, and a custom error schema.

Failure Modes

OpenAPI failures are reported during site build:

  • Unsupported schema conversion.
  • JSON serialization failure for the generated spec.
  • Hidden OpenAPI routes colliding with existing route paths.

CONNECT routes can be served by Vyuh but are not represented as OpenAPI operations because OpenAPI 3 does not model CONNECT.

Best Practices

  • Keep handler doc comments user-facing.
  • Use PatchOp for non-200 success statuses and documented error responses.
  • Prefer concrete request and response structs that derive JsonSchema.
  • Keep spec endpoints under the same prefix as the API they describe.

Tasks

Vyuh tasks are durable, typed background handlers for work that must survive process restarts, worker crashes, retries, timed delays, and delayed external decisions. Use them for emails, imports, report generation, webhook retries, approvals, chunked processing, polling loops, and long-running business work where fire-and-forget signals are not enough.

Tasks are part of the same runtime model as routes, commands, signals, emitters, and services. They are registered through bundles, submitted by input type, and inspected through the task store and console APIs.

Use tasks for work that needs persistence, retry, sleep, leases, or external resume. Do not use tasks for in-process fanout, site-lifetime loops, or interactive CLI tools.

When To Use Tasks

Use tasks when work needs one or more of these properties:

  • Durability across process restarts.
  • Retry after transient failures.
  • Delayed execution.
  • Continuation over multiple attempts.
  • Waiting for an external decision before continuing.
  • Controlled background concurrency.

Use signals for in-process notifications. Use emitters to produce scheduled or external events. Use services for site-lifetime clients, caches, and workers.

Mental Model

A task is one durable handler backed by one task record. It may run, save state, sleep, suspend, resume, retry, and eventually complete or fail.

Each task record stores:

  • input: immutable submitted data.
  • state: private continuation state saved by the handler.
  • resume_input: optional input supplied when a suspended task is resumed.
  • output: optional intermediate output saved while suspended.
  • result: final output after completion.

Each wake runs the handler with the latest durable snapshot:

input + state + resume_input -> handler -> Data<T> | TaskState<T> | ()

Return Data<T> or Result<Data<T>, Error> when a task completes with a typed result and does not need continuation control. Return TaskState<T> when the handler must suspend, sleep, retry, fail explicitly, or complete from a continuation. TaskOutcome still exists for low-level store implementors.

Vyuh tasks are durable continuations for a single unit of work. They do not provide a workflow DAG engine, child task orchestration, joins, branches, or dependency graphs.

Registration

The task macro is sugar over direct bundle registration. It does not unlock capabilities that direct registration cannot express.

Macro registration:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct SendEmailJob {
    to: String,
    subject: String,
}

#[bundles::task(name = "send_email")]
async fn send_email(input: Data<SendEmailJob>) {
    println!("sending email to {}", input.to);
}

let bundle = bundles::bundle! {
    send_email,
};
}

Equivalent direct registration:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::bundles;
use vyuh::bundles::IntoBundle;
use vyuh::tasks::TaskHandlerConf;

let bundle = bundles::task(
    send_email,
    TaskHandlerConf::new("send_email"),
)
.into_bundle();
}

Task names are used for registration, storage, diagnostics, logs, and console inspection. Submission is typed: site.tasks().submit(...) finds the registered handler by the submitted data type. Vyuh enforces one handler per task input type.

Handler Shapes

Fire-and-forget handlers can return nothing:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[bundles::task]
async fn send_email(input: Data<SendEmailJob>) {
    println!("sending email to {}", input.to);
}
}

Fallible fire-and-forget handlers can return Result<(), Error>:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[bundles::task]
async fn process_data(input: Data<ProcessingJob>) -> Result<(), Error> {
    println!("processing {}", input.data);
    Ok(())
}
}

Handlers that complete with a typed result can return Data<T>:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct ReportJob {
    account_id: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct Report {
    title: String,
}

#[bundles::task]
async fn build_report(input: Data<ReportJob>) -> Data<Report> {
    Data::new(Report {
        title: format!("report for {}", input.account_id),
    })
}
}

The fallible form is Result<Data<T>, Error>. On success, T is serialized into the task record result. On error, the task is committed as failed.

Handlers that need explicit continuation control should return Result<TaskState<T>, Error>:

#![allow(unused)]
fn main() {
use std::time::Duration;
use vyuh::prelude::*;

#[bundles::task]
async fn poll_status(input: Data<PollJob>) -> Result<TaskState<String>, Error> {
    if is_ready(input.id).await? {
        return Ok(TaskState::complete("ready".to_string())?);
    }

    Ok(TaskState::sleep(
        format!("waiting for {}", input.id),
        Duration::from_secs(30),
    )?)
}
}

Input, State, And Resume Data

Data<T> is the immutable submitted input. It stays the same for the lifetime of the task.

Suspension<T> is an optional handler argument for tasks that can suspend and later resume. suspension.get() returns None on the first run and Some(T) on a resumed run.

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct ApprovalRequest {
    document_id: i64,
    title: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
enum ApprovalDecision {
    Approved { approver: String },
    Rejected { approver: String, reason: String },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct PendingApproval {
    document_id: i64,
    title: String,
}

#[bundles::task(name = "approve_document")]
async fn approve_document(
    suspension: Suspension<ApprovalDecision>,
    input: Data<ApprovalRequest>,
) -> Result<TaskState<ApprovalDecision>, Error> {
    if let Some(decision) = suspension.get() {
        return Ok(TaskState::complete(decision)?);
    }

    let state = PendingApproval {
        document_id: input.document_id,
        title: input.title.clone(),
    };

    Ok(TaskState::suspend(
        ApprovalDecision::Approved {
            approver: "(pending)".to_string(),
        },
        state,
    )?)
}
}

The generic type on TaskState<T> is the output/result type. The state passed to TaskState::suspend or TaskState::sleep may be a different serializable type.

Complete, Suspend, Sleep, Retry, And Fail

Use TaskState constructors for explicit outcomes:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use std::time::Duration;
use vyuh::tasks::TaskState;

let done = TaskState::complete("ok".to_string())?;
let suspended = TaskState::suspend("waiting".to_string(), state)?;
let sleeping = TaskState::<String>::sleep(state, Duration::from_secs(30))?;
let retry = TaskState::<String>::retry(Some(Duration::from_secs(60)), "try later");
let failed = TaskState::<String>::fail("permanent failure");
}

An Err(vyuh::Error) from a handler is committed as a failed task outcome. Retry is never inferred from ErrorKind; return TaskState::retry(...) when the task should be tried again later.

Suspend And Resume

Suspension is for tasks that cannot continue until something else happens: approval, payment confirmation, a webhook, a file upload, or another application event.

When a task suspends, it stores private state and optional externally visible output. The task becomes durable and inactive. It does not consume a worker slot or keep a Rust future alive.

Resume targets a specific task ID:

#![allow(unused)]
fn main() {
let task_id = site.tasks().submit(ApprovalRequest {
    document_id: 101,
    title: "Budget".into(),
}).await?;

let resumed = site
    .tasks()
    .resume(task_id, ApprovalDecision::Approved {
        approver: "carol".into(),
    })
    .await?;
}

resume stores the serialized resume input, moves the suspended task back to pending, notifies workers, and returns the number of affected records. It returns 0 when the task ID does not identify a currently suspended task.

There are no retained topic events in the current task model. If an application needs to resume multiple tasks for one external event, it should keep its own mapping from event keys to task IDs and call resume for each task.

Sleep And Continuation

Sleep is for timed continuation. The handler saves state, chooses a delay, and Vyuh wakes the task after that delay:

#![allow(unused)]
fn main() {
TaskState::<String>::sleep(state, Duration::from_secs(30))?
}

Use sleep for polling external systems, chunked imports, slow retries with progress, and staged work where the next step is time-based rather than event-based.

Sleep is durable. If the process exits while a task is sleeping, the task remains pending with a future ready_at time and can be claimed after that time when workers are running again.

Submit Tasks

Submit by registered data type:

#![allow(unused)]
fn main() {
site.tasks().submit(SendEmailJob {
    to: "user@example.com".into(),
    subject: "Welcome".into(),
}).await?;
}

Use submit_with when the caller needs priority, an initial delay, identity, retry policy, lease duration, max attempts, or initial state:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use std::time::Duration;
use vyuh::tasks::TaskOptions;

site.tasks()
    .submit_with(
        SendEmailJob {
            to: "user@example.com".into(),
            subject: "Welcome".into(),
        },
        TaskOptions {
            priority: 10,
            initial_delay: Some(Duration::from_secs(300)),
            retry_delay: Some(Duration::from_secs(60)),
            lease_duration: Some(Duration::from_secs(900)),
            max_attempts: Some(5),
            identity: Some("welcome:user@example.com".into()),
            ..TaskOptions::default()
        },
    )
    .await?;
}

TaskOptions::identity is an optional duplicate key. When set, Vyuh allows only one active task with that identity. Active means pending, running, or suspended; terminal succeeded and failed tasks release the identity.

TaskOptions::priority defaults to 0. Higher values are claimed first. For tasks with the same priority, Vyuh orders by eligibility time and creation time.

Initial delayed execution is TaskOptions::initial_delay, timed continuation is TaskState::sleep, and recurring creation belongs in emitters.

Concurrency And Leases

TaskConf.concurrency is the maximum number of tasks a runner executes in parallel. TaskConf.batch_size controls how many tasks a runner claims at a time. TaskConf.lease_duration_ms controls the default lease duration for running tasks. Within each claim batch, eligible tasks are ordered by priority first.

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::tasks::TaskConf;

let conf = SiteConf::default().tasks(TaskConf {
    concurrency: 4,
    batch_size: 100,
    lease_duration_ms: 300_000,
    ..TaskConf::default()
});
}

Lease reclaim is conservative: an expired running task is eligible to be claimed again and may run another time. Use TaskOptions::lease_duration for task instances that are expected to run longer than the default lease. A longer lease reduces premature duplicate execution for slow work, but it also delays reclaim when the worker has actually crashed.

Stores

With a database backend feature enabled, Vyuh stores tasks durably:

  • postgres: vyuh.tasks
  • mysql: vyuh_tasks
  • sqlite: vyuh_tasks

All durable stores keep the lifecycle in one row and index the hot paths for claiming pending work, resuming suspended tasks, enforcing active identity uniqueness, and reclaiming expired running leases.

With no backend feature enabled, Vyuh uses MemoryTaskStore. This is good for quick starts, local experiments, docs, and tests that do not need durability. It is not a production durable queue.

Use Postgres for production multi-worker deployments by default. Use MySQL when the rest of the application is already on MySQL and the deployment uses an InnoDB-compatible server with row-locking support. Use SQLite when the app is embedded, local, single-process, or needs a durable queue without a separate database service.

Examples

The canonical runnable task example is:

cargo run -p vyuh --features sqlite --example tasks

It covers:

  • Fire-and-forget task handlers.
  • Fallible task handlers.
  • Direct registration without the task macro.
  • Suspend/resume with Suspension<T> and TaskState<T>.

Failure Modes

  • Unregistered task data types return TaskError::TaskNotFound.
  • Handler Err(vyuh::Error) values are committed as failed task outcomes.
  • Stale workers cannot overwrite tasks they no longer own.
  • Retried tasks become failed when max_attempts is reached.
  • Active task identities cannot be duplicated until the active task reaches a terminal state.
  • resume returns 0 when the task ID does not identify a suspended task.

Current Limitations

  • No exactly-once guarantee.
  • No retained topic events.
  • No durable per-attempt audit history.
  • No multi-task workflow orchestration, child tasks, joins, branches, dependency graphs, or workflow execution engine.
  • MemoryTaskStore is not durable and is not for production task queues.
  • SQLite is intended for embedded, local, and single-process task execution.

Commands

Vyuh commands are site-aware CLI entrypoints. Use them for administration, diagnostics, maintenance, one-off data repair, and local operational tools that should run against the same configured site as the web server.

Commands are not durable background work. Use Tasks for retryable work that must survive restarts, and Services for site-lifetime background loops.

Use commands for one-shot operational actions that need the configured Site. Do not use commands for user-facing HTTP endpoints, retryable background jobs, or long-running supervised workers.

Mental Model

  • Routes handle HTTP requests.
  • Commands handle administration, diagnostics, and maintenance.
  • Tasks handle durable background work with retry, sleep, and resume.
  • Services handle site-lifetime components and background loops.
SubsystemTriggerLifetimeUse ForNot For
RoutesHTTP requestone requestAPIs, pages, webhooksmaintenance scripts
CommandsCLI invocationone process commandadmin, repair, reindex, diagnosticsdurable background jobs
Taskstask submissionpersisted work unitretryable async work, sleeps, external resumeinteractive CLI tools
Servicessite startupsite lifetimeshared clients, caches, in-process loopsone-off operations

Overview

The main public pieces are:

  • bundles::command(handler, CommandConf) for registration.
  • CommandConf::new(name) for naming the command.
  • Data<T> for typed command arguments.
  • Site::run(conf, bundle) for command-aware application entrypoints.
  • Site::execute_command(name, args) for tests and internal execution.

Commands are registered through bundles and execute against a fully built Site. That means command handlers can extract Site and use the same database, templates, services, tasks, logging, and configuration as routes. Service constructors have completed and service workers have been spawned before the command handler runs.

Registration

Define a typed argument struct and register the command as a bundle part:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::commands::CommandConf;

#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct GreetArgs {
    name: String,
    #[serde(default)]
    loud: bool,
}

async fn greet(Data(args): Data<GreetArgs>) -> Result<(), Error> {
    let message = format!("hello {}", args.name);
    println!("{}", if args.loud { message.to_uppercase() } else { message });
    Ok(())
}

let bundle = bundles::bundle([bundles::command(
    greet,
    CommandConf::new("greet").description("Print a greeting."),
)]);
}

Command names must be unique. The reserved help command is provided by Vyuh. Flat names are the primary API today, but namespaced names such as user:create, search:reindex, and db:repair are a good convention for larger applications.

Running

Use Site::run as the normal command-aware application entrypoint. With no command arguments it runs the built-in serve command:

#[tokio::main]
async fn main() -> Result<(), vyuh::SiteError> {
    vyuh::Site::run(vyuh::SiteConf::from_env_with_files()?, app_bundle()).await
}

Then run commands by name through the application binary:

cargo run -- greet --name Vyuh --loud
cargo run -- help
cargo run -- greet --help

Built-in commands include help, serve, health, and config.

Site::run returns an error when site build or command execution fails. With a normal #[tokio::main] async fn main() -> Result<_, _>, success exits with code 0, while site build failures and command failures exit non-zero.

Each command invocation runs one command in that process. Vyuh does not take a global command lock, so separate processes may run commands concurrently. Use database locks, transactions, advisory locks, or application-level coordination when an operation must be exclusive.

Arguments

Command arguments come from the data type’s JsonSchema. Keep command argument structs simple and object-shaped:

  • strings: --name Vyuh
  • integers and numbers: --limit 10
  • booleans: --verbose, --verbose true, or --no-verbose
  • arrays: repeated values after one flag, such as --tag api web admin
  • optional fields: omitted when absent
  • required fields: reported as missing when not supplied

Unsupported schema shapes fail during site build instead of silently producing a command with no arguments.

Array values may also be split across repeated flags:

search:reindex --tag api web --tag admin

Empty arrays are not represented with a flag; omit an optional array field when there are no values. Empty strings are accepted when the shell passes an empty argument, for example --name "".

Data<T> stores an Arc<T> so the same wrapper can be shared across subsystems. It supports pattern matching, Deref, AsRef, and into_inner():

#![allow(unused)]
fn main() {
async fn greet(Data(args): Data<GreetArgs>) -> Result<(), vyuh::Error> {
    println!("hello {}", args.name);
    Ok(())
}
}

Site-Aware Commands

Extract Site when a command needs subsystem access:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

async fn reindex(site: Site, Data(args): Data<ReindexArgs>) -> Result<(), vyuh::Error> {
    let db = site.db();
    let templates = site.templates();
    let tasks = site.tasks();
    Ok(())
}
}

The full site is available because commands run after site build. Service constructors are different: they run while the site is still being assembled.

Commands may enqueue tasks and this is often a good pattern:

#![allow(unused)]
fn main() {
async fn rebuild(site: Site, Data(args): Data<RebuildArgs>) -> Result<(), vyuh::Error> {
    site.tasks()
        .submit_with(RebuildIndex { full: args.full }, Default::default())
        .await
        .map_err(vyuh::Error::other)?;
    Ok(())
}
}

Use this when the command should trigger durable work and return quickly. Do the work directly in the command only when it is naturally short-lived and operationally interactive.

Commands do not automatically run inside a database transaction. Use the normal database/session/transaction APIs explicitly when an operation needs atomicity.

Validation

Wrap command data in Valid<Data<T>> when CLI arguments should be validated with the same rules used by routes:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[derive(Deserialize, Serialize, JsonSchema, Validate)]
struct CreateUser {
    #[validate(email)]
    email: String,
    #[validate(min_length = 3)]
    name: String,
}

async fn create_user(Valid(Data(args)): Valid<Data<CreateUser>>) -> Result<(), Error> {
    println!("creating {}", args.email);
    Ok(())
}
}

Argument parsing errors are command errors. Validation failures keep their field-oriented structure and are rendered as CLI output:

Validation failed for command 'create-user':

  --email
    Enter a valid email address.

Use 'create-user --help' for usage.

See Validation for validation rules and Errors for the application/subsystem error boundary.

Help And Errors

help lists registered commands. <command> --help shows the flags derived from the command argument schema and field descriptions when available. CommandConf::description(...) overrides the handler doc-comment summary in help output:

#![allow(unused)]
fn main() {
bundles::command(
    reindex,
    CommandConf::new("search:reindex").description("Rebuild the search index."),
)
}

Commands and flags are shown in deterministic alphabetical order.

Command errors are explicit:

  • unknown commands mention help;
  • unknown flags include the command and flag name;
  • missing required arguments name the flag;
  • parse errors include the flag, supplied value, and expected type;
  • validation failures render field-oriented CLI output;
  • handler vyuh::Error values render compact application messages;
  • duplicate command names and reserved names fail site build.

CommandError is for command machinery. Application command handlers should return vyuh::Error.

Router Boundary

Commands do not need raw router access. Use Site::serve for server-only binaries or the built-in serve command through Site::run, and use vyuh::testing::router(&site) only for tests or interop that truly needs an Axum Router.

Future Extensions

The command schema gives Vyuh room to add richer CLI features without changing handler signatures:

  • structured CommandOutput renderers such as JSON, YAML, and tables;
  • shell completion generation;
  • richer command grouping for namespaced command sets.

Examples

Command handlers in this page show the supported patterns:

  • typed command arguments with Data<T>;
  • site-aware commands that extract Site;
  • commands that enqueue durable tasks and return quickly.

Current Limitations

  • Commands are in-process and scoped to one built site.
  • Commands are not durable, retried, scheduled, or supervised.
  • Argument parsing intentionally supports a small predictable flag syntax.
  • Commands should stay short-lived; long-running background behavior belongs in services or tasks.
  • Macro sugar for commands is deferred; direct registration is the supported API in this pass.

Signals

Signals are typed, in-process notifications for decoupling application code. They are fire-and-forget: Vyuh does not guarantee delivery, ordering, durability, retries, or handler completion. Use tasks when work must be durable or observable as a unit of background execution.

Use signals for lightweight local fanout after application events. Channels can also consume emitted signal payloads for client-facing live delivery over WebSocket, SSE, or long polling. Do not use signals for durable queues, scheduled polling, or work that must be retried.

Overview

A signal is dispatched by Rust data type. Every handler registered for that data type is eligible to run when the signal is emitted.

Signals are useful for lightweight local fan-out:

  • update an in-memory projection after a route succeeds,
  • notify several local handlers after an emitter produces data,
  • split non-critical side effects out of request handlers.

Signals are not a queue. If the process exits, pending emitted signals can be lost.

Macro Sugar And Direct API

#[bundles::signal] is sugar over direct bundle registration with bundles::signal(handler, SignalConf::default()).

Use the macro for ordinary handlers:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[bundles::signal]
async fn index_note_change(Data(event): Data<NoteChanged>) -> Result<(), vyuh::Error> {
    println!("note {} changed", event.id);
    Ok(())
}

let bundle = bundles::bundle! {
    index_note_change,
};
}

The equivalent direct registration is:

#![allow(unused)]
fn main() {
let bundle = bundles::bundle([bundles::signal::<NoteChanged, _, _>(
    index_note_change,
    signals::SignalConf::default(),
)]);
}

The macro does not add a unique runtime capability. Prefer the direct API for generated, conditional, or table-driven registration.

Data

Signal data types must implement Vyuh’s data bounds: they are serializable, deserializable, schema-capable, sendable, syncable, and 'static.

#![allow(unused)]
fn main() {
#[derive(Clone, Deserialize, Serialize, JsonSchema)]
struct NoteChanged {
    id: i64,
}
}

Handlers extract signal data with Data<T>. The data extractor must be the last handler argument.

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

#[bundles::signal]
async fn audit_note_change(site: Site, Data(event): Data<NoteChanged>) -> Result<(), vyuh::Error> {
    tracing::info!("note {} changed in {:?}", event.id, site.project_dir());
    Ok(())
}
}

Site and other site-derived extractors can appear before Data<T>. Handler logic should return vyuh::Error when it can fail. Vyuh logs handler errors and continues dispatching later handlers.

Emitting Signals

Application code emits signals through the site-scoped signal client:

#![allow(unused)]
fn main() {
site.signals().emit(NoteChanged { id: 42 })?;
}

emit queues dispatch on the site runtime and returns. It is fire-and-forget: emitting a payload with no handlers or channel subscribers is still Ok(()). Handler errors are logged and are not returned to the emitter.

Delayed Signals

SignalClient intentionally has no delayed emit API. Use emitters for scheduled event production, and use tasks when delayed work must be durable, observable, or retryable.

Bundles

Signal handlers are registered as BundlePart values. Macro signal handlers and direct bundles::signal(...) registration produce the same kind of bundle part.

When bundles are merged, handlers for the same data type are appended. A single emitted value can therefore fan out to multiple handlers.

See Bundles for BundlePart, bundle!, cross-module bundle organization, validation, composition behavior, and the general patch API.

Emitters

Emitters can produce typed data and target signals. Cron, periodic, and notification-driven emitters use the same signal dispatch path. Signals remain fire-and-forget even when the source is an emitter.

Examples

Run the signal examples in increasing complexity:

cargo run -p vyuh --no-default-features --features sqlite --example signals_simple
cargo run -p vyuh --no-default-features --features sqlite --example signals_macroless
cargo run -p vyuh --no-default-features --features sqlite --example signals_multiple_handlers
  • signals_simple: macro handler registration and immediate emit API.
  • signals_macroless: equivalent direct bundles::signal(...) registration.
  • signals_multiple_handlers: one data type with multiple handlers.

Failure Modes

  • Handler failure: the failure is logged and dispatch continues to later handlers.
  • Process shutdown: pending emitted signals can be cancelled or lost.
  • Data type mismatch: usually indicates manually constructed data was dispatched with the wrong type.

Best Practices

  • Keep signal handlers small and non-critical.
  • Use stable data structs instead of primitive values for public subsystem boundaries.
  • Use tasks for durable work, retries, delayed persistence, or continuation state.
  • Return vyuh::Error for application failures inside handlers; keep SignalError for signal machinery.
  • Treat scheduling as a convenience timer, not as a queue.
  • Prefer direct registration when signal handlers are generated or conditional.

Current Limitations

  • Signals are in-process only.
  • Dispatch is type-based, not name-based or topic-based.
  • There is no delivery acknowledgement.
  • There is no ordering guarantee across handlers or submissions.
  • Debounce is not part of the v0 signal surface.

Emitters

Emitters are typed in-process event sources. They run on the site runtime, produce Data<T> values, and dispatch that data to another subsystem. For v0, the public target is signals.

Emitters are not durable queues. Missed cron or periodic ticks are not replayed, Postgres notifications are not persisted by Vyuh, and handler failures are logged rather than retried. Use tasks when work must be durable or observable as a unit of background execution.

Use emitters to turn schedules or external notifications into typed Data<T>. Do not use emitters as durable schedulers, queues, or client-facing pub/sub.

Emitter handler execution is isolated from the engine loop and bounded by EmitterConf::max_in_flight_handlers. When that limit is reached, new emitter handler invocations are skipped and logged instead of blocking timers, debounce deadlines, or PgNotify receives.

Overview

Vyuh has three public emitter sources:

  • cron: produce data from a cron schedule.
  • periodic: produce data at a fixed interval.
  • pgnotify: produce data from a Postgres LISTEN/NOTIFY channel.

Emitter handlers return Data<T>. With the default signal target, the emitted data type T is offered to registered signal handlers and channel subscribers. A value with no consumers is ignored. Handlers that can fail should return Result<Data<T>, vyuh::Error>.

Macro Sugar And Direct API

Emitter macros are sugar over direct bundle registration APIs:

  • #[bundles::cron] maps to bundles::cron(handler, CronConf).
  • #[bundles::periodic] maps to bundles::periodic(handler, PeriodicConf).
  • #[bundles::pgnotify] maps to bundles::pgnotify(handler, PgNotifyConf).

Use the macro for ordinary static emitters:

#![allow(unused)]
fn main() {
#[bundles::periodic(secs = 30)]
async fn publish_heartbeat(IterCount(count): IterCount) -> Data<Heartbeat> {
    Data::new(Heartbeat { count })
}
}

The equivalent direct registration is:

#![allow(unused)]
fn main() {
let part = bundles::periodic::<Heartbeat, _, _>(
    publish_heartbeat,
    emitters::PeriodicConf {
        interval: std::time::Duration::from_secs(30),
        target: emitters::EmitTarget::Signal,
    },
);
}

The macro path does not add a unique runtime capability. Prefer direct registration when emitters are generated, conditional, or table-driven.

Handler Signatures

Emitter handlers can extract Site, IterCount, and IterInstant before returning Data<T>.

#![allow(unused)]
fn main() {
#[bundles::periodic(secs = 60)]
async fn publish_minute(site: Site, IterCount(count): IterCount) -> Result<Data<MinuteTick>, vyuh::Error> {
    Ok(Data::new(MinuteTick {
        count,
        project: site.project_dir().display().to_string(),
    }))
}
}

IterCount is the number of times that emitter work item has fired. It starts at 0. IterInstant is the previous fire time, or None for the first run.

Cron

Cron emitters use the cron crate schedule syntax. Macro cron expressions are parsed at compile time.

#![allow(unused)]
fn main() {
#[bundles::cron(expr = "0 0 0 * * *")]
async fn publish_daily() -> Data<DailyTick> {
    Data::new(DailyTick)
}
}

Direct registration uses CronConf:

#![allow(unused)]
fn main() {
let part = bundles::cron::<DailyTick, _, _>(
    publish_daily,
    emitters::CronConf {
        expr: "0 0 0 * * *".to_string(),
        target: emitters::EmitTarget::Signal,
    },
);
}

Cron emitters run in-process. If the site is stopped during a scheduled time, Vyuh does not replay that tick when the site starts again.

Periodic

Periodic emitters run on a fixed in-process interval. The macro accepts secs, millis, or both.

#![allow(unused)]
fn main() {
#[bundles::periodic(secs = 1, millis = 500)]
async fn publish_queue_tick() -> Data<QueueTick> {
    Data::new(QueueTick)
}
}

Direct registration uses PeriodicConf:

#![allow(unused)]
fn main() {
let part = bundles::periodic::<QueueTick, _, _>(
    publish_queue_tick,
    emitters::PeriodicConf {
        interval: std::time::Duration::from_millis(1500),
        target: emitters::EmitTarget::Signal,
    },
);
}

Periodic emitters are timers, not queues. Slow handlers and process shutdown can delay or lose ticks.

PgNotify

PgNotify emitters listen to a Postgres channel and receive the raw notification data as Data<String>.

#![allow(unused)]
fn main() {
#[bundles::pgnotify(channel = "notes_changed")]
async fn publish_note_notification(payload: Data<String>) -> Data<NoteNotification> {
    Data::new(NoteNotification {
        raw: payload.to_string(),
    })
}
}

Direct registration uses PgNotifyConf:

#![allow(unused)]
fn main() {
let part = bundles::pgnotify::<NoteNotification, _, _>(
    publish_note_notification,
    emitters::PgNotifyConf {
        channel: "notes_changed".to_string(),
        target: emitters::EmitTarget::Signal,
        debounce: None,
    },
);
}

PgNotify is Postgres-only. MySQL and SQLite builds can use cron and periodic emitters, but pgnotify requires Postgres LISTEN/NOTIFY.

PgNotify Debounce

PgNotify emitters can debounce bursty notifications before running the handler:

#![allow(unused)]
fn main() {
#[bundles::pgnotify(
    channel = "notes_changed",
    debounce_millis = 250,
    debounce = "leading_trailing"
)]
async fn publish_note_notification(payload: Data<String>) -> Data<NoteNotification> {
    Data::new(NoteNotification {
        raw: payload.to_string(),
    })
}
}

Supported modes are:

ModeBehavior
leadingrun immediately for the first notification and suppress the rest of the window
trailingrun once after a quiet window with the last payload
leading_trailingrun immediately, then run once more with the last payload only when more notifications arrived

If debounce_millis or debounce_secs is set without debounce, the mode defaults to trailing. Debounce is scoped to one PgNotify emitter registration, not shared globally by channel name.

When a PgNotify emitter produces the same Data<T> as a cron or periodic emitter, every raw notification still postpones that timer fallback. This means periodic or cron fallback runs when no notifications arrive, but is pushed back while notifications are active, even if debounce suppresses immediate handler execution.

Pending trailing emissions are not flushed on shutdown.

PgNotify listeners reconnect automatically with bounded backoff and re-listen to configured channels. PgNotify is still best-effort: notifications can be missed during database disconnects or dropped when the internal notification queue is full. Use periodic or cron fallback when missed notifications require reconciliation.

Emitter runtime limits can be configured on SiteConf:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::emitters::EmitterConf;

let conf = SiteConf::default().emitters(EmitterConf {
    notify_channel_capacity: 2048,
    max_in_flight_handlers: 128,
    pgnotify_reconnect_initial_ms: 250,
    pgnotify_reconnect_max_ms: 30_000,
});
}

Bundles

Emitters are registered as BundlePart values. Macro emitters and direct bundles::cron, bundles::periodic, or bundles::pgnotify registration produce the same kind of bundle part.

Emitter registrations are unique by emitted data type and emitter source kind. Registering two periodic emitters for the same data type, for example, is rejected during bundle validation.

See Bundles for BundlePart, bundle!, cross-module bundle organization, validation, composition behavior, and the general patch API.

Examples

Run the emitter examples in increasing complexity:

cargo run -p vyuh --no-default-features --features sqlite --example emitters_periodic
cargo run -p vyuh --no-default-features --features sqlite --example emitters_direct_api
cargo run -p vyuh --no-default-features --features sqlite --example emitters_cron
cargo run -p vyuh --no-default-features --features sqlite --example emitters_pgnotify
cargo run -p vyuh --no-default-features --features sqlite --example emitters_pgnotify_burst_debounce
cargo run -p vyuh --no-default-features --features sqlite --example signals_emitters
  • emitters_periodic: macro-based periodic emitter and signal handler.
  • emitters_direct_api: equivalent direct periodic registration.
  • emitters_cron: cron emitter using Site extraction.
  • emitters_pgnotify: Postgres notification emitter registration.
  • emitters_pgnotify_burst_debounce: PgNotify emitter with leading and trailing debounce.
  • signals_emitters: complete signal fanout plus cron, periodic, direct, and Postgres-gated PgNotify emitter registration in one runnable example.

Failure Modes

  • Invalid cron expression: macro registration fails at compile time; direct registration records a bundle error.
  • Invalid periodic interval attributes: the macro requires secs, millis, or both.
  • Duplicate emitter: the same data type and source kind is already registered.
  • PgNotify setup failure: startup fails if Postgres notification listening cannot be established.
  • Handler failure: the error is logged and the emitter continues running.
  • Signal target without a consumer: the emitted value is ignored.

Best Practices

  • Return stable data structs and handle the real work in signal handlers.
  • Keep emitter handlers small; use them to produce events, not durable work.
  • Return vyuh::Error for application failures; keep EmitterError for emitter registration and source machinery.
  • Use direct registration for generated or conditional emitter lists.
  • Keep pgnotify data parsing explicit and small.
  • Use tasks for durable continuations, retries, persistence, or job observability.

Current Limitations

  • Emitters are in-process only.
  • Cron and periodic ticks are not persisted or replayed.
  • PgNotify is Postgres-only.
  • The public v0 target is signals.

Channels

Vyuh channels deliver signal payloads to clients over WebSocket, SSE, or long polling. Use them when browser or machine clients need live updates from the same typed events that already drive in-process signal handlers.

Channels are not durable work queues. Use Tasks for durable background work and Signals for in-process handler fanout.

Mental Model

NeedUse
Client-facing live signal deliverychannels
In-process application event fanoutsignals
Scheduled or external event sourcesemitters
Durable retryable worktasks
Site-lifetime state and workersservices

Applications emit typed events with site.signals().emit(T). Channel subscribers declare which signal payload types a user should receive.

Subscribing

Routes extract Subscriber and Channels. Subscriber negotiates WebSocket, SSE, or long polling from the request; application handlers do not need Axum upgrade extractors.

#![allow(unused)]
fn main() {
use vyuh::auth::AuthUser;
use vyuh::prelude::*;

#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
struct TaskUpdated {
    task_id: i64,
}

#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
struct NotificationCreated {
    user_key: String,
    message: String,
}

async fn subscribe(
    user: AuthUser,
    sub: Subscriber,
    channels: Channels,
) -> Result<ChannelResponse, Error> {
    let stream = channels
        .user(UserKey::new(user.key.clone())?)
        .deliver::<TaskUpdated>()
        .deliver_if::<NotificationCreated>(move |msg| msg.user_key == user.key);

    sub.attach(stream).allow(WS | SSE | POLL).await
}
}

If allow(...) is omitted, all transports are allowed:

#![allow(unused)]
fn main() {
sub.attach(stream).await
}

Publishing

There is no channel-specific publish API for normal application events. Emit a signal:

#![allow(unused)]
fn main() {
site.signals().emit(TaskUpdated { task_id: 42 })?;
}

The emitted payload is delivered to registered signal handlers and to channel subscribers whose user stream accepts that payload type.

Delivery Rules

Delivery rules are user-scoped:

  • deliver::<T>() sends every emitted T to that user stream.
  • deliver_if::<T>(predicate) sends only payloads accepted by the predicate.
  • Multiple client connections for the same user share delivery rules.
  • Re-registering a UserKey replaces that user’s older delivery rules.
  • Predicates run on the server before the message is sent or retained.

Authorization belongs in the route before attaching the stream. Do not rely on client-side filtering for private data.

Transport Negotiation

Subscriber chooses a transport from the request:

  • WebSocket when upgrade headers are present, or ?transport=ws.
  • SSE when Accept: text/event-stream, or ?transport=sse.
  • Poll when ?transport=poll, or as the fallback.

Use allow(WS | SSE), allow(SSE), or another bitmask to restrict a route. A request for a disallowed transport returns a stable bad-request error.

JavaScript Clients

All transports deliver the same event envelope:

{
  "id": 123,
  "type": "TaskUpdated",
  "data": { "task_id": 42 },
  "created_at": 1710000000
}

Use the returned cursor or last event id when reconnecting or polling.

Polling

let cursor = null;

async function pollChannels() {
  const url = new URL("/events", window.location.origin);
  url.searchParams.set("transport", "poll");
  if (cursor !== null) {
    url.searchParams.set("cursor", cursor);
  }

  const response = await fetch(url, {
    headers: { Accept: "application/json" },
    credentials: "include",
  });
  if (!response.ok) {
    throw new Error(`channel poll failed: ${response.status}`);
  }

  const body = await response.json();
  cursor = body.cursor ?? cursor;

  for (const event of body.events) {
    handleChannelEvent(event);
  }
}

async function pollLoop() {
  for (;;) {
    try {
      await pollChannels();
    } catch (error) {
      console.error(error);
      await new Promise((resolve) => setTimeout(resolve, 1000));
    }
  }
}

pollLoop();

SSE

let lastEventId = null;

function connectSse() {
  const url = new URL("/events", window.location.origin);
  url.searchParams.set("transport", "sse");
  if (lastEventId !== null) {
    url.searchParams.set("after", lastEventId);
  }

  const events = new EventSource(url, { withCredentials: true });

  events.onmessage = (message) => {
    const event = JSON.parse(message.data);
    lastEventId = event.id;
    handleChannelEvent(event);
  };

  events.addEventListener("TaskUpdated", (message) => {
    const event = JSON.parse(message.data);
    lastEventId = event.id;
    handleTaskUpdated(event.data);
  });

  events.onerror = () => {
    events.close();
    setTimeout(connectSse, 1000);
  };
}

connectSse();

WebSocket

let cursor = null;
let socket = null;

function connectWebSocket() {
  const url = new URL("/events", window.location.origin);
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
  url.searchParams.set("transport", "ws");
  if (cursor !== null) {
    url.searchParams.set("cursor", cursor);
  }

  socket = new WebSocket(url);

  socket.onmessage = (message) => {
    const event = JSON.parse(message.data);
    cursor = event.id;
    handleChannelEvent(event);
  };

  socket.onclose = () => {
    socket = null;
    setTimeout(connectWebSocket, 1000);
  };

  socket.onerror = () => {
    socket.close();
  };
}

connectWebSocket();
function handleChannelEvent(event) {
  switch (event.type) {
    case "TaskUpdated":
      handleTaskUpdated(event.data);
      break;
    default:
      console.debug("unhandled channel event", event);
  }
}

function handleTaskUpdated(task) {
  console.log("task updated", task.task_id);
}

Replay And Backpressure

Channels provide live delivery with bounded replay. ChannelCursor is opaque; clients should pass it back unchanged as after or cursor.

The local backend keeps recent events in memory. It is fast and single-process. It is not durable and does not deliver across multiple server processes.

Subscribers have bounded queues. Slow clients are disconnected, so signal emission does not wait indefinitely on client consumption.

Configuration

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::channels::ChannelConf;

let conf = SiteConf::default().channels(ChannelConf {
    retention_events: 20_000,
    subscriber_queue: 512,
    ..ChannelConf::default()
});
}

Important limits include retention_events, max_message_bytes, replay_limit, subscriber_queue, and long_poll_timeout_ms.

Custom Backends

LocalChannelBackend is the default implementation. The public backend trait is still shaped around bounded replay, opaque cursors, non-blocking publish, and explicit validation so Redis-like backends can later provide cross-process delivery and replay storage.

Predicate closures are process-local. External stores should retain accepted messages and cursors, not predicate code.

Failure Modes

  • invalid cursor or user key: 400
  • disallowed transport: 400
  • oversized messages: 413
  • unavailable backend: 503
  • serialization or transport failure: application error

Current Limitations

  • LocalChannelBackend is process-local and in-memory.
  • Channels provide bounded replay, not durable delivery.
  • Authorization is application-owned and belongs in route handlers.
  • Predicate rules are registered by active subscriptions, not persistent config.

Services

Vyuh services are site-lifetime application components. Use them for shared clients, caches, coordinators, in-process state, and background loops that should be created once when the site starts.

Services are not durable work queues. Use Tasks for work that must survive process restarts, retries, sleeps, or external resume.

Use services for site-lifetime dependencies and workers that should be built once with the site. Do not use Data<T> for services; handlers should extract ServiceRef<T> or use site.service::<T>().

Overview

The main public pieces are:

  • #[bundles::service] for ergonomic service registration.
  • bundles::service(handler) for direct registration.
  • ServiceInstance<T> for returning a built service from a constructor.
  • Service for optional facade exposure and worker registration.
  • ServiceRef<T> and Site::service<T>() for using services.
  • ServiceRunner for service-owned background workers.

Services are built during site startup before routes are served. A service is stored as an Arc<T> and can be extracted by routes, workers, and other callable handlers.

Registration

Service constructors return ServiceInstance<T>:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::services::ServiceInstance;

#[derive(Default)]
struct Counter {
    value: std::sync::atomic::AtomicUsize,
}

impl vyuh::services::Service for Counter {}

#[bundles::service]
async fn counter() -> ServiceInstance<Counter> {
    Counter::default().into()
}

let bundle = bundles::bundle! {
    counter,
};
}

The direct API is equivalent:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;

let bundle = bundles::bundle([bundles::service(counter)]);
}

Only one service can be registered for a concrete service type. Duplicate registrations fail site build.

Construction

Constructors run while the site is being built. They can extract ServiceBuildContext or DbPool:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::db::DbPool;
use vyuh::services::ServiceInstance;

struct SearchIndex {
    db: DbPool,
}

impl vyuh::services::Service for SearchIndex {}

async fn search_index(db: DbPool) -> ServiceInstance<SearchIndex> {
    SearchIndex { db }.into()
}
}

The full Site is intentionally unavailable during service construction, because services are part of building the site.

Using Services

Routes can extract ServiceRef<T>:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::services::ServiceRef;

#[bundles::route(path = "/count")]
async fn count(counter: ServiceRef<Counter>) -> Html<String> {
    let next = counter.value.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
    Html(next.to_string())
}
}

Code that already has a Site can use Site::service<T>():

#![allow(unused)]
fn main() {
let counter = site.service::<Counter>()?;
}

Missing services return ServiceError::NotFound.

Facades

A service can expose a trait object facade. This lets routes depend on a narrow interface instead of the concrete service type:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use vyuh::services::{Service, ServiceError, ServiceExposer};

trait Mailer: Send + Sync {
    fn send(&self, to: &str);
}

struct SmtpMailer;

impl Mailer for SmtpMailer {
    fn send(&self, to: &str) {
        println!("send mail to {to}");
    }
}

impl Service for SmtpMailer {
    fn expose(exposer: &mut ServiceExposer<Self>) -> Result<(), ServiceError> {
        exposer.expose(|service| service as Arc<dyn Mailer>)
    }
}
}

Consumers can then request ServiceRef<dyn Mailer> or site.service::<dyn Mailer>(). Duplicate exposed facade types fail site build.

Workers

Services may register background workers from Service::run:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::services::{Service, ServiceError, ServiceRunner};

impl Service for SearchIndex {
    fn run(&mut self, runner: &mut ServiceRunner) -> Result<(), ServiceError> {
        runner.run("search-index-refresh", |site: Site| async move {
            let shutdown = site.shutdown_notifier();
            loop {
                tokio::select! {
                    _ = shutdown.notified() => break,
                    _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
                        // refresh in-process state
                    }
                }
            }
            Ok(())
        })
    }
}
}

Workers are simple Tokio tasks spawned once at site startup. If a worker returns Err, Vyuh logs the error and the worker stops. Vyuh does not restart service workers automatically; long-running workers should own their loop and listen for shutdown.

Examples

Failure Modes

  • Duplicate concrete service registrations fail site build.
  • Duplicate exposed facade types fail site build.
  • Missing service lookups return ServiceError::NotFound.
  • Service constructor extraction errors fail site build.
  • Worker errors are logged and stop that worker.

Current Limitations

  • Services are in-process and per-site-instance only.
  • Services are not durable and are not retried after process restart.
  • Service workers are not automatically restarted.
  • Vyuh does not provide distributed singleton coordination for services.

Templates

Vyuh templates provide server-side HTML rendering through Minijinja. Templates are loaded when a site is built and are available through site.templates() or the Templates route extractor.

Templates are private bundle resources. They live inside registered bundle asset dirs under templates/**. They are not served as public assets and are not copied by collect_static.

Overview

The main public pieces are:

  • SiteConf::templates(TemplateConf) for template environment configuration.
  • Asset dir templates/** for bundle-owned template files.
  • Site::templates() and the Templates route extractor for rendering.
  • Built-in helpers and filters for assets, reverse URLs, date/time formatting, and common display transforms.
  • TemplateError and TemplateFormatError for loading, rendering, and formatting failures.

Minijinja is the only template engine in v0.

Configuration

Use TemplateConf when the application needs explicit environment behavior:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::templates::{TemplateAutoEscape, TemplateConf, TemplateDateFormats, TemplateUndefined};

let conf = SiteConf::default().templates(TemplateConf {
    auto_escape: TemplateAutoEscape::ByExtension,
    undefined: TemplateUndefined::Strict,
    trim_blocks: true,
    lstrip_blocks: true,
    keep_trailing_newline: true,
    date_formats: TemplateDateFormats {
        date: "%d %b %Y".into(),
        time: "%H:%M".into(),
        datetime: "%d %b %Y, %H:%M".into(),
    },
});
}

Defaults:

  • autoescape by extension.
  • strict undefined values.
  • no block trimming or left stripping.
  • keep trailing newline.
  • date/time patterns use Chrono strftime syntax:
    • date: %Y-%m-%d
    • time: %H:%M
    • datetime: %Y-%m-%d %H:%M

SiteConf::timezone(...) controls local date/time formatting.

Template Sources

Templates are loaded from bundle asset dirs under templates/**. The templates/ prefix is stripped:

assets/templates/dashboard/base.html -> dashboard/base.html
assets/templates/dashboard/login.html -> dashboard/login.html

Non-template files in asset dirs are ignored by the template loader.

Rendering

Render through site.templates():

#![allow(unused)]
fn main() {
let html = site.templates().render(
    "hello.html",
    &serde_json::json!({ "name": "Vyuh" }),
)?;
}

Or extract Templates in a route:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::templates::{TemplateError, Templates};

#[bundles::route(path = "/hello")]
async fn hello(templates: Templates) -> Result<Html<String>, TemplateError> {
    templates.html("hello.html", &serde_json::json!({ "name": "Vyuh" }))
}
}

Templates exposes:

  • render(name, context) - render to String.
  • html(name, context) - render to Html<String>.
  • exists(name) - check if a template is loaded.
  • names() - list loaded template names for diagnostics.

Includes And Inheritance

Includes, imports, macros, and inheritance use the same template names Vyuh registers at site build time. A template loaded as dashboard/layouts/base.html is referenced by that exact name:

{% extends "dashboard/layouts/base.html" %}

{% block title %}Dashboard{% endblock %}

{% block content %}
  {% include "dashboard/components/flash.html" %}
  <h1>Hello {{ user.name }}</h1>
{% endblock %}

Macros use Minijinja’s normal import syntax:

{% from "dashboard/components/forms.html" import field %}
{{ field("email", "Email address") }}

Built-In Helpers

Vyuh registers helpers that are available to every template:

<link rel="stylesheet" href="{{ asset("dashboard/app.css") }}">
<a href="{{ url_for("user_detail", {"id": user.id}) }}">Profile</a>
Generated at {{ now()|format_datetime }}

Helpers:

  • asset(path) returns /assets/<path>.
  • url_for(name, params={}) reverses a named route and fails rendering if the route cannot be resolved.
  • now() returns the current UTC datetime.

Common filters:

  • slugify
  • filesizeformat
  • linebreaksbr
  • truncatechars

Date And Time Formatting

Date/time helpers use SiteConf::timezone(...) and TemplateConf::date_formats:

{{ post.published_at|format_datetime }}
{{ post.published_at|format_datetime("%d %b %Y, %H:%M") }}
{{ post.published_at|datetime }}
{{ post.published_at|format_date }}
{{ post.published_at|date }}
{{ post.published_at|format_time }}
{{ localdate() }}
{{ localdatetime() }}

The same formatting path is available from Rust:

#![allow(unused)]
fn main() {
let label = vyuh::templates::format_datetime(&site, created_at, None)?;
let custom = vyuh::templates::format_date(&site, created_at, Some("%d %b %Y"))?;
let today = vyuh::templates::localdate::<chrono::DateTime<chrono::Utc>>(&site, None)?;
}

Invalid or unsupported values return TemplateFormatError in Rust and a Minijinja render error in templates.

Assets Boundary

Templates and public assets share asset dirs but not visibility:

  • templates/** is private and loaded into Minijinja.
  • public/** is public and served or collected as an asset.

Use public asset URLs from templates:

<link rel="stylesheet" href="{{ asset("dashboard/dashboard.css") }}">

See Assets for public asset serving and collect_static.

Naming And Duplicates

Template names are explicit paths such as dashboard/base.html. There are no package names or hidden namespace rules.

Duplicate template names fail site build. This keeps rendering deterministic and prevents one bundle from silently replacing another bundle’s template.

Examples

Failure Modes

  • Missing templates return TemplateError::NotFound.
  • Duplicate template names fail during site build.
  • Invalid UTF-8 template files fail during site build.
  • Invalid template syntax fails during site build.
  • Render-time template errors return TemplateError::RenderError.
  • Date/time formatting failures return TemplateFormatError or render errors.

Current Limitations

  • Minijinja is the only supported engine.
  • Template filters and globals are framework-provided in v0; arbitrary custom filter/global registration is not yet a stable public API.
  • Templates are loaded at site build time; dynamic template reloading is not a public runtime feature.

Assets

Vyuh assets are bundle-owned files that ship with a feature. They are used for CSS, JavaScript, images, templates, SQL snippets, migrations, and other resource files that belong beside the routes, services, tasks, and commands that use them.

An asset dir is a structured resource root, not a plain static directory. Only files under public/ are web-accessible. Everything else is private framework or application resource data.

Overview

The main public pieces are:

  • #[bundles::asset_dir] for registering a bundle asset directory.
  • embed_silo!("path") for debug-filesystem and release-embedded assets.
  • Runtime serving of public/** under /assets.
  • collect_static for copying bundled public assets to a deployment directory.
  • Minijinja loading of private templates/** files.

Asset dirs are part of bundle composition. A feature can register routes, templates, public CSS, and private resources as one bundle.

Vyuh itself also uses this convention for shared framework web assets. The runtime crate owns vyuh/web/, which contains public CSS, JavaScript, images, landing-page source, and private console templates. When the built-in console is enabled, that directory is registered as an internal asset dir so the console can serve /assets/css/vyuh.css, /assets/css/vyuh.<hash>.css, logos, and small helper scripts without requiring application projects to copy them.

Directory Layout

Use convention folders inside each asset dir:

assets/
  public/
    dashboard/
      dashboard.css
  templates/
    dashboard/
      layouts/
        base.html
  sql/
    reports.sql
  migrations/
    001_create_reports.sql

The folders have different visibility:

  • public/** is served over HTTP and copied by collect_static.
  • templates/** is loaded into Minijinja and is not public.
  • sql/**, migrations/**, and other folders are private resources.

Public namespacing is done by folders under public/. For example, public/dashboard/dashboard.css is served as /assets/dashboard/dashboard.css.

Registration

Register an asset dir in a bundle:

#![allow(unused)]
fn main() {
use rust_silos::{Silo, embed_silo};
use vyuh::prelude::*;
use vyuh::embed;

const ASSETS: Silo = embed_silo!("assets");

#[bundles::asset_dir]
fn assets() -> embed::Dir {
    embed::Dir::new(ASSETS.clone())
}

let bundle = bundles::bundle! {
    assets,
};
}

embed_silo! reads from the filesystem in debug builds and embeds the files in release builds. That keeps local frontend iteration fast while making release binaries self-contained.

Runtime Serving

Vyuh serves bundled public assets under /assets by default. The public/ prefix is stripped from the URL:

public/dashboard/dashboard.css -> /assets/dashboard/dashboard.css
public/images/logo.svg -> /assets/images/logo.svg

Only public/** participates in runtime serving. Requests cannot reach templates/**, sql/**, migrations/**, or other private folders through the asset route.

Built-in framework assets follow the same rule:

vyuh/web/public/css/vyuh.css -> /assets/css/vyuh.css
vyuh/web/public/img/vyuh-logo-transparent.png -> /assets/img/vyuh-logo-transparent.png
vyuh/web/templates/console/layout.html -> private Minijinja template

Static serving is intentionally bundle-owned. Register application assets through bundle asset dirs so public files and private templates ship through the same debug-filesystem and release-embedding machinery.

Templates

Minijinja templates are loaded from templates/**. The templates/ prefix is stripped when the template is registered:

templates/dashboard/layouts/base.html -> dashboard/layouts/base.html
templates/dashboard/login.html -> dashboard/login.html

Template namespacing is done by folders under templates/. Public asset namespacing is done by folders under public/. The two namespaces are independent.

See Templates for rendering APIs, template source rules, and template failure modes.

A dashboard layout can refer to a public asset like this:

<link rel="stylesheet" href="/assets/dashboard/dashboard.css" />

Collect Static

collect_static copies all bundled public/** files to a target directory for deployment through a CDN, reverse proxy, or dedicated static file host.

The destination path strips the public/ prefix:

public/dashboard/dashboard.css -> <output-dir>/dashboard/dashboard.css
public/images/logo.svg -> <output-dir>/images/logo.svg

collect_static does not copy templates, SQL files, migrations, or other private resources. It copies the same public asset surface that runtime serving exposes.

Use collect_static when the application server should not serve assets directly in production, or when a deployment platform expects a static asset directory.

Debug And Release Behavior

Assets registered through embed_silo! have different storage behavior by build mode:

  • Debug builds read from the source filesystem.
  • Release builds serve embedded bytes from the compiled binary.

The logical asset paths stay the same in both modes. A file such as public/dashboard/dashboard.css is addressed as /assets/dashboard/dashboard.css whether it is read from disk during development or served from the binary in production.

Failure Modes

  • Files outside public/** are not publicly served or collected.
  • Missing public files return not found.
  • Invalid paths and traversal attempts are rejected.
  • Template names come from templates/**; public asset names come from public/**.
  • Static files must live under registered bundle asset dirs and public/** to be served by the runtime asset route.

Current Limitations

  • Asset dirs do not have package metadata.
  • Public URL namespacing is folder-based under public/.
  • Private resource folders are reserved for framework and application use; they are not exposed over HTTP.

Uploads

Vyuh handles file uploads through route multipart wrappers and site file storage. Multipart parsing belongs to vyuh::routes; uploaded/runtime file persistence belongs to site.file_storage().

Uploads are separate from assets. Assets are code-shipped files served from bundles and copied by collect_static. Uploads are runtime/user files stored by the configured file storage backend.

Overview

The main public pieces are:

  • MultipartForm<T> for typed multipart/form-data requests.
  • MultipartData for mapping a multipart request into a typed struct.
  • MultipartMap, MultipartSpec, FieldRule, and FileRule for macro-less upload handling.
  • UploadedFile, UploadedText, and JsonPart<T> for parsed multipart parts.
  • SiteConf::uploads(UploadConf) for upload limits and local paths.
  • site.file_storage() for saving accepted files.
  • LocalStorage as the default file storage backend.

Configuration

Configure upload limits and local storage paths on SiteConf:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::file_storage::UploadConf;

let conf = SiteConf::default().uploads(UploadConf {
    dir: "media/uploads".into(),
    base_url: Some("/media/uploads".into()),
    temp_dir: Some("tmp/uploads".into()),
    max_request_bytes: 25 * 1024 * 1024,
    max_file_bytes: 10 * 1024 * 1024,
    max_files: 20,
    max_fields: 100,
    memory_threshold_bytes: 256 * 1024,
});
}

Small uploads stay in memory until memory_threshold_bytes. Larger uploads are spooled to temporary files while the request is parsed. Oversized uploads stop streaming and return 413.

Typed Uploads

Typed multipart parsing uses MultipartData:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::routes::{MultipartForm, UploadedFile};
use vyuh::MultipartData;

#[derive(JsonSchema, MultipartData)]
struct AvatarUpload {
    display_name: String,
    #[upload(
        content_types = ["image/png", "image/jpeg"],
        extensions = ["png", "jpg", "jpeg"],
        sniff = "image",
        max_size = 2_000_000
    )]
    avatar: UploadedFile,
}

async fn upload(MultipartForm(input): MultipartForm<AvatarUpload>) {
    // input.avatar has passed the multipart rules above.
}
}

The derive generates the same MultipartData implementation that can be written manually. The runtime API does not require macros.

Macro-less Uploads

Use MultipartMap directly when the accepted fields are dynamic or when direct registration is clearer:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::routes::multipart::{FileRule, FieldRule, MultipartMap, MultipartSpec};

async fn upload_avatar(site: Site, form: MultipartMap) -> Result<Data<UploadOut>, Error> {
    let form = form.validate(
        MultipartSpec::new()
            .text("display_name", FieldRule::new().required().max_length(80))
            .file(
                "avatar",
                FileRule::new()
                    .required()
                    .content_types(["image/png", "image/jpeg"])
                    .extensions(["png", "jpg", "jpeg"])
                    .sniff_image()
                    .max_size(2_000_000),
            ),
    )?;

    let saved = site.file_storage().save(form.file("avatar")?).await?;
    Ok(Data::new(UploadOut { url: saved.url }))
}
}

MultipartMap parses the request first because it does not know the handler’s rules until validate(...) is called. MultipartForm<T> can reject disallowed field names, declared content types, and extensions while streaming because the spec is known before parsing.

MIME Screening

Vyuh treats these as separate checks:

  • declared multipart Content-Type;
  • filename extension;
  • sniffed MIME type from uploaded bytes.

sniff_image() uses the infer crate against a bounded byte prefix. It can reject invalid image content before the full file is accepted or saved, but it must read a small prefix first.

#![allow(unused)]
fn main() {
FileRule::new()
    .content_types(["image/png"])
    .extensions(["png"])
    .sniff_image()
}

Use SniffRule::mime([...]) when the accepted sniffed MIME types are not just images.

File Storage

Save accepted files through site.file_storage():

#![allow(unused)]
fn main() {
let saved = site.file_storage().save(&input.avatar).await?;
}

LocalStorage writes under UploadConf::dir. Generated names are collision-resistant. save_as(...) accepts only validated StorageName values: absolute paths, .., backslashes, NUL bytes, and empty names are rejected.

UploadedFile::file_name() is client metadata only. Do not use it directly as a storage name.

Failure Modes

Multipart failures use the normal Vyuh error pipeline:

  • malformed multipart returns 400;
  • unsupported declared or sniffed file type returns 415;
  • request or file size limit failures return 413;
  • validation failures return 422;
  • storage failures become vyuh::Error.

HTTP responses are rendered through the configured ErrorView/ErrorReport handlers. See Errors.

OpenAPI

MultipartForm<T> documents a multipart/form-data request body. UploadedFile fields are rendered as binary string fields:

type: string
format: binary

Validation metadata is published only when a route uses Valid<MultipartForm<T>>.

Examples

Current Limitations

  • Multipart handling is route-only.
  • LocalStorage is the only built-in storage backend in this pass.
  • Sniffing can reject after a bounded prefix is read, not before any bytes are received.
  • Uploads are runtime files; static assets remain a separate subsystem.

Database

Vyuh’s database subsystem is a thin SQLx-backed layer around database pools, sessions, query builders, typed row scanning, typed value binding, and database error mapping.

Vyuh has no default database backend feature. With no backend feature enabled, it uses SQLite-compatible SQLx aliases and a shared in-memory SQLite default database URL. This is useful for quick starts, docs, local experiments, and tests.

Production applications should enable exactly one backend feature. MySQL and SQLite are supported by the core query-builder and session APIs where SQLx can express the same behavior. Postgres-only features such as LISTEN/NOTIFY, row locking, and RETURNING * helpers are gated by the postgres feature. Durable task storage is available for Postgres, MySQL, and SQLite, with Postgres recommended for multi-worker deployments.

Overview

The main public pieces are:

  • DbConf and DbPool for SQLx pool setup.
  • DBSession for code that can run against either a pool or transaction.
  • db::select, db::insert, db::update, and db::delete for SQL-shaped query builders.
  • Statement for direct SQL when the builders are not the right fit.
  • Scannable and Bindable for structs used with builders.
  • DbError for database error normalization into framework errors and HTTP responses.

Query builders intentionally stay close to SQL. They do not hide table names, joins, filters, or ordering behind an ORM. SQL fragments remain visible, while bindings are still passed through SQLx arguments.

Direct SQLx Access

Vyuh does not replace SQLx. It keeps SQLx as the database foundation and exposes the underlying pool when direct SQLx is the better tool.

Use DbPool::as_sqlx() to reach the active SQLx pool:

#![allow(unused)]
fn main() {
use sqlx::Row as _;
use vyuh::db::DbPool;

async fn load_count(pool: &DbPool) -> Result<i64, vyuh::db::DbError> {
let row = sqlx::query("SELECT COUNT(*) AS total FROM notes")
    .fetch_one(pool.as_sqlx())
    .await?;
let total: i64 = row.try_get("total")?;
Ok(total)
}
}

Use direct SQLx for complex joins, backend-specific SQL, SQLx macros, streaming, custom JSON aggregation, and queries where the builder would hide more than it helps. Use Vyuh builders when you want portable named placeholders, typed Scannable/Bindable structs, and code that can run against DBSession implementations such as DbPool, transactions, or mocks.

Backend Features

No backend feature is enabled by default:

[dependencies]
vyuh = { version = "0.2" }

In this lightweight mode, DbConf::default() uses a shared in-memory SQLite URL and tasks use MemoryTaskStore. Do not use this mode when the application needs durable task storage or production database behavior.

Production applications should choose exactly one backend feature:

[dependencies]
vyuh = { version = "0.2", features = ["postgres"] }

Available backend features are:

  • postgres - enables Postgres SQLx types and Postgres-only helpers.
  • mysql - enables MySQL SQLx types for the common query/session surface.
  • sqlite - enables SQLite SQLx types for the common query/session surface.

Compile-time checks reject builds with multiple backend features.

Configuration

DbConf can be built directly, loaded from DATABASE_URL, or parsed from a URL with pool settings in the query string:

#![allow(unused)]
fn main() {
use vyuh::db::{DbConf, DbPool};

async fn build_pool() -> Result<(), vyuh::db::DbError> {
let conf = DbConf::from_url("postgres://localhost/app?max=20&min=2&lazy=true")?;
let pool = DbPool::from_conf(&conf).await?;
Ok(())
}
}

The supported URL options are:

  • max - maximum pool connections.
  • min - minimum pool connections.
  • lazy - whether SQLx should connect lazily.

Site::db() returns the site-scoped DbPool.

Macro Sugar And Direct Traits

Database derive macros are sugar over direct trait implementations:

  • #[derive(Scannable)] implements db::Scannable and sqlx::FromRow.
  • #[derive(Bindable)] implements db::Bindable.

The same behavior can be written manually by implementing those traits directly. Use the derives for ordinary structs and direct trait implementations when a type needs custom column ordering, nested scanning, or binding behavior.

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, vyuh::db::Scannable)]
struct Note {
    id: i64,
    title: String,
    done: bool,
}
}

The generated Scannable::scan_column_names() drives the selected columns for db::select("notes").all::<Note, _>(...).

Query Builders

Query builders are created through functions, not macros:

#![allow(unused)]
fn main() {
use vyuh::db::{self, FilteredBuilder};

async fn load<S: vyuh::db::DBSession>(session: &mut S) -> Result<(), vyuh::db::DbError> {
#[derive(Debug, Clone, vyuh::db::Scannable)]
struct Note { id: i64, title: String, done: bool }
let notes: Vec<Note> = db::select("notes")
    .filter("done = :done")
    .bind_as("done", false)
    .order_by("id", true)
    .all(session)
    .await?;
Ok(())
}
}

Builder errors, bind errors, placeholder errors, and invalid identifiers are stored in the builder and returned by the terminal async call.

The common terminal methods are:

  • execute for write queries.
  • one for exactly one row.
  • first for zero-or-one row.
  • all for all rows.
  • count, exists, and page for select queries.

Query Builder Methods

Shared Filtering

  • filter(sql) - Adds a raw SQL predicate joined with AND.
  • bind(value) - Adds a positional SQLx bind value.
  • bind_as(name, value) - Adds a named bind value used by :name placeholders.

db::select(table)

  • alias(prefix, alias) - Maps dotted scan-column prefixes to table aliases.
  • group_by(column) - Adds a GROUP BY column.
  • having(sql) - Adds a raw HAVING predicate joined with AND.
  • order_by(column, ascending) - Adds an ORDER BY expression.
  • paginate(page, per_page) - Sets one-indexed page pagination.
  • slice(offset, count) - Sets LIMIT and OFFSET directly.
  • select_expr(name, scope) - Supplies a computed expression for a scanned column.
  • for_update() - Adds FOR UPDATE on Postgres.
  • for_share() - Adds FOR SHARE on Postgres.
  • one(session) - Fetches exactly one typed row.
  • first(session) - Fetches an optional typed row.
  • all(session) - Fetches all typed rows.
  • count(session) - Fetches the count for the filtered query.
  • exists(session) - Fetches whether any filtered row exists.
  • page(session) - Fetches rows plus pagination metadata.

db::insert(table)

  • row(item) - Binds one Bindable item for insertion.
  • rows(items) - Binds multiple Bindable items for bulk insertion.
  • upsert(item, conflict_cols) - Inserts or does nothing on Postgres conflict.
  • upsert_update(item, conflict_cols) - Inserts or updates non-conflict columns on Postgres conflict.
  • execute(session) - Executes the insert and returns affected rows.
  • one(session) - Inserts and returns one row via Postgres RETURNING *.
  • first(session) - Inserts and returns an optional row via Postgres RETURNING *.
  • all(session) - Inserts and returns all rows via Postgres RETURNING *.

db::update(table)

  • set(item) - Builds the SET clause from a Bindable item.
  • execute(session) - Executes the update and returns affected rows.
  • one(session) - Updates and returns one row via Postgres RETURNING *.
  • first(session) - Updates and returns an optional row via Postgres RETURNING *.
  • all(session) - Updates and returns all rows via Postgres RETURNING *.

db::delete(table)

  • execute(session) - Executes the delete and returns affected rows.
  • first(session) - Deletes and returns an optional row via Postgres RETURNING *.
  • all(session) - Deletes and returns all rows via Postgres RETURNING *.

Named Placeholders

Vyuh supports named placeholders in builder SQL fragments:

#![allow(unused)]
fn main() {
use vyuh::db::{self, FilteredBuilder};

async fn count_open<S: vyuh::db::DBSession>(session: &mut S) -> Result<i64, vyuh::db::DbError> {
let total = db::select("notes")
    .filter("done = :done")
    .bind_as("done", false)
    .count(session)
    .await?;
Ok(total)
}
}

Named placeholders are resolved to the active backend’s placeholder syntax at execution time. Extra named bindings are ignored only when they are not required by the SQL; missing placeholders return a QueryError.

Inserts And Updates

Bindable controls which struct fields are written:

#![allow(unused)]
fn main() {
use vyuh::db::{self, FilteredBuilder};

#[derive(Debug, vyuh::db::Bindable)]
struct NotePatch {
    done: bool,
}

async fn mark_done<S: vyuh::db::DBSession>(session: &mut S) -> Result<(), vyuh::db::DbError> {
let patch = NotePatch { done: true };
db::update("notes")
    .set(&patch)
    .filter("id = :id")
    .bind_as("id", 1_i64)
    .execute(session)
    .await?;
Ok(())
}
}

Postgres builds also expose RETURNING * helpers for insert, update, and delete.

Direct Statements

Use Statement when hand-written SQL is clearer than a builder but you still want to execute through the DBSession abstraction:

#![allow(unused)]
fn main() {
use vyuh::db::{DBSession, Statement};

async fn count<S: DBSession>(session: &mut S) -> Result<i64, vyuh::db::DbError> {
let total: i64 = session
    .fetch_scalar(Statement::from_str("SELECT COUNT(*) FROM notes WHERE done = $1").bind(false))
    .await?;
Ok(total)
}
}

Statement is intentionally low-level. Placeholder syntax in raw SQL is the database driver’s syntax, not Vyuh’s named-placeholder syntax.

Sessions And Transactions

Query code should usually accept impl DBSession. That lets the same function run against a DbPool, a transaction, or the mock DB session used in tests.

#![allow(unused)]
fn main() {
use vyuh::db::{self, DBSession};

#[derive(Debug, vyuh::db::Bindable)]
struct NewTodo { title: String }
async fn create_todo<S: DBSession>(session: &mut S, title: String) -> Result<u64, vyuh::db::DbError> {
    db::insert("todos")
        .row(&NewTodo { title })
        .execute(session)
        .await
}
}

Transactions are started from DbPool::begin() and implement DBSession.

Mock Sessions

vyuh::db::mock::MockDBSession records SQL and returns planned responses. It is useful for testing query construction without a live database.

#![allow(unused)]
fn main() {
use vyuh::db;
use vyuh::db::mock::MockDBSession;

async fn test_query() -> Result<(), vyuh::db::DbError> {
let mut db = MockDBSession::new();
db.plan_fetch_scalar_ok("COUNT(*)", 2_i64);

let total = db::select("notes").count(&mut db).await?;
assert_eq!(total, 2);
Ok(())
}
}

Examples

Failure Modes

  • Invalid table/source identifiers return QueryError::InvalidIdentifier.
  • Missing row data for insert or update returns a bind error.
  • Empty bulk inserts are rejected.
  • Missing named placeholder values return a placeholder error.
  • SQLx row-not-found errors map to DbError::DoesNotExist.
  • SQLx database constraint errors map to DbError::Integrity.
  • Backend-specific helpers return DbError::Unsupported when unavailable.

Current Limitations

  • Vyuh does not provide migrations or schema management in v0.
  • DB derives do not form a full ORM; joins and relationship loading remain explicit SQL/query-builder work.
  • Raw Statement SQL uses native SQLx placeholder syntax.
  • Postgres-only helpers are intentionally not emulated on MySQL or SQLite.

Logging

Vyuh logging is built on Rust’s tracing ecosystem. A site initializes tracing when it is built, keeps file writer guards alive for the site lifetime, and lets applications route logs to stdout, stderr, or rotating files.

Logging is configuration-driven. Application code uses ordinary tracing macros such as tracing::info!, while SiteConf.logging decides which sinks receive those events and which filters are active.

Overview

The main public pieces are:

  • LoggingConf for the full logging setup.
  • LogRule for one sink plus one default filter.
  • LogSink for stdout, stderr, or file output.
  • Rotation for file sink rotation.
  • LoggingError for validation and initialization failures.

SiteConf::default() enables a pretty stdout rule in debug builds and no rules in release builds. Release applications should configure logging explicitly.

Configuration

Configure logging on SiteConf:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::logging::{LogRule, LogSink, LoggingConf, Rotation};

let conf = SiteConf::default().logging(LoggingConf {
    env_prefix: Some("APP_LOG".into()),
    rules: vec![
        LogRule {
            name: "APP".into(),
            sink: LogSink::Stdout { pretty: true },
            default_filter: "info,vyuh=warn".into(),
        },
        LogRule {
            name: "AUDIT".into(),
            sink: LogSink::File {
                dir: "logs".into(),
                rotation: Rotation::Daily,
            },
            default_filter: "warn".into(),
        },
    ],
});
}

Each rule creates one tracing layer. A rule can be disabled by resolving to off, 0, false, or no.

Environment Overrides

The environment prefix defaults to RUST_LOG. For each rule, Vyuh resolves the filter in this order:

  1. <PREFIX>_<UPPERCASE_RULE_NAME>
  2. <PREFIX>
  3. LogRule::default_filter

For example, with env_prefix: Some("APP_LOG") and a rule named Audit, Vyuh checks:

APP_LOG_AUDIT
APP_LOG

Rule names may use mixed case, but environment variable names use uppercase rule names. Filter values use normal tracing_subscriber::EnvFilter syntax:

APP_LOG=info
APP_LOG_AUDIT=vyuh::auth=debug,sqlx=warn
APP_LOG_AUDIT=off

Rule-specific overrides are useful when one sink should be verbose and another should stay quiet.

Sinks

Stdout and stderr support two formats:

  • pretty: true - human-readable development output with ANSI colors.
  • pretty: false - JSON output.

File sinks always write JSON and include target, span data, file/line metadata, thread metadata, and RFC3339 UTC timestamps. Relative file sink directories are resolved under SiteConf.project_dir; absolute directories are used as-is.

File sinks use non-blocking writers. Vyuh stores the writer guards inside the built Site, so logs continue flushing for the site lifetime.

Rotation

File sinks support:

  • Rotation::Daily
  • Rotation::Hourly
  • Rotation::Minutely

The rule name is used as the file prefix. Rule names must be unique because they are used for both environment variables and file prefixes.

Validation

Logging configuration is validated during site build:

  • Rule names must start with an ASCII letter, then contain only letters, digits, or underscores.
  • Rule names must be 48 characters or fewer.
  • Rule names must be unique.
  • env_prefix, when set, must be uppercase letters, digits, or underscores and must start with an uppercase letter.
  • Filters must parse as valid tracing filter directives unless they disable the rule with off, 0, false, or no.

Invalid logging configuration returns SiteError::LoggingError.

Tests

Tests often disable site logging to avoid global tracing subscriber conflicts and noisy output:

#![allow(unused)]
fn main() {
let conf = vyuh::SiteConf::default().log_init(false).logging(
    vyuh::logging::LoggingConf {
        env_prefix: None,
        rules: vec![],
    },
);
}

tracing has one global subscriber per process. If another test or application has already initialized tracing, site logging initialization can fail with LoggingError::SubscriberInit. For integration tests, prefer one shared logging initialization strategy or disable site logging.

Example

  • logging_setup.rs: configure stdout and rotating file logging with environment override names.

Failure Modes

  • Invalid rule names return LoggingError::InvalidRuleName.
  • Invalid environment prefixes return LoggingError::InvalidEnvPrefix.
  • Duplicate rule names return LoggingError::DuplicateRuleName.
  • Invalid filter syntax returns LoggingError::FilterParse.
  • File sink directory creation errors return LoggingError::DirCreation.
  • A second global tracing initialization returns LoggingError::SubscriberInit.

Current Limitations

  • Logging is initialized once per process through tracing’s global subscriber.
  • Vyuh does not currently expose runtime log-level reconfiguration.
  • File logging is JSON-only.

Console

Vyuh console is a built-in operational UI and JSON API for inspection. It is enabled by default in debug builds at /console, disabled by default in release builds, isolated from application auth, and read-only in this pass.

Use it for inspecting registered operations, task records, runtime status, OpenAPI for application routes, and redacted runtime configuration. Do not use it as an application admin framework or a command/task execution surface.

Mental Model

  • Console is a built-in operational app mounted at ConsoleConf.path when ConsoleConf.enabled is true.
  • ConsoleConf::default() enables the console in debug builds and disables it in release builds.
  • Console roles are separate from application roles.
  • Console auth uses a console-only cookie/session. Normal app JWTs do not grant console access.
  • The HTML UI is server-rendered with Minijinja and progressively enhanced with HTMX. JSON APIs remain available under /api.

Configuration

Configuration lives on SiteConf:

#![allow(unused)]
fn main() {
use vyuh::prelude::*;
use vyuh::console::ConsoleConf;

let conf = SiteConf::default().console(
    ConsoleConf::default()
        .enabled(true)
        .path("/console"),
);
}

Defaults:

FieldDefault
enabledcfg!(debug_assertions)
path/console
bootstrap_token_ttl_seconds300
session_ttl_seconds28800
print_bootstrap_urlLocalOnly
cookie_namevyuh_console
page_size_default50
page_size_max250
status_cache_ttl_seconds5

With LocalOnly, Vyuh prints a short-lived bootstrap URL only when the configured host is localhost, 127.0.0.1, or ::1:

Vyuh console enabled:
http://localhost:8080/console/login?token=...
Token expires in 300 seconds.

Bootstrap tokens are in-memory and are not persisted. Consuming a bootstrap token creates a console session cookie and redirects to the console root. The session cookie lasts 8 hours by default.

In debug builds on localhost, 127.0.0.1, or ::1, the console also allows direct access without a bootstrap token. In release builds, enable the console explicitly and keep the bootstrap/session flow or another guarded access policy in place.

Roles

Console roles live under vyuh::console:

#![allow(unused)]
fn main() {
use vyuh::console::ConsoleRole;
}

The roles are Viewer, Operator, and Admin. In this read-only pass, Viewer can access all console APIs. Operator and Admin are reserved for future guarded operations.

These roles do not affect AuthUser, permit!(...), API keys, or application authorization.

Endpoints

All endpoints are mounted under ConsoleConf.path.

MethodPathPurpose
GET/canonical status overview page
GET/login?token=...consume bootstrap token, set console cookie, and redirect to /
GET/login-pageshow console login guidance
GET/overviewstatus overview page
GET/runtimeformatted site, process, and system runtime page
GET/operationsoperation listing page with in-page inspector
GET/operations/{id}operation detail page
GET/taskstask listing page with filters and in-page inspector
GET/tasks/{id}task detail page
GET/openapiOpenAPI page for non-console routes
GET/confredacted runtime configuration page
POST/api/logoutclear console cookie
GET/api/sessioninspect current console session
GET/api/operationslist/search operation metadata
GET/api/operations/{id}inspect one operation
GET/api/taskslist task records
GET/api/tasks/{id}inspect one task record
GET/api/statuscombined site, process, and system status
GET/api/openapiOpenAPI JSON for non-console routes
GET/api/confredacted runtime configuration JSON

There are no mutating endpoints in v1. Console cannot run commands, retry or cancel tasks, fire signals, or control services.

Assets And Templates

Console pages use the package-owned vyuh/web/ assets:

/assets/css/vyuh.css
/assets/css/vyuh.<hash>.css
/assets/img/vyuh-logo-transparent.png
/assets/js/console.js

The HTML templates live under vyuh/web/templates/console/** and are loaded through the same bundle asset template path used by application templates. Applications do not need to copy console assets to enable the built-in console.

Operations

/api/operations is the single operation listing endpoint. Use query parameters for filtering:

/console/api/operations?kind=route&q=user&hidden=false&limit=50

Supported filters:

  • kind: route, command, task, service, signal, cron, periodic, pgnotify, or api_doc.
  • q: text search across name, summary, description, and path.
  • tag: operation tag.
  • owner: operation owner.
  • hidden: true or false.
  • limit and cursor: offset-style pagination.

The response includes operation metadata derived from the same bundle operation model used by routes, OpenAPI, commands, tasks, signals, emitters, and services. The HTML operations page uses the same filters and keeps selected operation request/response details in a right-side inspector.

OpenAPI

/api/openapi generates an OpenAPI JSON document from visible route operations outside the console bundle. /openapi renders the same JSON in the console UI.

Console routes and hidden documentation marker operations are excluded. This keeps the console OpenAPI view focused on the application surface even though the console itself is mounted into the same site.

Tasks

/api/tasks lists task records without claiming or modifying them:

/console/api/tasks?status=pending&priority_min=10&created_from=2026-06-01&created_to=2026-06-30&limit=50

Supported filters:

  • status: pending, running, suspended, succeeded, or failed.
  • name: registered task name.
  • priority_min: minimum task priority.
  • identity: task identity.
  • created_from: inclusive task creation date in YYYY-MM-DD format.
  • created_to: inclusive task creation date in YYYY-MM-DD format.
  • q: text search across name, identity, and last error.
  • limit and cursor: offset-style pagination.

/api/tasks/{id} returns the safe task detail shape for one task ID, including status, attempts, priority, timing, identity, last error, and JSON payload/state/resume/output/result fields when they parse as JSON. The HTML task page exposes search, status, name, identity, and date-range filters and shows selected task details without leaving the list.

Status

/api/status returns one redaction-safe object. /runtime renders the same status data as grouped operational sections with formatted CPU, memory, process, system, and site runtime details.

The status object includes:

  • site fields: Vyuh version, package name, host, port, project directory, timezone, database backend, uptime, enabled compile-time features, operation count, command count, and service count;
  • process fields: PID, executable path, current directory, argv, memory, virtual memory, CPU usage, and platform-supported thread/open-file counts;
  • system fields: hostname, OS, kernel, architecture, CPU, load average, memory, swap, and boot time.

Console never exposes env vars, secrets, JWT keys, API keys, cookies, full database URLs, or raw configuration.

Status is cached in-process for ConsoleConf.status_cache_ttl_seconds, default 5 seconds. Requests inside that window return the previous snapshot instead of refreshing system/process information again.

Config

/api/conf returns a redaction-safe configuration DTO. /conf renders the same DTO as a console page.

The config shape is operational, not a raw SiteConf serialization. It includes site host/port, project directory, timezone, selected database backend, console settings, task and emitter limits, upload limits, channel limits, HTTP middleware flags, and logging sink mode/path.

Sensitive values are omitted or redacted. Console config does not expose env vars, secret values, JWT key material, API key values, cookie values, or full database URLs.

Current Limitations

  • Console is read-only.
  • Console sessions are in-memory, process-local, and expire after ConsoleConf.session_ttl_seconds.
  • Pagination uses offset cursors in this pass.
  • Task listing is inspection-only and does not affect task leasing or retries.