use super::{BrowserSession, access};
use crate::browser::{
BrowserError, BrowserOutput,
request::{AxiosRequest, DiagnoseRequest, FetchRequest, NetworkLogRequest, XhrRequest},
};
use std::time::Duration;
const EVAL_TIMEOUT: Duration = Duration::from_secs(60);
pub(super) async fn network_log(
session: &BrowserSession,
request: NetworkLogRequest,
) -> Result<BrowserOutput, BrowserError> {
let page = access::current_page(session).await?;
let _ = super::lifecycle::install_page_hooks(&page).await;
let filter = serde_json::json!({
"limit": request.limit,
"url_contains": request.url_contains,
"method": request.method.map(|m| m.to_uppercase()),
});
let script = format!(
r#"(() => {{
const f = {filter};
const log = (window.__codetether_net_log || []).slice();
const limit = typeof f.limit === 'number' && f.limit > 0 ? f.limit : log.length;
const method = f.method || null;
const needle = f.url_contains || null;
const out = [];
for (let i = log.length - 1; i >= 0 && out.length < limit; i--) {{
const e = log[i];
if (method && e.method !== method) continue;
if (needle && (!e.url || e.url.indexOf(needle) === -1)) continue;
out.push(e);
}}
return out.reverse();
}})()"#,
filter = filter
);
let result = tokio::time::timeout(EVAL_TIMEOUT, page.evaluate_expression(script))
.await
.map_err(|_| BrowserError::EvaluationTimeout)??;
let value = result.object().value.clone().unwrap_or(serde_json::json!([]));
Ok(BrowserOutput::Json(value))
}
pub(super) async fn fetch(
session: &BrowserSession,
request: FetchRequest,
) -> Result<BrowserOutput, BrowserError> {
let page = access::current_page(session).await?;
let credentials = request.credentials.unwrap_or_else(|| "include".to_string());
let init = serde_json::json!({
"method": request.method.to_uppercase(),
"headers": request.headers.unwrap_or_default(),
"body": request.body,
"credentials": credentials,
});
let url = serde_json::to_string(&request.url)?;
let init_str = init.to_string();
let script = format!(
r#"(async () => {{
const init = {init_str};
if (init.body === null) delete init.body;
try {{
const r = await fetch({url}, init);
const headers = {{}};
try {{ r.headers.forEach((v, k) => {{ headers[String(k).toLowerCase()] = String(v); }}); }} catch (_) {{}}
let body = null;
try {{ body = await r.text(); }} catch (_) {{}}
return {{ ok: r.ok, status: r.status, status_text: r.statusText, url: r.url, headers, body }};
}} catch (err) {{
return {{ ok: false, status: 0, status_text: String((err && err.message) || err), url: {url}, headers: {{}}, body: null, error: String(err && err.stack || err) }};
}}
}})()"#,
url = url,
init_str = init_str,
);
let result = tokio::time::timeout(EVAL_TIMEOUT, page.evaluate_expression(script))
.await
.map_err(|_| BrowserError::EvaluationTimeout)??;
let value = result
.object()
.value
.clone()
.unwrap_or(serde_json::json!({"ok": false, "status": 0, "error": "no value"}));
Ok(BrowserOutput::Json(value))
}
pub(super) async fn xhr(
session: &BrowserSession,
request: XhrRequest,
) -> Result<BrowserOutput, BrowserError> {
let page = access::current_page(session).await?;
let headers_json = serde_json::to_string(&request.headers.unwrap_or_default())?;
let url_json = serde_json::to_string(&request.url)?;
let method_json = serde_json::to_string(&request.method.to_uppercase())?;
let body_json = serde_json::to_string(&request.body)?;
let with_credentials = request.with_credentials.unwrap_or(true);
let script = format!(
r#"(() => new Promise((resolve) => {{
try {{
const xhr = new XMLHttpRequest();
xhr.open({method_json}, {url_json}, true);
xhr.withCredentials = {with_credentials};
const headers = {headers_json};
for (const [k, v] of Object.entries(headers)) {{
try {{ xhr.setRequestHeader(k, v); }} catch (_) {{}}
}}
xhr.onload = () => {{
const hdrs = {{}};
try {{
const raw = xhr.getAllResponseHeaders() || '';
raw.trim().split(/\r?\n/).forEach((line) => {{
const idx = line.indexOf(':');
if (idx > 0) hdrs[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
}});
}} catch (_) {{}}
resolve({{
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
status_text: xhr.statusText,
url: xhr.responseURL,
headers: hdrs,
body: xhr.responseText,
}});
}};
xhr.onerror = () => resolve({{
ok: false, status: xhr.status || 0, status_text: 'network error',
url: {url_json}, headers: {{}}, body: null,
error: 'xhr.onerror (likely CORS / WAF / offline)',
}});
xhr.ontimeout = () => resolve({{
ok: false, status: 0, status_text: 'timeout',
url: {url_json}, headers: {{}}, body: null, error: 'xhr timeout',
}});
const body = {body_json};
xhr.send(body === null || body === undefined ? null : body);
}} catch (err) {{
resolve({{ ok: false, status: 0, status_text: String((err && err.message) || err), url: {url_json}, headers: {{}}, body: null, error: String(err && err.stack || err) }});
}}
}}))()"#,
method_json = method_json,
url_json = url_json,
with_credentials = with_credentials,
headers_json = headers_json,
body_json = body_json,
);
let result = tokio::time::timeout(EVAL_TIMEOUT, page.evaluate_expression(script))
.await
.map_err(|_| BrowserError::EvaluationTimeout)??;
let value = result
.object()
.value
.clone()
.unwrap_or(serde_json::json!({"ok": false, "status": 0, "error": "no value"}));
Ok(BrowserOutput::Json(value))
}
pub(super) async fn axios(
session: &BrowserSession,
request: AxiosRequest,
) -> Result<BrowserOutput, BrowserError> {
let page = access::current_page(session).await?;
let body_json = request.body.unwrap_or(serde_json::Value::Null);
let headers_json = serde_json::to_value(request.headers.unwrap_or_default())
.unwrap_or(serde_json::Value::Object(Default::default()));
let url_json = serde_json::to_string(&request.url)?;
let method = request.method.to_lowercase();
let body_methods = matches!(method.as_str(), "post" | "put" | "patch");
let axios_path = request
.axios_path
.unwrap_or_else(|| "__autodetect__".to_string());
let axios_path_json = serde_json::to_string(&axios_path)?;
let body_str = serde_json::to_string(&body_json)?;
let headers_str = serde_json::to_string(&headers_json)?;
let script = format!(
r#"(async () => {{
const needle = {axios_path_json};
const findAxios = () => {{
if (needle !== '__autodetect__') {{
try {{
const parts = needle.replace(/^window\./, '').split('.');
let obj = window;
for (const p of parts) obj = obj && obj[p];
if (obj && typeof obj.request === 'function') return {{ instance: obj, path: needle }};
}} catch (_) {{}}
return null;
}}
// Auto-discovery: walk window keys looking for an object that
// quacks like an axios instance. Prefer ones with a baseURL so
// relative URLs resolve.
const seen = new Set();
const candidates = [];
const pushIfAxios = (obj, path) => {{
if (!obj || typeof obj !== 'object' || seen.has(obj)) return;
seen.add(obj);
const isAxios = typeof obj.request === 'function'
&& obj.defaults && typeof obj.defaults === 'object';
if (isAxios) candidates.push({{ instance: obj, path, hasBaseURL: !!obj.defaults.baseURL }});
}};
try {{
if (window.axios) pushIfAxios(window.axios, 'window.axios');
for (const k of Object.keys(window)) {{
try {{ pushIfAxios(window[k], 'window.' + k); }} catch (_) {{}}
}}
}} catch (_) {{}}
// Favor instances with a baseURL over the bare library.
candidates.sort((a, b) => Number(b.hasBaseURL) - Number(a.hasBaseURL));
return candidates[0] || null;
}};
const found = findAxios();
if (!found) {{
return {{ ok: false, status: 0, error: 'no axios instance on window', axios_path: null }};
}}
const ax = found.instance;
const url = {url};
const body = {body_str};
const headers = {headers_str};
const config = {{ headers }};
try {{
let resp;
const method = {method_json};
if (method === 'get') resp = await ax.get(url, config);
else if (method === 'delete') resp = await ax.delete(url, config);
else if (method === 'head') resp = await ax.head(url, config);
else if ({body_methods}) resp = await ax[method](url, body, config);
else resp = await ax.request({{ method, url, data: body, headers }});
return {{
ok: true,
status: resp.status,
status_text: resp.statusText,
headers: resp.headers || {{}},
data: resp.data,
axios_path: found.path,
}};
}} catch (err) {{
const resp = err && err.response;
return {{
ok: false,
status: resp ? resp.status : 0,
status_text: resp ? resp.statusText : String((err && err.message) || err),
headers: (resp && resp.headers) || {{}},
data: resp && resp.data,
error: String((err && err.stack) || (err && err.message) || err),
axios_path: found.path,
}};
}}
}})()"#,
axios_path_json = axios_path_json,
url = url_json,
body_str = body_str,
headers_str = headers_str,
method_json = serde_json::to_string(&method)?,
body_methods = body_methods,
);
let result = tokio::time::timeout(EVAL_TIMEOUT, page.evaluate_expression(script))
.await
.map_err(|_| BrowserError::EvaluationTimeout)??;
let value = result
.object()
.value
.clone()
.unwrap_or(serde_json::json!({"ok": false, "status": 0, "error": "no value"}));
Ok(BrowserOutput::Json(value))
}
pub(super) async fn diagnose(
session: &BrowserSession,
_request: DiagnoseRequest,
) -> Result<BrowserOutput, BrowserError> {
let page = access::current_page(session).await?;
let _ = super::lifecycle::install_page_hooks(&page).await;
const SCRIPT: &str = r#"(async () => {
const out = {};
// Service workers — these intercept fetch and are a common cause of
// 'Failed to fetch' replays.
try {
if (navigator.serviceWorker && navigator.serviceWorker.getRegistrations) {
const regs = await navigator.serviceWorker.getRegistrations();
out.service_workers = regs.map((r) => ({
scope: r.scope,
active: r.active && { state: r.active.state, script: r.active.scriptURL },
waiting: r.waiting && { state: r.waiting.state, script: r.waiting.scriptURL },
installing: r.installing && { state: r.installing.state, script: r.installing.scriptURL },
}));
} else { out.service_workers = []; }
} catch (e) { out.service_workers_error = String(e); }
// Axios instances on window.
try {
const axs = [];
const seen = new Set();
const inspect = (obj, path) => {
if (!obj || typeof obj !== 'object' || seen.has(obj)) return;
seen.add(obj);
if (typeof obj.request === 'function' && obj.defaults && typeof obj.defaults === 'object') {
axs.push({
path,
baseURL: obj.defaults.baseURL || null,
common_headers: (obj.defaults.headers && obj.defaults.headers.common) || {},
withCredentials: !!obj.defaults.withCredentials,
interceptor_counts: {
request: (obj.interceptors && obj.interceptors.request && obj.interceptors.request.handlers && obj.interceptors.request.handlers.length) || 0,
response: (obj.interceptors && obj.interceptors.response && obj.interceptors.response.handlers && obj.interceptors.response.handlers.length) || 0,
},
});
}
};
if (window.axios) inspect(window.axios, 'window.axios');
for (const k of Object.keys(window)) {
try { inspect(window[k], 'window.' + k); } catch (_) {}
}
out.axios_instances = axs;
} catch (e) { out.axios_error = String(e); }
// Document CSP + origin + base.
try {
out.document = {
url: location.href,
origin: location.origin,
base: (document.querySelector('base') || {}).href || null,
csp_meta: Array.from(document.querySelectorAll('meta[http-equiv="Content-Security-Policy"]')).map((m) => m.content),
};
} catch (e) { out.document_error = String(e); }
// Network log summary.
try {
const log = (window.__codetether_net_log || []);
const by_method = {};
const by_kind = {};
for (const e of log) {
by_method[e.method] = (by_method[e.method] || 0) + 1;
by_kind[e.kind] = (by_kind[e.kind] || 0) + 1;
}
out.network_log = { total: log.length, by_method, by_kind, last: log.slice(-3) };
} catch (e) { out.network_log_error = String(e); }
return out;
})()"#;
let result = tokio::time::timeout(EVAL_TIMEOUT, page.evaluate_expression(SCRIPT))
.await
.map_err(|_| BrowserError::EvaluationTimeout)??;
let value = result
.object()
.value
.clone()
.unwrap_or(serde_json::json!({}));
Ok(BrowserOutput::Json(value))
}