diff --git a/Cargo.lock b/Cargo.lock index bffbc00..caed078 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -486,6 +486,7 @@ dependencies = [ "hemplate", "hemx-core", "hemx-derive", + "hemx-wasm", ] [[package]] @@ -510,6 +511,15 @@ dependencies = [ "syn", ] +[[package]] +name = "hemx-client-local-example" +version = "0.1.0" +dependencies = [ + "hemplate", + "hemx", + "hemx-build", +] + [[package]] name = "hemx-core" version = "0.1.0" @@ -634,6 +644,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "hemx-wasm" +version = "0.1.0" +dependencies = [ + "hemx-core", + "thirtyfour 0.36.1", + "tokio", + "wasm-bindgen", +] + [[package]] name = "hemx-workout-example" version = "0.1.0" @@ -900,13 +920,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1773,6 +1792,7 @@ dependencies = [ "serde_json", "serde_repr", "stringmatch", + "thirtyfour-macros", "thiserror 2.0.18", "tokio", "tracing", @@ -2090,9 +2110,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ "cfg-if", "once_cell", @@ -2103,9 +2123,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ "js-sys", "wasm-bindgen", @@ -2113,9 +2133,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2123,9 +2143,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", @@ -2136,18 +2156,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index e87179c..9e2659d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/techdemo", "examples/saas", "examples/workout"] +members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-wasm", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/client_local", "examples/techdemo", "examples/saas", "examples/workout"] [workspace.package] version = "0.1.0" diff --git a/PLAN.md b/PLAN.md index d32be8a..a390952 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:** Ready. +- **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. - **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:** real WASM/browser test visibly updates a generated target, network capture remains empty, invalid event/state restores pending UI with a diagnostic, server handlers are unchanged, and formatting/workspace tests/strict all-target Clippy/wasm-target checks pass. +- **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. ## Slice 2 — direct manipulation that survives interruption diff --git a/examples/client_local/Cargo.toml b/examples/client_local/Cargo.toml new file mode 100644 index 0000000..5382daa --- /dev/null +++ b/examples/client_local/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "hemx-client-local-example" +version.workspace = true +edition.workspace = true +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +hemx = { path = "../../hemx", features = ["client"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +hemplate = { path = "../../../hemplate/hemplate" } + +[build-dependencies] +hemx-build = { path = "../../hemx-build" } diff --git a/examples/client_local/build.rs b/examples/client_local/build.rs new file mode 100644 index 0000000..01192a4 --- /dev/null +++ b/examples/client_local/build.rs @@ -0,0 +1,5 @@ +fn main() { + hemx_build::app() + .run() + .expect("compile client-local template"); +} diff --git a/examples/client_local/src/lib.rs b/examples/client_local/src/lib.rs new file mode 100644 index 0000000..32cf6dc --- /dev/null +++ b/examples/client_local/src/lib.rs @@ -0,0 +1,7 @@ +#[hemx::surface] +pub mod ui {} + +#[hemx::handler(client)] +pub fn increment() -> impl hemx::IntoEffect { + ui::client_local::counter_panel.text("updated by Rust/WASM") +} diff --git a/examples/client_local/templates/client_local.heml b/examples/client_local/templates/client_local.heml new file mode 100644 index 0000000..bb9991a --- /dev/null +++ b/examples/client_local/templates/client_local.heml @@ -0,0 +1,4 @@ +
+
idle
+ +
diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index a93eb1b..02a164d 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -649,6 +649,7 @@ impl Resources { out.push_str(&format!("{inner} pub fn text(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); out.push_str(&format!("{inner} pub fn set(self, value: impl ::std::string::ToString) -> ::hemx::advanced::Effect {{ self.slot.text(value) }}\n")); out.push_str(&format!("{inner}}}\n")); + out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{inner}impl SlotTarget {{\n")); out.push_str(&format!("{inner} pub fn put(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); out.push_str(&format!("{inner} pub fn replace(self, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{ super::put(self.slot, view) }}\n")); @@ -660,6 +661,7 @@ impl Resources { .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) { let child = &res.ident; + out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!( "{inner}impl SlotTarget {{\n" )); @@ -681,6 +683,7 @@ impl Resources { out.push_str(&format!("{inner}{{\n")); out.push_str(&format!("{inner} const fn new(slot: ::hemx::advanced::KeyedSlot) -> Self {{ Self {{ slot, _marker: ::std::marker::PhantomData }} }}\n")); out.push_str(&format!("{inner}}}\n")); + out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!( "{inner}impl KeyedSlotTarget<::std::string::String, T, ()> {{\n" )); @@ -700,6 +703,7 @@ impl Resources { .filter(|res| components.contains(&res.ident) && Some(res.ident.as_str()) != component) { let child = &res.ident; + out.push_str(&format!("{inner}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!( "{inner}impl KeyedSlotTarget<::std::string::String, T, {child_prefix}{child}::Component> {{\n" )); @@ -948,6 +952,7 @@ impl Resources { "{pad} ::hemx::__private::html_trusted(lower(html))\n" )); out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!( "{pad}pub fn render(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{\n" @@ -961,14 +966,17 @@ impl Resources { )); out.push_str(&format!("{pad}}}\n\n")); out.push_str(&format!( - "{pad}pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n" + "{pad}#[cfg(not(target_arch = \"wasm32\"))]\n{pad}pub fn page(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n" )); + out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}pub fn render_html(view: &impl ::hemplate::Hemplate) -> ::hemx::Html {{ render(view) }}\n\n")); + out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}pub fn put(slot: ::hemx::advanced::Slot, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect {{\n")); out.push_str(&format!("{pad} slot.html(render(view))\n")); out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}pub fn append(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); out.push_str(&format!("{pad}where\n")); @@ -976,6 +984,7 @@ impl Resources { out.push_str(&format!("{pad}{{\n")); out.push_str(&format!("{pad} slot.append_html(key, render(view))\n")); out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}pub fn prepend(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); out.push_str(&format!("{pad}where\n")); @@ -983,6 +992,7 @@ impl Resources { out.push_str(&format!("{pad}{{\n")); out.push_str(&format!("{pad} slot.prepend_html(key, render(view))\n")); out.push_str(&format!("{pad}}}\n\n")); + out.push_str(&format!("{pad}#[cfg(not(target_arch = \"wasm32\"))]\n")); out.push_str(&format!("{pad}#[doc(hidden)]\n")); out.push_str(&format!("{pad}pub fn replace(slot: ::hemx::advanced::KeyedSlot, key: K, view: &impl ::hemplate::Hemplate) -> ::hemx::advanced::Effect\n")); out.push_str(&format!("{pad}where\n")); @@ -1597,6 +1607,8 @@ fn known_hemx_attr(name: &str) -> bool { | "data-hemx-atom" | "data-hemx-key" | "data-hemx-on" + | "data-hemx-client" + | "data-hemx-client-event" | "data-hemx-pending-class" | "data-hemx-indicator" | "data-hemx-confirm" @@ -1640,6 +1652,22 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i "expected `push`, `replace`, or empty for the default push behavior", )); } + "data-hemx-client" if value.trim().is_empty() => { + return Err(invalid_hemx_value( + path, + &attr.name, + value, + "expected a non-empty client handler name", + )); + } + "data-hemx-client-event" if !valid_event_list(value) => { + return Err(invalid_hemx_value( + path, + &attr.name, + value, + "expected a runtime-supported event", + )); + } "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 3d867e8..2a644d6 100644 --- a/hemx-derive/src/lib.rs +++ b/hemx-derive/src/lib.rs @@ -10,7 +10,20 @@ use syn::{ }; #[proc_macro_attribute] -pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream { +pub fn handler(attr: TokenStream, item: TokenStream) -> TokenStream { + let placement = attr.to_string(); + let is_client = match placement.as_str() { + "" => false, + "client" => true, + _ => { + return syn::Error::new( + proc_macro2::Span::call_site(), + "unsupported hemx handler placement; expected #[hemx::handler] or #[hemx::handler(client)]", + ) + .into_compile_error() + .into(); + } + }; let function = parse_macro_input!(item as ItemFn); let name = function.sig.ident.to_string(); @@ -73,7 +86,45 @@ pub fn handler(_attr: TokenStream, item: TokenStream) -> TokenStream { .into(); } - quote!(#function).into() + if !is_client { + return quote!(#function).into(); + } + if !function.sig.inputs.is_empty() + || 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" + ); + return quote!( + #function + compile_error!(#message); + ) + .into(); + } + + 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}"); + quote!( + #function + + #[cfg(target_arch = "wasm32")] + mod #export_module { + 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, + ) + } + } + ) + .into() } #[proc_macro_attribute] diff --git a/hemx-js/runtime/hemx.d.ts b/hemx-js/runtime/hemx.d.ts index b7fc03e..a812615 100644 --- a/hemx-js/runtime/hemx.d.ts +++ b/hemx-js/runtime/hemx.d.ts @@ -54,6 +54,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; } declare global { diff --git a/hemx-js/runtime/hemx.js b/hemx-js/runtime/hemx.js index 82ff5d9..f059803 100644 --- a/hemx-js/runtime/hemx.js +++ b/hemx-js/runtime/hemx.js @@ -18,6 +18,7 @@ const sseSources = new WeakMap(); const atomStores = new WeakMap(); const dragKeys = new WeakMap(); + const clientHandlers = new Map(); function roots() { const found = []; @@ -237,6 +238,15 @@ }); } + async function runClient(el) { + const name = el.getAttribute("data-hemx-client"); + 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)); + } + async function send(el, eventName, source = el) { if (el.getAttribute("data-hemx-confirm") && !confirm(el.getAttribute("data-hemx-confirm"))) return; const { form, data, multipart } = formDataFor(el, eventName, source); @@ -671,7 +681,11 @@ const direct = closestInRoot(event.target, root, (el) => el.hasAttribute(HID)); if (direct && defaultEvent(direct) === "click") { event.preventDefault(); - schedule(direct, name); + if (direct.hasAttribute("data-hemx-client")) { + runClient(direct).catch((error) => emit(root, "hemx:client-error", String(error))); + } else { + schedule(direct, name); + } return; } } @@ -935,7 +949,20 @@ if (root) navigateUrl(location.href, root, "none").catch((error) => emit(root, "hemx:error", String(error))); }); - window.hemx = Object.freeze({ runtimeAbiVersion, roots, rootOf, applyHtml, applyBatch, decodeBatch, atomValue, decodeAtomState }); + window.hemx = Object.freeze({ + runtimeAbiVersion, + roots, + rootOf, + applyHtml, + applyBatch, + decodeBatch, + atomValue, + decodeAtomState, + registerClientHandler(name, handler) { + if (!name || typeof handler !== "function") throw new Error("client handler registration requires a name and function"); + clientHandlers.set(name, handler); + }, + }); if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start); else start(); diff --git a/hemx-wasm/Cargo.toml b/hemx-wasm/Cargo.toml new file mode 100644 index 0000000..f494b1e --- /dev/null +++ b/hemx-wasm/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hemx-wasm" +version.workspace = true +edition.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +hemx-core = { path = "../hemx-core" } +wasm-bindgen = "=0.2.125" + +[dev-dependencies] +thirtyfour = "0.36" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/hemx-wasm/src/lib.rs b/hemx-wasm/src/lib.rs new file mode 100644 index 0000000..41f1116 --- /dev/null +++ b/hemx-wasm/src/lib.rs @@ -0,0 +1,42 @@ +//! Opt-in browser boundary for client-local hemx handlers. +//! +//! Application code reaches this crate through the `hemx` `client` feature and +//! `#[hemx::handler(client)]`; server-first applications do not depend on it. + +use hemx_core::{BuildFingerprint, IntoEffect}; + +#[doc(hidden)] +pub use wasm_bindgen::prelude::wasm_bindgen; +#[doc(hidden)] +pub use wasm_bindgen::*; + +/// Encodes a client handler result with the ordinary hemx effect wire format. +/// +/// Keeping this conversion here gives generated WASM exports one ABI boundary +/// instead of teaching the proc macro a second effect protocol. +#[doc(hidden)] +pub fn encode_handler_effect(effect: impl IntoEffect, fingerprint: BuildFingerprint) -> Vec { + effect.into_batch(fingerprint).to_wire() +} + +#[cfg(test)] +mod tests { + use super::encode_handler_effect; + use hemx_core::{BuildFingerprint, EffectBatch, Slot}; + + #[test] + fn client_handler_uses_the_ordinary_effect_wire_format() { + let fingerprint = BuildFingerprint(17); + let effect = Slot::<()>::new(4).text("local"); + let wire = encode_handler_effect(effect.clone(), fingerprint); + + assert_eq!( + EffectBatch::from_wire(&wire).expect("decode client effect"), + EffectBatch { + abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION, + fingerprint, + ops: vec![effect], + } + ); // req: client_local/005 req: client_local/009 + } +} diff --git a/hemx-wasm/tests/browser.rs b/hemx-wasm/tests/browser.rs new file mode 100644 index 0000000..7a0ebd2 --- /dev/null +++ b/hemx-wasm/tests/browser.rs @@ -0,0 +1,248 @@ +use std::fs; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; +use thirtyfour::prelude::*; + +const WEBDRIVER_ADDR: &str = "127.0.0.1:4451"; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); + +#[tokio::test] +async fn client_handler_applies_effect_batch_without_network() -> WebDriverResult<()> { + // req: client_local/005 req: client_local/009 req: client_local/010 + // test: client_local/014 + let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_owned(); + build_browser_artifact(&workspace); + let package = workspace.join("target/client-local-bindgen"); + let runtime = workspace.join("hemx-js/runtime/hemx.js"); + let server = StaticServer::start(package, runtime); + + let mut webdriver = Command::new("geckodriver"); + webdriver.arg("--port").arg("4451"); + let _webdriver = ProcessGuard::start(webdriver, WEBDRIVER_ADDR); + + let mut caps = DesiredCapabilities::firefox(); + caps.set_headless()?; + 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?; + let network_before = resource_count(&driver).await?; + + driver + .find(By::Css("[data-hemx-client='increment']")) + .await? + .click() + .await?; + wait_for_text( + &driver, + "[data-hemx-slot='counter_panel']", + "updated by Rust/WASM", + ) + .await?; + + assert_eq!( + resource_count(&driver).await?, + network_before, + "client handler made a network request" + ); + Ok::<(), WebDriverError>(()) + } + .await; + let quit = driver.quit().await; + result.and(quit) +} + +fn build_browser_artifact(workspace: &Path) { + let status = Command::new("cargo") + .current_dir(workspace) + .args([ + "build", + "-p", + "hemx-client-local-example", + "--target", + "wasm32-unknown-unknown", + ]) + .status() + .expect("run wasm cargo build"); + assert!(status.success(), "WASM build failed"); + + let output = workspace.join("target/client-local-bindgen"); + fs::create_dir_all(&output).expect("create wasm-bindgen output"); + let status = Command::new("wasm-bindgen") + .current_dir(workspace) + .arg("--target") + .arg("web") + .arg("--out-name") + .arg("client_local") + .arg("--out-dir") + .arg(&output) + .arg(workspace.join("target/wasm32-unknown-unknown/debug/hemx_client_local_example.wasm")) + .status() + .expect("run wasm-bindgen"); + assert!(status.success(), "wasm-bindgen failed"); +} + +struct StaticServer { + address: String, + stop: Arc, +} + +impl StaticServer { + fn start(package: PathBuf, runtime: PathBuf) -> 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(); + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&stop); + thread::spawn(move || { + while !thread_stop.load(Ordering::Relaxed) { + match listener.accept() { + Ok((stream, _)) => serve(stream, &package, &runtime), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)) + } + Err(error) => panic!("fixture accept failed: {error}"), + } + } + }); + Self { address, stop } + } + + fn url(&self) -> String { + format!("http://{}", self.address) + } +} + +impl Drop for StaticServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + } +} + +fn serve(mut stream: TcpStream, package: &Path, runtime: &Path) { + 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()), + "/hemx.js" => ( + "text/javascript; charset=utf-8", + fs::read(runtime).expect("read runtime"), + ), + "/client_local.js" => ( + "text/javascript; charset=utf-8", + fs::read(package.join("client_local.js")).expect("read bindings"), + ), + "/client_local_bg.wasm" => ( + "application/wasm", + fs::read(package.join("client_local_bg.wasm")).expect("read wasm"), + ), + _ => ("text/plain", b"not found".to_vec()), + }; + let status = if path == "/" || path == "/hemx.js" || path.starts_with("/client_local") { + "200 OK" + } else { + "404 Not Found" + }; + write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).expect("write fixture headers"); + stream.write_all(&body).expect("write fixture body"); +} + +fn fixture_html() -> String { + r#" +
+
idle
+ +
+ +"# + .to_owned() +} + +async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> { + let deadline = Instant::now() + STARTUP_TIMEOUT; + loop { + if driver + .execute(script, Vec::new()) + .await? + .json() + .as_bool() + .unwrap_or(false) + { + return Ok(()); + } + if Instant::now() >= deadline { + panic!("timed out waiting for browser fixture"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +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( + "return performance.getEntriesByType('resource').length", + Vec::new(), + ) + .await? + .json() + .as_u64() + .unwrap_or_default()) +} + +struct ProcessGuard(std::process::Child); + +impl ProcessGuard { + fn start(mut command: Command, address: &str) -> Self { + let child = command.spawn().expect("start geckodriver"); + let deadline = Instant::now() + STARTUP_TIMEOUT; + while std::net::TcpStream::connect(address).is_err() { + assert!(Instant::now() < deadline, "timed out starting geckodriver"); + thread::sleep(Duration::from_millis(50)); + } + Self(child) + } +} + +impl Drop for ProcessGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} diff --git a/hemx/Cargo.toml b/hemx/Cargo.toml index 388de76..af02f42 100644 --- a/hemx/Cargo.toml +++ b/hemx/Cargo.toml @@ -10,7 +10,14 @@ path = "src/lib.rs" name = "render" harness = false +[features] +default = [] +client = ["dep:hemx-wasm"] + [dependencies] -hemplate = { path = "../../hemplate/hemplate" } hemx-core = { path = "../hemx-core" } hemx-derive = { path = "../hemx-derive" } +hemx-wasm = { path = "../hemx-wasm", optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +hemplate = { path = "../../hemplate/hemplate" } diff --git a/hemx/src/lib.rs b/hemx/src/lib.rs index a18258a..a205f50 100644 --- a/hemx/src/lib.rs +++ b/hemx/src/lib.rs @@ -3,7 +3,9 @@ //! Most application code should depend on this crate, use the proc-macros from //! here, and import generated resources through `#[hemx::surface]`. -use hemx_core::{Effect, KeyedSlot, SafeHtml, Slot}; +use hemx_core::SafeHtml; +#[cfg(not(target_arch = "wasm32"))] +use hemx_core::{Effect, KeyedSlot, Slot}; pub use hemx_core::{ navigate, push, redirect, replace, CssClass, CssClasses, Form, FormContract, FormControlKind, @@ -19,6 +21,14 @@ pub use hemx_core::{ pub use hemx_core::{Atom, ComponentRef, EventName, GeneratedTarget, Handle, ParamName}; pub use hemx_derive::{app, component, form, handler, surface}; +/// Browser/WASM integration used by `#[hemx::handler(client)]`. +/// +/// The feature is opt-in so server-first applications do not compile or ship +/// WASM dependencies. req: client_local/001 req: client_local/004 +#[cfg(feature = "client")] +#[doc(hidden)] +pub use hemx_wasm as wasm; + /// Advanced/raw hemx primitives used by generated code, integrations, and tests. /// /// Beginner-facing application code should prefer generated targets, generated @@ -26,6 +36,7 @@ pub use hemx_derive::{app, component, form, handler, surface}; pub mod advanced { pub use hemx_core::*; + #[cfg(not(target_arch = "wasm32"))] pub fn render(view: &impl hemplate::Hemplate) -> crate::Html { crate::render_template(view) } @@ -79,6 +90,7 @@ pub mod __private { } } +#[cfg(not(target_arch = "wasm32"))] fn render_template(view: &impl hemplate::Hemplate) -> Html { let mut html = String::with_capacity(view.size_hint()); view.render_into(&mut html) @@ -86,6 +98,7 @@ fn render_template(view: &impl hemplate::Hemplate) -> Html { __private::html_trusted(html) } +#[cfg(not(target_arch = "wasm32"))] pub fn page(view: &impl hemplate::Hemplate) -> Html { render_template(view) } @@ -99,6 +112,7 @@ pub trait KeyedPartial { fn hemx_key(&self) -> String; } +#[cfg(not(target_arch = "wasm32"))] #[doc(hidden)] pub fn render_html(view: &impl hemplate::Hemplate) -> Html { render_template(view) @@ -108,6 +122,7 @@ pub fn render_html(view: &impl hemplate::Hemplate) -> Html { /// /// Prefer generated target objects such as `targets::list.put(&view)` so /// generated resource lowering stays attached to the view boundary. req: dx/006 +#[cfg(not(target_arch = "wasm32"))] #[doc(hidden)] pub trait RenderSlotExt { fn render(self, view: &impl hemplate::Hemplate) -> Effect; @@ -120,6 +135,7 @@ pub trait RenderSlotExt { } } +#[cfg(not(target_arch = "wasm32"))] impl RenderSlotExt for Slot { fn render(self, view: &impl hemplate::Hemplate) -> Effect { self.html(render_template(view)) @@ -130,6 +146,7 @@ impl RenderSlotExt for Slot { /// /// Prefer generated target objects such as `targets::row.append(&view)` so /// generated resource lowering stays attached to the view boundary. req: dx/006 +#[cfg(not(target_arch = "wasm32"))] #[doc(hidden)] pub trait RenderKeyedSlotExt { fn append(self, key: K, view: &impl hemplate::Hemplate) -> Effect; @@ -158,6 +175,7 @@ pub trait RenderKeyedSlotExt { } } +#[cfg(not(target_arch = "wasm32"))] impl RenderKeyedSlotExt for KeyedSlot where K: ToString,