Skip to content

Commit a676efe

Browse files
committed
Add GET /events SSE endpoint (Phase 2)
Implements handleSSE which streams server-sent events to connected clients. Sets SSE headers, subscribes to the broadcaster, and writes "data: update\n\n" after each debounced publish signal. The endpoint is unauthenticated. Also adds Flush() to the responseWriter middleware wrapper so the http.Flusher assertion in the handler succeeds through the middleware chain. Built with Raymond (Agent Orchestrator)
1 parent 247eaac commit a676efe

3 files changed

Lines changed: 161 additions & 0 deletions

File tree

internal/server/handlers_sse.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package server
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
)
7+
8+
// handleSSE streams server-sent events to the client. It sends a minimal
9+
// "update" event whenever the broadcaster fires, and exits when the request
10+
// context is cancelled.
11+
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
12+
flusher, ok := w.(http.Flusher)
13+
if !ok {
14+
http.Error(w, "streaming not supported", http.StatusInternalServerError)
15+
return
16+
}
17+
18+
w.Header().Set("Content-Type", "text/event-stream")
19+
w.Header().Set("Cache-Control", "no-cache")
20+
w.Header().Set("Connection", "keep-alive")
21+
22+
ch := s.broadcaster.subscribe()
23+
defer s.broadcaster.unsubscribe(ch)
24+
25+
for {
26+
select {
27+
case <-ch:
28+
fmt.Fprint(w, "data: update\n\n")
29+
flusher.Flush()
30+
case <-r.Context().Done():
31+
return
32+
}
33+
}
34+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
"time"
10+
)
11+
12+
func TestSSE_StatusAndContentType(t *testing.T) {
13+
srv := crudServer(t)
14+
15+
ctx, cancel := context.WithCancel(context.Background())
16+
defer cancel()
17+
18+
req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(ctx)
19+
w := httptest.NewRecorder()
20+
21+
done := make(chan struct{})
22+
go func() {
23+
srv.Router.ServeHTTP(w, req)
24+
close(done)
25+
}()
26+
27+
// Give the handler time to write headers.
28+
time.Sleep(50 * time.Millisecond)
29+
cancel()
30+
<-done
31+
32+
if w.Code != http.StatusOK {
33+
t.Fatalf("expected 200, got %d", w.Code)
34+
}
35+
ct := w.Header().Get("Content-Type")
36+
if ct != "text/event-stream" {
37+
t.Errorf("expected Content-Type text/event-stream, got %q", ct)
38+
}
39+
}
40+
41+
func TestSSE_ReceivesUpdateAfterPublish(t *testing.T) {
42+
srv := crudServer(t)
43+
44+
ctx, cancel := context.WithCancel(context.Background())
45+
defer cancel()
46+
47+
req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(ctx)
48+
w := httptest.NewRecorder()
49+
50+
done := make(chan struct{})
51+
go func() {
52+
srv.Router.ServeHTTP(w, req)
53+
close(done)
54+
}()
55+
56+
// Wait for handler to subscribe, then publish.
57+
time.Sleep(50 * time.Millisecond)
58+
srv.broadcaster.publish()
59+
60+
// Wait for debounce (200ms) plus a little margin.
61+
time.Sleep(300 * time.Millisecond)
62+
63+
cancel()
64+
<-done
65+
66+
body := w.Body.String()
67+
if !strings.Contains(body, "data: update\n\n") {
68+
t.Errorf("expected body to contain 'data: update\\n\\n', got %q", body)
69+
}
70+
}
71+
72+
func TestSSE_ExitsOnContextCancel(t *testing.T) {
73+
srv := crudServer(t)
74+
75+
ctx, cancel := context.WithCancel(context.Background())
76+
77+
req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(ctx)
78+
w := httptest.NewRecorder()
79+
80+
done := make(chan struct{})
81+
go func() {
82+
srv.Router.ServeHTTP(w, req)
83+
close(done)
84+
}()
85+
86+
time.Sleep(50 * time.Millisecond)
87+
cancel()
88+
89+
select {
90+
case <-done:
91+
// Handler exited cleanly.
92+
case <-time.After(2 * time.Second):
93+
t.Fatal("handler did not exit after context cancellation")
94+
}
95+
}
96+
97+
func TestSSE_NoAuthRequired(t *testing.T) {
98+
srv := crudServer(t)
99+
100+
ctx, cancel := context.WithCancel(context.Background())
101+
defer cancel()
102+
103+
req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(ctx)
104+
// No Authorization header — must still succeed.
105+
w := httptest.NewRecorder()
106+
107+
done := make(chan struct{})
108+
go func() {
109+
srv.Router.ServeHTTP(w, req)
110+
close(done)
111+
}()
112+
113+
time.Sleep(50 * time.Millisecond)
114+
cancel()
115+
<-done
116+
117+
if w.Code != http.StatusOK {
118+
t.Fatalf("expected 200 without auth, got %d", w.Code)
119+
}
120+
}

internal/server/server.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func New(cfg Config, p StoreProvider) (*Server, error) {
6767
srv.Router.Get("/api/v1/health", srv.handleHealth)
6868
srv.Router.Get("/api/v1/version", srv.handleVersion)
6969
srv.Router.Get("/api/v1/beads/status", srv.handleBeadsStatus)
70+
srv.Router.Get("/events", srv.handleSSE)
7071

7172
// All other API routes require auth
7273
srv.Router.Group(func(r chi.Router) {
@@ -168,3 +169,9 @@ func (rw *responseWriter) Write(b []byte) (int, error) {
168169
}
169170
return rw.ResponseWriter.Write(b)
170171
}
172+
173+
func (rw *responseWriter) Flush() {
174+
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
175+
f.Flush()
176+
}
177+
}

0 commit comments

Comments
 (0)