Rust in Everything
基于 reqwest 0.13.5

WASM

主线位置:请求主线在浏览器里的替身。send() 之后没有 tower 栈、没有连接池,也就没有连接主线:请求被翻译成一次 fetch(),DNS、TCP、TLS、HTTP/2、重定向、cookie 都由浏览器做;响应主线读的是 JS 的 Response 对象。本页只读源码,不在浏览器里跑;编译用 cargo check 实测。

flowchart TB
  rb["RequestBuilder"] --> ex["execute_request"]
  ex --> mh["合并默认头"]
  mh --> init["RequestInit"]
  init --> ab["AbortGuard"]
  ab --> jf["js_fetch"]
  jf --> fut["JsFuture"]
  fut --> resp["Response"]
  ab -. 随响应走 .-> resp

同一组名字,另一套实现

lib.rs 用两个宏切开整个 crate:

macro_rules! if_wasm {
    ($($item:item)*) => {$(
        #[cfg(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none")))]
        $item
    )*}
}

macro_rules! if_hyper {
    ($($item:item)*) => {$(
        #[cfg(not(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none"))))]
        $item
    )*}
}

if_wasm! 那一段导出 wasm::{Body, Client, ClientBuilder, Request, RequestBuilder, Response} 和 multipart,名字和非 WASM 目标上的 async_impl 完全相同,实现一行不共用(三套实现怎么分配名字见源码地图)。两边共用的只有 error、into_url、response(ResponseBuilderExt)和 util,以及 http / url 的再导出。

src/wasm/ 共 2186 行。依赖也换了一套:Cargo.toml 里 tokio、hyper、hyper-util、tower、tower-http、rustls 都在 [target.'cfg(not(all(target_arch = "wasm32", ...)))'.dependencies] 下,两边共用的只有 [dependencies] 里的 http、url、bytes、base64 等少数几个,WASM 目标另加 js-sys、wasm-bindgen、wasm-bindgen-futures、web-sys(和 stream 用的 wasm-streams)。同一个 crate(exp-reqwest/bw-wasm,reqwest = "=0.13.5" 加 json、stream,默认 feature 不动)分别看依赖树:

aarch64-apple-darwin      103
aws-lc-rs v1.19.0;h2 v0.4.20;hyper v1.12.0;hyper-util v0.1.21;rustls v0.23.46;tokio v1.54.1;tower v0.5.3;tower-http v0.6.11;
wasm32-unknown-unknown       63
js-sys v0.3.106;wasm-bindgen v0.2.129;wasm-bindgen-futures v0.4.79;web-sys v0.3.106;

(cargo tree -e normal --prefix none 去重后的行数,含 bw-wasm 自己;第二行是从中挑出的几个。)默认 feature 里的 default-tls、http2、system-proxy 在 WASM 上照样能打开,只是它们指向的依赖都在非 WASM 的表里,什么也拉不进来。

📝NOTE
冷知识:wasm32-wasip2 曾经也被当成浏览器

宏里的 any(target_os = "unknown", target_os = "none") 是 #3000「use wasm-bindgen ecosystem only for wasm32-unknown-* target」(11d835d,2026-04-17,首次发布在 v0.13.3)加的。之前两个宏只看 #[cfg(target_arch = "wasm32")],WASI 目标也会走 fetch 那一套、依赖 wasm-bindgen,可 WASI 运行时里没有浏览器的 fetch。改了以后 WASI 目标走 hyper 那一侧。走得通吗?对同一个 crate 跑 cargo check --target wasm32-wasip2:

error: Only features sync,macros,io-util,rt,time are supported on wasm.
error: could not compile `tokio` (lib) due to 1 previous error

非 WASM 那一侧需要 tokio 的网络支持,tokio 在 WASI 上不提供。所以 0.13.5 在 WASI 上是编不过,而不是编出一个运行时才失败的客户端。CHANGELOG 把这一条记成「(wasm) Only use wasm-bindgen on unknown-* targets」。

一次请求:翻译成 fetch

Client::execute_request 只做两件事:把 ClientBuilder 设的默认头填进请求的空位,然后调 fetch。fetch 逐项填一个 web_sys::RequestInit:

reqwestRequestInit / JS说明
methodmethod
头Headers,逐个 append值必须能 to_str()(可见 ASCII),否则是 builder 错误
默认 cors = true不设 modefetch_mode_no_cors() 才设 RequestMode::NoCors
credentials,默认 None不设fetch_credentials_same_origin / include / omit
cacheset_cachefetch_cache_* 六个
referrer / referrer_policy同名0.13.5 新加(#3049)
bodybody空 body 不设;见下文
timeoutAbortController 的 signal见下一节

然后建 web_sys::Request,交给 js_fetch:

fn js_fetch(req: &web_sys::Request) -> Promise {
    use wasm_bindgen::{JsCast, JsValue};
    let global = js_sys::global();

    if let Ok(true) = js_sys::Reflect::has(&global, &JsValue::from_str("ServiceWorkerGlobalScope"))
    {
        global
            .unchecked_into::<web_sys::ServiceWorkerGlobalScope>()
            .fetch_with_request(req)
    } else {
        // browser
        fetch_with_request(req)
    }
}

在 Service Worker 里调 ServiceWorkerGlobalScope.fetch,否则调全局的 fetch。返回的 Promise 经 wasm-bindgen-futures 的 JsFuture 变成 Rust 的 future。响应回来后,状态码和头逐个抄进一个 http::Response<web_sys::Response>,URL 取 JS Response.url,也就是浏览器跟完重定向之后的最终地址。

几件在非 WASM 上由 reqwest 做的事,这里一行代码都没有:

  • 重定向:RequestInit 不设 redirect,用浏览器默认的跟随。没有 redirect::Policy,也没有 reqwest 的跨域去敏感头逻辑(非 WASM 的做法见重定向),这些都是浏览器的规则。
  • 默认头:非 WASM 的 execute_request 会补 accept: */*(执行与 Pending),WASM 不补;wasm/client.rs 里的单元测试特意断言了「no accept header」。
  • 解压、cookie、代理、TLS:浏览器做,reqwest 不知道也管不了。

请求体只有完整的:wasm::Body 里是 Single::Bytes / Single::Text,或者 multipart 的 Form(转成浏览器的 FormData)。没有 wrap_stream,上传不能流式。响应体可以流式:stream feature 下 bytes_stream 用 wasm-streams 把 JS 的 ReadableStream 转成 Stream;text() 和 bytes() 直接调 JS 的 text()、arrayBuffer()。

AbortGuard:取消和超时

非 WASM 上,丢掉 future 就是取消请求。浏览器里 fetch 一旦发出就不归 Rust 管,丢掉 JsFuture 不会停下它,必须显式 abort。AbortGuard 包一个 AbortController,它的 signal 填进 RequestInit:

impl Drop for AbortGuard {
    fn drop(&mut self) {
        self.ctrl.abort();
        if let Some((id, _)) = self.timeout.take() {
            clear_timeout(id);
        }
    }
}

guard 在 fetch 里建,成功后移进 Response 的 _abort 字段,bytes_stream 还把它搬进流的闭包里(注释「Keep the abort guard alive as long as this stream is」)。所以:send() 的 future 被丢掉,请求被 abort;Response 或它的流被丢掉,还没读完的体被 abort;读完以后再 abort 是无害的空操作。

这两步是分两次做对的:#1755「Make wasm requests cancel when the future drops」(bb8fec4)加了 guard 并放进 Response,但 bytes_stream 拿走 body 时把 Response 拆了,guard 随之被丢掉,流刚开始读就被 abort;一个月后 #1782「wasm: fix premature abort for streaming bodies」(df2b3ba)把 guard 搬进流里。两个提交都首次发布在 v0.11.15。

超时也挂在这个 guard 上。AbortGuard::timeout 用 JS 的 setTimeout 定一个回调,到点 abort_with_reason("reqwest::errors::TimedOut"):

    fn timeout(&mut self, timeout: Duration) {
        let ctrl = self.ctrl.clone();
        let abort =
            Closure::once(move || ctrl.abort_with_reason(&"reqwest::errors::TimedOut".into()));
        let timeout = set_timeout(
            abort.as_ref().unchecked_ref::<js_sys::Function>(),
            timeout.as_millis().try_into().expect("timeout"),
        );
        // ...
    }

fetch 的 promise 被拒绝时,fetch 函数把错误转成字符串,等于 JsValue("reqwest::errors::TimedOut") 就换成 reqwest 自己的 TimedOut,is_timeout() 才认得出来(#2556,v0.12.13;超时本身是 #1760,同样首次发布在 v0.12.13)。几点和非 WASM 不同:

  • 只有 RequestBuilder::timeout,每个请求单独设;ClientBuilder 上没有 timeout。
  • guard 跟着 Response 走,计时器在读体时还在跑,所以它是覆盖到读完体的总超时;没有 connect_timeout、read_timeout。
  • setTimeout 的毫秒数是 i32,try_into().expect("timeout") 意味着超过 i32::MAX 毫秒(约 24.8 天)的超时会 panic。阻塞客户端的同类问题(Duration::MAX)在 0.13.5 修了(阻塞客户端的冷知识),这里没有。本页没有在浏览器里跑,这一条是读源码得出的。

哪些 API 在 WASM 下不存在

ClientBuilder 只有 4 个方法。 wasm::ClientBuilder 只有 new、build、user_agent、default_headers;它的 Config 只有两个字段:

struct Config {
    headers: HeaderMap,
    error: Option<crate::Error>,
}

异步 ClientBuilder 在 tag 上有 90 个 pub fn(含各种 feature 和平台下的),阻塞版 87 个。超时、连接池、代理、TLS、DNS、HTTP 版本、重定向、重试、cookie、解压、connector_layer,全都不在。

整个模块不在。 if_hyper! 包住的 pub mod:blocking、cookie、dns、redirect、retry、tls,以及 Proxy、NoProxy、Certificate、Identity、Upgraded 的导出。

类型上少了的方法。 Error::is_connect、is_dns 只在非 WASM 上有(error.rs 里的 cfg);is_timeout 在 WASM 上不看 hyper 的错误。wasm::Response 没有 version()(源码里注释掉了,旁边写着「It might not be possible to detect this in JS?」),也没有 remote_addr、extensions、cookies、chunk、upgrade。Response 也不是 Send:lib.rs 的 _assert_impls 里 assert_send::<Response>() 包在 #[cfg(not(target_arch = "wasm32"))] 里,JS 对象不能跨线程。

反过来,有 12 个方法只在 WASM 上有。 RequestBuilder 的 fetch_mode_no_cors、fetch_credentials_* 三个、fetch_cache_* 六个、fetch_referrer、fetch_referrer_policy,在非 WASM 的 async_impl::RequestBuilder 上不存在。共用一份代码的库要给它们加 cfg。

❗IMPORTANT
动脑筋:下面 6 行在 wasm32-unknown-unknown 上 cargo check,编译器报几个错误?
let _ = reqwest::Client::builder().timeout(Duration::from_secs(5));
let _ = reqwest::Client::builder().no_proxy();
let _ = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
let _ = reqwest::Client::builder().pool_idle_timeout(None);
let _ = reqwest::Client::builder().connect_timeout(Duration::from_secs(1));
let _ = reqwest::Proxy::all("http://127.0.0.1:1");

平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,cargo check --offline --target wasm32-unknown-unknown。

离线能不能编

能。本机装了 wasm32-unknown-unknown 目标,cargo 缓存里有 js-sys、web-sys、wasm-bindgen 系列,bw-wasm 的公共函数(建 Client、user_agent、get、timeout、error_for_status、text)离线 cargo check --target wasm32-unknown-unknown 一次通过,冷缓存 21.6 秒:

    Checking web-sys v0.3.106
    Checking wasm-streams v0.5.0
    Checking reqwest v0.13.5
    Checking bw-wasm v0.1.0 (.../bw-wasm)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 21.60s

同一个函数在 aarch64-apple-darwin 上也编得过:Client::builder().user_agent(..)、RequestBuilder::timeout、error_for_status、text 两边都有,这就是「同名」的用处。只做 check:按本卷的计划,WASM 一节只读源码,没有用 wasm-bindgen 生成 JS 胶水,也没有在浏览器里运行。

动脑筋答案

error[E0433]: cannot find `redirect` in `reqwest`
   --> src/lib.rs:20:56
   --> .../reqwest-0.13.5/src/lib.rs:380:13
error[E0433]: cannot find `Proxy` in `reqwest`
   --> src/lib.rs:23:20
   --> .../reqwest-0.13.5/src/lib.rs:364:27
error[E0599]: no method named `timeout` found for struct `ClientBuilder` in the current scope
  --> src/lib.rs:18:38
error[E0599]: no method named `no_proxy` found for struct `ClientBuilder` in the current scope
  --> src/lib.rs:19:38
error[E0599]: no method named `redirect` found for struct `ClientBuilder` in the current scope
  --> src/lib.rs:20:38
error[E0599]: no method named `pool_idle_timeout` found for struct `ClientBuilder` in the current scope
  --> src/lib.rs:21:38
error[E0599]: no method named `connect_timeout` found for struct `ClientBuilder` in the current scope
  --> src/lib.rs:22:38
error: could not compile `bw-wasm` (lib) due to 7 previous errors

(只保留了 error 行和位置行。)7 个:6 行各错一次,第 3 行错两次,redirect 方法不存在,reqwest::redirect 模块也不存在。两个 E0433 还额外指向 reqwest 的 lib.rs 第 380 和 364 行,那是 if_hyper! 里 pub mod redirect 和 pub use self::proxy::{Proxy,NoProxy} 的位置,编译器提示它们被 cfg 关掉了。同样的 6 行在 aarch64-apple-darwin 上编译通过。

小结

  • if_wasm! / if_hyper! 按 wasm32 加 unknown / none 切换实现:同名的 Client、Request、Response、Body,实现不共用;WASI 目标从 v0.13.3 起走 hyper 一侧,在 tokio 那里编不过。
  • 一次请求就是一次 fetch():方法、头、mode、credentials、cache、referrer、body 填进 RequestInit;重定向、cookie、TLS、解压、连接复用全交给浏览器,reqwest 不补 accept。
  • AbortGuard 跟着 Response 和 bytes_stream 走,丢掉就 abort;RequestBuilder::timeout 用 setTimeout 加 abort_with_reason 实现,覆盖到读完体,超过 i32::MAX 毫秒会 panic。
  • ClientBuilder 只有 new、build、user_agent、default_headers;blocking、cookie、dns、redirect、retry、tls、Proxy 不存在;Response 没有 version()、不是 Send;反过来 12 个 fetch_* 方法只在 WASM 上有。
  • 依赖从 103 个 crate 降到 63 个,没有 tokio、hyper、rustls;wasm32-unknown-unknown 离线 cargo check 能过。

💬 关联讨论

发起讨论
加载中...