mod register;
pub mod state;
use crate::error::EvalError;
use crate::eval::Vars;
use crate::eval::js::{arg, register, to_eval, yield_js};
use crate::fetch::cookie::{
CookieJar, merge_cookie_str, merge_login_into_headers, parse_cookie_str, registrable_domain,
request_registrable_domain, sanitize_header_value,
};
use crate::fetch::{FetchRequest, FetchResponse, Fetcher};
use crate::host::state::SourceState;
use crate::source::Method;
use boa_engine::object::ObjectInitializer;
use boa_engine::property::Attribute;
use boa_engine::{
Context, JsNativeError, JsObject, JsResult, JsValue, NativeFunction, Source, js_string,
};
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
use std::sync::Arc;
pub struct SourceHost {
pub state: SourceState,
pub dirty: bool,
domain: String,
fetcher: Arc<dyn Fetcher>,
rt: tokio::runtime::Runtime,
}
impl SourceHost {
pub fn new(
source_url: &str,
state: SourceState,
fetcher: Arc<dyn Fetcher>,
) -> Result<Self, EvalError> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| EvalError::Host(format!("create host runtime: {e}")))?;
Ok(Self {
state,
dirty: false,
domain: registrable_domain(source_url),
fetcher,
rt,
})
}
fn outbound_headers(
&self,
url: &str,
extra: Option<BTreeMap<String, String>>,
) -> HashMap<String, String> {
let mut headers = HashMap::new();
let domain = request_registrable_domain(url, &self.domain);
let jar_cookie = self.state.cookies.get(&domain);
merge_login_into_headers(
&self.state.login_header,
&self.domain,
&domain,
jar_cookie.map(String::as_str),
&mut headers,
);
if let Some(extra) = extra {
for (k, v) in extra {
headers.insert(k, sanitize_header_value(&v));
}
}
headers
}
fn request(
&mut self,
label: &str,
method: Method,
url: &str,
body: Option<String>,
extra_headers: Option<&str>,
) -> Result<FetchResponse, EvalError> {
let extra = match extra_headers {
Some(j) => Some(json_to_string_map(j)?),
None => None,
};
let req = FetchRequest {
url: url.to_string(),
method,
body,
headers: self.outbound_headers(url, extra),
..Default::default()
};
let fetcher = self.fetcher.clone();
let resp = self
.rt
.block_on(fetcher.fetch_full(req))
.map_err(|e| EvalError::Host(format!("{label} {url}: {e}")))?;
self.absorb_set_cookie(url, &resp);
Ok(resp)
}
fn absorb_set_cookie(&mut self, url: &str, resp: &FetchResponse) {
let Some(set_cookie) = resp.headers.get("set-cookie") else {
return;
};
let domain = request_registrable_domain(url, &self.domain);
let mut saved = BTreeMap::new();
if let Some(existing) = self.state.cookies.get(&domain) {
saved.insert(domain.clone(), existing.clone());
}
let mut jar = CookieJar::from_persistent(&saved);
jar.absorb_set_cookie(&domain, set_cookie);
match jar.cookie_header(&domain) {
Some(c) => {
self.state.cookies.insert(domain, c);
}
None => {
self.state.cookies.remove(&domain);
}
}
self.dirty = true;
}
fn ajax(&mut self, url: &str) -> Result<String, EvalError> {
self.request("ajax", Method::Get, url, None, None)
.map(|r| r.body)
}
fn connect(
&mut self,
url: &str,
extra_headers: Option<&str>,
) -> Result<FetchResponse, EvalError> {
self.request("connect", Method::Get, url, None, extra_headers)
}
fn post(
&mut self,
url: &str,
body: &str,
extra_headers: Option<&str>,
) -> Result<FetchResponse, EvalError> {
self.request(
"post",
Method::Post,
url,
Some(body.to_string()),
extra_headers,
)
}
fn set_login_header(&mut self, mut map: BTreeMap<String, String>) {
for v in map.values_mut() {
*v = sanitize_header_value(v);
}
self.state.login_header = map;
if let Some(cookie) = self
.state
.login_header
.get("Cookie")
.or_else(|| self.state.login_header.get("cookie"))
.cloned()
{
merge_cookie_jar(&mut self.state.cookies, &self.domain, &cookie);
}
self.dirty = true;
}
fn store_login_info(&mut self, plain: &str) -> Result<(), EvalError> {
self.state.set_login_info(plain)?;
self.dirty = true;
Ok(())
}
fn get_cookie(&self, domain: &str, key: Option<&str>) -> String {
let domain = domain.to_ascii_lowercase();
let jar = self.state.cookies.get(&domain).or_else(|| {
let reg = registrable_domain(&domain);
(reg != domain)
.then(|| self.state.cookies.get(®))
.flatten()
});
let Some(jar) = jar else {
return String::new();
};
match key {
None | Some("") => jar.clone(),
Some(k) => parse_cookie_str(jar).get(k).cloned().unwrap_or_default(),
}
}
}
thread_local! {
static ACTIVE_HOST: RefCell<Option<Rc<RefCell<SourceHost>>>> = const { RefCell::new(None) };
}
struct HostGuard;
impl HostGuard {
fn install(host: Rc<RefCell<SourceHost>>) -> Self {
ACTIVE_HOST.with(|slot| *slot.borrow_mut() = Some(host));
HostGuard
}
}
impl Drop for HostGuard {
fn drop(&mut self) {
ACTIVE_HOST.with(|slot| *slot.borrow_mut() = None);
}
}
fn active_host() -> Option<Rc<RefCell<SourceHost>>> {
ACTIVE_HOST.with(|slot| slot.borrow().clone())
}
fn merge_cookie_jar(jar: &mut BTreeMap<String, String>, domain: &str, add: &str) {
let add = sanitize_header_value(add);
let merged = merge_cookie_str(jar.get(domain).map(String::as_str).unwrap_or(""), &add);
jar.insert(domain.to_string(), merged);
}
fn json_to_string_map(json: &str) -> Result<BTreeMap<String, String>, EvalError> {
let v: serde_json::Value = serde_json::from_str(json)
.map_err(|e| EvalError::Host(format!("invalid json object: {e}")))?;
let obj = v
.as_object()
.ok_or_else(|| EvalError::Host("expected json object".into()))?;
Ok(obj
.iter()
.map(|(k, val)| {
let s = match val {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
(k.clone(), s)
})
.collect())
}
pub fn eval_js_with_host(
script: &str,
result: &str,
vars: &Vars,
host: Rc<RefCell<SourceHost>>,
) -> Result<String, EvalError> {
let _guard = HostGuard::install(host);
let mut ctx = Context::default();
register(&mut ctx, result, vars).map_err(to_eval)?; register::register_host(&mut ctx).map_err(to_eval)?; let value = ctx.eval(Source::from_bytes(script)).map_err(to_eval)?;
Ok(value
.to_string(&mut ctx)
.map_err(to_eval)?
.to_std_string_escaped())
}
pub fn eval_blocking(
script: &str,
result: &str,
vars: &Vars,
source_url: &str,
state: SourceState,
fetcher: Arc<dyn Fetcher>,
) -> Result<(String, SourceState, bool), EvalError> {
let host = Rc::new(RefCell::new(SourceHost::new(source_url, state, fetcher)?));
let out = eval_js_with_host(script, result, vars, host.clone())?;
let host = Rc::try_unwrap(host)
.map_err(|_| EvalError::Host("host still referenced after eval".into()))?
.into_inner();
Ok((out, host.state, host.dirty))
}
pub fn run_login(
login_js: &str,
source_url: &str,
state: SourceState,
fetcher: Arc<dyn Fetcher>,
) -> Result<(SourceState, bool), EvalError> {
let wrapped = format!("{login_js}\n;if(typeof login=='function'){{login.apply(this);}}\n");
let (_, state, dirty) = eval_blocking(&wrapped, "", &Vars::new(), source_url, state, fetcher)?;
Ok((state, dirty))
}
#[cfg(test)]
mod tests;