Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/utopia-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,6 @@ subtle.workspace = true

[dev-dependencies]
wiremock = "0.6.5"
# 静态托管那一段的单测:不连库地把请求推进 Router
tower = { version = "0.5", features = ["util"] }
tempfile.workspace = true
82 changes: 51 additions & 31 deletions crates/utopia-server/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ pub(crate) mod rule_routes;
mod search_routes;
mod settings_routes;
mod sources_routes;
#[cfg(test)]
mod static_files_tests;
mod token_routes;
mod tools;
mod tools_graph;
Expand All @@ -41,6 +43,54 @@ use crate::state::AppState;

const MAX_UPLOAD_BYTES: usize = 100 * 1024 * 1024;

/// 把构建产物挂上去。**单独一段、而且不带 state**,所以它可以脱开数据库单测:
/// 这一段的契约(哪条路 404、哪条路回首页、各自的缓存指示)已经破过两次,
/// 一次是升级白屏(#616),一次是把 404 也存一年。
fn with_static_files(mut app: Router, web_dist: &str) -> Router {
/* SPA 托管。**分两条路,因为它们的失败方式不一样**:

`/assets` 下面是构建产物,文件名带内容哈希。这里的 ServeDir **不带兜底**,
所以缺文件就是 404。从前它和页面共用一个带 history fallback 的服务,于是
升级之后旧哈希的请求拿到的是 `200 text/html` 的首页——浏览器按模块脚本
解析一张网页,光凭 MIME 就拒绝执行,界面白屏(#616)。

缓存指示按文件名本来的含义给:带哈希的产物换了内容就换名字,可以
`immutable` 存一年;`index.html` 每次都要回源确认,否则它会指着一批
已经不存在的哈希。少了这一条,升级要等浏览器的启发式缓存自己过期。 */
let index = std::path::Path::new(web_dist).join("index.html");
if index.exists() {
/* 每条路各自套自己的头。**层要挂在这一段的 Router 上,不能挂在整个
app 上**——挂在 app 上,API 的响应也会跟着被扣上 `immutable`。 */
let assets = Router::new()
.fallback_service(ServeDir::new(std::path::Path::new(web_dist).join("assets")))
/* **只有拿到东西的那一次才 `immutable`。**这个头从前是无条件套的,
于是 `/assets/<不存在的文件>` 的 404 也带着「存一年」——浏览器(和
中间的缓存)会把「这个文件不存在」记一年。而资源文件名带哈希,
一次部署之后请求的正是新名字:上一秒刚缓存下来的那条 404 会让
新版本的 js 在那台机器上整整一年拿不到。这正是 #616 要防的那条链,
只是发生在另一头。拿不到的那一次给 `no-cache`,下一次照常回源。 */
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
|res: &axum::response::Response| {
Some(if res.status().is_success() {
HeaderValue::from_static("public, max-age=31536000, immutable")
} else {
HeaderValue::from_static("no-cache")
})
},
));
let spa = Router::new()
.fallback_service(ServeDir::new(web_dist).fallback(ServeFile::new(index)))
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
));
app = app.nest("/assets", assets).fallback_service(spa);
tracing::info!("已托管前端产物: {}", web_dist);
}
app
}

pub fn router(state: AppState, cfg: &AppConfig) -> Router {
let api = Router::new()
.route("/health", get(health))
Expand Down Expand Up @@ -505,37 +555,7 @@ pub fn router(state: AppState, cfg: &AppConfig) -> Router {
.route("/api/{*rest}", any(|| async { ApiErr(AppError::NotFound) }))
.layer(cors);

/* SPA 托管。**分两条路,因为它们的失败方式不一样**:

`/assets` 下面是构建产物,文件名带内容哈希。这里的 ServeDir **不带兜底**,
所以缺文件就是 404。从前它和页面共用一个带 history fallback 的服务,于是
升级之后旧哈希的请求拿到的是 `200 text/html` 的首页——浏览器按模块脚本
解析一张网页,光凭 MIME 就拒绝执行,界面白屏(#616)。

缓存指示按文件名本来的含义给:带哈希的产物换了内容就换名字,可以
`immutable` 存一年;`index.html` 每次都要回源确认,否则它会指着一批
已经不存在的哈希。少了这一条,升级要等浏览器的启发式缓存自己过期。 */
let index = std::path::Path::new(&cfg.web_dist).join("index.html");
if index.exists() {
/* 每条路各自套自己的头。**层要挂在这一段的 Router 上,不能挂在整个
app 上**——挂在 app 上,API 的响应也会跟着被扣上 `immutable`。 */
let assets = Router::new()
.fallback_service(ServeDir::new(
std::path::Path::new(&cfg.web_dist).join("assets"),
))
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=31536000, immutable"),
));
let spa = Router::new()
.fallback_service(ServeDir::new(&cfg.web_dist).fallback(ServeFile::new(index)))
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
HeaderValue::from_static("no-cache"),
));
app = app.nest("/assets", assets).fallback_service(spa);
tracing::info!("已托管前端产物: {}", cfg.web_dist);
}
app = with_static_files(app, &cfg.web_dist);

// 最外层:先把请求来源放进 task-local,之后任何一层写审计都读得到
app.layer(TraceLayer::new_for_http())
Expand Down
89 changes: 89 additions & 0 deletions crates/utopia-server/src/api/static_files_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! 构建产物那一段的契约(#614 / #616)。不连库:`with_static_files` 不带 state。
//!
//! 这段代码破过两次,两次都是同一个形状——**一条路的失败被另一条路的成功语义
//! 盖住了**。第一次是缺文件回首页(浏览器按模块脚本解析一张网页,白屏);
//! 第二次是缺文件的 404 也被扣上 `immutable`,于是「这个文件不存在」被浏览器
//! 记一年,而资源名带哈希,下一次部署请求的正是那个刚被记下的名字。
//!
//! 所以这里逐条钉的是**状态码、Content-Type 与缓存指示三者一起**:少看一样,
//! 上面两次都能溜过去。
use super::with_static_files;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::Router;
use tower::ServiceExt;

/// 一个最小的 dist:一张首页,一个带哈希名的产物。
fn dist() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(
dir.path().join("index.html"),
"<!doctype html><title>u</title>",
)
.expect("index");
std::fs::create_dir(dir.path().join("assets")).expect("assets dir");
std::fs::write(
dir.path().join("assets/app-d34db33f.js"),
"export default 1;",
)
.expect("asset");
dir
}

async fn get(path: &str) -> (StatusCode, Option<String>, Option<String>) {
let dir = dist();
let app = with_static_files(Router::new(), dir.path().to_str().expect("utf-8 path"));
let res = app
.oneshot(
Request::builder()
.uri(path)
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
let head = |name: &str| {
res.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
};
(res.status(), head("content-type"), head("cache-control"))
}

#[tokio::test]
async fn a_hashed_asset_is_served_and_may_be_kept_for_a_year() {
let (status, ctype, cache) = get("/assets/app-d34db33f.js").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(ctype.as_deref(), Some("text/javascript"));
assert_eq!(
cache.as_deref(),
Some("public, max-age=31536000, immutable")
);
}

/// **缺掉的产物是 404,而且那条 404 不许被存起来。**
/// 回首页会白屏(#616);把 404 存一年,则下一次部署的新哈希在那台机器上
/// 一年拿不到——两条都要守,所以两个断言都在这里。
#[tokio::test]
async fn a_missing_asset_is_a_404_that_nobody_keeps() {
let (status, ctype, cache) = get("/assets/app-00000000.js").await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_ne!(ctype.as_deref(), Some("text/html"), "404 不许是一张网页");
assert_eq!(cache.as_deref(), Some("no-cache"));
}

/// 页面路由刷新拿首页——history fallback 还在,这一条是上面那条的对照。
#[tokio::test]
async fn a_page_route_still_gets_the_index() {
for path in ["/", "/graph", "/kb/anything/deep"] {
let (status, ctype, cache) = get(path).await;
assert_eq!(status, StatusCode::OK, "{path}");
assert_eq!(ctype.as_deref(), Some("text/html"), "{path}");
assert_eq!(
cache.as_deref(),
Some("no-cache"),
"{path} 的首页必须回源确认"
);
}
}
Loading