docs(03): research component system domain
Phase 3: Component System - Standard stack: zio-http-htmx 3.3.0, Pebble 4.1.1, htmx4s - Architecture patterns: YAML properties, lifecycle hooks, HTMX handlers - WinterCMS component patterns adapted to ZIO - Pitfalls: CSRF, XSS, state isolation, SSE connections
This commit is contained in:
822
.planning/phases/03-component-system/03-RESEARCH.md
Normal file
822
.planning/phases/03-component-system/03-RESEARCH.md
Normal file
@@ -0,0 +1,822 @@
|
||||
# Phase 3: Component System - Research
|
||||
|
||||
**Researched:** 2026-02-05
|
||||
**Domain:** Reusable UI components with YAML-defined properties, lifecycle hooks, HTMX handlers, and Twig-compatible templating
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
This phase implements the component system that enables reusable UI components responding to HTMX interactions. Components are defined with YAML property schemas (similar to WinterCMS Inspector configuration), registered per-plugin, and render via Twig-compatible templates. HTMX handlers (methods prefixed with `on`) respond to client-side triggers and return HTML fragments for swapping.
|
||||
|
||||
The recommended stack uses: ZIO HTTP 3.3.0+ with the official `zio-http-htmx` module for HTMX response headers, **Pebble 4.1.1** for Twig/Jinja2-compatible templating (chosen over ScalaTags per CONTEXT.md decision for Twig-compatible templates), circe-yaml for YAML property schema parsing (reusing Phase 2 infrastructure), and ZIO for component lifecycle management. The htmx4s library provides type-safe HTMX constants and ScalaTags integration for any programmatic HTML needs.
|
||||
|
||||
**Primary recommendation:** Define components as Scala traits with YAML property schemas parsed at plugin registration time. Use Pebble template engine for Twig-compatible rendering with `default.htm` convention. Implement HTMX handlers as `on*` methods returning `ZIO[ComponentEnv, ComponentError, Html]`. Integrate CSRF protection via request middleware injecting tokens, with validation on all HTMX POST requests.
|
||||
|
||||
## Standard Stack
|
||||
|
||||
The established libraries/tools for this domain:
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| zio-http-htmx | 3.3.0 | HTMX response headers | Official ZIO HTTP module for HTMX integration |
|
||||
| Pebble | 4.1.1 | Twig-compatible templates | Java templating with Jinja2/Twig syntax, i18n, autoescaping |
|
||||
| circe-yaml | 0.16.1 | YAML property parsing | Already in stack from Phase 2, proven for YAML parsing |
|
||||
| htmx4s-constants | latest | HTMX vocabulary | Type-safe HTMX attribute/header constants |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| htmx4s-scalatags | latest | ScalaTags HTMX attrs | When generating HTML programmatically (not templates) |
|
||||
| ScalaTags | 0.13.x | Programmatic HTML | Component helper methods, fragment generation |
|
||||
| zio-http | 3.3.0+ | HTTP server + SSE | Base HTTP server, Server-Sent Events streaming |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Pebble | ScalaTags only | ScalaTags is type-safe but not Twig-compatible; Pebble enables WinterCMS migration |
|
||||
| Pebble | Twirl | Twirl has Scala syntax, not Jinja2/Twig-compatible |
|
||||
| Pebble | Jinjava | Jinjava is also Jinja2-compatible; Pebble has better Twig inheritance |
|
||||
| htmx4s | Hand-roll constants | htmx4s provides IDE docs from htmx markdown, type safety |
|
||||
|
||||
**Installation (Mill build.mill):**
|
||||
```scala
|
||||
def mvnDeps = Seq(
|
||||
// Existing deps from Phase 1/2...
|
||||
|
||||
// HTMX integration
|
||||
mvn"dev.zio::zio-http-htmx:3.3.0",
|
||||
|
||||
// Twig-compatible templating
|
||||
mvn"io.pebbletemplates:pebble:4.1.1",
|
||||
|
||||
// HTMX constants (optional, for type-safe attributes)
|
||||
mvn"com.github.eikek::htmx4s-constants:latest",
|
||||
|
||||
// ScalaTags for programmatic HTML fragments
|
||||
mvn"com.lihaoyi::scalatags:0.13.1"
|
||||
)
|
||||
```
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Component Directory Structure
|
||||
```
|
||||
plugins/
|
||||
├── golem15/
|
||||
│ └── blog/
|
||||
│ ├── plugin.yaml
|
||||
│ ├── Plugin.scala # registerComponents method
|
||||
│ └── components/
|
||||
│ ├── Posts.scala # Component class
|
||||
│ └── posts/
|
||||
│ ├── component.yaml # Property schema
|
||||
│ ├── default.htm # Main template (required)
|
||||
│ ├── pagination.htm # Partial template
|
||||
│ └── item.htm # Item template
|
||||
```
|
||||
|
||||
### Pattern 1: Component Trait with Lifecycle Hooks
|
||||
**What:** Define components as Scala traits with `init`, `onRun`, and HTMX handler methods
|
||||
**When to use:** All components must implement this trait
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: WinterCMS ComponentBase pattern adapted to ZIO
|
||||
trait SummerComponent:
|
||||
/** Component identifier (alias used in pages) */
|
||||
def alias: String
|
||||
|
||||
/** Component metadata from YAML */
|
||||
def details: ComponentDetails
|
||||
|
||||
/** Property schema from component.yaml */
|
||||
def propertySchema: List[PropertyDef]
|
||||
|
||||
/** Current property values (set from page/layout) */
|
||||
def properties: Ref[Map[String, PropertyValue]]
|
||||
|
||||
/** Access to page context for rendering */
|
||||
def pageContext: PageContext
|
||||
|
||||
/** Called once when component is initialized (before HTMX handlers) */
|
||||
def init: ZIO[ComponentEnv, ComponentError, Unit] = ZIO.unit
|
||||
|
||||
/** Called when component is bound to page/layout, part of page lifecycle */
|
||||
def onRun: ZIO[ComponentEnv, ComponentError, Unit] = ZIO.unit
|
||||
|
||||
/** Render the default template with current context */
|
||||
def render: ZIO[ComponentEnv, ComponentError, Html]
|
||||
|
||||
/** Render a specific partial template */
|
||||
def renderPartial(name: String): ZIO[ComponentEnv, ComponentError, Html]
|
||||
|
||||
case class ComponentDetails(
|
||||
name: String,
|
||||
description: String,
|
||||
icon: Option[String] = None
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern 2: YAML Property Schema (WinterCMS Inspector Compatible)
|
||||
**What:** Define component properties in YAML with explicit types and validation
|
||||
**When to use:** Every component with configurable properties
|
||||
**Example:**
|
||||
```yaml
|
||||
# plugins/golem15/blog/components/posts/component.yaml
|
||||
# Source: WinterCMS Inspector configuration format
|
||||
name: Blog Posts
|
||||
description: Displays a list of blog posts with pagination
|
||||
icon: pencil
|
||||
|
||||
properties:
|
||||
postsPerPage:
|
||||
type: string
|
||||
title: Posts per page
|
||||
description: Number of posts to display per page
|
||||
default: "10"
|
||||
validation:
|
||||
integer:
|
||||
message: Posts per page must be a positive integer
|
||||
min:
|
||||
value: 1
|
||||
message: Must show at least 1 post
|
||||
max:
|
||||
value: 100
|
||||
message: Maximum 100 posts per page
|
||||
|
||||
categoryFilter:
|
||||
type: dropdown
|
||||
title: Category Filter
|
||||
description: Filter posts by category
|
||||
default: ""
|
||||
showExternalParam: true # Allow {{ :category }} binding
|
||||
# Options loaded dynamically from getCategoryFilterOptions()
|
||||
|
||||
sortOrder:
|
||||
type: dropdown
|
||||
title: Sort Order
|
||||
options:
|
||||
published_at desc: Published date (newest first)
|
||||
published_at asc: Published date (oldest first)
|
||||
title asc: Title (A-Z)
|
||||
default: published_at desc
|
||||
|
||||
noPostsMessage:
|
||||
type: string
|
||||
title: No posts message
|
||||
default: No posts found.
|
||||
group: Messages
|
||||
|
||||
throwNotFound:
|
||||
type: checkbox
|
||||
title: Throw 404 on empty
|
||||
description: Show 404 page when no posts match filter
|
||||
default: false
|
||||
group: Advanced
|
||||
```
|
||||
|
||||
```scala
|
||||
// Source: circe-yaml parsing + WinterCMS property types
|
||||
import io.circe.generic.auto.*
|
||||
import io.circe.yaml.parser
|
||||
|
||||
case class ComponentSchema(
|
||||
name: String,
|
||||
description: String,
|
||||
icon: Option[String],
|
||||
properties: Map[String, PropertyDef]
|
||||
)
|
||||
|
||||
case class PropertyDef(
|
||||
`type`: PropertyType,
|
||||
title: String,
|
||||
description: Option[String] = None,
|
||||
default: Option[PropertyValue] = None,
|
||||
options: Option[Map[String, String]] = None, // For dropdown/set
|
||||
group: Option[String] = None,
|
||||
showExternalParam: Boolean = false,
|
||||
validation: Option[ValidationRules] = None
|
||||
)
|
||||
|
||||
enum PropertyType:
|
||||
case String, Text, Checkbox, Dropdown, Set, Dictionary
|
||||
case Object, ObjectList, StringList, Autocomplete
|
||||
|
||||
sealed trait PropertyValue
|
||||
object PropertyValue:
|
||||
case class StringVal(value: String) extends PropertyValue
|
||||
case class BoolVal(value: Boolean) extends PropertyValue
|
||||
case class IntVal(value: Int) extends PropertyValue
|
||||
case class ListVal(values: List[PropertyValue]) extends PropertyValue
|
||||
case class ObjectVal(fields: Map[String, PropertyValue]) extends PropertyValue
|
||||
```
|
||||
|
||||
### Pattern 3: HTMX Handler Convention (on* Methods)
|
||||
**What:** Methods starting with `on` become HTMX handlers, routed via component alias
|
||||
**When to use:** All interactive component behaviors
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: WinterCMS AJAX handler pattern + CONTEXT.md decisions
|
||||
trait BlogPostsComponent extends SummerComponent:
|
||||
|
||||
// HTMX handler: POST /summer/component/blogPosts/onLoadMore
|
||||
def onLoadMore: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
|
||||
for
|
||||
page <- getFormValue("page").map(_.toIntOption.getOrElse(1))
|
||||
posts <- loadPosts(page)
|
||||
html <- renderPartial("items", Map("posts" -> posts))
|
||||
hasMore <- ZIO.succeed(posts.nonEmpty)
|
||||
yield HtmxResponse(
|
||||
html = html,
|
||||
triggers = if hasMore then Map.empty else Map("postsExhausted" -> "true"),
|
||||
retarget = None // Use original hx-target
|
||||
)
|
||||
|
||||
// HTMX handler: POST /summer/component/blogPosts/onFilter
|
||||
def onFilter: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
|
||||
for
|
||||
category <- getFormValue("category")
|
||||
posts <- loadPosts(1, category)
|
||||
html <- renderPartial("default")
|
||||
yield HtmxResponse(html)
|
||||
|
||||
case class HtmxResponse(
|
||||
html: Html,
|
||||
triggers: Map[String, String] = Map.empty, // HX-Trigger header
|
||||
retarget: Option[String] = None, // HX-Retarget header
|
||||
reswap: Option[String] = None, // HX-Reswap header
|
||||
oobSwaps: List[OobSwap] = List.empty // Out-of-band swaps
|
||||
)
|
||||
|
||||
case class OobSwap(
|
||||
targetId: String,
|
||||
html: Html,
|
||||
swapStrategy: String = "outerHTML"
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern 4: Component Registration in Plugin
|
||||
**What:** Plugins register components via `registerComponents` method returning alias -> class mapping
|
||||
**When to use:** Plugin boot phase, collected by ComponentManager
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: WinterCMS Plugin.registerComponents pattern
|
||||
class BlogPlugin extends SummerPlugin:
|
||||
override def id = PluginId("golem15", "blog")
|
||||
|
||||
override def registerComponents: Map[String, ComponentFactory] =
|
||||
Map(
|
||||
"blogPost" -> ComponentFactory[BlogPostComponent],
|
||||
"blogPosts" -> ComponentFactory[BlogPostsComponent],
|
||||
"blogCategories" -> ComponentFactory[BlogCategoriesComponent],
|
||||
"blogRssFeed" -> ComponentFactory[BlogRssFeedComponent]
|
||||
)
|
||||
|
||||
trait ComponentFactory[C <: SummerComponent]:
|
||||
def create(alias: String, properties: Map[String, PropertyValue], ctx: PageContext): ZIO[Any, Nothing, C]
|
||||
|
||||
// ComponentManager collects from all plugins
|
||||
trait ComponentManager:
|
||||
def listComponents: UIO[Map[String, ComponentFactory[?]]]
|
||||
def resolve(aliasOrClass: String): UIO[Option[ComponentFactory[?]]]
|
||||
def make[C <: SummerComponent](
|
||||
alias: String,
|
||||
properties: Map[String, PropertyValue],
|
||||
ctx: PageContext
|
||||
): IO[ComponentError, C]
|
||||
```
|
||||
|
||||
### Pattern 5: Pebble Template Integration
|
||||
**What:** Use Pebble engine for Twig-compatible templates with component context
|
||||
**When to use:** All component template rendering
|
||||
**Example:**
|
||||
```java
|
||||
// Source: Pebble Templates documentation
|
||||
// Pebble template syntax (Twig-compatible)
|
||||
// plugins/golem15/blog/components/posts/default.htm
|
||||
{% extends "layout" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="blog-posts" id="posts-{{ __SELF__ }}">
|
||||
{% if posts is empty %}
|
||||
<p class="no-posts">{{ noPostsMessage }}</p>
|
||||
{% else %}
|
||||
{% for post in posts %}
|
||||
{% include "posts/item" %}
|
||||
{% endfor %}
|
||||
|
||||
{% if hasMorePosts %}
|
||||
<button
|
||||
hx-post="{{ componentHandler('onLoadMore') }}"
|
||||
hx-target="#posts-{{ __SELF__ }}"
|
||||
hx-swap="beforeend"
|
||||
hx-vals='{"page": {{ currentPage + 1 }}}'
|
||||
class="btn btn-load-more">
|
||||
{{ 'Load More'|_ }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
```scala
|
||||
// Source: Pebble Java API wrapped for ZIO
|
||||
import io.pebbletemplates.pebble.PebbleEngine
|
||||
import io.pebbletemplates.pebble.loader.FileLoader
|
||||
|
||||
trait TemplateService:
|
||||
def render(template: String, context: Map[String, Any]): Task[Html]
|
||||
def renderString(templateContent: String, context: Map[String, Any]): Task[Html]
|
||||
|
||||
object TemplateService:
|
||||
val live: ZLayer[PluginManager, Nothing, TemplateService] =
|
||||
ZLayer.fromFunction { (plugins: PluginManager) =>
|
||||
new TemplateService:
|
||||
private val engine = new PebbleEngine.Builder()
|
||||
.loader(new ComponentTemplateLoader(plugins))
|
||||
.autoEscaping(true)
|
||||
.defaultLocale(Locale.ENGLISH)
|
||||
.build()
|
||||
|
||||
def render(template: String, context: Map[String, Any]): Task[Html] =
|
||||
ZIO.attemptBlocking {
|
||||
val writer = new StringWriter()
|
||||
val ctx = context.asJava
|
||||
engine.getTemplate(template).evaluate(writer, ctx)
|
||||
Html(writer.toString)
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Twig functions/filters
|
||||
// {{ asset('img/logo.png') }} -> ThemeService.asset()
|
||||
// {{ componentHandler('onRefresh') }} -> component URL builder
|
||||
// {{ 'text'|_ }} -> Translation filter
|
||||
```
|
||||
|
||||
### Pattern 6: CSRF Protection for HTMX Requests
|
||||
**What:** Inject CSRF token via middleware, validate on all HTMX POST/PUT/DELETE
|
||||
**When to use:** All HTMX handlers (per CONTEXT.md: CSRF always required)
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: HTMX CSRF pattern + OWASP recommendations
|
||||
// Middleware injects CSRF token into response headers and validates requests
|
||||
val csrfMiddleware: HandlerAspect[Any, Unit] = HandlerAspect.interceptHandler { handler =>
|
||||
Handler.fromFunctionZIO[Request] { request =>
|
||||
for
|
||||
session <- SessionService.get
|
||||
token <- session.csrfToken
|
||||
_ <- ZIO.when(request.method.isUnsafe) {
|
||||
val headerToken = request.header("X-CSRF-Token").map(_.value)
|
||||
val formToken = request.bodyAsMultipartForm.flatMap(_.get("_token"))
|
||||
val requestToken = headerToken.orElse(formToken)
|
||||
ZIO.when(requestToken != Some(token)) {
|
||||
ZIO.fail(CsrfViolation("Invalid CSRF token"))
|
||||
}
|
||||
}
|
||||
response <- handler(request)
|
||||
yield response.addHeader("X-CSRF-Token", token)
|
||||
}
|
||||
}
|
||||
|
||||
// Template helper for forms
|
||||
// <input type="hidden" name="_token" value="{{ csrfToken() }}">
|
||||
|
||||
// HTMX global header injection (in base layout)
|
||||
// <body hx-headers='{"X-CSRF-Token": "{{ csrfToken() }}"}'>
|
||||
```
|
||||
|
||||
### Pattern 7: Server-Sent Events for Real-Time Updates
|
||||
**What:** Components can push updates to clients via SSE stream
|
||||
**When to use:** Live updates (chat, notifications, progress bars)
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: ZIO HTTP SSE documentation
|
||||
// Component declares SSE capability
|
||||
trait LiveNotifications extends SummerComponent:
|
||||
|
||||
def onSubscribe: ZIO[ComponentEnv, ComponentError, Response] =
|
||||
for
|
||||
userId <- getCurrentUserId
|
||||
stream <- NotificationService.subscribeUser(userId)
|
||||
sse <- ZIO.succeed(
|
||||
stream.map { notification =>
|
||||
ServerSentEvent(
|
||||
data = renderNotification(notification),
|
||||
event = Some("notification"),
|
||||
id = Some(notification.id.toString)
|
||||
)
|
||||
}
|
||||
)
|
||||
yield Response(
|
||||
status = Status.Ok,
|
||||
headers = Headers(
|
||||
Header.ContentType(MediaType.text.`event-stream`),
|
||||
Header.CacheControl.NoCache
|
||||
),
|
||||
body = Body.fromStreamSSE(sse)
|
||||
)
|
||||
|
||||
// Template usage
|
||||
// <div hx-ext="sse" sse-connect="{{ componentHandler('onSubscribe') }}">
|
||||
// <div sse-swap="notification" hx-swap="beforeend"></div>
|
||||
// </div>
|
||||
```
|
||||
|
||||
### Pattern 8: Out-of-Band Swaps for Multiple Updates
|
||||
**What:** Single HTMX response updates multiple DOM elements
|
||||
**When to use:** Handler needs to update content outside its target
|
||||
**Example:**
|
||||
```scala
|
||||
// Source: htmx hx-swap-oob documentation
|
||||
def onAddToCart: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
|
||||
for
|
||||
productId <- getFormValue("product_id")
|
||||
_ <- CartService.addItem(productId)
|
||||
cart <- CartService.getCart
|
||||
itemHtml <- renderPartial("cart-item", Map("item" -> cart.lastItem))
|
||||
countHtml <- renderFragment(s"""<span id="cart-count" hx-swap-oob="true">${cart.itemCount}</span>""")
|
||||
yield HtmxResponse(
|
||||
html = itemHtml,
|
||||
oobSwaps = List(
|
||||
OobSwap("cart-count", countHtml),
|
||||
OobSwap("cart-total", renderCartTotal(cart))
|
||||
)
|
||||
)
|
||||
|
||||
// Response includes primary HTML plus OOB fragments:
|
||||
// <div>... item HTML ...</div>
|
||||
// <span id="cart-count" hx-swap-oob="true">5</span>
|
||||
// <span id="cart-total" hx-swap-oob="true">$99.00</span>
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Blocking in handlers:** Always return `ZIO`, never block threads
|
||||
- **Direct database access in components:** Use services, not repositories directly
|
||||
- **Mutable component state:** Use `Ref` for any state, properties are immutable per request
|
||||
- **Missing CSRF on unsafe methods:** All POST/PUT/DELETE require validation
|
||||
- **String concatenation for HTML:** Use templates or ScalaTags, never raw strings
|
||||
- **Synchronous template rendering:** Wrap Pebble in `ZIO.attemptBlocking`
|
||||
- **Hard-coded component aliases:** Use `__SELF__` macro in templates for uniqueness
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
Problems that look simple but have existing solutions:
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Twig-compatible templates | Custom parser | Pebble | Complex inheritance, filters, autoescaping |
|
||||
| HTMX response headers | Manual string headers | zio-http-htmx | Type-safe headers, correct formatting |
|
||||
| HTMX attribute constants | String literals | htmx4s-constants | IDE docs, typo prevention, refactoring |
|
||||
| HTML escaping | Manual escaping | Pebble autoescaping | XSS vulnerabilities, edge cases |
|
||||
| CSRF token generation | Random strings | Secure random + HMAC | Timing attacks, entropy issues |
|
||||
| Property validation | Custom validators | Use WinterCMS patterns | Complex rules (regex, ranges, dependencies) |
|
||||
| Component discovery | Manual registration | Reflective scan or explicit registration | Miss components, ordering issues |
|
||||
|
||||
**Key insight:** Component systems appear simple but involve complex concerns: property validation, template inheritance, security (XSS, CSRF), lifecycle management, and state isolation. Use proven patterns from WinterCMS adapted to ZIO.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Template Escaping Disabled Accidentally
|
||||
**What goes wrong:** XSS vulnerabilities when rendering user content
|
||||
**Why it happens:** Using `|raw` filter or disabling autoescaping for convenience
|
||||
**How to avoid:** Keep autoescaping enabled, use `|raw` only for known-safe HTML fragments
|
||||
**Warning signs:** HTML entities appearing in output, or HTML rendering unexpectedly
|
||||
|
||||
### Pitfall 2: Component State Leaking Between Requests
|
||||
**What goes wrong:** User A sees User B's data
|
||||
**Why it happens:** Storing state in class variables instead of request-scoped Ref
|
||||
**How to avoid:** All component state via `Ref` created per-request, never `var`
|
||||
**Warning signs:** Inconsistent behavior under load, data appearing randomly
|
||||
|
||||
### Pitfall 3: HTMX Handler Not Found (404)
|
||||
**What goes wrong:** HTMX POST returns 404, component method exists
|
||||
**Why it happens:** Handler method doesn't start with `on`, or component not registered
|
||||
**How to avoid:** Naming convention enforcement, handler discovery at boot time
|
||||
**Warning signs:** Console shows POST to correct URL, but 404 returned
|
||||
|
||||
### Pitfall 4: CSRF Token Mismatch After Cache
|
||||
**What goes wrong:** Form submissions fail with CSRF error after page was cached
|
||||
**Why it happens:** CDN/browser cached page with old CSRF token
|
||||
**How to avoid:** Use `Vary: Cookie` header, or refresh token via HTMX header on each response
|
||||
**Warning signs:** Works in development, fails in production with caching
|
||||
|
||||
### Pitfall 5: SSE Connection Exhaustion
|
||||
**What goes wrong:** Server runs out of connections, new SSE subscriptions fail
|
||||
**Why it happens:** Clients not closing SSE connections, no timeout
|
||||
**How to avoid:** Set connection timeout, use heartbeat to detect dead connections
|
||||
**Warning signs:** Gradual slowdown, connection refused errors
|
||||
|
||||
### Pitfall 6: Template Path Traversal
|
||||
**What goes wrong:** Attacker reads arbitrary files via template include
|
||||
**Why it happens:** User input in template path without validation
|
||||
**How to avoid:** Whitelist allowed templates, never use user input directly in paths
|
||||
**Warning signs:** Error messages revealing file paths, unexpected template content
|
||||
|
||||
### Pitfall 7: Blocking Pebble Rendering on ZIO Runtime
|
||||
**What goes wrong:** Thread pool exhaustion, timeout errors under load
|
||||
**Why it happens:** Pebble is synchronous, blocking ZIO fibers
|
||||
**How to avoid:** Wrap Pebble calls in `ZIO.attemptBlocking`, use blocking thread pool
|
||||
**Warning signs:** High latency spikes, fiber count explosion
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from official sources:
|
||||
|
||||
### Complete Component Implementation
|
||||
```scala
|
||||
// Source: WinterCMS Account component pattern adapted to Scala/ZIO
|
||||
package golem15.blog.components
|
||||
|
||||
import zio.*
|
||||
import io.circe.yaml.parser
|
||||
import io.pebbletemplates.pebble.PebbleEngine
|
||||
|
||||
class BlogPostsComponent(
|
||||
val alias: String,
|
||||
val propertySchema: List[PropertyDef],
|
||||
val properties: Ref[Map[String, PropertyValue]],
|
||||
val pageContext: PageContext,
|
||||
templateService: TemplateService,
|
||||
postService: PostService
|
||||
) extends SummerComponent:
|
||||
|
||||
override val details = ComponentDetails(
|
||||
name = "Blog Posts",
|
||||
description = "Displays a list of blog posts",
|
||||
icon = Some("pencil")
|
||||
)
|
||||
|
||||
// Page variables set in onRun, available in template
|
||||
private var posts: List[Post] = List.empty
|
||||
private var category: Option[Category] = None
|
||||
private var pageParam: String = "page"
|
||||
|
||||
override def onRun: ZIO[ComponentEnv, ComponentError, Unit] =
|
||||
for
|
||||
props <- properties.get
|
||||
catSlug = props.get("categoryFilter").collect { case StringVal(s) => s }
|
||||
cat <- catSlug.fold(ZIO.none)(slug =>
|
||||
categoryService.findBySlug(slug).map(Some(_))
|
||||
)
|
||||
perPage = props.get("postsPerPage")
|
||||
.collect { case StringVal(s) => s.toIntOption }
|
||||
.flatten.getOrElse(10)
|
||||
page = pageContext.param("page").flatMap(_.toIntOption).getOrElse(1)
|
||||
result <- postService.listFrontend(
|
||||
page = page,
|
||||
perPage = perPage,
|
||||
category = cat.map(_.id),
|
||||
published = !pageContext.isBackendEditor
|
||||
)
|
||||
yield
|
||||
this.posts = result.items
|
||||
this.category = cat
|
||||
this.pageParam = paramName("pageNumber").getOrElse("page")
|
||||
|
||||
override def render: ZIO[ComponentEnv, ComponentError, Html] =
|
||||
for
|
||||
props <- properties.get
|
||||
noPostsMsg = props.get("noPostsMessage")
|
||||
.collect { case StringVal(s) => s }
|
||||
.getOrElse("No posts found.")
|
||||
html <- templateService.render(
|
||||
s"$alias/default",
|
||||
Map(
|
||||
"__SELF__" -> alias,
|
||||
"posts" -> posts.asJava,
|
||||
"category" -> category.orNull,
|
||||
"noPostsMessage" -> noPostsMsg,
|
||||
"pageParam" -> pageParam
|
||||
)
|
||||
)
|
||||
yield html
|
||||
|
||||
// HTMX Handler: Load more posts
|
||||
def onLoadMore: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
|
||||
for
|
||||
page <- ZIO.serviceWithZIO[Request](_.formField("page"))
|
||||
.map(_.flatMap(_.toIntOption).getOrElse(1))
|
||||
props <- properties.get
|
||||
perPage = props.get("postsPerPage")
|
||||
.collect { case StringVal(s) => s.toIntOption }
|
||||
.flatten.getOrElse(10)
|
||||
result <- postService.listFrontend(page = page, perPage = perPage)
|
||||
html <- templateService.render(s"$alias/items", Map("posts" -> result.items.asJava))
|
||||
yield HtmxResponse(
|
||||
html = html,
|
||||
triggers = if result.hasMore then Map.empty else Map("postsExhausted" -> "true")
|
||||
)
|
||||
|
||||
// Dynamic options for categoryFilter dropdown
|
||||
def getCategoryFilterOptions: UIO[Map[String, String]] =
|
||||
categoryService.listAll.map { cats =>
|
||||
Map("" -> "- All Categories -") ++ cats.map(c => c.slug -> c.name).toMap
|
||||
}
|
||||
```
|
||||
|
||||
### HTMX Handler Router
|
||||
```scala
|
||||
// Source: ZIO HTTP routing + WinterCMS component handler pattern
|
||||
object ComponentRoutes:
|
||||
|
||||
val routes: Routes[ComponentManager & TemplateService & SessionService, Response] =
|
||||
Routes(
|
||||
// Component HTMX handler endpoint
|
||||
Method.POST / "summer" / "component" / string("alias") / string("handler") ->
|
||||
handler { (alias: String, handlerName: String, req: Request) =>
|
||||
for
|
||||
_ <- CsrfService.validate(req)
|
||||
manager <- ZIO.service[ComponentManager]
|
||||
factory <- manager.resolve(alias).someOrFail(ComponentNotFound(alias))
|
||||
ctx <- PageContext.fromRequest(req)
|
||||
props <- extractProperties(req)
|
||||
component <- factory.create(alias, props, ctx)
|
||||
result <- invokeHandler(component, handlerName)
|
||||
response <- buildHtmxResponse(result)
|
||||
yield response
|
||||
}
|
||||
)
|
||||
|
||||
private def invokeHandler(
|
||||
component: SummerComponent,
|
||||
handlerName: String
|
||||
): IO[ComponentError, HtmxResponse] =
|
||||
// Validate handler name starts with "on"
|
||||
if !handlerName.startsWith("on") then
|
||||
ZIO.fail(InvalidHandler(s"Handler must start with 'on': $handlerName"))
|
||||
else
|
||||
// Use reflection or macro to invoke handler method
|
||||
component.invokeHandler(handlerName)
|
||||
```
|
||||
|
||||
### YAML Property Schema Parser
|
||||
```scala
|
||||
// Source: circe-yaml + WinterCMS Inspector format
|
||||
import io.circe.*
|
||||
import io.circe.generic.auto.*
|
||||
import io.circe.yaml.parser
|
||||
|
||||
case class ComponentYaml(
|
||||
name: String,
|
||||
description: String,
|
||||
icon: Option[String] = None,
|
||||
properties: Map[String, PropertyDefYaml] = Map.empty
|
||||
)
|
||||
|
||||
case class PropertyDefYaml(
|
||||
`type`: String,
|
||||
title: String,
|
||||
description: Option[String] = None,
|
||||
default: Option[Json] = None,
|
||||
options: Option[Map[String, String]] = None,
|
||||
group: Option[String] = None,
|
||||
showExternalParam: Option[Boolean] = None,
|
||||
validation: Option[ValidationYaml] = None
|
||||
)
|
||||
|
||||
def parseComponentYaml(path: Path): IO[YamlError, ComponentSchema] =
|
||||
for
|
||||
content <- ZIO.attemptBlocking(Files.readString(path))
|
||||
.mapError(e => YamlError.ReadError(path, e))
|
||||
yaml <- ZIO.fromEither(parser.parse(content))
|
||||
.mapError(e => YamlError.ParseError(path, e))
|
||||
schema <- ZIO.fromEither(yaml.as[ComponentYaml])
|
||||
.mapError(e => YamlError.DecodeError(path, e))
|
||||
yield toComponentSchema(schema)
|
||||
|
||||
def toComponentSchema(yaml: ComponentYaml): ComponentSchema =
|
||||
ComponentSchema(
|
||||
name = yaml.name,
|
||||
description = yaml.description,
|
||||
icon = yaml.icon,
|
||||
properties = yaml.properties.map { case (name, prop) =>
|
||||
name -> PropertyDef(
|
||||
`type` = PropertyType.valueOf(prop.`type`.capitalize),
|
||||
title = prop.title,
|
||||
description = prop.description,
|
||||
default = prop.default.flatMap(parsePropertyValue),
|
||||
options = prop.options,
|
||||
group = prop.group,
|
||||
showExternalParam = prop.showExternalParam.getOrElse(false),
|
||||
validation = prop.validation.map(parseValidation)
|
||||
)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Pebble Custom Functions for Components
|
||||
```scala
|
||||
// Source: Pebble extension API
|
||||
import io.pebbletemplates.pebble.extension.*
|
||||
import io.pebbletemplates.pebble.template.EvaluationContext
|
||||
|
||||
class SummerPebbleExtension(
|
||||
themeService: ThemeService,
|
||||
componentContext: ComponentContext
|
||||
) extends AbstractExtension:
|
||||
|
||||
override def getFunctions: java.util.Map[String, Function] =
|
||||
Map(
|
||||
"asset" -> new AssetFunction(themeService),
|
||||
"componentHandler" -> new ComponentHandlerFunction(componentContext),
|
||||
"csrfToken" -> new CsrfTokenFunction(componentContext)
|
||||
).asJava
|
||||
|
||||
override def getFilters: java.util.Map[String, Filter] =
|
||||
Map(
|
||||
"_" -> new TranslateFilter(componentContext) // {{ 'text'|_ }}
|
||||
).asJava
|
||||
|
||||
class ComponentHandlerFunction(ctx: ComponentContext) extends Function:
|
||||
override def getArgumentNames = List("handler").asJava
|
||||
|
||||
override def execute(
|
||||
args: java.util.Map[String, Object],
|
||||
self: PebbleTemplate,
|
||||
context: EvaluationContext,
|
||||
lineNumber: Int
|
||||
): Object =
|
||||
val handler = args.get("handler").toString
|
||||
val alias = context.getVariable("__SELF__").toString
|
||||
s"/summer/component/$alias/$handler"
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| AJAX with JSON responses | HTMX with HTML fragments | 2020+ | Simpler, less JS, server-rendered |
|
||||
| SPA with React/Vue | Server-side + HTMX | 2023+ | Lower complexity for CMS-style apps |
|
||||
| Custom templating | Pebble (Twig-compatible) | Established | Migration from WinterCMS themes |
|
||||
| Callback-based handlers | ZIO effect-based handlers | ZIO 2.0 | Type-safe async, resource safety |
|
||||
| Session-based CSRF | Per-request tokens + SameSite | 2024+ | Better security, stateless option |
|
||||
| Polling for updates | Server-Sent Events | 2020+ | Efficient real-time, browser-native |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- jQuery AJAX: Use HTMX data attributes instead
|
||||
- PHP component classes: Scala traits with ZIO
|
||||
- `defineProperties()` method: YAML file at `component.yaml`
|
||||
- Synchronous handlers: All handlers return `ZIO`
|
||||
|
||||
## Open Questions
|
||||
|
||||
Things that couldn't be fully resolved:
|
||||
|
||||
1. **Template Hot-Reload in Development**
|
||||
- What we know: Pebble caches compiled templates
|
||||
- What's unclear: Best pattern for dev-mode reloading without restart
|
||||
- Recommendation: Use Pebble's cache.invalidate() on file change, integrate with Mill -w
|
||||
|
||||
2. **Component Inheritance Depth**
|
||||
- What we know: CONTEXT.md allows component inheritance
|
||||
- What's unclear: How deep inheritance should go, multiple inheritance
|
||||
- Recommendation: Single-level inheritance only (extend one base), use composition for reuse
|
||||
|
||||
3. **Property Type Extensibility**
|
||||
- What we know: WinterCMS has fixed property types (string, dropdown, etc.)
|
||||
- What's unclear: How plugins can define custom property types
|
||||
- Recommendation: Start with WinterCMS types, add extension mechanism in v2 if needed
|
||||
|
||||
4. **Pebble vs ScalaTags for Fragments**
|
||||
- What we know: Pebble for templates, ScalaTags for programmatic HTML
|
||||
- What's unclear: When to use which, can they interop?
|
||||
- Recommendation: Pebble for component templates, ScalaTags for helper methods returning `Html`
|
||||
|
||||
5. **HTMX Extension Loading**
|
||||
- What we know: HTMX has extensions (sse, json-enc, etc.)
|
||||
- What's unclear: How to manage extension loading per-component
|
||||
- Recommendation: Load common extensions in base layout, document per-component needs
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [ZIO HTTP HTMX Module](https://index.scala-lang.org/zio/zio-http/artifacts/zio-http-htmx/3.3.0) - Official module, version 3.3.0
|
||||
- [ZIO HTTP SSE Examples](https://ziohttp.com/examples/server-sent-events-in-endpoints/) - Server-Sent Events pattern
|
||||
- [Pebble Templates](https://pebbletemplates.io/) - Twig-compatible templating, version 4.1.1
|
||||
- [htmx4s Library](https://github.com/eikek/htmx4s) - Type-safe HTMX constants for Scala
|
||||
- [ScalaTags Documentation](https://com-lihaoyi.github.io/scalatags/) - Programmatic HTML generation
|
||||
- [HTMX OOB Swaps](https://htmx.org/attributes/hx-swap-oob/) - Out-of-band swap patterns
|
||||
- [HTMX CSRF Patterns](https://htmx.org/essays/web-security-basics-with-htmx/) - Security best practices
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [rockthejvm/scalatags-htmx-demo](https://github.com/rockthejvm/scalatags-htmx-demo) - ZIO + ScalaTags + HTMX architecture
|
||||
- WinterCMS ComponentBase.php - Reference implementation (local file)
|
||||
- WinterCMS Inspector documentation - Property schema format (local file)
|
||||
- [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) - Token patterns
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- Phase 2 Research - circe-yaml patterns (reuse)
|
||||
- Phase 1 Research - ZIO service patterns (reuse)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH - zio-http-htmx and Pebble verified via official sources
|
||||
- Architecture patterns: HIGH - Based on WinterCMS reference + ZIO official patterns
|
||||
- HTMX integration: HIGH - Official HTMX docs and zio-http module
|
||||
- Template engine: MEDIUM - Pebble is established but interop with ZIO needs validation
|
||||
- Property system: MEDIUM - Adapted from WinterCMS, Scala-specific validation untested
|
||||
|
||||
**Research date:** 2026-02-05
|
||||
**Valid until:** 2026-03-05 (30 days - stable patterns, Pebble/HTMX established)
|
||||
Reference in New Issue
Block a user