mirror of
https://github.com/pion/webrtc.git
synced 2025-12-24 11:51:03 +08:00
Add whip-whep-like example
A data-channel example with whip-whep like pattern. And a simple broadcast system. Co-authored-by: Joe Turki <git@joeturki.com>
This commit is contained in:
63
examples/data-channels-whip-whep-like/README.md
Normal file
63
examples/data-channels-whip-whep-like/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# whip-whep-like
|
||||
|
||||
This example demonstrates a WHIP/WHEP-like implementation using Pion WebRTC with DataChannel support for real-time chat.
|
||||
|
||||
**Note:** This is similar to but not exactly WHIP/WHEP, as the official WHIP/WHEP specifications focus on media streaming only and do not include DataChannel support. This example extends the WHIP/WHEP pattern to demonstrate peer-to-peer chat functionality with automatic username assignment and message broadcasting.
|
||||
|
||||
Key features:
|
||||
- **Real-time chat** with WebRTC DataChannels
|
||||
- **Automatic username generation** - Each user gets a unique random username (e.g., SneakyBear46)
|
||||
- **Message broadcasting** - All connected users receive messages from everyone else
|
||||
- **WHIP/WHEP-like signaling** - Simple HTTP-based signaling for easy integration
|
||||
|
||||
Further details about WHIP+WHEP and the WebRTC DataChannel implementation are below the instructions.
|
||||
|
||||
## Instructions
|
||||
|
||||
### Download the example
|
||||
|
||||
This example requires you to clone the repo since it is serving static HTML.
|
||||
|
||||
```
|
||||
git clone https://github.com/pion/webrtc.git
|
||||
cd webrtc/examples/data-channels-whip-whep-like
|
||||
```
|
||||
|
||||
### Run the server
|
||||
Execute `go run *.go`
|
||||
|
||||
### Connect and chat
|
||||
|
||||
1. Open [http://localhost:8080](http://localhost:8080) in your browser
|
||||
2. Click "Publish" or "Subscribe" to establish a DataChannel connection
|
||||
3. You'll be assigned a random username (e.g., "SneakyBear46")
|
||||
4. Type a message and click "Send Message" to broadcast to all connected users
|
||||
5. Open multiple tabs/windows to test multi-user chat
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
|
||||
## Why WHIP/WHEP for signaling?
|
||||
|
||||
This example uses a WHIP/WHEP-like signaling approach where an Offer is uploaded via HTTP and the server responds with an Answer. This simple API contract makes it easy to integrate WebRTC into web applications.
|
||||
|
||||
**Difference from standard WHIP/WHEP:** The official WHIP/WHEP specifications are designed for media streaming (audio/video) only. This example extends that pattern to include DataChannel support for real-time chat functionality.
|
||||
|
||||
## Implementation details
|
||||
|
||||
### Username generation
|
||||
Each connected user is automatically assigned a unique username combining:
|
||||
- An adjective (e.g., Sneaky, Brave, Quick)
|
||||
- An animal noun (e.g., Bear, Fox, Eagle)
|
||||
- A random number (0-999)
|
||||
|
||||
Congrats, you have used Pion WebRTC! Now start building something cool
|
||||
|
||||
## Why WHIP/WHEP?
|
||||
|
||||
WHIP/WHEP mandates that a Offer is uploaded via HTTP. The server responds with a Answer. With this strong API contract WebRTC support can be added to tools like OBS.
|
||||
|
||||
For more info on WHIP/WHEP specification, feel free to read some of these great resources:
|
||||
- https://webrtchacks.com/webrtc-cracks-the-whip-on-obs/
|
||||
- https://datatracker.ietf.org/doc/draft-ietf-wish-whip/
|
||||
- https://datatracker.ietf.org/doc/draft-ietf-wish-whep/
|
||||
- https://bloggeek.me/whip-whep-webrtc-live-streaming
|
||||
97
examples/data-channels-whip-whep-like/index.html
Normal file
97
examples/data-channels-whip-whep-like/index.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<html>
|
||||
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<head>
|
||||
<title>whip-whep</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button onclick="window.doWHIP()">Publish</button>
|
||||
<button onclick="window.doWHEP()">Subscribe</button>
|
||||
<br />
|
||||
Message<br />
|
||||
<textarea id="message">This is my DataChannel message!</textarea> <br/>
|
||||
<button onclick="window.sendMessage()">Send Message</button> <br />
|
||||
<h3> Logs </h3>
|
||||
<div id="logs"></div>
|
||||
|
||||
|
||||
<h3> ICE Connection States </h3>
|
||||
<div id="iceConnectionStates"></div> <br />
|
||||
</body>
|
||||
|
||||
<script>
|
||||
const log = msg => {
|
||||
document.getElementById('logs').innerHTML += msg + '<br>'
|
||||
}
|
||||
|
||||
let peerConnection = new RTCPeerConnection()
|
||||
|
||||
const sendChannel = peerConnection.createDataChannel('foo')
|
||||
sendChannel.onclose = () => console.log('sendChannel has closed')
|
||||
sendChannel.onopen = () => console.log('sendChannel has opened')
|
||||
sendChannel.onmessage = e => log(`From ${e.data}`)
|
||||
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
let el = document.createElement('p')
|
||||
el.appendChild(document.createTextNode(peerConnection.iceConnectionState))
|
||||
|
||||
document.getElementById('iceConnectionStates').appendChild(el);
|
||||
}
|
||||
|
||||
window.doWHEP = () => {
|
||||
|
||||
peerConnection.createOffer().then(offer => {
|
||||
peerConnection.setLocalDescription(offer)
|
||||
|
||||
fetch(`/whep`, {
|
||||
method: 'POST',
|
||||
body: offer.sdp,
|
||||
headers: {
|
||||
Authorization: `Bearer none`,
|
||||
'Content-Type': 'application/sdp'
|
||||
}
|
||||
}).then(r => r.text())
|
||||
.then(answer => {
|
||||
peerConnection.setRemoteDescription({
|
||||
sdp: answer,
|
||||
type: 'answer'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
window.doWHIP = () => {
|
||||
peerConnection.createOffer().then(offer => {
|
||||
peerConnection.setLocalDescription(offer)
|
||||
|
||||
fetch(`/whip`, {
|
||||
method: 'POST',
|
||||
body: offer.sdp,
|
||||
headers: {
|
||||
Authorization: `Bearer none`,
|
||||
'Content-Type': 'application/sdp'
|
||||
}
|
||||
}).then(r => r.text())
|
||||
.then(answer => {
|
||||
peerConnection.setRemoteDescription({
|
||||
sdp: answer,
|
||||
type: 'answer'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
window.sendMessage = () => {
|
||||
const message = document.getElementById('message').value
|
||||
if (message === '') {
|
||||
return alert('Message must not be empty')
|
||||
}
|
||||
|
||||
sendChannel.send(message)
|
||||
}
|
||||
</script>
|
||||
</html>
|
||||
258
examples/data-channels-whip-whep-like/main.go
Normal file
258
examples/data-channels-whip-whep-like/main.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// SPDX-FileCopyrightText: 2023 The Pion community <https://pion.ly>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build !js
|
||||
// +build !js
|
||||
|
||||
// whip-whep demonstrates how to use the WHIP/WHEP specifications to exchange SPD descriptions
|
||||
// and stream media to a WebRTC client in the browser or OBS.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
// nolint: gochecknoglobals
|
||||
var (
|
||||
peerConnectionConfiguration = webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Broadcast hub to forward messages between all connected clients.
|
||||
broadcastHub = &Hub{
|
||||
connections: make(map[*webrtc.DataChannel]bool),
|
||||
usernames: make(map[*webrtc.DataChannel]string),
|
||||
mu: sync.RWMutex{},
|
||||
}
|
||||
)
|
||||
|
||||
// Hub manages all connected DataChannels for broadcasting.
|
||||
type Hub struct {
|
||||
connections map[*webrtc.DataChannel]bool
|
||||
usernames map[*webrtc.DataChannel]string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// nolint: gochecknoglobals
|
||||
var (
|
||||
adjectives = []string{
|
||||
"Quick", "Swift", "Bright", "Bold", "Calm", "Cool", "Fast", "Happy",
|
||||
"Lucky", "Shy", "Sneaky", "Wise", "Brave", "Clever", "Kind", "Proud",
|
||||
}
|
||||
nouns = []string{
|
||||
"Fox", "Eagle", "Lion", "Tiger", "Wolf", "Dragon", "Hawk", "Bear",
|
||||
"Shark", "Falcon", "Leopard", "Panther", "Phoenix", "Raven", "Crow", "Owl",
|
||||
}
|
||||
)
|
||||
|
||||
// Register adds a DataChannel to the broadcast hub and assigns a random username.
|
||||
func (h *Hub) Register(channel *webrtc.DataChannel) string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.connections[channel] = true
|
||||
|
||||
username := h.generateUniqueUsername()
|
||||
h.usernames[channel] = username
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
// Unregister removes a DataChannel from the broadcast hub.
|
||||
func (h *Hub) Unregister(channel *webrtc.DataChannel) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.connections, channel)
|
||||
delete(h.usernames, channel)
|
||||
}
|
||||
|
||||
// generateUniqueUsername generates a unique username by combining an adjective and a noun.
|
||||
// It checks existing usernames and regenerates until it finds a unique one.
|
||||
// Must be called while holding h.mu.Lock().
|
||||
// nolint: gosec
|
||||
func (h *Hub) generateUniqueUsername() string {
|
||||
var username string
|
||||
for {
|
||||
adjective := adjectives[rand.Intn(len(adjectives))]
|
||||
noun := nouns[rand.Intn(len(nouns))]
|
||||
number := rand.Intn(1000)
|
||||
username = fmt.Sprintf("%s%s%d", adjective, noun, number)
|
||||
|
||||
// Check if this username already exists by iterating over map values directly
|
||||
exists := false
|
||||
for _, existingUsername := range h.usernames {
|
||||
if existingUsername == username {
|
||||
exists = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
// GetUsername returns the username for a DataChannel.
|
||||
func (h *Hub) GetUsername(channel *webrtc.DataChannel) string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
return h.usernames[channel]
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all registered DataChannels including the sender.
|
||||
func (h *Hub) Broadcast(message string, sender *webrtc.DataChannel) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
// Get the sender's username
|
||||
senderUsername := h.usernames[sender]
|
||||
formattedMessage := fmt.Sprintf("%s: %s", senderUsername, message)
|
||||
|
||||
for channel := range h.connections {
|
||||
// Check if channel is still open
|
||||
if channel.ReadyState() != webrtc.DataChannelStateOpen {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send message in goroutine to avoid blocking
|
||||
go func(ch *webrtc.DataChannel, msg string) {
|
||||
if err := ch.SendText(msg); err != nil {
|
||||
fmt.Printf("Failed to send broadcast message: %v\n", err)
|
||||
}
|
||||
}(channel, formattedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Count returns the number of connected clients.
|
||||
func (h *Hub) Count() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
return len(h.connections)
|
||||
}
|
||||
|
||||
// nolint:gocognit
|
||||
func main() {
|
||||
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
|
||||
http.Handle("/", http.FileServer(http.Dir(".")))
|
||||
http.HandleFunc("/whep", whepHandler)
|
||||
http.HandleFunc("/whip", whipHandler)
|
||||
|
||||
fmt.Println("Open http://localhost:8080 to access this demo")
|
||||
panic(http.ListenAndServe(":8080", nil)) // nolint: gosec
|
||||
}
|
||||
|
||||
func whipHandler(res http.ResponseWriter, req *http.Request) {
|
||||
// Read the offer from HTTP Request
|
||||
offer, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfiguration)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Send answer via HTTP Response
|
||||
writeAnswer(res, peerConnection, offer, "/whip")
|
||||
}
|
||||
|
||||
func whepHandler(res http.ResponseWriter, req *http.Request) {
|
||||
// Read the offer from HTTP Request
|
||||
offer, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfiguration)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Send answer via HTTP Response
|
||||
writeAnswer(res, peerConnection, offer, "/whep")
|
||||
}
|
||||
|
||||
func writeAnswer(res http.ResponseWriter, peerConnection *webrtc.PeerConnection, offer []byte, path string) {
|
||||
// Set the handler for ICE connection state
|
||||
// This will notify you when the peer has connected/disconnected
|
||||
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
|
||||
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
|
||||
|
||||
if connectionState == webrtc.ICEConnectionStateFailed ||
|
||||
connectionState == webrtc.ICEConnectionStateClosed {
|
||||
_ = peerConnection.Close()
|
||||
}
|
||||
})
|
||||
|
||||
peerConnection.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
|
||||
fmt.Printf("New DataChannel %s %d\n", dataChannel.Label(), dataChannel.ID())
|
||||
|
||||
dataChannel.OnOpen(func() {
|
||||
// register this channel in the broadcast hub and get assigned username
|
||||
username := broadcastHub.Register(dataChannel)
|
||||
fmt.Printf("Data channel '%s'-'%d' opened. Username: %s, Total clients: %d\n",
|
||||
dataChannel.Label(), dataChannel.ID(), username, broadcastHub.Count())
|
||||
})
|
||||
|
||||
dataChannel.OnClose(func() {
|
||||
fmt.Printf("Data channel '%s'-'%d' closed\n", dataChannel.Label(), dataChannel.ID())
|
||||
// unregister this channel from the broadcast hub
|
||||
broadcastHub.Unregister(dataChannel)
|
||||
})
|
||||
|
||||
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
|
||||
message := string(msg.Data)
|
||||
fmt.Printf("Message from DataChannel '%s': '%s'\n", dataChannel.Label(), message)
|
||||
|
||||
// broadcast the message to all other connected clients
|
||||
broadcastHub.Broadcast(message, dataChannel)
|
||||
})
|
||||
})
|
||||
|
||||
if err := peerConnection.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeOffer, SDP: string(offer),
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Create answer
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
|
||||
// WHIP+WHEP expects a Location header and a HTTP Status Code of 201
|
||||
res.Header().Add("Location", path)
|
||||
res.WriteHeader(http.StatusCreated)
|
||||
|
||||
// Write Answer with Candidates as HTTP Response
|
||||
fmt.Fprint(res, peerConnection.LocalDescription().SDP) //nolint: errcheck
|
||||
}
|
||||
Reference in New Issue
Block a user