feat(wasm): execute client handlers without requests

req: client_local/001\nreq: client_local/004\nreq: client_local/005\nreq: client_local/009\nreq: client_local/010\nreq: client_local/014\nreq: v1_release/001
This commit is contained in:
slhx agent
2026-07-13 12:33:54 +02:00
parent 0d49007c61
commit 40643e360d
16 changed files with 515 additions and 25 deletions
Generated
+35 -15
View File
@@ -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",
+1 -1
View File
@@ -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"
+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:** 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
+17
View File
@@ -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" }
+5
View File
@@ -0,0 +1,5 @@
fn main() {
hemx_build::app()
.run()
.expect("compile client-local template");
}
+7
View File
@@ -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")
}
@@ -0,0 +1,4 @@
<main data-hemx-root="client_local">
<section data-hemx-slot="counter_panel">idle</section>
<button type="button" data-hemx-handle="increment" data-hemx-client="increment">Increment locally</button>
</main>
+29 -1
View File
@@ -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<T> SlotTarget<T, ()> {{\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<T> SlotTarget<T, {child_prefix}{child}::Component> {{\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<K, T>) -> 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<T> 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<T> 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<T>(slot: ::hemx::advanced::Slot<T>, 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<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, 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<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, 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<K, T>(slot: ::hemx::advanced::KeyedSlot<K, T>, 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,
+53 -2
View File
@@ -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<u8> {
::hemx::wasm::encode_handler_effect(
super::#function_name(),
crate::ui::BUILD_FINGERPRINT,
)
}
}
)
.into()
}
#[proc_macro_attribute]
+1
View File
@@ -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<Uint8Array>): void;
}
declare global {
+29 -2
View File
@@ -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();
+15
View File
@@ -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"] }
+42
View File
@@ -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<u8> {
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
}
}
+248
View File
@@ -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<AtomicBool>,
}
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#"<!doctype html><html><body>
<main data-hemx-root="client_local">
<section data-hemx-slot="counter_panel">idle</section>
<button type="button" data-hid="1" data-hemx-client="increment">Increment locally</button>
</main>
<script src="/hemx.js"></script>
<script type="module">
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 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.__clientReady = true;
</script></body></html>"#
.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<u64> {
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();
}
}
+8 -1
View File
@@ -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" }
+19 -1
View File
@@ -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<T> RenderSlotExt for Slot<T> {
fn render(self, view: &impl hemplate::Hemplate) -> Effect {
self.html(render_template(view))
@@ -130,6 +146,7 @@ impl<T> RenderSlotExt for Slot<T> {
///
/// 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<K> {
fn append(self, key: K, view: &impl hemplate::Hemplate) -> Effect;
@@ -158,6 +175,7 @@ pub trait RenderKeyedSlotExt<K> {
}
}
#[cfg(not(target_arch = "wasm32"))]
impl<K, T> RenderKeyedSlotExt<K> for KeyedSlot<K, T>
where
K: ToString,