Skip to main content

ferridriver_script/bindings/
webapi.rs

1//! Native web-platform globals: `TextEncoder` / `TextDecoder` / `URL`
2//! plus `queueMicrotask` / `btoa` / `atob`.
3//!
4//! These are real `#[rquickjs::class]` bindings (Rust is the source of
5//! truth), not JS shims dispatching to hidden `__ferri*` helpers.
6//! `URL` is backed by the `url` crate; `URLSearchParams` comes from
7//! `rquickjs-extra-url` (installed separately) and is instantiated here
8//! via its registered global constructor — no string `eval`.
9
10use base64::Engine as _;
11use base64::engine::GeneralPurpose;
12use base64::engine::general_purpose::GeneralPurposeConfig;
13use rquickjs::function::{Constructor, Func, Opt};
14use rquickjs::{Class, Ctx, Function, JsLifetime, TypedArray, Value, class::Trace};
15
16/// TextEncoder — UTF-8 only, matching the WHATWG default.
17#[derive(Trace, JsLifetime, Default)]
18#[rquickjs::class(rename = "TextEncoder")]
19pub struct TextEncoder {}
20
21#[rquickjs::methods]
22impl TextEncoder {
23  #[qjs(constructor)]
24  pub fn new() -> Self {
25    Self {}
26  }
27
28  #[qjs(get, rename = "encoding")]
29  pub fn encoding(&self) -> &'static str {
30    "utf-8"
31  }
32
33  pub fn encode<'js>(&self, ctx: Ctx<'js>, input: Opt<String>) -> rquickjs::Result<TypedArray<'js, u8>> {
34    TypedArray::new(ctx, input.0.unwrap_or_default().into_bytes())
35  }
36}
37
38/// TextDecoder — UTF-8, lossy (matches `fatal: false`, the default).
39#[derive(Trace, JsLifetime)]
40#[rquickjs::class(rename = "TextDecoder")]
41pub struct TextDecoder {
42  encoding: String,
43}
44
45#[rquickjs::methods]
46impl TextDecoder {
47  #[qjs(constructor)]
48  pub fn new(label: Opt<String>) -> Self {
49    // We only implement utf-8; report it back regardless of label
50    // rather than pretending to honour an unsupported encoding.
51    let _ = label;
52    Self {
53      encoding: "utf-8".to_string(),
54    }
55  }
56
57  #[qjs(get, rename = "encoding")]
58  pub fn encoding(&self) -> String {
59    self.encoding.clone()
60  }
61
62  pub fn decode(&self, input: Opt<Value<'_>>) -> rquickjs::Result<String> {
63    let Some(v) = input.0 else {
64      return Ok(String::new());
65    };
66    let bytes = value_to_bytes(&v)?;
67    Ok(String::from_utf8_lossy(&bytes).into_owned())
68  }
69}
70
71/// Extract a byte buffer from a `Uint8Array`/`TypedArray`, an
72/// `ArrayBuffer`, or an array-like of numbers.
73fn value_to_bytes(v: &Value<'_>) -> rquickjs::Result<Vec<u8>> {
74  if let Ok(ta) = TypedArray::<u8>::from_value(v.clone())
75    && let Some(b) = ta.as_bytes()
76  {
77    return Ok(b.to_vec());
78  }
79  if let Some(obj) = v.as_object()
80    && let Some(buf) = rquickjs::ArrayBuffer::from_object(obj.clone())
81    && let Some(b) = buf.as_bytes()
82  {
83    return Ok(b.to_vec());
84  }
85  if let Some(arr) = v.as_array() {
86    let mut out = Vec::with_capacity(arr.len());
87    for item in arr.iter::<u8>() {
88      out.push(item?);
89    }
90    return Ok(out);
91  }
92  Ok(Vec::new())
93}
94
95/// WHATWG `URL`, backed by the `url` crate.
96#[derive(Trace, JsLifetime)]
97#[rquickjs::class(rename = "URL")]
98pub struct Url {
99  #[qjs(skip_trace)]
100  inner: url::Url,
101}
102
103#[rquickjs::methods]
104impl Url {
105  #[qjs(constructor)]
106  pub fn new(url: String, base: Opt<String>) -> rquickjs::Result<Self> {
107    let parsed = match base.0 {
108      Some(b) => url::Url::parse(&b)
109        .and_then(|base| base.join(&url))
110        .map_err(|e| rquickjs::Error::new_from_js_message("URL", "TypeError", e.to_string()))?,
111      None => {
112        url::Url::parse(&url).map_err(|e| rquickjs::Error::new_from_js_message("URL", "TypeError", e.to_string()))?
113      },
114    };
115    Ok(Self { inner: parsed })
116  }
117
118  #[qjs(get, rename = "href")]
119  pub fn href(&self) -> String {
120    self.inner.as_str().to_string()
121  }
122
123  /// `url.href = ...` reparses; an invalid value is a `TypeError`
124  /// (WHATWG: the `href` setter is the one component setter that
125  /// throws).
126  #[qjs(set, rename = "href")]
127  pub fn set_href(&mut self, value: String) -> rquickjs::Result<()> {
128    self.inner =
129      url::Url::parse(&value).map_err(|e| rquickjs::Error::new_from_js_message("URL", "TypeError", e.to_string()))?;
130    Ok(())
131  }
132
133  #[qjs(get, rename = "origin")]
134  pub fn origin(&self) -> String {
135    self.inner.origin().ascii_serialization()
136  }
137
138  #[qjs(get, rename = "protocol")]
139  pub fn protocol(&self) -> String {
140    format!("{}:", self.inner.scheme())
141  }
142
143  /// Component setters mirror the WHATWG URL setter steps: an invalid
144  /// value is ignored (no throw), matching browser behaviour.
145  #[qjs(set, rename = "protocol")]
146  pub fn set_protocol(&mut self, value: String) {
147    let scheme = value.strip_suffix(':').unwrap_or(&value);
148    let _ = self.inner.set_scheme(scheme);
149  }
150
151  #[qjs(get, rename = "username")]
152  pub fn username(&self) -> String {
153    self.inner.username().to_string()
154  }
155
156  #[qjs(set, rename = "username")]
157  pub fn set_username(&mut self, value: String) {
158    let _ = self.inner.set_username(&value);
159  }
160
161  #[qjs(get, rename = "password")]
162  pub fn password(&self) -> String {
163    self.inner.password().unwrap_or("").to_string()
164  }
165
166  #[qjs(set, rename = "password")]
167  pub fn set_password(&mut self, value: String) {
168    let _ = self
169      .inner
170      .set_password(if value.is_empty() { None } else { Some(&value) });
171  }
172
173  #[qjs(get, rename = "hostname")]
174  pub fn hostname(&self) -> String {
175    self.inner.host_str().unwrap_or("").to_string()
176  }
177
178  #[qjs(set, rename = "hostname")]
179  pub fn set_hostname(&mut self, value: String) {
180    let _ = self.inner.set_host(Some(&value));
181  }
182
183  #[qjs(get, rename = "port")]
184  pub fn port(&self) -> String {
185    self.inner.port().map(|p| p.to_string()).unwrap_or_default()
186  }
187
188  #[qjs(set, rename = "port")]
189  pub fn set_port(&mut self, value: String) {
190    let port = value.trim().parse::<u16>().ok();
191    let _ = self.inner.set_port(port);
192  }
193
194  #[qjs(get, rename = "host")]
195  pub fn host(&self) -> String {
196    match (self.inner.host_str(), self.inner.port()) {
197      (Some(h), Some(p)) => format!("{h}:{p}"),
198      (Some(h), None) => h.to_string(),
199      (None, _) => String::new(),
200    }
201  }
202
203  #[qjs(set, rename = "host")]
204  pub fn set_host(&mut self, value: String) {
205    if let Some((h, p)) = value.rsplit_once(':') {
206      if self.inner.set_host(Some(h)).is_ok() {
207        let _ = self.inner.set_port(p.parse::<u16>().ok());
208      }
209    } else {
210      let _ = self.inner.set_host(Some(&value));
211    }
212  }
213
214  #[qjs(get, rename = "pathname")]
215  pub fn pathname(&self) -> String {
216    self.inner.path().to_string()
217  }
218
219  #[qjs(set, rename = "pathname")]
220  pub fn set_pathname(&mut self, value: String) {
221    self.inner.set_path(&value);
222  }
223
224  #[qjs(get, rename = "search")]
225  pub fn search(&self) -> String {
226    match self.inner.query() {
227      Some(q) if !q.is_empty() => format!("?{q}"),
228      _ => String::new(),
229    }
230  }
231
232  #[qjs(set, rename = "search")]
233  pub fn set_search(&mut self, value: String) {
234    let q = value.strip_prefix('?').unwrap_or(&value);
235    self.inner.set_query(if q.is_empty() { None } else { Some(q) });
236  }
237
238  #[qjs(get, rename = "hash")]
239  pub fn hash(&self) -> String {
240    match self.inner.fragment() {
241      Some(f) if !f.is_empty() => format!("#{f}"),
242      _ => String::new(),
243    }
244  }
245
246  #[qjs(set, rename = "hash")]
247  pub fn set_hash(&mut self, value: String) {
248    let f = value.strip_prefix('#').unwrap_or(&value);
249    self.inner.set_fragment(if f.is_empty() { None } else { Some(f) });
250  }
251
252  /// Live-ish `URLSearchParams` over this URL's query, built through the
253  /// `URLSearchParams` global constructor (from `rquickjs-extra-url`).
254  #[qjs(get, rename = "searchParams")]
255  pub fn search_params<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
256    let query = self.inner.query().unwrap_or("");
257    let ctor: Constructor<'js> = ctx.globals().get("URLSearchParams")?;
258    ctor.construct((query.to_string(),))
259  }
260
261  #[qjs(rename = "toString")]
262  pub fn to_js_string(&self) -> String {
263    self.inner.as_str().to_string()
264  }
265
266  #[qjs(rename = "toJSON")]
267  pub fn to_json(&self) -> String {
268    self.inner.as_str().to_string()
269  }
270}
271
272/// WHATWG "forgiving-base64 decode"
273/// (<https://infra.spec.whatwg.org/#forgiving-base64-decode>): strip
274/// ALL ASCII whitespace (not just the ends), reject a length ≡ 1 mod 4,
275/// tolerate missing/partial `=` padding, and discard non-zero trailing
276/// bits. `base64::STANDARD` does none of this (canonical padding only,
277/// no whitespace), so a spec-conformant `atob` needs the explicit
278/// algorithm here.
279fn forgiving_base64_decode(input: &str) -> Result<Vec<u8>, &'static str> {
280  let mut s: String = input
281    .chars()
282    .filter(|c| !matches!(c, '\t' | '\n' | '\u{0C}' | '\r' | ' '))
283    .collect();
284  // At most two trailing '=' are stripped; any remaining '=' (or one
285  // that leaves length ≡ 1 mod 4) is invalid.
286  if s.ends_with('=') {
287    s.pop();
288    if s.ends_with('=') {
289      s.pop();
290    }
291  }
292  if s.len() % 4 == 1 || s.contains('=') {
293    return Err("invalid base64 length");
294  }
295  if !s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') {
296    return Err("invalid base64 character");
297  }
298  // No-pad alphabet, padding indifferent (we stripped it), trailing
299  // bits discarded — exactly the forgiving contract.
300  let engine = GeneralPurpose::new(
301    &base64::alphabet::STANDARD,
302    GeneralPurposeConfig::new()
303      .with_encode_padding(false)
304      .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent)
305      .with_decode_allow_trailing_bits(true),
306  );
307  engine.decode(s.as_bytes()).map_err(|_| "invalid base64")
308}
309
310/// Install the native web-API classes + globals. Called once at
311/// `Session::create`; persists across executions like the rest of the
312/// browser-like runtime surface.
313pub fn install(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
314  let globals = ctx.globals();
315
316  Class::<TextEncoder>::define(&globals)?;
317  Class::<TextDecoder>::define(&globals)?;
318  Class::<Url>::define(&globals)?;
319
320  // queueMicrotask: defer the callback onto the job queue (same
321  // primitive rquickjs-extra-timers' setImmediate uses).
322  globals.set(
323    "queueMicrotask",
324    Func::from(|cb: Function<'_>| -> rquickjs::Result<()> {
325      cb.defer::<()>(())?;
326      Ok(())
327    }),
328  )?;
329
330  // btoa/atob over a Latin1 "binary string", per the WHATWG contract.
331  globals.set(
332    "btoa",
333    Func::from(|s: String| -> rquickjs::Result<String> {
334      let mut bytes = Vec::with_capacity(s.len());
335      for ch in s.chars() {
336        let c = ch as u32;
337        if c > 0xFF {
338          return Err(rquickjs::Error::new_from_js_message(
339            "btoa",
340            "InvalidCharacterError",
341            "string contains characters outside the Latin1 range".to_string(),
342          ));
343        }
344        bytes.push(c as u8);
345      }
346      Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
347    }),
348  )?;
349  globals.set(
350    "atob",
351    Func::from(|s: String| -> rquickjs::Result<String> {
352      let bytes = forgiving_base64_decode(&s)
353        .map_err(|m| rquickjs::Error::new_from_js_message("atob", "InvalidCharacterError", m.to_string()))?;
354      Ok(bytes.into_iter().map(|b| b as char).collect())
355    }),
356  )?;
357
358  Ok(())
359}