feat(wasm): validate client event and state ABI
req: client_local/005\nreq: client_local/006\nreq: client_local/007\nreq: client_local/008\nreq: client_local/009\nreq: client_local/010\nreq: client_local/014
This commit is contained in:
+99
-1
@@ -10,6 +10,65 @@ pub use wasm_bindgen::prelude::wasm_bindgen;
|
||||
#[doc(hidden)]
|
||||
pub use wasm_bindgen::*;
|
||||
|
||||
pub const CLIENT_EVENT_ABI_VERSION: u32 = 1;
|
||||
pub const CLIENT_STATE_ABI_VERSION: u32 = 1;
|
||||
|
||||
/// Versioned browser event accepted by client-local handlers.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ClientEvent {
|
||||
pub kind: String,
|
||||
pub value: Option<String>,
|
||||
pub checked: Option<bool>,
|
||||
pub key: Option<String>,
|
||||
}
|
||||
|
||||
/// Explicit root-owned state passed to a client-local handler.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ClientState {
|
||||
pub encoded: String,
|
||||
}
|
||||
|
||||
/// Validates primitive wasm-bindgen values before application code runs.
|
||||
///
|
||||
/// Primitive arguments keep JavaScript from owning a second binary codec. The
|
||||
/// ordinary effect result remains postcard-encoded by `hemx-core`.
|
||||
#[doc(hidden)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn decode_client_inputs(
|
||||
event_version: u32,
|
||||
kind: String,
|
||||
value: Option<String>,
|
||||
checked: Option<bool>,
|
||||
key: Option<String>,
|
||||
state_version: u32,
|
||||
encoded_state: String,
|
||||
) -> Result<(ClientEvent, ClientState), String> {
|
||||
if event_version != CLIENT_EVENT_ABI_VERSION {
|
||||
return Err(format!(
|
||||
"unsupported client-local event ABI version {event_version}; expected {CLIENT_EVENT_ABI_VERSION}"
|
||||
));
|
||||
}
|
||||
if kind.is_empty() {
|
||||
return Err("invalid client-local event payload: event kind is empty".to_owned());
|
||||
}
|
||||
if state_version != CLIENT_STATE_ABI_VERSION {
|
||||
return Err(format!(
|
||||
"unsupported client-local state ABI version {state_version}; expected {CLIENT_STATE_ABI_VERSION}"
|
||||
));
|
||||
}
|
||||
Ok((
|
||||
ClientEvent {
|
||||
kind,
|
||||
value,
|
||||
checked,
|
||||
key,
|
||||
},
|
||||
ClientState {
|
||||
encoded: encoded_state,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Encodes a client handler result with the ordinary hemx effect wire format.
|
||||
///
|
||||
/// Keeping this conversion here gives generated WASM exports one ABI boundary
|
||||
@@ -21,9 +80,48 @@ pub fn encode_handler_effect(effect: impl IntoEffect, fingerprint: BuildFingerpr
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::encode_handler_effect;
|
||||
use super::{decode_client_inputs, encode_handler_effect, ClientEvent, ClientState};
|
||||
use hemx_core::{BuildFingerprint, EffectBatch, Slot};
|
||||
|
||||
#[test]
|
||||
fn client_inputs_are_typed_and_versioned() {
|
||||
assert_eq!(
|
||||
decode_client_inputs(
|
||||
1,
|
||||
"click".to_owned(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1,
|
||||
"count=3".to_owned(),
|
||||
),
|
||||
Ok((
|
||||
ClientEvent {
|
||||
kind: "click".to_owned(),
|
||||
value: None,
|
||||
checked: None,
|
||||
key: None,
|
||||
},
|
||||
ClientState {
|
||||
encoded: "count=3".to_owned(),
|
||||
},
|
||||
))
|
||||
); // req: client_local/005 req: client_local/007
|
||||
assert_eq!(
|
||||
decode_client_inputs(
|
||||
1,
|
||||
"click".to_owned(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
2,
|
||||
"count=3".to_owned(),
|
||||
)
|
||||
.expect_err("reject unknown state ABI"),
|
||||
"unsupported client-local state ABI version 2; expected 1"
|
||||
); // req: client_local/008
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_handler_uses_the_ordinary_effect_wire_format() {
|
||||
let fingerprint = BuildFingerprint(17);
|
||||
|
||||
@@ -45,7 +45,7 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
|
||||
wait_for_text(
|
||||
&driver,
|
||||
"[data-hemx-slot='counter_panel']",
|
||||
"updated by Rust/WASM",
|
||||
"updated by Rust/WASM (click, count=3)",
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -54,6 +54,46 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
|
||||
network_before,
|
||||
"client handler made a network request"
|
||||
);
|
||||
|
||||
driver
|
||||
.execute(
|
||||
"document.querySelector('[data-hemx-root]').setAttribute('data-hemx-client-state-version', '2'); return true",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
driver
|
||||
.find(By::Css("[data-hemx-client='increment']"))
|
||||
.await?
|
||||
.click()
|
||||
.await?;
|
||||
wait_until(&driver, "return window.__clientErrors.length === 1").await?;
|
||||
assert!(
|
||||
driver
|
||||
.execute("return window.__clientErrors[0].message", Vec::new())
|
||||
.await?
|
||||
.json()
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("unsupported client-local state ABI version 2; expected 1"),
|
||||
"invalid state must produce an actionable client-local diagnostic"
|
||||
);
|
||||
assert_eq!(
|
||||
resource_count(&driver).await?,
|
||||
network_before + 1,
|
||||
"declared server fallback was not requested"
|
||||
);
|
||||
assert!(
|
||||
driver
|
||||
.execute(
|
||||
"return !document.querySelector('[data-hemx-client]').classList.contains('is-pending')",
|
||||
Vec::new(),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.as_bool()
|
||||
.unwrap_or(false),
|
||||
"invalid input must restore pending UI"
|
||||
);
|
||||
Ok::<(), WebDriverError>(())
|
||||
}
|
||||
.await;
|
||||
@@ -160,9 +200,9 @@ fn serve(mut stream: TcpStream, package: &Path, runtime: &Path) {
|
||||
|
||||
fn fixture_html() -> String {
|
||||
r#"<!doctype html><html><body>
|
||||
<main data-hemx-root="client_local">
|
||||
<main data-hemx-root="client_local" data-hemx-st="count=3" data-hemx-client-state-version="1">
|
||||
<section data-hemx-slot="counter_panel">idle</section>
|
||||
<button type="button" data-hid="1" data-hemx-client="increment">Increment locally</button>
|
||||
<button type="button" data-hid="1" data-hemx-on="click" data-hemx-client="increment" data-hemx-client-fallback data-hemx-pending-class="is-pending">Increment locally</button>
|
||||
</main>
|
||||
<script src="/hemx.js"></script>
|
||||
<script type="module">
|
||||
@@ -170,11 +210,13 @@ import init, { __hemx_client_increment } from "/client_local.js";
|
||||
await init();
|
||||
const root = document.querySelector("[data-hemx-root]");
|
||||
const slot = root.querySelector("[data-hemx-slot]");
|
||||
const probe = __hemx_client_increment();
|
||||
const probe = __hemx_client_increment(1, "click", undefined, undefined, undefined, 1, "count=3");
|
||||
const batch = window.hemx.decodeBatch(probe);
|
||||
root.dataset.hemxBuild = String(batch.fingerprint);
|
||||
slot.dataset.sid = String(batch.ops[0].target.resource.id);
|
||||
window.hemx.registerClientHandler("increment", __hemx_client_increment);
|
||||
window.__clientErrors = [];
|
||||
root.addEventListener("hemx:client-error", (event) => window.__clientErrors.push(event.detail));
|
||||
window.__clientReady = true;
|
||||
</script></body></html>"#
|
||||
.to_owned()
|
||||
|
||||
Reference in New Issue
Block a user