Forge WebRTC is the WebRTC extension for Forge, the Go backend framework. It builds on Pion WebRTC v4 and the Forge Streaming extension to give you call rooms with mesh and SFU topologies, server-side peer connections with SDP offer and answer handling, ICE candidates, data channels, and STUN and TURN server configuration. It is early software. You can create rooms and negotiate real peer connections with it today, but the built-in signaling endpoint, SFU media forwarding and recording are unfinished, so read Status before you design a product around it.
Documentation · Quick start · Features · Call rooms · Peer connections · Status · Configuration · FAQ
You need Go 1.26 or later and a Go module:
go get github.com/xraph/forge@latest
go get github.com/xraph/forge/extensions/streaming@latest
go get github.com/xraph/forge/extensions/webrtc@latestSave this as main.go. It creates a call room at startup and answers a browser's SDP offer over plain HTTP:
package main
import (
"context"
"log"
"net/http"
"github.com/xraph/forge"
"github.com/xraph/forge/extensions/streaming"
"github.com/xraph/forge/extensions/webrtc"
)
func main() {
stream := streaming.NewExtension(streaming.WithLocalBackend())
rtc, err := webrtc.New(stream, webrtc.DefaultConfig())
if err != nil {
log.Fatal(err)
}
app := forge.NewApp(forge.AppConfig{
Name: "calls",
HTTPAddress: ":8080",
Extensions: []forge.Extension{stream, rtc},
})
err = forge.OnStarted(app, "create-call", func(ctx context.Context, _ forge.App) error {
_, err := rtc.CreateCallRoom(ctx, "standup", streaming.RoomOptions{
ID: "standup",
Name: "Daily standup",
})
return err
})
if err != nil {
log.Fatal(err)
}
r := app.Router()
// STUN and TURN servers, shaped for the browser's RTCPeerConnection.
err = r.GET("/ice-servers", func(ctx forge.Context) error {
cfg := rtc.GetConfig()
servers := []map[string]any{}
for _, s := range cfg.GetICEServers() {
servers = append(servers, map[string]any{
"urls": s.URLs,
"username": s.Username,
"credential": s.Credential,
})
}
return ctx.JSON(http.StatusOK, servers)
})
if err != nil {
log.Fatal(err)
}
// The browser posts its offer, the server joins the call and answers.
err = r.POST("/calls/:room/join", func(ctx forge.Context) error {
var offer webrtc.SessionDescription
if err := ctx.Bind(&offer); err != nil {
return err
}
peer, err := rtc.JoinCall(ctx.Context(), ctx.Param("room"), ctx.Query("user"), &webrtc.JoinOptions{})
if err != nil {
return err
}
if err := peer.SetRemoteDescription(ctx.Context(), &offer); err != nil {
return err
}
answer, err := peer.CreateAnswer(ctx.Context())
if err != nil {
return err
}
return ctx.JSON(http.StatusOK, map[string]string{
"type": string(answer.Type),
"sdp": answer.SDP,
})
})
if err != nil {
log.Fatal(err)
}
if err := app.Run(); err != nil {
log.Fatal(err)
}
}Run it with go mod tidy && go run ., then curl http://localhost:8080/ice-servers shows the two Google STUN servers from DefaultConfig(). From a page served on the same origin, a browser can open a data channel to the server like this:
const pc = new RTCPeerConnection({ iceServers: await (await fetch("/ice-servers")).json() });
pc.createDataChannel("chat");
await pc.setLocalDescription(await pc.createOffer());
// There is no trickle ICE endpoint, so send the offer once gathering is done.
await new Promise((done) => {
if (pc.iceGatheringState === "complete") return done();
pc.onicegatheringstatechange = () => pc.iceGatheringState === "complete" && done();
});
const res = await fetch("/calls/standup/join?user=ada", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pc.localDescription),
});
await pc.setRemoteDescription(await res.json());The server waits for its own ICE gathering (up to five seconds) before it returns the answer, so the answer already carries its candidates. Each user can join a room once. A second join with the same user fails until that user leaves with rtc.LeaveCall.
| You need | Forge WebRTC gives you |
|---|---|
| Call rooms | CreateCallRoom, GetCallRoom, GetCallRooms, DeleteCallRoom, each backed by a streaming room |
| Participants | JoinCall and LeaveCall per user, GetPeers, GetParticipants, mute and unmute, video on and off |
| Server-side peers | A Pion peer connection per participant with CreateOffer, CreateAnswer, SetRemoteDescription, AddICECandidate, tracks and stats |
| Events | OnICECandidate, OnTrack, OnConnectionStateChange and OnDataChannel callbacks |
| NAT traversal | STUN and TURN servers from config, returned by GetICEServers() |
| Topologies | Mesh (the default) and SFU, picked with WithTopology or WithSFU |
| Signaling helpers | A SignalingManager that sends webrtc.offer, webrtc.answer and webrtc.ice_candidate messages to a streaming room |
| Lifecycle | Depends on the streaming extension, closes every room on stop, and reports unhealthy when streaming is |
A call room pairs a streaming room with WebRTC peers. You need a room ID in the options, and the topology comes from the extension's config:
room, err := rtc.CreateCallRoom(ctx, "support-42", streaming.RoomOptions{
ID: "support-42",
Name: "Support call",
MaxMembers: 4,
})
peer, err := room.JoinCall(ctx, "agent-7", &webrtc.JoinOptions{
AudioEnabled: true,
VideoEnabled: true,
DisplayName: "Agent 7",
})
err = room.MuteUser(ctx, "agent-7")AudioEnabled and VideoEnabled add a local audio or video track to the server's peer for that user. rtc.JoinCall(ctx, roomID, userID, opts) and rtc.LeaveCall(ctx, roomID, userID) do the same through the extension and also update the webrtc.calls.* and webrtc.peers.active metrics. DeleteCallRoom closes every peer in the room.
JoinCall returns a webrtc.PeerConnection, a thin interface over a Pion *webrtc.PeerConnection created with your ICE servers:
peer.OnICECandidate(func(c *webrtc.ICECandidate) {
// send c to the client over your signaling channel
})
peer.OnConnectionStateChange(func(state webrtc.ConnectionState) {
log.Printf("peer %s is %s", peer.UserID(), state)
})
stats, err := peer.GetStats(ctx)SessionDescription and ICECandidate have no JSON tags, so they marshal with Go field names (Type, SDP). Decoding is case-insensitive, which is why the quick start can bind a browser's lowercase {type, sdp} straight into a SessionDescription, but you should shape what you send back to a browser yourself, as the quick start does, because the browser expects lowercase keys.
Here is what is unfinished today, so you can plan around it.
RegisterRoutes(router) mounts a WebSocket at /webrtc/signal/{roomID}, but its read loop drops every message it receives. The handler also asserts ctx.Get("user_id").(string), so put middleware on the route that sets user_id, or a connection panics. Even with a user it can only join a room that already exists, because the auto-create path never sets the room ID. Do your own signaling instead, over HTTP as in the quick start or over your own WebSocket handler.
The SFU topology creates an SFU router and SFU call rooms, but RTP packets are not forwarded between publishers and subscribers yet, so if you pick SFU expecting group calls you will get rooms where nobody hears anybody. Mesh rooms give every participant their own server-side peer. TopologyMCU is declared and rejected by CreateCallRoom.
StartScreenShare, StopScreenShare and GetQuality on a call room return webrtc.ErrNotImplemented. The extension never constructs a recorder or quality monitor, so GetRecorder() and GetQualityMonitor() return nil. NewQualityMonitor and NewRecorder exist if you want to wire them yourself.
Several config fields are stored and never read: MediaConfig (codecs, bitrates, resolution), SignalingEnabled, SignalingTimeout, RequireAuth, AllowGuests, RecordingEnabled, RecordingPath and MetricsEnabled. Pion negotiates codecs with its defaults.
You configure the extension in code. It does not read extensions.webrtc from your config file. Start from webrtc.DefaultConfig() and pass options to webrtc.New:
rtc, err := webrtc.New(stream, webrtc.DefaultConfig(),
webrtc.WithSTUNServers("stun:stun.example.com:3478"),
webrtc.WithTURNServer(webrtc.TURNConfig{
URLs: []string{"turn:turn.example.com:3478"},
Username: "calls",
Credential: os.Getenv("TURN_PASSWORD"),
}),
)| Option | Sets |
|---|---|
WithTopology(webrtc.Topology) |
TopologyMesh (default) or TopologySFU |
WithSFU(webrtc.SFUConfig) |
SFU topology plus its settings (workers, bandwidth, simulcast, quality layers) |
WithSTUNServers(...string) |
Replaces the STUN list |
WithTURNServer(webrtc.TURNConfig) |
Adds a TURN server with URLs and credentials |
WithAudioCodecs(...string), WithVideoCodecs(...string) |
MediaConfig codec lists (not applied yet) |
WithRecording(path) |
RecordingEnabled and RecordingPath (not applied yet) |
WithQualityMonitoring(webrtc.QualityConfig) |
Quality thresholds (not applied yet) |
New validates the result and returns an error when there are no STUN or TURN servers, when the topology is SFU without an SFUConfig, or when MaxVideoBitrate is below MinVideoBitrate. You also need the streaming extension registered in the same app, since WebRTC lists it as a dependency and sends its signaling through it.
| Topic | Where |
|---|---|
| Overview | WebRTC docs |
| Features and usage | WebRTC features |
| Configuration reference | WebRTC configuration |
| Function reference | WebRTC functions |
| Go API | pkg.go.dev |
| The streaming extension it builds on | Forge Streaming |
| The WebRTC stack underneath | Pion WebRTC |
The basic example wires auth, streaming and WebRTC with an SFU config and REST routes for creating, joining and leaving calls. It compiles with the module, but it relies on RegisterRoutes and recording, so read Status before you copy from it.
Register streaming.NewExtension() and webrtc.New(stream, webrtc.DefaultConfig()) in one Forge app, create a call room, and exchange SDP with the browser through JoinCall, SetRemoteDescription and CreateAnswer. The quick start does it over HTTP.
No. It passes the TURN servers you configure to Pion and to your clients through GetICEServers(). You run the TURN server yourself, coturn for example, or use a hosted one.
Not yet. SFU rooms and the router exist, but media is not forwarded between participants. For now each participant you add gets a peer connection to the server, and any forwarding is up to you.
Pion WebRTC v4, a pure Go implementation, so you do not need cgo or a browser engine on the server.
WebRTC is its own Go module inside the Forge repository. It replaces the root, streaming and auth modules with local paths. Build, vet and test it from its directory the way CI does:
cd extensions/webrtc
GOWORK=off go build ./...
GOWORK=off go vet ./...
GOWORK=off go test -short -count=1 ./...
golangci-lint run ./...-short skips the integration tests, which nothing in CI runs. Forge's AGENTS.md has the pinned lint version and the rest of the repository conventions. Commits follow Conventional Commits, for example fix(webrtc): ....
Forge WebRTC is part of Forge and is available under the Apache License 2.0.