From 79c055421e7bd4c2fe559f020cd6578f3baf0b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=BB=E7=AB=8B=E9=91=AB?= Date: Wed, 21 Oct 2026 00:00:23 +0800 Subject: [PATCH] fix: encode events that have no data A heartbeat event with a nil Data field panicked in reflect.Value.Kind. Write an empty data field instead. --- sse-encoder.go | 258 ++++++++++++----------- sse_test.go | 539 +++++++++++++++++++++++++------------------------ 2 files changed, 412 insertions(+), 385 deletions(-) diff --git a/sse-encoder.go b/sse-encoder.go index 1ef5b89..40d12bd 100644 --- a/sse-encoder.go +++ b/sse-encoder.go @@ -1,122 +1,136 @@ -// Copyright 2014 Manu Martinez-Almeida. All rights reserved. -// Use of this source code is governed by a MIT style -// license that can be found in the LICENSE file. - -package sse - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "reflect" - "strconv" - "strings" -) - -// Server-Sent Events -// W3C Working Draft 29 October 2009 -// http://www.w3.org/TR/2009/WD-eventsource-20091029/ - -const ContentType = "text/event-stream;charset=utf-8" - -var ( - contentType = []string{ContentType} - noCache = []string{"no-cache"} -) - -var fieldReplacer = strings.NewReplacer( - "\n", "\\n", - "\r", "\\r", -) - -var dataReplacer = strings.NewReplacer( - "\n", "\ndata:", - "\r", "\\r", -) - -type Event struct { - Event string - Id string //nolint:staticcheck // ST1003: public API field Id kept for backward compatibility - Retry uint - Data any -} - -func Encode(writer io.Writer, event Event) error { - w := checkWriter(writer) - writeID(w, event.Id) - writeEvent(w, event.Event) - writeRetry(w, event.Retry) - return writeData(w, event.Data) -} - -func writeID(w stringWriter, id string) { - if len(id) > 0 { - _, _ = w.WriteString("id:") - _, _ = fieldReplacer.WriteString(w, id) - _, _ = w.WriteString("\n") - } -} - -func writeEvent(w stringWriter, event string) { - if len(event) > 0 { - _, _ = w.WriteString("event:") - _, _ = fieldReplacer.WriteString(w, event) - _, _ = w.WriteString("\n") - } -} - -func writeRetry(w stringWriter, retry uint) { - if retry > 0 { - _, _ = w.WriteString("retry:") - _, _ = w.WriteString(strconv.FormatUint(uint64(retry), 10)) - _, _ = w.WriteString("\n") - } -} - -func writeData(w stringWriter, data any) error { - _, _ = w.WriteString("data:") - - bData, ok := data.([]byte) - if ok { - _, _ = dataReplacer.WriteString(w, string(bData)) - _, _ = w.WriteString("\n\n") - return nil - } - - switch kindOfData(data) { //nolint:exhaustive // remaining kinds need no special encoding - case reflect.Struct, reflect.Slice, reflect.Map: - err := json.NewEncoder(w).Encode(data) - if err != nil { - return err - } - _, _ = w.WriteString("\n") - default: - _, _ = dataReplacer.WriteString(w, fmt.Sprint(data)) - _, _ = w.WriteString("\n\n") - } - return nil -} - -func (r Event) Render(w http.ResponseWriter) error { - r.WriteContentType(w) - return Encode(w, r) -} - -func (r Event) WriteContentType(w http.ResponseWriter) { - header := w.Header() - header["Content-Type"] = contentType - - if _, exist := header["Cache-Control"]; !exist { - header["Cache-Control"] = noCache - } -} - -func kindOfData(data any) reflect.Kind { - value := reflect.ValueOf(data) - valueType := value.Kind() - if valueType == reflect.Pointer { - valueType = value.Elem().Kind() - } - return valueType -} +// Copyright 2014 Manu Martinez-Almeida. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package sse + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "reflect" + "strconv" + "strings" +) + +// Server-Sent Events +// W3C Working Draft 29 October 2009 +// http://www.w3.org/TR/2009/WD-eventsource-20091029/ + +const ContentType = "text/event-stream;charset=utf-8" + +var ( + contentType = []string{ContentType} + noCache = []string{"no-cache"} +) + +var fieldReplacer = strings.NewReplacer( + "\n", "\\n", + "\r", "\\r", +) + +var dataReplacer = strings.NewReplacer( + "\n", "\ndata:", + "\r", "\\r", +) + +type Event struct { + Event string + Id string //nolint:staticcheck // ST1003: public API field Id kept for backward compatibility + Retry uint + Data any +} + +func Encode(writer io.Writer, event Event) error { + w := checkWriter(writer) + writeID(w, event.Id) + writeEvent(w, event.Event) + writeRetry(w, event.Retry) + return writeData(w, event.Data) +} + +func writeID(w stringWriter, id string) { + if len(id) > 0 { + _, _ = w.WriteString("id:") + _, _ = fieldReplacer.WriteString(w, id) + _, _ = w.WriteString("\n") + } +} + +func writeEvent(w stringWriter, event string) { + if len(event) > 0 { + _, _ = w.WriteString("event:") + _, _ = fieldReplacer.WriteString(w, event) + _, _ = w.WriteString("\n") + } +} + +func writeRetry(w stringWriter, retry uint) { + if retry > 0 { + _, _ = w.WriteString("retry:") + _, _ = w.WriteString(strconv.FormatUint(uint64(retry), 10)) + _, _ = w.WriteString("\n") + } +} + +func writeData(w stringWriter, data any) error { + _, _ = w.WriteString("data:") + + // A heartbeat often sets only Event. nil must not reach reflect.Value.Kind. + if data == nil { + _, _ = w.WriteString("\n\n") + return nil + } + + bData, ok := data.([]byte) + if ok { + _, _ = dataReplacer.WriteString(w, string(bData)) + _, _ = w.WriteString("\n\n") + return nil + } + + switch kindOfData(data) { //nolint:exhaustive // remaining kinds need no special encoding + case reflect.Invalid: + _, _ = w.WriteString("\n\n") + case reflect.Struct, reflect.Slice, reflect.Map: + err := json.NewEncoder(w).Encode(data) + if err != nil { + return err + } + _, _ = w.WriteString("\n") + default: + _, _ = dataReplacer.WriteString(w, fmt.Sprint(data)) + _, _ = w.WriteString("\n\n") + } + return nil +} + +func (r Event) Render(w http.ResponseWriter) error { + r.WriteContentType(w) + return Encode(w, r) +} + +func (r Event) WriteContentType(w http.ResponseWriter) { + header := w.Header() + header["Content-Type"] = contentType + + if _, exist := header["Cache-Control"]; !exist { + header["Cache-Control"] = noCache + } +} + +func kindOfData(data any) reflect.Kind { + value := reflect.ValueOf(data) + if !value.IsValid() { + return reflect.Invalid + } + valueType := value.Kind() + if valueType == reflect.Pointer { + if value.IsNil() { + return reflect.Invalid + } + valueType = value.Elem().Kind() + } + return valueType +} diff --git a/sse_test.go b/sse_test.go index 1e992c1..682425d 100644 --- a/sse_test.go +++ b/sse_test.go @@ -1,263 +1,276 @@ -// Copyright 2014 Manu Martinez-Almeida. All rights reserved. -// Use of this source code is governed by a MIT style -// license that can be found in the LICENSE file. - -package sse - -import ( - "bytes" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const ( - testFooKey = "foo" - testBarKey = "bar" - testNewMessage = "new_message" - testBenchmarkData = "hi! how are you? I am fine. this is a long stupid message!!!" -) - -func TestEncodeOnlyData(t *testing.T) { - w := new(bytes.Buffer) - event := Event{ - Data: "junk\n\njk\nid:fake", - } - err := Encode(w, event) - require.NoError(t, err) - assert.Equal(t, `data:junk -data: -data:jk -data:id:fake - -`, w.String()) - - decoded, _ := Decode(w) - assert.Equal(t, "message", decoded[0].Event) - assert.Equal(t, decoded[0].Data, []Event{event}[0].Data) -} - -func TestEncodeWithEvent(t *testing.T) { - w := new(bytes.Buffer) - event := Event{ - Event: "t\n:<>\r\test", - Data: "junk\n\njk\nid:fake", - } - err := Encode(w, event) - require.NoError(t, err) - assert.Equal(t, `event:t\n:<>\r est -data:junk -data: -data:jk -data:id:fake - -`, w.String()) - - decoded, _ := Decode(w) - assert.Equal(t, "t\\n:<>\\r\test", decoded[0].Event) - assert.Equal(t, decoded[0].Data, []Event{event}[0].Data) -} - -func TestEncodeWithId(t *testing.T) { - w := new(bytes.Buffer) - err := Encode(w, Event{ - Id: "t\n:<>\r\test", - Data: "junk\n\njk\nid:fa\rke", - }) - require.NoError(t, err) - assert.Equal(t, `id:t\n:<>\r est -data:junk -data: -data:jk -data:id:fa\rke - -`, w.String()) -} - -func TestEncodeWithRetry(t *testing.T) { - w := new(bytes.Buffer) - err := Encode(w, Event{ - Retry: 11, - Data: "junk\n\njk\nid:fake\n", - }) - require.NoError(t, err) - assert.Equal(t, `retry:11 -data:junk -data: -data:jk -data:id:fake -data: - -`, w.String()) -} - -func TestEncodeWithEverything(t *testing.T) { - w := new(bytes.Buffer) - err := Encode(w, Event{ - Event: "abc", - Id: "12345", - Retry: 10, - Data: "some data", - }) - require.NoError(t, err) - assert.Equal(t, "id:12345\nevent:abc\nretry:10\ndata:some data\n\n", w.String()) -} - -func TestEncodeMap(t *testing.T) { - w := new(bytes.Buffer) - err := Encode(w, Event{ - Event: "a map", - Data: map[string]any{ - testFooKey: "b\n\rar", - testBarKey: "id: 2", - }, - }) - require.NoError(t, err) - assert.Equal(t, "event:a map\ndata:{\"bar\":\"id: 2\",\"foo\":\"b\\n\\rar\"}\n\n", w.String()) -} - -func TestEncodeSlice(t *testing.T) { - w := new(bytes.Buffer) - err := Encode(w, Event{ - Event: "a slice", - Data: []any{1, "text", map[string]any{testFooKey: testBarKey}}, - }) - require.NoError(t, err) - assert.Equal(t, "event:a slice\ndata:[1,\"text\",{\"foo\":\"bar\"}]\n\n", w.String()) -} - -func TestEncodeStruct(t *testing.T) { - myStruct := struct { - A int - B string `json:"value"` - }{1, "number"} - - w := new(bytes.Buffer) - err := Encode(w, Event{ - Event: "a struct", - Data: myStruct, - }) - require.NoError(t, err) - assert.Equal(t, "event:a struct\ndata:{\"A\":1,\"value\":\"number\"}\n\n", w.String()) - - w.Reset() - err = Encode(w, Event{ - Event: "a struct", - Data: &myStruct, - }) - require.NoError(t, err) - assert.Equal(t, "event:a struct\ndata:{\"A\":1,\"value\":\"number\"}\n\n", w.String()) -} - -func TestEncodeInteger(t *testing.T) { - w := new(bytes.Buffer) - err := Encode(w, Event{ - Event: "an integer", - Data: 1, - }) - require.NoError(t, err) - assert.Equal(t, "event:an integer\ndata:1\n\n", w.String()) -} - -func TestEncodeFloat(t *testing.T) { - w := new(bytes.Buffer) - err := Encode(w, Event{ - Event: "Float", - Data: 1.5, - }) - require.NoError(t, err) - assert.Equal(t, "event:Float\ndata:1.5\n\n", w.String()) -} - -func TestEncodeStream(t *testing.T) { - w := new(bytes.Buffer) - - _ = Encode(w, Event{ - Event: "float", - Data: 1.5, - }) - - _ = Encode(w, Event{ - Id: "123", - Data: map[string]any{testFooKey: testBarKey, testBarKey: testFooKey}, - }) - - _ = Encode(w, Event{ - Id: "124", - Event: "chat", - Data: "hi! dude", - }) - assert.Equal(t, "event:float\ndata:1.5\n\n"+ - "id:123\ndata:{\"bar\":\"foo\",\"foo\":\"bar\"}\n\n"+ - "id:124\nevent:chat\ndata:hi! dude\n\n", w.String()) -} - -func TestRenderSSE(t *testing.T) { - w := httptest.NewRecorder() - - err := (Event{ - Event: "msg", - Data: "hi! how are you?", - }).Render(w) - - require.NoError(t, err) - assert.Equal(t, "event:msg\ndata:hi! how are you?\n\n", w.Body.String()) - assert.Equal(t, "text/event-stream;charset=utf-8", w.Header().Get("Content-Type")) - assert.Equal(t, "no-cache", w.Header().Get("Cache-Control")) -} - -func BenchmarkResponseWriter(b *testing.B) { - w := httptest.NewRecorder() - b.ResetTimer() - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _ = (Event{ - Event: testNewMessage, - Data: testBenchmarkData, - }).Render(w) - } -} - -func BenchmarkFullSSE(b *testing.B) { - buf := new(bytes.Buffer) - b.ResetTimer() - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _ = Encode(buf, Event{ - Event: testNewMessage, - Id: "13435", - Retry: 10, - Data: testBenchmarkData, - }) - buf.Reset() - } -} - -func BenchmarkNoRetrySSE(b *testing.B) { - buf := new(bytes.Buffer) - b.ResetTimer() - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _ = Encode(buf, Event{ - Event: testNewMessage, - Id: "13435", - Data: testBenchmarkData, - }) - buf.Reset() - } -} - -func BenchmarkSimpleSSE(b *testing.B) { - buf := new(bytes.Buffer) - b.ResetTimer() - b.ReportAllocs() - for i := 0; i < b.N; i++ { - _ = Encode(buf, Event{ - Event: testNewMessage, - Data: testBenchmarkData, - }) - buf.Reset() - } -} +// Copyright 2014 Manu Martinez-Almeida. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package sse + +import ( + "bytes" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testFooKey = "foo" + testBarKey = "bar" + testNewMessage = "new_message" + testBenchmarkData = "hi! how are you? I am fine. this is a long stupid message!!!" +) + +func TestEncodeNilData(t *testing.T) { + w := new(bytes.Buffer) + err := Encode(w, Event{Event: "ping"}) + require.NoError(t, err) + assert.Equal(t, "event:ping\ndata:\n\n", w.String()) + + type payload struct{ Name string } + w.Reset() + err = Encode(w, Event{Data: (*payload)(nil)}) + require.NoError(t, err) + assert.Equal(t, "data:\n\n", w.String()) +} + +func TestEncodeOnlyData(t *testing.T) { + w := new(bytes.Buffer) + event := Event{ + Data: "junk\n\njk\nid:fake", + } + err := Encode(w, event) + require.NoError(t, err) + assert.Equal(t, `data:junk +data: +data:jk +data:id:fake + +`, w.String()) + + decoded, _ := Decode(w) + assert.Equal(t, "message", decoded[0].Event) + assert.Equal(t, decoded[0].Data, []Event{event}[0].Data) +} + +func TestEncodeWithEvent(t *testing.T) { + w := new(bytes.Buffer) + event := Event{ + Event: "t\n:<>\r\test", + Data: "junk\n\njk\nid:fake", + } + err := Encode(w, event) + require.NoError(t, err) + assert.Equal(t, `event:t\n:<>\r est +data:junk +data: +data:jk +data:id:fake + +`, w.String()) + + decoded, _ := Decode(w) + assert.Equal(t, "t\\n:<>\\r\test", decoded[0].Event) + assert.Equal(t, decoded[0].Data, []Event{event}[0].Data) +} + +func TestEncodeWithId(t *testing.T) { + w := new(bytes.Buffer) + err := Encode(w, Event{ + Id: "t\n:<>\r\test", + Data: "junk\n\njk\nid:fa\rke", + }) + require.NoError(t, err) + assert.Equal(t, `id:t\n:<>\r est +data:junk +data: +data:jk +data:id:fa\rke + +`, w.String()) +} + +func TestEncodeWithRetry(t *testing.T) { + w := new(bytes.Buffer) + err := Encode(w, Event{ + Retry: 11, + Data: "junk\n\njk\nid:fake\n", + }) + require.NoError(t, err) + assert.Equal(t, `retry:11 +data:junk +data: +data:jk +data:id:fake +data: + +`, w.String()) +} + +func TestEncodeWithEverything(t *testing.T) { + w := new(bytes.Buffer) + err := Encode(w, Event{ + Event: "abc", + Id: "12345", + Retry: 10, + Data: "some data", + }) + require.NoError(t, err) + assert.Equal(t, "id:12345\nevent:abc\nretry:10\ndata:some data\n\n", w.String()) +} + +func TestEncodeMap(t *testing.T) { + w := new(bytes.Buffer) + err := Encode(w, Event{ + Event: "a map", + Data: map[string]any{ + testFooKey: "b\n\rar", + testBarKey: "id: 2", + }, + }) + require.NoError(t, err) + assert.Equal(t, "event:a map\ndata:{\"bar\":\"id: 2\",\"foo\":\"b\\n\\rar\"}\n\n", w.String()) +} + +func TestEncodeSlice(t *testing.T) { + w := new(bytes.Buffer) + err := Encode(w, Event{ + Event: "a slice", + Data: []any{1, "text", map[string]any{testFooKey: testBarKey}}, + }) + require.NoError(t, err) + assert.Equal(t, "event:a slice\ndata:[1,\"text\",{\"foo\":\"bar\"}]\n\n", w.String()) +} + +func TestEncodeStruct(t *testing.T) { + myStruct := struct { + A int + B string `json:"value"` + }{1, "number"} + + w := new(bytes.Buffer) + err := Encode(w, Event{ + Event: "a struct", + Data: myStruct, + }) + require.NoError(t, err) + assert.Equal(t, "event:a struct\ndata:{\"A\":1,\"value\":\"number\"}\n\n", w.String()) + + w.Reset() + err = Encode(w, Event{ + Event: "a struct", + Data: &myStruct, + }) + require.NoError(t, err) + assert.Equal(t, "event:a struct\ndata:{\"A\":1,\"value\":\"number\"}\n\n", w.String()) +} + +func TestEncodeInteger(t *testing.T) { + w := new(bytes.Buffer) + err := Encode(w, Event{ + Event: "an integer", + Data: 1, + }) + require.NoError(t, err) + assert.Equal(t, "event:an integer\ndata:1\n\n", w.String()) +} + +func TestEncodeFloat(t *testing.T) { + w := new(bytes.Buffer) + err := Encode(w, Event{ + Event: "Float", + Data: 1.5, + }) + require.NoError(t, err) + assert.Equal(t, "event:Float\ndata:1.5\n\n", w.String()) +} + +func TestEncodeStream(t *testing.T) { + w := new(bytes.Buffer) + + _ = Encode(w, Event{ + Event: "float", + Data: 1.5, + }) + + _ = Encode(w, Event{ + Id: "123", + Data: map[string]any{testFooKey: testBarKey, testBarKey: testFooKey}, + }) + + _ = Encode(w, Event{ + Id: "124", + Event: "chat", + Data: "hi! dude", + }) + assert.Equal(t, "event:float\ndata:1.5\n\n"+ + "id:123\ndata:{\"bar\":\"foo\",\"foo\":\"bar\"}\n\n"+ + "id:124\nevent:chat\ndata:hi! dude\n\n", w.String()) +} + +func TestRenderSSE(t *testing.T) { + w := httptest.NewRecorder() + + err := (Event{ + Event: "msg", + Data: "hi! how are you?", + }).Render(w) + + require.NoError(t, err) + assert.Equal(t, "event:msg\ndata:hi! how are you?\n\n", w.Body.String()) + assert.Equal(t, "text/event-stream;charset=utf-8", w.Header().Get("Content-Type")) + assert.Equal(t, "no-cache", w.Header().Get("Cache-Control")) +} + +func BenchmarkResponseWriter(b *testing.B) { + w := httptest.NewRecorder() + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = (Event{ + Event: testNewMessage, + Data: testBenchmarkData, + }).Render(w) + } +} + +func BenchmarkFullSSE(b *testing.B) { + buf := new(bytes.Buffer) + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = Encode(buf, Event{ + Event: testNewMessage, + Id: "13435", + Retry: 10, + Data: testBenchmarkData, + }) + buf.Reset() + } +} + +func BenchmarkNoRetrySSE(b *testing.B) { + buf := new(bytes.Buffer) + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = Encode(buf, Event{ + Event: testNewMessage, + Id: "13435", + Data: testBenchmarkData, + }) + buf.Reset() + } +} + +func BenchmarkSimpleSSE(b *testing.B) { + buf := new(bytes.Buffer) + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = Encode(buf, Event{ + Event: testNewMessage, + Data: testBenchmarkData, + }) + buf.Reset() + } +}