feat(examples): add saas tutorial skeleton

Add a compile-tested v1 tutorial skeleton that proves the production-shaped app boundary without provider-heavy scope: auth/session context, CSRF-checked mutation, local persistence adapter, generated form/slot/keyed row/page/push effects, plain CSS, SSE shape, and one explicit metrics island.

req: examples/001

req: auth/001

req: auth/002

req: auth/004

req: form/001

req: failure/004

req: page_swap/002

req: push/003
This commit is contained in:
slhx agent
2026-06-05 09:15:57 +02:00
parent 3dfb3b684f
commit 38cfb263aa
15 changed files with 716 additions and 1 deletions
+82
View File
@@ -0,0 +1,82 @@
use axum::body::Body;
use axum::extract::{Query, State};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::Router;
use futures_util::stream;
use hemx::IntoEffect;
use hemx_axum::{runtime_js, sse, EffectResponse, InteractionRequest};
use hemx_saas_example::{home_page, live_status, registry, ui, AppContext};
use std::collections::BTreeMap;
use std::convert::Infallible;
#[tokio::main]
async fn main() {
let app = app(AppContext::demo());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3003")
.await
.expect("bind saas tutorial example");
axum::serve(listener, app)
.await
.expect("serve saas tutorial example");
}
fn app(ctx: AppContext) -> Router {
Router::new()
.route("/", get(home).post(interact))
.route("/settings", get(settings))
.route("/events", get(events))
.route("/hemx.js", get(runtime))
.route("/app.css", get(css))
.route("/metrics.js", get(metrics_js))
.with_state(ctx)
}
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(home_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 {
let count = ctx.projects().len();
if params.contains_key("once") {
return sse(stream::iter([Ok::<_, Infallible>(
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
)]));
}
sse(stream::iter([Ok::<_, Infallible>(
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
)]))
}
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")
}