Skip to content

Commit

Permalink
Add echo server for local tests
Browse files Browse the repository at this point in the history
  • Loading branch information
slytomcat committed Apr 18, 2024
1 parent 13ba26b commit 8afc1dd
Show file tree
Hide file tree
Showing 9 changed files with 392 additions and 3 deletions.
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ ws URL [flags]

Simply run ws with the destination URL. For security some sites check the origin header. ws will automatically send the destination URL as the origin. If this doesn't work you can specify it directly with the `--origin` option.

Example of usage with echo server (see below):
```
$ ws ws://localhost:3000/ws
$ ws ws://localhost:8080/ws
> {"type": "echo", "payload": "Hello, world"}
< {"type":"echo","payload":"Hello, world"}
> {"type": "broadcast", "payload": "Hello, world"}
Expand All @@ -47,4 +48,37 @@ Flags:
-s, --subprotocal string sec-websocket-protocal field
-t, --timestamp print timestamps for sent and received messages
-v, --version print version
```

# Echo server

Folder `echo-server` contains a very simple echo server. It allows to establish ws connection and just replay with received messages or send the message to all active connection. Server accept messages in JSON format (like `{"type": "echo", "payload": "Hello, world"}`).

Only wto types allowed:
- `echo` - the message replayed to sender only
- `broadcast` - the message is sent to all active connection and the result of broadcasting is sent to sender.

## build

```
cd echo-server
go build .
```

## start

```
./echo-server ws://localhost:8080/ws
```

## test

```
ws ws://localhost:8080/ws
> {"type": "echo", "payload": "Hello, world"}
< {"type":"echo","payload":"Hello, world"}
> {"type": "broadcast", "payload": "Hello, world"}
< {"type":"broadcast","payload":"Hello, world"}
< {"type":"broadcastResult","payload":"Hello, world","listenerCount":1}
> ^D
```
27 changes: 26 additions & 1 deletion connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"fmt"
"io"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -80,7 +82,10 @@ func connect(url string, rlConf *readline.Config) []error {
if err != nil {
return []error{err}
}
defer ws.Close()
defer func() {
TryCloseNormally(ws, "client disconnection")
ws.Close()
}()
rl, err := readline.NewEx(rlConf)
if err != nil {
return []error{err}
Expand Down Expand Up @@ -116,6 +121,14 @@ func connect(url string, rlConf *readline.Config) []error {
return []error{err}
}
}
go func() {
sig := make(chan os.Signal, 2)
signal.Notify(sig, os.Interrupt, os.Kill)
fmt.Printf("\n%s signal received, exiting...\n", <-sig)
rl.Close()
session.cancel()
}()

go session.readConsole()
go session.readWebsocket()
<-session.ctx.Done()
Expand Down Expand Up @@ -200,3 +213,15 @@ func (s *Session) readWebsocket() {
fmt.Fprint(s.rl.Stdout(), rxSprintf("%s< %s\n", getPrefix(), text))
}
}

// TryCloseNormally tries to close websocket connection normally i.e. according to RFC
// NOTE It doesn't close underlying connection as socket reader have to read and handle close response.
func TryCloseNormally(conn *websocket.Conn, message string) error {
closeMessage := websocket.FormatCloseMessage(websocket.CloseNormalClosure, message)
if err := conn.WriteControl(websocket.CloseMessage, closeMessage, time.Now().Add(time.Second)); err != nil {
if !strings.Contains(err.Error(), "close sent") {
return err
}
}
return nil
}
Binary file added echo-server/echo-server
Binary file not shown.
87 changes: 87 additions & 0 deletions echo-server/echo-server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package main

import (
"encoding/json"
"fmt"
"net/url"
"os"
"os/signal"
"strings"

"github.com/gorilla/websocket"
"github.com/slytomcat/ws/server"
)

type msg struct {
Type string `json:"type,omitempty"`
Payload string `json:"payload,omitempty"`
ListenerCount *int `json:"listenerCount,omitempty"`
}

func sendMsg(m msg, c *websocket.Conn) {
response, _ := json.Marshal(m)
c.WriteMessage(websocket.TextMessage, response)
}

func main() {
if len(os.Args) < 2 {
fmt.Printf("Usage: %s ws://host[:port][/path]", os.Args[0])
os.Exit(1)
}
u, err := url.Parse(os.Args[1])
if err != nil {
fmt.Printf("url parsing error: %v", err)
os.Exit(1)
}
srv := server.NewServer(u.Host)
if !strings.HasPrefix(u.Path, "/") {
u.Path = "/" + u.Path
}
srv.WSHandleFunc(u.Path, func(conn *websocket.Conn) {
in := msg{}
id := conn.RemoteAddr().String()
for {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure, websocket.CloseServiceRestart) {
fmt.Printf("echoHandler for %s: websocket reading error: %v\n", id, err)
} else {
fmt.Printf("echoHandler for %s: %s\n", id, err)
}
return
}
fmt.Printf("echoHandler for %s: handle message: %s\n", id, message)
if err := json.Unmarshal(message, &in); err != nil {
sendMsg(msg{"error", fmt.Sprintf("message parsing error: %v", err), nil}, conn)
continue
}
switch in.Type {
case "echo":
sendMsg(msg{in.Type, in.Payload, nil}, conn)
case "broadcast":
count := 0
out, _ := json.Marshal(msg{in.Type, in.Payload, nil})
srv.ForEachConnection(func(c *websocket.Conn) bool {
c.WriteMessage(websocket.TextMessage, out)
count++
return true
})
sendMsg(msg{"broadcastResult", in.Payload, &count}, conn)
default:
sendMsg(msg{"error", "unknown type", nil}, conn)
}
}
})
go func() {
sig := make(chan os.Signal, 2)
signal.Notify(sig, os.Interrupt, os.Kill)
fmt.Printf("\n%s signal received, exiting...\n", <-sig)
srv.Close()
}()
fmt.Printf("starting echo server on %s...\n", u.Host)
err = srv.ListenAndServe()
if err.Error() != "http: Server closed" {
fmt.Println(err)
os.Exit(1)
}
}
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
module github.com/slytomcat/ws

go 1.21.6
go 1.22.1

require (
github.com/chzyer/readline v1.5.1
github.com/fatih/color v1.16.0
github.com/gorilla/websocket v1.5.1
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.9.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/sys v0.16.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
Expand All @@ -16,17 +18,23 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
25 changes: 25 additions & 0 deletions server/echo_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package server

import (
"fmt"

"github.com/gorilla/websocket"
)

// EchoHandler is a handler that sends back all received messages
func EchoHandler(conn *websocket.Conn) {
id := conn.RemoteAddr().String()
for {
mt, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure, websocket.CloseServiceRestart) {
fmt.Printf("echoHandler for %s: websocket reading error: %v\n", id, err)
} else {
fmt.Printf("echoHandler for %s: %s\n", id, err)
}
return
}
fmt.Printf("echoHandler for %s: handle message: %s\n", id, message)
conn.WriteMessage(mt, message)
}
}
103 changes: 103 additions & 0 deletions server/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package server

import (
"fmt"
"net/http"
"strings"
"sync"
"time"

"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
HandshakeTimeout: time.Second,
Subprotocols: []string{},
CheckOrigin: func(r *http.Request) bool {
return true
},
}

// Server is a websocket/http server. It is wrapper for standard http.Server with additional functionality for websocket request handling.
type Server struct {
http.Server
connections sync.Map
handle func(*websocket.Conn)
mux *http.ServeMux
Upgrader websocket.Upgrader
}

// NewServer creates new websocket/http server. It is configured to start on provided addr and creates the standard serve mux and sets it as server handler.
// Use WSHandleFunc and HandleFunc for setting handlers on desired paths and start Server via ListenAndServe/ListenAndServeTLS.
// ForEachConnection allows to iterate over currently active WS connections.
// For example you can send some broadcast message via s.ForEachConnection(func(c *websocket.Conn){c.WriteMessage(websocket.TextMessage, message)})
func NewServer(addr string) *Server {
mux := http.NewServeMux()
return &Server{
mux: mux,
Upgrader: upgrader,
Server: http.Server{
Addr: addr,
Handler: mux,
},
}
}

// WSHandleFunc setups new WS handler for path
func (s *Server) WSHandleFunc(path string, handler func(*websocket.Conn)) {
s.mux.HandleFunc(path, s.serve(handler))
}

// HandleFunc setups new regular http handler for path
func (s *Server) HandleFunc(path string, handler func(w http.ResponseWriter, r *http.Request)) {
s.mux.HandleFunc(path, handler)
}

// Close correctly closes all active ws connections and close the server
func (s *Server) Close() error {
s.ForEachConnection(func(c *websocket.Conn) bool {
if err := TryCloseNormally(c, "server going down"); err != nil {
fmt.Printf("server: closing connection from %s error: %v\n", c.RemoteAddr(), err)
}
c.Close()
return true
})
return s.Server.Close()
}

// TryCloseNormally tries to close websocket connection normally i.e. according to RFC
// NOTE It doesn't close underlying connection as socket reader have to read and handle close response.
func TryCloseNormally(conn *websocket.Conn, message string) error {
closeMessage := websocket.FormatCloseMessage(websocket.CloseNormalClosure, message)
if err := conn.WriteControl(websocket.CloseMessage, closeMessage, time.Now().Add(time.Second)); err != nil {
if strings.Contains(err.Error(), "close sent") {
return nil
}
return err
}
return nil
}

func (s *Server) serve(handler func(*websocket.Conn)) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
connection, err := upgrader.Upgrade(w, r, nil)
if err != nil {
fmt.Printf("server: connection upgrade error: %v\n", err)
return
}
addr := connection.RemoteAddr().String()
fmt.Printf("server: new WS connection from %v\n", addr)
s.connections.Store(connection, nil)
handler(connection)
s.connections.Delete(connection)
fmt.Printf("server: WS connection from %v closed\n", addr)
connection.Close()
}
}

// ForEachConnection allow to iterate over all active connections
func (s *Server) ForEachConnection(f func(*websocket.Conn) bool) {
s.connections.Range(func(key, value any) bool {
return f(key.(*websocket.Conn))
})
}
Loading

0 comments on commit 8afc1dd

Please sign in to comment.