Compare commits
2 Commits
da28db9775
...
353174604e
| Author | SHA1 | Date | |
|---|---|---|---|
| 353174604e | |||
| e4bd6db13a |
@@ -0,0 +1,337 @@
|
|||||||
|
---
|
||||||
|
name: idiomatic-hemx
|
||||||
|
description: >-
|
||||||
|
Use when a user explicitly asks how to design, implement, review,
|
||||||
|
productionize, or scale an application built with hemx and hemplate,
|
||||||
|
including deciding whether or where microservices belong. Preserve hemx's
|
||||||
|
generated-resource and server-owned effect model, then choose the smallest
|
||||||
|
evidence-backed scale stage. Do not use for generic Rust/Axum architecture,
|
||||||
|
generic microservice advice, or development of the hemx framework itself.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Idiomatic hemx
|
||||||
|
|
||||||
|
## One job
|
||||||
|
|
||||||
|
Choose, explain, or implement the smallest production architecture that keeps a
|
||||||
|
hemx/hemplate application boring as it grows. The high-leverage move is usually
|
||||||
|
to preserve one direction of travel:
|
||||||
|
|
||||||
|
```text
|
||||||
|
semantic .heml surface
|
||||||
|
-> generated typed resources
|
||||||
|
-> ordinary Rust domain/application code
|
||||||
|
-> generated partial/page effects
|
||||||
|
-> tiny browser runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
Scale providers and deployment topology around that loop. Do not replace it
|
||||||
|
with selectors, a client state graph, raw wire operations, or speculative
|
||||||
|
services.
|
||||||
|
|
||||||
|
## Trigger boundary
|
||||||
|
|
||||||
|
Load this skill for explicit hemx/hemplate application usage, architecture,
|
||||||
|
production-readiness, scaling, or a review of those decisions. It also applies
|
||||||
|
when the user asks whether a hemx application should become microservices.
|
||||||
|
|
||||||
|
Do not load it for:
|
||||||
|
|
||||||
|
- generic Rust, Axum, HTML, CSS, database, or microservice questions with no hemx
|
||||||
|
application decision;
|
||||||
|
- visual design alone;
|
||||||
|
- changing hemx/hemplate internals rather than using their public model;
|
||||||
|
- a tiny obvious application patch where no authoring or scaling judgment is at
|
||||||
|
stake.
|
||||||
|
|
||||||
|
## Authority before taste
|
||||||
|
|
||||||
|
Inside the hemx repository, read `AGENTS.md`, then
|
||||||
|
`docs/v1-product-evidence.md` and `REQUIREMENTS.md`. Use `PLAN.md` only as the
|
||||||
|
mutable implementation cursor. Inspect only the nearest authoritative material
|
||||||
|
needed for the decision:
|
||||||
|
|
||||||
|
- `docs/hemplate-syntax.md` for real `.heml` syntax;
|
||||||
|
- `docs/tutorial-saas.md` for the production-shaped application boundary;
|
||||||
|
- `docs/recipes/reusable-partials.md` for composition;
|
||||||
|
- the auth, persistence, observability, deploy/versioning, offline, and local
|
||||||
|
command-log recipes for their named concerns;
|
||||||
|
- the nearest maintained example and current public Rust API before naming an
|
||||||
|
API in code.
|
||||||
|
|
||||||
|
Project requirements and observed code outrank this skill. Outside this
|
||||||
|
repository, establish the application's versions and elected contracts instead
|
||||||
|
of assuming current-main APIs. If auth, storage, transport, replay, deployment,
|
||||||
|
or service contracts are absent, identify the missing decision; do not invent a
|
||||||
|
provider or abstraction that makes the system look complete.
|
||||||
|
|
||||||
|
When invoked for a Hemx application repository, inspect its elected `AGENTS.md`
|
||||||
|
or equivalent instruction surface. Ensure it explicitly requires all HTML
|
||||||
|
surfaces and fragments to be authored as semantic `.heml` templates rendered
|
||||||
|
through Hemplate's generated typed resources, and explicitly forbids constructing,
|
||||||
|
concatenating, interpolating, or formatting HTML in Rust. If the current request
|
||||||
|
authorizes repository edits, patch that instruction surface before application
|
||||||
|
implementation; otherwise report the missing policy as a blocker. Do not copy the
|
||||||
|
rest of this skill into project instructions.
|
||||||
|
|
||||||
|
## Decision loop
|
||||||
|
|
||||||
|
### 1. Start with the user job and one load-bearing path
|
||||||
|
|
||||||
|
Name the concrete request, mutation, navigation, push update, or recovery path
|
||||||
|
that must work. Trace it end to end before discussing topology:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HTTP/browser input
|
||||||
|
-> normal auth, CSRF, and typed validation
|
||||||
|
-> application command/query
|
||||||
|
-> authoritative state transition
|
||||||
|
-> rendered generated target/page
|
||||||
|
-> EffectBatch response or canonical push bytes
|
||||||
|
-> root-scoped runtime application
|
||||||
|
-> visible success or explicit recovery
|
||||||
|
```
|
||||||
|
|
||||||
|
If this path is unclear, architecture diagrams and service boundaries are
|
||||||
|
premature.
|
||||||
|
|
||||||
|
### 2. Use the canonical authoring level
|
||||||
|
|
||||||
|
Default to:
|
||||||
|
|
||||||
|
- semantic `.heml` templates plus ordinary Rust; never construct, concatenate,
|
||||||
|
interpolate, or format HTML strings in Rust, including fragments for source
|
||||||
|
rendering, Markdown, errors, or test-facing pages;
|
||||||
|
- `#[hemx::app]`, plain `#[hemx::handler]`, generated components/resources,
|
||||||
|
typed form models, view wrappers, render/page helpers, and `IntoEffect`;
|
||||||
|
- generated target, form, control, keyed-slot, and event helpers;
|
||||||
|
- typed partial swaps expressed as generated target + rendered partial + swap
|
||||||
|
kind;
|
||||||
|
- real links and GET forms for navigation and history;
|
||||||
|
- `data-hemx-revealed-ahead` for viewport-ahead loading while the observed sentinel remains in normal document flow; never move the observed target with CSS to fake prefetch distance;
|
||||||
|
- plain CSS/SCSS for appearance;
|
||||||
|
- shared runtime paths and Axum adapters supplied by `hemx-axum`.
|
||||||
|
|
||||||
|
Keep domain types, authorization, persistence, routing, sessions, flags,
|
||||||
|
observability, and transport policy in the application or integration crate.
|
||||||
|
Keep `hemx-core` about typed resources and the closed effect/wire contract.
|
||||||
|
Compose reusable UI from templates, generated components, and ordinary Rust
|
||||||
|
functions rather than creating a client component framework.
|
||||||
|
|
||||||
|
Use advanced layers only when the job proves the need:
|
||||||
|
|
||||||
|
- opaque island JavaScript is a leaf adapter for high-frequency local behavior;
|
||||||
|
- client-local/wasm handlers are explicit opt-ins, not the default state model;
|
||||||
|
- atoms are addressable bootstrap/sync resources, not a general reactive store;
|
||||||
|
- SSE/WebSocket transport carries canonical versioned `EffectBatch` bytes; it
|
||||||
|
does not define domain policy.
|
||||||
|
|
||||||
|
### 3. Keep truth on the right side of the boundary
|
||||||
|
|
||||||
|
The browser DOM, stored HTML patches, and stored `EffectBatch` values are not
|
||||||
|
business truth. Keep authoritative state in ordinary application/domain models
|
||||||
|
and durable providers. Derive UI effects from that state.
|
||||||
|
|
||||||
|
For local/offline products, use explicit commands, events, and projections.
|
||||||
|
Replay, deletion, conflict resolution, reconciliation, and export semantics are
|
||||||
|
product contracts; stop when they have not been chosen. For server products,
|
||||||
|
normal HTTP security semantics remain authoritative even when interactions are
|
||||||
|
enhanced.
|
||||||
|
|
||||||
|
Expected failures must become typed, local, useful outcomes: generated field
|
||||||
|
errors/focus for validation, a root-scoped error outlet for recoverable request
|
||||||
|
failure, and fail-closed handling for malformed or incompatible responses.
|
||||||
|
Never turn infrastructure failure into a success-looking empty effect.
|
||||||
|
|
||||||
|
Prefer a **functional core with an imperative shell**. Keep validation,
|
||||||
|
normalization, authorization decisions, command application, state transitions,
|
||||||
|
and view-model/projection derivation as deterministic functions over explicit
|
||||||
|
inputs where that is honest. A useful shape is `state + command -> outcome` or
|
||||||
|
`facts -> view model`, with typed errors rather than hidden mutation. This makes
|
||||||
|
the largest behavior space cheap to unit-test and mutation-test.
|
||||||
|
|
||||||
|
Keep HTTP extraction, sessions, database I/O, clocks, randomness/IDs, queues,
|
||||||
|
push connections, and host capabilities in a thin handler/application shell.
|
||||||
|
Read their results once, pass ordinary values into the core, persist the returned
|
||||||
|
outcome, then render generated effects. Pass time, identity, or policy as data
|
||||||
|
when only the value matters; do not create a trait for every function merely to
|
||||||
|
mock it. Use a real adapter boundary when ownership or side effects are real.
|
||||||
|
Purity is a locality tool, not a religion: orchestration and I/O are inherently
|
||||||
|
effectful, and `EffectBatch` remains UI output rather than domain state.
|
||||||
|
|
||||||
|
### 4. Scale one pressure at a time
|
||||||
|
|
||||||
|
Use this ladder. Enter a stage only when measured load, availability goals,
|
||||||
|
ownership, compliance, or a distinct failure/resource profile requires it.
|
||||||
|
|
||||||
|
| Stage | Default shape | Required proof before moving on |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| One process | One deployable; app-owned adapters; simplest durable store that meets the product contract | The real product path, restart behavior, backup/recovery needs, and current bottleneck are known |
|
||||||
|
| Production monolith | One release unit for server, generated output, and runtime asset; external durable database/session providers as required | Integration tests cover auth, persistence, failures, migration, fingerprint mismatch, and rollback/reload behavior |
|
||||||
|
| Horizontal web tier | Stateless request replicas around shared authoritative providers; process memory is cache/ephemeral only unless affinity and loss semantics are explicit | Load evidence shows replica scale helps; migrations, readiness, draining, cache invalidation, and session/CSRF behavior work across replicas |
|
||||||
|
| Push/fan-out tier | Server-owned ongoing SSE/WebSocket connections; add a broker/backplane only when updates must cross processes | Reconnect, ordering, duplicate, authorization, backpressure, and replay expectations are explicit and tested at the required level |
|
||||||
|
| Worker or read-model split | Isolate a measured CPU, latency, queue, or failure domain while the application remains one understandable product | Job ownership, idempotency, timeout, retry, deduplication, observability, and recovery contracts exist |
|
||||||
|
| Microservices | Split an independently owned bounded capability with its own release/scaling/SLO pressure and explicit data/API/event contract | The boundary removes a demonstrated constraint and its distributed failure modes are cheaper than the monolith |
|
||||||
|
|
||||||
|
Prefer vertical resource tuning, query/index fixes, caching with explicit
|
||||||
|
freshness, bounded concurrency, and horizontal replicas before service
|
||||||
|
splitting. A large codebase is a module-boundary problem before it is a network
|
||||||
|
boundary problem.
|
||||||
|
|
||||||
|
### 5. Preserve the hemx boundary across services
|
||||||
|
|
||||||
|
When microservices are justified:
|
||||||
|
|
||||||
|
- keep hemplate rendering, generated resources, and UI `EffectBatch` creation in
|
||||||
|
the presentation/application edge that owns the page;
|
||||||
|
- exchange typed domain requests, responses, and events across services—not CSS
|
||||||
|
selectors, DOM instructions, raw hemx opcodes, or app-authored JSON versions
|
||||||
|
of the hemx wire format;
|
||||||
|
- name one authority for each write model and do not let services casually share
|
||||||
|
mutation ownership;
|
||||||
|
- version service contracts independently, while deploying each hemx server,
|
||||||
|
its generated metadata, build fingerprint, and runtime asset as a compatible
|
||||||
|
release unit;
|
||||||
|
- preserve end-user credentials, authorization, CSRF, tenancy, deadlines, and
|
||||||
|
trace context explicitly at each real trust boundary;
|
||||||
|
- make partial failure visible. Define timeout, idempotency, retry,
|
||||||
|
deduplication, ordering, compensation, and replay only where the chosen
|
||||||
|
interaction requires them—never as generic middleware theater.
|
||||||
|
|
||||||
|
Do not put a network hop between a handler and its renderer merely to claim
|
||||||
|
microservices. Do not use `EffectBatch` as a business event bus or durable event
|
||||||
|
log.
|
||||||
|
|
||||||
|
### 6. Organize by cohesive product responsibility
|
||||||
|
|
||||||
|
Start small; do not pre-create an architecture directory tree. A production app
|
||||||
|
usually needs only these durable seams:
|
||||||
|
|
||||||
|
```text
|
||||||
|
build.rs # hemx-build/global code generation only
|
||||||
|
src/main.rs # config, concrete providers, process/bootstrap
|
||||||
|
src/lib.rs # app/router composition and a testable app surface
|
||||||
|
src/<feature>/ # add only when a feature is already a real seam
|
||||||
|
templates/app_shell.heml # document shell
|
||||||
|
templates/<feature>.heml # feature surface
|
||||||
|
templates/partials/ # genuinely reused or independently swapped pieces
|
||||||
|
tests/<journey>.rs # process/integration behavior
|
||||||
|
tests/browser_<journey>.rs # only browser-dependent behavior
|
||||||
|
```
|
||||||
|
|
||||||
|
Treat that as a responsibility map, not mandatory scaffolding. A small cohesive
|
||||||
|
app can remain in `lib.rs`; file count is not architecture. When a feature has
|
||||||
|
its own state/commands, handlers, rendering, and tests, move that whole seam
|
||||||
|
together. Keep its domain model, typed input, application operation, handler,
|
||||||
|
and generated view calls near one another. Do not spread every request across
|
||||||
|
generic `controllers/`, `services/`, `repositories/`, `dto/`, and `utils/`
|
||||||
|
directories.
|
||||||
|
|
||||||
|
Keep `main.rs` boring and hard to test because it contains almost no policy.
|
||||||
|
Expose app construction or mounting from `lib.rs` so integration tests can use
|
||||||
|
the real router with controlled concrete providers. Put normal routing,
|
||||||
|
auth/session, persistence, queues, and transport adapters at the app boundary;
|
||||||
|
do not move them into hemx core or generated template modules. Keep generated
|
||||||
|
artifacts in the build output rather than copying them into source control.
|
||||||
|
|
||||||
|
Put tiny unit tests beside the responsible module, especially around pure
|
||||||
|
transitions, validation, authorization decisions, and projections. Put
|
||||||
|
cross-module HTTP, persistence, and process tests in `tests/`, named for behavior
|
||||||
|
rather than implementation. Keep browser journeys few and load-bearing. Extract
|
||||||
|
a shared partial or helper only after actual reuse or independent swap identity
|
||||||
|
appears.
|
||||||
|
If a microservice boundary becomes real, that service owns its contract,
|
||||||
|
provider/migrations, operational entry point, and contract tests; do not mirror
|
||||||
|
hypothetical services in the source tree first.
|
||||||
|
|
||||||
|
### 7. Prove behavior at the cheapest authoritative boundary
|
||||||
|
|
||||||
|
Choose the tool by what must be observed:
|
||||||
|
|
||||||
|
| Boundary | Default tool | What it proves |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Domain/state | Rust `#[test]` / `#[tokio::test]` | Parsing, invariants, commands, projections, and failure classes |
|
||||||
|
| Template/build | Compiler plus hemx-build diagnostics | Real `.heml` syntax, generated resources/forms, and cross-file references |
|
||||||
|
| Handler/effect | `hemx_test` | Generated targets/handles, rendered partials, form bodies, and effect batches without raw ids |
|
||||||
|
| Router/integration | Real Axum router, usually `tower::ServiceExt`, plus concrete test providers | HTTP status/headers/body, auth, CSRF, sessions, persistence, and malformed requests |
|
||||||
|
| Static rendered HTML | Rust `scraper` crate (HTML parser + CSS-selector queries) | Escaping, semantic structure, links/forms/attributes, and server-rendered fragments without launching a browser |
|
||||||
|
| Process lifecycle | `hemx_test::TestProcess` | Readiness, real sockets, child cleanup, restart, and production-binary behavior |
|
||||||
|
| Browser runtime | Rust `thirtyfour` crate (WebDriver client) with the repository-owned browser smoke | Delegated events, history, focus, polling/revealed bindings, keyed DOM identity, SSE, and recovery |
|
||||||
|
| Test quality/release | hemx xtask mutation/full-test commands | Mutation resistance and the elected bounded workspace verification path |
|
||||||
|
|
||||||
|
`scraper` is a Rust HTML parsing and CSS-selector library, not a browser or web
|
||||||
|
framework. Use it when inspecting final server-rendered HTML is enough. It does
|
||||||
|
not run JavaScript, apply `EffectBatch`, maintain focus/history, or prove
|
||||||
|
SSE/runtime behavior. `thirtyfour` is a Rust WebDriver client crate; use it only
|
||||||
|
when an actual browser semantic is the subject;
|
||||||
|
do not turn every handler assertion into a WebDriver journey. In this repository
|
||||||
|
`thirtyfour` is the elected Rust browser adapter, so do not add Playwright,
|
||||||
|
Fantoccini, or another competing browser stack merely from preference. Outside
|
||||||
|
this repository, preserve the application's existing runner unless a concrete
|
||||||
|
missing capability justifies migration.
|
||||||
|
|
||||||
|
For WebDriver tests, start the real process through the RAII `TestProcess`
|
||||||
|
harness, keep selectors in test adapters, and prefer generated-resource or
|
||||||
|
stable semantic helpers over copied implementation selectors. Browser selector
|
||||||
|
helpers are not authoring APIs. Avoid sleeps when the runner can wait for the
|
||||||
|
observable condition.
|
||||||
|
|
||||||
|
Match proof to risk:
|
||||||
|
|
||||||
|
1. Let compilation reject broken templates and generated contracts.
|
||||||
|
2. Unit-test ordinary domain parsing and state transitions without a browser.
|
||||||
|
3. Test handlers through `hemx_test` with useful generated-resource assertions.
|
||||||
|
4. Test route/auth/session/CSRF/persistence and wire failure behavior at the app
|
||||||
|
integration boundary.
|
||||||
|
5. Parse static HTML with `scraper` only for facts that do not require runtime
|
||||||
|
execution.
|
||||||
|
6. Use focused repository-owned browser smoke for dynamic attributes,
|
||||||
|
navigation/history, keyed reconciliation, polling/revealed behavior,
|
||||||
|
no-reload interaction, and runtime recovery.
|
||||||
|
7. Add compatibility, rolling-deploy, process-restart, multi-replica, load, or
|
||||||
|
fault tests only when the selected scale stage makes those behaviors part of
|
||||||
|
the contract.
|
||||||
|
|
||||||
|
In this repository, prefer the stable commands named by `AGENTS.md`, especially
|
||||||
|
focused crate tests and `cargo run -p hemx-xtask -- test`; use
|
||||||
|
`cargo run -p hemx-xtask -- html-examples-smoke` for the pattern gallery and
|
||||||
|
runtime behavior, and the xtask mutation command rather than direct `mutest`.
|
||||||
|
Do not substitute a passing literal lowering fixture, static HTML parse, or
|
||||||
|
mocks-only test for proof at the rendered/runtime consumer boundary.
|
||||||
|
|
||||||
|
## Refuse fake sophistication
|
||||||
|
|
||||||
|
Do not introduce:
|
||||||
|
|
||||||
|
- handwritten resource ids, selector retargeting, raw effect constructors, raw
|
||||||
|
registries, runtime opcodes, manual wire parsing, or hard-coded runtime URLs in
|
||||||
|
normal app code;
|
||||||
|
- a VDOM, client router, general client state graph, expression runtime,
|
||||||
|
per-node listeners, or handwritten JavaScript for ordinary forms/lists/swaps;
|
||||||
|
- core-owned auth, sessions, persistence, routing, multipart, transport, sync,
|
||||||
|
analytics, flags, or provider policy;
|
||||||
|
- a generic repository/service/provider interface before a concrete second use
|
||||||
|
or required external contract exists;
|
||||||
|
- microservices by entity name, team aspiration, file count, or hypothetical
|
||||||
|
scale;
|
||||||
|
- shared process memory presented as durable or cross-replica state;
|
||||||
|
- retries, caches, queues, brokers, sagas, event sourcing, CQRS, Kubernetes, or a
|
||||||
|
service mesh without a named failure/load contract and verification path.
|
||||||
|
|
||||||
|
“Suckless” here means fewer authorities and mechanisms, not fewer safety checks.
|
||||||
|
The elegant design keeps HTML semantics, typed boundaries, explicit ownership,
|
||||||
|
and failure truth while deleting accidental layers.
|
||||||
|
|
||||||
|
## Handoff
|
||||||
|
|
||||||
|
For architecture or review requests, report compactly:
|
||||||
|
|
||||||
|
- the user-visible path and current bottleneck/risk;
|
||||||
|
- the selected scale stage and why the previous stage is insufficient;
|
||||||
|
- state, rendering, transport, and service ownership;
|
||||||
|
- the smallest end-to-end change;
|
||||||
|
- complexity explicitly refused;
|
||||||
|
- proof run and any unresolved provider/product contract.
|
||||||
|
|
||||||
|
For implementation, make that slice reachable and verify it; do not leave a
|
||||||
|
“scalable” abstraction that no real path uses.
|
||||||
@@ -61,7 +61,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
|
|||||||
- Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008
|
- Routing, auth, sessions, transport, transitions, sync, async data helpers, multipart parsing/uploads, and storage belong in integration/user crates; hemx-axum preserves normal HTTP auth, credentials, CSRF, multipart/browser fallback, and progressive-enhancement semantics rather than defining policy in core. Sync is optional integration state reconciliation over push/transport, not core. req: auth/001 req: auth/002 req: auth/003 req: auth/004 req: auth/005 req: async_data/001 req: async_data/002 req: async_data/003 req: multipart/001 req: multipart/002 req: multipart/003 req: sync/001 req: sync/008
|
||||||
- The SaaS production reference must use real links/URLs for navigation and an ongoing server-owned canonical SSE stream; bounded one-event behavior is a test probe, not the public transport contract. Browser history restores saved generated page snapshots and scroll position when available, with partial-fetch fallback, and restored revealed bindings must re-arm. req: nav/001 req: nav/002 req: nav/005 req: convention/014 req: push/003 req: examples/014
|
- The SaaS production reference must use real links/URLs for navigation and an ongoing server-owned canonical SSE stream; bounded one-event behavior is a test probe, not the public transport contract. Browser history restores saved generated page snapshots and scroll position when available, with partial-fetch fallback, and restored revealed bindings must re-arm. req: nav/001 req: nav/002 req: nav/005 req: convention/014 req: push/003 req: examples/014
|
||||||
- Public examples and beginner APIs should use templates plus Rust, generated component APIs, resources, view wrappers, render/page helpers, `#[hemx::app]`, plain `#[hemx::handler]` functions, and `IntoEffect`, not atoms, raw ids, selectors, wire formats, runtime opcodes, manual registries, `$OUT_DIR` includes, raw render/lower calls, raw HTML construction, imperative DOM mutation, or raw effect constructors; keep advanced layers out of starters. req: canonical_authoring/001 req: canonical_authoring/004 req: canonical_authoring/006 req: canonical_authoring/010 req: canonical_authoring/015 req: invariant/003 req: dx/001 req: dx/002 req: dx/010 req: component/003 req: component/004 req: component/005 req: view/001 req: view/002 req: view/003 req: html_safety/001 req: html_safety/003 req: html_safety/005 req: public_api/001 req: public_api/002 req: public_api/003 req: public_api/005 req: public_api/006 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003 req: derive_app/001 req: derive_app/002 req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005
|
- Public examples and beginner APIs should use templates plus Rust, generated component APIs, resources, view wrappers, render/page helpers, `#[hemx::app]`, plain `#[hemx::handler]` functions, and `IntoEffect`, not atoms, raw ids, selectors, wire formats, runtime opcodes, manual registries, `$OUT_DIR` includes, raw render/lower calls, raw HTML construction, imperative DOM mutation, or raw effect constructors; keep advanced layers out of starters. req: canonical_authoring/001 req: canonical_authoring/004 req: canonical_authoring/006 req: canonical_authoring/010 req: canonical_authoring/015 req: invariant/003 req: dx/001 req: dx/002 req: dx/010 req: component/003 req: component/004 req: component/005 req: view/001 req: view/002 req: view/003 req: html_safety/001 req: html_safety/003 req: html_safety/005 req: public_api/001 req: public_api/002 req: public_api/003 req: public_api/005 req: public_api/006 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003 req: derive_app/001 req: derive_app/002 req: derive_handler/001 req: derive_handler/002 req: derive_handler/003 req: derive_handler/004 req: derive_handler/005
|
||||||
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry canonical versioned hemx `EffectBatch` bytes over server-owned SSE/WebSocket transport and keep `data-hemx-sse` root-scoped/same-origin by default. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
|
- Typed partial swaps should stay expressed as generated target plus rendered partial plus swap kind, not selector-driven rerendering or response-side selector retargeting; HTTP, page navigation, push, and island behavior adapt around that loop, and docs should layer new primitives progressively. Navigation is an effect/page-swap concern, not a core router framework; enhanced links and GET forms preserve real URL/history semantics so page state stays reloadable/shareable without a client state graph. Push streams carry canonical versioned hemx `EffectBatch` bytes over server-owned SSE/WebSocket transport and keep `data-hemx-sse` and `data-hemx-ws` root-scoped/same-origin by default. A Cloudflare Durable Object proof keeps stable named-object authority and persisted Rust domain state, uses WebSocket hibernation for eviction recovery, and broadcasts rendered effects without storing DOM patches or effects as domain truth. Preserve keyed/optional scope identity for addressable loop nodes, reconcile filtered keyed collections without clearing retained rows, prefer generated keyed-slot helpers over low-level keyed calls, and route self/row-update diagnostics toward local `data-hemx-slot`/`h-key` targets. req: canonical_authoring/002 req: canonical_authoring/014 req: modes/001 req: scope/001 req: list/001 req: list/002 req: list/003 req: list/004 req: list/005 req: list/006 req: nav/001 req: nav/002 req: nav/003 req: nav/004 req: nav/005 req: push/001 req: push/002 req: push/003 req: push/004 req: push/005 req: push/006 req: push/007 req: push/009 req: push/010 req: push/011 req: progressive_disclosure/004 req: page_swap/001 req: page_swap/002 req: page_swap/003 req: locality/001 req: locality/002 req: target_policy/001 req: target_policy/002
|
||||||
- `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008
|
- `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state, not HTMX syntax, selector targeting, or user-authored browser JavaScript. Shared runtime loading and declarative `data-hemx-*` are allowed. Boost containers enhance same-origin descendants only and preserve native external/download/new-tab behavior. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: examples/005 req: examples/007 req: examples/012 req: page_swap/007 req: page_swap/008
|
||||||
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007
|
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands; do not treat it as a mobile framework or store-submission bot. req: ceremony/005 req: ceremony/006 req: ceremony/007
|
||||||
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
|
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
|
||||||
|
|||||||
Generated
+446
-322
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-sync", "hemx-sync-macros", "hemx-wasm", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/client_local", "examples/techdemo", "examples/saas", "examples/workout"]
|
members = ["hemplate-runtime", "hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-sync", "hemx-sync-macros", "hemx-wasm", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/client_local", "examples/techdemo", "examples/saas", "examples/workout", "examples/cloudflare_do"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
# Active frontier — peak idiomatic examples
|
# PLAN — Cloudflare Durable Objects proof
|
||||||
|
|
||||||
**Parent ID:** `examples-idiom/001`
|
Parent outcome: prove that hemx keeps its semantic `.heml` authoring, generated typed resources, compile-time target checking, canonical `EffectBatch` wire format, and tiny runtime while a Cloudflare Durable Object owns one durable collaborative room and hibernating WebSocket fan-out.
|
||||||
|
|
||||||
- **User value:** A newcomer can move from first app to production reference and advanced integration while seeing one coherent hemx model: plain `.heml`, generated resources, typed Rust handlers/effects, real links/forms/URLs, server-owned truth, native fallback, and explicit leaf adapters.
|
Non-goals: a generic Cloudflare framework, Cloudflare-owned auth policy, RPC/alarm/queue abstractions, global object discovery, offline reconciliation, deployment automation, or treating stored HTML/effects as business truth.
|
||||||
- **State:** Ready — the canonical starter and Workout exemplar are already strong; bounded teaching leaks remain in the HTML gallery, client-local example, SaaS reference, and advanced Kanban boundary.
|
|
||||||
- **Non-goals:** no new framework primitives, client router, VDOM, selector authoring API, reactive expression language, global client store, CSS framework/design system, generalized asset pipeline, visual redesign, or feature expansion. Do not churn `examples/workout` or `examples/techdemo` without a concrete failing contract.
|
|
||||||
- **Build:**
|
|
||||||
- **`examples-idiom/002` — Done: the SaaS reference tells the production truth.** Settings is a real `/settings` link enhanced by the existing page-swap runtime, direct `/settings` renders the fallback page, and `/events` now sends an initial canonical batch followed by ongoing server-owned status updates; `?once` remains only as a bounded production-reference probe. The removed handler no longer simulates navigation with response effects. Raw selector/form construction remains confined to test adapters because generated handles are the asserted boundary, not an app authoring API. req: canonical_authoring/001 req: nav/001 req: nav/002 req: nav/004 req: push/003 req: examples/003 req: examples/004 req: examples/014
|
|
||||||
- **`examples-idiom/003` — Make the HTML pattern gallery mechanically copyable.** In `examples/html_examples/src/main.rs`, generated `gallery` resources, and nearby templates/tests, express the dependent-select flow through typed generated values/partials instead of duplicated string-to-option mapping, and remove request fields discarded only to satisfy example plumbing where the generated handler contract permits it. Preserve each visible htmx slug, native form semantics, server-owned state, inserted-content behavior, and no-reload smoke. req: htmx_equivalents/001 req: htmx_equivalents/003 req: htmx_equivalents/005 req: canonical_authoring/001 req: form/004
|
|
||||||
- **`examples-idiom/004` — Make client-local code visibly a leaf adapter.** In `examples/client_local/src/lib.rs` and its `.heml` surface, project the generated client event/state into a tiny typed counter-domain input/output instead of rendering raw event kind and encoded state as the example's product value. Keep the ordinary `#[hemx::handler(client)]` shape, generated event/state boundary, native event semantics, and no durable client state graph. req: client_local/001 req: client_local/003 req: client_local/004 req: canonical_authoring/017
|
|
||||||
- **`examples-idiom/005` — Separate the advanced Kanban adapter from ordinary hemx app code.** Move the cohesive sync/presence/session/storage transport responsibility from `examples/kanban/src/main.rs` behind one clearly named local integration module with a small route/state contract; keep board templates and ordinary handlers nearby and unchanged where possible. Update `examples/kanban/README.md` to label the fixture as the advanced local/offline/sync north-star, route beginners to the starter/Workout/gallery first, and name legacy `/sync-demo`/`sync.js` as a compatibility probe rather than recommended authoring. Preserve replay, export, deletion, reconnection, auth, and multiplayer browser proof. req: state/001 req: state/002 req: local/001 req: local/002 req: milestone/001 req: sync/001 req: sync/008
|
|
||||||
- **`examples-idiom/006` — Publish and enforce the example ladder.** In `README.md`, example READMEs, and the nearest existing xtask/example checks, identify `app new`/`examples/v0` as the first canonical app, `html_examples` as the pattern gallery, Workout as the product exemplar, SaaS as the production integration reference, `client_local` as the narrow leaf-adapter proof, Techdemo as exhaustive verification, and Kanban as advanced north-star integration. Add the smallest repository-owned guard that fails when beginner/reference authoring regresses to raw IDs, selectors, raw wire/effect constructors, `$OUT_DIR` includes, or app-authored DOM mutation; keep legitimate advanced/test adapters scoped rather than banning tokens globally. req: examples/001 req: examples/003 req: examples/004 req: examples/005 req: examples/007 req: examples/008 req: examples/009 req: canonical_authoring/001 req: progressive_disclosure/001 req: progressive_disclosure/002 req: progressive_disclosure/003
|
|
||||||
- **Blocked by:** none. The separate v1 legal release gate remains blocked on the owner license decision but does not block example work.
|
|
||||||
- **Proof:** each slice must make its user path observable, preserve the named native/recovery path, and pass its focused package/browser proof. Parent closure requires `cargo run -p hemx-xtask -- html-examples-smoke`, `cargo run -p hemx-xtask -- workout test`, focused SaaS/client-local/Kanban package tests, `cargo run -p hemx-xtask -- test`, `cargo check --workspace`, `cargo fmt --check`, `redgate list`, `redgate refs`, and a clean diff. Frustration signals to reject: a beginner must author selectors/raw IDs/wire ops; navigation loses URL/history; JavaScript becomes durable truth; an example claims live/recovery behavior with a one-shot stub; or advanced sync machinery appears to be the default app model.
|
|
||||||
|
|
||||||
## Blocked release decision retained
|
## Slice CF-1 — Canonical WebSocket push
|
||||||
|
|
||||||
- [ ] **State:** Needs decision — choose the repository distribution license and approved third-party SPDX set, then add root license file(s), workspace package metadata, `deny.toml`, and rerun the release matrix.
|
Outcome: a hemx root can receive binary `EffectBatch` updates over a same-origin WebSocket with the same ABI/fingerprint and root-scoped failure behavior as HTTP/SSE.
|
||||||
- **Blocked by:** owner/legal authority. Current third-party set includes Apache-2.0, Apache-2.0 WITH LLVM-exception, BSD-3-Clause, BSL-1.0, MIT, Unicode-3.0, and Unlicense; all 20 workspace packages currently lack license metadata.
|
Delta: push/001, push/003, push/005, push/009; runtime/001; failure/001.
|
||||||
- **Proof:** `cargo deny check advisories sources licenses` and the full `docs/v1-readiness.md` matrix pass, then readiness changes from NO-GO to GO without publication or deployment.
|
Path: `data-hemx-ws` on generated root -> runtime WebSocket -> binary frame -> existing `applyBatch` -> generated target or root error outlet.
|
||||||
|
Build: validate the root declaration in hemx-build; add runtime bind/cleanup/error behavior and focused consumer-boundary tests.
|
||||||
|
Risk: accepting text, cross-origin, malformed, or stale-build messages could bypass the canonical compatibility boundary or mutate the wrong root.
|
||||||
|
Proof: `cargo test -p hemx-build -p hemx-js` plus `cargo check --target wasm32-unknown-unknown -p hemx`.
|
||||||
|
Non-goals: client command protocol, reconnect/replay policy beyond the browser WebSocket primitive, multiplexing, or a general transport trait.
|
||||||
|
Residual risk: a real Cloudflare hibernation journey remains for CF-2.
|
||||||
|
State: Ready.
|
||||||
|
Blocked by: none.
|
||||||
|
|
||||||
|
## Slice CF-2 — Durable room exemplar
|
||||||
|
|
||||||
|
Outcome: two browser clients in one named room see a counter mutation rendered from durable Rust state without reload, and reopening the room after object restart/eviction restores the persisted count.
|
||||||
|
Delta: push/001, push/002, push/003, push/009, push/010, push/011; canonical_authoring/001; state/001; abi/004.
|
||||||
|
Path: Worker room URL -> stable Durable Object name -> `.heml` page -> WebSocket upgrade -> typed increment command -> persisted counter -> generated counter partial -> canonical `EffectBatch` -> hibernating sockets -> both roots update.
|
||||||
|
Build: add one `examples/cloudflare_do` worker-rs exemplar with build-time hemx generation, one semantic template, one Durable Object class, Wrangler migration/binding, and focused pure tests for command/state/render output.
|
||||||
|
Risk: target-toolchain incompatibility, using an in-memory socket registry, persisting UI output instead of state, or emitting noncanonical WebSocket bytes would invalidate the proof.
|
||||||
|
Proof: native tests for command/render behavior; `cargo check --target wasm32-unknown-unknown -p hemx-cloudflare-do-example`; then `wrangler dev` browser smoke when Wrangler is available.
|
||||||
|
Non-goals: production auth/CSRF/tenancy, alarms, queues, RPC wrappers, multi-object transactions, deployment, billing, or a public `hemx-cloudflare` crate.
|
||||||
|
Residual risk: hosted Cloudflare deployment, jurisdiction policy, and production credentials remain external.
|
||||||
|
State: Ready for local build; hosted runtime proof is Blocked.
|
||||||
|
Blocked by: Wrangler runtime availability and Cloudflare account credentials for hosted verification.
|
||||||
|
|||||||
@@ -496,6 +496,12 @@ what a valid business email is. [north_star]
|
|||||||
|
|
||||||
008 SSE must transport canonical `EffectBatch` bytes as one unpadded base64url value in the `hemx` event data field. [north_star]
|
008 SSE must transport canonical `EffectBatch` bytes as one unpadded base64url value in the `hemx` event data field. [north_star]
|
||||||
|
|
||||||
|
009 A root may declare one non-empty same-origin WebSocket URL with `data-hemx-ws`; the runtime accepts only binary canonical versioned `EffectBatch` messages, applies them through the normal fail-closed batch path, and reports text, malformed, incompatible, or cross-origin input through the root error outlet. [poc]
|
||||||
|
|
||||||
|
010 The Cloudflare proof keeps one stable named Durable Object as the authority for one room, persists ordinary Rust domain state in Durable Object storage, renders semantic `.heml` generated partials after mutation, and broadcasts canonical `EffectBatch` bytes rather than storing DOM patches or effects as domain truth. [poc]
|
||||||
|
|
||||||
|
011 The Cloudflare proof uses the Durable Objects WebSocket hibernation API so accepted sockets and persisted room state recover after eviction without a process-owned connection registry; malformed client messages and storage or broadcast failures remain explicit failures. [poc]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## sync
|
## sync
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ description = "Workspace compatibility alias from yanked spin 0.9 to maintained
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
once = ["spin_next/once"]
|
||||||
spin_mutex = ["spin_next/spin_mutex"]
|
spin_mutex = ["spin_next/spin_mutex"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
spin_next = { package = "spin", version = "=0.12.2", default-features = false }
|
spin_next = { package = "spin", version = "=0.12.2", default-features = false, features = ["once"] }
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "hemx-cloudflare-do-example"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
hemplate = { package = "hemplate-runtime", path = "../../hemplate-runtime" }
|
||||||
|
hemplate-derive = { path = "../../../hemplate/hemplate-derive" }
|
||||||
|
hemx = { path = "../../hemx" }
|
||||||
|
hemx-js = { path = "../../hemx-js" }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
worker = { version = "0.7.5", features = ["http", "queue"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
hemx-build = { path = "../../hemx-build" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
postcard = { version = "1", features = ["alloc"] }
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# hemx on Cloudflare Durable Objects
|
||||||
|
|
||||||
|
This proof keeps the hemx authoring and wire model intact while Cloudflare owns room placement, persistence, and hibernating WebSockets:
|
||||||
|
|
||||||
|
```text
|
||||||
|
room.heml -> hemx-build generated resources -> Rust RoomState
|
||||||
|
-> generated counter partial -> canonical EffectBatch bytes
|
||||||
|
-> Durable Object hibernating sockets -> hemx browser runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
The Durable Object stores only the counter. It never stores HTML, DOM patches, or `EffectBatch` values as domain truth.
|
||||||
|
|
||||||
|
## Local commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test -p hemx-cloudflare-do-example
|
||||||
|
cargo check --target wasm32-unknown-unknown -p hemx-cloudflare-do-example
|
||||||
|
cd examples/cloudflare_do
|
||||||
|
npx wrangler dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the printed local URL in two tabs. Incrementing in either tab should update both without reload. Stop and restart `wrangler dev`; the room counter should remain.
|
||||||
|
|
||||||
|
A hosted deployment requires an authorized Cloudflare account. Production auth, CSRF, tenancy, jurisdiction, reconnect replay, and deployment policy are deliberately outside this proof.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
fn main() {
|
||||||
|
let templates = std::path::PathBuf::from(
|
||||||
|
std::env::var_os("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR"),
|
||||||
|
)
|
||||||
|
.join("templates");
|
||||||
|
hemx_build::app()
|
||||||
|
.template_dir(templates)
|
||||||
|
.run()
|
||||||
|
.expect("compile cloudflare_do hemx surfaces");
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
use hemplate::Hemplate;
|
||||||
|
use hemplate_derive::Hemplate;
|
||||||
|
use hemx::advanced::EffectBatch;
|
||||||
|
use hemx::IntoEffect;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use worker::*;
|
||||||
|
|
||||||
|
#[hemx::surface]
|
||||||
|
pub mod ui {}
|
||||||
|
|
||||||
|
const ROOMS_BINDING: &str = "ROOMS";
|
||||||
|
const COUNT_KEY: &str = "count";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
|
struct RoomState {
|
||||||
|
count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum RoomCommand {
|
||||||
|
Increment,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoomState {
|
||||||
|
fn apply(self, command: RoomCommand) -> Result<Self> {
|
||||||
|
match command {
|
||||||
|
RoomCommand::Increment => self
|
||||||
|
.count
|
||||||
|
.checked_add(1)
|
||||||
|
.map(|count| Self { count })
|
||||||
|
.ok_or_else(|| Error::RustError("room counter overflowed".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Hemplate)]
|
||||||
|
struct Room {
|
||||||
|
count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Hemplate)]
|
||||||
|
#[hemplate = "partials"]
|
||||||
|
struct CounterView {
|
||||||
|
count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn counter_batch(state: RoomState) -> EffectBatch {
|
||||||
|
ui::room::put(
|
||||||
|
ui::room::advanced::slots::counter,
|
||||||
|
&CounterView { count: state.count },
|
||||||
|
)
|
||||||
|
.into_batch(ui::BUILD_FINGERPRINT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn room_page(state: RoomState) -> String {
|
||||||
|
ui::room::page(&Room { count: state.count }).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effect_bytes(state: RoomState) -> Result<Vec<u8>> {
|
||||||
|
Ok(counter_batch(state).to_wire())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_bytes(bytes: Vec<u8>, content_type: &str) -> Result<Response> {
|
||||||
|
let headers = Headers::new();
|
||||||
|
headers.set("content-type", content_type)?;
|
||||||
|
Response::from_bytes(bytes).map(|response| response.with_headers(headers))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_html(html: String) -> Result<Response> {
|
||||||
|
response_bytes(html.into_bytes(), "text/html; charset=utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[event(fetch)]
|
||||||
|
pub async fn fetch(request: Request, env: Env, _ctx: Context) -> Result<Response> {
|
||||||
|
match request.path().as_str() {
|
||||||
|
"/hemx.js" => response_bytes(
|
||||||
|
hemx_js::RUNTIME_JS.as_bytes().to_vec(),
|
||||||
|
"text/javascript; charset=utf-8",
|
||||||
|
),
|
||||||
|
path if path.starts_with("/rooms/") => {
|
||||||
|
let room_name =
|
||||||
|
room_name(path).ok_or_else(|| Error::RustError("missing room name".into()))?;
|
||||||
|
let stub = env.durable_object(ROOMS_BINDING)?.get_by_name(room_name)?;
|
||||||
|
stub.fetch_with_request(request).await
|
||||||
|
}
|
||||||
|
"/" => Response::redirect("/rooms/demo".parse()?),
|
||||||
|
_ => Response::error("not found", 404),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn room_name(path: &str) -> Option<&str> {
|
||||||
|
path.strip_prefix("/rooms/")?
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[durable_object]
|
||||||
|
pub struct DurableRoom {
|
||||||
|
state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DurableObject for DurableRoom {
|
||||||
|
fn new(state: State, _env: Env) -> Self {
|
||||||
|
Self { state }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch(&self, request: Request) -> Result<Response> {
|
||||||
|
match (request.method(), request.path().rsplit('/').next()) {
|
||||||
|
(Method::Get, Some("socket")) => self.accept_socket(request),
|
||||||
|
(Method::Post, Some("increment")) => self.increment().await,
|
||||||
|
(Method::Get, _) => response_html(self.page().await?),
|
||||||
|
_ => Response::error("not found", 404),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn websocket_message(
|
||||||
|
&self,
|
||||||
|
_ws: WebSocket,
|
||||||
|
_message: WebSocketIncomingMessage,
|
||||||
|
) -> Result<()> {
|
||||||
|
Err(Error::RustError(
|
||||||
|
"room commands use ordinary HTTP; WebSocket is server push only".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn websocket_close(
|
||||||
|
&self,
|
||||||
|
_ws: WebSocket,
|
||||||
|
_code: usize,
|
||||||
|
_reason: String,
|
||||||
|
_was_clean: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn websocket_error(&self, _ws: WebSocket, error: Error) -> Result<()> {
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DurableRoom {
|
||||||
|
async fn load(&self) -> Result<RoomState> {
|
||||||
|
Ok(RoomState {
|
||||||
|
count: self.state.storage().get(COUNT_KEY).await?.unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn page(&self) -> Result<String> {
|
||||||
|
let body = room_page(self.load().await?);
|
||||||
|
Ok(format!(
|
||||||
|
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Durable hemx room</title><script defer src=\"/hemx.js\"></script></head><body>{body}</body></html>"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_socket(&self, request: Request) -> Result<Response> {
|
||||||
|
if request.headers().get("upgrade")?.as_deref() != Some("websocket") {
|
||||||
|
return Response::error("expected WebSocket upgrade", 426);
|
||||||
|
}
|
||||||
|
let pair = WebSocketPair::new()?;
|
||||||
|
self.state.accept_web_socket(&pair.server);
|
||||||
|
Response::from_websocket(pair.client)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn increment(&self) -> Result<Response> {
|
||||||
|
let next = self.load().await?.apply(RoomCommand::Increment)?;
|
||||||
|
self.state.storage().put(COUNT_KEY, next.count).await?;
|
||||||
|
let bytes = effect_bytes(next)?;
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
for socket in self.state.get_websockets() {
|
||||||
|
if let Err(error) = socket.send_with_bytes(bytes.clone()) {
|
||||||
|
failures.push(error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failures.is_empty() {
|
||||||
|
response_bytes(bytes, "application/x-hemx-effects")
|
||||||
|
} else {
|
||||||
|
Err(Error::RustError(format!(
|
||||||
|
"counter persisted but WebSocket broadcast failed: {}",
|
||||||
|
failures.join("; ")
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_updates_domain_state_without_storing_ui_output() {
|
||||||
|
// req: push/010 req: state/001
|
||||||
|
assert_eq!(
|
||||||
|
RoomState { count: 4 }
|
||||||
|
.apply(RoomCommand::Increment)
|
||||||
|
.unwrap(),
|
||||||
|
RoomState { count: 5 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_target_and_template_render_survive_the_cloudflare_boundary() {
|
||||||
|
// req: canonical_authoring/001 req: push/010
|
||||||
|
let state = RoomState { count: 7 };
|
||||||
|
let page = room_page(state);
|
||||||
|
assert!(page.contains("data-sid=\""), "rendered page: {page}");
|
||||||
|
assert!(page.contains("Count: 7"), "rendered page: {page}");
|
||||||
|
assert!(page.contains("data-hemx-ws=\"/rooms/demo/socket\""));
|
||||||
|
|
||||||
|
let decoded = EffectBatch::from_wire(&effect_bytes(state).unwrap()).unwrap();
|
||||||
|
assert_eq!(decoded, counter_batch(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_room_names_are_extracted_without_inventing_global_discovery() {
|
||||||
|
assert_eq!(room_name("/rooms/demo/socket"), Some("demo"));
|
||||||
|
assert_eq!(room_name("/rooms/team-a"), Some("team-a"));
|
||||||
|
assert_eq!(room_name("/rooms/"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<strong>Count: {+ self.count +}</strong>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<main data-hemx-root="room" data-hemx-ws="/rooms/demo/socket" data-hemx-error="room_error">
|
||||||
|
<h1>Durable hemx room</h1>
|
||||||
|
<p>One Durable Object owns this room. Open it in two tabs.</p>
|
||||||
|
<section data-hemx-slot="counter" aria-live="polite">
|
||||||
|
<strong>Count: {+ self.count +}</strong>
|
||||||
|
</section>
|
||||||
|
<form method="post" action="/rooms/demo/increment" data-hemx-handle="increment">
|
||||||
|
<button type="submit">Increment</button>
|
||||||
|
</form>
|
||||||
|
<p data-hemx-slot="room_error" role="alert"></p>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "hemx-cloudflare-do-poc",
|
||||||
|
"main": "build/worker/shim.mjs",
|
||||||
|
"compatibility_date": "2026-08-13",
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{ "name": "ROOMS", "class_name": "DurableRoom" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{ "tag": "v1", "new_sqlite_classes": ["DurableRoom"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[package]
|
||||||
|
name = "hemplate-runtime"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
pub mod error {
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct HemplateError;
|
||||||
|
|
||||||
|
impl fmt::Display for HemplateError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str("hemplate rendering failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for HemplateError {}
|
||||||
|
|
||||||
|
impl From<fmt::Error> for HemplateError {
|
||||||
|
fn from(_: fmt::Error) -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Hemplate {
|
||||||
|
fn render_into(&self, buffer: &mut String) -> Result<(), error::HemplateError>;
|
||||||
|
|
||||||
|
fn render(&self) -> String {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
self.render_into(&mut buffer)
|
||||||
|
.expect("writing a hemplate String cannot fail");
|
||||||
|
buffer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render<T: Hemplate + ?Sized>(value: &T) -> Result<String, error::HemplateError> {
|
||||||
|
let mut buffer = String::new();
|
||||||
|
value.render_into(&mut buffer)?;
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HtmlEscape<T: fmt::Display>(T);
|
||||||
|
|
||||||
|
impl<T: fmt::Display> fmt::Display for HtmlEscape<T> {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
for character in self.0.to_string().chars() {
|
||||||
|
match character {
|
||||||
|
'&' => formatter.write_str("&")?,
|
||||||
|
'<' => formatter.write_str("<")?,
|
||||||
|
'>' => formatter.write_str(">")?,
|
||||||
|
'"' => formatter.write_str(""")?,
|
||||||
|
'\'' => formatter.write_str("'")?,
|
||||||
|
other => fmt::Write::write_char(formatter, other)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod render {
|
||||||
|
use super::{error::HemplateError, Hemplate, HtmlEscape};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
pub struct Escaped<'a, T: ?Sized>(pub &'a T);
|
||||||
|
|
||||||
|
impl<T: Hemplate + ?Sized> Escaped<'_, T> {
|
||||||
|
pub fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError> {
|
||||||
|
self.0.render_into(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait EscapedFallback {
|
||||||
|
fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: fmt::Display + ?Sized> EscapedFallback for Escaped<'_, T> {
|
||||||
|
fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError> {
|
||||||
|
use fmt::Write;
|
||||||
|
write!(buffer, "{}", HtmlEscape(self.0))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Raw<'a, T: ?Sized>(pub &'a T);
|
||||||
|
|
||||||
|
impl<T: Hemplate + ?Sized> Raw<'_, T> {
|
||||||
|
pub fn render_to(&self, _buffer: &mut String) -> Result<(), HemplateError> {
|
||||||
|
panic!("hemplate: raw interpolation must not be used with Hemplate types")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait RawFallback {
|
||||||
|
fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: fmt::Display + ?Sized> RawFallback for Raw<'_, T> {
|
||||||
|
fn render_to(&self, buffer: &mut String) -> Result<(), HemplateError> {
|
||||||
|
use fmt::Write;
|
||||||
|
write!(buffer, "{}", self.0)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RawGuard<'a, T: ?Sized>(pub &'a T);
|
||||||
|
pub struct RawInterpolationNotAllowedForHemplateTypes;
|
||||||
|
|
||||||
|
impl<T: Hemplate + ?Sized> RawGuard<'_, T> {
|
||||||
|
pub fn check(&self) -> RawInterpolationNotAllowedForHemplateTypes {
|
||||||
|
RawInterpolationNotAllowedForHemplateTypes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait RawGuardFallback {
|
||||||
|
fn check(&self);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: fmt::Display + ?Sized> RawGuardFallback for RawGuard<'_, T> {
|
||||||
|
fn check(&self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Hemplate + ?Sized> Hemplate for &T {
|
||||||
|
fn render_into(&self, buffer: &mut String) -> Result<(), HemplateError> {
|
||||||
|
(*self).render_into(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Hemplate + ?Sized> Hemplate for Box<T> {
|
||||||
|
fn render_into(&self, buffer: &mut String) -> Result<(), HemplateError> {
|
||||||
|
(**self).render_into(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-3
@@ -1601,6 +1601,7 @@ fn known_hemx_attr(name: &str) -> bool {
|
|||||||
name,
|
name,
|
||||||
"data-hemx-root"
|
"data-hemx-root"
|
||||||
| "data-hemx-sse"
|
| "data-hemx-sse"
|
||||||
|
| "data-hemx-ws"
|
||||||
| "data-hemx-st"
|
| "data-hemx-st"
|
||||||
| "data-hemx-handle"
|
| "data-hemx-handle"
|
||||||
| "data-hemx-slot"
|
| "data-hemx-slot"
|
||||||
@@ -1728,6 +1729,14 @@ fn reject_invalid_hemx_attr_values(path: &Path, attrs: &[SurfaceAttribute]) -> i
|
|||||||
"expected a non-empty same-origin SSE URL",
|
"expected a non-empty same-origin SSE URL",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
"data-hemx-ws" if value.trim().is_empty() => {
|
||||||
|
return Err(invalid_hemx_value(
|
||||||
|
path,
|
||||||
|
&attr.name,
|
||||||
|
value,
|
||||||
|
"expected a non-empty same-origin WebSocket URL",
|
||||||
|
));
|
||||||
|
}
|
||||||
"data-hemx-revealed-ahead"
|
"data-hemx-revealed-ahead"
|
||||||
if !value
|
if !value
|
||||||
.trim()
|
.trim()
|
||||||
@@ -1796,6 +1805,13 @@ fn reject_invalid_hemx_attr_placement(
|
|||||||
"expected placement on the same element as `data-hemx-root`",
|
"expected placement on the same element as `data-hemx-root`",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if has_attr(attrs, "data-hemx-ws") && !has_attr(attrs, "data-hemx-root") {
|
||||||
|
return Err(invalid_hemx_placement(
|
||||||
|
path,
|
||||||
|
"data-hemx-ws",
|
||||||
|
"expected placement on the same element as `data-hemx-root`",
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2789,12 +2805,12 @@ mod tests {
|
|||||||
let canonical_syms = resources.syms();
|
let canonical_syms = resources.syms();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stable_id("generated-rs", &canonical_generated),
|
stable_id("generated-rs", &canonical_generated),
|
||||||
2_620_423_950,
|
4_047_122_428,
|
||||||
"canonical generated Rust changed"
|
"canonical generated Rust changed"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
stable_id("generated-rs-global", &canonical_globals),
|
stable_id("generated-rs-global", &canonical_globals),
|
||||||
2_559_847_213,
|
1_196_973_467,
|
||||||
"canonical global-export Rust changed"
|
"canonical global-export Rust changed"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -4232,6 +4248,12 @@ fn main() {{
|
|||||||
"data-hemx-sse",
|
"data-hemx-sse",
|
||||||
"non-empty",
|
"non-empty",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"ws-empty",
|
||||||
|
r#"<main data-hemx-root="app" data-hemx-ws=" "></main>"#,
|
||||||
|
"data-hemx-ws",
|
||||||
|
"non-empty",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"delay-empty",
|
"delay-empty",
|
||||||
r#"<button data-hemx-handle="save" data-hemx-delay="">Save</button>"#,
|
r#"<button data-hemx-handle="save" data-hemx-delay="">Save</button>"#,
|
||||||
@@ -4358,6 +4380,7 @@ fn main() {{
|
|||||||
"data-hemx-on" => "expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`",
|
"data-hemx-on" => "expected runtime-supported events: `click`, `submit`, `input`, `change`, `keydown`, `dragstart`, `dragover`, or `drop`",
|
||||||
"data-hemx-confirm" => "expected a non-empty confirmation message",
|
"data-hemx-confirm" => "expected a non-empty confirmation message",
|
||||||
"data-hemx-sse" => "expected a non-empty same-origin SSE URL",
|
"data-hemx-sse" => "expected a non-empty same-origin SSE URL",
|
||||||
|
"data-hemx-ws" => "expected a non-empty same-origin WebSocket URL",
|
||||||
"data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle"
|
"data-hemx-debounce" | "data-hemx-delay" | "data-hemx-throttle"
|
||||||
| "data-hemx-every" | "data-hemx-interval" => {
|
| "data-hemx-every" | "data-hemx-interval" => {
|
||||||
"expected milliseconds like `250`/`250ms` or seconds like `1s`"
|
"expected milliseconds like `250`/`250ms` or seconds like `1s`"
|
||||||
@@ -4437,7 +4460,7 @@ fn main() {{
|
|||||||
}
|
}
|
||||||
|
|
||||||
let valid_source = r#"
|
let valid_source = r#"
|
||||||
<main data-hemx-root="app" data-hemx-client-module="/app.js" data-hemx-sse="/events">
|
<main data-hemx-root="app" data-hemx-client-module="/app.js" data-hemx-sse="/events" data-hemx-ws="/room/socket">
|
||||||
<button data-hemx-handle="save" data-hemx-policy="latest" data-hemx-on="click change" data-hemx-confirm="Save?" data-hemx-delay="250ms" data-hemx-throttle="1s">Save</button>
|
<button data-hemx-handle="save" data-hemx-policy="latest" data-hemx-on="click change" data-hemx-confirm="Save?" data-hemx-delay="250ms" data-hemx-throttle="1s">Save</button>
|
||||||
<button data-hemx-client="save" data-hemx-client-event="click" data-hemx-client-policy="drop" data-hemx-client-state-version="1">Client</button>
|
<button data-hemx-client="save" data-hemx-client-event="click" data-hemx-client-policy="drop" data-hemx-client-state-version="1">Client</button>
|
||||||
<form method="get" action="/search" data-hemx-history="replace"><input name="q"></form>
|
<form method="get" action="/search" data-hemx-history="replace"><input name="q"></form>
|
||||||
@@ -4500,6 +4523,12 @@ fn main() {{
|
|||||||
"data-hemx-sse",
|
"data-hemx-sse",
|
||||||
"expected placement on the same element as `data-hemx-root`",
|
"expected placement on the same element as `data-hemx-root`",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"ws-child",
|
||||||
|
r#"<section data-hemx-root="room"><div data-hemx-ws="/room/socket"></div></section>"#,
|
||||||
|
"data-hemx-ws",
|
||||||
|
"expected placement on the same element as `data-hemx-root`",
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let base = test_dir(&format!("hemx-build-invalid-placement-{case}-test"));
|
let base = test_dir(&format!("hemx-build-invalid-placement-{case}-test"));
|
||||||
let templates = base.join("templates");
|
let templates = base.join("templates");
|
||||||
@@ -4540,6 +4569,10 @@ fn main() {{
|
|||||||
"sse-root",
|
"sse-root",
|
||||||
r#"<main data-hemx-root="feed" data-hemx-sse="/events"></main>"#,
|
r#"<main data-hemx-root="feed" data-hemx-sse="/events"></main>"#,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"ws-root",
|
||||||
|
r#"<main data-hemx-root="room" data-hemx-ws="/room/socket"></main>"#,
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let base = test_dir(&format!("hemx-build-valid-placement-{case}-test"));
|
let base = test_dir(&format!("hemx-build-valid-placement-{case}-test"));
|
||||||
let templates = base.join("templates");
|
let templates = base.join("templates");
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
const busyStates = new WeakMap();
|
const busyStates = new WeakMap();
|
||||||
const disabledStates = new WeakMap();
|
const disabledStates = new WeakMap();
|
||||||
const sseSources = new WeakMap();
|
const sseSources = new WeakMap();
|
||||||
|
const webSockets = new WeakMap();
|
||||||
const atomStores = new WeakMap();
|
const atomStores = new WeakMap();
|
||||||
const dragKeys = new WeakMap();
|
const dragKeys = new WeakMap();
|
||||||
let currentOperationId = null;
|
let currentOperationId = null;
|
||||||
@@ -941,6 +942,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindWebSocket(root) {
|
||||||
|
const url = root.getAttribute("data-hemx-ws");
|
||||||
|
if (!url || webSockets.has(root) || typeof WebSocket === "undefined") return;
|
||||||
|
const href = new URL(url, location.href);
|
||||||
|
if (href.origin !== location.origin) {
|
||||||
|
const error = new Error("hemx WebSocket URL must be same-origin");
|
||||||
|
showError(root, error);
|
||||||
|
emit(root, "hemx:ws-error", url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
href.protocol = href.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const socket = new WebSocket(href.href);
|
||||||
|
socket.binaryType = "arraybuffer";
|
||||||
|
socket.addEventListener("message", (event) => applyWebSocketMessage(root, event));
|
||||||
|
socket.addEventListener("error", () => {
|
||||||
|
showError(root, new Error("hemx WebSocket failed"));
|
||||||
|
emit(root, "hemx:ws-error", url);
|
||||||
|
});
|
||||||
|
webSockets.set(root, socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWebSocketMessage(root, event) {
|
||||||
|
try {
|
||||||
|
if (!(event.data instanceof ArrayBuffer)) throw new Error("hemx WebSocket messages must be binary");
|
||||||
|
applyBatch(event.data, root);
|
||||||
|
showError(root, null);
|
||||||
|
} catch (error) {
|
||||||
|
showError(root, error);
|
||||||
|
emit(root, "hemx:error", String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function duration(value) {
|
function duration(value) {
|
||||||
if (!value) return 0;
|
if (!value) return 0;
|
||||||
const match = String(value).trim().match(/^(\d+)(ms|s)?$/);
|
const match = String(value).trim().match(/^(\d+)(ms|s)?$/);
|
||||||
@@ -1135,6 +1168,9 @@
|
|||||||
const source = sseSources.get(root);
|
const source = sseSources.get(root);
|
||||||
if (source) source.close();
|
if (source) source.close();
|
||||||
sseSources.delete(root);
|
sseSources.delete(root);
|
||||||
|
const socket = webSockets.get(root);
|
||||||
|
if (socket) socket.close();
|
||||||
|
webSockets.delete(root);
|
||||||
const observers = revealObservers.get(root);
|
const observers = revealObservers.get(root);
|
||||||
if (observers) observers.forEach((observer) => observer.disconnect());
|
if (observers) observers.forEach((observer) => observer.disconnect());
|
||||||
revealObservers.delete(root);
|
revealObservers.delete(root);
|
||||||
@@ -1177,6 +1213,12 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
emit(root, "hemx:sse-error", String(error));
|
emit(root, "hemx:sse-error", String(error));
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
bindWebSocket(root);
|
||||||
|
} catch (error) {
|
||||||
|
showError(root, error);
|
||||||
|
emit(root, "hemx:ws-error", String(error));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
new MutationObserver((records) => {
|
new MutationObserver((records) => {
|
||||||
records.forEach((record) => {
|
records.forEach((record) => {
|
||||||
|
|||||||
@@ -398,6 +398,23 @@ fn runtime_applies_sse_effect_batches_inside_roots() {
|
|||||||
assert!(source.contains("emit(root, \"hemx:sse-error\", url)"));
|
assert!(source.contains("emit(root, \"hemx:sse-error\", url)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_applies_binary_websocket_effect_batches_inside_roots() {
|
||||||
|
// req: push/001 req: push/003 req: push/009 req: runtime/001 req: failure/001
|
||||||
|
let source = hemx_js::RUNTIME_JS;
|
||||||
|
|
||||||
|
assert!(source.contains("const webSockets = new WeakMap()"));
|
||||||
|
assert!(source.contains("const url = root.getAttribute(\"data-hemx-ws\")"));
|
||||||
|
assert!(source.contains("if (href.origin !== location.origin)"));
|
||||||
|
assert!(source.contains("href.protocol = href.protocol === \"https:\" ? \"wss:\" : \"ws:\""));
|
||||||
|
assert!(source.contains("socket.binaryType = \"arraybuffer\""));
|
||||||
|
assert!(source.contains("if (!(event.data instanceof ArrayBuffer)) throw new Error"));
|
||||||
|
assert!(source.contains("applyBatch(event.data, root)"));
|
||||||
|
assert!(source.contains("showError(root, error)"));
|
||||||
|
assert!(source.contains("emit(root, \"hemx:ws-error\", url)"));
|
||||||
|
assert!(source.contains("if (socket) socket.close()"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_page_swaps_lowered_slot_ids() {
|
fn runtime_page_swaps_lowered_slot_ids() {
|
||||||
// req: wire/001
|
// req: wire/001
|
||||||
|
|||||||
+2
-2
@@ -8,8 +8,8 @@ use hemx_core::SafeHtml;
|
|||||||
use hemx_core::{KeyedSlot, Slot};
|
use hemx_core::{KeyedSlot, Slot};
|
||||||
|
|
||||||
pub use hemx_core::{
|
pub use hemx_core::{
|
||||||
navigate, push, redirect, replace, CssClass, CssClasses, Effect, Form, FormContract, FormControlKind,
|
navigate, push, redirect, replace, CssClass, CssClasses, Effect, Form, FormContract,
|
||||||
FormError, FormField, FormModel, FormValue, FromForm, IntoEffect,
|
FormControlKind, FormError, FormField, FormModel, FormValue, FromForm, IntoEffect,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Generated metadata tokens used by macros, generated code, integrations, and tests.
|
/// Generated metadata tokens used by macros, generated code, integrations, and tests.
|
||||||
|
|||||||
Reference in New Issue
Block a user