├── .github ├── dependabot.yml └── workflows │ ├── bearer.yml │ ├── codeql-analysis.yml │ ├── codeql.yml │ ├── go.yml │ └── goreleaser.yml ├── .golangci.yml ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── sse-decoder.go ├── sse-decoder_test.go ├── sse-encoder.go ├── sse_test.go └── writer.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: gomod 8 | directory: / 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /.github/workflows/bearer.yml: -------------------------------------------------------------------------------- 1 | name: Bearer PR Check 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | 7 | permissions: 8 | contents: read 9 | pull-requests: write 10 | 11 | jobs: 12 | rule_check: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - uses: reviewdog/action-setup@v1 19 | with: 20 | reviewdog_version: latest 21 | 22 | - name: Run Report 23 | id: report 24 | uses: bearer/bearer-action@v2 25 | with: 26 | format: rdjson 27 | output: rd.json 28 | diff: true 29 | 30 | - name: Run reviewdog 31 | if: always() 32 | env: 33 | REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | run: | 35 | cat rd.json | reviewdog -f=rdjson -reporter=github-pr-review 36 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [master] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [master] 20 | schedule: 21 | - cron: "37 2 * * 5" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["go"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v4 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v3 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v3 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v3 72 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [master] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [master] 20 | schedule: 21 | - cron: "41 23 * * 6" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["go"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v3 55 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: "^1" 22 | 23 | - name: Setup golangci-lint 24 | uses: golangci/golangci-lint-action@v7 25 | with: 26 | version: v2.0 27 | args: --verbose 28 | 29 | - name: Bearer 30 | uses: bearer/bearer-action@v2 31 | with: 32 | diff: true 33 | 34 | test: 35 | strategy: 36 | matrix: 37 | os: [ubuntu-latest] 38 | go: [1.23, 1.24] 39 | include: 40 | - os: ubuntu-latest 41 | go-build: ~/.cache/go-build 42 | name: ${{ matrix.os }} @ Go ${{ matrix.go }} 43 | runs-on: ${{ matrix.os }} 44 | env: 45 | GO111MODULE: on 46 | GOPROXY: https://proxy.golang.org 47 | steps: 48 | - name: Set up Go ${{ matrix.go }} 49 | uses: actions/setup-go@v5 50 | with: 51 | go-version: ${{ matrix.go }} 52 | 53 | - name: Checkout Code 54 | uses: actions/checkout@v4 55 | with: 56 | ref: ${{ github.ref }} 57 | 58 | - uses: actions/cache@v4 59 | with: 60 | path: | 61 | ${{ matrix.go-build }} 62 | ~/go/pkg/mod 63 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 64 | restore-keys: | 65 | ${{ runner.os }}-go- 66 | - name: Run Tests 67 | run: | 68 | go test -v -covermode=atomic -coverprofile=coverage.out 69 | 70 | - name: Upload coverage to Codecov 71 | uses: codecov/codecov-action@v5 72 | with: 73 | flags: ${{ matrix.os }},go-${{ matrix.go }} 74 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: Goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Setup go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version-file: go.mod 24 | check-latest: true 25 | 26 | - name: Run GoReleaser 27 | uses: goreleaser/goreleaser-action@v6 28 | with: 29 | # either 'goreleaser' (default) or 'goreleaser-pro' 30 | distribution: goreleaser 31 | version: latest 32 | args: release --clean 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - bodyclose 6 | - dogsled 7 | - dupl 8 | - errcheck 9 | - exhaustive 10 | - gochecknoinits 11 | - goconst 12 | - gocritic 13 | - gocyclo 14 | - goprintffuncname 15 | - gosec 16 | - govet 17 | - ineffassign 18 | - lll 19 | - misspell 20 | - nakedret 21 | - noctx 22 | - nolintlint 23 | - rowserrcheck 24 | - staticcheck 25 | - unconvert 26 | - unparam 27 | - unused 28 | - whitespace 29 | exclusions: 30 | generated: lax 31 | presets: 32 | - comments 33 | - common-false-positives 34 | - legacy 35 | - std-error-handling 36 | paths: 37 | - third_party$ 38 | - builtin$ 39 | - examples$ 40 | formatters: 41 | enable: 42 | - gofmt 43 | - gofumpt 44 | - goimports 45 | exclusions: 46 | generated: lax 47 | paths: 48 | - third_party$ 49 | - builtin$ 50 | - examples$ 51 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | builds: 2 | - # If true, skip the build. 3 | # Useful for library projects. 4 | # Default is false 5 | skip: true 6 | 7 | changelog: 8 | use: github 9 | groups: 10 | - title: Features 11 | regexp: "^.*feat[(\\w)]*:+.*$" 12 | order: 0 13 | - title: "Bug fixes" 14 | regexp: "^.*fix[(\\w)]*:+.*$" 15 | order: 1 16 | - title: "Enhancements" 17 | regexp: "^.*chore[(\\w)]*:+.*$" 18 | order: 2 19 | - title: "Refactor" 20 | regexp: "^.*refactor[(\\w)]*:+.*$" 21 | order: 3 22 | - title: "Build process updates" 23 | regexp: ^.*?(build|ci)(\(.+\))??!?:.+$ 24 | order: 4 25 | - title: "Documentation updates" 26 | regexp: ^.*?docs?(\(.+\))??!?:.+$ 27 | order: 4 28 | - title: Others 29 | order: 999 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Manuel Martínez-Almeida 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Server-Sent Events 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/gin-contrib/sse.svg)](https://pkg.go.dev/github.com/gin-contrib/sse) 4 | [![Run Tests](https://github.com/gin-contrib/sse/actions/workflows/go.yml/badge.svg)](https://github.com/gin-contrib/sse/actions/workflows/go.yml) 5 | [![codecov](https://codecov.io/gh/gin-contrib/sse/branch/master/graph/badge.svg)](https://codecov.io/gh/gin-contrib/sse) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/gin-contrib/sse)](https://goreportcard.com/report/github.com/gin-contrib/sse) 7 | 8 | Server-sent events (SSE) is a technology where a browser receives automatic updates from a server via HTTP connection. The Server-Sent Events EventSource API is [standardized as part of HTML5[1] by the W3C](http://www.w3.org/TR/2009/WD-eventsource-20091029/). 9 | 10 | - [Read this great SSE introduction by the HTML5Rocks guys](http://www.html5rocks.com/en/tutorials/eventsource/basics/) 11 | - [Browser support](http://caniuse.com/#feat=eventsource) 12 | 13 | ## Sample code 14 | 15 | ```go 16 | import "github.com/gin-contrib/sse" 17 | 18 | func httpHandler(w http.ResponseWriter, req *http.Request) { 19 | // data can be a primitive like a string, an integer or a float 20 | sse.Encode(w, sse.Event{ 21 | Event: "message", 22 | Data: "some data\nmore data", 23 | }) 24 | 25 | // also a complex type, like a map, a struct or a slice 26 | sse.Encode(w, sse.Event{ 27 | Id: "124", 28 | Event: "message", 29 | Data: map[string]interface{}{ 30 | "user": "manu", 31 | "date": time.Now().Unix(), 32 | "content": "hi!", 33 | }, 34 | }) 35 | } 36 | ``` 37 | 38 | ```sh 39 | event: message 40 | data: some data\\nmore data 41 | 42 | id: 124 43 | event: message 44 | data: {"content":"hi!","date":1431540810,"user":"manu"} 45 | 46 | ``` 47 | 48 | ## Content-Type 49 | 50 | ```go 51 | fmt.Println(sse.ContentType) 52 | ``` 53 | 54 | ```sh 55 | text/event-stream 56 | ``` 57 | 58 | ## Decoding support 59 | 60 | There is a client-side implementation of SSE coming soon. 61 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gin-contrib/sse 2 | 3 | go 1.23 4 | 5 | require github.com/stretchr/testify v1.10.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /sse-decoder.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Manu Martinez-Almeida. All rights reserved. 2 | // Use of this source code is governed by a MIT style 3 | // license that can be found in the LICENSE file. 4 | 5 | package sse 6 | 7 | import ( 8 | "bytes" 9 | "io" 10 | ) 11 | 12 | type decoder struct { 13 | events []Event 14 | } 15 | 16 | func Decode(r io.Reader) ([]Event, error) { 17 | var dec decoder 18 | return dec.decode(r) 19 | } 20 | 21 | func (d *decoder) dispatchEvent(event Event, data string) { 22 | dataLength := len(data) 23 | if dataLength > 0 { 24 | // If the data buffer's last character is a U+000A LINE FEED (LF) character, 25 | // then remove the last character from the data buffer. 26 | data = data[:dataLength-1] 27 | dataLength-- 28 | } 29 | if dataLength == 0 && event.Event == "" { 30 | return 31 | } 32 | if event.Event == "" { 33 | event.Event = "message" 34 | } 35 | event.Data = data 36 | d.events = append(d.events, event) 37 | } 38 | 39 | func (d *decoder) decode(r io.Reader) ([]Event, error) { 40 | buf, err := io.ReadAll(r) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | var currentEvent Event 46 | dataBuffer := new(bytes.Buffer) 47 | // TODO (and unit tests) 48 | // Lines must be separated by either a U+000D CARRIAGE RETURN U+000A LINE FEED (CRLF) character pair, 49 | // a single U+000A LINE FEED (LF) character, 50 | // or a single U+000D CARRIAGE RETURN (CR) character. 51 | lines := bytes.Split(buf, []byte{'\n'}) 52 | for _, line := range lines { 53 | if len(line) == 0 { 54 | // If the line is empty (a blank line). Dispatch the event. 55 | d.dispatchEvent(currentEvent, dataBuffer.String()) 56 | 57 | // reset current event and data buffer 58 | currentEvent = Event{} 59 | dataBuffer.Reset() 60 | continue 61 | } 62 | if line[0] == byte(':') { 63 | // If the line starts with a U+003A COLON character (:), ignore the line. 64 | continue 65 | } 66 | 67 | var field, value []byte 68 | colonIndex := bytes.IndexRune(line, ':') 69 | if colonIndex != -1 { 70 | // If the line contains a U+003A COLON character character (:) 71 | // Collect the characters on the line before the first U+003A COLON character (:), 72 | // and let field be that string. 73 | field = line[:colonIndex] 74 | // Collect the characters on the line after the first U+003A COLON character (:), 75 | // and let value be that string. 76 | value = line[colonIndex+1:] 77 | // If value starts with a single U+0020 SPACE character, remove it from value. 78 | if len(value) > 0 && value[0] == ' ' { 79 | value = value[1:] 80 | } 81 | } else { 82 | // Otherwise, the string is not empty but does not contain a U+003A COLON character character (:) 83 | // Use the whole line as the field name, and the empty string as the field value. 84 | field = line 85 | value = []byte{} 86 | } 87 | // The steps to process the field given a field name and a field value depend on the field name, 88 | // as given in the following list. Field names must be compared literally, 89 | // with no case folding performed. 90 | switch string(field) { 91 | case "event": 92 | // Set the event name buffer to field value. 93 | currentEvent.Event = string(value) 94 | case "id": 95 | // Set the event stream's last event ID to the field value. 96 | currentEvent.Id = string(value) 97 | case "retry": 98 | // If the field value consists of only characters in the range U+0030 DIGIT ZERO (0) to U+0039 DIGIT NINE (9), 99 | // then interpret the field value as an integer in base ten, and set the event stream's 100 | // reconnection time to that integer. 101 | // Otherwise, ignore the field. 102 | currentEvent.Id = string(value) 103 | case "data": 104 | // Append the field value to the data buffer, 105 | dataBuffer.Write(value) 106 | // then append a single U+000A LINE FEED (LF) character to the data buffer. 107 | dataBuffer.WriteString("\n") 108 | default: 109 | // Otherwise. The field is ignored. 110 | continue 111 | } 112 | } 113 | // Once the end of the file is reached, the user agent must dispatch the event one final time. 114 | d.dispatchEvent(currentEvent, dataBuffer.String()) 115 | 116 | return d.events, nil 117 | } 118 | -------------------------------------------------------------------------------- /sse-decoder_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Manu Martinez-Almeida. All rights reserved. 2 | // Use of this source code is governed by a MIT style 3 | // license that can be found in the LICENSE file. 4 | 5 | package sse 6 | 7 | import ( 8 | "bytes" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestDecodeSingle1(t *testing.T) { 15 | events, err := Decode(bytes.NewBufferString( 16 | `data: this is a text 17 | event: message 18 | fake: 19 | id: 123456789010 20 | : we can append data 21 | : and multiple comments should not break it 22 | data: a very nice one`)) 23 | 24 | assert.NoError(t, err) 25 | assert.Len(t, events, 1) 26 | assert.Equal(t, events[0].Event, "message") 27 | assert.Equal(t, events[0].Id, "123456789010") 28 | } 29 | 30 | func TestDecodeSingle2(t *testing.T) { 31 | events, err := Decode(bytes.NewBufferString( 32 | `: starting with a comment 33 | fake: 34 | 35 | data:this is a \ntext 36 | event:a message\n\n 37 | fake 38 | :and multiple comments\n should not break it\n\n 39 | id:1234567890\n10 40 | :we can append data 41 | data:a very nice one\n! 42 | 43 | 44 | `)) 45 | assert.NoError(t, err) 46 | assert.Len(t, events, 1) 47 | assert.Equal(t, events[0].Event, "a message\\n\\n") 48 | assert.Equal(t, events[0].Id, "1234567890\\n10") 49 | } 50 | 51 | func TestDecodeSingle3(t *testing.T) { 52 | events, err := Decode(bytes.NewBufferString( 53 | ` 54 | id:123456ABCabc789010 55 | event: message123 56 | : we can append data 57 | data:this is a text 58 | data: a very nice one 59 | data: 60 | data 61 | : ending with a comment`)) 62 | 63 | assert.NoError(t, err) 64 | assert.Len(t, events, 1) 65 | assert.Equal(t, events[0].Event, "message123") 66 | assert.Equal(t, events[0].Id, "123456ABCabc789010") 67 | } 68 | 69 | func TestDecodeMulti1(t *testing.T) { 70 | events, err := Decode(bytes.NewBufferString( 71 | ` 72 | id: 73 | event: weird event 74 | data:this is a text 75 | :data: this should NOT APER 76 | data: second line 77 | 78 | : a comment 79 | event: message 80 | id:123 81 | data:this is a text 82 | :data: this should NOT APER 83 | data: second line 84 | 85 | 86 | : a comment 87 | event: message 88 | id:123 89 | data:this is a text 90 | data: second line 91 | 92 | :hola 93 | 94 | data 95 | 96 | event: 97 | 98 | id`)) 99 | assert.NoError(t, err) 100 | assert.Len(t, events, 3) 101 | assert.Equal(t, events[0].Event, "weird event") 102 | assert.Equal(t, events[0].Id, "") 103 | } 104 | 105 | func TestDecodeW3C(t *testing.T) { 106 | events, err := Decode(bytes.NewBufferString( 107 | `data 108 | 109 | data 110 | data 111 | 112 | data: 113 | `)) 114 | assert.NoError(t, err) 115 | assert.Len(t, events, 1) 116 | } 117 | -------------------------------------------------------------------------------- /sse-encoder.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Manu Martinez-Almeida. All rights reserved. 2 | // Use of this source code is governed by a MIT style 3 | // license that can be found in the LICENSE file. 4 | 5 | package sse 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "reflect" 13 | "strconv" 14 | "strings" 15 | ) 16 | 17 | // Server-Sent Events 18 | // W3C Working Draft 29 October 2009 19 | // http://www.w3.org/TR/2009/WD-eventsource-20091029/ 20 | 21 | const ContentType = "text/event-stream;charset=utf-8" 22 | 23 | var ( 24 | contentType = []string{ContentType} 25 | noCache = []string{"no-cache"} 26 | ) 27 | 28 | var fieldReplacer = strings.NewReplacer( 29 | "\n", "\\n", 30 | "\r", "\\r") 31 | 32 | var dataReplacer = strings.NewReplacer( 33 | "\n", "\ndata:", 34 | "\r", "\\r") 35 | 36 | type Event struct { 37 | Event string 38 | Id string 39 | Retry uint 40 | Data interface{} 41 | } 42 | 43 | func Encode(writer io.Writer, event Event) error { 44 | w := checkWriter(writer) 45 | writeId(w, event.Id) 46 | writeEvent(w, event.Event) 47 | writeRetry(w, event.Retry) 48 | return writeData(w, event.Data) 49 | } 50 | 51 | func writeId(w stringWriter, id string) { 52 | if len(id) > 0 { 53 | _, _ = w.WriteString("id:") 54 | _, _ = fieldReplacer.WriteString(w, id) 55 | _, _ = w.WriteString("\n") 56 | } 57 | } 58 | 59 | func writeEvent(w stringWriter, event string) { 60 | if len(event) > 0 { 61 | _, _ = w.WriteString("event:") 62 | _, _ = fieldReplacer.WriteString(w, event) 63 | _, _ = w.WriteString("\n") 64 | } 65 | } 66 | 67 | func writeRetry(w stringWriter, retry uint) { 68 | if retry > 0 { 69 | _, _ = w.WriteString("retry:") 70 | _, _ = w.WriteString(strconv.FormatUint(uint64(retry), 10)) 71 | _, _ = w.WriteString("\n") 72 | } 73 | } 74 | 75 | func writeData(w stringWriter, data interface{}) error { 76 | _, _ = w.WriteString("data:") 77 | 78 | bData, ok := data.([]byte) 79 | if ok { 80 | _, _ = dataReplacer.WriteString(w, string(bData)) 81 | _, _ = w.WriteString("\n\n") 82 | return nil 83 | } 84 | 85 | switch kindOfData(data) { //nolint:exhaustive 86 | case reflect.Struct, reflect.Slice, reflect.Map: 87 | err := json.NewEncoder(w).Encode(data) 88 | if err != nil { 89 | return err 90 | } 91 | _, _ = w.WriteString("\n") 92 | default: 93 | _, _ = dataReplacer.WriteString(w, fmt.Sprint(data)) 94 | _, _ = w.WriteString("\n\n") 95 | } 96 | return nil 97 | } 98 | 99 | func (r Event) Render(w http.ResponseWriter) error { 100 | r.WriteContentType(w) 101 | return Encode(w, r) 102 | } 103 | 104 | func (r Event) WriteContentType(w http.ResponseWriter) { 105 | header := w.Header() 106 | header["Content-Type"] = contentType 107 | 108 | if _, exist := header["Cache-Control"]; !exist { 109 | header["Cache-Control"] = noCache 110 | } 111 | } 112 | 113 | func kindOfData(data interface{}) reflect.Kind { 114 | value := reflect.ValueOf(data) 115 | valueType := value.Kind() 116 | if valueType == reflect.Ptr { 117 | valueType = value.Elem().Kind() 118 | } 119 | return valueType 120 | } 121 | -------------------------------------------------------------------------------- /sse_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2014 Manu Martinez-Almeida. All rights reserved. 2 | // Use of this source code is governed by a MIT style 3 | // license that can be found in the LICENSE file. 4 | 5 | package sse 6 | 7 | import ( 8 | "bytes" 9 | "net/http/httptest" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestEncodeOnlyData(t *testing.T) { 16 | w := new(bytes.Buffer) 17 | event := Event{ 18 | Data: "junk\n\njk\nid:fake", 19 | } 20 | err := Encode(w, event) 21 | assert.NoError(t, err) 22 | assert.Equal(t, w.String(), 23 | `data:junk 24 | data: 25 | data:jk 26 | data:id:fake 27 | 28 | `) 29 | 30 | decoded, _ := Decode(w) 31 | assert.Equal(t, "message", decoded[0].Event) 32 | assert.Equal(t, decoded[0].Data, []Event{event}[0].Data) 33 | } 34 | 35 | func TestEncodeWithEvent(t *testing.T) { 36 | w := new(bytes.Buffer) 37 | event := Event{ 38 | Event: "t\n:<>\r\test", 39 | Data: "junk\n\njk\nid:fake", 40 | } 41 | err := Encode(w, event) 42 | assert.NoError(t, err) 43 | assert.Equal(t, w.String(), 44 | `event:t\n:<>\r est 45 | data:junk 46 | data: 47 | data:jk 48 | data:id:fake 49 | 50 | `) 51 | 52 | decoded, _ := Decode(w) 53 | assert.Equal(t, "t\\n:<>\\r\test", decoded[0].Event) 54 | assert.Equal(t, decoded[0].Data, []Event{event}[0].Data) 55 | } 56 | 57 | func TestEncodeWithId(t *testing.T) { 58 | w := new(bytes.Buffer) 59 | err := Encode(w, Event{ 60 | Id: "t\n:<>\r\test", 61 | Data: "junk\n\njk\nid:fa\rke", 62 | }) 63 | assert.NoError(t, err) 64 | assert.Equal(t, w.String(), 65 | `id:t\n:<>\r est 66 | data:junk 67 | data: 68 | data:jk 69 | data:id:fa\rke 70 | 71 | `) 72 | } 73 | 74 | func TestEncodeWithRetry(t *testing.T) { 75 | w := new(bytes.Buffer) 76 | err := Encode(w, Event{ 77 | Retry: 11, 78 | Data: "junk\n\njk\nid:fake\n", 79 | }) 80 | assert.NoError(t, err) 81 | assert.Equal(t, w.String(), 82 | `retry:11 83 | data:junk 84 | data: 85 | data:jk 86 | data:id:fake 87 | data: 88 | 89 | `) 90 | } 91 | 92 | func TestEncodeWithEverything(t *testing.T) { 93 | w := new(bytes.Buffer) 94 | err := Encode(w, Event{ 95 | Event: "abc", 96 | Id: "12345", 97 | Retry: 10, 98 | Data: "some data", 99 | }) 100 | assert.NoError(t, err) 101 | assert.Equal(t, w.String(), "id:12345\nevent:abc\nretry:10\ndata:some data\n\n") 102 | } 103 | 104 | func TestEncodeMap(t *testing.T) { 105 | w := new(bytes.Buffer) 106 | err := Encode(w, Event{ 107 | Event: "a map", 108 | Data: map[string]interface{}{ 109 | "foo": "b\n\rar", 110 | "bar": "id: 2", 111 | }, 112 | }) 113 | assert.NoError(t, err) 114 | assert.Equal(t, w.String(), "event:a map\ndata:{\"bar\":\"id: 2\",\"foo\":\"b\\n\\rar\"}\n\n") 115 | } 116 | 117 | func TestEncodeSlice(t *testing.T) { 118 | w := new(bytes.Buffer) 119 | err := Encode(w, Event{ 120 | Event: "a slice", 121 | Data: []interface{}{1, "text", map[string]interface{}{"foo": "bar"}}, 122 | }) 123 | assert.NoError(t, err) 124 | assert.Equal(t, w.String(), "event:a slice\ndata:[1,\"text\",{\"foo\":\"bar\"}]\n\n") 125 | } 126 | 127 | func TestEncodeStruct(t *testing.T) { 128 | myStruct := struct { 129 | A int 130 | B string `json:"value"` 131 | }{1, "number"} 132 | 133 | w := new(bytes.Buffer) 134 | err := Encode(w, Event{ 135 | Event: "a struct", 136 | Data: myStruct, 137 | }) 138 | assert.NoError(t, err) 139 | assert.Equal(t, w.String(), "event:a struct\ndata:{\"A\":1,\"value\":\"number\"}\n\n") 140 | 141 | w.Reset() 142 | err = Encode(w, Event{ 143 | Event: "a struct", 144 | Data: &myStruct, 145 | }) 146 | assert.NoError(t, err) 147 | assert.Equal(t, w.String(), "event:a struct\ndata:{\"A\":1,\"value\":\"number\"}\n\n") 148 | } 149 | 150 | func TestEncodeInteger(t *testing.T) { 151 | w := new(bytes.Buffer) 152 | err := Encode(w, Event{ 153 | Event: "an integer", 154 | Data: 1, 155 | }) 156 | assert.NoError(t, err) 157 | assert.Equal(t, w.String(), "event:an integer\ndata:1\n\n") 158 | } 159 | 160 | func TestEncodeFloat(t *testing.T) { 161 | w := new(bytes.Buffer) 162 | err := Encode(w, Event{ 163 | Event: "Float", 164 | Data: 1.5, 165 | }) 166 | assert.NoError(t, err) 167 | assert.Equal(t, w.String(), "event:Float\ndata:1.5\n\n") 168 | } 169 | 170 | func TestEncodeStream(t *testing.T) { 171 | w := new(bytes.Buffer) 172 | 173 | _ = Encode(w, Event{ 174 | Event: "float", 175 | Data: 1.5, 176 | }) 177 | 178 | _ = Encode(w, Event{ 179 | Id: "123", 180 | Data: map[string]interface{}{"foo": "bar", "bar": "foo"}, 181 | }) 182 | 183 | _ = Encode(w, Event{ 184 | Id: "124", 185 | Event: "chat", 186 | Data: "hi! dude", 187 | }) 188 | assert.Equal(t, w.String(), 189 | "event:float\ndata:1.5\n\n"+ 190 | "id:123\ndata:{\"bar\":\"foo\",\"foo\":\"bar\"}\n\n"+ 191 | "id:124\nevent:chat\ndata:hi! dude\n\n") 192 | } 193 | 194 | func TestRenderSSE(t *testing.T) { 195 | w := httptest.NewRecorder() 196 | 197 | err := (Event{ 198 | Event: "msg", 199 | Data: "hi! how are you?", 200 | }).Render(w) 201 | 202 | assert.NoError(t, err) 203 | assert.Equal(t, w.Body.String(), "event:msg\ndata:hi! how are you?\n\n") 204 | assert.Equal(t, w.Header().Get("Content-Type"), "text/event-stream;charset=utf-8") 205 | assert.Equal(t, w.Header().Get("Cache-Control"), "no-cache") 206 | } 207 | 208 | func BenchmarkResponseWriter(b *testing.B) { 209 | w := httptest.NewRecorder() 210 | b.ResetTimer() 211 | b.ReportAllocs() 212 | for i := 0; i < b.N; i++ { 213 | _ = (Event{ 214 | Event: "new_message", 215 | Data: "hi! how are you? I am fine. this is a long stupid message!!!", 216 | }).Render(w) 217 | } 218 | } 219 | 220 | func BenchmarkFullSSE(b *testing.B) { 221 | buf := new(bytes.Buffer) 222 | b.ResetTimer() 223 | b.ReportAllocs() 224 | for i := 0; i < b.N; i++ { 225 | _ = Encode(buf, Event{ 226 | Event: "new_message", 227 | Id: "13435", 228 | Retry: 10, 229 | Data: "hi! how are you? I am fine. this is a long stupid message!!!", 230 | }) 231 | buf.Reset() 232 | } 233 | } 234 | 235 | func BenchmarkNoRetrySSE(b *testing.B) { 236 | buf := new(bytes.Buffer) 237 | b.ResetTimer() 238 | b.ReportAllocs() 239 | for i := 0; i < b.N; i++ { 240 | _ = Encode(buf, Event{ 241 | Event: "new_message", 242 | Id: "13435", 243 | Data: "hi! how are you? I am fine. this is a long stupid message!!!", 244 | }) 245 | buf.Reset() 246 | } 247 | } 248 | 249 | func BenchmarkSimpleSSE(b *testing.B) { 250 | buf := new(bytes.Buffer) 251 | b.ResetTimer() 252 | b.ReportAllocs() 253 | for i := 0; i < b.N; i++ { 254 | _ = Encode(buf, Event{ 255 | Event: "new_message", 256 | Data: "hi! how are you? I am fine. this is a long stupid message!!!", 257 | }) 258 | buf.Reset() 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /writer.go: -------------------------------------------------------------------------------- 1 | package sse 2 | 3 | import "io" 4 | 5 | type stringWriter interface { 6 | io.Writer 7 | WriteString(string) (int, error) 8 | } 9 | 10 | type stringWrapper struct { 11 | io.Writer 12 | } 13 | 14 | func (w stringWrapper) WriteString(str string) (int, error) { 15 | return w.Write([]byte(str)) 16 | } 17 | 18 | func checkWriter(writer io.Writer) stringWriter { 19 | if w, ok := writer.(stringWriter); ok { 20 | return w 21 | } else { 22 | return stringWrapper{writer} 23 | } 24 | } 25 | --------------------------------------------------------------------------------