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:
slhx agent
2026-07-13 12:42:31 +02:00
parent 40643e360d
commit 38eae79a2f
9 changed files with 246 additions and 24 deletions
+99 -1
View File
@@ -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);
+46 -4
View File
@@ -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()