use deno_core::{JsRuntime, RuntimeOptions, v8};
use crate::{
dom::Dom,
js_runtime::{
extensions::{
console_ext::console_extension,
crypto_ext::crypto_extension,
dom_ext::dom_extension,
fetch_ext::fetch_extension,
timer_ext::{TimerState, timer_extension},
},
state::DomState,
},
};
pub struct BrowserJsRuntime {
inner: JsRuntime,
}
impl BrowserJsRuntime {
pub fn new(dom: Dom) -> Self {
Self::with_base_url(dom, None)
}
pub fn with_base_url(dom: Dom, base_url: Option<url::Url>) -> Self {
let mut state = DomState::new(dom);
if let Some(url) = base_url {
state = state.with_base_url(url);
}
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
console_extension::init(),
crypto_extension::init(),
dom_extension::init(),
timer_extension::init(),
fetch_extension::init(),
crate::js_runtime::extensions::input_ext::input_extension::init(),
crate::js_runtime::extensions::layout_ext::layout_extension::init(),
crate::js_runtime::extensions::nav_ext::nav_extension::init(),
crate::js_runtime::extensions::stealth_ext::stealth_extension::init(),
crate::js_runtime::extensions::sse_ext::sse_extension::init(),
crate::js_runtime::extensions::websocket_ext::websocket_extension::init(),
crate::js_runtime::extensions::perf_ext::perf_extension::init(),
crate::js_runtime::extensions::worker_ext::worker_extension::init(),
crate::js_runtime::extensions::canvas_ext::canvas_extension::init(),
crate::js_runtime::extensions::webgl_ext::webgl_extension::init(),
crate::js_runtime::extensions::audio_ext::audio_extension::init(),
],
..Default::default()
});
runtime.op_state().borrow_mut().put(state);
runtime.op_state().borrow_mut().put(TimerState::new());
runtime
.execute_script(
"[bootstrap:console]",
include_str!("js/console_bootstrap.js"),
)
.expect("console bootstrap failed");
runtime
.execute_script("[bootstrap:crypto]", include_str!("js/crypto_bootstrap.js"))
.expect("crypto bootstrap failed");
runtime
.execute_script("[bootstrap:timer]", include_str!("js/timer_bootstrap.js"))
.expect("timer bootstrap failed");
runtime
.execute_script("[bootstrap:dom]", include_str!("js/dom_bootstrap.js"))
.expect("dom bootstrap failed");
runtime
.execute_script("[bootstrap:fetch]", include_str!("js/fetch_bootstrap.js"))
.expect("fetch bootstrap failed");
runtime
.execute_script(
"[bootstrap:storage]",
include_str!("js/storage_bootstrap.js"),
)
.expect("storage bootstrap failed");
runtime
.execute_script(
"[bootstrap:stealth]",
include_str!("js/stealth_bootstrap.js"),
)
.expect("stealth bootstrap failed");
Self { inner: runtime }
}
pub fn execute_script(&mut self, code: &str) -> Result<String, JsError> {
let result = self
.inner
.execute_script("<anonymous>", code.to_string())
.map_err(|e| JsError::Execution(e.to_string()))?;
let __ctx = self.inner.main_context();
v8::scope_with_context!(scope, self.inner.v8_isolate(), __ctx);
let local = v8::Local::new(scope, result);
Ok(local
.to_string(scope)
.map(|s| s.to_rust_string_lossy(scope))
.unwrap_or_else(|| "undefined".to_string()))
}
pub async fn run_event_loop(&mut self) -> Result<(), JsError> {
self.inner
.run_event_loop(deno_core::PollEventLoopOptions::default())
.await
.map_err(|e| JsError::Execution(e.to_string()))
}
pub fn console_output(&mut self) -> Vec<crate::js_runtime::state::ConsoleMessage> {
let state = self.inner.op_state();
let state = state.borrow();
state.borrow::<DomState>().console_output.clone()
}
pub fn inner(&mut self) -> &mut JsRuntime {
&mut self.inner
}
pub fn set_user_agent(&mut self, ua: &str) {
let escaped = ua.replace('\\', "\\\\").replace('\'', "\\'");
let _ = self
.inner
.execute_script("<set-ua>", format!("globalThis.__hpx_ua = '{}';", escaped));
}
pub fn set_platform(&mut self, platform: &str, ua_platform: &str, ua_platform_version: &str) {
let p = platform.replace('\'', "\\'");
let uap = ua_platform.replace('\'', "\\'");
let uapv = ua_platform_version.replace('\'', "\\'");
let _ = self.inner.execute_script(
"<set-platform>",
format!(
"globalThis.__hpx_platform='{}';globalThis.__hpx_ua_platform='{}';globalThis.__hpx_ua_platform_version='{}';",
p, uap, uapv
),
);
}
pub fn set_stealth(&mut self, enabled: bool) {
let _ = self.inner.execute_script(
"<set-stealth>",
format!("globalThis.__hpx_stealth = {};", enabled),
);
}
pub fn run_page_init(&mut self) {
let _ = self
.inner
.execute_script("<hpx:page-init>", "globalThis.__hpx_init();".to_string());
}
}
impl Default for BrowserJsRuntime {
fn default() -> Self {
Self::new(Dom::new())
}
}
#[derive(Debug, thiserror::Error)]
pub enum JsError {
#[error("execution failed: {0}")]
Execution(String),
#[error("compilation failed: {0}")]
Compilation(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_eval() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt.execute_script("1 + 2").unwrap();
assert_eq!(result, "3");
}
#[test]
fn eval_string() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt.execute_script("'hello ' + 'world'").unwrap();
assert_eq!(result, "hello world");
}
#[test]
fn eval_syntax_error() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt.execute_script("function {{{}}}");
assert!(result.is_err());
}
#[test]
fn console_log_capture() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.execute_script("console.log('test message')").unwrap();
let output = rt.console_output();
assert_eq!(output.len(), 1);
assert_eq!(output[0].args[0], "test message");
assert_eq!(output[0].level, crate::js_runtime::state::ConsoleLevel::Log);
}
#[test]
fn console_warn_capture() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.execute_script("console.warn('warning msg')").unwrap();
let output = rt.console_output();
assert_eq!(output.len(), 1);
assert_eq!(
output[0].level,
crate::js_runtime::state::ConsoleLevel::Warn
);
}
#[test]
fn dom_create_element() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt
.execute_script("let el = document.createElement('div'); el.tagName")
.unwrap();
assert_eq!(result, "DIV");
}
#[test]
fn dom_text_content() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt
.execute_script(
r#"
let el = document.createElement('p');
el.textContent = 'hello';
el.textContent
"#,
)
.unwrap();
assert_eq!(result, "hello");
}
#[test]
fn dom_append_child() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt
.execute_script(
r#"
let parent = document.createElement('div');
let child = document.createElement('span');
parent.appendChild(child);
parent.childNodes.length
"#,
)
.unwrap();
assert_eq!(result, "1");
}
#[test]
fn dom_get_element_by_id() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt
.execute_script(
r#"
let el = document.createElement('div');
el.id = 'test';
// getElementById searches from document, so test direct API
let found = document.getElementById('nonexistent');
found === null
"#,
)
.unwrap();
assert_eq!(result, "true");
}
#[test]
fn dom_node_type() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt
.execute_script(
r#"
let el = document.createElement('div');
let text = document.createTextNode('hi');
`${el.nodeType}:${text.nodeType}`
"#,
)
.unwrap();
assert_eq!(result, "1:3");
}
#[test]
fn dom_parent_child_navigation() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt
.execute_script(
r#"
let parent = document.createElement('div');
let child = document.createElement('p');
parent.appendChild(child);
child.parentNode.tagName
"#,
)
.unwrap();
assert_eq!(result, "DIV");
}
#[test]
fn storage_local() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt
.execute_script(
r#"
localStorage.setItem('key', 'value');
localStorage.getItem('key')
"#,
)
.unwrap();
assert_eq!(result, "value");
}
#[test]
fn storage_length() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt
.execute_script(
r#"
localStorage.clear();
localStorage.setItem('a', '1');
localStorage.setItem('b', '2');
localStorage.length
"#,
)
.unwrap();
assert_eq!(result, "2");
}
#[test]
fn crypto_random_values() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt
.execute_script(
r#"
let buf = new Uint8Array(16);
crypto.getRandomValues(buf);
buf.length
"#,
)
.unwrap();
assert_eq!(result, "16");
}
#[test]
fn fetch_stub() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt.execute_script("typeof fetch").unwrap();
assert_eq!(result, "function");
}
#[test]
fn headers_class() {
let mut rt = BrowserJsRuntime::new(Dom::new());
let result = rt
.execute_script(
r#"
let h = new Headers([['content-type', 'text/html']]);
h.get('Content-Type')
"#,
)
.unwrap();
assert_eq!(result, "text/html");
}
#[test]
fn set_user_agent_test() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)");
let result = rt.execute_script("globalThis.__hpx_ua").unwrap();
assert_eq!(result, "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)");
}
#[test]
fn set_platform_test() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_platform("macos", "MacIntel", "15.0.0");
let result = rt
.execute_script(
"`${globalThis.__hpx_platform}|${globalThis.__hpx_ua_platform}|${globalThis.__hpx_ua_platform_version}`",
)
.unwrap();
assert_eq!(result, "macos|MacIntel|15.0.0");
}
#[test]
fn set_stealth_test() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
let result = rt.execute_script("globalThis.__hpx_stealth").unwrap();
assert_eq!(result, "true");
}
#[test]
fn run_page_init_test() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.set_user_agent("test-agent");
rt.run_page_init();
let result = rt.execute_script("typeof globalThis.__hpx_init").unwrap();
assert_eq!(result, "function");
}
#[test]
fn navigator_useragent_from_global() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36");
rt.set_platform("Win32", "Windows", "15.0.0");
rt.set_stealth(true);
rt.run_page_init();
let result = rt.execute_script("globalThis.navigator.userAgent").unwrap();
assert!(result.contains("Chrome/148"));
}
#[test]
fn webdriver_is_false() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt.execute_script("globalThis.navigator.webdriver").unwrap();
assert_eq!(result, "false");
}
#[test]
fn webdriver_descriptor_is_undefined() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script(
"Object.getOwnPropertyDescriptor(globalThis.navigator, 'webdriver') === undefined ? 'true' : 'false'",
)
.unwrap();
assert_eq!(result, "true");
}
#[test]
fn chrome_object_exists() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt.execute_script("typeof globalThis.chrome").unwrap();
assert_eq!(result, "object");
}
#[test]
fn chrome_csi_returns_timing() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script("typeof globalThis.chrome.csi().onloadT")
.unwrap();
assert_eq!(result, "number");
}
#[test]
fn screen_dimensions() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let w: i32 = rt
.execute_script("globalThis.screen.width")
.unwrap()
.parse()
.unwrap();
let h: i32 = rt
.execute_script("globalThis.screen.height")
.unwrap()
.parse()
.unwrap();
assert!(w > 0 && h > 0);
}
#[test]
fn grease_brands_count() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_user_agent("Mozilla/5.0 Chrome/148.0.0.0");
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script("globalThis.navigator.userAgentData.brands.length")
.unwrap();
assert_eq!(result, "3");
}
#[test]
fn grease_chromium_version() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_user_agent("Mozilla/5.0 Chrome/148.0.0.0");
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script(
"globalThis.navigator.userAgentData.brands.some(function(b){return b.brand==='Chromium' && b.version==='148'})",
)
.unwrap();
assert_eq!(result, "true");
}
#[test]
fn plugins_count() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script("globalThis.navigator.plugins.length")
.unwrap();
assert_eq!(result, "3");
}
#[test]
fn performance_memory_exists() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script("typeof globalThis.performance.memory.jsHeapSizeLimit")
.unwrap();
assert_eq!(result, "number");
}
#[test]
fn canvas_element_exists() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script("typeof HTMLCanvasElement !== 'undefined'")
.unwrap();
assert!(result == "true" || result == "false");
}
#[test]
fn audio_context_type() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt.execute_script("typeof AudioContext").unwrap();
assert_eq!(result, "function");
}
#[test]
fn audio_context_creates_instance() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script(
"var ctx = new AudioContext(); ctx.sampleRate === 44100 || ctx.sampleRate === 48000",
)
.unwrap();
assert_eq!(result, "true");
}
#[test]
fn audio_context_base_latency() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script("var ctx = new AudioContext(); typeof ctx.baseLatency")
.unwrap();
assert_eq!(result, "number");
}
#[test]
fn audio_context_state_running() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script("var ctx = new AudioContext(); ctx.state")
.unwrap();
assert_eq!(result, "running");
}
#[test]
fn audio_context_destination() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script("var ctx = new AudioContext(); ctx.destination.numberOfInputs")
.unwrap();
assert_eq!(result, "1");
}
#[test]
fn audio_context_webkit_compat() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt.execute_script("typeof webkitAudioContext").unwrap();
assert_eq!(result, "function");
}
#[test]
fn canvas_noise_deterministic_per_session() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_stealth(true);
rt.run_page_init();
let result = rt
.execute_script(
r#"
(function() {
try {
var c = document.createElement('canvas');
c.width = 2; c.height = 2;
var d1 = c.toDataURL(); var d2 = c.toDataURL();
return d1 === d2 ? 'true' : 'false';
} catch(e) {
return 'false';
}
})()
"#,
)
.unwrap();
assert!(result == "true" || result == "false");
}
#[test]
fn profile_to_globals_chrome_148_macos() {
let mut rt = BrowserJsRuntime::new(Dom::new());
rt.set_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36");
rt.set_platform("MacIntel", "macOS", "15.0.0");
rt.set_stealth(true);
rt.run_page_init();
let ua = rt.execute_script("navigator.userAgent").unwrap();
assert!(ua.contains("Macintosh"));
assert!(ua.contains("Chrome/148"));
let platform = rt.execute_script("navigator.platform").unwrap();
assert_eq!(platform, "MacIntel");
}
}