Skip to main content

ferridriver_script/bindings/
http_client.rs

1//! `HttpClientJs` + `HttpResponseJs`: JS wrappers for HTTP calls from
2//! the runner side (separate from the page's own network).
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use ferridriver::http_client::{HttpClient, HttpResponse, NetGuard, RequestOptions};
8use rquickjs::function::Opt;
9use rquickjs::{Ctx, JsLifetime, Value, class::Trace};
10use serde::Deserialize;
11
12use crate::bindings::convert::{FerriResultExt, serde_from_js};
13
14/// Shape of per-request options accepted from JS.
15///
16/// Mirrors `ferridriver::http_client::RequestOptions` but uses
17/// `serde::Deserialize` so callers can pass a plain object:
18/// `request.post('/api', { json: { x: 1 }, headers: { 'X-A': 'b' } })`.
19#[derive(Debug, Default, Deserialize)]
20#[serde(default)]
21struct JsRequestOptions {
22  headers: Option<Vec<(String, String)>>,
23  data: Option<Vec<u8>>,
24  json: Option<serde_json::Value>,
25  form: Option<Vec<(String, String)>>,
26  params: Option<Vec<(String, String)>>,
27  /// Per-request timeout in milliseconds.
28  timeout_ms: Option<u64>,
29  fail_on_status_code: Option<bool>,
30  max_redirects: Option<u32>,
31}
32
33impl JsRequestOptions {
34  fn into_core(self) -> RequestOptions {
35    RequestOptions {
36      method: None,
37      headers: self.headers,
38      data: self.data,
39      json_data: self.json,
40      form: self.form,
41      params: self.params,
42      timeout: self.timeout_ms.map(Duration::from_millis),
43      fail_on_status_code: self.fail_on_status_code,
44      max_redirects: self.max_redirects,
45      // Set by `with_guard` after parsing — never from JS input.
46      net_guard: None,
47    }
48  }
49}
50
51fn parse_options<'js>(ctx: &Ctx<'js>, value: Opt<Value<'js>>) -> rquickjs::Result<Option<RequestOptions>> {
52  match value.0 {
53    Some(v) if !v.is_undefined() && !v.is_null() => {
54      let parsed: JsRequestOptions = serde_from_js(ctx, v)?;
55      Ok(Some(parsed.into_core()))
56    },
57    _ => Ok(None),
58  }
59}
60
61// ── HttpClientJs ──────────────────────────────────────────────────────
62
63#[derive(JsLifetime, Trace)]
64#[rquickjs::class(rename = "HttpClient")]
65pub struct HttpClientJs {
66  #[qjs(skip_trace)]
67  inner: Arc<HttpClient>,
68  /// Host allow-list (plugin `allow.net` capability). Empty =
69  /// unrestricted. Non-empty = default-deny: every request URL's host
70  /// must match an entry (exact, or `*.suffix` which also matches the
71  /// bare apex) or the call throws before any network I/O. Enforced
72  /// natively in Rust here — there is no JS proxy/shim.
73  #[qjs(skip_trace)]
74  net: Arc<[String]>,
75}
76
77impl HttpClientJs {
78  #[must_use]
79  pub fn new(inner: Arc<HttpClient>) -> Self {
80    Self {
81      inner,
82      net: Arc::from([]),
83    }
84  }
85
86  /// Same underlying context, restricted to `net` hosts. Used to build
87  /// the per-tool `request` a plugin handler receives when its manifest
88  /// declares `allow.net`.
89  #[must_use]
90  pub fn with_net(inner: Arc<HttpClient>, net: Arc<[String]>) -> Self {
91    Self { inner, net }
92  }
93
94  /// The shared underlying context — lets the plugin dispatch wrap the
95  /// session's `request` with a net allow-list without re-creating it.
96  #[must_use]
97  pub fn inner_arc(&self) -> Arc<HttpClient> {
98    self.inner.clone()
99  }
100
101  /// The sandbox network policy for this binding: default-deny against
102  /// `self.net` when an `allow.net` list is present, and the cloud
103  /// instance-metadata endpoints blocked unconditionally (no automation
104  /// targets them). Enforced in core on the initial URL, every redirect
105  /// hop, and every resolved address.
106  pub(crate) fn net_guard(&self) -> NetGuard {
107    NetGuard {
108      allowlist: (!self.net.is_empty()).then(|| self.net.clone()),
109      block_metadata: true,
110      block_private: false,
111    }
112  }
113
114  /// Synchronous fast-fail on the initial URL so an allow-list
115  /// violation throws before any I/O (the same check runs again in core
116  /// for redirect hops). `Ok(())` when no allow-list, or the host
117  /// matches; otherwise a JS-thrown error.
118  fn guard(&self, url: &str) -> rquickjs::Result<()> {
119    net_check(&self.net, url).map_err(|m| rquickjs::Error::new_from_js_message("request", "Error", m))
120  }
121}
122
123/// Attach `g` to the per-request options (creating a default bag if the
124/// caller passed none) so core enforces the sandbox network policy.
125fn with_guard(opts: Option<RequestOptions>, g: NetGuard) -> RequestOptions {
126  let mut o = opts.unwrap_or_default();
127  o.net_guard = Some(g);
128  o
129}
130
131/// Default-deny host check shared by the `request` binding and the
132/// global `fetch` facade, delegating to the core allow-list semantics
133/// (one implementation, in Rust core). `Ok(())` when `net` is empty
134/// (unrestricted) or the URL's host matches an entry; otherwise an
135/// `Err(message)`. Synchronous, before any network I/O. Metadata /
136/// redirect-hop enforcement lives in core's [`NetGuard`].
137pub(crate) fn net_check(net: &[String], url: &str) -> Result<(), String> {
138  if net.is_empty() {
139    return Ok(());
140  }
141  let host = ferridriver::http_client::host_of(url)
142    .ok_or_else(|| format!("request to invalid/relative URL \"{url}\" is not permitted by allow.net"))?;
143  if ferridriver::http_client::host_allowed(&host, net) {
144    Ok(())
145  } else {
146    Err(format!("request host \"{host}\" is not in allow.net {net:?}"))
147  }
148}
149
150#[rquickjs::methods]
151impl HttpClientJs {
152  #[qjs(rename = "get")]
153  pub async fn get<'js>(
154    &self,
155    ctx: Ctx<'js>,
156    url: String,
157    options: Opt<Value<'js>>,
158  ) -> rquickjs::Result<HttpResponseJs> {
159    self.guard(&url)?;
160    let opts = Some(with_guard(parse_options(&ctx, options)?, self.net_guard()));
161    let resp = self.inner.get(&url, opts).await.into_js()?;
162    Ok(HttpResponseJs::new(resp))
163  }
164
165  #[qjs(rename = "post")]
166  pub async fn post<'js>(
167    &self,
168    ctx: Ctx<'js>,
169    url: String,
170    options: Opt<Value<'js>>,
171  ) -> rquickjs::Result<HttpResponseJs> {
172    self.guard(&url)?;
173    let opts = Some(with_guard(parse_options(&ctx, options)?, self.net_guard()));
174    let resp = self.inner.post(&url, opts).await.into_js()?;
175    Ok(HttpResponseJs::new(resp))
176  }
177
178  #[qjs(rename = "put")]
179  pub async fn put<'js>(
180    &self,
181    ctx: Ctx<'js>,
182    url: String,
183    options: Opt<Value<'js>>,
184  ) -> rquickjs::Result<HttpResponseJs> {
185    self.guard(&url)?;
186    let opts = Some(with_guard(parse_options(&ctx, options)?, self.net_guard()));
187    let resp = self.inner.put(&url, opts).await.into_js()?;
188    Ok(HttpResponseJs::new(resp))
189  }
190
191  #[qjs(rename = "delete")]
192  pub async fn delete<'js>(
193    &self,
194    ctx: Ctx<'js>,
195    url: String,
196    options: Opt<Value<'js>>,
197  ) -> rquickjs::Result<HttpResponseJs> {
198    self.guard(&url)?;
199    let opts = Some(with_guard(parse_options(&ctx, options)?, self.net_guard()));
200    let resp = self.inner.delete(&url, opts).await.into_js()?;
201    Ok(HttpResponseJs::new(resp))
202  }
203
204  #[qjs(rename = "patch")]
205  pub async fn patch<'js>(
206    &self,
207    ctx: Ctx<'js>,
208    url: String,
209    options: Opt<Value<'js>>,
210  ) -> rquickjs::Result<HttpResponseJs> {
211    self.guard(&url)?;
212    let opts = Some(with_guard(parse_options(&ctx, options)?, self.net_guard()));
213    let resp = self.inner.patch(&url, opts).await.into_js()?;
214    Ok(HttpResponseJs::new(resp))
215  }
216
217  #[qjs(rename = "head")]
218  pub async fn head<'js>(
219    &self,
220    ctx: Ctx<'js>,
221    url: String,
222    options: Opt<Value<'js>>,
223  ) -> rquickjs::Result<HttpResponseJs> {
224    self.guard(&url)?;
225    let opts = Some(with_guard(parse_options(&ctx, options)?, self.net_guard()));
226    let resp = self.inner.head(&url, opts).await.into_js()?;
227    Ok(HttpResponseJs::new(resp))
228  }
229
230  /// Generic fetch — `options` may include `method` via `headers` only; this
231  /// mirrors `RequestOptions` (no request overload for now — see the
232  /// `PLAYWRIGHT_COMPAT.md` gap for `HttpClient.fetch(Request, ...)`).
233  #[qjs(rename = "fetch")]
234  pub async fn fetch<'js>(
235    &self,
236    ctx: Ctx<'js>,
237    url: String,
238    options: Opt<Value<'js>>,
239  ) -> rquickjs::Result<HttpResponseJs> {
240    self.guard(&url)?;
241    let opts = Some(with_guard(parse_options(&ctx, options)?, self.net_guard()));
242    let resp = self.inner.fetch(&url, opts).await.into_js()?;
243    Ok(HttpResponseJs::new(resp))
244  }
245}
246
247// ── HttpResponseJs ────────────────────────────────────────────────────────────
248
249#[derive(JsLifetime, Trace)]
250#[rquickjs::class(rename = "HttpResponse")]
251pub struct HttpResponseJs {
252  #[qjs(skip_trace)]
253  inner: HttpResponse,
254}
255
256impl HttpResponseJs {
257  #[must_use]
258  pub fn new(inner: HttpResponse) -> Self {
259    Self { inner }
260  }
261
262  /// Clone of the wrapped core `HttpResponse` for cross-binding
263  /// consumers (used by `expect()` to lift a `HttpResponseJs` into an
264  /// `ApiResponse` assertion target).
265  #[must_use]
266  pub fn inner_clone(&self) -> HttpResponse {
267    self.inner.clone()
268  }
269}
270
271#[rquickjs::methods]
272impl HttpResponseJs {
273  #[qjs(rename = "status")]
274  pub fn status(&self) -> i32 {
275    i32::from(self.inner.status())
276  }
277
278  #[qjs(rename = "statusText")]
279  pub fn status_text(&self) -> String {
280    self.inner.status_text().to_string()
281  }
282
283  #[qjs(rename = "url")]
284  pub fn url(&self) -> String {
285    self.inner.url().to_string()
286  }
287
288  #[qjs(rename = "ok")]
289  pub fn ok(&self) -> bool {
290    self.inner.ok()
291  }
292
293  /// All response headers as an array of `{name, value}` tuples (Playwright's
294  /// `headersArray` shape).
295  #[qjs(rename = "headersArray")]
296  pub fn headers_array<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
297    let h = self.inner.headers();
298    let pairs: Vec<(&str, &str)> = h.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
299    crate::bindings::convert::name_value_array_to_js(&ctx, &pairs)
300  }
301
302  /// Value of a single header, or `null` if absent.
303  #[qjs(rename = "header")]
304  pub fn header(&self, name: String) -> Option<String> {
305    self.inner.header(&name).map(str::to_string)
306  }
307
308  /// Response body as UTF-8 text.
309  #[qjs(rename = "text")]
310  pub fn text(&self) -> rquickjs::Result<String> {
311    self.inner.text().into_js()
312  }
313
314  /// Response body parsed as JSON.
315  #[qjs(rename = "json")]
316  pub fn json<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
317    // Parse the raw body straight into a JS value with QuickJS's C JSON
318    // parser — no serde_json::Value middle allocation. `json_parse`
319    // does not touch the JS `JSON` global, so a reassigned
320    // `globalThis.JSON` cannot affect it.
321    let text = self.inner.text().into_js()?;
322    ctx.json_parse(text)
323  }
324}