readysite / pkg / application / controller.go
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
}
← Back