43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # WebRTC Video Chat
2 |
3 | Very basic and somewhat buggy reference implementation of multi-user, WebRTC video chat in the browser. This was developed for a meetup. I don't find slides very helpful in general, but if you want them, they are [here](https://slides.com/haydenbraxton/webrtc-video-chat). The last page of the slides has some links to some helpful learning resources, so do check that out.
4 |
5 | To start the signaling server and client server, run `npm start`. You can open the page at `http://localhost:5500/`.
6 |
7 | For the turn-server,
8 |
9 | ```bash
10 | cd ./turn-server
11 | ./build.sh
12 | ./build/turn-server --public-ip=
13 | ```
14 |
15 | When running locally, you can test by opening the page in different browser tabs. You can also test connecting from different devices on the same local network if you're using https.
16 |
17 | command args:
18 |
19 | - `--ssl-cert-path` path to ssl cert (not required when running locally)
20 | - `--ssl-key-path` path to ssl key (not required when running locally)
21 | - run node commands with `--prod` to use proper port numbers
22 | - run turn server with `--public-ip ` (set to your local ip address when running locally)
23 |
24 | ## Some ideas for enhancements if you want to tinker:
25 |
26 | - mute/unmute
27 | - turn camera on/off
28 | - switch camera source
29 | - allow participants to rename themselves
30 | - share screen (hint: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia)
31 | - live chat (not really WebRTC, but could still be fun to try if you're new to web sockets)
32 | - try forcing the peers to use a specific ICE candidate pair by filtering what ice candidates you send over the signaling server.
33 | - share files or other data over WebRTC (hint: https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createDataChannel)
34 | - experiment with different signaling server implementations. I built the simplest possible thing that would work, but you could use any technology or messaging system you want.
--------------------------------------------------------------------------------
/signaling-server/example/client.go:
--------------------------------------------------------------------------------
1 | // Copyright 2015 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 | //go:build ignore
6 | // +build ignore
7 |
8 | package example
9 |
10 | import (
11 | "flag"
12 | "log"
13 | "net/url"
14 | "os"
15 | "os/signal"
16 | "time"
17 |
18 | "github.com/gorilla/websocket"
19 | )
20 |
21 | var addr = flag.String("addr", "localhost:8080", "http service address")
22 |
23 | func main() {
24 | flag.Parse()
25 | log.SetFlags(0)
26 |
27 | interrupt := make(chan os.Signal, 1)
28 | signal.Notify(interrupt, os.Interrupt)
29 |
30 | u := url.URL{Scheme: "ws", Host: *addr, Path: "/echo"}
31 | log.Printf("connecting to %s", u.String())
32 |
33 | c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
34 | if err != nil {
35 | log.Fatal("dial:", err)
36 | }
37 | defer c.Close()
38 |
39 | done := make(chan struct{})
40 |
41 | go func() {
42 | defer close(done)
43 | for {
44 | _, message, err := c.ReadMessage()
45 | if err != nil {
46 | log.Println("read:", err)
47 | return
48 | }
49 | log.Printf("recv: %s", message)
50 | }
51 | }()
52 |
53 | ticker := time.NewTicker(time.Second)
54 | defer ticker.Stop()
55 |
56 | for {
57 | select {
58 | case <-done:
59 | return
60 | case t := <-ticker.C:
61 | err := c.WriteMessage(websocket.TextMessage, []byte(t.String()))
62 | if err != nil {
63 | log.Println("write:", err)
64 | return
65 | }
66 | case <-interrupt:
67 | log.Println("interrupt")
68 |
69 | // Cleanly close the connection by sending a close message and then
70 | // waiting (with timeout) for the server to close the connection.
71 | err := c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
72 | if err != nil {
73 | log.Println("write close:", err)
74 | return
75 | }
76 | select {
77 | case <-done:
78 | case <-time.After(time.Second):
79 | }
80 | return
81 | }
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/client/webrtc-util.js:
--------------------------------------------------------------------------------
1 | export function closePeerConnection(event, peerConnection) {
2 | if (!peerConnection) {
3 | return
4 | }
5 |
6 | // Disconnect all our event listeners; we don't want stray events
7 | // to interfere with the hangup while it's ongoing.
8 | peerConnection.ontrack = null;
9 | peerConnection.onnicecandidate = null;
10 | peerConnection.oniceconnectionstatechange = null;
11 | peerConnection.onsignalingstatechange = null;
12 | peerConnection.onicegatheringstatechange = null;
13 | peerConnection.onnotificationneeded = null;
14 |
15 | // Stop all transceivers on the connection
16 | peerConnection.getTransceivers().forEach(transceiver => {
17 | transceiver.stop();
18 | });
19 |
20 | peerConnection.close()
21 | }
22 |
23 | export function createPeerConnection(config = {
24 | peer: undefined,
25 | localMediaStream: undefined,
26 | onicecandidate: (event, peerContext) => {},
27 | oniceconnectionstatechange: (event, peerContext) => {},
28 | onsignalingstatechange: (event, peerContext) => {},
29 | onnegotiationneeded: (event, peerContext) => {},
30 | ontrack: (event, peerContext) => { },
31 | iceServers: []
32 | }) {
33 | // TODO: IMPLEMENT START
34 | let peerConnection = new RTCPeerConnection({ iceServers: config.iceServers })
35 |
36 | config.localMediaStream
37 | .getTracks()
38 | .forEach(track => peerConnection.addTransceiver(track, { streams: [config.localMediaStream] }))
39 |
40 | const peerContext = {
41 | peerConnection,
42 | peer: config.peer
43 | }
44 | // TODO: IMPLEMENT END
45 |
46 | peerConnection.onicecandidate = withPeerContext(config.onicecandidate, peerContext);
47 | peerConnection.oniceconnectionstatechange = withPeerContext(config.oniceconnectionstatechange, peerContext);
48 | peerConnection.onsignalingstatechange = withPeerContext(config.onsignalingstatechange, peerContext);
49 | peerConnection.onnegotiationneeded = withPeerContext(config.onnegotiationneeded, peerContext);
50 | peerConnection.ontrack = withPeerContext(config.ontrack, peerContext);
51 |
52 | return peerConnection
53 | }
54 |
55 | function withPeerContext(callback, peerContext) {
56 | return (event) => callback(event, peerContext)
57 | }
58 |
--------------------------------------------------------------------------------
/signaling-server/signaling-server.js:
--------------------------------------------------------------------------------
1 | import WebSocket from 'ws'
2 | import * as uuid from 'uuid';
3 | import { createServer as createHttpServer } from 'http'
4 | import { createServer as createHttpsServer } from 'https'
5 | import queue from 'queue'
6 | import { messageTypes } from '../shared/message-types.js'
7 | import { sslConfig } from '../ssl-config.js'
8 |
9 | const isProd = !!process.argv.includes('--prod')
10 | let httpServer = isProd
11 | ? createHttpsServer(sslConfig)
12 | : createHttpServer()
13 |
14 | const webSocketServer = new WebSocket.Server({ server: httpServer });
15 |
16 | const users = {}
17 | const joinRequestQueue = queue({ concurrency: 1, autostart: true })
18 |
19 | webSocketServer.on('connection', (connection) => {
20 | let user = {
21 | connection,
22 | userId: uuid.v4()
23 | }
24 |
25 | users[user.userId] = user
26 |
27 | sendToUser(user, { type: messageTypes.signalServerConnected, userId: user.userId })
28 |
29 | connection.onmessage = (event) => {
30 | let message = JSON.parse(event.data)
31 | handleMessage(message)
32 | }
33 |
34 | connection.onclose = () => {
35 | delete users[user.userId]
36 | sendUpdatedUserList()
37 | }
38 | });
39 |
40 | function handleMessage(message) {
41 | if (message.type === messageTypes.join) {
42 | joinRequestQueue.push(() => handleJoin(message))
43 | } else if (message.senderId && message.recipientId) {
44 | let recipient = users[message.recipientId]
45 | sendToUser(recipient, message)
46 | }
47 | }
48 |
49 | function handleJoin(message) {
50 | let user = users[message.senderId]
51 |
52 | users[message.senderId] = {
53 | ...user,
54 | userName: message.userName
55 | }
56 |
57 | return sendUpdatedUserList()
58 | }
59 |
60 | function sendUpdatedUserList() {
61 | return sendToAllUsers({
62 | type: messageTypes.userList,
63 | users: getUserList()
64 | })
65 | }
66 |
67 | function getUserList() {
68 | return Object.values(users).map(user => ({
69 | userName: user.userName,
70 | userId: user.userId
71 | }))
72 | }
73 |
74 | function sendToUser(user, message) {
75 | return new Promise((resolve, reject) =>
76 | user.connection.send(
77 | JSON.stringify(message),
78 | (error) => error ? reject(error) : resolve()
79 | )
80 | )
81 | }
82 |
83 | function sendToAllUsers(message) {
84 | return Promise.all(
85 | Object
86 | .values(users)
87 | .map(user =>
88 | sendToUser(user, message)
89 | .catch(error => console.log(error))
90 | )
91 | )
92 | }
93 |
94 | httpServer.listen({
95 | host: '0.0.0.0',
96 | port: isProd ? 444 : 5501
97 | })
--------------------------------------------------------------------------------
/turn-server/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
4 | github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
5 | github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
6 | github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
7 | github.com/pion/stun v0.3.5 h1:uLUCBCkQby4S1cf6CGuR9QrVOKcvUwFeemaC865QHDg=
8 | github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA=
9 | github.com/pion/transport v0.10.1 h1:2W+yJT+0mOQ160ThZYUx5Zp2skzshiNgxrNE9GUfhJM=
10 | github.com/pion/transport v0.10.1/go.mod h1:PBis1stIILMiis0PewDw91WJeLJkyIMcEk+DwKOzf4A=
11 | github.com/pion/turn/v2 v2.0.5 h1:iwMHqDfPEDEOFzwWKT56eFmh6DYC6o/+xnLAEzgISbA=
12 | github.com/pion/turn/v2 v2.0.5/go.mod h1:APg43CFyt/14Uy7heYUOGWdkem/Wu4PhCO/bjyrTqMw=
13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
15 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
16 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
17 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
18 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
19 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
20 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
21 | golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
22 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
23 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
24 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
25 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
27 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
28 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
29 |
--------------------------------------------------------------------------------
/signaling-server/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "flag"
6 | "fmt"
7 | "log"
8 | "net/http"
9 |
10 | "github.com/gorilla/websocket"
11 |
12 | "github.com/haydenbr/sigserver/signaling"
13 | )
14 |
15 | var host = flag.String("host", "0.0.0.0", "http host")
16 | var port = flag.String("port", "8080", "http port")
17 | var upgrader = websocket.Upgrader{} // use default options
18 |
19 | func handleConnection(c *websocket.Conn) string {
20 | newUser := signaling.CreateNewUser()
21 |
22 | sendServerAck(c, newUser)
23 | sendUserList(c)
24 |
25 | return newUser.Id
26 | }
27 |
28 | func sendServerAck(c *websocket.Conn, user *signaling.User) {
29 | wsWriteErr := c.WriteJSON(signaling.NewServerAckMessage(user.Id))
30 |
31 | if wsWriteErr != nil {
32 | fmt.Println("error sending server ack message:", wsWriteErr)
33 | }
34 | }
35 |
36 | func sendUserList(c *websocket.Conn) {
37 | c.WriteJSON(signaling.GetAckedUsers())
38 | }
39 |
40 | func sendNewUserNotification() {
41 | // notify all other users (acked and not acked) that there's a new user
42 | // we need to do this in a way that doesn't require us to save a pointer to each user's ws connection on the user object
43 | }
44 |
45 | func handleClientAck(c *websocket.Conn, clientAck *signaling.ClientAckPayload) {
46 | signaling.AckUser(clientAck.UserId, clientAck.UserName)
47 | sendNewUserNotification()
48 | }
49 |
50 | func sendUserLeave(c *websocket.Conn, userId string) {
51 | wsWriteErr := c.WriteJSON(signaling.Message{
52 | MessageType: signaling.ServerAck,
53 | Payload: signaling.ServerAckPayload{
54 | UserId: userId,
55 | },
56 | })
57 |
58 | if wsWriteErr != nil {
59 | fmt.Println("error sending server ack message:", wsWriteErr)
60 | }
61 | }
62 |
63 | func signal(w http.ResponseWriter, r *http.Request) {
64 | c, err := upgrader.Upgrade(w, r, nil)
65 | if err != nil {
66 | log.Print("upgrade:", err)
67 | return
68 | }
69 | defer c.Close()
70 |
71 | userId := handleConnection(c)
72 |
73 | for {
74 | messageType, message, readErr := c.ReadMessage()
75 |
76 | if readErr != nil {
77 | log.Println("read:", readErr)
78 | break
79 | }
80 |
81 | if messageType == websocket.CloseMessage {
82 | signaling.RemoveUser(userId)
83 | }
84 |
85 | if messageType == websocket.TextMessage {
86 | messageJson := new(signaling.Message)
87 | jsonErr := json.Unmarshal(message, &messageJson)
88 |
89 | if jsonErr != nil {
90 | log.Println("json parse error:", jsonErr)
91 | }
92 |
93 | // TODO: handle message, actually
94 | }
95 |
96 | log.Printf("unhandled message type: %d %v\n", messageType, message)
97 | }
98 | }
99 |
100 | func main() {
101 | flag.Parse()
102 | log.SetFlags(0)
103 |
104 | http.HandleFunc("/", signal)
105 |
106 | addr := *host + *port
107 | log.Fatal(http.ListenAndServe(addr, nil))
108 | }
109 |
--------------------------------------------------------------------------------
/signaling-server/example/server.go:
--------------------------------------------------------------------------------
1 | // Copyright 2015 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 | //go:build ignore
6 | // +build ignore
7 |
8 | package example
9 |
10 | import (
11 | "flag"
12 | "html/template"
13 | "log"
14 | "net/http"
15 |
16 | "github.com/gorilla/websocket"
17 | )
18 |
19 | var addr = flag.String("addr", "localhost:8080", "http service address")
20 |
21 | var upgrader = websocket.Upgrader{} // use default options
22 |
23 | func echo(w http.ResponseWriter, r *http.Request) {
24 | c, err := upgrader.Upgrade(w, r, nil)
25 | if err != nil {
26 | log.Print("upgrade:", err)
27 | return
28 | }
29 | defer c.Close()
30 | for {
31 | mt, message, err := c.ReadMessage()
32 | if err != nil {
33 | log.Println("read:", err)
34 | break
35 | }
36 | log.Printf("recv: %s", message)
37 | err = c.WriteMessage(mt, message)
38 | if err != nil {
39 | log.Println("write:", err)
40 | break
41 | }
42 | }
43 | }
44 |
45 | func home(w http.ResponseWriter, r *http.Request) {
46 | homeTemplate.Execute(w, "ws://"+r.Host+"/echo")
47 | }
48 |
49 | func main() {
50 | flag.Parse()
51 | log.SetFlags(0)
52 | http.HandleFunc("/echo", echo)
53 | http.HandleFunc("/", home)
54 | log.Fatal(http.ListenAndServe(*addr, nil))
55 | }
56 |
57 | var homeTemplate = template.Must(template.New("").Parse(`
58 |
59 |
60 |
61 |
62 |
116 |
117 |
118 |
119 |
120 |
Click "Open" to create a connection to the server,
121 | "Send" to send a message to the server and "Close" to close the connection.
122 | You can change the message and send multiple times.
123 |