mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 12:13:20 +00:00
craft-web-host: queue messages that arrive while the handler runs
A page host dispatches its events synchronously, so it can answer (a
saved for the save the handler just posted) while the handler is still
running. The nested borrow_mut then panicked. Both transports now hand
messages to an Inbox that queues each one and drains the queue one call
at a time, in order; nothing is dropped. The new test fails with the old
borrow ("already borrowed").
FileData::size() reads a Blob's length from the Blob; a host's size
field is no longer trusted for an app's size check. lib.rs gets the
crate-level deny of unwrap, expect, panic and the like.
This commit is contained in:
@@ -27,6 +27,8 @@ Compatibility with the bridges shipped before this crate:
|
||||
|
||||
- Fields marked `?` are optional. A host answers `saved` only for a `save` that carries an `id`; an app that sends none treats its save as done once it is posted.
|
||||
- A host that never sends `host` gets the behaviour of the earlier bridges.
|
||||
- `size` beside `file` is for hosts and older bridges; this crate takes a `Blob`'s length from the `Blob` itself.
|
||||
- A page host may dispatch an event while the app's handler runs (a `saved` answering the `save` it just posted). The bridge holds it and calls the handler again once the running call returns, so messages arrive one at a time and in order.
|
||||
- `version` is `"1"`. The earlier bridges put the app's own release in it (`"0.14.0"`), so a host treats a `version` it doesn't know as `"1"`. The crate sends the release as `appVersion`.
|
||||
|
||||
## Use in an app's web shell
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
//! messages are `CustomEvent`s on the canvas, the fields in `detail`). This module is the contract
|
||||
//! itself and builds everywhere; [`web`] (wasm32 only) is the bridge an app's web shell calls.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod web;
|
||||
|
||||
@@ -130,6 +135,34 @@ pub fn file_name(raw: Option<&str>, fallback: &str) -> String {
|
||||
trimmed.chars().take(MAX_NAME_CHARS).collect()
|
||||
}
|
||||
|
||||
/// Hands the host's messages to the app's handler one at a time. A page host dispatches its events
|
||||
/// synchronously, so it can answer while the handler runs (a `saved` for the `save` the handler
|
||||
/// just posted): that message waits here and is handled as soon as the running call returns, never
|
||||
/// dropped and never given to a second, overlapping call.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub(crate) struct Inbox<M, F> {
|
||||
handler: RefCell<F>,
|
||||
queue: RefCell<VecDeque<M>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
impl<M, F: FnMut(M)> Inbox<M, F> {
|
||||
pub(crate) fn new(handler: F) -> Self {
|
||||
Self { handler: RefCell::new(handler), queue: RefCell::new(VecDeque::new()) }
|
||||
}
|
||||
|
||||
pub(crate) fn deliver(&self, message: M) {
|
||||
self.queue.borrow_mut().push_back(message);
|
||||
// Already running: the outer call drains the queue.
|
||||
let Ok(mut handler) = self.handler.try_borrow_mut() else { return };
|
||||
loop {
|
||||
let next = self.queue.borrow_mut().pop_front();
|
||||
let Some(message) = next else { break };
|
||||
handler(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -173,4 +206,30 @@ mod tests {
|
||||
assert_eq!(file_name(None, "Document.pdf"), "Document.pdf");
|
||||
assert_eq!(file_name(Some(&"é".repeat(300)), "x").chars().count(), MAX_NAME_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_that_arrive_while_the_handler_runs_wait_their_turn() {
|
||||
use std::rc::Rc;
|
||||
type Shared = Rc<Inbox<&'static str, Box<dyn FnMut(&'static str)>>>;
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let inbox: Rc<RefCell<Option<Shared>>> = Rc::new(RefCell::new(None));
|
||||
let (log, me) = (seen.clone(), inbox.clone());
|
||||
let handler: Box<dyn FnMut(&'static str)> = Box::new(move |m: &'static str| {
|
||||
log.borrow_mut().push(format!("start {m}"));
|
||||
// The page answers inside the handler, as a synchronous dispatchEvent does.
|
||||
if m == "open"
|
||||
&& let Some(inbox) = me.borrow().clone()
|
||||
{
|
||||
inbox.deliver("saved");
|
||||
inbox.deliver("handoff-done");
|
||||
}
|
||||
log.borrow_mut().push(format!("end {m}"));
|
||||
});
|
||||
let shared = Rc::new(Inbox::new(handler));
|
||||
*inbox.borrow_mut() = Some(shared.clone());
|
||||
shared.deliver("open");
|
||||
assert_eq!(*seen.borrow(), ["start open", "end open", "start saved", "end saved", "start handoff-done", "end handoff-done"]);
|
||||
shared.deliver("host");
|
||||
assert_eq!(seen.borrow().len(), 8, "the inbox is usable again after draining");
|
||||
}
|
||||
}
|
||||
|
||||
+13
-14
@@ -7,29 +7,29 @@
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use wasm_bindgen::{JsCast as _, JsValue};
|
||||
use web_sys::js_sys;
|
||||
|
||||
use crate::{Incoming, Mode, Outgoing, VERSION, file_name, message_type};
|
||||
use crate::{Inbox, Incoming, Mode, Outgoing, VERSION, file_name, message_type};
|
||||
|
||||
/// A file the host passes in, not yet copied into the app's memory: check its size first.
|
||||
pub enum FileData {
|
||||
/// `bytes`: an `ArrayBuffer` or `Uint8Array`, viewed in place.
|
||||
Bytes(js_sys::Uint8Array),
|
||||
/// `file` + `size`: a `File` or `Blob`, for the app to read in slices.
|
||||
Blob { blob: web_sys::Blob, size: f64 },
|
||||
/// `file`: a `File` or `Blob`, for the app to read in slices.
|
||||
Blob(web_sys::Blob),
|
||||
}
|
||||
|
||||
impl FileData {
|
||||
/// The file's length in bytes.
|
||||
/// The file's length in bytes, from the data itself (a host's `size` field is not trusted).
|
||||
pub fn size(&self) -> f64 {
|
||||
match self {
|
||||
Self::Bytes(array) => f64::from(array.length()),
|
||||
Self::Blob { size, .. } => *size,
|
||||
Self::Blob(blob) => blob.size(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,10 +95,10 @@ impl Host {
|
||||
/// Hear the host's messages for this app. The listener lives as long as the page.
|
||||
pub fn listen(&self, handler: impl FnMut(Message) + 'static) {
|
||||
let app = self.app;
|
||||
let handler = Rc::new(RefCell::new(handler));
|
||||
let inbox = Rc::new(Inbox::new(handler));
|
||||
match &self.transport {
|
||||
Transport::Parent { window, parent, origin } => {
|
||||
let (parent, origin, handler) = (parent.clone(), origin.clone(), handler.clone());
|
||||
let (parent, origin, inbox) = (parent.clone(), origin.clone(), inbox.clone());
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
@@ -106,7 +106,7 @@ impl Host {
|
||||
let data = event.data();
|
||||
let Some(kind) = string(&data, "type").and_then(|t| Incoming::parse(app, &t)) else { return };
|
||||
if let Some(message) = read(kind, &data) {
|
||||
(handler.borrow_mut())(message);
|
||||
inbox.deliver(message);
|
||||
}
|
||||
});
|
||||
if window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
@@ -115,10 +115,10 @@ impl Host {
|
||||
}
|
||||
Transport::Page { canvas } => {
|
||||
for kind in Incoming::ALL {
|
||||
let handler = handler.clone();
|
||||
let inbox = inbox.clone();
|
||||
let on_event = Closure::<dyn FnMut(web_sys::CustomEvent)>::new(move |event: web_sys::CustomEvent| {
|
||||
if let Some(message) = read(kind, &event.detail()) {
|
||||
(handler.borrow_mut())(message);
|
||||
inbox.deliver(message);
|
||||
}
|
||||
});
|
||||
let name = message_type(app, kind.kind());
|
||||
@@ -213,12 +213,11 @@ fn read(kind: Incoming, data: &JsValue) -> Option<Message> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `file` (a `Blob`, with `size`) or `bytes` (an `ArrayBuffer` or `Uint8Array`), not copied.
|
||||
/// `file` (a `Blob`; its `size` field is ignored for the Blob's own) or `bytes` (an `ArrayBuffer` or `Uint8Array`), not copied.
|
||||
fn file_data(data: &JsValue) -> Option<FileData> {
|
||||
let file = get(data, "file");
|
||||
if let Some(blob) = file.dyn_ref::<web_sys::Blob>() {
|
||||
let size = get(data, "size").as_f64().unwrap_or_else(|| blob.size());
|
||||
return Some(FileData::Blob { blob: blob.clone(), size });
|
||||
return Some(FileData::Blob(blob.clone()));
|
||||
}
|
||||
let bytes = get(data, "bytes");
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
|
||||
Reference in New Issue
Block a user