Compare commits

...

14 Commits

Author SHA1 Message Date
tmk241 a3c72e21db feat: finalize the Hemx 0.4 core
CI / test (push) Has been cancelled
2026-09-02 17:28:40 +02:00
tmk241 1dc9734f16 Isolate generated artifacts 2026-09-02 01:01:08 +02:00
tmk241 3146de7794 Isolate Rust source facts 2026-09-02 00:38:03 +02:00
tmk241 4559434068 Localize Axum interaction forms 2026-09-01 23:50:26 +02:00
tmk241 42710354a6 Isolate template authoring validation 2026-09-01 23:14:12 +02:00
tmk241 5b29100340 Close deterministic generation proof 2026-09-01 09:50:52 +02:00
tmk241 9e12b1eb46 Enforce root-owned adapter lifecycles 2026-09-01 09:43:38 +02:00
tmk241 53f0a5db1a Preserve native interaction semantics 2026-09-01 09:21:23 +02:00
tmk241 3323aaecd6 Advance plan to remaining specification gaps 2026-09-01 08:34:22 +02:00
tmk241 52ddf8904c Remove legacy and Labs surfaces from public core 2026-09-01 08:01:01 +02:00
tmk241 ad0c8b316c Version closed effect ABI as 0.4 2026-09-01 01:04:27 +02:00
tmk241 31a0f02211 Implement closed typed effect path 2026-09-01 00:58:29 +02:00
tmk241 353174604e feat(runtime): add portable runtime support
req: push/009 req: push/010 req: push/011
2026-08-17 07:59:04 +02:00
slhx agent e4bd6db13a docs(agent): track idiomatic Hemx guidance 2026-08-03 00:09:43 +02:00
237 changed files with 7103 additions and 35197 deletions
-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/
+46 -61
View File
@@ -1,74 +1,59 @@
# hemx — AGENTS.md # AGENTS.md
## Purpose ## Authority and workflow
This file tells coding agents how to work in this repository. It is a durable operating contract, not a generated inventory. Repository-root `INTENT.tsv` and `SPEC.tsv` are the sole product authorities. `INTENT.tsv` owns `PROBLEM`, `FOR`, and `OUTCOME`; `SPEC.tsv` owns falsifiable `RULE` records and their `INTENTS` edges. Code, tests, runtime observations, public documentation, `handoff.md`, and history are evidence, not parallel canon. Durable behavior changes update the canonical TSV first. `PLAN.md` is the disposable current execution surface.
Keep it stable. Prefer pointers to canonical sources over copied structure, file lists, metrics, architecture maps, command inventories, or status snapshots. Use `# intent: <id> <role>` and `# spec: <id> <role>` with the Redgate roles `impl`, `check`, `doc`, and `exception`. Put `check` only on the local executable assertion or focused command that would fail for that rule. Other roles provide traceability but do not satisfy `redgate check`; exceptions expose debt and never make strict checks pass. Feed findings back into authority, implementation, or proof rather than weakening checks.
## Agent workflow Run `redgate list`, `redgate refs`, `redgate lint`, and `redgate check` before completion. Broad or risky completion claims also require an independent fresh-context conformance review after deterministic checks pass.
- Start from product intent and requirements; inspect code only after the target behavior is clear. Final handoffs include `INTENT IMPACT` and `SPECIFICATION IMPACT`: changed IDs, proof, and any unresolved gap.
- For v1 work, read `docs/v1-product-evidence.md`, then `REQUIREMENTS.md`; use `PLAN.md` only as the mutable implementation cursor.
- Read `REQUIREMENTS.md` before changing behavior.
- If behavior changes, update `REQUIREMENTS.md` in the same change.
- If implementation work does not change durable product obligations, acceptance, safety/recovery behavior, or verification duties, say `REQUIREMENT IMPACT: none` in the handoff and why.
- Cite relevant requirements in code, tests, or docs as `req: <component>/001`.
- Do not add citation-only padding to satisfy tooling; cite only where the requirement constrains the text.
- Use requirement tags consistently: stable area tags like `[parser]`, `[auth]`, `[ui]`; temporary planning tags like `[bootstrap]`, `[mvp]`, or `[milestone-1]` only while they are useful.
- Run `redgate list`, `redgate refs`, and `redgate health` when requirements change; include `redgate lint` when the change is meant to reduce requirement maintainability warnings.
## Git workflow ## Workspace ownership
- Commit complete, coherent slices only; do not commit broken work or temporary debug output. The public core consists of seven packages:
- Use Conventional Commits: `type(scope): summary`.
- Keep commit subjects readable; requirement IDs do not have to be in the subject.
- Every behavior-changing or requirement-changing commit should cite relevant requirement IDs in the commit body or trailers using `req: <component>/001`.
- Use commit history for evolution: `git log --grep 'req: parser/012'` should find the commits that changed that behavior.
- Use the current tree for state: `REQUIREMENTS.md`, citations, tests, and `redgate health` describe what is true now.
- Before committing requirement or behavior changes, run relevant tests and the strongest `redgate health` mode supported by the installed tool. Do not invent an unsupported `--strict` flag; report a tool/format mismatch explicitly.
## Requirements-first TDD ```text
hemx-core
hemx-derive
hemx-js
hemx-build
hemx-axum
hemx
hemx-test
```
- Write requirements as desired behavior, not as a snapshot of current behavior. `hemx-core` owns effects, typed resources, wire encoding, and fingerprints. `hemx-build` inspects Hemplate templates and emits generated resource metadata. `hemx-derive` owns macros. `hemx-js` owns the generic browser runtime. `hemx-axum` owns Axum transport. `hemx` is the facade. `hemx-test` owns public testing utilities.
- Keep each requirement row to one checkable obligation with an explicit redgate ring field; shorten near-limit rows or split oversized examples, escape hatches, or negative cases into separate stable IDs while preserving the original ID's main intent.
- First split intent into broad error classes: what can go wrong, and what outcome must hold.
- Test the largest risky classes before narrow examples.
- Add adversarial tests for malformed, hostile, ambiguous, missing, duplicated, and boundary inputs.
- When changing generated lowering, prove dynamic rendered attributes at the consumer boundary; literal lowering fixtures alone are insufficient.
- Do not over-codify existing behavior while direction is still moving.
- Add narrow concrete tests only after requirements converge into a stable direction.
## Redgate CLI Server-side Wasm uses normal `hemx` and the portable Hemplate runtime. Host parser and Tree-sitter dependencies must not enter its target normal dependency graph.
- `redgate list` — show requirements as TSV. The public GitHub mirror is release output. Keep `INTENT.tsv`, `SPEC.tsv`, `PLAN.md`, `.redgate/`, requirement checks, and private workflow text out of it while preserving the tested product tree. Experimental browser-local host, Wasm, and synchronization work belongs in the separate private Labs repository and must not be copied into this core.
- `redgate refs` — show `req:` citations found in the repo.
- `redgate health` — show uncited requirements, duplicate IDs, and stale citations.
- `redgate lint` — show maintainability warnings such as missing rings and oversized requirement rows; when fixing one row in a section, normalize nearby rows with the same warning if it stays a requirement-only cleanup.
- `redgate refs` — with the installed CLI, parse the elected requirement format and audit repository citations; `redgate health` additionally enforces a newer prescriptive-row style not yet elected by this requirements corpus.
- `redgate agents` — print this starter template; review, shrink, and edit before committing.
## Local guidance ## Required proof
- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run. Run the narrowest relevant test while editing. Before completion run:
- Prefer links or pointers to canonical sources over copied lists.
- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot. ```console
- Stable commands: `cargo run -p hemx-xtask -- test`, `cargo run -p hemx-xtask -- mutation [PACKAGE] [SHARD/TOTAL]`, `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo check --workspace`, `redgate refs`. Use the xtask runner for full verification so jobs are capped from local CPU and memory and commands resolve the workspace independently of the caller's directory; use the html_examples smoke for focused repo-owned browser verification of the HTML pattern gallery, no-reload dynamic interactions, and no `/tmp` scripts. Keep fast crate tests, focused browser smoke, and full xtask authority distinct; mutation shards use one-based `1..=TOTAL` numbering, allow the wrapper's 120-second floor for compiler probes, and rely on the wrapper's package-specific nested concurrency rather than direct `mutest` invocation. The full path should stay within a documented 10 minute local timeout or use every deterministic mutation shard under the same wrapper. req: test/004 req: test/006 req: test/012 req: test/013 req: test/014 req: test/015 req: test/016 req: test/020 req: test/022 req: test/023 redgate list
- Example behavior tests should prefer `hemx_test` generated-resource assertion methods over raw slot constants, raw effect/payload matching, or boolean predicates wrapped in opaque `assert!`; failures should include the expectation and actual effects, while rendered target/handle assertions should name the generated resource. Keep browser selector helpers as test adapters only, not authoring APIs. Process-backed tests use the RAII `TestProcess` harness rather than duplicating readiness loops and child cleanup. req: test/008 req: test/009 req: test/010 req: test/017 req: test/018 req: test/019 redgate refs
- Run the workout product exemplar with `cargo run -p hemx-xtask -- workout dev` and open `http://127.0.0.1:3028`; set `HEMX_WORKOUT_ADDR=127.0.0.1:3030` if the default port is busy. Its durable visual direction and recovery expectations live in `examples/workout/DESIGN.md`. req: examples/008 redgate lint
- Use the same Workout command surface for tests, production build, and mobile release: `cargo run -p hemx-xtask -- workout test`, `cargo run -p hemx-xtask -- workout build`, `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-release`, and `HEMX_WORKOUT_ORIGIN=https://workout.example.com cargo run -p hemx-xtask -- workout mobile-verify`; Android/iOS SDKs, store submission targets, and signing remain external blockers, not repo-owned secrets, and do not imply a broad `hemx-mobile` framework. req: examples/006 req: examples/011 req: examples/013 redgate check
- hemx core stays small: effects, typed ids, registries, and wire schema only; keep features in core only when they fit typed resources plus the closed EffectBatch op set, and treat DOM details as runtime lowering. Workspace crates stay separated, stable-Rust-compatible, and free of kitchen-sink boundaries; new primitives must delete special cases. Public identifiers should flow through typed wrappers over internal `ResourceId`/`ResourceRef`, not special-case opcodes. Wire output lowers symbolic authoring names to compact metadata, the versioned canonical hemx `EffectBatch` codec, postcard surface facts, and form-encoded public requests—not JSON. ABI/schema versions and build fingerprints must guard runtime/server compatibility. v0 scope is the checked hypermedia core plus page/runtime/wire/diagnostic/test/axum proof, not optional sync/wasm/query/auth/router breadth. req: v0_scope/001 req: v0_scope/002 req: v0_scope/005 req: laws/001 req: invariant/001 req: invariant/005 req: typed_id/001 req: typed_id/003 req: effect_algebra/001 req: effect_algebra/006 req: wire/001 req: wire/002 req: wire/003 req: wire/004 req: wire/005 req: wire/006 req: abi/001 req: abi/002 req: abi/003 req: abi/004 req: abi/005 req: misc/001 req: misc/002 req: misc/003 req: misc/004 req: misc/005 req: misc/006 req: misc/007 req: misc/008 req: misc/009 req: misc/010 cargo fmt --all -- --check
- Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008 cargo clippy --workspace --all-targets --all-features -- -D warnings
- The SaaS production reference must use real links/URLs for navigation and an ongoing server-owned canonical SSE stream; bounded one-event behavior is a test probe, not the public transport contract. Browser history restores saved generated page snapshots and scroll position when available, with partial-fetch fallback, and restored revealed bindings must re-arm. req: nav/001 req: nav/002 req: nav/005 req: convention/014 req: push/003 req: examples/014 cargo test --workspace --all-targets --all-features
- Public examples and beginner APIs should use templates plus Rust, generated component APIs, resources, view wrappers, render/page helpers, `#[hemx::app]`, plain `#[hemx::handler]` functions, and `IntoEffect`, not atoms, raw ids, selectors, wire formats, runtime opcodes, manual registries, `$OUT_DIR` includes, raw render/lower calls, raw HTML construction, imperative DOM mutation, or raw effect constructors; keep advanced layers out of starters. req: canonical_authoring/001 req: canonical_authoring/004 req: canonical_authoring/006 req: canonical_authoring/010 req: canonical_authoring/015 req: invariant/003 req: dx/001 req: dx/002 req: dx/010 req: component/003 req: component/004 req: component/005 req: view/001 req: view/002 req: view/003 req: html_safety/001 req: html_safety/003 req: html_safety/005 req: public_api/001 req: public_api/002 req: public_api/003 req: public_api/005 req: public_api/006 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003 req: derive_app/001 req: derive_app/002 req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005 cargo deny check licenses sources
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry canonical versioned hemx `EffectBatch` bytes over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002 cargo check -p hemx-server-wasm-test --target wasm32-unknown-unknown
- `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008 cargo tree -p hemx-server-wasm-test --target wasm32-unknown-unknown --edges normal,no-proc-macro
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007 ```
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
- The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. hemx-build consumes hemplate Surface facts and must not grow an independent `.heml` parser or CSS-path identity model. Plain CSS/SCSS owns appearance; generated class constants are ergonomic references, not a styling framework or behavior selector system. Generated resources, form/handle metadata, atoms, and event constants come from hemx-build facts, not hand-written app plumbing. Forms remain HTML-shaped, checked against user-authored Rust domain types, parsed through `FormValue`, and manipulated through generated form/control ids rather than selectors. Proc-macros stay local/side-effect-free while build.rs owns global codegen and hard build failures. No-op global codegen must preserve generated artifact timestamps so downstream Rust compilation remains fresh only when canonical output changes. req: boundary/001 req: boundary/002 req: boundary/003 req: boundary/004 req: surface/001 req: surface/002 req: surface/003 req: surface/004 req: surface/005 req: surface/006 req: surface/007 req: surface/008 req: surface/009 req: surface/010 req: codegen/001 req: codegen/003 req: codegen/004 req: codegen/005 req: codegen/006 req: form/001 req: form/004 req: form/007 req: form/008 req: form_effects/001 req: form_effects/002 req: form_effects/003 req: build/001 req: build/002 req: build/003 req: build/004 req: build/005 req: build/006 req: build/007 req: build/008 req: build/009 req: style/001 req: style/002 req: style/003 req: style/004 req: style/005 req: style/006 The target tree must contain neither `tree-sitter` nor `hemplate-parser`. Publishable package changes also require archive-based `cargo package --list` and `cargo package` checks for all seven packages.
- Optional `.heml` editor overlays must share authority with `hemx-build` diagnostics and `docs/hemplate-syntax.md`; `hemx-lsp` owns editor protocol glue for diagnostics/completion/hover and derive-known template facts, while VS Code/Cursor/Neovim keep normal HTML/tree-sitter tooling. Simple `h-for` completion facts cover one Rust identifier bound directly to a `self` vector field; malformed bindings and non-vector fields must not fabricate locals. Do not create a second template language, selector model, formatter, Rust type system, or custom editor framework. Compiler diagnostics with directive/target metadata select that source attribute instead of line 0 column 0. Cross-file template/handler references visible to build validation must fail at `cargo check` with useful spans; global completeness checks stay component-scoped unless caught at mount/tests. req: diagnostics/004 req: diagnostics/005 req: diagnostics/006 req: diagnostics/007 req: diagnostics/008 req: diag/009 req: diag/010 req: invariant/004 req: invariant/006 req: check/001 req: check/003
- JS runtime changes must preserve root-scoped lookup, delegated listeners, canonical hemx `EffectBatch` application, dynamic polling and viewport-aware revealed binding, fail-closed request handling, transactional/recoverable failure behavior, root-scoped error outlets, and tiny pending/failure/trigger-timing conventions without selectors, handler-name parsing, VDOM, expressions, or per-node listeners. Runtime `.d.ts` types are developer convenience only, not core tooling authority. req: invariant/002 req: runtime/001 req: runtime/002 req: runtime/003 req: runtime/005 req: runtime/006 req: runtime/007 req: failure/001 req: failure/002 req: failure/003 req: failure/004 req: failure/005 req: failure/006 req: convention/001 req: convention/002 req: convention/003 req: convention/004 req: convention/005 req: convention/006 req: convention/007 req: convention/008 req: convention/009 req: convention/010 req: convention/011 req: convention/012 req: convention/013 req: convention/014 req: convention/015 req: convention/016 req: convention/017 req: ts/001 ## Product constraints
- Opaque island JavaScript is a leaf adapter for high-frequency local behavior only; client-local handlers keep the server-handler shape while `hemx-wasm` owns concrete opt-in syntax. Use native events/generated helpers at the boundary and do not introduce a component runtime, client state graph, VDOM, selector interop, or second UI model. req: canonical_authoring/017 req: client_local/001 req: client_local/003 req: client_local/004 req: interop/001 req: interop/002 req: interop/003 req: interop/006 req: interop/007 req: interop/008 req: interop/009 req: interop/010 req: interop/011 req: interop/012
- Host capability adapters must stay at the `hemx-host` boundary: typed capabilities use fire/request/stream/schedule shapes; adapters may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/001 req: host/002 - The server owns effects and behavior; generated typed resources connect templates to handlers and effects.
- Local/offline app behavior should be commands/events/projections; do not add `hemx-local`, stored DOM patches, stored `EffectBatch` truth, or a core client state graph without a proven reusable contract. Atoms are explicit addressable/bootstrap/sync resources, not the default state container or a reactive framework. Replay, reconciliation, export, and deletion rules stay explicit product decisions, and exemplars should show UI effects as app-state output. The local-first multiplayer kanban remains an advanced north-star integration milestone, not beginner/API surface scope. req: canonical_authoring/018 req: canonical_authoring/019 req: state/001 req: state/002 req: state/003 req: state/004 req: state/005 req: state/006 req: state/007 req: local/001 req: local/002 req: local/003 req: local/004 req: milestone/001 req: milestone/002 req: milestone/003 - Raw HTML remains explicit and typed.
- Axum 0.8 apps should serve and load the shared runtime through hemx-axum helpers such as `runtime_js_path()` and `runtime_js()`, not hard-coded `/hemx.js` URLs or app-owned cache-busting strings; keep hemx-axum as route/runtime/handler adapter around generated partial swaps, not a routing owner. req: axum/002 req: axum_integration/001 req: axum_integration/002 req: axum_integration/003 req: axum_integration/005 req: axum_integration/006 - Canonical wire bytes and ABI compatibility remain deterministic.
- Framework behavior stays in `hemx-axum`; browser runtime behavior stays in `hemx-js`.
- Public docs change with APIs, package boundaries, or compatibility.
- Preserve licenses; do not commit archives, `target/`, coverage, fuzz, mutation, local editor, or private governance output to the public mirror.
Generated
+156 -1461
View File
File diff suppressed because it is too large Load Diff
+5 -8
View File
@@ -1,17 +1,14 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-sync", "hemx-sync-macros", "hemx-wasm", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/client_local", "examples/techdemo", "examples/saas", "examples/workout"] members = ["hemx", "hemx-core", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "tests/server-wasm"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.4.0"
edition = "2021" edition = "2021"
rust-version = "1.88"
license = "MIT"
repository = "https://github.com/tmk241/hemx"
[profile.release] [profile.release]
opt-level = "z" opt-level = "z"
lto = true lto = true
# Upstreams still request retired package identities. These tiny bridges re-export
# the maintained successors without introducing a second implementation.
[patch.crates-io]
paste = { path = "compat/paste" }
spin = { path = "compat/spin" }
+9
View File
@@ -0,0 +1,9 @@
ID PROBLEM FOR OUTCOME
server/001 Browser frameworks duplicate authoritative application state and policy Rust teams building ordinary server-owned web applications Keep state, authorization, validation, and recovery on the server
resource/001 Copied selectors and protocol identifiers drift away from rendered templates Template and handler authors Use generated typed resources and actions for every interaction boundary
protocol/001 Ad hoc browser commands create ambiguous ordering and compatibility Framework adapters and browser runtimes Apply one deterministic ordered effect protocol within an owned root
web/001 Framework abstractions often replace native web semantics unnecessarily People using Hemx applications Retain semantic HTML, accessibility, URLs, forms, and browser fallback
portable/001 Host-only parsing dependencies prevent portable server-side rendering Rust applications targeting Wasm and constrained servers Render through normal Hemx APIs without host parser dependencies
scope/001 Universal extension systems turn a small interaction layer into a client framework Maintainers and application authors Compose ordinary behavior from a small kernel, direct adapters, and explicit islands
maintainability/001 Build and adapter responsibilities are interleaved, making safe changes costly Hemx maintainers and downstream users relying on stable generated contracts Cohesive private ownership seams preserve behavior, diagnostics, and portability
assurance/001 Boundary changes can pass broad suites without focused semantic drift checks Maintainers changing generated APIs, handlers, adapters, and browser runtime Focused checks expose contract and integration drift at the owning seam
1 ID PROBLEM FOR OUTCOME
2 server/001 Browser frameworks duplicate authoritative application state and policy Rust teams building ordinary server-owned web applications Keep state, authorization, validation, and recovery on the server
3 resource/001 Copied selectors and protocol identifiers drift away from rendered templates Template and handler authors Use generated typed resources and actions for every interaction boundary
4 protocol/001 Ad hoc browser commands create ambiguous ordering and compatibility Framework adapters and browser runtimes Apply one deterministic ordered effect protocol within an owned root
5 web/001 Framework abstractions often replace native web semantics unnecessarily People using Hemx applications Retain semantic HTML, accessibility, URLs, forms, and browser fallback
6 portable/001 Host-only parsing dependencies prevent portable server-side rendering Rust applications targeting Wasm and constrained servers Render through normal Hemx APIs without host parser dependencies
7 scope/001 Universal extension systems turn a small interaction layer into a client framework Maintainers and application authors Compose ordinary behavior from a small kernel, direct adapters, and explicit islands
8 maintainability/001 Build and adapter responsibilities are interleaved, making safe changes costly Hemx maintainers and downstream users relying on stable generated contracts Cohesive private ownership seams preserve behavior, diagnostics, and portability
9 assurance/001 Boundary changes can pass broad suites without focused semantic drift checks Maintainers changing generated APIs, handlers, adapters, and browser runtime Focused checks expose contract and integration drift at the owning seam
+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.
+35 -17
View File
@@ -1,21 +1,39 @@
# Active frontier — peak idiomatic examples # PLAN
**Parent ID:** `examples-idiom/001` Current outcome: make Hemx internals easier to change without altering public behavior, generated contracts, diagnostics, or portability.
- **User value:** A newcomer can move from first app to production reference and advanced integration while seeing one coherent hemx model: plain `.heml`, generated resources, typed Rust handlers/effects, real links/forms/URLs, server-owned truth, native fallback, and explicit leaf adapters. ## HMX-M01 — Isolate template authoring validation
- **State:** Ready — the canonical starter and Workout exemplar are already strong; bounded teaching leaks remain in the HTML gallery, client-local example, SaaS reference, and advanced Kanban boundary.
- **Non-goals:** no new framework primitives, client router, VDOM, selector authoring API, reactive expression language, global client store, CSS framework/design system, generalized asset pipeline, visual redesign, or feature expansion. Do not churn `examples/workout` or `examples/techdemo` without a concrete failing contract.
- **Build:**
- **`examples-idiom/002` — Done: the SaaS reference tells the production truth.** Settings is a real `/settings` link enhanced by the existing page-swap runtime, direct `/settings` renders the fallback page, and `/events` now sends an initial canonical batch followed by ongoing server-owned status updates; `?once` remains only as a bounded production-reference probe. The removed handler no longer simulates navigation with response effects. Raw selector/form construction remains confined to test adapters because generated handles are the asserted boundary, not an app authoring API. req: canonical_authoring/001 req: nav/001 req: nav/002 req: nav/004 req: push/003 req: examples/003 req: examples/004 req: examples/014
- **`examples-idiom/003` — Make the HTML pattern gallery mechanically copyable.** In `examples/html_examples/src/main.rs`, generated `gallery` resources, and nearby templates/tests, express the dependent-select flow through typed generated values/partials instead of duplicated string-to-option mapping, and remove request fields discarded only to satisfy example plumbing where the generated handler contract permits it. Preserve each visible htmx slug, native form semantics, server-owned state, inserted-content behavior, and no-reload smoke. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: canonical_authoring/001 req: form/004
- **`examples-idiom/004` — Make client-local code visibly a leaf adapter.** In `examples/client_local/src/lib.rs` and its `.heml` surface, project the generated client event/state into a tiny typed counter-domain input/output instead of rendering raw event kind and encoded state as the example's product value. Keep the ordinary `#[hemx::handler(client)]` shape, generated event/state boundary, native event semantics, and no durable client state graph. req: client_local/001 req: client_local/003 req: client_local/004 req: canonical_authoring/017
- **`examples-idiom/005` — Separate the advanced Kanban adapter from ordinary hemx app code.** Move the cohesive sync/presence/session/storage transport responsibility from `examples/kanban/src/main.rs` behind one clearly named local integration module with a small route/state contract; keep board templates and ordinary handlers nearby and unchanged where possible. Update `examples/kanban/README.md` to label the fixture as the advanced local/offline/sync north-star, route beginners to the starter/Workout/gallery first, and name legacy `/sync-demo`/`sync.js` as a compatibility probe rather than recommended authoring. Preserve replay, export, deletion, reconnection, auth, and multiplayer browser proof. req: state/001 req: state/002 req: local/001 req: local/002 req: milestone/001 req: sync/001 req: sync/008
- **`examples-idiom/006` — Publish and enforce the example ladder.** In `README.md`, example READMEs, and the nearest existing xtask/example checks, identify `app new`/`examples/v0` as the first canonical app, `html_examples` as the pattern gallery, Workout as the product exemplar, SaaS as the production integration reference, `client_local` as the narrow leaf-adapter proof, Techdemo as exhaustive verification, and Kanban as advanced north-star integration. Add the smallest repository-owned guard that fails when beginner/reference authoring regresses to raw IDs, selectors, raw wire/effect constructors, `$OUT_DIR` includes, or app-authored DOM mutation; keep legitimate advanced/test adapters scoped rather than banning tokens globally. req: examples/001 req: examples/003 req: examples/004 req: examples/005 req: examples/007 req: examples/008 req: examples/009 req: canonical_authoring/001 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003
- **Blocked by:** none. The separate v1 legal release gate remains blocked on the owner license decision but does not block example work.
- **Proof:** each slice must make its user path observable, preserve the named native/recovery path, and pass its focused package/browser proof. Parent closure requires `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo run -p hemx-xtask -- workout test`, focused SaaS/client-local/Kanban package tests, `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `cargo fmt --check`, `redgate list`, `redgate refs`, and a clean diff. Frustration signals to reject: a beginner must author selectors/raw IDs/wire ops; navigation loses URL/history; JavaScript becomes durable truth; an example claims live/recovery behavior with a one-shot stub; or advanced sync machinery appears to be the default app model.
## Blocked release decision retained Outcome: template authoring validation is privately owned without public, diagnostic, ordering, or fingerprint drift.
Delta: architecture/001 assurance/001
Checks: `cargo test -p hemx-build`; focused contract/fingerprint/diagnostic checks; Redgate; diff check; fresh-context review.
State: Done
Blocked by: none
- [ ] **State:** Needs decision — choose the repository distribution license and approved third-party SPDX set, then add root license file(s), workspace package metadata, `deny.toml`, and rerun the release matrix. ## HMX-M02 — Isolate Rust source fact extraction
- **Blocked by:** owner/legal authority. Current third-party set includes Apache-2.0, Apache-2.0 WITH LLVM-exception, BSD-3-Clause, BSL-1.0, MIT, Unicode-3.0, and Unlicense; all 20 workspace packages currently lack license metadata.
- **Proof:** `cargo deny check advisories sources licenses` and the full `docs/v1-readiness.md` matrix pass, then readiness changes from NO-GO to GO without publication or deployment. Outcome: Rust/syn context facts are privately owned without generated contract, ordering, fingerprint, or host-boundary drift.
Delta: architecture/002 assurance/001
Checks: focused build facts/contracts/fingerprint tests; Wasm graph; Redgate; diff check; fresh review.
State: Done
Blocked by: none
## HMX-M03 — Isolate artifact emission and diagnostics
Outcome: generated artifacts and contract diagnostics are privately owned without byte, ordering, fingerprint, or diagnostic drift.
Delta: architecture/003 assurance/001
Checks: exact generated API/diagnostic/fingerprint/no-op fixtures; package checks; Redgate; diff check; fresh review.
State: Done
Blocked by: none
## HMX-M04 — Localize Axum interaction forms
Outcome: the interaction-form boundary is privately owned without public extraction, decoding, limit, or rejection drift.
Delta: architecture/004 assurance/002
Checks: Axum interaction boundary suite; derive Form<T> spelling check; custom multipart typed extraction; Redgate; diff check; fresh review.
State: Done
Blocked by: none
## Closure
Run all repository-required Redgate, fmt, clippy, workspace test, license, Wasm graph, and package archive gates. Finish with a fresh-context blocker-only review against INTENT.tsv, SPEC.tsv, this plan, the actual diff, and proof outputs. Keep `hemx-core`, `hemx-js`, `hemx-derive`, and public package boundaries cohesive unless a later validated responsibility seam requires change.
+88 -204
View File
@@ -1,210 +1,94 @@
# hemx # Hemx
hemx is checked hypermedia for Rust: write hemplate templates, write typed Rust Hemx is checked hypermedia for Rust. Applications render Hemplate views, handle
handlers, and return generated UI commands. The browser receives checked UI events in typed Rust functions, and return generated UI effects. The browser runs
commands; ordinary server-first apps do not need a frontend framework, a small effect interpreter instead of a virtual DOM, hydration framework, or
handwritten UI JavaScript, selector targeting, or raw runtime primitives. req: pitch/001 req: canonical_authoring/001 client-side expression language. The same core compiles for native servers and
server-side Wasm isolates; platform storage, sockets, and lifecycle remain
application concerns.
Status: the evidence-backed v1 behavior slices are implemented and browser-proven: ## How it works
server-first/page-enhanced behavior, client-local WASM, durable offline/sync, the
multiplayer Kanban milestone, and the production reference. Local workspace,
browser, performance, documentation, and canonical-example gates pass. The
warning-denied vulnerability and source audits are clean; strict license closure
awaits a repository license allowlist. See `docs/v1-product-evidence.md` for the
product boundary, `REQUIREMENTS.md` for authority, `PLAN.md` for execution state,
and `docs/v1-readiness.md` for evidence. No publishing is implied.
Template authoring: `.heml` is HTML plus a small hemplate overlay for escaped 1. `.heml` templates declare page roots, slots, forms, handles, and keyed targets.
text, trusted HTML, dynamic attributes, Rust-shaped control directives, generated 2. `hemx-build` generates typed Rust helpers from that surface.
slots/forms/handles, and keyed partial targets. See `docs/hemplate-syntax.md`. 3. `#[hemx::handler]` functions accept ordinary Rust inputs and return typed effects.
Editor setup for VS Code, Cursor, and Neovim lives in `docs/editor-support.md`; 4. `hemx-axum` serves pages, assets, handler routes, and effect responses.
VS Code/Cursor share the repo extension in `editors/vscode-hemx`, while all 5. The browser runtime validates the build fingerprint and applies effects within the current root.
editors keep normal HTML/tree-sitter highlighting and layer `hemx-build`
diagnostics on top.
Local checkout note: until the hemplate crates are published, this repository ```rust,ignore
expects `hemplate` checked out next to `hemx` as `../hemplate/hemplate`. The app #[hemx::handler]
scaffolder fails with that exact path if the prerequisite is missing, instead of async fn add_todo(form: NewTodo) -> impl IntoEffect {
creating an app that fails later with a vague Cargo path-dependency error. ui::todos().append(TodoRow::from(form))
}
## The normal path
For beginner and production-shaped app code, stay on this path. req: public_api/001 req: public_api/005
1. **Templates declare the surface.** `.heml` files declare roots, slots,
forms, handles, keys, page targets, optional pending states, and explicit
leaf islands with `data-hemx-*` attributes. The stable syntax surface lives in
`docs/hemplate-syntax.md`.
2. **Build generates typed helpers.** `hemx_build::app().run()` consumes the
hemplate Surface and emits generated Rust helpers for slots, forms, handles,
page targets, classes, and events. req: ceremony/003 req: build/001
3. **Handlers are plain Rust.** App code uses `#[hemx::handler]` functions with
ordinary domain types, framework extractors, generated `Form<T>` inputs,
explicit `data-*` params, and `impl IntoEffect` or fallible returns. req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005
4. **Handlers return generated commands.** Common handlers return helpers such
as `todos.append(todo)`, `todo_row.replace(row)`, `new_todo.error("title",
"Required")`, `new_todo.clear()`, `page.replace(view)`, or tuples of those
commands. req: dx/006 req: dx/007
5. **The runtime only applies effects.** The JavaScript runtime is a small,
root-scoped effect interpreter: no VDOM, no hydration framework, no client
expression engine, no selector retargeting, and no app state store. req: runtime/003 req: invariant/002
## Mental model: render → slot/key → effect → runtime
- **Render:** hemplate renders Rust view structs into checked HTML. App code
normally reaches rendering through generated `ui::page(...)` helpers at a
server page boundary, with explicit `SafeHtml` only for already-rendered
fragments, not raw HTML construction. req: html_safety/002 req: html_safety/004 req: view/001
- **Slot/key:** a generated slot names the target, and a generated keyed slot
also carries the stable row key. A list target inside `h-for` must have a
stable `h-key`, so row updates are addressable without CSS selectors. req: list/001
- **Effect:** handlers return typed commands that become a checked effect
response. Tuple composition is the normal fixed batch syntax; arrays and
`Vec<T: IntoEffect>` cover fixed or dynamic repeated partial updates.
- **Reuse:** the hemx answer to framework components is reusable hemplate
partials plus generated helpers, app-owned state, `IntoEffect` composition,
and explicit leaf islands when browser-owned behavior is necessary. See
`docs/recipes/reusable-partials.md`.
- **Runtime:** the browser checks the build fingerprint, resolves targets within
the current `data-hemx-root`, and applies compatible batches. Mismatched
server/runtime builds fail closed instead of silently mutating the wrong DOM.
req: abi/002 req: check/004 req: failure/005
## Forms and errors
Forms remain HTML forms. hemx checks the generated form contract against a
user-authored Rust form type, so domain newtypes such as `Email`, `TodoId`, and
`Title` parse through ordinary Rust traits rather than generated DTOs. req: form/001
Use validation effects for expected user mistakes, and `Result<impl IntoEffect,
E>` for fallible domain, database, or infrastructure work. Integration crates map
`E` to generated UI effects, redirects, events, or HTTP responses; the canonical
app code still uses the same handler shape for plain and fallible handlers. See
`docs/diagnostics.md` for common compile/build/runtime mistakes and fixes.
req: failure/004 req: derive_handler/004
## Pages, push, CSS, and islands
- Page navigation is a specialized generated page/slot effect around real
anchors and ordinary HTTP routes. hemx does not own routing. req: modes/002 req: axum_integration/002
- Server push streams send checked effect responses over framework-managed
transports such as SSE; auth and connection policy stay in the server
integration. req: wire/004 req: push/002
- Appearance is plain CSS. Generated class tokens can make dynamic classes
checked, but hemx does not introduce a styling runtime. req: style/001 req: style/002 req: style/003 req: style/004 req: style/005 req: style/006
- Custom JavaScript belongs at explicit opaque leaf boundaries: charts, maps,
editors, Web Components, or similar widgets. Islands communicate through
generated handles/events and do not create a second UI model. req: canonical_authoring/007 req: canonical_authoring/017 req: interop/003
## Production boundary
hemx is not a SaaS platform. Production concerns stay in normal Rust/web crates
and integrate at explicit boundaries. req: laws/002 req: auth/001
- **Persistence:** use SQLx or another storage adapter in your application
state/handlers. hemx should see ordinary domain values and generated UI
commands, not own the database layer. See `docs/recipes/sqlx-persistence.md`.
- **Auth/session:** use Axum/Tower extractors and middleware. Handlers may accept
typed auth/session context and return ordinary HTTP failures or generated UI
failures. See `docs/recipes/auth-session-csrf.md`. req: auth/002
- **CSRF:** keep CSRF policy in middleware/extractors with hidden form fields,
cookies, and normal SameSite/browser semantics. hemx preserves submitted form
fields and credentials semantics. See `docs/recipes/auth-session-csrf.md`. req: auth/004 req: auth/005
- **Observability, feature flags, killswitches, deploy:** use explicit platform
integrations around handlers, routes, runtime assets, and mobile shells. Core
hemx must not vendor providers or add framework-specific magic. See
`docs/recipes/observability-flags.md`, `docs/recipes/deploy-versioning.md`,
and `docs/recipes/mobile-release.md`. req: examples/011
- **Mobile starter:** create the phone-first path with `cargo run -p
hemx-xtask -- app new --mobile PATH`. The starter carries a real app flow,
typed host capabilities, command/event/projection recovery truth, and
inspectable mobile release-kit commands without adding a native UI framework.
Use it for Rust-owned hypermedia apps; use explicit native shells/islands for
heavy native UI, games, camera-heavy flows, deep OS integration, or complex
offline sync. req: ceremony/006 req: host/002
- **PWA/offline/sync:** optional adapters may reuse generated targets/effects,
but core hemx must not gain a mandatory client state graph or local app
runtime. Local truth is commands/events/projections, not stored DOM patches or
stored `EffectBatch` payloads. See `docs/recipes/pwa-offline.md` and
`docs/recipes/local-command-log.md`. req: canonical_authoring/008 req: canonical_authoring/018 req: canonical_authoring/019 req: local/001 req: local/002
- **Host capabilities:** browser, PWA, WebView, and native-shell capabilities use
`hemx-host` manifests/calls/events. Adapters return host facts to app code;
UI still changes through normal hemx effects. See
`docs/recipes/host-capabilities.md`. req: host/001 req: host/002 req: host/005
## Escape hatches
Advanced APIs are named and isolated. Raw effects, low-level ids, manual
registries, raw HTML/render/target construction, runtime hooks, SSE internals,
and island internals are for integration crates, tests, migrations, or explicit
leaf boundaries. They should not appear in beginner examples or ordinary handler
docs. req: public_api/002 req: public_api/005
## Versioning and deploy compatibility
The generated API, symbols, effect wire schema, and JavaScript runtime carry
schema/ABI versions. Deploy a matching server, generated output, and runtime
asset together. Build fingerprints are derived from the generated surface and ABI
parts; the runtime refuses incompatible effect responses and integrations should
fall back to a full page reload when possible. See `docs/recipes/deploy-versioning.md`. req: abi/001 req: abi/002 req: failure/005
Before a v1 release, the semver policy and upgrade notes should explicitly state
which surfaces are stable: beginner generated helpers and handler shapes; the
wire/runtime ABI; and advanced escape hatches that may remain integration-level.
See `docs/versioning.md`.
## Examples
- `examples/v0`: canonical beginner path covering counter, typed todo CRUD,
form wizard, auth action, page swaps, SSE notifications, and keyed list
updates. Create the generic starter with `cargo run -p hemx-xtask -- app new
PATH`; it includes a page, form, keyed row partial, notice slot, handlers,
tests, and generated append/replace/remove/dynamic-batch updates. Start here.
req: ceremony/005
- `examples/html_examples`: copy-paste HTML pattern gallery for htmx-style
CRUD/form/search/load UX patterns. It proves the hemx idiom for click-to-edit,
edit row, delete row, inline validation, click-to-load, and active search with
`.heml`, generated resources, server-owned Rust state, and tiny runtime
behavior. req: htmx_equivalents/001 req: htmx_equivalents/005 req: examples/001
- `examples/saas`: compile-tested v1 tutorial app covering auth/session,
CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain
CSS, and one explicit island without provider-heavy platform scope. Read the
walkthrough in `docs/tutorial-saas.md`; the SQLx persistence recipe in
`docs/recipes/sqlx-persistence.md` shows the provider boundary without moving
SQL into core.
- `examples/workout`: phone-first local-first product exemplar. Create a starter
with `cargo run -p hemx-xtask -- workout new PATH`; run the exemplar with
`cargo run -p hemx-xtask -- workout dev` and open `http://127.0.0.1:3028`.
It keeps workout truth as commands/events/projections and routes export
through the host capability boundary; `examples/workout/README.md` documents
the canonical dev, test, production build, mobile release, verification, and
failure-mode paths. req: examples/001 req: examples/006 req: local/001 req: host/005
- `examples/kanban`: advanced / north-star milestone boundary sketch. It may
expose manual registry or render escape hatches while exploring product limits.
- `examples/techdemo`: advanced integration demo with a leaf island and broader
product interactions.
## Local checks
```sh
# Fast compile/regression pass for the crate you touched.
cargo test -p <crate>
# Focused real-browser smoke for the HTML pattern gallery.
cargo run -p hemx-xtask -- html-examples-smoke
# Full local authority check; keep this green before shipping broad slices.
cargo run -p hemx-xtask -- test
cargo check --workspace
redgate health --strict
``` ```
Use `cargo run -p hemx-xtask -- test` for the full local verification path so ```html
jobs stay capped for local CPU and memory. The focused browser tier is <form data-hemx-form="new_todo">
`cargo run -p hemx-xtask -- html-examples-smoke`; it owns dynamic html_examples <input name="title" required>
browser behavior and should complete in about 30 seconds locally. The full tier <button type="submit">Add</button>
should complete within a 10 minute local timeout; if it grows beyond that, split </form>
it into deterministic repo-owned shards that together cover the same behavior, <ul data-hemx-slot="todos"></ul>
with `cargo run -p hemx-xtask -- test` remaining the full authority wrapper. ```
req: test/004 req: test/006 req: test/015 req: test/016
## Design boundary
Hemx owns checked UI effects and their browser runtime. It does not own routing,
databases, authentication, CSS, or application state. Those remain ordinary
Rust and web concerns. Browser-specific behavior belongs in explicit leaf islands
rather than a second application model.
The normal application path is:
- `hemx` for handler and effect APIs;
- `hemx-build` for generated resources;
- `hemx-axum` for Axum integration;
- `hemx-js` for the browser effect runtime.
Server-side Wasm uses the same core crates and canonical effect batches; it does
not require a separate Hemx Wasm runtime package.
## Workspace
| Package | Purpose |
| --- | --- |
| `hemx` | Application-facing facade and handler macro export |
| `hemx-core` | Effect types, protocol values, validation, and runtime primitives |
| `hemx-derive` | Procedural macros |
| `hemx-build` | Build-time template analysis and generated resources |
| `hemx-axum` | Axum routes, responses, assets, and server push |
| `hemx-js` | Browser runtime source |
| `hemx-test` | Test support for applications |
## Agent skill
The optional [`idiomatic-hemx`](https://github.com/tmk241/hemx-skills) skill
helps coding agents apply Hemx's generated-resource and server-owned effect
model:
```console
npx skills@latest add tmk241/hemx-skills --skill idiomatic-hemx
```
## Development
```console
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-targets --all-features
cargo deny check licenses sources
```
## Related projects and acknowledgements
[htmx](https://htmx.org/) helped popularize the HTML-over-the-wire,
hypermedia-driven approach that inspired Hemx. Hemx is an independent
implementation and does not bundle htmx.
Hemx builds on [Hemplate](https://github.com/tmk241/hemplate),
[Tokio](https://github.com/tokio-rs/tokio),
[Axum](https://github.com/tokio-rs/axum), and the Rust procedural-macro
ecosystem. Thank you to their maintainers and contributors.
## License
MIT
-1140
View File
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
ID RULE INTENTS
kernel/001 The wire effect algebra must contain only Patch, Insert, Remove, Move, Focus, Scroll, Visit, and Dispatch. protocol/001 scope/001
kernel/002 Patch must offer Morph and Replace; Insert and Move must use first, last, before, or after positions. protocol/001 resource/001
kernel/003 Every DOM effect target must be a generated resource reference, never a CSS selector. resource/001 protocol/001
kernel/004 A batch must validate its envelope before applying effects in order and stopping at the first failure. protocol/001
kernel/005 A later effect must be able to address a resource created by an earlier effect in the same batch. protocol/001 resource/001
kernel/006 Move must preserve the identity and browser-owned state of the moved node. protocol/001 web/001
kernel/007 Rendered HTML must enter an effect only through the explicit SafeHtml type. server/001 web/001
kernel/008 Identical semantic effect batches must produce identical canonical wire bytes. protocol/001
kernel/009 Decoding must reject invalid magic, versions, tags, lengths, UTF-8, fingerprints, and trailing bytes. protocol/001
kernel/010 Compatibility must never guess an opcode, resource, selector, fingerprint, or alternate wire meaning. protocol/001
kernel/011 Canonical effect batches must round-trip between typed values and wire bytes without semantic loss. protocol/001
kernel/012 A compatibility fixture must accept only its declared wire ABI version. protocol/001
kernel/013 Focus must change focus without encoding form validation or other application policy. protocol/001 web/001
kernel/014 Scroll must reveal its resource without changing focus. protocol/001 web/001
kernel/015 Dispatch must carry a generated typed non-visual payload without mutating the DOM. protocol/001 resource/001
kernel/016 Visit must update URL history through partial navigation with ordinary navigation fallback. protocol/001 web/001
resource/001 Generated resource types must expose only operations valid for their capability. resource/001
resource/002 Generated APIs must carry resource, action, fingerprint, opcode, and marker values for application code. resource/001
resource/003 Repeated resources must use generated stable keys for typed Remove and Move operations. resource/001 protocol/001
resource/004 Generated form helpers must address fields through typed resources, not copied selectors or IDs. resource/001 server/001
resource/005 Generated artifacts must change only when their semantic template inputs change. resource/001 protocol/001
resource/006 Missing or invalid generated resources must fail compilation with an actionable source diagnostic. resource/001
html/001 Generated markup must use data-hemx-root, build, resource, key, action, and island markers. resource/001 protocol/001
html/002 Native event defaults must need no attribute; data-hemx-on must only override the native event. web/001 scope/001
html/003 Navigation must use anchors or GET forms and retain ordinary browser fallback without enhancement. web/001 server/001
html/004 On validation failure, rendered forms must identify invalid fields and associate accessible messages. web/001 server/001
html/005 Hemx must preserve native keyboard, IME, autofill, file selection, and constraint-validation behavior. web/001 scope/001
html/006 Request policy must cover only concurrency, debounce, throttle, confirmation, navigation, and history. web/001 scope/001
html/007 Concurrency policy must be one of latest, queue, drop, or parallel. web/001 scope/001
runtime/001 The browser runtime must apply an effect only within the root owning its generated resource. protocol/001 resource/001
runtime/002 Loading the runtime must expose diagnostics without performing startup side effects. protocol/001
runtime/003 Ordinary forms must submit URL-encoded data unless native semantics select another encoding. web/001 server/001
runtime/004 GET forms must produce URL-state navigation while retaining ordinary navigation fallback. web/001 server/001
runtime/005 HTTP, decode, or effect failure must stop the batch and emit one root-scoped diagnostic. protocol/001
runtime/006 SSE and WebSocket adapters must carry unchanged canonical effect-batch bytes. protocol/001 scope/001
runtime/007 Each optional adapter must bind once, scan inserted fragments, clean removals, and enforce root ownership. scope/001 protocol/001
runtime/008 An explicit island must own its subtree; Hemx must not morph through island-owned nodes. scope/001 web/001
axum/001 Axum effect responses must carry canonical batch bytes and the generated build fingerprint. protocol/001 resource/001
axum/002 Axum must serve the generic browser runtime without owning application behavior. server/001 scope/001
axum/003 Partial navigation must preserve status and title metadata and support full-navigation recovery. web/001 server/001
axum/004 Interaction requests must enforce media type and configured body limits before handler dispatch. server/001
axum/005 Typed dispatch responses must preserve status, canonical bytes, and actionable diagnostics. protocol/001 server/001
build/001 A template read failure must report the source path and I/O cause. resource/001
build/002 Identical inspected template inputs must produce identical generated contract fingerprints. resource/001 protocol/001
build/003 A no-op build must not rewrite an unchanged generated contract artifact. resource/001
derive/001 The surface macro must preserve user-authored inline module items while adding generated resources. resource/001
derive/002 The handler macro must reject unknown handles and invalid handler signatures at compilation. resource/001 server/001
derive/003 The component macro must reject a component missing its required handler implementation. resource/001 server/001
derive/004 A reference to an absent generated resource must fail compilation. resource/001
wasm/001 Normal server-side Hemx APIs must compile for wasm32 without host parser dependencies. portable/001
boundary/001 Framework transport behavior must stay in hemx-axum and generic browser behavior in hemx-js. scope/001
boundary/002 Optional transport, timer, and reveal behavior must be direct lifecycle adapters, not a plugin registry. scope/001 protocol/001
boundary/003 The core must have no effects for classes, styles, attributes, scripts, validation, dialogs, or transport. scope/001 web/001
boundary/004 Hemx must not maintain a client application store that mirrors authoritative server state. server/001 scope/001
test/001 Public test utilities must inspect synchronous and asynchronous handlers through one effect model. server/001 protocol/001
test/002 A failed HTML update assertion must report both expected and actual effects. protocol/001
test/003 Public test utilities must inspect complete documents with owned HTML structure. web/001
test/004 With Axum support enabled, public test utilities must inspect effect responses through a real router. protocol/001 server/001
test/005 The public process helper must wait for delayed TCP readiness and capture child output on failure. server/001
architecture/001 hemx-build template authoring validation must live in a private module separate from artifact emission. maintainability/001
architecture/002 hemx-build Rust source fact extraction must live in a private module separate from validation and emission. maintainability/001
architecture/003 hemx-build artifact emission and contract diagnostics must live behind the public builder in a private module. maintainability/001
architecture/004 hemx-axum interaction-form extraction and decoding must live in one private module behind public adapter types. maintainability/001
assurance/001 Generated-contract checks must compare resource capabilities, stable fingerprints, and source diagnostics. assurance/001 resource/001
assurance/002 Handler compile checks must cover each documented Form<T> spelling and custom multipart extraction. assurance/001 server/001
1 ID RULE INTENTS
2 kernel/001 The wire effect algebra must contain only Patch, Insert, Remove, Move, Focus, Scroll, Visit, and Dispatch. protocol/001 scope/001
3 kernel/002 Patch must offer Morph and Replace; Insert and Move must use first, last, before, or after positions. protocol/001 resource/001
4 kernel/003 Every DOM effect target must be a generated resource reference, never a CSS selector. resource/001 protocol/001
5 kernel/004 A batch must validate its envelope before applying effects in order and stopping at the first failure. protocol/001
6 kernel/005 A later effect must be able to address a resource created by an earlier effect in the same batch. protocol/001 resource/001
7 kernel/006 Move must preserve the identity and browser-owned state of the moved node. protocol/001 web/001
8 kernel/007 Rendered HTML must enter an effect only through the explicit SafeHtml type. server/001 web/001
9 kernel/008 Identical semantic effect batches must produce identical canonical wire bytes. protocol/001
10 kernel/009 Decoding must reject invalid magic, versions, tags, lengths, UTF-8, fingerprints, and trailing bytes. protocol/001
11 kernel/010 Compatibility must never guess an opcode, resource, selector, fingerprint, or alternate wire meaning. protocol/001
12 kernel/011 Canonical effect batches must round-trip between typed values and wire bytes without semantic loss. protocol/001
13 kernel/012 A compatibility fixture must accept only its declared wire ABI version. protocol/001
14 kernel/013 Focus must change focus without encoding form validation or other application policy. protocol/001 web/001
15 kernel/014 Scroll must reveal its resource without changing focus. protocol/001 web/001
16 kernel/015 Dispatch must carry a generated typed non-visual payload without mutating the DOM. protocol/001 resource/001
17 kernel/016 Visit must update URL history through partial navigation with ordinary navigation fallback. protocol/001 web/001
18 resource/001 Generated resource types must expose only operations valid for their capability. resource/001
19 resource/002 Generated APIs must carry resource, action, fingerprint, opcode, and marker values for application code. resource/001
20 resource/003 Repeated resources must use generated stable keys for typed Remove and Move operations. resource/001 protocol/001
21 resource/004 Generated form helpers must address fields through typed resources, not copied selectors or IDs. resource/001 server/001
22 resource/005 Generated artifacts must change only when their semantic template inputs change. resource/001 protocol/001
23 resource/006 Missing or invalid generated resources must fail compilation with an actionable source diagnostic. resource/001
24 html/001 Generated markup must use data-hemx-root, build, resource, key, action, and island markers. resource/001 protocol/001
25 html/002 Native event defaults must need no attribute; data-hemx-on must only override the native event. web/001 scope/001
26 html/003 Navigation must use anchors or GET forms and retain ordinary browser fallback without enhancement. web/001 server/001
27 html/004 On validation failure, rendered forms must identify invalid fields and associate accessible messages. web/001 server/001
28 html/005 Hemx must preserve native keyboard, IME, autofill, file selection, and constraint-validation behavior. web/001 scope/001
29 html/006 Request policy must cover only concurrency, debounce, throttle, confirmation, navigation, and history. web/001 scope/001
30 html/007 Concurrency policy must be one of latest, queue, drop, or parallel. web/001 scope/001
31 runtime/001 The browser runtime must apply an effect only within the root owning its generated resource. protocol/001 resource/001
32 runtime/002 Loading the runtime must expose diagnostics without performing startup side effects. protocol/001
33 runtime/003 Ordinary forms must submit URL-encoded data unless native semantics select another encoding. web/001 server/001
34 runtime/004 GET forms must produce URL-state navigation while retaining ordinary navigation fallback. web/001 server/001
35 runtime/005 HTTP, decode, or effect failure must stop the batch and emit one root-scoped diagnostic. protocol/001
36 runtime/006 SSE and WebSocket adapters must carry unchanged canonical effect-batch bytes. protocol/001 scope/001
37 runtime/007 Each optional adapter must bind once, scan inserted fragments, clean removals, and enforce root ownership. scope/001 protocol/001
38 runtime/008 An explicit island must own its subtree; Hemx must not morph through island-owned nodes. scope/001 web/001
39 axum/001 Axum effect responses must carry canonical batch bytes and the generated build fingerprint. protocol/001 resource/001
40 axum/002 Axum must serve the generic browser runtime without owning application behavior. server/001 scope/001
41 axum/003 Partial navigation must preserve status and title metadata and support full-navigation recovery. web/001 server/001
42 axum/004 Interaction requests must enforce media type and configured body limits before handler dispatch. server/001
43 axum/005 Typed dispatch responses must preserve status, canonical bytes, and actionable diagnostics. protocol/001 server/001
44 build/001 A template read failure must report the source path and I/O cause. resource/001
45 build/002 Identical inspected template inputs must produce identical generated contract fingerprints. resource/001 protocol/001
46 build/003 A no-op build must not rewrite an unchanged generated contract artifact. resource/001
47 derive/001 The surface macro must preserve user-authored inline module items while adding generated resources. resource/001
48 derive/002 The handler macro must reject unknown handles and invalid handler signatures at compilation. resource/001 server/001
49 derive/003 The component macro must reject a component missing its required handler implementation. resource/001 server/001
50 derive/004 A reference to an absent generated resource must fail compilation. resource/001
51 wasm/001 Normal server-side Hemx APIs must compile for wasm32 without host parser dependencies. portable/001
52 boundary/001 Framework transport behavior must stay in hemx-axum and generic browser behavior in hemx-js. scope/001
53 boundary/002 Optional transport, timer, and reveal behavior must be direct lifecycle adapters, not a plugin registry. scope/001 protocol/001
54 boundary/003 The core must have no effects for classes, styles, attributes, scripts, validation, dialogs, or transport. scope/001 web/001
55 boundary/004 Hemx must not maintain a client application store that mirrors authoritative server state. server/001 scope/001
56 test/001 Public test utilities must inspect synchronous and asynchronous handlers through one effect model. server/001 protocol/001
57 test/002 A failed HTML update assertion must report both expected and actual effects. protocol/001
58 test/003 Public test utilities must inspect complete documents with owned HTML structure. web/001
59 test/004 With Axum support enabled, public test utilities must inspect effect responses through a real router. protocol/001 server/001
60 test/005 The public process helper must wait for delayed TCP readiness and capture child output on failure. server/001
61 architecture/001 hemx-build template authoring validation must live in a private module separate from artifact emission. maintainability/001
62 architecture/002 hemx-build Rust source fact extraction must live in a private module separate from validation and emission. maintainability/001
63 architecture/003 hemx-build artifact emission and contract diagnostics must live behind the public builder in a private module. maintainability/001
64 architecture/004 hemx-axum interaction-form extraction and decoding must live in one private module behind public adapter types. maintainability/001
65 assurance/001 Generated-contract checks must compare resource capabilities, stable fingerprints, and source diagnostics. assurance/001 resource/001
66 assurance/002 Handler compile checks must cover each documented Form<T> spelling and custom multipart extraction. assurance/001 server/001
-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;
-15
View File
@@ -1,15 +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 = []
spin_mutex = ["spin_next/spin_mutex"]
[dependencies]
spin_next = { package = "spin", version = "=0.12.2", default-features = false }
-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-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