Rust in Everything
基于 dioxus 0.7.10

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) 拼进 URLaxum::extract::Path((id,))
query ?a&b__QueryParams__ { a, b } 经 serde_qs::to_stringpayloads::Query(__QueryParams__ { a, b }),serde_qs::from_str
其他___Body_Serialize___ { .. } 经 serde_json::to_vec,Content-Type: application/jsonBytes::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 + DeserializeOwnedsend_json
9唯一的参数 O: IntoRequestO::into_request,比如 Form 走 send_form
1都不满足VerifyEncode = CantEncode,编译期检查报错
ExtractRequest(服务端收)11没有 body 参数,server 参数整体 FromRequest交给提取器
10参数结构体 DeserializeOwned读 body,serde_json::from_slice
9唯一的参数 FromRequest交给它自己读 body
RequestDecodeResult(客户端收)3T: FromResponse交给它
2T: DeserializeOwned2xx 按 JSON 解 T,否则解 ErrorPayload
MakeAxumResponse(服务端发)4T: FromResponse + IntoResponseinto_response
3T: Serialize + DeserializeOwnedserde_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)20042
Err(ServerFnError::new("boom"))500ErrorPayload,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")500debug: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。

📝NOTE
冷知识:失败的响应以前也会被改成 302

最初这段逻辑不看状态码。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 时同一个调用加的头进的是页面响应,而且只在首块提交之前有效(见一次请求的渲染)。

📝NOTE
没有蠢问题

问: 我的 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。

❗IMPORTANT
动脑筋: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 都是公开端点,校验和鉴权只能在函数体里做。

💬 关联讨论

发起讨论
加载中...