Skip to main content

blitz_platform_api/
lib.rs

1//! Runtime-agnostic host platform APIs: `fetch`, and per-origin storage.
2//!
3//! The third crate on the same split that produced `blitz-dom-api`. The DOM
4//! facade took DOM operations and removed the runtime; this takes the platform
5//! APIs and removes both the runtime *and* the transport, so what is left is
6//! the part every binding would otherwise write again.
7//!
8//! ```text
9//!   blitz-traits         FetchProvider, StorageProvider, OriginKey
10//!         |                  the embedder implements these
11//!   blitz-platform-api   this crate: origin scoping, the in-flight table,
12//!         |              the completion queue, the counters
13//!   blitz-wasm           binds it for a WebAssembly guest
14//!   blitz-script         can bind the same thing for JavaScript
15//! ```
16//!
17//! # Why this is not inside `blitz-wasm`
18//!
19//! Because JavaScript has no `fetch()` either. `blitz-script`'s [`fetch`
20//! module][script-fetch] is synchronous `<script src>` loading and nothing
21//! else, and chuzz's `WEB_API_SHIM` supplies an in-memory `localStorage` and a
22//! deliberately non-conformant `URL`. Written inside the wasm binding, all of
23//! this would have to be written a second time for Boa. Written here, Boa's
24//! binding is argument coercion over the same host.
25//!
26//! [script-fetch]: ../../blitz-script/src/fetch.rs
27//!
28//! # What is *not* here
29//!
30//! No HTTP. This crate never opens a socket, parses a header off the wire, or
31//! names a client. `blitz-net` already ships `reqwest` with HTTP/2, cookies,
32//! compression and a cacache disk cache, and it implements
33//! [`FetchProvider`](blitz_traits::platform::FetchProvider) over that same
34//! client. Anything here that looked like HTTP logic would be a second, worse
35//! implementation of it.
36//!
37//! No runtime. Nothing here is `async`, nothing spawns, and nothing blocks.
38//! [`PlatformHost::start_fetch`] hands the request to the provider and returns
39//! an id; the provider answers on whatever thread it likes; the answer waits in
40//! a queue until an embedder drains it.
41//!
42//! `tests/no_client_or_engine.rs` asserts both against the resolved dependency
43//! graph, in the manner of `blitz-dom-api`'s `no_boa.rs`.
44//!
45//! # Borrow discipline, and why fetch completes the way a click dispatches
46//!
47//! **A completed fetch is delivered by draining a queue, never by a callback
48//! that reaches into a document.**
49//!
50//! `blitz-wasm` already learned this on the event path: calling a guest from
51//! inside `EventHandler::handle_event` would run guest code while the
52//! `EventDriver` holds the document, and the guest's first act is to mutate the
53//! DOM. Its answer was to queue listener ids during propagation and call the
54//! guest afterwards, with the borrow gone.
55//!
56//! Fetch has the identical hazard from the other direction: a response arrives
57//! on a network thread at a moment nothing knows about. So it takes the
58//! identical answer, and this crate is built so the wrong version does not
59//! compile. Nothing here can reach a document, because nothing here has ever
60//! been given one: [`PlatformHost`] holds an origin, two providers and a table.
61//! The completion handler holds a [`Weak`](std::sync::Weak) reference to that
62//! table and nothing else.
63//!
64//! # Origin scoping
65//!
66//! **A [`PlatformHost`] is built for one origin and holds it for life.** Every
67//! storage call on it is scoped to that origin, and there is no method that
68//! takes an origin as an argument. A binding therefore cannot pass the wrong
69//! one, in the same way `blitz-wasm`'s event handler cannot reach the guest.
70//!
71//! See [`OriginKey`](blitz_traits::platform::OriginKey) for why `file:` and
72//! `data:` documents each get their own opaque origin rather than sharing one
73//! bucket.
74
75pub mod counters;
76pub mod fetch;
77pub mod storage;
78
79pub use counters::PlatformCounters;
80pub use fetch::{FetchState, RequestId};
81pub use storage::MemoryStorage;
82
83use std::sync::{Arc, Mutex, Weak};
84
85use blitz_traits::platform::{
86    FetchError, FetchHandler, FetchProvider, FetchRequest, FetchResponse, OriginKey, StorageError,
87    StorageProvider,
88};
89
90use crate::fetch::{InFlight, Slot};
91
92/// Called when a fetch completes, so an embedder knows to drain the queue.
93///
94/// Without one, a response lands in the queue and nothing asks for it until
95/// something else happens to wake the event loop, which in a GUI means "until
96/// the user moves the mouse". That is not a slow fetch, it is a hung one.
97pub type ReadyWaker = Arc<dyn Fn() + Send + Sync + 'static>;
98
99/// The platform APIs available to one document, at one origin.
100///
101/// Construct with [`PlatformHost::new`], hold it for the life of the document,
102/// and drain it with [`take_ready`](PlatformHost::take_ready).
103pub struct PlatformHost {
104    origin: OriginKey,
105    fetch_provider: Arc<dyn FetchProvider>,
106    storage_provider: Arc<dyn StorageProvider>,
107    /// `Arc` because completion handlers hold `Weak`s to it, and those outlive
108    /// this host whenever a request is still in flight at teardown.
109    inflight: Arc<Mutex<InFlight>>,
110    waker: Option<ReadyWaker>,
111    counters: Mutex<PlatformCounters>,
112}
113
114impl PlatformHost {
115    pub fn new(
116        origin: OriginKey,
117        fetch_provider: Arc<dyn FetchProvider>,
118        storage_provider: Arc<dyn StorageProvider>,
119    ) -> Self {
120        Self {
121            origin,
122            fetch_provider,
123            storage_provider,
124            inflight: Arc::new(Mutex::new(InFlight::default())),
125            waker: None,
126            counters: Mutex::new(PlatformCounters::default()),
127        }
128    }
129
130    /// Install the callback that says "a response is ready to drain".
131    ///
132    /// It runs on whatever thread the provider answered on, so it must do the
133    /// minimum: set a flag, or ask a window for a frame. It must not take the
134    /// document, and it must not call back into this host.
135    pub fn with_waker(mut self, waker: ReadyWaker) -> Self {
136        self.waker = Some(waker);
137        self
138    }
139
140    /// The origin every storage call on this host is scoped to.
141    pub fn origin(&self) -> &OriginKey {
142        &self.origin
143    }
144
145    pub fn counters(&self) -> PlatformCounters {
146        *self.counters.lock().unwrap()
147    }
148
149    // -- fetch ------------------------------------------------------------
150
151    /// Begin a request. Returns immediately.
152    ///
153    /// The returned id names the request until [`release`](Self::release) is
154    /// called on it. It is *not* a promise and it does not carry a result: the
155    /// result arrives in the ready queue, which is the only place a completion
156    /// is ever observed.
157    pub fn start_fetch(&self, request: FetchRequest) -> RequestId {
158        let sent = request.body.as_ref().map(|body| body.len()).unwrap_or(0);
159
160        let id = {
161            let mut inflight = self.inflight.lock().unwrap();
162            inflight.begin()
163        };
164
165        {
166            let mut counters = self.counters.lock().unwrap();
167            counters.fetches_started += 1;
168            counters.fetch_bytes_sent += sent as u64;
169        }
170
171        self.fetch_provider.fetch(
172            request,
173            Box::new(Completion {
174                id,
175                inflight: Arc::downgrade(&self.inflight),
176                waker: self.waker.clone(),
177            }),
178        );
179
180        id
181    }
182
183    /// Every request that has completed since the last call, and no others.
184    ///
185    /// **Call this with no document borrow held.** The whole point of the queue
186    /// is that a caller chooses the moment, and the moment must be one where it
187    /// is safe to run guest code. Each id remains valid, and its response
188    /// readable, until [`release`](Self::release).
189    pub fn take_ready(&self) -> Vec<RequestId> {
190        let ready = {
191            let mut inflight = self.inflight.lock().unwrap();
192            std::mem::take(&mut inflight.ready)
193        };
194
195        if !ready.is_empty() {
196            let received: u64 = ready
197                .iter()
198                .filter_map(|id| self.with_response(*id, |response| response.body.len() as u64))
199                .sum();
200            let mut counters = self.counters.lock().unwrap();
201            counters.fetches_completed += ready.len() as u64;
202            counters.fetch_bytes_received += received;
203        }
204
205        ready
206    }
207
208    /// What happened to a request.
209    pub fn state(&self, id: RequestId) -> FetchState {
210        let inflight = self.inflight.lock().unwrap();
211        match inflight.slots.get(&id) {
212            None => FetchState::Unknown,
213            Some(Slot::Pending) => FetchState::Pending,
214            Some(Slot::Done(answer)) => match answer.as_ref() {
215                Ok(_) => FetchState::Response,
216                Err(error) => FetchState::Failed(error.clone()),
217            },
218        }
219    }
220
221    /// Read something out of a completed response without cloning it.
222    ///
223    /// A closure rather than a returned reference because the response lives
224    /// behind a lock, and handing a `&FetchResponse` out would mean handing the
225    /// guard out with it. The `Option` is `None` when the id is unknown or the
226    /// request did not produce a response.
227    pub fn with_response<T>(
228        &self,
229        id: RequestId,
230        read: impl FnOnce(&FetchResponse) -> T,
231    ) -> Option<T> {
232        let inflight = self.inflight.lock().unwrap();
233        match inflight.slots.get(&id) {
234            Some(Slot::Done(answer)) => answer.as_ref().as_ref().ok().map(read),
235            _ => None,
236        }
237    }
238
239    /// Forget a request.
240    ///
241    /// Valid at any point, including while still pending: the completion
242    /// handler holds only an id and finds nothing to write into, so a late
243    /// answer is dropped rather than resurrecting the entry. That is what makes
244    /// tearing down a document with requests in flight safe.
245    pub fn release(&self, id: RequestId) -> bool {
246        let mut inflight = self.inflight.lock().unwrap();
247        inflight.ready.retain(|ready| *ready != id);
248        inflight.slots.remove(&id).is_some()
249    }
250
251    /// How many requests are known to this host, pending or completed.
252    pub fn tracked_requests(&self) -> usize {
253        self.inflight.lock().unwrap().slots.len()
254    }
255
256    // -- storage ----------------------------------------------------------
257    //
258    // No method here takes an origin. See the crate docs: the host holds one
259    // and a binding has no way to name another.
260
261    pub fn storage_get(&self, key: &str) -> Option<String> {
262        let value = self.storage_provider.get(&self.origin, key);
263        let mut counters = self.counters.lock().unwrap();
264        counters.storage_reads += 1;
265        counters.storage_bytes_read += value.as_ref().map(|v| v.len()).unwrap_or(0) as u64;
266        value
267    }
268
269    pub fn storage_set(&self, key: &str, value: &str) -> Result<(), StorageError> {
270        let result = self.storage_provider.set(&self.origin, key, value);
271        if result.is_ok() {
272            let mut counters = self.counters.lock().unwrap();
273            counters.storage_writes += 1;
274            counters.storage_bytes_written += (key.len() + value.len()) as u64;
275        }
276        result
277    }
278
279    pub fn storage_remove(&self, key: &str) {
280        self.storage_provider.remove(&self.origin, key);
281        self.counters.lock().unwrap().storage_writes += 1;
282    }
283
284    pub fn storage_clear(&self) {
285        self.storage_provider.clear(&self.origin);
286        self.counters.lock().unwrap().storage_writes += 1;
287    }
288}
289
290/// The handler handed to the provider for one request.
291///
292/// Holds an id, a `Weak` to the table, and a waker. **It cannot reach the
293/// document, the guest, or the host**, because it was never given any of them.
294/// Same technique as `blitz-wasm`'s `WasmEventHandler`, which is constructed
295/// with three field references and therefore has no path to a guest export
296/// whatever it intends.
297struct Completion {
298    id: RequestId,
299    inflight: Weak<Mutex<InFlight>>,
300    waker: Option<ReadyWaker>,
301}
302
303impl FetchHandler for Completion {
304    fn complete(self: Box<Self>, result: Result<FetchResponse, FetchError>) {
305        let Some(inflight) = self.inflight.upgrade() else {
306            // The host is gone. Dropping the response is the whole answer.
307            return;
308        };
309
310        {
311            let mut inflight = inflight.lock().unwrap();
312            // `get_mut` rather than `insert`: a released request must stay
313            // released. Re-inserting here would resurrect an entry the caller
314            // has already forgotten, and it would never be drained.
315            let Some(slot) = inflight.slots.get_mut(&self.id) else {
316                return;
317            };
318            *slot = Slot::Done(Box::new(result));
319            inflight.ready.push(self.id);
320        }
321
322        if let Some(waker) = &self.waker {
323            waker();
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests;