dioxus 生成的 Router
主线位置:装配主线。本站自己几乎不写
Router::new():dioxus-server 先把 server 函数、静态文件和 SSR 装进一个Router,main.rs拿到时它已经是Router<()>;本页逐步看它是怎么装出来的,再看main.rs往上加了什么、加的顺序对请求主线意味着什么。
flowchart TB app["router(App)"] --> rsf["注册 server 函数"] rsf --> ssa["挂 public 文件"] ssa --> fb["SSR fallback"] fb --> ws["with_state"] ws --> hz["main.rs:/healthz"] hz --> mounts["四个 mount"] mounts --> layers["四次 layer"] layers --> serve["axum::serve"]
本页的站点源码链接都指向本站仓库的提交 89a61b1。那个提交的 Cargo.lock 锁的是 dioxus 0.7.9(dioxus-server、dioxus-fullstack、dioxus-fullstack-macro 同为 0.7.9)、axum 0.8.9、tower-http 0.6.11。dioxus 的源码按「crate + 版本 + 文件」引用。
从 router(App) 到 Router<()>
main.rs 第 369 行拿到的 Router 来自 dioxus::server::router(App)。dioxus-server 0.7.9 的 src/launch.rs:
pub fn router(app: fn() -> Element) -> Router {
let cfg = ServeConfig::new();
apply_base_path(
Router::new().serve_dioxus_application(cfg.clone(), app),
app,
cfg,
base_path().map(|s| s.to_string()),
)
}
apply_base_path 只在配了 base path 时才把整个路由 nest 到前缀下;本站的 Dioxus.toml 没配,它原样返回。真正干活的是 src/server.rs 里的 serve_dioxus_application:
fn serve_dioxus_application<M: 'static>(
self,
cfg: ServeConfig,
app: impl ComponentFunction<(), M> + Send + Sync,
) -> Router<()> {
self.register_server_functions()
.serve_static_assets()
.fallback(get(FullstackState::render_handler))
.with_state(FullstackState::new(cfg, app))
}
四步,前三步都在 Router<FullstackState> 上做,第四步 with_state 把它变成 Router<()>。FullstackState 里是 ServeConfig、构造 VirtualDom 的闭包、SSR 渲染器池,以及一个 tokio-util 的 LocalPoolHandle(线程数取 available_parallelism)。只有 dioxus 自己的 handler 用 State<FullstackState> 取它;with_state 之后,这个状态已经装进每个端点,main.rs 在 Router<()> 上再加的 handler 用不到它(「还欠着的状态」这个说法见 状态:State 与 FromRef)。下面分别看前三步。
server 函数:一条 POST 路由一个函数
register_server_functions:
fn register_server_functions(mut self) -> Router<FullstackState> {
use std::collections::HashSet;
let mut seen = HashSet::new();
for func in ServerFunction::collect() {
if seen.insert(format!("{} {}", func.method(), func.path())) {
tracing::info!("Registering: {} {}", func.method(), func.path());
self = self.route(func.path(), func.method_router())
}
}
self
}
ServerFunction::collect() 是 inventory::iter::<ServerFunction>().collect()。每个 server 函数在编译时由宏展开出一段 inventory::submit!,把 (方法, 路径, 构造 MethodRouter 的函数) 登记进一张全局表;启动时这里把表读出来,逐个 Router::route。所以 main.rs 里看不到任何一个 server 函数的名字,它们都是「链接进来就注册」。
路径和方法由 dioxus-fullstack-macro 0.7.9 决定。本站源码里有 72 个 #[post("/api/...")] 和 4 个 #[server]:
| 写法 | 方法 | 路径 |
|---|---|---|
#[post("/api/topics/list")] | POST | 原样,{x} 形式的参数直接是 axum 0.8 的路径语法 |
#[server](不写路径) | POST | /api + 函数名 + 一串十进制数字,比如 /api/list_blog_posts 后面紧跟数字 |
那串数字是 xxh64(concat!(env!("CARGO_MANIFEST_DIR"), ":", module_path!())),为的是不同模块里同名的函数不撞路径。代价是路径跟着构建目录走:客户端和服务端要从同一个目录编译(或者都设 SERVER_FN_OVERRIDE_KEY)才对得上。
func.method_router() 返回的是 ServerFunction::make_handler 造出的 MethodRouter<FullstackState>(dioxus-server 0.7.9 src/serverfn.rs):
axum::routing::method_routing::on(
method
.try_into()
.expect("MethodFilter only supports standard HTTP methods"),
move |state: State<FullstackState>, request: Request| async move {
// ...
let result = state.rt.spawn_pinned(move || async move {
// ...
let (parts, body) = request.into_parts();
let server_context = FullstackContext::new(parts.clone());
let request = axum::extract::Request::from_parts(parts, body);
// ...
}).await;
match result {
Ok(response) => response,
Err(err) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::new(if cfg!(debug_assertions) {
format!("Server function panicked: {}", err)
} else {
"Internal Server Error".to_string()
}))
.unwrap(),
}
},
)
站在 axum 这边看,每个 server 函数只是一个两参数的 handler:State<FullstackState>(FromRequestParts)加 Request(FromRequest,拿走整个请求)。参数的解码、返回值的编码都不归 axum 管,而是在 spawn_pinned 起的任务里由宏生成的代码做:
- 用
LocalPoolHandle::spawn_pinned跑,是为了允许 server 函数的 future 不是Send。代价是 handler 里的 panic 不会冒到 axum,而是变成JoinError,再被上面的match变成 500。 - 请求体按 JSON 解:dioxus-fullstack 0.7.9
src/magic.rs里,参数走 body 的那个extract_axum先Bytes::from_request(request, &()).await.unwrap(),空 body 当成{},再serde_json::from_slice。 - 成功的返回值
serde_json::to_string后配Content-Type: application/json,状态码 200;错误按错误类型编成 JSON 的ErrorPayload。 - 请求头里
Accept含text/html(普通表单提交)、响应成功又没有Location时,改成302跳回Referer。
Router::route 碰到已有的路径会把两个 MethodRouter 合并,同一路径同一方法则 panic。dioxus 先用 HashSet 按「方法 路径」去重,所以两个 crate 里写了同一个 #[post] 路径时不会 panic,而是只注册 inventory 先给出的那一个,另一个静默丢掉。本站 76 个路径没有重复。
axum 自己遇到重复会 panic,信息是 Overlapping method route. Handler for `POST /api/topics/list` already exists(下面实验的最后一行)。在 #1306「Show path in panic message when merging overlapping MethodRouters」之前,合并两个 MethodRouter 时 panic 的只有一句不带路径的「Cannot merge two method routes that both define GET」。PR 描述:「The panic message will now contain the path (/foo/bar) so the error is hopefully easier to track down.」它在 2022-08-23 合并,首次发布于 axum-v0.6.0-rc.1,正式版 0.6.0。今天的代码里两种措辞都还在:merge_for_path 拿得到路径时用带路径的那句,拿不到路径时(直接调 MethodRouter::merge)退回旧的那句。dioxus 的去重恰好让这句 panic 不会出现,换来的是静默丢掉一个。
public 目录:每个文件一组路由
第二步 serve_static_assets 找到 public/ 目录(环境变量 DIOXUS_PUBLIC_PATH,没设就是可执行文件旁边的 public),交给 serve_dir_cached。它遍历目录,跳过 index.html(那一页由 SSR 生成),其余每一项:
if path.is_dir() {
// ...
#[cfg(debug_assertions)]
{
router = router.nest_service(&route, ServeDir::new(&path));
}
#[cfg(not(debug_assertions))]
{
router = serve_dir_cached(router, public_path, &path);
}
} else {
let serve_file = ServeFile::new(&path).precompressed_br();
// ...
if file_name_looks_immutable(&route) {
router = router.nest_service(&route, cache_response_forever(serve_file))
} else {
router = router.nest_service(&route, serve_file)
}
}
- release 构建递归展开,每个文件一次
nest_service,服务是ServeFile;debug 构建对子目录直接挂ServeDir,源码注释说是为了让新生成的文件(比如 wasm 热补丁模块)不用重建路由就能访问。 - 文件名里有
-dxh加十六进制哈希的(main-dxh1a2b.js这种),用 tower 的ServiceExt::map_response加上Cache-Control: public, max-age=31536000, immutable:内容变了文件名就变,可以永久缓存。 - 都配了
precompressed_br():目录里有.br版本、客户端接受 br 时直接给它。 nest_service每调用一次往路由树里插三条:{prefix}、{prefix}/和{prefix}/{*..}(nest、merge 与 fallback)。release 构建里,public/有多少个文件,路由树里就有三倍的条目。
这些 ServeFile 用的是 dioxus-server 自己依赖的 tower-http(本站锁的同一个 0.6.11);本站自己的静态目录在下一页。
SSR 是 fallback,而且只认 GET
第三步 .fallback(get(FullstackState::render_handler))。Router::fallback 收的是 Handler,而 MethodRouter 本身实现了 Handler<(), S>,所以可以把 get(...) 整个当 fallback。fallback 本身是第二棵路由树里的 / 和 /{*..}(见 nest、merge 与 fallback),没匹配上任何路由的请求都进这个 MethodRouter,再由它按方法分发:GET 和 HEAD 去 render_handler,其余方法落到 MethodRouter::new 默认的那个兜底,回 405(带 Allow,见 MethodRouter)。
render_handler 先 request.into_parts(),body 直接丢掉,用 Parts 跑一遍 SSR;成功时响应体是 Body::from_stream(rx),rx 是一个 futures_channel::mpsc::Receiver,后台任务边渲染边往里写 HTML 片段。
下面这个程序照着 dioxus 的做法搭了一个同形状的 Router:注册 server 函数(换成一个返回路径的 handler)、SSR fallback(换成返回 ssr 加路径)、with_state,略去静态文件,再按 main.rs 的顺序加 /healthz 和一层中间件;中间件只记一笔「我被调用过」。
axum = "=0.8.9"
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
use std::collections::HashSet;
use std::sync::Mutex;
use axum::{
Router,
body::{Body, to_bytes},
extract::{Request, State},
http::{Method, header},
middleware::{Next, from_fn},
response::Response,
routing::{MethodFilter, get, on},
};
use tower::ServiceExt;
// 相当于 FullstackState:只有 dioxus 自己的 handler 能拿到它
#[derive(Clone)]
struct FullstackState;
static SEEN: Mutex<Vec<String>> = Mutex::new(Vec::new());
// 相当于 inventory 收集到的 ServerFunction:(方法, 路径),故意放一个重复项
const SERVER_FNS: &[(&str, &str)] = &[
("POST", "/api/topics/list"),
("POST", "/api/list_blog_posts1234"), // #[server] 不写路径:/api/ + 函数名 + 一串数字
("POST", "/api/topics/list"), // 两个 crate 写了同一个路径
];
async fn server_fn(State(_): State<FullstackState>, req: Request) -> String {
format!("server fn {}", req.uri().path())
}
async fn render_handler(State(_): State<FullstackState>, req: Request) -> String {
format!("ssr {}", req.uri().path())
}
async fn outer_mw(req: Request, next: Next) -> Response {
SEEN.lock().unwrap().push(format!("{} {}", req.method(), req.uri().path()));
next.run(req).await
}
fn dioxus_router() -> Router {
let mut router: Router<FullstackState> = Router::new();
// register_server_functions:按「方法 路径」去重,再逐个 route
let mut seen = HashSet::new();
for (method, path) in SERVER_FNS {
if seen.insert(format!("{method} {path}")) {
let filter = MethodFilter::try_from(Method::from_bytes(method.as_bytes()).unwrap()).unwrap();
router = router.route(path, on(filter, server_fn));
}
}
// SSR 作为 fallback,然后立刻 with_state
router.fallback(get(render_handler)).with_state(FullstackState)
}
async fn send(app: &Router, method: Method, path: &str) {
SEEN.lock().unwrap().clear();
let req = Request::builder().method(method.clone()).uri(path).body(Body::empty()).unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let status = res.status().as_u16();
let allow = res.headers().get(header::ALLOW).map(|v| v.to_str().unwrap().to_owned());
let body = to_bytes(res.into_body(), 1024).await.unwrap();
let seen = !SEEN.lock().unwrap().is_empty();
println!(
"{:<5}{:<26} {status} allow={:<10} mw={seen:<5} body={:?}",
method.as_str(),
path,
allow.unwrap_or("-".into()),
String::from_utf8_lossy(&body)
);
}
#[tokio::main]
async fn main() {
// main.rs 的顺序:先 route / mount,最后才 layer
let app = dioxus_router()
.route("/healthz", get(|| async { "ok" }))
.layer(from_fn(outer_mw));
send(&app, Method::GET, "/healthz").await;
send(&app, Method::POST, "/api/topics/list").await;
send(&app, Method::GET, "/api/topics/list").await;
send(&app, Method::POST, "/api/list_blog_posts1234").await;
send(&app, Method::GET, "/blog/hello").await;
send(&app, Method::HEAD, "/blog/hello").await;
send(&app, Method::POST, "/blog/hello").await;
send(&app, Method::POST, "/api/nope").await;
// 不去重会怎样:axum 自己对同一方法同一路径 panic
std::panic::set_hook(Box::new(|_| {}));
let r = std::panic::catch_unwind(|| {
Router::<()>::new()
.route("/api/topics/list", on(MethodFilter::POST, || async {}))
.route("/api/topics/list", on(MethodFilter::POST, || async {}))
});
if let Err(e) = r {
let msg = e.downcast_ref::<String>().cloned().unwrap_or_default();
println!("without dedupe: panic: {msg}");
}
}
POST /api/nope 回什么状态码?/api/nope 不是任何 server 函数的路径,看起来像一个 API 地址。它是 404,还是别的?响应带不带 Allow,带的话是什么?
main.rs 加了什么,按什么顺序
拿到 Router<()> 以后,main.rs 是这样接下去的:
let router = dioxus::server::router(App)
// S3:健康检查(ok/degraded),供 LB / 监控 / 部署脚本判活。
.route("/healthz", get(crate::server::health::healthz));
#[cfg(feature = "accounts")]
let router = crate::server::auth_routes::mount(router, cookie_is_secure);
let router = crate::server::static_assets::mount(router, assets_root);
let router = crate::server::pay_routes::mount(router);
// ...
let router = crate::server::seo::mount(router, &base_url);
// S2(风险 R4):应用层 per-IP 限流,仅作用于 /api/*(auth / pay 更严)。
// ...
let router = router.layer(axum::middleware::from_fn(crate::server::rate_limit::rate_limit_mw));
// ...(尾斜杠、压缩、安全头,共四次 layer)
Ok(router)
四个 mount 函数加了什么、用了哪些提取器,见下一页 路由与静态资源;四层 layer 的顺序和理由,tower 卷 本站的中间件栈 已经讲过。这里只核对一件事:有没有路由加在 layer 之后。
Router::layer 只套已经存在的路由和 fallback,之后再 route 的不会被包(layer 与 route_layer)。按这个提交逐处看:
main.rs:/healthz和四个mount都在第 382 行第一次layer之前,layer之后只有Ok(router)。- dioxus 在闭包返回之后也没再加路由。dioxus-server 0.7.9 的
serve_router在 release 构建里直接axum::serve(listener, serve_callback().await.unwrap());debug 构建(dx serve)走自己的 accept 循环,也只是router.into_make_service(),热补丁时重新调一次闭包、重新into_make_service。router()的文档注释里写着「WebSocket handling for live reload and devtools」,但 0.7.9 的这条路径里并没有注册任何 WebSocket 路由。 mount函数内部:/js和/courses两个子Router先fallback_service再layer,套的是已经存在的 fallback,没有漏。
所以没有漏网的路由:server 函数、SSR、静态文件、/healthz、OAuth、支付回调、sitemap,每个请求都穿过这四层。上面那个程序的输出(见文末动脑筋答案)每一行都是 mw=true,包括 405 和走 fallback 的那几行。这也意味着 server 函数都在限流范围内:它们的路径都以 /api/ 开头,正是 rate_limit_mw 管的前缀。
还有一个顺带的后果。release 构建把 Router 直接交给 axum::serve,每接受一条连接都会 self.clone().with_state(()),把所有端点复制一遍(Router 的内部结构)。dioxus 那部分已经 with_state 过,都是现成的 Route,复制只是克隆;main.rs 自己加的 /healthz、OAuth、支付、SEO 这些 handler 路由还没拿到状态,每条连接都要重新 into_route、重新套那四层。路由不多,这只是每条连接多做的一点工作;先 into_make_service() 再交给 serve 就能在启动时一次做完,debug 路径正是这么做的。
问: main.rs 和几个 mount 函数里的 handler 全是闭包,靠 move 捕获 cookie_is_secure、base_url 这样的配置。为什么不用 State?
答: 用不了。dioxus::server::router(App) 返回时已经 with_state 过,类型是 Router<()>;在它上面 .route(path, get(handler)),handler 里的 State<T> 要求 T: FromRef<()>,只有 T = () 满足。想用 State,得另起一个 Router::new().route(...).with_state(cfg) 再 merge 进来;本站的配置都是启动时就定下、之后不变的几个值,闭包捕获更直接。FullstackState 的字段都不公开,拿到也用不上。
一处缺口:server 函数的 body 上限
上面说过,参数走 body 的 server 函数取 body 用的是 Bytes::from_request(request, &()).await.unwrap()。axum-core 的 Bytes::from_request 会先 with_limited_body:请求的 extensions 里没有 DefaultBodyLimit 时,上限是 DEFAULT_LIMIT = 2_097_152(2 MiB),超了就返回 rejection(机制见 body 提取器)。axum 自己的 handler 会把 rejection 变成 413;这里是 unwrap(),于是 panic。本站没有在任何地方设 DefaultBodyLimit,dioxus-server 和 dioxus-fullstack 0.7.9 也没有。
本站最大的 body 是评论区的图片上传。upload_image 是 #[post("/api/upload")],参数是 name: String, data_base64: String,限制写在 UPLOAD_MAX_BYTES = 5 MiB(解码后),超了回「文件过大(限制 5 MB)」。base64 让体积变成 4/3,5 MiB 的图片是 6.7 MB 的 JSON,过不了 2 MiB 那一关。下面的程序照抄这段取 body 的代码,像 make_handler 一样放进另一个任务里跑(这里用 tokio::spawn,panic 时同样得到 JoinError),发几个不同大小的上传:
axum = "=0.8.9"
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use axum::{
Router,
body::{Body, Bytes, to_bytes},
extract::{FromRequest, Request},
http::StatusCode,
response::{IntoResponse, Response},
routing::post,
};
use serde::Deserialize;
use tower::ServiceExt;
#[derive(Deserialize)]
struct UploadArgs {
name: String,
data_base64: String,
}
async fn upload_image(req: Request) -> Response {
// make_handler 用的是 LocalPoolHandle::spawn_pinned,这里用 tokio::spawn,panic 时同样得到 JoinError
let result = tokio::spawn(async move {
let bytes = Bytes::from_request(req, &()).await.unwrap();
match serde_json::from_slice::<UploadArgs>(&bytes) {
Ok(args) => {
// 站点自己的 5 MiB 检查(check_upload_size:按 len * 3 / 4 估算)
let estimated = args.data_base64.len() * 3 / 4;
if estimated > 5 * 1024 * 1024 {
(StatusCode::INTERNAL_SERVER_ERROR, "文件过大(限制 5 MB)").into_response()
} else {
format!("ok {} ~{} bytes", args.name, estimated).into_response()
}
}
Err(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
}
})
.await;
match result {
Ok(res) => res,
Err(_) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::new("Internal Server Error".to_string()))
.unwrap(),
}
}
#[tokio::main]
async fn main() {
std::panic::set_hook(Box::new(|info| {
let msg = info.payload().downcast_ref::<String>().cloned().unwrap_or_default();
println!(" [panic] {msg}");
}));
let app = Router::new().route("/api/upload", post(upload_image));
for image_bytes in [1_000_000usize, 1_500_000, 1_600_000, 4_000_000, 6_000_000] {
let b64_len = image_bytes.div_ceil(3) * 4;
let json = format!(r#"{{"name":"a.png","data_base64":"{}"}}"#, "A".repeat(b64_len));
println!("image {image_bytes} B -> json {} B", json.len());
let req = Request::post("/api/upload").header("content-type", "application/json").body(Body::from(json)).unwrap();
let res = app.clone().oneshot(req).await.unwrap();
let status = res.status().as_u16();
let body = to_bytes(res.into_body(), 1 << 20).await.unwrap();
println!(" {status} {:?}", String::from_utf8_lossy(&body));
}
}
image 1000000 B -> json 1333369 B
200 "ok a.png ~1000002 bytes"
image 1500000 B -> json 2000033 B
200 "ok a.png ~1500000 bytes"
image 1600000 B -> json 2133369 B
[panic] called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))
500 "Internal Server Error"
image 4000000 B -> json 5333369 B
[panic] called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))
500 "Internal Server Error"
image 6000000 B -> json 8000033 B
[panic] called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))
500 "Internal Server Error"
(M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同。)
- 真正的上限是 JSON 2 MiB,折合图片大约 1.5 MB;5 MiB 那道检查只对更小的图片起作用,而那些图片本来就不会超。
- 超过的请求不是 413,而是 panic 之后的 500,body 是
Internal Server Error(debug 构建里是Server function panicked: ...)。最后一行 6 MB 的图片本该得到「文件过大」,也一样是 500。 - 前端的
handle_upload只处理if let Ok(url) = upload_image(...),失败时什么也不显示,所以用户看到的是「传了,没反应」。
这是写作时(提交 89a61b1)的状态。要让 5 MiB 生效,就得在这些路由上放宽 DefaultBodyLimit(它是一个设置请求扩展的 layer,套在路由外面即可);放宽到多少、只放 /api/upload 还是全部 server 函数,是本站自己的取舍。
动脑筋答案
上面第一个程序的输出,M2 Max、rustc 1.99.0、--release,连跑 3 次相同:
GET /healthz 200 allow=- mw=true body="ok"
POST /api/topics/list 200 allow=- mw=true body="server fn /api/topics/list"
GET /api/topics/list 405 allow=POST mw=true body=""
POST /api/list_blog_posts1234 200 allow=- mw=true body="server fn /api/list_blog_posts1234"
GET /blog/hello 200 allow=- mw=true body="ssr /blog/hello"
HEAD /blog/hello 200 allow=- mw=true body=""
POST /blog/hello 405 allow=GET,HEAD mw=true body=""
POST /api/nope 405 allow=GET,HEAD mw=true body=""
without dedupe: panic: Overlapping method route. Handler for `POST /api/topics/list` already exists
POST /api/nope是 405,Allow: GET,HEAD。 它没匹配上任何路由,进了 fallback;fallback 是get(render_handler),只有 GET(和随之而来的 HEAD)槽,POST 落到MethodRouter默认的兜底。Allow列的是 SSR 能接的方法,不是这个「API」能接的方法。本站里,所有拼错了路径的 POST 请求、以及任何非 GET 的未知地址,都是这个 405,而不是 404。GET /api/topics/list也是 405,但Allow: POST:路径匹配上了 server 函数的路由,是那个MethodRouter自己的 405。HEAD /blog/hello走 SSR 的 GET 处理,body 被丢掉。- 重复的
/api/topics/list只注册了一次,没有 panic;最后一行是不去重时 axum 的反应。 - 每一行都是
mw=true:405、fallback、/healthz都穿过了最后加的那层。
小结
dioxus::server::router(App)依次注册 server 函数、静态文件、SSR fallback,然后with_state(FullstackState),交给main.rs的已经是Router<()>。- server 函数靠
inventory在启动时收集,每个一条路由:#[post("/api/x")]是POST /api/x,#[server]是POST /api/函数名加一串由构建目录算出的数字;在 axum 看来是State加Request两个提取器,编解码都在 dioxus 生成的代码里。重复路径被静默去重。 - SSR fallback 是
get(...),未知地址上的非 GET 请求回 405Allow: GET,HEAD,不是 404。 main.rs的路由都加在四次layer之前,dioxus 之后也不再加路由,没有漏网的路由;server 函数都在/api/下,受限流管。- server 函数取 body 时对
Bytes::from_request直接unwrap,默认 2 MiB 上限一超就 panic 成 500;本站上传的 5 MiB 限制因此实际是 JSON 2 MiB。