feat(runtime): add portable runtime support
req: push/009 req: push/010 req: push/011
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
+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-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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[package]
|
||||
name = "hemplate-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
@@ -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("&")?,
|
||||
'<' => formatter.write_str("<")?,
|
||||
'>' => formatter.write_str(">")?,
|
||||
'"' => formatter.write_str(""")?,
|
||||
'\'' => formatter.write_str("'")?,
|
||||
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
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user