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:
+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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user