Remove legacy and Labs surfaces from public core

This commit is contained in:
tmk241
2026-09-01 08:01:01 +02:00
parent ad0c8b316c
commit 52ddf8904c
185 changed files with 81 additions and 27543 deletions
-337
View File
@@ -1,337 +0,0 @@
---
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.
-58
View File
@@ -1,58 +0,0 @@
# Explicit infrastructure/invariant classifications for the package-native release gate.
# - test_process_try_wait: OS process-status failures cannot be injected portably.
# - test_process_poll_delay: poll cadence is operational; readiness and timeout are integration-proven.
# - Drop for TestProcess: mutating reaping leaks helper processes beyond the test lifecycle.
# - inspection_fingerprint: deliberately unobservable test-harness metadata.
# - BuildFingerprint::from_parts loop-progress mutations: syntactically valid but
# non-terminating const-loop mutants; deterministic hash outputs are asserted.
# - Infallible header parsing and multipart byte collection: adjacent public tests
# prove exact ETag/runtime headers and streamed multipart errors; unwrap mutants
# are behaviorally equivalent at these validated boundaries.
# - hemx-build source inspection delegates to hemplate's currently infallible
# Surface parser; file I/O and invalid Rust-context errors remain explicitly proven.
# The direct surface_for_heml_source unwrap mutant is equivalent for the same seam.
# - AppBuilder reuses that same parser seam. Directory-open and recursive errors are
# proven, while a per-entry readdir fault cannot be injected portably after a
# successful read_dir; its unwrap mutant is classified as infrastructure-only.
# - write_if_changed propagates non-NotFound read errors; for ordinary filesystem
# paths, attempting the same write returns the same OS error, so the guard mutant
# is externally equivalent while create/update/no-op behavior is mutation-proven.
# - stylesheet_class_tokens loop-progress mutants are deterministically
# non-terminating; sorted, deduplicated, boundary-aware outputs are asserted.
# - context path words are filtered non-empty before extracting their first char;
# `?` and `unwrap` are equivalent under that local iterator invariant.
# - Rust-fact named fields always carry identifiers by syn's type contract. Per-entry
# and recursive read_dir errors cannot be injected portably after the parent opens;
# parent-open, source-read, and parse failures remain explicitly proven.
# - Registry-helper syntax is emitted entirely from quote-owned static tokens. Its
# parse succeeds by construction; expect/unwrap and expect-message mutations are
# equivalent, while exact generated registration and public diagnostics are proven.
exclude_re = [
"test_process_try_wait",
"test_process_poll_delay",
"delete statement std::thread::sleep\\(Duration::from_millis\\(25\\)\\)",
"<impl Drop for TestProcess>::drop",
"inspection_fingerprint",
"replace \\+= with \\*= in BuildFingerprint::from_parts",
"replace 1 with 0 in BuildFingerprint::from_parts",
"replace field \\.bytes\\(\\) \\.await \\.map_err.* with field.bytes\\(\\).await.map_err.*unwrap\\(\\) in InteractionForm::parse_multipart",
"replace String::from_utf8.* with String::from_utf8.*unwrap\\(\\) in InteractionForm::parse_multipart",
"replace HeaderValue::from_str.*runtime_js_hash.* with HeaderValue::from_str.*unwrap\\(\\) in <impl IntoResponse for RuntimeJs>::into_response",
'replace "runtime hash is a valid ETag" with "" in <impl IntoResponse for RuntimeJs>::into_response',
"replace build_ast.* with build_ast.*unwrap\\(\\) in surface_for_heml_source",
"replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in diagnostics_for_heml_source",
"replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in generated_targets_for_heml_source",
"replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in template_context_facts_for_heml_source",
"replace surface_for_heml_source.* with surface_for_heml_source.*unwrap\\(\\) in AppBuilder::run",
"replace entry\\? with entry.unwrap\\(\\) in collect_input_files_into",
"replace match guard error.kind\\(\\) == io::ErrorKind::NotFound with true in write_if_changed",
"replace \\+= with (?:-=|\\*=) in stylesheet_class_tokens",
"replace 1 with 0 in stylesheet_class_tokens",
"replace chars.next\\(\\)\\? with chars.next\\(\\).unwrap\\(\\) in context_type_for_heml_path",
"replace entry\\? with entry.unwrap\\(\\) in collect_rust_struct_facts",
"replace collect_rust_struct_facts.*\\? with collect_rust_struct_facts.*unwrap\\(\\) in collect_rust_struct_facts",
'replace syn::parse2.* with syn::parse2.*unwrap\(\) in add_app_registry_helper',
'replace "generated app registry helper parses" with "" in add_app_registry_helper',
'replace "generated component register helper parses" with "" in add_component_register_helper',
'replace "generated component state register helper parses" with "" in add_component_register_helper',
]
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
msg_file="$1"
# Require scope if REQs exist
if [ -f REQUIREMENTS.md ]; then
if ! grep -qE '^[a-z]+(\(.+\))?:' "$msg_file"; then
echo "error: commit requires scope — e.g. feat(parser): ..."
exit 1
fi
fi
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
changed=$(git diff --cached --name-only)
# fail only when this commit changes REQs without reviewing AGENTS.md;
# do not block unrelated commits just because an earlier commit changed REQs.
if echo "$changed" | grep -q '^REQUIREMENTS.md$' && ! echo "$changed" | grep -q '^AGENTS.md$'; then
echo "error: REQUIREMENTS.md changed without AGENTS.md — review AGENTS.md or run: redgate agents > AGENTS.md"
exit 1
fi
-12
View File
@@ -1,12 +0,0 @@
# Tool Registry
| Tool | Description |
|------|-------------|
| redgate | Requirements-first governance: list, refs, health, agents |
## redgate usage
- `redgate list` — TSV of all requirements
- `redgate refs` — find req: citations in source
- `redgate health` — ok/uncited per requirement
- `redgate agents` — render AGENTS.md from REQUIREMENTS.md
+29
View File
@@ -0,0 +1,29 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- run: cargo test --workspace --all-targets --all-features
- run: cargo check -p hemx-server-wasm-test --target wasm32-unknown-unknown
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check
command-arguments: licenses sources
+2
View File
@@ -1 +1,3 @@
/target/ /target/
**/target/
**/node_modules/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-11
View File
@@ -1,11 +0,0 @@
[package]
name = "paste"
version = "1.0.15"
edition = "2021"
rust-version = "1.56"
publish = false
license = "MIT OR Apache-2.0"
description = "Workspace compatibility alias from paste to its maintained successor pastey"
[dependencies]
pastey = "=0.2.3"
-6
View File
@@ -1,6 +0,0 @@
#![forbid(unsafe_code)]
//! Compatibility export for dependencies that still name the unmaintained
//! `paste` crate. New code should depend on `pastey` directly.
pub use pastey::paste;
-16
View File
@@ -1,16 +0,0 @@
[package]
name = "spin"
version = "0.9.8"
edition = "2021"
rust-version = "1.71"
publish = false
license = "MIT"
description = "Workspace compatibility alias from yanked spin 0.9 to maintained spin 0.12"
[features]
default = []
once = ["spin_next/once"]
spin_mutex = ["spin_next/spin_mutex"]
[dependencies]
spin_next = { package = "spin", version = "=0.12.2", default-features = false, features = ["once"] }
-6
View File
@@ -1,6 +0,0 @@
#![forbid(unsafe_code)]
//! Compatibility export for dependencies that still require yanked `spin 0.9`.
//! New code should depend on the maintained `spin` release directly.
pub use spin_next::*;
+29
View File
@@ -0,0 +1,29 @@
[graph]
all-features = true
[licenses]
allow = [
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"MIT",
"MIT-0",
"MPL-2.0",
"NCSA",
"Unicode-3.0",
"Unlicense",
"Zlib",
]
confidence-threshold = 0.8
unused-allowed-license = "allow"
[licenses.private]
ignore = false
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
allow-git = []
-89
View File
@@ -1,89 +0,0 @@
# Diagnostics guide
hemx diagnostics should tell a Rust developer which template fact, generated
helper, or handler shape is wrong, and what to change next. They should not teach
raw ids, selector targeting, runtime opcodes, or Cargo internals in the normal
path. req: diagnostics/001 req: diagnostics/002 req: diagnostics/003
Use this guide as the v1 checklist for common mistakes in beginner and
production-shaped apps. Structured `hemx-build` diagnostics expose a file path,
directive, target, expected template fact, and repair action so an optional
editor overlay can share compiler authority without becoming a custom editor
framework.
## Where errors happen
- **Template/build diagnostics** come from `hemx_build::app().run()` while reading
`.heml` files and CSS. Fix the template or generated-surface convention.
- **Derive/compile diagnostics** come from `#[hemx::surface]`, `#[hemx::form]`,
`#[hemx::handler]`, `#[hemx::component]`, and `#[hemx::app]`. Fix Rust code so
it matches the generated surface.
- **Runtime diagnostics** come from the tiny browser runtime when a deployed page
and response are incompatible or a target cannot be applied. Fix deployment or
recover with a full page response. req: failure/005
## Common mistakes and fixes
| Mistake | Diagnostic shape | Fix |
| --- | --- | --- |
| Handler has no matching template handle | `unknown hemx handle \`save\`; add \`data-hemx-handle="save"\`` | Add the handle to the template, rename the function, or put the handler in the matching component. |
| Component is missing a generated handler | `#[hemx::component] missing handler implementation(s): delete` | Add a `#[hemx::handler] fn delete(...)` in that component, or remove the template handle. |
| Handler name is ambiguous across components | `ambiguous generated handle name(s): save` | Scope the component with `#[hemx::component("todos")]` or rename handles so the generated path is unique. |
| Handler misses generated params | `hemx handler \`show\` is missing generated param argument(s): mode` | Add typed handler arguments for every `data-hemx-param-*` fact generated by the template. |
| Form handler omits the form argument | `handles a generated form and must accept a typed form argument` | Accept `Form<NewThing>`/`hemx::Form<NewThing>`/integration equivalent and derive `#[hemx::form("...")]` for the type. |
| Form struct misses a control | `hemx form \`new_todo\` is missing field \`title\`` | Add a Rust field matching the form control name, or rename the template control. |
| Required/multiple form control has wrong Rust shape | `required ... must not be Option<_>` or `accepts multiple values and must be Vec<_>` | Match HTML required/multiple semantics with `T`, `Option<T>`, or `Vec<T>` as appropriate. |
| Form field type cannot parse submitted values | compiler mentions `T: FormValue` / `T: hemx::FormValue` | Implement `FromStr`/the expected form value trait for the domain newtype, or use a parseable domain type. |
| Generated resources are unavailable | `could not find generated hemx module` / `could not find generated hemx symbols` plus `add hemx_build::app().run()? to build.rs` | Add or fix `build.rs`, then rerun `cargo check`; do not copy `$OUT_DIR` paths into app code. |
| Unknown `data-hemx-*` attribute | `unknown hemx attribute ... check the spelling or use a non-hemx data-* attribute` | Fix the spelling, use the supported hemx attribute, or rename app metadata to a non-hemx `data-*` attribute. |
| Selector-style targeting | ``data-hemx-target` is selector-style targeting; hemx uses generated resources` | Put `data-hemx-slot` on the local target and return a generated slot/page/form effect. |
| Generated target appears in a loop without a stable key | `inside an h-for without h-key; add a stable h-key="item.id"` | Add a stable `h-key` to the owning loop; use generated keyed helpers for row updates. |
| Page/SSE attributes are on the wrong element | `expected a real <a href=...>` / `expected placement on the same element as data-hemx-root` | Keep page navigation on anchors and put root-scoped runtime attributes on the root element. |
| Result handler error type is not mappable | compiler reports the error type does not satisfy `IntoHandlerFailure` | Implement `IntoHandlerFailure` for the app error, or keep expected validation as generated UI effects instead of `Err`. req: failure/004 |
| Old page talks to a new server/runtime | runtime refuses the partial update on fingerprint mismatch | Serve a self-consistent release or fall back to full page reload/navigation. See `docs/recipes/deploy-versioning.md`. req: abi/004 |
| Missing runtime target | runtime emits a missing-target diagnostic in development and fails/no-ops according to target kind | Fix the template/generated helper mismatch; do not retarget with selectors. req: failure/001 |
## What a good diagnostic should include
A v1-quality diagnostic should include:
- the user-facing name: handle, form, slot, key, param, class, event, or template
- the source area: template path, Rust item, or deployment/runtime boundary
- the concrete expected shape, not an internal representation
- one next action that preserves generated helpers and the tiny runtime
Avoid beginner-facing messages that suggest `ResourceId`, `ResourceRef`, raw
`Effect`, manual registries, selector strings, or runtime opcodes. If an advanced
escape hatch is genuinely required, say that it is advanced and name the safer
normal path first. req: public_api/002 req: public_api/005
## Editor overlay boundary
A `.heml` editor overlay is optional and subordinate to the compiler. It may read
`docs/hemplate-syntax.md`, run or reuse `hemx-build` diagnostics, and present
compiler-shaped diagnostics, completion, hover, and navigation for documented
syntax and generated targets. It must not define a second template language,
formatter, selector targeting model, JavaScript expression layer, or custom editor
framework. If editor feedback disagrees with `hemx-build`, `hemx-build` wins.
req: diagnostics/004
## Verification anchors
Current recurring checks cover the most common classes:
- `cargo test -p hemx-build` covers template/build diagnostics such as unknown
hemx attributes, selector-style targeting, invalid runtime attribute values,
missing keys, and invalid page/SSE placement.
- `cargo test -p hemx-derive --test compile_fail` covers derive/compile
diagnostics for missing handlers, form mismatch, params, missing generated
files, unknown scoped components, ambiguous handles, and generated resource
lookup.
- `cargo test -p hemx-js` covers root-scoped runtime behavior, selectorless
targeting, fingerprint mismatch refusal, SSE application, and recoverable
runtime events.
- `cargo test -p hemx-test --test examples_contract` keeps public examples from
teaching forbidden normal-path constructs.
Before claiming the diagnostics story is closed for v1, run those gates plus
`cargo run -p hemx-xtask -- test`, `cargo check --workspace`, and
`redgate refs` on a clean tree. The installed CLI's `health` mode additionally requires every historical row to use its newer prescriptive wording, which is not the elected compatibility gate for this corpus. req: test/003 req: test/004
-168
View File
@@ -1,168 +0,0 @@
# `.heml` editor support
`.heml` authoring should feel like HTML first: keep normal HTML highlighting,
formatting, tag matching, and tree-sitter queries, then layer hemx compiler
feedback on top. The shared authority is `hemx-build` diagnostics plus
`docs/hemplate-syntax.md`; editors must not carry separate parser rules for the
hemplate language. req: diagnostics/004 req: diagnostics/005
## Shared language service
`hemx-lsp` owns editor protocol behavior; `hemx-xtask` stays a project workflow
runner, not the language-service home.
From the repo, run the stdio language service:
```sh
cargo run -p hemx-lsp -- lsp
```
Or install the same binary and run it directly:
```sh
cargo install --path hemx-lsp
hemx-lsp lsp
```
It speaks standard LSP framing over stdin/stdout. Today it supports open/change/save
text synchronization, compiler-backed `textDocument/publishDiagnostics`, and
small completion/hover entries for documented `.heml` constructs from
`docs/hemplate-syntax.md`. Generated targets discovered by `hemx-build` in an
open document are offered as `ui::target` completions. For derive-known template
contexts, `self.` field completion/hover and simple `h-for` locals such as
`exercise in &self.plan` come from hemx-owned Rust struct facts, not an editor
parser or rust-analyzer proxy. It intentionally does not format templates, parse
JavaScript, parse arbitrary Rust expressions, or replace HTML tooling.
For scripts and editor wrappers that only need one-shot diagnostics, run:
```sh
cargo run -p hemx-lsp -- diagnostics path/to/file.heml
```
The one-shot command prints a JSON object shaped like LSP
`textDocument/publishDiagnostics` parameters:
```json
{
"uri": "file:///absolute/path/to/file.heml",
"diagnostics": [
{
"range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } },
"severity": 1,
"source": "hemx-build",
"code": "unkeyed-generated-target",
"message": "data-hemx-slot=\"todo_row\" is inside h-for=\"todo in &self.todos\" without h-key",
"data": {
"directive": "data-hemx-slot",
"target": "todo_row",
"expected": "a stable template h-key on h-for=\"todo in &self.todos\" so generated keyed helpers such as ui::todo_row.replace(row) can target this partial",
"repair": "add h-key=\"todo.id\" to that h-for; dynamic +data-key on the child is rendered HTML, not the template fact hemx uses for generated targets"
}
}
]
}
```
The diagnostic payload comes from `hemx-build`; editor integrations should display
it as-is instead of recreating the rule.
## Highlighting boundary
Repo-owned `.heml` highlighting is an HTML overlay, not a new language. Normal
HTML highlighting owns tags, attributes, strings, comments, folding, and tag
matching. The hemplate overlay may highlight only documented syntax tokens from
`docs/hemplate-syntax.md`:
- escaped text delimiters and expression regions: `{+` and `+}`;
- trusted/rendered HTML delimiters and expression regions: `{+=` and `=+}`;
- dynamic attribute prefixes such as `+class`, `+disabled`, and `+aria-label`;
- structural directives: `h-if`, `h-for`, `h-key`, `h-match`, and `h-case`;
- hemx facts recorded as ordinary attributes: `data-hemx-root`,
`data-hemx-slot`, `data-hemx-form`, `data-hemx-handle`, and other checked
`data-hemx-*` authoring attributes.
Highlighting must not own diagnostics, completion, hover, formatting, Rust
expression parsing, selector behavior, generated Rust facts, or build
validation. Those remain with `hemx-build`, `hemx-lsp`, normal HTML tooling, and
Rust tooling. Repo tests for highlighting should therefore be fixture/query tests
for captures over these token classes; provider packaging or visual editor smoke
is a separate release slice and cannot become syntax authority. The current
repo-owned fixture and golden capture contract live in
`docs/fixtures/hemplate-highlighting/`. req: diagnostics/004 req: diagnostics/008
## VS Code and Cursor
Use the shared repo extension in `editors/vscode-hemx` for VS Code and Cursor.
It sets `.heml` to the built-in HTML language mode, starts `hemx-lsp`, and maps
LSP diagnostics/completion/hover into the editor without adding a separate grammar.
Hovering a generated root, slot, form, or handle value reports its resource kind
and generated `ui::<name>` Rust symbol from the current template.
req: diagnostics/005 req: diag/010
When the workspace root is this repository, the extension starts:
```sh
cargo run -p hemx-lsp -- lsp
```
In app workspaces, install `hemx-lsp` and the extension starts:
```sh
hemx-lsp lsp
```
If you do not use the extension, keep the same HTML association manually so HTML
syntax highlighting, completion, folding, and tag matching keep working:
```json
{
"files.associations": {
"*.heml": "html"
}
}
```
Use the one-shot diagnostics command only as a fallback task if your editor cannot
launch a stdio LSP server. Do not copy hemplate syntax into a VS Code/Cursor-only
grammar.
## Neovim
Use HTML filetype and tree-sitter HTML highlighting for `.heml`:
```lua
vim.filetype.add({ extension = { heml = "html" } })
```
If you use nvim-treesitter, this keeps `.heml` on the HTML parser. Start the
shared LSP service with Neovim's built-in client:
```lua
vim.lsp.start({
name = "hemx-heml",
cmd = { "cargo", "run", "-p", "hemx-lsp", "--", "lsp" },
root_dir = vim.fs.root(0, { "Cargo.toml", ".git" }) or vim.fn.getcwd(),
})
```
Use `cargo run -p hemx-lsp -- diagnostics %` only as a fallback if LSP is
unavailable. Do not add a separate `.heml` tree-sitter grammar unless HTML
injection can no longer represent the documented syntax in
`docs/hemplate-syntax.md`.
## Known limits and boundary
If `hemx-lsp` is missing, crashes, or cannot be started by the editor, `.heml`
files should still open as HTML and keep normal highlighting/tag tooling; use the
one-shot diagnostics command until the service is available.
This foundation intentionally supports diagnostics, completion, and hover/help.
It does not yet implement broad go-to-definition/reference navigation, formatting,
refactoring, semantic Rust analysis, arbitrary Rust expression parsing, or a
`.heml` tree-sitter parser fork.
Editor support may add startup glue, diagnostics display, completion, hover/help,
and navigation over documented `.heml` facts. It must not add a second template
language, editor-owned formatter, selector targeting model, JavaScript expression
layer, or editor-specific diagnostics that disagree with `hemx-build`.
@@ -1,17 +0,0 @@
capture literal
@attribute.hemx data-hemx-root
@attribute.hemx data-hemx-form
@attribute.hemx data-hemx-handle
@attribute.hemx data-hemx-slot
@attribute.dynamic.hemplate +class
@keyword.control.hemplate h-if
@keyword.control.hemplate h-for
@keyword.control.hemplate h-key
@keyword.control.hemplate h-match
@keyword.control.hemplate h-case
@punctuation.special.hemplate.escaped.open {+
@punctuation.special.hemplate.escaped.close +}
@punctuation.special.hemplate.trusted.open {+=
@punctuation.special.hemplate.trusted.close =+}
@embedded.rust.hemplate result.title
@embedded.rust.hemplate result.summary_html
1 capture literal
2 @attribute.hemx data-hemx-root
3 @attribute.hemx data-hemx-form
4 @attribute.hemx data-hemx-handle
5 @attribute.hemx data-hemx-slot
6 @attribute.dynamic.hemplate +class
7 @keyword.control.hemplate h-if
8 @keyword.control.hemplate h-for
9 @keyword.control.hemplate h-key
10 @keyword.control.hemplate h-match
11 @keyword.control.hemplate h-case
12 @punctuation.special.hemplate.escaped.open {+
13 @punctuation.special.hemplate.escaped.close +}
14 @punctuation.special.hemplate.trusted.open {+=
15 @punctuation.special.hemplate.trusted.close =+}
16 @embedded.rust.hemplate result.title
17 @embedded.rust.hemplate result.summary_html
@@ -1,18 +0,0 @@
<main data-hemx-root="demo">
<form data-hemx-form="search" data-hemx-handle="run_search">
<input +class="self.search_class" name="query" />
</form>
<section h-if="self.show_results">
<template h-for="result in &self.results" h-key="result.id">
<article data-hemx-slot="result_row">
<h2>{+ result.title +}</h2>
<div>{+= result.summary_html =+}</div>
</article>
</template>
</section>
<template h-match="self.state">
<p h-case="ViewState::Empty">No results</p>
</template>
</main>
-81
View File
@@ -1,81 +0,0 @@
# `.heml` syntax surface
`.heml` files are ordinary HTML plus the small hemplate surface below. Use normal
HTML tooling first; hemx/hemplate adds checks for the few template facts that
Rust code generation needs. req: diagnostics/001 req: diagnostics/002
## Text and HTML
- `{+ expr +}` inserts escaped text.
- `{+= expr =+}` inserts trusted/rendered HTML. Use it only for values already
represented as trusted HTML in Rust.
```html
<h1>{+ self.title +}</h1>
<div>{+= self.body_html =+}</div>
```
## Dynamic attributes
Prefix an HTML attribute with `+` when its value is a Rust expression.
```html
<a +href="self.url">{+ self.label +}</a>
<button +disabled="self.saving">Save</button>
```
Dynamic attributes render HTML. They do not replace template facts such as
`h-key` on a loop or `data-hemx-slot` names used by generated helpers.
## Control flow
```html
<section h-if="self.logged_in">Welcome back</section>
<li h-for="todo in &self.todos" h-key="todo.id">
{+ todo.title +}
</li>
<div h-match="self.state">
<p h-case="State::Loading">Loading</p>
<p h-case="State::Ready">Ready</p>
<p h-case="_">Unknown</p>
</div>
```
`h-key` is required when generated targets live inside `h-for`; it must be the
stable template fact on the loop that owns the repeated target. `+data-key` on a
child is just rendered HTML and is not enough for generated keyed helpers.
## Generated hemx targets
Generated targets are named in templates and used from Rust through generated
helpers. Do not target them with CSS selectors or raw ids in normal app code.
```html
<main data-hemx-root="todos">
<form data-hemx-form="new_todo" data-hemx-handle="add_todo">
<input name="title" required>
</form>
<p data-hemx-slot="notice">{+ self.notice +}</p>
<ul>
<li h-for="row in &self.rows" h-key="row.id" data-hemx-slot="todo_row">
{+ row.title +}
</li>
</ul>
</main>
```
Rust handlers then use generated helpers such as
`ui::notice.set("Saved")`, `ui::todo_row.replace(row)`, and composed
`IntoEffect` batches. The template owns target names; Rust owns state, commands,
events, and projections.
## Boundary
This file defines the stable public authoring surface for hemx examples and
beginner docs. It does not introduce a client component framework, custom editor
framework, JavaScript expression language, selector targeting model, or stored DOM
truth.
-239
View File
@@ -1,239 +0,0 @@
# Recipe: auth/session and CSRF boundary for the SaaS tutorial
This recipe turns the `examples/saas` demo session into a production-shaped
application boundary without adding authentication, authorization, session, or
CSRF policy to hemx core. hemx receives a typed context and generated form
values; Axum/Tower middleware and extractors own cookies, credentials, and
rejection policy. req: laws/002 req: auth/001
Use this alongside `docs/recipes/sqlx-persistence.md`: authenticate the request,
verify CSRF for mutations, then call the application store and return generated
UI effects. req: auth/002 req: auth/004
## Boundary rule
Keep these concerns outside hemx crates:
- password or OAuth provider selection
- session cookie format, signing, storage, rotation, and expiration
- CSRF token minting, binding, and verification
- redirect vs HTTP error policy for non-enhanced requests
- role/permission checks
Keep these concerns inside normal app code:
- typed extractors such as `CurrentSession`
- app state such as `AppContext { session, store }`
- generated hemx form fields such as hidden `csrf`
- `Result<impl IntoEffect, AppError>` mapping for enhanced failures
The handler should read like ordinary Rust domain code, not framework magic.
## Axum state and session extractor
A real app would use a provider crate such as `tower-sessions`, `async-session`,
`axum-login`, or a custom signed-cookie middleware. The hemx boundary is the
same either way: produce a typed session before the handler runs.
```rust
use axum::extract::{FromRequestParts, State};
use axum::http::request::Parts;
use axum::response::{IntoResponse, Redirect, Response};
use std::sync::Arc;
#[derive(Clone)]
pub struct SecurityState {
sessions: Arc<dyn SessionStore>,
csrf: Arc<CsrfService>,
}
#[derive(Clone, Debug)]
pub struct CurrentSession {
pub user_id: UserId,
pub email: String,
pub csrf: CsrfToken,
}
pub struct AuthRequired;
impl IntoResponse for AuthRequired {
fn into_response(self) -> Response {
Redirect::to("/login").into_response()
}
}
#[axum::async_trait]
impl FromRequestParts<AppState> for CurrentSession {
type Rejection = AuthRequired;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let cookie = parts
.headers
.get(axum::http::header::COOKIE)
.and_then(|value| value.to_str().ok())
.ok_or(AuthRequired)?;
state
.security
.sessions
.load(cookie)
.await
.ok_or(AuthRequired)
}
}
```
`CurrentSession` is an app extractor. It can be used in normal Axum routes, in
middleware, or copied into `AppContext` before dispatching hemx interactions.
hemx does not need to know how the session was loaded. req: auth/002
## CSRF token in the template
The template stays ordinary HTML: a hidden field plus normal cookie semantics.
The token value is a Rust field rendered by hemplate and parsed by the generated
form type. req: auth/003 req: auth/004 req: auth/005
```heml
<form data-hemx-handle="create_project" data-hemx-form="new_project">
<input type="hidden" name="csrf" +value="self.csrf">
<input name="name" required="required">
<button type="submit">Create project</button>
<p data-hemx-error-for="name"></p>
</form>
```
```rust
#[derive(Clone, Debug)]
#[hemx::form("new_project")]
pub struct NewProject {
csrf: CsrfToken,
name: ProjectName,
}
```
The browser submits the same form with or without the hemx runtime. Cookies,
SameSite behavior, and credential inclusion remain browser/framework concerns.
`hemx_axum::InteractionRequest` accepts only URL-encoded and multipart forms;
apply Axum's `DefaultBodyLimit` (or a compatible host limit) to every mutation
route. Media-type and size checks run before dispatch, while CSRF remains the
explicit application or middleware check shown below. req: security/003
## Mutation handler
Verify the session and CSRF token before persistence. Expected validation
returns a generated form effect; auth/CSRF failures return an application error
that maps to a generated UI effect or an HTTP response depending on the route.
req: form/001 req: failure/004
```rust
#[hemx::handler]
async fn create_project(
State(ctx): State<AppContext>,
Form(form): Form<NewProject>,
) -> Result<impl IntoEffect, AppError> {
let session = ctx.session().ok_or(AppError::MissingSession)?;
ctx.csrf.verify(&session, &form.csrf)?;
if form.name.as_str().is_empty() {
return Err(AppError::Validation("Project name required"));
}
let project = ctx.store.insert(form.name, &session).await?;
let total = ctx.store.list().await?.len();
Ok((
dashboard::project_row.append(ProjectRow::from(project)),
dashboard::summary.set(project_summary(total)),
dashboard::new_project.clear(),
dashboard::flash.set("Project created"),
))
}
```
## Failure mapping
Keep policy in the app error type. Enhanced requests can render generated UI;
non-enhanced routes can redirect or return an HTTP status before hemx dispatch.
```rust
pub enum AppError {
MissingSession,
CsrfRejected,
Validation(&'static str),
StoreUnavailable,
}
impl IntoHandlerFailure for AppError {
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
match self {
Self::MissingSession => HandlerFailure::response(
axum::http::StatusCode::UNAUTHORIZED,
"Sign in to continue",
),
Self::CsrfRejected => HandlerFailure::effects(
dashboard::flash.set("Refresh the page before trying again"),
context,
),
Self::Validation(message) => HandlerFailure::effects(
(
dashboard::new_project.error("name", message),
dashboard::new_project.focus("name"),
),
context,
),
Self::StoreUnavailable => HandlerFailure::effects(
dashboard::flash.set("Project storage is temporarily unavailable"),
context,
),
}
}
}
```
This keeps error policy explicit while preserving the same handler shape as the
local tutorial skeleton.
## Route wiring
For full-page routes, extract the session before rendering. For enhanced
interaction routes, build the app context from the extracted session and shared
application state, then dispatch the generated registry.
```rust
async fn home(
State(app): State<AppState>,
session: CurrentSession,
) -> impl IntoResponse {
Html(home_page(&AppContext::new(session, app.store.clone())).into_string())
}
async fn interact(
State(app): State<AppState>,
session: CurrentSession,
request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> {
let ctx = AppContext::new(session, app.store.clone());
request.dispatch_async(registry(ctx)).await
}
```
The same `AppContext` can contain a SQLx-backed store, an in-memory test store,
or a fake store for unit tests. hemx only observes the typed handler inputs and
the generated effects returned by the handler.
## Tests
Keep provider checks at the application boundary:
- request without a valid session is rejected before mutation
- stale CSRF token does not call the store
- valid session + CSRF stores the project and returns generated row/summary/form
effects
- validation failures target generated form errors, not selectors
`examples/saas` already has the local-store version of these checks; a provider
app should run the same interaction assertions with its real session/CSRF
middleware and store adapter. req: examples/001 req: test/001
-153
View File
@@ -1,153 +0,0 @@
# Recipe: deploy and version compatibility
This recipe describes the production deployment boundary for a hemx app. The
server binary, generated Rust helpers, generated symbol/fingerprint metadata, and
JavaScript runtime asset must be treated as one release unit. hemx core provides
the ABI/fingerprint checks; the application and platform own rollout, caching,
observability, and rollback policy. req: abi/001 req: abi/002 req: runtime/004
Use this for `examples/saas`-style apps before putting multiple app versions
behind a load balancer or CDN.
## Release unit
A compatible release contains:
- the Rust server binary built from the same checkout as `build.rs`
- generated `hemx.generated.rs` and symbols produced during that build
- the `hemx-js` runtime asset served by that server or deployed with the same
release
- templates, CSS, island JavaScript, migrations, and app config for that release
Do not mix a newly built server with an old runtime asset, old generated output,
or old cached page shell. Build fingerprints are derived from Surface/schema/ABI
parts, so mismatches are detected and partial updates fail closed instead of
mutating the wrong DOM. req: abi/003 req: abi/004 req: failure/005
## Asset serving
Serve the embedded runtime at the helper-provided fingerprinted path from the
same release as the server:
```rust
use axum::{routing::get, Router};
use hemx_axum::{runtime_js, runtime_js_path};
let app = Router::new().route(runtime_js_path(), get(runtime));
async fn runtime() -> impl axum::response::IntoResponse {
runtime_js()
}
```
Render page shells with that same `runtime_js_path()` value:
```html
<script +src="self.runtime_src" defer></script>
```
`runtime_js()` is safe for long-lived caching because the public path includes a
hash of the embedded runtime bytes and the response carries immutable cache
headers. Do not publish app-owned version query strings or a long-lived
unversioned runtime URL. CSS and explicit island scripts should follow the same
release path policy.
## Rolling deploys
Rolling deploys are safe when every response serves a self-consistent release.
The easiest policy is sticky-by-release routing:
- page HTML, interaction POSTs, SSE/polling endpoints, and the
`runtime_js_path()` asset come from the same server revision
- a load balancer cookie or platform routing key keeps an active browser on one
revision during the rollout window
- old revisions stay alive until active SSE connections and in-flight forms have
drained
If sticky routing is not available, make the mismatch behavior user-safe:
- keep full page GETs compatible across one adjacent version when practical
- allow interaction responses to fail closed on fingerprint mismatch
- prefer redirect/reload fallback over best-effort partial mutation
- report mismatch counts so rollouts can be paused quickly
The runtime must not grow a negotiation protocol or compatibility shim in core;
capability negotiation belongs to optional integration crates. req: runtime/004
## Fingerprint and mismatch behavior
Initial roots carry the build fingerprint, and effect responses carry the
fingerprint for the batch. The runtime compares them before applying effects.
On mismatch, the app should recover by reloading or navigating to a full page
owned by the current server revision. req: abi/003 req: abi/004
Recommended app behavior:
```text
fingerprint mismatch
-> record metric: hemx.fingerprint_mismatch
-> show a short-lived "Updating…" notice if possible
-> perform full page reload/navigation
```
Never ignore a mismatch to preserve a partial update. Resource ids are stable
within a build and best-effort across compatible symbol paths, but they are not a
persistence or cross-version addressing contract. req: abi/005
## Semver policy for v1 apps
For v1, document changes in three buckets:
- **Beginner API:** generated helpers, `#[hemx::app]`, `#[hemx::component]`,
`#[hemx::handler]`, `#[hemx::form]`, generated page-boundary rendering, tuple
`IntoEffect`, and `Result<impl IntoEffect, E>` mapping. Breaking changes
require a major version or an explicit migration note.
- **Wire/runtime ABI:** EffectBatch schema, runtime ABI version, and fingerprint
inputs. Incompatible changes must bump ABI versions and fail closed at runtime.
- **Advanced escape hatches:** raw effects, manual registries, low-level ids,
raw render/target construction, runtime hooks, SSE internals, and island
internals. These may evolve faster, but must remain named as advanced and must
not leak into beginner docs. req: public_api/002 req: public_api/005
Upgrade notes should explain what changed, whether generated code must be
regenerated, whether the helper-provided runtime asset must be rolled with the
server, and what fallback users see if an old page talks to a new server. Use
`docs/versioning.md` as the release-policy checklist.
## Deployment checklist
Before promoting a release:
```sh
cargo run -p hemx-xtask -- test
cargo check --workspace
redgate refs
```
Then verify deployment-specific behavior:
- page HTML includes the intended `runtime_js_path()`, CSS, and island asset
release paths
- interaction endpoints return the same build fingerprint as the initial root
- SSE/polling endpoints stream batches from the same revision
- a stale page talking to the new server reloads or navigates instead of applying
a partial update
- fingerprint mismatch metrics/logs are visible to the platform team
- rollback serves a self-consistent old server/runtime pair
These checks belong in the app/platform pipeline. hemx should provide the small
runtime handshake and clear failure boundary, not a deployment platform.
## Observability hooks
Track deployment compatibility as app/platform metrics:
- `hemx.fingerprint_mismatch`
- `hemx.effect_decode_error`
- `hemx.missing_target`
- `hemx.sse_reconnect`
- `hemx.full_reload_fallback`
The metric names are suggestions, not core API. The important behavior is that a
team can see mismatches, pause a rollout, and recover with a full page response
without weakening the runtime's tiny, selectorless contract. req: failure/001 req: failure/005
-69
View File
@@ -1,69 +0,0 @@
# Recipe: typed host capabilities
`hemx-host` is the boundary between a hemx app and a browser, PWA,
WebView, or native shell. It is not a mobile framework and it is not a new UI
runtime. A host adapter can perform explicit host side effects or return facts;
app code still owns domain decisions and returns normal hemx effects. req: host/001 req: host/002
## Contract
Declare the capability shape the app may use:
```rust
use hemx_host::{Capability, CapabilityManifest, CapabilityShape, CapabilityUse};
let manifest = CapabilityManifest::new([
CapabilityUse::new(Capability::Haptics, CapabilityShape::Fire),
CapabilityUse::new(Capability::Share, CapabilityShape::Request),
]);
```
Check the manifest against the concrete host profile before executing calls:
```rust
use hemx_host::{HostProfile, HostCheckError};
let host = HostProfile::new(
"web",
[CapabilityUse::new(Capability::Share, CapabilityShape::Request)],
);
let result: Result<(), HostCheckError> = manifest.check(&host);
```
Permission-sensitive capabilities such as microphone, camera, notifications,
secure storage, file picker, and geolocation need a user-facing reason in the
manifest before standard host checks pass. req: host/003 req: host/004
## Browser/PWA adapter
`hemx-host::BROWSER_HOST_JS` is an optional tiny browser adapter. It exposes
`window.hemxBrowserHost.perform(call)`, accepts the serde JSON shape of
`HostCall`, calls browser APIs such as `navigator.share` or `navigator.vibrate`,
and returns the serde JSON shape of `HostEvent`. It does not query, patch, or
own the DOM; the app consumes the host event and returns ordinary hemx effects.
req: host/001 req: host/002 req: host/005
## Event flow
Host events are facts, not app mutations. Denied, timeout, unavailable, and
error cases all use `HostEvent::Failed(HostFailure { kind, ... })`, so app code
handles one typed result shape before producing UI effects:
```text
HostEvent
→ app/domain command
→ domain validation and optional persistence
→ projection/rendering
→ generated UI effects
```
Adapters must not mutate DOM, append domain events, or write application state
on behalf of the app. req: host/002 req: host/005
## Mobile
iOS and Android shells are thin host adapters around a WebView. They implement
manifest-backed calls such as haptics, share, microphone streams, secure
storage, notifications, and explicit custom capabilities; hemx still owns UI
effects and the app still owns state. req: host/001 req: host/002
-55
View File
@@ -1,55 +0,0 @@
# Recipe: local command log
A hemx app may feel local-first without making hemx core a client database or
sync framework. The local artifact is an app-owned command/event log plus a
projection; hemx effects are rendered output, not stored truth. req: local/001
req: local/002
## Decision: no `hemx-local` crate yet
`hemx local` remains an app/recipe pattern for now, not a reusable hemx layer.
The host capability path proves that thin typed contracts work when the shared
semantics are obvious: manifest, call, event, and host-check failure. The local
exemplar proves a safer boundary for offline work: command, domain event,
projection, then generated UI effects. It does not yet prove common storage,
reconciliation, export, deletion, or conflict semantics across apps, so a crate
would freeze product policy too early. req: local/002 req: local/003 req:
local/004
A future reusable layer must first prove at least two independent apps share the
same command-log contract without sharing domain policy, storage provider, sync
provider, or conflict rules. Until then, recipes and app-owned integrations are
more honest and easier to delete. req: local/002 req: local/003
## Shape
```text
user intent
→ LocalCommand
→ domain validation
→ LocalEvent
→ Projection
→ generated UI effects
```
The log may live in memory, IndexedDB, SQLite, a native host store, or another
app-chosen persistence layer. That storage choice is not hemx core. req: local/002
## Replay and sync
Replaying local work to a server, remote AI/STT gateway, backup target, or peer
sync engine is explicit product policy. The app decides what can be queued,
exported, deleted, reconciled, retried, rejected, or redacted. A local projection
can render immediate feedback while those decisions remain pending. req: local/003
## Boundary
Do not persist DOM patches as truth. Do not persist generated UI effect payloads
as the local application log. Those are render instructions produced after
app/domain code accepts commands and projects events. req: local/001 req:
local/004
Use `hemx-host` only when the local log needs device or shell capabilities such
as secure storage, files, haptics, microphone, or notifications. The host still
returns facts; app code still owns the command/event/projection policy. req:
host/002 req: local/003
-119
View File
@@ -1,119 +0,0 @@
# Recipe: Workout mobile release
The Workout exemplar is the production-shaped mobile path for hemx. It stays
boring on purpose: hemx builds the server app and writes mobile shell metadata;
Android/iOS SDKs, store signing, provisioning, and submission remain external
vendor work. req: examples/011
## Command surface
Create a standalone phone-first starter from the public app command when you want
this path outside the repository:
```sh
cargo run -p hemx-xtask -- app new --mobile PATH
```
The created app includes app-owned `hemx-app mobile-release` and
`hemx-app mobile-verify` commands, plus `MOBILE_STARTER.md` naming the host,
recovery, and release-kit boundary. req: ceremony/006
## When to use this path
Use hemx mobile when the app is still a Rust-owned hypermedia product: forms,
lists, keyed partial updates, server-verified actions, installability, offline or
host recovery from app-owned command/event/projection truth, and a few explicit
host capabilities such as share, haptics, clipboard, notifications, or file
picking. The payoff is fewer moving parts: no client component runtime, no native
UI abstraction, no plugin marketplace, and no hidden mobile state graph. req:
ceremony/006 req: examples/011
Do not use hemx mobile as a replacement for apps whose product center is heavy
native UI, games, deep OS integration, camera-heavy capture/editing, complex
native navigation stacks, background services, or complex multi-device offline
sync. For those, keep hemx as a server/API surface or use an explicit native
shell/island where the browser should not own the interaction. req: host/002
For the in-repository exemplar:
```sh
cargo run -p hemx-xtask -- workout dev
cargo run -p hemx-xtask -- workout test
cargo run -p hemx-xtask -- workout build
cargo run -p hemx-xtask -- workout mobile-release
cargo run -p hemx-xtask -- workout mobile-verify
cargo run -p hemx-xtask -- workout doctor
```
`workout mobile-release` builds `target/release/hemx-workout-example` and writes
a release kit under `target/hemx-mobile/workout` by default:
```text
target/hemx-mobile/workout/
release-manifest.json
BLOCKERS.md
android/twa-release.json
android/README.md
ios/webview-release.json
ios/README.md
```
Use `workout mobile-verify` as the store-readiness product gate: it runs the
Workout product tests, then checks the generated kit and release binary. It
fails on broken app value/recovery/host-boundary tests, a non-HTTPS production
origin, missing/inconsistent Android or iOS metadata, or external
toolchain/signing blockers that were not written into the manifest and
`BLOCKERS.md`. Use `workout doctor` when you only want to see missing external
inputs.
## Production configuration
Set these explicitly for a real app release:
```sh
HEMX_WORKOUT_APP_ID=com.example.workout
HEMX_WORKOUT_APP_NAME="Workout Copilot"
HEMX_WORKOUT_VERSION=1.0.0
HEMX_WORKOUT_ORIGIN=https://workout.example.com
HEMX_WORKOUT_ANDROID_PACKAGE=com.example.workout
HEMX_WORKOUT_IOS_BUNDLE_ID=com.example.workout
HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout
```
The generated manifest records:
- app identity and version;
- the production HTTPS origin used by Android and iOS shells;
- `target/release/hemx-workout-example` as the server artifact;
- the exact runtime asset path and SHA-256 digest served by the same release;
- `asset-integrity.tsv` as a plain-text integrity receipt for mobile shell review;
- cache policy: release-scoped HTML/CSS/runtime assets only;
- offline truth policy: app-owned command/event/projection records, never DOM
patches or UI effect payloads;
- host capability policy: Android and iOS shell metadata declare share/haptics and
the same denied, timeout, unavailable, and error result kinds handled by app
code before UI effects;
- environment/secrets boundary: public shell config in the kit, signing secrets
outside the repo;
- rollback: redeploy the previous server binary and rebuild store artifacts from
the previous shell metadata/signing inputs. req: local/001 req: host/002
## Android and iOS artifacts
The command writes release-ready metadata, not store-signed binaries. That is the
honest boundary: producing `.aab`/`.apk` and `.ipa` files requires vendor SDKs,
signing credentials, and store accounts on the release machine.
Android blockers are reported when the Android SDK/JDK/signing key or Play
Console submission target are not visible. iOS blockers are reported when Xcode,
the Apple signing team, or the App Store Connect submission team are not visible.
These blockers are copied into `BLOCKERS.md` so the release kit can be reviewed
without guessing what is still external. req: examples/006
## What this does not add
This is not a `hemx-mobile` framework, sync layer, client database, or native UI
runtime. The mobile shells load the production Workout web app and route host
capabilities such as share/haptics through the typed host boundary before UI
effects are produced; denied, timeout, unavailable, and error cases share the
same host result shape. req: host/002 req: local/002
-206
View File
@@ -1,206 +0,0 @@
# Recipe: observability, feature flags, and killswitches
This recipe shows where production telemetry and rollout controls belong in a
hemx app. Metrics, traces, feature flags, A/B assignment, and killswitches are
application/platform integrations, not hemx core features. hemx should expose a
small effect boundary, preserve normal HTTP behavior, and leave provider choice
to the app. req: laws/002 req: laws/004
Use this with `examples/saas` after the auth/session, CSRF, persistence, and
deploy/versioning boundaries are in place.
## Boundary rule
Keep these concerns outside hemx crates:
- metrics/tracing providers such as OpenTelemetry, Datadog, Prometheus, Honeycomb,
or platform logs
- feature flag providers and assignment stores
- A/B test bucketing and analytics destinations
- rollout and killswitch policy
- alerting, dashboards, and incident response
Keep these concerns in app/integration code:
- route and handler spans
- effect-response counters
- provider-specific labels and sampling policy
- generated UI effects that show degraded or disabled states
- app-owned flags passed through typed state or extractors
The normal handler shape remains typed Rust returning generated effects.
## Instrument routes and dispatch, not the runtime
Instrument the server boundary around ordinary Axum routes and hemx interaction
dispatch. The browser runtime should not become an analytics SDK.
```rust
async fn interact(
State(app): State<AppState>,
session: CurrentSession,
request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> {
let handle_id = request.handle_id();
let span = tracing::info_span!(
"hemx.interaction",
handle_id,
user_id = %session.user_id,
release = %app.release_id,
);
async move {
let ctx = AppContext::new(session, app.store.clone(), app.flags.clone());
let result = request.dispatch_async(registry(ctx)).await;
match &result {
Ok(_) => metrics::counter!("hemx.interaction.ok").increment(1),
Err(_) => metrics::counter!("hemx.interaction.error").increment(1),
}
result
}
.instrument(span)
.await
}
```
The exact crates are app choices. The important part is that observability wraps
routes, handlers, and provider adapters instead of adding client-side state or
selector-based probes. req: runtime/003 req: runtime/004
## Feature flags as typed app state
Flags should be ordinary typed state. Handlers read the flag and return generated
UI effects or normal HTTP responses.
```rust
#[derive(Clone)]
pub struct FeatureFlags {
project_creation: bool,
beta_metrics_island: bool,
}
#[derive(Clone)]
pub struct AppContext {
session: CurrentSession,
store: ProjectStore,
flags: FeatureFlags,
}
#[hemx::handler]
async fn create_project(
State(ctx): State<AppContext>,
Form(form): Form<NewProject>,
) -> Result<impl IntoEffect, AppError> {
if !ctx.flags.project_creation {
return Ok((
dashboard::flash.set("Project creation is temporarily disabled"),
dashboard::new_project.disable_while_pending(),
));
}
ctx.verify_csrf(&form.csrf)?;
let project = ctx.store.insert(form.name, &ctx.session).await?;
Ok((
dashboard::project_row.append(ProjectRow::from(project)),
dashboard::new_project.clear(),
dashboard::flash.set("Project created"),
))
}
```
A flag provider may refresh `FeatureFlags` from a database, config service, or
static file. hemx does not need a flag API; the generated helpers are enough to
show enabled, disabled, or degraded UI.
## Killswitches
A killswitch is a product decision at the application boundary. Prefer explicit
failure or degraded UI over silently dropping effects.
Good killswitch targets:
- disable one mutation handler while leaving page rendering intact
- switch from enhanced interaction to full-page form response
- disable an island or live status stream while keeping the server-rendered page
usable
- pause SSE/polling and show a generated status message
Example for an SSE/live-status killswitch:
```rust
pub fn live_status(ctx: &AppContext) -> impl IntoEffect {
if !ctx.flags.live_status {
return dashboard::live_status.set("Live status is paused");
}
dashboard::live_status.set(format!("heartbeat: {} projects", ctx.projects().len()))
}
```
Do not add a generic client-side killswitch to the runtime. The runtime applies
checked effects; the app decides which effects to produce. req: failure/004
## A/B tests and analytics
A/B assignment belongs in auth/session or request context:
```rust
pub struct ExperimentContext {
variant: &'static str,
}
#[hemx::handler]
async fn open_settings(
State(ctx): State<AppContext>,
) -> impl IntoEffect {
let panel = if ctx.experiments.variant == "compact" {
SettingsPage::compact()
} else {
SettingsPage::full()
};
(
dashboard::page_panel.put(&panel),
dashboard::nav.set("Settings"),
hemx::push("/settings"),
)
}
```
Analytics can be emitted server-side when the handler runs or through explicit
native events returned by the handler. Avoid hidden DOM scraping or selector
listeners as the normal path.
## Metrics to track
Suggested app/platform metrics:
- `hemx.interaction.ok`
- `hemx.interaction.error`
- `hemx.form.parse_error`
- `hemx.handler.failure`
- `hemx.fingerprint_mismatch`
- `hemx.missing_target`
- `hemx.sse.reconnect`
- `hemx.killswitch.active`
Provider names, label sets, sampling, and retention are platform decisions. Do
not bake them into hemx core.
## Tests
Keep tests at the app boundary:
- flag disabled: handler does not call the store and returns a generated disabled
or flash effect
- flag enabled: handler follows the normal generated-helper path
- killswitch active: live status or island is paused with generated UI feedback
- provider failure: app maps the failure through `AppError` without panicking
- metrics wrapper records ok/error paths without changing effect contents
`examples/saas` can exercise those checks with an in-memory fake flag provider;
a real deployment can use the same tests around a provider-backed `FeatureFlags`
loader. req: examples/001 req: test/001
-133
View File
@@ -1,133 +0,0 @@
# Recipe: optional PWA/offline adapter boundary
This recipe describes how a hemx app can add a cached shell or offline queue
without turning core hemx into a client app framework. Offline/PWA support is
opt-in adapter territory: reuse generated targets and server-canonical effects,
but keep service workers, queues, conflict policy, and local storage outside
`hemx`, `hemx-core`, `hemx-build`, `hemx-derive`, `hemx-axum`, and the tiny
runtime. req: canonical_authoring/008 req: canonical_authoring/018 req: canonical_authoring/019 req: runtime/003 req: runtime/004
Use this only after the normal server-first path works. A hemx app is allowed to
fail interactions while offline and recover with a full page once the network is
back.
## Boundary rule
Keep these concerns outside hemx core:
- service worker registration and cache policy
- local persistence stores such as IndexedDB
- offline mutation queues
- background sync, retry, and conflict resolution
- CRDTs or collaborative sync engines
- analytics for offline queue health
Keep these concerns in app/integration code:
- deciding which pages/assets are safe to cache
- deciding which mutations may be queued
- serializing a domain command for later replay
- reconciling queued commands with server-canonical effect responses
- showing generated UI feedback such as "offline", "queued", "synced", or
"conflict"
The normal path remains server-first typed handlers and generated effects.
## Cached shell
A PWA shell may cache page HTML, CSS, the matching `runtime_js_path()` asset, and
explicit island scripts for one release. It must obey the same release-unit
policy as `docs/recipes/deploy-versioning.md`: cached server HTML and cached
runtime assets must be compatible with the server that receives later
interactions. req: abi/002 req: abi/004
Recommended behavior:
- cache only content-addressed or release-scoped assets
- evict cached shells on release/fingerprint mismatch
- fall back to a full page GET when unsure
- do not patch cached DOM with selector retargeting
The service worker is app code. hemx core should not register or own it.
## Offline mutation queue
If a mutation is safe to queue, store an app-domain command, not a raw DOM patch
or runtime opcode:
```rust
#[derive(serde::Serialize, serde::Deserialize)]
pub enum OfflineCommand {
CreateProject { csrf: CsrfToken, name: ProjectName },
}
```
When the browser is offline, the adapter can add the command to an IndexedDB
queue and show generated UI feedback from the app shell:
```rust
pub fn queued_project_notice() -> impl IntoEffect {
(
dashboard::flash.set("Project will be created when you are back online"),
dashboard::live_status.set("Offline: 1 change queued"),
)
}
```
When the network returns, replay the command to the normal server endpoint. The
server still runs auth/session, CSRF, validation, persistence, and returns the
canonical generated effects. req: auth/002 req: auth/004 req: failure/004
Do not store `EffectBatch` as the source of truth for later replay. Effects are
UI outcomes for a server decision; queued commands are user intent that the
server must validate again.
## Reconciliation
The server is authoritative. A replay may succeed, fail validation, fail auth,
fail CSRF, or conflict with newer state. The adapter should apply the returned
server effects when compatible and otherwise navigate/reload to server-rendered
truth.
Suggested outcomes:
- **success:** apply generated append/replace/remove/summary effects from the
server response
- **validation failure:** apply generated form error/focus effects
- **auth or CSRF failure:** discard or pause the queue and navigate to sign-in or
refresh the page
- **conflict:** ask the server for the current page/partial and replace a
generated target, or show a generated conflict notice
- **fingerprint mismatch:** reload/navigate instead of applying queued effects
This keeps conflict policy in the app and keeps core runtime selectorless. req: failure/005
## Optional sync crate shape
A future `hemx-sync` or app-local adapter may provide helpers around this model,
but it should remain optional and explicit:
```rust
pub trait OfflineQueue {
async fn push(&self, command: OfflineCommand) -> Result<(), QueueError>;
async fn drain(&self, session: CurrentSession) -> Result<(), QueueError>;
}
```
Such an adapter may reuse generated slots, forms, and keyed resources, but it
must not make every app value a client-side atom or introduce a mandatory local
state graph. req: sync/001 req: sync/007
## Tests
Keep tests at the adapter boundary:
- offline command is stored as a domain command, not a raw effect
- queued command replays through the same handler route as an online submit
- server validation and CSRF checks still run during replay
- fingerprint/runtime mismatch causes reload/navigation instead of partial apply
- conflict response uses generated UI feedback or full page refresh
- no selector targeting or client app store is required for normal forms/lists
For the current v1 tutorial, `examples/saas` remains the server-first canonical
path. Offline/PWA is an optional recipe, not required app scaffolding. req: examples/001 req: test/001
-41
View File
@@ -1,41 +0,0 @@
# Where are my components?
In hemx, the reusable UI unit is a **checked hemplate partial plus generated Rust
helpers**, not a client component instance. You still get reuse and composition;
the ownership moves to places Rust apps can inspect and test. req: canonical_authoring/002 req: canonical_authoring/003
| Framework component job | hemx home |
| --- | --- |
| Markup and local UI shape | A `.heml` partial rendered from a Rust view struct. |
| Props | The view struct fields passed into the partial/helper. |
| Stable child identity | `h-key` on repeated partials, exposed through generated keyed helpers. |
| Events | Real forms, links, handles, and explicit generated events. |
| State | App-owned Rust state, commands/events/projections, or integration-owned stores. |
| Updating the UI | Generated commands such as `ui::todo_row.replace(row)`. |
| Composition | `impl IntoEffect`: tuples for fixed mixed batches, arrays for fixed repeated batches, and `Vec<T: IntoEffect>` for dynamic repeated batches. |
| Client-only widgets | Explicit islands or Web Components at leaf boundaries. |
A reusable row should be one partial used in both places: initial render and later
updates. The handler builds domain state, converts it to a view value, and returns
generated commands:
```rust
(
rows
.into_iter()
.map(|row| ui::todo_row.replace(row))
.collect::<Vec<_>>(),
ui::summary.set(summary),
ui::notice.set("Saved"),
)
```
That is the component story: the row partial is reusable; the generated helper
knows the target and swap kind; `IntoEffect` composes the update without a client
component runtime, selector lookup, raw ids, raw opcodes, or manual registry
plumbing. req: public_api/005
Use an island only when the browser must own high-frequency local behavior, such
as a chart, map, editor, or media widget. The island is an explicit leaf; it can
emit facts back through generated handles/events, but the app still changes
server-owned UI through normal hemx effects. req: interop/003
-174
View File
@@ -1,174 +0,0 @@
# Recipe: SQLx persistence for the SaaS tutorial
This recipe replaces the tutorial app's in-memory `LocalProjectStore` with an
application-owned SQLx adapter. SQLx is deliberately a recipe dependency, not a
hemx core dependency: hemx still sees ordinary Rust domain values, typed forms,
and generated UI commands. req: laws/002 req: laws/004 req: auth/001
Use this when the `examples/saas` flow is ready to persist projects outside the
process. Keep auth/session and CSRF checks in middleware/extractors or app state,
then call the store from the handler only after those checks pass. req: auth/002 req: auth/004
## Cargo feature in the app, not hemx
Add SQLx to the application crate that owns persistence:
```toml
# examples/saas/Cargo.toml or your app crate
[dependencies]
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "macros", "migrate"] }
```
Do not add SQLx to `hemx`, `hemx-core`, `hemx-build`, `hemx-derive`, or
`hemx-axum`. Persistence is app/domain policy, not a UI runtime primitive.
## Schema
```sql
-- migrations/0001_projects.sql
CREATE TABLE projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
owner_email TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
## Adapter
The adapter has the same shape as `LocalProjectStore`: insert a domain command,
return a domain record, and let the handler convert that record into the
hemplate view type used by generated helpers. req: examples/001 req: canonical_authoring/002
```rust
use sqlx::{Row, SqlitePool};
#[derive(Clone)]
pub struct SqlxProjectStore {
pool: SqlitePool,
}
impl SqlxProjectStore {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub async fn insert(
&self,
name: ProjectName,
session: &Session,
) -> Result<ProjectRecord, AppError> {
let row = sqlx::query(
r#"
INSERT INTO projects (name, owner_email)
VALUES (?, ?)
RETURNING id, name, owner_email
"#,
)
.bind(name.as_str())
.bind(&session.email)
.fetch_one(&self.pool)
.await
.map_err(AppError::from_sqlx)?;
Ok(ProjectRecord {
id: ProjectId(row.try_get::<i64, _>("id").map_err(AppError::from_sqlx)? as u64),
name: row.try_get("name").map_err(AppError::from_sqlx)?,
owner: row.try_get("owner_email").map_err(AppError::from_sqlx)?,
})
}
pub async fn list(&self) -> Result<Vec<ProjectRecord>, AppError> {
let rows = sqlx::query(
r#"
SELECT id, name, owner_email
FROM projects
ORDER BY id
"#,
)
.fetch_all(&self.pool)
.await
.map_err(AppError::from_sqlx)?;
rows.into_iter()
.map(|row| {
Ok(ProjectRecord {
id: ProjectId(row.try_get::<i64, _>("id").map_err(AppError::from_sqlx)? as u64),
name: row.try_get("name").map_err(AppError::from_sqlx)?,
owner: row.try_get("owner_email").map_err(AppError::from_sqlx)?,
})
})
.collect()
}
}
```
Keep SQLx errors in the app error type and map them through the existing
`Result<impl IntoEffect, AppError>` boundary. Expected validation remains a
form UI effect; unexpected persistence failure becomes an app failure effect or
HTTP response. req: failure/004
```rust
impl AppError {
fn from_sqlx(error: sqlx::Error) -> Self {
eprintln!("project store failed: {error}");
AppError::StoreUnavailable
}
}
```
## Handler boundary
The handler shape does not change. Only the store implementation changes.
```rust
#[hemx::handler]
async fn create_project(
State(ctx): State<AppContext>,
Form(form): Form<NewProject>,
) -> Result<impl IntoEffect, AppError> {
ctx.require_session()?;
ctx.verify_csrf(&form.csrf)?;
if form.name.as_str().is_empty() {
return Err(AppError::Validation("Project name required"));
}
let project = ctx.store.insert(form.name, &ctx.session).await?;
let total = ctx.store.list().await?.len();
Ok((
dashboard::project_row.append(ProjectRow::from(project)),
dashboard::summary.set(project_summary(total)),
dashboard::new_project.clear(),
dashboard::flash.set("Project created"),
))
}
```
The important invariant is that SQLx never appears in templates, generated
helpers, the JavaScript runtime, or hemx core. It is an application adapter
behind ordinary Rust state. req: invariant/005
## Test shape
Prefer an app-level integration test with an in-memory SQLite pool and migrations:
```rust
let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let ctx = AppContext::with_store(Session::demo(), SqlxProjectStore::new(pool));
let response = InteractionRequest::from(form(
dashboard::create_project,
&[("csrf", "demo-csrf"), ("name", "Launch checklist")],
))
.dispatch_async(registry(ctx.clone()))
.await?;
let effects = inspect_batch(response.batch);
assert!(effects.inserts_html_containing(dashboard::project_row, "1", "Launch checklist"));
```
This proves the same generated form/slot/keyed-row behavior as the local adapter
while exercising a real provider at the application boundary. req: examples/001 req: test/001
-230
View File
@@ -1,230 +0,0 @@
# Tutorial: production-shaped SaaS app
This walkthrough explains the canonical v1 tutorial path in `examples/saas`.
It is intentionally provider-light: the app proves auth/session shape,
CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain
CSS, and one explicit island without moving SQL, auth, flags, deploy, or
observability providers into hemx core. req: examples/001 req: laws/002
Run it:
```sh
cargo run -p hemx-saas-example
cargo test -p hemx-saas-example
```
## What you are building
The tutorial app is a small project dashboard:
- a full page shell rendered by Rust and hemplate
- a `Dashboard` template with a project creation form
- typed domain inputs: `CsrfToken`, `ProjectName`, and `ProjectId`
- an app-owned `LocalProjectStore` persistence adapter
- an auth/session-shaped `AppContext`
- a CSRF-checked mutation handler
- generated form, summary, flash, keyed row, page-panel, and live-status effects
- an SSE/polling-shaped live status endpoint
- plain CSS and one explicit metrics island script
The important point is not the project domain; it is the boundary: templates
declare the UI surface, Rust owns domain state, handlers return generated UI
commands, and the browser runtime only applies checked effects. req: canonical_authoring/001 req: modes/001
## Files to read first
- `examples/saas/templates/dashboard.heml` — the UI contract
- `examples/saas/src/lib.rs` — domain types, app context, handlers, and tests
- `examples/saas/src/main.rs` — Axum route wiring and runtime/static assets
- `examples/saas/templates/app.css` — plain CSS
- `examples/saas/templates/metrics.js` — explicit leaf-island JavaScript
- `examples/saas/README.md` — scope and provider boundaries
## 1. Declare the surface in hemplate
The dashboard template names only facts that hemx can check and generate:
```heml
<section data-hemx-root="dashboard" data-hemx-sse="/events">
<form data-hemx-handle="create_project" data-hemx-form="new_project">
<input type="hidden" name="csrf" +value="self.csrf">
<input name="name" required="required">
<p data-hemx-error-for="name"></p>
</form>
<p data-hemx-slot="flash">{+ self.flash +}</p>
<p data-hemx-slot="summary">{+ self.summary +}</p>
<ul data-hemx-slot="project_row">
<template h-for="row in &self.rows" h-key="row.id">
{+ row +}
</template>
</ul>
</section>
```
There are no selectors, numeric ids, raw targets, or runtime opcodes in the
template. The `h-key` gives the keyed row target enough information for generated
append/replace/remove helpers. `{+ row +}` renders the child hemplate partial;
`{+= html =+}` is only for already-trusted HTML. req: canonical_authoring/002 req: list/001
## 2. Keep domain types ordinary
The form type is Rust domain code, not a generated DTO:
```rust
#[derive(Clone, Debug)]
#[hemx::form("new_project")]
pub struct NewProject {
csrf: CsrfToken,
name: ProjectName,
}
```
`ProjectName` trims submitted input via `FromStr`; `CsrfToken` is a typed value;
`ProjectId` implements `Display` for stable keyed row ids. The generated form
contract checks that the Rust shape matches the HTML controls. req: form/001 req: codegen/004
## 3. Put platform boundaries in app state
`AppContext` carries the authenticated session and persistence adapter:
```rust
#[derive(Clone)]
pub struct AppContext {
session: Session,
store: LocalProjectStore,
}
```
The local store is deliberately small and testable. Production providers are
recipes, not core dependencies:
- SQLx: `docs/recipes/sqlx-persistence.md`
- auth/session and CSRF middleware: `docs/recipes/auth-session-csrf.md`
- observability, feature flags, and killswitches:
`docs/recipes/observability-flags.md`
- deploy/runtime compatibility: `docs/recipes/deploy-versioning.md`
This keeps hemx focused on the UI contract while the app owns platform choices.
req: auth/001 req: laws/004
## 4. Write one boring handler
The create handler checks session/CSRF, validates input, persists a record, and
returns generated UI commands:
```rust
#[hemx::handler]
async fn create_project(
State(ctx): State<AppContext>,
Form(form): Form<NewProject>,
) -> Result<impl IntoEffect, AppError> {
if form.csrf != ctx.session.csrf {
return Err(AppError::CsrfRejected);
}
if form.name.as_str().is_empty() {
return Err(AppError::Validation("Project name required"));
}
let project = ctx.store.insert(form.name, &ctx.session)?;
let total = ctx.projects().len();
Ok((
dashboard::project_row.append(ProjectRow::from(project)),
dashboard::summary.set(project_summary(total)),
dashboard::new_project.clear(),
dashboard::flash.set("Project created"),
dashboard::live_status.set(format!("{total} projects persisted locally")),
))
}
```
The handler does not choose targets with CSS selectors, construct raw effects,
parse raw forms, or call the runtime. It returns intent through generated helpers
and tuple composition. req: canonical_authoring/003 req: dx/007
## 5. Map failures explicitly
Expected validation and platform failures cross one app error boundary:
```rust
impl IntoHandlerFailure for AppError {
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
match self {
AppError::Validation(message) => HandlerFailure::effects(
(
dashboard::new_project.error("name", message),
dashboard::new_project.focus("name"),
),
context,
),
other => HandlerFailure::effects(dashboard::flash.set(other.message()), context),
}
}
}
```
That keeps user mistakes visible in the generated form error target and keeps
infrastructure failures out of the normal success path. req: failure/004
## 6. Add page and push shape without a frontend app
The settings handler swaps a generated page panel and pushes history:
```rust
(
dashboard::page_panel.put(&SettingsPage { message: "..." }),
dashboard::nav.set("Settings"),
hemx::push("/settings"),
)
```
The live-status endpoint sends generated effect batches over SSE/polling-shaped
transport. Routing, auth, and connection policy stay in Axum/app code; hemx does
not become a router or transport framework. req: page_swap/002 req: push/003
## 7. Keep CSS and islands explicit
Appearance is plain CSS in `templates/app.css`. The metrics widget is an opaque
leaf island declared with `data-hemx-island="metrics"` and implemented by
`templates/metrics.js`. The island may inspect its own leaf DOM; ordinary forms,
lists, page swaps, and live status do not require handwritten JavaScript. req: canonical_authoring/007 req: dx/008
## 8. Test at the product boundary
`cargo test -p hemx-saas-example` proves the tutorial shape:
- the page contains the root, generated form, CSRF field, SSE marker, island,
CSS, and island asset
- stale CSRF does not mutate the store and maps to generated UI
- validation maps to a generated form error
- valid mutation persists locally and returns generated keyed row, summary, form,
and live-status effects
- page swap and push shape use generated targets
These tests are intentionally app-level. They prove behavior without browser
provider setup or external database side effects. req: test/001 req: examples/001
## 9. Productionize by swapping adapters, not changing hemx
To move from the local tutorial skeleton to production:
1. Replace `LocalProjectStore` with a SQLx adapter from
`docs/recipes/sqlx-persistence.md`.
2. Replace the demo `Session` with an Axum/Tower extractor and CSRF service from
`docs/recipes/auth-session-csrf.md`.
3. Wrap routes/handlers with app-owned metrics, flags, and killswitches from
`docs/recipes/observability-flags.md`.
4. Add optional PWA/offline behavior only through the adapter boundary in
`docs/recipes/pwa-offline.md`.
5. Deploy server, generated output, and the helper-provided runtime asset as one
release unit following `docs/recipes/deploy-versioning.md`.
6. Follow `docs/versioning.md` for semver and upgrade notes.
7. Use `docs/diagnostics.md` when a template/build/derive/runtime mistake fails
the app.
The handler and template model should stay recognizable throughout those swaps.
If productionizing requires raw ids, selector retargeting, manual registries, or
client app state, treat that as a design smell and either add a named advanced
escape hatch or keep the provider integration outside the beginner path. req: public_api/005 req: runtime/003
-166
View File
@@ -1,166 +0,0 @@
# Hemx v1 product evidence
This document records external evidence used to sharpen the hemx v1 requirements.
It is not authority over `REQUIREMENTS.md`, and precedent does not prove demand.
The product decision remains: checked hypermedia for Rust, with server-first as the
simple default and client-local/offline execution as explicit opt-in layers over
the same generated-resource and effect contract.
Research checked on 2026-07-13.
## User job and alternatives
The target user is a Rust team building an interaction-heavy web application that
wants server-rendered HTML and ordinary Rust domain logic without accepting a
second selector/string contract or a component/VDOM runtime. Today that team can:
- use server-only hypermedia and accept round-trip latency;
- add handwritten JavaScript and own two state/effect models;
- adopt React/Vue or another client framework for local interaction;
- use LiveView/Turbo-style server-driven interaction; or
- build a local-first sync engine directly.
Those alternatives work. Hemx v1 is justified only if execution location can be
an opt-in handler choice while generated resources, `EffectBatch`, failure
semantics, and server authority stay coherent.
## Evidence and decisions
### Linear: local responsiveness requires a real sync architecture
Sources:
- [Scaling the Linear Sync Engine](https://linear.app/now/scaling-the-linear-sync-engine)
- [Linear Method](https://linear.app/method/introduction)
- [Linear Security](https://linear.app/security)
- [How Linear uses Google Cloud databases](https://cloud.google.com/blog/products/databases/product-workflow-tool-linear-uses-google-cloud-databases)
Linear materializes fast local interaction with a client-side data model and a
server replication/sync system rather than hiding latency behind cosmetic
loading states. Its published architecture discusses initial synchronization,
real-time updates, database change capture, and scaling work; its product method
also values deliberate, opinionated workflows. Its security page treats access,
encryption, backups, monitoring, incident handling, and independent assurance as
operational systems rather than UI features.
**Use in hemx:** client-local work must be genuinely local; offline/sync must have
durable identities, bounded queues, resumable acknowledgement, migration,
conflict/rejection behavior, and observable recovery. Production proof must cover
operations and failures, not only the happy-path API.
**Do not copy:** hemx is a framework, not Linear's product. It must not grow issue
tracking, workspace policy, SSO/SCIM, a hosted database, or a mandatory global
client graph. Authentication, authorization, encryption policy, backups, and
retention remain application/platform concerns; hemx integrations must expose
boundaries that let applications enforce and test them.
### Local-first: offline is a data-ownership and recovery promise
Source: [Local-first software: You own your data, in spite of the cloud](https://www.inkandswitch.com/essay/local-first/).
The local-first work identifies availability without a network, multi-device
coordination, ownership, longevity, and collaboration as distinct properties. A
cache or optimistic DOM patch does not establish them.
**Use in hemx:** persisted commands/domain events are truth; DOM effects are
projections. Queue durability, export/deletion, schema upgrades, conflict policy,
and recovery from corruption/quota failure must be explicit. “Offline capable”
cannot mean only that a shell loads.
**Do not copy:** CRDTs are not the default. Hemx v1 keeps the server authoritative
and requires explicit opt-in policy where collaboration semantics differ.
### Hypermedia and live-server systems: preserve browser and deploy semantics
Sources:
- [HTMX documentation](https://htmx.org/docs/)
- [Phoenix LiveView deployments](https://hexdocs.pm/phoenix_live_view/deployments.html)
- [Turbo Handbook](https://turbo.hotwired.dev/handbook/introduction)
These systems demonstrate progressive enhancement, history-aware navigation,
request synchronization, server-driven DOM updates, reconnect/deployment
concerns, and the value of preserving ordinary links and forms.
**Use in hemx:** real `href`/form fallback, back/forward correctness, stale-request
suppression, deploy fingerprint refusal, reconnect behavior, and clear full-page
recovery are release requirements.
**Do not copy:** selector mini-languages, implicit component lifecycles, and a
router owned by core remain outside hemx.
### Platform primitives: use the browser's durable and accessible contracts
Sources:
- [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
- [Using Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers)
- [WCAG 2.2 quick reference](https://www.w3.org/WAI/WCAG22/quickref/)
- [RAIL performance model](https://web.dev/articles/rail)
IndexedDB provides transactional browser storage; service workers provide an
HTTPS-bound offline/network interception lifecycle. WCAG 2.2 makes keyboard
operation, visible focus, status/error communication, and programmatic
name/role/value release concerns. RAIL treats roughly 100 ms as the response
window in which direct manipulation feels immediate.
**Use in hemx:** optional adapters reuse platform storage/service-worker
primitives; storage failures and upgrades are recoverable. Generated interaction
must preserve semantic HTML, keyboard operation, focus, status/error
announcements, and reduced-motion preferences. Client-local interaction gets an
observable latency/frame budget rather than a “fast” adjective.
**Do not copy:** hemx core does not mandate IndexedDB, a service worker, or an
application cache policy.
### Security and release discipline: framework controls need testable boundaries
Sources:
- [OWASP Application Security Verification Standard 5.0](https://owasp.org/www-project-application-security-verification-standard/)
- [Cargo SemVer compatibility](https://doc.rust-lang.org/cargo/reference/semver.html)
- [cargo-audit](https://github.com/rust-secure-code/cargo-audit)
ASVS provides a test-oriented baseline for web controls such as encoding,
injection prevention, session/access boundaries, validation, and logging. Cargo's
SemVer guidance shows that public Rust items, traits, features, MSRV, and runtime
behavior all carry compatibility risk. `cargo-audit` checks the committed lockfile
against RustSec advisories.
**Use in hemx:** unsafe HTML stays type-gated; integrations make origin/CSRF,
authorization, limits, and security logging testable; replay never bypasses
current authorization. v1 has an explicit public/generated/wire/runtime
compatibility policy, migration evidence, MSRV/browser support, and a pinned
lockfile advisory audit before release approval.
**Do not copy:** hemx does not claim application-level ASVS compliance. It proves
only controls and boundaries it owns. Publishing remains a separate explicit
human decision.
## Product thesis
Hemx v1 should feel like boring server-rendered HTML with typed, selectorless
partial swaps, while letting a team opt one handler into local WASM or durable
sync without changing the resource/effect language. The smallest coherent
mechanism is:
1. hemplate Surface facts and generated resources;
2. one handler shape with explicit execution placement;
3. one versioned `EffectBatch` application contract;
4. server-first by default;
5. explicit local state ownership;
6. optional durable command-log/reconciliation adapters; and
7. fail-closed versioning plus native-browser recovery.
## Kill tests
Reshape or drop client-local/sync work if any slice requires:
- a second effect protocol or selector target language;
- hidden global state or a component lifecycle;
- bespoke JavaScript per application handler;
- persisted DOM/effect payloads as domain truth;
- a mandatory browser database, service worker, CRDT, or conflict policy;
- authorization decisions cached across replay without server revalidation; or
- inaccessible interaction or failure states that cannot preserve native HTML
fallback.
-192
View File
@@ -1,192 +0,0 @@
# v1 readiness audit
This audit records the proven server-first/page-enhanced baseline. It is not a
marketing release announcement and no longer claims the full v1 north star is
closed. The product evidence in `docs/v1-product-evidence.md` and current
requirements add client-local WASM, durable offline/sync, accessibility,
security, operations, performance, compatibility, and production-reference
closure. Their implementation order lives in `PLAN.md`. req: examples/001 req: public_api/001 req: v1_release/001
## Current status
- Server-first and page-enhanced baseline: proven by the evidence below.
- Client-local WASM: real generated-resource browser/WASM execution proven.
- Durable offline/sync and multiplayer milestone: framework-owned replay,
acknowledgement, convergence, presence, recovery, and accessibility proven.
- Production reference: authenticated mutation, origin/CSRF denial, atomic
rollback-safe persistence, restart recovery, health/readiness, diagnostics,
metrics, CSP, and mixed-build fail-closed recovery proven.
- V1 closure matrix: not closed. The recorded local workspace, browser,
performance, docs, and example gates pass, warning-denied vulnerability and
source audits are clean, and the mutation-applicable library/proc-macro matrix
has no unexplained survivors. Strict license closure still awaits an owner-chosen
license for 20 currently unlicensed workspace packages and an allowlist decision
for Apache-2.0, Apache-2.0 WITH LLVM-exception, BSD-3-Clause, BSL-1.0, MIT,
Unicode-3.0, and Unlicense dependencies; this blocks a production-ready claim.
req: test/020 req: test/021 req: v1_release/006
- Publishing and deployment: explicitly unauthorized.
## Baseline evidence
### Canonical tutorial app
Status: satisfied.
Evidence:
- `examples/saas` is a compile-tested tutorial app with typed domain values,
`#[hemx::form("new_project")]`, auth/session-shaped `AppContext`, CSRF-safe
mutation, local persistence adapter, generated keyed row/form/summary/page/live
effects, full-page route fallback for settings, enhanced page-panel swap,
SSE/polling shape, plain CSS, one explicit metrics island, and tests.
- `docs/tutorial-saas.md` walks through the app from template to production
provider handoff.
- `docs/recipes/sqlx-persistence.md` shows how to replace `LocalProjectStore`
with an app-owned SQLx adapter without moving SQLx into core.
Release decision:
- The supported v1 production boundary is the compile-tested local persistence
adapter plus provider-explicit recipes. SQLx/auth/observability/deploy/PWA stay
app integrations rather than required workspace dependencies, so the tutorial
remains runnable in CI without credentials or external services.
### Beginner API stability
Status: satisfied for the current v1 goal.
Evidence:
- Normal path is documented around `app`, `component`, `handler`, `form`,
`page`, generated helpers, tuple `IntoEffect`, and `Result<impl IntoEffect, E>`
mapping.
- `docs/versioning.md` defines stable beginner API vs wire/runtime ABI vs
advanced escape hatches.
- `examples/v0` and `examples/saas` exercise the normal path without manual
registries or raw ids in app authoring.
### Advanced APIs isolated
Status: satisfied.
Evidence:
- `README.md`, `docs/versioning.md`, and `docs/diagnostics.md` identify raw
effects, ids, render/target construction, manual registries, runtime hooks,
SSE internals, and island internals as advanced.
- Public examples label `v0` as beginner, `examples/saas` as the tutorial app,
`kanban` as advanced/north-star, and `techdemo` as advanced.
- Forbidden-normal-path scans only hit explicit route/static asset serving,
deploy/versioning text, or the `examples/saas` metrics island.
### Docs explain the model in one sitting
Status: satisfied.
Evidence:
- `README.md` explains render → slot/key → effect → runtime, forms/errors,
pages/push, CSS/islands, production boundaries, escape hatches, and
deploy/version compatibility.
- `docs/tutorial-saas.md` provides the product walkthrough.
- Recipes cover SQLx, auth/session + CSRF, observability/flags/killswitches,
deploy/versioning, and optional PWA/offline.
- `docs/diagnostics.md` and `docs/versioning.md` cover failure and release
policy.
### Diagnostics
Status: satisfied for the current v1 goal.
Evidence:
- `docs/diagnostics.md` names common mistakes and desired fixes in author
language.
- Existing gates cover build diagnostics, derive compile-fail diagnostics,
runtime root/fingerprint behavior, result-handler mapping, and example
contract checks.
- Final diagnostics gates include `cargo test -p hemx-build`,
`cargo test -p hemx-derive --test compile_fail`, `cargo test -p hemx-js`, and
`cargo test -p hemx-test --test examples_contract`.
### Production recipes
Status: satisfied.
Evidence:
- SQLx: `docs/recipes/sqlx-persistence.md`
- auth/session + CSRF: `docs/recipes/auth-session-csrf.md`
- observability/metrics + feature flags/killswitches:
`docs/recipes/observability-flags.md`
- deploy/versioning: `docs/recipes/deploy-versioning.md`
- mobile release: `docs/recipes/mobile-release.md`
- optional PWA/offline: `docs/recipes/pwa-offline.md`
### Public examples
Status: satisfied.
Evidence:
- `examples/v0/README.md` is the beginner entry.
- `examples/saas/README.md` identifies the production-shaped tutorial app.
- `examples/kanban/README.md` identifies Kanban as advanced/north-star.
- `examples/techdemo/README.md` identifies Techdemo as advanced.
- Contract tests guard against browser JavaScript and low-level resource plumbing
in canonical examples.
### Runtime remains tiny and selectorless
Status: satisfied.
Evidence:
- `README.md`, `docs/versioning.md`, `docs/recipes/deploy-versioning.md`,
`docs/recipes/observability-flags.md`, and `docs/recipes/pwa-offline.md` keep
runtime scope to checked effect application and reject VDOM/hydration/client
store/selector-retargeting growth.
- `examples/saas/templates/metrics.js` uses selectors only inside an explicit
leaf island, not for normal hemx targeting.
- `cargo test -p hemx-js` covers runtime root/fingerprint behavior.
### Versioning explicit
Status: satisfied.
Evidence:
- `docs/versioning.md` defines semver tiers, wire/runtime ABI policy, advanced
escape-hatch policy, upgrade-note template, and release checklist.
- `docs/recipes/deploy-versioning.md` documents release units, asset caching,
rolling deploy behavior, fingerprint mismatch behavior, and rollback checks.
## Final closure gates
The following baseline commands remain required. They are insufficient for full
v1 closure until the browser/WASM/offline/multiplayer, accessibility, security,
performance, compatibility, and production-reference proofs in `v1_release/*`
also pass. Run them only as local validation; none publishes or deploys.
Run these on the final tree before GOAL_DONE:
```sh
cargo run -p hemx-xtask -- test
cargo run -p hemx-xtask -- mutation
cargo check --workspace
cargo test -p hemx-saas-example
cargo test -p hemx-v0-examples
cargo test -p hemx-build
cargo test -p hemx-js
cargo test -p hemx-derive --test compile_fail
cargo test -p hemx-test --test examples_contract
redgate list
redgate refs
redgate health
git diff --check
```
Also run the forbidden-normal-path scan over `README.md`, `docs/`, `examples/v0`,
`examples/saas`, and the public advanced example READMEs. Expected remaining hits
are explicit route/static asset serving, deploy/versioning docs, or explicit
leaf-island JavaScript.
-173
View File
@@ -1,173 +0,0 @@
# v1 versioning and upgrade policy
hemx v1 should be boring to upgrade: beginner apps can rely on the generated
helper and handler model, while advanced escape hatches remain explicitly named
and easier to audit. This policy defines what must be stable for v1 and how to
ship breaking changes without hiding incompatibility behind runtime magic. req: abi/001 req: public_api/001
## Stability tiers
### Stable beginner API
These are the v1 normal path and require semver-major treatment for breaking
changes:
- `#[hemx::surface]`, `#[hemx::app]`, `#[hemx::component]`, `#[hemx::handler]`,
and `#[hemx::form]`
- generated component helpers for slots, keyed partials, forms, handles, page
targets, page-boundary rendering, class tokens, and events
- `hemx::page(...)` only at explicit server shell boundaries
- tuple `IntoEffect` composition
- `Result<impl IntoEffect, E>` handlers with `IntoHandlerFailure`
- generated form commands such as `clear`, `reset`, `error`, and `focus`
- generated keyed commands such as `append`, `replace`, and `remove`
A change is breaking if a production-shaped app like `examples/saas` must rewrite
normal handler/template code that was using those APIs correctly. req: examples/001 req: canonical_authoring/006
### Stable compatibility contract
These must remain explicit and fail closed when incompatible:
- Surface schema version consumed by `hemx_build`
- generated symbols and deterministic resource allocation inputs
- EffectBatch wire/schema ABI
- JavaScript runtime ABI
- build fingerprint inputs and mismatch behavior
An incompatible wire/runtime change must bump the relevant ABI version and cause
old pages or old runtimes to refuse partial updates rather than silently applying
wrong effects. req: abi/002 req: abi/003 req: abi/004 req: failure/005
### Supported compatibility matrix
The v1 support claim is deliberately narrow:
| Boundary | Supported | Fails closed when |
|---|---|---|
| Rust toolchain | stable Rust, workspace edition 2021 | an unsupported compiler cannot build the workspace |
| Browser/WASM | Firefox browser suite plus the generated real-WASM path | WASM/bootstrap cannot load or bind |
| Effect wire | ABI `1` only | decoding preserves the version, `is_compatible()` is false, and runtimes refuse application |
| Generated resources | one matching build fingerprint | a stale fingerprint receives reload recovery instead of mutation |
| Durable sync | schema `1`; legacy flat schema-1 records upgrade in place | unknown schema or malformed projection is rejected |
| Runtime set | same-tree `hemx-js`, `hemx-wasm`, generated bindings, and framework sync runtime | mismatched assets have no compatibility guarantee |
| Canonical examples | `v0`, Kanban, client-local, and SaaS workspace packages | an example no longer builds or its focused proof fails |
No support claim is made for untested browser engines, future wire/schema versions, or arbitrary cross-release runtime mixing. req: abi/001 req: abi/003 req: public_api/003 req: v1_release/007
### Advanced escape hatches
These are public but advanced. They may evolve faster, but every change still
needs a migration note and must not leak into beginner docs:
- raw effects and batches
- manual registries
- low-level resource ids and raw targets
- raw HTML/render/target construction
- runtime hooks and SSE internals
- island internals and custom integration glue
Advanced APIs are for integration crates, tests, migrations, or explicit leaf
boundaries. They are not a second beginner API. req: public_api/002 req: public_api/005
## What counts as breaking
Breaking for the beginner API:
- renaming generated helper methods or changing their return contracts
- requiring manual registry wiring for canonical apps
- requiring user-authored JavaScript or selector targeting for ordinary forms,
partial swaps, page swaps, or SSE/polling
- moving validation/error UI off generated form helpers
- changing handler argument inference so existing valid handlers stop compiling
- changing `Result<impl IntoEffect, E>` mapping so app errors no longer map at
the integration boundary
Breaking for compatibility:
- changing effect wire encoding without an ABI bump
- changing runtime target lookup semantics without a fingerprint/ABI bump
- changing generated id allocation inputs without a fingerprint change
- allowing mismatched server/runtime builds to apply partial updates
Not breaking:
- improving diagnostics while keeping spans and fixes user-facing
- adding generated helpers that are aliases around existing behavior when they
remove real friction
- adding new advanced escape hatches that are clearly named and isolated
- adding production recipes for providers outside core
- changing examples to better express the canonical path, when the documented API
remains compatible
## Upgrade note template
Every release with public API, generated ABI, runtime, or recipe changes should
include upgrade notes with this shape:
````md
## Upgrade to hemx X.Y.Z
### Who is affected
- Beginner app code: yes/no
- Generated helpers: yes/no
- Wire/runtime ABI: yes/no
- Advanced escape hatches: yes/no
- Recipes/examples only: yes/no
### Required actions
- Regenerate generated code with `cargo check` or your normal build.
- Deploy server and the helper-provided runtime asset from the same release if
ABI/fingerprint changed.
- Update any renamed helpers or advanced calls listed below.
### Compatibility behavior
- Old page + new server: reload/fail closed/compatible
- New page + old server: reload/fail closed/compatible
- Rolling deploy requirement: sticky release routing / normal routing
### Migrations
- Before: ...
- After: ...
### Verification
```sh
cargo run -p hemx-xtask -- test
cargo check --workspace
redgate refs
```
````
## Release checklist
Before tagging a v1-compatible release:
- `examples/v0`, `examples/client_local`, `examples/kanban`, and `examples/saas`
compile and their package tests pass; v0 and SaaS remain the canonical public
surface examples without raw ids, raw effects,
selector targeting, manual registries, raw render/lower calls, or user-authored
UI JavaScript in the normal path. req: examples/004 req: examples/005
- `docs/diagnostics.md` describes any new common error class in user language.
req: diag/001 req: diag/002
- The canonical local release gate is `cargo run -p hemx-xtask -- test`; there
are no separate `public-api` or `ownership-check` xtask subcommands.
- `docs/recipes/deploy-versioning.md` remains accurate for runtime asset and
fingerprint behavior.
- Any incompatible generated ABI/runtime change bumps the relevant ABI/fingerprint
inputs and has tests for fail-closed behavior. req: abi/005
- The checked-in ABI-v1 byte fixture in `hemx-core/tests/effect_batch.rs`, the
legacy flat durable-record browser migration, and canonical example package
tests all pass. req: abi/001 req: abi/003 req: v1_release/007
- Advanced APIs touched by the release are still named as escape hatches in docs.
- Upgrade notes state whether users must regenerate code, redeploy the
helper-provided runtime asset, or change app code.
## Policy for v1 cutover
v1 is ready to cut only when the normal path can stay stable for the canonical
SaaS tutorial: hemplate templates, typed handlers, generated helpers, tuple
effects, result error mapping, page/push shape, explicit provider adapters,
plain CSS, and one island boundary. If stabilizing one of those surfaces would
require adding runtime negotiation, selector retargeting, a client state store, or
provider-specific core code, defer the feature or keep it advanced instead of
weakening the v1 contract. req: runtime/003 req: runtime/004 req: laws/004
-39
View File
@@ -1,39 +0,0 @@
# Hemx HEML for VS Code and Cursor
This extension keeps `.heml` files in VS Code's HTML language mode and layers the
shared `hemx-lsp` service on top for diagnostics, completion, and hover. It does
not define a separate grammar, formatter, selector model, or editor-only parser.
req: diagnostics/004 req: diagnostics/005
## Run from a hemx checkout
Open the repository in VS Code/Cursor and use this extension from source. The
extension detects `hemx-lsp/Cargo.toml` at the workspace root and starts:
```sh
cargo run -p hemx-lsp -- lsp
```
## Run with an installed binary
Install the shared service and open any app workspace:
```sh
cargo install --path hemx-lsp
```
The extension then starts:
```sh
hemx-lsp lsp
```
If your binary lives elsewhere, set `hemx.heml.lspCommand` and
`hemx.heml.lspArgs` in VS Code/Cursor settings.
## Behavior
- `.heml` defaults to VS Code's `html` language mode.
- Diagnostics are displayed from `hemx-build` via `hemx-lsp`.
- Completion and hover come from `hemx-lsp` and `docs/hemplate-syntax.md`.
- If the language service cannot start, normal HTML highlighting still works.
-320
View File
@@ -1,320 +0,0 @@
'use strict';
const cp = require('child_process');
const fs = require('fs');
const path = require('path');
const vscode = require('vscode');
let client;
let diagnostics;
function activate(context) {
diagnostics = vscode.languages.createDiagnosticCollection('hemx-build');
context.subscriptions.push(diagnostics);
client = new HemxLspClient(context, diagnostics);
context.subscriptions.push({ dispose: () => client.dispose() });
client.start();
const selector = [
{ scheme: 'file', pattern: '**/*.heml' },
{ scheme: 'untitled', pattern: '**/*.heml' }
];
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(doc => client.didOpen(doc)));
context.subscriptions.push(vscode.workspace.onDidChangeTextDocument(event => client.didChange(event.document)));
context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(doc => client.didSave(doc)));
context.subscriptions.push(vscode.workspace.onDidCloseTextDocument(doc => client.didClose(doc)));
context.subscriptions.push(vscode.languages.registerCompletionItemProvider(selector, {
provideCompletionItems(document, position) {
return client.completion(document, position);
}
}, 'h', '+', 'd', '='));
context.subscriptions.push(vscode.languages.registerHoverProvider(selector, {
provideHover(document, position) {
return client.hover(document, position);
}
}));
for (const doc of vscode.workspace.textDocuments) {
client.didOpen(doc);
}
}
function deactivate() {
if (client) {
client.dispose();
}
}
class HemxLspClient {
constructor(context, diagnosticCollection) {
this.context = context;
this.diagnosticCollection = diagnosticCollection;
this.proc = undefined;
this.buffer = Buffer.alloc(0);
this.nextId = 1;
this.pending = new Map();
this.opened = new Set();
this.ready = Promise.resolve(false);
this.warned = false;
}
start() {
const spec = lspCommandSpec();
try {
this.proc = cp.spawn(spec.command, spec.args, {
cwd: spec.cwd,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
});
} catch (err) {
this.warnOnce(`failed to start hemx-lsp: ${err.message}`);
this.ready = Promise.resolve(false);
return;
}
this.proc.on('error', err => this.warnOnce(`failed to start hemx-lsp: ${err.message}`));
this.proc.stderr.on('data', data => {
const text = data.toString('utf8').trim();
if (text) {
console.error(`[hemx-lsp] ${text}`);
}
});
this.proc.stdout.on('data', data => this.readMessages(data));
this.proc.on('exit', code => {
if (code !== 0 && code !== null) {
this.warnOnce(`hemx-lsp exited with status ${code}; .heml files keep normal HTML support`);
}
});
this.ready = this.request('initialize', {
processId: process.pid,
rootUri: workspaceRootUri(),
capabilities: {}
}).then(() => {
this.notify('initialized', {});
return true;
}).catch(err => {
this.warnOnce(`hemx-lsp initialize failed: ${err.message}`);
return false;
});
}
dispose() {
this.diagnosticCollection.clear();
if (this.proc && !this.proc.killed) {
this.request('shutdown', {}).catch(() => undefined).finally(() => {
this.notify('exit', {});
this.proc.kill();
});
}
}
async didOpen(document) {
if (!isHeml(document)) return;
if (!await this.ready) return;
this.opened.add(document.uri.toString());
this.notify('textDocument/didOpen', {
textDocument: textDocumentItem(document)
});
}
async didChange(document) {
if (!isHeml(document)) return;
if (!await this.ready) return;
if (!this.opened.has(document.uri.toString())) {
return this.didOpen(document);
}
this.notify('textDocument/didChange', {
textDocument: versionedTextDocumentIdentifier(document),
contentChanges: [{ text: document.getText() }]
});
}
async didSave(document) {
if (!isHeml(document)) return;
if (!await this.ready) return;
this.notify('textDocument/didSave', {
textDocument: textDocumentIdentifier(document),
text: document.getText()
});
}
async didClose(document) {
if (!isHeml(document)) return;
this.opened.delete(document.uri.toString());
this.diagnosticCollection.delete(document.uri);
if (!await this.ready) return;
this.notify('textDocument/didClose', {
textDocument: textDocumentIdentifier(document)
});
}
async completion(document, position) {
if (!isHeml(document) || !await this.ready) return undefined;
const response = await this.request('textDocument/completion', {
textDocument: textDocumentIdentifier(document),
position: lspPosition(position)
});
const items = Array.isArray(response) ? response : response && response.items;
if (!Array.isArray(items)) return undefined;
return items.map(toCompletionItem);
}
async hover(document, position) {
if (!isHeml(document) || !await this.ready) return undefined;
const response = await this.request('textDocument/hover', {
textDocument: textDocumentIdentifier(document),
position: lspPosition(position)
});
if (!response || response === null || !response.contents) return undefined;
return new vscode.Hover(markdownFromLsp(response.contents));
}
request(method, params) {
const id = this.nextId++;
this.send({ jsonrpc: '2.0', id, method, params });
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
});
}
notify(method, params) {
this.send({ jsonrpc: '2.0', method, params });
}
send(message) {
if (!this.proc || !this.proc.stdin.writable) return;
const body = Buffer.from(JSON.stringify(message), 'utf8');
this.proc.stdin.write(`Content-Length: ${body.length}\r\n\r\n`);
this.proc.stdin.write(body);
}
readMessages(data) {
this.buffer = Buffer.concat([this.buffer, data]);
while (true) {
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd < 0) return;
const header = this.buffer.slice(0, headerEnd).toString('ascii');
const match = /content-length:\s*(\d+)/i.exec(header);
if (!match) {
this.buffer = this.buffer.slice(headerEnd + 4);
continue;
}
const length = Number(match[1]);
const start = headerEnd + 4;
const end = start + length;
if (this.buffer.length < end) return;
const body = this.buffer.slice(start, end).toString('utf8');
this.buffer = this.buffer.slice(end);
this.handleMessage(JSON.parse(body));
}
}
handleMessage(message) {
if (message.id !== undefined && this.pending.has(message.id)) {
const pending = this.pending.get(message.id);
this.pending.delete(message.id);
if (message.error) pending.reject(new Error(message.error.message || 'LSP request failed'));
else pending.resolve(message.result);
return;
}
if (message.method === 'textDocument/publishDiagnostics') {
this.publishDiagnostics(message.params || {});
}
}
publishDiagnostics(params) {
const uri = vscode.Uri.parse(params.uri);
const mapped = (params.diagnostics || []).map(diag => {
const range = new vscode.Range(
diag.range.start.line,
diag.range.start.character,
diag.range.end.line,
diag.range.end.character
);
const item = new vscode.Diagnostic(range, diag.message, toDiagnosticSeverity(diag.severity));
item.source = diag.source || 'hemx-build';
item.code = diag.code;
return item;
});
this.diagnosticCollection.set(uri, mapped);
}
warnOnce(message) {
if (this.warned) return;
this.warned = true;
vscode.window.showWarningMessage(message);
}
}
function isHeml(document) {
return document.uri.scheme === 'file' && document.fileName.endsWith('.heml');
}
function lspCommandSpec() {
const config = vscode.workspace.getConfiguration('hemx.heml');
const configuredCommand = config.get('lspCommand', '');
const configuredArgs = config.get('lspArgs', []);
const folder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0];
const cwd = folder ? folder.uri.fsPath : process.cwd();
if (configuredCommand) {
return { command: configuredCommand, args: configuredArgs, cwd };
}
if (fs.existsSync(path.join(cwd, 'hemx-lsp', 'Cargo.toml'))) {
return { command: 'cargo', args: ['run', '-p', 'hemx-lsp', '--', 'lsp'], cwd };
}
return { command: 'hemx-lsp', args: ['lsp'], cwd };
}
function workspaceRootUri() {
const folder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0];
return folder ? folder.uri.toString() : null;
}
function textDocumentItem(document) {
return {
uri: document.uri.toString(),
languageId: document.languageId,
version: document.version,
text: document.getText()
};
}
function textDocumentIdentifier(document) {
return { uri: document.uri.toString() };
}
function versionedTextDocumentIdentifier(document) {
return { uri: document.uri.toString(), version: document.version };
}
function lspPosition(position) {
return { line: position.line, character: position.character };
}
function toCompletionItem(item) {
const completion = new vscode.CompletionItem(item.label, vscode.CompletionItemKind.Property);
completion.detail = item.detail;
completion.insertText = item.insertText || item.label;
if (item.documentation) {
completion.documentation = markdownFromLsp(item.documentation);
}
return completion;
}
function markdownFromLsp(contents) {
if (typeof contents === 'string') return new vscode.MarkdownString(contents);
if (contents && typeof contents.value === 'string') return new vscode.MarkdownString(contents.value);
if (Array.isArray(contents)) return new vscode.MarkdownString(contents.map(part => typeof part === 'string' ? part : part.value || '').join('\n\n'));
return new vscode.MarkdownString('');
}
function toDiagnosticSeverity(severity) {
return severity === 1 ? vscode.DiagnosticSeverity.Error : vscode.DiagnosticSeverity.Warning;
}
module.exports = { activate, deactivate };
-44
View File
@@ -1,44 +0,0 @@
{
"name": "hemx-heml",
"displayName": "Hemx HEML",
"description": "Compiler-backed .heml diagnostics, completion, and hover while preserving VS Code HTML tooling.",
"version": "0.1.0",
"publisher": "hemx",
"engines": {
"vscode": "^1.80.0"
},
"categories": [
"Programming Languages"
],
"activationEvents": [
"workspaceContains:**/*.heml",
"onLanguage:html",
"onLanguage:heml"
],
"main": "./extension.js",
"contributes": {
"configurationDefaults": {
"files.associations": {
"*.heml": "html"
}
},
"configuration": {
"title": "Hemx HEML",
"properties": {
"hemx.heml.lspCommand": {
"type": "string",
"default": "",
"description": "Command used to start hemx-lsp. Empty means: use `cargo run -p hemx-lsp -- lsp` inside the hemx repo, otherwise `hemx-lsp lsp`."
},
"hemx.heml.lspArgs": {
"type": "array",
"default": [],
"items": {
"type": "string"
},
"description": "Arguments for hemx.heml.lspCommand. Leave empty to use the automatic repo/installed-binary defaults."
}
}
}
}
}
-26
View File
@@ -1,26 +0,0 @@
[package]
name = "hemx-client-local-example"
version.workspace = true
edition.workspace = true
publish = false
[features]
default = []
fixture = []
[lib]
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "fixture"
path = "src/bin/fixture.rs"
required-features = ["fixture"]
[dependencies]
hemx = { path = "../../hemx", features = ["client"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
hemplate = { path = "../../../hemplate/hemplate" }
[build-dependencies]
hemx-build = { path = "../../hemx-build" }
-5
View File
@@ -1,5 +0,0 @@
fn main() {
hemx_build::app()
.run()
.expect("compile client-local template");
}
-3
View File
@@ -1,3 +0,0 @@
fn main() {
print!("{}", hemx_client_local_example::render_fixture());
}
-22
View File
@@ -1,22 +0,0 @@
#[hemx::surface]
pub mod ui {}
#[cfg(not(target_arch = "wasm32"))]
#[derive(hemplate::Hemplate)]
pub struct ClientLocal;
#[cfg(not(target_arch = "wasm32"))]
pub fn render_fixture() -> hemx::Html {
ui::client_local::render(&ClientLocal)
}
#[hemx::handler(client)]
pub fn increment(
event: hemx::wasm::ClientEvent,
state: hemx::wasm::ClientState,
) -> impl hemx::IntoEffect {
ui::client_local::counter_panel.text(format!(
"updated by Rust/WASM ({}, {})",
event.kind, state.encoded
))
}
@@ -1,4 +0,0 @@
<main data-hemx-root="client_local" data-hemx-st="count=3" data-hemx-client-state-version="1" data-hemx-client-module="/client_local.js">
<section data-hemx-slot="counter_panel">idle</section>
<button type="button" data-hemx-handle="increment" data-hemx-on="click" data-hemx-client="increment" data-hemx-client-policy="latest" data-hemx-client-fallback data-hemx-pending-class="is-pending">Increment locally</button>
</main>
-22
View File
@@ -1,22 +0,0 @@
[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
@@ -1,24 +0,0 @@
# 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
@@ -1,10 +0,0 @@
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
@@ -1,220 +0,0 @@
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);
}
}
@@ -1 +0,0 @@
<strong>Count: {+ self.count +}</strong>
@@ -1,11 +0,0 @@
<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
@@ -1,14 +0,0 @@
{
"$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"] }
]
}
-22
View File
@@ -1,22 +0,0 @@
[package]
name = "hemx-html-examples"
version.workspace = true
edition.workspace = true
publish = false
[lib]
path = "src/lib.rs"
[dependencies]
axum = "0.8"
hemplate = { path = "../../../hemplate/hemplate" }
hemx = { path = "../../hemx" }
hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
[dev-dependencies]
scraper = "0.25"
hemx-test = { path = "../../hemx-test" }
[build-dependencies]
hemx-build = { path = "../../hemx-build" }
-74
View File
@@ -1,74 +0,0 @@
# hemx HTML examples
A copy-pasteable pattern gallery for the boring HTML UX patterns popularized by
htmx. The point is not to clone htmx attributes; it is to show the hemx idiom:
plain `.heml`, generated resources, server-owned Rust state, keyed partials, and
tiny runtime behavior. req: htmx_equivalents/001 req: htmx_equivalents/005 req: examples/001
Run it:
```sh
cargo run -p hemx-html-examples
```
Open <http://127.0.0.1:3029>.
The active-search example is URL state rather than an interaction handle: its
GET form serializes the visible `q` control into the page URL, live input uses
`data-hemx-history="replace"`, and the explicit submit button uses
`data-hemx-history="push"`. Reload, bookmark, and browser back/forward therefore
ask the same server route to re-render the filtered gallery instead of restoring
client-owned search state. req: page_swap/009 req: page_swap/010
## Pattern matrix
Names match the htmx example URL slug exactly, e.g. `modal-custom` from
`https://htmx.org/examples/modal-custom/`. Rust resource names use normal
identifier spelling only where the language requires it.
Status legend:
- **implemented**: copyable `.heml` and server handlers exist in this example.
- **integration-owned**: use hemx generated resources plus app/host/browser policy;
do not grow hemx core for the policy.
- **refused**: would clone htmx/client framework behavior or a third-party UI kit.
- **deferred**: useful, but needs a later vertical slice and proof before becoming
a copyable hemx pattern.
| htmx example slug | Status | hemx idiom / boundary | Proof anchor |
| --- | --- | --- | --- |
| `click-to-edit` | implemented | A read view and edit form are the same generated `contact_card` partial; the server toggles `editing` and returns `gallery::contact_card.replace(...)`. | `templates/partials/contact_card.heml`, `contact_card_handlers::edit_contact`, `save_contact` |
| `bulk-update` | deferred | Same generated-form path as inline validation, but needs a real multi-row selection/write slice so batch semantics are tested instead of claimed. | Next slice should add keyed batch rows plus one server-owned bulk command. |
| `click-to-load` | implemented | The server owns the loaded count and returns generated keyed `loaded_row` replacements plus status text. | `gallery_handlers::load_more`, `LoadedRow` |
| `delete-row` | implemented | Server state removes the row and returns `gallery::editable_row.remove(id)`. | `editable_row_handlers::delete_row` |
| `edit-row` | implemented | A table row is a keyed `.heml` partial with generated edit/save forms; no selector target strings. | `templates/partials/editable_row.heml`, `editable_row_handlers::edit_row`, `save_row` |
| `lazy-load` | implemented | `data-hemx-revealed` dispatches a generated form once when visible; the server swaps a generated lazy panel. | `gallery.heml`, `gallery_handlers::lazy_load`, `LazyPanel` |
| `inline-validation` | implemented | A generated form reports field failure with `validate_email_form.error(...)`, focuses the field, and updates status text. | `templates/gallery.heml`, `gallery_handlers::validate_email` |
| `infinite-scroll` | implemented | A revealed sentinel form posts to the same server-owned loading model and replaces generated keyed rows; `data-hemx-revealed-ahead` opts into viewport-ahead loading without moving the observed element. | `gallery_handlers::infinite_scroll`, `data-hemx-revealed`, `data-hemx-revealed-ahead`, `infinite_row` |
| `active-search` | implemented | The search form uses GET URL state; the server derives result rows and reconciles generated keyed partials by removing filtered-out keys, replacing retained keys, and appending newly visible keys. | `gallery_handlers::search`, `SearchResult` |
| `progress-bar` | implemented | The Tick progress button advances server-owned progress and replaces a generated progress partial with visible percentage text. | `gallery_handlers::tick_progress`, `ProgressMeter` |
| `value-select` | implemented | The first select posts a generated form; the server derives and replaces generated option rows for the second select. | `gallery_handlers::choose_category`, `ValueOption` |
| `animations` | integration-owned | CSS transitions are presentation policy around generated replacements; hemx should only preserve stable DOM boundaries. | Use keyed partials and app CSS; no core animation framework. |
| `file-upload` | integration-owned | Upload transport, size limits, progress, storage, and security are app/integration policy. | Needs product-owned upload route before becoming copyable. |
| `file-upload-input` | integration-owned | Preserving file inputs after errors is browser/security policy; hemx should not fake file state in core effects. | Use app-owned upload form policy. |
| `reset-user-input` | implemented | A generated form updates status and returns `.clear()` after successful submission. | `gallery_handlers::reset_message` |
| `dialogs` | integration-owned | Browser `alert/confirm/prompt` are app policy; hemx can expose event boundaries but should not own dialog UX. | Use native controls or host/app code. |
| `modal-uikit` | refused | Third-party UI kit integration is not a hemx core pattern. | Keep as app-owned integration. |
| `modal-bootstrap` | refused | Third-party UI kit integration is not a hemx core pattern. | Keep as app-owned integration. |
| `modal-custom` | deferred | A custom modal can be a generated partial plus focus/escape policy, but needs accessibility proof before copy/paste. | Later slice should include keyboard/focus tests. |
| `tabs-hateoas` | deferred | Good hemx fit: server-owned selected tab and generated tab panel replacement; needs a focused slice. | Later slice should add one tab group. |
| `tabs-javascript` | refused | Client-owned tab state is exactly what generated server-owned state is meant to avoid unless a product needs it. | Prefer `tabs-hateoas`. |
| `keyboard-shortcuts` | integration-owned | Keyboard policy belongs to the app/host; hemx should only receive explicit events. | Use generated app-level events / app JS when needed. |
| `sortable` | integration-owned | Drag/drop ordering needs a browser library or pointer policy plus server reorder command. | Keep Sortable.js as app-owned integration until proven reusable. |
| `update-other-content` | implemented | Generated effects can update multiple slots from one handler; validation and search already update status plus rows/errors. | `validate_email`, `search` handlers. |
| `confirm` | integration-owned | Confirmation wording and irreversible-action policy belong to the app; hemx should not own a global confirm system. | Use native confirm/app dialog around generated delete forms. |
| `async-auth` | integration-owned | Token refresh/auth sessions belong to auth/session integration, not hemx core. | See auth/session recipe boundary. |
| `web-components` | integration-owned | Shadow DOM/custom elements are host integration; hemx can emit events but should not pierce component internals. | Use app-owned web component adapters. |
| `move-before` | refused | Experimental DOM preservation API is not a stable hemx contract. | Avoid until browser support and a product need make it boring. |
## Boundary
Implemented rows must remain runnable hemx behavior. Deferred/integration-owned/refused
rows are not failures; they prevent a trophy checklist from turning hemx into a
client framework. Promote a deferred row only when the slice proves a reusable,
boring contract with `.heml`, generated resources, server-owned state, and tests.
-3
View File
@@ -1,3 +0,0 @@
fn main() {
hemx_build::app().run().unwrap();
}
-2
View File
@@ -1,2 +0,0 @@
#[hemx::surface]
pub mod ui {}
File diff suppressed because it is too large Load Diff
-124
View File
@@ -1,124 +0,0 @@
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--ink: #222222;
--muted: #666666;
--border: #d4d4d4;
--accent: #3465a4;
--accent-hover: #29528a;
--danger: #c0392b;
--danger-hover: #a93226;
--radius: 6px;
--space: 1.25rem;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: var(--space);
font-family: var(--font-body);
background: var(--bg);
color: var(--ink);
line-height: 1.55;
}
main {
max-width: 820px;
margin: 0 auto;
}
header {
margin-bottom: calc(var(--space) * 1.5);
}
header p:first-child {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.75rem;
color: var(--muted);
margin: 0 0 0.25rem;
}
h1 {
font-family: var(--font-mono);
font-size: 1.75rem;
margin: 0 0 0.5rem;
}
header p:last-child {
color: var(--muted);
margin: 0;
}
section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: calc(var(--space) * 1.25);
margin-bottom: var(--space);
}
section h2 {
font-family: var(--font-mono);
font-size: 1.15rem;
margin: 0 0 var(--space);
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
p { margin: 0 0 var(--space); }
form { margin: 0 0 var(--space); }
label { font-weight: 500; }
article label { display: block; margin-bottom: 0.75rem; }
article label input { display: block; width: 100%; margin-top: 0.3rem; }
input, select, button {
font: inherit;
padding: 0.45rem 0.65rem;
border-radius: var(--radius);
border: 1px solid var(--border);
}
input, select { width: 100%; max-width: 360px; }
input:focus, select:focus, button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
button {
background: var(--accent);
color: #fff;
border-color: var(--accent);
cursor: pointer;
font-weight: 500;
}
button:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
[data-hemx-handle="delete_row"] button,
[data-hemx-handle*="delete"] button,
.danger {
background: var(--danger);
border-color: var(--danger);
}
[data-hemx-handle="delete_row"] button:hover,
[data-hemx-handle*="delete"] button:hover,
.danger:hover {
background: var(--danger-hover);
border-color: var(--danger-hover);
}
td form { display: inline-block; margin-right: 0.4rem; }
table {
width: 100%;
border-collapse: collapse;
margin: var(--space) 0;
}
th, td {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
}
th { font-weight: 600; color: var(--muted); }
ul { padding-left: 1.25rem; margin: 0 0 var(--space); }
progress {
width: 100%;
height: 1rem;
accent-color: var(--accent);
}
[data-hemx-error-for] {
color: var(--danger);
font-size: 0.9rem;
margin: 0.25rem 0 0;
}
.htmx-indicator { opacity: 0.5; }
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hemx HTML examples</title>
<link rel="stylesheet" href="/app.css">
<script +src="self.runtime_src" defer></script>
</head>
<body>
{+= self.body =+}
</body>
</html>
@@ -1,130 +0,0 @@
<main data-hemx-root="gallery">
<header>
<p>Copy-pasteable hemx HTML patterns</p>
<h1>HTML UX pattern gallery</h1>
<p>Server-owned Rust state, boring .heml, generated resources, and tiny runtime behavior.</p>
</header>
<section id="click-to-edit" data-htmx-example="click-to-edit" aria-labelledby="click-to-edit-heading">
<h2 id="click-to-edit-heading">click-to-edit</h2>
<div data-hemx-slot="contact_card">
<template h-for="contact in &self.contacts" h-key="contact.id">
{+ contact +}
</template>
</div>
</section>
<section id="edit-row" data-htmx-example="edit-row" aria-labelledby="edit-row-heading">
<h2 id="edit-row-heading">edit-row</h2>
<p id="delete-row" data-htmx-example="delete-row">delete-row uses the same keyed row partial and a generated remove effect.</p>
<table>
<thead>
<tr><th>Task</th><th>Actions</th></tr>
</thead>
<tbody data-hemx-slot="editable_row">
<template h-for="row in &self.rows" h-key="row.id">
{+ row +}
</template>
</tbody>
</table>
</section>
<section id="inline-validation" data-htmx-example="inline-validation" aria-labelledby="validation-heading">
<h2 id="validation-heading">inline-validation</h2>
<form data-hemx-handle="validate_email" data-hemx-form="validate_email" data-hemx-on="input">
<label>Email <input name="email" +value="self.email" required="required"></label>
<button type="submit">Validate</button>
<p data-hemx-error-for="email"></p>
<p data-hemx-slot="email_status">{+ self.email_status +}</p>
</form>
</section>
<section id="lazy-load" data-htmx-example="lazy-load" aria-labelledby="lazy-heading">
<h2 id="lazy-heading">lazy-load</h2>
<form data-hemx-handle="lazy_load" data-hemx-form="lazy_load" data-hemx-revealed="true">
<input type="hidden" name="request" value="lazy">
<button type="submit">Load lazy content</button>
</form>
<div data-hemx-slot="lazy_panel">{+ self.lazy_panel +}</div>
</section>
<section id="click-to-load" data-htmx-example="click-to-load" aria-labelledby="load-heading">
<h2 id="load-heading">click-to-load</h2>
<ul data-hemx-slot="loaded_row">
<template h-for="row in &self.loaded_rows" h-key="row.id">
{+ row +}
</template>
</ul>
<form data-hemx-handle="load_more" data-hemx-form="load_more">
<input type="hidden" name="request" value="more">
<button type="submit">Load more</button>
</form>
<p data-hemx-slot="load_status">{+ self.load_status +}</p>
</section>
<section id="infinite-scroll" data-htmx-example="infinite-scroll" aria-labelledby="infinite-heading">
<h2 id="infinite-heading">infinite-scroll</h2>
<ul data-hemx-slot="infinite_row">
<template h-for="row in &self.infinite_rows" h-key="row.id">
{+ row +}
</template>
</ul>
<form data-hemx-handle="infinite_scroll" data-hemx-form="infinite_scroll" data-hemx-revealed="true" data-hemx-revealed-ahead="1">
<input type="hidden" name="request" value="more">
<button type="submit">Reveal more rows</button>
</form>
<p data-hemx-slot="infinite_status">{+ self.infinite_status +}</p>
</section>
<section id="progress-bar" data-htmx-example="progress-bar" aria-labelledby="progress-heading">
<h2 id="progress-heading">progress-bar</h2>
<form data-hemx-handle="tick_progress" data-hemx-form="tick_progress">
<input type="hidden" name="request" value="tick">
<button type="submit">Tick progress</button>
</form>
<p data-hemx-slot="progress_meter">
<progress max="100" +value="self.progress">{+ self.progress_label +}</progress>
</p>
</section>
<section id="value-select" data-htmx-example="value-select" aria-labelledby="value-heading">
<h2 id="value-heading">value-select</h2>
<form data-hemx-handle="choose_category" data-hemx-form="choose_category">
<label>Category
<select name="category">
<option value="letters">Letters</option>
<option value="numbers">Numbers</option>
</select>
</label>
<button type="submit">Choose</button>
</form>
<select data-hemx-slot="value_option" name="value">
<template h-for="option in &self.value_options" h-key="option.id">
{+ option +}
</template>
</select>
</section>
<section id="reset-user-input" data-htmx-example="reset-user-input" aria-labelledby="reset-heading">
<h2 id="reset-heading">reset-user-input</h2>
<form data-hemx-handle="reset_message" data-hemx-form="reset_message">
<label>Message <input name="message"></label>
<button type="submit">Send and reset</button>
</form>
<p data-hemx-slot="reset_status">{+ self.reset_status +}</p>
</section>
<section id="active-search" data-htmx-example="active-search" aria-labelledby="search-heading">
<h2 id="search-heading">active-search</h2>
<form method="get" action="/" data-hemx-history="replace" data-hemx-on="input" data-hemx-debounce="150ms">
<label>Search <input name="q" +value="self.query"></label>
<button type="submit" data-hemx-history="push">Search</button>
</form>
<p data-hemx-slot="search_status">{+ self.search_status +}</p>
<ul data-hemx-slot="search_result">
<template h-for="result in &self.search_results" h-key="result.id">
{+ result +}
</template>
</ul>
</section>
</main>
@@ -1,15 +0,0 @@
<article +data-key="self.id">
<div h-if="!self.editing">
<h3>{+ self.name +}</h3>
<p>{+ self.email +}</p>
<form data-hemx-handle="edit_contact" data-hemx-form="edit_contact">
<button type="submit" name="id" +value="self.id">Edit</button>
</form>
</div>
<form h-if="self.editing" data-hemx-handle="save_contact" data-hemx-form="save_contact">
<input type="hidden" name="id" +value="self.id">
<label>Name <input name="name" +value="self.name" required="required"></label>
<label>Email <input name="email" +value="self.email" required="required"></label>
<button type="submit">Save</button>
</form>
</article>
@@ -1,14 +0,0 @@
<tr +data-key="self.id">
<td h-if="!self.editing">{+ self.title +}</td>
<td h-if="!self.editing">
<form data-hemx-handle="edit_row" data-hemx-form="edit_row"><button type="submit" name="id" +value="self.id">Edit</button></form>
<form data-hemx-handle="delete_row" data-hemx-form="delete_row"><button type="submit" name="id" +value="self.id">Delete</button></form>
</td>
<td h-if="self.editing" colspan="2">
<form data-hemx-handle="save_row" data-hemx-form="save_row">
<input type="hidden" name="id" +value="self.id">
<label>Task <input name="title" +value="self.title" required="required"></label>
<button type="submit">Save</button>
</form>
</td>
</tr>
@@ -1 +0,0 @@
<li +data-key="self.id">{+ self.title +}</li>
@@ -1 +0,0 @@
<li +data-key="self.id">{+ self.label +}</li>
@@ -1 +0,0 @@
<option +data-key="self.id" +value="self.value" +selected="self.selected">{+ self.label +}</option>
-343
View File
@@ -1,343 +0,0 @@
# Milestone: Local-first Multiplayer Kanban
A board with drag-and-drop cards, 60fps pointer-follow, optimistic updates,
offline queue, conflict reconciliation, live presence, and SSR-first rendering —
all without React/Vue/VDOM, in a single typed Rust codebase.
This is an explicitly advanced/low-level north-star boundary sketch for hemx + hemplate + hemx-sync, not the beginner-facing authoring path. Raw sync/effect/wire vocabulary below is excluded from beginner-facing examples by design. req: milestone/001 req: milestone/002 req: milestone/003
---
## 1. Template: `board.heml`
```html
<section data-hemx-root="board" data-hemx-slot="board" data-hemx-atom="board">
<header>
<h1>{+ self.title +}</h1>
<form data-hemx-handle="create_card" data-hemx-form="create_card">
<input name="title" type="text" required>
<select name="column">
<template h-for="column in &self.columns" h-key="column.id">
<option +value="column.id">{+ column.title +}</option>
</template>
</select>
<button>Add card</button>
</form>
</header>
<div class="columns" data-hemx-slot="columns">
<template h-for="column in &self.columns" h-key="column.id">
<section class="column" data-hemx-slot="column" +data-column-id="column.id">
<h2>{+ column.title +}</h2>
<div class="cards" +data-column-id="column.id">
<template h-for="card in &column.cards" h-key="card.id">
<article class="card" data-hemx-slot="card" data-hemx-handle="drag_card" +data-card-id="card.id" draggable="true">
<strong>{+ card.title +}</strong>
<small>{+ card.assignee +}</small>
</article>
</template>
</div>
</section>
</template>
</div>
<aside data-hemx-slot="presence">
<template h-for="user in &self.online_users" h-key="user.id">
<span>{+ user.name +}</span>
</template>
</aside>
</section>
```
Notes on keyed scopes:
- `h-for="column in &self.columns" h-key="column.id"` — **required** for hemx-addressable nodes inside
- `h-for="card in &column.cards" h-key="card.id"` — **required**
- A slot inside a keyed loop is addressed as a generated keyed resource, never by selector strings or positional DOM targeting
- Without `h-key`, hemx rejects the build — no runtime selector fallback
---
## 2. What hemplate exports
hemplate does **not** interpret `data-hemx-*`. It records raw facts:
```rust
Node {
id: NodeId(12),
element: "article",
attrs: [
("class", "card"),
("data-hemx-slot", "card"),
("data-hemx-handle", "drag_card"),
("data-card-id", "{card.id}"),
("draggable", "true"),
],
scope: ScopeId(For { binding: "card", key_expr: "card.id" }),
}
FormSurface {
handle_attr: Some("create_card"),
controls: [
Control { name: "title", kind: Text, required: true },
Control { name: "column", kind: Select, required: true },
],
}
```
hemx reads this from hemplate Surface facts and generates scoped typed resources:
```rust
use ui::board::{forms, targets};
targets::card.replace(card.id, &CardView::from(card));
forms::create_card.clear("title");
ui::page(&BoardView::from(board));
```
No string desync. No manual ids. The generated module owns the names.
---
## 3. App State
```rust
#[hemx::app]
pub struct BoardApp {
pub board: Atom<BoardState>,
pub drag: Atom<Option<DragState>>,
pub online_users: Atom<Vec<UserPresence>>,
}
```
The same struct runs on server (SSR) and in WASM (client-local effects).
---
## 4. Normal Form: Server-first
```rust
#[derive(HemxForm)]
pub struct CreateCardForm {
pub title: String,
pub column: ColumnId,
}
#[hemx::handler]
pub fn create_card(
form: Form<CreateCardForm>,
app: &mut BoardApp,
) -> impl IntoEffect {
let card = Card { id: CardId::new(), title: form.title, assignee: "Thomas".into() };
app.board.update(|board| board.insert_card(form.column, card.clone()));
(
targets::card.append(card.id, &CardView::from(card)),
forms::create_card.clear("title"),
// hemx-sync: queue atomic board state diff for sync
SyncEffect::send_patch(atoms::board, Patch::insert_card(form.column, card)),
)
}
```
HTML submits as usual. Server returns a typed update batch. Browser applies DOM ops.
---
## 5. Drag: 60fps client-local WASM
```rust
#[hemx::handler(client)]
pub fn drag_card(
event: DragEvent,
app: &mut BoardApp,
) -> impl IntoEffect {
app.drag.set(Some(DragState {
card_id: event.card_id,
from_column: event.column_id,
pointer_x: event.x,
pointer_y: event.y,
}));
// Client-local extension APIs stay typed by generated resources;
// names below are illustrative until hemx-sync lands.
Effect::batch((
Effect::class_keyed(slots::CARD, event.card_id, "dragging", true),
Effect::transform_keyed(
slots::CARD,
event.card_id,
Transform::translate(event.x, event.y),
),
))
}
```
Zero round-trip. Zero custom JS. Pure Rust → typed updates → DOM.
---
## 6. Drop: optimistic update + sync
```rust
#[hemx::handler(client)]
pub fn drop_card(
event: DropEvent,
app: &mut BoardApp,
) -> impl IntoEffect {
let patch = app.board.update(|board| {
board.move_card(event.card_id, event.to_column, event.before_card)
});
app.drag.set(None);
// Client-local extension APIs stay typed by generated resources;
// names below are illustrative until hemx-sync lands.
Effect::batch((
Effect::move_keyed(
slots::CARD,
event.card_id,
slots::COLUMN,
event.to_column,
InsertBefore(event.before_card),
),
Effect::class_keyed(slots::CARD, event.card_id, "dragging", false),
// hemx-sync: queue patch, send when online
SyncEffect::send_patch(atoms::BOARD, patch),
))
}
```
A pure htmx+SSR app cannot model this: 60fps pointer → local transient drag → optimistic update → offline queue → reconciliation. You'd need custom JS or a parallel React/Vue layer.
hemx models it in one type graph.
---
## 7. Server reconciliation
```rust
#[hemx_sync::handler]
pub fn apply_board_patch(
patch: BoardPatch,
app: &mut BoardApp,
user: UserId,
) -> impl IntoEffect {
let result = app.board.update(|board| board.apply_patch_from(user, patch));
match result {
PatchResult::Accepted { changed_cards } => Effect::batch((
Effect::ack(atoms::BOARD),
Effect::broadcast(
Channel::Board(app.board.id()),
Effect::batch(changed_cards.into_iter().map(|c|
targets::card.replace(c.id, &CardView::from(c))
)),
),
)),
PatchResult::Conflict { canonical_board } => Effect::batch((
Effect::set(atoms::BOARD, canonical_board.clone()),
targets::board.put(&BoardView::from(canonical_board)),
)),
}
}
```
Server-authoritative on conflict. No Redux sagas. No React Query cache fades.
---
## 8. Presence
```rust
#[hemx_sync::presence]
pub fn user_joined(user: UserPresence) -> impl IntoEffect {
targets::presence_user.append(user.id, &PresenceBadge::from(user))
}
```
Browser receives typed update bytes over WebSocket/SSE:
```text
append keyed presence user
remove keyed presence user
```
The runtime does not know "presence". It executes generated DOM updates.
---
## 9. What app authors write; what the browser receives
Initial SSR stays an ordinary rendered template with symbolic hemx attributes at
the authoring boundary:
```html
<section data-hemx-root="board" data-hemx-slot="board" data-hemx-atom="board">
...
<article data-hemx-slot="card" data-hemx-handle="select_card" +data-card-id="card.id">
{+ card.title +}
</article>
...
</section>
<!-- the app shell loads the helper-provided runtime asset and any bootstrap state -->
```
The compiler lowers those symbols to compact runtime metadata, but that metadata
is not an app-authoring contract. Runtime attachment: the helper-provided runtime
asset installs delegated root listeners for forms, clicks, and pointer/drag
events. App authors keep composing generated resources; they do not attach
per-node listeners, copy numeric ids, or write selector glue.
No framework download. No VDOM. No hydration. No game loop.
---
## 10. Why this is not a React/Vue/htmx app
| Concern | React/Vue | htmx+SSR | hemx |
|---|---|---|---|
| SSR | RSC/Vue SSR | native | native (hemplate) |
| 60fps drag | 100ms re-render + React-DnD | custom JS | WASM handler, typed update |
| Optimistic update | useOptimistic | impossible | `board.update` → `SyncEffect::send_patch` |
| Offline support | Service Worker + custom | impossible | patch queue in `hemx-sync` |
| Conflict resolution | manual / Yjs CRDT | impossible | server-authoritative patch |
| Presence | WebSocket + custom state | SSE possible | `Effect::broadcast` over channel |
| Keyed DOM | React key | not a concern | `KeyedSlot<K, T>` compile-time |
| Forms | React Hook Form | HTML native, but no validation bridge | `Form<T>` derived from `.heml` surface |
| Routing | React Router / Vue Router | HTML links, but no state routing | `Effect::navigate` with scroll/title |
| Total JS shipped | ~300KB+ | ~20KB htmx + custom | ~3KB hemx.js interpreter |
---
## 11. The claim
```text
A local-first multiplayer board where all high-frequency UI runs as Rust/WASM effects,
all durable state syncs through hemx-sync,
all HTML is hemplate-rendered,
and the browser runtime only executes typed postcard DOM ops.
```
Not:
```text
server Rust here
client TypeScript there
shared schema somewhere
validation duplicated
DOM identity by positional DOM lookup
state sync by convention
```
But:
```text
Rust owns types.
hemplate owns structure.
hemx owns interaction.
browser executes ops.
```
-47
View File
@@ -1,47 +0,0 @@
[package]
name = "hemx-kanban-example"
version.workspace = true
edition.workspace = true
publish = false
[features]
default = ["server"]
server = ["dep:axum", "dep:futures-util", "dep:hemx-axum", "dep:serde", "dep:serde_json", "dep:tokio"]
client = ["hemx/client"]
fixture = []
[lib]
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "hemx-kanban-example"
path = "src/main.rs"
required-features = ["server"]
[[bin]]
name = "client-fixture"
path = "src/bin/client_fixture.rs"
required-features = ["fixture"]
[dependencies]
axum = { version = "0.8", optional = true }
futures-util = { version = "0.3", optional = true }
hemx = { path = "../../hemx" }
hemx-axum = { path = "../../hemx-axum", optional = true }
hemx-sync = { path = "../../hemx-sync" }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "time"], optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
hemplate = { path = "../../../hemplate/hemplate" }
[dev-dependencies]
hemx-test = { path = "../../hemx-test" }
scraper = "0.25"
thirtyfour = "0.35"
tower = { version = "0.5", features = ["util"] }
[build-dependencies]
hemx-build = { path = "../../hemx-build" }
-20
View File
@@ -1,20 +0,0 @@
# hemx Kanban advanced milestone example
This is an explicitly advanced/low-level north-star boundary sketch, not beginner-facing guidance. It exercises the product boundary described in `../kanban.md`; use `examples/v0` for the canonical beginner path.
Run:
cargo run -p hemx-kanban-example
Open <http://127.0.0.1:3001>.
The example is a server-first Kanban board with:
- add-card form
- move-left / move-right card controls
- delete-card controls
- generated target objects for checked slot updates
- tuple-composed `IntoEffect` responses
- SSE presence updates
It intentionally uses buttons instead of custom JavaScript drag-and-drop; drag/local-first sync remain north-star features in `examples/kanban.md`.
-3
View File
@@ -1,3 +0,0 @@
fn main() {
hemx_build::app().run().unwrap();
}
@@ -1,3 +0,0 @@
fn main() {
print!("{}", hemx_kanban_example::render_client_fixture());
}
-243
View File
@@ -1,243 +0,0 @@
#[hemx::surface]
pub mod ui {}
#[cfg(feature = "client")]
use hemx_sync::SyncEffect as DurableSync;
#[cfg(feature = "client")]
#[derive(Clone, Debug, Eq, PartialEq)]
struct CardId(String);
#[cfg(feature = "client")]
struct ReorderCommand {
card: CardId,
input_kind: String,
}
#[cfg(feature = "client")]
struct CardReordered {
card: CardId,
input_kind: String,
}
#[cfg(feature = "client")]
struct BoardProjection {
first: CardId,
}
#[cfg(feature = "client")]
struct ProjectedReorder {
card: CardId,
before: Option<CardId>,
input_kind: String,
}
#[cfg(feature = "client")]
impl ReorderCommand {
fn from_client(event: hemx::wasm::ClientEvent) -> Self {
Self {
card: CardId(
event
.value
.filter(|card| !card.is_empty())
.unwrap_or_else(|| "1".into()),
),
input_kind: event.kind,
}
}
fn decide(self) -> CardReordered {
CardReordered {
card: self.card,
input_kind: self.input_kind,
}
}
}
#[cfg(feature = "client")]
impl BoardProjection {
fn restore(state: hemx::wasm::ClientState) -> Self {
Self {
first: CardId(state.encoded.split('|').next().unwrap_or("1").to_owned()),
}
}
fn apply(self, event: CardReordered) -> ProjectedReorder {
let before = (event.card != self.first).then_some(self.first);
ProjectedReorder {
card: event.card,
before,
input_kind: event.input_kind,
}
}
}
#[cfg(feature = "client")]
#[hemx::handler(client)]
pub fn reorder_card(
event: hemx::wasm::ClientEvent,
state: hemx::wasm::ClientState,
) -> impl hemx::IntoEffect {
let projected =
BoardProjection::restore(state).apply(ReorderCommand::from_client(event).decide());
let card = projected.card.0;
let patch = hemx_sync::FlatPatch::for_interaction(
"cardColumn",
hemx_sync::PatchValue::String("done".to_owned()),
)
.expect("generated Kanban patch is valid");
let move_effect = match projected.before {
Some(before) => ui::client_board::client_cards.move_before(card.clone(), before.0),
None => ui::client_board::client_cards.move_to_end(card.clone()),
};
DurableSync::durable(
patch,
(
move_effect,
ui::client_board::client_notice
.text(format!("Moved {card} with {}", projected.input_kind)),
),
ui::BUILD_FINGERPRINT,
)
}
#[cfg(all(test, feature = "client"))]
mod client_tests {
use super::*;
use hemx::IntoEffect;
#[test]
fn client_reorder_carries_flat_patch_in_ordinary_effect_batch() {
let batch = reorder_card(
hemx::wasm::ClientEvent {
kind: "drop".to_owned(),
value: None,
checked: None,
key: None,
},
hemx::wasm::ClientState {
encoded: "1|2".to_owned(),
},
)
.into_batch(ui::BUILD_FINGERPRINT);
assert_eq!(batch.ops.len(), 3);
let wire = String::from_utf8_lossy(&batch.to_wire()).into_owned();
assert!(wire.contains(hemx_sync::PATCH_EVENT));
assert!(wire.contains("$hemx-interaction"));
assert!(wire.contains("\"projection\":["));
}
}
#[cfg(all(feature = "fixture", not(target_arch = "wasm32")))]
mod fixture {
use super::ui;
use hemplate::Hemplate;
use hemx::Html;
#[derive(Hemplate)]
struct ClientBoard {
cards: Vec<ClientCard>,
}
#[derive(Hemplate)]
struct ClientCard {
id: u64,
title: &'static str,
}
pub fn render() -> Html {
ui::client_board::page(&ClientBoard {
cards: vec![
ClientCard {
id: 1,
title: "First",
},
ClientCard {
id: 2,
title: "Second",
},
],
})
}
}
#[cfg(all(feature = "fixture", not(target_arch = "wasm32")))]
pub fn render_client_fixture() -> hemx::Html {
fixture::render()
}
#[cfg(test)]
mod tests {
use super::ui::{board, board_card};
use hemplate::Hemplate;
use hemx::IntoEffect;
use hemx_test::inspect;
use scraper::{Html, Selector};
#[derive(Hemplate)]
#[hemplate = "partials"]
struct BoardColumns {
columns: Vec<String>,
}
#[allow(dead_code)]
#[derive(Clone, Debug)]
#[hemx::form("create_card")]
struct CreateCard {
title: String,
column: String,
}
// req: examples/001 req: codegen/002 req: list/003
#[test]
fn kanban_board_updates_generated_slot() {
fn render_board() -> impl IntoEffect {
board::board.put(&empty_board())
}
let effect = inspect(render_board());
assert!(effect.updates_html(board::board));
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn kanban_board_test_payload_is_rendered_by_a_hemplate_view() {
let html = super::ui::page(&empty_board());
let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector(".columns")).count(), 1);
}
fn empty_board() -> BoardColumns {
// req: html_safety/002 req: view/001
BoardColumns {
columns: Vec::new(),
}
}
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
}
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
#[test]
fn kanban_form_handler_is_checked_against_hemplate_form() {
#[hemx::handler]
fn create_card(_form: hemx::Form<CreateCard>) -> impl IntoEffect {
board::notice.text("queued")
}
let effect = inspect(create_card(CreateCard::FORM));
assert!(effect.updates_text(board::notice));
}
// req: examples/001 req: form/002 req: codegen/003
#[test]
fn kanban_template_exports_form_and_card_handles() {
assert_ne!(board::create_card.id(), board_card::move_right.id());
assert_eq!(
board::create_card_form.field("title").resource,
board::create_card_form.id()
);
}
}
File diff suppressed because it is too large Load Diff
-404
View File
@@ -1,404 +0,0 @@
const DATABASE = "hemx-kanban-v1";
const DATABASE_VERSION = 3;
const COMMANDS = "commands";
const META = "meta";
const ACCOUNT_INDEX = "byAccountPartition";
const COMMAND_SCHEMA = 2;
const LEGACY_COMMAND_SCHEMA = 1;
const MIGRATION_KEY = "commandSchemaMigration";
const ACCOUNT_PARTITION_SESSION = "hemx-kanban-account-partition-v1";
const EXPORT_SCHEMA = 1;
const MAX_REPLAY_COMMANDS = 64;
const REPLAY_BUDGET_MS = 250; // req: performance/007
const SESSION = "hemx-kanban-session-v1";
const ROOT = '[data-hemx-root][data-hemx-client-module="/kanban_client.js"]';
function result(request) {
return new Promise((resolve, reject) => {
request.addEventListener("success", () => resolve(request.result), { once: true });
request.addEventListener("error", () => reject(request.error || new Error("IndexedDB request failed")), { once: true });
});
}
function completed(transaction) {
return new Promise((resolve, reject) => {
transaction.addEventListener("complete", resolve, { once: true });
transaction.addEventListener("abort", () => reject(transaction.error || new Error("IndexedDB transaction aborted")), { once: true });
transaction.addEventListener("error", () => reject(transaction.error || new Error("IndexedDB transaction failed")), { once: true });
});
}
function migrateCommandLog(request, oldVersion) {
const database = request.result;
const commands = database.objectStoreNames.contains(COMMANDS)
? request.transaction.objectStore(COMMANDS)
: database.createObjectStore(COMMANDS, { keyPath: "id" });
if (!commands.indexNames.contains(ACCOUNT_INDEX)) commands.createIndex(ACCOUNT_INDEX, "accountPartition");
if (!database.objectStoreNames.contains(META)) database.createObjectStore(META);
if (oldVersion === 0 || oldVersion >= DATABASE_VERSION) return;
const transaction = request.transaction;
const meta = transaction.objectStore(META);
const all = commands.getAll();
all.addEventListener("success", () => {
const legacy = all.result;
if (legacy.some((command) => command.schemaVersion !== LEGACY_COMMAND_SCHEMA && command.schemaVersion !== COMMAND_SCHEMA)) {
transaction.abort();
return;
}
for (const command of legacy) {
commands.put({
...command,
schemaVersion: COMMAND_SCHEMA,
targetColumn: command.targetColumn || "done",
accountPartition: command.accountPartition || "demo:demo",
queuedAt: Number.isSafeInteger(command.queuedAt) ? command.queuedAt : Date.now(),
});
}
meta.put({ from: oldVersion, to: DATABASE_VERSION, migrated: legacy.length }, MIGRATION_KEY);
}, { once: true });
}
function openCommandLog() {
const request = indexedDB.open(DATABASE, DATABASE_VERSION);
request.addEventListener("upgradeneeded", (event) => migrateCommandLog(request, event.oldVersion));
return result(request);
}
async function currentAccountPartition() {
let response;
try {
response = await fetch("/sync/context", { credentials: "same-origin", cache: "no-store" });
} catch (error) {
const cached = sessionStorage.getItem(ACCOUNT_PARTITION_SESSION);
if (cached) return cached;
throw error;
}
if (!response.ok) {
const cached = sessionStorage.getItem(ACCOUNT_PARTITION_SESSION);
if (response.status === 404 && cached) return cached;
throw new Error(`account context failed with ${response.status}`);
}
const context = await response.json();
if (!context || typeof context.accountPartition !== "string" || !context.accountPartition) {
throw new Error("account context omitted accountPartition");
}
sessionStorage.setItem(ACCOUNT_PARTITION_SESSION, context.accountPartition);
return context.accountPartition;
}
function clientReady(root) {
if (root.hasAttribute("data-hemx-client-ready")) return Promise.resolve();
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
if (!root.hasAttribute("data-hemx-client-ready")) return;
observer.disconnect();
resolve();
});
observer.observe(root, { attributes: true, attributeFilter: ["data-hemx-client-ready"] });
});
}
async function prepareOfflineShell(root) {
if (!("serviceWorker" in navigator)) throw new Error("service workers are unavailable");
await navigator.serviceWorker.register("/offline.js", { scope: "/" });
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise((resolve) => navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true }));
}
root.setAttribute("data-kanban-offline-ready", "");
}
function stableSession() {
let session = sessionStorage.getItem(SESSION);
if (!session) {
session = crypto.randomUUID();
sessionStorage.setItem(SESSION, session);
}
return session;
}
async function appendReorder(database, accountPartition, wire) {
const transaction = database.transaction([COMMANDS, META], "readwrite");
const done = completed(transaction);
const completion = done.then(
() => null,
(error) => error,
);
const meta = transaction.objectStore(META);
const commands = transaction.objectStore(COMMANDS);
const actorKey = `actor:${accountPartition}`;
const causalKey = `causal:${accountPartition}`;
const actorRequest = result(meta.get(actorKey));
const causalRequest = result(meta.get(causalKey));
const [storedActor, storedCausal] = await Promise.all([actorRequest, causalRequest]);
const actor = storedActor || crypto.randomUUID();
const causal = (storedCausal || 0) + 1;
const command = {
id: `${actor}:${causal}`,
schemaVersion: COMMAND_SCHEMA,
accountPartition,
actor,
session: stableSession(),
causal,
queuedAt: Date.now(),
kind: "reorder_card",
cardId: String(wire[2] || "1"),
targetColumn: "done",
eventKind: String(wire[1] || "click"),
key: wire[4] ? String(wire[4]) : null,
};
let append;
let counted;
try {
meta.put(actor, actorKey);
meta.put(causal, causalKey);
append = result(commands.add(command));
counted = result(commands.index(ACCOUNT_INDEX).count(accountPartition));
} catch (error) {
transaction.abort();
await completion;
throw error;
}
try {
const [, count] = await Promise.all([append, counted]);
const transactionError = await completion;
if (transactionError) throw transactionError;
return { command, count };
} catch (error) {
await completion;
throw error;
}
}
async function storedCommands(database, accountPartition) {
const transaction = database.transaction(COMMANDS, "readonly");
const done = completed(transaction);
const commands = await result(transaction.objectStore(COMMANDS).index(ACCOUNT_INDEX).getAll(accountPartition));
await done;
return commands.sort((left, right) => left.causal - right.causal);
}
class ReplayLimitError extends Error {
constructor(actual) {
super(`durable replay limit exceeded: ${actual} > ${MAX_REPLAY_COMMANDS}`);
this.name = "ReplayLimitError";
}
}
function invalidCommand(command, field) {
const id = command && typeof command.id === "string" && command.id ? command.id : "record";
throw new Error(`invalid durable command ${id}: ${field}`);
}
function validate(command) {
if (!command || typeof command !== "object") invalidCommand(command, "record");
if (!Number.isSafeInteger(command.schemaVersion)) invalidCommand(command, "schemaVersion");
if (command.schemaVersion !== COMMAND_SCHEMA) {
throw new Error(`unsupported durable command ${command.id || "record"}`);
}
if (typeof command.id !== "string" || !command.id) invalidCommand(command, "id");
if (typeof command.accountPartition !== "string" || !command.accountPartition) invalidCommand(command, "accountPartition");
if (typeof command.actor !== "string" || !command.actor) invalidCommand(command, "actor");
if (typeof command.session !== "string" || !command.session) invalidCommand(command, "session");
if (!Number.isSafeInteger(command.causal) || command.causal < 1) invalidCommand(command, "causal");
if (!Number.isSafeInteger(command.queuedAt) || command.queuedAt < 0) invalidCommand(command, "queuedAt");
if (command.id !== `${command.actor}:${command.causal}`) invalidCommand(command, "id");
if (command.kind !== "reorder_card") invalidCommand(command, "kind");
if (typeof command.cardId !== "string" || !command.cardId) invalidCommand(command, "cardId");
if (command.targetColumn !== "done") invalidCommand(command, "targetColumn");
if (typeof command.eventKind !== "string" || !command.eventKind) invalidCommand(command, "eventKind");
if (command.key !== null && typeof command.key !== "string") invalidCommand(command, "key");
return command;
}
async function project(root, wasmHandler, command) {
const checked = validate(command);
const batch = await wasmHandler(
1,
checked.eventKind,
checked.cardId,
undefined,
checked.key || undefined,
1,
root.getAttribute("data-hemx-st") || "",
);
if (!(batch instanceof Uint8Array)) throw new Error("reorder_card returned an invalid effect batch");
return batch;
}
function report(root, stage, error) {
const code = error && typeof error.name === "string" ? error.name : "Error";
const message = error instanceof Error ? error.message : String(error);
root.setAttribute("data-kanban-command-phase", "failed");
root.removeAttribute("aria-busy");
root.setAttribute("data-kanban-command-error", `${stage}: ${message}`);
root.setAttribute("data-kanban-command-error-stage", stage);
root.setAttribute("data-kanban-command-error-code", code);
announce(root, `Local command ${stage} failed (${code}). Recovery controls remain available.`);
root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, code, message } }));
}
function announce(root, message) {
const status = root.querySelector('[role="status"]');
if (status) status.textContent = message;
}
function exportCommands(root, commands) {
const payload = { schemaVersion: EXPORT_SCHEMA, commands };
const json = JSON.stringify(payload, null, 2);
const url = URL.createObjectURL(new Blob([json], { type: "application/json" }));
const download = document.createElement("a");
download.href = url;
download.download = "hemx-kanban-commands.json";
download.hidden = true;
document.body.append(download);
download.click();
download.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
announce(root, `Exported ${commands.length} command${commands.length === 1 ? "" : "s"}.`);
root.dispatchEvent(new CustomEvent("kanban:commands-exported", { detail: payload }));
}
async function clearCommands(database, accountPartition) {
const transaction = database.transaction(COMMANDS, "readwrite");
const done = completed(transaction);
const commands = transaction.objectStore(COMMANDS);
const cursor = commands.index(ACCOUNT_INDEX).openKeyCursor(IDBKeyRange.only(accountPartition));
cursor.addEventListener("success", () => {
if (!cursor.result) return;
commands.delete(cursor.result.primaryKey);
cursor.result.continue();
});
await done;
}
async function resetLocalData(database) {
database.close();
await result(indexedDB.deleteDatabase(DATABASE));
sessionStorage.removeItem(SESSION);
await Promise.all((await caches.keys()).filter((name) => name.startsWith("hemx-kanban-shell-")).map((name) => caches.delete(name)));
await Promise.all((await navigator.serviceWorker.getRegistrations()).map((registration) => registration.unregister()));
}
function disarmRecoveryControls(controls) {
for (const control of controls) {
if (!control.dataset.confirmLabel) continue;
control.textContent = control.dataset.confirmLabel;
delete control.dataset.confirmLabel;
}
}
function installRecoveryControls(root, database, accountPartition) {
const controls = [...root.querySelectorAll("[data-kanban-command-action]")];
for (const control of controls) {
control.addEventListener("click", async () => {
const action = control.getAttribute("data-kanban-command-action");
if ((action === "delete" || action === "reset") && !control.dataset.confirmLabel) {
disarmRecoveryControls(controls);
control.dataset.confirmLabel = control.textContent;
control.textContent = `Confirm ${control.textContent.toLowerCase()}`;
announce(root, `${control.dataset.confirmLabel} requires confirmation.`);
return;
}
if (action === "export") disarmRecoveryControls(controls);
controls.forEach((item) => { item.disabled = true; });
try {
if (action === "export") {
exportCommands(root, await storedCommands(database, accountPartition));
controls.forEach((item) => { item.disabled = false; });
return;
}
if (action === "delete") {
await clearCommands(database, accountPartition);
root.dispatchEvent(new CustomEvent("kanban:commands-deleted"));
} else if (action === "reset") {
await resetLocalData(database);
root.dispatchEvent(new CustomEvent("kanban:local-data-reset"));
} else {
throw new Error(`unsupported recovery action ${action}`);
}
location.reload();
} catch (error) {
controls.forEach((item) => { item.disabled = false; });
disarmRecoveryControls(controls);
report(root, action || "recovery", error);
}
});
}
}
async function start() {
const root = document.querySelector(ROOT);
if (!root) return;
root.setAttribute("data-kanban-load-id", crypto.randomUUID());
const accountPartition = await currentAccountPartition();
root.setAttribute("data-kanban-account-partition", accountPartition);
const databasePromise = openCommandLog();
const offlineReady = prepareOfflineShell(root).catch((error) => report(root, "offline", error));
await clientReady(root);
let wasmHandler;
const durableHandler = async (...wire) => {
const queuedCard = String(wire[2] || "1");
root.setAttribute("data-kanban-command-phase", "queued");
root.setAttribute("aria-busy", "true");
announce(root, `Queued card ${queuedCard}; saving for offline use.`);
root.dispatchEvent(new CustomEvent("kanban:command-queued", { detail: { cardId: queuedCard } }));
let command;
let count;
try {
({ command, count } = await appendReorder(await databasePromise, accountPartition, wire));
} catch (error) {
report(root, "persist", error);
throw error;
}
root.setAttribute("data-kanban-command-phase", "durable");
root.removeAttribute("aria-busy");
root.setAttribute("data-kanban-command-count", String(count));
root.dispatchEvent(new CustomEvent("kanban:command-persisted", {
detail: {
id: command.id,
schemaVersion: command.schemaVersion,
actor: command.actor,
session: command.session,
causal: command.causal,
targetColumn: command.targetColumn,
},
}));
try {
return await project(root, wasmHandler, command);
} catch (error) {
report(root, "project", error);
throw error;
}
};
wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler);
if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered");
const database = await databasePromise;
installRecoveryControls(root, database, accountPartition);
root.setAttribute("data-kanban-replay-limit", String(MAX_REPLAY_COMMANDS));
try {
const commands = await storedCommands(database, accountPartition);
if (commands.length > MAX_REPLAY_COMMANDS) throw new ReplayLimitError(commands.length);
commands.forEach(validate);
const replayStarted = performance.now();
const batches = await Promise.all(commands.map((command) => project(root, wasmHandler, command)));
for (const batch of batches) window.hemx.applyBatch(batch, root);
const replayMs = performance.now() - replayStarted;
root.setAttribute("data-kanban-replay-ms", replayMs.toFixed(3));
root.setAttribute("data-kanban-replay-budget-ms", String(REPLAY_BUDGET_MS));
root.toggleAttribute("data-kanban-replay-over-budget", replayMs > REPLAY_BUDGET_MS);
root.setAttribute("data-kanban-command-count", String(commands.length));
root.setAttribute("data-kanban-command-ready", "");
await offlineReady;
} catch (error) {
report(root, "restore", error);
throw error;
}
}
start().catch((error) => {
const root = document.querySelector(ROOT);
if (root && !root.hasAttribute("data-kanban-command-error")) report(root, "open", error);
console.error("kanban durable command log failed", error);
});
-30
View File
@@ -1,30 +0,0 @@
const CACHE = "hemx-kanban-shell-v1";
const SHELL = [
"/",
"/hemx.js",
"/hemx.client.js",
"/kanban_client.js",
"/kanban_client_bg.wasm",
"/app.js",
];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((names) => Promise.all(names.filter((name) => name.startsWith("hemx-kanban-shell-") && name !== CACHE).map((name) => caches.delete(name))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const url = new URL(event.request.url);
if (url.origin !== self.location.origin || !SHELL.includes(url.pathname)) return;
event.respondWith(
caches.match(event.request, { ignoreSearch: true }).then((cached) => cached || fetch(event.request)),
);
});
-20
View File
@@ -1,20 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hemx Kanban</title>
<script +src="self.runtime_src" defer></script>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; }
form { display: flex; gap: .5rem; flex-wrap: wrap; margin: 1rem 0; }
.columns { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }
.column { border: 1px solid #ddd; border-radius: .5rem; padding: 1rem; background: #fafafa; }
.card { background: white; border: 1px solid #ccc; border-radius: .5rem; margin: .75rem 0; padding: .75rem; }
.card menu { display: flex; gap: .35rem; padding: 0; margin: .5rem 0 0; }
.presence { color: #376; }
button, input, select { font: inherit; }
</style>
</head>
<body>{+= self.body =+}</body>
</html>
-17
View File
@@ -1,17 +0,0 @@
<section data-hemx-root="kanban" data-hemx-sse="/sync/broadcast?channel=board">
<header>
<h1>hemx Kanban</h1>
<form data-hemx-handle="create_card" data-hemx-form="create_card" data-hemx-disable-while-pending>
<input name="title" type="text" required="required" placeholder="Card title">
<select name="column" required="required">{+= self.options =+}</select>
<button type="submit">Add card</button>
</form>
<p id="kanban-status" data-hemx-slot="notice" role="status" aria-live="polite">Ready</p>
</header>
<div data-hemx-slot="board">{+= self.board =+}</div>
<aside data-hemx-slot="presence">Waiting for presence…</aside>
<output id="sync-ack" data-hemx-atom="sync_ack" aria-live="polite">pending</output>
<output data-hemx-slot="sync_status" aria-live="polite">Waiting for acknowledgement…</output>
</section>
@@ -1,15 +0,0 @@
<section data-hemx-root="kanban_client" data-hemx-st="1|2" data-hemx-client-state-version="1" data-sync-endpoint="/sync/patches" data-hemx-client-module="/kanban_client.js">
<p id="kanban-status" data-hemx-slot="client_notice" role="status" aria-live="polite">Ready</p>
<ul data-hemx-slot="client_cards">
<template h-for="card in &self.cards" h-key="card.id">
{+ card +}
</template>
</ul>
<fieldset>
<legend>Offline commands</legend>
<button type="button" data-kanban-command-action="export">Export commands</button>
<button type="button" data-kanban-command-action="delete">Delete commands</button>
<button type="button" data-kanban-command-action="reset">Reset local data</button>
</fieldset>
<div data-hemx-handle="reorder_card" data-hemx-on="drop" data-hemx-client="reorder_card" data-hemx-client-event="drop" data-hemx-client-policy="latest">Drop card</div>
</section>
@@ -1,4 +0,0 @@
<li class="card" +data-key="self.id" draggable="true" data-hemx-handle="client_card" data-hemx-on="dragstart">
<span>{+ self.title +}</span>
<button type="button" data-hemx-handle="client_move_right" data-hemx-on="click keydown" data-hemx-client="reorder_card" data-hemx-client-event="click keydown" data-hemx-client-policy="latest" +data-card-id="self.id" aria-describedby="kanban-status">Move right</button>
</li>
@@ -1,21 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hemx Kanban sync</title>
</head>
<body>
<section data-kanban-sync data-sync-version="1" data-sync-upload-limit="2" aria-labelledby="sync-title">
<h2 id="sync-title">Sync status</h2>
<p role="status" aria-live="polite">Waiting for pending commands.</p>
<output data-sync-diagnostics aria-label="Redacted sync diagnostics"></output>
<button type="button" data-sync-retry>Retry sync now</button>
<button type="button" data-sync-export disabled>Export this account's queue</button>
<button type="button" data-sync-use-canonical disabled>Use canonical state and continue</button>
<button type="button" data-sync-keep-local disabled>Keep local change and continue</button>
</section>
<script +src="self.runtime_src" defer></script>
<script type="module" src="/sync.js"></script>
</body>
</html>
@@ -1,16 +0,0 @@
<article class="card" +data-key="self.id">
<strong>{+ self.title +}</strong>
<menu>
<button h-if="self.left_disabled" type="button" aria-label="Move card left" data-hemx-handle="move_left" +data-card-id="self.id" disabled="disabled">←</button>
<form h-else method="post" action="/move">
<input type="hidden" name="card_id" +value="self.id">
<button type="submit" name="direction" value="left" aria-label="Move card left" data-hemx-handle="move_left" +data-card-id="self.id">←</button>
</form>
<button h-if="self.right_disabled" type="button" aria-label="Move card right" data-hemx-handle="move_right" +data-card-id="self.id" disabled="disabled">→</button>
<form h-else method="post" action="/move">
<input type="hidden" name="card_id" +value="self.id">
<button type="submit" name="direction" value="right" aria-label="Move card right" data-hemx-handle="move_right" +data-card-id="self.id">→</button>
</form>
<button type="button" data-hemx-handle="delete_card" +data-card-id="self.id">Delete</button>
</menu>
</article>
@@ -1,6 +0,0 @@
<section class="column">
<h2>{+ self.title +}</h2>
<template h-for="card in &self.cards">
{+ card +}
</template>
</section>
@@ -1,5 +0,0 @@
<div class="columns">
<template h-for="column in &self.columns">
{+ column +}
</template>
</div>
@@ -1 +0,0 @@
<option +value="self.id">{+ self.title +}</option>
@@ -1,3 +0,0 @@
<template h-for="option in &self.options">
{+ option +}
</template>
@@ -1 +0,0 @@
<span class="presence">Ada online</span> <span class="presence">Grace online</span> <small>tick #{+ self.count +}</small>
File diff suppressed because it is too large Load Diff
-755
View File
@@ -1,755 +0,0 @@
const DATABASE = "hemx-kanban-v1";
const DATABASE_VERSION = 3;
const COMMANDS = "commands";
const ACCOUNT_INDEX = "byAccountPartition";
const COMMAND_SCHEMA = 2;
const LEGACY_COMMAND_SCHEMA = 1;
const MIGRATION_KEY = "commandSchemaMigration";
const MAX_ATTEMPTS = 3;
const BACKOFF_MS = [25, 50];
const REQUEST_TIMEOUT_MS = 1_000;
const ACKNOWLEDGEMENT_STREAM_BUFFER_LIMIT = 64;
const root = document.querySelector("[data-kanban-sync]");
const TAB_ID = sessionStorage.getItem("hemx-kanban-sync-tab-id") || crypto.randomUUID();
const LEASE_MS = 5000;
const LEASE_POLL_MS = 100;
let database;
let accountPartition;
let uploadLimit;
let retryTimer;
let leaseTimer;
let acknowledgementSource;
const activeRequests = new Set();
let synchronizing = false;
let uploadsThisRun = 0;
let uploadedTotal = 0;
let inFlightUploads = 0;
let maxObservedInFlight = 0;
let acknowledgementStartedAt;
let conflictCount = 0;
let rejectionCount = 0;
let activeConflict;
let manualRetryCommand;
let stopped = false;
class UploadError extends Error {
constructor(status, retryable, kind, reason) {
super(`sync upload failed with ${status}`);
this.name = "UploadError";
this.status = status;
this.retryable = retryable;
this.kind = kind;
this.reason = reason;
}
}
// req: operations/003
export async function fetchWithTimeout(
input,
init = {},
fetchImplementation = fetch,
timeoutMs = REQUEST_TIMEOUT_MS,
) {
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
throw new TypeError("sync request timeout must be a positive integer");
}
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(new DOMException(`sync request timed out after ${timeoutMs} ms`, "TimeoutError")),
timeoutMs,
);
activeRequests.add(controller);
try {
return await fetchImplementation(input, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeout);
activeRequests.delete(controller);
}
}
function requestResult(request) {
return new Promise((resolve, reject) => {
request.addEventListener("success", () => resolve(request.result), { once: true });
request.addEventListener("error", () => reject(request.error || new Error("IndexedDB request failed")), { once: true });
});
}
function transactionDone(transaction) {
return new Promise((resolve, reject) => {
transaction.addEventListener("complete", resolve, { once: true });
transaction.addEventListener("abort", () => reject(transaction.error || new Error("IndexedDB transaction aborted")), { once: true });
transaction.addEventListener("error", () => reject(transaction.error || new Error("IndexedDB transaction failed")), { once: true });
});
}
function migrateCommandLog(request, oldVersion) {
const database = request.result;
const commands = database.objectStoreNames.contains(COMMANDS)
? request.transaction.objectStore(COMMANDS)
: database.createObjectStore(COMMANDS, { keyPath: "id" });
if (!commands.indexNames.contains(ACCOUNT_INDEX)) commands.createIndex(ACCOUNT_INDEX, "accountPartition");
if (!database.objectStoreNames.contains("meta")) database.createObjectStore("meta");
if (oldVersion === 0 || oldVersion >= DATABASE_VERSION) return;
const transaction = request.transaction;
const meta = transaction.objectStore("meta");
const all = commands.getAll();
all.addEventListener("success", () => {
const legacy = all.result;
if (legacy.some((command) => command.schemaVersion !== LEGACY_COMMAND_SCHEMA && command.schemaVersion !== COMMAND_SCHEMA)) {
transaction.abort();
return;
}
for (const command of legacy) {
commands.put({
...command,
schemaVersion: COMMAND_SCHEMA,
targetColumn: command.targetColumn || "done",
accountPartition: command.accountPartition || "demo:demo",
queuedAt: Number.isSafeInteger(command.queuedAt) ? command.queuedAt : Date.now(),
});
}
meta.put({ from: oldVersion, to: DATABASE_VERSION, migrated: legacy.length }, MIGRATION_KEY);
}, { once: true });
}
async function openLog() {
const request = indexedDB.open(DATABASE, DATABASE_VERSION);
request.addEventListener("upgradeneeded", (event) => migrateCommandLog(request, event.oldVersion));
return requestResult(request);
}
export function validateQueuedCommand(command) {
if (!command || Object.getPrototypeOf(command) !== Object.prototype) {
throw new TypeError("queued command must be an object");
}
if (command.schemaVersion !== COMMAND_SCHEMA) {
throw new RangeError(`unsupported queued command schema version ${command.schemaVersion}`);
}
const boundedString = (field, maximum) => {
const value = command[field];
if (typeof value !== "string" || value.length === 0 || value.length > maximum) {
throw new TypeError(`queued command ${field} is invalid`);
}
};
boundedString("id", 256);
boundedString("accountPartition", 128);
boundedString("actor", 128);
boundedString("session", 128);
boundedString("cardId", 128);
if (!Number.isSafeInteger(command.causal) || command.causal < 1) {
throw new TypeError("queued command causal is invalid");
}
const queuedAt = command.queuedAt === undefined ? 0 : command.queuedAt;
if (!Number.isSafeInteger(queuedAt) || queuedAt < 0) {
throw new TypeError("queued command queuedAt is invalid");
}
if (command.kind !== "reorder_card") throw new TypeError(`unknown queued command kind ${command.kind}`);
if (command.targetColumn !== "done") throw new TypeError(`unknown queued command target ${command.targetColumn}`);
if (!["click", "drop", "keydown"].includes(command.eventKind)) {
throw new TypeError(`unknown queued command event kind ${command.eventKind}`);
}
if (command.key !== null && (typeof command.key !== "string" || command.key.length > 64)) {
throw new TypeError("queued command key is invalid");
}
return command.queuedAt === undefined ? { ...command, queuedAt } : command;
}
async function pendingCommands(database) {
const transaction = database.transaction(COMMANDS, "readonly");
const done = transactionDone(transaction);
const commands = await requestResult(transaction.objectStore(COMMANDS).index(ACCOUNT_INDEX).getAll(accountPartition));
await done;
return commands.map(validateQueuedCommand).sort((left, right) => left.causal - right.causal);
}
async function removePendingCommand(database, commandId) {
const transaction = database.transaction(COMMANDS, "readwrite");
const done = transactionDone(transaction);
transaction.objectStore(COMMANDS).delete(commandId);
await done;
}
function decideRebase(snapshot, command) {
const canonical = snapshot.cards.find((card) => String(card.id) === command.cardId);
if (!canonical) return { kind: "conflicted", reason: "card-missing", canonicalColumn: "missing" };
if (command.kind === "reorder_card" && canonical.column === "done") {
return { kind: "converged", reason: "intent-already-canonical", canonicalColumn: canonical.column };
}
return { kind: "conflicted", reason: "canonical-state-diverged", canonicalColumn: canonical.column };
}
// The built-in policy is deliberately a named module export: applications that
// need custom merge or CRDT semantics must import and wire a different policy.
export function reconcileServerAuthoritative(snapshot, commandSequence, serverResults) {
if (!snapshot || !Array.isArray(snapshot.cards) || !Number.isSafeInteger(snapshot.serverSequence)) {
throw new TypeError("reconciliation snapshot is invalid");
}
if (!Array.isArray(commandSequence) || !Array.isArray(serverResults)) {
throw new TypeError("reconciliation commands and server results must be arrays");
}
const resultCursor = serverResults.reduce((cursor, result) => {
if (!result || !Number.isSafeInteger(result.serverSequence)) {
throw new TypeError("reconciliation server result is invalid");
}
return Math.max(cursor, result.serverSequence);
}, 0);
if (resultCursor > snapshot.serverSequence) {
throw new RangeError("reconciliation server result is newer than the canonical snapshot");
}
const command = commandSequence[0];
const decision = command
? decideRebase(snapshot, command)
: { kind: "idle", reason: "no-pending-command", canonicalColumn: "unchanged" };
return {
model: "server-authoritative-v1",
snapshotSequence: snapshot.serverSequence,
serverResultCursor: resultCursor,
serverResultCount: serverResults.length,
commandCount: commandSequence.length,
retainedCommandCount: decision.kind === "converged"
? Math.max(0, commandSequence.length - 1)
: commandSequence.length,
decision,
};
}
async function claimUploaderLease(database) {
const transaction = database.transaction("meta", "readwrite");
const done = transactionDone(transaction);
const meta = transaction.objectStore("meta");
const now = Date.now();
const leaseKey = `uploaderLease:${accountPartition}`;
const current = await requestResult(meta.get(leaseKey));
if (current && current.owner !== TAB_ID && current.expiresAt > now) {
await done;
return { leader: false, owner: current.owner, expiresAt: current.expiresAt };
}
const lease = { owner: TAB_ID, expiresAt: now + LEASE_MS };
meta.put(lease, leaseKey);
await done;
return { leader: true, ...lease };
}
async function releaseUploaderLease(database) {
const transaction = database.transaction("meta", "readwrite");
const done = transactionDone(transaction);
const meta = transaction.objectStore("meta");
const leaseKey = `uploaderLease:${accountPartition}`;
const current = await requestResult(meta.get(leaseKey));
if (current?.owner === TAB_ID) meta.delete(leaseKey);
await done;
}
function publishLease(lease) {
root.setAttribute("data-sync-tab-id", TAB_ID);
root.setAttribute("data-sync-leader", String(lease.leader));
root.setAttribute("data-sync-lease-owner", lease.owner || TAB_ID);
root.setAttribute("data-sync-lease-expires", String(lease.expiresAt));
}
async function commitConvergedRebase(database, snapshot, command) {
const transaction = database.transaction([COMMANDS, "meta"], "readwrite");
const done = transactionDone(transaction);
transaction.objectStore("meta").put(snapshot, "canonicalSnapshot");
transaction.objectStore("meta").put(snapshot.serverSequence, "acknowledgementCursor");
transaction.objectStore(COMMANDS).delete(command.queueCommandId || command.id);
await done;
}
function setPhase(phase, message) {
root.setAttribute("data-sync-phase", phase);
root.querySelector('[role="status"]').textContent = message;
}
function ageBucket(milliseconds) {
if (milliseconds < 1000) return "lt-1s";
if (milliseconds < 10000) return "1s-10s";
if (milliseconds < 60000) return "10s-1m";
return "gte-1m";
}
function latencyBucket(milliseconds) {
if (milliseconds < 50) return "lt-50ms";
if (milliseconds < 250) return "50ms-250ms";
if (milliseconds < 1000) return "250ms-1s";
return "gte-1s";
}
function publishDiagnostics(commands) {
const queued = Array.isArray(commands) ? commands : [];
const oldest = queued.reduce((value, command) => {
return Number.isSafeInteger(command.queuedAt) ? Math.min(value, command.queuedAt) : value;
}, Date.now());
root.setAttribute("data-sync-diag-queue-count", String(queued.length));
root.setAttribute("data-sync-diag-oldest-age-bucket", queued.length === 0 ? "empty" : ageBucket(Date.now() - oldest));
root.setAttribute("data-sync-diag-cursor", root.getAttribute("data-sync-ack-sequence") || "0");
root.setAttribute("data-sync-diag-conflicts", String(conflictCount));
root.setAttribute("data-sync-diag-rejections", String(rejectionCount));
const diagnostics = root.querySelector("[data-sync-diagnostics]");
diagnostics.textContent = `Queue ${queued.length}; oldest ${root.getAttribute("data-sync-diag-oldest-age-bucket")}; cursor ${root.getAttribute("data-sync-diag-cursor")}; acknowledgement ${root.getAttribute("data-sync-diag-ack-latency-bucket") || "none"}; conflicts ${conflictCount}; rejections ${rejectionCount}.`;
}
function validatePending(command) {
if (!command || command.schemaVersion !== COMMAND_SCHEMA || command.accountPartition !== accountPartition || command.kind !== "reorder_card" || typeof command.id !== "string" || !command.id || typeof command.cardId !== "string" || !command.cardId || command.targetColumn !== "done") {
throw new Error("invalid pending command");
}
return command;
}
function setOnline(online) {
root.setAttribute("data-sync-connection", online ? "online" : "offline");
}
function setManualRetryAvailable(available) {
const retry = root.querySelector("[data-sync-retry]");
retry.disabled = !available;
if (available) root.setAttribute("data-sync-manual-retry", "available");
else root.removeAttribute("data-sync-manual-retry");
}
function setExportAvailable(available) {
root.querySelector("[data-sync-export]").disabled = !available;
}
function setConflictResolutionAvailable(available) {
root.querySelector("[data-sync-use-canonical]").disabled = !available;
root.querySelector("[data-sync-keep-local]").disabled = !available;
}
async function keepLocalChange() {
if (!activeConflict) return;
const { command, snapshot } = activeConflict;
const retryCommand = {
...command,
id: `${command.id}:keep:${snapshot.serverSequence}`,
queueCommandId: command.id,
conflictResolution: "keep-local-change",
basedOnServerSequence: snapshot.serverSequence,
};
setConflictResolutionAvailable(false);
root.setAttribute("data-sync-conflict-resolution", "keep-local-pending");
root.setAttribute("data-sync-resolution-command-id", retryCommand.id);
root.setAttribute("data-sync-resolved-command-id", command.id);
synchronizing = false;
clearTimeout(leaseTimer);
await releaseUploaderLease(database);
root.setAttribute("data-sync-leader", "false");
await synchronize(retryCommand);
}
async function useCanonicalState() {
if (!activeConflict) return;
const { command, snapshot } = activeConflict;
setConflictResolutionAvailable(false);
await removePendingCommand(database, command.id);
const remaining = await pendingCommands(database);
root.setAttribute("data-sync-conflict-resolution", "used-canonical-state");
root.setAttribute("data-sync-resolved-command-id", command.id);
root.setAttribute("data-sync-pending-count", String(remaining.length));
setExportAvailable(remaining.length > 0);
setPhase("conflict-resolved", `Used canonical snapshot ${snapshot.serverSequence}; removed ${command.id} and retained ${remaining.length} queued command${remaining.length === 1 ? "" : "s"}.`);
activeConflict = undefined;
uploadsThisRun = 0;
synchronizing = false;
clearTimeout(leaseTimer);
await releaseUploaderLease(database);
root.setAttribute("data-sync-leader", "false");
await continuePendingWork();
}
async function exportPendingWork() {
const commands = await pendingCommands(database);
if (commands.length === 0) return;
const payload = JSON.stringify({ accountPartition, commands }, null, 2);
const url = URL.createObjectURL(new Blob([payload], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = "hemx-kanban-queue.json";
link.click();
URL.revokeObjectURL(url);
root.setAttribute("data-sync-exported-count", String(commands.length));
}
function scheduleManualRetry(command, error) {
clearTimeout(retryTimer);
manualRetryCommand = command;
root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error));
setManualRetryAvailable(true);
setPhase("offline", "Sync is offline after bounded retries; the durable command remains queued. Retry now when ready.");
root.dispatchEvent(new CustomEvent("kanban:sync-exhausted", { detail: { commandId: command.id, attempts: MAX_ATTEMPTS } }));
}
async function upload(command) {
root.setAttribute("data-sync-max-attempts", String(MAX_ATTEMPTS));
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
root.setAttribute("data-sync-attempts", String(attempt));
setPhase(attempt === 1 ? "uploading" : "retrying", `Uploading ${command.id} (attempt ${attempt} of ${MAX_ATTEMPTS}).`);
try {
const query = new URLSearchParams({ command_id: command.id, card_id: command.cardId, column: command.targetColumn });
const response = await fetchWithTimeout(`/sync/commands?${query}`, { method: "POST" });
if (response.status === 503 && attempt < MAX_ATTEMPTS) {
const base = BACKOFF_MS[attempt - 1];
const delay = base + Math.floor(Math.random() * base);
root.setAttribute("data-sync-last-backoff-base-ms", String(base));
root.setAttribute("data-sync-last-backoff-ms", String(delay));
root.dispatchEvent(new CustomEvent("kanban:sync-retry", { detail: { attempt, base, delay } }));
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
if (!response.ok) {
const problem = await response.json().catch(() => ({}));
const kind = typeof problem.kind === "string" ? problem.kind : "unclassified-rejection";
const reason = typeof problem.error === "string" ? problem.error : "unclassified rejection";
throw new UploadError(response.status, response.status >= 500, kind, reason);
}
return response.json();
} catch (error) {
if (error instanceof UploadError && !error.retryable) throw error;
if (attempt === MAX_ATTEMPTS) throw error;
const base = BACKOFF_MS[attempt - 1];
const delay = base + Math.floor(Math.random() * base);
root.setAttribute("data-sync-last-backoff-base-ms", String(base));
root.setAttribute("data-sync-last-backoff-ms", String(delay));
root.dispatchEvent(new CustomEvent("kanban:sync-retry", { detail: { attempt, base, delay } }));
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw new Error("sync retry limit exhausted");
}
function beginUpload() {
inFlightUploads += 1;
maxObservedInFlight = Math.max(maxObservedInFlight, inFlightUploads);
root.setAttribute("data-sync-in-flight", String(inFlightUploads));
root.setAttribute("data-sync-max-observed-in-flight", String(maxObservedInFlight));
}
function finishUpload() {
inFlightUploads -= 1;
root.setAttribute("data-sync-in-flight", String(inFlightUploads));
}
async function continuePendingWork() {
const commands = await pendingCommands(database);
root.setAttribute("data-sync-pending-count", String(commands.length));
publishDiagnostics(commands);
setExportAvailable(commands.length > 0);
if (commands.length === 0) return;
if (uploadsThisRun >= uploadLimit) {
setManualRetryAvailable(true);
setPhase("backpressured", `Upload limit ${uploadLimit} reached; ${commands.length} durable command${commands.length === 1 ? " remains" : "s remain"} queued. Retry now to continue.`);
return;
}
setTimeout(() => synchronize(validatePending(commands[0])).catch(failPermanently), 0);
}
async function renewOfflineLease(command) {
if (stopped || root.getAttribute("data-sync-phase") !== "offline") return;
const lease = await claimUploaderLease(database);
publishLease(lease);
if (!lease.leader) {
setPhase("standby", "Another tab owns sync; waiting for lease takeover.");
leaseTimer = setTimeout(() => runLeaseLoop(command).catch(failPermanently), LEASE_POLL_MS);
return;
}
leaseTimer = setTimeout(
() => renewOfflineLease(command).catch(failPermanently),
LEASE_MS / 2,
);
}
async function synchronize(command) {
if (synchronizing) return;
synchronizing = true;
const lease = await claimUploaderLease(database);
publishLease(lease);
if (!lease.leader) {
synchronizing = false;
setPhase("standby", "Another tab owns sync; waiting for lease takeover.");
return;
}
clearTimeout(leaseTimer);
leaseTimer = setTimeout(() => {
if (stopped || root.getAttribute("data-sync-phase") === "acknowledged") return;
if (root.getAttribute("data-sync-phase") === "offline") {
renewOfflineLease(command).catch(failPermanently);
} else {
synchronize(command).catch(failPermanently);
}
}, LEASE_MS / 2);
root.removeAttribute("data-sync-error");
root.removeAttribute("data-sync-manual-retry");
setOnline(navigator.onLine);
try {
beginUpload();
let acknowledgement;
try {
acknowledgement = await upload(command);
} finally {
finishUpload();
}
setOnline(true);
root.setAttribute("data-sync-upload-sequence", String(acknowledgement.serverSequence));
acknowledgementStartedAt = performance.now();
setPhase("awaiting-ack", `Command ${command.id} uploaded; awaiting canonical acknowledgement.`);
const reconnect = command.session || command.actor || "kanban";
const source = new EventSource(`/sync/acknowledgements?after=0&reconnect=${encodeURIComponent(reconnect)}`);
acknowledgementSource = source;
let opens = 0;
source.addEventListener("open", () => {
opens += 1;
root.setAttribute("data-sync-transport-opens", String(opens));
root.setAttribute("data-sync-stream-state", "open");
});
source.addEventListener("heartbeat", () => {
const heartbeats = Number(root.getAttribute("data-sync-heartbeats") || "0") + 1;
root.setAttribute("data-sync-heartbeats", String(heartbeats));
root.setAttribute("data-sync-stream-state", "healthy");
});
source.addEventListener("error", () => {
const reconnects = Number(root.getAttribute("data-sync-reconnects") || "0") + 1;
root.setAttribute("data-sync-reconnects", String(reconnects));
root.setAttribute("data-sync-stream-state", "reconnecting");
});
source.addEventListener("acknowledgement", async (event) => {
const canonical = JSON.parse(event.data);
if (canonical.commandId !== command.id) return;
source.close();
if (acknowledgementSource === source) acknowledgementSource = undefined;
root.setAttribute("data-sync-pending-before-ack", String((await pendingCommands(database)).length));
const queueCommandId = command.queueCommandId || command.id;
await removePendingCommand(database, queueCommandId);
manualRetryCommand = undefined;
if (command.conflictResolution === "keep-local-change") {
activeConflict = undefined;
setConflictResolutionAvailable(false);
root.setAttribute("data-sync-conflict-resolution", "kept-local-change");
root.setAttribute("data-sync-resolved-command-id", queueCommandId);
}
uploadsThisRun += 1;
uploadedTotal += 1;
root.setAttribute("data-sync-uploaded-this-run", String(uploadsThisRun));
root.setAttribute("data-sync-uploaded-total", String(uploadedTotal));
const pendingAfterAck = (await pendingCommands(database)).length;
root.setAttribute("data-sync-pending-count", String(pendingAfterAck));
setExportAvailable(pendingAfterAck > 0);
root.setAttribute("data-sync-ack-sequence", String(canonical.serverSequence));
root.setAttribute("data-sync-diag-cursor", String(canonical.serverSequence));
root.setAttribute("data-sync-diag-ack-latency-bucket", latencyBucket(performance.now() - acknowledgementStartedAt));
root.setAttribute("data-sync-canonical-column", canonical.canonicalColumn);
publishDiagnostics(await pendingCommands(database));
setPhase("acknowledged", `Queued change acknowledged in ${canonical.canonicalColumn}.`);
root.dispatchEvent(new CustomEvent("kanban:sync-acknowledged", { detail: canonical }));
synchronizing = false;
clearTimeout(leaseTimer);
await releaseUploaderLease(database);
root.setAttribute("data-sync-leader", "false");
await continuePendingWork();
});
source.addEventListener("snapshot-required", async (event) => {
const missing = JSON.parse(event.data);
const response = await fetchWithTimeout(missing.snapshotUrl);
if (!response.ok) throw new Error(`snapshot failed with ${response.status}`);
const snapshot = await response.json();
const queued = await pendingCommands(database);
const reconciliation = reconcileServerAuthoritative(snapshot, queued, [{
status: "snapshot-required",
serverSequence: missing.latest,
}]);
const decision = reconciliation.decision;
const converged = decision.kind === "converged";
root.setAttribute("data-sync-reconciliation-model", reconciliation.model);
root.setAttribute("data-sync-reconciliation-result-cursor", String(reconciliation.serverResultCursor));
root.setAttribute("data-sync-reconciliation-retained-count", String(reconciliation.retainedCommandCount));
root.setAttribute("data-sync-snapshot-sequence", String(snapshot.serverSequence));
root.setAttribute("data-sync-snapshot-schema", String(snapshot.schemaVersion));
root.setAttribute("data-sync-snapshot-card-count", String(snapshot.cards.length));
root.setAttribute("data-sync-rebase-pending-count", String(queued.length));
root.setAttribute("data-sync-rebase-decision", decision.kind);
root.setAttribute("data-sync-rebase-reason", decision.reason);
root.setAttribute("data-sync-canonical-column", decision.canonicalColumn);
if (converged) {
activeConflict = undefined;
manualRetryCommand = undefined;
setConflictResolutionAvailable(false);
await commitConvergedRebase(database, snapshot, command);
if (command.conflictResolution === "keep-local-change") {
root.setAttribute("data-sync-conflict-resolution", "kept-local-change");
root.setAttribute("data-sync-resolved-command-id", command.queueCommandId);
}
root.setAttribute("data-sync-pending-count", String((await pendingCommands(database)).length));
root.setAttribute("data-sync-ack-sequence", String(snapshot.serverSequence));
setPhase("rebased", `Canonical snapshot ${snapshot.serverSequence} already satisfies ${command.id}; committed and removed the pending command.`);
root.dispatchEvent(new CustomEvent("kanban:sync-rebased", { detail: { snapshot, command, decision } }));
} else {
conflictCount += 1;
activeConflict = { command, snapshot, decision };
publishDiagnostics(await pendingCommands(database));
setConflictResolutionAvailable(true);
setPhase("conflicted", `Canonical snapshot ${snapshot.serverSequence} conflicts with ${command.id} (${decision.reason}); the pending command remains queued.`);
root.dispatchEvent(new CustomEvent("kanban:sync-conflicted", { detail: { snapshot, command, decision } }));
}
synchronizing = false;
source.close();
if (acknowledgementSource === source) acknowledgementSource = undefined;
clearTimeout(leaseTimer);
await releaseUploaderLease(database);
root.setAttribute("data-sync-leader", "false");
if (converged) await continuePendingWork();
});
} catch (error) {
synchronizing = false;
if (error instanceof UploadError && !error.retryable) {
setOnline(true);
clearTimeout(leaseTimer);
root.setAttribute("data-sync-error", error.message);
root.setAttribute("data-sync-error-status", String(error.status));
const remaining = await pendingCommands(database);
setManualRetryAvailable(false);
rejectionCount += 1;
publishDiagnostics(remaining);
if (command.conflictResolution === "keep-local-change") {
manualRetryCommand = undefined;
setConflictResolutionAvailable(true);
root.setAttribute("data-sync-error-kind", error.kind);
root.setAttribute("data-sync-error-reason", error.reason);
root.setAttribute("data-sync-pending-count", String(remaining.length));
root.setAttribute("data-sync-conflict-resolution", "keep-local-rejected");
setPhase("resolution-rejected", `Keep-local command ${command.id} was rejected (${error.status}: ${error.reason}); the conflicted command and ${remaining.length - 1} queued suffix command${remaining.length === 2 ? "" : "s"} remain in order.`);
} else if (error.kind === "authorization-denial") {
root.setAttribute("data-sync-error-kind", "authorization-denial");
root.setAttribute("data-sync-pending-count", "redacted");
root.setAttribute("data-sync-redacted-pending", "true");
root.removeAttribute("data-sync-error-reason");
root.removeAttribute("data-sync-rejected-command-id");
setPhase("authorization-denied", "Current session cannot access local queued work. Sign back into the owning account to continue.");
} else {
root.setAttribute("data-sync-error-kind", "permanent-rejection");
root.setAttribute("data-sync-error-reason", error.reason);
root.setAttribute("data-sync-rejected-command-id", command.id);
root.setAttribute("data-sync-pending-count", String(remaining.length));
setPhase("rejected", `Command ${command.id} was permanently rejected (${error.status}: ${error.reason}); ${remaining.length} durable command${remaining.length === 1 ? " remains" : "s remain"} queued for review.`);
}
await releaseUploaderLease(database);
root.setAttribute("data-sync-leader", "false");
return;
}
setOnline(false);
scheduleManualRetry(command, error);
}
}
async function runLeaseLoop(command) {
if (stopped) return;
const phase = root.getAttribute("data-sync-phase");
if (phase === "acknowledged" || phase === "rebased" || phase === "conflicted" || phase === "failed") return;
if (root.getAttribute("data-sync-leader") === "true") {
await synchronize(command);
return;
}
const lease = await claimUploaderLease(database);
publishLease(lease);
if (lease.leader) {
await synchronize(command);
return;
}
setPhase("standby", "Another tab owns sync; waiting for lease takeover.");
leaseTimer = setTimeout(() => runLeaseLoop(command).catch(failPermanently), LEASE_POLL_MS);
}
async function start() {
if (!root) return;
root.setAttribute("data-sync-request-timeout-ms", String(REQUEST_TIMEOUT_MS));
root.setAttribute("data-sync-stream-buffer-limit", String(ACKNOWLEDGEMENT_STREAM_BUFFER_LIMIT));
const contextResponse = await fetchWithTimeout("/sync/context", { credentials: "same-origin", cache: "no-store" });
if (!contextResponse.ok) throw new Error(`account context failed with ${contextResponse.status}`);
const context = await contextResponse.json();
if (!context || typeof context.accountPartition !== "string" || !context.accountPartition) {
throw new Error("account context omitted accountPartition");
}
accountPartition = context.accountPartition;
root.setAttribute("data-sync-account-partition", accountPartition);
uploadLimit = Number.parseInt(root.getAttribute("data-sync-upload-limit"), 10);
if (!Number.isSafeInteger(uploadLimit) || uploadLimit < 1) throw new Error("data-sync-upload-limit must be a positive integer");
database = await openLog();
const migration = await requestResult(database.transaction("meta", "readonly").objectStore("meta").get(MIGRATION_KEY));
root.setAttribute("data-sync-database-version", String(database.version));
root.setAttribute("data-sync-command-schema", String(COMMAND_SCHEMA));
if (migration) {
root.setAttribute("data-sync-migration-from", String(migration.from));
root.setAttribute("data-sync-migration-to", String(migration.to));
root.setAttribute("data-sync-migrated-count", String(migration.migrated));
}
const commands = await pendingCommands(database);
root.setAttribute("data-sync-uploaded-this-run", "0");
root.setAttribute("data-sync-uploaded-total", "0");
root.setAttribute("data-sync-in-flight", "0");
root.setAttribute("data-sync-max-observed-in-flight", "0");
root.setAttribute("data-sync-pending-count", String(commands.length));
publishDiagnostics(commands);
root.setAttribute("data-sync-diag-ack-latency-bucket", "none");
setExportAvailable(commands.length > 0);
setConflictResolutionAvailable(false);
setManualRetryAvailable(false);
if (commands.length === 0) {
setPhase("idle", "No pending commands.");
return;
}
const command = validatePending(commands[0]);
root.addEventListener("click", async (event) => {
if (event.target.closest("[data-sync-keep-local]")) {
await keepLocalChange();
return;
}
if (event.target.closest("[data-sync-use-canonical]")) {
await useCanonicalState();
return;
}
if (event.target.closest("[data-sync-export]")) {
await exportPendingWork();
return;
}
if (!event.target.closest("[data-sync-retry]")) return;
uploadsThisRun = 0;
root.setAttribute("data-sync-uploaded-this-run", "0");
setManualRetryAvailable(false);
const [next] = await pendingCommands(database);
const retry = manualRetryCommand || (next && validatePending(next));
if (retry) synchronize(retry).catch(failPermanently);
});
window.addEventListener("online", async () => {
if (root.getAttribute("data-sync-phase") !== "offline") return;
setManualRetryAvailable(false);
const [next] = await pendingCommands(database);
const retry = manualRetryCommand || (next && validatePending(next));
if (retry) synchronize(retry).catch(failPermanently);
});
await runLeaseLoop(command);
}
window.addEventListener("pagehide", () => {
stopped = true;
clearTimeout(leaseTimer);
clearTimeout(retryTimer);
if (acknowledgementSource) {
acknowledgementSource.close();
root.setAttribute("data-sync-stream-state", "cancelled");
}
acknowledgementSource = undefined;
for (const controller of activeRequests) {
controller.abort(new DOMException("sync cancelled because page is hidden", "AbortError"));
}
if (database) releaseUploaderLease(database).catch(() => {});
});
function failPermanently(error) {
synchronizing = false;
root.setAttribute("data-sync-error", error instanceof Error ? error.message : String(error));
setPhase("failed", "Sync failed; the durable command remains queued.");
}
start().catch((error) => {
if (!root) return;
failPermanently(error);
});
-27
View File
@@ -1,27 +0,0 @@
[package]
name = "hemx-saas-example"
version.workspace = true
edition.workspace = true
publish = false
[lib]
path = "src/lib.rs"
[[bin]]
name = "hemx-saas-example"
path = "src/main.rs"
[dependencies]
axum = "0.8"
futures-util = "0.3"
hemplate = { path = "../../../hemplate/hemplate" }
hemx = { path = "../../hemx" }
hemx-axum = { path = "../../hemx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
scraper = "0.25"
hemx-test = { path = "../../hemx-test" }
[build-dependencies]
hemx-build = { path = "../../hemx-build" }
-33
View File
@@ -1,33 +0,0 @@
# hemx SaaS tutorial app
This is the compile-tested v1 production-shaped tutorial app. It intentionally uses an equivalent local persistence adapter and provider recipes as the supported v1 production boundary: auth/session, CSRF, SQLx persistence, deploy, metrics, flags, offline behavior, and islands are explicit app integrations, not hemx core services. Read the walkthrough in `../../docs/tutorial-saas.md`. req: examples/001 req: auth/001
What it proves:
- typed form/newtype inputs for project creation
- auth/session context passed through normal Rust state
- CSRF-safe mutation checked before persistence
- local atomic-file persistence adapter with rollback and process-restart proof instead of a vendored SQL/auth provider
- a bounded `POST /projects` reference boundary requiring the current bearer session, exact origin, CSRF token, and matching generated build fingerprint when supplied
- `/health/live`, dependency-aware `/health/ready`, and aggregate `/metrics` endpoints with secret-free structured diagnostics
- generated form, slot, keyed row, page-swap, and live-status commands
- page shell with plain CSS and one explicit metrics island script
- compile-time surface generation plus interaction tests
For provider-explicit boundaries, see `../../docs/recipes/sqlx-persistence.md`, `../../docs/recipes/auth-session-csrf.md`, `../../docs/recipes/observability-flags.md`, `../../docs/recipes/deploy-versioning.md`, and `../../docs/recipes/pwa-offline.md`.
What it deliberately keeps out of the tutorial crate:
- a vendored SQL/auth/metrics/flags/deploy provider dependency
- provider credentials, external services, migrations, or browser automation
- billing, account administration, or other SaaS platform scope
Database encryption, backups, retention, incident policy, and identity-provider compliance remain host responsibilities; hemx does not claim them as framework controls. Those production concerns belong in app adapters and recipes so the tutorial remains runnable in CI without external side effects. req: security/009
Run:
```sh
HEMX_SAAS_STORE=/tmp/hemx-saas-projects.tsv cargo run -p hemx-saas-example
cargo test -p hemx-saas-example --test production_reference
cargo test -p hemx-saas-example
```
-3
View File
@@ -1,3 +0,0 @@
fn main() {
hemx_build::app().run().unwrap();
}
-742
View File
@@ -1,742 +0,0 @@
#[hemx::surface]
pub mod ui {}
use hemplate::Hemplate;
use hemx::{Html, IntoEffect};
use hemx_axum::{
interactions, runtime_js_path, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure,
Registry, State,
};
use std::convert::Infallible;
use std::fmt::Display;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use ui::dashboard;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SessionId(u64);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Session {
session_id: SessionId,
user_id: UserId,
email: String,
csrf: CsrfToken,
origin: String,
bearer: String,
}
impl Session {
pub fn demo() -> Self {
Self {
session_id: SessionId(1),
user_id: UserId(42),
email: "founder@example.com".to_owned(),
csrf: CsrfToken("demo-csrf".to_owned()),
origin: "http://127.0.0.1:3000".to_owned(),
bearer: "Bearer demo-session".to_owned(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct UserId(u64);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CsrfToken(String);
impl FromStr for CsrfToken {
type Err = Infallible;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Ok(Self(value.to_owned()))
}
}
impl Display for CsrfToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectName(String);
impl ProjectName {
fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for ProjectName {
type Err = Infallible;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Ok(Self(value.trim().to_owned()))
}
}
#[derive(Clone, Debug)]
#[hemx::form("new_project")]
pub struct NewProject {
csrf: CsrfToken,
name: ProjectName,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProjectId(u64);
impl Display for ProjectId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectRecord {
id: ProjectId,
name: String,
owner: String,
}
impl ProjectRecord {
fn encode(&self) -> String {
format!("{}\t{}\t{}\n", self.id.0, self.owner, self.name)
}
fn decode(line: &str) -> io::Result<Self> {
let mut fields = line.splitn(3, '\t');
let id = fields
.next()
.and_then(|value| value.parse().ok())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project id"))?;
let owner = fields
.next()
.filter(|value| !value.is_empty())
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project owner"))?;
let name = fields
.next()
.filter(|value| !value.is_empty() && !value.contains(['\n', '\r', '\t']))
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project name"))?;
Ok(Self {
id: ProjectId(id),
name: name.to_owned(),
owner: owner.to_owned(),
})
}
}
#[derive(Clone, Default)]
pub struct LocalProjectStore {
projects: Arc<Mutex<Vec<ProjectRecord>>>,
path: Option<Arc<PathBuf>>,
}
impl LocalProjectStore {
pub fn durable(path: impl Into<PathBuf>) -> io::Result<Self> {
let path = path.into();
let projects = match fs::read_to_string(&path) {
Ok(contents) => contents
.lines()
.map(ProjectRecord::decode)
.collect::<io::Result<Vec<_>>>()?,
Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(error),
};
Ok(Self {
projects: Arc::new(Mutex::new(projects)),
path: Some(Arc::new(path)),
})
}
pub fn insert(&self, name: ProjectName, session: &Session) -> Result<ProjectRecord, AppError> {
if name.as_str() == "fail-store" {
return Err(AppError::StoreUnavailable);
}
let mut projects = self.projects.lock().unwrap();
let id = ProjectId(projects.last().map_or(1, |project| project.id.0 + 1));
let record = ProjectRecord {
id,
name: name.as_str().to_owned(),
owner: session.email.clone(),
};
let mut next = projects.clone();
next.push(record.clone());
if let Some(path) = self.path.as_deref() {
persist_projects(path, &next).map_err(|_| AppError::StoreUnavailable)?;
}
*projects = next;
Ok(record)
}
pub fn list(&self) -> Vec<ProjectRecord> {
self.projects.lock().unwrap().clone()
}
fn ready(&self) -> bool {
let Some(path) = self.path.as_deref() else {
return true;
};
if path.exists() && !path.is_file() {
return false;
}
path.parent().unwrap_or_else(|| Path::new(".")).is_dir()
}
}
fn persist_projects(path: &Path, projects: &[ProjectRecord]) -> io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)?;
let temporary = path.with_extension("tmp");
let mut file = fs::File::create(&temporary)?;
for project in projects {
file.write_all(project.encode().as_bytes())?;
}
file.sync_all()?;
if let Err(error) = fs::rename(&temporary, path) {
let _ = fs::remove_file(temporary);
return Err(error);
}
#[cfg(unix)]
fs::File::open(parent)?.sync_all()?;
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestCorrelationId(String);
impl Display for RequestCorrelationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone)]
pub struct MutationDiagnostic {
pub request_id: RequestCorrelationId,
pub session_id: SessionId,
pub user_id: UserId,
pub outcome: &'static str,
pub duration_micros: u64,
}
pub trait DiagnosticSink: Send + Sync {
fn record(&self, diagnostic: MutationDiagnostic);
}
struct StderrDiagnosticSink;
impl DiagnosticSink for StderrDiagnosticSink {
fn record(&self, diagnostic: MutationDiagnostic) {
eprintln!(
"event=saas.project_mutation request_id={} session_id={} user_id={} outcome={} duration_micros={}",
diagnostic.request_id,
diagnostic.session_id.0,
diagnostic.user_id.0,
diagnostic.outcome,
diagnostic.duration_micros
);
}
}
#[derive(Default)]
struct MutationMetrics {
attempts: AtomicU64,
succeeded: AtomicU64,
denied: AtomicU64,
invalid: AtomicU64,
mismatch: AtomicU64,
failed: AtomicU64,
duration_micros: AtomicU64,
next_request_id: AtomicU64,
}
#[derive(Clone)]
pub struct AppContext {
session: Session,
store: LocalProjectStore,
metrics: Arc<MutationMetrics>,
diagnostics: Arc<dyn DiagnosticSink>,
}
impl AppContext {
pub fn demo() -> Self {
Self {
session: Session::demo(),
store: LocalProjectStore::default(),
metrics: Arc::default(),
diagnostics: Arc::new(StderrDiagnosticSink),
}
}
pub fn durable(path: impl Into<PathBuf>, origin: impl Into<String>) -> io::Result<Self> {
let mut session = Session::demo();
session.origin = origin.into();
Ok(Self {
session,
store: LocalProjectStore::durable(path)?,
metrics: Arc::default(),
diagnostics: Arc::new(StderrDiagnosticSink),
})
}
pub fn authorize_mutation(
&self,
bearer: &str,
csrf: &CsrfToken,
origin: &str,
) -> Result<(), AppError> {
if self.session.email.is_empty() || bearer != self.session.bearer {
return Err(AppError::MissingSession);
}
if csrf != &self.session.csrf {
return Err(AppError::CsrfRejected);
}
if origin != self.session.origin {
return Err(AppError::OriginRejected);
}
Ok(())
}
pub fn csrf(&self) -> &CsrfToken {
&self.session.csrf
}
pub fn projects(&self) -> Vec<ProjectRecord> {
self.store.list()
}
pub fn ready(&self) -> bool {
self.store.ready()
}
pub fn with_diagnostic_sink(mut self, diagnostics: Arc<dyn DiagnosticSink>) -> Self {
self.diagnostics = diagnostics;
self
}
pub fn next_request_id(&self) -> RequestCorrelationId {
let sequence = self
.metrics
.next_request_id
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
RequestCorrelationId(format!("req-{}-{sequence}", std::process::id()))
}
pub fn record_mutation(
&self,
request_id: RequestCorrelationId,
outcome: &'static str,
duration: Duration,
) {
self.metrics.attempts.fetch_add(1, Ordering::Relaxed);
match outcome {
"succeeded" => &self.metrics.succeeded,
"denied" => &self.metrics.denied,
"invalid" => &self.metrics.invalid,
"mismatch" => &self.metrics.mismatch,
_ => &self.metrics.failed,
}
.fetch_add(1, Ordering::Relaxed);
let duration_micros = duration.as_micros().min(u128::from(u64::MAX)) as u64;
self.metrics
.duration_micros
.fetch_add(duration_micros, Ordering::Relaxed);
self.diagnostics.record(MutationDiagnostic {
request_id,
session_id: self.session.session_id,
user_id: self.session.user_id,
outcome,
duration_micros,
});
}
pub fn metrics_json(&self) -> String {
format!(
"{{\"project_mutation\":{{\"attempts\":{},\"succeeded\":{},\"denied\":{},\"invalid\":{},\"mismatch\":{},\"failed\":{},\"duration_micros\":{}}}}}",
self.metrics.attempts.load(Ordering::Relaxed),
self.metrics.succeeded.load(Ordering::Relaxed),
self.metrics.denied.load(Ordering::Relaxed),
self.metrics.invalid.load(Ordering::Relaxed),
self.metrics.mismatch.load(Ordering::Relaxed),
self.metrics.failed.load(Ordering::Relaxed),
self.metrics.duration_micros.load(Ordering::Relaxed),
)
}
pub fn create_project_authorized(
&self,
name: &str,
bearer: &str,
csrf: &str,
origin: &str,
) -> Result<ProjectRecord, AppError> {
let csrf = CsrfToken::from_str(csrf).expect("CSRF tokens are infallible strings");
self.authorize_mutation(bearer, &csrf, origin)?;
self.create_project(
ProjectName::from_str(name).expect("project names are infallible strings"),
)
}
fn create_project(&self, name: ProjectName) -> Result<ProjectRecord, AppError> {
if name.as_str().is_empty() {
return Err(AppError::Validation("Project name required"));
}
if name.as_str().len() > 100 || name.as_str().contains(['\n', '\r', '\t']) {
return Err(AppError::Validation("Project name is invalid"));
}
self.store.insert(name, &self.session)
}
}
#[derive(Debug)]
pub enum AppError {
MissingSession,
CsrfRejected,
OriginRejected,
StoreUnavailable,
Validation(&'static str),
}
impl AppError {
fn message(&self) -> &'static str {
match self {
Self::MissingSession => "Sign in to continue",
Self::CsrfRejected => "Refresh the page before creating another project",
Self::OriginRejected => "Origin verification failed",
Self::StoreUnavailable => "Project storage is temporarily unavailable",
Self::Validation(message) => message,
}
}
}
impl Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message())
}
}
impl std::error::Error for AppError {}
impl IntoHandlerFailure for AppError {
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
match self {
Self::Validation(message) => HandlerFailure::effects(
(
dashboard::new_project.error("name", message),
dashboard::new_project.focus("name"),
),
context,
),
other => HandlerFailure::effects(dashboard::flash.set(other.message()), context),
}
}
}
#[derive(Hemplate)]
pub struct Dashboard {
csrf: CsrfToken,
flash: String,
summary: String,
rows: Vec<ProjectRow>,
project_count: usize,
show_projects: bool,
settings: SettingsPage,
}
impl Dashboard {
pub fn from_context(ctx: &AppContext) -> Self {
let projects = ctx.projects();
Self {
csrf: ctx.csrf().clone(),
flash: "Signed in with a demo session".to_owned(),
summary: project_summary(projects.len()),
project_count: projects.len(),
show_projects: true,
settings: SettingsPage::production_boundaries(),
rows: projects.into_iter().map(ProjectRow::from).collect(),
}
}
pub fn settings(ctx: &AppContext) -> Self {
let mut dashboard = Self::from_context(ctx);
dashboard.show_projects = false;
dashboard.flash.clear();
dashboard
}
}
#[derive(Hemplate)]
#[hemplate = "partials"]
pub struct ProjectRow {
id: ProjectId,
name: String,
owner: String,
}
impl From<ProjectRecord> for ProjectRow {
fn from(record: ProjectRecord) -> Self {
Self {
id: record.id,
name: record.name,
owner: record.owner,
}
}
}
impl hemx::KeyedPartial for ProjectRow {
fn hemx_key(&self) -> String {
self.id.to_string()
}
}
#[derive(Hemplate)]
#[hemplate = "partials"]
pub struct SettingsPage {
message: &'static str,
}
impl SettingsPage {
fn production_boundaries() -> Self {
Self {
message: "Auth, CSRF, persistence, metrics, and deploy stay explicit app integrations.",
}
}
}
#[derive(Hemplate)]
pub struct AppShell {
title: &'static str,
runtime_src: &'static str,
body: Html,
}
pub fn home_page(ctx: &AppContext) -> Html {
ui::page(&AppShell {
title: "hemx SaaS tutorial",
runtime_src: runtime_js_path(),
body: ui::page(&Dashboard::from_context(ctx)),
})
}
pub fn settings_page(ctx: &AppContext) -> Html {
ui::page(&AppShell {
title: "hemx SaaS tutorial settings",
runtime_src: runtime_js_path(),
body: ui::page(&Dashboard::settings(ctx)),
})
}
#[hemx::app(dashboard_handlers)]
pub fn registry(ctx: AppContext) -> Registry {
interactions(ui::BUILD_FINGERPRINT)
}
#[hemx::component("dashboard")]
mod dashboard_handlers {
use super::*;
#[hemx::handler]
pub async fn create_project(
State(ctx): State<AppContext>,
Form(form): Form<NewProject>,
) -> Result<impl IntoEffect, AppError> {
if ctx.session.email.is_empty() {
return Err(AppError::MissingSession);
}
if form.csrf != ctx.session.csrf {
return Err(AppError::CsrfRejected);
}
let project = ctx.create_project(form.name)?;
let total = ctx.projects().len();
Ok((
dashboard::project_row.append(ProjectRow::from(project)),
dashboard::summary.set(project_summary(total)),
dashboard::new_project.clear(),
dashboard::flash.set("Project created"),
dashboard::live_status.set(format!("{total} projects persisted locally")),
))
}
}
pub fn live_status(projects: usize) -> impl IntoEffect {
dashboard::live_status.set(format!("heartbeat: {projects} projects"))
}
fn project_summary(total: usize) -> String {
match total {
0 => "No projects yet".to_owned(),
1 => "1 project".to_owned(),
total => format!("{total} projects"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use hemx_axum::{InteractionForm, InteractionRequest};
use hemx_test::{any_root_selector, inspect, inspect_batch, target_selector};
use scraper::{Html as ParsedHtml, Selector};
fn form<I>(handle: hemx::Handle<I>, fields: &[(&str, &str)]) -> InteractionForm {
InteractionForm::for_handle(
handle,
fields
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned())),
)
}
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
}
#[derive(Default)]
struct RecordingDiagnostics(Mutex<Vec<MutationDiagnostic>>);
impl DiagnosticSink for RecordingDiagnostics {
fn record(&self, diagnostic: MutationDiagnostic) {
self.0.lock().unwrap().push(diagnostic);
}
}
#[test]
fn mutation_diagnostics_are_structured_and_cannot_carry_request_secrets() {
// req: operations/003 req: operations/005
let diagnostics = Arc::new(RecordingDiagnostics::default());
let ctx = AppContext::demo().with_diagnostic_sink(diagnostics.clone());
let request_id = ctx.next_request_id();
ctx.record_mutation(request_id.clone(), "denied", Duration::from_micros(7));
let recorded = diagnostics.0.lock().unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].request_id, request_id);
assert_eq!(recorded[0].session_id, SessionId(1));
assert_eq!(recorded[0].user_id, UserId(42));
assert_eq!(recorded[0].outcome, "denied");
assert_eq!(recorded[0].duration_micros, 7);
}
#[test]
fn home_page_documents_the_production_app_boundaries() {
// req: examples/001 req: auth/001 req: auth/004 req: interop/003
let ctx = AppContext::demo();
let html = home_page(&ctx);
let document = ParsedHtml::parse_document(html.as_str());
assert_eq!(document.select(&selector(any_root_selector())).count(), 1);
assert_eq!(
document
.select(&selector(&format!(
"form{}",
target_selector(dashboard::new_project)
)))
.count(),
1
);
assert_eq!(document.select(&selector("input[name='csrf']")).count(), 1);
assert_eq!(
document
.select(&selector("[data-hemx-sse='/events']"))
.count(),
1
);
assert_eq!(
document
.select(&selector("[data-hemx-island='metrics']"))
.count(),
1
);
assert!(html.as_str().contains("/app.css"));
assert!(html.as_str().contains("/metrics.js"));
}
#[test]
fn settings_page_renders_the_full_page_fallback() {
// req: examples/001 req: page_swap/002
let ctx = AppContext::demo();
let html = settings_page(&ctx);
let document = ParsedHtml::parse_document(html.as_str());
assert_eq!(document.select(&selector(any_root_selector())).count(), 1);
assert_eq!(
document
.select(&selector(&format!(
"{} .settings-page",
target_selector(dashboard::page_panel)
)))
.count(),
1
);
assert!(html.as_str().contains("explicit app integrations"));
assert!(!html
.as_str()
.contains("form data-hemx-handle=\"create_project\""));
}
#[tokio::test]
async fn create_project_is_auth_csrf_checked_and_persisted_locally() {
// req: examples/001 req: auth/002 req: auth/004 req: form/001 req: failure/004
let ctx = AppContext::demo();
let rejected = inspect_batch(
InteractionRequest::from(form(
dashboard::create_project,
&[("csrf", "stale"), ("name", "Launch checklist")],
))
.dispatch_async(registry(ctx.clone()))
.await
.unwrap()
.batch,
);
assert!(ctx.projects().is_empty());
assert!(rejected.updates_text(dashboard::flash));
assert!(rejected.payload_contains("Refresh the page"));
let validation = inspect_batch(
InteractionRequest::from(form(
dashboard::create_project,
&[("csrf", "demo-csrf"), ("name", " ")],
))
.dispatch_async(registry(ctx.clone()))
.await
.unwrap()
.batch,
);
assert!(ctx.projects().is_empty());
assert!(validation.payload_contains("Project name required"));
let created = inspect_batch(
InteractionRequest::from(form(
dashboard::create_project,
&[("csrf", "demo-csrf"), ("name", "Launch checklist")],
))
.dispatch_async(registry(ctx.clone()))
.await
.unwrap()
.batch,
);
assert_eq!(ctx.projects()[0].name, "Launch checklist");
assert!(created.inserts_html_containing(dashboard::project_row, "1", "Launch checklist"));
assert!(created.updates_text(dashboard::summary));
assert!(created.resets_form(dashboard::new_project));
assert!(created.updates_text(dashboard::live_status));
}
#[test]
fn live_status_uses_the_generated_dashboard_target() {
// req: push/003 req: examples/014
let ctx = AppContext::demo();
let heartbeat = inspect(live_status(ctx.projects().len()));
assert!(heartbeat.updates_text(dashboard::live_status));
assert!(heartbeat.payload_contains("heartbeat"));
}
}
-228
View File
@@ -1,228 +0,0 @@
use axum::body::Body;
use axum::extract::{DefaultBodyLimit, Form, Query, Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use futures_util::{stream, StreamExt};
use hemx::IntoEffect;
use hemx_axum::{runtime_js, runtime_js_path, sse, EffectResponse, InteractionRequest};
use hemx_saas_example::{home_page, live_status, registry, settings_page, ui, AppContext};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::path::PathBuf;
use std::time::{Duration, Instant};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let address = std::env::var("HEMX_SAAS_ADDR").unwrap_or_else(|_| "127.0.0.1:3003".to_owned());
let store = std::env::var_os("HEMX_SAAS_STORE")
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join("hemx-saas-projects.tsv"));
let app = app(AppContext::durable(store, format!("http://{address}"))?);
let listener = tokio::net::TcpListener::bind(&address).await?;
axum::serve(listener, app).await?;
Ok(())
}
fn app(ctx: AppContext) -> Router {
Router::new()
.route("/", get(home).post(interact))
.route("/settings", get(settings))
.route("/projects", post(create_project))
.route("/health/live", get(health_live))
.route("/health/ready", get(health_ready))
.route("/metrics", get(metrics))
.route("/events", get(events))
.route(runtime_js_path(), get(runtime))
.route("/app.css", get(css))
.route("/metrics.js", get(metrics_js))
.layer(DefaultBodyLimit::max(8 * 1024))
.layer(middleware::from_fn(security_headers))
.with_state(ctx)
}
// req: security/006 req: security/009
async fn security_headers(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
"content-security-policy",
HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"),
);
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert(
"referrer-policy",
HeaderValue::from_static("strict-origin-when-cross-origin"),
);
response
}
async fn home(State(ctx): State<AppContext>) -> impl IntoResponse {
axum::response::Html(home_page(&ctx).into_string())
}
async fn settings(State(ctx): State<AppContext>) -> impl IntoResponse {
axum::response::Html(settings_page(&ctx).into_string())
}
async fn interact(
State(ctx): State<AppContext>,
request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> {
request.dispatch_async(registry(ctx)).await
}
async fn events(
Query(params): Query<BTreeMap<String, String>>,
State(ctx): State<AppContext>,
) -> impl IntoResponse {
// The production reference exposes an ongoing server-owned stream; `once`
// keeps a bounded probe for package tests without changing the public path.
// req: examples/014
let event = |ctx: &AppContext| {
Ok::<_, Infallible>(live_status(ctx.projects().len()).into_batch(ui::BUILD_FINGERPRINT))
};
let initial = stream::once(std::future::ready(event(&ctx)));
if params.contains_key("once") {
return sse(initial.left_stream());
}
let updates = stream::unfold(ctx, move |ctx| async move {
tokio::time::sleep(Duration::from_secs(15)).await;
Some((event(&ctx), ctx))
});
sse(initial.chain(updates).right_stream())
}
// req: auth/001 req: auth/002 req: auth/004
// req: security/004 req: v1_release/003
async fn create_project(
State(ctx): State<AppContext>,
headers: HeaderMap,
Form(form): Form<BTreeMap<String, String>>,
) -> Response {
let started = Instant::now();
let request_id = ctx.next_request_id();
let bearer = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let origin = headers
.get("origin")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let name = form.get("name").map(String::as_str).unwrap_or_default();
let csrf = form.get("csrf").map(String::as_str).unwrap_or_default();
if let Some(client_fingerprint) = headers
.get("x-hemx-fingerprint")
.and_then(|value| value.to_str().ok())
{
let current_fingerprint = ui::BUILD_FINGERPRINT.0.to_string();
if client_fingerprint != current_fingerprint {
ctx.record_mutation(request_id.clone(), "mismatch", started.elapsed());
return Response::builder()
.status(StatusCode::CONFLICT)
.header("content-type", "application/problem+json")
.header("x-hemx-recovery", "reload")
.header("x-hemx-fingerprint", current_fingerprint)
.header("x-request-id", request_id.to_string())
.body(Body::from("{\"code\":\"deployment-mismatch\"}"))
.expect("deployment mismatch response");
}
}
let (outcome, mut response) = match ctx.create_project_authorized(name, bearer, csrf, origin) {
Ok(_) => (
"succeeded",
(StatusCode::SEE_OTHER, [("location", "/")], "").into_response(),
),
Err(
hemx_saas_example::AppError::MissingSession
| hemx_saas_example::AppError::CsrfRejected
| hemx_saas_example::AppError::OriginRejected,
) => (
"denied",
problem(StatusCode::FORBIDDEN, "authorization-denied"),
),
Err(hemx_saas_example::AppError::Validation(_)) => (
"invalid",
problem(StatusCode::BAD_REQUEST, "invalid-project"),
),
Err(_) => (
"failed",
problem(StatusCode::SERVICE_UNAVAILABLE, "storage-unavailable"),
),
};
ctx.record_mutation(request_id.clone(), outcome, started.elapsed());
response.headers_mut().insert(
"x-request-id",
HeaderValue::from_str(&request_id.to_string()).expect("generated request ID is a header"),
);
response
}
fn problem(status: StatusCode, code: &'static str) -> Response {
Response::builder()
.status(status)
.header("content-type", "application/problem+json")
.body(Body::from(format!("{{\"code\":\"{code}\"}}")))
.expect("problem response")
}
// req: operations/007
async fn health_live() -> Response {
json_response(StatusCode::OK, "{\"status\":\"live\"}".to_owned())
}
// req: operations/007
async fn health_ready(State(ctx): State<AppContext>) -> Response {
if ctx.ready() {
json_response(
StatusCode::OK,
format!(
"{{\"status\":\"ready\",\"fingerprint\":\"{}\"}}",
ui::BUILD_FINGERPRINT.0
),
)
} else {
json_response(
StatusCode::SERVICE_UNAVAILABLE,
"{\"status\":\"not-ready\",\"code\":\"storage-unavailable\"}".to_owned(),
)
}
}
// req: operations/005 req: operations/007
async fn metrics(State(ctx): State<AppContext>) -> Response {
json_response(StatusCode::OK, ctx.metrics_json())
}
fn json_response(status: StatusCode, body: String) -> Response {
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Body::from(body))
.expect("JSON response")
}
async fn runtime() -> impl IntoResponse {
runtime_js()
}
async fn css() -> Response {
Response::builder()
.header("content-type", "text/css; charset=utf-8")
.body(Body::from(include_str!("../templates/app.css")))
.expect("css response")
}
async fn metrics_js() -> Response {
Response::builder()
.header("content-type", "text/javascript; charset=utf-8")
.body(Body::from(include_str!("../templates/metrics.js")))
.expect("metrics js response")
}
-16
View File
@@ -1,16 +0,0 @@
:root { color-scheme: light; font-family: Inter, system-ui, sans-serif; }
body { margin: 0; background: #f7f4ee; color: #201b16; }
.dashboard { max-width: 960px; margin: 0 auto; padding: 2rem; }
.hero, .panel, .status-row { background: white; border: 1px solid #e6ded2; border-radius: 18px; padding: 1.25rem; box-shadow: 0 12px 40px rgba(34, 24, 8, 0.08); }
.eyebrow { color: #8a5a00; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.lede { max-width: 56rem; color: #5d5147; }
.tabs, .project-form, .status-row { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.tabs { margin: 1rem 0; }
button, input { font: inherit; }
button { border: 0; border-radius: 999px; background: #1f5eff; color: white; padding: .65rem 1rem; }
input { border: 1px solid #cfc4b8; border-radius: 10px; padding: .55rem .7rem; }
.field-error, .flash { color: #a02b12; font-weight: 700; }
.summary { color: #516034; }
.project-list { display: grid; gap: .7rem; padding: 0; list-style: none; }
.project-row { display: flex; justify-content: space-between; border: 1px solid #eee0cb; border-radius: 12px; padding: .75rem; }
.metrics-island { min-width: 18rem; border-left: 4px solid #1f5eff; padding-left: 1rem; }
-14
View File
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{+ self.title +}</title>
<link rel="stylesheet" href="/app.css">
<script +src="self.runtime_src" defer></script>
<script src="/metrics.js" defer></script>
</head>
<body>
{+= self.body =+}
</body>
</html>
-46
View File
@@ -1,46 +0,0 @@
<section class="dashboard" data-hemx-root="dashboard" data-hemx-sse="/events">
<header class="hero">
<p class="eyebrow">Production-shaped SaaS path</p>
<h1>Projects</h1>
<p class="lede">Auth-gated mutations, CSRF checks, local persistence, typed forms, generated swaps, page swaps, live status, plain CSS, and one explicit island.</p>
</header>
<nav class="tabs" data-hemx-slot="nav">
<a href="/" data-hemx-nav="">Projects</a>
<a href="/settings" data-hemx-nav>Settings</a>
</nav>
<section class="panel" data-hemx-slot="page_panel">
<div h-if="self.show_projects">
<form class="project-form" data-hemx-handle="create_project" data-hemx-form="new_project" data-hemx-disable-while-pending>
<input type="hidden" name="csrf" +value="self.csrf">
<label>Project name
<input name="name" required="required" maxlength="64" value="Launch checklist">
</label>
<button type="submit">Create project</button>
<p class="field-error" data-hemx-error-for="name"></p>
</form>
<p class="flash" data-hemx-slot="flash">{+ self.flash +}</p>
<p class="summary" data-hemx-slot="summary">{+ self.summary +}</p>
<ul class="project-list" data-hemx-slot="project_row">
<template h-for="row in &self.rows" h-key="row.id">
{+ row +}
</template>
</ul>
</div>
<div h-if="!self.show_projects">
{+ self.settings +}
</div>
</section>
<section class="status-row">
<p data-hemx-slot="live_status">Waiting for status…</p>
<article class="metrics-island" data-hemx-island="metrics" +data-project-count="self.project_count">
<h2>Metrics island</h2>
<canvas width="320" height="140" aria-label="Project metrics chart"></canvas>
<p data-island-readout="">Waiting for island script…</p>
</article>
</section>
</section>
-14
View File
@@ -1,14 +0,0 @@
(() => {
function render(island) {
const count = island.getAttribute("data-project-count") || "0";
const readout = island.querySelector("[data-island-readout]");
if (readout) readout.textContent = `${count} persisted project${count === "1" ? "" : "s"}`;
}
function boot() {
for (const island of document.querySelectorAll('[data-hemx-island="metrics"]')) render(island);
}
document.addEventListener("DOMContentLoaded", boot);
document.addEventListener("hemx:after-settle", boot);
})();
@@ -1,4 +0,0 @@
<li class="project-row" +data-key="self.id">
<strong>{+ self.name +}</strong>
<span>{+ self.owner +}</span>
</li>
@@ -1,4 +0,0 @@
<section class="settings-page">
<h2>Settings</h2>
<p>{+ self.message +}</p>
</section>
-311
View File
@@ -1,311 +0,0 @@
use hemx_test::TestProcess;
use std::fs;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const STARTUP_TIMEOUT: Duration = Duration::from_secs(12);
fn available_address() -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("reserve test port");
let address = listener.local_addr().expect("test address");
drop(listener);
address.to_string()
}
fn test_path(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock")
.as_nanos();
std::env::temp_dir().join(format!("hemx-saas-{label}-{}-{nonce}", std::process::id()))
}
fn start(address: &str, store: &Path) -> TestProcess {
let mut command = Command::new(env!("CARGO_BIN_EXE_hemx-saas-example"));
command
.env("HEMX_SAAS_ADDR", address)
.env("HEMX_SAAS_STORE", store);
TestProcess::start(command, "hemx-saas", address, STARTUP_TIMEOUT).expect("start SaaS app")
}
fn request(
address: &str,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: &str,
) -> String {
let mut stream = TcpStream::connect(address).expect("connect to SaaS app");
write!(
stream,
"{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n",
body.len()
)
.expect("write request line");
for (name, value) in headers {
write!(stream, "{name}: {value}\r\n").expect("write request header");
}
write!(stream, "\r\n{body}").expect("finish request");
let mut response = String::new();
stream.read_to_string(&mut response).expect("read response");
response
}
fn create(address: &str, name: &str, bearer: &str, csrf: &str, origin: &str) -> String {
create_at_version(address, name, bearer, csrf, origin, None)
}
fn create_at_version(
address: &str,
name: &str,
bearer: &str,
csrf: &str,
origin: &str,
fingerprint: Option<&str>,
) -> String {
let mut headers = vec![
("Authorization", bearer),
("Origin", origin),
("Content-Type", "application/x-www-form-urlencoded"),
];
if let Some(fingerprint) = fingerprint {
headers.push(("x-hemx-fingerprint", fingerprint));
}
request(
address,
"POST",
"/projects",
&headers,
&format!("name={name}&csrf={csrf}"),
)
}
fn response_header<'a>(response: &'a str, name: &str) -> &'a str {
response
.lines()
.find_map(|line| {
let (header_name, value) = line.split_once(':')?;
header_name.eq_ignore_ascii_case(name).then(|| value.trim())
})
.unwrap_or_else(|| panic!("missing {name} response header"))
}
fn ready_fingerprint(response: &str) -> &str {
let marker = "\"fingerprint\":\"";
let start = response.find(marker).expect("readiness fingerprint") + marker.len();
let end = response[start..].find('"').expect("fingerprint end") + start;
&response[start..end]
}
#[test]
fn authenticated_project_mutation_is_atomic_and_survives_restart() {
// test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: security/006
// test req: security/009 req: operations/001 req: operations/006 req: v1_release/003
let address = available_address();
let origin = format!("http://{address}");
let store = test_path("durable");
{
let _app = start(&address, &store);
let home = request(&address, "GET", "/", &[], "");
let csp = response_header(&home, "content-security-policy");
assert!(csp.contains("default-src 'self'"), "{csp}");
assert!(csp.contains("script-src 'self'"), "{csp}");
assert!(csp.contains("object-src 'none'"), "{csp}");
assert!(csp.contains("form-action 'self'"), "{csp}");
assert!(!csp.contains("unsafe-inline"), "{csp}");
assert!(!csp.contains("unsafe-eval"), "{csp}");
assert_eq!(response_header(&home, "x-content-type-options"), "nosniff");
assert_eq!(
response_header(&home, "referrer-policy"),
"strict-origin-when-cross-origin"
);
assert!(!home.contains("<script>"));
assert!(!home.contains("javascript:"));
assert!(
home.contains("href=\"/settings\" data-hemx-nav"),
"settings must remain a real, enhanceable link"
);
let settings = request(&address, "GET", "/settings", &[], "");
assert!(settings.starts_with("HTTP/1.1 200"), "{settings}");
assert!(settings.contains("<h2>Settings</h2>"), "{settings}");
assert!(settings.contains("explicit app integrations"), "{settings}");
let events = request(&address, "GET", "/events?once=1", &[], "");
assert!(events.starts_with("HTTP/1.1 200"), "{events}");
assert_eq!(
response_header(&events, "content-type"),
"text/event-stream"
);
assert!(events.contains("event: hemx"), "{events}");
assert!(events.contains("data:"), "{events}");
// test req: nav/001 req: nav/002 req: push/003 req: examples/014
let live = request(&address, "GET", "/health/live", &[], "");
assert!(live.starts_with("HTTP/1.1 200"), "{live}");
assert!(live.contains("{\"status\":\"live\"}"), "{live}");
let ready = request(&address, "GET", "/health/ready", &[], "");
assert!(ready.starts_with("HTTP/1.1 200"), "{ready}");
assert!(ready.contains("{\"status\":\"ready\","), "{ready}");
let denied_responses = [
create(
&address,
"DeniedAuth",
"Bearer secret-auth-material",
"demo-csrf",
&origin,
),
create(
&address,
"DeniedCsrf",
"Bearer demo-session",
"stale",
&origin,
),
create(
&address,
"DeniedOrigin",
"Bearer demo-session",
"demo-csrf",
"https://attacker.invalid",
),
];
for denied in &denied_responses {
assert!(denied.starts_with("HTTP/1.1 403"), "{denied}");
assert!(response_header(denied, "x-request-id").starts_with("req-"));
assert!(denied.contains("{\"code\":\"authorization-denied\"}"));
assert!(!denied.contains("Denied"));
assert!(!denied.contains("demo-csrf"));
assert!(!denied.contains("secret-auth-material"));
assert!(!denied.contains("attacker.invalid"));
}
let wrong_content_type = request(
&address,
"POST",
"/projects",
&[
("Authorization", "Bearer demo-session"),
("Origin", origin.as_str()),
("Content-Type", "text/plain"),
],
"name=WrongType&csrf=demo-csrf",
);
assert!(
wrong_content_type.starts_with("HTTP/1.1 415"),
"{wrong_content_type}"
);
let oversized = request(
&address,
"POST",
"/projects",
&[
("Authorization", "Bearer demo-session"),
("Origin", origin.as_str()),
("Content-Type", "application/x-www-form-urlencoded"),
],
&format!("name={}&csrf=demo-csrf", "x".repeat(9 * 1024)),
);
assert!(oversized.starts_with("HTTP/1.1 413"), "{oversized}");
let before = request(&address, "GET", "/", &[], "");
assert!(!before.contains("DeniedAuth"));
assert!(!before.contains("DeniedCsrf"));
assert!(!before.contains("DeniedOrigin"));
assert!(!before.contains("WrongType"));
let denied_metrics = request(&address, "GET", "/metrics", &[], "");
assert!(
denied_metrics.starts_with("HTTP/1.1 200"),
"{denied_metrics}"
);
assert!(
denied_metrics.contains("\"attempts\":3"),
"{denied_metrics}"
);
assert!(denied_metrics.contains("\"denied\":3"), "{denied_metrics}");
assert!(!denied_metrics.contains("Denied"));
assert!(!denied_metrics.contains("demo-csrf"));
assert!(!denied_metrics.contains("secret-auth-material"));
let stale = create_at_version(
&address,
"Stale%20Project",
"Bearer demo-session",
"demo-csrf",
&origin,
Some("0"),
);
assert!(stale.starts_with("HTTP/1.1 409"), "{stale}");
assert!(response_header(&stale, "x-request-id").starts_with("req-"));
assert!(stale.contains("{\"code\":\"deployment-mismatch\"}"));
assert!(stale
.to_ascii_lowercase()
.contains("x-hemx-recovery: reload"));
assert!(!request(&address, "GET", "/", &[], "").contains("Stale Project"));
let fingerprint = ready_fingerprint(&ready);
let allowed = create_at_version(
&address,
"Durable%20Project",
"Bearer demo-session",
"demo-csrf",
&origin,
Some(fingerprint),
);
assert!(allowed.starts_with("HTTP/1.1 303"), "{allowed}");
let allowed_request_id = response_header(&allowed, "x-request-id");
assert!(allowed_request_id.starts_with("req-"));
assert_ne!(allowed_request_id, response_header(&stale, "x-request-id"));
assert!(request(&address, "GET", "/", &[], "").contains("Durable Project"));
let metrics = request(&address, "GET", "/metrics", &[], "");
assert!(metrics.contains("\"attempts\":5"), "{metrics}");
assert!(metrics.contains("\"succeeded\":1"), "{metrics}");
assert!(metrics.contains("\"mismatch\":1"), "{metrics}");
}
{
let _restarted = start(&address, &store);
let restored = request(&address, "GET", "/", &[], "");
assert!(restored.contains("Durable Project"), "{restored}");
assert!(restored.contains("1 project"), "{restored}");
}
let _ = fs::remove_file(store);
}
#[test]
fn failed_durable_commit_rolls_back_visible_state() {
// test req: failure/004 req: operations/002 req: operations/007 req: operations/008 req: v1_release/003
let address = available_address();
let origin = format!("http://{address}");
let store = test_path("rollback");
let _app = start(&address, &store);
fs::create_dir(&store).expect("block atomic rename destination");
let not_ready = request(&address, "GET", "/health/ready", &[], "");
assert!(not_ready.starts_with("HTTP/1.1 503"), "{not_ready}");
assert!(not_ready.contains("\"code\":\"storage-unavailable\""));
assert!(request(&address, "GET", "/health/live", &[], "").starts_with("HTTP/1.1 200"));
let rejected = create(
&address,
"Must%20Rollback",
"Bearer demo-session",
"demo-csrf",
&origin,
);
assert!(rejected.starts_with("HTTP/1.1 503"), "{rejected}");
assert!(response_header(&rejected, "x-request-id").starts_with("req-"));
assert!(rejected.contains("{\"code\":\"storage-unavailable\"}"));
assert!(!rejected.contains("Must Rollback"));
assert!(!rejected.contains("demo-csrf"));
assert!(!request(&address, "GET", "/", &[], "").contains("Must Rollback"));
assert!(!store.with_extension("tmp").exists());
let metrics = request(&address, "GET", "/metrics", &[], "");
assert!(metrics.contains("\"attempts\":1"), "{metrics}");
assert!(metrics.contains("\"failed\":1"), "{metrics}");
assert!(!metrics.contains("Must Rollback"));
let _ = fs::remove_dir(store);
}
-25
View File
@@ -1,25 +0,0 @@
[package]
name = "hemx-techdemo"
version.workspace = true
edition.workspace = true
publish = false
[lib]
path = "src/lib.rs"
[dependencies]
axum = "0.8"
futures-util = "0.3"
hemplate = { path = "../../../hemplate/hemplate" }
hemx = { path = "../../hemx" }
hemx-axum = { path = "../../hemx-axum" }
hemx-host = { path = "../../hemx-host" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
scraper = "0.25"
hemx-test = { path = "../../hemx-test" }
thirtyfour = "0.35"
[build-dependencies]
hemx-build = { path = "../../hemx-build" }
-28
View File
@@ -1,28 +0,0 @@
# hemx full techdemo
Run:
cargo run -p hemx-techdemo
Open <http://127.0.0.1:3002>.
This is a polished Linear-style product demo for planning typed work across lanes. It is tailored to showcase hemx strengths:
- modern SSR-first UI
- generated target objects from `.heml`
- hemplate partials for issue lanes, cards, and inspector panels
- native form posts wired through generated form/handle resources
- multi-target tuple-composed `IntoEffect` responses
- generated slot updates instead of selectors
- root-scoped runtime application without selector lookups
- page-enhancer navigation with native link fallback
- SSE server push into a generated slot
- drag-and-drop lane moves persisted by typed server handlers through the hemx runtime
- an explicit advanced opaque canvas island fed by a generated event helper, without teaching hemx core about the widget
- no user-authored browser JavaScript in hemx-managed UI; the island JavaScript is a leaf-widget escape hatch
Verification:
cargo test -p hemx-techdemo --test e2e
cargo test -p hemx-techdemo --test browser_e2e
mutest -p hemx-techdemo -f examples/techdemo/src/main.rs -F 'registry' -j 2 --timeout 90 -- --test e2e
-3
View File
@@ -1,3 +0,0 @@
fn main() {
hemx_build::app().run().unwrap();
}
-73
View File
@@ -1,73 +0,0 @@
#[hemx::surface]
pub mod ui {}
#[cfg(test)]
mod tests {
use super::ui::control_center::{hero_metrics, launch_work, launch_work_form, notice};
use super::ui::issue_card::advance_work;
use super::ui::issue_lane::events as lane_events;
use hemplate::Hemplate;
use hemx::IntoEffect;
use hemx_test::inspect;
#[derive(Hemplate)]
#[hemplate = "partials"]
struct FastMetric {
label: &'static str,
}
#[allow(dead_code)]
#[derive(Clone, Debug)]
#[hemx::form("launch_work")]
struct LaunchWork {
title: String,
lane: String,
impact: Option<u8>,
}
// req: examples/001 req: codegen/002 req: public_api/001
#[test]
fn techdemo_uses_generated_slots_for_multi_target_updates() {
fn update() -> impl IntoEffect {
(
hero_metrics.put(&FastMetric { label: "fast" }),
notice.text("typed"),
)
}
let batch = inspect(update());
assert!(batch.has_target(hero_metrics));
assert!(batch.has_target(notice));
}
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
#[test]
fn techdemo_form_handler_is_checked_against_hemplate_form() {
#[hemx::handler]
fn launch_work(_form: hemx::Form<LaunchWork>) -> impl IntoEffect {
notice.text("queued")
}
let batch = inspect(launch_work(LaunchWork::FORM));
assert!(batch.updates_text(notice));
}
// req: examples/001 req: form/002 req: codegen/003
#[test]
fn techdemo_exports_form_and_interaction_handles() {
assert_ne!(launch_work.id(), advance_work.id());
assert_eq!(
launch_work_form.field("title").resource,
launch_work_form.id()
);
}
// req: codegen/006
#[test]
fn techdemo_exports_generated_event_constants() {
assert_eq!(lane_events::drop.as_str(), "drop");
let event = inspect(lane_events::drop.emit("card-1"));
assert!(event.emits("drop", "card-1"));
}
}
File diff suppressed because it is too large Load Diff
-53
View File
@@ -1,53 +0,0 @@
:root { color-scheme: dark; --bg:#070814; --panel:rgba(255,255,255,.08); --line:rgba(255,255,255,.16); --text:#f7f7ff; --muted:#aeb3d8; --hot:#ff4fd8; --cyan:#44e7ff; --lime:#b8ff5a; --amber:#ffd166; }
* { box-sizing:border-box; min-width:0; }
html { font-feature-settings:"cv02","cv03","cv04","ss01"; text-rendering:geometricPrecision; }
body { margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; color:var(--text); background: radial-gradient(circle at 12% 8%, rgba(68,231,255,.28), transparent 28rem), radial-gradient(circle at 82% 4%, rgba(255,79,216,.22), transparent 24rem), radial-gradient(circle at 70% 70%, rgba(184,255,90,.08), transparent 30rem), linear-gradient(135deg, #070814 0%, #111534 55%, #080916 100%); overflow-x:hidden; }
body::before { content:""; position:fixed; inset:0; pointer-events:none; background-image:linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px); background-size:42px 42px; mask-image:linear-gradient(to bottom, black, transparent); }
main { width:min(1180px, calc(100vw - 32px)); margin:0 auto; padding:38px 0 56px; }
.hero-shell { display:grid; grid-template-columns:1.35fr .85fr; gap:22px; align-items:stretch; }
.hero-copy, .hero-panel, .command-card, .board-card, .glass-card, .topology { border:1px solid var(--line); background:linear-gradient(145deg, rgba(255,255,255,.14), rgba(255,255,255,.055)); box-shadow:0 24px 90px rgba(0,0,0,.36), inset 0 1px 0 rgba(255,255,255,.12); backdrop-filter: blur(22px) saturate(145%); border-radius:28px; }
.hero-copy { padding:34px; overflow:hidden; position:relative; }
.hero-copy::after { content:"hemx"; position:absolute; right:-18px; bottom:8px; font-size:86px; font-weight:900; color:rgba(255,255,255,.045); }
.eyebrow { color:var(--cyan); text-transform:uppercase; letter-spacing:.2em; font-weight:800; font-size:12px; }
h1 { font-size:clamp(42px, 7vw, 84px); line-height:.88; letter-spacing:-.075em; margin:12px 0 18px; max-width:900px; text-wrap:balance; overflow-wrap:anywhere; }
h2 { margin:0 0 16px; letter-spacing:-.035em; }
.lede { color:var(--muted); font-size:19px; line-height:1.55; max-width:720px; }
.hero-panel { padding:24px; }
.metrics { display:grid; gap:14px; }
.metric { border:1px solid var(--line); border-radius:22px; padding:18px; background:rgba(0,0,0,.18); }
.metric strong { display:block; font-size:34px; line-height:1; }
.metric span { color:var(--muted); font-size:13px; }
.topology { display:flex; gap:10px; margin:18px 0; padding:10px; }
.topology a { color:var(--text); text-decoration:none; padding:12px 16px; border-radius:18px; background:rgba(255,255,255,.08); }
.workspace { display:grid; grid-template-columns:360px 1fr; gap:18px; }
.command-card, .board-card, .glass-card { padding:22px; }
label { display:grid; gap:8px; color:var(--muted); font-size:13px; margin:12px 0; }
input, select, button { width:100%; border:1px solid var(--line); border-radius:16px; color:var(--text); background:rgba(2,4,18,.55); padding:13px 14px; font:inherit; outline:none; transition:transform .16s ease, border-color .16s ease, background .16s ease, box-shadow .16s ease; }
input:focus, select:focus { border-color:rgba(68,231,255,.75); box-shadow:0 0 0 4px rgba(68,231,255,.11); }
button { cursor:pointer; font-weight:800; background:linear-gradient(135deg, rgba(68,231,255,.25), rgba(255,79,216,.22)); }
.primary-action { background:linear-gradient(135deg, var(--cyan), var(--hot)); color:#050610; border:0; box-shadow:0 16px 42px rgba(68,231,255,.24); }
button:hover { border-color:rgba(68,231,255,.7); transform:translateY(-1px); box-shadow:0 14px 40px rgba(0,0,0,.24); }
.quick-actions { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:12px; }
.notice { color:var(--lime); min-height:1.4em; }
.section-heading { display:flex; justify-content:space-between; gap:12px; color:var(--muted); margin-bottom:14px; }
.section-heading strong { color:var(--cyan); }
.lanes { display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; align-items:start; }
.work-card header { display:flex; justify-content:space-between; gap:10px; align-items:start; }
.pill { display:inline-flex; border:1px solid var(--line); border-radius:999px; padding:4px 9px; font-size:12px; color:var(--lime); }
.impact { height:7px; border-radius:999px; background:rgba(255,255,255,.12); overflow:hidden; margin:12px 0; }
.impact i { display:block; height:100%; background:linear-gradient(90deg,var(--cyan),var(--hot)); }
.card-actions { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:8px; }
.card-actions button { padding:9px 10px; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.insight-grid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:18px; margin-top:18px; }
.glass-card { min-height:220px; }
.glow { box-shadow:0 0 0 1px rgba(184,255,90,.12), 0 24px 90px rgba(184,255,90,.08); }
.activity { display:grid; gap:10px; padding:0; margin:0; list-style:none; }
.activity li, .inspector-row, .live-row { border:1px solid var(--line); border-radius:16px; padding:12px; background:rgba(0,0,0,.18); color:var(--muted); overflow-wrap:anywhere; }
.inspector-hero { border:1px solid rgba(68,231,255,.35); border-radius:20px; padding:16px; margin-bottom:12px; background:linear-gradient(135deg, rgba(68,231,255,.15), rgba(255,79,216,.1)); }
.inspector-hero span { display:block; color:var(--cyan); font-size:12px; text-transform:uppercase; letter-spacing:.14em; font-weight:900; }
.inspector-hero strong { display:block; font-size:22px; letter-spacing:-.035em; margin-top:8px; overflow-wrap:anywhere; }
.inspector-hero em { display:block; color:var(--muted); font-style:normal; margin-top:6px; }
.inspector-row b { color:var(--text); }
.live-row strong { color:var(--lime); }
code { color:var(--cyan); }
@media (max-width: 920px) { .hero-shell,.workspace,.insight-grid { grid-template-columns:1fr; } .lanes { grid-template-columns:1fr; } }
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hemx Techdemo</title>
<script +src="self.runtime_src" defer></script>
<script src="/island.js" defer></script>
<link rel="stylesheet" href="/app.css">
<link rel="stylesheet" href="/control_center.css">
</head>
<body>{+= self.body =+}</body>
</html>

Some files were not shown because too many files have changed in this diff Show More