├── 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 |
37 | My new Message
38 |
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 |
109 | $$MESSAGE$$
110 |
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 |
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 |
--------------------------------------------------------------------------------