Skip to main content

blitz_script/
document.rs

1//! [`ScriptDocument`]: a [`Document`] implementation with JavaScript support
2
3use 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
24/// A `<script>` element found in the document
25struct PendingScript {
26    node_id: NodeId,
27    src: Option<String>,
28    inline_text: String,
29}
30
31/// A [`Document`] which executes the JavaScript contained in the document's
32/// `<script>` tags, exposing DOM APIs backed by `blitz-dom` to the scripts.
33///
34/// Construct with [`ScriptDocument::from_html`], then call
35/// [`execute_scripts`](ScriptDocument::execute_scripts) (this also happens
36/// automatically on the first [`poll`](Document::poll)). UI events pushed via
37/// [`handle_ui_event`](Document::handle_ui_event) are dispatched to JavaScript
38/// event listeners before Blitz's default actions run.
39pub 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    /// Which `<script>` nodes have already run.
46    ///
47    /// By node rather than a single "done" flag, because scripts keep arriving
48    /// after the first pass: a loader that appends its bundle, an analytics
49    /// snippet, a lazily inserted chunk. Keyed this way, a rescan can tell a
50    /// script it has run from one it has not without running anything twice.
51    executed_scripts: std::collections::HashSet<NodeId>,
52    poll_hook: Option<PollHook>,
53
54    // Timer wakeups: a background thread which wakes the event loop (via the
55    // `Waker` passed to `poll`) when the next JS timer is due.
56    waker: Arc<Mutex<Option<Waker>>>,
57    timer_thread: Option<Sender<Instant>>,
58}
59
60impl ScriptDocument {
61    /// Parse HTML into a [`ScriptDocument`].
62    ///
63    /// Note: this does *not* execute any scripts yet. Call
64    /// [`execute_scripts`](Self::execute_scripts) to do so (or rely on the
65    /// first `poll` doing it automatically).
66    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    /// Override the [`ScriptFetcher`] used to load external (`src="..."`) scripts.
105    /// The default fetcher supports `file:` and `data:` URLs.
106    pub fn with_fetcher(mut self, fetcher: impl ScriptFetcher) -> Self {
107        self.fetcher = Box::new(fetcher);
108        self
109    }
110
111    /// Install the host callback invoked by `window.ipc.postMessage(body)`.
112    ///
113    /// The callback may forward work to another thread, but JavaScript and DOM state remain on
114    /// the document's owning thread. Replacing the callback is supported before script startup.
115    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    /// Install work that an embedder needs to run on the document thread during polling.
120    ///
121    /// The hook runs after document scripts and due timers. Returning `true` requests a redraw.
122    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    /// Append document-thread work without discarding an embedder's existing
130    /// poll lifecycle.
131    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    /// Execute the document's `<script>` elements in document order, then fire
147    /// the `DOMContentLoaded` and `load` events.
148    ///
149    /// Does nothing if scripts have already been executed.
150    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    /// The resolved URLs of the document's external (`<script src="...">`) scripts,
167    /// in document order.
168    ///
169    /// The [`ScriptFetcher`] API is synchronous, so embedders with asynchronous
170    /// networking can use this to prefetch script sources before calling
171    /// [`execute_scripts`](Self::execute_scripts), and then serve them from memory
172    /// via a custom fetcher (see [`with_fetcher`](Self::with_fetcher)).
173    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    /// Resolve a script `src` attribute against the document's base URL
182    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    /// Evaluate arbitrary JavaScript code in the document's script context
190    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    /// Evaluate JavaScript and convert its result to JSON.
198    ///
199    /// Embedders use this for APIs that return an evaluation result, such as Tauri's
200    /// `eval_script_with_callback`. JavaScript exceptions are recorded in the runtime diagnostics
201    /// and returned as an error.
202    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    /// Current document URL for automation and diagnostics.
227    pub fn current_url(&self) -> Option<&Url> {
228        self.base_url.as_ref()
229    }
230
231    /// Serialize the current document tree as HTML.
232    pub fn page_source(&self) -> String {
233        self.inner.borrow().root_element().outer_html()
234    }
235
236    /// Dispatch a synthetic DOM event (e.g. a click created with
237    /// [`Node::synthetic_click_event`](blitz_dom::Node::synthetic_click_event))
238    /// through the document's event driver. The event is exposed to JavaScript
239    /// event listeners, and Blitz's default actions run unless prevented.
240    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    /// Run every `<script>` that has appeared since the last pass.
255    ///
256    /// Called on each poll as well as at startup, because script elements are
257    /// not only a parser product: a page can build one and append it, and that
258    /// is how most real loaders bring in their bundle. Running only the markup
259    /// the parser produced meant those were fetched by nobody and the page
260    /// simply never started.
261    ///
262    /// A `src` script reports `load` when it runs and `error` when it does not.
263    /// Loaders wait on those before continuing — nofilter.io keeps the body
264    /// hidden until the bundle's `load` arrives — so a script that ran silently
265    /// would still leave the page blank.
266    fn run_pending_scripts(&mut self) {
267        // Collect first, then run: executing a script can append more, and the
268        // borrow on the document has to be released before any of them runs.
269        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            // Marked before running, not after: a script that appends another
277            // copy of itself, or that throws, must not be retried on every
278            // poll for the life of the page.
279            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    /// Find `<script>` elements in document order
308    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                    // Skip non-JavaScript script types (e.g. JSON data blocks).
321                    // `module` scripts are treated as classic scripts for now.
322                    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    /// Ensure the timer thread is armed to wake the event loop when the next
355    /// JS timer is due.
356    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 the thread has exited (channel disconnected) drop the sender so a
372        // new thread is spawned next time.
373        if sender.send(deadline).is_err() {
374            self.timer_thread = None;
375        }
376    }
377}
378
379/// Background thread which wakes the event loop when JS timers are due
380fn 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        // JS may have mutated the DOM or scheduled timers
433        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    /// The real poll. Split out so every exit path is timed by the wrapper
451    /// above rather than by a stopwatch threaded through each early return.
452    fn poll_inner(&mut self, task_context: Option<TaskContext>, profiling: bool) -> bool {
453        // Store the waker so the timer thread can wake the event loop
454        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        // A scripted document may itself be an embedder. Chuzz's Solid chrome
466        // is one: its `<web-view>` elements own the page documents. Poll those
467        // children at the same outer boundary so their timers, resource
468        // completions, and script work continue to make progress.
469        let subdocument_changes = self
470            .inner
471            .borrow_mut()
472            .poll_subdocuments(task_context.as_ref().map(TaskContext::waker));
473
474        // Execute scripts on first poll if they haven't been run explicitly
475        let mut ran = subdocument_changes;
476        if !self.scripts_executed {
477            // One-time: parsing and running the application bundle. Separated
478            // because it is startup cost, and folding it into the steady-state
479            // numbers made every per-poll average meaningless.
480            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            // Steady state: pick up any script the page appended since the last
488            // turn. Cheap when there are none, and it is the only path by which
489            // a runtime-injected bundle ever runs.
490            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            // The embedder's per-poll work. For a Solid application this is
497            // where reactive updates and DOM mutation actually happen, so it is
498            // the bucket that matters once startup is excluded.
499            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}