feat(runtime): add portable runtime support
req: push/009 req: push/010 req: push/011
This commit is contained in:
@@ -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"] }
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"] }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user