salvo-static-files

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Salvo Static File Serving

Salvo 静态文件服务

This skill helps serve static files in Salvo applications, including directories, single files, and embedded assets.
本技能可帮助在Salvo应用中提供静态文件服务,包括目录、单个文件和嵌入式资源。

Setup

配置

toml
[dependencies]
salvo = { version = "0.89.0", features = ["serve-static"] }
toml
[dependencies]
salvo = { version = "0.89.0", features = ["serve-static"] }

For embedded files

用于嵌入式文件

rust-embed = "8"
undefined
rust-embed = "8"
undefined

Serving a Directory

托管目录

rust
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;
}
rust
use salvo::prelude::*;
use salvo::serve_static::StaticDir;

#[tokio::main]
async fn main() {
    let router = Router::with_path("{*path}").get(
        StaticDir::new(["static", "public"])  // 多个备选目录
            .defaults("index.html")            // 目录的默认文件
            .auto_list(true)                   // 启用目录列表
    );

    let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
    Server::new(acceptor).serve(router).await;
}

StaticDir Options

StaticDir 配置选项

rust
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");
rust
use salvo::serve_static::StaticDir;

let static_handler = StaticDir::new(["static"])
    // 访问目录时的默认文件
    .defaults("index.html")
    // 启用目录列表
    .auto_list(true)
    // 包含隐藏文件(以.开头)
    .include_dot_files(false)
    // 设置缓存控制头
    .cache_control("max-age=3600");

Serving a Single File

托管单个文件

rust
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;
}
rust
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:
rust
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;
}
在编译时嵌入文件,实现单二进制文件部署:
rust
use rust_embed::RustEmbed;
use salvo::prelude::*;
use salvo::serve_static::static_embed;

#[derive(RustEmbed)]
#[folder = "static"]  // 要嵌入的文件夹
struct Assets;

#[tokio::main]
async fn main() {
    let router = Router::with_path("{*path}").get(
        static_embed::<Assets>()
            .fallback("index.html")  // SPA 回退页面
    );

    let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
    Server::new(acceptor).serve(router).await;
}

Combined API and Static Files

API 与静态文件结合

rust
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;
}
rust
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 路由
        .push(
            Router::with_path("api")
                .push(Router::with_path("users").get(api_users))
                .push(Router::with_path("posts").get(api_posts))
        )
        // 其他路径均返回静态文件
        .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

SPA(单页应用)支持

rust
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;
}
rust
use rust_embed::RustEmbed;
use salvo::prelude::*;
use salvo::serve_static::static_embed;

#[derive(RustEmbed)]
#[folder = "dist"]  // Vue/React 构建输出目录
struct Assets;

#[tokio::main]
async fn main() {
    let router = Router::new()
        // 优先匹配API路由
        .push(Router::with_path("api/{**rest}").get(api_handler))
        // SPA - 所有其他路由均返回index.html
        .push(
            Router::with_path("{*path}").get(
                static_embed::<Assets>()
                    .fallback("index.html")  // 所有路由回退到index.html
            )
        );

    let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
    Server::new(acceptor).serve(router).await;
}

Serving Different Asset Types

托管不同类型的资源

rust
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;
}
rust
use salvo::prelude::*;
use salvo::serve_static::StaticDir;

#[tokio::main]
async fn main() {
    let router = Router::new()
        // CSS文件
        .push(
            Router::with_path("css/{*path}").get(
                StaticDir::new(["static/css"])
                    .cache_control("max-age=31536000")  // 带哈希的资源缓存1年
            )
        )
        // JavaScript文件
        .push(
            Router::with_path("js/{*path}").get(
                StaticDir::new(["static/js"])
                    .cache_control("max-age=31536000")
            )
        )
        // 图片
        .push(
            Router::with_path("images/{*path}").get(
                StaticDir::new(["static/images"])
                    .cache_control("max-age=86400")  // 缓存1天
            )
        )
        // 上传文件(用户内容,不长期缓存)
        .push(
            Router::with_path("uploads/{*path}").get(
                StaticDir::new(["uploads"])
                    .cache_control("max-age=3600")  // 缓存1小时
            )
        );

    let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
    Server::new(acceptor).serve(router).await;
}

File Downloads

文件下载

rust
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);
        }
    }
}
rust
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);

    // 发送带有下载头的文件
    match NamedFile::builder(&file_path)
        .attached_name(&filename)  // 强制以指定文件名下载
        .send(req.headers(), res)
        .await
    {
        Ok(_) => {}
        Err(_) => {
            res.status_code(StatusCode::NOT_FOUND);
            res.render("文件不存在");
        }
    }
}

#[handler]
async fn view_pdf(req: &mut Request, res: &mut Response) {
    // 在浏览器中预览PDF(不触发下载)
    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

目录列表

rust
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;
}
rust
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)           // 启用目录列表
            .include_dot_files(false)  // 隐藏隐藏文件
            .defaults("index.html")    // 如果存在则显示index.html
    );

    let acceptor = TcpListener::new("0.0.0.0:8080").bind().await;
    Server::new(acceptor).serve(router).await;
}

Conditional Static Serving

条件式静态文件服务

rust
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;
}
rust
use salvo::prelude::*;
use salvo::serve_static::StaticDir;

#[handler]
async fn check_auth(
    depot: &mut Depot,
    res: &mut Response,
    ctrl: &mut FlowCtrl,
) {
    // 检查用户是否有权访问受保护的文件
    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("请登录后访问文件");
        ctrl.skip_rest();
    }
}

#[tokio::main]
async fn main() {
    let router = Router::new()
        // 公开静态文件
        .push(
            Router::with_path("public/{*path}").get(
                StaticDir::new(["static/public"])
            )
        )
        // 受保护的静态文件
        .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

多备选目录

rust
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");
rust
use salvo::serve_static::StaticDir;

// 按顺序尝试目录
let static_handler = StaticDir::new([
    "static/overrides",  // 优先使用自定义覆盖文件
    "static/default",    // 其次是默认文件
    "node_modules",      // 最后是npm包
])
.defaults("index.html");

Embedded Assets with Custom Handling

自定义处理嵌入式资源

rust
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);
            }
        }
    }
}
rust
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) => {
            // 确定内容类型
            let content_type = mime_guess::from_path(&path)
                .first_or_octet_stream()
                .to_string();

            res.headers_mut()
                .insert("Content-Type", content_type.parse().unwrap());

            // 为生产环境添加缓存
            if path.contains(".") {  // 带扩展名的文件属于资源
                res.headers_mut()
                    .insert("Cache-Control", "max-age=31536000".parse().unwrap());
            }

            res.write_body(content.data.to_vec()).ok();
        }
        None => {
            // SPA 回退
            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

完整生产环境示例

rust
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;
}
rust
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() {
    // 为所有响应启用压缩
    let compression = Compression::new()
        .enable_gzip(flate2::Compression::default())
        .enable_brotli(11);

    let router = Router::new()
        .hoop(compression)
        // API 路由
        .push(
            Router::with_path("api")
                .push(Router::with_path("data").get(api_handler))
        )
        // 上传文件(不嵌入)
        .push(
            Router::with_path("uploads/{*path}").get(
                StaticDir::new(["uploads"])
                    .cache_control("max-age=3600")
            )
        )
        // 带SPA支持的嵌入式静态文件
        .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

最佳实践

  1. Use embedded files for deployment: Single binary is easier to deploy
  2. Set cache headers: Long cache for hashed assets, short for dynamic content
  3. Enable compression: Serve gzip/brotli compressed files
  4. SPA fallback: Return index.html for client-side routing
  5. Separate API from static: Use distinct paths for API and static content
  6. Security: Don't expose sensitive files, check paths
  7. Directory listing: Disable in production unless intentional
  8. Multiple directories: Use fallback order for themes/overrides
  1. 部署时使用嵌入式文件:单二进制文件更易于部署
  2. 设置缓存头:带哈希的资源使用长缓存,动态内容使用短缓存
  3. 启用压缩:提供gzip/brotli压缩后的文件
  4. SPA回退:为客户端路由返回index.html
  5. 分离API与静态文件:为API和静态文件使用不同的路径
  6. 安全性:不要暴露敏感文件,检查路径合法性
  7. 目录列表:生产环境中除非必要,否则禁用
  8. 多目录配置:使用回退顺序实现主题/覆盖功能