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`] 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`] 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`] 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;