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
8.4 KiB
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:
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
Dashboardtemplate with a project creation form - typed domain inputs:
CsrfToken,ProjectName, andProjectId - an app-owned
LocalProjectStorepersistence 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 contractexamples/saas/src/lib.rs— domain types, app context, handlers, and testsexamples/saas/src/main.rs— Axum route wiring and runtime/static assetsexamples/saas/templates/app.css— plain CSSexamples/saas/templates/metrics.js— explicit leaf-island JavaScriptexamples/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:
<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:
#[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:
#[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:
#[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:
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:
(
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:
- Replace
LocalProjectStorewith a SQLx adapter fromdocs/recipes/sqlx-persistence.md. - Replace the demo
Sessionwith an Axum/Tower extractor and CSRF service fromdocs/recipes/auth-session-csrf.md. - Wrap routes/handlers with app-owned metrics, flags, and killswitches from
docs/recipes/observability-flags.md. - Add optional PWA/offline behavior only through the adapter boundary in
docs/recipes/pwa-offline.md. - Deploy server, generated output, and the helper-provided runtime asset as one
release unit following
docs/recipes/deploy-versioning.md. - Follow
docs/versioning.mdfor semver and upgrade notes. - Use
docs/diagnostics.mdwhen 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