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:
Generated
+35
-15
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" }
|
||||
@@ -0,0 +1,5 @@
|
||||
fn main() {
|
||||
hemx_build::app()
|
||||
.run()
|
||||
.expect("compile client-local template");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Vendored
+1
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user