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
+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"] }
]
}