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
+2 -2
View File
@@ -15,11 +15,11 @@ encryption, retention, backup, and deployment policy remain host concerns.
## Slice 1 — one real client-local handler
- [ ] **User value:** a Rust author marks one high-frequency handler local and gets immediate browser behavior without app-authored JavaScript or a request.
- **State:** In progress. The first zero-parameter handler now compiles to real WASM, is registered by the runtime, applies the ordinary generated-target `EffectBatch`, and is browser-proven with zero interaction request. Typed events/state and fallback/error recovery remain.
- **State:** In progress. Client handlers now receive versioned `ClientEvent`/root-owned `ClientState`; incompatible input is rejected before handler execution, reports an actionable `hemx:client-error`, restores pending UI, and invokes an explicitly declared server fallback. Real WASM still applies the ordinary generated-target `EffectBatch` with zero request on valid input. Generated bootstrap must replace the browser proof's manual import/registration glue before the slice is complete.
- **Build:** add the smallest optional `hemx-wasm` boundary for `#[hemx::handler(client)]`; export only opted-in handlers; generate typed event/state ABI glue; run one existing generated-target interaction through the ordinary `EffectBatch` interpreter; preserve an explicit native/server fallback.
- **Refusals:** no VDOM, component lifecycle, global store, sync queue, second effect protocol, or generic WASM framework.
- **Requirements:** `client_local/001-010`, `security/001`, `security/005-006`, `performance/003`, `v1_release/001`.
- **Proof:** `cargo test -p hemx-wasm --test browser client_handler_applies_effect_batch_without_network -- --exact` visibly updates a generated target through real WASM and keeps the resource count unchanged. Slice completion additionally requires invalid event/state recovery, unchanged server handlers, and formatting/workspace tests/strict all-target Clippy/wasm-target checks.
- **Proof:** `cargo test -p hemx-wasm --test browser client_handler_applies_effect_batch_without_network -- --exact` visibly updates a generated target through real WASM, keeps the resource count unchanged for valid input, and proves invalid state diagnostics, pending restoration, and one declared fallback request. Slice completion additionally requires generated bootstrap with no app-authored JavaScript plus unchanged server handlers and formatting/workspace tests/strict all-target Clippy/wasm-target checks.
## Slice 2 — direct manipulation that survives interruption
+8 -2
View File
@@ -2,6 +2,12 @@
pub mod ui {}
#[hemx::handler(client)]
pub fn increment() -> impl hemx::IntoEffect {
ui::client_local::counter_panel.text("updated by Rust/WASM")
pub fn increment(
event: hemx::wasm::ClientEvent,
state: hemx::wasm::ClientState,
) -> impl hemx::IntoEffect {
ui::client_local::counter_panel.text(format!(
"updated by Rust/WASM ({}, {})",
event.kind, state.encoded
))
}
@@ -1,4 +1,4 @@
<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-hemx-handle="increment" data-hemx-client="increment">Increment locally</button>
<button type="button" data-hemx-handle="increment" data-hemx-on="click" data-hemx-client="increment" data-hemx-client-fallback data-hemx-pending-class="is-pending">Increment locally</button>
</main>
+16
View File
@@ -1609,6 +1609,8 @@ fn known_hemx_attr(name: &str) -> bool {
| "data-hemx-on"
| "data-hemx-client"
| "data-hemx-client-event"
| "data-hemx-client-fallback"
| "data-hemx-client-state-version"
| "data-hemx-pending-class"
| "data-hemx-indicator"
| "data-hemx-confirm"
@@ -1668,6 +1670,20 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i
"expected a runtime-supported event",
));
}
"data-hemx-client-state-version"
if value
.parse::<u32>()
.ok()
.filter(|version| *version > 0)
.is_none() =>
{
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a positive client state ABI version",
));
}
"data-hemx-on" if !valid_event_list(value) => {
return Err(invalid_hemx_value(
path,
+31 -6
View File
@@ -89,14 +89,15 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
if !is_client {
return quote!(#function).into();
}
if !function.sig.inputs.is_empty()
let input_count = function.sig.inputs.len();
if !matches!(input_count, 0 | 2)
|| function.sig.asyncness.is_some()
|| function.sig.unsafety.is_some()
|| function.sig.constness.is_some()
|| !function.sig.generics.params.is_empty()
{
let message = format!(
"client-local hemx handler `{name}` must be a safe, synchronous, non-generic function with no parameters"
"client-local hemx handler `{name}` must be safe, synchronous, non-generic, and accept either no parameters or `(hemx::wasm::ClientEvent, hemx::wasm::ClientState)`"
);
return quote!(
#function
@@ -108,6 +109,11 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
let function_name = &function.sig.ident;
let export_name = format_ident!("__hemx_client_{function_name}");
let export_module = format_ident!("__hemx_client_export_{function_name}");
let invoke_handler = if input_count == 0 {
quote!(super::#function_name())
} else {
quote!(super::#function_name(event, state))
};
quote!(
#function
@@ -116,11 +122,30 @@ pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream {
use ::hemx::wasm as wasm_bindgen;
#[::hemx::wasm::wasm_bindgen(js_name = #export_name)]
pub fn invoke() -> ::std::vec::Vec<u8> {
::hemx::wasm::encode_handler_effect(
super::#function_name(),
crate::ui::BUILD_FINGERPRINT,
#[allow(clippy::too_many_arguments)]
pub fn invoke(
event_version: u32,
event_kind: ::std::string::String,
event_value: ::std::option::Option<::std::string::String>,
event_checked: ::std::option::Option<bool>,
event_key: ::std::option::Option<::std::string::String>,
state_version: u32,
encoded_state: ::std::string::String,
) -> ::std::result::Result<::std::vec::Vec<u8>, ::hemx::wasm::JsValue> {
let (event, state) = ::hemx::wasm::decode_client_inputs(
event_version,
event_kind,
event_value,
event_checked,
event_key,
state_version,
encoded_state,
)
.map_err(|error| ::hemx::wasm::JsValue::from_str(&error))?;
Ok(::hemx::wasm::encode_handler_effect(
#invoke_handler,
crate::ui::BUILD_FINGERPRINT,
))
}
}
)
+11 -1
View File
@@ -45,6 +45,16 @@ export interface AtomSnapshot {
bytes: Uint8Array;
}
export type ClientHandler = (
eventVersion: number,
eventKind: string,
eventValue: string | undefined,
eventChecked: boolean | undefined,
eventKey: string | undefined,
stateVersion: number,
encodedState: string,
) => Uint8Array | Promise<Uint8Array>;
export interface HemxRuntime {
readonly runtimeAbiVersion: number;
roots(): Element[];
@@ -54,7 +64,7 @@ export interface HemxRuntime {
decodeBatch(buffer: ArrayBuffer): EffectBatch;
atomValue(root: Element | ParentNode | null | undefined, id: number): Uint8Array | undefined;
decodeAtomState(encoded: string): AtomSnapshot[];
registerClientHandler(name: string, handler: () => Uint8Array | Promise<Uint8Array>): void;
registerClientHandler(name: string, handler: ClientHandler): void;
}
declare global {
+31 -6
View File
@@ -238,13 +238,34 @@
});
}
async function runClient(el) {
async function runClient(el, event) {
const name = el.getAttribute("data-hemx-client");
const root = rootOf(el);
const handler = clientHandlers.get(name);
if (!handler) throw new Error(`unknown client-local hemx handler: ${name}`);
const wire = await handler();
if (!(wire instanceof Uint8Array)) throw new Error(`client-local hemx handler ${name} returned an invalid effect batch`);
applyBatch(wire, rootOf(el));
showError(el, null);
showPending(el, true);
try {
if (!handler) throw new Error(`unknown client-local hemx handler: ${name}`);
const stateVersion = Number(root.getAttribute("data-hemx-client-state-version") || "1");
const wire = await handler(
1,
event.type,
"value" in el ? String(el.value) : undefined,
"checked" in el ? Boolean(el.checked) : undefined,
event.key || undefined,
stateVersion,
root.getAttribute(STATE) || "",
);
if (!(wire instanceof Uint8Array)) throw new Error(`client-local hemx handler ${name} returned an invalid effect batch`);
applyBatch(wire, root);
} catch (error) {
const fallback = el.hasAttribute("data-hemx-client-fallback");
showError(el, error);
emit(root, "hemx:client-error", { handler: name, message: String(error), fallback });
if (fallback) await send(el, event.type, el);
} finally {
showPending(el, false);
}
}
async function send(el, eventName, source = el) {
@@ -682,7 +703,11 @@
if (direct && defaultEvent(direct) === "click") {
event.preventDefault();
if (direct.hasAttribute("data-hemx-client")) {
runClient(direct).catch((error) => emit(root, "hemx:client-error", String(error)));
runClient(direct, event).catch((error) => emit(root, "hemx:client-error", {
handler: direct.getAttribute("data-hemx-client"),
message: String(error),
fallback: false,
}));
} else {
schedule(direct, name);
}
+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()