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 @@
+
+
+
+
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#"
+
+
+
+
+
+"#
+ .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,