controller.go
216 lines1
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
}