stream.go

101 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
package application

import (
	"fmt"
	"net/http"
	"strings"
)

// Streamer sends Server-Sent Events. Pair it with HTMX's SSE extension for
// real-time updates. Create one with c.Stream(w), which sets the SSE headers and
// flushes immediately, then push events with Send, SendData, or Render.
//
//	func (c *Home) Live(w http.ResponseWriter, r *http.Request) {
//		s := c.Stream(w)
//		for {
//			select {
//			case <-r.Context().Done():
//				return
//			case msg := <-updates:
//				s.Send("messages", "<div>"+msg+"</div>")
//			}
//		}
//	}
//
//	<div hx-ext="sse" sse-connect="/live" sse-swap="messages">Loading...</div>
type Streamer struct {
	w       http.ResponseWriter
	flusher http.Flusher
	app     *App
}

// Stream begins an SSE response: it sets the event-stream headers, disables proxy
// buffering, and flushes to establish the connection.
func (c *BaseController) Stream(w http.ResponseWriter) *Streamer {
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	w.Header().Set("X-Accel-Buffering", "no")

	flusher, _ := w.(http.Flusher)
	if flusher != nil {
		flusher.Flush()
	}
	return &Streamer{w: w, flusher: flusher, app: c.App}
}

// Send emits a named SSE event, matching HTMX's sse-swap. Returns an error if the
// client has 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
	}
	s.flush()
	return nil
}

// SendData emits an unnamed ("message") SSE event — for JSON or data consumed by
// JavaScript rather than swapped by HTMX.
func (s *Streamer) SendData(data string) error {
	if err := s.writeData(data); err != nil {
		return err
	}
	s.flush()
	return nil
}

// Render renders a template to HTML and emits it as a named SSE event — the
// common HTMX pattern where the server produces the final markup. Newlines are
// collapsed to spaces (safe for HTML, required by the SSE line protocol).
func (s *Streamer) Render(event, templateName string, data any) error {
	html, err := s.app.RenderToString(templateName, data)
	if err != nil {
		return err
	}
	html = strings.ReplaceAll(html, "\n", " ")
	if _, err = fmt.Fprintf(s.w, "event: %s\ndata: %s\n\n", event, html); err != nil {
		return err
	}
	s.flush()
	return nil
}

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

func (s *Streamer) flush() {
	if s.flusher != nil {
		s.flusher.Flush()
	}
}