diff --git a/Cargo.lock b/Cargo.lock index caed078..49a41d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,6 +508,7 @@ dependencies = [ "hemplate-core", "hemx-core", "quote", + "serde_json", "syn", ] diff --git a/PLAN.md b/PLAN.md index b3435c7..735ce59 100644 --- a/PLAN.md +++ b/PLAN.md @@ -14,12 +14,12 @@ 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. 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. +- [x] **User value:** a Rust author marks one high-frequency handler local and gets immediate browser behavior without app-authored JavaScript or a request. +- **State:** Done. `hemx-build` validates same-origin client module metadata and generates the WASM import, initialization, handler registration, fingerprint, and ready marker. Client handlers 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 applies the ordinary generated-target `EffectBatch` with zero request on valid input. - **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, 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. +- **Proof:** `cargo test -p hemx-wasm --test browser client_handler_applies_effect_batch_without_network -- --exact` serves only generated template HTML, the ordinary runtime, and generated client bootstrap; it 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. Formatting, workspace tests, strict all-target Clippy, and wasm-target build pass. ## Slice 2 — direct manipulation that survives interruption diff --git a/examples/client_local/Cargo.toml b/examples/client_local/Cargo.toml index 5382daa..c7d2a27 100644 --- a/examples/client_local/Cargo.toml +++ b/examples/client_local/Cargo.toml @@ -4,9 +4,18 @@ version.workspace = true edition.workspace = true publish = false +[features] +default = [] +fixture = [] + [lib] crate-type = ["cdylib", "rlib"] +[[bin]] +name = "fixture" +path = "src/bin/fixture.rs" +required-features = ["fixture"] + [dependencies] hemx = { path = "../../hemx", features = ["client"] } diff --git a/examples/client_local/src/bin/fixture.rs b/examples/client_local/src/bin/fixture.rs new file mode 100644 index 0000000..7314ca9 --- /dev/null +++ b/examples/client_local/src/bin/fixture.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", hemx_client_local_example::render_fixture()); +} diff --git a/examples/client_local/src/lib.rs b/examples/client_local/src/lib.rs index 2e38576..382a7b9 100644 --- a/examples/client_local/src/lib.rs +++ b/examples/client_local/src/lib.rs @@ -1,6 +1,15 @@ #[hemx::surface] pub mod ui {} +#[cfg(not(target_arch = "wasm32"))] +#[derive(hemplate::Hemplate)] +pub struct ClientLocal; + +#[cfg(not(target_arch = "wasm32"))] +pub fn render_fixture() -> hemx::Html { + ui::client_local::render(&ClientLocal) +} + #[hemx::handler(client)] pub fn increment( event: hemx::wasm::ClientEvent, diff --git a/examples/client_local/templates/client_local.heml b/examples/client_local/templates/client_local.heml index 508b70d..365a6d9 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/Cargo.toml b/hemx-build/Cargo.toml index 7ba299b..bcee7f0 100644 --- a/hemx-build/Cargo.toml +++ b/hemx-build/Cargo.toml @@ -10,4 +10,5 @@ path = "src/lib.rs" hemplate-core = { path = "../../hemplate/hemplate-core", features = ["surface"] } hemx-core = { path = "../hemx-core" } quote = "1" +serde_json = "1" syn = { version = "2", features = ["full"] } diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index 0ce8794..5244edc 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -228,6 +228,10 @@ impl AppBuilder { resources.generated_rs(self.global_exports).as_bytes(), )?; write_if_changed(&out_dir.join("hemx.syms"), resources.syms().as_bytes())?; + write_if_changed( + &out_dir.join("hemx.client.js"), + resources.client_bootstrap()?.as_bytes(), + )?; Ok(()) } } @@ -262,6 +266,8 @@ struct Resources { atoms: BTreeMap, classes: BTreeMap, events: BTreeMap, + client_handlers: BTreeSet, + client_modules: BTreeSet, } #[derive(Clone, Debug)] @@ -334,6 +340,21 @@ impl Resources { } } + if let Some(handler) = static_attr(&node.attrs, "data-hemx-client") { + let Some(handler) = rust_ident(&handler) else { + return Err(invalid_hemx_value( + path, + "data-hemx-client", + &handler, + "expected a Rust handler identifier", + )); + }; + self.client_handlers.insert(handler); + } + if let Some(module) = static_attr(&node.attrs, "data-hemx-client-module") { + self.client_modules.insert(module); + } + if let Some(name) = static_attr(&node.attrs, "data-hemx-slot") { reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?; let keyed = is_inside_keyed_for(surface, node.scope) @@ -1163,6 +1184,50 @@ fn __hemx_attr(tag: &str, attr: &str) -> Option<::std::string::String> { components } + fn client_bootstrap(&self) -> io::Result { + if self.client_handlers.is_empty() { + return Ok(String::new()); + } + let module = match self.client_modules.len() { + 1 => self.client_modules.iter().next().expect("one module"), + 0 => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "client-local handlers require one data-hemx-client-module on a hemx root", + )); + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "client-local handlers must share one data-hemx-client-module per generated application", + )); + } + }; + let exports = self + .client_handlers + .iter() + .map(|handler| format!("__hemx_client_{handler}")) + .collect::>(); + let mut out = format!( + "import init, {{ {} }} from {};\nawait init();\n", + exports.join(", "), + serde_json::to_string(module).expect("serialize client module") + ); + for handler in &self.client_handlers { + out.push_str(&format!( + "window.hemx.registerClientHandler({}, __hemx_client_{});\n", + serde_json::to_string(handler).expect("serialize client handler"), + handler + )); + } + let fingerprint = hemx_core::BuildFingerprint::from_parts(&self.fingerprint_parts()).0; + out.push_str(&format!( + "document.querySelectorAll('[data-hemx-root]').forEach((root) => {{ root.setAttribute('data-hemx-build', '{}'); root.setAttribute('data-hemx-client-ready', ''); }});\n", + fingerprint + )); + Ok(out) + } + fn syms(&self) -> String { let mut out = String::from("hemx-syms-v1\n"); for res in self.slots.values() { @@ -1610,6 +1675,7 @@ fn known_hemx_attr(name: &str) -> bool { | "data-hemx-client" | "data-hemx-client-event" | "data-hemx-client-fallback" + | "data-hemx-client-module" | "data-hemx-client-state-version" | "data-hemx-pending-class" | "data-hemx-indicator" @@ -1662,6 +1728,14 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i "expected a non-empty client handler name", )); } + "data-hemx-client-module" if !valid_client_module(value) => { + return Err(invalid_hemx_value( + path, + &attr.name, + value, + "expected a same-origin module specifier beginning with `/`, `./`, or `../`", + )); + } "data-hemx-client-event" if !valid_event_list(value) => { return Err(invalid_hemx_value( path, @@ -1748,6 +1822,13 @@ fn reject_invalid_hemx_attr_placement( "expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms", )); } + if has_attr(attrs, "data-hemx-client-module") && !has_attr(attrs, "data-hemx-root") { + return Err(invalid_hemx_placement( + path, + "data-hemx-client-module", + "expected placement on the same element as `data-hemx-root`", + )); + } if has_attr(attrs, "data-hemx-sse") && !has_attr(attrs, "data-hemx-root") { return Err(invalid_hemx_placement( path, @@ -1776,6 +1857,14 @@ fn valid_policy(value: &str) -> bool { matches!(value.trim(), "latest" | "queue" | "drop" | "parallel") } +fn valid_client_module(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() + && !value.starts_with("//") + && !value.contains(':') + && (value.starts_with('/') || value.starts_with("./") || value.starts_with("../")) +} + fn valid_event_list(value: &str) -> bool { let mut events = event_tokens(value).peekable(); events.peek().is_some() && events.all(valid_runtime_event) diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs index efa4a92..d1915a8 100644 --- a/hemx-wasm/tests/browser.rs +++ b/hemx-wasm/tests/browser.rs @@ -20,10 +20,9 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul .parent() .expect("workspace root") .to_owned(); - build_browser_artifact(&workspace); - let package = workspace.join("target/client-local-bindgen"); + let (package, bootstrap, rendered) = build_browser_artifact(&workspace); let runtime = workspace.join("hemx-js/runtime/hemx.js"); - let server = StaticServer::start(package, runtime); + let server = StaticServer::start(package, runtime, bootstrap, rendered); let mut webdriver = Command::new("geckodriver"); webdriver.arg("--port").arg("4451"); @@ -34,7 +33,17 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul let driver = WebDriver::new(&format!("http://{WEBDRIVER_ADDR}"), caps).await?; let result = async { driver.goto(&server.url()).await?; - wait_until(&driver, "return window.__clientReady === true").await?; + wait_until( + &driver, + "return document.querySelector('[data-hemx-root]').hasAttribute('data-hemx-client-ready')", + ) + .await?; + driver + .execute( + "window.__clientErrors = []; document.querySelector('[data-hemx-root]').addEventListener('hemx:client-error', (event) => window.__clientErrors.push(event.detail)); return true", + Vec::new(), + ) + .await?; let network_before = resource_count(&driver).await?; driver @@ -42,10 +51,9 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul .await? .click() .await?; - wait_for_text( + wait_until( &driver, - "[data-hemx-slot='counter_panel']", - "updated by Rust/WASM (click, count=3)", + "return document.querySelector('[data-sid]').textContent.includes('updated by Rust/WASM (click, count=3)')", ) .await?; @@ -101,7 +109,7 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul result.and(quit) } -fn build_browser_artifact(workspace: &Path) { +fn build_browser_artifact(workspace: &Path) -> (PathBuf, PathBuf, String) { let status = Command::new("cargo") .current_dir(workspace) .args([ @@ -129,6 +137,45 @@ fn build_browser_artifact(workspace: &Path) { .status() .expect("run wasm-bindgen"); assert!(status.success(), "wasm-bindgen failed"); + + let rendered = Command::new("cargo") + .current_dir(workspace) + .args([ + "run", + "-q", + "-p", + "hemx-client-local-example", + "--features", + "fixture", + "--bin", + "fixture", + ]) + .output() + .expect("render generated client fixture"); + assert!(rendered.status.success(), "generated fixture render failed"); + let bootstrap = + newest_generated_bootstrap(&workspace.join("target/wasm32-unknown-unknown/debug/build")); + ( + output, + bootstrap, + String::from_utf8(rendered.stdout).expect("fixture is UTF-8"), + ) +} + +fn newest_generated_bootstrap(build_dir: &Path) -> PathBuf { + fs::read_dir(build_dir) + .expect("read wasm build directory") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("hemx-client-local-example-") + }) + .map(|entry| entry.path().join("out/hemx.client.js")) + .filter(|path| path.is_file()) + .max_by_key(|path| path.metadata().and_then(|meta| meta.modified()).ok()) + .expect("generated hemx client bootstrap") } struct StaticServer { @@ -137,7 +184,7 @@ struct StaticServer { } impl StaticServer { - fn start(package: PathBuf, runtime: PathBuf) -> Self { + fn start(package: PathBuf, runtime: PathBuf, bootstrap: PathBuf, rendered: String) -> Self { let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture"); listener.set_nonblocking(true).expect("nonblocking fixture"); let address = listener.local_addr().expect("fixture address").to_string(); @@ -146,7 +193,7 @@ impl StaticServer { thread::spawn(move || { while !thread_stop.load(Ordering::Relaxed) { match listener.accept() { - Ok((stream, _)) => serve(stream, &package, &runtime), + Ok((stream, _)) => serve(stream, &package, &runtime, &bootstrap, &rendered), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)) } @@ -168,13 +215,16 @@ impl Drop for StaticServer { } } -fn serve(mut stream: TcpStream, package: &Path, runtime: &Path) { +fn serve(mut stream: TcpStream, package: &Path, runtime: &Path, bootstrap: &Path, rendered: &str) { let mut request = [0_u8; 2048]; let length = stream.read(&mut request).unwrap_or(0); let first = String::from_utf8_lossy(&request[..length]); let path = first.split_whitespace().nth(1).unwrap_or("/"); let (content_type, body) = match path { - "/" => ("text/html; charset=utf-8", fixture_html().into_bytes()), + "/" => ( + "text/html; charset=utf-8", + fixture_html(rendered).into_bytes(), + ), "/hemx.js" => ( "text/javascript; charset=utf-8", fs::read(runtime).expect("read runtime"), @@ -187,9 +237,17 @@ fn serve(mut stream: TcpStream, package: &Path, runtime: &Path) { "application/wasm", fs::read(package.join("client_local_bg.wasm")).expect("read wasm"), ), + "/hemx.client.js" => ( + "text/javascript; charset=utf-8", + fs::read(bootstrap).expect("read generated client bootstrap"), + ), _ => ("text/plain", b"not found".to_vec()), }; - let status = if path == "/" || path == "/hemx.js" || path.starts_with("/client_local") { + let status = if path == "/" + || path == "/hemx.js" + || path == "/hemx.client.js" + || path.starts_with("/client_local") + { "200 OK" } else { "404 Not Found" @@ -198,28 +256,10 @@ fn serve(mut stream: TcpStream, package: &Path, runtime: &Path) { stream.write_all(&body).expect("write fixture body"); } -fn fixture_html() -> String { - r#" -
-
idle
- -
- -"# - .to_owned() +fn fixture_html(rendered: &str) -> String { + format!( + "{rendered}" + ) } async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> { @@ -241,21 +281,6 @@ async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> { } } -async fn wait_for_text(driver: &WebDriver, selector: &str, text: &str) -> WebDriverResult<()> { - let deadline = Instant::now() + STARTUP_TIMEOUT; - loop { - if let Ok(element) = driver.find(By::Css(selector)).await { - if element.text().await.unwrap_or_default().contains(text) { - return Ok(()); - } - } - if Instant::now() >= deadline { - panic!("timed out waiting for {text:?}"); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } -} - async fn resource_count(driver: &WebDriver) -> WebDriverResult { Ok(driver .execute(