10000 Merge pull request #190 from nhooyr/chat-example · coder/websocket@bdb8742 · GitHub
[go: up one dir, main page]

Skip to content

Commit bdb8742

Browse files
authored
Merge pull request #190 from nhooyr/chat-example
Add chat example
2 parents c733166 + d44dcb9 commit bdb8742

File tree

11 files changed

+391
-2
lines changed

11 files changed

+391
-2
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ go get nhooyr.io/websocket
3434

3535
For a production quality example that demonstrates the complete API, see the [echo example](https://godoc.org/nhooyr.io/websocket#example-package--Echo).
3636

37+
For a full stack example, see [./chat-example](./chat-example).
38+
3739
### Server
3840

3941
```go

chat-example/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Chat Example
2+
3+
This directory contains a full stack example of a simple chat webapp using nhooyr.io/websocket.
4+
5+
```bash
6+
$ cd chat-example
7+
$ go run . localhost:0
8+
listening on http://127.0.0.1:51055
9+
```
10+
11+
Visit the printed URL to submit and view broadcasted messages in a browser.
12+
13+
![Image of Example](https://i.imgur.com/VwJl9Bh.png)
14+
15+
## Structure
16+
17+
The frontend is contained in `index.html`, `index.js` and `index.css`. It sets up the
18+
DOM with a scrollable div at the top that is populated with new messages as they are broadcast.
19+
At the bottom it adds a form to submit messages.
20+
The messages are received via the WebSocket `/subscribe` endpoint and published via
21+
the HTTP POST `/publish` endpoint.
22+
23+
The server portion is `main.go` and `chat.go` and implements serving the static frontend
24+
assets, the `/subscribe` WebSocket endpoint and the HTTP POST `/publish` endpoint.
25+
26+
The code is well commented. I would recommend starting in `main.go` and then `chat.go` followed by
27+
`index.html` and then `index.js`.

chat-example/chat.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io"
7+
"io/ioutil"
8+
"log"
9+
"net/http"
10+
"sync"
11+
"time"
12+
13+
"nhooyr.io/websocket"
14+
)
15+
16+
// chatServer enables broadcasting to a set of subscribers.
17+
type chatServer struct {
18+
subscribersMu sync.RWMutex
19+
subscribers map[chan<- []byte]struct{}
20+
}
21+
22+
// subscribeHandler accepts the WebSocket connection and then subscribes
23+
// it to all future messages.
24+
func (cs *chatServer) subscribeHandler(w http.ResponseWriter, r *http.Request) {
25+
c, err := websocket.Accept(w, r, nil)
26+
if err != nil {
27+
log.Print(err)
28+
return
29+
}
30+
defer c.Close(websocket.StatusInternalError, "")
31+
32+
err = cs.subscribe(r.Context(), c)
33+
if errors.Is(err, context.Canceled) {
34+
return
35+
}
36+
if websocket.CloseStatus(err) == websocket.StatusNormalClosure ||
37+
websocket.CloseStatus(err) == websocket.StatusGoingAway {
38+
return
39+
}
40+
if err != nil {
41+
log.Print(err)
42+
}
43+
}
44+
45+
// publishHandler reads the request body with a limit of 8192 bytes and then publishes
46+
// the received message.
47+
func (cs *chatServer) publishHandler(w http.ResponseWriter, r *http.Request) {
48+
if r.Method != "POST" {
49+
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
50+
return
51+
}
52+
body := io.LimitReader(r.Body, 8192)
53+
msg, err := ioutil.ReadAll(body)
54+
if err != nil {
55+
http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
56+
return
57+
}
58+
59+
cs.publish(msg)
60+
}
61+
62+
// subscribe subscribes the given WebSocket to all broadcast messages.
63+
// It creates a msgs chan with a buffer of 16 to give some room to slower
64+
// connections and then registers it. It then listens for all messages
65+
// and writes them to the WebSocket. If the context is cancelled or
66+
// an error occurs, it returns and deletes the subscription.
67+
//
68+
// It uses CloseRead to keep reading from the connection to process control
69+
// messages and cancel the context if the connection drops.
70+
func (cs *chatServer) subscribe(ctx context.Context, c *websocket.Conn) error {
71+
ctx = c.CloseRead(ctx)
72+
73+
msgs := make(chan []byte, 16)
74+
cs.addSubscriber(msgs)
75+
defer cs.deleteSubscriber(msgs)
76+
77+
for {
78+
select {
79+
case msg := <-msgs:
80+
err := writeTimeout(ctx, time.Second*5, c, msg)
81+
if err != nil {
82+
return err
83+
}
84+
case <-ctx.Done():
85+
return ctx.Err()
86+
}
87+
}
88+
}
89+
90+
// publish publishes the msg to all subscribers.
91+
// It never blocks and so messages to slow subscribers
92+
// are dropped.
93+
func (cs *chatServer) publish(msg []byte) {
94+
cs.subscribersMu.RLock()
95+
defer cs.subscribersMu.RUnlock()
96+
97+
for c := range cs.subscribers {
98+
select {
99+
case c <- msg:
100+
default:
101+
}
102+
}
103+
}
104+
105+
// addSubscriber registers a subscriber with a channel
106+
// on which to send messages.
107+
func (cs *chatServer) addSubscriber(msgs chan<- []byte) {
108+
cs.subscribersMu.Lock()
109+
if cs.subscribers == nil {
110+
cs.subscribers = make(map[chan<- []byte]struct{})
111+
}
112+
cs.subscribers[msgs] = struct{}{}
113+
cs.subscribersMu.Unlock()
114+
}
115+
116+
// deleteSubscriber deletes the subscriber with the given msgs channel.
117+
func (cs *chatServer) deleteSubscriber(msgs chan []byte) {
118+
cs.subscribersMu.Lock()
119+
delete(cs.subscribers, msgs)
120+
cs.subscribersMu.Unlock()
121+
}
122+
123+
func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
124+
ctx, cancel := context.WithTimeout(ctx, timeout)
125+
defer cancel()
126+
127+
return c.Write(ctx, websocket.MessageText, msg)
128+
}

chat-example/go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module nhooyr.io/websocket/example-chat
2+
3+
go 1.13
4+
5+
require nhooyr.io/websocket v1.8.2

chat-example/go.sum

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
2+
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
3+
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
4+
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
5+
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
6+
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
7+
github.com/klauspost/compress v1.10.0 h1:92XGj1AcYzA6UrVdd4qIIBrT8OroryvRvdmg/IfmC7Y=
8+
github.com/klauspost/compress v1.10.0/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
9+
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
10+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
11+
nhooyr.io/websocket v1.8.2 h1:LwdzfyyOZKtVFoXay6A39Acu03KmidSZ3YUUvPa13PA=
12+
nhooyr.io/websocket v1.8.2/go.mod h1:LiqdCg1Cu7TPWxEvPjPa0TGYxCsy4pHNTN9gGluwBpQ=

chat-example/index.css

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
body {
2+
width: 100vw;
3+
min-width: 320px;
4+
}
5+
6+
#root {
7+
padding: 40px 20px;
8+
max-width: 480px;
9+
margin: auto;
10+
height: 100vh;
11+
12+
display: flex;
13+
flex-direction: column;
14+
align-items: center;
15+
justify-content: center;
16 1241 +
}
17+
18+
#root > * + * {
19+
margin: 20px 0 0 0;
< F438 /td>
20+
}
21+
22+
/* 100vh on safari does not include the bottom bar. */
23+
@supports (-webkit-overflow-scrolling: touch) {
24+
#root {
25+
height: 85vh;
26+
}
27+
}
28+
29+
#message-log {
30+
width: 100%;
31+
flex-grow: 1;
32+
overflow: auto;
33+
}
34+
35+
#message-log p:first-child {
36+
margin: 0;
37+
}
38+
39+
#message-log > * + * {
40+
margin: 10px 0 0 0;
41+
}
42+
43+
#publish-form-container {
44+
width: 100%;
45+
}
46+
47+
#publish-form {
48+
width: 100%;
49+
display: flex;
50+
height: 40px;
51+
}
52+
53+
#publish-form > * + * {
54+
margin: 0 0 0 10px;
55+
}
56+
57+
#publish-form input[type="text"] {
58+
flex-grow: 1;
59+
60+
-moz-appearance: none;
61+
-webkit-appearance: none;
62+
word-break: normal;
63+
border-radius: 5px;
64+
border: 1px solid #ccc;
65+
}
66+
67+
#publish-form input[type="submit"] {
68+
color: white;
69+
background-color: black;
70+
border-radius: 5px;
71+
padding: 5px 10px;
72+
border: none;
73+
}
74+
75+
#publish-form input[type="submit"]:hover {
76+
background-color: red;
77+
}
78+
79+
#publish-form input[type="submit"]:active {
80+
background-color: red;
81+
}

chat-example/index.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<!DOCTYPE html>
2+
<html lang="en-CA">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>nhooyr.io/websocket - Chat Example</title>
6+
<meta name="viewport" content="width=device-width" />
7+
8+
<link href="https://unpkg.com/sanitize.css" rel="stylesheet" />
9+
<link href="https://unpkg.com/sanitize.css/typography.css" rel="stylesheet" />
10+
<link href="https://unpkg.com/sanitize.css/forms.css" rel="stylesheet" />
11+
<link href="/index.css" rel="stylesheet" />
12+
</head>
13+
<body>
14+
<div id="root">
15+
<div id="message-log"></div>
16+
<div id="publish-form-container">
17+
<form id="publish-form">
18+
<input name="message" id="message-input" type="text" />
19+
<input value="Submit" type="submit" />
20+
</form>
21+
</div>
22+
</div>
23+
<script type="text/javascript" src="/index.js"></script>
24+
</body>
25+
</html>

chat-example/index.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
;(() => {
2+
// expectingMessage is set to true
3+
// if the user has just submitted a message
4+
// and so we should scroll the next message into view when received.
5+
let expectingMessage = false
6+
function dial() {
7+
const conn = new WebSocket(`ws://${location.host}/subscribe`)
8+
9+
conn.addEventListener("close", ev => {
10+
console.info("websocket disconnected, reconnecting in 1000ms", ev)
11+
setTimeout(dial, 1000)
12+
})
13+
conn.addEventListener("open", ev => {
14+
console.info("websocket connected")
15+
})
16+
17+
// This is where we handle messages received.
18+
conn.addEventListener("message", ev => {
19+
if (typeof ev.data !== "string") {
20+
console.error("unexpected message type", typeof ev.data)
21+
return
22+
}
23+
const p = appendLog(ev.data)
24+
if (expectingMessage) {
25+
p.scrollIntoView()
26+
expectingMessage = false
27+
}
28+
})
29+
}
30+
dial()
31+
32+
const messageLog = document.getElementById("message-log")
33+
const publishForm = document.getElementById("publish-form")
34+
const messageInput = document.getElementById("message-input")
35+
36+
// appendLog appends the passed text to messageLog.
37+
function appendLog(text) {
38+
const p = document.createElement("p")
39+
// Adding a timestamp to each message makes the log easier to read.
40+
p.innerText = `${new Date().toLocaleTimeString()}: ${text}`
41+
messageLog.append(p)
42+
return p
43+
}
44+
appendLog("Submit a message to get started!")
45+
46+
// onsubmit publishes the message from the user when the form is submitted.
47+
publishForm.onsubmit = ev => {
48+
ev.preventDefault()
49+
50+
const msg = messageInput.value
51+
if (msg === "") {
52+
return
53+
}
54+
messageInput.value = ""
55+
56+
expectingMessage = true
57+
fetch("/publish", {
58+
method: "POST",
59+
body: msg,
60+
})
61+
}
62+
})()

0 commit comments

Comments
 (0)
0