bf7560b7a8
Replace fixed /hemx.js wording with helper-provided runtime asset language so docs match hemx-axum runtime_js_path deployment boundaries. req: axum_integration/005 req: examples/001
231 lines
8.4 KiB
Markdown
231 lines
8.4 KiB
Markdown
# 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
|