CUB-136: add SSE endpoint in Go backend
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m9s
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m9s
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/CubeCraft-Creations/Extrudex/backend/internal/config"
|
"github.com/CubeCraft-Creations/Extrudex/backend/internal/config"
|
||||||
"github.com/CubeCraft-Creations/Extrudex/backend/internal/db"
|
"github.com/CubeCraft-Creations/Extrudex/backend/internal/db"
|
||||||
"github.com/CubeCraft-Creations/Extrudex/backend/internal/router"
|
"github.com/CubeCraft-Creations/Extrudex/backend/internal/router"
|
||||||
|
"github.com/CubeCraft-Creations/Extrudex/backend/internal/sse"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -39,15 +40,24 @@ func main() {
|
|||||||
|
|
||||||
slog.Info("database connected")
|
slog.Info("database connected")
|
||||||
|
|
||||||
|
// Create SSE broadcaster and start it
|
||||||
|
sseBC := sse.NewBroadcaster(128)
|
||||||
|
sseBC.Start()
|
||||||
|
defer sseBC.Stop()
|
||||||
|
|
||||||
|
slog.Info("sse broadcaster started")
|
||||||
|
|
||||||
// Create router
|
// Create router
|
||||||
r := router.New(cfg, dbPool)
|
r := router.New(cfg, dbPool, sseBC)
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
|
// WriteTimeout is 0 for SSE support — the Chi middleware.Timeout(60s)
|
||||||
|
// handles request-level timeouts on non-SSE routes.
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: r,
|
Handler: r,
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
WriteTimeout: 15 * time.Second,
|
WriteTimeout: 0, // disabled for SSE long-lived connections
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ import (
|
|||||||
"github.com/CubeCraft-Creations/Extrudex/backend/internal/handlers"
|
"github.com/CubeCraft-Creations/Extrudex/backend/internal/handlers"
|
||||||
"github.com/CubeCraft-Creations/Extrudex/backend/internal/repositories"
|
"github.com/CubeCraft-Creations/Extrudex/backend/internal/repositories"
|
||||||
"github.com/CubeCraft-Creations/Extrudex/backend/internal/services"
|
"github.com/CubeCraft-Creations/Extrudex/backend/internal/services"
|
||||||
|
"github.com/CubeCraft-Creations/Extrudex/backend/internal/sse"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New creates and configures a Chi router with all middleware and handlers mounted.
|
// New creates and configures a Chi router with all middleware and handlers mounted.
|
||||||
func New(cfg *config.Config, dbPool *pgxpool.Pool) chi.Router {
|
func New(cfg *config.Config, dbPool *pgxpool.Pool, sseBC *sse.Broadcaster) chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
@@ -22,7 +23,7 @@ func New(cfg *config.Config, dbPool *pgxpool.Pool) chi.Router {
|
|||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(middleware.Timeout(60 * time.Second))
|
// Timeout middleware is applied per-route below to exclude SSE
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
r.Use(func(next http.Handler) http.Handler {
|
r.Use(func(next http.Handler) http.Handler {
|
||||||
@@ -38,9 +39,9 @@ func New(cfg *config.Config, dbPool *pgxpool.Pool) chi.Router {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Health check
|
// Health check (with timeout)
|
||||||
healthHandler := handlers.NewHealthHandler(dbPool)
|
healthHandler := handlers.NewHealthHandler(dbPool)
|
||||||
r.Get("/health", healthHandler.ServeHTTP)
|
r.With(middleware.Timeout(30 * time.Second)).Get("/health", healthHandler.ServeHTTP)
|
||||||
|
|
||||||
// ── Repositories ──────────────────────────────────────────────────────
|
// ── Repositories ──────────────────────────────────────────────────────
|
||||||
materialRepo := repositories.NewMaterialRepository(dbPool)
|
materialRepo := repositories.NewMaterialRepository(dbPool)
|
||||||
@@ -61,8 +62,9 @@ func New(cfg *config.Config, dbPool *pgxpool.Pool) chi.Router {
|
|||||||
printJobHandler := handlers.NewPrintJobHandler(printJobService)
|
printJobHandler := handlers.NewPrintJobHandler(printJobService)
|
||||||
usageLogHandler := handlers.NewUsageLogHandler(usageLogRepo)
|
usageLogHandler := handlers.NewUsageLogHandler(usageLogRepo)
|
||||||
|
|
||||||
// ── API Routes ────────────────────────────────────────────────────────
|
// ── API Routes (with timeout) ─────────────────────────────────────────
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
r.Use(middleware.Timeout(60 * time.Second))
|
||||||
r.Get("/materials", materialHandler.List)
|
r.Get("/materials", materialHandler.List)
|
||||||
|
|
||||||
r.Route("/filaments", func(r chi.Router) {
|
r.Route("/filaments", func(r chi.Router) {
|
||||||
@@ -78,6 +80,10 @@ func New(cfg *config.Config, dbPool *pgxpool.Pool) chi.Router {
|
|||||||
r.Get("/printers", printerHandler.List)
|
r.Get("/printers", printerHandler.List)
|
||||||
r.Get("/print-jobs", printJobHandler.List)
|
r.Get("/print-jobs", printJobHandler.List)
|
||||||
r.Get("/usage-logs", usageLogHandler.List)
|
r.Get("/usage-logs", usageLogHandler.List)
|
||||||
|
|
||||||
|
// SSE Events stream
|
||||||
|
sseHandler := sse.NewHandler(sseBC)
|
||||||
|
r.Get("/events", sseHandler.ServeHTTP)
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
133
backend/internal/sse/broadcaster.go
Normal file
133
backend/internal/sse/broadcaster.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package sse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// client represents a single SSE subscriber — identified by its send channel.
|
||||||
|
type client struct {
|
||||||
|
ch chan string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcaster receives Events on its input channel and fans them out to every
|
||||||
|
// connected client. Subscribe adds a new client; Unsubscribe removes one.
|
||||||
|
// Start must be called before the broadcaster accepts events.
|
||||||
|
type Broadcaster struct {
|
||||||
|
input chan Event
|
||||||
|
subscribe chan client
|
||||||
|
unsubscribe chan client
|
||||||
|
clients map[chan string]struct{}
|
||||||
|
done chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBroadcaster creates a Broadcaster. bufSize controls the buffer depth for
|
||||||
|
// the input channel as well as for each per-client outbound channel.
|
||||||
|
func NewBroadcaster(bufSize int) *Broadcaster {
|
||||||
|
if bufSize <= 0 {
|
||||||
|
bufSize = 64
|
||||||
|
}
|
||||||
|
return &Broadcaster{
|
||||||
|
input: make(chan Event, bufSize),
|
||||||
|
subscribe: make(chan client),
|
||||||
|
unsubscribe: make(chan client),
|
||||||
|
clients: make(map[chan string]struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish pushes an event into the broadcaster. Safe for concurrent use.
|
||||||
|
func (b *Broadcaster) Publish(ev Event) {
|
||||||
|
select {
|
||||||
|
case b.input <- ev:
|
||||||
|
case <-b.done:
|
||||||
|
// Silently drop during shutdown.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches the broadcaster's fan-out loop in a goroutine.
|
||||||
|
// It must be called before Publish is used.
|
||||||
|
func (b *Broadcaster) Start() {
|
||||||
|
go b.loop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop terminates the fan-out loop and closes all client channels.
|
||||||
|
// It is safe to call multiple times.
|
||||||
|
func (b *Broadcaster) Stop() {
|
||||||
|
b.once.Do(func() {
|
||||||
|
close(b.done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe returns a new client channel that receives SSE-formatted strings.
|
||||||
|
func (b *Broadcaster) Subscribe() chan string {
|
||||||
|
c := client{ch: make(chan string, 64)}
|
||||||
|
select {
|
||||||
|
case b.subscribe <- c:
|
||||||
|
case <-b.done:
|
||||||
|
// Broadcaster already stopped — return a closed chan so the handler
|
||||||
|
// can bail out quickly.
|
||||||
|
ch := make(chan string)
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
return c.ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a client channel and closes it.
|
||||||
|
func (b *Broadcaster) Unsubscribe(ch chan string) {
|
||||||
|
c := client{ch: ch}
|
||||||
|
select {
|
||||||
|
case b.unsubscribe <- c:
|
||||||
|
case <-b.done:
|
||||||
|
// Already shutting down — channels will be cleaned up by Stop.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loop is the core fan-out goroutine.
|
||||||
|
func (b *Broadcaster) loop() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ev := <-b.input:
|
||||||
|
sse := ev.toSSE()
|
||||||
|
for ch := range b.clients {
|
||||||
|
// Non-blocking send — slow clients are dropped.
|
||||||
|
select {
|
||||||
|
case ch <- sse:
|
||||||
|
default:
|
||||||
|
slog.Warn("sse broadcaster: dropping event for slow client", "type", ev.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case c := <-b.subscribe:
|
||||||
|
b.clients[c.ch] = struct{}{}
|
||||||
|
slog.Debug("sse broadcaster: client connected", "total_clients", len(b.clients))
|
||||||
|
|
||||||
|
case c := <-b.unsubscribe:
|
||||||
|
if _, ok := b.clients[c.ch]; ok {
|
||||||
|
delete(b.clients, c.ch)
|
||||||
|
close(c.ch)
|
||||||
|
slog.Debug("sse broadcaster: client disconnected", "total_clients", len(b.clients))
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-b.done:
|
||||||
|
// Drain remaining events in input before shutting down.
|
||||||
|
for ev := range b.input {
|
||||||
|
sse := ev.toSSE()
|
||||||
|
for ch := range b.clients {
|
||||||
|
select {
|
||||||
|
case ch <- sse:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Close all remaining client channels.
|
||||||
|
for ch := range b.clients {
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
b.clients = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
backend/internal/sse/events.go
Normal file
92
backend/internal/sse/events.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Package sse provides Server-Sent Events infrastructure for real-time updates.
|
||||||
|
// Includes event types, a central broadcaster, and an HTTP handler.
|
||||||
|
package sse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventType identifies the category of an SSE event.
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventPrinterStatus EventType = "printer.status"
|
||||||
|
EventJobStarted EventType = "job.started"
|
||||||
|
EventJobCompleted EventType = "job.completed"
|
||||||
|
EventFilamentLow EventType = "filament.low"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event is a JSON-serializable SSE event pushed through the broadcaster.
|
||||||
|
type Event struct {
|
||||||
|
Type EventType `json:"type"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrinterStatusPayload carries printer online/offline/printing state.
|
||||||
|
type PrinterStatusPayload struct {
|
||||||
|
PrinterID int `json:"printer_id"`
|
||||||
|
PrinterName string `json:"printer_name"`
|
||||||
|
Status string `json:"status"` // "online", "offline", "printing"
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobStartedPayload carries initial print job info.
|
||||||
|
type JobStartedPayload struct {
|
||||||
|
JobID int `json:"job_id"`
|
||||||
|
JobName string `json:"job_name"`
|
||||||
|
PrinterID int `json:"printer_id"`
|
||||||
|
SpoolID *int `json:"spool_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobCompletedPayload carries final print job data including usage.
|
||||||
|
type JobCompletedPayload struct {
|
||||||
|
JobID int `json:"job_id"`
|
||||||
|
JobName string `json:"job_name"`
|
||||||
|
PrinterID int `json:"printer_id"`
|
||||||
|
DurationSeconds *int `json:"duration_seconds,omitempty"`
|
||||||
|
TotalGramsUsed *float64 `json:"total_grams_used,omitempty"`
|
||||||
|
TotalCostUSD *float64 `json:"total_cost_usd,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilamentLowPayload alerts that a spool is below its threshold.
|
||||||
|
type FilamentLowPayload struct {
|
||||||
|
SpoolID int `json:"spool_id"`
|
||||||
|
SpoolName string `json:"spool_name"`
|
||||||
|
RemainingGrams int `json:"remaining_grams"`
|
||||||
|
ThresholdGrams int `json:"threshold_grams"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEvent creates an Event with the current timestamp from a typed payload.
|
||||||
|
func NewEvent(eventType EventType, payload any) (Event, error) {
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return Event{}, err
|
||||||
|
}
|
||||||
|
return Event{
|
||||||
|
Type: eventType,
|
||||||
|
Payload: raw,
|
||||||
|
Timestamp: time.Now().UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustEvent creates an Event and panics on marshal failure (for use with
|
||||||
|
// known-good payloads in tests and internal wiring).
|
||||||
|
func MustEvent(eventType EventType, payload any) Event {
|
||||||
|
ev, err := NewEvent(eventType, payload)
|
||||||
|
if err != nil {
|
||||||
|
panic("sse.MustEvent: failed to marshal payload: " + err.Error())
|
||||||
|
}
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
|
// toSSE formats this Event as a standard SSE message string ready to be
|
||||||
|
// written to a response writer. The format is:
|
||||||
|
//
|
||||||
|
// event: <type>
|
||||||
|
// data: <json>
|
||||||
|
//
|
||||||
|
func (e Event) toSSE() string {
|
||||||
|
data, _ := json.Marshal(e)
|
||||||
|
return "event: " + string(e.Type) + "\n" + "data: " + string(data) + "\n\n"
|
||||||
|
}
|
||||||
59
backend/internal/sse/handler.go
Normal file
59
backend/internal/sse/handler.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package sse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler is the HTTP handler for the GET /api/events SSE stream.
|
||||||
|
// It registers a client with the broadcaster, streams events as they arrive,
|
||||||
|
// and unregisters on disconnect.
|
||||||
|
type Handler struct {
|
||||||
|
bc *Broadcaster
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a Handler backed by the given Broadcaster.
|
||||||
|
func NewHandler(bc *Broadcaster) *Handler {
|
||||||
|
return &Handler{bc: bc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements the SSE streaming endpoint.
|
||||||
|
// Flusher is required; clients that do not support flushing receive a 501.
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming not supported", http.StatusNotImplemented)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE-specific headers
|
||||||
|
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") // Disable nginx buffering
|
||||||
|
|
||||||
|
// Write headers immediately
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
// Subscribe to the broadcaster
|
||||||
|
ch := h.bc.Subscribe()
|
||||||
|
defer h.bc.Unsubscribe(ch)
|
||||||
|
|
||||||
|
// Use request context for cancellation when the client disconnects.
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case msg, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := w.Write([]byte(msg))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user