Skip to main content

ferridriver_script/bindings/
context.rs

1//! `BrowserContextJs`: JS wrapper around `ferridriver::context::ContextRef`.
2
3use std::sync::Arc;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use ferridriver::context::ContextRef;
7use rquickjs::function::Opt;
8use rquickjs::{Ctx, JsLifetime, Value, class::Trace};
9use rustc_hash::FxHashMap;
10
11use crate::bindings::convert::{FerriResultExt, init_script_from_js, serde_from_js, serde_to_js};
12use crate::bindings::page::{call_predicate_truthy, url_value_to_matcher, with_page_callbacks};
13
14#[derive(JsLifetime, Trace)]
15#[rquickjs::class(rename = "BrowserContext")]
16pub struct BrowserContextJs {
17  #[qjs(skip_trace)]
18  inner: Arc<ContextRef>,
19  /// Per-context route registration counter. Mirrors `PageJs::next_route_id`;
20  /// each `context.route(matcher, fn)` gets a unique id used as the key in
21  /// the shared `PageCallbacks` userdata registry.
22  #[qjs(skip_trace)]
23  next_route_id: Arc<AtomicU64>,
24  /// Always-true `UrlMatcher`s registered for predicate routes, keyed by id,
25  /// so `context.unroute(fn)` can drop exactly the matching registration by
26  /// `Arc` identity. Mirrors `PageJs::route_matchers`.
27  #[qjs(skip_trace)]
28  route_matchers: Arc<std::sync::Mutex<FxHashMap<u64, ferridriver::url_matcher::UrlMatcher>>>,
29}
30
31impl BrowserContextJs {
32  #[must_use]
33  pub fn new(inner: Arc<ContextRef>) -> Self {
34    // Context route ids share the per-session `PageCallbacks` userdata
35    // registry with page routes (and with other contexts), so they're
36    // drawn from a process-global counter offset above any per-page id
37    // range to avoid key collisions.
38    static CONTEXT_ROUTE_BASE: AtomicU64 = AtomicU64::new(1 << 48);
39    Self {
40      inner,
41      next_route_id: Arc::new(AtomicU64::new(CONTEXT_ROUTE_BASE.fetch_add(1 << 20, Ordering::Relaxed))),
42      route_matchers: Arc::new(std::sync::Mutex::new(FxHashMap::default())),
43    }
44  }
45}
46
47#[rquickjs::methods]
48impl BrowserContextJs {
49  // ── Cookies ───────────────────────────────────────────────────────────────
50
51  /// All cookies visible in this context.
52  ///
53  /// Returns an array of `{ name, value, domain, path, secure, httpOnly,
54  /// expires, sameSite }` objects matching Playwright's cookie shape.
55  #[qjs(rename = "cookies")]
56  pub async fn cookies<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
57    let cookies = self.inner.cookies().await.into_js()?;
58    serde_to_js(&ctx, &cookies)
59  }
60
61  /// Append cookies to this context.
62  ///
63  /// `cookies` is an array matching Playwright's `SetNetworkCookieParam[]`:
64  /// only `name` + `value` are required, plus either `url` OR `domain`+`path`.
65  /// `secure`, `httpOnly`, `sameSite`, `expires` all default when absent.
66  #[qjs(rename = "addCookies")]
67  pub async fn add_cookies<'js>(&self, ctx: Ctx<'js>, cookies: Value<'js>) -> rquickjs::Result<()> {
68    let parsed: Vec<ferridriver::backend::SetCookieParams> = serde_from_js(&ctx, cookies)?;
69    let cookies: Vec<ferridriver::backend::CookieData> = parsed.into_iter().map(Into::into).collect();
70    self.inner.add_cookies(cookies).await.into_js()
71  }
72
73  /// Playwright: `context.clearCookies(options?)`. Without options
74  /// clears every cookie; with `{ name?, domain?, path? }` only
75  /// cookies matching ALL specified filters are cleared. Filter
76  /// values are exact-match strings — Playwright's TS surface accepts
77  /// `string | RegExp` here too; regex filters are tracked under
78  /// "Section B" pending a Rust core extension.
79  #[qjs(rename = "clearCookies")]
80  pub async fn clear_cookies<'js>(
81    &self,
82    ctx: rquickjs::Ctx<'js>,
83    options: rquickjs::function::Opt<rquickjs::Value<'js>>,
84  ) -> rquickjs::Result<()> {
85    match options.0 {
86      None => self.inner.clear_cookies().await.into_js(),
87      Some(v) if v.is_undefined() || v.is_null() => self.inner.clear_cookies().await.into_js(),
88      Some(v) => {
89        #[derive(serde::Deserialize, Default)]
90        struct Filter {
91          name: Option<String>,
92          domain: Option<String>,
93          path: Option<String>,
94        }
95        let parsed: Filter = crate::bindings::convert::serde_from_js(&ctx, v)?;
96        let core = ferridriver::backend::ClearCookieOptions {
97          name: parsed.name,
98          domain: parsed.domain,
99          path: parsed.path,
100        };
101        self.inner.clear_cookies_filtered(&core).await.into_js()
102      },
103    }
104  }
105
106  /// Delete a cookie by name (optionally scoped to a domain).
107  #[qjs(rename = "deleteCookie")]
108  pub async fn delete_cookie(&self, name: String, domain: Opt<String>) -> rquickjs::Result<()> {
109    self.inner.delete_cookie(&name, domain.0.as_deref()).await.into_js()
110  }
111
112  /// Export the current storage state — cookies + per-origin localStorage.
113  ///
114  /// Playwright: `storageState(options?: { path?, indexedDB? })
115  ///   : Promise<{ cookies, origins }>`
116  /// (`/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:460`).
117  /// `path` writes the JSON to disk; `indexedDB` is accepted for parity but
118  /// IndexedDB is not yet collected.
119  #[qjs(rename = "storageState")]
120  pub async fn storage_state<'js>(&self, ctx: Ctx<'js>, options: Opt<Value<'js>>) -> rquickjs::Result<Value<'js>> {
121    #[derive(serde::Deserialize)]
122    #[serde(rename_all = "camelCase")]
123    struct JsStorageStateOptions {
124      path: Option<String>,
125      indexed_db: Option<bool>,
126    }
127    let core_opts = match options.0 {
128      Some(v) if !v.is_undefined() && !v.is_null() => {
129        let parsed: JsStorageStateOptions = serde_from_js(&ctx, v)?;
130        Some(ferridriver::options::StorageStateOptions {
131          path: parsed.path.map(std::path::PathBuf::from),
132          indexed_db: parsed.indexed_db,
133        })
134      },
135      _ => None,
136    };
137    let state = self.inner.storage_state(core_opts).await.into_js()?;
138    serde_to_js(&ctx, &state)
139  }
140
141  // ── Permissions ───────────────────────────────────────────────────────────
142
143  /// Grant a set of permissions (e.g. `['geolocation', 'notifications']`),
144  /// optionally scoped to `origin`.
145  #[qjs(rename = "grantPermissions")]
146  pub async fn grant_permissions(&self, permissions: Vec<String>, origin: Opt<String>) -> rquickjs::Result<()> {
147    self
148      .inner
149      .grant_permissions(&permissions, origin.0.as_deref())
150      .await
151      .into_js()
152  }
153
154  /// Revoke all previously granted permissions.
155  #[qjs(rename = "clearPermissions")]
156  pub async fn clear_permissions(&self) -> rquickjs::Result<()> {
157    self.inner.clear_permissions().await.into_js()
158  }
159
160  // ── Emulation ─────────────────────────────────────────────────────────────
161
162  /// Override the geolocation reported to pages in this context.
163  #[qjs(rename = "setGeolocation")]
164  pub async fn set_geolocation(&self, latitude: f64, longitude: f64, accuracy: f64) -> rquickjs::Result<()> {
165    self
166      .inner
167      .set_geolocation(latitude, longitude, accuracy)
168      .await
169      .into_js()
170  }
171
172  /// Toggle offline mode for this context.
173  #[qjs(rename = "setOffline")]
174  pub async fn set_offline(&self, offline: bool) -> rquickjs::Result<()> {
175    self.inner.set_offline(offline).await.into_js()
176  }
177
178  /// Playwright: `browserContext.setHTTPCredentials(httpCredentials |
179  /// null)` —
180  /// `/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:355`.
181  /// Accepts `{ username, password, origin?, send? }` or `null` /
182  /// `undefined` (clears stored credentials).
183  #[qjs(rename = "setHTTPCredentials")]
184  pub async fn set_http_credentials<'js>(&self, ctx: Ctx<'js>, credentials: Opt<Value<'js>>) -> rquickjs::Result<()> {
185    let creds = match credentials.0 {
186      None => None,
187      Some(v) if v.is_undefined() || v.is_null() => None,
188      Some(v) => {
189        #[derive(serde::Deserialize)]
190        #[serde(rename_all = "camelCase")]
191        struct JsCreds {
192          username: String,
193          password: String,
194          origin: Option<String>,
195          send: Option<String>,
196        }
197        let parsed: JsCreds = serde_from_js(&ctx, v)?;
198        Some(ferridriver::options::HttpCredentials {
199          username: parsed.username,
200          password: parsed.password,
201          origin: parsed.origin,
202          send: parsed.send.and_then(|s| match s.as_str() {
203            "always" => Some(ferridriver::options::HttpCredentialsSend::Always),
204            "unauthorized" => Some(ferridriver::options::HttpCredentialsSend::Unauthorized),
205            _ => None,
206          }),
207        })
208      },
209    };
210    self.inner.set_http_credentials(creds).await.into_js()
211  }
212
213  /// Set HTTP headers sent with every request in this context.
214  ///
215  /// `headers` is a plain object (e.g. `{ 'X-Foo': 'bar' }`).
216  #[qjs(rename = "setExtraHTTPHeaders")]
217  pub async fn set_extra_http_headers<'js>(&self, ctx: Ctx<'js>, headers: Value<'js>) -> rquickjs::Result<()> {
218    let map: FxHashMap<String, String> = serde_from_js(&ctx, headers)?;
219    self.inner.set_extra_http_headers(&map).await.into_js()
220  }
221
222  // ── Routing ─────────────────────────────────────────────────────────────
223
224  /// Playwright: `browserContext.route(url, handler)` —
225  /// `/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:377`.
226  /// Routes every page in this context (current and future). Mirrors the
227  /// `PageJs::route` dispatch: predicate functions register an always-true
228  /// core matcher and are evaluated in the JS runtime via the session's
229  /// `AsyncContext`; the JS callback / predicate live in the shared
230  /// `PageCallbacks` userdata registry keyed by route id.
231  #[qjs(rename = "route")]
232  pub async fn route<'js>(
233    &self,
234    ctx: Ctx<'js>,
235    url: Value<'js>,
236    handler: rquickjs::Function<'js>,
237    options: rquickjs::function::Opt<Value<'js>>,
238  ) -> rquickjs::Result<()> {
239    let times = crate::bindings::page::parse_route_times(&options)?;
240    let async_ctx = match ctx.userdata::<crate::engine::SessionAsyncCtx>() {
241      Some(ud) => ud.0.clone(),
242      None => {
243        return Err(rquickjs::Error::new_from_js_message(
244          "context.route",
245          "Error",
246          "context.route requires the script engine's AsyncContext".to_string(),
247        ));
248      },
249    };
250    let id = self.next_route_id.fetch_add(1, Ordering::Relaxed);
251    let saved_handler = rquickjs::Persistent::save(&ctx, handler);
252    with_page_callbacks(&ctx, |r| r.insert_route_handler(id, saved_handler))?;
253
254    let has_predicate = url.as_function().is_some();
255    let matcher = if let Some(pred) = url.as_function() {
256      let saved_pred = rquickjs::Persistent::save(&ctx, pred.clone());
257      with_page_callbacks(&ctx, |r| r.insert_route_pred(id, saved_pred))?;
258      let m = ferridriver::url_matcher::UrlMatcher::predicate(|_| true);
259      self
260        .route_matchers
261        .lock()
262        .unwrap_or_else(std::sync::PoisonError::into_inner)
263        .insert(id, m.clone());
264      m
265    } else {
266      url_value_to_matcher(&ctx, url)?
267    };
268
269    let rust_handler: ferridriver::route::RouteHandler = std::sync::Arc::new(move |route| {
270      let async_ctx = async_ctx.clone();
271      tokio::spawn(async move {
272        use rquickjs::class::Class;
273        let _: rquickjs::Result<()> = rquickjs::async_with!(async_ctx => |ctx| {
274          if has_predicate {
275            let pred = with_page_callbacks(&ctx, |r| r.get_route_pred(id))?
276              .ok_or_else(|| rquickjs::Error::new_from_js_message("context.route", "Error", "route predicate gone".to_string()))?
277              .restore(&ctx)?;
278            let url_ctor: rquickjs::function::Constructor<'_> = ctx.globals().get("URL")?;
279            let url_obj: rquickjs::Value<'_> = url_ctor.construct((route.request().url.clone(),))?;
280            if !call_predicate_truthy(&pred, url_obj, &ctx).await? {
281              route.continue_route(ferridriver::route::ContinueOverrides::default());
282              return Ok(());
283            }
284          }
285          let f = with_page_callbacks(&ctx, |r| r.get_route_handler(id))?
286            .ok_or_else(|| rquickjs::Error::new_from_js_message("context.route", "Error", "route handler gone".to_string()))?
287            .restore(&ctx)?;
288          let route_class = Class::instance(ctx.clone(), crate::bindings::network::RouteJs::new(route))?;
289          let _: rquickjs::Value<'_> = f.call((route_class,))?;
290          Ok(())
291        })
292        .await;
293      });
294    });
295
296    self.inner.route(matcher, rust_handler, times).await.into_js()?;
297    Ok(())
298  }
299
300  /// Playwright: `browserContext.routeFromHAR(har, options?)`. Replay-only.
301  #[qjs(rename = "routeFromHAR")]
302  pub async fn route_from_har(&self, har: String, options: rquickjs::function::Opt<Value<'_>>) -> rquickjs::Result<()> {
303    let opts = crate::bindings::page::parse_har_options(&options)?;
304    self
305      .inner
306      .route_from_har(std::path::Path::new(&har), opts)
307      .await
308      .into_js()
309  }
310
311  /// Playwright: `browserContext.unroute(url, handler?)` —
312  /// `/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:411`.
313  /// A predicate is matched by `===` against the function passed to `route`.
314  #[qjs(rename = "unroute")]
315  pub async fn unroute<'js>(&self, ctx: Ctx<'js>, url: Value<'js>) -> rquickjs::Result<()> {
316    if let Some(pred) = url.as_function() {
317      let saved = with_page_callbacks(&ctx, |r| r.route_preds_snapshot())?;
318      let mut victims: Vec<u64> = Vec::new();
319      for (id, sp) in saved {
320        let stored = sp.restore(&ctx)?;
321        if stored.as_value() == pred.as_value() {
322          victims.push(id);
323        }
324      }
325      for id in victims {
326        let m = self
327          .route_matchers
328          .lock()
329          .unwrap_or_else(std::sync::PoisonError::into_inner)
330          .remove(&id);
331        if let Some(m) = m {
332          self.inner.unroute(&m).await.into_js()?;
333        }
334        with_page_callbacks(&ctx, |r| r.remove_route(id))?;
335      }
336      return Ok(());
337    }
338    let matcher = url_value_to_matcher(&ctx, url)?;
339    self.inner.unroute(&matcher).await.into_js()
340  }
341
342  // ── Init scripts ──────────────────────────────────────────────────────────
343
344  /// Register a JS snippet to run on every new page in this context before
345  /// page scripts execute. Mirrors Playwright's
346  /// `browserContext.addInitScript(script, arg)` — see
347  /// `/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:356`.
348  /// Accepts `Function | string | { path?, content? }` + optional `arg`
349  /// exactly like the NAPI binding.
350  #[qjs(rename = "addInitScript")]
351  pub async fn add_init_script<'js>(
352    &self,
353    ctx: Ctx<'js>,
354    script: Value<'js>,
355    arg: Opt<Value<'js>>,
356  ) -> rquickjs::Result<Value<'js>> {
357    let (init, arg_json) = init_script_from_js(&ctx, script, arg.0)?;
358    let disposable = self.inner.add_init_script(init, arg_json).await.into_js()?;
359    let instance =
360      rquickjs::class::Class::instance(ctx.clone(), crate::bindings::disposable::DisposableJs::new(disposable))?;
361    rquickjs::IntoJs::into_js(instance, &ctx)
362  }
363
364  // ── Timeouts ──────────────────────────────────────────────────────────────
365
366  /// Playwright: `browserContext.setDefaultTimeout(timeout)` —
367  /// `/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:286`.
368  /// Core stores the value behind an `Arc<AtomicU64>` so the setter works
369  /// through this shared `&self` handle.
370  #[qjs(rename = "setDefaultTimeout")]
371  pub fn set_default_timeout(&self, timeout: f64) {
372    self
373      .inner
374      .set_default_timeout(crate::bindings::convert::ms_f64_to_u64(timeout));
375  }
376
377  /// Playwright: `browserContext.setDefaultNavigationTimeout(timeout)` —
378  /// `/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:282`.
379  #[qjs(rename = "setDefaultNavigationTimeout")]
380  pub fn set_default_navigation_timeout(&self, timeout: f64) {
381    self
382      .inner
383      .set_default_navigation_timeout(crate::bindings::convert::ms_f64_to_u64(timeout));
384  }
385
386  // ── Lifecycle ─────────────────────────────────────────────────────────────
387
388  /// Name of the session this context belongs to.
389  #[qjs(rename = "name")]
390  pub fn name(&self) -> String {
391    self.inner.name().to_string()
392  }
393
394  /// Playwright: `browserContext.browser(): Browser | null` —
395  /// `/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:290`.
396  /// Returns the parent browser, or `null` if the context was not created
397  /// from a `Browser`.
398  #[qjs(rename = "browser")]
399  pub fn browser<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
400    use rquickjs::class::Class;
401    match self.inner.browser() {
402      Some(b) => {
403        let wrapper = crate::bindings::browser::BrowserJs::new(std::sync::Arc::new(b.clone()));
404        let instance = Class::instance(ctx.clone(), wrapper)?;
405        rquickjs::IntoJs::into_js(instance, &ctx)
406      },
407      None => Ok(Value::new_null(ctx)),
408    }
409  }
410
411  /// Playwright: `browserContext.isClosed(): boolean` —
412  /// `/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:298`.
413  #[qjs(rename = "isClosed")]
414  pub fn is_closed(&self) -> bool {
415    self.inner.is_closed()
416  }
417
418  /// Close the context (tears down the underlying browser state).
419  #[qjs(rename = "close")]
420  pub async fn close(&self) -> rquickjs::Result<()> {
421    self.inner.close().await.into_js()
422  }
423
424  // ── Page creation ──────────────────────────────────────────────────────
425
426  /// Playwright: `browser.newContext().newPage(): Promise<Page>` —
427  /// `/tmp/playwright/packages/playwright-core/types/types.d.ts` (on
428  /// `BrowserContext`). Opens a new tab in this context; the returned
429  /// [`crate::bindings::page::PageJs`] inherits the context's
430  /// `recordVideo` configuration (if any) and every other per-context
431  /// setting wired through [`ContextRef`].
432  #[qjs(rename = "newPage")]
433  pub async fn new_page<'js>(&self, ctx: Ctx<'js>) -> rquickjs::Result<Value<'js>> {
434    use rquickjs::class::Class;
435    let page = self.inner.new_page().await.into_js()?;
436    let wrapper = crate::bindings::page::pagejs_for_ctx(&ctx, page);
437    let instance = Class::instance(ctx.clone(), wrapper)?;
438    rquickjs::IntoJs::into_js(instance, &ctx)
439  }
440
441  // ── Video recording ────────────────────────────────────────────────────
442
443  /// Playwright:
444  /// `browser.newContext({ recordVideo: { dir, size? } })` —
445  /// `/tmp/playwright/packages/playwright-core/types/types.d.ts:10150`.
446  /// Transitional API: §4.1's `BrowserContextOptions` bag will fold
447  /// this into the full options struct.
448  #[qjs(rename = "setRecordVideo")]
449  pub async fn set_record_video<'js>(&self, ctx: Ctx<'js>, options: Value<'js>) -> rquickjs::Result<()> {
450    #[derive(serde::Deserialize)]
451    #[serde(rename_all = "camelCase")]
452    struct JsRecordVideoOptions {
453      dir: String,
454      size: Option<JsVideoSize>,
455    }
456    #[derive(serde::Deserialize)]
457    struct JsVideoSize {
458      width: f64,
459      height: f64,
460    }
461    let parsed: JsRecordVideoOptions = serde_from_js(&ctx, options)?;
462    let opts = ferridriver::options::RecordVideoOptions {
463      dir: std::path::PathBuf::from(parsed.dir),
464      size: parsed.size.map(|s| ferridriver::options::VideoSize {
465        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
466        width: s.width.max(0.0) as u32,
467        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
468        height: s.height.max(0.0) as u32,
469      }),
470    };
471    self.inner.set_record_video(opts).await.into_js()
472  }
473
474  // ── Exposed bindings / functions ───────────────────────────────────────
475
476  /// Playwright: `browserContext.exposeBinding(name, callback)` —
477  /// `/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:364`.
478  ///
479  /// Binds `window[name]` on every page in this context (current +
480  /// future). The page-side call routes back into `callback`, invoked
481  /// as `callback(source, ...args)` where `source` is
482  /// `{ context, page, frame }` (identity strings) and the page-side
483  /// call args are spread (Playwright parity). The callback's return
484  /// value (awaiting any returned promise) is delivered to the
485  /// page-side caller. Returns a `{ dispose() }` Disposable.
486  #[qjs(rename = "exposeBinding")]
487  pub async fn expose_binding<'js>(
488    &self,
489    ctx: Ctx<'js>,
490    name: String,
491    callback: rquickjs::Function<'js>,
492  ) -> rquickjs::Result<Value<'js>> {
493    let binding = self.make_binding(&ctx, &name, callback, true)?;
494    self.inner.expose_binding(&name, binding).await.into_js()?;
495    self.make_disposable(&ctx, name)
496  }
497
498  /// Playwright: `browserContext.exposeFunction(name, callback)` —
499  /// `/tmp/playwright/packages/playwright-core/src/client/browserContext.ts:370`.
500  ///
501  /// `exposeFunction` is `exposeBinding` minus the `source` argument:
502  /// the callback receives only the spread page-side call args.
503  #[qjs(rename = "exposeFunction")]
504  pub async fn expose_function<'js>(
505    &self,
506    ctx: Ctx<'js>,
507    name: String,
508    callback: rquickjs::Function<'js>,
509  ) -> rquickjs::Result<Value<'js>> {
510    let binding = self.make_binding(&ctx, &name, callback, false)?;
511    self.inner.expose_binding(&name, binding).await.into_js()?;
512    self.make_disposable(&ctx, name)
513  }
514
515  // ── Context-level events ───────────────────────────────────────────────
516
517  /// Wait for the next context-scoped event. Currently supports
518  /// `'weberror'` — returns a live [`crate::bindings::web_error::WebErrorJs`].
519  /// Playwright: `browserContext.waitForEvent(event, options?)`.
520  #[qjs(rename = "waitForEvent")]
521  pub async fn wait_for_event<'js>(
522    &self,
523    ctx: Ctx<'js>,
524    event: String,
525    timeout_ms: Opt<f64>,
526  ) -> rquickjs::Result<Value<'js>> {
527    use rquickjs::class::Class;
528    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
529    let timeout = timeout_ms.0.unwrap_or(30_000.0) as u64;
530    let ev = self
531      .inner
532      .wait_for_event(&event, timeout)
533      .await
534      .map_err(|e| rquickjs::Error::new_from_js_message("BrowserContext.waitForEvent", "Error", e.to_string()))?;
535    match ev {
536      ferridriver::events::ContextEvent::WebError(err) => {
537        let wrapper = crate::bindings::web_error::WebErrorJs::new(err);
538        let instance = Class::instance(ctx.clone(), wrapper)?;
539        rquickjs::IntoJs::into_js(instance, &ctx)
540      },
541    }
542  }
543}
544
545impl BrowserContextJs {
546  /// Stash `callback` in the shared exposed-callback registry and build
547  /// an [`ferridriver::ExposedBinding`] that dispatches back into the
548  /// script context via the session `AsyncContext`. When `with_source`
549  /// is true the `{ context, page, frame }` source object is prepended
550  /// to the spread args (`exposeBinding`); otherwise only the args are
551  /// spread (`exposeFunction`).
552  fn make_binding<'js>(
553    &self,
554    ctx: &Ctx<'js>,
555    name: &str,
556    callback: rquickjs::Function<'js>,
557    with_source: bool,
558  ) -> rquickjs::Result<ferridriver::ExposedBinding> {
559    let async_ctx = match ctx.userdata::<crate::engine::SessionAsyncCtx>() {
560      Some(ud) => ud.0.clone(),
561      None => {
562        return Err(rquickjs::Error::new_from_js_message(
563          "BrowserContext.exposeBinding",
564          "Error",
565          "exposeBinding requires the script engine's AsyncContext".to_string(),
566        ));
567      },
568    };
569    let saved = rquickjs::Persistent::save(ctx, callback);
570    crate::bindings::page::insert_exposed_callback(ctx, name.to_string(), saved)?;
571
572    let name = name.to_string();
573    let binding: ferridriver::ExposedBinding = Arc::new(move |source, args| {
574      let async_ctx = async_ctx.clone();
575      let name = name.clone();
576      Box::pin(async move {
577        let out: rquickjs::Result<serde_json::Value> = rquickjs::async_with!(async_ctx => |ctx| {
578          let f = crate::bindings::page::get_exposed_callback(&ctx, &name)?
579            .ok_or_else(|| {
580              rquickjs::Error::new_from_js_message(
581                "BrowserContext.exposeBinding",
582                "Error",
583                "exposed callback gone".to_string(),
584              )
585            })?
586            .restore(&ctx)?;
587          // Playwright spreads the page-side call args into the
588          // callback. For exposeBinding the BindingSource object is the
589          // first argument; for exposeFunction it is omitted.
590          let mut call_args = rquickjs::function::Args::new_unsized(ctx.clone());
591          if with_source {
592            let src = rquickjs::Object::new(ctx.clone())?;
593            src.set("context", source.context.clone())?;
594            src.set("page", source.page.clone())?;
595            src.set("frame", source.frame.clone())?;
596            call_args.push_arg(src)?;
597          }
598          for v in &args {
599            // `json_to_js` (NOT serde): a transitive dep force-enables
600            // `serde_json/arbitrary_precision`, under which the serde
601            // path turns numbers into wrapper objects.
602            call_args.push_arg(crate::bindings::convert::json_to_js(&ctx, v)?)?;
603          }
604          let mp: rquickjs::promise::MaybePromise<'_> = call_args.apply(&f)?;
605          let res = mp.into_future::<rquickjs::Value<'_>>().await?;
606          let json = match ctx.json_stringify(res)? {
607            Some(s) => serde_json::from_str(&s.to_string()?).unwrap_or(serde_json::Value::Null),
608            None => serde_json::Value::Null,
609          };
610          Ok(json)
611        })
612        .await;
613        out.unwrap_or(serde_json::Value::Null)
614      })
615    });
616    Ok(binding)
617  }
618
619  /// Build the `{ dispose() }` Disposable returned from
620  /// `exposeBinding` / `exposeFunction`. `dispose()` removes the
621  /// binding from the registry and from every page in the context
622  /// (`window[name]` is deleted on each page).
623  fn make_disposable<'js>(&self, ctx: &Ctx<'js>, name: String) -> rquickjs::Result<Value<'js>> {
624    let obj = rquickjs::Object::new(ctx.clone())?;
625    let inner = self.inner.clone();
626    let dispose = rquickjs::Function::new(
627      ctx.clone(),
628      rquickjs::prelude::Async(move || {
629        let inner = inner.clone();
630        let name = name.clone();
631        // Core removal drops the binding from the registry AND removes
632        // `window[name]` from every page in the context. The stashed
633        // QuickJS callback stays in the name-keyed registry but is never
634        // invoked again (the page-side proxy is gone); re-entering the
635        // engine `AsyncContext` from this JS-invoked async fn would
636        // deadlock against the script's own outer `async_with`.
637        async move {
638          let _ = inner.remove_exposed_binding(&name).await;
639        }
640      }),
641    )?;
642    obj.set("dispose", dispose)?;
643    rquickjs::IntoJs::into_js(obj, ctx)
644  }
645}