4.7 KB
controller.go
package application
import (
"fmt"
"net/http"
"strconv"
"strings"
)
// Controller is the interface that controllers implement.
// The Handle method receives the request and returns a controller instance
// that can be used by templates to access controller methods.
type Controller interface {
Handle(r *http.Request) Controller
}
// BaseController is the base type for all controllers.
// Embed this in your controller structs.
type BaseController struct {
*App
*http.Request
}
// Setup initializes the controller with the application.
// Override this in your controller to register routes.
func (c *BaseController) Setup(app *App) {
c.App = app
}
// QueryParam returns a query parameter with a default value
func (c *BaseController) QueryParam(name, defaultValue string) string {
if c.Request == nil {
return defaultValue
}
value := c.URL.Query().Get(name)
if value == "" {
return defaultValue
}
return value
}
// IntParam returns an integer query parameter with a default value
func (c *BaseController) IntParam(name string, defaultValue int) int {
str := c.QueryParam(name, "")
if str == "" {
return defaultValue
}
value, err := strconv.Atoi(str)
if err != nil {
return defaultValue
}
return value
}
// IsHTMX returns true if the request is from HTMX
func (c *BaseController) IsHTMX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// Redirect sends a redirect response (HX-Location for HTMX, HTTP redirect otherwise)
func (c *BaseController) Redirect(w http.ResponseWriter, r *http.Request, path string) {
if c.IsHTMX(r) {
w.Header().Set("HX-Location", path)
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, path, http.StatusSeeOther)
}
// Refresh sends an HX-Refresh header to reload the page
func (c *BaseController) Refresh(w http.ResponseWriter, r *http.Request) {
if c.IsHTMX(r) {
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
}
// Streamer provides Server-Sent Events (SSE) streaming.
// Use with HTMX's SSE extension for real-time updates.
type Streamer struct {
w http.ResponseWriter
flusher http.Flusher
app *App
}
// Stream creates a new SSE streamer for real-time updates.
// Sets appropriate headers and returns a Streamer for sending events.
//
// Example:
//
// func (c *HomeController) Live(w http.ResponseWriter, r *http.Request) {
// stream := c.Stream(w)
// for {
// select {
// case <-r.Context().Done():
// return
// case msg := <-updates:
// stream.Send("messages", "<div>"+msg+"</div>")
// }
// }
// }
//
// Template usage with HTMX SSE extension:
//
// <div hx-ext="sse" sse-connect="/live" sse-swap="messages">
// Loading...
// </div>
func (c *BaseController) Stream(w http.ResponseWriter) *Streamer {
// Always set SSE headers first
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// Disable buffering at proxy level (nginx, cloudflare, etc.)
w.Header().Set("X-Accel-Buffering", "no")
flusher, _ := w.(http.Flusher)
// Flush immediately to establish connection
if flusher != nil {
flusher.Flush()
}
return &Streamer{w: w, flusher: flusher, app: c.App}
}
// Send sends an SSE event with the given name and data.
// The event name corresponds to sse-swap in HTMX.
// Newlines in data are properly handled per SSE spec.
func (s *Streamer) Send(event, data string) {
fmt.Fprintf(s.w, "event: %s\n", event)
s.writeData(data)
if s.flusher != nil {
s.flusher.Flush()
}
}
// SendData sends an SSE message event (default event type).
// Newlines in data are properly handled per SSE spec.
func (s *Streamer) SendData(data string) {
s.writeData(data)
if s.flusher != nil {
s.flusher.Flush()
}
}
// writeData writes data lines per SSE spec (each line prefixed with "data:").
func (s *Streamer) writeData(data string) {
lines := strings.Split(data, "\n")
for _, line := range lines {
fmt.Fprintf(s.w, "data: %s\n", line)
}
fmt.Fprint(s.w, "\n") // Empty line terminates the event
}
// Render renders a template and sends it via SSE.
// For partials, use just the filename (e.g., "live-time.html").
// Newlines are replaced with spaces (safe for HTML).
//
// Example:
//
// stream.Render("time", "live-time.html", time.Now().Format("15:04:05"))
func (s *Streamer) Render(event, templateName string, data any) error {
html, err := s.app.RenderToString(templateName, data)
if err != nil {
return err
}
// Replace newlines with spaces (HTML ignores whitespace)
html = strings.ReplaceAll(html, "\n", " ")
fmt.Fprintf(s.w, "event: %s\ndata: %s\n\n", event, html)
if s.flusher != nil {
s.flusher.Flush()
}
return nil
}