feat(build): generate client handler bootstrap

req: client_local/001\nreq: client_local/004\nreq: client_local/006\nreq: client_local/007\nreq: client_local/008\nreq: client_local/009\nreq: client_local/010\nreq: client_local/014\nreq: security/002\nreq: security/006\nreq: v1_release/001\nreq: v1_release/008
This commit is contained in:
slhx agent
2026-07-13 13:02:29 +02:00
parent 38eae79a2f
commit ac525fe572
9 changed files with 191 additions and 54 deletions
Generated
+1
View File
@@ -508,6 +508,7 @@ dependencies = [
"hemplate-core",
"hemx-core",
"quote",
"serde_json",
"syn",
]
+3 -3
View File
@@ -14,12 +14,12 @@ encryption, retention, backup, and deployment policy remain host concerns.
## Slice 1 — one real client-local handler
- [ ] **User value:** a Rust author marks one high-frequency handler local and gets immediate browser behavior without app-authored JavaScript or a request.
- **State:** In progress. Client handlers now receive versioned `ClientEvent`/root-owned `ClientState`; incompatible input is rejected before handler execution, reports an actionable `hemx:client-error`, restores pending UI, and invokes an explicitly declared server fallback. Real WASM still applies the ordinary generated-target `EffectBatch` with zero request on valid input. Generated bootstrap must replace the browser proof's manual import/registration glue before the slice is complete.
- [x] **User value:** a Rust author marks one high-frequency handler local and gets immediate browser behavior without app-authored JavaScript or a request.
- **State:** Done. `hemx-build` validates same-origin client module metadata and generates the WASM import, initialization, handler registration, fingerprint, and ready marker. Client handlers receive versioned `ClientEvent`/root-owned `ClientState`; incompatible input is rejected before handler execution, reports an actionable `hemx:client-error`, restores pending UI, and invokes an explicitly declared server fallback. Real WASM applies the ordinary generated-target `EffectBatch` with zero request on valid input.
- **Build:** add the smallest optional `hemx-wasm` boundary for `#[hemx::handler(client)]`; export only opted-in handlers; generate typed event/state ABI glue; run one existing generated-target interaction through the ordinary `EffectBatch` interpreter; preserve an explicit native/server fallback.
- **Refusals:** no VDOM, component lifecycle, global store, sync queue, second effect protocol, or generic WASM framework.
- **Requirements:** `client_local/001-010`, `security/001`, `security/005-006`, `performance/003`, `v1_release/001`.
- **Proof:** `cargo test -p hemx-wasm --test browser client_handler_applies_effect_batch_without_network -- --exact` visibly updates a generated target through real WASM, keeps the resource count unchanged for valid input, and proves invalid state diagnostics, pending restoration, and one declared fallback request. Slice completion additionally requires generated bootstrap with no app-authored JavaScript plus unchanged server handlers and formatting/workspace tests/strict all-target Clippy/wasm-target checks.
- **Proof:** `cargo test -p hemx-wasm --test browser client_handler_applies_effect_batch_without_network -- --exact` serves only generated template HTML, the ordinary runtime, and generated client bootstrap; it visibly updates a generated target through real WASM, keeps the resource count unchanged for valid input, and proves invalid state diagnostics, pending restoration, and one declared fallback request. Formatting, workspace tests, strict all-target Clippy, and wasm-target build pass.
## Slice 2 — direct manipulation that survives interruption
+9
View File
@@ -4,9 +4,18 @@ version.workspace = true
edition.workspace = true
publish = false
[features]
default = []
fixture = []
[lib]
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "fixture"
path = "src/bin/fixture.rs"
required-features = ["fixture"]
[dependencies]
hemx = { path = "../../hemx", features = ["client"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
print!("{}", hemx_client_local_example::render_fixture());
}
+9
View File
@@ -1,6 +1,15 @@
#[hemx::surface]
pub mod ui {}
#[cfg(not(target_arch = "wasm32"))]
#[derive(hemplate::Hemplate)]
pub struct ClientLocal;
#[cfg(not(target_arch = "wasm32"))]
pub fn render_fixture() -> hemx::Html {
ui::client_local::render(&ClientLocal)
}
#[hemx::handler(client)]
pub fn increment(
event: hemx::wasm::ClientEvent,
@@ -1,4 +1,4 @@
<main data-hemx-root="client_local" data-hemx-st="count=3" data-hemx-client-state-version="1">
<main data-hemx-root="client_local" data-hemx-st="count=3" data-hemx-client-state-version="1" data-hemx-client-module="/client_local.js">
<section data-hemx-slot="counter_panel">idle</section>
<button type="button" data-hemx-handle="increment" data-hemx-on="click" data-hemx-client="increment" data-hemx-client-fallback data-hemx-pending-class="is-pending">Increment locally</button>
</main>
+1
View File
@@ -10,4 +10,5 @@ path = "src/lib.rs"
hemplate-core = { path = "../../hemplate/hemplate-core", features = ["surface"] }
hemx-core = { path = "../hemx-core" }
quote = "1"
serde_json = "1"
syn = { version = "2", features = ["full"] }
+89
View File
@@ -228,6 +228,10 @@ impl AppBuilder {
resources.generated_rs(self.global_exports).as_bytes(),
)?;
write_if_changed(&out_dir.join("hemx.syms"), resources.syms().as_bytes())?;
write_if_changed(
&out_dir.join("hemx.client.js"),
resources.client_bootstrap()?.as_bytes(),
)?;
Ok(())
}
}
@@ -262,6 +266,8 @@ struct Resources {
atoms: BTreeMap<String, Resource>,
classes: BTreeMap<String, ClassToken>,
events: BTreeMap<String, EventToken>,
client_handlers: BTreeSet<String>,
client_modules: BTreeSet<String>,
}
#[derive(Clone, Debug)]
@@ -334,6 +340,21 @@ impl Resources {
}
}
if let Some(handler) = static_attr(&node.attrs, "data-hemx-client") {
let Some(handler) = rust_ident(&handler) else {
return Err(invalid_hemx_value(
path,
"data-hemx-client",
&handler,
"expected a Rust handler identifier",
));
};
self.client_handlers.insert(handler);
}
if let Some(module) = static_attr(&node.attrs, "data-hemx-client-module") {
self.client_modules.insert(module);
}
if let Some(name) = static_attr(&node.attrs, "data-hemx-slot") {
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
let keyed = is_inside_keyed_for(surface, node.scope)
@@ -1163,6 +1184,50 @@ fn __hemx_attr(tag: &str, attr: &str) -> Option<::std::string::String> {
components
}
fn client_bootstrap(&self) -> io::Result<String> {
if self.client_handlers.is_empty() {
return Ok(String::new());
}
let module = match self.client_modules.len() {
1 => self.client_modules.iter().next().expect("one module"),
0 => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"client-local handlers require one data-hemx-client-module on a hemx root",
));
}
_ => {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"client-local handlers must share one data-hemx-client-module per generated application",
));
}
};
let exports = self
.client_handlers
.iter()
.map(|handler| format!("__hemx_client_{handler}"))
.collect::<Vec<_>>();
let mut out = format!(
"import init, {{ {} }} from {};\nawait init();\n",
exports.join(", "),
serde_json::to_string(module).expect("serialize client module")
);
for handler in &self.client_handlers {
out.push_str(&format!(
"window.hemx.registerClientHandler({}, __hemx_client_{});\n",
serde_json::to_string(handler).expect("serialize client handler"),
handler
));
}
let fingerprint = hemx_core::BuildFingerprint::from_parts(&self.fingerprint_parts()).0;
out.push_str(&format!(
"document.querySelectorAll('[data-hemx-root]').forEach((root) => {{ root.setAttribute('data-hemx-build', '{}'); root.setAttribute('data-hemx-client-ready', ''); }});\n",
fingerprint
));
Ok(out)
}
fn syms(&self) -> String {
let mut out = String::from("hemx-syms-v1\n");
for res in self.slots.values() {
@@ -1610,6 +1675,7 @@ fn known_hemx_attr(name: &str) -> bool {
| "data-hemx-client"
| "data-hemx-client-event"
| "data-hemx-client-fallback"
| "data-hemx-client-module"
| "data-hemx-client-state-version"
| "data-hemx-pending-class"
| "data-hemx-indicator"
@@ -1662,6 +1728,14 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i
"expected a non-empty client handler name",
));
}
"data-hemx-client-module" if !valid_client_module(value) => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a same-origin module specifier beginning with `/`, `./`, or `../`",
));
}
"data-hemx-client-event" if !valid_event_list(value) => {
return Err(invalid_hemx_value(
path,
@@ -1748,6 +1822,13 @@ fn reject_invalid_hemx_attr_placement(
"expected a container around descendant links/forms; use `data-hemx-nav` on anchors or `data-hemx-handle` on forms",
));
}
if has_attr(attrs, "data-hemx-client-module") && !has_attr(attrs, "data-hemx-root") {
return Err(invalid_hemx_placement(
path,
"data-hemx-client-module",
"expected placement on the same element as `data-hemx-root`",
));
}
if has_attr(attrs, "data-hemx-sse") && !has_attr(attrs, "data-hemx-root") {
return Err(invalid_hemx_placement(
path,
@@ -1776,6 +1857,14 @@ fn valid_policy(value: &str) -> bool {
matches!(value.trim(), "latest" | "queue" | "drop" | "parallel")
}
fn valid_client_module(value: &str) -> bool {
let value = value.trim();
!value.is_empty()
&& !value.starts_with("//")
&& !value.contains(':')
&& (value.starts_with('/') || value.starts_with("./") || value.starts_with("../"))
}
fn valid_event_list(value: &str) -> bool {
let mut events = event_tokens(value).peekable();
events.peek().is_some() && events.all(valid_runtime_event)
+75 -50
View File
@@ -20,10 +20,9 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
.parent()
.expect("workspace root")
.to_owned();
build_browser_artifact(&workspace);
let package = workspace.join("target/client-local-bindgen");
let (package, bootstrap, rendered) = build_browser_artifact(&workspace);
let runtime = workspace.join("hemx-js/runtime/hemx.js");
let server = StaticServer::start(package, runtime);
let server = StaticServer::start(package, runtime, bootstrap, rendered);
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg("4451");
@@ -34,7 +33,17 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
let driver = WebDriver::new(&format!("http://{WEBDRIVER_ADDR}"), caps).await?;
let result = async {
driver.goto(&server.url()).await?;
wait_until(&driver, "return window.__clientReady === true").await?;
wait_until(
&driver,
"return document.querySelector('[data-hemx-root]').hasAttribute('data-hemx-client-ready')",
)
.await?;
driver
.execute(
"window.__clientErrors = []; document.querySelector('[data-hemx-root]').addEventListener('hemx:client-error', (event) => window.__clientErrors.push(event.detail)); return true",
Vec::new(),
)
.await?;
let network_before = resource_count(&driver).await?;
driver
@@ -42,10 +51,9 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
.await?
.click()
.await?;
wait_for_text(
wait_until(
&driver,
"[data-hemx-slot='counter_panel']",
"updated by Rust/WASM (click, count=3)",
"return document.querySelector('[data-sid]').textContent.includes('updated by Rust/WASM (click, count=3)')",
)
.await?;
@@ -101,7 +109,7 @@ async fn client_handler_applies_effect_batch_without_network() -> WebDriverResul
result.and(quit)
}
fn build_browser_artifact(workspace: &Path) {
fn build_browser_artifact(workspace: &Path) -> (PathBuf, PathBuf, String) {
let status = Command::new("cargo")
.current_dir(workspace)
.args([
@@ -129,6 +137,45 @@ fn build_browser_artifact(workspace: &Path) {
.status()
.expect("run wasm-bindgen");
assert!(status.success(), "wasm-bindgen failed");
let rendered = Command::new("cargo")
.current_dir(workspace)
.args([
"run",
"-q",
"-p",
"hemx-client-local-example",
"--features",
"fixture",
"--bin",
"fixture",
])
.output()
.expect("render generated client fixture");
assert!(rendered.status.success(), "generated fixture render failed");
let bootstrap =
newest_generated_bootstrap(&workspace.join("target/wasm32-unknown-unknown/debug/build"));
(
output,
bootstrap,
String::from_utf8(rendered.stdout).expect("fixture is UTF-8"),
)
}
fn newest_generated_bootstrap(build_dir: &Path) -> PathBuf {
fs::read_dir(build_dir)
.expect("read wasm build directory")
.filter_map(Result::ok)
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("hemx-client-local-example-")
})
.map(|entry| entry.path().join("out/hemx.client.js"))
.filter(|path| path.is_file())
.max_by_key(|path| path.metadata().and_then(|meta| meta.modified()).ok())
.expect("generated hemx client bootstrap")
}
struct StaticServer {
@@ -137,7 +184,7 @@ struct StaticServer {
}
impl StaticServer {
fn start(package: PathBuf, runtime: PathBuf) -> Self {
fn start(package: PathBuf, runtime: PathBuf, bootstrap: PathBuf, rendered: String) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture");
listener.set_nonblocking(true).expect("nonblocking fixture");
let address = listener.local_addr().expect("fixture address").to_string();
@@ -146,7 +193,7 @@ impl StaticServer {
thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => serve(stream, &package, &runtime),
Ok((stream, _)) => serve(stream, &package, &runtime, &bootstrap, &rendered),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10))
}
@@ -168,13 +215,16 @@ impl Drop for StaticServer {
}
}
fn serve(mut stream: TcpStream, package: &Path, runtime: &Path) {
fn serve(mut stream: TcpStream, package: &Path, runtime: &Path, bootstrap: &Path, rendered: &str) {
let mut request = [0_u8; 2048];
let length = stream.read(&mut request).unwrap_or(0);
let first = String::from_utf8_lossy(&request[..length]);
let path = first.split_whitespace().nth(1).unwrap_or("/");
let (content_type, body) = match path {
"/" => ("text/html; charset=utf-8", fixture_html().into_bytes()),
"/" => (
"text/html; charset=utf-8",
fixture_html(rendered).into_bytes(),
),
"/hemx.js" => (
"text/javascript; charset=utf-8",
fs::read(runtime).expect("read runtime"),
@@ -187,9 +237,17 @@ fn serve(mut stream: TcpStream, package: &Path, runtime: &Path) {
"application/wasm",
fs::read(package.join("client_local_bg.wasm")).expect("read wasm"),
),
"/hemx.client.js" => (
"text/javascript; charset=utf-8",
fs::read(bootstrap).expect("read generated client bootstrap"),
),
_ => ("text/plain", b"not found".to_vec()),
};
let status = if path == "/" || path == "/hemx.js" || path.starts_with("/client_local") {
let status = if path == "/"
|| path == "/hemx.js"
|| path == "/hemx.client.js"
|| path.starts_with("/client_local")
{
"200 OK"
} else {
"404 Not Found"
@@ -198,28 +256,10 @@ fn serve(mut stream: TcpStream, package: &Path, runtime: &Path) {
stream.write_all(&body).expect("write fixture body");
}
fn fixture_html() -> String {
r#"<!doctype html><html><body>
<main data-hemx-root="client_local" data-hemx-st="count=3" data-hemx-client-state-version="1">
<section data-hemx-slot="counter_panel">idle</section>
<button type="button" data-hid="1" data-hemx-on="click" data-hemx-client="increment" data-hemx-client-fallback data-hemx-pending-class="is-pending">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(1, "click", undefined, undefined, undefined, 1, "count=3");
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.__clientErrors = [];
root.addEventListener("hemx:client-error", (event) => window.__clientErrors.push(event.detail));
window.__clientReady = true;
</script></body></html>"#
.to_owned()
fn fixture_html(rendered: &str) -> String {
format!(
"<!doctype html><html><body>{rendered}<script src=\"/hemx.js\"></script><script type=\"module\" src=\"/hemx.client.js\"></script></body></html>"
)
}
async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> {
@@ -241,21 +281,6 @@ async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> {
}
}
async fn wait_for_text(driver: &WebDriver, selector: &str, text: &str) -> WebDriverResult<()> {
let deadline = Instant::now() + STARTUP_TIMEOUT;
loop {
if let Ok(element) = driver.find(By::Css(selector)).await {
if element.text().await.unwrap_or_default().contains(text) {
return Ok(());
}
}
if Instant::now() >= deadline {
panic!("timed out waiting for {text:?}");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
async fn resource_count(driver: &WebDriver) -> WebDriverResult<u64> {
Ok(driver
.execute(