playwright_cdp/worker.rs
1//! `Worker` — web/service/shared-worker capture via the CDP `Target` domain.
2//!
3//! [`Page::on_worker`](crate::page::Page::on_worker) enables flattened auto-attach
4//! on the page session (`Target.setAutoAttach { flatten: true }`) and dispatches a
5//! [`Worker`] for every child target whose type is `worker`, `service_worker`, or
6//! `shared_worker` (surfaced via `Target.attachedToTarget`). Each worker gets its
7//! own sub-session on the same flattened connection; evaluation runs there via
8//! `Runtime.evaluate`.
9
10use crate::cdp::session::CdpSession;
11use crate::error::{Error, Result};
12use serde::de::DeserializeOwned;
13use serde_json::{json, Value};
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::Arc;
16
17/// A web/service/shared worker owned by a page.
18///
19/// Produced by [`Page::on_worker`](crate::page::Page::on_worker). The worker is
20/// driven through its own CDP sub-session; call [`evaluate`](Worker::evaluate)
21/// to run JS in the worker's execution context.
22#[derive(Clone)]
23pub struct Worker {
24 inner: Arc<WorkerInner>,
25}
26
27struct WorkerInner {
28 url: String,
29 session: CdpSession,
30}
31
32impl Worker {
33 pub(crate) fn new(connection: Arc<crate::cdp::connection::CdpConnection>, session_id: String, url: String) -> Self {
34 let session = CdpSession::target(connection, session_id);
35 // Best-effort: enable the Runtime domain on the worker session so
36 // `Runtime.evaluate` works. We don't await this here (we're in a sync
37 // ctor); the listener task fires it after construction.
38 Self {
39 inner: Arc::new(WorkerInner { url, session }),
40 }
41 }
42
43 /// The worker's script URL (e.g. `blob:...`, `https://.../sw.js`).
44 pub fn url(&self) -> &str {
45 &self.inner.url
46 }
47
48 /// (Internal) the worker's CDP sub-session.
49 #[allow(dead_code)]
50 pub(crate) fn session(&self) -> &CdpSession {
51 &self.inner.session
52 }
53
54 /// Best-effort `Runtime.enable` on the worker session so evaluate works.
55 pub(crate) async fn enable_runtime(&self) {
56 let _ = self.inner.session.send("Runtime.enable", json!({})).await;
57 }
58
59 /// Register a handler invoked once when the worker is destroyed.
60 ///
61 /// A worker is reported as destroyed when the browser detaches its target
62 /// (`Target.detachedFromTarget` whose `sessionId` matches this worker's
63 /// sub-session). That event is delivered on the parent session, so we
64 /// subscribe at the connection (browser/root) level and filter by session
65 /// id. We also watch the worker's own session for
66 /// `Runtime.executionContextDestroyed` as a fallback. The handler fires at
67 /// most once: the first signal wins, the other is suppressed.
68 pub fn on_close<F, Fut>(&self, handler: F)
69 where
70 F: Fn() -> Fut + Send + Sync + 'static,
71 Fut: std::future::Future<Output = ()> + Send + 'static,
72 {
73 let session_id = match self.inner.session.session_id() {
74 Some(s) => s.to_string(),
75 // No session id to track — nothing to observe; drop the handler.
76 None => return,
77 };
78 let conn = self.inner.session.connection().clone();
79
80 // Box the handler so both watcher tasks can share a single, one-shot
81 // invocation slot.
82 let boxed: Arc<
83 dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
84 + Send
85 + Sync,
86 > = Arc::new(move || Box::pin(handler()));
87 let fired = Arc::new(AtomicBool::new(false));
88
89 // Subscribe at the browser/root level: `Target.detachedFromTarget`
90 // for the worker arrives on the parent session, not the worker's own.
91 let mut root_rx = conn.subscribe(None);
92 let boxed_root = Arc::clone(&boxed);
93 let fired_root = Arc::clone(&fired);
94 tokio::spawn(async move {
95 loop {
96 let ev = match root_rx.recv().await {
97 Ok(ev) => ev,
98 Err(_) => break,
99 };
100 if ev.method != "Target.detachedFromTarget" {
101 continue;
102 }
103 let detached = ev
104 .params
105 .get("sessionId")
106 .or_else(|| ev.params.get("session_id"))
107 .and_then(|v| v.as_str());
108 if detached == Some(session_id.as_str()) {
109 if !fired_root.swap(true, Ordering::SeqCst) {
110 (boxed_root)().await;
111 }
112 break;
113 }
114 }
115 });
116
117 // Fallback: watch the worker's own session for context teardown.
118 let mut self_rx = self.inner.session.subscribe();
119 let boxed_ctx = Arc::clone(&boxed);
120 let fired_ctx = Arc::clone(&fired);
121 tokio::spawn(async move {
122 loop {
123 let ev = match self_rx.recv().await {
124 Ok(ev) => ev,
125 Err(_) => break,
126 };
127 if ev.method == "Runtime.executionContextDestroyed"
128 && !fired_ctx.swap(true, Ordering::SeqCst)
129 {
130 (boxed_ctx)().await;
131 break;
132 }
133 }
134 });
135 }
136
137 /// Evaluate a JS expression in the worker's execution context, returning a
138 /// typed result.
139 ///
140 /// `expression` is wrapped as `(() => { return (<expression>); })()` and
141 /// the result's `value` is parsed with serde.
142 pub async fn evaluate<R: DeserializeOwned>(&self, expression: &str) -> Result<R> {
143 let wrapped = format!("(() => {{ return ({expression}); }})()");
144 let resp = self
145 .inner
146 .session
147 .send(
148 "Runtime.evaluate",
149 json!({
150 "expression": wrapped,
151 "returnByValue": true,
152 "awaitPromise": true,
153 }),
154 )
155 .await?;
156 if let Some(exc) = resp.get("exceptionDetails") {
157 let msg = exc
158 .get("exception")
159 .and_then(|e| e.get("description"))
160 .and_then(|v| v.as_str())
161 .unwrap_or("evaluation threw");
162 return Err(Error::ProtocolError(format!("eval error: {msg}")));
163 }
164 let value = resp
165 .get("result")
166 .and_then(|r| r.get("value"))
167 .cloned()
168 .unwrap_or(Value::Null);
169 serde_json::from_value::<R>(value).map_err(Error::from)
170 }
171}