1use std::cell::RefCell;
4use std::rc::Rc;
5use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
6use std::sync::{Arc, Mutex};
7use std::task::{Context as TaskContext, Waker};
8
9use blitz_dom::{
10 BaseDocument, DEFAULT_CSS, DocGuard, DocGuardMut, Document, DocumentConfig, EventDriver, NodeId,
11};
12use blitz_html::{DocumentHtmlParser, HtmlProvider};
13use blitz_traits::events::{DomEvent, UiEvent};
14use url::Url;
15use web_time::Instant;
16
17use crate::event_handler::ScriptEventHandler;
18use crate::fetch::{DefaultScriptFetcher, ScriptFetcher};
19use crate::runtime::ScriptRuntime;
20
21type PollHook =
22 Box<dyn for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static>;
23
24struct PendingScript {
26 node_id: NodeId,
27 src: Option<String>,
28 inline_text: String,
29}
30
31pub struct ScriptDocument {
40 inner: Rc<RefCell<BaseDocument>>,
41 runtime: ScriptRuntime,
42 base_url: Option<Url>,
43 fetcher: Box<dyn ScriptFetcher>,
44 scripts_executed: bool,
45 executed_scripts: std::collections::HashSet<NodeId>,
52 poll_hook: Option<PollHook>,
53
54 waker: Arc<Mutex<Option<Waker>>>,
57 timer_thread: Option<Sender<Instant>>,
58}
59
60impl ScriptDocument {
61 pub fn from_html(html: &str, mut config: DocumentConfig) -> Self {
67 if let Some(ss) = &mut config.ua_stylesheets {
68 if !ss.iter().any(|s| s == DEFAULT_CSS) {
69 ss.push(String::from(DEFAULT_CSS));
70 }
71 } else {
72 config.ua_stylesheets = Some(vec![String::from(DEFAULT_CSS)]);
73 }
74 if config.html_parser_provider.is_none() {
75 config.html_parser_provider = Some(Arc::new(HtmlProvider));
76 }
77
78 let base_url = config
79 .base_url
80 .as_deref()
81 .and_then(|url| Url::parse(url).ok());
82
83 let mut doc = BaseDocument::new(config);
84 let mut mutr = doc.mutate();
85 DocumentHtmlParser::parse_into_mutator(&mut mutr, html);
86 drop(mutr);
87
88 let inner = Rc::new(RefCell::new(doc));
89 let runtime = ScriptRuntime::new(Rc::clone(&inner), base_url.as_ref());
90
91 Self {
92 inner,
93 runtime,
94 base_url,
95 fetcher: Box::new(DefaultScriptFetcher),
96 scripts_executed: false,
97 executed_scripts: std::collections::HashSet::new(),
98 poll_hook: None,
99 waker: Arc::new(Mutex::new(None)),
100 timer_thread: None,
101 }
102 }
103
104 pub fn with_fetcher(mut self, fetcher: impl ScriptFetcher) -> Self {
107 self.fetcher = Box::new(fetcher);
108 self
109 }
110
111 pub fn set_ipc_handler(&mut self, handler: impl Fn(String) + 'static) {
116 self.runtime.ctx.state.borrow_mut().ipc_handler = Some(Rc::new(handler));
117 }
118
119 pub fn set_poll_hook(
123 &mut self,
124 hook: impl for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static,
125 ) {
126 self.poll_hook = Some(Box::new(hook));
127 }
128
129 pub fn add_poll_hook(
132 &mut self,
133 mut hook: impl for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static,
134 ) {
135 let Some(mut existing) = self.poll_hook.take() else {
136 self.poll_hook = Some(Box::new(hook));
137 return;
138 };
139 self.poll_hook = Some(Box::new(move |document, task_context| {
140 let ran_existing = existing(document, task_context);
141 let ran_added = hook(document, task_context);
142 ran_existing | ran_added
143 }));
144 }
145
146 pub fn execute_scripts(&mut self) {
151 let _profiling = self.runtime.ctx.enter_profiling_boundary();
152 if self.scripts_executed {
153 return;
154 }
155 self.scripts_executed = true;
156
157 self.run_pending_scripts();
158
159 self.runtime.dispatch_document_event("DOMContentLoaded");
160 self.runtime.dispatch_window_event("load");
161
162 self.request_redraw();
163 self.arm_timer_thread();
164 }
165
166 pub fn external_script_urls(&self) -> Vec<Url> {
174 self.collect_scripts()
175 .iter()
176 .filter_map(|script| script.src.as_deref())
177 .filter_map(|src| self.resolve_script_url(src))
178 .collect()
179 }
180
181 fn resolve_script_url(&self, src: &str) -> Option<Url> {
183 match &self.base_url {
184 Some(base) => base.join(src).ok(),
185 None => Url::parse(src).ok(),
186 }
187 }
188
189 pub fn eval(&mut self, code: &str) {
191 let _profiling = self.runtime.ctx.enter_profiling_boundary();
192 self.runtime.eval(code, "<eval>");
193 self.request_redraw();
194 self.arm_timer_thread();
195 }
196
197 pub fn eval_json(&mut self, code: &str) -> Result<serde_json::Value, String> {
203 let _profiling = self.runtime.ctx.enter_profiling_boundary();
204 let result = self.runtime.eval_json(code, "<eval with result>");
205 self.request_redraw();
206 self.arm_timer_thread();
207 result
208 }
209
210 #[cfg(feature = "debug-control")]
211 pub(crate) fn console_entries_after(
212 &self,
213 sequence: u64,
214 ) -> Vec<crate::runtime::DiagnosticEntry> {
215 self.runtime.console_entries_after(sequence)
216 }
217
218 #[cfg(feature = "debug-control")]
219 pub(crate) fn runtime_errors_after(
220 &self,
221 sequence: u64,
222 ) -> Vec<crate::runtime::DiagnosticEntry> {
223 self.runtime.runtime_errors_after(sequence)
224 }
225
226 pub fn current_url(&self) -> Option<&Url> {
228 self.base_url.as_ref()
229 }
230
231 pub fn page_source(&self) -> String {
233 self.inner.borrow().root_element().outer_html()
234 }
235
236 pub fn dispatch_dom_event(&mut self, event: DomEvent) {
241 let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
242 let profiling = profiling_boundary.enabled();
243 let handler = ScriptEventHandler {
244 runtime: &mut self.runtime,
245 profiling,
246 };
247 let mut driver = EventDriver::new(&mut self.inner, handler);
248 driver.handle_dom_event(event);
249
250 self.request_redraw();
251 self.arm_timer_thread();
252 }
253
254 fn run_pending_scripts(&mut self) {
267 let pending: Vec<PendingScript> = self
270 .collect_scripts()
271 .into_iter()
272 .filter(|script| !self.executed_scripts.contains(&script.node_id))
273 .collect();
274
275 for script in pending {
276 self.executed_scripts.insert(script.node_id);
280 match script.src {
281 Some(src) => {
282 let Some(url) = self.resolve_script_url(&src) else {
283 eprintln!("blitz-script: could not resolve script URL {src:?}");
284 self.runtime.dispatch_node_event(script.node_id, "error");
285 continue;
286 };
287 match self.fetcher.fetch(&url) {
288 Ok(code) => {
289 self.runtime.eval(&code, url.as_str());
290 self.runtime.dispatch_node_event(script.node_id, "load");
291 }
292 Err(error) => {
293 eprintln!("blitz-script: failed to fetch script {url}: {error}");
294 self.runtime.dispatch_node_event(script.node_id, "error");
295 }
296 }
297 }
298 None => {
299 if !script.inline_text.trim().is_empty() {
300 self.runtime.eval(&script.inline_text, "<inline script>");
301 }
302 }
303 }
304 }
305 }
306
307 fn collect_scripts(&self) -> Vec<PendingScript> {
309 let doc = self.inner.borrow();
310 let mut scripts = Vec::new();
311 let mut stack = vec![doc.root_node().id];
312
313 while let Some(node_id) = stack.pop() {
314 let Some(node) = doc.get_node(node_id) else {
315 continue;
316 };
317
318 if let Some(element) = node.element_data() {
319 if element.name.local == blitz_dom::local_name!("script") {
320 let script_type = element
323 .attr(blitz_dom::local_name!("type"))
324 .unwrap_or("")
325 .trim()
326 .to_ascii_lowercase();
327 let is_js = matches!(
328 script_type.as_str(),
329 "" | "text/javascript" | "application/javascript" | "module"
330 );
331 if is_js {
332 scripts.push(PendingScript {
333 node_id,
334 src: element
335 .attr(blitz_dom::local_name!("src"))
336 .map(str::to_string),
337 inline_text: node.text_content(),
338 });
339 }
340 continue;
341 }
342 }
343
344 stack.extend(node.children.iter().rev().copied());
345 }
346
347 scripts
348 }
349
350 fn request_redraw(&self) {
351 self.inner.borrow().shell_provider.request_redraw();
352 }
353
354 fn arm_timer_thread(&mut self) {
357 let Some(deadline) = self.runtime.next_timer_deadline() else {
358 return;
359 };
360
361 let sender = self.timer_thread.get_or_insert_with(|| {
362 let (tx, rx) = channel::<Instant>();
363 let waker = Arc::clone(&self.waker);
364 std::thread::Builder::new()
365 .name("blitz-script-timers".to_string())
366 .spawn(move || timer_thread_main(rx, waker))
367 .expect("failed to spawn timer thread");
368 tx
369 });
370
371 if sender.send(deadline).is_err() {
374 self.timer_thread = None;
375 }
376 }
377}
378
379fn timer_thread_main(rx: Receiver<Instant>, waker: Arc<Mutex<Option<Waker>>>) {
381 let mut deadline: Option<Instant> = None;
382
383 loop {
384 match deadline {
385 None => match rx.recv() {
386 Ok(new_deadline) => deadline = Some(new_deadline),
387 Err(_) => return,
388 },
389 Some(current) => {
390 let now = Instant::now();
391 if current <= now {
392 if let Some(waker) = waker.lock().unwrap().as_ref() {
393 waker.wake_by_ref();
394 }
395 deadline = None;
396 continue;
397 }
398 match rx.recv_timeout(current - now) {
399 Ok(new_deadline) => deadline = Some(new_deadline.min(current)),
400 Err(RecvTimeoutError::Timeout) => {
401 if let Some(waker) = waker.lock().unwrap().as_ref() {
402 waker.wake_by_ref();
403 }
404 deadline = None;
405 }
406 Err(RecvTimeoutError::Disconnected) => return,
407 }
408 }
409 }
410 }
411}
412
413impl Document for ScriptDocument {
414 fn inner(&self) -> DocGuard<'_> {
415 DocGuard::RefCell(self.inner.borrow())
416 }
417
418 fn inner_mut(&mut self) -> DocGuardMut<'_> {
419 DocGuardMut::RefCell(self.inner.borrow_mut())
420 }
421
422 fn handle_ui_event(&mut self, event: UiEvent) {
423 let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
424 let profiling = profiling_boundary.enabled();
425 let handler = ScriptEventHandler {
426 runtime: &mut self.runtime,
427 profiling,
428 };
429 let mut driver = EventDriver::new(&mut self.inner, handler);
430 driver.handle_ui_event(event);
431
432 self.request_redraw();
434 self.arm_timer_thread();
435 }
436
437 fn poll(&mut self, task_context: Option<TaskContext>) -> bool {
438 let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
439 let profiling = profiling_boundary.enabled();
440 let poll_started = profiling.then(std::time::Instant::now);
441 let ran = self.poll_inner(task_context, profiling);
442 if let Some(started) = poll_started {
443 crate::script_stats::record_poll(started.elapsed(), ran);
444 }
445 ran
446 }
447}
448
449impl ScriptDocument {
450 fn poll_inner(&mut self, task_context: Option<TaskContext>, profiling: bool) -> bool {
453 if let Some(cx) = &task_context {
455 let mut waker = self.waker.lock().unwrap();
456 let stale = waker
457 .as_ref()
458 .map(|old| !old.will_wake(cx.waker()))
459 .unwrap_or(true);
460 if stale {
461 *waker = Some(cx.waker().clone());
462 }
463 }
464
465 let subdocument_changes = self
470 .inner
471 .borrow_mut()
472 .poll_subdocuments(task_context.as_ref().map(TaskContext::waker));
473
474 let mut ran = subdocument_changes;
476 if !self.scripts_executed {
477 let started = profiling.then(std::time::Instant::now);
481 self.execute_scripts();
482 if let Some(started) = started {
483 crate::script_stats::record_work("startup:execute_scripts", started.elapsed());
484 }
485 ran = true;
486 } else {
487 self.run_pending_scripts();
491 }
492
493 ran |= self.runtime.run_due_timers(profiling);
494
495 if let Some(mut hook) = self.poll_hook.take() {
496 let started = profiling.then(std::time::Instant::now);
500 ran |= hook(self, task_context.as_ref());
501 if let Some(started) = started {
502 crate::script_stats::record_work("poll_hook", started.elapsed());
503 }
504 self.poll_hook = Some(hook);
505 }
506
507 self.arm_timer_thread();
508 ran
509 }
510}