Hoppscotch Relay:为 Hoppscotch 桌面端构建的 Rust HTTP 请求中继层详解
本篇指南围绕 Hoppscotch 仓库中的 hoppscotch-relay 包(packages/hoppscotch-relay/README.md)展开:它是一个高性能的 HTTP 请求-响应中继库,为 Hoppscotch Desktop 与 Hoppscotch Agent 提供浏览器无法实现的底层网络能力——CORS 覆盖、自定义请求头、客户端证书认证、自定义根证书、代理与本地系统集成。读完本文,你将掌握该库的完整 API 用法(基本请求、各类请求体、证书、代理、请求取消),并能从源码层面理解其基于 curl-rust fork 与静态 OpenSSL 的实现原理。
1. 它解决什么问题:浏览器网络栈的能力边界
在 Web 端,HTTP 请求受限于浏览器的 fetch/XHR 安全模型:不能随意修改 Host 头、不能上传客户端证书、无法信任自签 CA、无法走本地代理。Hoppscotch 桌面端(Tauri 应用)为了让桌面版与 Web 版体验一致,把“真正发请求”这一步下沉到了 Rust 原生层——也就是 hoppscotch-relay 这个 crate。
从 Cargo.toml 可以看到其核心依赖选择,这也是 README 中“consistent SSL/TLS behavior across different platforms(跨平台一致的 SSL/TLS 行为)”说法的实现依据:
[dependencies]
# 基于 curl-rust 的定制 fork,启用 NTLM 特性以支持 NTLM 代理认证
curl = { git = "https://github.com/CuriousCorrelation/curl-rust.git", features = ["ntlm"] }
tokio-util = "0.7.12"
# vendored 特性:静态编译 OpenSSL,避免依赖系统 OpenSSL 版本差异
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-rust fork(而非官方
curlcrate),以启用 NTLM 代理认证等特性; openssl与openssl-sys都开启vendored特性,即从源码静态编译 OpenSSL。Cargo.toml 中的注释明确说明:这样做是为了跟随 curl-rust fork 所依赖的openssl-sys版本,避免从源码编译时出现版本不匹配问题。其效果是:无论目标机器上装了什么版本的系统 OpenSSL,relay 的行为都一致。
对外 API 在 src/lib.rs 中统一导出,非常收敛:
pub use error::{RelayError, RelayResult};
pub use interop::{RequestWithMetadata, ResponseWithMetadata};
pub use relay::run_request_task;
即整个库的公开面就是“请求描述结构体 + 响应描述结构体 + 一个执行函数 + 一套错误类型”,其余模块(error、interop、relay、util)均为 pub(crate) 内部实现。
2. 核心数据模型:RequestWithMetadata 与 ResponseWithMetadata
所有请求配置集中在 src/interop.rs 的 RequestWithMetadata 中,它同时也是 serde 可序列化/可反序列化的结构(Serialize/Deserialize),这意味着前端(WebView)可以通过 JSON 把完整请求配置跨进程传给 Rust 层:
pub struct RequestWithMetadata {
pub req_id: usize, // 请求 ID,用于与前端侧的请求追踪对应
pub method: String, // HTTP 方法,任意值(custom_request)
pub endpoint: String, // 目标 URL
pub headers: Vec<KeyValuePair>, // 请求头列表
pub body: Option<BodyDef>, // 请求体(可选)
pub validate_certs: bool, // 是否校验 TLS 证书(peer + host)
pub root_cert_bundle_files: Vec<Vec<u8>>, // 自定义根证书包(PEM 字节,可多个)
pub client_cert: Option<ClientCertDef>, // 客户端证书(PEM 或 PFX)
pub proxy: Option<ProxyConfig>, // 代理配置
}
2.1 请求体类型 BodyDef
pub enum BodyDef {
Text(String), // 原始文本/JSON
URLEncoded(Vec<KeyValuePair>), // 表单键值对
FormData(Vec<FormDataEntry>), // multipart 表单
}
pub enum FormDataValue {
Text(String),
File { filename: String, data: Vec<u8>, mime: String }, // 文件上传
}
2.2 客户端证书 ClientCertDef
支持两种格式(见 src/interop.rs L76-L86):
pub enum ClientCertDef {
PEMCert { certificate_pem: Vec<u8>, key_pem: Vec<u8> }, // PEM 证书 + 私钥
PFXCert { certificate_pfx: Vec<u8>, password: String }, // PKCS#12/PFX + 口令
}
2.3 响应结构 ResponseWithMetadata
pub struct ResponseWithMetadata {
pub status: u16, // 状态码
pub status_text: String,// 状态文本(如 "OK")
pub headers: Vec<KeyValuePair>,
pub data: Vec<u8>, // 响应体原始字节
pub time_start_ms: u128,// 传输开始时间戳(ms,自 UNIX_EPOCH)
pub time_end_ms: u128, // 传输结束时间戳
}
这里的 time_start_ms/time_end_ms 是围绕 transfer.perform() 前后用 SystemTime::now() 取到的绝对时间戳(src/relay.rs L286-L311),因此 time_end_ms - time_start_ms 即为本次请求的总耗时,README 示例中的用法即源于此。状态文本则由 src/util.rs 的 get_status_text 通过 http::StatusCode::canonical_reason() 得到,未知状态码返回 "Unknown Status"。
3. 安装与基本用法
3.1 安装
按照 README 说明,将 crate 加入你的 Cargo.toml:
[dependencies]
hoppscotch-relay = "0.1.1"
当前仓库中该 crate 的版本号(Cargo.toml L3)即为 0.1.1,与文档一致。
3.2 基本 GET 请求
use hoppscotch_relay::{RequestWithMetadata, KeyValuePair};
use tokio_util::sync::CancellationToken;
// Create a basic GET request
let request = RequestWithMetadata::new(
1, // Request ID
"GET".to_string(), // Method
"https://api.example.com/data".to_string(), // Endpoint
vec![ // Headers
KeyValuePair {
key: "Accept".to_string(),
value: "application/json".to_string(),
}
],
None, // Body
true, // Validate certificates
vec![], // Root certificate bundles
None, // Client certificate
None, // Proxy configuration
);
// Execute the request with cancellation support
let cancel_token = CancellationToken::new();
let response = hoppscotch_relay::run_request_task(&request, cancel_token)?;
println!("Status: {} {}", response.status, response.status_text);
println!("Response time: {}ms", response.time_end_ms - response.time_start_ms);
注意 RequestWithMetadata::new(...) 的参数顺序与结构体字段顺序一致:req_id, method, endpoint, headers, body, validate_certs, root_cert_bundle_files, client_cert, proxy。执行函数 run_request_task 的签名是(src/relay.rs L16-L19):
pub fn run_request_task(
req: &RequestWithMetadata,
cancel_token: CancellationToken,
) -> Result<ResponseWithMetadata, RelayError>
从源码结构看,run_request_task 本身是一个同步阻塞函数:它直接操作 curl 的 Easy 句柄并调用 transfer.perform()。README 特性列表中提到的“Async design”应理解为:调用方可以把它放进 tokio::spawn 等异步任务中隔离执行(后文取消示例正是这种用法),并通过 CancellationToken 实现跨任务取消。
4. 请求体实战:JSON、URL 编码与文件上传
4.1 POST JSON Body
let mut request = RequestWithMetadata::new(
2,
"POST".to_string(),
"https://api.example.com/users".to_string(),
vec![KeyValuePair {
key: "Content-Type".to_string(),
value: "application/json".to_string(),
}],
Some(BodyDef::Text(r#"{"name": "John Doe"}"#.to_string())),
true,
vec![],
None,
None,
);
let response = hoppscotch_relay::run_request_task(&request, CancellationToken::new())?;
对应实现位于 src/relay.rs 的 apply_body_to_curl_handle(L371-L446):BodyDef::Text 走 curl_handle.post_fields_copy(text.as_bytes()),即把原始字节作为 POST fields 发送,不做任何转义——所以 JSON、XML 等任意 raw body 都可以直接放进来。
4.2 URL 编码表单
BodyDef::URLEncoded(Vec<KeyValuePair>) 在实现中会先用 url_escape::encode_www_form_urlencoded 对 key 和 value 分别做 RFC 3986 表单编码,再用 & 连接后发送:
Some(BodyDef::URLEncoded(entries)) => {
let data = entries.iter().map(|KeyValuePair { key, value }| {
format!(
"{}={}",
&url_escape::encode_www_form_urlencoded(key),
&url_escape::encode_www_form_urlencoded(value),
)
}).collect::<Vec<String>>().join("&");
curl_handle.post_fields_copy(data.as_bytes())...
}
也就是说,调用方只需要传原始键值对,编码细节由库内部完成。
4.3 multipart 文件上传
let form_data = vec![
FormDataEntry {
key: "file".to_string(),
value: FormDataValue::File {
filename: "document.pdf".to_string(),
data: std::fs::read("document.pdf")?,
mime: "application/pdf".to_string(),
},
},
FormDataEntry {
key: "description".to_string(),
value: FormDataValue::Text("Important document".to_string()),
},
];
let mut request = RequestWithMetadata::new(
3,
"POST".to_string(),
"https://api.example.com/upload".to_string(),
vec![],
Some(BodyDef::FormData(form_data)),
true,
vec![],
None,
None,
);
实现上,每个 FormDataEntry 被映射为 curl 的 Form part:文本值走 part.contents(...),文件值走 part.buffer(filename, data).content_type(mime) 以内存字节(而非文件路径)作为上传源,最后 curl_handle.httppost(form) 组装完成。这意味着文件内容以 Vec<u8> 形式随请求描述一起从前端传入,天然契合 Tauri 命令序列化模型。
5. TLS 能力:证书校验、客户端证书与自定义根证书包
5.1 证书校验开关
validate_certs: bool 会同时作用于 peer 与 host 两项校验(src/relay.rs L96-L124):
curl_handle.ssl_verify_peer(req.validate_certs)?;
curl_handle.ssl_verify_host(req.validate_certs)?;
即传入 false 时完全跳过 TLS 校验(常用于访问内网自签 CA 的 API),传入 true 时恢复正常校验——此时若你用的是私有 CA,就需要配合下一条的自定义根证书包。
5.2 自定义根证书包(root_cert_bundle_files)
root_cert_bundle_files 是一个 Vec<Vec<u8>>,每个元素是一份 PEM 编码证书包的原始字节。源码中的处理路径是(L152-L219):
- 通过 curl 的
ssl_ctx_function回调拿到 libcurl 创建的SSL_CTX裸指针; get_x509_certs_from_root_cert_bundle_safe(L558-L573)用openssl::x509::X509::stack_from_pem逐个解析每个 bundle,解析失败的包只告警不中断;- 将解析出的每张
X509证书add_cert进SSL_CTX的 cert store; - 最后执行
std::mem::forget(ssl_ctx_builder)。
第 4 步是这段实现中最精妙的细节:SslContextBuilder 只是 openssl_sys::SSL_CTX 指针的安全包装,真正的 SSL_CTX 内存由 curl 拥有并在连接清理时释放。如果让 Rust 的 Drop 再释放一次就会发生 double free。源码注释引用了 libcurl 文档中“libcurl does not guarantee the lifetime of the passed in object once this callback function has returned”来解释为何故意“泄漏”这个薄包装(L195-L209)。
5.3 客户端证书认证
let client_cert = ClientCertDef::PEMCert {
certificate_pem: std::fs::read("client.crt")?,
key_pem: std::fs::read("client.key")?,
};
let mut request = RequestWithMetadata::new(
4,
"GET".to_string(),
"https://secure-api.example.com".to_string(),
vec![],
None,
true,
vec![],
Some(client_cert),
None,
);
从 src/relay.rs 的 apply_client_cert_to_curl_handle(L448-L556)看,两种格式的处理路径不同:
- PEMCert:直接以 blob 形式设置
ssl_cert_type("PEM")+ssl_cert_blob和ssl_key_type("PEM")+ssl_key_blob,不落盘、不依赖系统证书目录; - PFXCert:先用
openssl::pkcs12::Pkcs12::from_der解析 PFX 二进制,再用parse2(password)以口令解出证书与私钥,随后统一转换成 PEM 格式(证书to_pem(),私钥private_key_to_pem_pkcs8())走与 PEM 相同的路径下发给 curl。若 PFX 中缺少证书或私钥任一,返回明确的RequestRunError。
这种“PFX 转 PEM 再下发”的设计,让 Windows 用户习惯的 .p12/.pfx 单文件证书与 Linux/macOS 习惯的 .crt+.key 文件在 API 层保持一致体验。
6. 代理配置与实现细节
README 用法示例:
let proxy_config = ProxyConfig {
url: "http://proxy.example.com:8080".to_string(),
};
let mut request = RequestWithMetadata::new(
5,
"GET".to_string(),
"https://api.example.com".to_string(),
vec![],
None,
true,
vec![],
None,
Some(proxy_config),
);
实现见 src/relay.rs 的 apply_proxy_config_to_curl_handle(L575-L595),共两步:
handle.proxy_auth(curl::easy::Auth::new().auto(true))?; // 自动协商代理认证方式
handle.proxy(&proxy_config.url)?; // 设置代理地址
这里有两点需要与 README 的特性描述对照理解:
ProxyConfig结构体只有url一个字段(src/interop.rs L71-L74)。所谓“Authentication support”来自Auth::new().auto(true):启用后 curl 会自动在 Basic/Digest/NTLM/Negotiate 等方式间协商,凭据通常内嵌在代理 URL 中(如http://user:pass@host:port);- NTLM 支持并非默认 curl 能力,而是 Cargo.toml 中 curl-rust fork 显式开启的
features = ["ntlm"]。
7. 请求取消:CancellationToken 与 progress 回调的配合
README 中的取消示例:
use tokio_util::sync::CancellationToken;
let cancel_token = CancellationToken::new();
let cancel_token_clone = cancel_token.clone();
// Spawn the request in a separate task
let request_handle = tokio::spawn(async move {
hoppscotch_relay::run_request_task(&request, cancel_token_clone)
});
// Cancel the request after 5 seconds
tokio::time::sleep(Duration::from_secs(5)).await;
cancel_token.cancel();
其底层机制在 src/relay.rs L221-L247:由于 run_request_task 是阻塞式 transfer.perform(),取消无法用 async/await 的 select! 实现,而是挂在 curl 的 progress 回调上——每次传输进度更新时检查 cancel_token.is_cancelled(),一旦为真就返回 false,curl 随即中止传输;回调同时会打印已下载/上传的字节数日志。这与 README 中“Async design / Request cancellation support / Progress logs”三条特性一一对应:
- Progress logs:未取消时以 debug 级别输出
Download: dlnow/dltotal, Upload: ulnow/ultotal; - Cancellation:取消后回调返回
false中止请求,并记录取消时刻的传输进度。
同时 progress(true) 在任务开头被强制启用(L32-L42),启用失败会直接返回错误——进度回调是整个取消机制的载体,所以它是硬性依赖而非可选项。
8. 错误处理与可观测性
8.1 错误模型
src/error.rs 定义了全部对外错误:
#[derive(Debug, Error, Serialize)]
pub enum RelayError {
#[error("Invalid method")]
InvalidMethod, // custom_request(&method) 失败
#[error("Invalid URL")]
InvalidUrl, // url(&endpoint) 失败
#[error("Invalid headers")]
InvalidHeaders, // http_headers(...) 失败
#[error("Request run error: {0}")]
RequestRunError(String), // 其余运行期错误(body/证书/代理/传输等)
...
}
pub type RelayResult<T> = std::result::Result<T, RelayError>;
RelayError 派生了 Serialize,因此错误也能序列化回前端展示。执行函数内每一步 curl 调用(方法、URL、headers、body、SSL 校验、客户端证书、代理)都有独立分支,配置类错误映射为对应语义变体,其余统一收敛为 RequestRunError。
8.2 日志
整个 run_request_task 全程埋点 log::info!/log::debug!/log::warn!:请求启动参数、证书包中每张证书的 Subject/有效期、响应头逐行接收、每个 body chunk 的累计字节数、最终状态码与耗时汇总(L338-L347)都在日志中可见。Cargo.toml 同时依赖 log 与 env_logger,宿主程序(如 Tauri 应用)负责初始化日志后端并控制级别,relay 自身只产生日志事件。
9. 从源码构建
README 建议从独立仓库克隆构建。在当前 monorepo 中,hoppscotch-relay 是工作区子包,直接在该目录执行即可:
# 方式一:按 README,克隆独立仓库
# git clone https://github.com/hoppscotch/hoppscotch-relay
# cd hoppscotch-relay
cargo build --release
# 方式二:在 Hoppscotch 仓库内直接构建该子包(当前仓库实际结构)
cd packages/hoppscotch-relay
cargo build --release
构建注意事项:由于 openssl/openssl-sys 均为 vendored,首次构建会从源码编译 OpenSSL,需要目标平台具备 C 编译器工具链;这也是“跨平台 SSL/TLS 行为一致”的来源——不依赖系统 OpenSSL 版本。
10. 小结与适用边界
hoppscotch-relay 用极小的 API 面(4 个导出项)把桌面端 API 开发最刚需的底层网络能力封装到位:任意方法与 body 类型、PEM/PFX 客户端证书、自定义根证书包、代理(含 NTLM)、请求取消与完整响应度量。几个使用边界值得留意:
run_request_task是阻塞函数,生产集成时应放入独立任务/线程,避免阻塞 async runtime 主线程;- 响应体
data是完整缓冲的Vec<u8>,适合 API 调试场景,不适合作为超大文件下载的通用方案; - 代理凭据不单独建模,依赖 URL 内嵌 + curl auto-auth 协商,这是当前
ProxyConfig结构决定的(从源码结构看,后续如需独立 username/password 字段,只需扩展该结构并在apply_proxy_config_to_curl_handle中补proxy_userpass调用)。
关键源码索引:请求执行主流程 src/relay.rs、数据模型 src/interop.rs、导出面 src/lib.rs、错误定义 src/error.rs、依赖与 OpenSSL 策略 Cargo.toml。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00