feat(runtime): add portable runtime support

req: push/009 req: push/010 req: push/011
This commit is contained in:
tmk241
2026-08-17 07:59:04 +02:00
parent e4bd6db13a
commit 353174604e
19 changed files with 1018 additions and 347 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008
- The SaaS production reference must use real links/URLs for navigation and an ongoing server-owned canonical SSE stream; bounded one-event behavior is a test probe, not the public transport contract. Browser history restores saved generated page snapshots and scroll position when available, with partial-fetch fallback, and restored revealed bindings must re-arm. req: nav/001 req: nav/002 req: nav/005 req: convention/014 req: push/003 req: examples/014
- Public examples and beginner APIs should use templates plus Rust, generated component APIs, resources, view wrappers, render/page helpers, `#[hemx::app]`, plain `#[hemx::handler]` functions, and `IntoEffect`, not atoms, raw ids, selectors, wire formats, runtime opcodes, manual registries, `$OUT_DIR` includes, raw render/lower calls, raw HTML construction, imperative DOM mutation, or raw effect constructors; keep advanced layers out of starters. req: canonical_authoring/001 req: canonical_authoring/004 req: canonical_authoring/006 req: canonical_authoring/010 req: canonical_authoring/015 req: invariant/003 req: dx/001 req: dx/002 req: dx/010 req: component/003 req: component/004 req: component/005 req: view/001 req: view/002 req: view/003 req: html_safety/001 req: html_safety/003 req: html_safety/005 req: public_api/001 req: public_api/002 req: public_api/003 req: public_api/005 req: public_api/006 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003 req: derive_app/001 req: derive_app/002 req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry canonical versioned hemx `EffectBatch` bytes over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry canonical versioned hemx `EffectBatch` bytes over server-owned SSE/WebSocket transport and keep `data-hemx-sse` and `data-hemx-ws` root-scoped/same-origin by default. A Cloudflare Durable Object proof keeps stable named-object authority and persisted Rust domain state, uses WebSocket hibernation for eviction recovery, and broadcasts rendered effects without storing DOM patches or effects as domain truth. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: push/009 req: push/010 req: push/011 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
- `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
Generated
+446 -322
View File
File diff suppressed because it is too large Load Diff
+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-sync", "hemx-sync-macros", "hemx-wasm", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/client_local", "examples/techdemo", "examples/saas", "examples/workout"]
members = ["hemplate-runtime", "hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-sync", "hemx-sync-macros", "hemx-wasm", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/client_local", "examples/techdemo", "examples/saas", "examples/workout", "examples/cloudflare_do"]
[workspace.package]
version = "0.1.0"
+27 -17
View File
@@ -1,21 +1,31 @@
# Active frontier — peak idiomatic examples
# PLAN — Cloudflare Durable Objects proof
**Parent ID:** `examples-idiom/001`
Parent outcome: prove that hemx keeps its semantic `.heml` authoring, generated typed resources, compile-time target checking, canonical `EffectBatch` wire format, and tiny runtime while a Cloudflare Durable Object owns one durable collaborative room and hibernating WebSocket fan-out.
- **User value:** A newcomer can move from first app to production reference and advanced integration while seeing one coherent hemx model: plain `.heml`, generated resources, typed Rust handlers/effects, real links/forms/URLs, server-owned truth, native fallback, and explicit leaf adapters.
- **State:** Ready — the canonical starter and Workout exemplar are already strong; bounded teaching leaks remain in the HTML gallery, client-local example, SaaS reference, and advanced Kanban boundary.
- **Non-goals:** no new framework primitives, client router, VDOM, selector authoring API, reactive expression language, global client store, CSS framework/design system, generalized asset pipeline, visual redesign, or feature expansion. Do not churn `examples/workout` or `examples/techdemo` without a concrete failing contract.
- **Build:**
- **`examples-idiom/002` — Done: the SaaS reference tells the production truth.** Settings is a real `/settings` link enhanced by the existing page-swap runtime, direct `/settings` renders the fallback page, and `/events` now sends an initial canonical batch followed by ongoing server-owned status updates; `?once` remains only as a bounded production-reference probe. The removed handler no longer simulates navigation with response effects. Raw selector/form construction remains confined to test adapters because generated handles are the asserted boundary, not an app authoring API. req: canonical_authoring/001 req: nav/001 req: nav/002 req: nav/004 req: push/003 req: examples/003 req: examples/004 req: examples/014
- **`examples-idiom/003` — Make the HTML pattern gallery mechanically copyable.** In `examples/html_examples/src/main.rs`, generated `gallery` resources, and nearby templates/tests, express the dependent-select flow through typed generated values/partials instead of duplicated string-to-option mapping, and remove request fields discarded only to satisfy example plumbing where the generated handler contract permits it. Preserve each visible htmx slug, native form semantics, server-owned state, inserted-content behavior, and no-reload smoke. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: canonical_authoring/001 req: form/004
- **`examples-idiom/004` — Make client-local code visibly a leaf adapter.** In `examples/client_local/src/lib.rs` and its `.heml` surface, project the generated client event/state into a tiny typed counter-domain input/output instead of rendering raw event kind and encoded state as the example's product value. Keep the ordinary `#[hemx::handler(client)]` shape, generated event/state boundary, native event semantics, and no durable client state graph. req: client_local/001 req: client_local/003 req: client_local/004 req: canonical_authoring/017
- **`examples-idiom/005` — Separate the advanced Kanban adapter from ordinary hemx app code.** Move the cohesive sync/presence/session/storage transport responsibility from `examples/kanban/src/main.rs` behind one clearly named local integration module with a small route/state contract; keep board templates and ordinary handlers nearby and unchanged where possible. Update `examples/kanban/README.md` to label the fixture as the advanced local/offline/sync north-star, route beginners to the starter/Workout/gallery first, and name legacy `/sync-demo`/`sync.js` as a compatibility probe rather than recommended authoring. Preserve replay, export, deletion, reconnection, auth, and multiplayer browser proof. req: state/001 req: state/002 req: local/001 req: local/002 req: milestone/001 req: sync/001 req: sync/008
- **`examples-idiom/006` — Publish and enforce the example ladder.** In `README.md`, example READMEs, and the nearest existing xtask/example checks, identify `app new`/`examples/v0` as the first canonical app, `html_examples` as the pattern gallery, Workout as the product exemplar, SaaS as the production integration reference, `client_local` as the narrow leaf-adapter proof, Techdemo as exhaustive verification, and Kanban as advanced north-star integration. Add the smallest repository-owned guard that fails when beginner/reference authoring regresses to raw IDs, selectors, raw wire/effect constructors, `$OUT_DIR` includes, or app-authored DOM mutation; keep legitimate advanced/test adapters scoped rather than banning tokens globally. req: examples/001 req: examples/003 req: examples/004 req: examples/005 req: examples/007 req: examples/008 req: examples/009 req: canonical_authoring/001 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003
- **Blocked by:** none. The separate v1 legal release gate remains blocked on the owner license decision but does not block example work.
- **Proof:** each slice must make its user path observable, preserve the named native/recovery path, and pass its focused package/browser proof. Parent closure requires `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo run -p hemx-xtask -- workout test`, focused SaaS/client-local/Kanban package tests, `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `cargo fmt --check`, `redgate list`, `redgate refs`, and a clean diff. Frustration signals to reject: a beginner must author selectors/raw IDs/wire ops; navigation loses URL/history; JavaScript becomes durable truth; an example claims live/recovery behavior with a one-shot stub; or advanced sync machinery appears to be the default app model.
Non-goals: a generic Cloudflare framework, Cloudflare-owned auth policy, RPC/alarm/queue abstractions, global object discovery, offline reconciliation, deployment automation, or treating stored HTML/effects as business truth.
## Blocked release decision retained
## Slice CF-1 — Canonical WebSocket push
- [ ] **State:** Needs decision — choose the repository distribution license and approved third-party SPDX set, then add root license file(s), workspace package metadata, `deny.toml`, and rerun the release matrix.
- **Blocked by:** owner/legal authority. Current third-party set includes Apache-2.0, Apache-2.0 WITH LLVM-exception, BSD-3-Clause, BSL-1.0, MIT, Unicode-3.0, and Unlicense; all 20 workspace packages currently lack license metadata.
- **Proof:** `cargo deny check advisories sources licenses` and the full `docs/v1-readiness.md` matrix pass, then readiness changes from NO-GO to GO without publication or deployment.
Outcome: a hemx root can receive binary `EffectBatch` updates over a same-origin WebSocket with the same ABI/fingerprint and root-scoped failure behavior as HTTP/SSE.
Delta: push/001, push/003, push/005, push/009; runtime/001; failure/001.
Path: `data-hemx-ws` on generated root -> runtime WebSocket -> binary frame -> existing `applyBatch` -> generated target or root error outlet.
Build: validate the root declaration in hemx-build; add runtime bind/cleanup/error behavior and focused consumer-boundary tests.
Risk: accepting text, cross-origin, malformed, or stale-build messages could bypass the canonical compatibility boundary or mutate the wrong root.
Proof: `cargo test -p hemx-build -p hemx-js` plus `cargo check --target wasm32-unknown-unknown -p hemx`.
Non-goals: client command protocol, reconnect/replay policy beyond the browser WebSocket primitive, multiplexing, or a general transport trait.
Residual risk: a real Cloudflare hibernation journey remains for CF-2.
State: Ready.
Blocked by: none.
## Slice CF-2 — Durable room exemplar
Outcome: two browser clients in one named room see a counter mutation rendered from durable Rust state without reload, and reopening the room after object restart/eviction restores the persisted count.
Delta: push/001, push/002, push/003, push/009, push/010, push/011; canonical_authoring/001; state/001; abi/004.
Path: Worker room URL -> stable Durable Object name -> `.heml` page -> WebSocket upgrade -> typed increment command -> persisted counter -> generated counter partial -> canonical `EffectBatch` -> hibernating sockets -> both roots update.
Build: add one `examples/cloudflare_do` worker-rs exemplar with build-time hemx generation, one semantic template, one Durable Object class, Wrangler migration/binding, and focused pure tests for command/state/render output.
Risk: target-toolchain incompatibility, using an in-memory socket registry, persisting UI output instead of state, or emitting noncanonical WebSocket bytes would invalidate the proof.
Proof: native tests for command/render behavior; `cargo check --target wasm32-unknown-unknown -p hemx-cloudflare-do-example`; then `wrangler dev` browser smoke when Wrangler is available.
Non-goals: production auth/CSRF/tenancy, alarms, queues, RPC wrappers, multi-object transactions, deployment, billing, or a public `hemx-cloudflare` crate.
Residual risk: hosted Cloudflare deployment, jurisdiction policy, and production credentials remain external.
State: Ready for local build; hosted runtime proof is Blocked.
Blocked by: Wrangler runtime availability and Cloudflare account credentials for hosted verification.
+6
View File
@@ -496,6 +496,12 @@ what a valid business email is. [north_star]
008 SSE must transport canonical `EffectBatch` bytes as one unpadded base64url value in the `hemx` event data field. [north_star]
009 A root may declare one non-empty same-origin WebSocket URL with `data-hemx-ws`; the runtime accepts only binary canonical versioned `EffectBatch` messages, applies them through the normal fail-closed batch path, and reports text, malformed, incompatible, or cross-origin input through the root error outlet. [poc]
010 The Cloudflare proof keeps one stable named Durable Object as the authority for one room, persists ordinary Rust domain state in Durable Object storage, renders semantic `.heml` generated partials after mutation, and broadcasts canonical `EffectBatch` bytes rather than storing DOM patches or effects as domain truth. [poc]
011 The Cloudflare proof uses the Durable Objects WebSocket hibernation API so accepted sockets and persisted room state recover after eviction without a process-owned connection registry; malformed client messages and storage or broadcast failures remain explicit failures. [poc]
---
## sync
+2 -1
View File
@@ -9,7 +9,8 @@ description = "Workspace compatibility alias from yanked spin 0.9 to maintained
[features]
default = []
once = ["spin_next/once"]
spin_mutex = ["spin_next/spin_mutex"]
[dependencies]
spin_next = { package = "spin", version = "=0.12.2", default-features = false }
spin_next = { package = "spin", version = "=0.12.2", default-features = false, features = ["once"] }
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "hemx-cloudflare-do-example"
version.workspace = true
edition.workspace = true
publish = false
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
hemplate = { package = "hemplate-runtime", path = "../../hemplate-runtime" }
hemplate-derive = { path = "../../../hemplate/hemplate-derive" }
hemx = { path = "../../hemx" }
hemx-js = { path = "../../hemx-js" }
serde = { version = "1", features = ["derive"] }
worker = { version = "0.7.5", features = ["http", "queue"] }
[build-dependencies]
hemx-build = { path = "../../hemx-build" }
[dev-dependencies]
postcard = { version = "1", features = ["alloc"] }
+24
View File
@@ -0,0 +1,24 @@
# hemx on Cloudflare Durable Objects
This proof keeps the hemx authoring and wire model intact while Cloudflare owns room placement, persistence, and hibernating WebSockets:
```text
room.heml -> hemx-build generated resources -> Rust RoomState
-> generated counter partial -> canonical EffectBatch bytes
-> Durable Object hibernating sockets -> hemx browser runtime
```
The Durable Object stores only the counter. It never stores HTML, DOM patches, or `EffectBatch` values as domain truth.
## Local commands
```sh
cargo test -p hemx-cloudflare-do-example
cargo check --target wasm32-unknown-unknown -p hemx-cloudflare-do-example
cd examples/cloudflare_do
npx wrangler dev
```
Open the printed local URL in two tabs. Incrementing in either tab should update both without reload. Stop and restart `wrangler dev`; the room counter should remain.
A hosted deployment requires an authorized Cloudflare account. Production auth, CSRF, tenancy, jurisdiction, reconnect replay, and deployment policy are deliberately outside this proof.
+10
View File
@@ -0,0 +1,10 @@
fn main() {
let templates = std::path::PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR"),
)
.join("templates");
hemx_build::app()
.template_dir(templates)
.run()
.expect("compile cloudflare_do hemx surfaces");
}
+220
View File
@@ -0,0 +1,220 @@
use hemplate::Hemplate;
use hemplate_derive::Hemplate;
use hemx::advanced::EffectBatch;
use hemx::IntoEffect;
use serde::{Deserialize, Serialize};
use worker::*;
#[hemx::surface]
pub mod ui {}
const ROOMS_BINDING: &str = "ROOMS";
const COUNT_KEY: &str = "count";
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
struct RoomState {
count: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RoomCommand {
Increment,
}
impl RoomState {
fn apply(self, command: RoomCommand) -> Result<Self> {
match command {
RoomCommand::Increment => self
.count
.checked_add(1)
.map(|count| Self { count })
.ok_or_else(|| Error::RustError("room counter overflowed".into())),
}
}
}
#[derive(Hemplate)]
struct Room {
count: u64,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct CounterView {
count: u64,
}
fn counter_batch(state: RoomState) -> EffectBatch {
ui::room::put(
ui::room::advanced::slots::counter,
&CounterView { count: state.count },
)
.into_batch(ui::BUILD_FINGERPRINT)
}
fn room_page(state: RoomState) -> String {
ui::room::page(&Room { count: state.count }).to_string()
}
fn effect_bytes(state: RoomState) -> Result<Vec<u8>> {
Ok(counter_batch(state).to_wire())
}
fn response_bytes(bytes: Vec<u8>, content_type: &str) -> Result<Response> {
let headers = Headers::new();
headers.set("content-type", content_type)?;
Response::from_bytes(bytes).map(|response| response.with_headers(headers))
}
fn response_html(html: String) -> Result<Response> {
response_bytes(html.into_bytes(), "text/html; charset=utf-8")
}
#[event(fetch)]
pub async fn fetch(request: Request, env: Env, _ctx: Context) -> Result<Response> {
match request.path().as_str() {
"/hemx.js" => response_bytes(
hemx_js::RUNTIME_JS.as_bytes().to_vec(),
"text/javascript; charset=utf-8",
),
path if path.starts_with("/rooms/") => {
let room_name =
room_name(path).ok_or_else(|| Error::RustError("missing room name".into()))?;
let stub = env.durable_object(ROOMS_BINDING)?.get_by_name(room_name)?;
stub.fetch_with_request(request).await
}
"/" => Response::redirect("/rooms/demo".parse()?),
_ => Response::error("not found", 404),
}
}
fn room_name(path: &str) -> Option<&str> {
path.strip_prefix("/rooms/")?
.split('/')
.next()
.filter(|name| !name.is_empty())
}
#[durable_object]
pub struct DurableRoom {
state: State,
}
impl DurableObject for DurableRoom {
fn new(state: State, _env: Env) -> Self {
Self { state }
}
async fn fetch(&self, request: Request) -> Result<Response> {
match (request.method(), request.path().rsplit('/').next()) {
(Method::Get, Some("socket")) => self.accept_socket(request),
(Method::Post, Some("increment")) => self.increment().await,
(Method::Get, _) => response_html(self.page().await?),
_ => Response::error("not found", 404),
}
}
async fn websocket_message(
&self,
_ws: WebSocket,
_message: WebSocketIncomingMessage,
) -> Result<()> {
Err(Error::RustError(
"room commands use ordinary HTTP; WebSocket is server push only".into(),
))
}
async fn websocket_close(
&self,
_ws: WebSocket,
_code: usize,
_reason: String,
_was_clean: bool,
) -> Result<()> {
Ok(())
}
async fn websocket_error(&self, _ws: WebSocket, error: Error) -> Result<()> {
Err(error)
}
}
impl DurableRoom {
async fn load(&self) -> Result<RoomState> {
Ok(RoomState {
count: self.state.storage().get(COUNT_KEY).await?.unwrap_or(0),
})
}
async fn page(&self) -> Result<String> {
let body = room_page(self.load().await?);
Ok(format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Durable hemx room</title><script defer src=\"/hemx.js\"></script></head><body>{body}</body></html>"
))
}
fn accept_socket(&self, request: Request) -> Result<Response> {
if request.headers().get("upgrade")?.as_deref() != Some("websocket") {
return Response::error("expected WebSocket upgrade", 426);
}
let pair = WebSocketPair::new()?;
self.state.accept_web_socket(&pair.server);
Response::from_websocket(pair.client)
}
async fn increment(&self) -> Result<Response> {
let next = self.load().await?.apply(RoomCommand::Increment)?;
self.state.storage().put(COUNT_KEY, next.count).await?;
let bytes = effect_bytes(next)?;
let mut failures = Vec::new();
for socket in self.state.get_websockets() {
if let Err(error) = socket.send_with_bytes(bytes.clone()) {
failures.push(error.to_string());
}
}
if failures.is_empty() {
response_bytes(bytes, "application/x-hemx-effects")
} else {
Err(Error::RustError(format!(
"counter persisted but WebSocket broadcast failed: {}",
failures.join("; ")
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_updates_domain_state_without_storing_ui_output() {
// req: push/010 req: state/001
assert_eq!(
RoomState { count: 4 }
.apply(RoomCommand::Increment)
.unwrap(),
RoomState { count: 5 }
);
}
#[test]
fn generated_target_and_template_render_survive_the_cloudflare_boundary() {
// req: canonical_authoring/001 req: push/010
let state = RoomState { count: 7 };
let page = room_page(state);
assert!(page.contains("data-sid=\""), "rendered page: {page}");
assert!(page.contains("Count: 7"), "rendered page: {page}");
assert!(page.contains("data-hemx-ws=\"/rooms/demo/socket\""));
let decoded = EffectBatch::from_wire(&effect_bytes(state).unwrap()).unwrap();
assert_eq!(decoded, counter_batch(state));
}
#[test]
fn stable_room_names_are_extracted_without_inventing_global_discovery() {
assert_eq!(room_name("/rooms/demo/socket"), Some("demo"));
assert_eq!(room_name("/rooms/team-a"), Some("team-a"));
assert_eq!(room_name("/rooms/"), None);
}
}
@@ -0,0 +1 @@
<strong>Count: {+ self.count +}</strong>
@@ -0,0 +1,11 @@
<main data-hemx-root="room" data-hemx-ws="/rooms/demo/socket" data-hemx-error="room_error">
<h1>Durable hemx room</h1>
<p>One Durable Object owns this room. Open it in two tabs.</p>
<section data-hemx-slot="counter" aria-live="polite">
<strong>Count: {+ self.count +}</strong>
</section>
<form method="post" action="/rooms/demo/increment" data-hemx-handle="increment">
<button type="submit">Increment</button>
</form>
<p data-hemx-slot="room_error" role="alert"></p>
</main>
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "hemx-cloudflare-do-poc",
"main": "build/worker/shim.mjs",
"compatibility_date": "2026-08-13",
"durable_objects": {
"bindings": [
{ "name": "ROOMS", "class_name": "DurableRoom" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["DurableRoom"] }
]
}
+5
View File
@@ -0,0 +1,5 @@
[package]
name = "hemplate-runtime"
version.workspace = true
edition.workspace = true
publish = false
+131
View File
@@ -0,0 +1,131 @@
use std::fmt;
pub mod error {
use std::fmt;
#[derive(Debug)]
pub struct HemplateError;
impl fmt::Display for HemplateError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("hemplate rendering failed")
}
}
impl std::error::Error for HemplateError {}
impl From<fmt::Error> for HemplateError {
fn from(_: fmt::Error) -> Self {
Self
}
}
}
pub trait Hemplate {
fn render_into(&self, buffer: &mut String) -> Result<(), error::HemplateError>;
fn render(&self) -> String {
let mut buffer = String::new();
self.render_into(&mut buffer)
.expect("writing a hemplate String cannot fail");
buffer
}
}
pub fn render<T: Hemplate + ?Sized>(value: &T) -> Result<String, error::HemplateError> {
let mut buffer = String::new();
value.render_into(&mut buffer)?;
Ok(buffer)
}
struct HtmlEscape<T: fmt::Display>(T);
impl<T: fmt::Display> fmt::Display for HtmlEscape<T> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for character in self.0.to_string().chars() {
match character {
'&' => formatter.write_str("&amp;")?,
'<' => formatter.write_str("&lt;")?,
'>' => formatter.write_str("&gt;")?,
'"' => formatter.write_str("&quot;")?,
'\'' => formatter.write_str("&#x27;")?,
other => fmt::Write::write_char(formatter, other)?,
}
}
Ok(())
}
}
pub mod render {
use super::{error::HemplateError, Hemplate, HtmlEscape};
use std::fmt;
pub struct Escaped<'a, T: ?Sized>(pub &'a T);
impl<T: Hemplate + ?Sized> Escaped<'_, T> {
pub fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError> {
self.0.render_into(buffer)
}
}
pub trait EscapedFallback {
fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError>;
}
impl<T: fmt::Display + ?Sized> EscapedFallback for Escaped<'_, T> {
fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError> {
use fmt::Write;
write!(buffer, "{}", HtmlEscape(self.0))?;
Ok(())
}
}
pub struct Raw<'a, T: ?Sized>(pub &'a T);
impl<T: Hemplate + ?Sized> Raw<'_, T> {
pub fn render_to(&self, _buffer: &mut String) -> Result<(), HemplateError> {
panic!("hemplate: raw interpolation must not be used with Hemplate types")
}
}
pub trait RawFallback {
fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError>;
}
impl<T: fmt::Display + ?Sized> RawFallback for Raw<'_, T> {
fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError> {
use fmt::Write;
write!(buffer, "{}", self.0)?;
Ok(())
}
}
pub struct RawGuard<'a, T: ?Sized>(pub &'a T);
pub struct RawInterpolationNotAllowedForHemplateTypes;
impl<T: Hemplate + ?Sized> RawGuard<'_, T> {
pub fn check(&self) -> RawInterpolationNotAllowedForHemplateTypes {
RawInterpolationNotAllowedForHemplateTypes
}
}
pub trait RawGuardFallback {
fn check(&self);
}
impl<T: fmt::Display + ?Sized> RawGuardFallback for RawGuard<'_, T> {
fn check(&self) {}
}
impl<T: Hemplate + ?Sized> Hemplate for &T {
fn render_into(&self, buffer: &mut String) -> Result<(), HemplateError> {
(*self).render_into(buffer)
}
}
impl<T: Hemplate + ?Sized> Hemplate for Box<T> {
fn render_into(&self, buffer: &mut String) -> Result<(), HemplateError> {
(**self).render_into(buffer)
}
}
}
+36 -3
View File
@@ -1601,6 +1601,7 @@ fn known_hemx_attr(name: &str) -> bool {
name,
"data-hemx-root"
| "data-hemx-sse"
| "data-hemx-ws"
| "data-hemx-st"
| "data-hemx-handle"
| "data-hemx-slot"
@@ -1728,6 +1729,14 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i
"expected a non-empty same-origin SSE URL",
));
}
"data-hemx-ws" if value.trim().is_empty() => {
return Err(invalid_hemx_value(
path,
&attr.name,
value,
"expected a non-empty same-origin WebSocket URL",
));
}
"data-hemx-revealed-ahead"
if !value
.trim()
@@ -1796,6 +1805,13 @@ fn reject_invalid_hemx_attr_placement(
"expected placement on the same element as `data-hemx-root`",
));
}
if has_attr(attrs, "data-hemx-ws") && !has_attr(attrs, "data-hemx-root") {
return Err(invalid_hemx_placement(
path,
"data-hemx-ws",
"expected placement on the same element as `data-hemx-root`",
));
}
Ok(())
}
@@ -2789,12 +2805,12 @@ mod tests {
let canonical_syms = resources.syms();
assert_eq!(
stable_id("generated-rs", &canonical_generated),
2_620_423_950,
4_047_122_428,
"canonical generated Rust changed"
);
assert_eq!(
stable_id("generated-rs-global", &canonical_globals),
2_559_847_213,
1_196_973_467,
"canonical global-export Rust changed"
);
assert_eq!(
@@ -4232,6 +4248,12 @@ fn main() {{
"data-hemx-sse",
"non-empty",
),
(
"ws-empty",
r#"<main data-hemx-root="app" data-hemx-ws=" "></main>"#,
"data-hemx-ws",
"non-empty",
),
(
"delay-empty",
r#"<button data-hemx-handle="save" data-hemx-delay="">Save</button>"#,
@@ -4358,6 +4380,7 @@ fn main() {{
"data-hemx-on" => "expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`",
"data-hemx-confirm" => "expected a non-empty confirmation message",
"data-hemx-sse" => "expected a non-empty same-origin SSE URL",
"data-hemx-ws" => "expected a non-empty same-origin WebSocket URL",
"data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle"
| "data-hemx-every" | "data-hemx-interval" => {
"expected milliseconds like `250`/`250ms` or seconds like `1s`"
@@ -4437,7 +4460,7 @@ fn main() {{
}
let valid_source = r#"
<main data-hemx-root="app" data-hemx-client-module="/app.js" data-hemx-sse="/events">
<main data-hemx-root="app" data-hemx-client-module="/app.js" data-hemx-sse="/events" data-hemx-ws="/room/socket">
<button data-hemx-handle="save" data-hemx-policy="latest" data-hemx-on="click change" data-hemx-confirm="Save?" data-hemx-delay="250ms" data-hemx-throttle="1s">Save</button>
<button data-hemx-client="save" data-hemx-client-event="click" data-hemx-client-policy="drop" data-hemx-client-state-version="1">Client</button>
<form method="get" action="/search" data-hemx-history="replace"><input name="q"></form>
@@ -4500,6 +4523,12 @@ fn main() {{
"data-hemx-sse",
"expected placement on the same element as `data-hemx-root`",
),
(
"ws-child",
r#"<section data-hemx-root="room"><div data-hemx-ws="/room/socket"></div></section>"#,
"data-hemx-ws",
"expected placement on the same element as `data-hemx-root`",
),
] {
let base = test_dir(&format!("hemx-build-invalid-placement-{case}-test"));
let templates = base.join("templates");
@@ -4540,6 +4569,10 @@ fn main() {{
"sse-root",
r#"<main data-hemx-root="feed" data-hemx-sse="/events"></main>"#,
),
(
"ws-root",
r#"<main data-hemx-root="room" data-hemx-ws="/room/socket"></main>"#,
),
] {
let base = test_dir(&format!("hemx-build-valid-placement-{case}-test"));
let templates = base.join("templates");
+42
View File
@@ -16,6 +16,7 @@
const busyStates = new WeakMap();
const disabledStates = new WeakMap();
const sseSources = new WeakMap();
const webSockets = new WeakMap();
const atomStores = new WeakMap();
const dragKeys = new WeakMap();
let currentOperationId = null;
@@ -941,6 +942,38 @@
}
}
function bindWebSocket(root) {
const url = root.getAttribute("data-hemx-ws");
if (!url || webSockets.has(root) || typeof WebSocket === "undefined") return;
const href = new URL(url, location.href);
if (href.origin !== location.origin) {
const error = new Error("hemx WebSocket URL must be same-origin");
showError(root, error);
emit(root, "hemx:ws-error", url);
return;
}
href.protocol = href.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(href.href);
socket.binaryType = "arraybuffer";
socket.addEventListener("message", (event) => applyWebSocketMessage(root, event));
socket.addEventListener("error", () => {
showError(root, new Error("hemx WebSocket failed"));
emit(root, "hemx:ws-error", url);
});
webSockets.set(root, socket);
}
function applyWebSocketMessage(root, event) {
try {
if (!(event.data instanceof ArrayBuffer)) throw new Error("hemx WebSocket messages must be binary");
applyBatch(event.data, root);
showError(root, null);
} catch (error) {
showError(root, error);
emit(root, "hemx:error", String(error));
}
}
function duration(value) {
if (!value) return 0;
const match = String(value).trim().match(/^(\d+)(ms|s)?$/);
@@ -1135,6 +1168,9 @@
const source = sseSources.get(root);
if (source) source.close();
sseSources.delete(root);
const socket = webSockets.get(root);
if (socket) socket.close();
webSockets.delete(root);
const observers = revealObservers.get(root);
if (observers) observers.forEach((observer) => observer.disconnect());
revealObservers.delete(root);
@@ -1177,6 +1213,12 @@
} catch (error) {
emit(root, "hemx:sse-error", String(error));
}
try {
bindWebSocket(root);
} catch (error) {
showError(root, error);
emit(root, "hemx:ws-error", String(error));
}
});
new MutationObserver((records) => {
records.forEach((record) => {
+17
View File
@@ -398,6 +398,23 @@ fn runtime_applies_sse_effect_batches_inside_roots() {
assert!(source.contains("emit(root, \"hemx:sse-error\", url)"));
}
#[test]
fn runtime_applies_binary_websocket_effect_batches_inside_roots() {
// req: push/001 req: push/003 req: push/009 req: runtime/001 req: failure/001
let source = hemx_js::RUNTIME_JS;
assert!(source.contains("const webSockets = new WeakMap()"));
assert!(source.contains("const url = root.getAttribute(\"data-hemx-ws\")"));
assert!(source.contains("if (href.origin !== location.origin)"));
assert!(source.contains("href.protocol = href.protocol === \"https:\" ? \"wss:\" : \"ws:\""));
assert!(source.contains("socket.binaryType = \"arraybuffer\""));
assert!(source.contains("if (!(event.data instanceof ArrayBuffer)) throw new Error"));
assert!(source.contains("applyBatch(event.data, root)"));
assert!(source.contains("showError(root, error)"));
assert!(source.contains("emit(root, \"hemx:ws-error\", url)"));
assert!(source.contains("if (socket) socket.close()"));
}
#[test]
fn runtime_page_swaps_lowered_slot_ids() {
// req: wire/001
+2 -2
View File
@@ -8,8 +8,8 @@ use hemx_core::SafeHtml;
use hemx_core::{KeyedSlot, Slot};
pub use hemx_core::{
navigate, push, redirect, replace, CssClass, CssClasses, Effect, Form, FormContract, FormControlKind,
FormError, FormField, FormModel, FormValue, FromForm, IntoEffect,
navigate, push, redirect, replace, CssClass, CssClasses, Effect, Form, FormContract,
FormControlKind, FormError, FormField, FormModel, FormValue, FromForm, IntoEffect,
};
/// Generated metadata tokens used by macros, generated code, integrations, and tests.