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    // SAFETY: copied out immediately.
174    let bytes = unsafe { buf.as_bytes() }.unwrap_or_default().to_vec();
175    return Ok(rquickjs::ArrayBuffer::new(ctx.clone(), bytes)?.into_value());
176  }
177  if let Ok(ta) = TypedArray::<u8>::from_value(value.clone()) {
178    // SAFETY: copied out immediately.
179    let bytes = unsafe { ta.as_bytes() }.unwrap_or_default().to_vec();
180    return Ok(TypedArray::new(ctx.clone(), bytes)?.into_value());
181  }
182
183  if let Some(arr) = value.as_array() {
184    let out = rquickjs::Array::new(ctx.clone())?;
185    seen.push((value.clone(), out.clone().into_value()));
186    for i in 0..arr.len() {
187      let item: Value<'js> = arr.get(i)?;
188      out.set(i, clone_value(ctx, &item, seen)?)?;
189    }
190    return Ok(out.into_value());
191  }
192
193  if is_a("Map")? {
194    let ctor: rquickjs::function::Constructor<'js> = globals.get("Map")?;
195    let out: Value<'js> = ctor.construct(())?;
196    seen.push((value.clone(), out.clone()));
197    let out_obj = out.as_object().cloned().unwrap_or_else(|| obj.clone());
198    let set: rquickjs::Function<'js> = out_obj.get("set")?;
199    for entry in iterate_entries(ctx, obj)? {
200      let (k, v) = entry?;
201      set.call::<_, ()>((
202        This(out_obj.clone()),
203        clone_value(ctx, &k, seen)?,
204        clone_value(ctx, &v, seen)?,
205      ))?;
206    }
207    return Ok(out);
208  }
209  if is_a("Set")? {
210    let ctor: rquickjs::function::Constructor<'js> = globals.get("Set")?;
211    let out: Value<'js> = ctor.construct(())?;
212    seen.push((value.clone(), out.clone()));
213    let out_obj = out.as_object().cloned().unwrap_or_else(|| obj.clone());
214    let add: rquickjs::Function<'js> = out_obj.get("add")?;
215    for entry in iterate_entries(ctx, obj)? {
216      let (k, _) = entry?;
217      add.call::<_, ()>((This(out_obj.clone()), clone_value(ctx, &k, seen)?))?;
218    }
219    return Ok(out);
220  }
221
222  // Anything with a non-Object prototype (a class instance, including
223  // the native web classes) is not a cloneable "plain object".
224  let object_ctor: Value<'js> = globals.get("Object")?;
225  let proto = obj.get_prototype();
226  let object_proto = object_ctor
227    .as_object()
228    .and_then(|o| o.get::<_, Value<'js>>("prototype").ok())
229    .and_then(|v| v.as_object().cloned());
230  if proto.is_some() && proto != object_proto {
231    return Err(data_clone_error(ctx, "an object that is not a plain object"));
232  }
233
234  let out = Object::new(ctx.clone())?;
235  seen.push((value.clone(), out.clone().into_value()));
236  for key in obj.keys::<String>() {
237    let key = key?;
238    let v: Value<'js> = obj.get(&key)?;
239    out.set(key, clone_value(ctx, &v, seen)?)?;
240  }
241  Ok(out.into_value())
242}
243
244/// `[...target.entries()]` as `(key, value)` pairs — how a `Map`'s
245/// contents (and, with the value ignored, a `Set`'s) are read without
246/// assuming an internal representation.
247#[allow(clippy::type_complexity)]
248fn iterate_entries<'js>(
249  ctx: &Ctx<'js>,
250  target: &Object<'js>,
251) -> rquickjs::Result<Vec<rquickjs::Result<(Value<'js>, Value<'js>)>>> {
252  let entries: rquickjs::Function<'js> = target.get("entries")?;
253  let iter: Value<'js> = entries.call((This(target.clone()),))?;
254  let array_ctor: Value<'js> = ctx.globals().get("Array")?;
255  let from: rquickjs::Function<'js> = array_ctor
256    .as_object()
257    .ok_or_else(|| rquickjs::Exception::throw_type(ctx, "Array is not an object"))?
258    .get("from")?;
259  let list: rquickjs::Array<'js> = from.call((This(array_ctor), iter))?;
260  Ok(
261    (0..list.len())
262      .map(|i| {
263        let pair: rquickjs::Array<'js> = list.get(i)?;
264        Ok((pair.get(0)?, pair.get(1)?))
265      })
266      .collect(),
267  )
268}