htmgo Framework

repository·master·Indexed 21 days ago

https://github.com/maddalax/htmgo

A lightweight, pure Go framework for building interactive web applications using htmx. It enables the creation of single deployable binaries with built-in support for Tailwind CSS, live reloading, and automatic route registration. The framework includes a pluggable caching system with Store interfaces to prevent memory exhaustion, WebSocket support via EnableExtension, and a RequestContext for managing HTMX metadata and application services.

Tokens
3.8K
Snippets
13
Records
15
Agent score
76%

What's inside htmgo

  1. Introduction to htmgo

    master

    htmgo is a lightweight, pure Go framework designed to build interactive websites and web applications using Go and htmx. It allows developers to create fast, interactive sites using hypermedia attributes wrapped in Go code, minimizing the need for manual JavaScript. A key benefit is that the entire application compiles into a single deployable binary.

    func IndexPage(ctx *h.RequestContext) *h.Page {
      now := time.Now()
      return h.NewPage(
        h.Div(
          h.Class("flex gap-2"),
          h.TextF("the current time is %s", now.String())
        )
      )
    }
  2. Core features of htmgo

    master

    htmgo provides several developer-experience and deployment features:

    • Single Deployable Binary: Everything is compiled into one file.
    • Live Reload: Automatically rebuilds CSS, Go code, Ent schemas, and routes upon file changes.
    • Automatic Registration: Pages and partials are automatically registered based on their file paths.
    • Built-in Tailwind CSS: Full support out of the box with zero configuration required.
    • Custom HTMX Extensions: Includes built-in extensions to reduce boilerplate for common tasks.
  3. How the pluggable cache system works

    master

    The htmgo framework uses a pluggable architecture for caching to prevent memory exhaustion and DDoS vulnerabilities associated with unbounded TTL-only caches.

    Developers can interact with the cache system in three ways:

    1. Default Behavior: Use the built-in TTLStore (backward compatible).
    2. Per-Component: Pass a specific store to a cached component using the h.WithCacheStore(store) option.
    3. Global Override: Set the h.DefaultCacheProvider function to change the cache implementation for the entire application.

    This allows for easy transitions from simple in-memory TTL caches to memory-bounded LRU caches or distributed stores like Redis.

    // Per-component usage
    UserProfile = h.CachedPerKeyT(
        15*time.Minute,
        func(userID int) (int, h.GetElementFunc) {
            return userID, func() *h.Element { return h.Div(h.Text("User profile")) }
        },
        h.WithCacheStore(lruCache),
    )
  4. Security: Prevent DoS with memory-bounded caches

    master

    For public-facing applications, avoid using unbounded TTL-only caches. Instead, use a memory-bounded cache like LRUStore to prevent attackers from exhausting server memory by generating high-cardinality cache keys.

    // Limit cache to reasonable size based on your server's memory
    cache := cache.NewLRUStore[any, string](100_000)
    
    // Use for all user-specific caching
    UserContent := h.CachedPerKey(
        5*time.Minute,
        getUserContent,
        h.WithCacheStore(cache),
    )
  5. Change the default cache globally

    master

    You can override the default cache provider for your entire application by assigning a provider function to h.DefaultCacheProvider in your init() function.

    func init() {
        // All cached components will use LRU by default
        h.DefaultCacheProvider = func () cache.Store[any, string] {
            return cache.NewLRUStore[any, string](50_000)
        }
    }
  6. Implement a custom cache adapter

    master

    To integrate a third-party library (e.g., go-freelru or a Redis client), create a struct that implements the cache.Store[K, V] interface.

    Warning: When implementing GetOrCompute for third-party libraries that do not natively support atomic computation, you must implement your own synchronization (e.g., using sync.Mutex) to maintain the atomic guarantees required to prevent cache stampedes.

    type FreeLRUAdapter[K comparable, V any] struct {
        lru *freelru.LRU[K, V]
    }
    
    func (s *FreeLRUAdapter[K, V]) Set(key K, value V, ttl time.Duration) {
        s.lru.Add(key, value)
    }
    
    func (s *FreeLRUAdapter[K, V]) GetOrCompute(key K, compute func() V, ttl time.Duration) V {
        if val, ok := s.lru.Get(key); ok {
            return val
        }
        value := compute()
        s.lru.Add(key, value)
        return value
    }
    
    func (s *FreeLRUAdapter[K, V]) Delete(key K) { s.lru.Remove(key) }
    func (s *FreeLRUAdapter[K, V]) Purge()      { s.lru.Clear() }
    func (s *FreeLRUAdapter[K, V]) Close()      {}
  7. Start an htmgo application

    master

    Use h.Start(opts) to initialize and run the server. You can configure the port, enable live reload, and provide a ServiceLocator for dependency injection via h.AppOpts. If Register is provided, it is called during the application startup sequence.

    Common configuration options include:

    • Port: The port to listen on (defaults to 3000 if not set).
    • LiveReload: Enables live reload support (only works in development mode).
    • ServiceLocator: A pointer to a service.Locator for managing application services.
    • Register: A callback function func(app *App) used to register routes or middleware on the app instance.
    h.Start(h.AppOpts{
        Port:       8080,
        LiveReload: true,
        Register: func(app *h.App) {
            // Register routes here
        },
    })
  8. Use minimal-htmgo for basic HTML rendering and JS support

    master

    The minimal-htmgo example is designed for developers who only need htmgo for its core HTML rendering and JavaScript support capabilities, without the additional automation provided in standard templates.

    Use this configuration if you want to manually manage your assets and routing. Note that this mode removes the following automatic features:

    1. Live reloading: You must manually refresh or implement your own watcher.
    2. Tailwind recompilation: Tailwind CSS will not be automatically recompiled.
    3. Route registration: Page and partial routes are not automatically registered; you must define them manually.
    4. Single binary support: Unlike other htmgo examples that use an embedded file system (e.g., assets_prod.go), this version requires a physical /public/ directory containing your assets to be present at runtime.
  9. Use a custom cache with WithCacheStore

    master

    To use a specific cache implementation (like an LRU cache) for a single component, pass the h.WithCacheStore option to the caching function.

    var (
        // Create a memory-bounded LRU cache
        lruCache = cache.NewLRUStore[any, string](10_000) // Max 10,000 items
    
        // Use it with a cached component
        UserProfile = h.CachedPerKeyT(
            15*time.Minute,
            func (userID int) (int, h.GetElementFunc) {
                return userID, func () *h.Element {
                    return h.Div(h.Text("User profile"))
                }
            },
            h.WithCacheStore(lruCache), // Pass the custom cache
        )
    )
  10. Use the default TTL-based cache

    master

    By default, htmgo uses a TTL-based cache. No additional configuration is required for existing code patterns using h.CachedPerKeyT or similar functions.

    // No changes needed - works exactly as before
    UserProfile := h.CachedPerKeyT(
      15*time.Minute,
      func(userID int) (int, h.GetElementFunc) {
        return userID, func() *h.Element {
            return h.Div(h.Text("User profile"))
        }
      },
    )
  11. The Store interface for custom caching

    master

    To implement a custom cache in htmgo, you must satisfy the Store[K comparable, V any] interface. This interface allows you to define how data is stored, retrieved, and evicted. A key feature is the GetOrCompute method, which provides atomic guarantees to prevent cache stampedes by ensuring only one goroutine executes the computation function for a specific key when multiple requests arrive simultaneously.

    type Store[K comparable, V any] interface {
    	// Set adds or updates an entry in the cache with the given TTL
    	Set(key K, value V, ttl time.Duration)
    
    	// GetOrCompute atomically gets an existing value or computes and stores a new value
    	// This prevents duplicate computation when multiple goroutines request the same key
    	GetOrCompute(key K, compute func() V, ttl time.Duration) V
    
    	// Delete removes an entry from the cache
    	Delete(key K)
    
    	// Purge removes all items from the cache
    	Purge()
    
    	// Close releases any resources used by the cache
    	Close()
    }
  12. Access the RequestContext in handlers

    master

    The RequestContext is automatically injected into the request context by the framework middleware. To access it within a standard http.HandlerFunc, use h.GetRequestContext(r).

    RequestContext provides high-level helpers for:

    • HTTP Methods: IsHttpPost(), IsHttpGet(), IsHttpPut(), IsHttpDelete().
    • Parameters: FormValue(key), Header(key), UrlParam(key) (via chi), and QueryParam(key).
    • HTMX Metadata: IsHxRequest(), HxTargetId(), HxTriggerName(), HxTriggerId(), IsBoosted(), and HxCurrentBrowserUrl().
    • State Management: Set(key, value) and Get(key) for request-scoped key-value storage.
    • Services: ServiceLocator() to retrieve application services.
    • Response Helpers: SetCookie(cookie) and Redirect(path, code).
    func MyHandler(w http.ResponseWriter, r *http.Request) {
        ctx := h.GetRequestContext(r)
        
        if ctx.IsHttpPost() {
            val := ctx.FormValue("username")
            // ...
        }
        
        if ctx.IsHxRequest() {
            target := ctx.HxTargetId()
            // Handle HTMX specific logic
        }
    }