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::{Constructor, Func, This};
20use rquickjs::{Class, Ctx, Filter, 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  let realm = Realm::read(&ctx)?;
119  clone_value(&ctx, &realm, &value, &mut seen)
120}
121
122/// The constructors and the prototype the clone walk compares against.
123///
124/// Read once per `structuredClone`, not once per object: deciding what
125/// an object is used to cost four global lookups and four `instanceof`
126/// prototype walks EVERY time the walk descended, which on a document
127/// of small objects is most of the work.
128struct Realm<'js> {
129  date: Value<'js>,
130  regexp: Value<'js>,
131  map: Value<'js>,
132  set: Value<'js>,
133  object_proto: Option<Object<'js>>,
134}
135
136impl<'js> Realm<'js> {
137  fn read(ctx: &Ctx<'js>) -> rquickjs::Result<Self> {
138    let globals = ctx.globals();
139    let object: Value<'js> = globals.get("Object")?;
140    Ok(Self {
141      date: globals.get("Date")?,
142      regexp: globals.get("RegExp")?,
143      map: globals.get("Map")?,
144      set: globals.get("Set")?,
145      object_proto: object
146        .as_object()
147        .and_then(|o| o.get::<_, Value<'js>>("prototype").ok())
148        .and_then(|v| v.as_object().cloned()),
149    })
150  }
151}
152
153fn data_clone_error(ctx: &Ctx<'_>, what: &str) -> rquickjs::Error {
154  let ex = crate::exceptions::DOMException::new_with_name(
155    ctx,
156    crate::exceptions::DOMExceptionName::DataCloneError,
157    format!("{what} could not be cloned"),
158  );
159  match ex.and_then(|ex| Class::instance(ctx.clone(), ex)) {
160    Ok(ex) => ctx.throw(ex.into_value()),
161    Err(e) => e,
162  }
163}
164
165fn clone_value<'js>(
166  ctx: &Ctx<'js>,
167  realm: &Realm<'js>,
168  value: &Value<'js>,
169  seen: &mut Vec<(Value<'js>, Value<'js>)>,
170) -> rquickjs::Result<Value<'js>> {
171  if value.is_function() {
172    return Err(data_clone_error(ctx, "a function"));
173  }
174  if value.type_of() == rquickjs::Type::Symbol {
175    return Err(data_clone_error(ctx, "a symbol"));
176  }
177  let Some(obj) = value.as_object() else {
178    // Primitives are immutable: cloning is identity.
179    return Ok(value.clone());
180  };
181  if let Some((_, clone)) = seen.iter().find(|(orig, _)| orig.as_object() == Some(obj)) {
182    return Ok(clone.clone());
183  }
184
185  // Arrays and plain objects first, and both answer from the object
186  // itself: an array is a native type test, and a plain object is the
187  // one whose prototype IS `Object.prototype`. Between them they are
188  // almost everything a document contains, and neither now costs a
189  // single `instanceof` walk.
190  if let Some(arr) = value.as_array() {
191    let out = rquickjs::Array::new(ctx.clone())?;
192    seen.push((value.clone(), out.clone().into_value()));
193    for i in 0..arr.len() {
194      let item: Value<'js> = arr.get(i)?;
195      out.set(i, clone_value(ctx, realm, &item, seen)?)?;
196    }
197    return Ok(out.into_value());
198  }
199
200  let proto = obj.get_prototype();
201  // `Object.create(null)` has no prototype and is still plain.
202  if proto.is_none() || proto == realm.object_proto {
203    let out = Object::new(ctx.clone())?;
204    seen.push((value.clone(), out.clone().into_value()));
205    // Own enumerable string keys, as `Value` pairs: taking them as
206    // `String` allocated and UTF-8-converted every key twice, once to
207    // read it and once to write it back.
208    for entry in obj.own_props::<Value<'js>, Value<'js>>(Filter::new().enum_only().string()) {
209      let (key, v) = entry?;
210      out.set(key, clone_value(ctx, realm, &v, seen)?)?;
211    }
212    return Ok(out.into_value());
213  }
214
215  // Dates and RegExps round-trip through their own constructors.
216  if obj.is_instance_of(&realm.date) {
217    let ctor = Constructor::from_value(realm.date.clone())?;
218    let time: f64 = obj
219      .get::<_, rquickjs::Function<'js>>("getTime")?
220      .call((This(obj.clone()),))?;
221    return ctor.construct::<_, Value<'js>>((time,));
222  }
223  if obj.is_instance_of(&realm.regexp) {
224    let ctor = Constructor::from_value(realm.regexp.clone())?;
225    let source: String = obj.get("source")?;
226    let flags: String = obj.get("flags")?;
227    return ctor.construct::<_, Value<'js>>((source, flags));
228  }
229  if let Some(buf) = rquickjs::ArrayBuffer::from_object(obj.clone()) {
230    // SAFETY: copied out immediately.
231    let bytes = unsafe { buf.as_bytes() }.unwrap_or_default().to_vec();
232    return Ok(rquickjs::ArrayBuffer::new(ctx.clone(), bytes)?.into_value());
233  }
234  if let Ok(ta) = TypedArray::<u8>::from_value(value.clone()) {
235    // SAFETY: copied out immediately.
236    let bytes = unsafe { ta.as_bytes() }.unwrap_or_default().to_vec();
237    return Ok(TypedArray::new(ctx.clone(), bytes)?.into_value());
238  }
239
240  if obj.is_instance_of(&realm.map) {
241    let ctor = Constructor::from_value(realm.map.clone())?;
242    let out: Value<'js> = ctor.construct(())?;
243    seen.push((value.clone(), out.clone()));
244    let out_obj = out.as_object().cloned().unwrap_or_else(|| obj.clone());
245    let set: rquickjs::Function<'js> = out_obj.get("set")?;
246    for entry in iterate_entries(ctx, obj)? {
247      let (k, v) = entry?;
248      set.call::<_, ()>((
249        This(out_obj.clone()),
250        clone_value(ctx, realm, &k, seen)?,
251        clone_value(ctx, realm, &v, seen)?,
252      ))?;
253    }
254    return Ok(out);
255  }
256  if obj.is_instance_of(&realm.set) {
257    let ctor = Constructor::from_value(realm.set.clone())?;
258    let out: Value<'js> = ctor.construct(())?;
259    seen.push((value.clone(), out.clone()));
260    let out_obj = out.as_object().cloned().unwrap_or_else(|| obj.clone());
261    let add: rquickjs::Function<'js> = out_obj.get("add")?;
262    for entry in iterate_entries(ctx, obj)? {
263      let (k, _) = entry?;
264      add.call::<_, ()>((This(out_obj.clone()), clone_value(ctx, realm, &k, seen)?))?;
265    }
266    return Ok(out);
267  }
268
269  // Anything left has a prototype of its own that is none of the
270  // cloneable exotics: a class instance, including the native web
271  // classes.
272  Err(data_clone_error(ctx, "an object that is not a plain object"))
273}
274
275/// `[...target.entries()]` as `(key, value)` pairs — how a `Map`'s
276/// contents (and, with the value ignored, a `Set`'s) are read without
277/// assuming an internal representation.
278#[allow(clippy::type_complexity)]
279fn iterate_entries<'js>(
280  ctx: &Ctx<'js>,
281  target: &Object<'js>,
282) -> rquickjs::Result<Vec<rquickjs::Result<(Value<'js>, Value<'js>)>>> {
283  let entries: rquickjs::Function<'js> = target.get("entries")?;
284  let iter: Value<'js> = entries.call((This(target.clone()),))?;
285  let array_ctor: Value<'js> = ctx.globals().get("Array")?;
286  let from: rquickjs::Function<'js> = array_ctor
287    .as_object()
288    .ok_or_else(|| rquickjs::Exception::throw_type(ctx, "Array is not an object"))?
289    .get("from")?;
290  let list: rquickjs::Array<'js> = from.call((This(array_ctor), iter))?;
291  Ok(
292    (0..list.len())
293      .map(|i| {
294        let pair: rquickjs::Array<'js> = list.get(i)?;
295        Ok((pair.get(0)?, pair.get(1)?))
296      })
297      .collect(),
298  )
299}