Skip to content

Commit 4b37248

Browse files
committed
feat: add InitSSE(), SSEStream() and fix deprecated CloseNotifier in Stream()
1 parent 5f4f964 commit 4b37248

3 files changed

Lines changed: 162 additions & 11 deletions

File tree

context.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1315,19 +1315,87 @@ func (c *Context) FileAttachment(filepath, filename string) {
13151315
http.ServeFile(c.Writer, c.Request, filepath)
13161316
}
13171317

1318+
// InitSSE prepares the response for a Server-Sent Events stream by setting the
1319+
// required HTTP headers: Content-Type is set to "text/event-stream",
1320+
// Cache-Control to "no-cache", and Connection to "keep-alive".
1321+
// The headers are flushed to the client immediately so that the browser opens
1322+
// the stream before the first event is sent.
1323+
//
1324+
// Call this once at the beginning of your SSE handler, before any SSEvent call:
1325+
//
1326+
// router.GET("/stream", func(c *gin.Context) {
1327+
// c.InitSSE()
1328+
// for i := range 5 {
1329+
// c.SSEvent("message", i)
1330+
// c.Writer.Flush()
1331+
// }
1332+
// })
1333+
func (c *Context) InitSSE() {
1334+
c.Writer.Header().Set("Content-Type", sse.ContentType)
1335+
c.Writer.Header().Set("Cache-Control", "no-cache")
1336+
c.Writer.Header().Set("Connection", "keep-alive")
1337+
c.Writer.WriteHeaderNow()
1338+
}
1339+
13181340
// SSEvent writes a Server-Sent Event into the body stream.
1341+
// It sets Content-Type and Cache-Control headers on the first call if they have
1342+
// not already been set (e.g. by InitSSE). The writer is NOT flushed automatically;
1343+
// call c.Writer.Flush() after each event to push it to the client immediately.
1344+
// To include the optional id or retry fields use c.Render(-1, sse.Event{…}) directly.
13191345
func (c *Context) SSEvent(name string, message any) {
13201346
c.Render(-1, sse.Event{
13211347
Event: name,
13221348
Data: message,
13231349
})
13241350
}
13251351

1352+
// SSEStream initializes an SSE response and calls step in a loop to send events
1353+
// until either the client disconnects or step returns false.
1354+
//
1355+
// It returns true when the client disconnected (c.Request.Context() was cancelled)
1356+
// and false when step returned false (normal end-of-stream).
1357+
//
1358+
// The writer is flushed automatically after every successful step call.
1359+
// step receives the current Context so it can call c.SSEvent, c.Render, or
1360+
// inspect c.Request.Context().Done() for its own blocking select:
1361+
//
1362+
// router.GET("/events", func(c *gin.Context) {
1363+
// ch := make(chan string)
1364+
// go produce(ch)
1365+
// c.SSEStream(func(c *gin.Context) bool {
1366+
// select {
1367+
// case msg, ok := <-ch:
1368+
// if !ok {
1369+
// return false // channel closed → end stream normally
1370+
// }
1371+
// c.SSEvent("message", msg)
1372+
// return true
1373+
// case <-c.Request.Context().Done():
1374+
// return false // client gone → end stream
1375+
// }
1376+
// })
1377+
// })
1378+
func (c *Context) SSEStream(step func(c *Context) bool) bool {
1379+
c.InitSSE()
1380+
ctx := c.Request.Context()
1381+
for {
1382+
select {
1383+
case <-ctx.Done():
1384+
return true
1385+
default:
1386+
if !step(c) {
1387+
return false
1388+
}
1389+
c.Writer.Flush()
1390+
}
1391+
}
1392+
}
1393+
13261394
// Stream sends a streaming response and returns a boolean
13271395
// indicates "Is client disconnected in middle of stream"
13281396
func (c *Context) Stream(step func(w io.Writer) bool) bool {
13291397
w := c.Writer
1330-
clientGone := w.CloseNotify()
1398+
clientGone := c.Request.Context().Done()
13311399
for {
13321400
select {
13331401
case <-clientGone:

context_test.go

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1441,6 +1441,58 @@ func TestContextRenderSSE(t *testing.T) {
14411441
assert.Equal(t, strings.ReplaceAll(w.Body.String(), " ", ""), strings.ReplaceAll("event:float\ndata:1.5\n\nid:123\ndata:text\n\nevent:chat\ndata:{\"bar\":\"foo\",\"foo\":\"bar\"}\n\n", " ", ""))
14421442
}
14431443

1444+
func TestContextInitSSE(t *testing.T) {
1445+
w := httptest.NewRecorder()
1446+
c, _ := CreateTestContext(w)
1447+
c.Request, _ = http.NewRequest(http.MethodGet, "/", nil)
1448+
1449+
c.InitSSE()
1450+
1451+
assert.Equal(t, sse.ContentType, w.Header().Get("Content-Type"))
1452+
assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
1453+
assert.Equal(t, "keep-alive", w.Header().Get("Connection"))
1454+
assert.Equal(t, http.StatusOK, w.Code)
1455+
}
1456+
1457+
func TestContextSSEStreamNormalEnd(t *testing.T) {
1458+
w := httptest.NewRecorder()
1459+
c, _ := CreateTestContext(w)
1460+
c.Request, _ = http.NewRequest(http.MethodGet, "/", nil)
1461+
1462+
count := 0
1463+
disconnected := c.SSEStream(func(c *Context) bool {
1464+
count++
1465+
c.SSEvent("ping", count)
1466+
return count < 3
1467+
})
1468+
1469+
assert.False(t, disconnected)
1470+
assert.Equal(t, 3, count)
1471+
assert.Equal(t, sse.ContentType, w.Header().Get("Content-Type"))
1472+
assert.Equal(t, "no-cache", w.Header().Get("Cache-Control"))
1473+
assert.Equal(t, "keep-alive", w.Header().Get("Connection"))
1474+
assert.Contains(t, w.Body.String(), "event:ping")
1475+
}
1476+
1477+
func TestContextSSEStreamClientDisconnect(t *testing.T) {
1478+
w := httptest.NewRecorder()
1479+
c, _ := CreateTestContext(w)
1480+
1481+
ctx, cancel := context.WithCancel(context.Background())
1482+
defer cancel()
1483+
c.Request, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
1484+
1485+
// step watches its own context so the result is deterministic:
1486+
// cancel() guarantees Done() is closed before the receive.
1487+
result := c.SSEStream(func(c *Context) bool {
1488+
cancel() // trigger cancellation
1489+
<-c.Request.Context().Done()
1490+
return false // step returns false → SSEStream returns false
1491+
})
1492+
1493+
assert.False(t, result)
1494+
}
1495+
14441496
func TestContextRenderFile(t *testing.T) {
14451497
w := httptest.NewRecorder()
14461498
c, _ := CreateTestContext(w)
@@ -3030,10 +3082,6 @@ func (r *TestResponseRecorder) CloseNotify() <-chan bool {
30303082
return r.closeChannel
30313083
}
30323084

3033-
func (r *TestResponseRecorder) closeClient() {
3034-
r.closeChannel <- true
3035-
}
3036-
30373085
func CreateTestResponseRecorder() *TestResponseRecorder {
30383086
return &TestResponseRecorder{
30393087
httptest.NewRecorder(),
@@ -3044,6 +3092,7 @@ func CreateTestResponseRecorder() *TestResponseRecorder {
30443092
func TestContextStream(t *testing.T) {
30453093
w := CreateTestResponseRecorder()
30463094
c, _ := CreateTestContext(w)
3095+
c.Request, _ = http.NewRequest(http.MethodGet, "/", nil)
30473096

30483097
stopStream := true
30493098
c.Stream(func(w io.Writer) bool {
@@ -3064,17 +3113,21 @@ func TestContextStreamWithClientGone(t *testing.T) {
30643113
w := CreateTestResponseRecorder()
30653114
c, _ := CreateTestContext(w)
30663115

3067-
c.Stream(func(writer io.Writer) bool {
3068-
defer func() {
3069-
w.closeClient()
3070-
}()
3116+
ctx, cancel := context.WithCancel(context.Background())
3117+
defer cancel()
3118+
c.Request, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
30713119

3120+
// step detects ctx cancellation via a direct channel receive and returns false,
3121+
// so Stream terminates. This tests the context-based disconnect path.
3122+
result := c.Stream(func(writer io.Writer) bool {
30723123
_, err := writer.Write([]byte("test"))
30733124
require.NoError(t, err)
3074-
3075-
return true
3125+
cancel()
3126+
<-ctx.Done()
3127+
return false
30763128
})
30773129

3130+
assert.False(t, result)
30783131
assert.Equal(t, "test", w.Body.String())
30793132
}
30803133

docs/doc.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1879,6 +1879,36 @@ func main() {
18791879
}
18801880
```
18811881

1882+
### Server-Sent Events (SSE)
1883+
1884+
Use `c.InitSSE()` to set the required headers, then `c.SSEvent()` + `c.Writer.Flush()` to push events:
1885+
1886+
```go
1887+
router.GET("/stream", func(c *gin.Context) {
1888+
c.InitSSE()
1889+
for i := range 5 {
1890+
c.SSEvent("message", gin.H{"count": i})
1891+
c.Writer.Flush()
1892+
}
1893+
})
1894+
```
1895+
1896+
For a long-running stream that stops when the client disconnects, use `c.SSEStream()`:
1897+
1898+
```go
1899+
router.GET("/stream", func(c *gin.Context) {
1900+
i := 0
1901+
c.SSEStream(func(c *gin.Context) bool {
1902+
i++
1903+
c.SSEvent("message", gin.H{"count": i})
1904+
return i < 10 // return false to end the stream normally
1905+
})
1906+
})
1907+
```
1908+
1909+
`SSEStream` returns `true` if the client disconnected mid-stream, `false` if the step
1910+
function ended the stream by returning `false`.
1911+
18821912
### HTML rendering
18831913

18841914
Using LoadHTMLGlob() or LoadHTMLFiles() or LoadHTMLFS()

0 commit comments

Comments
 (0)