diff --git a/Cargo.lock b/Cargo.lock index 545c9ad..15224cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -489,6 +489,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "hemx-saas-example" +version = "0.1.0" +dependencies = [ + "axum", + "futures-util", + "hemplate", + "hemx", + "hemx-axum", + "hemx-build", + "hemx-test", + "scraper", + "tokio", +] + [[package]] name = "hemx-techdemo" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 52f2bd3..8139a8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["hemx", "hemx-core", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo"] +members = ["hemx", "hemx-core", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo", "examples/saas"] [workspace.package] version = "0.1.0" diff --git a/README.md b/README.md index aeb75d7..a71e96d 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,9 @@ wire/runtime ABI; and advanced escape hatches that may remain integration-level. - `examples/v0`: canonical beginner path covering counter, typed todo CRUD, form wizard, auth action, page swaps, SSE notifications, and keyed list updates. Start here. +- `examples/saas`: compile-tested v1 tutorial skeleton covering auth/session, + CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain + CSS, and one explicit island without provider-heavy platform scope. - `examples/kanban`: advanced / north-star milestone boundary sketch. It may expose manual registry or render escape hatches while exploring product limits. - `examples/techdemo`: advanced integration demo with a leaf island and broader diff --git a/examples/saas/Cargo.toml b/examples/saas/Cargo.toml new file mode 100644 index 0000000..a4bb58b --- /dev/null +++ b/examples/saas/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "hemx-saas-example" +version.workspace = true +edition.workspace = true +publish = false + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "hemx-saas-example" +path = "src/main.rs" + +[dependencies] +axum = "0.7" +futures-util = "0.3" +hemplate = { path = "../../../hemplate/hemplate" } +hemx = { path = "../../hemx" } +hemx-axum = { path = "../../hemx-axum" } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } + +[dev-dependencies] +scraper = "0.23" +hemx-test = { path = "../../hemx-test" } + +[build-dependencies] +hemx-build = { path = "../../hemx-build" } diff --git a/examples/saas/README.md b/examples/saas/README.md new file mode 100644 index 0000000..478d6ea --- /dev/null +++ b/examples/saas/README.md @@ -0,0 +1,27 @@ +# hemx SaaS tutorial skeleton + +This is the compile-tested skeleton for the v1 production-shaped tutorial app. It is intentionally provider-light: auth/session, CSRF, persistence, deploy, metrics, and islands are explicit app boundaries, not hemx core services. req: examples/001 req: auth/001 + +What it proves today: + +- typed form/newtype inputs for project creation +- auth/session context passed through normal Rust state +- CSRF-safe mutation checked before persistence +- local in-memory persistence adapter instead of a vendored SQL/auth provider +- generated form, slot, keyed row, page-swap, and live-status commands +- page shell with plain CSS and one explicit metrics island script +- compile-time surface generation plus interaction tests + +What it deliberately does not claim yet: + +- real SQLx migrations or a database pool +- production cookie/session middleware +- a deploy target, flags, analytics, billing, or offline sync +- browser automation for the metrics island + +Run: + +```sh +cargo run -p hemx-saas-example +cargo test -p hemx-saas-example +``` diff --git a/examples/saas/build.rs b/examples/saas/build.rs new file mode 100644 index 0000000..99fa6f3 --- /dev/null +++ b/examples/saas/build.rs @@ -0,0 +1,3 @@ +fn main() { + hemx_build::app().run().unwrap(); +} diff --git a/examples/saas/src/lib.rs b/examples/saas/src/lib.rs new file mode 100644 index 0000000..848b706 --- /dev/null +++ b/examples/saas/src/lib.rs @@ -0,0 +1,463 @@ +#[hemx::surface] +pub mod ui {} + +use hemplate::Hemplate; +use hemx::{push, Html, IntoEffect}; +use hemx_axum::{ + interactions, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure, Registry, State, +}; +use std::convert::Infallible; +use std::fmt::{Display, Write as _}; +use std::str::FromStr; +use std::sync::{Arc, Mutex}; + +use ui::dashboard; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Session { + user_id: UserId, + email: String, + csrf: CsrfToken, +} + +impl Session { + pub fn demo() -> Self { + Self { + user_id: UserId(42), + email: "founder@example.com".to_owned(), + csrf: CsrfToken("demo-csrf".to_owned()), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UserId(u64); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CsrfToken(String); + +impl FromStr for CsrfToken { + type Err = Infallible; + + fn from_str(value: &str) -> Result { + Ok(Self(value.to_owned())) + } +} + +impl Display for CsrfToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectName(String); + +impl ProjectName { + fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for ProjectName { + type Err = Infallible; + + fn from_str(value: &str) -> Result { + Ok(Self(value.trim().to_owned())) + } +} + +#[derive(Clone, Debug)] +#[hemx::form("new_project")] +pub struct NewProject { + csrf: CsrfToken, + name: ProjectName, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProjectId(u64); + +impl Display for ProjectId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectRecord { + id: ProjectId, + name: String, + owner: String, +} + +#[derive(Clone, Default)] +pub struct LocalProjectStore { + projects: Arc>>, +} + +impl LocalProjectStore { + pub fn insert(&self, name: ProjectName, session: &Session) -> Result { + if name.as_str() == "fail-store" { + return Err(AppError::StoreUnavailable); + } + + let mut projects = self.projects.lock().unwrap(); + let id = ProjectId(projects.last().map_or(1, |project| project.id.0 + 1)); + let record = ProjectRecord { + id, + name: name.as_str().to_owned(), + owner: session.email.clone(), + }; + projects.push(record.clone()); + Ok(record) + } + + pub fn list(&self) -> Vec { + self.projects.lock().unwrap().clone() + } +} + +#[derive(Clone)] +pub struct AppContext { + session: Session, + store: LocalProjectStore, +} + +impl AppContext { + pub fn demo() -> Self { + Self { + session: Session::demo(), + store: LocalProjectStore::default(), + } + } + + pub fn csrf(&self) -> &CsrfToken { + &self.session.csrf + } + + pub fn projects(&self) -> Vec { + self.store.list() + } +} + +#[derive(Debug)] +pub enum AppError { + MissingSession, + CsrfRejected, + StoreUnavailable, + Validation(&'static str), +} + +impl AppError { + fn message(&self) -> &'static str { + match self { + Self::MissingSession => "Sign in to continue", + Self::CsrfRejected => "Refresh the page before creating another project", + Self::StoreUnavailable => "Project storage is temporarily unavailable", + Self::Validation(message) => message, + } + } +} + +impl IntoHandlerFailure for AppError { + fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { + match self { + Self::Validation(message) => HandlerFailure::effects( + ( + dashboard::new_project.error("name", message), + dashboard::new_project.focus("name"), + ), + context, + ), + other => HandlerFailure::effects(dashboard::flash.set(other.message()), context), + } + } +} + +pub struct Dashboard { + csrf: CsrfToken, + flash: String, + summary: String, + rows: Vec, + project_count: usize, +} + +impl Dashboard { + pub fn from_context(ctx: &AppContext) -> Self { + let projects = ctx.projects(); + Self { + csrf: ctx.csrf().clone(), + flash: "Signed in with a demo session".to_owned(), + summary: project_summary(projects.len()), + project_count: projects.len(), + rows: projects.into_iter().map(ProjectRow::from).collect(), + } + } +} + +#[derive(Hemplate)] +#[hemplate = "partials"] +pub struct ProjectRow { + id: ProjectId, + name: String, + owner: String, +} + +impl Hemplate for Dashboard { + fn render_into(&self, buf: &mut String) -> Result<(), hemplate::error::HemplateError> { + buf.push_str("
\n"); + buf.push_str("

Production-shaped SaaS path

Projects

Auth-gated mutations, CSRF checks, local persistence, typed forms, generated swaps, page swaps, live status, plain CSS, and one explicit island.

\n"); + buf.push_str(" \n"); + buf.push_str("
\n"); + buf.push_str("
\n"); + write!( + buf, + " \n", + hemplate::HtmlEscape(&self.csrf.to_string()) + )?; + buf.push_str("

\n
\n"); + write!( + buf, + "

{}

\n", + hemplate::HtmlEscape(&self.flash) + )?; + write!( + buf, + "

{}

\n", + hemplate::HtmlEscape(&self.summary) + )?; + buf.push_str("
    \n"); + for row in &self.rows { + buf.push_str(" "); + row.render_into(buf)?; + buf.push('\n'); + } + buf.push_str("
\n
\n

Waiting for status…

"); + write!(buf, "

Metrics island

Waiting for island script…

", self.project_count)?; + buf.push_str("
\n
\n"); + Ok(()) + } +} + +impl From for ProjectRow { + fn from(record: ProjectRecord) -> Self { + Self { + id: record.id, + name: record.name, + owner: record.owner, + } + } +} + +impl hemx::KeyedPartial for ProjectRow { + fn hemx_key(&self) -> String { + self.id.to_string() + } +} + +#[derive(Hemplate)] +#[hemplate = "partials"] +pub struct SettingsPage { + message: &'static str, +} + +#[derive(Hemplate)] +pub struct AppShell { + title: &'static str, + body: Html, +} + +pub fn home_page(ctx: &AppContext) -> Html { + hemx::page(&AppShell { + title: "hemx SaaS tutorial", + body: hemx::page(&Dashboard::from_context(ctx)), + }) +} + +#[hemx::app(dashboard_handlers)] +pub fn registry(ctx: AppContext) -> Registry { + interactions(ui::BUILD_FINGERPRINT) +} + +#[hemx::component("dashboard")] +mod dashboard_handlers { + use super::*; + + #[hemx::handler] + pub async fn create_project( + State(ctx): State, + Form(form): Form, + ) -> Result { + if ctx.session.email.is_empty() { + return Err(AppError::MissingSession); + } + 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")), + )) + } + + #[hemx::handler] + pub async fn open_settings(State(_ctx): State) -> impl IntoEffect { + ( + dashboard::page_panel.put(&SettingsPage { + message: + "Auth, CSRF, persistence, metrics, and deploy stay explicit app integrations.", + }), + dashboard::nav.set("Settings"), + push("/settings"), + ) + } +} + +pub fn live_status(projects: usize) -> impl IntoEffect { + dashboard::live_status.set(format!("heartbeat: {projects} projects")) +} + +fn project_summary(total: usize) -> String { + match total { + 0 => "No projects yet".to_owned(), + 1 => "1 project".to_owned(), + total => format!("{total} projects"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hemx_axum::{InteractionForm, InteractionRequest}; + use hemx_test::{inspect, inspect_batch}; + use scraper::{Html as ParsedHtml, Selector}; + + fn form(handle: hemx::Handle, fields: &[(&str, &str)]) -> InteractionForm { + InteractionForm::for_handle( + handle, + fields + .iter() + .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())), + ) + } + + fn selector(value: &str) -> Selector { + Selector::parse(value).expect("test selector parses") + } + + #[test] + fn home_page_documents_the_production_skeleton_boundaries() { + // req: examples/001 req: auth/001 req: auth/004 req: interop/003 + let ctx = AppContext::demo(); + let html = home_page(&ctx); + let document = ParsedHtml::parse_document(html.as_str()); + + assert_eq!( + document + .select(&selector("[data-hemx-root='dashboard']")) + .count(), + 1 + ); + assert_eq!( + document + .select(&selector("form[data-hemx-form='new_project']")) + .count(), + 1 + ); + assert_eq!(document.select(&selector("input[name='csrf']")).count(), 1); + assert_eq!( + document + .select(&selector("[data-hemx-sse='/events']")) + .count(), + 1 + ); + assert_eq!( + document + .select(&selector("[data-hemx-island='metrics']")) + .count(), + 1 + ); + assert!(html.as_str().contains("/app.css")); + assert!(html.as_str().contains("/metrics.js")); + } + + #[tokio::test] + async fn create_project_is_auth_csrf_checked_and_persisted_locally() { + // req: examples/001 req: auth/002 req: auth/004 req: form/001 req: failure/004 + let ctx = AppContext::demo(); + + let rejected = inspect_batch( + InteractionRequest::from(form( + dashboard::create_project, + &[("csrf", "stale"), ("name", "Launch checklist")], + )) + .dispatch_async(registry(ctx.clone())) + .await + .unwrap() + .batch, + ); + assert!(ctx.projects().is_empty()); + assert!(rejected.updates_text(dashboard::flash)); + assert!(rejected.payload_contains("Refresh the page")); + + let validation = inspect_batch( + InteractionRequest::from(form( + dashboard::create_project, + &[("csrf", "demo-csrf"), ("name", " ")], + )) + .dispatch_async(registry(ctx.clone())) + .await + .unwrap() + .batch, + ); + assert!(ctx.projects().is_empty()); + assert!(validation.payload_contains("Project name required")); + + let created = inspect_batch( + InteractionRequest::from(form( + dashboard::create_project, + &[("csrf", "demo-csrf"), ("name", "Launch checklist")], + )) + .dispatch_async(registry(ctx.clone())) + .await + .unwrap() + .batch, + ); + assert_eq!(ctx.projects()[0].name, "Launch checklist"); + assert!(created.inserts_html_containing(dashboard::project_row, "1", "Launch checklist")); + assert!(created.updates_text(dashboard::summary)); + assert!(created.resets_form(dashboard::new_project)); + assert!(created.updates_text(dashboard::live_status)); + } + + #[tokio::test] + async fn page_swap_and_push_shape_use_generated_targets() { + // req: page_swap/002 req: push/003 req: examples/001 + let ctx = AppContext::demo(); + let settings = inspect_batch( + InteractionRequest::from(form(dashboard::open_settings, &[])) + .dispatch_async(registry(ctx.clone())) + .await + .unwrap() + .batch, + ); + assert!( + settings.updates_html_containing(dashboard::page_panel, "explicit app integrations") + ); + assert!(settings.updates_text(dashboard::nav)); + assert!(settings.pushes_to("/settings")); + + let heartbeat = inspect(live_status(ctx.projects().len())); + assert!(heartbeat.updates_text(dashboard::live_status)); + assert!(heartbeat.payload_contains("heartbeat")); + } +} diff --git a/examples/saas/src/main.rs b/examples/saas/src/main.rs new file mode 100644 index 0000000..e964f1b --- /dev/null +++ b/examples/saas/src/main.rs @@ -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) -> impl IntoResponse { + axum::response::Html(home_page(&ctx).into_string()) +} + +async fn settings(State(ctx): State) -> impl IntoResponse { + axum::response::Html(home_page(&ctx).into_string()) +} + +async fn interact( + State(ctx): State, + request: InteractionRequest, +) -> Result { + request.dispatch_async(registry(ctx)).await +} + +async fn events( + Query(params): Query>, + State(ctx): State, +) -> 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") +} diff --git a/examples/saas/templates/app.css b/examples/saas/templates/app.css new file mode 100644 index 0000000..23bcf96 --- /dev/null +++ b/examples/saas/templates/app.css @@ -0,0 +1,16 @@ +:root { color-scheme: light; font-family: Inter, system-ui, sans-serif; } +body { margin: 0; background: #f7f4ee; color: #201b16; } +.dashboard { max-width: 960px; margin: 0 auto; padding: 2rem; } +.hero, .panel, .status-row { background: white; border: 1px solid #e6ded2; border-radius: 18px; padding: 1.25rem; box-shadow: 0 12px 40px rgba(34, 24, 8, 0.08); } +.eyebrow { color: #8a5a00; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; } +.lede { max-width: 56rem; color: #5d5147; } +.tabs, .project-form, .status-row { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; } +.tabs { margin: 1rem 0; } +button, input { font: inherit; } +button { border: 0; border-radius: 999px; background: #1f5eff; color: white; padding: .65rem 1rem; } +input { border: 1px solid #cfc4b8; border-radius: 10px; padding: .55rem .7rem; } +.field-error, .flash { color: #a02b12; font-weight: 700; } +.summary { color: #516034; } +.project-list { display: grid; gap: .7rem; padding: 0; list-style: none; } +.project-row { display: flex; justify-content: space-between; border: 1px solid #eee0cb; border-radius: 12px; padding: .75rem; } +.metrics-island { min-width: 18rem; border-left: 4px solid #1f5eff; padding-left: 1rem; } diff --git a/examples/saas/templates/app_shell.heml b/examples/saas/templates/app_shell.heml new file mode 100644 index 0000000..55796ab --- /dev/null +++ b/examples/saas/templates/app_shell.heml @@ -0,0 +1,14 @@ + + + + + + {+ self.title +} + + + + + + {+= self.body =+} + + diff --git a/examples/saas/templates/dashboard.heml b/examples/saas/templates/dashboard.heml new file mode 100644 index 0000000..8679cd8 --- /dev/null +++ b/examples/saas/templates/dashboard.heml @@ -0,0 +1,41 @@ +
+
+

Production-shaped SaaS path

+

Projects

+

Auth-gated mutations, CSRF checks, local persistence, typed forms, generated swaps, page swaps, live status, plain CSS, and one explicit island.

+
+ + + +
+
+ + + +

+
+ +

{+ self.flash +}

+

{+ self.summary +}

+ +
    + +
+
+ +
+

Waiting for status…

+
+

Metrics island

+ +

Waiting for island script…

+
+
+
diff --git a/examples/saas/templates/metrics.js b/examples/saas/templates/metrics.js new file mode 100644 index 0000000..5ca3c44 --- /dev/null +++ b/examples/saas/templates/metrics.js @@ -0,0 +1,14 @@ +(() => { + function render(island) { + const count = island.getAttribute("data-project-count") || "0"; + const readout = island.querySelector("[data-island-readout]"); + if (readout) readout.textContent = `${count} persisted project${count === "1" ? "" : "s"}`; + } + + function boot() { + for (const island of document.querySelectorAll('[data-hemx-island="metrics"]')) render(island); + } + + document.addEventListener("DOMContentLoaded", boot); + document.addEventListener("hemx:after-settle", boot); +})(); diff --git a/examples/saas/templates/partials/project_row.heml b/examples/saas/templates/partials/project_row.heml new file mode 100644 index 0000000..9f700f5 --- /dev/null +++ b/examples/saas/templates/partials/project_row.heml @@ -0,0 +1,4 @@ +
  • + {+ self.name +} + {+ self.owner +} +
  • diff --git a/examples/saas/templates/partials/settings_page.heml b/examples/saas/templates/partials/settings_page.heml new file mode 100644 index 0000000..e8e5c16 --- /dev/null +++ b/examples/saas/templates/partials/settings_page.heml @@ -0,0 +1,4 @@ +
    +

    Settings

    +

    {+ self.message +}

    +
    diff --git a/hemx-test/tests/examples_contract.rs b/hemx-test/tests/examples_contract.rs index 5a6aeda..b136876 100644 --- a/hemx-test/tests/examples_contract.rs +++ b/hemx-test/tests/examples_contract.rs @@ -301,6 +301,8 @@ fn allowed_example_script(path: &Path, line: &str) -> bool { line.contains(r#""#) || (path.ends_with("examples/techdemo/templates/app_shell.heml") && line.contains(r#""#)) + || (path.ends_with("examples/saas/templates/app_shell.heml") + && line.contains(r#""#)) } fn contains_inline_event_handler(line: &str) -> bool {