controller.go

216 lines
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216
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.
//
// Create via c.Stream(w), which sets SSE headers and flushes immediately.
//
// Three ways to send events:
//
//   - Send(event, data): Named event — client listens via sse-swap="event".
//     Use for HTMX targets where the event name selects which DOM element to swap.
//
//   - SendData(data): Default (unnamed) "message" event — client receives via
//     onmessage or sse-swap="message". Use for simple data push (e.g., JSON payloads
//     consumed by JavaScript, not HTMX swap targets).
//
//   - Render(event, template, data): Named event with server-rendered HTML.
//     Renders a Go template to string and sends it as an SSE event. Use when the
//     server produces the final HTML (the common HTMX pattern). Newlines in rendered
//     HTML are replaced with spaces (safe for HTML, required by SSE spec).
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.
// Returns an error if the write fails (e.g., client disconnected).
func (s *Streamer) Send(event, data string) error {
	if _, err := fmt.Fprintf(s.w, "event: %s\n", event); err != nil {
		return err
	}
	if err := s.writeData(data); err != nil {
		return err
	}
	if s.flusher != nil {
		s.flusher.Flush()
	}
	return nil
}

// SendData sends an SSE message event (default event type).
// Newlines in data are properly handled per SSE spec.
// Returns an error if the write fails (e.g., client disconnected).
func (s *Streamer) SendData(data string) error {
	if err := s.writeData(data); err != nil {
		return err
	}
	if s.flusher != nil {
		s.flusher.Flush()
	}
	return nil
}

// writeData writes data lines per SSE spec (each line prefixed with "data:").
func (s *Streamer) writeData(data string) error {
	lines := strings.Split(data, "\n")
	for _, line := range lines {
		if _, err := fmt.Fprintf(s.w, "data: %s\n", line); err != nil {
			return err
		}
	}
	if _, err := fmt.Fprint(s.w, "\n"); err != nil {
		return err
	}
	return nil
}

// 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", " ")
	if _, err = fmt.Fprintf(s.w, "event: %s\ndata: %s\n\n", event, html); err != nil {
		return err
	}

	if s.flusher != nil {
		s.flusher.Flush()
	}

	return nil
}