refactor: 合并为单 crate,前端改为内嵌 React 控制台
合并 stash-cli / stash-core 为根目录单 crate(lib + bin),新增 React + Vite + TS 控制台替换 Askama 模板。 build.rs 用 walkdir 把 stash-web/dist 同步到 $OUT_DIR/web,由 include_dir! 编译期内嵌,产出单文件二进制。 路由改为 / 与 /assets/** 走内嵌前端,其余交给 ServeDir,缺失路径返回内置 404。同步更新依赖、.gitignore 与 README。
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// 静态页面托管根目录(存放一个或多个前端项目)
|
||||
pub root: PathBuf,
|
||||
/// 监听端口
|
||||
pub port: u16,
|
||||
/// 监听地址,默认 127.0.0.1
|
||||
pub host: String,
|
||||
/// 是否在根路径没有 index.html 时展示项目仪表盘
|
||||
pub directory_listing: bool,
|
||||
/// 是否注入 CORS 标头
|
||||
pub enable_cors: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
root: PathBuf::from("../public"),
|
||||
port: 8080,
|
||||
host: "127.0.0.1".to_string(),
|
||||
directory_listing: true,
|
||||
enable_cors: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum StashError {
|
||||
#[error("IO 错误: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("文件监听失败: {0}")]
|
||||
Watch(#[from] notify::Error),
|
||||
|
||||
#[error("目录不存在或非法: {0}")]
|
||||
InvalidDirectory(String),
|
||||
|
||||
#[error("网络地址解析失败: {0}")]
|
||||
AddrParse(#[from] std::net::AddrParseError),
|
||||
|
||||
#[error("端口绑定失败: {0}")]
|
||||
ServerBind(String),
|
||||
|
||||
#[error("未找到资源: {0}")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for StashError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, err_msg) = match self {
|
||||
StashError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||
StashError::InvalidDirectory(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
};
|
||||
|
||||
let body = Json(json!({
|
||||
"error": err_msg,
|
||||
"status": status.as_u16(),
|
||||
}));
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod listing;
|
||||
pub mod server;
|
||||
mod watcher;
|
||||
|
||||
pub use config::Config;
|
||||
pub use error::StashError;
|
||||
pub use listing::{AppProject, scan_projects};
|
||||
pub use server::{ServerHandle, start};
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
use serde::Serialize;
|
||||
use std::{fmt::Write as _, path::Path};
|
||||
|
||||
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
|
||||
pub struct AppProject {
|
||||
pub name: String,
|
||||
pub route_path: String,
|
||||
}
|
||||
|
||||
impl AppProject {
|
||||
pub fn initial(&self) -> String {
|
||||
self.name
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('A')
|
||||
.to_uppercase()
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scan_projects(root: &Path) -> Vec<AppProject> {
|
||||
let mut projects = Vec::new();
|
||||
|
||||
let Ok(read_dir) = std::fs::read_dir(root) else {
|
||||
return projects;
|
||||
};
|
||||
|
||||
for item in read_dir.flatten() {
|
||||
let path = item.path();
|
||||
let name = item.file_name().to_string_lossy().to_string();
|
||||
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
let has_index = path.join("index.html").exists() || path.join("index.htm").exists();
|
||||
if has_index {
|
||||
let route_path = format!("/{}/", encode_path_segment(&name));
|
||||
projects.push(AppProject { name, route_path });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
projects.sort_by(|a, b| {
|
||||
a.name
|
||||
.to_lowercase()
|
||||
.cmp(&b.name.to_lowercase())
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
projects
|
||||
}
|
||||
|
||||
fn encode_path_segment(segment: &str) -> String {
|
||||
let mut encoded = String::with_capacity(segment.len());
|
||||
|
||||
for byte in segment.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||
encoded.push(char::from(byte));
|
||||
} else {
|
||||
write!(&mut encoded, "%{byte:02X}").expect("写入字符串不会失败");
|
||||
}
|
||||
}
|
||||
|
||||
encoded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TestDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestDir {
|
||||
fn new() -> Self {
|
||||
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("stash-core-test-{}-{id}", std::process::id()));
|
||||
fs::create_dir_all(&path).expect("应能创建测试目录");
|
||||
Self { path }
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn add_project(&self, name: &str, index_file: &str) {
|
||||
let project = self.path.join(name);
|
||||
fs::create_dir_all(&project).expect("应能创建项目目录");
|
||||
fs::write(project.join(index_file), "<!doctype html>").expect("应能创建入口文件");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_projects_detects_only_immediate_visible_projects() {
|
||||
let root = TestDir::new();
|
||||
root.add_project("with-html", "index.html");
|
||||
root.add_project("with-htm", "index.htm");
|
||||
root.add_project(".hidden", "index.html");
|
||||
root.add_project("no-index", "readme.txt");
|
||||
root.add_project("parent/child", "index.html");
|
||||
fs::create_dir_all(root.path().join("plain-directory")).expect("应能创建普通目录");
|
||||
|
||||
let names: Vec<_> = scan_projects(root.path())
|
||||
.into_iter()
|
||||
.map(|project| project.name)
|
||||
.collect();
|
||||
|
||||
assert_eq!(names, vec!["with-htm", "with-html"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_projects_sorts_names_case_insensitively() {
|
||||
let root = TestDir::new();
|
||||
root.add_project("zeta", "index.html");
|
||||
root.add_project("ALPHA", "index.html");
|
||||
root.add_project("middle", "index.html");
|
||||
|
||||
let names: Vec<_> = scan_projects(root.path())
|
||||
.into_iter()
|
||||
.map(|project| project.name)
|
||||
.collect();
|
||||
|
||||
assert_eq!(names, vec!["ALPHA", "middle", "zeta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_projects_url_encodes_route_segments() {
|
||||
let root = TestDir::new();
|
||||
root.add_project("space name", "index.html");
|
||||
root.add_project("中文", "index.html");
|
||||
|
||||
let projects = scan_projects(root.path());
|
||||
|
||||
assert_eq!(projects[0].name, "space name");
|
||||
assert_eq!(projects[0].route_path, "/space%20name/");
|
||||
assert_eq!(projects[1].name, "中文");
|
||||
assert_eq!(projects[1].route_path, "/%E4%B8%AD%E6%96%87/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_segment_encoding_preserves_unreserved_characters() {
|
||||
assert_eq!(encode_path_segment("aZ09-_.~/?#%"), "aZ09-_.~%2F%3F%23%25");
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
use std::path::PathBuf;
|
||||
use clap::Parser;
|
||||
use colored::*;
|
||||
use stash::{Config, start};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "stash",
|
||||
author = "stash developers",
|
||||
version,
|
||||
about = "🚀 本地静态页面托管与多项目管理工具",
|
||||
long_about = "支持本地静态页面按目录隔离托管,支持前端直接调用同源接口,提供清爽的项目导航列表。"
|
||||
)]
|
||||
struct Cli {
|
||||
/// 静态页面托管根目录(支持相对路径和绝对路径)
|
||||
#[arg(short, long, default_value = "./public", value_name = "DIR")]
|
||||
root: PathBuf,
|
||||
|
||||
/// 监听端口
|
||||
#[arg(short, long, default_value_t = 8080, value_name = "PORT")]
|
||||
port: u16,
|
||||
|
||||
/// 绑定监听地址(默认 127.0.0.1,如需局域网访问可传 0.0.0.0)
|
||||
#[arg(long, default_value = "127.0.0.1", value_name = "HOST")]
|
||||
host: String,
|
||||
|
||||
/// 关闭根目录项目导航列表功能(仅托管静态文件)
|
||||
#[arg(long, default_value_t = false)]
|
||||
no_listing: bool,
|
||||
|
||||
/// 关闭 CORS 跨域标头注入
|
||||
#[arg(long, default_value_t = false)]
|
||||
no_cors: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// 1. 初始化 tracing 日志
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
// 2. 解析命令行输入
|
||||
let cli = Cli::parse();
|
||||
|
||||
// 检查目录是否存在,不存在则友好提示并自动创建默认 public
|
||||
if !cli.root.exists() {
|
||||
if cli.root == PathBuf::from("./public") {
|
||||
println!(
|
||||
"{} 默认托管目录 {} 不存在,正在自动创建...",
|
||||
"[INFO]".cyan(),
|
||||
"./public".yellow()
|
||||
);
|
||||
std::fs::create_dir_all(&cli.root)?;
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} 指定的托管目录不存在: {}",
|
||||
"[ERROR]".red().bold(),
|
||||
cli.root.display().to_string().yellow()
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let abs_root = cli.root.canonicalize().unwrap_or_else(|_| cli.root.clone());
|
||||
|
||||
// 3. 构造 stash-core 配置并启动
|
||||
let config = Config {
|
||||
root: abs_root.clone(),
|
||||
port: cli.port,
|
||||
host: cli.host,
|
||||
directory_listing: !cli.no_listing,
|
||||
enable_cors: !cli.no_cors,
|
||||
};
|
||||
|
||||
let handle = match start(config).await {
|
||||
Ok(h) => h,
|
||||
Err(err) => {
|
||||
eprintln!("{} 启动失败: {}", "[FATAL]".red().bold(), err);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 打印启动成功 Banner
|
||||
println!();
|
||||
println!("{}", "==================================================".bright_blue());
|
||||
println!(" ✨ {} {}", "Stash Server".green().bold(), format!("v{}", env!("CARGO_PKG_VERSION")).dimmed());
|
||||
println!("{}", "==================================================".bright_blue());
|
||||
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
|
||||
println!(" ➜ 托管路径: {}", abs_root.display().to_string().white());
|
||||
println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white());
|
||||
println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed());
|
||||
println!("{}", "--------------------------------------------------".bright_blue());
|
||||
println!(" 按 {} 停止服务", "Ctrl+C".yellow().bold());
|
||||
println!();
|
||||
|
||||
// 5. 跨平台捕获退出信号
|
||||
wait_for_shutdown_signal().await;
|
||||
|
||||
println!("\n{} 正在停止服务并释放端口...", "[SYS]".yellow());
|
||||
handle.stop();
|
||||
|
||||
// 留出时间让 tokio 优雅关闭 socket 句柄
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
println!("{} 服务已安全退出。\n", "[DONE]".green().bold());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 监听停机信号(支持 Windows Ctrl+C 以及 Linux/macOS SIGINT/SIGTERM)
|
||||
async fn wait_for_shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("无法注册 Ctrl+C 信号处理器");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("无法注册 SIGTERM 信号处理器")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{OriginalUri, Path, State},
|
||||
http::{HeaderValue, StatusCode, header},
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
services::ServeDir,
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::StashError;
|
||||
use crate::listing::{AppProject, scan_projects};
|
||||
use crate::watcher;
|
||||
|
||||
/// build.rs 会把 `stash-web/dist` 同步到 `$OUT_DIR/web`,
|
||||
/// 这里把整棵目录树在编译期内嵌,最终产出带前端的单文件二进制。
|
||||
static WEB: Dir<'static> = include_dir!("$OUT_DIR/web");
|
||||
|
||||
/// Vite 产物文件名带内容 hash,可以长期强缓存;HTML 每次都要回源校验。
|
||||
const HASHED_ASSET_CACHE: &str = "public, max-age=31536000, immutable";
|
||||
const HTML_CACHE: &str = "no-cache";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: Arc<Config>,
|
||||
pub project_updates: broadcast::Sender<Vec<AppProject>>,
|
||||
}
|
||||
|
||||
pub struct ServerHandle {
|
||||
shutdown_tx: oneshot::Sender<()>,
|
||||
pub local_addr: SocketAddr,
|
||||
}
|
||||
|
||||
impl ServerHandle {
|
||||
pub fn stop(self) {
|
||||
let _ = self.shutdown_tx.send(());
|
||||
}
|
||||
}
|
||||
|
||||
/// 根路径:返回内嵌前端的入口页。
|
||||
async fn web_index() -> Response {
|
||||
embedded_file("index.html").unwrap_or_else(|| not_found_page("/"))
|
||||
}
|
||||
|
||||
/// `/assets/**`:Vite 产出的 CSS / JS / 图片。
|
||||
async fn web_asset(OriginalUri(uri): OriginalUri, Path(path): Path<String>) -> Response {
|
||||
let file = format!("assets/{path}");
|
||||
embedded_file(&file).unwrap_or_else(|| not_found_page(uri.path()))
|
||||
}
|
||||
|
||||
/// 前端放在 dist 根目录的其他文件(favicon 等)。
|
||||
async fn web_root_file(OriginalUri(uri): OriginalUri) -> Response {
|
||||
let path = uri.path().trim_start_matches('/');
|
||||
embedded_file(path).unwrap_or_else(|| not_found_page(uri.path()))
|
||||
}
|
||||
|
||||
/// 从内嵌目录取文件并组装响应;找不到返回 `None`。
|
||||
fn embedded_file(path: &str) -> Option<Response> {
|
||||
let file = WEB.get_file(path)?;
|
||||
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
let content_type = HeaderValue::from_str(mime.as_ref())
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
|
||||
let cache = if path.starts_with("assets/") {
|
||||
HeaderValue::from_static(HASHED_ASSET_CACHE)
|
||||
} else {
|
||||
HeaderValue::from_static(HTML_CACHE)
|
||||
};
|
||||
|
||||
Some(
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(header::CACHE_CONTROL, cache),
|
||||
],
|
||||
file.contents(),
|
||||
)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 统一 404:内嵌前端没有这个文件,托管目录里也没有这个路径。
|
||||
fn not_found_page(path: &str) -> Response {
|
||||
let body = format!(
|
||||
r#"<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><title>404 · 页面不存在</title></head>
|
||||
<body style="margin:0;display:grid;place-items:center;min-height:100vh;background:#f7f9fb;color:#1e293b;font-family:system-ui,-apple-system,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif">
|
||||
<main style="text-align:center;padding:24px">
|
||||
<p style="margin:0;font-size:64px;font-weight:800;line-height:1;color:#cbd5e1">404</p>
|
||||
<h1 style="margin:12px 0 8px;font-size:18px;font-weight:600">资源或页面不存在</h1>
|
||||
<p style="margin:0 0 20px;color:#64748b"><code style="font-family:ui-monospace,Consolas,monospace;font-size:13px">{}</code></p>
|
||||
<a href="/" style="display:inline-block;padding:8px 16px;border-radius:8px;background:#3b82f6;color:#fff;text-decoration:none;font-size:14px">返回本地应用</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#,
|
||||
escape_html(path)
|
||||
);
|
||||
|
||||
(StatusCode::NOT_FOUND, Html(body)).into_response()
|
||||
}
|
||||
|
||||
async fn not_found_handler(OriginalUri(uri): OriginalUri) -> Response {
|
||||
not_found_page(uri.path())
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
/// API: 健康检查
|
||||
async fn health_check() -> impl IntoResponse {
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"service": "stash"
|
||||
}))
|
||||
}
|
||||
|
||||
/// API: 项目列表
|
||||
async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let projects = scan_projects(&state.config.root);
|
||||
Json(serde_json::json!({
|
||||
"projects": projects
|
||||
}))
|
||||
}
|
||||
|
||||
/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
|
||||
async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let root = state.config.root.clone();
|
||||
let initial_projects = scan_projects(&root);
|
||||
let mut updates = state.project_updates.subscribe();
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
yield Ok::<Event, Infallible>(project_event(initial_projects));
|
||||
|
||||
loop {
|
||||
match updates.recv().await {
|
||||
Ok(projects) => yield Ok(project_event(projects)),
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
warn!("项目事件订阅落后 {skipped} 条,正在重新扫描");
|
||||
yield Ok(project_event(scan_projects(&root)));
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Sse::new(stream).keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(Duration::from_secs(15))
|
||||
.text("keep-alive"),
|
||||
)
|
||||
}
|
||||
|
||||
fn project_event(projects: Vec<AppProject>) -> Event {
|
||||
Event::default()
|
||||
.event("projects")
|
||||
.json_data(serde_json::json!({ "projects": projects }))
|
||||
.unwrap_or_else(|_| {
|
||||
warn!("序列化项目事件失败,将发送空项目列表");
|
||||
Event::default()
|
||||
.event("projects")
|
||||
.data(r#"{"projects":[]}"#)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
||||
if !config.root.exists() {
|
||||
return Err(StashError::InvalidDirectory(
|
||||
config.root.display().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (project_updates, _) = broadcast::channel(32);
|
||||
watcher::start(config.root.clone(), project_updates.clone())?;
|
||||
|
||||
let shared_state = AppState {
|
||||
config: Arc::new(config.clone()),
|
||||
project_updates,
|
||||
};
|
||||
|
||||
// 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落:
|
||||
// 前端目前没有客户端路由,把 404 冒充 200 只会让排错更难。
|
||||
let serve_dir = ServeDir::new(&config.root)
|
||||
.append_index_html_on_directories(true)
|
||||
.fallback(axum::handler::HandlerWithoutStateExt::into_service(
|
||||
not_found_handler,
|
||||
));
|
||||
|
||||
let mut app = Router::new()
|
||||
.route("/api/health", get(health_check))
|
||||
.route("/api/projects", get(list_projects_api))
|
||||
.route("/api/projects/events", get(project_events))
|
||||
.route("/", get(web_index))
|
||||
.route("/index.html", get(web_index))
|
||||
.route("/favicon.svg", get(web_root_file))
|
||||
.route("/assets/{*path}", get(web_asset))
|
||||
.fallback_service(serve_dir)
|
||||
.with_state(shared_state)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
if config.enable_cors {
|
||||
app = app.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any),
|
||||
);
|
||||
}
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.map_err(|e| StashError::ServerBind(format!("端口 {} 绑定失败: {}", config.port, e)))?;
|
||||
|
||||
let local_addr = listener.local_addr()?;
|
||||
info!("Stash 服务运行于: http://{}", local_addr);
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
info!("Stash 优雅停机...");
|
||||
});
|
||||
|
||||
if let Err(err) = server.await {
|
||||
warn!("服务异常退出: {}", err);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(ServerHandle {
|
||||
shutdown_tx,
|
||||
local_addr,
|
||||
})
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
use crate::{AppProject, error::StashError, scan_projects};
|
||||
use notify::{Event, EventKind, RecursiveMode, Watcher};
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tracing::warn;
|
||||
|
||||
const EVENT_DEBOUNCE: Duration = Duration::from_millis(150);
|
||||
|
||||
pub fn start(root: PathBuf, updates: broadcast::Sender<Vec<AppProject>>) -> Result<(), StashError> {
|
||||
let mut previous_projects = scan_projects(&root);
|
||||
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
|
||||
let mut watcher = notify::recommended_watcher(move |result| {
|
||||
let _ = event_tx.send(result);
|
||||
})?;
|
||||
watcher.watch(&root, RecursiveMode::Recursive)?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _watcher = watcher;
|
||||
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
if !should_rescan(&event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Editors and build tools often emit a burst of file events for one change.
|
||||
tokio::time::sleep(EVENT_DEBOUNCE).await;
|
||||
while event_rx.try_recv().is_ok() {}
|
||||
|
||||
let projects = scan_projects(&root);
|
||||
if projects == previous_projects {
|
||||
continue;
|
||||
}
|
||||
|
||||
previous_projects = projects.clone();
|
||||
let _ = updates.send(projects);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_rescan(event: ¬ify::Result<Event>) -> bool {
|
||||
match event {
|
||||
Ok(event) => !matches!(event.kind, EventKind::Access(_)),
|
||||
Err(error) => {
|
||||
warn!("文件监听事件出错,将重新扫描项目: {error}");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use notify::{
|
||||
Event,
|
||||
event::{AccessKind, CreateKind, EventKind},
|
||||
};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TestDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestDir {
|
||||
fn new() -> Self {
|
||||
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir()
|
||||
.join(format!("stash-watcher-test-{}-{id}", std::process::id()));
|
||||
fs::create_dir_all(&path).expect("应能创建测试目录");
|
||||
Self { path }
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_events_do_not_trigger_project_scans() {
|
||||
let access = Event::new(EventKind::Access(AccessKind::Read));
|
||||
let create = Event::new(EventKind::Create(CreateKind::Folder));
|
||||
|
||||
assert!(!should_rescan(&Ok(access)));
|
||||
assert!(should_rescan(&Ok(create)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn broadcasts_when_a_project_is_added() {
|
||||
let root = TestDir::new();
|
||||
let (updates, _) = broadcast::channel(8);
|
||||
let mut receiver = updates.subscribe();
|
||||
|
||||
start(root.path().to_path_buf(), updates).expect("应能启动文件监听");
|
||||
|
||||
let project = root.path().join("new-project");
|
||||
fs::create_dir_all(&project).expect("应能创建项目目录");
|
||||
fs::write(project.join("index.html"), "<!doctype html>").expect("应能创建入口文件");
|
||||
|
||||
let projects = tokio::time::timeout(Duration::from_secs(5), receiver.recv())
|
||||
.await
|
||||
.expect("应在超时前收到项目更新")
|
||||
.expect("项目更新通道不应关闭");
|
||||
|
||||
assert_eq!(projects.len(), 1);
|
||||
assert_eq!(projects[0].name, "new-project");
|
||||
assert_eq!(projects[0].route_path, "/new-project/");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user