package jobs import ( "encoding/json" "fmt" "net/http" "sync" "sync/atomic" "github.com/labstack/echo/v4" ) // SSEBridge provides an HTTP handler that bridges NATS progress events to SSE. // This follows the notetaker pattern: subscribe to NATS, forward to SSE client. func (d *Dispatcher) SSEHandler() echo.HandlerFunc { return func(c echo.Context) error { jobID := c.Param("id") if jobID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "job ID required"}) } // Check flusher support before writing any headers flusher, ok := c.Response().Writer.(http.Flusher) if !ok { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "streaming not supported"}) } // Set SSE headers c.Response().Header().Set("Content-Type", "text/event-stream") c.Response().Header().Set("Cache-Control", "no-cache") c.Response().Header().Set("Connection", "keep-alive") c.Response().WriteHeader(http.StatusOK) // Thread-safe event writer with close guard to prevent writes after handler returns var mu sync.Mutex var closed atomic.Bool sendEvent := func(event string, data any) { if closed.Load() { return } jsonData, err := json.Marshal(data) if err != nil { return } mu.Lock() defer mu.Unlock() fmt.Fprintf(c.Response(), "event: %s\ndata: %s\n\n", event, jsonData) flusher.Flush() } // Send current job state first job, err := d.store.GetJob(jobID) if err == nil { sendEvent("status", ProgressEvent{ JobID: jobID, Status: job.Status, }) // If already terminal, send done and close — no need to subscribe if job.Status == "completed" || job.Status == "failed" || job.Status == "cancelled" { sendEvent("done", ProgressEvent{ JobID: jobID, Status: job.Status, }) return nil } } // done is closed when a terminal state event is received, so the // handler can return promptly instead of waiting for client disconnect. done := make(chan struct{}) closeOnce := sync.Once{} // Subscribe to progress events for this job sub, err := d.SubscribeProgress(jobID, func(evt ProgressEvent) { sendEvent("progress", evt) // Close the stream on terminal states if evt.Status == "completed" || evt.Status == "failed" || evt.Status == "cancelled" { sendEvent("done", evt) closeOnce.Do(func() { close(done) }) } }) if err != nil { // Headers already written as SSE — cannot send JSON error; use SSE event instead sendEvent("error", map[string]string{"error": "failed to subscribe"}) return nil } // Wait for client disconnect or terminal state select { case <-c.Request().Context().Done(): case <-done: } closed.Store(true) sub.Unsubscribe() return nil } }