Skip to content

Add chat example #190

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Feb 17, 2020
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ go get nhooyr.io/websocket

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

For a full stack example, see [./chat-example](./chat-example).

### Server

```go
Expand Down
27 changes: 27 additions & 0 deletions chat-example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Chat Example

This directory contains a full stack example of a simple chat webapp using nhooyr.io/websocket.

```bash
$ cd chat-example
$ go run . localhost:0
listening on http://127.0.0.1:51055
```

Visit the printed URL to submit and view broadcasted messages in a browser.

![Image of Example](https://i.imgur.com/VwJl9Bh.png)

## Structure

The frontend is contained in `index.html`, `index.js` and `index.css`. It sets up the
DOM with a scrollable div at the top that is populated with new messages as they are broadcast.
At the bottom it adds a form to submit messages.
The messages are received via the WebSocket `/subscribe` endpoint and published via
the HTTP POST `/publish` endpoint.

The server portion is `main.go` and `chat.go` and implements serving the static frontend
assets, the `/subscribe` WebSocket endpoint and the HTTP POST `/publish` endpoint.

The code is well commented. I would recommend starting in `main.go` and then `chat.go` followed by
`index.html` and then `index.js`.
128 changes: 128 additions & 0 deletions chat-example/chat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package main

import (
"context"
"errors"
"io"
"io/ioutil"
"log"
"net/http"
"sync"
"time"

"nhooyr.io/websocket"
)

// chatServer enables broadcasting to a set of subscribers.
type chatServer struct {
subscribersMu sync.RWMutex
subscribers map[chan<- []byte]struct{}
}

// subscribeHandler accepts the WebSocket connection and then subscribes
// it to all future messages.
func (cs *chatServer) subscribeHandler(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, nil)
if err != nil {
log.Print(err)
return
}
defer c.Close(websocket.StatusInternalError, "")

err = cs.subscribe(r.Context(), c)
if errors.Is(err, context.Canceled) {
return
}
if websocket.CloseStatus(err) == websocket.StatusNormalClosure ||
websocket.CloseStatus(err) == websocket.StatusGoingAway {
return
}
if err != nil {
log.Print(err)
}
}

// publishHandler reads the request body with a limit of 8192 bytes and then publishes
// the received message.
func (cs *chatServer) publishHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
body := io.LimitReader(r.Body, 8192)
msg, err := ioutil.ReadAll(body)
if err != nil {
http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
return
}

cs.publish(msg)
}

// subscribe subscribes the given WebSocket to all broadcast messages.
// It creates a msgs chan with a buffer of 16 to give some room to slower
// connections and then registers it. It then listens for all messages
// and writes them to the WebSocket. If the context is cancelled or
// an error occurs, it returns and deletes the subscription.
//
// It uses CloseRead to keep reading from the connection to process control
// messages and cancel the context if the connection drops.
func (cs *chatServer) subscribe(ctx context.Context, c *websocket.Conn) error {
ctx = c.CloseRead(ctx)

msgs := make(chan []byte, 16)
cs.addSubscriber(msgs)
defer cs.deleteSubscriber(msgs)

for {
select {
case msg := <-msgs:
err := writeTimeout(ctx, time.Second*5, c, msg)
if err != nil {
return err
}
case <-ctx.Done():
return ctx.Err()
}
}
}

// publish publishes the msg to all subscribers.
// It never blocks and so messages to slow subscribers
// are dropped.
func (cs *chatServer) publish(msg []byte) {
cs.subscribersMu.RLock()
defer cs.subscribersMu.RUnlock()

for c := range cs.subscribers {
select {
case c <- msg:
default:
}
}
}

// addSubscriber registers a subscriber with a channel
// on which to send messages.
func (cs *chatServer) addSubscriber(msgs chan<- []byte) {
cs.subscribersMu.Lock()
if cs.subscribers == nil {
cs.subscribers = make(map[chan<- []byte]struct{})
}
cs.subscribers[msgs] = struct{}{}
cs.subscribersMu.Unlock()
}

// deleteSubscriber deletes the subscriber with the given msgs channel.
func (cs *chatServer) deleteSubscriber(msgs chan []byte) {
cs.subscribersMu.Lock()
delete(cs.subscribers, msgs)
cs.subscribersMu.Unlock()
}

func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, msg []byte) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

return c.Write(ctx, websocket.MessageText, msg)
}
5 changes: 5 additions & 0 deletions chat-example/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module nhooyr.io/websocket/example-chat

go 1.13

require nhooyr.io/websocket v1.8.2
12 changes: 12 additions & 0 deletions chat-example/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/compress v1.10.0 h1:92XGj1AcYzA6UrVdd4qIIBrT8OroryvRvdmg/IfmC7Y=
github.com/klauspost/compress v1.10.0/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
nhooyr.io/websocket v1.8.2 h1:LwdzfyyOZKtVFoXay6A39Acu03KmidSZ3YUUvPa13PA=
nhooyr.io/websocket v1.8.2/go.mod h1:LiqdCg1Cu7TPWxEvPjPa0TGYxCsy4pHNTN9gGluwBpQ=
81 changes: 81 additions & 0 deletions chat-example/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
body {
width: 100vw;
min-width: 320px;
}

#root {
padding: 40px 20px;
max-width: 480px;
margin: auto;
height: 100vh;

display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}

#root > * + * {
margin: 20px 0 0 0;
}

/* 100vh on safari does not include the bottom bar. */
@supports (-webkit-overflow-scrolling: touch) {
#root {
height: 85vh;
}
}

#message-log {
width: 100%;
flex-grow: 1;
overflow: auto;
}

#message-log p:first-child {
margin: 0;
}

#message-log > * + * {
margin: 10px 0 0 0;
}

#publish-form-container {
width: 100%;
}

#publish-form {
width: 100%;
display: flex;
height: 40px;
}

#publish-form > * + * {
margin: 0 0 0 10px;
}

#publish-form input[type="text"] {
flex-grow: 1;

-moz-appearance: none;
-webkit-appearance: none;
word-break: normal;
border-radius: 5px;
border: 1px solid #ccc;
}

#publish-form input[type="submit"] {
color: white;
background-color: black;
border-radius: 5px;
padding: 5px 10px;
border: none;
}

#publish-form input[type="submit"]:hover {
background-color: red;
}

#publish-form input[type="submit"]:active {
background-color: red;
}
25 changes: 25 additions & 0 deletions chat-example/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en-CA">
<head>
<meta charset="UTF-8" />
<title>nhooyr.io/websocket - Chat Example</title>
<meta name="viewport" content="width=device-width" />

<link href="https://unpkg.com/sanitize.css" rel="stylesheet" />
<link href="https://unpkg.com/sanitize.css/typography.css" rel="stylesheet" />
<link href="https://unpkg.com/sanitize.css/forms.css" rel="stylesheet" />
<link href="/index.css" rel="stylesheet" />
</head>
<body>
<div id="root">
<div id="message-log"></div>
<div id="publish-form-container">
<form id="publish-form">
<input name="message" id="message-input" type="text" />
<input value="Submit" type="submit" />
</form>
</div>
</div>
<script type="text/javascript" src="/index.js"></script>
</body>
</html>
62 changes: 62 additions & 0 deletions chat-example/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
;(() => {
// expectingMessage is set to true
// if the user has just submitted a message
// and so we should scroll the next message into view when received.
let expectingMessage = false
function dial() {
const conn = new WebSocket(`ws://${location.host}/subscribe`)

conn.addEventListener("close", ev => {
console.info("websocket disconnected, reconnecting in 1000ms", ev)
setTimeout(dial, 1000)
})
conn.addEventListener("open", ev => {
console.info("websocket connected")
})

// This is where we handle messages received.
conn.addEventListener("message", ev => {
if (typeof ev.data !== "string") {
console.error("unexpected message type", typeof ev.data)
return
}
const p = appendLog(ev.data)
if (expectingMessage) {
p.scrollIntoView()
expectingMessage = false
}
})
}
dial()

const messageLog = document.getElementById("message-log")
const publishForm = document.getElementById("publish-form")
const messageInput = document.getElementById("message-input")

// appendLog appends the passed text to messageLog.
function appendLog(text) {
const p = document.createElement("p")
// Adding a timestamp to each message makes the log easier to read.
p.innerText = `${new Date().toLocaleTimeString()}: ${text}`
messageLog.append(p)
return p
}
appendLog("Submit a message to get started!")

// onsubmit publishes the message from the user when the form is submitted.
publishForm.onsubmit = ev => {
ev.preventDefault()

const msg = messageInput.value
if (msg === "") {
return
}
messageInput.value = ""

expectingMessage = true
fetch("/publish", {
method: "POST",
body: msg,
})
}
})()
Loading