Skip to main content

ferrijs_std/web/
mod.rs

1//! Web-platform globals with no upstream in llrt: `atob` / `btoa`,
2//! `structuredClone`, `performance`, `FormData`, the compression streams
3//! and the timers.
4//!
5//! The timers are not installed by [`init`]: they carry host state across
6//! a scheduled callback, so the host installs them with its own
7//! [`timers::CallbackPolicy`].
8
9pub mod blob_bytes;
10pub mod compression;
11pub mod form_data;
12pub mod js_iterator;
13pub mod performance;
14pub mod timers;
15
16use base64::Engine as _;
17use base64::engine::GeneralPurpose;
18use base64::engine::general_purpose::GeneralPurposeConfig;
19use rquickjs::function::{Func, This};
20use rquickjs::{Class, Ctx, Object, TypedArray, Value};
21
22/// Install `atob`, `btoa`, `structuredClone`, `performance`, `FormData`
23/// and `CompressionStream` / `DecompressionStream`.
24///
25/// # Errors
26///
27/// Propagates the global writes.
28pub fn init(ctx: &Ctx<'_>) -> rquickjs::Result<()> {
29  let globals = ctx.globals();
30
31  // btoa/atob over a Latin1 "binary string", per the WHATWG contract.
32  globals.set(
33    "btoa",
34    Func::from(|s: String| -> rquickjs::Result<String> {
35      let mut bytes = Vec::with_capacity(s.len());
36      for ch in s.chars() {
37        let c = ch as u32;
38        if c > 0xFF {
39          return Err(rquickjs::Error::new_from_js_message(
40            "btoa",
41            "InvalidCharacterError",
42            "string contains characters outside the Latin1 range".to_string(),
43          ));
44        }
45        bytes.push(c as u8);
46      }
47      Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
48    }),
49  )?;
50  globals.set(
51    "atob",
52    Func::from(|s: String| -> rquickjs::Result<String> {
53      let bytes = forgiving_base64_decode(&s)
54        .map_err(|m| rquickjs::Error::new_from_js_message("atob", "InvalidCharacterError", m.to_string()))?;
55      Ok(bytes.into_iter().map(|b| b as char).collect())
56    }),
57  )?;
58
59  globals.set("structuredClone", Func::from(structured_clone))?;
60
61  performance::init(ctx)?;
62
63  rquickjs::Class::<form_data::FormDataJs>::define(&globals)?;
64  compression::install(ctx)?;
65
66  Ok(())
67}
68
69/// WHATWG "forgiving-base64 decode"
70/// (<https://infra.spec.whatwg.org/#forgiving-base64-decode>): strip
71/// ALL ASCII whitespace (not just the ends), reject a length ≡ 1 mod 4,
72/// tolerate missing/partial `=` padding, and discard non-zero trailing
73/// bits. `base64::STANDARD` does none of this (canonical padding only,
74/// no whitespace), so a spec-conformant `atob` needs the explicit
75/// algorithm here.
76fn forgiving_base64_decode(input: &str) -> Result<Vec<u8>, &'static str> {
77  let mut s: String = input
78    .chars()
79    .filter(|c| !matches!(c, '\t' | '\n' | '\u{0C}' | '\r' | ' '))
80    .collect();
81  // At most two trailing '=' are stripped; any remaining '=' (or one
82  // that leaves length ≡ 1 mod 4) is invalid.
83  if s.ends_with('=') {
84    s.pop();
85    if s.ends_with('=') {
86      s.pop();
87    }
88  }
89  if s.len() % 4 == 1 || s.contains('=') {
90    return Err("invalid base64 length");
91  }
92  if !s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') {
93    return Err("invalid base64 character");
94  }
95  // No-pad alphabet, padding indifferent (we stripped it), trailing
96  // bits discarded — exactly the forgiving contract.
97  let engine = GeneralPurpose::new(
98    &base64::alphabet::STANDARD,
99    GeneralPurposeConfig::new()
100      .with_encode_padding(false)
101      .with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent)
102      .with_decode_allow_trailing_bits(true),
103  );
104  engine.decode(s.as_bytes()).map_err(|_| "invalid base64")
105}
106
107/// HTML `structuredClone(value)` — a deep clone by the structured-clone
108/// algorithm.
109///
110/// Handles cycles and repeated references (the same object reached twice
111/// stays the same object in the clone), `Array`, plain `Object`, `Map`,
112/// `Set`, `Date`, `RegExp`, `ArrayBuffer` and typed arrays. Functions,
113/// symbols and class instances are not cloneable and raise a
114/// `DataCloneError` `DOMException`, per spec — never a silent
115/// pass-through, which would alias the original.
116fn structured_clone<'js>(ctx: Ctx<'js>, value: Value<'js>) -> rquickjs::Result<Value<'js>> {
117  let mut seen: Vec<(Value<'js>, Value<'js>)> = Vec::new();
118  clone_value(&ctx, &value, &mut seen)
119}
120
121fn data_clone_error(ctx: &Ctx<'_>, what: &str) -> rquickjs::Error {
122  let ex = crate::exceptions::DOMException::new_with_name(
123    ctx,
124    crate::exceptions::DOMExceptionName::DataCloneError,
125    format!("{what} could not be cloned"),
126  );
127  match ex.and_then(|ex| Class::instance(ctx.clone(), ex)) {
128    Ok(ex) => ctx.throw(ex.into_value()),
129    Err(e) => e,
130  }
131}
132
133fn clone_value<'js>(
134  ctx: &Ctx<'js>,
135  value: &Value<'js>,
136  seen: &mut Vec<(Value<'js>, Value<'js>)>,
137) -> rquickjs::Result<Value<'js>> {
138  if value.is_function() {
139    return Err(data_clone_error(ctx, "a function"));
140  }
141  if value.type_of() == rquickjs::Type::Symbol {
142    return Err(data_clone_error(ctx, "a symbol"));
143  }
144  let Some(obj) = value.as_object() else {
145    // Primitives are immutable: cloning is identity.
146    return Ok(value.clone());
147  };
148  if let Some((_, clone)) = seen.iter().find(|(orig, _)| orig.as_object() == Some(obj)) {
149    return Ok(clone.clone());
150  }
151
152  let globals = ctx.globals();
153  let is_a = |name: &str| -> rquickjs::Result<bool> {
154    let ctor: Value<'js> = globals.get(name)?;
155    Ok(obj.is_instance_of(&ctor))
156  };
157
158  // Dates and RegExps round-trip through their own constructors.
159  if is_a("Date")? {
160    let ctor: rquickjs::function::Constructor<'js> = globals.get("Date")?;
161    let time: f64 = obj
162      .get::<_, rquickjs::Function<'js>>("getTime")?
163      .call((This(obj.clone()),))?;
164    return ctor.construct::<_, Value<'js>>((time,));
165  }
166  if is_a("RegExp")? {
167    let ctor: rquickjs::function::Constructor<'js> = globals.get("RegExp")?;
168    let source: String = obj.get("source")?;
169    let flags: String = obj.get("flags")?;
170    return ctor.construct::<_, Value<'js>>((source, flags));
171  }
172  if let Some(buf) = rquickjs::ArrayBuffer::from_object(obj.clone()) {
173    let bytes = buf.as_bytes().unwrap_or_default().to_vec();
174    return Ok(rquickjs::ArrayBuffer::new(ctx.clone(), bytes)?.into_value());
175  }
176  if let Ok(ta) = TypedArray::<u8>::from_value(value.clone()) {
177    let bytes = ta.as_bytes().unwrap_or_default().to_vec();
178    return Ok(TypedArray::new(ctx.clone(), bytes)?.into_value());
179  }
180
181  if let Some(arr) = value.as_array() {
182    let out = rquickjs::Array::new(ctx.clone())?;
183    seen.push((value.clone(), out.clone().into_value()));
184    for i in 0..arr.len() {
185      let item: Value<'js> = arr.get(i)?;
186      out.set(i, clone_value(ctx, &item, seen)?)?;
187    }
188    return Ok(out.into_value());
189  }
190
191  if is_a("Map")? {
192    let ctor: rquickjs::function::Constructor<'js> = globals.get("Map")?;
193    let out: Value<'js> = ctor.construct(())?;
194    seen.push((value.clone(), out.clone()));
195    let out_obj = out.as_object().cloned().unwrap_or_else(|| obj.clone());
196    let set: rquickjs::Function<'js> = out_obj.get("set")?;
197    for entry in iterate_entries(ctx, obj)? {
198      let (k, v) = entry?;
199      set.call::<_, ()>((
200        This(out_obj.clone()),
201        clone_value(ctx, &k, seen)?,
202        clone_value(ctx, &v, seen)?,
203      ))?;
204    }
205    return Ok(out);
206  }
207  if is_a("Set")? {
208    let ctor: rquickjs::function::Constructor<'js> = globals.get("Set")?;
209    let out: Value<'js> = ctor.construct(())?;
210    seen.push((value.clone(), out.clone()));
211    let out_obj = out.as_object().cloned().unwrap_or_else(|| obj.clone());
212    let add: rquickjs::Function<'js> = out_obj.get("add")?;
213    for entry in iterate_entries(ctx, obj)? {
214      let (k, _) = entry?;
215      add.call::<_, ()>((This(out_obj.clone()), clone_value(ctx, &k, seen)?))?;
216    }
217    return Ok(out);
218  }
219
220  // Anything with a non-Object prototype (a class instance, including
221  // the native web classes) is not a cloneable "plain object".
222  let object_ctor: Value<'js> = globals.get("Object")?;
223  let proto = obj.get_prototype();
224  let object_proto = object_ctor
225    .as_object()
226    .and_then(|o| o.get::<_, Value<'js>>("prototype").ok())
227    .and_then(|v| v.as_object().cloned());
228  if proto.is_some() && proto != object_proto {
229    return Err(data_clone_error(ctx, "an object that is not a plain object"));
230  }
231
232  let out = Object::new(ctx.clone())?;
233  seen.push((value.clone(), out.clone().into_value()));
234  for key in obj.keys::<String>() {
235    let key = key?;
236    let v: Value<'js> = obj.get(&key)?;
237    out.set(key, clone_value(ctx, &v, seen)?)?;
238  }
239  Ok(out.into_value())
240}
241
242/// `[...target.entries()]` as `(key, value)` pairs — how a `Map`'s
243/// contents (and, with the value ignored, a `Set`'s) are read without
244/// assuming an internal representation.
245#[allow(clippy::type_complexity)]
246fn iterate_entries<'js>(
247  ctx: &Ctx<'js>,
248  target: &Object<'js>,
249) -> rquickjs::Result<Vec<rquickjs::Result<(Value<'js>, Value<'js>)>>> {
250  let entries: rquickjs::Function<'js> = target.get("entries")?;
251  let iter: Value<'js> = entries.call((This(target.clone()),))?;
252  let array_ctor: Value<'js> = ctx.globals().get("Array")?;
253  let from: rquickjs::Function<'js> = array_ctor
254    .as_object()
255    .ok_or_else(|| rquickjs::Exception::throw_type(ctx, "Array is not an object"))?
256    .get("from")?;
257  let list: rquickjs::Array<'js> = from.call((This(array_ctor), iter))?;
258  Ok(
259    (0..list.len())
260      .map(|i| {
261        let pair: rquickjs::Array<'js> = list.get(i)?;
262        Ok((pair.get(0)?, pair.get(1)?))
263      })
264      .collect(),
265  )
266}