use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque};
use std::rc::Rc;
use std::sync::Arc;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use deno_core::op2;
use deno_core::Extension;
use deno_core::JsBuffer;
use deno_core::v8;
use deno_core::OpState;
use telemaco_dom::{DomTree, NodeData, NodeId};
use telemaco_dom::tree::{AttachShadowError, ShadowRootMode};
#[cfg(feature = "render")]
use telemaco_net::{RequestCredentials, RequestMode, ResourceRequest};
#[cfg(feature = "stealth")]
use telemaco_net::StealthHttpClient;
use telemaco_net::{
CallbackRegistry, CookieJar, TelemacoHttpClient, RequestInfo, ResourceType, Response,
};
use tokio::sync::Mutex;
#[cfg(feature = "render")]
use serde::Deserialize;
use crate::import_map::ImportMap;
use crate::write_stream::DocumentWriteStream;
pub type InterceptCallback = Arc<
Mutex<
Option<Box<dyn Fn(String, String, String) -> Option<(u16, String, String)> + Send + Sync>>,
>,
>;
#[derive(Debug)]
pub enum InterceptResolution {
Continue {
url: Option<String>,
method: Option<String>,
headers: Option<HashMap<String, String>>,
body: Option<String>,
},
Fulfill {
status: u16,
headers: HashMap<String, String>,
body: String,
},
Fail {
reason: String,
},
}
pub struct InterceptedRequest {
pub request_id: String,
pub url: String,
pub method: String,
pub headers: HashMap<String, String>,
pub resource_type: String,
pub resolver: tokio::sync::oneshot::Sender<InterceptResolution>,
}
#[derive(Debug, Clone)]
pub struct StoredNetworkResponseBody {
pub body: String,
pub base64_encoded: bool,
}
#[derive(Debug, Clone)]
pub struct JsNetworkEvent {
pub request_id: String,
pub url: String,
pub method: String,
pub status: u16,
pub response_headers: HashMap<String, String>,
pub body_size: usize,
pub timestamp: f64,
}
#[cfg(feature = "render")]
pub use telemaco_render::ImageRequestProfile;
#[cfg(feature = "render")]
pub(crate) struct CanvasBackingSurface {
pub width: u32,
pub height: u32,
pub pixels: JsBuffer,
}
pub struct TelemacoState {
pub dom: Option<DomTree>,
pub url: String,
pub encoding: String,
pub title: String,
pub referrer: String,
pub blocked_urls: Vec<String>,
pub cookie_jar: Option<Arc<CookieJar>>,
pub http_client: Option<Arc<TelemacoHttpClient>>,
pub callbacks: Option<Arc<CallbackRegistry>>,
#[cfg(feature = "stealth")]
pub stealth_client: Option<Arc<StealthHttpClient>>,
pub pending_navigation: Option<(String, String, String)>,
pub intercept_tx: Option<tokio::sync::mpsc::UnboundedSender<InterceptedRequest>>,
pub intercept_counter: u64,
pub intercept_enabled: bool,
pub pending_binding_calls: Vec<(String, String)>,
pub pending_runtime_events: VecDeque<RuntimeEvent>,
pub runtime_events_enabled: bool,
pub runtime_exception_counter: u64,
pub network_response_bodies: HashMap<String, StoredNetworkResponseBody>,
pub network_response_body_order: VecDeque<String>,
pub network_response_body_counter: u64,
pub fetched_urls: Vec<String>,
pub js_network_events: Vec<JsNetworkEvent>,
pub pending_frames: Vec<PendingFrame>,
pub pending_frame_bytes: usize,
pub frame_id_counter: u32,
pub frame_id: u32,
pub pending_frame_messages: Vec<PendingFrameMessage>,
pub pending_frame_message_bytes: usize,
pub page_in_flight: Arc<std::sync::atomic::AtomicU32>,
pub activity_generation: u64,
pub document_generation: u64,
pub base_url_cache: RefCell<Option<BaseUrlCache>>,
#[cfg(feature = "render")]
pub prepared_render: Option<telemaco_render::PreparedRender>,
#[cfg(feature = "render")]
pub render_media: telemaco_render::CssMediaType,
#[cfg(feature = "render")]
pub animation_sample: telemaco_render::AnimationSample,
#[cfg(feature = "render")]
pub animation_timeline: telemaco_render::AnimationTimelineState,
#[cfg(feature = "render")]
pub animation_timeline_origin: std::time::Instant,
#[cfg(feature = "render")]
pub animation_task_generation: u64,
#[cfg(feature = "render")]
pub animation_sampled_task_generation: u64,
#[cfg(feature = "render")]
pub pending_style_mutations: Vec<telemaco_render::RetainedStyleMutation>,
#[cfg(feature = "render")]
pub render_resources: telemaco_render::RenderResourceCache,
#[cfg(feature = "render")]
pub render_image_in_flight:
HashMap<(u64, String, ImageRequestProfile), Vec<tokio::sync::oneshot::Sender<()>>>,
#[cfg(feature = "render")]
pub stylesheet_cache: telemaco_render::StylesheetCache,
#[cfg(feature = "render")]
pub dynamic_fonts: Vec<telemaco_render::DynamicFontFace>,
#[cfg(feature = "render")]
pub(crate) canvas_surfaces: HashMap<NodeId, CanvasBackingSurface>,
#[cfg(feature = "render")]
pub viewport: (f32, f32),
#[cfg(feature = "render")]
pub scroll_offset: (f32, f32),
#[cfg(feature = "render")]
pub element_scroll_offsets: HashMap<NodeId, (f32, f32)>,
#[cfg(feature = "render")]
pub scroll_generation: u64,
#[cfg(feature = "render")]
pub resolved_scroll: Option<(u64, telemaco_render::ResolvedScrollState)>,
pub(crate) import_map: Rc<RefCell<ImportMap>>,
pub(crate) already_started_scripts: RefCell<HashSet<NodeId>>,
pub(crate) write_stream: RefCell<Option<crate::write_stream::DocumentWriteStream>>,
}
pub struct PendingFrame {
pub frame_id: u32,
pub url: String,
pub html: String,
pub viewport_width: u64,
pub viewport_height: u64,
pub parent_frame_id: u32,
}
pub struct PendingFrameMessage {
pub target_frame_id: u32,
pub source_frame_id: u32,
pub origin: String,
pub target_origin: String,
pub data_json: String,
}
impl TelemacoState {
pub fn new() -> Self {
TelemacoState {
dom: None,
url: "about:blank".to_string(),
encoding: "UTF-8".to_string(),
title: String::new(),
referrer: String::new(),
blocked_urls: Vec::new(),
cookie_jar: None,
http_client: None,
callbacks: None,
#[cfg(feature = "stealth")]
stealth_client: None,
pending_navigation: None,
intercept_tx: None,
intercept_counter: 0,
intercept_enabled: false,
pending_binding_calls: Vec::new(),
pending_runtime_events: VecDeque::new(),
runtime_events_enabled: false,
runtime_exception_counter: 0,
network_response_bodies: HashMap::new(),
network_response_body_order: VecDeque::new(),
network_response_body_counter: 0,
fetched_urls: Vec::new(),
js_network_events: Vec::new(),
pending_frames: Vec::new(),
pending_frame_bytes: 0,
frame_id_counter: 0,
frame_id: 0,
pending_frame_messages: Vec::new(),
pending_frame_message_bytes: 0,
page_in_flight: Arc::new(std::sync::atomic::AtomicU32::new(0)),
activity_generation: 0,
document_generation: 0,
base_url_cache: RefCell::new(None),
#[cfg(feature = "render")]
prepared_render: None,
#[cfg(feature = "render")]
render_media: telemaco_render::CssMediaType::Screen,
#[cfg(feature = "render")]
animation_sample: telemaco_render::AnimationSample::default(),
#[cfg(feature = "render")]
animation_timeline: telemaco_render::AnimationTimelineState::default(),
#[cfg(feature = "render")]
animation_timeline_origin: std::time::Instant::now(),
#[cfg(feature = "render")]
animation_task_generation: 0,
#[cfg(feature = "render")]
animation_sampled_task_generation: 0,
#[cfg(feature = "render")]
pending_style_mutations: Vec::new(),
#[cfg(feature = "render")]
render_resources: telemaco_render::RenderResourceCache::default(),
#[cfg(feature = "render")]
render_image_in_flight: HashMap::new(),
#[cfg(feature = "render")]
stylesheet_cache: telemaco_render::StylesheetCache::default(),
#[cfg(feature = "render")]
dynamic_fonts: Vec::new(),
#[cfg(feature = "render")]
canvas_surfaces: HashMap::new(),
#[cfg(feature = "render")]
viewport: (1280.0, 720.0),
#[cfg(feature = "render")]
scroll_offset: (0.0, 0.0),
#[cfg(feature = "render")]
element_scroll_offsets: HashMap::new(),
#[cfg(feature = "render")]
scroll_generation: 0,
#[cfg(feature = "render")]
resolved_scroll: None,
import_map: Rc::new(RefCell::new(ImportMap::default())),
already_started_scripts: RefCell::new(HashSet::new()),
write_stream: RefCell::new(None),
}
}
}
#[derive(Debug, Clone)]
pub struct RuntimeConsoleEvent {
pub kind: String,
pub args: Vec<serde_json::Value>,
pub timestamp: f64,
}
#[derive(Debug, Clone)]
pub struct RuntimeExceptionEvent {
pub exception_id: u64,
pub name: String,
pub description: String,
pub url: String,
pub line_number: i64,
pub column_number: i64,
pub stack_trace: Vec<serde_json::Value>,
pub timestamp: f64,
}
#[derive(Debug, Clone)]
pub enum RuntimeEvent {
Console(RuntimeConsoleEvent),
Exception(RuntimeExceptionEvent),
}
pub(crate) fn node_is_script(dom: &DomTree, node_id: NodeId) -> bool {
dom.with_node(node_id, |node| {
node.as_element()
.map(|name| name.local.as_ref().eq_ignore_ascii_case("script"))
.unwrap_or(false)
})
.unwrap_or(false)
}
fn script_nodes_including_template_contents(dom: &DomTree, root: NodeId) -> Vec<NodeId> {
let mut scripts = Vec::new();
let mut stack = vec![root];
while let Some(node_id) = stack.pop() {
if node_is_script(dom, node_id) {
scripts.push(node_id);
}
let template_contents = dom
.with_node(node_id, |node| match &node.data {
NodeData::Element {
template_contents, ..
} => *template_contents,
_ => None,
})
.flatten();
if let Some(contents) = template_contents {
stack.push(contents);
}
let children = dom.children(node_id);
for child in children.into_iter().rev() {
stack.push(child);
}
}
scripts
}
pub(crate) fn mark_script_subtree_started(state: &TelemacoState, root: NodeId) {
let Some(dom) = state.dom.as_ref() else {
return;
};
let scripts = script_nodes_including_template_contents(dom, root);
state.already_started_scripts.borrow_mut().extend(scripts);
}
fn propagate_script_start_state(
dom: &DomTree,
source_root: NodeId,
cloned_root: NodeId,
started: &RefCell<HashSet<NodeId>>,
) {
let mut pairs = vec![(source_root, cloned_root)];
let mut additions = Vec::new();
let current = started.borrow();
while let Some((source, cloned)) = pairs.pop() {
if current.contains(&source) {
additions.push(cloned);
}
let source_template = dom
.with_node(source, |node| match &node.data {
NodeData::Element {
template_contents, ..
} => *template_contents,
_ => None,
})
.flatten();
let cloned_template = dom
.with_node(cloned, |node| match &node.data {
NodeData::Element {
template_contents, ..
} => *template_contents,
_ => None,
})
.flatten();
if let (Some(source_contents), Some(cloned_contents)) = (source_template, cloned_template) {
pairs.push((source_contents, cloned_contents));
}
let source_children = dom.children(source);
let cloned_children = dom.children(cloned);
for pair in source_children.into_iter().zip(cloned_children).rev() {
pairs.push(pair);
}
}
drop(current);
started.borrow_mut().extend(additions);
}
fn response_body_entry_limit() -> usize {
std::env::var("TELEMACO_NETWORK_BODY_BUFFER_ENTRIES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(128)
}
fn response_body_byte_limit() -> usize {
std::env::var("TELEMACO_NETWORK_BODY_BUFFER_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(2 * 1024 * 1024)
}
fn fetch_max_body_bytes() -> usize {
std::env::var("TELEMACO_FETCH_MAX_BODY_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(100 * 1024 * 1024)
}
async fn read_body_capped(
mut response: reqwest::Response,
max: usize,
) -> Result<Vec<u8>, deno_error::JsErrorBox> {
if let Some(len) = response.content_length() {
if len > max as u64 {
return Err(deno_error::JsErrorBox::generic(format!(
"response body of {len} bytes exceeds the maximum of {max}"
)));
}
}
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| deno_error::JsErrorBox::generic(e.to_string()))?
{
if buf.len() + chunk.len() > max {
return Err(deno_error::JsErrorBox::generic(format!(
"response body exceeds the maximum of {max} bytes"
)));
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
pub type SharedState = Rc<RefCell<TelemacoState>>;
#[derive(Default)]
pub struct RealmStates {
entries: Vec<(v8::Global<v8::Context>, u32, SharedState)>,
}
impl RealmStates {
pub fn register(
&mut self,
context: v8::Global<v8::Context>,
frame_id: u32,
state: SharedState,
) {
self.entries.push((context, frame_id, state));
}
pub fn forget(&mut self, context: &v8::Global<v8::Context>) {
self.entries.retain(|(known, _, _)| known != context);
}
fn by_frame_id(&self, frame_id: u32) -> Option<SharedState> {
self.entries
.iter()
.find(|(_, id, _)| *id == frame_id)
.map(|(_, _, state)| state.clone())
}
}
pub fn frame_state(op_state: &OpState, frame_id: u32) -> SharedState {
let page = || op_state.borrow::<SharedState>().clone();
if frame_id == 0 {
return page();
}
match op_state.try_borrow::<Rc<RefCell<RealmStates>>>() {
Some(registry) => registry.borrow().by_frame_id(frame_id).unwrap_or_else(page),
None => page(),
}
}
pub fn realm_state(scope: &mut v8::HandleScope, op_state: &OpState) -> SharedState {
let page = || op_state.borrow::<SharedState>().clone();
let registry = match op_state.try_borrow::<Rc<RefCell<RealmStates>>>() {
Some(registry) => registry.clone(),
None => return page(),
};
let registry = registry.borrow();
if registry.entries.is_empty() {
return page();
}
let current = scope.get_entered_or_microtask_context();
registry
.entries
.iter()
.find(|(context, _, _)| *context == current)
.map(|(_, _, state)| state.clone())
.unwrap_or_else(page)
}
#[derive(Clone, Copy, Debug, Default)]
struct RenderMutationImpact {
connected: bool,
actual_change: bool,
}
fn node_is_connected(dom: &DomTree, node: NodeId) -> bool {
dom.is_connected(node)
}
#[cfg(feature = "render")]
fn shadow_including_connected_nodes(dom: &DomTree) -> HashSet<NodeId> {
let mut connected = HashSet::new();
let mut stack = vec![dom.document()];
while let Some(node) = stack.pop() {
if !connected.insert(node) {
continue;
}
stack.extend(dom.children(node));
if let Some(shadow_children) = dom.shadow_children(node) {
stack.extend(shadow_children);
}
}
connected
}
fn render_mutation_impact(
dom: &DomTree,
cmd: &str,
arg1: &str,
arg2: &str,
) -> RenderMutationImpact {
let node = |value: &str| value.parse::<u32>().ok().map(NodeId::new);
match cmd {
"set_attribute" => {
let Some(target) = node(arg1) else {
return RenderMutationImpact::default();
};
let Some((name, value)) = arg2.split_once('\0') else {
return RenderMutationImpact::default();
};
let old = dom
.with_node(target, |node| node.get_attribute(name).map(str::to_owned))
.flatten();
RenderMutationImpact {
connected: node_is_connected(dom, target),
actual_change: old.as_deref() != Some(value),
}
}
"set_attribute_ns" => {
let Some(target) = node(arg1) else {
return RenderMutationImpact::default();
};
let mut parts = arg2.splitn(3, '\0');
let namespace = parts.next().unwrap_or("");
let qualified = parts.next().unwrap_or("");
let value = parts.next().unwrap_or("");
let local = qualified
.split_once(':')
.map(|(_, local)| local)
.unwrap_or(qualified);
let old = dom
.with_node(target, |node| {
node.get_attribute_ns(namespace, local).map(str::to_owned)
})
.flatten();
RenderMutationImpact {
connected: node_is_connected(dom, target),
actual_change: old.as_deref() != Some(value),
}
}
"remove_attribute" => {
let Some(target) = node(arg1) else {
return RenderMutationImpact::default();
};
let existed = dom
.with_node(target, |node| node.get_attribute(arg2).is_some())
.unwrap_or(false);
RenderMutationImpact {
connected: node_is_connected(dom, target),
actual_change: existed,
}
}
"remove_attribute_ns" => {
let Some(target) = node(arg1) else {
return RenderMutationImpact::default();
};
let (namespace, local) = arg2.split_once('\0').unwrap_or(("", arg2));
let existed = dom
.with_node(target, |node| {
node.get_attribute_ns(namespace, local).is_some()
})
.unwrap_or(false);
RenderMutationImpact {
connected: node_is_connected(dom, target),
actual_change: existed,
}
}
"append_child" => {
let (Some(parent), Some(child)) = (node(arg1), node(arg2)) else {
return RenderMutationImpact::default();
};
if dom.get_node(parent).is_none() || dom.get_node(child).is_none() {
return RenderMutationImpact::default();
}
let old_parent = dom.get_node(child).and_then(|node| node.parent);
let already_last =
old_parent == Some(parent) && dom.children(parent).last().copied() == Some(child);
RenderMutationImpact {
connected: node_is_connected(dom, parent) || node_is_connected(dom, child),
actual_change: !already_last,
}
}
"remove_child" => {
let Some(child) = node(arg1) else {
return RenderMutationImpact::default();
};
RenderMutationImpact {
connected: node_is_connected(dom, child),
actual_change: dom.get_node(child).and_then(|node| node.parent).is_some(),
}
}
"insert_before" => {
let (Some(new_node), Some(reference)) = (node(arg1), node(arg2)) else {
return RenderMutationImpact::default();
};
if dom.get_node(new_node).is_none() {
return RenderMutationImpact::default();
}
let Some(reference_parent) = dom.get_node(reference).and_then(|node| node.parent)
else {
return RenderMutationImpact::default();
};
let new_was_connected = node_is_connected(dom, new_node);
let already_immediately_before =
dom.get_node(reference).and_then(|node| node.prev_sibling) == Some(new_node);
RenderMutationImpact {
connected: node_is_connected(dom, reference_parent) || new_was_connected,
actual_change: new_node != reference && !already_immediately_before,
}
}
"set_inner_html" | "set_inner_html_context" => {
let Some(target) = node(arg1) else {
return RenderMutationImpact::default();
};
RenderMutationImpact {
connected: node_is_connected(dom, target),
actual_change: dom.get_node(target).is_some(),
}
}
"set_text_content" => {
let Some(target) = node(arg1) else {
return RenderMutationImpact::default();
};
let changed = dom
.with_node(target, |node| match &node.data {
NodeData::Text { contents } | NodeData::Comment { contents } => {
contents.as_str() != arg2
}
NodeData::ProcessingInstruction { data, .. } => data.as_str() != arg2,
_ => {
let children = dom.children(target);
match children.as_slice() {
[] => !arg2.is_empty(),
[child] => dom
.with_node(*child, |child| match &child.data {
NodeData::Text { contents } => contents.as_str() != arg2,
_ => true,
})
.unwrap_or(true),
_ => true,
}
}
})
.unwrap_or(false);
RenderMutationImpact {
connected: node_is_connected(dom, target),
actual_change: changed,
}
}
_ => RenderMutationImpact::default(),
}
}
#[cfg(feature = "render")]
fn retained_style_mutation(
dom: &DomTree,
cmd: &str,
arg1: &str,
arg2: &str,
) -> Option<telemaco_render::RetainedStyleMutation> {
let node = NodeId::new(arg1.parse::<u32>().ok()?);
if dom.containing_shadow_root(node).is_some() {
return None;
}
match cmd {
"set_attribute" => {
let (name, value) = arg2.split_once('\0')?;
if telemaco_render::dom::retained_attribute_mutation_kind(dom, node, name)
== telemaco_render::dom::RetainedAttributeMutationKind::Full
{
return None;
}
let keeps_selector_value = !name.eq_ignore_ascii_case("style");
Some(telemaco_render::AttributeStyleMutation {
node,
name: name.to_string(),
old_value: keeps_selector_value
.then(|| {
dom.with_node(node, |node| {
node.get_attribute(name).map(str::to_owned)
})
.flatten()
})
.flatten(),
new_value: keeps_selector_value.then(|| value.to_string()),
}
.into())
}
"remove_attribute" => {
if telemaco_render::dom::retained_attribute_mutation_kind(dom, node, arg2)
== telemaco_render::dom::RetainedAttributeMutationKind::Full
{
return None;
}
let keeps_selector_value = !arg2.eq_ignore_ascii_case("style");
Some(telemaco_render::AttributeStyleMutation {
node,
name: arg2.to_string(),
old_value: keeps_selector_value
.then(|| {
dom.with_node(node, |node| {
node.get_attribute(arg2).map(str::to_owned)
})
.flatten()
})
.flatten(),
new_value: None,
}
.into())
}
"append_child" => {
let child = NodeId::new(arg2.parse::<u32>().ok()?);
dom.get_node(node)?;
let old_parent = dom.get_node(child)?.parent;
Some(
telemaco_render::TreeStyleMutation::Insert {
node: child,
old_parent,
new_parent: node,
}
.into(),
)
}
"remove_child" => {
let old_parent = dom.get_node(node)?.parent?;
Some(
telemaco_render::TreeStyleMutation::Remove { node, old_parent }.into(),
)
}
"insert_before" => {
let reference = NodeId::new(arg2.parse::<u32>().ok()?);
let new_parent = dom.get_node(reference)?.parent?;
if dom.containing_shadow_root(new_parent).is_some() {
return None;
}
let old_parent = dom.get_node(node)?.parent;
Some(
telemaco_render::TreeStyleMutation::Insert {
node,
old_parent,
new_parent,
}
.into(),
)
}
"set_text_content" => match &dom.get_node(node)?.data {
NodeData::Text { .. } => Some(
telemaco_render::TreeStyleMutation::Text {
node,
parent: dom.get_node(node)?.parent,
}
.into(),
),
_ => None,
},
_ => None,
}
}
#[cfg(feature = "render")]
const MAX_PENDING_STYLE_MUTATIONS: usize = 4_096;
#[cfg(feature = "render")]
pub(crate) fn queue_retained_style_mutation(
pending: &mut Vec<telemaco_render::RetainedStyleMutation>,
mutation: telemaco_render::RetainedStyleMutation,
) -> bool {
let is_resource = matches!(mutation, telemaco_render::RetainedStyleMutation::Resource);
let has_resource = pending
.iter()
.any(|queued| matches!(queued, telemaco_render::RetainedStyleMutation::Resource));
if is_resource && has_resource {
return true;
}
if let telemaco_render::RetainedStyleMutation::Animation { node } = &mutation {
if pending.iter().any(|queued| {
matches!(
queued,
telemaco_render::RetainedStyleMutation::Animation { node: current }
if current == node
)
}) {
return true;
}
}
if let telemaco_render::RetainedStyleMutation::WaapiAnimation { node } = &mutation {
if pending.iter().any(|queued| {
matches!(
queued,
telemaco_render::RetainedStyleMutation::WaapiAnimation { node: current }
if current == node
)
}) {
return true;
}
}
if let telemaco_render::RetainedStyleMutation::Attribute(next) = &mutation {
if let Some(telemaco_render::RetainedStyleMutation::Attribute(current)) =
pending.iter_mut().find(|queued| {
matches!(
queued,
telemaco_render::RetainedStyleMutation::Attribute(current)
if current.node == next.node
&& current.name.eq_ignore_ascii_case(&next.name)
)
})
{
current.new_value.clone_from(&next.new_value);
return true;
}
}
let style_damage_len = pending.len() - usize::from(has_resource);
if !is_resource && style_damage_len >= MAX_PENDING_STYLE_MUTATIONS {
return false;
}
pending.push(mutation);
true
}
#[cfg(feature = "render")]
pub(crate) fn invalidate_render_resource_geometry(state: &mut TelemacoState) {
if state.prepared_render.is_some()
&& !queue_retained_style_mutation(
&mut state.pending_style_mutations,
telemaco_render::RetainedStyleMutation::Resource,
)
{
state.prepared_render = None;
state.pending_style_mutations.clear();
}
state.resolved_scroll = None;
}
#[cfg(feature = "render")]
fn render_timing_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var_os("TELEMACO_RENDER_TIMING").is_some())
}
#[cfg(feature = "render")]
fn is_render_mutation_command(cmd: &str) -> bool {
matches!(
cmd,
"set_attribute"
| "remove_attribute"
| "set_attribute_ns"
| "remove_attribute_ns"
| "append_child"
| "remove_child"
| "insert_before"
| "set_inner_html"
| "set_inner_html_context"
| "set_text_content"
)
}
fn fragment_context_and_html(arg: &str) -> (html5ever::QualName, &str) {
let mut parts = arg.splitn(3, '\0');
let first = parts.next().unwrap_or("body");
let second = parts.next();
let third = parts.next();
let (namespace, qualified, html) = match (second, third) {
(Some(qualified), Some(html)) => (first, qualified, html),
(Some(html), None) => ("http://www.w3.org/1999/xhtml", first, html),
(None, None) => ("http://www.w3.org/1999/xhtml", "body", first),
(None, Some(_)) => unreachable!(),
};
let (prefix, local) = match qualified.split_once(':') {
Some((prefix, local)) if !prefix.is_empty() && !local.is_empty() => {
(Some(html5ever::Prefix::from(prefix)), local)
}
_ => (None, if qualified.is_empty() { "body" } else { qualified }),
};
(
html5ever::QualName::new(
prefix,
html5ever::Namespace::from(namespace),
html5ever::LocalName::from(local),
),
html,
)
}
#[op2(fast)]
fn op_script_mark_started(state: &OpState, nid: u32) -> bool {
let shared = state.borrow::<SharedState>().clone();
let state = shared.borrow();
let Some(dom) = state.dom.as_ref() else {
return false;
};
let node_id = NodeId::new(nid);
if !node_is_script(dom, node_id) {
return false;
}
state.already_started_scripts.borrow_mut().insert(node_id);
true
}
#[op2(fast)]
fn op_script_try_start(state: &OpState, nid: u32) -> bool {
let shared = state.borrow::<SharedState>().clone();
let state = shared.borrow();
let Some(dom) = state.dom.as_ref() else {
return false;
};
let node_id = NodeId::new(nid);
if !node_is_script(dom, node_id) {
return false;
}
let newly_started = state.already_started_scripts.borrow_mut().insert(node_id);
newly_started
}
#[op2(fast)]
fn op_shadow_attach(state: &OpState, host_nid: u32, #[string] mode: String) -> i32 {
let mode = match mode.as_str() {
"open" => ShadowRootMode::Open,
"closed" => ShadowRootMode::Closed,
_ => return -1,
};
let shared = state.borrow::<SharedState>().clone();
let state = shared.borrow();
let Some(dom) = state.dom.as_ref() else {
return -1;
};
match dom.attach_shadow_root(NodeId::new(host_nid), mode) {
Ok(root) => root.raw() as i32,
Err(AttachShadowError::HostAlreadyHasShadowRoot) => -2,
Err(_) => -1,
}
}
#[op2]
#[string]
fn op_shadow_root_info(state: &OpState, host_nid: u32) -> String {
let shared = state.borrow::<SharedState>().clone();
let state = shared.borrow();
let Some(dom) = state.dom.as_ref() else {
return String::new();
};
dom.shadow_root(NodeId::new(host_nid))
.and_then(|root| dom.shadow_root_info(root))
.map(|shadow| {
let mode = match shadow.mode {
ShadowRootMode::Open => "open",
ShadowRootMode::Closed => "closed",
};
format!("{}\0{mode}", shadow.id.raw())
})
.unwrap_or_default()
}
#[op2]
#[string]
fn op_dom(
state: &OpState,
#[string] cmd: String,
#[string] arg1: String,
#[string] arg2: String,
frame_id: u32,
) -> String {
let shared = frame_state(state, frame_id);
std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
op_dom_inner(shared, cmd, arg1, arg2)
}))
.unwrap_or_else(|_| {
tracing::error!("op_dom panicked; returning null");
"null".to_string()
})
}
fn op_dom_inner(shared: SharedState, cmd: String, arg1: String, arg2: String) -> String {
{
#[cfg(feature = "render")]
let reset_nodes = {
let state = shared.borrow();
let mut roots = Vec::new();
if let Some(dom) = state.dom.as_ref() {
match cmd.as_str() {
"remove_child" => {
if let Ok(node) = arg1.parse::<u32>() {
roots.push(NodeId::new(node));
}
}
"append_child" => {
if let Ok(node) = arg2.parse::<u32>() {
let node = NodeId::new(node);
if dom.get_node(node).and_then(|node| node.parent).is_some() {
roots.push(node);
}
}
}
"insert_before" => {
if let Ok(node) = arg1.parse::<u32>() {
let node = NodeId::new(node);
if dom.get_node(node).and_then(|node| node.parent).is_some() {
roots.push(node);
}
}
}
"set_inner_html" | "set_inner_html_context" | "set_text_content" => {
if let Ok(node) = arg1.parse::<u32>() {
roots.extend(dom.children(NodeId::new(node)));
}
}
_ => {}
}
roots
.into_iter()
.flat_map(|root| {
let mut nodes = vec![root];
nodes.extend(dom.descendants(root));
nodes
})
.collect::<HashSet<_>>()
} else {
HashSet::new()
}
};
let mut state = shared.borrow_mut();
let impact = state
.dom
.as_ref()
.map(|dom| render_mutation_impact(dom, &cmd, &arg1, &arg2))
.unwrap_or_default();
#[cfg(feature = "render")]
let retained_style_mutation = state
.dom
.as_ref()
.and_then(|dom| retained_style_mutation(dom, &cmd, &arg1, &arg2));
let invalidate = impact.connected && impact.actual_change;
if invalidate {
state.activity_generation = state.activity_generation.wrapping_add(1);
}
#[cfg(feature = "render")]
if !reset_nodes.is_empty() {
state
.element_scroll_offsets
.retain(|node, _| !reset_nodes.contains(node));
state.scroll_generation = state.scroll_generation.wrapping_add(1);
if invalidate {
state.animation_timeline.remove_subtree(reset_nodes.iter());
}
}
#[cfg(feature = "render")]
let had_prepared_render = state.prepared_render.is_some();
#[cfg(feature = "render")]
if invalidate {
let mutation_time_ms = (state.animation_timeline_origin.elapsed().as_secs_f64()
* 1_000.0)
.min(f64::from(f32::MAX)) as f32;
let direct_root = match cmd.as_str() {
"append_child" => arg2.parse::<u32>().ok(),
"insert_before"
| "set_attribute"
| "remove_attribute"
| "set_attribute_ns"
| "remove_attribute_ns" => arg1.parse::<u32>().ok(),
_ => None,
}
.map(NodeId::new);
let direct_nodes = direct_root
.and_then(|root| {
state.dom.as_ref().map(|dom| {
std::iter::once(root)
.chain(dom.descendants(root))
.collect::<Vec<_>>()
})
})
.unwrap_or_default();
for node in direct_nodes {
state
.animation_timeline
.note_start_candidate(node, mutation_time_ms);
}
let scope_root = match cmd.as_str() {
"append_child" => arg1.parse::<u32>().ok().map(NodeId::new),
"insert_before" => arg2
.parse::<u32>()
.ok()
.map(NodeId::new)
.and_then(|reference| {
state.dom.as_ref()?.get_node(reference)?.parent
}),
"remove_child" => arg1
.parse::<u32>()
.ok()
.map(NodeId::new)
.and_then(|child| state.dom.as_ref()?.get_node(child)?.parent),
"set_inner_html" | "set_inner_html_context" | "set_text_content" => {
arg1.parse::<u32>().ok().map(NodeId::new)
}
_ => None,
};
if let Some(root) = scope_root {
state
.animation_timeline
.note_subtree_start_candidate(root, mutation_time_ms);
}
if let Some(mutation) = retained_style_mutation {
let retained = state.prepared_render.is_some()
&& queue_retained_style_mutation(
&mut state.pending_style_mutations,
mutation,
);
if !retained {
state.prepared_render = None;
state.pending_style_mutations.clear();
}
} else {
state.prepared_render = None;
state.pending_style_mutations.clear();
}
state.resolved_scroll = None;
}
#[cfg(feature = "render")]
if had_prepared_render && is_render_mutation_command(&cmd) && render_timing_enabled() {
static MUTATION_SEQUENCE: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
let sequence = MUTATION_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
let detail = match cmd.as_str() {
"set_attribute" => arg2.split_once('\0').map(|(name, _)| name).unwrap_or(""),
"remove_attribute" => arg2.as_str(),
_ => "",
};
eprintln!(
"[timing] render-cache mutation sequence={} cmd={} node={} detail={} connected={} actual_change={} invalidated={}",
sequence, cmd, arg1, detail, impact.connected, impact.actual_change, invalidate
);
}
}
let gs = shared.borrow();
let dom = match &gs.dom {
Some(d) => d,
None => return "null".to_string(),
};
match cmd.as_str() {
"document_node_id" => dom.document().index().to_string(),
"document_title" => {
let title = dom
.query_selector("title")
.ok()
.flatten()
.map(|title_id| {
dom.text_content(title_id)
.split(|ch| matches!(ch, '\t' | '\n' | '\u{000C}' | '\r' | ' '))
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
serde_json::to_string(&title).unwrap_or("\"\"".into())
}
"document_url" => serde_json::to_string(&gs.url).unwrap_or("\"\"".into()),
"document_base_url" => serde_json::to_string(
&document_base_url_memoized(&gs).unwrap_or_else(|| gs.url.clone()),
)
.unwrap_or("\"\"".into()),
"document_base_href" => {
serde_json::to_string(&document_base_href_memoized(&gs).unwrap_or_default())
.unwrap_or("\"\"".into())
}
"document_referrer" => serde_json::to_string(&gs.referrer).unwrap_or("\"\"".into()),
"document_encoding" => serde_json::to_string(&gs.encoding).unwrap_or("\"UTF-8\"".into()),
"document_element" => {
for cid in dom.children(dom.document()) {
if let Some(n) = dom.get_node(cid) {
if n.as_element()
.map(|name| name.local.as_ref() == "html")
.unwrap_or(false)
{
return cid.index().to_string();
}
}
}
"-1".into()
}
"document_doctype" => {
for cid in dom.children(dom.document()) {
if let Some(n) = dom.get_node(cid) {
if let telemaco_dom::NodeData::Doctype {
name,
public_id,
system_id,
} = &n.data
{
return serde_json::json!({
"name": name,
"publicId": public_id,
"systemId": system_id,
"nodeId": cid.index(),
})
.to_string();
}
}
}
"null".into()
}
"get_element_by_id" => {
let doc = dom.document();
let nid = dom.get_element_by_id(&arg1);
let live = nid.filter(|&n| dom.ancestors(n).contains(&doc));
match live {
Some(n) => n.index().to_string(),
None => {
let sel = format!(
"[id=\"{}\"]",
arg1.replace('\\', "\\\\").replace('"', "\\\"")
);
dom.query_selector(&sel)
.ok()
.flatten()
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
}
}
"query_selector" => dom
.query_selector(&arg1)
.ok()
.flatten()
.map(|id| id.index().to_string())
.unwrap_or("-1".into()),
"query_selector_all" => {
let ids: Vec<i32> = dom
.query_selector_all(&arg1)
.ok()
.map(|ids| ids.iter().map(|id| id.index() as i32).collect())
.unwrap_or_default();
serde_json::to_string(&ids).unwrap_or("[]".into())
}
"query_selector_scoped" => {
let root_nid = arg1.parse::<u32>().unwrap_or(0);
dom.query_selector_from(NodeId::new(root_nid), &arg2)
.ok()
.flatten()
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
"query_selector_all_scoped" => {
let root_nid = arg1.parse::<u32>().unwrap_or(0);
let ids: Vec<i32> = dom
.query_selector_all_from(NodeId::new(root_nid), &arg2)
.ok()
.map(|ids| ids.iter().map(|id| id.index() as i32).collect())
.unwrap_or_default();
serde_json::to_string(&ids).unwrap_or("[]".into())
}
"matches_selector" => {
let nid = NodeId::new(arg1.parse::<u32>().unwrap_or(0));
dom.matches_selector(nid, &arg2)
.unwrap_or(false)
.to_string()
}
"node_type" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
dom.with_node(NodeId::new(nid), |n| match &n.data {
NodeData::Document => "9",
NodeData::Element { .. } => "1",
NodeData::Text { .. } => "3",
NodeData::Comment { .. } => "8",
NodeData::Doctype { .. } => "10",
NodeData::ProcessingInstruction { .. } => "7",
})
.unwrap_or("0")
.into()
}
"node_name" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let name: String = dom
.with_node(NodeId::new(nid), |n| match &n.data {
NodeData::Document => "#document".to_string(),
NodeData::Element { name, .. } => name.local.as_ref().to_ascii_uppercase(),
NodeData::Text { .. } => "#text".to_string(),
NodeData::Comment { .. } => "#comment".to_string(),
NodeData::Doctype { name, .. } => name.clone(),
NodeData::ProcessingInstruction { target, .. } => target.clone(),
})
.unwrap_or_default();
serde_json::to_string(&name).unwrap_or("\"\"".into())
}
"text_content" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
serde_json::to_string(&dom.text_content(NodeId::new(nid))).unwrap_or("\"\"".into())
}
"parent_node" | "first_child" | "last_child" | "next_sibling" | "prev_sibling" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
dom.with_node(NodeId::new(nid), |n| match cmd.as_str() {
"parent_node" => n.parent,
"first_child" => n.first_child,
"last_child" => n.last_child,
"next_sibling" => n.next_sibling,
"prev_sibling" => n.prev_sibling,
_ => None,
})
.flatten()
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
"next_in_subtree" => {
let root = NodeId::new(arg1.parse::<u32>().unwrap_or(0));
let current = NodeId::new(arg2.parse::<u32>().unwrap_or(0));
dom.next_in_subtree(root, current)
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
"prev_in_subtree" => {
let root = NodeId::new(arg1.parse::<u32>().unwrap_or(0));
let current = NodeId::new(arg2.parse::<u32>().unwrap_or(0));
dom.prev_in_subtree(root, current)
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
"next_after_subtree" => {
let root = NodeId::new(arg1.parse::<u32>().unwrap_or(0));
let current = NodeId::new(arg2.parse::<u32>().unwrap_or(0));
dom.next_after_subtree(root, current)
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
"child_nodes" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let ids: Vec<i32> = dom
.children(NodeId::new(nid))
.iter()
.map(|id| id.index() as i32)
.collect();
serde_json::to_string(&ids).unwrap_or("[]".into())
}
"tag_name" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let name = dom
.with_node(NodeId::new(nid), |n| {
n.as_element().map(|name| {
if name.ns == html5ever::ns!(html) {
name.local.as_ref().to_ascii_uppercase()
} else {
match &name.prefix {
Some(prefix) => format!("{}:{}", prefix, name.local),
None => name.local.to_string(),
}
}
})
})
.flatten()
.unwrap_or_default();
serde_json::to_string(&name).unwrap_or("\"\"".into())
}
"local_name" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let name = dom
.with_node(NodeId::new(nid), |n| {
n.as_element().map(|name| name.local.to_string())
})
.flatten()
.unwrap_or_default();
serde_json::to_string(&name).unwrap_or("\"\"".into())
}
"namespace_uri" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let ns = dom
.with_node(NodeId::new(nid), |n| {
n.as_element().map(|name| name.ns.as_ref().to_string())
})
.flatten()
.unwrap_or_default();
serde_json::to_string(&ns).unwrap_or("\"\"".into())
}
"get_attribute" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let val = dom
.with_node(NodeId::new(nid), |n| {
n.get_attribute(&arg2).map(|s| s.to_string())
})
.flatten();
serde_json::to_string(&val).unwrap_or("null".into())
}
"attribute_names" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let names: Vec<String> = dom
.with_node(NodeId::new(nid), |n| {
n.attrs()
.map(|a| a.iter().map(|x| x.qualified_name()).collect())
.unwrap_or_default()
})
.unwrap_or_default();
serde_json::to_string(&names).unwrap_or("[]".into())
}
"set_attribute" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let node_id = NodeId::new(nid);
if let Some((name, value)) = arg2.split_once('\0') {
if name == "id" {
let old_id = dom
.with_node(node_id, |n| n.get_attribute("id").map(|s| s.to_string()))
.flatten();
dom.with_node_mut(node_id, |n| n.set_attribute(name, value.to_string()));
dom.update_id_index(node_id, old_id.as_deref(), Some(value));
} else {
dom.with_node_mut(node_id, |n| n.set_attribute(name, value.to_string()));
}
}
"true".into()
}
"inner_html" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
serde_json::to_string(&dom.inner_html(NodeId::new(nid))).unwrap_or("\"\"".into())
}
"outer_html" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
serde_json::to_string(&dom.outer_html(NodeId::new(nid))).unwrap_or("\"\"".into())
}
"append_child" => {
let parent = match arg1.parse::<u32>() {
Ok(n) => n,
Err(_) => return "false".into(),
};
let child = match arg2.parse::<u32>() {
Ok(n) => n,
Err(_) => return "false".into(),
};
let parent = NodeId::new(parent);
let child = NodeId::new(child);
dom.append_child(parent, child);
(dom.get_node(child).and_then(|node| node.parent) == Some(parent)).to_string()
}
"remove_child" => {
let child = match arg1.parse::<u32>() {
Ok(n) => n,
Err(_) => return "false".into(),
};
let child = NodeId::new(child);
let had_parent = dom.get_node(child).is_some_and(|node| node.parent.is_some());
dom.remove_child(child);
(had_parent && dom.get_node(child).is_some_and(|node| node.parent.is_none())).to_string()
}
"insert_before" => {
let new_node = match arg1.parse::<u32>() {
Ok(n) => n,
Err(_) => return "false".into(),
};
let ref_node = match arg2.parse::<u32>() {
Ok(n) => n,
Err(_) => return "false".into(),
};
let ref_node = NodeId::new(ref_node);
let new_node = NodeId::new(new_node);
let expected_parent = dom.get_node(ref_node).and_then(|node| node.parent);
dom.insert_before(ref_node, new_node);
(expected_parent.is_some()
&& dom.get_node(new_node).and_then(|node| node.parent) == expected_parent)
.to_string()
}
"remove_attribute" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
dom.with_node_mut(NodeId::new(nid), |n| {
if let NodeData::Element { attrs, .. } = &mut n.data {
attrs.retain(|a| !a.qualified_name_eq(&arg2));
}
});
"true".into()
}
"get_attribute_ns" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let (ns, local) = arg2.split_once('\0').unwrap_or(("", arg2.as_str()));
let val = dom
.with_node(NodeId::new(nid), |n| n.get_attribute_ns(ns, local).map(|s| s.to_string()))
.flatten();
serde_json::to_string(&val).unwrap_or("null".into())
}
"set_attribute_ns" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let node_id = NodeId::new(nid);
let mut parts = arg2.splitn(3, '\0');
let ns = parts.next().unwrap_or("");
let qualified = parts.next().unwrap_or("");
let value = parts.next().unwrap_or("");
if !qualified.is_empty() {
let local = qualified
.split_once(':')
.map(|(_, local)| local)
.unwrap_or(qualified);
if ns.is_empty() && local == "id" {
let old_id = dom
.with_node(node_id, |n| n.get_attribute("id").map(str::to_owned))
.flatten();
dom.with_node_mut(node_id, |n| {
n.set_attribute_ns(ns, qualified, value.to_string())
});
dom.update_id_index(node_id, old_id.as_deref(), Some(value));
} else {
dom.with_node_mut(node_id, |n| {
n.set_attribute_ns(ns, qualified, value.to_string())
});
}
}
"true".into()
}
"remove_attribute_ns" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let node_id = NodeId::new(nid);
let (ns, local) = arg2.split_once('\0').unwrap_or(("", arg2.as_str()));
if ns.is_empty() && local == "id" {
let old_id = dom
.with_node(node_id, |n| n.get_attribute("id").map(str::to_owned))
.flatten();
dom.with_node_mut(node_id, |n| n.remove_attribute_ns(ns, local));
dom.update_id_index(node_id, old_id.as_deref(), None);
} else {
dom.with_node_mut(node_id, |n| n.remove_attribute_ns(ns, local));
}
"true".into()
}
"set_inner_html" => {
let nid = match arg1.parse::<u32>() {
Ok(n) if n > 0 => n,
_ => return "false".into(),
};
let target = NodeId::new(nid);
let children = dom.children(target);
for child in children {
dom.detach(child);
}
if !arg2.is_empty() {
let context_name = dom
.with_node(target, |node| match &node.data {
NodeData::Element { name, .. } => Some(name.clone()),
_ => None,
})
.flatten();
let fragment = match context_name {
Some(name) => telemaco_dom::parse_fragment_with_context(&arg2, name),
None => telemaco_dom::parse_fragment(&arg2),
};
let import_root = fragment.fragment_root();
dom.import_children_from(target, &fragment, import_root);
for child in dom.children(target) {
mark_script_subtree_started(&gs, child);
}
}
"true".into()
}
"set_inner_html_context" => {
let nid = match arg1.parse::<u32>() {
Ok(n) if n > 0 => n,
_ => return "false".into(),
};
let target = NodeId::new(nid);
let (context_name, html) = fragment_context_and_html(&arg2);
for child in dom.children(target) {
dom.detach(child);
}
if !html.is_empty() {
let fragment = telemaco_dom::parse_fragment_with_context(html, context_name);
let import_root = fragment.fragment_root();
dom.import_children_from(target, &fragment, import_root);
for child in dom.children(target) {
mark_script_subtree_started(&gs, child);
}
}
"true".into()
}
"set_fragment_html_executable" => {
let nid = match arg1.parse::<u32>() {
Ok(n) if n > 0 => n,
_ => return "false".into(),
};
let target = NodeId::new(nid);
let (context_name, html) = fragment_context_and_html(&arg2);
for child in dom.children(target) {
dom.detach(child);
}
if !html.is_empty() {
let fragment = telemaco_dom::parse_fragment_with_context(html, context_name);
let import_root = fragment.fragment_root();
dom.import_children_from(target, &fragment, import_root);
}
"true".into()
}
"document_write" => {
let mut slot = gs.write_stream.borrow_mut();
let stream = slot.get_or_insert_with(DocumentWriteStream::new);
let pairs: Vec<[i32; 2]> = stream
.write(&arg2, dom)
.iter()
.map(|placement| {
[
placement.parent.map_or(0, |id| id.index() as i32),
placement.node.index() as i32,
]
})
.collect();
serde_json::to_string(&pairs).unwrap_or("[]".into())
}
"document_write_reset" => {
*gs.write_stream.borrow_mut() = None;
"true".into()
}
"set_text_content" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
dom.with_node_mut(NodeId::new(nid), |n| match &mut n.data {
NodeData::Text { contents } => {
*contents = arg2.clone();
}
NodeData::Comment { contents } => {
*contents = arg2.clone();
}
NodeData::ProcessingInstruction { data, .. } => {
*data = arg2.clone();
}
_ => {}
});
"true".into()
}
"template_contents" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
dom.template_contents(NodeId::new(nid))
.map(|id| id.index().to_string())
.unwrap_or("-1".into())
}
"create_document_fragment" => dom.new_node(NodeData::Document).index().to_string(),
"clone_node" => {
let nid = match arg1.parse::<u32>() {
Ok(n) => n,
Err(_) => return "-1".into(),
};
let source = NodeId::new(nid);
match dom.clone_node(source, arg2 == "true") {
Some(cloned) => {
propagate_script_start_state(dom, source, cloned, &gs.already_started_scripts);
cloned.index().to_string()
}
None => "-1".into(),
}
}
"create_element" => dom
.new_node(NodeData::Element {
name: html5ever::QualName::new(
None,
html5ever::ns!(html),
html5ever::LocalName::from(arg1.as_str()),
),
attrs: vec![],
template_contents: None,
mathml_annotation_xml_integration_point: false,
})
.index()
.to_string(),
"create_element_ns" => {
let (namespace, qualified) = arg1.split_once('\0').unwrap_or(("", arg1.as_str()));
let (prefix, local) = match qualified.split_once(':') {
Some((prefix, local)) if !prefix.is_empty() && !local.is_empty() => {
(Some(html5ever::Prefix::from(prefix)), local)
}
None if !qualified.is_empty() => (None, qualified),
_ => return "-1".into(),
};
dom.new_node(NodeData::Element {
name: html5ever::QualName::new(
prefix,
html5ever::Namespace::from(namespace),
html5ever::LocalName::from(local),
),
attrs: vec![],
template_contents: None,
mathml_annotation_xml_integration_point: false,
})
.index()
.to_string()
}
"create_text_node" => dom
.new_node(NodeData::Text {
contents: arg1.clone(),
})
.index()
.to_string(),
"create_comment_node" => dom
.new_node(NodeData::Comment {
contents: arg1.clone(),
})
.index()
.to_string(),
"create_processing_instruction" => {
dom.new_node(NodeData::ProcessingInstruction {
target: arg1.clone(),
data: arg2.clone(),
})
.index()
.to_string()
}
"create_doctype" => {
dom.new_node(NodeData::Doctype {
name: arg1.clone(),
public_id: arg2.clone(),
system_id: String::new(),
})
.index()
.to_string()
}
"pi_target" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let val = dom
.with_node(NodeId::new(nid), |n| match &n.data {
NodeData::ProcessingInstruction { target, .. } => Some(target.clone()),
_ => None,
})
.flatten()
.unwrap_or_default();
serde_json::to_string(&val).unwrap_or("\"\"".into())
}
"doctype_name" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let val = dom
.with_node(NodeId::new(nid), |n| match &n.data {
NodeData::Doctype { name, .. } => Some(name.clone()),
_ => None,
})
.flatten()
.unwrap_or_default();
serde_json::to_string(&val).unwrap_or("\"\"".into())
}
"doctype_public_id" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let val = dom
.with_node(NodeId::new(nid), |n| match &n.data {
NodeData::Doctype { public_id, .. } => Some(public_id.clone()),
_ => None,
})
.flatten()
.unwrap_or_default();
serde_json::to_string(&val).unwrap_or("\"\"".into())
}
"element_children" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let ids: Vec<i32> = dom
.children(NodeId::new(nid))
.iter()
.filter(|&&id| dom.get_node(id).map(|n| n.is_element()).unwrap_or(false))
.map(|id| id.index() as i32)
.collect();
serde_json::to_string(&ids).unwrap_or("[]".into())
}
"has_child_nodes" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
dom.with_node(NodeId::new(nid), |n| n.first_child.is_some())
.unwrap_or(false)
.to_string()
}
"contains" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
let other = arg2.parse::<u32>().unwrap_or(0);
dom.descendants(NodeId::new(nid))
.contains(&NodeId::new(other))
.to_string()
}
"is_connected" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
dom.is_connected(NodeId::new(nid)).to_string()
}
"node_index" => {
let nid = arg1.parse::<u32>().unwrap_or(0);
node_child_index(dom, NodeId::new(nid)).to_string()
}
"compare_order" => {
let a = NodeId::new(arg1.parse::<u32>().unwrap_or(0));
let b = NodeId::new(arg2.parse::<u32>().unwrap_or(0));
compare_node_order(dom, a, b).to_string()
}
"node_root" => {
let mut cur = NodeId::new(arg1.parse::<u32>().unwrap_or(0));
while let Some(p) = dom.with_node(cur, |x| x.parent).flatten() {
cur = p;
}
cur.index().to_string()
}
_ => "null".into(),
}
}
fn node_child_index(dom: &DomTree, n: NodeId) -> usize {
let mut i = 0usize;
let mut cur = dom.with_node(n, |x| x.prev_sibling).flatten();
while let Some(p) = cur {
i += 1;
cur = dom.with_node(p, |x| x.prev_sibling).flatten();
}
i
}
fn node_ancestors_root_first(dom: &DomTree, n: NodeId) -> Vec<NodeId> {
let mut v = vec![n];
let mut cur = n;
while let Some(p) = dom.with_node(cur, |x| x.parent).flatten() {
v.push(p);
cur = p;
}
v.reverse();
v
}
fn compare_node_order(dom: &DomTree, a: NodeId, b: NodeId) -> i32 {
if a == b {
return 0;
}
let aa = node_ancestors_root_first(dom, a);
let bb = node_ancestors_root_first(dom, b);
if aa[0] != bb[0] {
return if a.index() < b.index() { -1 } else { 1 };
}
let mut i = 0usize;
while i < aa.len() && i < bb.len() && aa[i] == bb[i] {
i += 1;
}
if i >= aa.len() {
return -1; }
if i >= bb.len() {
return 1; }
if node_child_index(dom, aa[i]) < node_child_index(dom, bb[i]) {
-1
} else {
1
}
}
#[op2(fast)]
fn op_runtime_events_enabled(state: &OpState) -> bool {
state.borrow::<SharedState>().borrow().runtime_events_enabled
}
#[op2(fast)]
fn op_console_msg(
state: &OpState,
#[string] level: &str,
#[string] msg: &str,
#[string] args_json: &str,
) {
match level {
"warn" | "warning" => tracing::warn!(target: "telemaco::console", "{}", msg),
"error" => tracing::error!(target: "telemaco::console", "{}", msg),
_ => tracing::info!(target: "telemaco::console", "{}", msg),
}
let page = state.borrow::<SharedState>().clone();
let mut page = page.borrow_mut();
if !page.runtime_events_enabled {
return;
}
let Ok(args) = serde_json::from_str::<Vec<serde_json::Value>>(args_json) else {
return;
};
if page.pending_runtime_events.len() >= 1_024 {
page.pending_runtime_events.pop_front();
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
* 1_000.0;
page.pending_runtime_events
.push_back(RuntimeEvent::Console(RuntimeConsoleEvent {
kind: level.to_string(),
args,
timestamp,
}));
}
static FETCH_CLIENT_CACHE: std::sync::OnceLock<
std::sync::RwLock<std::collections::HashMap<String, reqwest::Client>>,
> = std::sync::OnceLock::new();
pub fn cached_request_client(proxy_url: Option<&str>) -> Result<reqwest::Client, String> {
let key = proxy_url.unwrap_or("").to_string();
let cache =
FETCH_CLIENT_CACHE.get_or_init(|| std::sync::RwLock::new(std::collections::HashMap::new()));
if let Ok(read) = cache.read() {
if let Some(client) = read.get(&key) {
return Ok(client.clone());
}
}
let client = build_request_client(proxy_url)?;
if let Ok(mut write) = cache.write() {
write.entry(key).or_insert_with(|| client.clone());
}
Ok(client)
}
fn build_request_client(proxy_url: Option<&str>) -> Result<reqwest::Client, String> {
let mut builder = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(fetch_timeout())
.dns_resolver(std::sync::Arc::new(telemaco_net::SsrfGuardResolver::new(
false,
)))
.pool_idle_timeout(std::time::Duration::from_secs(300))
.tcp_keepalive(std::time::Duration::from_secs(60));
if let Some(proxy) = proxy_url {
let p = reqwest::Proxy::all(proxy)
.map_err(|e| format!("Invalid op_fetch_url proxy '{}': {}", proxy, e))?;
builder = builder.proxy(p);
}
builder
.build()
.map_err(|e| format!("failed to build reqwest::Client: {}", e))
}
fn fetch_timeout() -> std::time::Duration {
let timeout_ms = std::env::var("TELEMACO_FETCH_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(30_000);
std::time::Duration::from_millis(timeout_ms)
}
const FETCH_REDIRECT_LIMIT: usize = 20;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FetchCredentials {
Omit,
SameOrigin,
Include,
}
impl FetchCredentials {
fn parse(value: &str) -> Self {
match value {
"omit" => Self::Omit,
"include" => Self::Include,
_ => Self::SameOrigin,
}
}
fn allows(self, page_origin: &str, request_url: &str) -> bool {
match self {
Self::Omit => false,
Self::Include => true,
Self::SameOrigin => request_origin(request_url)
.map(|origin| origin == page_origin)
.unwrap_or(false),
}
}
}
fn request_origin(request_url: &str) -> Option<String> {
url::Url::parse(request_url)
.ok()
.map(|url| url.origin().ascii_serialization())
}
fn cors_response_allows(
credentials: FetchCredentials,
page_origin: &str,
allowed_origin: &str,
allow_credentials: &str,
) -> bool {
if credentials == FetchCredentials::Include {
allowed_origin == page_origin && allow_credentials == "true"
} else {
allowed_origin == "*" || allowed_origin == page_origin
}
}
#[op2(async)]
#[string]
async fn op_fetch_url(
state: Rc<RefCell<OpState>>,
#[string] url: String,
#[string] method: String,
#[string] headers_json: String,
#[buffer] body: JsBuffer,
#[string] origin: String,
#[string] mode: String,
#[string] credentials: String,
) -> Result<String, deno_error::JsErrorBox> {
let body = body.to_vec();
tracing::debug!(
"op_fetch_url called: {} {} (intercept check pending)",
method,
url
);
let (cookie_jar, in_flight, page_in_flight, intercept_tx, proxy_url, callbacks, http_client) = {
let state_borrow = state.borrow();
let gs = state_borrow.borrow::<SharedState>().clone();
let mut gs = gs.borrow_mut();
for pattern in &gs.blocked_urls {
if pattern == "*" || url.contains(pattern) || glob_match(pattern, &url) {
return Ok(serde_json::json!({
"status": 0,
"body": "",
"url": url,
"headers": {},
"blocked": true,
})
.to_string());
}
}
gs.fetched_urls.push(url.clone());
let jar = gs.cookie_jar.clone();
let in_flight = gs.http_client.as_ref().map(|c| c.in_flight.clone());
let proxy_url = gs
.http_client
.as_ref()
.and_then(|c| c.proxy_url().map(|s| s.to_string()));
tracing::debug!(
"op_fetch_url: intercept_enabled={}, has_tx={}",
gs.intercept_enabled,
gs.intercept_tx.is_some()
);
let itx = if gs.intercept_enabled {
gs.intercept_counter += 1;
gs.intercept_tx
.clone()
.map(|tx| (tx, format!("intercept-{}", gs.intercept_counter)))
} else {
None
};
(
jar,
in_flight,
Arc::clone(&gs.page_in_flight),
itx,
proxy_url,
gs.callbacks.clone(),
gs.http_client.clone(),
)
};
let allow_private_network = http_client
.as_ref()
.is_some_and(|client| client.allow_private_network);
if let Ok(parsed_url) = url::Url::parse(&url) {
if let Err(e) = validate_fetch_url(&parsed_url, allow_private_network) {
return Ok(serde_json::json!({
"status": 0,
"body": "",
"url": url,
"headers": {},
"blocked": true,
"error": e,
})
.to_string());
}
}
struct PageInFlightGuard(Arc<std::sync::atomic::AtomicU32>);
impl Drop for PageInFlightGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
page_in_flight.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let _page_in_flight = PageInFlightGuard(page_in_flight);
let mut override_url: Option<String> = None;
let mut override_method: Option<String> = None;
let mut override_headers: Option<HashMap<String, String>> = None;
let mut override_body: Option<Vec<u8>> = None;
if let Some((tx, request_id)) = intercept_tx {
let custom_headers: HashMap<String, String> =
serde_json::from_str(&headers_json).unwrap_or_default();
let (resolve_tx, resolve_rx) = tokio::sync::oneshot::channel();
let intercepted = InterceptedRequest {
request_id: request_id.clone(),
url: url.clone(),
method: method.clone(),
headers: custom_headers.clone(),
resource_type: "Fetch".to_string(),
resolver: resolve_tx,
};
if tx.send(intercepted).is_ok() {
match resolve_rx.await {
Ok(InterceptResolution::Fulfill {
status,
headers: h,
body: b,
}) => {
let resp_headers: HashMap<String, String> = h;
return Ok(serde_json::json!({
"status": status,
"body": b,
"url": url,
"headers": resp_headers,
})
.to_string());
}
Ok(InterceptResolution::Fail { reason }) => {
return Ok(serde_json::json!({
"status": 0,
"body": "",
"url": url,
"headers": {},
"blocked": true,
"error": reason,
})
.to_string());
}
Ok(InterceptResolution::Continue {
url,
method,
headers,
body,
}) => {
override_url = url;
override_method = method;
override_headers = headers;
override_body = body.map(String::into_bytes);
tracing::debug!(
"Interception: continue (overrides url={} method={} headers={} body={})",
override_url.is_some(),
override_method.is_some(),
override_headers.is_some(),
override_body.is_some()
);
}
Err(_) => {}
}
}
}
let url = if let Some(new_url) = override_url {
if let Ok(parsed) = url::Url::parse(&new_url) {
if let Err(reason) = validate_fetch_url(&parsed, allow_private_network) {
return Ok(serde_json::json!({
"status": 0,
"body": "",
"url": new_url,
"blocked": true,
"error": format!("Intercept rewrite to forbidden URL blocked: {}", reason),
})
.to_string());
}
}
new_url
} else {
url
};
let method = override_method.unwrap_or(method);
let body = override_body.unwrap_or(body);
let client = match &http_client {
Some(client) => client.request_client().await,
None => {
cached_request_client(proxy_url.as_deref()).map_err(deno_error::JsErrorBox::generic)?
}
};
let initial_request_origin = request_origin(&url).unwrap_or_default();
let page_origin = if origin.is_empty() {
initial_request_origin.clone()
} else {
origin.clone()
};
let is_cross_origin = !page_origin.is_empty() && initial_request_origin != page_origin;
let credentials = FetchCredentials::parse(&credentials);
let req_method: reqwest::Method = method.parse().unwrap_or(reqwest::Method::GET);
let custom_headers: std::collections::HashMap<String, String> =
override_headers.unwrap_or_else(|| serde_json::from_str(&headers_json).unwrap_or_default());
if let Some(ref cbs) = callbacks {
if cbs.has_request_callbacks().await {
if let Ok(parsed) = url::Url::parse(&url) {
let info = RequestInfo {
url: parsed,
method: method.clone(),
headers: custom_headers.clone(),
resource_type: ResourceType::Fetch,
};
cbs.fire_request(&info).await;
}
}
}
let needs_preflight = is_cross_origin
&& mode == "cors"
&& (req_method != reqwest::Method::GET
&& req_method != reqwest::Method::HEAD
&& req_method != reqwest::Method::POST
|| custom_headers.keys().any(|k| {
let kl = k.to_lowercase();
kl != "accept"
&& kl != "accept-language"
&& kl != "content-language"
&& kl != "content-type"
}));
if needs_preflight {
let preflight = client
.request(reqwest::Method::OPTIONS, &url)
.timeout(fetch_timeout())
.header("Origin", &page_origin)
.header("Access-Control-Request-Method", method.as_str())
.header(
"Access-Control-Request-Headers",
custom_headers
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", "),
)
.send()
.await
.map_err(|e| {
deno_error::JsErrorBox::generic(format!("CORS preflight failed: {}", e))
})?;
let allowed_origin = preflight
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let allow_credentials = preflight
.headers()
.get("access-control-allow-credentials")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !cors_response_allows(credentials, &page_origin, allowed_origin, allow_credentials) {
return Err(deno_error::JsErrorBox::generic(format!(
"CORS preflight: Origin '{}' not allowed by Access-Control-Allow-Origin '{}'",
page_origin, allowed_origin
)));
}
}
#[cfg(feature = "stealth")]
{
let stealth = {
let st = state.borrow();
let gs = st.borrow::<SharedState>().clone();
let client = gs.borrow().stealth_client.clone();
client
};
if let Some(stealth) = stealth {
return stealth_fetch_all(
stealth,
url.clone(),
req_method.as_str().to_string(),
custom_headers.clone(),
body.clone(),
page_origin.clone(),
mode.clone(),
credentials,
callbacks.clone(),
allow_private_network,
)
.await;
}
}
let mut current_url = url.clone();
let mut current_method = req_method;
let mut current_body = body;
let mut redirects_followed: usize = 0;
let response = loop {
let mut req = client
.request(current_method.clone(), ¤t_url)
.timeout(fetch_timeout());
let current_is_cross_origin = request_origin(¤t_url)
.map(|request_origin| request_origin != page_origin)
.unwrap_or(false);
if current_is_cross_origin {
req = req.header("Origin", &page_origin);
}
let credentials_allowed = credentials.allows(&page_origin, ¤t_url);
if credentials_allowed {
if let Some(ref jar) = cookie_jar {
if let Ok(parsed_url) = url::Url::parse(¤t_url) {
let cookie_header = jar.get_cookie_header(&parsed_url);
if !cookie_header.is_empty() {
req = req.header("Cookie", &cookie_header);
}
}
}
}
if !custom_headers
.keys()
.any(|k| k.eq_ignore_ascii_case("user-agent"))
{
req = req.header(
"User-Agent",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
);
}
for (k, v) in &custom_headers {
req = req.header(k.as_str(), v.as_str());
}
if !current_body.is_empty() {
req = req.body(current_body.clone());
}
if let Some(ref counter) = in_flight {
counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
let resp = req.send().await.map_err(|e| {
if let Some(ref counter) = in_flight {
counter.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
deno_error::JsErrorBox::generic(e.to_string())
})?;
if let Some(ref counter) = in_flight {
counter.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
if credentials_allowed {
if let Some(ref jar) = cookie_jar {
if let Ok(parsed_url) = url::Url::parse(¤t_url) {
for val in resp.headers().get_all(reqwest::header::SET_COOKIE) {
if let Ok(s) = val.to_str() {
jar.set_cookie(s, &parsed_url);
}
}
}
}
}
if !resp.status().is_redirection() {
break resp;
}
let location_header = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let Some(location) = location_header else {
break resp;
};
let base = match url::Url::parse(¤t_url) {
Ok(b) => b,
Err(_) => break resp,
};
let next_url = match base.join(&location) {
Ok(u) => u,
Err(_) => break resp,
};
if let Err(reason) = validate_fetch_url(&next_url, allow_private_network) {
return Ok(serde_json::json!({
"status": 0,
"body": "",
"url": next_url.to_string(),
"headers": {},
"blocked": true,
"error": format!("Redirect to forbidden URL blocked: {}", reason),
})
.to_string());
}
redirects_followed += 1;
if redirects_followed > FETCH_REDIRECT_LIMIT {
return Ok(serde_json::json!({
"status": 0,
"body": "",
"url": next_url.to_string(),
"headers": {},
"blocked": true,
"error": format!("Too many redirects (>{})", FETCH_REDIRECT_LIMIT),
})
.to_string());
}
let status_code = resp.status().as_u16();
if status_code == 301 || status_code == 302 || status_code == 303 {
current_method = reqwest::Method::GET;
current_body.clear();
}
current_url = next_url.to_string();
};
let status = response.status().as_u16();
let resp_headers: std::collections::HashMap<String, String> = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let final_is_cross_origin = request_origin(¤t_url)
.map(|request_origin| request_origin != page_origin)
.unwrap_or(false);
if final_is_cross_origin && mode == "cors" {
let allowed = resp_headers
.get("access-control-allow-origin")
.map(|s| s.as_str())
.unwrap_or("");
let allow_credentials = resp_headers
.get("access-control-allow-credentials")
.map(|s| s.as_str())
.unwrap_or("");
if !cors_response_allows(credentials, &page_origin, allowed, allow_credentials) {
return Ok(serde_json::json!({
"status": 0,
"body": "",
"url": url,
"headers": {},
"corsBlocked": true,
"corsError": if credentials == FetchCredentials::Include {
format!(
"CORS error: credentialed request requires Access-Control-Allow-Origin '{}' and Access-Control-Allow-Credentials 'true'",
page_origin
)
} else {
format!("CORS error: Origin '{}' not in Access-Control-Allow-Origin '{}'", page_origin, allowed)
},
})
.to_string());
}
}
let resp_bytes = read_body_capped(response, fetch_max_body_bytes()).await?;
let resp_body = String::from_utf8_lossy(&resp_bytes).to_string();
let resp_body_base64 = BASE64.encode(&resp_bytes);
if let Some(ref cbs) = callbacks {
if cbs.has_response_callbacks().await {
let resp = fetch_response(&url, status, resp_headers.clone(), resp_bytes.to_vec());
let info = RequestInfo {
url: resp.url.clone(),
method: method.clone(),
headers: resp_headers.clone(),
resource_type: ResourceType::Fetch,
};
cbs.fire_response(&info, &resp).await;
}
}
let response_request_id = {
let state_borrow = state.borrow();
let gs = state_borrow.borrow::<SharedState>().clone();
let mut gs = gs.borrow_mut();
gs.network_response_body_counter += 1;
let request_id = format!("fetch-{}", gs.network_response_body_counter);
let max_entries = response_body_entry_limit();
let max_bytes = response_body_byte_limit();
if max_entries > 0 && max_bytes > 0 && resp_bytes.len() <= max_bytes {
gs.network_response_bodies.insert(
request_id.clone(),
StoredNetworkResponseBody {
body: resp_body.clone(),
base64_encoded: false,
},
);
gs.network_response_body_order.push_back(request_id.clone());
while gs.network_response_body_order.len() > max_entries {
if let Some(oldest) = gs.network_response_body_order.pop_front() {
gs.network_response_bodies.remove(&oldest);
}
}
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
gs.js_network_events.push(JsNetworkEvent {
request_id: request_id.clone(),
url: url.clone(),
method: method.clone(),
status,
response_headers: resp_headers.clone(),
body_size: resp_bytes.len(),
timestamp,
});
const MAX_JS_NETWORK_EVENTS: usize = 4096;
if gs.js_network_events.len() > MAX_JS_NETWORK_EVENTS {
let overflow = gs.js_network_events.len() - MAX_JS_NETWORK_EVENTS;
gs.js_network_events.drain(0..overflow);
}
request_id
};
tracing::debug!(
"op_fetch_url completed: {} {} ({} bytes)",
method,
url,
resp_body.len()
);
Ok(serde_json::json!({
"status": status,
"body": resp_body,
"bodyBase64": resp_body_base64,
"requestId": response_request_id,
"url": url,
"headers": resp_headers,
})
.to_string())
}
fn fetch_response(
url: &str,
status: u16,
headers: HashMap<String, String>,
body: Vec<u8>,
) -> Response {
Response {
url: url::Url::parse(url).unwrap_or_else(|_| url::Url::parse("http://0.0.0.0/").unwrap()),
status,
headers,
body,
redirected_from: Vec::new(),
}
}
#[cfg(feature = "stealth")]
async fn stealth_fetch_all(
stealth: Arc<StealthHttpClient>,
url: String,
method: String,
custom_headers: HashMap<String, String>,
body: Vec<u8>,
page_origin: String,
mode: String,
credentials: FetchCredentials,
callbacks: Option<Arc<CallbackRegistry>>,
allow_private_network: bool,
) -> Result<String, deno_error::JsErrorBox> {
let mut current_url = url.clone();
let mut current_method = method;
let mut current_body = body;
let mut redirects_followed: usize = 0;
let (status, resp_headers, resp_bytes): (u16, HashMap<String, String>, Vec<u8>) = loop {
let parsed_current = match url::Url::parse(¤t_url) {
Ok(u) => u,
Err(_) => {
return Ok(serde_json::json!({
"status": 0, "body": "", "url": current_url, "headers": {},
})
.to_string());
}
};
let mut req_headers: HashMap<String, String> = HashMap::new();
let current_is_cross_origin = parsed_current.origin().ascii_serialization() != page_origin;
if current_is_cross_origin {
req_headers.insert("origin".to_string(), page_origin.clone());
}
for (k, v) in &custom_headers {
req_headers.insert(k.to_lowercase(), v.clone());
}
let credentials_allowed = credentials.allows(&page_origin, ¤t_url);
let r = stealth
.send_single(
¤t_method,
&parsed_current,
&req_headers,
¤t_body,
credentials_allowed,
credentials_allowed,
)
.await
.map_err(|e| deno_error::JsErrorBox::generic(e.to_string()))?;
if !(300..400).contains(&r.status) {
break (r.status, r.headers, r.body);
}
let Some(location) = r.headers.get("location").cloned() else {
break (r.status, r.headers, r.body);
};
let next_url = match parsed_current.join(&location) {
Ok(u) => u,
Err(_) => break (r.status, r.headers, r.body),
};
if let Err(reason) = validate_fetch_url(&next_url, allow_private_network) {
return Ok(serde_json::json!({
"status": 0, "body": "", "url": next_url.to_string(), "headers": {},
"blocked": true,
"error": format!("Redirect to forbidden URL blocked: {}", reason),
})
.to_string());
}
redirects_followed += 1;
if redirects_followed > FETCH_REDIRECT_LIMIT {
return Ok(serde_json::json!({
"status": 0, "body": "", "url": next_url.to_string(), "headers": {},
"blocked": true,
"error": format!("Too many redirects (>{})", FETCH_REDIRECT_LIMIT),
})
.to_string());
}
if r.status == 301 || r.status == 302 || r.status == 303 {
current_method = "GET".to_string();
current_body.clear();
}
current_url = next_url.to_string();
};
let final_is_cross_origin = request_origin(¤t_url)
.map(|request_origin| request_origin != page_origin)
.unwrap_or(false);
if final_is_cross_origin && mode == "cors" {
let allowed = resp_headers
.get("access-control-allow-origin")
.map(|s| s.as_str())
.unwrap_or("");
let allow_credentials = resp_headers
.get("access-control-allow-credentials")
.map(|s| s.as_str())
.unwrap_or("");
if !cors_response_allows(credentials, &page_origin, allowed, allow_credentials) {
return Ok(serde_json::json!({
"status": 0, "body": "", "url": url, "headers": {},
"corsBlocked": true,
"corsError": if credentials == FetchCredentials::Include {
format!(
"CORS error: credentialed request requires Access-Control-Allow-Origin '{}' and Access-Control-Allow-Credentials 'true'",
page_origin
)
} else {
format!(
"CORS error: Origin '{}' not in Access-Control-Allow-Origin '{}'",
page_origin, allowed
)
},
})
.to_string());
}
}
let resp_body = String::from_utf8_lossy(&resp_bytes).to_string();
let resp_body_base64 = BASE64.encode(&resp_bytes);
if let Some(ref cbs) = callbacks {
if cbs.has_response_callbacks().await {
let resp = fetch_response(&url, status, resp_headers.clone(), resp_bytes.clone());
let info = RequestInfo {
url: resp.url.clone(),
method: current_method.clone(),
headers: resp_headers.clone(),
resource_type: ResourceType::Fetch,
};
cbs.fire_response(&info, &resp).await;
}
}
Ok(serde_json::json!({
"status": status,
"body": resp_body,
"bodyBase64": resp_body_base64,
"url": url,
"headers": resp_headers,
})
.to_string())
}
fn glob_match(pattern: &str, url: &str) -> bool {
if pattern == "*" {
return true;
}
let mut remainder = url;
let mut first = true;
for part in pattern.split('*') {
if part.is_empty() {
continue;
}
let Some(index) = remainder.find(part) else {
return false;
};
if first && !pattern.starts_with('*') && index != 0 {
return false;
}
remainder = &remainder[index + part.len()..];
first = false;
}
pattern.ends_with('*') || remainder.is_empty()
}
#[cfg(test)]
mod tests {
use super::{cors_response_allows, glob_match, validate_fetch_url, FetchCredentials};
use crate::runtime::TelemacoJsRuntime;
use telemaco_dom::parse_html;
#[cfg(feature = "render")]
use super::{
ensure_prepared_geometry, ensure_prepared_render, node_is_connected,
queue_retained_style_mutation, retained_style_mutation,
shadow_including_connected_nodes, TelemacoState, MAX_PENDING_STYLE_MUTATIONS,
};
#[cfg(feature = "render")]
use telemaco_dom::ShadowRootMode;
use super::read_body_capped;
use super::{pbkdf2_derive, PBKDF2_MAX_ITERATIONS, PBKDF2_MAX_OUTPUT_BYTES};
#[test]
fn pbkdf2_rejects_excessive_iterations() {
let err = pbkdf2_derive("SHA-256", b"pw", b"salt", PBKDF2_MAX_ITERATIONS + 1, 32)
.expect_err("iteration count above the cap must be rejected");
assert!(
err.to_string().contains("iteration"),
"error should name the iteration cap: {err}"
);
}
#[test]
fn pbkdf2_rejects_excessive_output_length() {
let err = pbkdf2_derive("SHA-256", b"pw", b"salt", 1_000, PBKDF2_MAX_OUTPUT_BYTES + 1)
.expect_err("output length above the cap must be rejected");
assert!(
err.to_string().contains("length"),
"error should name the length cap: {err}"
);
}
#[test]
fn pbkdf2_derives_within_limits() {
let dk = pbkdf2_derive("SHA-256", b"password", b"salt", 1_000, 32)
.expect("ordinary parameters must derive successfully");
assert_eq!(dk.len(), 32, "derived key must have the requested length");
}
async fn serve_body_once(body_len: usize, with_content_length: bool) -> std::net::SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let (mut sock, _) = listener.accept().await.unwrap();
let mut request = [0u8; 2048];
let _ = sock.read(&mut request).await;
let mut header = String::from("HTTP/1.1 200 OK\r\nConnection: close\r\n");
if with_content_length {
header.push_str(&format!("Content-Length: {body_len}\r\n"));
}
header.push_str("\r\n");
let _ = sock.write_all(header.as_bytes()).await;
let chunk = vec![b'a'; 64 * 1024];
let mut sent = 0;
while sent < body_len {
let n = std::cmp::min(chunk.len(), body_len - sent);
if sock.write_all(&chunk[..n]).await.is_err() {
break;
}
sent += n;
}
let _ = sock.shutdown().await;
});
addr
}
#[tokio::test]
async fn read_body_capped_rejects_oversized_streamed_body() {
let addr = serve_body_once(4 * 1024 * 1024, false).await;
let resp = reqwest::Client::new()
.get(format!("http://{addr}/"))
.send()
.await
.expect("request should reach the local server");
let err = read_body_capped(resp, 1024 * 1024)
.await
.expect_err("a body larger than the cap must be rejected");
assert!(
err.to_string().contains("maximum"),
"error should mention the cap: {err}"
);
}
#[tokio::test]
async fn read_body_capped_reads_body_within_cap() {
let addr = serve_body_once(1024, true).await;
let resp = reqwest::Client::new()
.get(format!("http://{addr}/"))
.send()
.await
.expect("request should reach the local server");
let body = read_body_capped(resp, 1024 * 1024)
.await
.expect("a small body must be read successfully");
assert_eq!(body.len(), 1024, "should read the whole small body");
}
#[test]
fn glob_match_handles_cdp_blocked_url_patterns() {
assert!(glob_match(
"*://*.google.com/maps/vt/*",
"https://www.google.com/maps/vt/pb=!1m4!1m3",
));
assert!(glob_match(
"*://*.gstatic.com/*.woff2",
"https://fonts.gstatic.com/s/inter/v18/font.woff2",
));
assert!(glob_match(
"https://example.com/assets/*",
"https://example.com/assets/app.js",
));
assert!(!glob_match(
"https://example.com/assets/*",
"https://cdn.example.com/assets/app.js",
));
assert!(!glob_match(
"*://*.gstatic.com/*.woff2",
"https://fonts.gstatic.com/s/inter/v18/font.woff",
));
}
#[test]
fn fetch_credentials_gate_cookie_send_and_storage_per_request_origin() {
let page_origin = "https://www.example.com";
let same_origin_url = "https://www.example.com/api";
let explicit_default_port = "https://www.example.com:443/api";
let cross_origin_url = "https://api.example.com/data";
assert!(!FetchCredentials::Omit.allows(page_origin, same_origin_url));
assert!(!FetchCredentials::Omit.allows(page_origin, cross_origin_url));
assert!(FetchCredentials::SameOrigin.allows(page_origin, same_origin_url));
assert!(FetchCredentials::SameOrigin.allows(page_origin, explicit_default_port));
assert!(!FetchCredentials::SameOrigin.allows(page_origin, cross_origin_url));
assert!(FetchCredentials::Include.allows(page_origin, same_origin_url));
assert!(FetchCredentials::Include.allows(page_origin, cross_origin_url));
}
#[test]
fn credentialed_cors_requires_exact_origin_and_allow_credentials() {
let page_origin = "https://www.example.com";
assert!(cors_response_allows(
FetchCredentials::SameOrigin,
page_origin,
"*",
"",
));
assert!(!cors_response_allows(
FetchCredentials::Include,
page_origin,
"*",
"true",
));
assert!(!cors_response_allows(
FetchCredentials::Include,
page_origin,
page_origin,
"",
));
assert!(cors_response_allows(
FetchCredentials::Include,
page_origin,
page_origin,
"true",
));
}
#[test]
fn fetch_url_validation_honors_per_context_private_network_opt_in() {
let loopback = url::Url::parse("http://127.0.0.1:8080/resource").unwrap();
assert!(validate_fetch_url(&loopback, true).is_ok());
}
#[test]
fn fetch_url_validation_rejects_file_scheme() {
let file = url::Url::parse("file:///etc/passwd").unwrap();
let err = validate_fetch_url(&file, true)
.expect_err("file:// must be rejected by the fetch scheme gate");
assert!(
err.to_lowercase().contains("scheme"),
"error should name the forbidden scheme: {err}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn posted_task_chains_complete_without_zero_delay_timer_floor() {
let mut runtime = TelemacoJsRuntime::new();
runtime.set_dom(parse_html("<html><body></body></html>"));
runtime.set_url("http://example.com/posted-task-test");
runtime.run_page_init();
runtime
.execute_script(
"posted-task-throughput",
r#"
globalThis.__postedTaskBench = {
message: 0,
postTask: 0,
yields: 0,
started: performance.now(),
finished: 0,
};
const markFinished = () => {
if (__postedTaskBench.message === 100 &&
__postedTaskBench.postTask === 100 &&
__postedTaskBench.yields === 100) {
__postedTaskBench.finished = performance.now();
}
};
const channel = new MessageChannel();
channel.port2.onmessage = () => {
__postedTaskBench.message++;
if (__postedTaskBench.message < 100) channel.port1.postMessage(null);
else markFinished();
};
channel.port1.postMessage(null);
const postNext = () => scheduler.postTask(() => {
__postedTaskBench.postTask++;
if (__postedTaskBench.postTask < 100) postNext();
else markFinished();
});
postNext();
scheduler.postTask(async () => {
while (__postedTaskBench.yields < 100) {
await scheduler.yield();
__postedTaskBench.yields++;
}
markFinished();
});
"#,
)
.unwrap();
runtime.run_event_loop_bounded(100).await.unwrap();
let result = runtime
.evaluate(
r#"[
__postedTaskBench.message,
__postedTaskBench.postTask,
__postedTaskBench.yields,
__postedTaskBench.finished - __postedTaskBench.started,
]"#,
)
.unwrap();
let values = result.as_array().unwrap();
assert!(
values[..3].iter().all(|value| value.as_f64() == Some(100.0)),
"posted-task chains did not finish inside the 100ms pump: {result}",
);
assert!(
values[3].as_f64().is_some_and(|elapsed| elapsed >= 0.0 && elapsed < 75.0),
"300 chained posted-task deliveries retained timer-wheel latency: {result}",
);
}
#[tokio::test(flavor = "current_thread")]
async fn shared_posted_task_queue_preserves_priority_fifo_and_microtasks() {
let mut runtime = TelemacoJsRuntime::new();
runtime.set_dom(parse_html("<html><body></body></html>"));
runtime.set_url("http://example.com/posted-task-order");
runtime.run_page_init();
runtime
.execute_script(
"shared-posted-task-order",
r#"
globalThis.__sharedPostedOrder = ["sync"];
const channel = new MessageChannel();
channel.port2.onmessage = event => {
__sharedPostedOrder.push("message-" + event.data);
Promise.resolve().then(() => {
__sharedPostedOrder.push("message-" + event.data + "-microtask");
});
};
channel.port1.postMessage(1);
scheduler.postTask(() => {
__sharedPostedOrder.push("visible");
Promise.resolve().then(() => __sharedPostedOrder.push("visible-microtask"));
});
channel.port1.postMessage(2);
scheduler.postTask(() => {
__sharedPostedOrder.push("background");
}, { priority: "background" });
scheduler.postTask(() => {
__sharedPostedOrder.push("blocking");
Promise.resolve().then(() => __sharedPostedOrder.push("blocking-microtask"));
}, { priority: "user-blocking" });
Promise.resolve().then(() => __sharedPostedOrder.push("initial-microtask"));
"#,
)
.unwrap();
runtime.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
runtime.evaluate("__sharedPostedOrder").unwrap(),
serde_json::json!([
"sync",
"initial-microtask",
"blocking",
"blocking-microtask",
"message-1",
"message-1-microtask",
"visible",
"visible-microtask",
"message-2",
"message-2-microtask",
"background",
]),
);
}
#[cfg(feature = "render")]
#[test]
fn connected_shadow_nodes_invalidate_without_entering_light_tree_retention() {
let dom = parse_html(
r#"<x-host id="host"></x-host><div id="source"><span id="shadow-child"></span></div>"#,
);
let host = dom.get_element_by_id("host").unwrap();
let source = dom.get_element_by_id("source").unwrap();
let child = dom.get_element_by_id("shadow-child").unwrap();
let root = dom
.attach_shadow_root(host, ShadowRootMode::Open)
.unwrap();
dom.append_child(root, child);
assert!(node_is_connected(&dom, child));
assert!(shadow_including_connected_nodes(&dom).contains(&child));
assert!(
retained_style_mutation(&dom, "set_attribute", &child.index().to_string(), "class\0changed")
.is_none(),
"shadow mutations require a full scoped cascade"
);
dom.append_child(source, host);
assert!(node_is_connected(&dom, child));
dom.remove(source);
assert!(!node_is_connected(&dom, child));
assert!(!shadow_including_connected_nodes(&dom).contains(&child));
}
#[cfg(feature = "render")]
#[test]
fn repeated_inline_style_writes_share_one_retained_dirty_marker_per_node() {
let mut pending = Vec::new();
let style_mutation = |raw| {
telemaco_render::RetainedStyleMutation::Attribute(
telemaco_render::AttributeStyleMutation {
node: telemaco_dom::tree::NodeId::new(raw),
name: "style".to_string(),
old_value: None,
new_value: None,
},
)
};
for raw in 1..=200 {
assert!(queue_retained_style_mutation(
&mut pending,
style_mutation(raw),
));
assert!(queue_retained_style_mutation(
&mut pending,
style_mutation(raw),
));
}
assert_eq!(pending.len(), 200);
for raw in 201..=MAX_PENDING_STYLE_MUTATIONS as u32 {
assert!(queue_retained_style_mutation(
&mut pending,
style_mutation(raw),
));
}
assert_eq!(pending.len(), MAX_PENDING_STYLE_MUTATIONS);
assert!(queue_retained_style_mutation(
&mut pending,
style_mutation(1),
));
assert!(!queue_retained_style_mutation(
&mut pending,
style_mutation(MAX_PENDING_STYLE_MUTATIONS as u32 + 1),
));
assert_eq!(pending.len(), MAX_PENDING_STYLE_MUTATIONS);
}
#[cfg(feature = "render")]
#[test]
fn repeated_selector_attribute_writes_keep_only_the_rendered_transition() {
let node = telemaco_dom::tree::NodeId::new(7);
let mutation = |old: &str, new: &str| {
telemaco_render::RetainedStyleMutation::Attribute(
telemaco_render::AttributeStyleMutation {
node,
name: "class".to_string(),
old_value: Some(old.to_string()),
new_value: Some(new.to_string()),
},
)
};
let mut pending = Vec::new();
assert!(queue_retained_style_mutation(
&mut pending,
mutation("before", "intermediate"),
));
assert!(queue_retained_style_mutation(
&mut pending,
mutation("intermediate", "after"),
));
assert_eq!(
pending,
vec![telemaco_render::RetainedStyleMutation::Attribute(
telemaco_render::AttributeStyleMutation {
node,
name: "class".to_string(),
old_value: Some("before".to_string()),
new_value: Some("after".to_string()),
}
)]
);
}
#[cfg(feature = "render")]
#[test]
fn repeated_animation_changes_share_one_retained_dirty_marker_per_node() {
let mut pending = Vec::new();
let first = telemaco_dom::tree::NodeId::new(1);
let second = telemaco_dom::tree::NodeId::new(2);
for _ in 0..300 {
assert!(queue_retained_style_mutation(
&mut pending,
telemaco_render::RetainedStyleMutation::Animation { node: first },
));
}
assert!(queue_retained_style_mutation(
&mut pending,
telemaco_render::RetainedStyleMutation::Animation { node: second },
));
assert_eq!(
pending,
vec![
telemaco_render::RetainedStyleMutation::Animation { node: first },
telemaco_render::RetainedStyleMutation::Animation { node: second },
]
);
}
#[cfg(feature = "render")]
#[test]
fn repeated_resource_changes_share_one_retained_refresh_marker() {
let mut pending = vec![telemaco_render::RetainedStyleMutation::Animation {
node: telemaco_dom::tree::NodeId::new(1),
}];
for _ in 0..300 {
assert!(queue_retained_style_mutation(
&mut pending,
telemaco_render::RetainedStyleMutation::Resource,
));
}
assert_eq!(
pending,
vec![
telemaco_render::RetainedStyleMutation::Animation {
node: telemaco_dom::tree::NodeId::new(1),
},
telemaco_render::RetainedStyleMutation::Resource,
]
);
let mut full_style_batch = (1..=MAX_PENDING_STYLE_MUTATIONS)
.map(|raw| telemaco_render::RetainedStyleMutation::Animation {
node: telemaco_dom::tree::NodeId::new(raw as u32),
})
.collect::<Vec<_>>();
assert!(queue_retained_style_mutation(
&mut full_style_batch,
telemaco_render::RetainedStyleMutation::Resource,
));
assert_eq!(full_style_batch.len(), MAX_PENDING_STYLE_MUTATIONS + 1);
assert!(!queue_retained_style_mutation(
&mut full_style_batch,
telemaco_render::RetainedStyleMutation::Animation {
node: telemaco_dom::tree::NodeId::new(5_000),
},
));
}
#[cfg(feature = "render")]
#[test]
fn geometry_consumer_defers_paint_only_sample_until_exact_consumer() {
let dom = parse_html(
r#"<style>
@keyframes fade { from { opacity:0 } to { opacity:1 } }
#box { width:40px;height:20px;animation:fade 1000ms linear both }
</style><div id="box"></div>"#,
);
let box_node = dom.get_element_by_id("box").unwrap();
let mut state = TelemacoState::new();
state.dom = Some(dom);
state.animation_sample = telemaco_render::AnimationSample::document(0.0);
ensure_prepared_render(&mut state).expect("initial render");
assert_eq!(
state.prepared_render.as_ref().unwrap().layout().styles[&box_node].opacity,
Some(0.0),
);
state.animation_sample = telemaco_render::AnimationSample::document(500.0);
let geometry = ensure_prepared_geometry(&mut state).expect("retained geometry");
assert_eq!(geometry.animation_sample_time().milliseconds, 0.0);
assert_eq!(geometry.document_rect(box_node).unwrap().width, 40.0);
assert_eq!(geometry.layout().styles[&box_node].opacity, Some(0.0));
let exact = ensure_prepared_render(&mut state).expect("exact sampled style");
assert_eq!(exact.animation_sample_time().milliseconds, 500.0);
let opacity = exact.layout().styles[&box_node].opacity.unwrap();
assert!((opacity - 0.5).abs() < 0.01, "exact opacity was {opacity}");
assert_eq!(exact.document_rect(box_node).unwrap().width, 40.0);
}
#[cfg(feature = "render")]
#[test]
fn geometry_consumer_materializes_geometry_animation_sample() {
let dom = parse_html(
r#"<style>
@keyframes grow { from { width:20px } to { width:100px } }
#box { height:20px;animation:grow 1000ms linear both }
</style><div id="box"></div>"#,
);
let box_node = dom.get_element_by_id("box").unwrap();
let mut state = TelemacoState::new();
state.dom = Some(dom);
state.animation_sample = telemaco_render::AnimationSample::document(0.0);
ensure_prepared_render(&mut state).expect("initial render");
state.animation_sample = telemaco_render::AnimationSample::document(500.0);
let geometry = ensure_prepared_geometry(&mut state).expect("sampled geometry");
assert_eq!(geometry.animation_sample_time().milliseconds, 500.0);
let width = geometry.document_rect(box_node).unwrap().width;
assert!((width - 60.0).abs() < 0.1, "sampled width was {width}");
}
}
fn validate_fetch_url(url: &url::Url, allow_private_network: bool) -> Result<(), String> {
let scheme = url.scheme();
if scheme != "http" && scheme != "https" {
return Err(format!(
"Forbidden URL scheme '{}' - only http and https are allowed",
scheme
));
}
if allow_private_network || telemaco_net::env_allows_private_network() {
return Ok(());
}
if let Some(host) = url.host() {
match host {
url::Host::Ipv4(ip) => {
if telemaco_net::is_forbidden_ip(std::net::IpAddr::V4(ip)) {
return Err(format!(
"Access to private/internal IP address {} is not allowed",
ip
));
}
}
url::Host::Ipv6(ip) => {
if telemaco_net::is_forbidden_ip(std::net::IpAddr::V6(ip)) {
return Err(format!(
"Access to private/internal IPv6 address {} is not allowed",
ip
));
}
}
url::Host::Domain(domain) => {
let lower_domain = domain.to_lowercase();
if lower_domain == "localhost"
|| lower_domain.ends_with(".localhost")
|| lower_domain == "127.0.0.1"
|| lower_domain == "::1"
{
return Err(format!(
"Access to localhost domain '{}' is not allowed",
domain
));
}
}
}
}
Ok(())
}
#[op2]
#[string]
fn op_get_cookies(scope: &mut v8::HandleScope, state: &OpState) -> String {
let gs = realm_state(scope, state);
let gs = gs.borrow();
let jar = match &gs.cookie_jar {
Some(j) => j,
None => return String::new(),
};
let url = match url::Url::parse(&gs.url) {
Ok(u) => u,
Err(_) => return String::new(),
};
jar.get_js_visible_cookies(&url)
}
#[op2(fast)]
fn op_set_cookie(scope: &mut v8::HandleScope, state: &OpState, #[string] cookie_str: &str) {
let gs = realm_state(scope, state);
let gs = gs.borrow();
let jar = match &gs.cookie_jar {
Some(j) => j,
None => return,
};
let url = match url::Url::parse(&gs.url) {
Ok(u) => u,
Err(_) => return,
};
jar.set_cookie_from_js(cookie_str, &url);
}
#[op2(fast)]
fn op_navigate(
scope: &mut v8::HandleScope,
state: &OpState,
#[string] url: &str,
#[string] method: &str,
#[string] body: &str,
) {
let gs = realm_state(scope, state);
let mut gs = gs.borrow_mut();
gs.url = url.to_string();
gs.pending_navigation = Some((url.to_string(), method.to_string(), body.to_string()));
}
fn frame_message_queue_entry_limit() -> usize {
std::env::var("TELEMACO_FRAME_MESSAGE_QUEUE_ENTRIES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(4096)
}
fn frame_message_queue_byte_limit() -> usize {
std::env::var("TELEMACO_FRAME_MESSAGE_QUEUE_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8 * 1024 * 1024)
}
#[op2(fast)]
fn op_post_frame_message(
state: &OpState,
target_frame_id: u32,
source_frame_id: u32,
#[string] origin: &str,
#[string] target_origin: &str,
#[string] data_json: &str,
) {
let gs = state.borrow::<SharedState>().clone();
let mut gs = gs.borrow_mut();
let over_entries = gs.pending_frame_messages.len() >= frame_message_queue_entry_limit();
let over_bytes = gs
.pending_frame_message_bytes
.saturating_add(data_json.len())
> frame_message_queue_byte_limit();
if over_entries || over_bytes {
tracing::warn!(
"dropping a postMessage for frame {}: {} already queued, {} bytes",
target_frame_id,
gs.pending_frame_messages.len(),
gs.pending_frame_message_bytes,
);
return;
}
gs.pending_frame_message_bytes = gs.pending_frame_message_bytes.saturating_add(data_json.len());
gs.pending_frame_messages.push(PendingFrameMessage {
target_frame_id,
source_frame_id,
origin: origin.to_string(),
target_origin: target_origin.to_string(),
data_json: data_json.to_string(),
});
}
#[op2(async)]
async fn op_sleep(#[number] millis: u64) {
tokio::time::sleep(std::time::Duration::from_millis(millis)).await;
}
const MAX_PENDING_FRAME_DOCUMENTS: usize = 64;
const MAX_PENDING_FRAME_BYTES: usize = 32 * 1024 * 1024;
#[op2(fast)]
fn op_frame_document_ready(
scope: &mut v8::HandleScope,
state: &OpState,
#[string] url: &str,
#[string] html: &str,
#[number] viewport_width: u64,
#[number] viewport_height: u64,
) -> u32 {
let parent_frame_id = realm_state(scope, state).borrow().frame_id;
let gs = state.borrow::<SharedState>().clone();
let mut gs = gs.borrow_mut();
let bytes = url.len().saturating_add(html.len());
if gs.pending_frames.len() >= MAX_PENDING_FRAME_DOCUMENTS
|| gs.pending_frame_bytes.saturating_add(bytes) > MAX_PENDING_FRAME_BYTES
{
tracing::warn!(
"dropping frame document: {} pending documents, {} bytes",
gs.pending_frames.len(),
gs.pending_frame_bytes,
);
return 0;
}
let Some(frame_id) = gs.frame_id_counter.checked_add(1) else {
tracing::warn!("frame id space exhausted");
return 0;
};
gs.frame_id_counter = frame_id;
gs.pending_frame_bytes = gs.pending_frame_bytes.saturating_add(bytes);
gs.pending_frames.push(PendingFrame {
frame_id,
url: url.to_string(),
html: html.to_string(),
viewport_width,
viewport_height,
parent_frame_id,
});
frame_id
}
#[op2(fast)]
fn op_async_runtime_available() -> bool {
tokio::runtime::Handle::try_current().is_ok()
}
#[op2(async)]
async fn op_posted_task() {
tokio::task::yield_now().await;
}
#[op2(fast)]
fn op_binding_called(state: &OpState, #[string] name: &str, #[string] payload: &str) {
let gs = state.borrow::<SharedState>().clone();
let mut gs = gs.borrow_mut();
gs.pending_binding_calls
.push((name.to_string(), payload.to_string()));
}
#[op2]
#[buffer]
fn op_subtle_digest(#[string] algorithm: &str, #[buffer] data: &[u8]) -> Vec<u8> {
use sha1::Digest as _;
let alg = algorithm.to_ascii_uppercase();
match alg.as_str() {
"SHA-1" => sha1::Sha1::digest(data).to_vec(),
"SHA-256" => sha2::Sha256::digest(data).to_vec(),
"SHA-384" => sha2::Sha384::digest(data).to_vec(),
"SHA-512" => sha2::Sha512::digest(data).to_vec(),
"SHA-512/224" => sha2::Sha512_224::digest(data).to_vec(),
"SHA-512/256" => sha2::Sha512_256::digest(data).to_vec(),
_ => vec![],
}
}
fn crypto_err(msg: impl std::fmt::Display) -> deno_error::JsErrorBox {
deno_error::JsErrorBox::generic(msg.to_string())
}
#[op2]
#[buffer]
fn op_subtle_hmac(
#[string] hash: &str,
#[buffer] key: &[u8],
#[buffer] data: &[u8],
) -> Result<Vec<u8>, deno_error::JsErrorBox> {
use hmac::{Hmac, Mac};
macro_rules! run {
($d:ty) => {{
let mut mac = Hmac::<$d>::new_from_slice(key).map_err(crypto_err)?;
mac.update(data);
mac.finalize().into_bytes().to_vec()
}};
}
Ok(match hash {
"SHA-1" => run!(sha1::Sha1),
"SHA-256" => run!(sha2::Sha256),
"SHA-384" => run!(sha2::Sha384),
"SHA-512" => run!(sha2::Sha512),
_ => return Err(crypto_err("unsupported HMAC hash")),
})
}
#[op2]
#[buffer]
fn op_subtle_aes_gcm(
encrypt: bool,
#[buffer] key: &[u8],
#[buffer] iv: &[u8],
#[buffer] aad: &[u8],
#[buffer] data: &[u8],
) -> Result<Vec<u8>, deno_error::JsErrorBox> {
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::aes::{Aes192, Aes256};
use aes_gcm::{AesGcm, Nonce};
type Aes192Gcm = AesGcm<Aes192, aes_gcm::aead::consts::U12>;
type Aes256Gcm = AesGcm<Aes256, aes_gcm::aead::consts::U12>;
if iv.len() != 12 {
return Err(crypto_err("AES-GCM requires a 96-bit (12-byte) IV"));
}
let nonce = Nonce::from_slice(iv);
macro_rules! run {
($ty:ty) => {{
let cipher = <$ty>::new_from_slice(key).map_err(crypto_err)?;
if encrypt {
cipher
.encrypt(nonce, Payload { msg: data, aad })
.map_err(|_| crypto_err("AES-GCM encryption failed"))?
} else {
cipher
.decrypt(nonce, Payload { msg: data, aad })
.map_err(|_| {
crypto_err("AES-GCM decryption failed: authentication tag mismatch")
})?
}
}};
}
Ok(match key.len() {
16 => run!(aes_gcm::Aes128Gcm),
24 => run!(Aes192Gcm),
32 => run!(Aes256Gcm),
_ => return Err(crypto_err("AES-GCM key must be 128, 192, or 256 bits")),
})
}
#[op2]
#[buffer]
fn op_subtle_aes_cbc(
encrypt: bool,
#[buffer] key: &[u8],
#[buffer] iv: &[u8],
#[buffer] data: &[u8],
) -> Result<Vec<u8>, deno_error::JsErrorBox> {
use cbc::cipher::block_padding::Pkcs7;
use cbc::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit};
use cbc::{Decryptor, Encryptor};
if iv.len() != 16 {
return Err(crypto_err("AES-CBC requires a 16-byte IV"));
}
macro_rules! run {
($cipher:ty) => {{
if encrypt {
Encryptor::<$cipher>::new_from_slices(key, iv)
.map_err(crypto_err)?
.encrypt_padded_vec_mut::<Pkcs7>(data)
} else {
Decryptor::<$cipher>::new_from_slices(key, iv)
.map_err(crypto_err)?
.decrypt_padded_vec_mut::<Pkcs7>(data)
.map_err(|_| crypto_err("AES-CBC decryption failed: invalid padding"))?
}
}};
}
Ok(match key.len() {
16 => run!(aes::Aes128),
24 => run!(aes::Aes192),
32 => run!(aes::Aes256),
_ => return Err(crypto_err("AES-CBC key must be 128, 192, or 256 bits")),
})
}
#[op2]
#[buffer]
fn op_subtle_aes_ctr(
#[buffer] key: &[u8],
#[buffer] counter: &[u8],
counter_length: u32,
#[buffer] data: &[u8],
) -> Result<Vec<u8>, deno_error::JsErrorBox> {
use ctr::cipher::{KeyIvInit, StreamCipher};
if counter.len() != 16 {
return Err(crypto_err("AES-CTR requires a 16-byte counter block"));
}
let mut buf = data.to_vec();
macro_rules! run {
($ty:ty) => {{
<$ty>::new_from_slices(key, counter)
.map_err(crypto_err)?
.apply_keystream(&mut buf);
}};
}
macro_rules! by_key {
($flavor:ident) => {
match key.len() {
16 => run!(ctr::$flavor<aes::Aes128>),
24 => run!(ctr::$flavor<aes::Aes192>),
32 => run!(ctr::$flavor<aes::Aes256>),
_ => return Err(crypto_err("AES-CTR key must be 128, 192, or 256 bits")),
}
};
}
match counter_length {
128 => by_key!(Ctr128BE),
64 => by_key!(Ctr64BE),
32 => by_key!(Ctr32BE),
_ => {
return Err(crypto_err(
"AES-CTR supports counter lengths of 32, 64, or 128 bits",
))
}
}
Ok(buf)
}
const PBKDF2_MAX_ITERATIONS: u32 = 10_000_000;
const PBKDF2_MAX_OUTPUT_BYTES: u32 = 1024 * 1024;
fn pbkdf2_derive(
hash: &str,
password: &[u8],
salt: &[u8],
iterations: u32,
length: u32,
) -> Result<Vec<u8>, deno_error::JsErrorBox> {
if iterations > PBKDF2_MAX_ITERATIONS {
return Err(crypto_err(format!(
"PBKDF2 iteration count {iterations} exceeds the supported maximum of {PBKDF2_MAX_ITERATIONS}"
)));
}
if length > PBKDF2_MAX_OUTPUT_BYTES {
return Err(crypto_err(format!(
"PBKDF2 output length {length} bytes exceeds the supported maximum of {PBKDF2_MAX_OUTPUT_BYTES}"
)));
}
use pbkdf2::pbkdf2_hmac;
let mut dk = vec![0u8; length as usize];
match hash {
"SHA-1" => pbkdf2_hmac::<sha1::Sha1>(password, salt, iterations, &mut dk),
"SHA-256" => pbkdf2_hmac::<sha2::Sha256>(password, salt, iterations, &mut dk),
"SHA-384" => pbkdf2_hmac::<sha2::Sha384>(password, salt, iterations, &mut dk),
"SHA-512" => pbkdf2_hmac::<sha2::Sha512>(password, salt, iterations, &mut dk),
_ => return Err(crypto_err("unsupported PBKDF2 hash")),
}
Ok(dk)
}
#[op2]
#[buffer]
fn op_subtle_pbkdf2(
#[string] hash: &str,
#[buffer] password: &[u8],
#[buffer] salt: &[u8],
iterations: u32,
length: u32,
) -> Result<Vec<u8>, deno_error::JsErrorBox> {
pbkdf2_derive(hash, password, salt, iterations, length)
}
#[op2]
#[buffer]
fn op_subtle_hkdf(
#[string] hash: &str,
#[buffer] ikm: &[u8],
#[buffer] salt: &[u8],
#[buffer] info: &[u8],
length: u32,
) -> Result<Vec<u8>, deno_error::JsErrorBox> {
use hkdf::Hkdf;
let mut okm = vec![0u8; length as usize];
macro_rules! run {
($d:ty) => {
Hkdf::<$d>::new(Some(salt), ikm)
.expand(info, &mut okm)
.map_err(|_| crypto_err("HKDF: requested key length is too long"))?
};
}
match hash {
"SHA-1" => run!(sha1::Sha1),
"SHA-256" => run!(sha2::Sha256),
"SHA-384" => run!(sha2::Sha384),
"SHA-512" => run!(sha2::Sha512),
_ => return Err(crypto_err("unsupported HKDF hash")),
}
Ok(okm)
}
#[op2]
#[buffer]
fn op_random_bytes(len: u32) -> Result<Vec<u8>, deno_error::JsErrorBox> {
let mut buf = vec![0u8; len as usize];
getrandom::getrandom(&mut buf).map_err(|e| crypto_err(format!("getrandom failed: {e}")))?;
Ok(buf)
}
fn url_components(u: &url::Url) -> serde_json::Value {
let port = u.port().map(|p| p.to_string()).unwrap_or_default();
let hostname = u.host_str().unwrap_or("").to_string();
let host = if hostname.is_empty() {
String::new()
} else if port.is_empty() {
hostname.clone()
} else {
format!("{hostname}:{port}")
};
let search = match u.query() {
Some(q) if !q.is_empty() => format!("?{q}"),
_ => String::new(),
};
let hash = match u.fragment() {
Some(f) if !f.is_empty() => format!("#{f}"),
_ => String::new(),
};
serde_json::json!({
"ok": true,
"href": u.as_str(),
"protocol": format!("{}:", u.scheme()),
"username": u.username(),
"password": u.password().unwrap_or(""),
"host": host,
"hostname": hostname,
"port": port,
"pathname": u.path(),
"search": search,
"hash": hash,
"origin": u.origin().ascii_serialization(),
})
}
#[op2]
#[string]
fn op_url_parse(#[string] href: &str, #[string] base: &str) -> String {
std::panic::catch_unwind(|| {
let parsed = if base.is_empty() {
url::Url::parse(href)
} else {
url::Url::parse(base).and_then(|b| b.join(href))
};
match parsed {
Ok(u) => url_components(&u).to_string(),
Err(_) => "{\"ok\":false}".to_string(),
}
})
.unwrap_or_else(|_| "{\"ok\":false}".to_string())
}
fn url_set_inner(href: &str, part: &str, value: &str) -> Option<serde_json::Value> {
let mut u = url::Url::parse(href).ok()?;
match part {
"href" => {
let nu = url::Url::parse(value).ok()?;
return Some(url_components(&nu));
}
"protocol" => {
let _ = u.set_scheme(value.trim_end_matches(':'));
}
"username" => {
let _ = u.set_username(value);
}
"password" => {
let _ = u.set_password(if value.is_empty() { None } else { Some(value) });
}
"host" => set_host_port(&mut u, value),
"hostname" => {
if !value.is_empty() {
let _ = u.set_host(Some(value));
}
}
"port" => {
if value.is_empty() {
let _ = u.set_port(None);
} else if let Ok(p) = value.parse::<u16>() {
let _ = u.set_port(Some(p));
}
}
"pathname" => u.set_path(value),
"search" => {
let q = value.strip_prefix('?').unwrap_or(value);
u.set_query(if q.is_empty() { None } else { Some(q) });
}
"hash" => {
let f = value.strip_prefix('#').unwrap_or(value);
u.set_fragment(if f.is_empty() { None } else { Some(f) });
}
_ => {}
}
Some(url_components(&u))
}
#[op2]
#[string]
fn op_url_set(#[string] href: &str, #[string] part: &str, #[string] value: &str) -> String {
match std::panic::catch_unwind(|| url_set_inner(href, part, value)) {
Ok(Some(v)) => v.to_string(),
_ => match url::Url::parse(href) {
Ok(u) => url_components(&u).to_string(),
Err(_) => "{\"ok\":false}".to_string(),
},
}
}
fn set_host_port(u: &mut url::Url, value: &str) {
if value.starts_with('[') {
if let Some(close) = value.find(']') {
let host = &value[..=close];
let rest = &value[close + 1..];
if u.set_host(Some(host)).is_ok() {
if let Some(p) = rest.strip_prefix(':') {
if let Ok(pn) = p.parse::<u16>() {
let _ = u.set_port(Some(pn));
}
}
}
return;
}
}
if let Some(idx) = value.rfind(':') {
let (h, p) = (&value[..idx], &value[idx + 1..]);
if p.is_empty() || p.chars().all(|c| c.is_ascii_digit()) {
if u.set_host(Some(h)).is_ok() {
if p.is_empty() {
let _ = u.set_port(None);
} else if let Ok(pn) = p.parse::<u16>() {
let _ = u.set_port(Some(pn));
}
}
return;
}
}
let _ = u.set_host(Some(value));
}
#[op2]
#[string]
fn op_url_resolve(#[string] href: &str, #[string] base: &str) -> String {
std::panic::catch_unwind(|| {
let parsed = if base.is_empty() {
url::Url::parse(href)
} else {
url::Url::parse(base).and_then(|b| b.join(href))
};
parsed.map(|u| u.as_str().to_string()).unwrap_or_default()
})
.unwrap_or_default()
}
#[op2]
#[string]
fn op_document_domain_candidate(#[string] current: &str, #[string] input: &str) -> String {
let canonical = match url::Host::parse(input) {
Ok(host) => host.to_string().to_ascii_lowercase(),
Err(_) => return String::new(),
};
let current = current.to_ascii_lowercase();
if canonical == current {
return canonical;
}
if current.parse::<std::net::IpAddr>().is_ok()
|| canonical.parse::<std::net::IpAddr>().is_ok()
|| !current.ends_with(&format!(".{canonical}"))
{
return String::new();
}
match psl::domain_str(¤t) {
Some(registrable) if canonical.len() >= registrable.len() => canonical,
_ => String::new(),
}
}
#[op2]
#[string]
fn op_add_import_map(
state: &OpState,
#[string] source: String,
#[string] base_url: String,
) -> String {
let shared = state.borrow::<SharedState>().clone();
let import_map = shared.borrow().import_map.clone();
let parsed = match ImportMap::parse(&source, &base_url) {
Ok(map) => map,
Err(error) => return error,
};
let result = match import_map.try_borrow_mut() {
Ok(mut current) => {
current.merge(parsed);
String::new()
}
Err(_) => "Import map is already borrowed".to_string(),
};
result
}
#[op2]
#[string]
fn op_encoding_for_label(#[string] label: &str) -> String {
telemaco_net::label_name(label).unwrap_or_default()
}
#[op2]
#[string]
fn op_text_decode(
#[string] label: &str,
#[buffer] bytes: &[u8],
fatal: bool,
ignore_bom: bool,
) -> String {
match telemaco_net::decode_with_label(label, bytes, fatal, ignore_bom) {
Some(s) => serde_json::json!({ "ok": true, "v": s }).to_string(),
None => "{\"ok\":false}".to_string(),
}
}
#[op2]
#[string]
fn op_url_encode_query(#[string] query: &str, #[string] label: &str, special: bool) -> String {
telemaco_net::url_encode_query(query, label, special).unwrap_or_else(|| query.to_string())
}
#[cfg(feature = "render")]
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct DynamicFontFaceInput {
family: String,
source: String,
style: String,
weight: String,
unicode_range: String,
}
#[cfg(feature = "render")]
#[op2(fast)]
fn op_set_dynamic_fonts(state: &OpState, #[string] registrations: &str) -> bool {
let Ok(inputs) = serde_json::from_str::<Vec<DynamicFontFaceInput>>(registrations) else {
return false;
};
if inputs.len() > 256
|| inputs
.iter()
.try_fold(0usize, |total, face| total.checked_add(face.source.len()))
.map_or(true, |total| total > 64 * 1024 * 1024)
|| inputs.iter().any(|face| {
face.family.len() > 1024
|| face.source.len() > 12 * 1024 * 1024
|| face.style.len() > 256
|| face.weight.len() > 256
|| face.unicode_range.len() > 4096
})
{
return false;
}
let fonts = inputs
.into_iter()
.map(|face| telemaco_render::DynamicFontFace {
family: face.family,
source: face.source,
style: face.style,
weight: face.weight,
unicode_range: face.unicode_range,
})
.collect::<Vec<_>>();
let shared = state.borrow::<SharedState>().clone();
let mut state = shared.borrow_mut();
if state.dynamic_fonts != fonts {
state.dynamic_fonts = fonts;
invalidate_render_resource_geometry(&mut state);
}
true
}
#[cfg(feature = "render")]
#[op2]
fn op_canvas_register_surface(
state: &OpState,
nid: u32,
width: u32,
height: u32,
#[buffer] pixels: JsBuffer,
) -> bool {
const MAX_CANVAS_DIMENSION: u32 = 32_767;
const MAX_CANVAS_PIXELS: usize = 67_108_864;
const MAX_CANVAS_SURFACE_BYTES: usize = 256 * 1024 * 1024;
let Some(expected) = (width as usize)
.checked_mul(height as usize)
.and_then(|pixels| pixels.checked_mul(4))
else {
return false;
};
if width > MAX_CANVAS_DIMENSION
|| height > MAX_CANVAS_DIMENSION
|| expected / 4 > MAX_CANVAS_PIXELS
|| pixels.len() != expected
{
return false;
}
let shared = state.borrow::<SharedState>().clone();
let mut state = shared.borrow_mut();
let node = NodeId::new(nid);
let is_canvas = state
.dom
.as_ref()
.and_then(|dom| dom.get_node(node))
.is_some_and(|node| {
node.as_element()
.is_some_and(|name| name.local.as_ref() == "canvas")
});
if !is_canvas {
return false;
}
let replacing = state.canvas_surfaces.get(&node).map(|surface| surface.pixels.len());
let retained_bytes = state
.canvas_surfaces
.values()
.try_fold(0usize, |total, surface| total.checked_add(surface.pixels.len()))
.and_then(|total| total.checked_sub(replacing.unwrap_or(0)))
.and_then(|total| total.checked_add(expected));
if retained_bytes.is_none_or(|bytes| bytes > MAX_CANVAS_SURFACE_BYTES) {
return false;
}
state.canvas_surfaces.insert(
node,
CanvasBackingSurface {
width,
height,
pixels,
},
);
true
}
#[cfg(feature = "render")]
#[op2(fast)]
fn op_canvas_paint_damage(state: &OpState, nid: u32) -> bool {
let shared = state.borrow::<SharedState>().clone();
let mut state = shared.borrow_mut();
let node = NodeId::new(nid);
if !state.canvas_surfaces.contains_key(&node) {
return false;
}
let connected = state
.dom
.as_ref()
.is_some_and(|dom| node_is_connected(dom, node));
if connected {
state.activity_generation = state.activity_generation.wrapping_add(1);
}
connected
}
pub fn build_extension() -> Extension {
let mut ops = vec![
op_dom(),
op_script_mark_started(),
op_script_try_start(),
op_shadow_attach(),
op_shadow_root_info(),
op_runtime_events_enabled(),
op_console_msg(),
op_fetch_url(),
op_get_cookies(),
op_set_cookie(),
op_navigate(),
op_frame_document_ready(),
op_post_frame_message(),
op_sleep(),
op_async_runtime_available(),
op_posted_task(),
op_binding_called(),
op_subtle_digest(),
op_subtle_hmac(),
op_subtle_aes_gcm(),
op_subtle_aes_cbc(),
op_subtle_aes_ctr(),
op_subtle_pbkdf2(),
op_subtle_hkdf(),
op_random_bytes(),
op_url_parse(),
op_url_set(),
op_url_resolve(),
op_document_domain_candidate(),
op_add_import_map(),
op_encoding_for_label(),
op_text_decode(),
op_url_encode_query(),
];
#[cfg(feature = "render")]
{
ops.push(op_begin_render_task());
ops.push(op_set_dynamic_fonts());
ops.push(op_canvas_register_surface());
ops.push(op_canvas_paint_damage());
ops.push(op_image_metadata());
ops.push(op_load_image_metadata());
ops.push(op_layout_geometry());
ops.push(op_resize_observer_measurements());
ops.push(op_intersection_observer_measurements());
ops.push(op_computed_style());
ops.push(op_css_supports());
ops.push(op_layout_metrics());
ops.push(op_element_scroll_metrics());
ops.push(op_element_scroll_to());
ops.push(op_scroll_offset());
ops.push(op_scroll_to());
ops.push(op_waapi_create());
ops.push(op_waapi_control());
}
Extension {
name: "telemaco_dom",
ops: std::borrow::Cow::Owned(ops),
..Default::default()
}
}
#[cfg(feature = "render")]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WaapiCreateInput {
id: u64,
node: u32,
keyframes: Vec<WaapiKeyframeInput>,
duration: f32,
delay: f32,
iterations: f32,
#[serde(default)]
iterations_infinite: bool,
fill: String,
direction: String,
easing_bezier: Option<[f32; 4]>,
linear_easing: Option<Vec<f32>>,
}
#[cfg(feature = "render")]
#[derive(Deserialize)]
struct WaapiKeyframeInput {
offset: f32,
opacity: Option<f32>,
transform: Option<String>,
}
#[cfg(feature = "render")]
fn waapi_document_time_ms(state: &TelemacoState) -> f32 {
state.animation_timeline_origin.elapsed().as_secs_f32() * 1000.0
}
#[cfg(feature = "render")]
fn invalidate_waapi_render(state: &mut TelemacoState, node: NodeId) {
if state.prepared_render.is_some()
&& !queue_retained_style_mutation(
&mut state.pending_style_mutations,
telemaco_render::RetainedStyleMutation::WaapiAnimation { node },
)
{
state.prepared_render = None;
state.pending_style_mutations.clear();
}
state.resolved_scroll = None;
state.activity_generation = state.activity_generation.wrapping_add(1);
}
#[cfg(feature = "render")]
#[op2(fast)]
fn op_waapi_create(state: &OpState, #[string] input: &str) -> bool {
let Ok(input) = serde_json::from_str::<WaapiCreateInput>(input) else {
return false;
};
if !input.duration.is_finite()
|| input.duration < 0.0
|| !input.delay.is_finite()
|| !input.iterations.is_finite()
|| input.iterations < 0.0
|| input.keyframes.is_empty()
{
return false;
}
let shared = state.borrow::<SharedState>().clone();
let mut state = shared.borrow_mut();
let node = NodeId::new(input.node);
if state.dom.as_ref().and_then(|dom| dom.get_node(node)).is_none() {
return false;
}
let start_time_ms = waapi_document_time_ms(&state);
let fill_mode = match input.fill.as_str() {
"forwards" => telemaco_render::AnimationFillMode::Forwards,
"backwards" => telemaco_render::AnimationFillMode::Backwards,
"both" => telemaco_render::AnimationFillMode::Both,
_ => telemaco_render::AnimationFillMode::None,
};
let direction = match input.direction.as_str() {
"reverse" => telemaco_render::AnimationDirection::Reverse,
"alternate" => telemaco_render::AnimationDirection::Alternate,
"alternate-reverse" => telemaco_render::AnimationDirection::AlternateReverse,
_ => telemaco_render::AnimationDirection::Normal,
};
let iterations = if input.iterations_infinite {
f32::INFINITY
} else {
input.iterations
};
state.animation_timeline.register_waapi(telemaco_render::WaapiAnimation {
id: input.id,
node,
keyframes: input.keyframes.into_iter().map(|frame| telemaco_render::WaapiKeyframe {
offset: frame.offset.clamp(0.0, 1.0),
opacity: frame.opacity.map(|value| value.clamp(0.0, 1.0)),
transform: frame.transform,
}).collect(),
timing: telemaco_render::AnimationTiming {
duration_ms: input.duration,
delay_ms: input.delay,
iteration_count: iterations,
direction,
fill_mode,
play_state: telemaco_render::AnimationPlayState::Running,
},
easing: input.easing_bezier,
linear_easing: input.linear_easing,
start_time_ms,
hold_time_ms: None,
play_state: telemaco_render::WaapiPlayState::Running,
});
invalidate_waapi_render(&mut state, node);
true
}
#[cfg(feature = "render")]
#[op2(fast)]
fn op_waapi_control(
state: &OpState,
id: f64,
#[string] action: &str,
value: f64,
) -> bool {
if !id.is_finite() || id < 0.0 {
return false;
}
let shared = state.borrow::<SharedState>().clone();
let mut state = shared.borrow_mut();
let id = id as u64;
let Some(node) = state.animation_timeline.waapi_node(id) else {
return false;
};
let document_time = waapi_document_time_ms(&state);
let changed = match action {
"cancel" => state.animation_timeline.cancel_waapi(id),
"finish" => state.animation_timeline.finish_waapi(id),
"pause" => state.animation_timeline.set_waapi_play_state(
id,
telemaco_render::WaapiPlayState::Paused,
document_time,
),
"play" => state.animation_timeline.set_waapi_play_state(
id,
telemaco_render::WaapiPlayState::Running,
document_time,
),
"currentTime" if value.is_finite() => state.animation_timeline.set_waapi_current_time(
id,
document_time,
value as f32,
),
_ => false,
};
if changed {
invalidate_waapi_render(&mut state, node);
}
changed
}
pub(crate) fn document_base_url(state: &TelemacoState) -> Option<String> {
let document_url = url::Url::parse(&state.url).ok()?;
let base_href = state.dom.as_ref().and_then(|dom| {
dom.query_selector("base[href]")
.ok()
.flatten()
.and_then(|id| {
dom.get_node(id)
.and_then(|node| node.get_attribute("href").map(str::to_string))
})
});
match base_href {
Some(href) => match document_url.join(&href) {
Ok(base) if base.scheme() != "data" && base.scheme() != "javascript" => {
Some(base.to_string())
}
_ => Some(document_url.to_string()),
},
None => Some(document_url.to_string()),
}
}
fn document_base_href(state: &TelemacoState) -> Option<String> {
state.dom.as_ref().and_then(|dom| {
dom.query_selector("base[href]")
.ok()
.flatten()
.and_then(|id| {
dom.get_node(id)
.and_then(|node| node.get_attribute("href").map(str::to_string))
})
})
}
pub struct BaseUrlCache {
activity_generation: u64,
document_generation: u64,
url: String,
resolved: Option<String>,
raw_href: Option<String>,
}
fn base_values_memoized(state: &TelemacoState) -> (Option<String>, Option<String>) {
if let Some(cached) = state.base_url_cache.borrow().as_ref() {
if cached.activity_generation == state.activity_generation
&& cached.document_generation == state.document_generation
&& cached.url == state.url
{
return (cached.resolved.clone(), cached.raw_href.clone());
}
}
let resolved = document_base_url(state);
let raw_href = document_base_href(state);
*state.base_url_cache.borrow_mut() = Some(BaseUrlCache {
activity_generation: state.activity_generation,
document_generation: state.document_generation,
url: state.url.clone(),
resolved: resolved.clone(),
raw_href: raw_href.clone(),
});
(resolved, raw_href)
}
pub(crate) fn document_base_url_memoized(state: &TelemacoState) -> Option<String> {
base_values_memoized(state).0
}
pub(crate) fn document_base_href_memoized(state: &TelemacoState) -> Option<String> {
base_values_memoized(state).1
}
#[cfg(feature = "render")]
pub(crate) fn ensure_prepared_render(
state: &mut TelemacoState,
) -> Option<&telemaco_render::PreparedRender> {
let base_url = document_base_url(state);
let viewport = state.viewport;
let render_media = state.render_media;
let animation_sample = state.animation_sample;
let incompatible = state.prepared_render.as_ref().is_some_and(|prepared| {
prepared.viewport() != viewport
|| prepared.base_url() != base_url.as_deref()
});
let needs_rebuild = state.prepared_render.as_ref().map_or(true, |prepared| {
incompatible || prepared.animation_sample() != animation_sample
}) || !state.pending_style_mutations.is_empty();
if needs_rebuild {
if let Some(dom) = state.dom.as_ref() {
state
.animation_timeline
.materialize_start_candidates(dom);
}
let previous = (!incompatible && render_media == telemaco_render::CssMediaType::Screen)
.then(|| state.prepared_render.take())
.flatten();
let mutations = std::mem::take(&mut state.pending_style_mutations);
let prepared = {
let dom = state.dom.as_ref()?;
match previous {
Some(previous) => telemaco_render::prepare_dom_with_retained_styles_with_animation_state(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
previous,
&mutations,
animation_sample,
&mut state.animation_timeline,
)
.or_else(|| {
telemaco_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
animation_sample,
&mut state.animation_timeline,
)
})?,
None => match render_media {
telemaco_render::CssMediaType::Screen => telemaco_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
animation_sample,
&mut state.animation_timeline,
)?,
telemaco_render::CssMediaType::Print => telemaco_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_for_media_with_animation_state(
dom,
viewport,
base_url.as_deref(),
&mut state.render_resources,
&state.dynamic_fonts,
&mut state.stylesheet_cache,
render_media,
animation_sample,
&mut state.animation_timeline,
)?,
},
}
};
if animation_sample.mode == telemaco_render::AnimationSampleMode::DocumentTime {
state.animation_timeline.clear_start_candidates();
}
let connected = state
.dom
.as_ref()
.map(shadow_including_connected_nodes);
if let Some(connected) = connected {
state
.animation_timeline
.retain_nodes(|node| connected.contains(&node));
}
state.prepared_render = Some(prepared);
state.resolved_scroll = None;
}
state.prepared_render.as_ref()
}
#[cfg(feature = "render")]
fn ensure_prepared_geometry(
state: &mut TelemacoState,
) -> Option<&telemaco_render::PreparedRender> {
let base_url = document_base_url(state);
let reusable = state.pending_style_mutations.is_empty()
&& !state.animation_timeline.has_pending_start_candidates()
&& state.prepared_render.as_ref().is_some_and(|prepared| {
prepared.viewport() == state.viewport
&& prepared.base_url() == base_url.as_deref()
&& (prepared.animation_sample() == state.animation_sample
|| prepared.can_reuse_geometry_for_animation_sample(state.animation_sample))
});
if reusable {
return state.prepared_render.as_ref();
}
ensure_prepared_render(state)
}
#[cfg(feature = "render")]
pub(crate) fn sample_live_document_animations(state: &mut TelemacoState) {
if state.animation_sampled_task_generation == state.animation_task_generation {
return;
}
state.animation_sampled_task_generation = state.animation_task_generation;
let sample = telemaco_render::AnimationSample::document(
(state.animation_timeline_origin.elapsed().as_secs_f64() * 1_000.0)
.min(f64::from(f32::MAX)) as f32,
);
if state.animation_sample == sample {
return;
}
if sample.time.milliseconds > state.animation_sample.time.milliseconds
&& state.animation_sample.mode == telemaco_render::AnimationSampleMode::DocumentTime
&& state.pending_style_mutations.is_empty()
&& state.prepared_render.as_mut().is_some_and(|prepared| {
prepared.advance_inactive_animation_sample_time(sample.time)
})
{
state.animation_sample = sample;
return;
}
let forward_document_sample =
sample.mode == telemaco_render::AnimationSampleMode::DocumentTime
&& state.animation_sample.mode == telemaco_render::AnimationSampleMode::DocumentTime
&& sample.time.milliseconds > state.animation_sample.time.milliseconds;
state.animation_sample = sample;
if !forward_document_sample {
state.prepared_render = None;
state.pending_style_mutations.clear();
}
state.resolved_scroll = None;
}
#[cfg(feature = "render")]
pub(crate) fn begin_animation_task(state: &mut TelemacoState) {
state.animation_task_generation = state.animation_task_generation.wrapping_add(1);
}
#[cfg(feature = "render")]
#[op2(fast)]
fn op_begin_render_task(state: &OpState) {
let shared = state.borrow::<SharedState>().clone();
begin_animation_task(&mut shared.borrow_mut());
}
#[cfg(feature = "render")]
pub(crate) fn ensure_resolved_scroll(state: &mut TelemacoState) -> Option<()> {
ensure_resolved_scroll_for_consumer(state, false)
}
#[cfg(feature = "render")]
fn ensure_resolved_scroll_for_geometry(state: &mut TelemacoState) -> Option<()> {
ensure_resolved_scroll_for_consumer(state, true)
}
#[cfg(feature = "render")]
fn ensure_resolved_scroll_for_consumer(
state: &mut TelemacoState,
geometry_only: bool,
) -> Option<()> {
if geometry_only {
ensure_prepared_geometry(state)?;
} else {
ensure_prepared_render(state)?;
}
if state
.resolved_scroll
.as_ref()
.is_some_and(|(generation, _)| *generation == state.scroll_generation)
{
return Some(());
}
let valid = state
.prepared_render
.as_ref()?
.scroll_container_nodes()
.collect::<HashSet<_>>();
let snapshot = {
let dom = state.dom.as_ref()?;
state.prepared_render.as_ref()?.resolve_scroll_state(
dom,
state.scroll_offset,
&state.element_scroll_offsets,
)
};
state.scroll_offset = snapshot.root_offset();
for node in valid {
let offset = state
.prepared_render
.as_ref()?
.element_scroll_metrics(node, &snapshot)
.map(|metrics| metrics.offset)
.unwrap_or((0.0, 0.0));
if offset == (0.0, 0.0) {
state.element_scroll_offsets.remove(&node);
} else {
state.element_scroll_offsets.insert(node, offset);
}
}
state.resolved_scroll = Some((state.scroll_generation, snapshot));
Some(())
}
#[cfg(feature = "render")]
fn image_metadata_json(
current_src: String,
density: f32,
known: bool,
dimensions: Option<(f32, f32)>,
) -> String {
if !known {
return serde_json::json!({
"state": "pending",
"currentSrc": current_src,
"density": density,
})
.to_string();
}
match dimensions {
Some((width, height)) => serde_json::json!({
"state": "loaded",
"ok": true,
"currentSrc": current_src,
"density": density,
"width": width,
"height": height,
})
.to_string(),
None => serde_json::json!({
"state": "error",
"ok": false,
"currentSrc": current_src,
"density": density,
})
.to_string(),
}
}
#[cfg(feature = "render")]
fn image_request_profile(dom: &DomTree, node_id: NodeId) -> ImageRequestProfile {
match dom
.get_node(node_id)
.and_then(|node| node.get_attribute("crossorigin").map(str::to_owned))
.map(|value| value.trim().to_ascii_lowercase())
.as_deref()
{
Some("use-credentials") => ImageRequestProfile::CorsInclude,
Some(_) => ImageRequestProfile::CorsSameOrigin,
None => ImageRequestProfile::NoCorsInclude,
}
}
#[cfg(feature = "render")]
fn profiled_cached_image_metadata(
gs: &TelemacoState,
node_id: NodeId,
) -> Option<(String, f32, bool, Option<(f32, f32)>)> {
let dom = gs.dom.as_ref()?;
let base_url = document_base_url(gs);
gs.render_resources.cached_image_element_metadata(
dom,
node_id,
gs.viewport,
base_url.as_deref(),
)
}
#[cfg(feature = "render")]
fn cached_image_metadata_for_node(gs: &TelemacoState, node_id: NodeId) -> String {
match profiled_cached_image_metadata(gs, node_id) {
Some((current_src, density, known, dimensions)) => {
image_metadata_json(current_src, density, known, dimensions)
}
None => serde_json::json!({ "ok": false, "currentSrc": "" }).to_string(),
}
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_image_metadata(state: &OpState, nid: u32, _cached_only: bool) -> String {
let shared = state.borrow::<SharedState>().clone();
let gs = shared.borrow();
let node_id = NodeId::new(nid);
let is_image = gs.dom.as_ref().is_some_and(|dom| {
dom.get_node(node_id).is_some_and(|node| {
node.as_element()
.is_some_and(|element| element.local.as_ref() == "img")
})
});
if !is_image {
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
}
cached_image_metadata_for_node(&gs, node_id)
}
#[cfg(feature = "render")]
fn load_image_metadata_without_page_transport(gs: &mut TelemacoState, node_id: NodeId) -> String {
let base_url = document_base_url(&gs);
let viewport = gs.viewport;
let previous_dimensions = gs.dom.as_ref().and_then(|dom| {
gs.render_resources
.cached_image_element_metadata(dom, node_id, viewport, base_url.as_deref())
.and_then(|(_, _, known, dimensions)| known.then_some(dimensions).flatten())
});
let Some(dom) = gs.dom.as_ref() else {
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
};
let Some((current_src, density, dimensions)) = gs.render_resources.image_element_metadata(
dom,
node_id,
viewport,
base_url.as_deref(),
) else {
return serde_json::json!({
"state": "error",
"ok": false,
"currentSrc": "",
})
.to_string();
};
if dimensions.is_some() && dimensions != previous_dimensions {
invalidate_render_resource_geometry(gs);
}
image_metadata_json(current_src, density, true, dimensions)
}
#[cfg(feature = "render")]
fn finish_async_image_metadata(
shared: &SharedState,
node_id: NodeId,
document_generation: u64,
expected_url: &str,
request_profile: ImageRequestProfile,
) -> String {
let gs = shared.borrow();
if gs.document_generation != document_generation {
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
.to_string();
}
let Some(dom) = gs.dom.as_ref() else {
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
.to_string();
};
if image_request_profile(dom, node_id) != request_profile {
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
.to_string();
}
let Some((current_src, density, known, dimensions)) =
profiled_cached_image_metadata(&gs, node_id)
else {
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
.to_string();
};
if current_src != expected_url {
return serde_json::json!({ "state": "stale", "currentSrc": current_src }).to_string();
}
image_metadata_json(current_src, density, known, dimensions)
}
#[cfg(feature = "render")]
#[op2(async)]
#[string]
async fn op_load_image_metadata(state: Rc<RefCell<OpState>>, nid: u32) -> String {
let shared = {
let state = state.borrow();
state.borrow::<SharedState>().clone()
};
let node_id = NodeId::new(nid);
let (
document_generation,
selected_url,
request_profile,
resource_request,
http_client,
callbacks,
page_in_flight,
blocked,
) = {
let gs = shared.borrow();
let Some(dom) = gs.dom.as_ref() else {
return serde_json::json!({ "state": "stale", "currentSrc": "" }).to_string();
};
let is_image = dom.get_node(node_id).is_some_and(|node| {
node.as_element()
.is_some_and(|element| element.local.as_ref() == "img")
});
if !is_image {
return serde_json::json!({ "state": "stale", "currentSrc": "" }).to_string();
}
let profile = image_request_profile(dom, node_id);
let Some((selected_url, _, known, _)) =
profiled_cached_image_metadata(&gs, node_id)
else {
return serde_json::json!({ "state": "error", "ok": false, "currentSrc": "" })
.to_string();
};
if known {
return cached_image_metadata_for_node(&gs, node_id);
}
let initiator = url::Url::parse(&gs.url)
.or_else(|_| url::Url::parse(&selected_url))
.unwrap_or_else(|_| url::Url::parse("about:blank").unwrap());
let mut request = ResourceRequest::subresource(ResourceType::Image, &initiator);
match profile {
ImageRequestProfile::CorsInclude => {
request.mode = RequestMode::Cors;
request.credentials = RequestCredentials::Include;
}
ImageRequestProfile::CorsSameOrigin => {
request.mode = RequestMode::Cors;
request.credentials = RequestCredentials::SameOrigin;
}
ImageRequestProfile::NoCorsInclude => {}
}
let blocked = gs.blocked_urls.iter().any(|pattern| {
pattern == "*" || selected_url.contains(pattern) || glob_match(pattern, &selected_url)
});
(
gs.document_generation,
selected_url,
profile,
request,
gs.http_client.clone(),
gs.callbacks.clone(),
Arc::clone(&gs.page_in_flight),
blocked,
)
};
#[cfg(feature = "stealth")]
let stealth_client = shared.borrow().stealth_client.clone();
#[cfg(feature = "stealth")]
let has_page_transport = http_client.is_some() || stealth_client.is_some();
#[cfg(not(feature = "stealth"))]
let has_page_transport = http_client.is_some();
if !has_page_transport {
return load_image_metadata_without_page_transport(&mut shared.borrow_mut(), node_id);
}
let request_key = (document_generation, selected_url.clone(), request_profile);
let follower = {
let mut gs = shared.borrow_mut();
if let Some(waiters) = gs.render_image_in_flight.get_mut(&request_key) {
let (sender, receiver) = tokio::sync::oneshot::channel();
waiters.push(sender);
Some(receiver)
} else {
gs.render_image_in_flight.insert(request_key.clone(), Vec::new());
None
}
};
if let Some(receiver) = follower {
let _ = receiver.await;
return finish_async_image_metadata(
&shared,
node_id,
document_generation,
&selected_url,
request_profile,
);
}
struct PageImageInFlightGuard(Arc<std::sync::atomic::AtomicU32>);
impl Drop for PageImageInFlightGuard {
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
page_in_flight.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let _page_in_flight = PageImageInFlightGuard(page_in_flight);
let parsed_url = url::Url::parse(&selected_url).ok();
let response = if blocked || parsed_url.is_none() {
None
} else {
let parsed_url = parsed_url.as_ref().unwrap();
#[cfg(feature = "stealth")]
{
if let Some(client) = stealth_client {
client
.fetch_resource_with_callbacks(
parsed_url,
resource_request.clone(),
callbacks.as_deref(),
)
.await
.ok()
} else {
http_client
.as_ref()
.unwrap()
.fetch_resource_with_callbacks(
parsed_url,
resource_request,
callbacks.as_deref(),
)
.await
.ok()
}
}
#[cfg(not(feature = "stealth"))]
{
http_client
.as_ref()
.unwrap()
.fetch_resource_with_callbacks(
parsed_url,
resource_request,
callbacks.as_deref(),
)
.await
.ok()
}
};
let bytes = response.and_then(|response| {
(200..300)
.contains(&response.status)
.then_some(response.body)
});
let waiters = {
let mut gs = shared.borrow_mut();
if gs.document_generation == document_generation {
match bytes {
Some(bytes) => {
if telemaco_render::image_intrinsic_dimensions(&bytes).is_some() {
gs.render_resources.seed_image(
selected_url.clone(),
request_profile,
bytes,
);
invalidate_render_resource_geometry(&mut gs);
} else {
gs.render_resources
.seed_image_missing(selected_url.clone(), request_profile);
}
}
None => {
gs.render_resources
.seed_image_missing(selected_url.clone(), request_profile);
}
}
}
gs.render_image_in_flight
.remove(&request_key)
.unwrap_or_default()
};
for waiter in waiters {
let _ = waiter.send(());
}
finish_async_image_metadata(
&shared,
node_id,
document_generation,
&selected_url,
request_profile,
)
}
#[cfg(feature = "render")]
pub(crate) fn clamp_scroll_offset(state: &mut TelemacoState, requested: (f32, f32)) -> (f32, f32) {
clamp_scroll_offset_for_consumer(state, requested, false)
}
#[cfg(feature = "render")]
fn clamp_scroll_offset_for_geometry(
state: &mut TelemacoState,
requested: (f32, f32),
) -> (f32, f32) {
clamp_scroll_offset_for_consumer(state, requested, true)
}
#[cfg(feature = "render")]
fn clamp_scroll_offset_for_consumer(
state: &mut TelemacoState,
requested: (f32, f32),
geometry_only: bool,
) -> (f32, f32) {
let prepared = if geometry_only {
ensure_prepared_geometry(state)
} else {
ensure_prepared_render(state)
};
let clamped = prepared
.map(|prepared| prepared.clamp_scroll(requested))
.unwrap_or((0.0, 0.0));
if state.scroll_offset != clamped {
state.scroll_offset = clamped;
state.activity_generation = state.activity_generation.wrapping_add(1);
state.scroll_generation = state.scroll_generation.wrapping_add(1);
state.resolved_scroll = None;
}
state.scroll_offset
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String {
let shared = state.borrow::<SharedState>().clone();
let nid: u32 = nid_str.parse().unwrap_or(0);
let nid = telemaco_dom::tree::NodeId::new(nid);
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
if ensure_resolved_scroll_for_geometry(&mut gs).is_some() {
let Some((_, scroll)) = gs.resolved_scroll.as_ref() else {
return String::new();
};
let Some(prepared) = gs.prepared_render.as_ref() else {
return String::new();
};
let Some(rect) = prepared.viewport_rect_with_scroll(nid, scroll) else {
return String::new();
};
let Some((client_width, client_height)) = prepared.client_size(nid) else {
return String::new();
};
let Some(client_rects) = prepared.viewport_client_rects_with_scroll(nid, scroll) else {
return String::new();
};
let client_rects = client_rects
.into_iter()
.map(|rect| {
serde_json::json!({
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
})
})
.collect::<Vec<_>>();
let viewport_fixed = prepared.viewport_fixed_nodes().contains(&nid);
return serde_json::json!({
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"clientWidth": client_width,
"clientHeight": client_height,
"clientRects": client_rects,
"viewportFixed": viewport_fixed,
})
.to_string();
}
String::new()
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_resize_observer_measurements(state: &OpState, #[string] nids_json: String) -> String {
let nids = serde_json::from_str::<Vec<u32>>(&nids_json).unwrap_or_default();
if nids.is_empty() {
return "[]".to_string();
}
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
if ensure_resolved_scroll_for_geometry(&mut gs).is_none() {
return serde_json::to_string(&vec![serde_json::Value::Null; nids.len()])
.unwrap_or_else(|_| "[]".to_string());
}
let Some((_, scroll)) = gs.resolved_scroll.as_ref() else {
return serde_json::to_string(&vec![serde_json::Value::Null; nids.len()])
.unwrap_or_else(|_| "[]".to_string());
};
let Some(prepared) = gs.prepared_render.as_ref() else {
return serde_json::to_string(&vec![serde_json::Value::Null; nids.len()])
.unwrap_or_else(|_| "[]".to_string());
};
let style_value =
|snapshot: &std::collections::HashMap<&'static str, String>, name: &'static str| {
snapshot.get(name).cloned().unwrap_or_default()
};
let measurements = nids
.into_iter()
.map(|nid| {
let nid = telemaco_dom::tree::NodeId::new(nid);
let rect = prepared.viewport_rect_with_scroll(nid, scroll)?;
let (client_width, client_height) = prepared.client_size(nid)?;
let snapshot = prepared.computed_style(nid)?;
Some(serde_json::json!({
"x": rect.x,
"y": rect.y,
"clientWidth": client_width,
"clientHeight": client_height,
"paddingTop": style_value(&snapshot, "padding-top"),
"paddingRight": style_value(&snapshot, "padding-right"),
"paddingBottom": style_value(&snapshot, "padding-bottom"),
"paddingLeft": style_value(&snapshot, "padding-left"),
"borderTopWidth": style_value(&snapshot, "border-top-width"),
"borderRightWidth": style_value(&snapshot, "border-right-width"),
"borderBottomWidth": style_value(&snapshot, "border-bottom-width"),
"borderLeftWidth": style_value(&snapshot, "border-left-width"),
"writingMode": style_value(&snapshot, "writing-mode"),
"display": style_value(&snapshot, "display"),
}))
})
.collect::<Vec<_>>();
serde_json::to_string(&measurements).unwrap_or_else(|_| "[]".to_string())
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_intersection_observer_measurements(
state: &OpState,
#[string] nids_json: String,
) -> String {
let nids = serde_json::from_str::<Vec<u32>>(&nids_json).unwrap_or_default();
if nids.is_empty() {
return "[]".to_string();
}
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
if ensure_resolved_scroll_for_geometry(&mut gs).is_none() {
return serde_json::to_string(&vec![serde_json::Value::Null; nids.len()])
.unwrap_or_else(|_| "[]".to_string());
}
let Some((_, scroll)) = gs.resolved_scroll.as_ref() else {
return serde_json::to_string(&vec![serde_json::Value::Null; nids.len()])
.unwrap_or_else(|_| "[]".to_string());
};
let Some(prepared) = gs.prepared_render.as_ref() else {
return serde_json::to_string(&vec![serde_json::Value::Null; nids.len()])
.unwrap_or_else(|_| "[]".to_string());
};
let style_value =
|snapshot: &std::collections::HashMap<&'static str, String>, name: &'static str| {
snapshot.get(name).cloned().unwrap_or_default()
};
let measurements = nids
.into_iter()
.map(|nid| {
let nid = telemaco_dom::tree::NodeId::new(nid);
let rect = prepared.viewport_rect_with_scroll(nid, scroll)?;
let (client_width, client_height) = prepared.client_size(nid)?;
let snapshot = prepared.computed_style(nid)?;
Some(serde_json::json!({
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
"clientWidth": client_width,
"clientHeight": client_height,
"borderTopWidth": style_value(&snapshot, "border-top-width"),
"borderLeftWidth": style_value(&snapshot, "border-left-width"),
"overflowX": style_value(&snapshot, "overflow-x"),
"overflowY": style_value(&snapshot, "overflow-y"),
}))
})
.collect::<Vec<_>>();
serde_json::to_string(&measurements).unwrap_or_else(|_| "[]".to_string())
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_computed_style(state: &OpState, #[string] nid_str: String) -> String {
let shared = state.borrow::<SharedState>().clone();
let nid: u32 = nid_str.parse().unwrap_or(0);
let nid = telemaco_dom::tree::NodeId::new(nid);
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
let Some(prepared) = ensure_prepared_render(&mut gs) else {
return String::new();
};
let Some(snapshot) = prepared.computed_style(nid) else {
return String::new();
};
let custom = prepared.computed_custom_properties(nid).unwrap_or_default();
let mut object = serde_json::Map::with_capacity(snapshot.len() + custom.len());
for (name, value) in snapshot {
object.insert(name.to_string(), serde_json::Value::String(value));
}
for (name, value) in custom {
object.insert(name, serde_json::Value::String(value));
}
serde_json::Value::Object(object).to_string()
}
#[cfg(feature = "render")]
#[op2(fast)]
fn op_css_supports(#[string] name: &str, #[string] value: &str) -> bool {
telemaco_render::style::supports_declaration(name, value)
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_layout_metrics(state: &OpState) -> String {
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
let viewport = gs.viewport;
let content = ensure_prepared_geometry(&mut gs)
.map(|prepared| prepared.content_size())
.unwrap_or(viewport);
format!(
"{{\"scrollWidth\":{},\"scrollHeight\":{},\"clientWidth\":{},\"clientHeight\":{}}}",
content.0, content.1, viewport.0, viewport.1
)
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_element_scroll_metrics(state: &OpState, #[string] nid_str: String) -> String {
let shared = state.borrow::<SharedState>().clone();
let nid = NodeId::new(nid_str.parse().unwrap_or(0));
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
if ensure_resolved_scroll_for_geometry(&mut gs).is_none() {
return String::new();
}
let Some((_, scroll)) = gs.resolved_scroll.as_ref() else {
return String::new();
};
let Some(metrics) = gs
.prepared_render
.as_ref()
.and_then(|prepared| prepared.element_scroll_metrics(nid, scroll))
else {
return r#"{"scrollWidth":0,"scrollHeight":0,"clientWidth":0,"clientHeight":0,"x":0,"y":0,"maxX":0,"maxY":0,"hasBox":false}"#.to_string();
};
serde_json::json!({
"scrollWidth": metrics.content_size.0,
"scrollHeight": metrics.content_size.1,
"clientWidth": metrics.client_size.0,
"clientHeight": metrics.client_size.1,
"x": metrics.offset.0,
"y": metrics.offset.1,
"maxX": metrics.max_offset.0,
"maxY": metrics.max_offset.1,
"hasBox": true,
})
.to_string()
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_element_scroll_to(state: &OpState, #[string] nid_str: String, x: f64, y: f64) -> String {
let shared = state.borrow::<SharedState>().clone();
let nid = NodeId::new(nid_str.parse().unwrap_or(0));
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
if ensure_resolved_scroll_for_geometry(&mut gs).is_none() {
return String::new();
}
let current = gs.resolved_scroll.as_ref().and_then(|(_, scroll)| {
gs.prepared_render
.as_ref()?
.element_scroll_metrics(nid, scroll)
});
let Some(current) = current else {
return String::new();
};
let clamp = |value: f64, max: f32| {
if value.is_finite() {
telemaco_render::quantize_scroll_value(value as f32, 1.0).clamp(0.0, max)
} else {
0.0
}
};
let requested = (
clamp(x, current.max_offset.0),
clamp(y, current.max_offset.1),
);
if requested != current.offset {
if requested == (0.0, 0.0) {
gs.element_scroll_offsets.remove(&nid);
} else {
gs.element_scroll_offsets.insert(nid, requested);
}
gs.activity_generation = gs.activity_generation.wrapping_add(1);
gs.scroll_generation = gs.scroll_generation.wrapping_add(1);
gs.resolved_scroll = None;
return format!("{{\"x\":{},\"y\":{}}}", requested.0, requested.1);
}
format!("{{\"x\":{},\"y\":{}}}", current.offset.0, current.offset.1)
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_scroll_offset(state: &OpState) -> String {
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
let requested = gs.scroll_offset;
let (x, y) = clamp_scroll_offset_for_geometry(&mut gs, requested);
format!("{{\"x\":{},\"y\":{}}}", x, y)
}
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_scroll_to(state: &OpState, x: f64, y: f64) -> String {
let shared = state.borrow::<SharedState>().clone();
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
let (x, y) = clamp_scroll_offset_for_geometry(&mut gs, (x as f32, y as f32));
format!("{{\"x\":{},\"y\":{}}}", x, y)
}