Remove legacy and Labs surfaces from public core
This commit is contained in:
@@ -1,337 +0,0 @@
|
|||||||
---
|
|
||||||
name: idiomatic-hemx
|
|
||||||
description: >-
|
|
||||||
Use when a user explicitly asks how to design, implement, review,
|
|
||||||
productionize, or scale an application built with hemx and hemplate,
|
|
||||||
including deciding whether or where microservices belong. Preserve hemx's
|
|
||||||
generated-resource and server-owned effect model, then choose the smallest
|
|
||||||
evidence-backed scale stage. Do not use for generic Rust/Axum architecture,
|
|
||||||
generic microservice advice, or development of the hemx framework itself.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Idiomatic hemx
|
|
||||||
|
|
||||||
## One job
|
|
||||||
|
|
||||||
Choose, explain, or implement the smallest production architecture that keeps a
|
|
||||||
hemx/hemplate application boring as it grows. The high-leverage move is usually
|
|
||||||
to preserve one direction of travel:
|
|
||||||
|
|
||||||
```text
|
|
||||||
semantic .heml surface
|
|
||||||
-> generated typed resources
|
|
||||||
-> ordinary Rust domain/application code
|
|
||||||
-> generated partial/page effects
|
|
||||||
-> tiny browser runtime
|
|
||||||
```
|
|
||||||
|
|
||||||
Scale providers and deployment topology around that loop. Do not replace it
|
|
||||||
with selectors, a client state graph, raw wire operations, or speculative
|
|
||||||
services.
|
|
||||||
|
|
||||||
## Trigger boundary
|
|
||||||
|
|
||||||
Load this skill for explicit hemx/hemplate application usage, architecture,
|
|
||||||
production-readiness, scaling, or a review of those decisions. It also applies
|
|
||||||
when the user asks whether a hemx application should become microservices.
|
|
||||||
|
|
||||||
Do not load it for:
|
|
||||||
|
|
||||||
- generic Rust, Axum, HTML, CSS, database, or microservice questions with no hemx
|
|
||||||
application decision;
|
|
||||||
- visual design alone;
|
|
||||||
- changing hemx/hemplate internals rather than using their public model;
|
|
||||||
- a tiny obvious application patch where no authoring or scaling judgment is at
|
|
||||||
stake.
|
|
||||||
|
|
||||||
## Authority before taste
|
|
||||||
|
|
||||||
Inside the hemx repository, read `AGENTS.md`, then
|
|
||||||
`docs/v1-product-evidence.md` and `REQUIREMENTS.md`. Use `PLAN.md` only as the
|
|
||||||
mutable implementation cursor. Inspect only the nearest authoritative material
|
|
||||||
needed for the decision:
|
|
||||||
|
|
||||||
- `docs/hemplate-syntax.md` for real `.heml` syntax;
|
|
||||||
- `docs/tutorial-saas.md` for the production-shaped application boundary;
|
|
||||||
- `docs/recipes/reusable-partials.md` for composition;
|
|
||||||
- the auth, persistence, observability, deploy/versioning, offline, and local
|
|
||||||
command-log recipes for their named concerns;
|
|
||||||
- the nearest maintained example and current public Rust API before naming an
|
|
||||||
API in code.
|
|
||||||
|
|
||||||
Project requirements and observed code outrank this skill. Outside this
|
|
||||||
repository, establish the application's versions and elected contracts instead
|
|
||||||
of assuming current-main APIs. If auth, storage, transport, replay, deployment,
|
|
||||||
or service contracts are absent, identify the missing decision; do not invent a
|
|
||||||
provider or abstraction that makes the system look complete.
|
|
||||||
|
|
||||||
When invoked for a Hemx application repository, inspect its elected `AGENTS.md`
|
|
||||||
or equivalent instruction surface. Ensure it explicitly requires all HTML
|
|
||||||
surfaces and fragments to be authored as semantic `.heml` templates rendered
|
|
||||||
through Hemplate's generated typed resources, and explicitly forbids constructing,
|
|
||||||
concatenating, interpolating, or formatting HTML in Rust. If the current request
|
|
||||||
authorizes repository edits, patch that instruction surface before application
|
|
||||||
implementation; otherwise report the missing policy as a blocker. Do not copy the
|
|
||||||
rest of this skill into project instructions.
|
|
||||||
|
|
||||||
## Decision loop
|
|
||||||
|
|
||||||
### 1. Start with the user job and one load-bearing path
|
|
||||||
|
|
||||||
Name the concrete request, mutation, navigation, push update, or recovery path
|
|
||||||
that must work. Trace it end to end before discussing topology:
|
|
||||||
|
|
||||||
```text
|
|
||||||
HTTP/browser input
|
|
||||||
-> normal auth, CSRF, and typed validation
|
|
||||||
-> application command/query
|
|
||||||
-> authoritative state transition
|
|
||||||
-> rendered generated target/page
|
|
||||||
-> EffectBatch response or canonical push bytes
|
|
||||||
-> root-scoped runtime application
|
|
||||||
-> visible success or explicit recovery
|
|
||||||
```
|
|
||||||
|
|
||||||
If this path is unclear, architecture diagrams and service boundaries are
|
|
||||||
premature.
|
|
||||||
|
|
||||||
### 2. Use the canonical authoring level
|
|
||||||
|
|
||||||
Default to:
|
|
||||||
|
|
||||||
- semantic `.heml` templates plus ordinary Rust; never construct, concatenate,
|
|
||||||
interpolate, or format HTML strings in Rust, including fragments for source
|
|
||||||
rendering, Markdown, errors, or test-facing pages;
|
|
||||||
- `#[hemx::app]`, plain `#[hemx::handler]`, generated components/resources,
|
|
||||||
typed form models, view wrappers, render/page helpers, and `IntoEffect`;
|
|
||||||
- generated target, form, control, keyed-slot, and event helpers;
|
|
||||||
- typed partial swaps expressed as generated target + rendered partial + swap
|
|
||||||
kind;
|
|
||||||
- real links and GET forms for navigation and history;
|
|
||||||
- `data-hemx-revealed-ahead` for viewport-ahead loading while the observed sentinel remains in normal document flow; never move the observed target with CSS to fake prefetch distance;
|
|
||||||
- plain CSS/SCSS for appearance;
|
|
||||||
- shared runtime paths and Axum adapters supplied by `hemx-axum`.
|
|
||||||
|
|
||||||
Keep domain types, authorization, persistence, routing, sessions, flags,
|
|
||||||
observability, and transport policy in the application or integration crate.
|
|
||||||
Keep `hemx-core` about typed resources and the closed effect/wire contract.
|
|
||||||
Compose reusable UI from templates, generated components, and ordinary Rust
|
|
||||||
functions rather than creating a client component framework.
|
|
||||||
|
|
||||||
Use advanced layers only when the job proves the need:
|
|
||||||
|
|
||||||
- opaque island JavaScript is a leaf adapter for high-frequency local behavior;
|
|
||||||
- client-local/wasm handlers are explicit opt-ins, not the default state model;
|
|
||||||
- atoms are addressable bootstrap/sync resources, not a general reactive store;
|
|
||||||
- SSE/WebSocket transport carries canonical versioned `EffectBatch` bytes; it
|
|
||||||
does not define domain policy.
|
|
||||||
|
|
||||||
### 3. Keep truth on the right side of the boundary
|
|
||||||
|
|
||||||
The browser DOM, stored HTML patches, and stored `EffectBatch` values are not
|
|
||||||
business truth. Keep authoritative state in ordinary application/domain models
|
|
||||||
and durable providers. Derive UI effects from that state.
|
|
||||||
|
|
||||||
For local/offline products, use explicit commands, events, and projections.
|
|
||||||
Replay, deletion, conflict resolution, reconciliation, and export semantics are
|
|
||||||
product contracts; stop when they have not been chosen. For server products,
|
|
||||||
normal HTTP security semantics remain authoritative even when interactions are
|
|
||||||
enhanced.
|
|
||||||
|
|
||||||
Expected failures must become typed, local, useful outcomes: generated field
|
|
||||||
errors/focus for validation, a root-scoped error outlet for recoverable request
|
|
||||||
failure, and fail-closed handling for malformed or incompatible responses.
|
|
||||||
Never turn infrastructure failure into a success-looking empty effect.
|
|
||||||
|
|
||||||
Prefer a **functional core with an imperative shell**. Keep validation,
|
|
||||||
normalization, authorization decisions, command application, state transitions,
|
|
||||||
and view-model/projection derivation as deterministic functions over explicit
|
|
||||||
inputs where that is honest. A useful shape is `state + command -> outcome` or
|
|
||||||
`facts -> view model`, with typed errors rather than hidden mutation. This makes
|
|
||||||
the largest behavior space cheap to unit-test and mutation-test.
|
|
||||||
|
|
||||||
Keep HTTP extraction, sessions, database I/O, clocks, randomness/IDs, queues,
|
|
||||||
push connections, and host capabilities in a thin handler/application shell.
|
|
||||||
Read their results once, pass ordinary values into the core, persist the returned
|
|
||||||
outcome, then render generated effects. Pass time, identity, or policy as data
|
|
||||||
when only the value matters; do not create a trait for every function merely to
|
|
||||||
mock it. Use a real adapter boundary when ownership or side effects are real.
|
|
||||||
Purity is a locality tool, not a religion: orchestration and I/O are inherently
|
|
||||||
effectful, and `EffectBatch` remains UI output rather than domain state.
|
|
||||||
|
|
||||||
### 4. Scale one pressure at a time
|
|
||||||
|
|
||||||
Use this ladder. Enter a stage only when measured load, availability goals,
|
|
||||||
ownership, compliance, or a distinct failure/resource profile requires it.
|
|
||||||
|
|
||||||
| Stage | Default shape | Required proof before moving on |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| One process | One deployable; app-owned adapters; simplest durable store that meets the product contract | The real product path, restart behavior, backup/recovery needs, and current bottleneck are known |
|
|
||||||
| Production monolith | One release unit for server, generated output, and runtime asset; external durable database/session providers as required | Integration tests cover auth, persistence, failures, migration, fingerprint mismatch, and rollback/reload behavior |
|
|
||||||
| Horizontal web tier | Stateless request replicas around shared authoritative providers; process memory is cache/ephemeral only unless affinity and loss semantics are explicit | Load evidence shows replica scale helps; migrations, readiness, draining, cache invalidation, and session/CSRF behavior work across replicas |
|
|
||||||
| Push/fan-out tier | Server-owned ongoing SSE/WebSocket connections; add a broker/backplane only when updates must cross processes | Reconnect, ordering, duplicate, authorization, backpressure, and replay expectations are explicit and tested at the required level |
|
|
||||||
| Worker or read-model split | Isolate a measured CPU, latency, queue, or failure domain while the application remains one understandable product | Job ownership, idempotency, timeout, retry, deduplication, observability, and recovery contracts exist |
|
|
||||||
| Microservices | Split an independently owned bounded capability with its own release/scaling/SLO pressure and explicit data/API/event contract | The boundary removes a demonstrated constraint and its distributed failure modes are cheaper than the monolith |
|
|
||||||
|
|
||||||
Prefer vertical resource tuning, query/index fixes, caching with explicit
|
|
||||||
freshness, bounded concurrency, and horizontal replicas before service
|
|
||||||
splitting. A large codebase is a module-boundary problem before it is a network
|
|
||||||
boundary problem.
|
|
||||||
|
|
||||||
### 5. Preserve the hemx boundary across services
|
|
||||||
|
|
||||||
When microservices are justified:
|
|
||||||
|
|
||||||
- keep hemplate rendering, generated resources, and UI `EffectBatch` creation in
|
|
||||||
the presentation/application edge that owns the page;
|
|
||||||
- exchange typed domain requests, responses, and events across services—not CSS
|
|
||||||
selectors, DOM instructions, raw hemx opcodes, or app-authored JSON versions
|
|
||||||
of the hemx wire format;
|
|
||||||
- name one authority for each write model and do not let services casually share
|
|
||||||
mutation ownership;
|
|
||||||
- version service contracts independently, while deploying each hemx server,
|
|
||||||
its generated metadata, build fingerprint, and runtime asset as a compatible
|
|
||||||
release unit;
|
|
||||||
- preserve end-user credentials, authorization, CSRF, tenancy, deadlines, and
|
|
||||||
trace context explicitly at each real trust boundary;
|
|
||||||
- make partial failure visible. Define timeout, idempotency, retry,
|
|
||||||
deduplication, ordering, compensation, and replay only where the chosen
|
|
||||||
interaction requires them—never as generic middleware theater.
|
|
||||||
|
|
||||||
Do not put a network hop between a handler and its renderer merely to claim
|
|
||||||
microservices. Do not use `EffectBatch` as a business event bus or durable event
|
|
||||||
log.
|
|
||||||
|
|
||||||
### 6. Organize by cohesive product responsibility
|
|
||||||
|
|
||||||
Start small; do not pre-create an architecture directory tree. A production app
|
|
||||||
usually needs only these durable seams:
|
|
||||||
|
|
||||||
```text
|
|
||||||
build.rs # hemx-build/global code generation only
|
|
||||||
src/main.rs # config, concrete providers, process/bootstrap
|
|
||||||
src/lib.rs # app/router composition and a testable app surface
|
|
||||||
src/<feature>/ # add only when a feature is already a real seam
|
|
||||||
templates/app_shell.heml # document shell
|
|
||||||
templates/<feature>.heml # feature surface
|
|
||||||
templates/partials/ # genuinely reused or independently swapped pieces
|
|
||||||
tests/<journey>.rs # process/integration behavior
|
|
||||||
tests/browser_<journey>.rs # only browser-dependent behavior
|
|
||||||
```
|
|
||||||
|
|
||||||
Treat that as a responsibility map, not mandatory scaffolding. A small cohesive
|
|
||||||
app can remain in `lib.rs`; file count is not architecture. When a feature has
|
|
||||||
its own state/commands, handlers, rendering, and tests, move that whole seam
|
|
||||||
together. Keep its domain model, typed input, application operation, handler,
|
|
||||||
and generated view calls near one another. Do not spread every request across
|
|
||||||
generic `controllers/`, `services/`, `repositories/`, `dto/`, and `utils/`
|
|
||||||
directories.
|
|
||||||
|
|
||||||
Keep `main.rs` boring and hard to test because it contains almost no policy.
|
|
||||||
Expose app construction or mounting from `lib.rs` so integration tests can use
|
|
||||||
the real router with controlled concrete providers. Put normal routing,
|
|
||||||
auth/session, persistence, queues, and transport adapters at the app boundary;
|
|
||||||
do not move them into hemx core or generated template modules. Keep generated
|
|
||||||
artifacts in the build output rather than copying them into source control.
|
|
||||||
|
|
||||||
Put tiny unit tests beside the responsible module, especially around pure
|
|
||||||
transitions, validation, authorization decisions, and projections. Put
|
|
||||||
cross-module HTTP, persistence, and process tests in `tests/`, named for behavior
|
|
||||||
rather than implementation. Keep browser journeys few and load-bearing. Extract
|
|
||||||
a shared partial or helper only after actual reuse or independent swap identity
|
|
||||||
appears.
|
|
||||||
If a microservice boundary becomes real, that service owns its contract,
|
|
||||||
provider/migrations, operational entry point, and contract tests; do not mirror
|
|
||||||
hypothetical services in the source tree first.
|
|
||||||
|
|
||||||
### 7. Prove behavior at the cheapest authoritative boundary
|
|
||||||
|
|
||||||
Choose the tool by what must be observed:
|
|
||||||
|
|
||||||
| Boundary | Default tool | What it proves |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Domain/state | Rust `#[test]` / `#[tokio::test]` | Parsing, invariants, commands, projections, and failure classes |
|
|
||||||
| Template/build | Compiler plus hemx-build diagnostics | Real `.heml` syntax, generated resources/forms, and cross-file references |
|
|
||||||
| Handler/effect | `hemx_test` | Generated targets/handles, rendered partials, form bodies, and effect batches without raw ids |
|
|
||||||
| Router/integration | Real Axum router, usually `tower::ServiceExt`, plus concrete test providers | HTTP status/headers/body, auth, CSRF, sessions, persistence, and malformed requests |
|
|
||||||
| Static rendered HTML | Rust `scraper` crate (HTML parser + CSS-selector queries) | Escaping, semantic structure, links/forms/attributes, and server-rendered fragments without launching a browser |
|
|
||||||
| Process lifecycle | `hemx_test::TestProcess` | Readiness, real sockets, child cleanup, restart, and production-binary behavior |
|
|
||||||
| Browser runtime | Rust `thirtyfour` crate (WebDriver client) with the repository-owned browser smoke | Delegated events, history, focus, polling/revealed bindings, keyed DOM identity, SSE, and recovery |
|
|
||||||
| Test quality/release | hemx xtask mutation/full-test commands | Mutation resistance and the elected bounded workspace verification path |
|
|
||||||
|
|
||||||
`scraper` is a Rust HTML parsing and CSS-selector library, not a browser or web
|
|
||||||
framework. Use it when inspecting final server-rendered HTML is enough. It does
|
|
||||||
not run JavaScript, apply `EffectBatch`, maintain focus/history, or prove
|
|
||||||
SSE/runtime behavior. `thirtyfour` is a Rust WebDriver client crate; use it only
|
|
||||||
when an actual browser semantic is the subject;
|
|
||||||
do not turn every handler assertion into a WebDriver journey. In this repository
|
|
||||||
`thirtyfour` is the elected Rust browser adapter, so do not add Playwright,
|
|
||||||
Fantoccini, or another competing browser stack merely from preference. Outside
|
|
||||||
this repository, preserve the application's existing runner unless a concrete
|
|
||||||
missing capability justifies migration.
|
|
||||||
|
|
||||||
For WebDriver tests, start the real process through the RAII `TestProcess`
|
|
||||||
harness, keep selectors in test adapters, and prefer generated-resource or
|
|
||||||
stable semantic helpers over copied implementation selectors. Browser selector
|
|
||||||
helpers are not authoring APIs. Avoid sleeps when the runner can wait for the
|
|
||||||
observable condition.
|
|
||||||
|
|
||||||
Match proof to risk:
|
|
||||||
|
|
||||||
1. Let compilation reject broken templates and generated contracts.
|
|
||||||
2. Unit-test ordinary domain parsing and state transitions without a browser.
|
|
||||||
3. Test handlers through `hemx_test` with useful generated-resource assertions.
|
|
||||||
4. Test route/auth/session/CSRF/persistence and wire failure behavior at the app
|
|
||||||
integration boundary.
|
|
||||||
5. Parse static HTML with `scraper` only for facts that do not require runtime
|
|
||||||
execution.
|
|
||||||
6. Use focused repository-owned browser smoke for dynamic attributes,
|
|
||||||
navigation/history, keyed reconciliation, polling/revealed behavior,
|
|
||||||
no-reload interaction, and runtime recovery.
|
|
||||||
7. Add compatibility, rolling-deploy, process-restart, multi-replica, load, or
|
|
||||||
fault tests only when the selected scale stage makes those behaviors part of
|
|
||||||
the contract.
|
|
||||||
|
|
||||||
In this repository, prefer the stable commands named by `AGENTS.md`, especially
|
|
||||||
focused crate tests and `cargo run -p hemx-xtask -- test`; use
|
|
||||||
`cargo run -p hemx-xtask -- html-examples-smoke` for the pattern gallery and
|
|
||||||
runtime behavior, and the xtask mutation command rather than direct `mutest`.
|
|
||||||
Do not substitute a passing literal lowering fixture, static HTML parse, or
|
|
||||||
mocks-only test for proof at the rendered/runtime consumer boundary.
|
|
||||||
|
|
||||||
## Refuse fake sophistication
|
|
||||||
|
|
||||||
Do not introduce:
|
|
||||||
|
|
||||||
- handwritten resource ids, selector retargeting, raw effect constructors, raw
|
|
||||||
registries, runtime opcodes, manual wire parsing, or hard-coded runtime URLs in
|
|
||||||
normal app code;
|
|
||||||
- a VDOM, client router, general client state graph, expression runtime,
|
|
||||||
per-node listeners, or handwritten JavaScript for ordinary forms/lists/swaps;
|
|
||||||
- core-owned auth, sessions, persistence, routing, multipart, transport, sync,
|
|
||||||
analytics, flags, or provider policy;
|
|
||||||
- a generic repository/service/provider interface before a concrete second use
|
|
||||||
or required external contract exists;
|
|
||||||
- microservices by entity name, team aspiration, file count, or hypothetical
|
|
||||||
scale;
|
|
||||||
- shared process memory presented as durable or cross-replica state;
|
|
||||||
- retries, caches, queues, brokers, sagas, event sourcing, CQRS, Kubernetes, or a
|
|
||||||
service mesh without a named failure/load contract and verification path.
|
|
||||||
|
|
||||||
“Suckless” here means fewer authorities and mechanisms, not fewer safety checks.
|
|
||||||
The elegant design keeps HTML semantics, typed boundaries, explicit ownership,
|
|
||||||
and failure truth while deleting accidental layers.
|
|
||||||
|
|
||||||
## Handoff
|
|
||||||
|
|
||||||
For architecture or review requests, report compactly:
|
|
||||||
|
|
||||||
- the user-visible path and current bottleneck/risk;
|
|
||||||
- the selected scale stage and why the previous stage is insufficient;
|
|
||||||
- state, rendering, transport, and service ownership;
|
|
||||||
- the smallest end-to-end change;
|
|
||||||
- complexity explicitly refused;
|
|
||||||
- proof run and any unresolved provider/product contract.
|
|
||||||
|
|
||||||
For implementation, make that slice reachable and verify it; do not leave a
|
|
||||||
“scalable” abstraction that no real path uses.
|
|
||||||
@@ -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/
|
||||||
|
**/target/
|
||||||
|
**/node_modules/
|
||||||
|
|||||||
@@ -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,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,16 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "spin"
|
|
||||||
version = "0.9.8"
|
|
||||||
edition = "2021"
|
|
||||||
rust-version = "1.71"
|
|
||||||
publish = false
|
|
||||||
license = "MIT"
|
|
||||||
description = "Workspace compatibility alias from yanked spin 0.9 to maintained spin 0.12"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
once = ["spin_next/once"]
|
|
||||||
spin_mutex = ["spin_next/spin_mutex"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
spin_next = { package = "spin", version = "=0.12.2", default-features = false, features = ["once"] }
|
|
||||||
@@ -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-cloudflare-do-example"
|
|
||||||
version.workspace = true
|
|
||||||
edition.workspace = true
|
|
||||||
publish = false
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib", "rlib"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
hemplate = { package = "hemplate-runtime", path = "../../hemplate-runtime" }
|
|
||||||
hemplate-derive = { path = "../../../hemplate/hemplate-derive" }
|
|
||||||
hemx = { path = "../../hemx" }
|
|
||||||
hemx-js = { path = "../../hemx-js" }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
worker = { version = "0.7.5", features = ["http", "queue"] }
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
hemx-build = { path = "../../hemx-build" }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
postcard = { version = "1", features = ["alloc"] }
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# hemx on Cloudflare Durable Objects
|
|
||||||
|
|
||||||
This proof keeps the hemx authoring and wire model intact while Cloudflare owns room placement, persistence, and hibernating WebSockets:
|
|
||||||
|
|
||||||
```text
|
|
||||||
room.heml -> hemx-build generated resources -> Rust RoomState
|
|
||||||
-> generated counter partial -> canonical EffectBatch bytes
|
|
||||||
-> Durable Object hibernating sockets -> hemx browser runtime
|
|
||||||
```
|
|
||||||
|
|
||||||
The Durable Object stores only the counter. It never stores HTML, DOM patches, or `EffectBatch` values as domain truth.
|
|
||||||
|
|
||||||
## Local commands
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo test -p hemx-cloudflare-do-example
|
|
||||||
cargo check --target wasm32-unknown-unknown -p hemx-cloudflare-do-example
|
|
||||||
cd examples/cloudflare_do
|
|
||||||
npx wrangler dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open the printed local URL in two tabs. Incrementing in either tab should update both without reload. Stop and restart `wrangler dev`; the room counter should remain.
|
|
||||||
|
|
||||||
A hosted deployment requires an authorized Cloudflare account. Production auth, CSRF, tenancy, jurisdiction, reconnect replay, and deployment policy are deliberately outside this proof.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
let templates = std::path::PathBuf::from(
|
|
||||||
std::env::var_os("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR"),
|
|
||||||
)
|
|
||||||
.join("templates");
|
|
||||||
hemx_build::app()
|
|
||||||
.template_dir(templates)
|
|
||||||
.run()
|
|
||||||
.expect("compile cloudflare_do hemx surfaces");
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
use hemplate::Hemplate;
|
|
||||||
use hemplate_derive::Hemplate;
|
|
||||||
use hemx::advanced::EffectBatch;
|
|
||||||
use hemx::IntoEffect;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use worker::*;
|
|
||||||
|
|
||||||
#[hemx::surface]
|
|
||||||
pub mod ui {}
|
|
||||||
|
|
||||||
const ROOMS_BINDING: &str = "ROOMS";
|
|
||||||
const COUNT_KEY: &str = "count";
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
|
||||||
struct RoomState {
|
|
||||||
count: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
enum RoomCommand {
|
|
||||||
Increment,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RoomState {
|
|
||||||
fn apply(self, command: RoomCommand) -> Result<Self> {
|
|
||||||
match command {
|
|
||||||
RoomCommand::Increment => self
|
|
||||||
.count
|
|
||||||
.checked_add(1)
|
|
||||||
.map(|count| Self { count })
|
|
||||||
.ok_or_else(|| Error::RustError("room counter overflowed".into())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
|
||||||
struct Room {
|
|
||||||
count: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
|
||||||
#[hemplate = "partials"]
|
|
||||||
struct CounterView {
|
|
||||||
count: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn counter_batch(state: RoomState) -> EffectBatch {
|
|
||||||
ui::room::put(
|
|
||||||
ui::room::advanced::slots::counter,
|
|
||||||
&CounterView { count: state.count },
|
|
||||||
)
|
|
||||||
.into_batch(ui::BUILD_FINGERPRINT)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn room_page(state: RoomState) -> String {
|
|
||||||
ui::room::page(&Room { count: state.count }).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn effect_bytes(state: RoomState) -> Result<Vec<u8>> {
|
|
||||||
Ok(counter_batch(state).to_wire())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn response_bytes(bytes: Vec<u8>, content_type: &str) -> Result<Response> {
|
|
||||||
let headers = Headers::new();
|
|
||||||
headers.set("content-type", content_type)?;
|
|
||||||
Response::from_bytes(bytes).map(|response| response.with_headers(headers))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn response_html(html: String) -> Result<Response> {
|
|
||||||
response_bytes(html.into_bytes(), "text/html; charset=utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[event(fetch)]
|
|
||||||
pub async fn fetch(request: Request, env: Env, _ctx: Context) -> Result<Response> {
|
|
||||||
match request.path().as_str() {
|
|
||||||
"/hemx.js" => response_bytes(
|
|
||||||
hemx_js::RUNTIME_JS.as_bytes().to_vec(),
|
|
||||||
"text/javascript; charset=utf-8",
|
|
||||||
),
|
|
||||||
path if path.starts_with("/rooms/") => {
|
|
||||||
let room_name =
|
|
||||||
room_name(path).ok_or_else(|| Error::RustError("missing room name".into()))?;
|
|
||||||
let stub = env.durable_object(ROOMS_BINDING)?.get_by_name(room_name)?;
|
|
||||||
stub.fetch_with_request(request).await
|
|
||||||
}
|
|
||||||
"/" => Response::redirect("/rooms/demo".parse()?),
|
|
||||||
_ => Response::error("not found", 404),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn room_name(path: &str) -> Option<&str> {
|
|
||||||
path.strip_prefix("/rooms/")?
|
|
||||||
.split('/')
|
|
||||||
.next()
|
|
||||||
.filter(|name| !name.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[durable_object]
|
|
||||||
pub struct DurableRoom {
|
|
||||||
state: State,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DurableObject for DurableRoom {
|
|
||||||
fn new(state: State, _env: Env) -> Self {
|
|
||||||
Self { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch(&self, request: Request) -> Result<Response> {
|
|
||||||
match (request.method(), request.path().rsplit('/').next()) {
|
|
||||||
(Method::Get, Some("socket")) => self.accept_socket(request),
|
|
||||||
(Method::Post, Some("increment")) => self.increment().await,
|
|
||||||
(Method::Get, _) => response_html(self.page().await?),
|
|
||||||
_ => Response::error("not found", 404),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn websocket_message(
|
|
||||||
&self,
|
|
||||||
_ws: WebSocket,
|
|
||||||
_message: WebSocketIncomingMessage,
|
|
||||||
) -> Result<()> {
|
|
||||||
Err(Error::RustError(
|
|
||||||
"room commands use ordinary HTTP; WebSocket is server push only".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn websocket_close(
|
|
||||||
&self,
|
|
||||||
_ws: WebSocket,
|
|
||||||
_code: usize,
|
|
||||||
_reason: String,
|
|
||||||
_was_clean: bool,
|
|
||||||
) -> Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn websocket_error(&self, _ws: WebSocket, error: Error) -> Result<()> {
|
|
||||||
Err(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DurableRoom {
|
|
||||||
async fn load(&self) -> Result<RoomState> {
|
|
||||||
Ok(RoomState {
|
|
||||||
count: self.state.storage().get(COUNT_KEY).await?.unwrap_or(0),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn page(&self) -> Result<String> {
|
|
||||||
let body = room_page(self.load().await?);
|
|
||||||
Ok(format!(
|
|
||||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Durable hemx room</title><script defer src=\"/hemx.js\"></script></head><body>{body}</body></html>"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn accept_socket(&self, request: Request) -> Result<Response> {
|
|
||||||
if request.headers().get("upgrade")?.as_deref() != Some("websocket") {
|
|
||||||
return Response::error("expected WebSocket upgrade", 426);
|
|
||||||
}
|
|
||||||
let pair = WebSocketPair::new()?;
|
|
||||||
self.state.accept_web_socket(&pair.server);
|
|
||||||
Response::from_websocket(pair.client)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn increment(&self) -> Result<Response> {
|
|
||||||
let next = self.load().await?.apply(RoomCommand::Increment)?;
|
|
||||||
self.state.storage().put(COUNT_KEY, next.count).await?;
|
|
||||||
let bytes = effect_bytes(next)?;
|
|
||||||
let mut failures = Vec::new();
|
|
||||||
for socket in self.state.get_websockets() {
|
|
||||||
if let Err(error) = socket.send_with_bytes(bytes.clone()) {
|
|
||||||
failures.push(error.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if failures.is_empty() {
|
|
||||||
response_bytes(bytes, "application/x-hemx-effects")
|
|
||||||
} else {
|
|
||||||
Err(Error::RustError(format!(
|
|
||||||
"counter persisted but WebSocket broadcast failed: {}",
|
|
||||||
failures.join("; ")
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_updates_domain_state_without_storing_ui_output() {
|
|
||||||
// req: push/010 req: state/001
|
|
||||||
assert_eq!(
|
|
||||||
RoomState { count: 4 }
|
|
||||||
.apply(RoomCommand::Increment)
|
|
||||||
.unwrap(),
|
|
||||||
RoomState { count: 5 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generated_target_and_template_render_survive_the_cloudflare_boundary() {
|
|
||||||
// req: canonical_authoring/001 req: push/010
|
|
||||||
let state = RoomState { count: 7 };
|
|
||||||
let page = room_page(state);
|
|
||||||
assert!(page.contains("data-sid=\""), "rendered page: {page}");
|
|
||||||
assert!(page.contains("Count: 7"), "rendered page: {page}");
|
|
||||||
assert!(page.contains("data-hemx-ws=\"/rooms/demo/socket\""));
|
|
||||||
|
|
||||||
let decoded = EffectBatch::from_wire(&effect_bytes(state).unwrap()).unwrap();
|
|
||||||
assert_eq!(decoded, counter_batch(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stable_room_names_are_extracted_without_inventing_global_discovery() {
|
|
||||||
assert_eq!(room_name("/rooms/demo/socket"), Some("demo"));
|
|
||||||
assert_eq!(room_name("/rooms/team-a"), Some("team-a"));
|
|
||||||
assert_eq!(room_name("/rooms/"), None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<strong>Count: {+ self.count +}</strong>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<main data-hemx-root="room" data-hemx-ws="/rooms/demo/socket" data-hemx-error="room_error">
|
|
||||||
<h1>Durable hemx room</h1>
|
|
||||||
<p>One Durable Object owns this room. Open it in two tabs.</p>
|
|
||||||
<section data-hemx-slot="counter" aria-live="polite">
|
|
||||||
<strong>Count: {+ self.count +}</strong>
|
|
||||||
</section>
|
|
||||||
<form method="post" action="/rooms/demo/increment" data-hemx-handle="increment">
|
|
||||||
<button type="submit">Increment</button>
|
|
||||||
</form>
|
|
||||||
<p data-hemx-slot="room_error" role="alert"></p>
|
|
||||||
</main>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
|
||||||
"name": "hemx-cloudflare-do-poc",
|
|
||||||
"main": "build/worker/shim.mjs",
|
|
||||||
"compatibility_date": "2026-08-13",
|
|
||||||
"durable_objects": {
|
|
||||||
"bindings": [
|
|
||||||
{ "name": "ROOMS", "class_name": "DurableRoom" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"migrations": [
|
|
||||||
{ "tag": "v1", "new_sqlite_classes": ["DurableRoom"] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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