Compare commits
14 Commits
da28db9775
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a3c72e21db | |||
| 1dc9734f16 | |||
| 3146de7794 | |||
| 4559434068 | |||
| 42710354a6 | |||
| 5b29100340 | |||
| 9e12b1eb46 | |||
| 53f0a5db1a | |||
| 3323aaecd6 | |||
| 52ddf8904c | |||
| ad0c8b316c | |||
| 31a0f02211 | |||
| 353174604e | |||
| e4bd6db13a |
@@ -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',
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1 +1,3 @@
|
||||
/target/
|
||||
**/target/
|
||||
**/node_modules/
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
Final handoffs include `INTENT IMPACT` and `SPECIFICATION IMPACT`: changed IDs, proof, and any unresolved gap.
|
||||
|
||||
## Git workflow
|
||||
## Workspace ownership
|
||||
|
||||
- Commit complete, coherent slices only; do not commit broken work or temporary debug output.
|
||||
- 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.
|
||||
The public core consists of seven packages:
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
`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.
|
||||
|
||||
## 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.
|
||||
- `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.
|
||||
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.
|
||||
|
||||
## Local guidance
|
||||
## Required proof
|
||||
|
||||
- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually 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.
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008
|
||||
- The SaaS production reference must use real links/URLs for navigation and an ongoing server-owned canonical SSE stream; bounded one-event behavior is a test probe, not the public transport contract. Browser history restores saved generated page snapshots and scroll position when available, with partial-fetch fallback, and restored revealed bindings must re-arm. req: nav/001 req: nav/002 req: nav/005 req: convention/014 req: push/003 req: examples/014
|
||||
- Public examples and beginner APIs should use templates plus Rust, generated component APIs, resources, view wrappers, render/page helpers, `#[hemx::app]`, plain `#[hemx::handler]` functions, and `IntoEffect`, not atoms, raw ids, selectors, wire formats, runtime opcodes, manual registries, `$OUT_DIR` includes, raw render/lower calls, raw HTML construction, imperative DOM mutation, or raw effect constructors; keep advanced layers out of starters. req: canonical_authoring/001 req: canonical_authoring/004 req: canonical_authoring/006 req: canonical_authoring/010 req: canonical_authoring/015 req: invariant/003 req: dx/001 req: dx/002 req: dx/010 req: component/003 req: component/004 req: component/005 req: view/001 req: view/002 req: view/003 req: html_safety/001 req: html_safety/003 req: html_safety/005 req: public_api/001 req: public_api/002 req: public_api/003 req: public_api/005 req: public_api/006 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003 req: derive_app/001 req: derive_app/002 req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005
|
||||
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry canonical versioned hemx `EffectBatch` bytes over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
|
||||
- `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008
|
||||
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007
|
||||
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
Run the narrowest relevant test while editing. Before completion run:
|
||||
|
||||
```console
|
||||
redgate list
|
||||
redgate refs
|
||||
redgate lint
|
||||
redgate check
|
||||
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
|
||||
cargo check -p hemx-server-wasm-test --target wasm32-unknown-unknown
|
||||
cargo tree -p hemx-server-wasm-test --target wasm32-unknown-unknown --edges normal,no-proc-macro
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Product constraints
|
||||
|
||||
- The server owns effects and behavior; generated typed resources connect templates to handlers and effects.
|
||||
- Raw HTML remains explicit and typed.
|
||||
- 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
File diff suppressed because it is too large
Load Diff
+5
-8
@@ -1,17 +1,14 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-sync", "hemx-sync-macros", "hemx-wasm", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/client_local", "examples/techdemo", "examples/saas", "examples/workout"]
|
||||
members = ["hemx", "hemx-core", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "tests/server-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/tmk241/hemx"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
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" }
|
||||
|
||||
@@ -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
|
||||
|
@@ -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.
|
||||
@@ -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.
|
||||
- **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.
|
||||
## HMX-M01 — Isolate template authoring validation
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
## HMX-M02 — Isolate Rust source fact extraction
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,210 +1,94 @@
|
||||
# hemx
|
||||
# Hemx
|
||||
|
||||
hemx is checked hypermedia for Rust: write hemplate templates, write typed Rust
|
||||
handlers, and return generated UI commands. The browser receives checked UI
|
||||
commands; ordinary server-first apps do not need a frontend framework,
|
||||
handwritten UI JavaScript, selector targeting, or raw runtime primitives. req: pitch/001 req: canonical_authoring/001
|
||||
Hemx is checked hypermedia for Rust. Applications render Hemplate views, handle
|
||||
events in typed Rust functions, and return generated UI effects. The browser runs
|
||||
a small effect interpreter instead of a virtual DOM, hydration framework, or
|
||||
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:
|
||||
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.
|
||||
## How it works
|
||||
|
||||
Template authoring: `.heml` is HTML plus a small hemplate overlay for escaped
|
||||
text, trusted HTML, dynamic attributes, Rust-shaped control directives, generated
|
||||
slots/forms/handles, and keyed partial targets. See `docs/hemplate-syntax.md`.
|
||||
Editor setup for VS Code, Cursor, and Neovim lives in `docs/editor-support.md`;
|
||||
VS Code/Cursor share the repo extension in `editors/vscode-hemx`, while all
|
||||
editors keep normal HTML/tree-sitter highlighting and layer `hemx-build`
|
||||
diagnostics on top.
|
||||
1. `.heml` templates declare page roots, slots, forms, handles, and keyed targets.
|
||||
2. `hemx-build` generates typed Rust helpers from that surface.
|
||||
3. `#[hemx::handler]` functions accept ordinary Rust inputs and return typed effects.
|
||||
4. `hemx-axum` serves pages, assets, handler routes, and effect responses.
|
||||
5. The browser runtime validates the build fingerprint and applies effects within the current root.
|
||||
|
||||
Local checkout note: until the hemplate crates are published, this repository
|
||||
expects `hemplate` checked out next to `hemx` as `../hemplate/hemplate`. The app
|
||||
scaffolder fails with that exact path if the prerequisite is missing, instead of
|
||||
creating an app that fails later with a vague Cargo path-dependency error.
|
||||
|
||||
## 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
|
||||
```rust,ignore
|
||||
#[hemx::handler]
|
||||
async fn add_todo(form: NewTodo) -> impl IntoEffect {
|
||||
ui::todos().append(TodoRow::from(form))
|
||||
}
|
||||
```
|
||||
|
||||
Use `cargo run -p hemx-xtask -- test` for the full local verification path so
|
||||
jobs stay capped for local CPU and memory. The focused browser tier is
|
||||
`cargo run -p hemx-xtask -- html-examples-smoke`; it owns dynamic html_examples
|
||||
browser behavior and should complete in about 30 seconds locally. The full tier
|
||||
should complete within a 10 minute local timeout; if it grows beyond that, split
|
||||
it into deterministic repo-owned shards that together cover the same behavior,
|
||||
with `cargo run -p hemx-xtask -- test` remaining the full authority wrapper.
|
||||
req: test/004 req: test/006 req: test/015 req: test/016
|
||||
```html
|
||||
<form data-hemx-form="new_todo">
|
||||
<input name="title" required>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
<ul data-hemx-slot="todos"></ul>
|
||||
```
|
||||
|
||||
## 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
File diff suppressed because it is too large
Load Diff
@@ -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,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"
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
@@ -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::*;
|
||||
@@ -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 = []
|
||||
@@ -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
|
||||
@@ -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,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>
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 };
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -1,5 +0,0 @@
|
||||
fn main() {
|
||||
hemx_build::app()
|
||||
.run()
|
||||
.expect("compile client-local template");
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
print!("{}", hemx_client_local_example::render_fixture());
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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" }
|
||||
@@ -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.
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
hemx_build::app().run().unwrap();
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
#[hemx::surface]
|
||||
pub mod ui {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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" }
|
||||
@@ -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`.
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
hemx_build::app().run().unwrap();
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
print!("{}", hemx_kanban_example::render_client_fixture());
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
hemx_build::app().run().unwrap();
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
hemx_build::app().run().unwrap();
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user