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::{BlitzPointerEvent, DomEvent, UiEvent};
14use url::Url;
15use web_time::Instant;
16
17use crate::event_handler::ScriptEventHandler;
18use crate::fetch::{DefaultScriptFetcher, ScriptFetcher};
19use crate::module::SharedFetcher;
20use crate::runtime::ScriptRuntime;
21
22type PollHook =
23 Box<dyn for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static>;
24
25/// What a `<script>` element's `type` says it holds.
26#[derive(Clone, Copy, PartialEq, Eq)]
27enum ScriptKind {
28 Classic,
29 /// `type="module"`. Parsed in module goal and given a loader; a module run
30 /// as a classic script fails on its own first `import` statement.
31 Module,
32 /// `type="importmap"`. Not code: the JSON that gives bare specifiers their
33 /// meaning, and it has to be installed before any module resolves one.
34 ImportMap,
35}
36
37/// A `<script>` element found in the document
38struct PendingScript {
39 node_id: NodeId,
40 src: Option<String>,
41 inline_text: String,
42 kind: ScriptKind,
43 /// Runs after every non-deferred script in the same batch.
44 ///
45 /// True for module scripts, which are deferred by definition, and for
46 /// `<script defer src>`. `async` takes it back off both: an async script
47 /// runs whenever its fetch lands, which here is immediately.
48 deferred: bool,
49}
50
51/// A [`Document`] which executes the JavaScript contained in the document's
52/// `<script>` tags, exposing DOM APIs backed by `blitz-dom` to the scripts.
53///
54/// Construct with [`ScriptDocument::from_html`], then call
55/// [`execute_scripts`](ScriptDocument::execute_scripts) (this also happens
56/// automatically on the first [`poll`](Document::poll)). UI events pushed via
57/// [`handle_ui_event`](Document::handle_ui_event) are dispatched to JavaScript
58/// event listeners before Blitz's default actions run.
59pub struct ScriptDocument {
60 inner: Rc<RefCell<BaseDocument>>,
61 runtime: ScriptRuntime,
62 base_url: Option<Url>,
63 /// Shared with the module loader inside the script context.
64 ///
65 /// The loader is fixed at context construction, which happens in
66 /// `from_html`, while an embedder installs its fetcher afterwards through
67 /// [`with_fetcher`](Self::with_fetcher). The cell is what lets the later
68 /// call reach the earlier object.
69 fetcher: SharedFetcher,
70 scripts_executed: bool,
71 /// Which `<script>` nodes have already run.
72 ///
73 /// By node rather than a single "done" flag, because scripts keep arriving
74 /// after the first pass: a loader that appends its bundle, an analytics
75 /// snippet, a lazily inserted chunk. Keyed this way, a rescan can tell a
76 /// script it has run from one it has not without running anything twice.
77 executed_scripts: std::collections::HashSet<NodeId>,
78 poll_hook: Option<PollHook>,
79
80 // Timer wakeups: a background thread which wakes the event loop (via the
81 // `Waker` passed to `poll`) when the next JS timer is due.
82 waker: Arc<Mutex<Option<Waker>>>,
83 timer_thread: Option<Sender<Instant>>,
84}
85
86impl ScriptDocument {
87 /// Parse HTML into a [`ScriptDocument`].
88 ///
89 /// Note: this does *not* execute any scripts yet. Call
90 /// [`execute_scripts`](Self::execute_scripts) to do so (or rely on the
91 /// first `poll` doing it automatically).
92 pub fn from_html(html: &str, mut config: DocumentConfig) -> Self {
93 if let Some(ss) = &mut config.ua_stylesheets {
94 if !ss.iter().any(|s| s == DEFAULT_CSS) {
95 ss.push(String::from(DEFAULT_CSS));
96 }
97 } else {
98 config.ua_stylesheets = Some(vec![String::from(DEFAULT_CSS)]);
99 }
100 if config.html_parser_provider.is_none() {
101 config.html_parser_provider = Some(Arc::new(HtmlProvider));
102 }
103
104 let base_url = config
105 .base_url
106 .as_deref()
107 .and_then(|url| Url::parse(url).ok());
108
109 let mut doc = BaseDocument::new(config);
110 let mut mutr = doc.mutate();
111 DocumentHtmlParser::parse_into_mutator(&mut mutr, html);
112 drop(mutr);
113
114 let inner = Rc::new(RefCell::new(doc));
115 let fetcher: SharedFetcher = Rc::new(RefCell::new(Rc::new(DefaultScriptFetcher)));
116 let runtime = ScriptRuntime::new(Rc::clone(&inner), base_url.as_ref(), Rc::clone(&fetcher));
117
118 Self {
119 inner,
120 runtime,
121 base_url,
122 fetcher,
123 scripts_executed: false,
124 executed_scripts: std::collections::HashSet::new(),
125 poll_hook: None,
126 waker: Arc::new(Mutex::new(None)),
127 timer_thread: None,
128 }
129 }
130
131 /// Override the [`ScriptFetcher`] used to load external (`src="..."`) scripts.
132 /// The default fetcher supports `file:` and `data:` URLs.
133 pub fn with_fetcher(self, fetcher: impl ScriptFetcher) -> Self {
134 *self.fetcher.borrow_mut() = Rc::new(fetcher);
135 self
136 }
137
138 /// Install the host callback invoked by `window.ipc.postMessage(body)`.
139 ///
140 /// The callback may forward work to another thread, but JavaScript and DOM state remain on
141 /// the document's owning thread. Replacing the callback is supported before script startup.
142 pub fn set_ipc_handler(&mut self, handler: impl Fn(String) + 'static) {
143 self.runtime.ctx.state.borrow_mut().ipc_handler = Some(Rc::new(handler));
144 }
145
146 /// Install work that an embedder needs to run on the document thread during polling.
147 ///
148 /// The hook runs after document scripts and due timers. Returning `true` requests a redraw.
149 pub fn set_poll_hook(
150 &mut self,
151 hook: impl for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static,
152 ) {
153 self.poll_hook = Some(Box::new(hook));
154 }
155
156 /// Append document-thread work without discarding an embedder's existing
157 /// poll lifecycle.
158 pub fn add_poll_hook(
159 &mut self,
160 mut hook: impl for<'a> FnMut(&mut ScriptDocument, Option<&TaskContext<'a>>) -> bool + 'static,
161 ) {
162 let Some(mut existing) = self.poll_hook.take() else {
163 self.poll_hook = Some(Box::new(hook));
164 return;
165 };
166 self.poll_hook = Some(Box::new(move |document, task_context| {
167 let ran_existing = existing(document, task_context);
168 let ran_added = hook(document, task_context);
169 ran_existing | ran_added
170 }));
171 }
172
173 /// Execute the document's `<script>` elements in document order, then fire
174 /// the `DOMContentLoaded` and `load` events.
175 ///
176 /// Does nothing if scripts have already been executed.
177 pub fn execute_scripts(&mut self) {
178 let _profiling = self.runtime.ctx.enter_profiling_boundary();
179 if self.scripts_executed {
180 return;
181 }
182 self.scripts_executed = true;
183
184 self.run_pending_scripts();
185
186 self.runtime.dispatch_document_event("DOMContentLoaded");
187 self.runtime.dispatch_window_event("load");
188
189 self.request_redraw();
190 self.arm_timer_thread();
191 }
192
193 /// The resolved URLs of the document's external (`<script src="...">`) scripts,
194 /// in document order.
195 ///
196 /// The [`ScriptFetcher`] API is synchronous, so embedders with asynchronous
197 /// networking can use this to prefetch script sources before calling
198 /// [`execute_scripts`](Self::execute_scripts), and then serve them from memory
199 /// via a custom fetcher (see [`with_fetcher`](Self::with_fetcher)).
200 pub fn external_script_urls(&self) -> Vec<Url> {
201 self.collect_scripts()
202 .iter()
203 // An import map is inline-only per the HTML spec, so a `src` on one
204 // names nothing an embedder should prefetch.
205 .filter(|script| script.kind != ScriptKind::ImportMap)
206 .filter_map(|script| script.src.as_deref())
207 .filter_map(|src| self.resolve_script_url(src))
208 .collect()
209 }
210
211 /// Resolve a script `src` attribute against the document's base URL
212 fn resolve_script_url(&self, src: &str) -> Option<Url> {
213 match &self.base_url {
214 Some(base) => base.join(src).ok(),
215 None => Url::parse(src).ok(),
216 }
217 }
218
219 /// Evaluate arbitrary JavaScript code in the document's script context
220 pub fn eval(&mut self, code: &str) {
221 let _profiling = self.runtime.ctx.enter_profiling_boundary();
222 self.runtime.eval(code, "<eval>");
223 self.request_redraw();
224 self.arm_timer_thread();
225 }
226
227 /// Evaluate JavaScript and convert its result to JSON.
228 ///
229 /// Embedders use this for APIs that return an evaluation result, such as Tauri's
230 /// `eval_script_with_callback`. JavaScript exceptions are recorded in the runtime diagnostics
231 /// and returned as an error.
232 pub fn eval_json(&mut self, code: &str) -> Result<serde_json::Value, String> {
233 let _profiling = self.runtime.ctx.enter_profiling_boundary();
234 let result = self.runtime.eval_json(code, "<eval with result>");
235 self.request_redraw();
236 self.arm_timer_thread();
237 result
238 }
239
240 #[cfg(feature = "debug-control")]
241 pub(crate) fn console_entries_after(
242 &self,
243 sequence: u64,
244 ) -> Vec<crate::runtime::DiagnosticEntry> {
245 self.runtime.console_entries_after(sequence)
246 }
247
248 #[cfg(feature = "debug-control")]
249 pub(crate) fn runtime_errors_after(
250 &self,
251 sequence: u64,
252 ) -> Vec<crate::runtime::DiagnosticEntry> {
253 self.runtime.runtime_errors_after(sequence)
254 }
255
256 /// Current document URL for automation and diagnostics.
257 pub fn current_url(&self) -> Option<&Url> {
258 self.base_url.as_ref()
259 }
260
261 /// Serialize the current document tree as HTML.
262 pub fn page_source(&self) -> String {
263 self.inner.borrow().root_element().outer_html()
264 }
265
266 /// Dispatch a synthetic DOM event (e.g. a click created with
267 /// [`Node::synthetic_click_event`](blitz_dom::Node::synthetic_click_event))
268 /// through the document's event driver. The event is exposed to JavaScript
269 /// event listeners, and Blitz's default actions run unless prevented.
270 pub fn dispatch_dom_event(&mut self, event: DomEvent) {
271 let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
272 let profiling = profiling_boundary.enabled();
273 let handler = ScriptEventHandler {
274 runtime: &mut self.runtime,
275 profiling,
276 };
277 let mut driver = EventDriver::new(&mut self.inner, handler);
278 driver.handle_dom_event(event);
279
280 self.request_redraw();
281 self.arm_timer_thread();
282 }
283
284 /// Run every `<script>` that has appeared since the last pass.
285 ///
286 /// Called on each poll as well as at startup, because script elements are
287 /// not only a parser product: a page can build one and append it, and that
288 /// is how most real loaders bring in their bundle. Running only the markup
289 /// the parser produced meant those were fetched by nobody and the page
290 /// simply never started.
291 ///
292 /// A `src` script reports `load` when it runs and `error` when it does not.
293 /// Loaders wait on those before continuing — nofilter.io keeps the body
294 /// hidden until the bundle's `load` arrives — so a script that ran silently
295 /// would still leave the page blank.
296 fn run_pending_scripts(&mut self) {
297 // Collect first, then run: executing a script can append more, and the
298 // borrow on the document has to be released before any of them runs.
299 let pending: Vec<PendingScript> = self
300 .collect_scripts()
301 .into_iter()
302 .filter(|script| !self.executed_scripts.contains(&script.node_id))
303 .collect();
304
305 // Import maps first, across the whole batch. A page is free to write
306 // its map after the module that needs it, and a map installed too late
307 // is the same as no map at all: the module has already failed to
308 // resolve its first bare specifier.
309 for script in &pending {
310 if script.kind == ScriptKind::ImportMap && !script.inline_text.trim().is_empty() {
311 self.runtime
312 .set_import_map(&script.inline_text, self.base_url.as_ref());
313 }
314 }
315
316 // Two passes, because a module is a deferred script and a browser runs
317 // every non-deferred classic script before any of them. Measured
318 // against Chromium on a page mixing all five forms: the parser-blocking
319 // classics run first in document order, then modules and `defer`
320 // scripts together, also in document order.
321 //
322 // Document order alone was right until modules existed. It is now
323 // wrong in a way that bites: an inline classic script writing
324 // `window.__CONFIG__` after a module tag runs *before* that module in
325 // a browser, and the module reads the config it expects.
326 let (deferred, immediate): (Vec<_>, Vec<_>) =
327 pending.into_iter().partition(|script| script.deferred);
328
329 for script in immediate.into_iter().chain(deferred) {
330 // Marked before running, not after: a script that appends another
331 // copy of itself, or that throws, must not be retried on every
332 // poll for the life of the page.
333 self.executed_scripts.insert(script.node_id);
334 if script.kind == ScriptKind::ImportMap {
335 continue;
336 }
337 match script.src {
338 Some(src) => {
339 let Some(url) = self.resolve_script_url(&src) else {
340 eprintln!("blitz-script: could not resolve script URL {src:?}");
341 self.runtime.dispatch_node_event(script.node_id, "error");
342 continue;
343 };
344 let fetcher = Rc::clone(&self.fetcher.borrow());
345 match fetcher.fetch(&url) {
346 Ok(code) => {
347 if script.kind == ScriptKind::Module {
348 self.runtime.eval_module(&code, Some(&url), url.as_str());
349 } else {
350 self.runtime.eval_at(&code, Some(&url), url.as_str());
351 }
352 self.runtime.dispatch_node_event(script.node_id, "load");
353 }
354 Err(error) => {
355 eprintln!("blitz-script: failed to fetch script {url}: {error}");
356 self.runtime.dispatch_node_event(script.node_id, "error");
357 }
358 }
359 }
360 None => {
361 if !script.inline_text.trim().is_empty() {
362 // An inline script has no URL of its own, so its
363 // relative imports and `import.meta.url` resolve
364 // against the document, exactly as in a browser.
365 let base_url = self.base_url.clone();
366 if script.kind == ScriptKind::Module {
367 self.runtime.eval_module(
368 &script.inline_text,
369 base_url.as_ref(),
370 "<inline module>",
371 );
372 } else {
373 self.runtime.eval_at(
374 &script.inline_text,
375 base_url.as_ref(),
376 "<inline script>",
377 );
378 }
379 }
380 }
381 }
382 }
383 }
384
385 /// Find `<script>` elements in document order
386 fn collect_scripts(&self) -> Vec<PendingScript> {
387 let doc = self.inner.borrow();
388 let mut scripts = Vec::new();
389 let mut stack = vec![doc.root_node().id];
390
391 while let Some(node_id) = stack.pop() {
392 let Some(node) = doc.get_node(node_id) else {
393 continue;
394 };
395
396 if let Some(element) = node.element_data() {
397 if element.name.local == blitz_dom::local_name!("script") {
398 // Skip non-JavaScript script types (e.g. JSON data blocks).
399 let script_type = element
400 .attr(blitz_dom::local_name!("type"))
401 .unwrap_or("")
402 .trim()
403 .to_ascii_lowercase();
404 let kind = match script_type.as_str() {
405 "module" => Some(ScriptKind::Module),
406 "importmap" => Some(ScriptKind::ImportMap),
407 "" | "text/javascript" | "application/javascript" => {
408 // `nomodule` marks the classic fallback a page
409 // ships for engines without module support. Now
410 // that modules run, taking the fallback as well
411 // would mount the same application twice.
412 if element.attr(blitz_dom::local_name!("nomodule")).is_some() {
413 None
414 } else {
415 Some(ScriptKind::Classic)
416 }
417 }
418 _ => None,
419 };
420
421 if let Some(kind) = kind {
422 let src = element
423 .attr(blitz_dom::local_name!("src"))
424 .map(str::to_string);
425 let is_async = element.attr(blitz_dom::local_name!("async")).is_some();
426
427 // `defer` is only meaningful on an external classic
428 // script; the spec ignores it on an inline one. A
429 // module is deferred whether or not it says so.
430 let deferred = !is_async
431 && match kind {
432 ScriptKind::Module => true,
433 ScriptKind::Classic => {
434 src.is_some()
435 && element.attr(blitz_dom::local_name!("defer")).is_some()
436 }
437 ScriptKind::ImportMap => false,
438 };
439
440 scripts.push(PendingScript {
441 node_id,
442 src,
443 inline_text: node.text_content(),
444 kind,
445 deferred,
446 });
447 }
448 continue;
449 }
450 }
451
452 stack.extend(node.children.iter().rev().copied());
453 }
454
455 scripts
456 }
457
458 fn request_redraw(&self) {
459 self.inner.borrow().shell_provider.request_redraw();
460 }
461
462 /// Ensure the timer thread is armed to wake the event loop when the next
463 /// JS timer is due.
464 fn arm_timer_thread(&mut self) {
465 let Some(deadline) = self.runtime.next_timer_deadline() else {
466 return;
467 };
468
469 let sender = self.timer_thread.get_or_insert_with(|| {
470 let (tx, rx) = channel::<Instant>();
471 let waker = Arc::clone(&self.waker);
472 std::thread::Builder::new()
473 .name("blitz-script-timers".to_string())
474 .spawn(move || timer_thread_main(rx, waker))
475 .expect("failed to spawn timer thread");
476 tx
477 });
478
479 // If the thread has exited (channel disconnected) drop the sender so a
480 // new thread is spawned next time.
481 if sender.send(deadline).is_err() {
482 self.timer_thread = None;
483 }
484 }
485}
486
487/// Background thread which wakes the event loop when JS timers are due
488fn timer_thread_main(rx: Receiver<Instant>, waker: Arc<Mutex<Option<Waker>>>) {
489 let mut deadline: Option<Instant> = None;
490
491 loop {
492 match deadline {
493 None => match rx.recv() {
494 Ok(new_deadline) => deadline = Some(new_deadline),
495 Err(_) => return,
496 },
497 Some(current) => {
498 let now = Instant::now();
499 if current <= now {
500 if let Some(waker) = waker.lock().unwrap().as_ref() {
501 waker.wake_by_ref();
502 }
503 deadline = None;
504 continue;
505 }
506 match rx.recv_timeout(current - now) {
507 Ok(new_deadline) => deadline = Some(new_deadline.min(current)),
508 Err(RecvTimeoutError::Timeout) => {
509 if let Some(waker) = waker.lock().unwrap().as_ref() {
510 waker.wake_by_ref();
511 }
512 deadline = None;
513 }
514 Err(RecvTimeoutError::Disconnected) => return,
515 }
516 }
517 }
518 }
519}
520
521impl Document for ScriptDocument {
522 fn inner(&self) -> DocGuard<'_> {
523 DocGuard::RefCell(self.inner.borrow())
524 }
525
526 fn inner_mut(&mut self) -> DocGuardMut<'_> {
527 DocGuardMut::RefCell(self.inner.borrow_mut())
528 }
529
530 fn handle_ui_event(&mut self, event: UiEvent) {
531 let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
532 let profiling = profiling_boundary.enabled();
533 let handler = ScriptEventHandler {
534 runtime: &mut self.runtime,
535 profiling,
536 };
537 let mut driver = EventDriver::new(&mut self.inner, handler);
538 driver.handle_ui_event(event);
539
540 // JS may have mutated the DOM or scheduled timers
541 self.request_redraw();
542 self.arm_timer_thread();
543 }
544
545 fn poll(&mut self, task_context: Option<TaskContext>) -> bool {
546 let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
547 let profiling = profiling_boundary.enabled();
548 let poll_started = profiling.then(std::time::Instant::now);
549 let ran = self.poll_inner(task_context, profiling);
550 if let Some(started) = poll_started {
551 crate::script_stats::record_poll(started.elapsed(), ran);
552 }
553 ran
554 }
555}
556
557impl ScriptDocument {
558 /// Move a semantic automation pointer to the exact resolved DOM node.
559 ///
560 /// Normal window input remains coordinate hit-tested through
561 /// [`Document::handle_ui_event`]. Debug-control callers already selected a
562 /// node by id, so routing those coordinates through hit testing again can
563 /// silently select an overlapping descendant or retained overlay.
564 pub fn handle_pointer_move_to_node(&mut self, event: BlitzPointerEvent, node_id: NodeId) {
565 let profiling_boundary = self.runtime.ctx.enter_profiling_boundary();
566 let profiling = profiling_boundary.enabled();
567 let handler = ScriptEventHandler {
568 runtime: &mut self.runtime,
569 profiling,
570 };
571 let mut driver = EventDriver::new(&mut self.inner, handler);
572 driver.handle_pointer_move_to_node(&event, node_id);
573
574 self.request_redraw();
575 self.arm_timer_thread();
576 }
577
578 /// The real poll. Split out so every exit path is timed by the wrapper
579 /// above rather than by a stopwatch threaded through each early return.
580 fn poll_inner(&mut self, task_context: Option<TaskContext>, profiling: bool) -> bool {
581 // Store the waker so the timer thread can wake the event loop
582 if let Some(cx) = &task_context {
583 let mut waker = self.waker.lock().unwrap();
584 let stale = waker
585 .as_ref()
586 .map(|old| !old.will_wake(cx.waker()))
587 .unwrap_or(true);
588 if stale {
589 *waker = Some(cx.waker().clone());
590 }
591 }
592
593 // A scripted document may itself be an embedder. Chuzz's Solid chrome
594 // is one: its `<web-view>` elements own the page documents. Poll those
595 // children at the same outer boundary so their timers, resource
596 // completions, and script work continue to make progress.
597 let subdocument_changes = self
598 .inner
599 .borrow_mut()
600 .poll_subdocuments(task_context.as_ref().map(TaskContext::waker));
601
602 // Execute scripts on first poll if they haven't been run explicitly
603 let mut ran = subdocument_changes;
604 if !self.scripts_executed {
605 // One-time: parsing and running the application bundle. Separated
606 // because it is startup cost, and folding it into the steady-state
607 // numbers made every per-poll average meaningless.
608 let started = profiling.then(std::time::Instant::now);
609 self.execute_scripts();
610 if let Some(started) = started {
611 crate::script_stats::record_work("startup:execute_scripts", started.elapsed());
612 }
613 ran = true;
614 } else {
615 // Steady state: pick up any script the page appended since the last
616 // turn. Cheap when there are none, and it is the only path by which
617 // a runtime-injected bundle ever runs.
618 self.run_pending_scripts();
619 }
620
621 ran |= self.runtime.run_due_timers(profiling);
622
623 // A module that opened with a top-level `await` may have settled since
624 // the last turn. Checking here is what turns a silent half-mounted page
625 // into a reported rejection.
626 self.runtime.poll_module_evaluations();
627
628 if let Some(mut hook) = self.poll_hook.take() {
629 // The embedder's per-poll work. For a Solid application this is
630 // where reactive updates and DOM mutation actually happen, so it is
631 // the bucket that matters once startup is excluded.
632 let started = profiling.then(std::time::Instant::now);
633 ran |= hook(self, task_context.as_ref());
634 if let Some(started) = started {
635 crate::script_stats::record_work("poll_hook", started.elapsed());
636 }
637 self.poll_hook = Some(hook);
638 }
639
640 // Reclaim what removal could not judge at the time. A node is detached
641 // while script may still hold its wrapper, and the answer only becomes
642 // knowable once the collector has run; this is the point where asking
643 // is cheap and the answer is current.
644 self.runtime.sweep_detached_nodes();
645
646 self.arm_timer_thread();
647 ran
648 }
649}