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 withAuthConf::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, orSite::test. - Inside handlers and workers, where
Siteor subsystem handles can be extracted when framework access is needed.
Overview
The main public pieces are:
SiteConffor 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.Siteaccessors such asdb(),tasks(),templates(),service(),auth(),signals(), andreverse().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:
| Method | Purpose |
|---|---|
Site::build | build the site object without starting HTTP |
Site::run | command-aware application entrypoint; no args defaults to serve |
Site::serve | build and directly serve HTTP, ignoring commands |
Site::test | build a test site with an explicit SQLx pool |
site.start | serve 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::IOErrororSiteError::ServeError.
Current Limitations
Siteis an in-process application handle, not a distributed coordinator.- Background engines are tied to the process that built the site.
Site::testuses 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: directbundles::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
Bundlevalues from feature modules and compose them withmerge. - Apply
with_prefix,with_tags, andlayerat clear composition boundaries. - Call
with_openapiafter 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.
RouteConfdefines the route name, path, accepted methods, and optional slash behavior.- A
Bundlecollects 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 byreverse(), operation IDs, and diagnostic metadata. Macro routes default to the function name.path: Axum-style absolute path, such as/notesor/notes/{id}.methods: aMethodsfilter. Macro routes default toGET.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:
Siteis 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 contributesmultipart/form-datametadata. See Uploads.Valid<E>wraps a request extractor and runsValidateafter parsing.AuthUser,permit!(Role, Variant), andApiKeycontribute security metadata.
Common outputs:
Json<T>becomes anapplication/jsonresponse.Html<String>becomes atext/htmlresponse.StatusCodeand()become empty responses.- Raw
Responseis 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 directbundles::route(..., RouteConf)registration.routes_reverse: named routes, path parameters, multi-method registration, prefixing, andreverse().
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_prefixat 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:
| Wrapper | Source | Parse failure | OpenAPI behavior |
|---|---|---|---|
Data<T> | JSON body | 400 | JSON request body or response body |
Json<T> | JSON body | 400 | JSON request body or response body |
Query<T> | query string | 400 | query parameters |
Path<T> | path captures | 400 | path parameters |
Form<T> | URL-encoded form body | 400 | form request body |
MultipartForm<T> | multipart form body | 400, 413, 415 | multipart form request body |
BodyBytes | raw body bytes | 400 | opaque 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
Validaterejected the parsed value. Validation errors return422with field entries containingcode,message, andparams.
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 amultipart/form-datarequest body.BodyBytesbecomes 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
422only whenValid<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>andJson<T>return JSON whenT: Serialize.Html<T>returns HTML.StatusCode,NoContent, redirects, and rawResponsecover 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 type | OpenAPI metadata |
|---|---|
Data<T> | JSON response body when T: JsonSchema |
Json<T> | JSON response body when T: JsonSchema |
Html<String> | text/html response |
StatusCode | empty response; use overrides for exact status docs |
NoContent or () | empty success response |
Response | unknown unless patched |
Use OpenAPI response overrides for non-200 success responses,
additional error responses, custom descriptions, or raw responses.
Examples
routes_json_post.rs: JSON response wrappers in route handlers.openapi_responses.rs: documented response metadata and error responses.
Failure Modes
- Serialization failure for
Data<T>orJson<T>becomes an application error. - Template rendering failure returns
TemplateErrorand flows through the error pipeline. - Raw
Responsevalues 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>, andForm<T>parse request data.Valid<E>opts a parsed extractor into runtime validation.#[derive(Validate)]implements the validation rules.ValidationSchemaexposes 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.Validatedecides runtime correctness.ValidationSchemadescribes supported constraints for OpenAPI.- Unsupported or business-specific rules never appear in OpenAPI unless they
opt in with explicit
custom_schemavendor 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:
| Category | Rules |
|---|---|
| Strings | min_length, max_length, exact_length, pattern |
| Formats | email, url, uuid, phone_e164, ipv4, ipv6, date, datetime |
| Numbers | min, max, exclusive_min, exclusive_max, multiple_of |
| Collections | min_items, max_items, unique_items |
| Choices | enum_values(...) |
| Nested data | delegate |
| Custom logic | custom = "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 usesValid<E>. - OpenAPI contains only constraints that Vyuh can represent accurately, plus
explicit
custom_schemavendor 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
| Layer | Type | Use |
|---|---|---|
| Application error | vyuh::Error | normal handler failure from routes, commands, tasks, signals, and emitters |
| Subsystem error | CommandError, TaskError, SignalError, EmitterError, SiteError | parsing, registration, storage, dispatch, startup, and other framework machinery |
| Render input | ErrorView | transport-neutral error data passed to JSON, HTML, and command renderers |
| HTTP JSON body | ErrorReport | default JSON response body for routes and middleware |
| CLI output | command renderer | human-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.SignalErrorandEmitterError: 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
Errorvalues are normalized intoErrorView.
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:
| Kind | HTTP Status |
|---|---|
BadRequest | 400 |
Unauthorized | 401 |
Forbidden | 403 |
NotFound | 404 |
Conflict | 409 |
Integrity | 409 |
Invalid | 422 |
RateLimited | 429 |
Unavailable | 503 |
Other | 500 |
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
| Need | Use |
|---|---|
| Public route | no auth type |
| Authenticated JWT user | AuthUser |
| Static role mask | permit!(Role, Admin) |
| Dynamic permission | handler or service logic |
| Machine-to-machine auth | ApiKey |
| Issue or refresh JWTs | site.auth() |
| Django password hashes | make_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)andrefresh_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(...)andrefresh_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 JWTsub.roles: au64static 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:
AuthUseraddsbearerAuth.permit!(Role, ...)addsbearerAuthwith role scopes.ApiKeyaddsapiKeyAuth.
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
auth_jwt_basic.rs: issue JWTs and protect a route withAuthUser.auth_cookies.rs: opt-in cookies and refresh flow.auth_roles_static.rs: static role masks andpermit!.auth_dynamic_permission.rs: dynamic authorization in handler code.auth_api_key.rs: verifier-backed API-key route.auth_api_key_openapi.rs: API-key OpenAPI security metadata.
Failure Modes
- Missing JWTs return
AuthError::MissingTokenand HTTP401. - Invalid JWTs return
AuthError::InvalidTokenand HTTP401. - Expired JWTs return
AuthError::ExpiredTokenand HTTP401. - Access/refresh token-kind mismatch returns
AuthError::WrongTokenKindand HTTP401. - Missing API keys return
AuthError::MissingApiKeyand HTTP401. - Invalid API keys return
AuthError::InvalidApiKeyand HTTP401. - Failed audience, role, or permission checks return
AuthError::Forbiddenand HTTP403.
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.SlashPolicyfor 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::Middlewareandroutes::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:
| Option | Default |
|---|---|
| panic catching | enabled |
| request id | enabled, x-request-id |
| slash policy | Auto |
| trace | disabled |
| compression | disabled |
| CORS | disabled |
| timeout | disabled |
| body limit | disabled |
| security headers | disabled |
| shutdown grace period | 10000 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:
| Policy | Behavior |
|---|---|
Exact | only the declared path matches |
Trim | alternate trailing slash rewrites internally |
RedirectAppend | missing slash redirects to slash form with 308 |
RedirectRemove | trailing slash redirects to non-slash form with 308 |
Auto | HTML 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/docsto/docs/; a declared/aboutredirects/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
middlewares_global.rs: site-wide HTTP middleware configuration.middlewares_path_normalization.rs: slash policy behavior.
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
Acceptheaders.
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:
RouteConfsupplies 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.
PatchOpoverrides names, descriptions, argument metadata, response metadata, status codes, and extra responses.OpenApiConfcontrols 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 andPatchOpresponse 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
PatchOpfor 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.tasksmysql:vyuh_taskssqlite: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>andTaskState<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_attemptsis reached. - Active task identities cannot be duplicated until the active task reaches a terminal state.
resumereturns0when 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.
MemoryTaskStoreis 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.
| Subsystem | Trigger | Lifetime | Use For | Not For |
|---|---|---|---|---|
| Routes | HTTP request | one request | APIs, pages, webhooks | maintenance scripts |
| Commands | CLI invocation | one process command | admin, repair, reindex, diagnostics | durable background jobs |
| Tasks | task submission | persisted work unit | retryable async work, sleeps, external resume | interactive CLI tools |
| Services | site startup | site lifetime | shared clients, caches, in-process loops | one-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::Errorvalues 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
CommandOutputrenderers 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 directbundles::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::Errorfor application failures inside handlers; keepSignalErrorfor 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 PostgresLISTEN/NOTIFYchannel.
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 tobundles::cron(handler, CronConf).#[bundles::periodic]maps tobundles::periodic(handler, PeriodicConf).#[bundles::pgnotify]maps tobundles::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:
| Mode | Behavior |
|---|---|
leading | run immediately for the first notification and suppress the rest of the window |
trailing | run once after a quiet window with the last payload |
leading_trailing | run 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 usingSiteextraction.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::Errorfor application failures; keepEmitterErrorfor 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
| Need | Use |
|---|---|
| Client-facing live signal delivery | channels |
| In-process application event fanout | signals |
| Scheduled or external event sources | emitters |
| Durable retryable work | tasks |
| Site-lifetime state and workers | services |
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 emittedTto 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
UserKeyreplaces 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
LocalChannelBackendis 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.Servicefor optional facade exposure and worker registration.ServiceRef<T>andSite::service<T>()for using services.ServiceRunnerfor 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
services_concrete.rs: concrete service registration and route extraction.services_direct.rs: equivalent direct registration throughbundles::service.services_facade.rs: expose and use a trait object facade.services_worker.rs: service-owned background worker with shutdown handling.
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 theTemplatesroute extractor for rendering.- Built-in helpers and filters for assets, reverse URLs, date/time formatting, and common display transforms.
TemplateErrorandTemplateFormatErrorfor 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
- date:
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 toString.html(name, context)- render toHtml<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:
slugifyfilesizeformatlinebreaksbrtruncatechars
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
templates_project.rs: project template directory configuration.templates_assets.rs: templates shipped through#[bundles::asset_dir].templates_route.rs: route extraction ofTemplates.templates_config.rs: environment configuration.templates_datetime.rs: date/time formatting configuration and Rust utilities.
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
TemplateFormatErroror 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_staticfor 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 bycollect_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 frompublic/**. - 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 typedmultipart/form-datarequests.MultipartDatafor mapping a multipart request into a typed struct.MultipartMap,MultipartSpec,FieldRule, andFileRulefor macro-less upload handling.UploadedFile,UploadedText, andJsonPart<T>for parsed multipart parts.SiteConf::uploads(UploadConf)for upload limits and local paths.site.file_storage()for saving accepted files.LocalStorageas 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
uploads_basic.rs: basic typed upload.uploads_validated.rs: MIME, extension, sniffing, and size checks.uploads_macroless.rs:MultipartMapandMultipartSpec.uploads_large.rs: large upload configuration.
Current Limitations
- Multipart handling is route-only.
LocalStorageis 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:
DbConfandDbPoolfor SQLx pool setup.DBSessionfor code that can run against either a pool or transaction.db::select,db::insert,db::update, anddb::deletefor SQL-shaped query builders.Statementfor direct SQL when the builders are not the right fit.ScannableandBindablefor structs used with builders.DbErrorfor 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)]implementsdb::Scannableandsqlx::FromRow.#[derive(Bindable)]implementsdb::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:
executefor write queries.onefor exactly one row.firstfor zero-or-one row.allfor all rows.count,exists, andpagefor select queries.
Query Builder Methods
Shared Filtering
filter(sql)- Adds a raw SQL predicate joined withAND.bind(value)- Adds a positional SQLx bind value.bind_as(name, value)- Adds a named bind value used by:nameplaceholders.
db::select(table)
alias(prefix, alias)- Maps dotted scan-column prefixes to table aliases.group_by(column)- Adds aGROUP BYcolumn.having(sql)- Adds a rawHAVINGpredicate joined withAND.order_by(column, ascending)- Adds anORDER BYexpression.paginate(page, per_page)- Sets one-indexed page pagination.slice(offset, count)- SetsLIMITandOFFSETdirectly.select_expr(name, scope)- Supplies a computed expression for a scanned column.for_update()- AddsFOR UPDATEon Postgres.for_share()- AddsFOR SHAREon 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 oneBindableitem for insertion.rows(items)- Binds multipleBindableitems 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 PostgresRETURNING *.first(session)- Inserts and returns an optional row via PostgresRETURNING *.all(session)- Inserts and returns all rows via PostgresRETURNING *.
db::update(table)
set(item)- Builds theSETclause from aBindableitem.execute(session)- Executes the update and returns affected rows.one(session)- Updates and returns one row via PostgresRETURNING *.first(session)- Updates and returns an optional row via PostgresRETURNING *.all(session)- Updates and returns all rows via PostgresRETURNING *.
db::delete(table)
execute(session)- Executes the delete and returns affected rows.first(session)- Deletes and returns an optional row via PostgresRETURNING *.all(session)- Deletes and returns all rows via PostgresRETURNING *.
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
db_basic.rs: select rows with a typedScannableresult and named filters.db_writes.rs: insert and update rows withBindablestructs.db_raw_statement.rs: execute direct SQL throughStatement.db_sqlx.rs: use direct SQLx against the underlyingDbPool.db_transactions.rs: run builders inside a transaction.
Failure Modes
- Invalid table/source identifiers return
QueryError::InvalidIdentifier. - Missing row data for
insertorupdatereturns 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::Unsupportedwhen 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
StatementSQL 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:
LoggingConffor the full logging setup.LogRulefor one sink plus one default filter.LogSinkfor stdout, stderr, or file output.Rotationfor file sink rotation.LoggingErrorfor 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:
<PREFIX>_<UPPERCASE_RULE_NAME><PREFIX>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::DailyRotation::HourlyRotation::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, orno.
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.pathwhenConsoleConf.enabledis 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:
| Field | Default |
|---|---|
enabled | cfg!(debug_assertions) |
path | /console |
bootstrap_token_ttl_seconds | 300 |
session_ttl_seconds | 28800 |
print_bootstrap_url | LocalOnly |
cookie_name | vyuh_console |
page_size_default | 50 |
page_size_max | 250 |
status_cache_ttl_seconds | 5 |
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.
| Method | Path | Purpose |
|---|---|---|
GET | / | canonical status overview page |
GET | /login?token=... | consume bootstrap token, set console cookie, and redirect to / |
GET | /login-page | show console login guidance |
GET | /overview | status overview page |
GET | /runtime | formatted site, process, and system runtime page |
GET | /operations | operation listing page with in-page inspector |
GET | /operations/{id} | operation detail page |
GET | /tasks | task listing page with filters and in-page inspector |
GET | /tasks/{id} | task detail page |
GET | /openapi | OpenAPI page for non-console routes |
GET | /conf | redacted runtime configuration page |
POST | /api/logout | clear console cookie |
GET | /api/session | inspect current console session |
GET | /api/operations | list/search operation metadata |
GET | /api/operations/{id} | inspect one operation |
GET | /api/tasks | list task records |
GET | /api/tasks/{id} | inspect one task record |
GET | /api/status | combined site, process, and system status |
GET | /api/openapi | OpenAPI JSON for non-console routes |
GET | /api/conf | redacted 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, orapi_doc.q: text search across name, summary, description, and path.tag: operation tag.owner: operation owner.hidden:trueorfalse.limitandcursor: 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, orfailed.name: registered task name.priority_min: minimum task priority.identity: task identity.created_from: inclusive task creation date inYYYY-MM-DDformat.created_to: inclusive task creation date inYYYY-MM-DDformat.q: text search across name, identity, and last error.limitandcursor: 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.