web-api: refuse calls beyond an in-flight limit, and say who can call window.<app>

* web-api: refuse calls beyond an in-flight limit, and say who can call window.<app>

At most 64 calls (PageApi::with_max_in_flight) may wait for the app at once. A call made while that many wait rejects at once with a busy error and never reaches the app, so a page script that calls in a loop without awaiting cannot grow the app queue without bound, as the desktop control server already answers busy when its queue is full. The README also says the API is a property of the page global with no key: any script in the page origin has the same command control as the desktop control server.

* web-api: turn a busy call away before converting its params, and say a same-origin parent or opener can call window.<app>

A call over the in-flight limit used to stringify and convert its params before the busy check, so a large payload still cost the work it was being refused for. The params are now built by a closure the call runs only once it holds a slot.

The README said a parent, a child or an opener cannot reach window.<app>. That holds across origins only: a same-origin parent, child or opener with a window reference calls it directly, and the craft-web-host origin and source checks do not apply to that call.

---------

Co-authored-by: Helen Kwok <107752876+helenkwok@users.noreply.github.com>
This commit is contained in:
Helen Kwok authored and GitHub committed 2026-11-12 10:06:13 -05:00
1 parent d624d5895a
commit 2bfc45c493
3 files changed
+83 -10

No files matched your search

+7 -1
View File
@@ -11,6 +11,12 @@ keeps them. Each function returns a Promise that resolves with the method's `res
an `Error` carrying `error`. Every call has a timeout (30 s by default, `PageApi::with_timeout`);
when the app does not answer in time, the error says so and the app's request future is dropped, so an app that releases a request when its future goes away (the desktop `ControlRequest` has `abandon`) never runs a call the page was told had failed.
At most 64 calls may wait for the app at once (`PageApi::with_max_in_flight`); a call made while that many are waiting rejects at once with a `busy` error and never reaches the app, so a script that calls in a loop without awaiting cannot grow the app's queue without bound. The size of a call's params is not capped here: opening a file passes the file's bytes in them, and each app already limits the files it opens.
## Who can call it
`window.<app>` is a plain property of the page's own global object: it is not a network port and it has no key. Any script that runs in the page's origin can call every function and so has the same command control as the app's desktop control server; a page from another origin cannot reach it, but a same-origin parent, child or opener that holds a reference to the window can call it directly, and frame or opener separation alone does not stop that (the host messages in `craft-web-host` check `event.origin` and `event.source`, and those checks do not apply to a direct call). Treat it as you treat the page's own scripts.
`screenshot()` resolves with the reply the app's control method sends, plus `data` when the reply carries the PNG inline: the base64 PNG, whatever field the app names it (`pngBase64`, `png_base64`, `png` or `base64`). A reply with no inline PNG, such as one the app wrote to a `path`, has no `data` and is unchanged.
Which functions are installed follows the app's `Methods` table: only the functions whose control
@@ -61,4 +67,4 @@ method through `request(method, params)`.
`src/methods.rs` carries the native unit tests (the default and empty tables). `tests/page.rs` runs
under `wasm-bindgen-test` in Node with a canned control channel: install and keeping an app's own
functions, the table deciding which functions exist, the method and params each function sends,
error replies as rejections, and the timeout.
error replies as rejections, the timeout, and the in-flight limit.
+43 -9
View File
@@ -32,14 +32,22 @@ pub struct PageApi {
/// Each control request gets a timeout: if the app does not answer within this many
/// milliseconds, the error says so (a hidden tab answers when it is shown again).
timeout_ms: f64,
/// Calls waiting for their reply right now.
in_flight: Cell<usize>,
/// The most calls that may wait at once; one more is refused with a `busy` error.
max_in_flight: usize,
}
/// How many calls may wait for the app at once by default, as many as the desktop control server
/// queues.
const DEFAULT_MAX_IN_FLIGHT: usize = 64;
impl PageApi {
/// `app` is the global name to install (`"vectorcraft"` → `window.vectorcraft`); `methods` is
/// the control method behind each function ([`Methods::default`] is the desktop control-server
/// method names).
pub fn new(app: &str, methods: Methods, request: Request) -> Self {
Self { app: app.to_string(), methods, request, timeout_ms: 30_000.0 }
Self { app: app.to_string(), methods, request, timeout_ms: 30_000.0, in_flight: Cell::new(0), max_in_flight: DEFAULT_MAX_IN_FLIGHT }
}
/// Set the timeout for every control request, in milliseconds (default 30 s).
@@ -48,6 +56,14 @@ impl PageApi {
self
}
/// Set how many calls may wait for the app at once (default 64). A call made while that many are
/// waiting is refused with a `busy` error before it reaches the app, so a page script that calls
/// in a loop without awaiting cannot grow the app's queue without bound.
pub fn with_max_in_flight(mut self, max: usize) -> Self {
self.max_in_flight = max;
self
}
/// Install `window.<app>` = `{request, execute, commands, docInspect, uiInspect, screenshot}` —
/// each a Promise-returning function that resolves with the method's `result` or rejects with
/// an `Error` carrying `error`. Only the functions whose control method the app's [`Methods`]
@@ -76,7 +92,7 @@ impl PageApi {
"request",
Closure::<dyn FnMut(String, JsValue) -> js_sys::Promise>::new(move |method: String, params: JsValue| {
let page = page.clone();
future_to_promise(async move { request_promise_outcome(&page.call(&method, from_js(&params)).await) })
future_to_promise(async move { request_promise_outcome(&page.call(&method, || from_js(&params)).await) })
})
.into_js_value(),
)?;
@@ -88,7 +104,7 @@ impl PageApi {
Closure::<dyn FnMut(String, JsValue) -> js_sys::Promise>::new(move |command: String, params: JsValue| {
let page = page.clone();
future_to_promise(async move {
let params = json!({"command": command, "params": from_js(&params)});
let params = || json!({"command": command, "params": from_js(&params)});
request_promise_outcome(&page.call(method, params).await)
})
})
@@ -102,7 +118,7 @@ impl PageApi {
"commands",
Closure::<dyn FnMut() -> js_sys::Promise>::new(move || {
let page = page.clone();
future_to_promise(async move { request_promise_outcome(&page.call(method, json!({})).await) })
future_to_promise(async move { request_promise_outcome(&page.call(method, || json!({})).await) })
})
.into_js_value(),
)?;
@@ -115,7 +131,7 @@ impl PageApi {
name,
Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(move |params: JsValue| {
let page = page.clone();
future_to_promise(async move { request_promise_outcome(&page.call(method, from_js(&params)).await) })
future_to_promise(async move { request_promise_outcome(&page.call(method, || from_js(&params)).await) })
})
.into_js_value(),
)?;
@@ -128,7 +144,7 @@ impl PageApi {
"screenshot",
Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(move |params: JsValue| {
let page = page.clone();
let params = screenshot_params(from_js(&params));
let params = move || screenshot_params(from_js(&params));
future_to_promise(async move { request_promise_outcome(&screenshot_data(page.call(method, params).await)) })
})
.into_js_value(),
@@ -142,10 +158,18 @@ impl PageApi {
/// with the page's timeout. If the app does not answer within the timeout, the error says so
/// (a hidden tab answers when it is shown again) and the app's request future is dropped: an app
/// that releases a request when its future goes away never runs, late, a call the page was told
/// had failed.
async fn call(&self, method: &str, params: Value) -> Value {
/// had failed. `params` is built after the in-flight check, so a call that is turned away costs no conversion.
async fn call(&self, method: &str, params: impl FnOnce() -> Value) -> Value {
if self.in_flight.get() >= self.max_in_flight {
return json!({
"ok": false,
"error": format!("{} is busy: {} calls are already waiting for an answer", self.app, self.max_in_flight),
});
}
self.in_flight.set(self.in_flight.get() + 1);
let _slot = InFlight(&self.in_flight);
let timeout = Timeout::new(self.timeout_ms);
let mut work = (self.request)(method.to_string(), params);
let mut work = (self.request)(method.to_string(), params()); // params are built only once a slot is held
let reply = std::future::poll_fn(|cx| {
if let Poll::Ready(reply) = work.as_mut().poll(cx) {
return Poll::Ready(Some(reply));
@@ -171,6 +195,16 @@ impl PageApi {
}
}
/// One waiting call's place in the in-flight count, given back when the call ends (answered, timed out
/// or dropped).
struct InFlight<'a>(&'a Cell<usize>);
impl Drop for InFlight<'_> {
fn drop(&mut self) {
self.0.set(self.0.get() - 1);
}
}
/// `screenshot` params: `data: true` is added so the reply carries the PNG, unless the caller set
/// `data` itself.
fn screenshot_params(mut params: Value) -> Value {
+33
View File
@@ -212,3 +212,36 @@ async fn screenshot_carries_the_png_as_data_whatever_the_app_calls_it() {
let out = call("screenshot", &[]).await.expect("screenshot");
assert_eq!(to_value(&out), json!({"base64": "UE5H", "width": 2, "data": "UE5H"}));
}
/// Start `window.vectorcraft.<name>(args…)` without awaiting it, so several calls can be in flight.
fn start(name: &str, args: &[JsValue]) -> js_sys::Promise {
let api = api();
let f: js_sys::Function = js_sys::Reflect::get(&api, &JsValue::from_str(name)).expect(name).unchecked_into();
f.apply(&api, &args.iter().collect::<js_sys::Array>()).expect("call").unchecked_into()
}
fn message(err: JsValue) -> String {
String::from(err.unchecked_into::<js_sys::Error>().message())
}
#[wasm_bindgen_test]
async fn a_call_beyond_the_in_flight_limit_is_refused_and_its_slot_comes_back() {
reset();
let never: Request = Rc::new(|_method: String, _params: Value| {
Box::pin(async {
let _ = JsFuture::from(js_sys::Promise::new(&mut |_, _| {})).await;
Value::Null
})
});
Rc::new(PageApi::new("vectorcraft", Methods::default(), never).with_timeout(50).with_max_in_flight(1)).install().expect("install");
let first = start("commands", &[]);
let second = start("commands", &[]);
let busy = JsFuture::from(second).await.expect_err("a second call while one waits is refused");
assert!(message(busy).contains("vectorcraft is busy: 1 calls are already waiting"), "the refusal says why");
let timed_out = JsFuture::from(first).await.expect_err("the first call times out");
assert!(message(timed_out).contains("did not answer"), "the first call was accepted and waited");
let again = call("commands", &[]).await.expect_err("a later call is accepted again");
assert!(message(again).contains("did not answer"), "its slot came back once the first call ended");
}