diff --git a/PLAN.md b/PLAN.md index a390952..b3435c7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/examples/client_local/src/lib.rs b/examples/client_local/src/lib.rs index 32cf6dc..2e38576 100644 --- a/examples/client_local/src/lib.rs +++ b/examples/client_local/src/lib.rs @@ -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 + )) } diff --git a/examples/client_local/templates/client_local.heml b/examples/client_local/templates/client_local.heml index bb9991a..508b70d 100644 --- a/examples/client_local/templates/client_local.heml +++ b/examples/client_local/templates/client_local.heml @@ -1,4 +1,4 @@ -
+
idle
- +
diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 02a164d..0ce8794 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -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::() + .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, diff --git a/hemx-derive/src/lib.rs b/hemx-derive/src/lib.rs index 2a644d6..cc429f6 100644 --- a/hemx-derive/src/lib.rs +++ b/hemx-derive/src/lib.rs @@ -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 { - ::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, + event_key: ::std::option::Option<::std::string::String>, + state_version: u32, + encoded_state: ::std::string::String, + ) -> ::std::result::Result<::std::vec::Vec, ::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, + )) } } ) diff --git a/hemx-js/runtime/hemx.d.ts b/hemx-js/runtime/hemx.d.ts index a812615..cbd5f63 100644 --- a/hemx-js/runtime/hemx.d.ts +++ b/hemx-js/runtime/hemx.d.ts @@ -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; + 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): void; + registerClientHandler(name: string, handler: ClientHandler): void; } declare global { diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index f059803..c579555 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -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); } diff --git a/hemx-wasm/src/lib.rs b/hemx-wasm/src/lib.rs index 41f1116..34c9df0 100644 --- a/hemx-wasm/src/lib.rs +++ b/hemx-wasm/src/lib.rs @@ -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, + pub checked: Option, + pub key: Option, +} + +/// 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, + checked: Option, + key: Option, + 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); diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index 7a0ebd2..efa4a92 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -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#" -
+
idle
- +
"# .to_owned()