server function
主线位置:调用主线从头到尾。宏把一个
async fn改写成两半:客户端一半在ClientRequest里编码参数、发请求、解码响应;服务端一半把函数体登记成一条 axum 路由,HTTP 请求进来时解码、执行、编码。服务端渲染时组件里调用它,走的是服务端一半的最后一段:直接调用函数体,这一段接在首屏主线上(见一次页面请求)。
sequenceDiagram participant CF as 客户端 double participant EN as ServerFnEncoder participant AX as axum 路由 participant MH as make_handler participant FB as 函数体 CF->>CF: ClientRequest::new 拼 URL 和 query CF->>EN: fetch_client 参数打包成结构体 EN->>AX: POST /api/double JSON body AX->>MH: 本地线程池 spawn_pinned MH->>MH: extract_axum 解码 MH->>FB: double(n).await FB-->>MH: Result i32 ServerFnError MH-->>CF: make_axum_response 和 make_axum_error CF->>CF: decode_client_response 和 decode_client_err
本页用一个实验应用 fs2-app:dioxus = "=0.7.10"(router、fullstack),十几个 server function,每个演示一种参数或错误;dx build --platform web --fullstack(dx 0.7.9)编出服务端二进制,IP=127.0.0.1 PORT=18800 ./server 启动。另有一个原生客户端 fs2-client:用 #[path] 引入同一个 api.rs,不开 server feature,set_server_url("http://127.0.0.1:18800") 后在 tokio 里逐个调用,用来看客户端那一半解码出什么。平台:M2 Max、macOS 27、rustc 1.99.0、Chrome 156。没特别说明时用 debug 构建。
一个宏,两份代码
#[post]、#[get]、#[put]、#[delete]、#[patch] 是同一个实现(post 等几个入口都转给 route_impl_with_route),只是方法不同。0.7 之前的 #[server](server)保留下来做兼容:固定 POST,前缀默认 /api,再交给同一个函数。
对下面这个函数分别 cargo expand(实验 crate 的 src/bin/expand.rs):
#[post("/api/double")]
async fn double(n: i32) -> Result<i32, ServerFnError> {
Ok(n * 2)
}
两次展开的开头一样:生成的函数签名和原来一样,函数体里先定义两个只在这个函数里可见的结构体,再定义路径常量,再做两项编译期检查:
async fn double(#[allow(unused_mut)] n: i32) -> Result<i32, ServerFnError> {
// ...
struct __QueryParams__ {}
// ... Serialize / Deserialize 的派生展开
struct ___Body_Serialize___<__Ty0> {
n: __Ty0,
}
// ...
const __ENDPOINT_PATH: &str = /* concatcp!("", "/api/double", "") */;
{
_ = dioxus_fullstack::assert_is_result::<Result<i32, ServerFnError>>();
let verify_token = (&&&&&&&&&&&&&&ServerFnEncoder::<
___Body_Serialize___<i32>,
(i32,),
>::new())
.verify_can_serialize();
dioxus_fullstack::assert_can_encode(verify_token);
// ...
};
不开 server feature(wasm 客户端,或者原生客户端)时,接下来只剩客户端一半:
#[allow(clippy::unused_unit)]
{
let client = dioxus_fullstack::ClientRequest::new(
dioxus_fullstack::http::Method::POST,
::alloc::__export::must_use({
::alloc::fmt::format(format_args!("/api/double"))
}),
&__QueryParams__ {},
);
let response = (&&&&&&&&&&&&&&ServerFnEncoder::<
___Body_Serialize___<i32>,
(i32,),
>::new())
.fetch_client(client, ___Body_Serialize___ { n }, |data| { (data.n,) })
.await;
let decoded = (&&&&&ServerFnDecoder::<Result<i32, ServerFnError>>::new())
.decode_client_response(response)
.await;
let result = (&&&&&ServerFnDecoder::<Result<i32, ServerFnError>>::new())
.decode_client_err(decoded)
.await;
return result;
}
用户写的函数体 Ok(n * 2) 在客户端展开里根本不存在。开 server feature 时,这一块换成:
{
async fn double(n: i32) -> Result<i32, ServerFnError> {
Ok(n * 2)
}
#[allow(clippy::unused_unit)]
fn __inner__function__(
___state: dioxus_server::axum::extract::State<FullstackContext>,
___request: dioxus_server::axum::extract::Request,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = dioxus_server::axum::response::Response>,
>,
> {
Box::pin(async move {
match (&&&&&&&&&&&&&&ServerFnEncoder::<
___Body_Serialize___<i32>,
(i32,),
>::new())
.extract_axum(___state.0, ___request, |data| { (data.n,) })
.await
{
Ok(
(
(n,),
(
dioxus_server::axum::extract::Path(()),
dioxus_fullstack::payloads::Query(__QueryParams__ {}),
),
),
) => {
let res = double(n).await;
let encoded = (&&&&&&ServerFnDecoder::<
Result<i32, ServerFnError>,
>::new())
.make_axum_response(res);
(&&&&&ServerFnDecoder::<Result<i32, ServerFnError>>::new())
.make_axum_error(encoded)
}
Err(res) => res,
}
})
}
#[allow(non_upper_case_globals)]
const _: () = {
static __INVENTORY: ::inventory::Node = ::inventory::Node {
value: &{
dioxus_server::ServerFunction::new(
dioxus_server::http::Method::POST,
__ENDPOINT_PATH,
|| {
dioxus_server::ServerFunction::make_handler(
dioxus_server::http::Method::POST,
__inner__function__,
)
},
)
},
// ...
};
// ...
#[used]
#[link_section = "__DATA,__mod_init_func,mod_init_funcs"]
static __CTOR: unsafe extern "C" fn() = __ctor;
};
let () = dioxus_fullstack::FullstackContext::extract::<(), _>().await?;
return double(n).await;
}
服务端一半有三样东西:
- 原样复制进来的内层
async fn double,与外层同名,内层遮住外层。 __inner__function__:HTTP 入口。它把请求解码成(n,),调内层double,再把Result编成Response。inventory::submit!展开成一个静态节点加一个放进__mod_init_func段的构造函数:程序启动、main运行之前,加载器调用它,把节点挂进全局链表。
外层函数自己的函数体是最后两行:按 server 参数(见后文)从当前请求里提取,然后直接 .await 内层函数。服务端渲染时组件调用 double(1) 就落在这里,不编码、不发请求(一次页面请求里用日志确认过)。
宏为什么生成 (&&&&&&&&&&&&&&ServerFnEncoder::<..>::new()) 这种写法,后文讲编码时再说。函数体之后还有一个 #[allow(unreachable_code)] { unreachable!() }:两个 cfg 块各以 return 结束,任何一种配置下只会编进其中一块,最后这一块只是让函数在语法上有尾表达式。还有一层检查挂在整个函数上:#[deny(unexpected_cfgs, reason = "...")],crate 没声明 server feature 时,cfg(feature = "server") 触发编译错误,reason 里写着「Using Dioxus Server Functions requires a server feature flag in your Cargo.toml」。
登记:inventory 与 register_server_functions
ServerFunction 只有三个字段:path、method、handler: fn() -> MethodRouter<FullstackState>。inventory::Collect 的实现给它一个静态的 Registry,ServerFunction::collect 就是 inventory::iter 读出全部节点。启动时 register_server_functions 逐个 Router::route(path, method_router),并按「方法 路径」去重。这一段在 axum 那边长什么样、重复的路径为什么静默丢掉一个,axum 卷的 dioxus 生成的 Router 讲过,这里不重复。实验应用启动时打出的日志(顺序由链表决定,和源码顺序无关):
2026-10-28T11:52:29.459013Z INFO dioxus_server::server: Registering: POST /api/fail_code
2026-10-28T11:52:29.459524Z INFO dioxus_server::server: Registering: GET /api/q
2026-10-28T11:52:29.460039Z INFO dioxus_server::server: Registering: POST /api/http_err
2026-10-28T11:52:29.460067Z INFO dioxus_server::server: Registering: GET /api/getbody
...
2026-10-28T11:52:29.460191Z INFO dioxus_server::server: Registering: POST /api/legacy13032562940984779216
2026-10-28T11:52:29.460205Z INFO dioxus_server::server: Registering: POST /api/double
2026-10-28T11:52:29.460218Z INFO dioxus_server::server: Registering: POST /api/named
2026-10-28T11:52:29.460231Z INFO dioxus_server::server: Registering: GET /api/item/{id}
...
2026-10-28T11:52:29.460264Z INFO dioxus_server::server: Registering: GET /api/add
make_handler 用 axum::routing::method_routing::on(method, ..) 包一层:用渲染页面的同一个 LocalPoolHandle 把 __inner__function__ 放到本地线程上跑(server function 的 future 可以不是 Send),并在任务里用 FullstackContext::scope 设好 task-local,函数体里才能调 FullstackContext::current()。任务 panic 时得到 JoinError,返回 500。
路径怎么定
路径在宏展开时算成一个 const &str,客户端和服务端各算一次,两边必须算出同一个字符串。规则在 route_impl_with_route 的 endpoint_path 一段:
let hash = match route.prefix.as_ref() {
// Implicit route lit, we need to hash the function signature to avoid collisions
Some(_) if route.route_lit.is_none() => {
// let enable_hash = option_env!("DISABLE_SERVER_FN_HASH").is_none();
let key_env_var = match option_env!("SERVER_FN_OVERRIDE_KEY") {
Some(_) => "SERVER_FN_OVERRIDE_KEY",
None => "CARGO_MANIFEST_DIR",
};
quote! {
dioxus_fullstack::xxhash_rust::const_xxh64::xxh64(
concat!(env!(#key_env_var), ":", module_path!()).as_bytes(),
0
)
}
}
// Explicit route lit, no need to hash
_ => quote! { "" },
};
| 写法 | 注册成 | 说明 |
|---|---|---|
#[post("/api/double")] | POST /api/double | 显式路径原样用 |
#[get("/api/item/{id}")] | GET /api/item/{id} | {id} 或 :id 是路径参数,名字必须是函数参数,*rest 是通配;生成 axum 0.8 的 {id} 语法 |
#[get("/api/add?a&b")] | GET /api/add | ? 后面是 query 参数,a 和 b 从参数表里拿走 |
#[get("/api/q?:n")] | GET /api/q | :n 或 {n} 是「整个 query 就是 n」(catch-all),下面会看到它对基本类型不可用 |
#[server] | POST /api/legacy13032562940984779216 | 前缀 /api 加函数名,后面紧跟一个十进制的 xxh64 |
#[server(endpoint = "named")] | POST /api/named | 有 endpoint 就不加 hash,前缀照样加 |
只有「有前缀、没有显式路径」这一种情况加 hash,hash 的输入是 CARGO_MANIFEST_DIR 加冒号加 module_path!()。expand 出来的就是这样一串(fs2-client 里 expand 这个 bin):
__cf_osRcTFl4A::pmr::PConvWrapper("/api")
.to_pargument_display(fmt),
__cf_osRcTFl4A::pmr::PConvWrapper("/legacy")
.to_pargument_display(fmt),
__cf_osRcTFl4A::pmr::PConvWrapper(
dioxus_fullstack::xxhash_rust::const_xxh64::xxh64(
"~/exp-dioxus/fs2-client:expand"
.as_bytes(),
0,
),
)
.to_pargument_display(fmt),
所以 #[server] 的路径跟着 crate 所在的目录和模块路径走。dx 从同一个目录编客户端和服务端,两边一致;fs2-client 引入同一份 api.rs,但它的 CARGO_MANIFEST_DIR 是另一个目录、模块是 exp_dioxus_fs2_client::api,于是它调 legacy 时算出的是另一个路径,这个路径在服务端没有登记,落进 fallback,而 fallback 只认 GET:
legacy(hi) = Err(ServerError { message: "HTTP 405: null", code: 405, details: None })
named(hi) = Ok("named got hi")
SERVER_FN_OVERRIDE_KEY 那个 option_env! 写在过程宏自己的代码里,在编译 dioxus-fullstack-macro 时求值;它存在时,生成的代码改用用户 crate 编译时的 env!("SERVER_FN_OVERRIDE_KEY")。#[server] 的其余老参数(prefix、endpoint 之外的 encoding、input、output、client 等)仍然能解析(impl Parse for ServerFnArgs),但除了 name 会生成一条弃用警告,其他都不起作用:Route 里对应的字段叫 _input_encoding / _output_encoding,注释是「todo: support these since server_fn had them」。
参数怎么编码
CompiledRoute::from_route 先从参数表里拿走路径参数和 query 参数,剩下的参数全部装进 ___Body_Serialize___,走 JSON body。分法只看路由字符串,不看 HTTP 方法:
| 参数出现在 | 客户端 | 服务端 |
|---|---|---|
路径 {id} | format!("/api/item/{id}", id = id) 拼进 URL | axum::extract::Path((id,)) |
query ?a&b | __QueryParams__ { a, b } 经 serde_qs::to_string | payloads::Query(__QueryParams__ { a, b }),serde_qs::from_str |
| 其他 | ___Body_Serialize___ { .. } 经 serde_json::to_vec,Content-Type: application/json | Bytes::from_request 后 serde_json::from_slice |
客户端的 send_json 在序列化结果是空、{} 或 null 时改发空 body,服务端读到空 body 当成 {},所以没有参数的函数一个字节也不发。服务端不看 Content-Type:上面的实验里 curl -d '{"n":21}' 默认带的是 application/x-www-form-urlencoded,照样返回 42。参数结构体就是普通的 serde 派生,字段名是参数名,{"n":21} 的来历见 serde 卷的 派生出来的 Serialize。
请求在两种客户端上发出去的方式不同。原生客户端用 reqwest(new_reqwest_request,带一个进程级的 cookie jar);wasm 上不用 reqwest,new_gloo_request 用 gloo-net 直接调浏览器的 fetch,URL 只取 path 和 query,相对当前页面。两种都加一个 X-Request-Client: dioxus 请求头,但服务端的代码里没有任何地方读它。
GET 的 body 参数和 query 的 unwrap
「剩下的都走 body」对 GET 也成立。#[get("/api/getbody")] async fn get_body(n: i32) 能编译,原生客户端调它是 Ok(2)(reqwest 允许 GET 带 body)。在浏览器里点按钮调同一个函数,fetch 拒绝构造请求(release 构建,headless Chrome 里读 #out2 的文字):
get_body(1) = Err(Request(Request("TypeError: Failed to construct 'Request': Request with GET/HEAD method cannot have body.")))
GET 的参数要写进路由字符串的 query 部分(#[get("/api/add?a&b")])。query 那一边,ClientRequest::new 对 serde_qs 的结果直接 unwrap:
pub fn new(method: http::Method, path: String, params: &impl Serialize) -> Self {
Self::fetch_inner(method, path, serde_qs::to_string(params).unwrap())
}
普通 query 参数会被包进 __QueryParams__ 结构体,serde_qs 0.15 能写结构体、map、序列和大部分基本类型,一般不出错。catch-all 不包:#[get("/api/q?:n")] 把 n 本身交给 serde_qs::to_string。serde_qs 的顶层只接受结构体和 map,顶层的 i32 没有键可写,返回 Error::Custom。原生客户端调 catch_all(7):
catch_all(7) = ...
thread 'main' (1784976) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-fullstack-0.7.10/src/client.rs:30:69:
called `Result::unwrap()` on an `Err` value: Custom("tried to serialize a value before serializing key")
请求还没发就 panic 了。浏览器里(release 构建)是 wasm 的 RuntimeError: unreachable,#out2 一直是空的。服务端那一边同样不通,Query::<i32> 用 serde_qs::from_str 解顶层的 i32 也失败:
### http://127.0.0.1:18800/api/q?7
HTTP/1.1 500 Internal Server Error
content-type: application/json
{"error":"error deserializing server function results: cannot deserialize primitive at the top level.Try deserializing into a struct."}
宏接受了一个两边都不可能工作的写法。catch-all 只能用结构体或 map 类型的参数。ClientRequest::extend_query 里也是同样的 unwrap。
自动特化:一串 & 选实现
参数可以是「几个可序列化的值」,也可以是一个自己知道怎么放进请求的类型(Form<T>、Json<T>、文件、流、WebSocket……);返回值可以是可反序列化的值,也可以是 Response 那一类类型。Rust 没有稳定的特化,magic.rs(入口是 ServerFnEncoder 和 ServerFnDecoder 两个零大小类型)用的是 autoderef 特化:同一个 trait 给 &&&&&&&&&&ServerFnEncoder、&&&&&&&&&ServerFnEncoder、&ServerFnEncoder 等不同层数的引用各实现一次,宏生成的调用点用比所有实现都多的 &(14 个)。方法查找从接收者类型开始逐层自动解引用,先碰到哪一层的实现、且它的 where 条件满足,就用哪一个。层数越多优先级越高。
| trait | 层数 | 条件 | 做什么 |
|---|---|---|---|
EncodeRequest(客户端发) | 10 | 参数结构体 Serialize + DeserializeOwned | send_json |
| 9 | 唯一的参数 O: IntoRequest | O::into_request,比如 Form 走 send_form | |
| 1 | 都不满足 | VerifyEncode = CantEncode,编译期检查报错 | |
ExtractRequest(服务端收) | 11 | 没有 body 参数,server 参数整体 FromRequest | 交给提取器 |
| 10 | 参数结构体 DeserializeOwned | 读 body,serde_json::from_slice | |
| 9 | 唯一的参数 FromRequest | 交给它自己读 body | |
RequestDecodeResult(客户端收) | 3 | T: FromResponse | 交给它 |
| 2 | T: DeserializeOwned | 2xx 按 JSON 解 T,否则解 ErrorPayload | |
MakeAxumResponse(服务端发) | 4 | T: FromResponse + IntoResponse | into_response |
| 3 | T: Serialize + DeserializeOwned | serde_json::to_string,200,application/json |
最低一层的「兜底」实现返回一个标记类型(CantEncode / CantDecode),宏生成的检查块把它传给 assert_can_encode / assert_can_decode,这两个函数要求参数实现一个只为 EncodeIsVerified / DecodeIsVerified 实现的 trait,trait 上挂着 #[diagnostic::on_unimplemented(message = "❌ Invalid Arguments to ServerFn ❌" ...)]。所以参数类型不合适时,错误出在检查块,而且是一句人话,而不是一屏 trait 求解失败。
encoding.rs 里的 Encoding trait(JsonEncoding、CborEncoding,以及 feature 打开的 postcard、msgpack)不参与上面的默认路径。默认路径上 JSON 是写死的 serde_json;Encoding 只给流式的 Streaming<T, E> 用(JsonStream<T>、CborStream<T> 等别名)。
payloads/ 下面每个文件是一种可以直接当参数或返回值的类型,各自实现 IntoRequest / FromRequest 或 FromResponse / IntoResponse:axum_types.rs(Json、Html、Redirect、NoContent)、cbor.rs、form.rs、multipart.rs、files.rs、text.rs、header.rs、query.rs、stream.rs(字节流和按编码分帧的流)、sse.rs、websocket.rs(ws feature),以及 feature 打开的 postcard.rs、msgpack.rs。本页不展开它们。
ServerFnError 与状态码
返回类型的错误部分决定响应长什么样。MakeAxumError 按错误类型分四种实现:
- 错误类型实现
AsStatusCode + From<ServerFnError> + Serialize + DeserializeOwned + Display(ServerFnError自己,以及自定义的 thiserror 枚举):状态码取as_status_code,只有ServerError { code }用code,其他分支一律 500;正文是ErrorPayload { message: err.to_string(), code, data: Some(err) }。 anyhow::Error和CapturedError(dioxus::Result的默认错误类型):从里面 downcast 出ServerFnError或HttpError填message和code,但响应的状态码固定 500。StatusCode:状态码原样,message是状态码的文字。HttpError:状态码原样,message是它的消息。
逐个 curl(curls.sh,-i 去掉了 date、content-length 两行):
### -X POST http://127.0.0.1:18800/api/double -H content-type: application/json -d {"n":21}
HTTP/1.1 200 OK
content-type: application/json
42
### -X POST http://127.0.0.1:18800/api/fail_new
HTTP/1.1 500 Internal Server Error
content-type: application/json
{"message":"error running server function: boom (details: None)","code":500,"data":{"ServerError":{"message":"boom","code":500}}}
### -X POST http://127.0.0.1:18800/api/fail_code
HTTP/1.1 418 I'm a teapot
content-type: application/json
{"message":"error running server function: teapot (details: Some(\n Object {\n \"why\": String(\"short and stout\"),\n },\n))","code":418,"data":{"ServerError":{"message":"teapot","code":418,"details":{"why":"short and stout"}}}}
### -X POST http://127.0.0.1:18800/api/http_err
HTTP/1.1 403 Forbidden
content-type: application/json
{"message":"members only","code":403}
### -X POST http://127.0.0.1:18800/api/status
HTTP/1.1 418 I'm a teapot
content-type: application/json
{"message":"418 I'm a teapot","code":418}
### -X POST http://127.0.0.1:18800/api/panic
HTTP/1.1 500 Internal Server Error
Server function panicked: task 35 panicked with message "kaboom"
| 函数返回 | 状态码 | 正文 |
|---|---|---|
Ok(42) | 200 | 42 |
Err(ServerFnError::new("boom")) | 500 | ErrorPayload,message 是 thiserror 的 Display(带 (details: None)),data 是整个错误的 JSON |
Err(ServerFnError::ServerError { code: 418, details: Some(..) }) | 418 | 同上,message 里是 details 的 {:#?} 输出,换行和缩进原样进了 JSON |
HttpError::forbidden("members only") | 403 | {"message":"members only","code":403} |
Err(StatusCode::IM_A_TEAPOT) | 418 | {"message":"418 I'm a teapot","code":418} |
dioxus::Result 里 HttpError::not_found(..)? | 见动脑筋 | |
panic!("kaboom") | 500 | debug:Server function panicked: task 35 panicked with message "kaboom",纯文本;release:Internal Server Error |
ServerFnError::new 和 ServerError 的 message 字段是 Display 的结果,客户端解出的 message 却是原来的 "boom":客户端那一半看到 data 能按错误类型反序列化,就直接用 data(RequestDecodeErr 的第一种实现)。原生客户端打印的结果:
double(21) = Ok(42)
fail_new() = Err(ServerError { message: "boom", code: 500, details: None })
fail_code() = Err(ServerError { message: "teapot", code: 418, details: Some(Object {"why": String("short and stout")}) })
http_err() = Err(HttpError { status: 403, message: Some("members only") })
captured() = Err(CapturedError(error running server function: no such item (details: None)))
status() = Err(418)
boom() = Err(ServerError { message: "HTTP 500: Server function panicked: task 97 panicked with message \"kaboom\"", code: 500, details: None })
panic 的正文不是 JSON,客户端解 ErrorPayload 失败,退回到「HTTP 500: 加正文文字」。
框架自己产生的错误
请求没走到函数体时,错误来自框架,形状又不一样:
### http://127.0.0.1:18800/api/add?a=1
HTTP/1.1 500 Internal Server Error
content-type: application/json
{"error":"error deserializing server function results: missing field `b`"}
### -X POST http://127.0.0.1:18800/api/double -d {"n":"x"}
HTTP/1.1 500 Internal Server Error
content-type: application/json
{"error":"error deserializing server function results: invalid type: string \"x\", expected i32 at line 1 column 8"}
### -X POST http://127.0.0.1:18800/api/double -d not json
HTTP/1.1 500 Internal Server Error
content-type: application/json
{"error":"error deserializing server function results: expected ident at line 1 column 2"}
### -X POST http://127.0.0.1:18800/api/double
HTTP/1.1 500 Internal Server Error
content-type: application/json
{"error":"error deserializing server function results: missing field `n` at line 1 column 2"}
### http://127.0.0.1:18800/api/double
HTTP/1.1 405 Method Not Allowed
allow: POST
### -X POST http://127.0.0.1:18800/api/nothing
HTTP/1.1 405 Method Not Allowed
allow: GET,HEAD
- 参数解不出来是 500,不是 400。body 的
serde_json错误经From<serde_json::Error>变成ServerFnError::Deserialization,query 的serde_qs错误也被包成Deserialization;IntoResponse for ServerFnError对ServerError以外的分支一律 500,正文是{"error": ..},和函数返回错误时的{"message","code","data"}是两种格式。Deserialization的文字是「error deserializing server function results」,这里实际是参数解不出来;专门表示参数错误的ServerFnError::Args在 0.7.10 里没有任何地方构造。 - 方法不对是 axum
MethodRouter的 405,带Allow,正文为空(见 axum 卷 MethodRouter)。不存在的/api/...路径落进页面渲染的 fallback:POST 是 405,GET 会被当成一次页面请求渲染(本应用有 router,渲染结果是 404 页面)。 - body 超过 2 MiB:取 body 的那一行是
Bytes::from_request(request, &()).await.unwrap(),axum 的默认上限返回 rejection,unwrap让任务 panic,make_handler把JoinError变成 500。3 MB 的 JSON:
HTTP/1.1 100 Continue
HTTP/1.1 500 Internal Server Error
content-length: 192
connection: close
date: Wed, 28 Oct 2026 11:52:46 GMT
Server function panicked: task 76 panicked with message "called `Result::unwrap()` on an `Err` value: FailedToBufferBody(LengthLimitError(LengthLimitError(Error { inner: LengthLimitError })))"
服务端 stderr 同时多一段 panic 信息,指向 dioxus-fullstack-0.7.10/src/magic.rs:542:69。release 构建的正文只有 Internal Server Error。本该是 413,客户端拿到的是 500。上限从哪来、怎么放宽,以及本站怎么在 dioxus 读 body 之前先回 413,见 axum 卷的 length limit exceeded 和 dioxus 生成的 Router 最后一节。
表单提交会被改成 302
make_handler 里还有一段:请求的 Accept 含 text/html、带 Referer、响应成功且没有 Location 时,把响应改成 302 Found,Location 指回 Referer。这是给不用 JS 的 <form method="post"> 准备的(提交完回到原页面),但它只看请求头,不看请求是谁发的:
### -X POST http://127.0.0.1:18800/api/double -d {"n":21} -H accept: text/html -H referer: http://127.0.0.1/somewhere
HTTP/1.1 302 Found
content-type: application/json
location: http://127.0.0.1/somewhere
42
同样的头,换成会失败的 GET /api/add?a=1,返回的是原样的 500。
最初这段逻辑不看状态码。PR #5429「fix(server): don't redirect error responses from #[get] server functions」的描述是:「The redirect logic in make_handler unconditionally converts any response to a 302 Found redirect when the request has Accept: text/html and a Referer header. This breaks #[get] endpoints used as OAuth callbacks or any endpoint that needs to return error status codes when accessed via browser navigation.」修法是加上 response.status().is_success() 的判断,「Error responses pass through unchanged」,也就是现在源码里 if accepts_html && response.status().is_success() 那一行和它上面那段注释。首次发布于 0.7.5(git tag --contains)。浏览器直接导航到一个 #[get] server function 时,Accept 里总有 text/html,在 0.7.5 之前,一个返回 403 的 OAuth 回调在浏览器里会变成一次跳回上一页。
在函数体里拿请求信息
函数体跑在 FullstackContext::scope 里。FullstackContext::current 先查 task-local(HTTP 调用时由 make_handler 设置),查不到再从当前 dioxus runtime 的 scope 往上找 context(服务端渲染时由渲染池放在根上)。两种调用方式下函数体都拿得到「当前请求」,只是请求不同:经 HTTP 时是这次 POST,SSR 时是页面的 GET。
拿请求头有两种写法:
// 1. server-only argument: listed after the route, invisible to the client
#[get("/api/whoami", headers: HeaderMap)]
pub async fn whoami() -> Result<String, ServerFnError> {
let user = headers
.get("cookie")
// ... parse `user=...`
}
// 2. extract inside the body
#[post("/api/ctx")]
pub async fn ctx_info() -> Result<String, ServerFnError> {
let headers: HeaderMap = dioxus::fullstack::FullstackContext::extract().await?;
// ...
}
第一种是 0.7.3 加的 server 参数:路由字符串后面的 name: Type 只加在服务端那一半的签名上,客户端的函数签名里没有它。HTTP 调用时它和 Path、Query 一起作为 FromRequestParts 提取;SSR 直接调用时由外层函数体里那句 FullstackContext::extract::<(HeaderMap,), _>() 从当前请求取。第二种 FullstackContext::extract 可以在任何地方调用:复制一份请求的 Parts、配一个空 body,交给任意 axum 提取器(FromRequest<FullstackContext, M>),所以只能用不读 body 的提取器;提取器拒绝时,把拒绝的响应读成文字,包成 ServerFnError::ServerError。当前没有请求时(比如在后台任务里),它造一个 GET / 加 X-Dummy-Header: true 的假请求,而不是报错。
### -X POST http://127.0.0.1:18800/api/ctx -H user-agent: curl-test
HTTP/1.1 200 OK
content-type: application/json
"POST /api/ctx / ua=curl-test"
### http://127.0.0.1:18800/api/whoami -H cookie: user=alice
HTTP/1.1 200 OK
content-type: application/json
"alice"
同一个 whoami 在页面 /me 里经 use_server_future(whoami) 调用,curl -H 'cookie: user=alice' /me 时服务端日志是:
2026-10-28T11:59:30.294465Z INFO exp_dioxus_fs2_app::api: whoami() -> alice in GET /me
函数体看到的是页面请求 GET /me 和它的 cookie,没有经过 HTTP,server 参数也照样提取出来了。设置响应头用 FullstackContext::current() 拿到 context 后 add_response_header,make_handler 在函数返回后把它们合并进响应。SSR 时同一个调用加的头进的是页面响应,而且只在首块提交之前有效(见一次请求的渲染)。
问: 我的 server function 只在自己的组件里调用,参数也是组件里算出来的,是不是就不用担心别人乱传?
答: 不是。上面每一条 curl 都没有经过任何 dioxus 客户端:每个 server function 都是一条公开的 HTTP 路由,路径在日志里、在 wasm 里都能看到(#[server] 的那串数字也只是为了不撞名,不是秘密)。客户端发的 X-Request-Client: dioxus 服务端从来不检查,有没有它结果都一样。参数要在函数体里校验,权限要在函数体里从 cookie 或 header 判断。浏览器那边还有一层:Accept: text/html 的表单 POST 会被当成正常调用,再 302 回去,跨站表单能不能打到你的函数,取决于你的 cookie 设置(SameSite),dioxus 不做 CSRF 检查。
最后回到状态码。上面的表里有一行留空:返回类型是 dioxus::Result 时,错误类型是 CapturedError,它走的是 anyhow 那一份 MakeAxumError。
dioxus::Result 里的 404 变成了什么?#[post("/api/captured")]
pub async fn captured() -> dioxus::Result<i32> {
HttpError::not_found::<()>("no such item")?;
Ok(1)
}
dioxus::Result<T> 的错误类型是 CapturedError。curl -i -X POST http://127.0.0.1:18800/api/captured 的状态行是 404 还是 500?正文的 code 呢?(M2 Max、macOS 27、rustc 1.99.0,debug 构建。)
动脑筋答案
### -X POST http://127.0.0.1:18800/api/captured
HTTP/1.1 500 Internal Server Error
content-type: application/json
{"message":"no such item","code":404}
状态行是 500,正文里的 code 是 404,两者不一致。CapturedError 走的是 MakeAxumError for &&ServerFnDecoder<Result<T, CapturedError>>:错误只有一个引用(_strong_count() == 1)时取出里面的 anyhow::Error,转给 anyhow 那一份实现。那份实现会 downcast:先试 ServerFnError,再试 HttpError,找到 HttpError 就用它的消息和状态码填 ErrorPayload,但设状态码的那一行是写死的 *resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR。
原生客户端调用它得到的是 Err(CapturedError(error running server function: no such item (details: None))):客户端读到非 2xx,解出 ErrorPayload { code: 404 },构造 ServerFnError::ServerError { code: 404, .. },再转成 CapturedError,404 藏在里面,要 downcast 才拿得到。要让 HTTP 状态码本身是 404,返回类型得是 Result<T, HttpError>、Result<T, StatusCode> 或 Result<T, ServerFnError>(配 ServerError { code: 404 })。
小结
#[post]/#[get]/#[server]把函数改写成同名函数,函数体是两个cfg块:客户端一半造ClientRequest、编码、发请求、解码;服务端一半保留原函数体,生成__inner__function__这个 axum 入口,用inventory::submit!在main之前登记,SSR 时直接.await原函数体。- 显式路径原样用;
#[server]不写endpoint时是/api/函数名加xxh64(CARGO_MANIFEST_DIR:module_path)的十进制,换个目录编译客户端就对不上(405)。encoding等老参数能解析但不起作用。 - 路径参数进 URL、
?a&b进 query(serde_qs),其余参数一律进 JSON body,GET 也一样,浏览器里 GET 带 body 直接失败;catch-all query?:n配基本类型时客户端在ClientRequest::new的unwrap处 panic,服务端也解不出。 - 用一串
&做 autoderef 特化,在「可序列化」和「自己会进出请求的类型」之间选实现;选不到时由on_unimplemented给出可读的编译错误。 - 函数返回的错误编成
{"message","code","data"},状态码来自ServerError.code、HttpError、StatusCode,CapturedError/ anyhow 的状态码固定 500;框架自己的解码失败是 500 加{"error"};超过 2 MiB 的 body 让 handler panic,回 500 而不是 413;panic 在 debug 下把消息原样发给客户端。 - 函数体里用 server 参数或
FullstackContext::extract拿请求头,HTTP 调用和 SSR 调用拿到的是各自的请求;每个 server function 都是公开端点,校验和鉴权只能在函数体里做。