Compare commits

...

2 Commits

Author SHA1 Message Date
tmk241 353174604e feat(runtime): add portable runtime support
req: push/009 req: push/010 req: push/011
2026-08-17 07:59:04 +02:00
slhx agent e4bd6db13a docs(agent): track idiomatic Hemx guidance 2026-08-03 00:09:43 +02:00
20 changed files with 1355 additions and 347 deletions
+337
View File
@@ -0,0 +1,337 @@
---
name: idiomatic-hemx
description: >-
Use when a user explicitly asks how to design, implement, review,
productionize, or scale an application built with hemx and hemplate,
including deciding whether or where microservices belong. Preserve hemx's
generated-resource and server-owned effect model, then choose the smallest
evidence-backed scale stage. Do not use for generic Rust/Axum architecture,
generic microservice advice, or development of the hemx framework itself.
---
# Idiomatic hemx
## One job
Choose, explain, or implement the smallest production architecture that keeps a
hemx/hemplate application boring as it grows. The high-leverage move is usually
to preserve one direction of travel:
```text
semantic .heml surface
-> generated typed resources
-> ordinary Rust domain/application code
-> generated partial/page effects
-> tiny browser runtime
```
Scale providers and deployment topology around that loop. Do not replace it
with selectors, a client state graph, raw wire operations, or speculative
services.
## Trigger boundary
Load this skill for explicit hemx/hemplate application usage, architecture,
production-readiness, scaling, or a review of those decisions. It also applies
when the user asks whether a hemx application should become microservices.
Do not load it for:
- generic Rust, Axum, HTML, CSS, database, or microservice questions with no hemx
application decision;
- visual design alone;
- changing hemx/hemplate internals rather than using their public model;
- a tiny obvious application patch where no authoring or scaling judgment is at
stake.
## Authority before taste
Inside the hemx repository, read `AGENTS.md`, then
`docs/v1-product-evidence.md` and `REQUIREMENTS.md`. Use `PLAN.md` only as the
mutable implementation cursor. Inspect only the nearest authoritative material
needed for the decision:
- `docs/hemplate-syntax.md` for real `.heml` syntax;
- `docs/tutorial-saas.md` for the production-shaped application boundary;
- `docs/recipes/reusable-partials.md` for composition;
- the auth, persistence, observability, deploy/versioning, offline, and local
command-log recipes for their named concerns;
- the nearest maintained example and current public Rust API before naming an
API in code.
Project requirements and observed code outrank this skill. Outside this
repository, establish the application's versions and elected contracts instead
of assuming current-main APIs. If auth, storage, transport, replay, deployment,
or service contracts are absent, identify the missing decision; do not invent a
provider or abstraction that makes the system look complete.
When invoked for a Hemx application repository, inspect its elected `AGENTS.md`
or equivalent instruction surface. Ensure it explicitly requires all HTML
surfaces and fragments to be authored as semantic `.heml` templates rendered
through Hemplate's generated typed resources, and explicitly forbids constructing,
concatenating, interpolating, or formatting HTML in Rust. If the current request
authorizes repository edits, patch that instruction surface before application
implementation; otherwise report the missing policy as a blocker. Do not copy the
rest of this skill into project instructions.
## Decision loop
### 1. Start with the user job and one load-bearing path
Name the concrete request, mutation, navigation, push update, or recovery path
that must work. Trace it end to end before discussing topology:
```text
HTTP/browser input
-> normal auth, CSRF, and typed validation
-> application command/query
-> authoritative state transition
-> rendered generated target/page
-> EffectBatch response or canonical push bytes
-> root-scoped runtime application
-> visible success or explicit recovery
```
If this path is unclear, architecture diagrams and service boundaries are
premature.
### 2. Use the canonical authoring level
Default to:
- semantic `.heml` templates plus ordinary Rust; never construct, concatenate,
interpolate, or format HTML strings in Rust, including fragments for source
rendering, Markdown, errors, or test-facing pages;
- `#[hemx::app]`, plain `#[hemx::handler]`, generated components/resources,
typed form models, view wrappers, render/page helpers, and `IntoEffect`;
- generated target, form, control, keyed-slot, and event helpers;
- typed partial swaps expressed as generated target + rendered partial + swap
kind;
- real links and GET forms for navigation and history;
- `data-hemx-revealed-ahead` for viewport-ahead loading while the observed sentinel remains in normal document flow; never move the observed target with CSS to fake prefetch distance;
- plain CSS/SCSS for appearance;
- shared runtime paths and Axum adapters supplied by `hemx-axum`.
Keep domain types, authorization, persistence, routing, sessions, flags,
observability, and transport policy in the application or integration crate.
Keep `hemx-core` about typed resources and the closed effect/wire contract.
Compose reusable UI from templates, generated components, and ordinary Rust
functions rather than creating a client component framework.
Use advanced layers only when the job proves the need:
- opaque island JavaScript is a leaf adapter for high-frequency local behavior;
- client-local/wasm handlers are explicit opt-ins, not the default state model;
- atoms are addressable bootstrap/sync resources, not a general reactive store;
- SSE/WebSocket transport carries canonical versioned `EffectBatch` bytes; it
does not define domain policy.
### 3. Keep truth on the right side of the boundary
The browser DOM, stored HTML patches, and stored `EffectBatch` values are not
business truth. Keep authoritative state in ordinary application/domain models
and durable providers. Derive UI effects from that state.
For local/offline products, use explicit commands, events, and projections.
Replay, deletion, conflict resolution, reconciliation, and export semantics are
product contracts; stop when they have not been chosen. For server products,
normal HTTP security semantics remain authoritative even when interactions are
enhanced.
Expected failures must become typed, local, useful outcomes: generated field
errors/focus for validation, a root-scoped error outlet for recoverable request
failure, and fail-closed handling for malformed or incompatible responses.
Never turn infrastructure failure into a success-looking empty effect.
Prefer a **functional core with an imperative shell**. Keep validation,
normalization, authorization decisions, command application, state transitions,
and view-model/projection derivation as deterministic functions over explicit
inputs where that is honest. A useful shape is `state + command -> outcome` or
`facts -> view model`, with typed errors rather than hidden mutation. This makes
the largest behavior space cheap to unit-test and mutation-test.
Keep HTTP extraction, sessions, database I/O, clocks, randomness/IDs, queues,
push connections, and host capabilities in a thin handler/application shell.
Read their results once, pass ordinary values into the core, persist the returned
outcome, then render generated effects. Pass time, identity, or policy as data
when only the value matters; do not create a trait for every function merely to
mock it. Use a real adapter boundary when ownership or side effects are real.
Purity is a locality tool, not a religion: orchestration and I/O are inherently
effectful, and `EffectBatch` remains UI output rather than domain state.
### 4. Scale one pressure at a time
Use this ladder. Enter a stage only when measured load, availability goals,
ownership, compliance, or a distinct failure/resource profile requires it.
| Stage | Default shape | Required proof before moving on |
| --- | --- | --- |
| One process | One deployable; app-owned adapters; simplest durable store that meets the product contract | The real product path, restart behavior, backup/recovery needs, and current bottleneck are known |
| Production monolith | One release unit for server, generated output, and runtime asset; external durable database/session providers as required | Integration tests cover auth, persistence, failures, migration, fingerprint mismatch, and rollback/reload behavior |
| Horizontal web tier | Stateless request replicas around shared authoritative providers; process memory is cache/ephemeral only unless affinity and loss semantics are explicit | Load evidence shows replica scale helps; migrations, readiness, draining, cache invalidation, and session/CSRF behavior work across replicas |
| Push/fan-out tier | Server-owned ongoing SSE/WebSocket connections; add a broker/backplane only when updates must cross processes | Reconnect, ordering, duplicate, authorization, backpressure, and replay expectations are explicit and tested at the required level |
| Worker or read-model split | Isolate a measured CPU, latency, queue, or failure domain while the application remains one understandable product | Job ownership, idempotency, timeout, retry, deduplication, observability, and recovery contracts exist |
| Microservices | Split an independently owned bounded capability with its own release/scaling/SLO pressure and explicit data/API/event contract | The boundary removes a demonstrated constraint and its distributed failure modes are cheaper than the monolith |
Prefer vertical resource tuning, query/index fixes, caching with explicit
freshness, bounded concurrency, and horizontal replicas before service
splitting. A large codebase is a module-boundary problem before it is a network
boundary problem.
### 5. Preserve the hemx boundary across services
When microservices are justified:
- keep hemplate rendering, generated resources, and UI `EffectBatch` creation in
the presentation/application edge that owns the page;
- exchange typed domain requests, responses, and events across services—not CSS
selectors, DOM instructions, raw hemx opcodes, or app-authored JSON versions
of the hemx wire format;
- name one authority for each write model and do not let services casually share
mutation ownership;
- version service contracts independently, while deploying each hemx server,
its generated metadata, build fingerprint, and runtime asset as a compatible
release unit;
- preserve end-user credentials, authorization, CSRF, tenancy, deadlines, and
trace context explicitly at each real trust boundary;
- make partial failure visible. Define timeout, idempotency, retry,
deduplication, ordering, compensation, and replay only where the chosen
interaction requires them—never as generic middleware theater.
Do not put a network hop between a handler and its renderer merely to claim
microservices. Do not use `EffectBatch` as a business event bus or durable event
log.
### 6. Organize by cohesive product responsibility
Start small; do not pre-create an architecture directory tree. A production app
usually needs only these durable seams:
```text
build.rs # hemx-build/global code generation only
src/main.rs # config, concrete providers, process/bootstrap
src/lib.rs # app/router composition and a testable app surface
src/<feature>/ # add only when a feature is already a real seam
templates/app_shell.heml # document shell
templates/<feature>.heml # feature surface
templates/partials/ # genuinely reused or independently swapped pieces
tests/<journey>.rs # process/integration behavior
tests/browser_<journey>.rs # only browser-dependent behavior
```
Treat that as a responsibility map, not mandatory scaffolding. A small cohesive
app can remain in `lib.rs`; file count is not architecture. When a feature has
its own state/commands, handlers, rendering, and tests, move that whole seam
together. Keep its domain model, typed input, application operation, handler,
and generated view calls near one another. Do not spread every request across
generic `controllers/`, `services/`, `repositories/`, `dto/`, and `utils/`
directories.
Keep `main.rs` boring and hard to test because it contains almost no policy.
Expose app construction or mounting from `lib.rs` so integration tests can use
the real router with controlled concrete providers. Put normal routing,
auth/session, persistence, queues, and transport adapters at the app boundary;
do not move them into hemx core or generated template modules. Keep generated
artifacts in the build output rather than copying them into source control.
Put tiny unit tests beside the responsible module, especially around pure
transitions, validation, authorization decisions, and projections. Put
cross-module HTTP, persistence, and process tests in `tests/`, named for behavior
rather than implementation. Keep browser journeys few and load-bearing. Extract
a shared partial or helper only after actual reuse or independent swap identity
appears.
If a microservice boundary becomes real, that service owns its contract,
provider/migrations, operational entry point, and contract tests; do not mirror
hypothetical services in the source tree first.
### 7. Prove behavior at the cheapest authoritative boundary
Choose the tool by what must be observed:
| Boundary | Default tool | What it proves |
| --- | --- | --- |
| Domain/state | Rust `#[test]` / `#[tokio::test]` | Parsing, invariants, commands, projections, and failure classes |
| Template/build | Compiler plus hemx-build diagnostics | Real `.heml` syntax, generated resources/forms, and cross-file references |
| Handler/effect | `hemx_test` | Generated targets/handles, rendered partials, form bodies, and effect batches without raw ids |
| Router/integration | Real Axum router, usually `tower::ServiceExt`, plus concrete test providers | HTTP status/headers/body, auth, CSRF, sessions, persistence, and malformed requests |
| Static rendered HTML | Rust `scraper` crate (HTML parser + CSS-selector queries) | Escaping, semantic structure, links/forms/attributes, and server-rendered fragments without launching a browser |
| Process lifecycle | `hemx_test::TestProcess` | Readiness, real sockets, child cleanup, restart, and production-binary behavior |
| Browser runtime | Rust `thirtyfour` crate (WebDriver client) with the repository-owned browser smoke | Delegated events, history, focus, polling/revealed bindings, keyed DOM identity, SSE, and recovery |
| Test quality/release | hemx xtask mutation/full-test commands | Mutation resistance and the elected bounded workspace verification path |
`scraper` is a Rust HTML parsing and CSS-selector library, not a browser or web
framework. Use it when inspecting final server-rendered HTML is enough. It does
not run JavaScript, apply `EffectBatch`, maintain focus/history, or prove
SSE/runtime behavior. `thirtyfour` is a Rust WebDriver client crate; use it only
when an actual browser semantic is the subject;
do not turn every handler assertion into a WebDriver journey. In this repository
`thirtyfour` is the elected Rust browser adapter, so do not add Playwright,
Fantoccini, or another competing browser stack merely from preference. Outside
this repository, preserve the application's existing runner unless a concrete
missing capability justifies migration.
For WebDriver tests, start the real process through the RAII `TestProcess`
harness, keep selectors in test adapters, and prefer generated-resource or
stable semantic helpers over copied implementation selectors. Browser selector
helpers are not authoring APIs. Avoid sleeps when the runner can wait for the
observable condition.
Match proof to risk:
1. Let compilation reject broken templates and generated contracts.
2. Unit-test ordinary domain parsing and state transitions without a browser.
3. Test handlers through `hemx_test` with useful generated-resource assertions.
4. Test route/auth/session/CSRF/persistence and wire failure behavior at the app
integration boundary.
5. Parse static HTML with `scraper` only for facts that do not require runtime
execution.
6. Use focused repository-owned browser smoke for dynamic attributes,
navigation/history, keyed reconciliation, polling/revealed behavior,
no-reload interaction, and runtime recovery.
7. Add compatibility, rolling-deploy, process-restart, multi-replica, load, or
fault tests only when the selected scale stage makes those behaviors part of
the contract.
In this repository, prefer the stable commands named by `AGENTS.md`, especially
focused crate tests and `cargo run -p hemx-xtask -- test`; use
`cargo run -p hemx-xtask -- html-examples-smoke` for the pattern gallery and
runtime behavior, and the xtask mutation command rather than direct `mutest`.
Do not substitute a passing literal lowering fixture, static HTML parse, or
mocks-only test for proof at the rendered/runtime consumer boundary.
## Refuse fake sophistication
Do not introduce:
- handwritten resource ids, selector retargeting, raw effect constructors, raw
registries, runtime opcodes, manual wire parsing, or hard-coded runtime URLs in
normal app code;
- a VDOM, client router, general client state graph, expression runtime,
per-node listeners, or handwritten JavaScript for ordinary forms/lists/swaps;
- core-owned auth, sessions, persistence, routing, multipart, transport, sync,
analytics, flags, or provider policy;
- a generic repository/service/provider interface before a concrete second use
or required external contract exists;
- microservices by entity name, team aspiration, file count, or hypothetical
scale;
- shared process memory presented as durable or cross-replica state;
- retries, caches, queues, brokers, sagas, event sourcing, CQRS, Kubernetes, or a
service mesh without a named failure/load contract and verification path.
“Suckless” here means fewer authorities and mechanisms, not fewer safety checks.
The elegant design keeps HTML semantics, typed boundaries, explicit ownership,
and failure truth while deleting accidental layers.
## Handoff
For architecture or review requests, report compactly:
- the user-visible path and current bottleneck/risk;
- the selected scale stage and why the previous stage is insufficient;
- state, rendering, transport, and service ownership;
- the smallest end-to-end change;
- complexity explicitly refused;
- proof run and any unresolved provider/product contract.
For implementation, make that slice reachable and verify it; do not leave a
“scalable” abstraction that no real path uses.
+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.