Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ^1.21
go-version: 1.24.0
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.61
version: v1.64.5
unit-tests:
name: Unit Tests
runs-on: ubuntu-latest
Expand All @@ -37,7 +37,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ^1.21
go-version: 1.24.0
- name: Unit Tests
run: |
make unit-tests
Expand Down
Empty file added ext/sse/README.md
Empty file.
50 changes: 50 additions & 0 deletions ext/sse/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package sse

import (
"context"
"encoding/json"
"errors"
"fmt"
)

var ErrClientClosed = errors.New("sse: client closed")

// Client represents a WebSocket client that handles HTTP responses and supports
// flushing data to the client. It contains a response writer, a flusher for
// sending data immediately, and a channel for managing the client's lifecycle.
type Client struct {
s Streamer

ctx context.Context
}

// Connect establishes a connection for the Client using the provided Streamer.
// It assigns the Streamer to the Client's rw field and ensures that it implements
// the http.Flusher interface for flushing data.
func (c *Client) Connect(ctx context.Context, s Streamer) {
c.s = s
c.ctx = ctx
}

// Send sends an event to the client by writing the event name and data to the response writer.
// It marshals the event data into JSON format and flushes the output to ensure the data is sent immediately.
// This method is part of the Client struct and is intended for use in server-sent events (SSE) communication.
func (c *Client) Send(event Event) error {
select {
case <-c.ctx.Done():
return ErrClientClosed
default:
buf, err := json.Marshal(event.Data)
if err != nil {
return err
}
_, err = fmt.Fprintf(c.s, "event: %s\ndata: %s\n\n", event.Name, string(buf))
if err != nil {
return err
}

c.s.Flush()
}

return nil
}
8 changes: 8 additions & 0 deletions ext/sse/event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package sse

// Event represents a server-sent event with a name and associated data.
// It can be used to transmit information from the server to the client in real-time.
type Event struct {
Name string
Data any
}
86 changes: 86 additions & 0 deletions ext/sse/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Package sse provides a server implementation for Server-Sent Events (SSE).
// SSE is a technology enabling a client to receive automatic updates from a server via HTTP connection.
package sse

import (
"context"
"sync"

"github.com/yaitoo/async"
)

// Server represents a structure that manages connected clients
// in a concurrent environment. It uses a read-write mutex to
// ensure safe access to the clients map, which holds the
// active Client instances identified by their unique keys.
type Server struct {
sync.RWMutex
clients map[string]*Client
}

// New creates and returns a new instance of the Server struct.
func New() *Server {
return &Server{
clients: make(map[string]*Client),
}
}

// Join adds a new client to the server or retrieves an existing one based on the provided ID.
// It establishes a connection with the specified Streamer and sets the appropriate headers
// for Server-Sent Events (SSE). If a client with the given ID already exists, it reuses that client.
func (s *Server) Join(ctx context.Context, id string, sm Streamer) *Client {
s.Lock()
defer s.Unlock()
c, ok := s.clients[id]

if !ok {
c = &Client{}
s.clients[id] = c
}

c.Connect(ctx, sm)

sm.Header().Set("Content-Type", "text/event-stream")
sm.Header().Set("Cache-Control", "no-cache")
sm.Header().Set("Connection", "keep-alive")

return c
}

// Leave removes a client from the server's client list by its ID.
// This method is safe for concurrent use, as it locks the server
// before modifying the clients map and ensures that the lock is
// released afterward.
func (s *Server) Leave(id string) {
s.Lock()
defer s.Unlock()

delete(s.clients, id)
}

// Get retrieves the Client associated with the given id from the Server.
// It uses a read lock to ensure thread-safe access to the clients map.
// Returns nil if no Client is found for the specified id.
func (s *Server) Get(id string) *Client {
s.RLock()
defer s.RUnlock()
return s.clients[id]
}

// Broadcast sends the specified event to all connected clients.
// It acquires a read lock to ensure thread-safe access to the clients slice,
// and spawns a goroutine for each client to handle the sending of the event.
func (s *Server) Broadcast(ctx context.Context, event Event) ([]error, error) {
s.RLock()
defer s.RUnlock()

task := async.NewA()

for _, c := range s.clients {
task.Add(func(ctx context.Context) error {
return c.Send(event)
})
}

return task.Wait(ctx)
}
108 changes: 108 additions & 0 deletions ext/sse/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package sse

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"
)

func TestServer(t *testing.T) {
t.Run("join", func(t *testing.T) {
srv := New()
rw := httptest.NewRecorder()

c1 := srv.Join(context.TODO(), "join", rw)

c2 := srv.Join(context.TODO(), "join", rw)

require.Equal(t, c1, c2)

c3 := srv.Get("join")

require.Equal(t, c1, c3)

srv.Leave("join")

c4 := srv.Get("join")
require.Nil(t, c4)

})

t.Run("send", func(t *testing.T) {
srv := New()
rw := httptest.NewRecorder()

c := srv.Join(context.TODO(), "send", rw)

err := c.Send(Event{Name: "event1", Data: "data1"})
require.NoError(t, err)
buf := rw.Body.Bytes()
require.Equal(t, "event: event1\ndata: \"data1\"\n\n", string(buf))

err = c.Send(Event{Name: "event2", Data: "data2"})
require.NoError(t, err)
buf = rw.Body.Bytes()
require.Equal(t, "event: event1\ndata: \"data1\"\n\nevent: event2\ndata: \"data2\"\n\n", string(buf))
})

t.Run("broadcast", func(t *testing.T) {
srv := New()

rw1 := httptest.NewRecorder()
rw2 := httptest.NewRecorder()

c1 := srv.Join(context.TODO(), "c1", rw1)
require.NotNil(t, c1)

c2 := srv.Join(context.TODO(), "c2", rw2)
require.NotNil(t, c2)

errs, err := srv.Broadcast(context.TODO(), Event{Name: "event1", Data: "data1"})
require.NoError(t, err)
require.Nil(t, errs)

buf1 := rw1.Body.Bytes()
buf2 := rw2.Body.Bytes()

require.Equal(t, buf1, buf2)
require.Equal(t, "event: event1\ndata: \"data1\"\n\n", string(buf1))
})

t.Run("invalid", func(t *testing.T) {
srv := New()

rw := &streamerMock{
ResponseWriter: httptest.NewRecorder(),
}

ctx, cancel := context.WithCancel(context.TODO())

c := srv.Join(ctx, "invalid", rw)

err := c.Send(Event{Name: "event1", Data: make(chan int)})
require.Error(t, err)

err = c.Send(Event{Name: "event1"})
require.Error(t, err)

cancel()

err = c.Send(Event{Name: "event1"})
require.ErrorIs(t, err, ErrClientClosed)

})
}

type streamerMock struct {
http.ResponseWriter
}

func (*streamerMock) Write([]byte) (int, error) {
return 0, errors.New("mock: invalid")
}

func (*streamerMock) Flush() {}
10 changes: 10 additions & 0 deletions ext/sse/streamer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package sse

import (
"net/http"
)

type Streamer interface {
http.ResponseWriter
http.Flusher
}
7 changes: 5 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
module github.com/yaitoo/xun

go 1.22
go 1.23.0

toolchain go1.24.0

require (
github.com/go-playground/form/v4 v4.2.1
github.com/go-playground/locales v0.14.1
github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.25.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.33.0
github.com/yaitoo/async v1.0.4
golang.org/x/crypto v0.35.0
)

require (
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
github.com/yaitoo/async v1.0.4 h1:u+SWuJcSckgBOcMjMYz9IviojeCatDrdni3YNGLCiHY=
github.com/yaitoo/async v1.0.4/go.mod h1:IpSO7Ei7AxiqLxFqDjN4rJaVlt8wm4ZxMXyyQaWmM1g=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
Expand Down
Loading