├── README.md ├── client.go ├── go.mod ├── go.sum ├── home.html ├── hub.go ├── main.go └── renovate.json /README.md: -------------------------------------------------------------------------------- 1 | # Go Example for TurboStreams over WebSockets 2 | 3 | Simple example for using [Turbo](https://turbo.hotwire.dev/)s [Stream](https://turbo.hotwire.dev/reference/streams)s in Go with the [Gorilla WebSocket](https://github.com/gorilla/websocket) toolkit. 4 | 5 | Run the sample using the following command: 6 | 7 | $ go run *.go 8 | 9 | To use the chat example, open http://localhost:8080/ in your browser. 10 | 11 | ## Frontend 12 | The frontend connects to the Turbo Stream using plain JavaScript like: 13 | 14 | ```html 15 | 16 | 19 | ``` 20 | 21 | After that the frontend is connected to the Turbo Stream and get's all messages. Every chat message is appended to the dom element with id `board`. 22 | 23 | This _should_ work with html markup too but I have not gotten it working yet. 24 | 25 | ## Server 26 | 27 | The server receives the new chat message via web socket. Then it wraps the message as Turbo Stream action with action `append` and broadcasts it to all subscribers. That way all subscribed users see the new message on the board. 28 | 29 | The raw text message sent over the web socket is: 30 | ```json 31 | { 32 | "identifier": 33 | "{\"channel\":\"Turbo::StreamsChannel\", \"signed_stream_name\":\"**mysignature**\"}", 34 | "message": 35 | " 36 | 39 | " 40 | } 41 | ``` 42 | 43 | ## Credits 44 | 45 | Based on [Gorilla WebSocket Chat Example](https://github.com/gorilla/websocket/tree/master/examples/chat) 46 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "log" 10 | "net/http" 11 | "strings" 12 | "time" 13 | 14 | "github.com/gorilla/websocket" 15 | ) 16 | 17 | const ( 18 | // Time allowed to write a message to the peer. 19 | writeWait = 10 * time.Second 20 | 21 | // Time allowed to read the next pong message from the peer. 22 | pongWait = 60 * time.Second 23 | 24 | // Send pings to peer with this period. Must be less than pongWait. 25 | pingPeriod = (pongWait * 9) / 10 26 | 27 | // Maximum message size allowed from peer. 28 | maxMessageSize = 512 29 | ) 30 | 31 | var ( 32 | newline = []byte{'\n'} 33 | space = []byte{' '} 34 | ) 35 | 36 | var upgrader = websocket.Upgrader{ 37 | ReadBufferSize: 1024, 38 | WriteBufferSize: 1024, 39 | } 40 | 41 | // Client is a middleman between the websocket connection and the hub. 42 | type Client struct { 43 | hub *Hub 44 | 45 | // The websocket connection. 46 | conn *websocket.Conn 47 | 48 | // Buffered channel of outbound messages. 49 | send chan []byte 50 | } 51 | 52 | // readPump pumps messages from the websocket connection to the hub. 53 | // 54 | // The application runs readPump in a per-connection goroutine. The application 55 | // ensures that there is at most one reader on a connection by executing all 56 | // reads from this goroutine. 57 | func (c *Client) readPump() { 58 | defer func() { 59 | c.hub.unregister <- c 60 | c.conn.Close() 61 | }() 62 | c.conn.SetReadLimit(maxMessageSize) 63 | c.conn.SetReadDeadline(time.Now().Add(pongWait)) 64 | c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil }) 65 | for { 66 | _, message, err := c.conn.ReadMessage() 67 | if err != nil { 68 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { 69 | log.Printf("error: %v", err) 70 | } 71 | break 72 | } 73 | message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1)) 74 | c.hub.broadcast <- message 75 | } 76 | } 77 | 78 | // writePump pumps messages from the hub to the websocket connection. 79 | // 80 | // A goroutine running writePump is started for each connection. The 81 | // application ensures that there is at most one writer to a connection by 82 | // executing all writes from this goroutine. 83 | func (c *Client) writePump() { 84 | ticker := time.NewTicker(pingPeriod) 85 | defer func() { 86 | ticker.Stop() 87 | c.conn.Close() 88 | }() 89 | for { 90 | select { 91 | case message, ok := <-c.send: 92 | c.conn.SetWriteDeadline(time.Now().Add(writeWait)) 93 | if !ok { 94 | // The hub closed the channel. 95 | c.conn.WriteMessage(websocket.CloseMessage, []byte{}) 96 | return 97 | } 98 | 99 | w, err := c.conn.NextWriter(websocket.TextMessage) 100 | if err != nil { 101 | return 102 | } 103 | msg := `{ 104 | "identifier": 105 | "{\"channel\":\"Turbo::StreamsChannel\",\"signed_stream_name\":\"**mysignature**\"}", 106 | "message": 107 | " 108 | 111 | " 112 | }` 113 | 114 | msg = strings.Replace(msg, "$$MESSAGE$$", string(message), -1) 115 | w.Write([]byte(msg)) 116 | 117 | // Add queued chat messages to the current websocket message. 118 | n := len(c.send) 119 | for i := 0; i < n; i++ { 120 | w.Write(newline) 121 | w.Write(<-c.send) 122 | } 123 | 124 | if err := w.Close(); err != nil { 125 | return 126 | } 127 | case <-ticker.C: 128 | c.conn.SetWriteDeadline(time.Now().Add(writeWait)) 129 | if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { 130 | return 131 | } 132 | } 133 | } 134 | } 135 | 136 | // serveWs handles websocket requests from the peer. 137 | func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) { 138 | conn, err := upgrader.Upgrade(w, r, nil) 139 | if err != nil { 140 | log.Println(err) 141 | return 142 | } 143 | client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} 144 | client.hub.register <- client 145 | 146 | // Allow collection of memory referenced by the caller by doing all work in 147 | // new goroutines. 148 | go client.writePump() 149 | go client.readPump() 150 | } 151 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go_websocket_turbo 2 | 3 | go 1.22 4 | 5 | require github.com/gorilla/websocket v1.5.3 6 | 7 | require golang.org/x/net v0.25.0 // indirect 8 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 2 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 3 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 4 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 5 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 6 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 7 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 8 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 9 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 10 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 11 | -------------------------------------------------------------------------------- /home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Chat Example 5 | 6 | 42 | 79 | 80 | 81 | 88 | 89 |
90 |
91 | 92 | 93 |
94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /hub.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | // Hub maintains the set of active clients and broadcasts messages to the 8 | // clients. 9 | type Hub struct { 10 | // Registered clients. 11 | clients map[*Client]bool 12 | 13 | // Inbound messages from the clients. 14 | broadcast chan []byte 15 | 16 | // Register requests from the clients. 17 | register chan *Client 18 | 19 | // Unregister requests from clients. 20 | unregister chan *Client 21 | } 22 | 23 | func newHub() *Hub { 24 | return &Hub{ 25 | broadcast: make(chan []byte), 26 | register: make(chan *Client), 27 | unregister: make(chan *Client), 28 | clients: make(map[*Client]bool), 29 | } 30 | } 31 | 32 | func (h *Hub) run() { 33 | for { 34 | select { 35 | case client := <-h.register: 36 | h.clients[client] = true 37 | case client := <-h.unregister: 38 | if _, ok := h.clients[client]; ok { 39 | delete(h.clients, client) 40 | close(client.send) 41 | } 42 | case message := <-h.broadcast: 43 | for client := range h.clients { 44 | select { 45 | case client.send <- message: 46 | default: 47 | close(client.send) 48 | delete(h.clients, client) 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "flag" 9 | "log" 10 | "net/http" 11 | ) 12 | 13 | var addr = flag.String("addr", ":8080", "http service address") 14 | 15 | func serveHome(w http.ResponseWriter, r *http.Request) { 16 | log.Println(r.URL) 17 | if r.URL.Path != "/" { 18 | http.Error(w, "Not found", http.StatusNotFound) 19 | return 20 | } 21 | if r.Method != "GET" { 22 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 23 | return 24 | } 25 | http.ServeFile(w, r, "home.html") 26 | } 27 | 28 | func main() { 29 | flag.Parse() 30 | hub := newHub() 31 | go hub.run() 32 | http.HandleFunc("/", serveHome) 33 | http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { 34 | serveWs(hub, w, r) 35 | }) 36 | err := http.ListenAndServe(*addr, nil) 37 | if err != nil { 38 | log.Fatal("ListenAndServe: ", err) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | --------------------------------------------------------------------------------