首页
/ Hoppscotch Desktop 的 Relay 详解:基于 libcurl 的 HTTP 请求中继层实现

Hoppscotch Desktop 的 Relay 详解:基于 libcurl 的 HTTP 请求中继层实现

2026-09-04 12:03:21作者:蔡丛锟

Relay 是 Hoppscotch 桌面端与 Agent 中负责“把一次 HTTP 请求真正发出去”的 Rust crate。它封装了自定义头、证书、代理、本地系统集成等浏览器 Webview 无法直接完成的网络能力,通过一个 Request → execute() → Response 的异步接口,外加 cancel(request_id) 的取消机制,将 libcurl 的全部传输能力暴露给前端。读完本篇,你将理解 Relay 的请求模型、安全/认证配置项、错误类型,以及它在源码中的线程模型与调用链。

定位与适用场景

根据 README,Relay 的定义是:

A HTTP request-response relay used by Hoppscotch Desktop and Hoppscotch Agent for more advanced request handling including custom headers, certificates, proxies, and local system integration.

也就是说,它是 Hoppscotch Desktop 与 Hoppscotch Agent 共用的请求中继层。浏览器环境对自定义 TLS 证书、代理认证、Digest 认证等支持有限,桌面端因此把这类“高级请求处理”下沉到原生侧,由 Relay 完成。crate 元信息见 Cargo.tomlname = "relay",版本 0.1.1)。

需要注意的分发限制(README 原文标注为 IMPORTANT):该 crate 目前只能通过 Git 获取,未发布到 crates.io。README 给出的安装方式是:

[dependencies]
relay = { git = "https://github.com/CuriousCorrelation/relay.git" }

README 列出的特性清单包括:基于 libcurl 的 HTTP 客户端;HTTP/1.1、HTTP/2、HTTP/3 支持;SSL/TLS 证书管理;带认证代理的支持;多种认证方式(Basic、Bearer、Digest);内容处理(JSON、Form Data、Binary);自定义安全配置;带取消支持的异步请求执行。

安装与运行环境要求

README 明确给出了运行前提(Requirements):

  • Rust 1.77.2 或更高版本
  • OpenSSL 开发库
  • 带 SSL 与 HTTP/2 支持的 libcurl

其中有一个值得注意的工程细节(README 标注为 WARNING):Relay 对部分依赖使用了自定义 fork,目的有两个——NTLM 支持,以及跨平台一致的 OpenSSL 后端。这与 Cargo.toml 中的实际依赖完全对应:

curl = { git = "https://github.com/CuriousCorrelation/curl-rust.git", features = ["ntlm"] }
openssl = { version = "0.10.66", features = ["vendored"] }
# NOTE: This crate follows `openssl-sys` from https://github.com/CuriousCorrelation/curl-rust.git
# to avoid issues from version mismatch when compiling from source.
openssl-sys = { version = "0.9.64", features = ["vendored"] }

curl 依赖来自 CuriousCorrelation 维护的 curl-rust fork(开启 ntlm feature),而 openssl-sys 显式声明为 vendored 并跟随 fork 的版本,以避免从源码编译 libcurl 时出现 OpenSSL 版本不匹配问题。其余关键依赖还包括 tokio-util(用于取消令牌)、dashmap(并发请求表)、http/http-serde(标准类型与序列化)、thiserror(错误派生)、timeinfer(内容类型推断)等,均可以在 Cargo.toml 中直接核对。

公开 API 与基本用法

crate 的对外面非常收敛。src/lib.rs 仅导出两个模块级入口与两个类型:

pub use interop::{Request, Response};
pub use relay::{cancel, execute};

即:构造 Requestawait execute(request) 得到 Response、需要时 await cancel(request_id)。README 给出的用法示例:

use relay::{Request, Response, execute};

let request = Request {
    id: 1,
    url: "https://api.example.com".to_string(),
    method: Method::Get,
    version: Version::Http2,
    // ... configure other options
};

let response = execute(request).await?;

这里 MethodVersion 来自 http crate(Cargo.toml 中的 http = "1.1.0"),因此 HTTP 方法、协议版本都复用 Rust 生态的标准类型。README 同时强调:所有请求都是异步执行的,并可通过 cancel(request_id) 取消

请求模型:Request 的完整字段

RequestResponse 定义在 src/interop.rs,且都实现了 Serialize/Deserialize——这是桌面端与 Rust 侧跨进程传参(Tauri IPC 的 JSON)的基础。Request 的全部字段(interop.rs#L333-L347):

pub struct Request {
    pub id: i64,                    // 请求标识,用于取消与响应关联
    pub url: String,
    pub method: Method,             // http crate 的 Method,serde 走 http_serde
    pub version: Version,           // HTTP/1.1、2、3
    pub headers: Option<HashMap<String, String>>,
    pub params: Option<HashMap<String, String>>,
    pub content: Option<ContentType>,
    pub auth: Option<AuthType>,
    pub security: Option<SecurityConfig>,
    pub proxy: Option<ProxyConfig>,
    pub meta: Option<RequestMeta>,
}

几个子模型值得展开:

请求体:ContentType

ContentTypeinterop.rs#L145-L182)是一个按 kind 标签区分的 tagged enum,对应 README 中“Content handling (JSON, Form Data, Binary)”的描述:

  • Text { content: String, media_type }
  • Json { content: serde_json::Value, media_type }
  • Xml { content: String, media_type }
  • Form { content: FormData, media_type }
  • Binary { content: Bytes, media_type, filename: Option<String> }
  • Multipart { content: FormData, media_type }
  • Urlencoded { content: String, media_type }

其中 FormDataVec<(String, Vec<FormValue>)>FormValue 进一步区分 TextFile { filename, content_type, data }interop.rs#L128-L141),因此 multipart 上传可以混合文本字段与二进制文件。MediaType 则枚举了约 40 种常见 MIME 类型(文本、application、音频、视频、图像),并以 #[serde(other)] Other 兜底未知类型(interop.rs#L9-L126)。

行为选项:RequestOptions

RequestMeta.options 中的 RequestOptionsinterop.rs#L321-L330):

字段 类型 作用
timeout Option<u64> 请求超时,毫秒
follow_redirects Option<bool> 是否跟随重定向
max_redirects Option<u32> 最大重定向次数
decompress Option<bool> 是否自动解压缩
cookies Option<bool> 是否启用 cookie 处理
keep_alive Option<bool> TCP keep-alive

这些选项在 src/request.rssetup_basics() 中逐项落到 curl 句柄:timeout 转为 Duration::from_millisrequest.rs#L148-L159);decompress = false 时把 Accept-Encoding 固定为 identity 以关闭自动解压(request.rs#L161-L172);cookies = true 时调用 cookie_file("") 启用 curl 的内存 cookie 引擎(request.rs#L174-L185)。默认情况下 accept_encoding("") 表示接受所有编码(request.rs#L106-L114)。

响应:Response 与元数据

Responseinterop.rs#L356-L369)包含 idstatusstatusTextversionheaderscookies(完整解析后的 Cookie 列表,含 domain/path/expires/sameSite)、bodyBytes + MediaType)以及 metaResponseMeta 携带两组统计(interop.rs#L405-L422):

  • TimingInfo { start, end }:请求时间窗口;
  • SizeInfo { headers, body, total }:头部、正文与总字节数。

这与 README 示例“Blazingly fast”之外更实际的卖点一致:桌面端 UI 可以直接展示耗时与传输量。

认证:AuthType 的六种模式与支持边界

AuthTypeinterop.rs#L229-L277)覆盖 NoneBasicBearerDigestApiKeyOAuth2Aws 七种形态(README 特性清单中列出 Basic/Bearer/Digest,源码实际还支持 API Key、OAuth2 与 AWS 签名)。其分派逻辑集中在 src/auth.rsset_auth()auth.rs#L20-L83),各模式的底层实现要点:

  • Basic:直接 handle.username() / handle.password(),由 libcurl 处理(auth.rs#L85-L106)。
  • Bearer:写入 Authorization: Bearer {token} 头(auth.rs#L108-L112)。
  • Digest:在 Basic 凭据之外,额外用 curl::easy::Auth 开启 digest(true)http_authauth.rs#L146-L164)。Digest 的可选参数 realmnoncealgorithm(MD5/SHA-256/SHA-512)、qop(auth/auth-int)等定义在 interop.rs#L279-L292
  • ApiKeyHeader 位置直接注入请求头;Query 位置在 auth.rs 中留有注释,说明应在设置 URL 前拼入查询参数,该逻辑目前位于 request.rs 中一段被注释的 TODO 代码块——从源码结构看,API Key 的 query 模式尚处于迁移中,阅读代码时应留意这一点。
  • AWS SigV4set_aws_auth 目前只打印日志并直接返回 Ok(())auth.rs#L133-L144),注释表明“AWS SigV4 auth is handled at application level”,即签名在调用方(前端/Agent)完成,Relay 仅透传。
  • OAuth2:这是 Relay 中唯一会发起“额外请求”的认证模式。set_auth 对 OAuth2 的三级回退策略(auth.rs#L62-L77)是:有 access_token 则按 Bearer 使用;只有 refresh_token 则走 grant_type=refresh_token 刷新;否则按 GrantType 发起授权流。GrantTypeinterop.rs#L193-L220)定义了四种:
GrantType 字段 Relay 的支持情况
ClientCredentials token_endpoint、client_id、client_secret 支持,直接 POST token endpoint
Password token_endpoint、username、password 支持,直接 POST token endpoint
AuthorizationCode auth_endpoint、token_endpoint、client_id、client_secret 不支持,返回 UnsupportedFeature
Implicit auth_endpoint、client_id 不支持,返回 UnsupportedFeature

不支持的原因是源码中明确的:Authorization Code 与 Implicit 流程“requires browser interaction”(auth.rs#L184-L199)。令牌获取本身在 request_token() 中用一个独立的 Easy 句柄向 token endpoint 发送 URL 编码后的表单,并把 JSON 响应解析为 TokenResponse { access_token, token_type, expires_in, refresh_token, scope }auth.rs#L263-L325),最后把拿到的 access_token 落回 Bearer 头。

安全配置:证书、校验与 CA

README 的 Security Features 一节给出的示例是:

let security_config = SecurityConfig {
    validate_certificates: Some(true),
    verify_host: Some(true),
    certificates: Some(CertificateConfig {
        client: Some(CertificateType::Pem {
            cert: cert_data,
            key: key_data
        }),
        ca: Some(vec![ca_cert_data])
    })
};

对照实际源码,字段名略有差异:SecurityConfiginterop.rs#L301-L308)的字段是 certificatesverify_host(serde 名 verifyHost)、verify_peer(serde 名 verifyPeer),而非 README 示例中的 validate_certificates。以源码为准,Rust 侧可写成:

let security_config = SecurityConfig {
    verify_peer: Some(true),          // 对应 serde 字段 verifyPeer
    verify_host: Some(true),
    certificates: Some(CertificateConfig {
        client: Some(CertificateType::Pem { cert: cert_data, key: key_data }),
        ca: Some(vec![ca_cert_data]),
    }),
};

CertificateType 支持 Pem { cert, key }Pfx { data, password } 两种形态(interop.rs#L294-L299)。src/security.rs 中的 SecurityHandler::configure() 展示了这些字段如何映射到 libcurl:

  1. verify_peerhandle.ssl_verify_peer(verify),失败时返回 RelayError::Certificatesecurity.rs#L24-L33);
  2. verify_hosthandle.ssl_verify_host(verify)security.rs#L35-L44);
  3. 客户端证书:PEM 走 ssl_cert_type("PEM") + ssl_cert_blob + ssl_key_type("PEM") + ssl_key_blobsecurity.rs#L76-L114);PFX(PKCS#12)则先用 openssl::pkcs12::Pkcs12::from_der + parse2(password) 解出证书与私钥,转成 PEM 后复用同一条路径(security.rs#L116-L158)——这正是依赖中引入 openssl crate 的直接原因;
  4. CA 证书列表:逐个 ssl_cainfo_blob 注入(security.rs#L160-L172)。

这套能力对应的正是桌面端“跳过证书校验 / 使用自签名 CA / mTLS 客户端证书”等高级网络设置。

代理配置

ProxyConfig { url, auth: Option<ProxyAuth> }ProxyAuth { username, password } 定义在 interop.rs#L371-L381。应用侧逻辑在 CurlRequest::prepare() 的后半段(request.rs#L228-L262):先 handle.proxy(url),再把代理认证方式设为 Auth::new().auto(true),最后仅在用户名与密码都非空时写入 proxy_username / proxy_passwordauto 让 libcurl 自行协商 HTTP Basic 或 NTLM——NTLM 能力正是来自前面提到的 curl-rust fork 的 ntlm feature。

执行与取消:源码级的线程模型

README 的 NOTE 说“所有请求异步执行且可取消”,src/relay.rs 给出了具体机制:

lazy_static::lazy_static! {
    static ref ACTIVE_REQUESTS: DashMap<i64, Arc<AtomicBool>> = DashMap::new();
}
  • execute(request)async fn,但真正耗时的 curl 传输是阻塞的,因此它在函数体内 std::thread::spawn 一个专用线程执行 execute_request(),主协程通过 handle.join() 取回结果(relay.rs#L104-L155)。每个请求独立线程,避免了 libcurl Easy 句柄的共享与借用问题,代价是每请求一个线程。
  • 取消路径:execute 入口先把请求的 Arc<AtomicBool> 注册进 ACTIVE_REQUESTS(key 为 request.id)。cancel(request_id) 只需把对应布尔置真(relay.rs#L158-L175);若找不到该 id 则返回 RelayError::Network { message: "Request not found" }。执行线程结束后检查取消标志,被取消的请求统一收敛为 RelayError::Abort { message: "Request cancelled by user" }relay.rs#L128-L139)。
  • 单条请求的组装流程在 execute_request()relay.rs#L27-L101):创建 Easy 句柄 → CurlRequest::prepare() 完成方法/URL/版本/内容/认证/安全/代理配置 → TransferHandler 挂接写回调并执行传输 → 读取 response_code()header_size() → 交给 ResponseHandler 组装最终 Response(含起止时间与大小统计)。全程使用 tracing 记录 method、url、status、body_size 等结构化日志,并固定开启 verbose(true) 与 debug 回调,把 libcurl 的握手/重定向细节输出到 trace 层。

值得说明的一点:CancellationToken 也被创建并传给 TransferHandlerrelay.rs#L116-L126),但当前对用户的取消判定最终以 AtomicBool 为准;从源码结构看,取消是“协作式”的——传输线程完成后才收敛为 Abort 错误。

错误模型:RelayError

README 的 Error Handling 一节给出的简化版是:

#[derive(Error)]
pub enum RelayError {
    Network { message: String, cause: Option<String> },
    Certificate { message: String, cause: Option<String> },
    Parse { message: String, cause: Option<String> },
    // ... other variants
}

完整的 RelayError 定义在 src/error.rs,共有五个变体,且同样实现了 Serialize/Deserialize,方便跨 IPC 传递:

变体 含义 典型触发点
UnsupportedFeature { feature, message, relay } 请求了当前 relay 不支持的特性 OAuth2 的 Authorization Code / Implicit 流程、Implicit 刷新
Network { message, cause } 网络/传输错误 curl 句柄配置失败、线程 panic、“Request not found”
Timeout { message, phase: Option<TimeoutPhase> } 超时,且能指出阶段 TimeoutPhase 细分 Connect(建连)、Tls(握手)、Response(等待响应)
Certificate { message, cause } 证书错误 ssl_verify_peer 等安全配置失败、PKCS#12 解析失败
Parse { message, cause } 响应解析失败 如 OAuth2 token 响应 JSON 解析失败
Abort { message } 请求被中止 cancel(request_id) 之后

此外 error.rs 还定义了 RequestResult<T>Success { response } / Error { error } 的 tagged 枚举,error.rs#L63-L68),作为跨进程返回“要么成功要么错误”的统一封装。

桌面端如何调用 Relay

Relay 并非孤立 crate:Hoppscotch 前端通过 kernel 抽象访问它。packages/hoppscotch-desktop/src/kernel/relay.ts 展示了 TS 侧的封装形态:

export const Relay = (() => {
  const module = () => getModule("relay")

  return {
    capabilities: () => module().capabilities,
    canHandle: (request: RelayRequest): E.Either<RelayError, true> =>
      module().canHandle(request),
    execute: (request: RelayRequest) => module().execute(request),
  } as const
})()

execute 的返回结构是 { cancel, emitter, response }——一个可取消句柄、一个事件发射器、以及一个 Promise<Either<RelayError, RelayResponse>>。这与 Rust 侧的 execute/cancel + tracing 事件流是一一对应的:前端的 cancel() 最终落到 Rust 的 cancel(request_id),而 Request/Response 的 serde 结构(camelCase 标签、tagged enum)保证了 JSON 边界的稳定序列化。结合 src/lib.rs 的导出面可以推断,canHandle/capabilities 属于上层 kernel 协议,Relay crate 本身只暴露 executecancel 与两个互操作类型。

小结

Relay 是 Hoppscotch Desktop 网络栈中“最后一公里”的 Rust 实现:以 Cargo.toml 中 fork 版 curl + vendored OpenSSL 为底座,用 interop.rs 中一组 serde 友好的 tagged 枚举把请求/响应建模成可 JSON 传输的契约;request.rs 负责把契约逐项映射为 libcurl 配置;auth.rssecurity.rs 分别补齐认证与证书能力;relay.rs 用“每请求一线程 + DashMap 取消标志”提供异步与取消语义;error.rs 则把失败收敛为可跨进程传递的 RelayError。使用时需牢记三点前提:Rust ≥ 1.77.2、依赖通过 Git 引用(含 NTLM/NTLM 一致性所需的 fork)、OAuth2 的交互式授权流不在 Relay 支持范围内。

登录后查看全文
热门项目推荐
相关项目推荐