Use when you have a written implementation plan to execute in a separate session with review checkpoints
npx skills add salvo-rs/salvo-skills --skill "salvo-static-files"
Install specific skill from multi-skill repository
# Description
Serve static files, directories, and embedded assets. Use for CSS, JavaScript, images, and downloadable content.
# SKILL.md
name: salvo-static-files
description: Serve static files, directories, and embedded assets. Use for CSS, JavaScript, images, and downloadable content.
Salvo Static File Serving
This skill helps serve static files in Salvo applications, including directories, single files, and embedded assets.
Setup
[dependencies]
salvo = { version = "1.88.1", features = ["serve-static"] }
# For embedded files
rust-embed = "8"
Serving a Directory
use salvo::prelude::*;
use salvo::serve_static::StaticDir;
#[tokio::main]
async fn main() {
let router = Router::with_path("{*path}").get(
StaticDir::new(["static", "public"]) // Multiple fallback directories
.defaults("index.html") // Default file for directories
.auto_list(true) // Enable directory listing
);
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
StaticDir Options
use salvo::serve_static::StaticDir;
let static_handler = StaticDir::new(["static"])
// Default file when accessing directories
.defaults("index.html")
// Enable directory listing
.auto_list(true)
// Include hidden files (starting with .)
.include_dot_files(false)
// Set cache control headers
.cache_control("max-age=3600");
Serving a Single File
use salvo::prelude::*;
use salvo::serve_static::StaticFile;
#[tokio::main]
async fn main() {
let router = Router::new()
.push(Router::with_path("favicon.ico").get(StaticFile::new("static/favicon.ico")))
.push(Router::with_path("robots.txt").get(StaticFile::new("static/robots.txt")));
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
Embedded Static Files
Embed files at compile time for single-binary deployment:
use rust_embed::RustEmbed;
use salvo::prelude::*;
use salvo::serve_static::static_embed;
#[derive(RustEmbed)]
#[folder = "static"] // Folder to embed
struct Assets;
#[tokio::main]
async fn main() {
let router = Router::with_path("{*path}").get(
static_embed::<Assets>()
.fallback("index.html") // SPA fallback
);
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
Combined API and Static Files
use salvo::prelude::*;
use salvo::serve_static::StaticDir;
#[handler]
async fn api_users() -> Json<Vec<String>> {
Json(vec!["Alice".to_string(), "Bob".to_string()])
}
#[handler]
async fn api_posts() -> Json<Vec<String>> {
Json(vec!["Post 1".to_string(), "Post 2".to_string()])
}
#[tokio::main]
async fn main() {
let router = Router::new()
// API routes
.push(
Router::with_path("api")
.push(Router::with_path("users").get(api_users))
.push(Router::with_path("posts").get(api_posts))
)
// Static files for everything else
.push(
Router::with_path("{*path}").get(
StaticDir::new(["static"])
.defaults("index.html")
)
);
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
SPA (Single Page Application) Support
use rust_embed::RustEmbed;
use salvo::prelude::*;
use salvo::serve_static::static_embed;
#[derive(RustEmbed)]
#[folder = "dist"] // Vue/React build output
struct Assets;
#[tokio::main]
async fn main() {
let router = Router::new()
// API routes first
.push(Router::with_path("api/{**rest}").get(api_handler))
// SPA - serve index.html for all other routes
.push(
Router::with_path("{*path}").get(
static_embed::<Assets>()
.fallback("index.html") // All routes fall back to index.html
)
);
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
Serving Different Asset Types
use salvo::prelude::*;
use salvo::serve_static::StaticDir;
#[tokio::main]
async fn main() {
let router = Router::new()
// CSS files
.push(
Router::with_path("css/{*path}").get(
StaticDir::new(["static/css"])
.cache_control("max-age=31536000") // 1 year for hashed assets
)
)
// JavaScript files
.push(
Router::with_path("js/{*path}").get(
StaticDir::new(["static/js"])
.cache_control("max-age=31536000")
)
)
// Images
.push(
Router::with_path("images/{*path}").get(
StaticDir::new(["static/images"])
.cache_control("max-age=86400") // 1 day
)
)
// Uploads (user content, no long cache)
.push(
Router::with_path("uploads/{*path}").get(
StaticDir::new(["uploads"])
.cache_control("max-age=3600") // 1 hour
)
);
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
File Downloads
use salvo::prelude::*;
use salvo::fs::NamedFile;
#[handler]
async fn download_file(req: &mut Request, res: &mut Response) {
let filename: String = req.param("filename").unwrap();
let file_path = format!("downloads/{}", filename);
// Serve file with download headers
match NamedFile::builder(&file_path)
.attached_name(&filename) // Forces download with filename
.send(req.headers(), res)
.await
{
Ok(_) => {}
Err(_) => {
res.status_code(StatusCode::NOT_FOUND);
res.render("File not found");
}
}
}
#[handler]
async fn view_pdf(req: &mut Request, res: &mut Response) {
// Serve PDF for viewing in browser (not download)
match NamedFile::builder("documents/report.pdf")
.content_type("application/pdf")
.send(req.headers(), res)
.await
{
Ok(_) => {}
Err(_) => {
res.status_code(StatusCode::NOT_FOUND);
}
}
}
Directory Listing
use salvo::prelude::*;
use salvo::serve_static::StaticDir;
#[tokio::main]
async fn main() {
let router = Router::with_path("{*path}").get(
StaticDir::new(["files"])
.auto_list(true) // Enable directory listing
.include_dot_files(false) // Hide hidden files
.defaults("index.html") // Show index.html if exists
);
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
Conditional Static Serving
use salvo::prelude::*;
use salvo::serve_static::StaticDir;
#[handler]
async fn check_auth(
depot: &mut Depot,
res: &mut Response,
ctrl: &mut FlowCtrl,
) {
// Check if user is authenticated for protected files
let is_authenticated = depot
.session_mut()
.and_then(|s| s.get::<bool>("logged_in"))
.unwrap_or(false);
if !is_authenticated {
res.status_code(StatusCode::UNAUTHORIZED);
res.render("Please login to access files");
ctrl.skip_rest();
}
}
#[tokio::main]
async fn main() {
let router = Router::new()
// Public static files
.push(
Router::with_path("public/{*path}").get(
StaticDir::new(["static/public"])
)
)
// Protected static files
.push(
Router::with_path("private/{*path}")
.hoop(check_auth)
.get(StaticDir::new(["static/private"]))
);
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
Multiple Fallback Directories
use salvo::serve_static::StaticDir;
// Try directories in order
let static_handler = StaticDir::new([
"static/overrides", // Custom overrides first
"static/default", // Default files second
"node_modules", // npm packages last
])
.defaults("index.html");
Embedded Assets with Custom Handling
use rust_embed::RustEmbed;
use salvo::prelude::*;
#[derive(RustEmbed)]
#[folder = "static"]
struct Assets;
#[handler]
async fn custom_static(req: &mut Request, res: &mut Response) {
let path = req.param::<String>("path").unwrap_or_default();
match Assets::get(&path) {
Some(content) => {
// Determine content type
let content_type = mime_guess::from_path(&path)
.first_or_octet_stream()
.to_string();
res.headers_mut()
.insert("Content-Type", content_type.parse().unwrap());
// Add caching for production
if path.contains(".") { // Has extension = asset
res.headers_mut()
.insert("Cache-Control", "max-age=31536000".parse().unwrap());
}
res.write_body(content.data.to_vec()).ok();
}
None => {
// SPA fallback
if let Some(index) = Assets::get("index.html") {
res.headers_mut()
.insert("Content-Type", "text/html".parse().unwrap());
res.write_body(index.data.to_vec()).ok();
} else {
res.status_code(StatusCode::NOT_FOUND);
}
}
}
}
Complete Production Example
use rust_embed::RustEmbed;
use salvo::prelude::*;
use salvo::serve_static::{StaticDir, static_embed};
use salvo::compression::Compression;
#[derive(RustEmbed)]
#[folder = "dist"]
struct Assets;
#[handler]
async fn api_handler() -> &'static str {
"API Response"
}
#[tokio::main]
async fn main() {
// Compression for all responses
let compression = Compression::new()
.enable_gzip(flate2::Compression::default())
.enable_brotli(11);
let router = Router::new()
.hoop(compression)
// API routes
.push(
Router::with_path("api")
.push(Router::with_path("data").get(api_handler))
)
// Uploads (not embedded)
.push(
Router::with_path("uploads/{*path}").get(
StaticDir::new(["uploads"])
.cache_control("max-age=3600")
)
)
// Embedded static files with SPA support
.push(
Router::with_path("{*path}").get(
static_embed::<Assets>()
.fallback("index.html")
)
);
let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
Server::new(acceptor).serve(router).await;
}
Best Practices
- Use embedded files for deployment: Single binary is easier to deploy
- Set cache headers: Long cache for hashed assets, short for dynamic content
- Enable compression: Serve gzip/brotli compressed files
- SPA fallback: Return index.html for client-side routing
- Separate API from static: Use distinct paths for API and static content
- Security: Don't expose sensitive files, check paths
- Directory listing: Disable in production unless intentional
- Multiple directories: Use fallback order for themes/overrides
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.