Skip to main content

ferridriver_script/bindings/
fetch.rs

1//! A WHATWG-ish `fetch` + `Headers` + `Response`, so npm packages that
2//! expect `fetch` work. It is a thin surface over the SAME
3//! `ferridriver::http_client` core the Playwright-style `request`
4//! binding uses — one HTTP stack, one place the net policy applies. The
5//! ergonomic `request` API stays; this just adds the standard entry
6//! point.
7//!
8//! Web-standard names: `Headers`, `Request`, `Response` are the WHATWG
9//! classes (the Playwright page-network `Request`/`Response` are no
10//! longer globals — they were never globals in Playwright either, only
11//! return values). `Headers` is spec (lowercase + RFC7230 validate,
12//! value normalize, `, ` combine, separate `set-cookie` +
13//! `getSetCookie`, sorted real iterators, `forEach`). `Response` /
14//! `Request` are constructible with the spec accessors
15//! (`status`/`ok`/`redirected`/`type`/`bodyUsed`/`headers`/...),
16//! single-use bodies (`text`/`json`/`arrayBuffer`), `clone()`, and
17//! static `Response.json`/`error`/`redirect`. `fetch(url, { signal })`
18//! is wired to `AbortController`/`AbortSignal` (see [`super::abort`]):
19//! an already-aborted signal rejects before I/O and an in-flight abort
20//! drops the request future. `Response.body` is a `ReadableStream`
21//! that pulls chunks live off the socket (the body is NOT buffered;
22//! `text()`/`json()`/`arrayBuffer()` drain it on demand) — see
23//! [`super::streams`]. `Blob` and `FormData` (see [`super::blob`] /
24//! [`super::form_data`]) are accepted as bodies — a `Blob` sends its
25//! bytes + type, a `FormData` is serialized as `multipart/form-data`.
26//! Still a subset: `clone()` of a not-yet-read streamed `Response`
27//! throws (no stream tee); a `signal` on a `Request`
28//! instance is not yet forwarded (pass it via `init.signal`);
29//! `init.redirect` maps onto the per-request redirect
30//! cap (`manual`/`error` -> don't follow; a spec-exact opaque-redirect /
31//! rejection is not distinguishable through reqwest's per-request
32//! policy).
33//!
34//! Net policy: `fetch` is a facade over the SAME core a net-restricted
35//! tool's `request` wraps, so the `allow.net` allow-list must bind here
36//! too — otherwise a tool restricted to host X could reach anywhere via
37//! the global `fetch`. The per-tool allow-list lives in `NetPolicyUd`
38//! (VM userdata); `plugins::dispatch_tool` brackets each handler poll so
39//! the policy in effect is whichever tool's continuation is running, and
40//! `fetch` snapshots it synchronously at call time (before any I/O).
41
42use std::sync::{Arc, Mutex};
43use std::time::Duration;
44
45use ferridriver::http_client::{HttpClient, RequestOptions};
46use rquickjs::atom::PredefinedAtom;
47use rquickjs::function::{Func, Opt};
48use rquickjs::{Coerced, Ctx, IntoJs, Object, Value, class::Class, class::Trace};
49
50use crate::bindings::convert::json_to_js;
51use crate::bindings::http_client::net_check;
52
53/// Hard cap on a single buffered `fetch` body (`text`/`json`/
54/// `arrayBuffer`). QuickJS's `memory_limit` only bounds the JS heap;
55/// the drained body is a Rust allocation, so without this a script
56/// reading an unbounded/huge response could exhaust host memory well
57/// past the JS quota. Streaming via `Response.body` is unaffected.
58const MAX_FETCH_BODY_BYTES: usize = 64 * 1024 * 1024;
59
60/// Wall-clock bound on draining one streamed body, so a slow-loris /
61/// never-ending response cannot pin a session forever (the per-script
62/// interrupt-handler timeout does not fire during a native await).
63const FETCH_BODY_DRAIN_TIMEOUT: Duration = Duration::from_secs(120);
64
65/// Per-VM carrier of the *currently active* tool net allow-list. `None`
66/// (the resting state, and what the top-level script sees) means
67/// unrestricted; `Some(list)` means default-deny against `list`.
68///
69/// One cell per session VM, stored as rquickjs userdata at
70/// [`crate::engine::Session::create`]. `plugins::dispatch_tool` swaps the
71/// active policy in/out around every poll of a tool handler's future so
72/// nested and concurrently-interleaved tool calls each see their own
73/// declared `allow.net` — the swap is synchronous and the `fetch` guard
74/// reads the cell synchronously within the same poll, so single-threaded
75/// QuickJS execution makes it race-free without locking the JS thread.
76#[derive(Clone, Default)]
77pub(crate) struct NetPolicy(Arc<Mutex<Option<Arc<[String]>>>>);
78
79impl NetPolicy {
80  fn lock(&self) -> std::sync::MutexGuard<'_, Option<Arc<[String]>>> {
81    self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
82  }
83
84  /// Snapshot the active allow-list (cheap clone of the `Arc`).
85  pub(crate) fn current(&self) -> Option<Arc<[String]>> {
86    self.lock().clone()
87  }
88
89  /// Install `next` as the active policy, returning the previous value
90  /// so a poll-scoped guard can restore it.
91  pub(crate) fn swap(&self, next: Option<Arc<[String]>>) -> Option<Arc<[String]>> {
92    std::mem::replace(&mut *self.lock(), next)
93  }
94}
95
96/// rquickjs userdata wrapper for the session's [`NetPolicy`] cell.
97pub(crate) struct NetPolicyUd(pub(crate) NetPolicy);
98
99// SAFETY: holds only owned `'static` data (`Arc`/`Mutex`), no borrowed JS.
100#[allow(unsafe_code)]
101unsafe impl rquickjs::JsLifetime<'_> for NetPolicyUd {
102  type Changed<'to> = NetPolicyUd;
103}
104
105/// Snapshot the session's active net allow-list, if any. Called
106/// synchronously at `fetch()` invocation time so the snapshot reflects
107/// the tool whose continuation is currently executing.
108pub(crate) fn active_net(ctx: &Ctx<'_>) -> Option<Arc<[String]>> {
109  ctx.userdata::<NetPolicyUd>().and_then(|u| u.0.current())
110}
111
112/// WHATWG `Headers` (spec subset, no external deps): names are
113/// lowercased and RFC7230-validated, values are HTTP-whitespace
114/// normalized and validated, `append` combines same-name values with
115/// `, ` (`; ` for `cookie`) while `set-cookie` is kept as separate
116/// entries, `getSetCookie()` returns them all, and iteration is sorted
117/// by name. `keys`/`values`/`entries`/`[Symbol.iterator]` return real
118/// iterator objects.
119#[derive(Trace)]
120#[rquickjs::class(rename = "Headers")]
121pub struct HeadersJs {
122  /// Lowercased name -> spec-combined value. `set-cookie` may appear
123  /// multiple times (never combined).
124  #[qjs(skip_trace)]
125  pairs: Vec<(String, String)>,
126}
127
128#[derive(Clone, Copy)]
129enum IterKind {
130  Entries,
131  Keys,
132  Values,
133}
134
135/// A real JS iterator over a sorted header snapshot: `{ next(),
136/// [Symbol.iterator]() }`. Captures only `Send` data (the crate builds
137/// rquickjs with `parallel`, so `Func` closures must be `Send`); JS
138/// values are built from `ctx` inside `next`. `[Symbol.iterator]`
139/// returns an object sharing THIS cursor's position (`pos`), so it
140/// behaves as the spec's "return the iterator itself" — `[...it]` after
141/// a partial `next()` continues rather than restarting.
142fn make_header_iter<'js>(
143  ctx: &Ctx<'js>,
144  data: Arc<Vec<(String, String)>>,
145  pos: Arc<std::sync::atomic::AtomicUsize>,
146  kind: IterKind,
147) -> rquickjs::Result<Object<'js>> {
148  let it = Object::new(ctx.clone())?;
149  {
150    let data = data.clone();
151    let pos = pos.clone();
152    it.set(
153      PredefinedAtom::Next,
154      Func::from(move |ctx: Ctx<'js>| -> rquickjs::Result<Object<'js>> {
155        let r = Object::new(ctx.clone())?;
156        let i = pos.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
157        if let Some((k, v)) = data.get(i) {
158          let value: Value<'js> = match kind {
159            IterKind::Entries => {
160              let a = rquickjs::Array::new(ctx.clone())?;
161              a.set(0, k.clone())?;
162              a.set(1, v.clone())?;
163              a.into_value()
164            },
165            IterKind::Keys => k.clone().into_js(&ctx)?,
166            IterKind::Values => v.clone().into_js(&ctx)?,
167          };
168          r.set(PredefinedAtom::Value, value)?;
169          r.set(PredefinedAtom::Done, false)?;
170        } else {
171          pos.store(data.len(), std::sync::atomic::Ordering::Relaxed);
172          r.set(PredefinedAtom::Value, Value::new_undefined(ctx.clone()))?;
173          r.set(PredefinedAtom::Done, true)?;
174        }
175        Ok(r)
176      }),
177    )?;
178  }
179  {
180    let data = data.clone();
181    let pos = pos.clone();
182    it.set(
183      PredefinedAtom::SymbolIterator,
184      Func::from(move |ctx: Ctx<'js>| make_header_iter(&ctx, data.clone(), pos.clone(), kind)),
185    )?;
186  }
187  Ok(it)
188}
189
190/// Fresh iterator (cursor at 0) over a header snapshot.
191fn new_header_iter<'js>(ctx: &Ctx<'js>, data: Vec<(String, String)>, kind: IterKind) -> rquickjs::Result<Object<'js>> {
192  make_header_iter(
193    ctx,
194    Arc::new(data),
195    Arc::new(std::sync::atomic::AtomicUsize::new(0)),
196    kind,
197  )
198}
199
200/// RFC 7230 token: a valid header field name.
201fn is_header_name(name: &str) -> bool {
202  !name.is_empty()
203    && name.bytes().all(|b| {
204      matches!(b,
205        b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+'
206        | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~'
207        | b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z')
208    })
209}
210
211/// A valid (already-normalized) header field value: HTAB, SP, VCHAR,
212/// form-feed, or NBSP.
213fn is_header_value(value: &str) -> bool {
214  value
215    .chars()
216    .all(|c| c == '\t' || c == ' ' || ('\u{21}'..='\u{7E}').contains(&c) || c == '\u{0C}' || c == '\u{00A0}')
217}
218
219/// WHATWG header value normalization (WPT `headers-normalize`): strip
220/// leading/trailing SP/HTAB, drop bare CR/LF, and treat an obs-fold
221/// (CRLF + SP/HTAB) as a single space; runs of inner whitespace
222/// collapse to the last one seen.
223fn normalize_header_value(text: &str) -> String {
224  let input = text.as_bytes();
225  let mut out: Vec<u8> = Vec::with_capacity(input.len());
226  let mut read = 0;
227  while read < input.len() && (input[read] == b' ' || input[read] == b'\t') {
228    read += 1;
229  }
230  let mut pending: Option<u8> = None;
231  while read < input.len() {
232    match input[read] {
233      b'\r'
234        if read + 2 < input.len()
235          && input[read + 1] == b'\n'
236          && (input[read + 2] == b' ' || input[read + 2] == b'\t') =>
237      {
238        pending = Some(input[read + 2]);
239        read += 3;
240      },
241      b'\r' | b'\n' => read += 1,
242      b' ' | b'\t' => {
243        pending = Some(input[read]);
244        read += 1;
245      },
246      byte => {
247        if let Some(ws) = pending.take()
248          && !out.is_empty()
249        {
250          out.push(ws);
251        }
252        out.push(byte);
253        read += 1;
254      },
255    }
256  }
257  while matches!(out.last(), Some(b' ' | b'\t')) {
258    out.pop();
259  }
260  String::from_utf8_lossy(&out).into_owned()
261}
262
263/// WHATWG `Response` (spec subset). Constructible (`new Response(body?,
264/// init?)`), with `status`/`ok`/`statusText`/`url`/`redirected`/`type`/
265/// `bodyUsed`/`headers` accessors, `text`/`json`/`arrayBuffer` body
266/// readers (single-use: a second read throws, per spec), `clone()`
267/// (throws once the body is used), and static `Response.json`,
268/// `Response.error`, `Response.redirect`. This is the global `Response`
269/// (the Playwright page-network `Response` is no longer a global — it is
270/// only ever a return value, matching Playwright itself).
271#[derive(Trace)]
272#[rquickjs::class(rename = "Response")]
273pub struct FetchResponseJs {
274  #[qjs(skip_trace)]
275  status: u16,
276  #[qjs(skip_trace)]
277  status_text: String,
278  #[qjs(skip_trace)]
279  url: String,
280  #[qjs(skip_trace)]
281  headers: Vec<(String, String)>,
282  #[qjs(skip_trace)]
283  body: Vec<u8>,
284  #[qjs(skip_trace)]
285  redirected: bool,
286  #[qjs(skip_trace)]
287  type_: &'static str,
288  #[qjs(skip_trace)]
289  body_used: bool,
290  /// `Some` for a `fetch()` result: the live, not-yet-buffered
291  /// response. `text`/`json`/`arrayBuffer` drain it; `body` hands it to
292  /// a `ReadableStream`. `None` for a constructed/`Response.json/error/
293  /// redirect` (the bytes are in `body`).
294  #[qjs(skip_trace)]
295  net: Option<Arc<tokio::sync::Mutex<Option<ferridriver::http_client::HttpStreamResponse>>>>,
296}
297
298/// WHATWG `Request` (spec subset). Constructible (`new Request(input,
299/// init?)` where `input` is a URL string or another `Request`), with
300/// `url`/`method`/`headers`/`redirect`/`credentials`/`bodyUsed`
301/// accessors and `text`/`json`/`arrayBuffer`/`clone`. `signal` is
302/// accepted and stored but not yet wired (AbortController follow-up);
303/// `fetch` reads `url`/`method`/`headers`/`body`/`redirect` off a
304/// `Request` argument.
305#[derive(Trace)]
306#[rquickjs::class(rename = "Request")]
307pub struct FetchRequestJs {
308  #[qjs(skip_trace)]
309  url: String,
310  #[qjs(skip_trace)]
311  method: String,
312  #[qjs(skip_trace)]
313  headers: Vec<(String, String)>,
314  #[qjs(skip_trace)]
315  body: Vec<u8>,
316  #[qjs(skip_trace)]
317  redirect: String,
318  #[qjs(skip_trace)]
319  credentials: String,
320  #[qjs(skip_trace)]
321  body_used: bool,
322}
323
324// SAFETY: only owned `'static` data.
325#[allow(unsafe_code)]
326unsafe impl rquickjs::JsLifetime<'_> for HeadersJs {
327  type Changed<'to> = HeadersJs;
328}
329#[allow(unsafe_code)]
330unsafe impl rquickjs::JsLifetime<'_> for FetchResponseJs {
331  type Changed<'to> = FetchResponseJs;
332}
333#[allow(unsafe_code)]
334unsafe impl rquickjs::JsLifetime<'_> for FetchRequestJs {
335  type Changed<'to> = FetchRequestJs;
336}
337
338/// Extract a request/response body from a JS value, returning the bytes
339/// and the default `content-type` the body type implies (string ->
340/// `text/plain;charset=UTF-8`, object -> JSON; `Headers`/null/undefined
341/// -> none). Caller applies the content-type only if not already set.
342fn extract_body<'js>(ctx: &Ctx<'js>, v: &Value<'js>) -> (Vec<u8>, Option<&'static str>) {
343  if v.is_undefined() || v.is_null() {
344    return (Vec::new(), None);
345  }
346  if let Some(s) = v.as_string().and_then(|s| s.to_string().ok()) {
347    return (s.into_bytes(), Some("text/plain;charset=UTF-8"));
348  }
349  if v.is_object() {
350    if let Ok(j) = crate::bindings::convert::serde_from_js::<serde_json::Value>(ctx, v.clone()) {
351      return (j.to_string().into_bytes(), Some("application/json"));
352    }
353  }
354  (Vec::new(), None)
355}
356
357/// Parse a `Response`/`Request` `init` bag's `headers` into raw pairs
358/// and apply `default_ct` as `content-type` unless already present.
359fn init_headers(init: Option<&Object<'_>>, default_ct: Option<&'static str>) -> Vec<(String, String)> {
360  let mut pairs = init
361    .and_then(|o| o.get::<_, Value<'_>>("headers").ok())
362    .map(|v| header_pairs_from(&v))
363    .unwrap_or_default();
364  if let Some(ct) = default_ct
365    && !pairs.iter().any(|(k, _)| k == "content-type")
366  {
367    pairs.push(("content-type".to_string(), ct.to_string()));
368  }
369  pairs
370}
371
372/// Infallible best-effort extraction of `(name,value)` pairs from a JS
373/// value (`Headers` instance, `[[k,v],...]` sequence, or record) for
374/// the outbound request `headers` — invalid entries are skipped rather
375/// than thrown (the throwing path is the `Headers` constructor).
376fn header_pairs_from(v: &Value<'_>) -> Vec<(String, String)> {
377  if let Ok(h) = Class::<HeadersJs>::from_value(v) {
378    return h.borrow().pairs.clone();
379  }
380  let mut acc = HeadersJs { pairs: Vec::new() };
381  if let Some(arr) = v.as_array() {
382    for i in 0..arr.len() {
383      if let Ok(entry) = arr.get::<Value<'_>>(i)
384        && let Some(pair) = entry.as_array()
385        && pair.len() == 2
386        && let (Ok(k), Ok(val)) = (pair.get::<Coerced<String>>(0), pair.get::<Coerced<String>>(1))
387        && is_header_name(&k.0)
388      {
389        acc.append_normalized(k.0.to_ascii_lowercase(), normalize_header_value(&val.0));
390      }
391    }
392    return acc.pairs;
393  }
394  if let Some(obj) = v.as_object()
395    && let Ok(keys) = obj.keys::<String>().collect::<rquickjs::Result<Vec<_>>>()
396  {
397    for k in keys {
398      if let Ok(val) = obj.get::<_, Coerced<String>>(k.as_str())
399        && is_header_name(&k)
400      {
401        acc.append_normalized(k.to_ascii_lowercase(), normalize_header_value(&val.0));
402      }
403    }
404  }
405  acc.pairs
406}
407
408impl HeadersJs {
409  /// Spec "append": `set-cookie` is never combined; other repeats join
410  /// with `, ` (`; ` for `cookie`). `name_lc` must already be lowercased
411  /// and `value` normalized.
412  fn append_normalized(&mut self, name_lc: String, value: String) {
413    if name_lc == "set-cookie" {
414      self.pairs.push((name_lc, value));
415      return;
416    }
417    if let Some(i) = self.pairs.iter().position(|(k, _)| k == &name_lc) {
418      // WHATWG "Headers append": every non-`set-cookie` repeat combines
419      // with `, ` (0x2C 0x20). There is no per-name separator in the
420      // spec — the old `; ` for `cookie` was a non-standard deviation.
421      self.pairs[i].1 = format!("{}, {value}", self.pairs[i].1);
422    } else {
423      self.pairs.push((name_lc, value));
424    }
425  }
426
427  /// Build from known server/response pairs (lowercase + normalize +
428  /// spec-combine). Used by `FetchResponseJs::headers`.
429  pub(crate) fn from_pairs<I: IntoIterator<Item = (String, String)>>(it: I) -> Self {
430    let mut h = Self { pairs: Vec::new() };
431    for (k, v) in it {
432      h.append_normalized(k.to_ascii_lowercase(), normalize_header_value(&v));
433    }
434    h
435  }
436
437  /// Sorted-by-name snapshot for iteration (`sort_by` is stable, so
438  /// repeated `set-cookie` keep insertion order).
439  fn sorted(&self) -> Vec<(String, String)> {
440    let mut v = self.pairs.clone();
441    v.sort_by(|a, b| a.0.cmp(&b.0));
442    v
443  }
444
445  fn check_name(ctx: &Ctx<'_>, name: &str) -> rquickjs::Result<String> {
446    if is_header_name(name) {
447      Ok(name.to_ascii_lowercase())
448    } else {
449      Err(rquickjs::Exception::throw_type(
450        ctx,
451        &format!("Invalid header name: {name:?}"),
452      ))
453    }
454  }
455
456  fn check_value(ctx: &Ctx<'_>, raw: &str) -> rquickjs::Result<String> {
457    let v = normalize_header_value(raw);
458    if is_header_value(&v) {
459      Ok(v)
460    } else {
461      Err(rquickjs::Exception::throw_type(ctx, "Invalid header value"))
462    }
463  }
464
465  fn fill_from_value<'js>(&mut self, ctx: &Ctx<'js>, v: &Value<'js>) -> rquickjs::Result<()> {
466    if let Ok(other) = Class::<HeadersJs>::from_value(v) {
467      for (k, val) in &other.borrow().pairs {
468        self.append_normalized(k.clone(), val.clone());
469      }
470      return Ok(());
471    }
472    if let Some(arr) = v.as_array() {
473      for i in 0..arr.len() {
474        let entry = arr.get::<Value<'js>>(i)?;
475        let pair = entry
476          .as_array()
477          .ok_or_else(|| rquickjs::Exception::throw_type(ctx, "Header init entry is not a [name, value] pair"))?;
478        if pair.len() != 2 {
479          return Err(rquickjs::Exception::throw_type(
480            ctx,
481            "Header init entry must be a [name, value] pair",
482          ));
483        }
484        let name = Self::check_name(ctx, &pair.get::<Coerced<String>>(0)?.0)?;
485        let value = Self::check_value(ctx, &pair.get::<Coerced<String>>(1)?.0)?;
486        self.append_normalized(name, value);
487      }
488      return Ok(());
489    }
490    if let Some(obj) = v.as_object() {
491      for k in obj.keys::<String>().collect::<rquickjs::Result<Vec<_>>>()? {
492        let name = Self::check_name(ctx, &k)?;
493        let value = Self::check_value(ctx, &obj.get::<_, Coerced<String>>(k.as_str())?.0)?;
494        self.append_normalized(name, value);
495      }
496    }
497    Ok(())
498  }
499}
500
501#[rquickjs::methods]
502impl HeadersJs {
503  #[qjs(constructor)]
504  pub fn new<'js>(ctx: Ctx<'js>, init: Opt<Value<'js>>) -> rquickjs::Result<Self> {
505    let mut h = Self { pairs: Vec::new() };
506    if let Some(v) = init.0 {
507      if v.is_null() || v.is_number() {
508        return Err(rquickjs::Exception::throw_type(
509          &ctx,
510          "Failed to construct 'Headers': invalid init",
511        ));
512      }
513      if !v.is_undefined() {
514        h.fill_from_value(&ctx, &v)?;
515      }
516    }
517    Ok(h)
518  }
519
520  #[qjs(rename = "append")]
521  pub fn append(&mut self, ctx: Ctx<'_>, name: String, value: Coerced<String>) -> rquickjs::Result<()> {
522    let n = Self::check_name(&ctx, &name)?;
523    let v = Self::check_value(&ctx, &value.0)?;
524    self.append_normalized(n, v);
525    Ok(())
526  }
527
528  #[qjs(rename = "set")]
529  pub fn set(&mut self, ctx: Ctx<'_>, name: String, value: Coerced<String>) -> rquickjs::Result<()> {
530    let n = Self::check_name(&ctx, &name)?;
531    let v = Self::check_value(&ctx, &value.0)?;
532    self.pairs.retain(|(k, _)| k != &n);
533    self.pairs.push((n, v));
534    Ok(())
535  }
536
537  #[qjs(rename = "get")]
538  pub fn get<'js>(&self, ctx: Ctx<'js>, name: String) -> rquickjs::Result<Value<'js>> {
539    let n = Self::check_name(&ctx, &name)?;
540    let matches: Vec<&str> = self
541      .pairs
542      .iter()
543      .filter(|(k, _)| k == &n)
544      .map(|(_, v)| v.as_str())
545      .collect();
546    if matches.is_empty() {
547      Ok(Value::new_null(ctx))
548    } else {
549      matches.join(", ").into_js(&ctx)
550    }
551  }
552
553  #[qjs(rename = "getSetCookie")]
554  pub fn get_set_cookie(&self) -> Vec<String> {
555    self
556      .pairs
557      .iter()
558      .filter(|(k, _)| k == "set-cookie")
559      .map(|(_, v)| v.clone())
560      .collect()
561  }
562
563  #[qjs(rename = "has")]
564  pub fn has(&self, ctx: Ctx<'_>, name: String) -> rquickjs::Result<bool> {
565    let n = Self::check_name(&ctx, &name)?;
566    Ok(self.pairs.iter().any(|(k, _)| k == &n))
567  }
568
569  #[qjs(rename = "delete")]
570  pub fn delete(&mut self, ctx: Ctx<'_>, name: String) -> rquickjs::Result<()> {
571    let n = Self::check_name(&ctx, &name)?;
572    self.pairs.retain(|(k, _)| k != &n);
573    Ok(())
574  }
575
576  #[qjs(rename = "entries")]
577  pub fn entries<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Object<'js>> {
578    new_header_iter(&ctx, self.sorted(), IterKind::Entries)
579  }
580
581  #[qjs(rename = "keys")]
582  pub fn keys<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Object<'js>> {
583    new_header_iter(&ctx, self.sorted(), IterKind::Keys)
584  }
585
586  #[qjs(rename = "values")]
587  pub fn values<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Object<'js>> {
588    new_header_iter(&ctx, self.sorted(), IterKind::Values)
589  }
590
591  #[qjs(rename = PredefinedAtom::SymbolIterator)]
592  pub fn js_iterator<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Object<'js>> {
593    new_header_iter(&ctx, self.sorted(), IterKind::Entries)
594  }
595
596  #[qjs(rename = "forEach")]
597  pub fn for_each(&self, cb: rquickjs::Function<'_>) -> rquickjs::Result<()> {
598    for (k, v) in self.sorted() {
599      cb.call::<_, ()>((v, k))?;
600    }
601    Ok(())
602  }
603}
604
605impl FetchResponseJs {
606  /// The `Response` a `fetch()` resolves to: status/headers are known,
607  /// the body streams from `stream` (not buffered).
608  fn from_stream(
609    status: u16,
610    status_text: String,
611    url: String,
612    headers: Vec<(String, String)>,
613    redirected: bool,
614    stream: ferridriver::http_client::HttpStreamResponse,
615  ) -> Self {
616    Self {
617      status,
618      status_text,
619      url,
620      headers,
621      body: Vec::new(),
622      redirected,
623      type_: "basic",
624      body_used: false,
625      net: Some(Arc::new(tokio::sync::Mutex::new(Some(stream)))),
626    }
627  }
628
629  /// WHATWG "consume body": a second read is a `TypeError`. Drains the
630  /// live response to completion when this is a streamed `fetch` body,
631  /// else returns the in-memory bytes.
632  async fn consume(&mut self, ctx: &Ctx<'_>) -> rquickjs::Result<Vec<u8>> {
633    if self.body_used {
634      return Err(rquickjs::Exception::throw_type(ctx, "Body has already been consumed"));
635    }
636    self.body_used = true;
637    if let Some(net) = &self.net {
638      let mut guard = net.lock().await;
639      let mut out = Vec::new();
640      if let Some(resp) = guard.as_mut() {
641        let drained = tokio::time::timeout(FETCH_BODY_DRAIN_TIMEOUT, async {
642          while let Some(chunk) = resp.chunk().await.map_err(|e| e.to_string())? {
643            if out.len() + chunk.len() > MAX_FETCH_BODY_BYTES {
644              return Err(format!("response body exceeded {MAX_FETCH_BODY_BYTES} bytes"));
645            }
646            out.extend_from_slice(&chunk);
647          }
648          Ok::<(), String>(())
649        })
650        .await;
651        // Free the socket regardless of outcome so a rejected read does
652        // not leave the connection (and its buffers) pinned.
653        *guard = None;
654        match drained {
655          Ok(Ok(())) => {},
656          Ok(Err(msg)) => return Err(rquickjs::Exception::throw_type(ctx, &msg)),
657          Err(_) => {
658            return Err(rquickjs::Exception::throw_type(ctx, "response body read timed out"));
659          },
660        }
661        return Ok(out);
662      }
663      *guard = None;
664      return Ok(out);
665    }
666    Ok(std::mem::take(&mut self.body))
667  }
668}
669
670#[rquickjs::methods]
671impl FetchResponseJs {
672  /// `new Response(body?, init?)` — `init`: `{ status?, statusText?,
673  /// headers? }`. `status` outside 200..=599 is a `RangeError`.
674  #[qjs(constructor)]
675  pub fn new<'js>(ctx: Ctx<'js>, body: Opt<Value<'js>>, init: Opt<Object<'js>>) -> rquickjs::Result<Self> {
676    let init = init.0;
677    let status = match init.as_ref().and_then(|o| o.get::<_, i64>("status").ok()) {
678      Some(s) if !(200..=599).contains(&s) => {
679        return Err(rquickjs::Exception::throw_range(
680          &ctx,
681          "Failed to construct 'Response': status is outside the range [200, 599]",
682        ));
683      },
684      Some(s) => s as u16,
685      None => 200,
686    };
687    let status_text = init
688      .as_ref()
689      .and_then(|o| o.get::<_, String>("statusText").ok())
690      .unwrap_or_default();
691    // WHATWG: a null-body status (204/205/304) with a non-null body is
692    // a `TypeError`.
693    let has_body = body.0.as_ref().is_some_and(|v| !v.is_null() && !v.is_undefined());
694    if has_body && matches!(status, 204 | 205 | 304) {
695      return Err(rquickjs::Exception::throw_type(
696        &ctx,
697        "Failed to construct 'Response': Response with null body status cannot have body",
698      ));
699    }
700    let (bytes, default_ct) = body.0.map_or((Vec::new(), None), |v| extract_body(&ctx, &v));
701    Ok(Self {
702      status,
703      status_text,
704      url: String::new(),
705      headers: init_headers(init.as_ref(), default_ct),
706      body: bytes,
707      redirected: false,
708      type_: "default",
709      body_used: false,
710      net: None,
711    })
712  }
713
714  /// `Response.json(data, init?)` — JSON body + `application/json`.
715  #[qjs(static, rename = "json")]
716  pub fn json_static<'js>(ctx: Ctx<'js>, data: Value<'js>, init: Opt<Object<'js>>) -> rquickjs::Result<Self> {
717    let init = init.0;
718    let json: serde_json::Value = crate::bindings::convert::serde_from_js(&ctx, data)?;
719    let status = init
720      .as_ref()
721      .and_then(|o| o.get::<_, i64>("status").ok())
722      .unwrap_or(200) as u16;
723    let status_text = init
724      .as_ref()
725      .and_then(|o| o.get::<_, String>("statusText").ok())
726      .unwrap_or_default();
727    Ok(Self {
728      status,
729      status_text,
730      url: String::new(),
731      headers: init_headers(init.as_ref(), Some("application/json")),
732      body: json.to_string().into_bytes(),
733      redirected: false,
734      type_: "default",
735      body_used: false,
736      net: None,
737    })
738  }
739
740  /// `Response.error()` — a network-error response (status 0).
741  #[qjs(static, rename = "error")]
742  pub fn error() -> Self {
743    Self {
744      status: 0,
745      status_text: String::new(),
746      url: String::new(),
747      headers: Vec::new(),
748      body: Vec::new(),
749      redirected: false,
750      type_: "error",
751      body_used: false,
752      net: None,
753    }
754  }
755
756  /// `Response.redirect(url, status=302)` — status must be a redirect
757  /// code (301/302/303/307/308) or it is a `RangeError`.
758  #[qjs(static, rename = "redirect")]
759  pub fn redirect(ctx: Ctx<'_>, url: String, status: Opt<i64>) -> rquickjs::Result<Self> {
760    let status = status.0.unwrap_or(302);
761    if ![301, 302, 303, 307, 308].contains(&status) {
762      return Err(rquickjs::Exception::throw_range(&ctx, "Invalid redirect status code"));
763    }
764    Ok(Self {
765      status: status as u16,
766      status_text: String::new(),
767      url: String::new(),
768      headers: vec![("location".to_string(), url)],
769      body: Vec::new(),
770      redirected: false,
771      type_: "default",
772      body_used: false,
773      net: None,
774    })
775  }
776
777  #[qjs(get, rename = "status")]
778  pub fn status(&self) -> u16 {
779    self.status
780  }
781  #[qjs(get, rename = "ok")]
782  pub fn ok(&self) -> bool {
783    (200..300).contains(&self.status)
784  }
785  #[qjs(get, rename = "statusText")]
786  pub fn status_text(&self) -> String {
787    self.status_text.clone()
788  }
789  #[qjs(get, rename = "url")]
790  pub fn url(&self) -> String {
791    self.url.clone()
792  }
793  #[qjs(get, rename = "redirected")]
794  pub fn redirected(&self) -> bool {
795    self.redirected
796  }
797  #[qjs(get, rename = "type")]
798  pub fn type_(&self) -> String {
799    self.type_.to_string()
800  }
801  #[qjs(get, rename = "bodyUsed")]
802  pub fn body_used(&self) -> bool {
803    self.body_used
804  }
805
806  #[qjs(get, rename = "headers")]
807  pub fn headers<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Class<'js, HeadersJs>> {
808    Class::instance(ctx, HeadersJs::from_pairs(self.headers.iter().cloned()))
809  }
810
811  /// `Response.body` — a `ReadableStream`. For a streamed `fetch`
812  /// result the stream pulls chunks live off the socket (the body is
813  /// NOT buffered); for a constructed `Response` it is the in-memory
814  /// bytes. Empty/done once the body was consumed by
815  /// `text()`/`json()`/`arrayBuffer()`.
816  #[qjs(get, rename = "body")]
817  pub fn body<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Class<'js, crate::bindings::streams::ReadableStreamJs>> {
818    let stream = match &self.net {
819      Some(net) => crate::bindings::streams::ReadableStreamJs::from_net(net.clone()),
820      None => crate::bindings::streams::ReadableStreamJs::from_bytes(self.body.clone()),
821    };
822    Class::instance(ctx, stream)
823  }
824
825  #[qjs(rename = "text")]
826  pub async fn text(&mut self, ctx: Ctx<'_>) -> rquickjs::Result<String> {
827    let b = self.consume(&ctx).await?;
828    Ok(String::from_utf8_lossy(&b).into_owned())
829  }
830
831  #[qjs(rename = "json")]
832  pub async fn json<'js>(&mut self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
833    let b = self.consume(&ctx).await?;
834    let v: serde_json::Value = serde_json::from_slice(&b)
835      .map_err(|e| rquickjs::Error::new_from_js_message("Response.json", "Error", e.to_string()))?;
836    json_to_js(&ctx, &v)
837  }
838
839  #[qjs(rename = "arrayBuffer")]
840  pub async fn array_buffer<'js>(&mut self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
841    let b = self.consume(&ctx).await?;
842    rquickjs::ArrayBuffer::new(ctx.clone(), b).map(rquickjs::ArrayBuffer::into_value)
843  }
844
845  #[qjs(rename = "clone")]
846  pub fn clone_(&self, ctx: Ctx<'_>) -> rquickjs::Result<Self> {
847    if self.body_used {
848      return Err(rquickjs::Exception::throw_type(&ctx, "Cannot clone a used Response"));
849    }
850    if self.net.is_some() {
851      // Cloning would require teeing the live stream; not supported in
852      // this subset (the body has not been buffered).
853      return Err(rquickjs::Exception::throw_type(
854        &ctx,
855        "Cannot clone a streaming Response (body is not buffered)",
856      ));
857    }
858    Ok(Self {
859      status: self.status,
860      status_text: self.status_text.clone(),
861      url: self.url.clone(),
862      headers: self.headers.clone(),
863      body: self.body.clone(),
864      redirected: self.redirected,
865      type_: self.type_,
866      body_used: false,
867      net: None,
868    })
869  }
870}
871
872#[rquickjs::methods]
873impl FetchRequestJs {
874  /// `new Request(input, init?)` — `input` is a URL string or another
875  /// `Request`; `init`: `{ method?, headers?, body?, redirect?,
876  /// credentials?, signal? }` (`signal` accepted, not yet wired).
877  #[qjs(constructor)]
878  pub fn new<'js>(ctx: Ctx<'js>, input: Value<'js>, init: Opt<Object<'js>>) -> Self {
879    let init = init.0;
880    let mut req = if let Ok(other) = Class::<FetchRequestJs>::from_value(&input) {
881      let o = other.borrow();
882      Self {
883        url: o.url.clone(),
884        method: o.method.clone(),
885        headers: o.headers.clone(),
886        body: o.body.clone(),
887        redirect: o.redirect.clone(),
888        credentials: o.credentials.clone(),
889        body_used: false,
890      }
891    } else {
892      Self {
893        url: input.as_string().and_then(|s| s.to_string().ok()).unwrap_or_default(),
894        method: "GET".to_string(),
895        headers: Vec::new(),
896        body: Vec::new(),
897        redirect: "follow".to_string(),
898        credentials: "same-origin".to_string(),
899        body_used: false,
900      }
901    };
902    if let Some(o) = init.as_ref() {
903      if let Ok(m) = o.get::<_, String>("method") {
904        req.method = m.to_ascii_uppercase();
905      }
906      if let Ok(r) = o.get::<_, String>("redirect") {
907        req.redirect = r;
908      }
909      if let Ok(c) = o.get::<_, String>("credentials") {
910        req.credentials = c;
911      }
912      let (bytes, default_ct) = o
913        .get::<_, Value<'_>>("body")
914        .ok()
915        .map_or((Vec::new(), None), |v| extract_body(&ctx, &v));
916      if !bytes.is_empty() {
917        req.body = bytes;
918      }
919      req.headers = {
920        let mut h = init_headers(init.as_ref(), default_ct);
921        if h.is_empty() {
922          std::mem::take(&mut req.headers)
923        } else {
924          if let Ok(existing) = Class::<FetchRequestJs>::from_value(&input) {
925            for (k, v) in &existing.borrow().headers {
926              if !h.iter().any(|(hk, _)| hk == k) {
927                h.push((k.clone(), v.clone()));
928              }
929            }
930          }
931          h
932        }
933      };
934    }
935    req
936  }
937
938  #[qjs(get, rename = "url")]
939  pub fn url(&self) -> String {
940    self.url.clone()
941  }
942  #[qjs(get, rename = "method")]
943  pub fn method(&self) -> String {
944    self.method.clone()
945  }
946  #[qjs(get, rename = "redirect")]
947  pub fn redirect(&self) -> String {
948    self.redirect.clone()
949  }
950  #[qjs(get, rename = "credentials")]
951  pub fn credentials(&self) -> String {
952    self.credentials.clone()
953  }
954  #[qjs(get, rename = "bodyUsed")]
955  pub fn body_used(&self) -> bool {
956    self.body_used
957  }
958  #[qjs(get, rename = "headers")]
959  pub fn headers<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Class<'js, HeadersJs>> {
960    Class::instance(ctx, HeadersJs::from_pairs(self.headers.iter().cloned()))
961  }
962
963  #[qjs(rename = "text")]
964  pub fn text(&mut self, ctx: Ctx<'_>) -> rquickjs::Result<String> {
965    if self.body_used {
966      return Err(rquickjs::Exception::throw_type(&ctx, "Body has already been consumed"));
967    }
968    self.body_used = true;
969    Ok(String::from_utf8_lossy(&std::mem::take(&mut self.body)).into_owned())
970  }
971
972  #[qjs(rename = "json")]
973  pub fn json<'js>(&mut self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
974    if self.body_used {
975      return Err(rquickjs::Exception::throw_type(&ctx, "Body has already been consumed"));
976    }
977    self.body_used = true;
978    let v: serde_json::Value = serde_json::from_slice(&std::mem::take(&mut self.body))
979      .map_err(|e| rquickjs::Error::new_from_js_message("Request.json", "Error", e.to_string()))?;
980    json_to_js(&ctx, &v)
981  }
982
983  #[qjs(rename = "clone")]
984  pub fn clone_(&self, ctx: Ctx<'_>) -> rquickjs::Result<Self> {
985    if self.body_used {
986      return Err(rquickjs::Exception::throw_type(&ctx, "Cannot clone a used Request"));
987    }
988    Ok(Self {
989      url: self.url.clone(),
990      method: self.method.clone(),
991      headers: self.headers.clone(),
992      body: self.body.clone(),
993      redirect: self.redirect.clone(),
994      credentials: self.credentials.clone(),
995      body_used: false,
996    })
997  }
998}
999
1000/// Install `globalThis.fetch`, bound to `cx` (the session's HTTP
1001/// context — same one the `request` binding wraps). Net policy that
1002/// applies to `request` applies here because it is the same core.
1003pub fn install(ctx: &Ctx<'_>, cx: Arc<HttpClient>) -> rquickjs::Result<()> {
1004  // Forward into a generic fn so `Ctx`/`Value`/return share one `'js`
1005  // (an inline closure gives each arg its own lifetime and the returned
1006  // promise Value cannot be proven to outlive them) — same pattern as
1007  // the plugin dispatch closure.
1008  let f = rquickjs::Function::new(ctx.clone(), move |ctx, input, init| {
1009    do_fetch(ctx, input, init, cx.clone())
1010  })?;
1011  ctx.globals().set("fetch", f)?;
1012  Ok(())
1013}
1014
1015fn do_fetch<'js>(
1016  ctx: Ctx<'js>,
1017  input: Value<'js>,
1018  init: Opt<Object<'js>>,
1019  cx: Arc<HttpClient>,
1020) -> rquickjs::Result<Value<'js>> {
1021  {
1022    // `input` may be a URL string, a `Request` instance, or an object
1023    // with a `url`. A `Request` seeds method/headers/body/redirect; the
1024    // `init` bag overrides each.
1025    let req = Class::<FetchRequestJs>::from_value(&input).ok();
1026    let url = req
1027      .as_ref()
1028      .map(|r| r.borrow().url.clone())
1029      .or_else(|| input.as_string().and_then(|s| s.to_string().ok()))
1030      .or_else(|| input.as_object().and_then(|o| o.get::<_, String>("url").ok()))
1031      .unwrap_or_default();
1032    // Snapshot the net policy NOW (synchronously, while this `fetch()`
1033    // call is still on the calling tool's stack) so the allow-list
1034    // checked below is the caller's, not whatever runs by the time the
1035    // request future is polled.
1036    let net = active_net(&ctx);
1037    let init = init.0;
1038    let method = init
1039      .as_ref()
1040      .and_then(|o| o.get::<_, String>("method").ok())
1041      .or_else(|| req.as_ref().map(|r| r.borrow().method.clone()));
1042    let mut headers_vec: Vec<(String, String)> = init
1043      .as_ref()
1044      .and_then(|o| o.get::<_, Value<'_>>("headers").ok())
1045      .map(|v| header_pairs_from(&v))
1046      .or_else(|| req.as_ref().map(|r| r.borrow().headers.clone()))
1047      .unwrap_or_default();
1048    // body: string -> raw; `Blob` -> bytes (+ its type); `FormData` ->
1049    // multipart (content-type MUST be the boundary one); other object
1050    // -> JSON; else a Request's own body. `body_ct` is the content-type
1051    // the body implies (FormData overrides, Blob only fills if absent).
1052    let body_val = init.as_ref().and_then(|o| o.get::<_, Value<'_>>("body").ok());
1053    let (data, json_data, body_ct, force_ct) = if let Some(b) = &body_val {
1054      if let Some(s) = b.as_string().and_then(|s| s.to_string().ok()) {
1055        (Some(s.into_bytes()), None, None, false)
1056      } else if let Ok(fd) = Class::<crate::bindings::form_data::FormDataJs>::from_value(b) {
1057        let (bytes, ct) = fd.borrow().to_multipart();
1058        (Some(bytes), None, Some(ct), true)
1059      } else if let Some((bytes, ct)) = crate::bindings::blob::BlobJs::from_js_blob(b) {
1060        (Some(bytes), None, (!ct.is_empty()).then_some(ct), false)
1061      } else if b.is_object() {
1062        let j: Option<serde_json::Value> = crate::bindings::convert::serde_from_js(&ctx, b.clone()).ok();
1063        (None, j, None, false)
1064      } else {
1065        (None, None, None, false)
1066      }
1067    } else {
1068      match req.as_ref().map(|r| r.borrow().body.clone()) {
1069        Some(b) if !b.is_empty() => (Some(b), None, None, false),
1070        _ => (None, None, None, false),
1071      }
1072    };
1073    if let Some(ct) = body_ct {
1074      let has_ct = headers_vec.iter().any(|(k, _)| k == "content-type");
1075      if force_ct {
1076        headers_vec.retain(|(k, _)| k != "content-type");
1077        headers_vec.push(("content-type".to_string(), ct));
1078      } else if !has_ct {
1079        headers_vec.push(("content-type".to_string(), ct));
1080      }
1081    }
1082    let headers = (!headers_vec.is_empty()).then_some(headers_vec);
1083    // `init.redirect` (or the Request's) maps onto the per-request
1084    // redirect cap: "follow" (default) keeps the client default;
1085    // "manual"/"error" pin 0 so a 3xx is returned rather than followed.
1086    // (A spec-exact "manual" opaque-redirect / "error" rejection is not
1087    // distinguishable through reqwest's per-request policy; the 3xx is
1088    // surfaced instead. Documented subset.)
1089    let redirect = init
1090      .as_ref()
1091      .and_then(|o| o.get::<_, String>("redirect").ok())
1092      .or_else(|| req.as_ref().map(|r| r.borrow().redirect.clone()));
1093    let max_redirects = match redirect.as_deref() {
1094      Some("manual" | "error") => Some(0),
1095      _ => None,
1096    };
1097    // `init.signal` (an `AbortSignal`): grab its native channel so the
1098    // request future can be dropped when it aborts.
1099    let signal = init
1100      .as_ref()
1101      .and_then(|o| o.get::<_, Value<'_>>("signal").ok())
1102      .and_then(|v| Class::<crate::bindings::abort::AbortSignalJs<'js>>::from_value(&v).ok())
1103      .map(|s| crate::bindings::abort::AbortSignalJs::inner_of(&s));
1104    let promised = rquickjs::promise::Promised::from(async move {
1105      if let Some(list) = net.as_deref()
1106        && let Err(msg) = net_check(list, &url)
1107      {
1108        return Err(rquickjs::Error::new_from_js_message("fetch", "Error", msg));
1109      }
1110      let opts = RequestOptions {
1111        method,
1112        headers,
1113        data,
1114        json_data,
1115        max_redirects,
1116        // Same sandbox policy as the `request` binding (one core
1117        // implementation): the active `allow.net` list is enforced on
1118        // the initial URL AND every redirect hop, and the cloud
1119        // metadata endpoints are blocked for every script `fetch`
1120        // regardless of allow-list (closes the default-open SSRF).
1121        net_guard: Some(ferridriver::http_client::NetGuard {
1122          allowlist: net.clone(),
1123          block_metadata: true,
1124          block_private: false,
1125        }),
1126        ..Default::default()
1127      };
1128      if let Some(sig) = &signal
1129        && sig.is_aborted()
1130      {
1131        return Err(rquickjs::Error::new_from_js_message(
1132          "fetch",
1133          "AbortError",
1134          sig.reason_message(),
1135        ));
1136      }
1137      // Streamed: status/headers resolve here, the body is pulled
1138      // incrementally later (via Response.body / text() / json()).
1139      let fut = cx.fetch_stream(&url, Some(opts));
1140      let resp = match &signal {
1141        Some(sig) => {
1142          tokio::select! {
1143            r = fut => r.map_err(|e| rquickjs::Error::new_from_js_message("fetch", "Error", e.to_string()))?,
1144            () = sig.aborted() => {
1145              return Err(rquickjs::Error::new_from_js_message("fetch", "AbortError", sig.reason_message()));
1146            }
1147          }
1148        },
1149        None => fut
1150          .await
1151          .map_err(|e| rquickjs::Error::new_from_js_message("fetch", "Error", e.to_string()))?,
1152      };
1153      let final_url = resp.url().to_string();
1154      // Best-effort: a differing final URL means at least one hop was
1155      // followed (the core does not yet expose a redirect count).
1156      let redirected = !final_url.is_empty() && final_url != url;
1157      let out = FetchResponseJs::from_stream(
1158        resp.status(),
1159        resp.status_text().to_string(),
1160        final_url,
1161        resp.headers().to_vec(),
1162        redirected,
1163        resp,
1164      );
1165      Ok::<_, rquickjs::Error>(out)
1166    });
1167    promised.into_js(&ctx)
1168  }
1169}