Skip to main content

hatchery_node/
browser_capture.rs

1//! Linux dig2browser capture inside the node (feature `d2b`).
2//!
3//! Owns a headless Chromium via dig2browser, captures viewport frames (JPEG via
4//! CDP `Page.captureScreenshot`, PNG fallback), and applies dig2-wire input.
5//! Transport is the restream G4aControl sidecar — this module never dials HQ.
6
7#![cfg(feature = "d2b")]
8
9use dig2browser::browser::{
10    BrowserPreference, BrowserProfile, LaunchConfig, StealthBrowser, StealthConfig, StealthPage,
11};
12use dig2browser::detect::args::RendererSandboxMode;
13use hatchery_core::{
14    decode_browser_input, BrowserFrameRegion, BrowserFrameTrace, BrowserInput, BrowserMouseButton, BrowserStreamEvent,
15};
16use serde_json::json;
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::time::Duration;
20use tokio::sync::{mpsc, oneshot, Mutex};
21
22const DEFAULT_URL: &str = "about:blank";
23const CAPTURE_EVERY: Duration = Duration::from_millis(250);
24const META_EVERY: Duration = Duration::from_millis(1000);
25const VIEW_W: u32 = 1280;
26const VIEW_H: u32 = 720;
27
28pub struct BrowserSessionHandle {
29    pub session: String,
30    input_tx: mpsc::UnboundedSender<BrowserInput>,
31    stop_tx: Option<oneshot::Sender<()>>,
32    join: Option<tokio::task::JoinHandle<()>>,
33}
34
35impl BrowserSessionHandle {
36    pub fn push_input(&self, input: BrowserInput) {
37        let _ = self.input_tx.send(input);
38    }
39
40    pub async fn stop(mut self) {
41        if let Some(tx) = self.stop_tx.take() {
42            let _ = tx.send(());
43        }
44        if let Some(join) = self.join.take() {
45            let _ = join.await;
46        }
47    }
48}
49
50/// Registry of live browser sessions on this node.
51#[derive(Default)]
52pub struct BrowserSessionMap {
53    inner: Mutex<std::collections::BTreeMap<String, BrowserSessionHandle>>,
54}
55
56impl BrowserSessionMap {
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    pub async fn start(
62        &self,
63        node_id: impl Into<String>,
64        session: String,
65        url: String,
66        profile_dir: PathBuf,
67        event_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
68    ) -> Result<(), String> {
69        hatchery_core::validate_browser_session_id(&session)?;
70        let node_id = node_id.into();
71        let mut guard = self.inner.lock().await;
72        if guard.contains_key(&session) {
73            // Re-open from HQ (panel reopened / reload): reuse the running browser.
74            let _ = event_tx.send(BrowserStreamEvent::Started { node_id, session: session.clone(), url });
75            if let Some(h) = guard.get(&session) { h.push_input(BrowserInput::Navigate { url: String::new() }); }
76            return Ok(());
77        }
78        let handle = spawn_session(
79            node_id,
80            session.clone(),
81            if url.trim().is_empty() {
82                DEFAULT_URL.to_owned()
83            } else {
84                url
85            },
86            profile_dir,
87            event_tx,
88        )?;
89        guard.insert(session, handle);
90        Ok(())
91    }
92
93    pub async fn input(&self, session: &str, bytes: &[u8]) -> Result<(), String> {
94        let input = decode_browser_input(bytes)?;
95        let guard = self.inner.lock().await;
96        let Some(handle) = guard.get(session) else {
97            return Err("browser session not found".into());
98        };
99        handle.push_input(input);
100        Ok(())
101    }
102
103    pub async fn stop(&self, session: &str) -> Result<(), String> {
104        let mut guard = self.inner.lock().await;
105        let Some(handle) = guard.remove(session) else {
106            return Err("browser session not found".into());
107        };
108        drop(guard);
109        handle.stop().await;
110        Ok(())
111    }
112
113    pub async fn stop_all(&self) {
114        let mut guard = self.inner.lock().await;
115        let handles: Vec<_> = std::mem::take(&mut *guard).into_values().collect();
116        drop(guard);
117        for h in handles {
118            h.stop().await;
119        }
120    }
121}
122
123fn spawn_session(
124    node_id: String,
125    session: String,
126    start_url: String,
127    profile_dir: PathBuf,
128    event_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
129) -> Result<BrowserSessionHandle, String> {
130    let (input_tx, input_rx) = mpsc::unbounded_channel();
131    let (stop_tx, stop_rx) = oneshot::channel();
132    let session_for_task = session.clone();
133    let join = tokio::spawn(async move {
134        if let Err(err) = run_session(
135            node_id.clone(),
136            session_for_task.clone(),
137            start_url,
138            profile_dir,
139            input_rx,
140            stop_rx,
141            event_tx.clone(),
142        )
143        .await
144        {
145            let _ = event_tx.send(BrowserStreamEvent::Error {
146                node_id: node_id.clone(),
147                session: session_for_task.clone(),
148                message: err,
149            });
150        }
151        let _ = event_tx.send(BrowserStreamEvent::Stopped {
152            node_id,
153            session: session_for_task,
154            reason: "closed".into(),
155        });
156    });
157    Ok(BrowserSessionHandle {
158        session,
159        input_tx,
160        stop_tx: Some(stop_tx),
161        join: Some(join),
162    })
163}
164
165async fn run_session(
166    node_id: String,
167    session: String,
168    start_url: String,
169    profile_dir: PathBuf,
170    mut input_rx: mpsc::UnboundedReceiver<BrowserInput>,
171    mut stop_rx: oneshot::Receiver<()>,
172    event_tx: mpsc::UnboundedSender<BrowserStreamEvent>,
173) -> Result<(), String> {
174    std::fs::create_dir_all(&profile_dir).map_err(|e| e.to_string())?;
175    let launch = LaunchConfig {
176        // Headful on Xvfb when a DISPLAY is present (less bot-detection); else headless.
177        headless: std::env::var("HATCHERY_D2B_HEADFUL").map(|v| v != "1").unwrap_or(true)
178            || std::env::var_os("DISPLAY").is_none(),
179        window_size: (VIEW_W, VIEW_H),
180        profile: BrowserProfile::Persistent(profile_dir.clone()),
181        browser_pref: BrowserPreference::ChromeOnly,
182        renderer_sandbox: RendererSandboxMode::CompatibilityDisabled,
183        extra_args: vec![
184            "--force-device-scale-factor=1".into(),
185            "--disable-dev-shm-usage".into(),
186        ],
187        ..LaunchConfig::default()
188    };
189    let browser = StealthBrowser::launch_with(launch, StealthConfig::default())
190        .await
191        .map_err(|e| e.to_string())?;
192    let page = browser
193        .new_page("about:blank")
194        .await
195        .map_err(|e| e.to_string())?;
196    let _ = page.set_viewport(VIEW_W, VIEW_H, 1.0).await;
197    // Headful: keep our page in front (a background tab never paints).
198    let _ = page.cdp_call("Page.bringToFront", None).await;
199    let _ = page
200        .cdp_call("Emulation.setFocusEmulationEnabled", Some(json!({ "enabled": true })))
201        .await;
202    let _ = page.cdp_call("Page.navigate", Some(json!({ "url": start_url }))).await;
203    let _ = event_tx.send(BrowserStreamEvent::Started {
204        node_id: node_id.clone(),
205        session: session.clone(),
206        url: start_url.clone(),
207    });
208
209    // True screencast over a side CDP connection to our page target (push frames,
210    // ack each); falls back to polling captureScreenshot if it can't be set up.
211    let (sc_tx, mut sc_rx) = mpsc::channel::<Vec<u8>>(4);
212    let screencast = match screencast::start(&profile_dir, &page, sc_tx).await {
213        Ok(h) => Some(h),
214        Err(e) => {
215            tracing::warn!(%session, error = %e, "d2b screencast unavailable; polling");
216            None
217        }
218    };
219
220    let mut tick = tokio::time::interval(if screencast.is_some() { META_EVERY } else { CAPTURE_EVERY });
221    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
222    let mut last_hash: u64 = 0;
223    let (mut url, mut title) = (start_url.clone(), String::new());
224    let mut input_n: u64 = 0;
225    // (input_n, received, cdp_ms, dispatched)
226    let mut pending: Option<(u64, std::time::Instant, u32, std::time::Instant, u32)> = None;
227    let mut last_frame: Option<Vec<u8>> = None;
228    let mut delta = delta::Delta::default();
229    loop {
230        let frame: Option<(Vec<u8>, &'static str)> = tokio::select! {
231            _ = &mut stop_rx => break,
232            f = sc_rx.recv(), if screencast.is_some() => f.map(|b| (b, "screencast")),
233            _ = tick.tick() => {
234                if screencast.is_some() {
235                    // Screencast pushes pixels; the tick only refreshes url/title.
236                    match tokio::time::timeout(Duration::from_secs(3), page_meta(&page)).await {
237                        Ok(Ok((u, ti))) if u != url || ti != title => {
238                            url = u; title = ti;
239                            last_frame.clone().map(|b| (b, "meta"))
240                        }
241                        _ => None,
242                    }
243                } else {
244                    if let Ok(Ok((u, ti))) = tokio::time::timeout(Duration::from_secs(3), page_meta(&page)).await { url = u; title = ti; }
245                    match tokio::time::timeout(Duration::from_secs(5), capture_frame(&page)).await {
246                        Ok(Ok(bytes)) => {
247                            let hash = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); bytes.hash(&mut h); h.finish() };
248                            if hash != last_hash { last_hash = hash; Some((bytes, "poll")) } else { None }
249                        }
250                        _ => None,
251                    }
252                }
253            }
254            input = input_rx.recv() => {
255                let Some(input) = input else { break };
256                let received = std::time::Instant::now();
257                input_n += 1;
258                let queue_ms = 0u32;
259                if matches!(&input, BrowserInput::Navigate { url } if url.is_empty()) {
260                    // Re-open: resend the last frame so a new viewer paints at once.
261                    last_frame.clone().map(|b| (b, "replay"))
262                } else {
263                    let t0 = std::time::Instant::now();
264                    match apply_input(&page, &input).await {
265                        Err(err) => { tracing::warn!(%session, error = %err, "browser input failed"); None }
266                        Ok(()) => {
267                            let cdp_ms = t0.elapsed().as_millis() as u32;
268                            pending = Some((input_n, received, cdp_ms, std::time::Instant::now(), queue_ms));
269                            if screencast.is_none() {
270                                last_hash = 0;
271                                tick.reset_immediately();
272                            }
273                            None
274                        }
275                    }
276                }
277            }
278        };
279        let Some((bytes, source)) = frame else { continue };
280        // Screencast frames become deltas (changed rectangle only) when small enough:
281        // a few KB fit one TCP window on every long hop instead of several RTTs.
282        let (bytes, region) = if source == "screencast" {
283            match delta.encode(&bytes) {
284                delta::Out::Unchanged => continue,
285                delta::Out::Full => (bytes, None),
286                delta::Out::Region(b, r) => (b, Some(r)),
287            }
288        } else if source == "replay" || source == "meta" {
289            (delta.full_jpeg().unwrap_or(bytes), None)
290        } else {
291            (bytes, None)
292        };
293        if region.is_none() && (source == "screencast" || source == "poll") {
294            last_frame = Some(bytes.clone());
295        }
296        let trace = if source == "screencast" || source == "poll" {
297            pending.take().map(|(n, received, cdp_ms, dispatched, queue_ms)| BrowserFrameTrace {
298                input_n: n,
299                queue_ms,
300                cdp_ms,
301                to_frame_ms: dispatched.elapsed().as_millis() as u32,
302                node_ms: received.elapsed().as_millis() as u32,
303                source: if region.is_some() { "screencast-delta".to_owned() } else { source.to_owned() },
304            })
305        } else { None };
306        let _ = event_tx.send(BrowserStreamEvent::Frame {
307            node_id: node_id.clone(),
308            session: session.clone(),
309            bytes,
310            width: VIEW_W,
311            height: VIEW_H,
312            url: url.clone(),
313            title: title.clone(),
314            trace,
315            region,
316        });
317    }
318    if let Some(h) = screencast { h.abort(); }
319    let _ = browser.close().await;
320    Ok(())
321}
322
323async fn page_meta(page: &StealthPage) -> Result<(String, String), String> {
324    let v = page.eval("[location.href, document.title]").await.map_err(|e| e.to_string())?;
325    let a = v.as_array().cloned().unwrap_or_default();
326    Ok((
327        a.first().and_then(|x| x.as_str()).unwrap_or_default().to_owned(),
328        a.get(1).and_then(|x| x.as_str()).unwrap_or_default().to_owned(),
329    ))
330}
331
332/// Delta frames: diff each decoded frame against the previous one in 32px tiles
333/// and send only the changed rectangle when it is a small part of the screen.
334mod delta {
335    use super::BrowserFrameRegion;
336    use image::{codecs::jpeg::JpegEncoder, ImageReader, RgbImage};
337
338    const TILE: u32 = 32;
339    #[derive(Default)]
340    pub struct Delta { prev: Option<RgbImage> }
341    pub enum Out { Unchanged, Full, Region(Vec<u8>, BrowserFrameRegion) }
342
343    impl Delta {
344        pub fn encode(&mut self, jpeg: &[u8]) -> Out {
345            let Ok(img) = ImageReader::new(std::io::Cursor::new(jpeg)).with_guessed_format().map_err(|_| ()).and_then(|r| r.decode().map_err(|_| ())) else { return Out::Full };
346            let cur = img.to_rgb8();
347            let Some(prev) = self.prev.as_ref().filter(|p| p.dimensions() == cur.dimensions()) else {
348                self.prev = Some(cur);
349                return Out::Full;
350            };
351            let (w, h) = cur.dimensions();
352            let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);
353            let (pr, cr) = (prev.as_raw(), cur.as_raw());
354            for ty in (0..h).step_by(TILE as usize) {
355                for tx in (0..w).step_by(TILE as usize) {
356                    let tw = TILE.min(w - tx); let th = TILE.min(h - ty);
357                    let mut changed = false;
358                    for yy in ty..ty + th {
359                        let a = ((yy * w + tx) * 3) as usize; let b = a + (tw * 3) as usize;
360                        // JPEG noise tolerance: a tile changes when any byte moves > 24.
361                        if pr[a..b].iter().zip(&cr[a..b]).any(|(p, c)| p.abs_diff(*c) > 24) { changed = true; break; }
362                    }
363                    if changed { x0 = x0.min(tx); y0 = y0.min(ty); x1 = x1.max(tx + tw); y1 = y1.max(ty + th); }
364                }
365            }
366            if x0 == u32::MAX { return Out::Unchanged; }
367            let (rw, rh) = (x1 - x0, y1 - y0);
368            if (rw * rh) as u64 * 2 > (w * h) as u64 {
369                self.prev = Some(cur);
370                return Out::Full;
371            }
372            let crop = image::imageops::crop_imm(&cur, x0, y0, rw, rh).to_image();
373            let mut out = Vec::new();
374            if JpegEncoder::new_with_quality(&mut out, 70).encode_image(&crop).is_err() { self.prev = Some(cur); return Out::Full; }
375            // Base the next diff on what the viewer now shows (prev + this region).
376            let mut next = prev.clone();
377            image::imageops::replace(&mut next, &crop, x0 as i64, y0 as i64);
378            self.prev = Some(next);
379            Out::Region(out, BrowserFrameRegion { x: x0, y: y0, w: rw, h: rh, src_w: w, src_h: h })
380        }
381
382        /// Current composite as a full JPEG (for a viewer that just attached).
383        pub fn full_jpeg(&self) -> Option<Vec<u8>> {
384            let img = self.prev.as_ref()?;
385            let mut out = Vec::new();
386            JpegEncoder::new_with_quality(&mut out, 60).encode_image(img).ok()?;
387            Some(out)
388        }
389    }
390}
391
392/// Side CDP websocket to our page target for Page.startScreencast.
393mod screencast {
394    use super::*;
395    use futures_util::{SinkExt, StreamExt};
396    use tokio_tungstenite::tungstenite::Message;
397
398    /// DevTools port of the Chrome that owns `profile_dir`: DevToolsActivePort if
399    /// present, else the --remote-debugging-port of the process using that profile.
400    fn devtools_port(profile_dir: &std::path::Path) -> Option<u16> {
401        if let Ok(s) = std::fs::read_to_string(profile_dir.join("DevToolsActivePort")) {
402            if let Some(p) = s.lines().next().and_then(|l| l.trim().parse().ok()) { return Some(p); }
403        }
404        let want = format!("--user-data-dir={}", profile_dir.display());
405        for entry in std::fs::read_dir("/proc").ok()?.flatten() {
406            let Ok(raw) = std::fs::read(entry.path().join("cmdline")) else { continue };
407            let args: Vec<String> = raw.split(|b| *b == 0).map(|a| String::from_utf8_lossy(a).into_owned()).collect();
408            if !args.iter().any(|a| a == &want) { continue; }
409            if let Some(p) = args.iter().find_map(|a| a.strip_prefix("--remote-debugging-port=").and_then(|v| v.parse().ok())) {
410                return Some(p);
411            }
412        }
413        None
414    }
415
416    /// Max JPEG bytes per frame so one frame fits a single restream message.
417    const BUDGET: usize = 40 * 1024;
418
419    pub async fn start(
420        profile_dir: &std::path::Path,
421        page: &StealthPage,
422        out: mpsc::Sender<Vec<u8>>,
423    ) -> Result<tokio::task::JoinHandle<()>, String> {
424        let port = devtools_port(profile_dir).ok_or("devtools port not found")?;
425        let info = page.cdp_call("Target.getTargetInfo", None).await.map_err(|e| e.to_string())?;
426        let target = info["targetInfo"]["targetId"].as_str().ok_or("no target id")?.to_owned();
427        let url = format!("ws://127.0.0.1:{port}/devtools/page/{target}");
428        let (ws, _) = tokio_tungstenite::connect_async(&url).await.map_err(|e| e.to_string())?;
429        let (mut tx, mut rx) = ws.split();
430        let mut quality: u32 = 60;
431        let mut max_w: u32 = VIEW_W;
432        let mut id: u64 = 1;
433        let start_msg = |id: u64, q: u32, w: u32| json!({ "id": id, "method": "Page.startScreencast",
434            "params": { "format": "jpeg", "quality": q, "maxWidth": w, "maxHeight": w * VIEW_H / VIEW_W, "everyNthFrame": 1 } }).to_string();
435        tx.send(Message::text(json!({ "id": id, "method": "Page.enable" }).to_string())).await.map_err(|e| e.to_string())?;
436        id += 1;
437        tx.send(Message::text(start_msg(id, quality, max_w))).await.map_err(|e| e.to_string())?;
438        Ok(tokio::spawn(async move {
439            while let Some(Ok(msg)) = rx.next().await {
440                let Message::Text(text) = msg else { continue };
441                let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else { continue };
442                if v["method"] != "Page.screencastFrame" { continue; }
443                let sid = v["params"]["sessionId"].clone();
444                // Ack first so Chrome can start producing the next frame immediately.
445                id += 1;
446                let _ = tx.send(Message::text(json!({ "id": id, "method": "Page.screencastFrameAck", "params": { "sessionId": sid } }).to_string())).await;
447                let Some(b64) = v["params"]["data"].as_str() else { continue };
448                use base64::Engine;
449                let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(b64) else { continue };
450                if bytes.len() > BUDGET {
451                    // Too big for one restream message: step quality, then size, and restart.
452                    if quality > 25 { quality -= 15 } else if max_w > 640 { max_w = max_w * 3 / 4 } else { continue }
453                    id += 1;
454                    let _ = tx.send(Message::text(json!({ "id": id, "method": "Page.stopScreencast" }).to_string())).await;
455                    id += 1;
456                    let _ = tx.send(Message::text(start_msg(id, quality, max_w))).await;
457                    continue;
458                }
459                // Keep latency low: if the consumer is behind, drop rather than queue.
460                let _ = out.try_send(bytes);
461            }
462        }))
463    }
464}
465
466async fn capture_frame(page: &StealthPage) -> Result<Vec<u8>, String> {
467    // JPEG via CDP, sized to fit one Noise transport message (64 KiB incl. envelope):
468    // step quality down, then scale, until under FRAME_BUDGET. Oversized frames used to
469    // kill the restream uplink ("noise: input error") on busy pages.
470    const FRAME_BUDGET: usize = 40 * 1024;
471    let mut bytes = Vec::new();
472    'fit: for scale in [1.0f64, 0.75, 0.5] {
473        for q in [60u32, 45, 32, 22, 14] {
474            let mut params = json!({ "format": "jpeg", "quality": q });
475            if scale < 1.0 {
476                params["clip"] = json!({ "x": 0, "y": 0, "width": VIEW_W, "height": VIEW_H, "scale": scale });
477            }
478            let v = page.cdp_call("Page.captureScreenshot", Some(params)).await.map_err(|e| e.to_string())?;
479            let b64 = v.get("data").and_then(|x| x.as_str()).ok_or("no jpeg data")?;
480            use base64::Engine;
481            bytes = base64::engine::general_purpose::STANDARD.decode(b64).map_err(|e| e.to_string())?;
482            if bytes.len() <= FRAME_BUDGET {
483                break 'fit;
484            }
485        }
486    }
487    if bytes.len() > FRAME_BUDGET {
488        return Err(format!("frame too large ({} bytes)", bytes.len()));
489    }
490    Ok(bytes)
491}
492
493async fn apply_input(page: &StealthPage, input: &BrowserInput) -> Result<(), String> {
494    match input {
495        BrowserInput::Click { x, y, button } => {
496            // Raw CDP, viewport CSS pixels (DPR 1): move, press, release — a real
497            // trusted click that also reaches cross-origin iframes (e.g. Turnstile).
498            let b = match button {
499                BrowserMouseButton::Left => "left",
500                BrowserMouseButton::Right => "right",
501                BrowserMouseButton::Middle => "middle",
502            };
503            tracing::info!(x = *x, y = *y, button = b, "d2b click");
504            page.cdp_call(
505                "Input.dispatchMouseEvent",
506                Some(json!({ "type": "mouseMoved", "x": x, "y": y, "button": "none" })),
507            )
508            .await
509            .map_err(|e| e.to_string())?;
510            for ty in ["mousePressed", "mouseReleased"] {
511                page.cdp_call(
512                    "Input.dispatchMouseEvent",
513                    Some(json!({ "type": ty, "x": x, "y": y, "button": b, "buttons": if ty == "mousePressed" { 1 } else { 0 }, "clickCount": 1 })),
514                )
515                .await
516                .map_err(|e| e.to_string())?;
517            }
518        }
519        BrowserInput::Scroll { x, y, dx, dy } => {
520            page.wheel(*x, *y, *dx, *dy)
521                .await
522                .map_err(|e| e.to_string())?
523        }
524        BrowserInput::Key { key } => page.key_press(key).await.map_err(|e| e.to_string())?,
525        BrowserInput::Text { text } => {
526            // Prefer CDP insertText; fall back to per-character key_press.
527            match page
528                .cdp_call("Input.insertText", Some(json!({ "text": text })))
529                .await
530            {
531                Ok(_) => {}
532                Err(_) => {
533                    for ch in text.chars() {
534                        page.key_press(&ch.to_string())
535                            .await
536                            .map_err(|e| e.to_string())?;
537                    }
538                }
539            }
540        }
541        BrowserInput::Navigate { url } if url.is_empty() => {}
542        BrowserInput::Navigate { url } => {
543            // Raw Page.navigate returns once the request commits; frames keep
544            // streaming while the page loads (goto blocked the loop on load).
545            tracing::info!(%url, "d2b navigate");
546            let _ = page.cdp_call("Page.bringToFront", None).await;
547            let r = tokio::time::timeout(
548                Duration::from_secs(20),
549                page.cdp_call("Page.navigate", Some(json!({ "url": url }))),
550            )
551            .await
552            .map_err(|_| "navigate timed out".to_string())?
553            .map_err(|e| e.to_string())?;
554            if let Some(err) = r.get("errorText").and_then(|v| v.as_str()) {
555                if !err.is_empty() {
556                    return Err(format!("navigate: {err}"));
557                }
558            }
559        }
560    }
561    Ok(())
562}
563
564/// Shared map for the restream bridge (process-global for the node binary).
565pub fn shared_map() -> Arc<BrowserSessionMap> {
566    use std::sync::OnceLock;
567    static MAP: OnceLock<Arc<BrowserSessionMap>> = OnceLock::new();
568    MAP.get_or_init(|| Arc::new(BrowserSessionMap::new())).clone()
569}