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:
2026-10-21 17:59:08 +08:00
parent 92c510e959
commit e522a98f6f
48 changed files with 5113 additions and 273 deletions
+27
View File
@@ -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,
}
}
}
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
/// 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
View File
@@ -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: &notify::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/");
}
}